From 8241949357263be64013a8410171def68cff920c Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 9 Jun 2026 22:29:23 -0700 Subject: [PATCH] v1.57.9.0 feat: source-clean gbrain render (dev-setup --out-dir + machine-wide gbrain-refresh) (#1951) * feat(gbrain-detect): add --is-ok live-detection exit-code gate Single source of truth for 'is gbrain usable'. Runs live detection (never reads the possibly-stale gbrain-detection.json) and exits 0 iff status is ok, so setup, bin/dev-setup, and gstack-config can gate brain-aware rendering on one shared check instead of re-grepping the JSON. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(gen-skill-docs): add --out-dir with surgical section-path rewrite --out-dir mirrors the Claude skill tree (SKILL.md + sections) into a separate directory instead of writing in place, and rewrites the literal section-base path (~/.claude/skills/gstack//sections/) in generated content to point at the out-dir. The rewrite is surgical: only /sections/ paths move; bin/, browse/, docs/ references stay pointed at the global install. Global extras (proactive-suggestions.json) are skipped in out-dir mode. Default (no flag) behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(dev-setup): render gbrain :user variant to an untracked workspace dir Stops the dev/Conductor workspace from dirtying tracked SKILL.md source. setup honors GSTACK_SKIP_GBRAIN_REGEN (passed inline by dev-setup, never exported) and skips the in-place :user regen; detection is still persisted (PID-unique tmp so concurrent workspaces can't clobber it). dev-setup instead renders the :user variant into .claude/gstack-rendered (gitignored, per-workspace) and repoints the workspace SKILL.md symlinks at it, so the workspace gets brain-aware blocks while the worktree stays canonical. dev-teardown removes the render. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(dev-skill): refresh the untracked brain-aware render on template change After the default in-place regen (which keeps the worktree canonical and runs validation), also re-render the :user variant into .claude/gstack-rendered when it exists, so live template edits reflect at the workspace's runtime. Never creates the render dir during plain template dev. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(gstack-config): gbrain-refresh renders brain-aware blocks into the install Extends gbrain-refresh to render the :user variant into the global install (~/.claude/skills/gstack) so every project's Claude sessions get brain-aware blocks, not just the gstack dev workspace. Guarded against mutating the wrong directory: the target must exist, not be a symlink (a symlinked install points at a dev worktree), and look like a real gstack clone (VERSION + package.json). Idempotent and self-documenting. CLAUDE.md's deploy section now notes that 'git reset --hard' reverts the blocks and to re-run gbrain-refresh. Co-Authored-By: Claude Opus 4.8 (1M context) * test: cover gstack-gbrain-detect --is-ok + dev-skill render refresh Fills the two automated-coverage gaps from the eng review: --is-ok exit-code gate (no-cli -> nonzero, healthy -> 0, plus an agrees-with-JSON no-skew check reusing the deterministic fake-gbrain harness) and a static tripwire that dev-skill re-renders the :user variant into the workspace render dir only when it already exists. Co-Authored-By: Claude Opus 4.8 (1M context) * chore: bump version and changelog (v1.57.9.0) Co-Authored-By: Claude Opus 4.8 (1M context) * docs: document brain-aware dev-setup render for v1.57.9.0 Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .gitignore | 1 + CHANGELOG.md | 71 +++++++++++++++++ CLAUDE.md | 6 ++ CONTRIBUTING.md | 20 ++++- VERSION | 2 +- bin/dev-setup | 46 ++++++++++- bin/dev-teardown | 9 ++- bin/gstack-config | 25 +++++- bin/gstack-gbrain-detect | 10 +++ package.json | 2 +- scripts/dev-skill.ts | 18 +++++ scripts/gen-skill-docs.ts | 59 +++++++++++++- setup | 33 +++++--- test/dev-setup-render-isolation.test.ts | 91 ++++++++++++++++++++++ test/gbrain-detect-shape.test.ts | 75 +++++++++++++++++- test/gbrain-refresh-install-render.test.ts | 60 ++++++++++++++ test/gen-skill-docs-out-dir.test.ts | 84 ++++++++++++++++++++ 17 files changed, 590 insertions(+), 22 deletions(-) create mode 100644 test/dev-setup-render-isolation.test.ts create mode 100644 test/gbrain-refresh-install-render.test.ts create mode 100644 test/gen-skill-docs-out-dir.test.ts diff --git a/.gitignore b/.gitignore index 42b2c2a04..6eac08f36 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ make-pdf/dist/ bin/gstack-global-discover* .gstack/ .claude/skills/ +.claude/gstack-rendered/ .claude/scheduled_tasks.lock .claude/*.lock .agents/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 438536cad..3f8cffae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,76 @@ # Changelog +## [1.57.9.0] - 2026-06-09 + +## **Your gstack checkout stays clean when gbrain is installed.** +## **Brain-aware skill blocks render to an untracked spot, never into tracked source.** + +Before this, finishing a Conductor or dev-workspace setup with gbrain installed +rewrote 16 planning and review SKILL.md files in place, adding 326 lines of +brain-aware blocks straight into tracked source. Your working tree came back dirty, +one stray `git add` away from committing a token regression for everyone who does +not run gbrain. Now `gen-skill-docs --out-dir` renders the brain-aware variant into +an untracked per-workspace directory, and `bin/dev-setup` repoints the workspace's +skill symlinks at it. The dev workspace gets the full gbrain experience (context-load +and save-to-brain blocks live at runtime), while the tracked SKILL.md files stay +byte-for-byte canonical. To turn the blocks on across all your projects' Claude +sessions, `gstack-config gbrain-refresh` now renders them into your global install, +guarded so it never mutates a symlinked or non-gstack directory. + +### The numbers that matter + +Structural facts of the change, verifiable from the diff plus `bun run gen:skill-docs` +(zero drift) and the new behavioral test (`test/gen-skill-docs-out-dir.test.ts`). + +| When gbrain is installed | Before | After | +|---|---|---| +| Tracked SKILL.md files dirtied by dev-setup | 16 (+326 lines) | 0 | +| Where brain-aware blocks render in a dev workspace | in-place, tracked source | `.claude/gstack-rendered/`, untracked | +| Brain-aware blocks across other projects | re-run `./setup` or hand-edit | `gstack-config gbrain-refresh` (idempotent) | +| "Is gbrain usable" check | per-caller JSON grep, can read stale state | `gstack-gbrain-detect --is-ok` (one live gate) | + +The section-path rewrite is surgical: only `~/.claude/skills/gstack//sections/` +references move to the render dir, so `bin/` and `docs/` references still resolve to +the install. + +### What this means for you + +If you develop gstack with gbrain on, `git status` is clean again after setup, and +you can stop fishing brain-block drift out of your commits. After a +`git reset --hard` deploy of your install, re-run `gstack-config gbrain-refresh` to +restore the machine-wide blocks (it is idempotent, and the deploy note in CLAUDE.md +spells this out). + +### Itemized changes + +#### Added +- `gen-skill-docs --out-dir `: render the Claude SKILL.md + sections into a + separate directory instead of in place, rewriting only the section-base path so + section reads resolve to the render. Default (no flag) output is unchanged. +- `gstack-gbrain-detect --is-ok`: live-detection exit-code gate (0 iff gbrain is + usable), so setup, dev-setup, and gstack-config share one check. +- `gstack-config gbrain-refresh` now renders brain-aware blocks into the global + install (`~/.claude/skills/gstack`), guarded against symlinked or non-gstack + targets and self-documenting about the `reset --hard` re-run cycle. + +#### Changed +- `bin/dev-setup` renders the brain-aware variant into `.claude/gstack-rendered` + (gitignored) and repoints workspace skill symlinks at it; the worktree stays + canonical. `GSTACK_SKIP_GBRAIN_REGEN` is passed inline to the nested setup, never + exported. +- `setup` honors `GSTACK_SKIP_GBRAIN_REGEN` (skips the in-place brain regen on dev + trees) and writes detection state to a PID-unique tmp so concurrent workspaces + cannot clobber it. +- `scripts/dev-skill.ts` refreshes the workspace render on template change, only + when the render dir already exists. +- `bin/dev-teardown` removes the untracked render. + +#### For contributors +- New tests: `test/gen-skill-docs-out-dir.test.ts` (behavioral: worktree unchanged, + blocks rendered, section paths rewritten), `test/dev-setup-render-isolation.test.ts` + and `test/gbrain-refresh-install-render.test.ts` (static tripwires), plus + `--is-ok` coverage in `test/gbrain-detect-shape.test.ts`. + ## [1.57.8.0] - 2026-06-09 ## **`browse` is now the one Chromium on the box, for offline rendering too.** diff --git a/CLAUDE.md b/CLAUDE.md index 41db0093e..03384ae79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -883,6 +883,12 @@ The active skill lives at `~/.claude/skills/gstack/`. After making changes: 2. Fetch and reset in the skill directory: `cd ~/.claude/skills/gstack && git fetch origin && git reset --hard origin/main` 3. Rebuild: `cd ~/.claude/skills/gstack && bun run build` +**If you use gbrain:** the `git reset --hard` in step 2 reverts the brain-aware +(`GBRAIN_CONTEXT_LOAD` / `GBRAIN_SAVE_RESULTS`) blocks that `gstack-config +gbrain-refresh` renders into the install (those generated blocks differ from +`main` by design). After deploying, re-run `gstack-config gbrain-refresh` to +restore them across all your projects' Claude sessions. It's idempotent. + Or copy the binaries directly: - `cp browse/dist/browse ~/.claude/skills/gstack/browse/dist/browse` - `cp design/dist/design ~/.claude/skills/gstack/design/dist/design` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a4872fc47..5a56ef5d3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -106,6 +106,22 @@ bun run build bin/dev-teardown ``` +### Brain-aware blocks in a dev workspace (gbrain installed) + +If gbrain is installed and usable (`bin/gstack-gbrain-detect --is-ok` exits 0), +`bin/dev-setup` keeps your tracked `SKILL.md` files canonical and renders the +brain-aware variant (the `GBRAIN_CONTEXT_LOAD` / `GBRAIN_SAVE_RESULTS` blocks) +into `.claude/gstack-rendered/` (gitignored, per-workspace). It then repoints the +workspace's `SKILL.md` symlinks at that render, so your Claude sessions get the +full gbrain experience while `git status` stays clean. Under the hood, dev-setup +passes `GSTACK_SKIP_GBRAIN_REGEN=1` inline to the nested `./setup` (so it never +dirties tracked source) and runs `gen:skill-docs:user --out-dir .claude/gstack-rendered`, +which rewrites only the section-base paths to point at the render. `bin/dev-teardown` +removes the render. To make the blocks live across your *other* projects' Claude +sessions, run `gstack-config gbrain-refresh`, which renders them into the global +install (`~/.claude/skills/gstack`), guarded so it never touches a symlinked or +non-gstack directory. + ## Testing & evals ### Setup @@ -334,8 +350,8 @@ If you're using [Conductor](https://conductor.build) to run multiple Claude Code | Hook | Script | What it does | |------|--------|-------------| -| `setup` | `bin/dev-setup` | Copies `.env` from main worktree, installs deps, symlinks skills, runs `./setup` non-interactively | -| `archive` | `bin/dev-teardown` | Removes skill symlinks, cleans up `.claude/` directory | +| `setup` | `bin/dev-setup` | Copies `.env` from main worktree, installs deps, symlinks skills, runs `./setup` non-interactively, and (if gbrain is installed) renders brain-aware blocks into `.claude/gstack-rendered/` without dirtying tracked source | +| `archive` | `bin/dev-teardown` | Removes skill symlinks, the `.claude/gstack-rendered/` render, and cleans up `.claude/` directory | When Conductor creates a new workspace, `bin/dev-setup` runs automatically. It detects the main worktree (via `git worktree list`), copies your `.env` so API keys carry over, and sets up dev mode — no manual steps needed. diff --git a/VERSION b/VERSION index caf2638d9..10521b5e0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.57.8.0 +1.57.9.0 diff --git a/bin/dev-setup b/bin/dev-setup index 0d8460f91..00a286706 100755 --- a/bin/dev-setup +++ b/bin/dev-setup @@ -72,7 +72,48 @@ fi # no-op skip (no install, no decline marker). A dev workspace must never mutate # global settings.json. To install the hooks, run `./setup --plan-tune-hooks` # directly (outside dev-setup). Saved prefix/other config preferences still apply. -"$GSTACK_LINK/setup" --plan-tune-hooks=prompt /dev/null; then + echo "" + echo "gbrain detected — rendering brain-aware skills into .claude/gstack-rendered (workspace-only, untracked)..." + rm -rf "$RENDER_DIR" + if ( cd "$REPO_ROOT" && bun run gen:skill-docs:user --host claude --out-dir "$RENDER_DIR" >/dev/null 2>&1 ); then + # Repoint each project-local SKILL.md symlink whose worktree target has a + # rendered counterpart. The skill DIRECTORY name (basename of the symlink + # target's dir) maps to RENDER_DIR//SKILL.md, which is robust to + # frontmatter renames and the gstack- prefix on the link name. + repointed=0 + for skill_link in "$REPO_ROOT"/.claude/skills/*/SKILL.md; do + [ -L "$skill_link" ] || continue + target="$(readlink "$skill_link")" + skilldir="$(basename "$(dirname "$target")")" + rendered="$RENDER_DIR/$skilldir/SKILL.md" + if [ -f "$rendered" ]; then ln -snf "$rendered" "$skill_link"; repointed=$((repointed + 1)); fi + done + echo " $repointed workspace skills now serve brain-aware blocks (worktree stays canonical)." + else + echo " warning: brain-aware render failed — workspace uses canonical skills." + fi +fi echo "" echo "Dev mode active. Skills resolve from this working tree." @@ -80,4 +121,7 @@ echo " .claude/skills/gstack → $REPO_ROOT" echo " .agents/skills/gstack → $REPO_ROOT" echo "Edit any SKILL.md and test immediately — no copy/deploy needed." echo "" +echo "To make brain-aware blocks live across your OTHER projects too, run:" +echo " gstack-config gbrain-refresh" +echo "" echo "To tear down: bin/dev-teardown" diff --git a/bin/dev-teardown b/bin/dev-teardown index dc8f74260..06189e189 100755 --- a/bin/dev-teardown +++ b/bin/dev-teardown @@ -24,9 +24,16 @@ if [ -d "$CLAUDE_SKILLS" ]; then fi rmdir "$CLAUDE_SKILLS" 2>/dev/null || true - rmdir "$REPO_ROOT/.claude" 2>/dev/null || true fi +# ─── Clean up the untracked brain-aware render (bin/dev-setup step 7) ── +RENDER_DIR="$REPO_ROOT/.claude/gstack-rendered" +if [ -d "$RENDER_DIR" ]; then + rm -rf "$RENDER_DIR" + removed+=("claude/gstack-rendered") +fi +rmdir "$REPO_ROOT/.claude" 2>/dev/null || true + # ─── Clean up .agents/skills/ ──────────────────────────────── AGENTS_SKILLS="$REPO_ROOT/.agents/skills" if [ -d "$AGENTS_SKILLS" ]; then diff --git a/bin/gstack-config b/bin/gstack-config index 01defcef8..ec465b281 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -396,8 +396,29 @@ case "${1:-}" in case "$STATUS" in ok) - echo "Detected gbrain v$VERSION → brain-aware blocks will render in planning-skill SKILL.md files." - echo "Run 'bun run gen:skill-docs' in the gstack repo (or re-run ./setup) to regenerate now." + echo "Detected gbrain v$VERSION." + # Render brain-aware blocks INTO the global install so EVERY project's + # Claude sessions get them (other projects read SKILL.md + sections from + # ~/.claude/skills/gstack via absolute paths baked at gen time). Guards + # (never mutate an arbitrary directory): the target must exist, not be a + # symlink (a symlinked install points at a dev worktree — rendering there + # would dirty tracked source), and look like a real gstack clone. + INSTALL_DIR="$HOME/.claude/skills/gstack" + if [ ! -d "$INSTALL_DIR" ]; then + echo "No global install at $INSTALL_DIR — nothing to render. (Dev workspaces get blocks via bin/dev-setup.)" + elif [ -L "$INSTALL_DIR" ]; then + echo "Skip: $INSTALL_DIR is a symlink (likely a dev worktree). Rendering there would dirty tracked source — run bin/dev-setup in that worktree instead." + elif [ ! -f "$INSTALL_DIR/VERSION" ] || [ ! -f "$INSTALL_DIR/package.json" ]; then + echo "Skip: $INSTALL_DIR doesn't look like a gstack clone (missing VERSION/package.json) — refusing to modify it." + elif ! command -v bun >/dev/null 2>&1; then + echo "Skip: bun not on PATH — can't render. Install bun, then re-run 'gstack-config gbrain-refresh'." + elif ( cd "$INSTALL_DIR" && bun run gen:skill-docs:user --host claude >/dev/null 2>&1 ); then + echo "Rendered brain-aware blocks into $INSTALL_DIR — now live across all your projects' Claude sessions." + echo "Note: this dirties the install's git tree (generated blocks differ from main, by design)." + echo " A 'git reset --hard origin/main' there reverts them; re-run 'gstack-config gbrain-refresh' to restore." + else + echo "Warning: render failed. Run 'cd $INSTALL_DIR && bun run gen:skill-docs:user --host claude' manually to see the error." + fi ;; *) echo "gbrain not detected (local-status: $STATUS) → brain-aware blocks will be suppressed in planning-skill SKILL.md files." diff --git a/bin/gstack-gbrain-detect b/bin/gstack-gbrain-detect index 897bec243..4eee753da 100755 --- a/bin/gstack-gbrain-detect +++ b/bin/gstack-gbrain-detect @@ -234,4 +234,14 @@ function main(): void { process.stdout.write(JSON.stringify(out, null, 2) + "\n"); } +// --is-ok: live engine-status gate. Exits 0 iff gbrain is usable ("ok"), 1 +// otherwise. Runs detection live (never reads the possibly-stale +// gbrain-detection.json), so callers — setup, bin/dev-setup, and +// `gstack-config gbrain-refresh` — can decide whether to render the gbrain +// :user variant without duplicating the JSON grep. Prints nothing on stdout. +if (process.argv.includes("--is-ok")) { + const noCache = process.env.GSTACK_DETECT_NO_CACHE === "1"; + process.exit(localEngineStatus({ noCache }) === "ok" ? 0 : 1); +} + main(); diff --git a/package.json b/package.json index 789aa8db8..53da1d736 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.57.8.0", + "version": "1.57.9.0", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/scripts/dev-skill.ts b/scripts/dev-skill.ts index ae6ba30ad..f585f57b5 100644 --- a/scripts/dev-skill.ts +++ b/scripts/dev-skill.ts @@ -50,6 +50,24 @@ function regenerateAndValidate() { console.log(` [check] \u2705 ${output} — ${totalValid} commands, all valid`); } } + + // Dev workspace render isolation: the default in-place regen above keeps the + // worktree canonical. If bin/dev-setup set up an untracked brain-aware render + // (.claude/gstack-rendered), refresh it too so live template edits reflect at + // this workspace's runtime. Only runs when the render dir already exists — we + // never create it during plain template dev. + const RENDER_DIR = path.join(ROOT, '.claude', 'gstack-rendered'); + if (fs.existsSync(RENDER_DIR)) { + try { + execSync( + `bun run scripts/gen-skill-docs.ts --respect-detection --host claude --out-dir ${JSON.stringify(RENDER_DIR)}`, + { cwd: ROOT, stdio: 'pipe' }, + ); + console.log(' [render] refreshed .claude/gstack-rendered (brain-aware workspace copy)'); + } catch (err: any) { + console.log(` [render] ERROR: ${err.stderr?.toString().trim() || err.message}`); + } + } } // Initial run diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index 5fea07713..45f617a1b 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -137,6 +137,39 @@ const EXPLAIN_LEVEL: 'default' | 'terse' = (() => { return val; })(); +// ─── Out-dir (dev workspace render isolation) ─────────────── +// --out-dir redirects Claude SKILL.md + section output to a separate +// (untracked) directory instead of writing in place, AND rewrites the literal +// section-base path (`~/.claude/skills/gstack//sections/`) inside the +// generated content to point at the out-dir, so section Reads resolve to the +// rendered copy rather than the global install. Used by bin/dev-setup to render +// the gbrain `:user` variant for a Conductor workspace without dirtying tracked +// source. Default (unset) = in-place, behavior unchanged. Claude host only. +const OUT_DIR_ARG = process.argv.find(a => a.startsWith('--out-dir')); +const OUT_DIR: string | null = (() => { + if (!OUT_DIR_ARG) return null; + const val = OUT_DIR_ARG.includes('=') + ? OUT_DIR_ARG.split('=')[1] + : process.argv[process.argv.indexOf(OUT_DIR_ARG) + 1]; + if (!val) throw new Error('--out-dir requires a directory path'); + return path.resolve(val); +})(); + +/** + * When rendering to an out-dir, repoint the literal section-base path at the + * out-dir so section Reads resolve to the rendered copy, not the global install. + * Surgical: ONLY paths containing `/sections/` are rewritten — bin/, browse/, + * docs/ references keep pointing at `~/.claude/skills/gstack` (the global + * install, which still works). No-op when --out-dir is unset. + */ +function rewriteSectionBase(content: string): string { + if (!OUT_DIR) return content; + return content.replace( + /~\/\.claude\/skills\/gstack\/([^\s)`"'*]+\/sections\/)/g, + `${OUT_DIR}/$1`, + ); +} + // HostPaths, HOST_PATHS, and TemplateContext imported from ./resolvers/types (line 7-8) // Design constants (AI_SLOP_BLACKLIST, OPENAI_HARD_REJECTIONS, OPENAI_LITMUS_CHECKS) // live in ./resolvers/constants and are consumed by resolvers directly. @@ -768,6 +801,12 @@ function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath: // Determine skill directory relative to ROOT const skillDir = path.relative(ROOT, path.dirname(tmplPath)); + // --out-dir (Claude only): mirror the skill tree into the out-dir instead of + // writing in place. External hosts compute their own paths below. + if (OUT_DIR && host === 'claude') { + outputPath = path.join(OUT_DIR, skillDir, path.basename(tmplPath).replace(/\.tmpl$/, '')); + } + // Extract name/description: name drives external skill naming + setup symlinks // (and TemplateContext.skillName via buildContext); description feeds external // host metadata. When frontmatter name: differs from directory name (e.g. @@ -822,6 +861,9 @@ function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath: } } + // --out-dir: repoint section-base paths to the out-dir (no-op otherwise). + if (host === 'claude') content = rewriteSectionBase(content); + return { outputPath, content, symlinkLoop, catalogParts }; } @@ -860,6 +902,10 @@ function processSectionTemplate( // External hosts: rewrite cross-reference paths/tools (no frontmatter to transform). if (host !== 'claude') { content = applyHostRewrites(content, hostConfig); + } else { + // --out-dir: a section may cross-reference another section by absolute path; + // repoint those to the out-dir too (no-op when --out-dir is unset). + content = rewriteSectionBase(content); } // Plain generated header (no frontmatter to insert after). @@ -868,7 +914,7 @@ function processSectionTemplate( const fileName = path.basename(sectionTmplPath).replace(/\.tmpl$/, ''); let outputPath: string; if (host === 'claude') { - outputPath = path.join(ROOT, skillDir, 'sections', fileName); + outputPath = path.join(OUT_DIR || ROOT, skillDir, 'sections', fileName); } else { const externalName = externalSkillName(skillDir, parentName); outputPath = path.join(ROOT, hostConfig.hostSubdir, 'skills', externalName, 'sections', fileName); @@ -933,7 +979,7 @@ for (const currentHost of hostsToRun) { voice_line: catalogParts.voiceLine, }; } - const relOutput = path.relative(ROOT, outputPath); + const relOutput = path.relative(OUT_DIR || ROOT, outputPath); if (symlinkLoop) { console.log(`SKIPPED (symlink loop): ${relOutput}`); @@ -946,6 +992,9 @@ for (const currentHost of hostsToRun) { console.log(`FRESH: ${relOutput}`); } } else { + // In-place writes land in existing dirs; --out-dir needs the mirrored + // skill dir created first. + if (OUT_DIR) fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, content); console.log(`GENERATED: ${relOutput}`); } @@ -982,7 +1031,7 @@ for (const currentHost of hostsToRun) { currentHostConfig.generation.skipSkills.includes(sec.skillDir)) continue; const { outputPath, content } = processSectionTemplate(path.join(ROOT, sec.tmpl), sec.skillDir, currentHost); - const relOutput = path.relative(ROOT, outputPath); + const relOutput = path.relative(OUT_DIR || ROOT, outputPath); if (DRY_RUN) { const existing = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf-8') : ''; @@ -1079,7 +1128,9 @@ The orchestrator will persist the plan link to its own memory/knowledge store. // No timestamp field — keeps the file content-deterministic across runs so // CI dry-run freshness checks don't flap on regen. If a per-run timestamp // is ever needed for debugging, write it to a separate `.gen-stamp` file. - if (currentHost === 'claude' && CATALOG_MODE === 'trim' && Object.keys(proactiveAggregate).length > 0 && !DRY_RUN) { + // Skip the global proactive-suggestions.json in --out-dir mode: it lives at + // a repo path (scripts/) and the dev workspace render doesn't need it. + if (currentHost === 'claude' && CATALOG_MODE === 'trim' && Object.keys(proactiveAggregate).length > 0 && !DRY_RUN && !OUT_DIR) { const proactivePath = path.join(ROOT, 'scripts', 'proactive-suggestions.json'); // Sort keys alphabetically so the serialized JSON is identical across // machines regardless of filesystem-iteration order. Without this, CI diff --git a/setup b/setup index 0c180f7bf..ec1db22b7 100755 --- a/setup +++ b/setup @@ -1286,22 +1286,37 @@ fi DETECT_BIN="$SOURCE_GSTACK_DIR/bin/gstack-gbrain-detect" GBRAIN_STATE_DIR="${GSTACK_HOME:-$HOME/.gstack}" DETECTION_FILE="$GBRAIN_STATE_DIR/gbrain-detection.json" +# PID-unique tmp so concurrent setups (parallel Conductor workspaces) can't +# clobber each other's in-flight detection write. +DETECTION_TMP="$DETECTION_FILE.$$.tmp" mkdir -p "$GBRAIN_STATE_DIR" if [ -x "$DETECT_BIN" ]; then - if "$DETECT_BIN" > "$DETECTION_FILE.tmp" 2>/dev/null; then - mv "$DETECTION_FILE.tmp" "$DETECTION_FILE" - if grep -q '"gbrain_local_status": "ok"' "$DETECTION_FILE" 2>/dev/null; then - log "gbrain detected — regenerating Claude SKILL.md with brain-aware blocks (~250 token overhead per planning skill)..." - ( - cd "$SOURCE_GSTACK_DIR" - bun_cmd run gen:skill-docs:user --host claude 2>&1 | tail -3 - ) || log " warning: gen:skill-docs:user failed — run 'bun run gen:skill-docs:user' manually if you want brain-aware blocks" + if "$DETECT_BIN" > "$DETECTION_TMP" 2>/dev/null; then + mv "$DETECTION_TMP" "$DETECTION_FILE" + # Single source of truth for "is gbrain usable" — `--is-ok` runs live + # detection (exit 0 iff ok), so setup, bin/dev-setup, and gstack-config + # all gate on the same check instead of re-grepping the JSON. + if "$DETECT_BIN" --is-ok 2>/dev/null; then + if [ -n "${GSTACK_SKIP_GBRAIN_REGEN:-}" ]; then + # Dev/source tree (set by bin/dev-setup): never regenerate tracked + # SKILL.md in place — that dirties checked-in source. Detection is + # still persisted above; the dev workspace renders the :user variant + # into an untracked dir, and other projects get blocks via + # `gstack-config gbrain-refresh`. + log "gbrain detected — GSTACK_SKIP_GBRAIN_REGEN set: leaving tracked SKILL.md canonical (dev/source tree)." + else + log "gbrain detected — regenerating Claude SKILL.md with brain-aware blocks (~250 token overhead per planning skill)..." + ( + cd "$SOURCE_GSTACK_DIR" + bun_cmd run gen:skill-docs:user --host claude 2>&1 | tail -3 + ) || log " warning: gen:skill-docs:user failed — run 'bun run gen:skill-docs:user' manually if you want brain-aware blocks" + fi else log "gbrain not detected — brain-aware blocks suppressed in planning-skill SKILL.md files (zero token overhead)." log " To enable: install gbrain via /setup-gbrain, then re-run ./setup or 'gstack-config gbrain-refresh'." fi else - rm -f "$DETECTION_FILE.tmp" + rm -f "$DETECTION_TMP" log " warning: gstack-gbrain-detect failed — brain-aware blocks will stay suppressed" fi fi diff --git a/test/dev-setup-render-isolation.test.ts b/test/dev-setup-render-isolation.test.ts new file mode 100644 index 000000000..fbfeb790c --- /dev/null +++ b/test/dev-setup-render-isolation.test.ts @@ -0,0 +1,91 @@ +import { describe, test, expect } from 'bun:test'; +import * as path from 'path'; +import * as fs from 'fs'; + +// Static tripwires for the B2 render-isolation wiring. These fail CI if a +// refactor drops a load-bearing line, re-introducing the "dev-setup dirties +// tracked SKILL.md" drift (or worse, leaks the skip-guard into real installs). +const ROOT = path.resolve(import.meta.dir, '..'); +const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf-8'); + +describe('dev-setup: worktree stays canonical', () => { + const devSetup = read('bin/dev-setup'); + + test('passes GSTACK_SKIP_GBRAIN_REGEN inline on the nested setup call', () => { + expect(devSetup).toContain('GSTACK_SKIP_GBRAIN_REGEN=1 "$GSTACK_LINK/setup"'); + }); + + test('never exports GSTACK_SKIP_GBRAIN_REGEN (would leak into other setup paths)', () => { + expect(devSetup).not.toMatch(/export\s+GSTACK_SKIP_GBRAIN_REGEN/); + }); + + test('renders the :user variant into an out-dir, not in place', () => { + expect(devSetup).toContain('--out-dir'); + expect(devSetup).toContain('.claude/gstack-rendered'); + }); + + test('gates the render on gstack-gbrain-detect --is-ok', () => { + expect(devSetup).toContain('--is-ok'); + }); +}); + +describe('setup: honors GSTACK_SKIP_GBRAIN_REGEN', () => { + const setup = read('setup'); + + test('skips the in-place :user regen when the guard is set', () => { + expect(setup).toContain('${GSTACK_SKIP_GBRAIN_REGEN:-}'); + // The guard must wrap the in-place render, not the detection persist. + const idx = setup.indexOf('GSTACK_SKIP_GBRAIN_REGEN'); + const after = setup.slice(idx, idx + 600); + expect(after).toContain('leaving tracked SKILL.md canonical'); + }); + + test('uses a PID-unique detection tmp (no concurrent clobber)', () => { + expect(setup).toContain('$DETECTION_FILE.$$.tmp'); + }); + + test('gates detection on the shared --is-ok check', () => { + expect(setup).toContain('"$DETECT_BIN" --is-ok'); + }); +}); + +describe('gen-skill-docs: section rewrite is gated on --out-dir', () => { + const gen = read('scripts/gen-skill-docs.ts'); + + test('rewriteSectionBase is a no-op without --out-dir', () => { + expect(gen).toContain('function rewriteSectionBase'); + const idx = gen.indexOf('function rewriteSectionBase'); + const body = gen.slice(idx, idx + 400); + expect(body).toContain('if (!OUT_DIR) return content'); + expect(body).toContain('sections'); // surgical: regex targets only /sections/ paths + }); +}); + +describe('dev-teardown: removes the untracked render', () => { + const teardown = read('bin/dev-teardown'); + + test('rm -rf the gstack-rendered dir', () => { + expect(teardown).toContain('gstack-rendered'); + expect(teardown).toMatch(/rm -rf .*RENDER_DIR/); + }); +}); + +describe('.gitignore: render dir is declared untracked', () => { + test('.claude/gstack-rendered/ is ignored', () => { + expect(read('.gitignore')).toContain('.claude/gstack-rendered/'); + }); +}); + +describe('dev-skill: refreshes the render on template change', () => { + const devSkill = read('scripts/dev-skill.ts'); + + test('re-renders the :user variant into the workspace render dir', () => { + expect(devSkill).toContain('gstack-rendered'); + expect(devSkill).toContain('--out-dir'); + expect(devSkill).toContain('--respect-detection'); + }); + + test('only refreshes when the render dir already exists (never creates it during plain dev)', () => { + expect(devSkill).toContain('fs.existsSync(RENDER_DIR)'); + }); +}); diff --git a/test/gbrain-detect-shape.test.ts b/test/gbrain-detect-shape.test.ts index 465e55623..e2f67ee07 100644 --- a/test/gbrain-detect-shape.test.ts +++ b/test/gbrain-detect-shape.test.ts @@ -16,7 +16,7 @@ */ import { describe, it, expect } from "bun:test"; -import { execFileSync } from "child_process"; +import { execFileSync, spawnSync } from "child_process"; import { mkdtempSync, mkdirSync, @@ -47,6 +47,16 @@ function runDetect(env: Partial): string { }); } +/** Run detect with --is-ok and return its exit code (never throws). */ +function runIsOk(env: Partial): number { + const r = spawnSync(BUN_BIN, ["run", DETECT_BIN, "--is-ok"], { + timeout: 15_000, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, ...env }, + }); + return r.status ?? 1; +} + interface DetectShape { gbrain_on_path: boolean; gbrain_version: string | null; @@ -244,3 +254,66 @@ exit 0 } }); }); + +describe("bin/gstack-gbrain-detect --is-ok — live gate", () => { + it("exits non-zero when gbrain is not on PATH (no-cli)", () => { + const tmp = mkdtempSync(join(tmpdir(), "detect-isok-")); + try { + const code = runIsOk({ + HOME: tmp, + PATH: "/usr/bin:/bin", // no gbrain + GSTACK_HOME: tmp, + GSTACK_DETECT_NO_CACHE: "1", + }); + expect(code).not.toBe(0); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("exits 0 when a fake gbrain reports a healthy engine (ok)", () => { + const tmp = mkdtempSync(join(tmpdir(), "detect-isok-")); + const bindir = join(tmp, "bin"); + const home = join(tmp, "home"); + const configDir = join(home, ".gbrain"); + try { + mkdirSync(bindir, { recursive: true }); + mkdirSync(configDir, { recursive: true }); + writeFileSync(join(configDir, "config.json"), JSON.stringify({ engine: "pglite" })); + const fake = `#!/bin/sh +case "$1 $2" in + "--version ") echo "gbrain 0.33.1.0"; exit 0 ;; + "sources list") echo '{"sources":[]}'; exit 0 ;; + "doctor "*) echo '{"status":"ok","checks":[]}'; exit 0 ;; +esac +exit 0 +`; + const gbrainPath = join(bindir, "gbrain"); + writeFileSync(gbrainPath, fake); + chmodSync(gbrainPath, 0o755); + + const code = runIsOk({ + HOME: home, + PATH: `${bindir}:/usr/bin:/bin`, + GSTACK_HOME: tmp, + GSTACK_DETECT_NO_CACHE: "1", + }); + expect(code).toBe(0); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("exit code agrees with the JSON gbrain_local_status (no skew)", () => { + // Run both surfaces against the same env and assert they never disagree. + const tmp = mkdtempSync(join(tmpdir(), "detect-isok-")); + try { + const env = { HOME: tmp, PATH: "/usr/bin:/bin", GSTACK_HOME: tmp, GSTACK_DETECT_NO_CACHE: "1" }; + const status = (JSON.parse(runDetect(env)) as DetectShape).gbrain_local_status; + const code = runIsOk(env); + expect(code === 0).toBe(status === "ok"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/test/gbrain-refresh-install-render.test.ts b/test/gbrain-refresh-install-render.test.ts new file mode 100644 index 000000000..f1494d47d --- /dev/null +++ b/test/gbrain-refresh-install-render.test.ts @@ -0,0 +1,60 @@ +import { describe, test, expect } from 'bun:test'; +import * as path from 'path'; +import * as fs from 'fs'; + +// Static tripwires for the C (machine-wide) render in `gstack-config +// gbrain-refresh`. The render mutates the shared global install, so the guards +// that stop it from touching the wrong directory are load-bearing — these fail +// CI if any guard is dropped. +const ROOT = path.resolve(import.meta.dir, '..'); +const SRC = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-config'), 'utf-8'); + +// Pull out just the gbrain-refresh `ok)` branch so assertions can't be +// satisfied by unrelated text elsewhere in the file. +function okBranch(): string { + const start = SRC.indexOf('gbrain-refresh)'); + const ok = SRC.indexOf('ok)', start); + const end = SRC.indexOf(';;', ok); + if (start < 0 || ok < 0 || end < 0) throw new Error('Could not locate gbrain-refresh ok) branch'); + return SRC.slice(ok, end); +} + +describe('gstack-config gbrain-refresh: machine-wide render guards', () => { + const branch = okBranch(); + + test('targets the global install', () => { + expect(branch).toContain('$HOME/.claude/skills/gstack'); + }); + + test('refuses a symlinked install (would dirty a dev worktree)', () => { + expect(branch).toMatch(/\[ -L "\$INSTALL_DIR" \]/); + }); + + test('verifies it is a real gstack clone before mutating it', () => { + expect(branch).toContain('$INSTALL_DIR/VERSION'); + expect(branch).toContain('$INSTALL_DIR/package.json'); + }); + + test('requires bun on PATH', () => { + expect(branch).toContain('command -v bun'); + }); + + test('renders the :user variant in place into the install', () => { + expect(branch).toContain('gen:skill-docs:user --host claude'); + }); + + test('is self-documenting about the reset --hard / re-run cycle', () => { + expect(branch).toContain('reset --hard'); + expect(branch).toContain('gbrain-refresh'); + }); +}); + +describe('CLAUDE.md: deploy section documents the re-run', () => { + test('notes re-running gbrain-refresh after reset --hard', () => { + const claudeMd = fs.readFileSync(path.join(ROOT, 'CLAUDE.md'), 'utf-8'); + const idx = claudeMd.indexOf('## Deploying to the active skill'); + expect(idx).toBeGreaterThan(-1); + const section = claudeMd.slice(idx, idx + 1200); + expect(section).toContain('gbrain-refresh'); + }); +}); diff --git a/test/gen-skill-docs-out-dir.test.ts b/test/gen-skill-docs-out-dir.test.ts new file mode 100644 index 000000000..fbc1345e4 --- /dev/null +++ b/test/gen-skill-docs-out-dir.test.ts @@ -0,0 +1,84 @@ +import { describe, test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import { createHash } from 'crypto'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; + +const ROOT = path.resolve(import.meta.dir, '..'); + +// Render the gbrain `:user` variant into a temp out-dir, forcing detection ON +// via a crafted GSTACK_HOME so the test is deterministic regardless of whether +// the dev machine actually has gbrain installed. Asserts the B2 contract: +// (a) the worktree SKILL.md is byte-unchanged (source stays canonical), +// (b) the out-dir SKILL.md gained the inline Brain Context Load block, +// (c) its section refs point at the out-dir, not ~/.claude/skills/gstack, +// (d) bin/ refs are left pointing at the global install, +// (e) the out-dir section file gained the Save Results to Brain block. +describe('gen-skill-docs --out-dir (B2 render isolation)', () => { + function hashFile(p: string): string { + return createHash('sha256').update(fs.readFileSync(p)).digest('hex'); + } + + test('renders :user to out-dir, rewrites section paths, leaves worktree canonical', () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-home-')); + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-out-')); + const worktreeSkill = path.join(ROOT, 'ship', 'SKILL.md'); + const beforeHash = hashFile(worktreeSkill); + try { + // Force gbrain detection ON for --respect-detection. + fs.writeFileSync( + path.join(tmpHome, 'gbrain-detection.json'), + JSON.stringify({ gbrain_local_status: 'ok', gbrain_version: '9.9.9' }), + ); + + const res = spawnSync( + 'bun', + ['run', 'scripts/gen-skill-docs.ts', '--respect-detection', '--host', 'claude', '--out-dir', outDir], + { cwd: ROOT, encoding: 'utf-8', timeout: 120_000, env: { ...process.env, GSTACK_HOME: tmpHome } }, + ); + expect(res.status).toBe(0); + + const outSkill = path.join(outDir, 'ship', 'SKILL.md'); + const outSection = path.join(outDir, 'ship', 'sections', 'adversarial.md'); + expect(fs.existsSync(outSkill)).toBe(true); + const skillContent = fs.readFileSync(outSkill, 'utf-8'); + + // (a) worktree byte-unchanged + expect(hashFile(worktreeSkill)).toBe(beforeHash); + + // (b) inline block present in the rendered SKILL.md + expect(skillContent).toContain('Brain Context Load'); + + // (c) section refs repointed to the out-dir; none left pointing at the install + expect(skillContent).toContain(`${outDir}/ship/sections/`); + expect(skillContent).not.toContain('~/.claude/skills/gstack/ship/sections/'); + + // (d) bin refs are NOT rewritten — they still resolve to the global install + expect(skillContent).toContain('~/.claude/skills/gstack/bin/'); + + // (e) the SAVE block landed in the rendered section file + expect(fs.existsSync(outSection)).toBe(true); + expect(fs.readFileSync(outSection, 'utf-8')).toContain('Save Results to Brain'); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(outDir, { recursive: true, force: true }); + } + }); + + test('global extras (proactive-suggestions.json) are NOT written in out-dir mode', () => { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-out-')); + try { + const res = spawnSync( + 'bun', + ['run', 'scripts/gen-skill-docs.ts', '--host', 'claude', '--out-dir', outDir], + { cwd: ROOT, encoding: 'utf-8', timeout: 120_000 }, + ); + expect(res.status).toBe(0); + // proactive-suggestions.json lives at a repo path; out-dir mode must skip it. + expect(fs.existsSync(path.join(outDir, 'scripts', 'proactive-suggestions.json'))).toBe(false); + } finally { + fs.rmSync(outDir, { recursive: true, force: true }); + } + }); +});