From 7ab271318b3d4215406c963c4f2840766dc97ba6 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 08:33:04 -0700 Subject: [PATCH 001/126] fix(test): host-config goldens self-provision .agents/.factory artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2532. The codex/factory golden tests read gitignored artifacts that only gen-skill-docs.test.ts (serial tree-mutating phase) produces, so the file failed in isolation and on clean clones (the #2536 "3 failures then 0" symptom). beforeAll now generates a host's artifacts iff its ship SKILL.md is missing — never overwriting existing ones, so stale artifacts still fail the golden. The file is also classified TREE_MUTATING so its provisioning runs in the serial window, not racing parallel readers. Verified: full pass with .agents/ and .factory/ deleted (74/74 in isolation). Co-Authored-By: Claude Fable 5 --- scripts/test-free-shards.ts | 2 ++ test/host-config.test.ts | 29 ++++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/scripts/test-free-shards.ts b/scripts/test-free-shards.ts index 386b25a29c..4ac9c69cc5 100755 --- a/scripts/test-free-shards.ts +++ b/scripts/test-free-shards.ts @@ -355,6 +355,8 @@ export const TREE_MUTATING: Record = { 'test/skill-validation.test.ts': 'regenerates .agents/ (codex host) artifacts in place (3 sites)', 'test/gbrain-detection-override.test.ts': 'regenerates SKILL.md in place with --respect-detection (gbrain variant), then git-restores — readers see inflated skeletons mid-window', + 'test/host-config.test.ts': + 'golden tests read .agents/.factory artifacts produced by gen-skill-docs.test.ts, and its beforeAll generates them when missing (#2532) — must not race the parallel readers or run before the mutators window', // Ratchet readers (measure the tree; need it quiet): 'test/parity-suite.test.ts': 'RATCHET READER — parity caps measure live SKILL.md/section bytes', 'test/skill-size-budget.test.ts': 'RATCHET READER — per-skill and corpus size budgets measure the live tree', diff --git a/test/host-config.test.ts b/test/host-config.test.ts index ffdf5353a2..b685bb89dc 100644 --- a/test/host-config.test.ts +++ b/test/host-config.test.ts @@ -3,7 +3,7 @@ * host-config-export.ts, and golden-file regression checks. */ -import { describe, test, expect } from 'bun:test'; +import { describe, test, expect, beforeAll } from 'bun:test'; import * as fs from 'fs'; import * as path from 'path'; import { validateHostConfig, validateAllConfigs, type HostConfig } from '../scripts/host-config'; @@ -421,6 +421,33 @@ describe('host-config-export.ts CLI', () => { describe('golden-file regression', () => { const GOLDEN_DIR = path.join(ROOT, 'test', 'fixtures', 'golden'); + // #2532: the codex/factory goldens read gitignored .agents/ and .factory/ + // artifacts that only gen-skill-docs.test.ts (a serial tree-mutating file) + // produces. On a clean clone — or when this file runs in isolation — those + // dirs don't exist and the goldens fail with ENOENT, an order dependency, + // not a regression. Self-provision: generate a host's artifacts iff its + // ship SKILL.md is missing. Existing artifacts are never overwritten here, + // so a genuinely stale artifact still fails the golden (that is the test's + // job; freshness enforcement lives in gen-skill-docs.test.ts). + beforeAll(() => { + const hostArtifacts: Array<[string, string]> = [ + ['codex', path.join(ROOT, '.agents', 'skills', 'gstack-ship', 'SKILL.md')], + ['factory', path.join(ROOT, '.factory', 'skills', 'gstack-ship', 'SKILL.md')], + ]; + for (const [host, artifact] of hostArtifacts) { + if (fs.existsSync(artifact)) continue; + const result = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', host], { + cwd: ROOT, + }); + if (result.exitCode !== 0) { + throw new Error( + `golden-file beforeAll: gen-skill-docs --host ${host} failed (exit ${result.exitCode}):\n` + + result.stderr.toString(), + ); + } + } + }); + test('Claude ship skill matches golden baseline', () => { const golden = fs.readFileSync(path.join(GOLDEN_DIR, 'claude-ship-SKILL.md'), 'utf-8'); const current = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8'); From cb8c79ac778544cb98dcc549dcfffa7c13ea56fd Mon Sep 17 00:00:00 2001 From: Stefan Andrei <89592870+sneakygriff@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:32:33 +0300 Subject: [PATCH 002/126] fix(test): exempt the live repo tree from hermetic-wiring's operator-~/.claude ban MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill-seeding tripwire asserted every seeded symlink target must NOT start with ~/.claude — but on the default global-git install the repo itself lives at ~/.claude/skills/gstack, so every CORRECT symlink (which must resolve into the live repo tree, as the very next assertion requires) carried the banned prefix. The test could never pass on a default install: pristine v1.64.1.0 (c118e240) fails it in any worktree under ~/.claude/skills/ and passes elsewhere (verified 2026-08-15). Exempt targets that realpath into the resolved repo ROOT before applying the operatorClaude ban — realpath both sides so a symlinked HOME can't dodge the tripwire. Genuine escapes (a target under ~/.claude but outside the repo) still fail with the escape message. Co-Authored-By: Claude Fable 5 --- test/hermetic-wiring.test.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/test/hermetic-wiring.test.ts b/test/hermetic-wiring.test.ts index 658344cfe7..87fe135231 100644 --- a/test/hermetic-wiring.test.ts +++ b/test/hermetic-wiring.test.ts @@ -125,10 +125,21 @@ describe('hermetic wiring tripwire', () => { expect(configDir.startsWith(runRoot + path.sep)).toBe(true); expect(configDir.startsWith(operatorClaude)).toBe(false); const skillsDir = path.join(configDir, 'skills'); + const repoRootReal = fs.realpathSync(ROOT) + path.sep; for (const entry of fs.readdirSync(skillsDir)) { const target = fs.readlinkSync(path.join(skillsDir, entry, 'SKILL.md')); - expect(target.startsWith(operatorClaude), `${entry}: symlink escapes to ${target}`).toBe(false); - expect(fs.realpathSync(target).startsWith(fs.realpathSync(ROOT) + path.sep), `${entry}: symlink outside repo: ${target}`).toBe(true); + const resolved = fs.realpathSync(target); + // Targets inside the live repo checkout are the blessed edge — exempt + // them BEFORE the operator-~/.claude ban. On the default global-git + // install the repo itself lives at ~/.claude/skills/gstack, so every + // CORRECT symlink carries the operatorClaude prefix and an unexempted + // ban can never pass (regression 2026-08-15: pristine v1.64.1.0 fails + // this test in any worktree under ~/.claude/skills/ and passes + // elsewhere — realpath both sides so a symlinked HOME can't dodge it). + if (!resolved.startsWith(repoRootReal)) { + expect(resolved.startsWith(operatorClaude), `${entry}: symlink escapes to ${target}`).toBe(false); + } + expect(resolved.startsWith(repoRootReal), `${entry}: symlink outside repo: ${target}`).toBe(true); } }); }); From ba979dbd6f1ab470c88a4ef8bf80cd08c330cf00 Mon Sep 17 00:00:00 2001 From: Stefan Andrei <89592870+sneakygriff@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:46:29 +0300 Subject: [PATCH 003/126] fix(gen-skill-docs): quote YAML inline scalars containing '...' (Bun strict parser breaks on bare ellipsis) A bare ... inside a plain YAML scalar is a document-end marker that strict YAML parsers (Bun.YAML among them) reject mid-scalar. catalog-trim truncation appends '...' to any description whose lead exceeds 200 chars, so any truncated description would generate a SKILL.md with unparseable frontmatter. Add the ellipsis test to toYamlInlineScalar's needsQuote so such scalars are emitted double-quoted, plus unit coverage for the quoting rules. Co-Authored-By: Claude Fable 5 --- scripts/gen-skill-docs.ts | 1 + test/catalog-trim.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index b348c51622..fbff381b7b 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -413,6 +413,7 @@ export function toYamlInlineScalar(s: string): string { s !== s.trim() || // leading/trailing whitespace /:(\s|$)/.test(s) || // "foo: bar" / trailing colon → mapping ambiguity /\s#/.test(s) || // " #" → inline comment + /\.\.\./.test(s) || // "..." → document-end marker; strict parsers reject mid-scalar (catalog-trim truncation appends it) /^[\s>|&*!%@`"'#,\[\]{}?-]/.test(s); // leading YAML indicator char return needsQuote ? JSON.stringify(s) : s; } diff --git a/test/catalog-trim.test.ts b/test/catalog-trim.test.ts index 6ff8cb4dd8..79380c3414 100644 --- a/test/catalog-trim.test.ts +++ b/test/catalog-trim.test.ts @@ -23,8 +23,37 @@ import { buildTrimmedDescription, buildWhenToInvokeSection, applyCatalogTrim, + toYamlInlineScalar, } from '../scripts/gen-skill-docs'; +describe('toYamlInlineScalar', () => { + const parses = (out: string) => Bun.YAML.parse(`d: ${out}`); + + test("scalar containing '...' (YAML document-end marker) is quoted and round-trips", () => { + const out = toYamlInlineScalar('Truncated lead ends with... more (gstack)'); + expect(out.startsWith('"')).toBe(true); + expect((parses(out) as { d: string }).d).toContain('...'); + }); + + test("interior ': ' is quoted (nested-mapping ambiguity, #1778)", () => { + const out = toYamlInlineScalar('Ship workflow: detect and merge'); + expect(out.startsWith('"')).toBe(true); + expect((parses(out) as { d: string }).d).toBe('Ship workflow: detect and merge'); + }); + + test('plain safe scalar passes through unquoted', () => { + expect(toYamlInlineScalar('Simple description here')).toBe('Simple description here'); + }); + + test('leading YAML indicator char is quoted', () => { + expect(toYamlInlineScalar('- leading dash').startsWith('"')).toBe(true); + }); + + test('trailing whitespace is quoted', () => { + expect(toYamlInlineScalar('has trailing space ').startsWith('"')).toBe(true); + }); +}); + describe('splitCatalogDescription', () => { test('extracts lead sentence + routing prose from simple multi-line description', () => { const desc = From 480ebe4f261419fe504e8501c61e4f70388230d8 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 08:38:06 -0700 Subject: [PATCH 004/126] fix(gen-skill-docs): throw when a template contains {{PREAMBLE}} twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardens the #2508/#2362 class: a second {{PREAMBLE}} occurrence — even a prose mention, which is exactly how spec/SKILL.md.tmpl re-expanded the full ~12K-token preamble mid-document — now fails generation with the template path instead of silently shipping a doubled preamble. Pure exported guard (assertSinglePreamble) called from resolvePlaceholders, unit-tested with the original prose-mention shape. Co-Authored-By: Claude Fable 5 --- scripts/gen-skill-docs.ts | 20 ++++++++++++++++++++ test/gen-skill-docs.test.ts | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index fbff381b7b..0f7ac97dd2 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -668,12 +668,32 @@ function applyHostRewrites(content: string, hostConfig: HostConfig): string { * unresolved. Extracted so SKILL.md and section templates resolve through the * exact same path — a security/sanitization fix to one can't miss the other. */ +/** + * A second {{PREAMBLE}} in one template re-expands the entire ~12K-token + * preamble mid-document (#2508/#2362 — a PROSE mention of the macro in + * spec/SKILL.md.tmpl expanded it a second time, +43KB per /spec load). + * Resolution is context-blind, so any second occurrence — code fence, prose, + * anywhere — is a generation error, never intentional. Throw at render time + * so the mistake cannot reach a generated SKILL.md again. + */ +export function assertSinglePreamble(tmplContent: string, relTmplPath: string): void { + const count = (tmplContent.match(/\{\{PREAMBLE\}\}/g) || []).length; + if (count > 1) { + throw new Error( + `${relTmplPath} contains {{PREAMBLE}} ${count} times — a template may reference it ` + + `at most once (each occurrence expands the full preamble; see #2508/#2362). ` + + `Refer to "the preamble" in prose instead of the macro.`, + ); + } +} + function resolvePlaceholders( tmplContent: string, ctx: TemplateContext, hostConfig: HostConfig, relTmplPath: string, ): string { + assertSinglePreamble(tmplContent, relTmplPath); // effectiveSuppressedResolvers() honors --respect-detection: when gbrain is // detected locally, GBRAIN_* resolvers un-suppress. Shared by SKILL.md and // section generation so both paths get the same gbrain-aware behavior. diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index e9d440e02d..1c9bf1fb46 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -1,4 +1,5 @@ import { describe, test, expect, beforeAll } from 'bun:test'; +import { assertSinglePreamble } from '../scripts/gen-skill-docs'; import { COMMAND_DESCRIPTIONS } from '../browse/src/commands'; import { SNAPSHOT_FLAGS } from '../browse/src/snapshot'; import * as fs from 'fs'; @@ -1507,6 +1508,25 @@ describe('CHANGELOG_WORKFLOW resolver', () => { }); }); +// --- Duplicate {{PREAMBLE}} guard (#2508/#2362) --- + +describe('assertSinglePreamble', () => { + test('one {{PREAMBLE}} passes', () => { + expect(() => assertSinglePreamble('a\n{{PREAMBLE}}\nb', 'x/SKILL.md.tmpl')).not.toThrow(); + }); + + test('zero {{PREAMBLE}} passes (sections have none)', () => { + expect(() => assertSinglePreamble('no macro here', 'x/sections/y.md.tmpl')).not.toThrow(); + }); + + test('a second occurrence throws with the template path — even in prose', () => { + // The original #2508 bug WAS a prose mention: "emitted by {{PREAMBLE}}'s + // preamble bash". Resolution is context-blind, so the guard must be too. + const tmpl = '{{PREAMBLE}}\n\n...later: emitted by {{PREAMBLE}}\'s preamble bash'; + expect(() => assertSinglePreamble(tmpl, 'spec/SKILL.md.tmpl')).toThrow(/spec\/SKILL\.md\.tmpl.*2 times/); + }); +}); + // --- Parameterized resolver infrastructure tests --- describe('parameterized resolver support', () => { From 7ed17bf76d1f7646509377e0b6b1d354ae3d884e Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 08:38:24 -0700 Subject: [PATCH 005/126] fix(test): classify catalog-trim.test.ts as tree-mutating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovered while landing the duplicate-{{PREAMBLE}} guard: importing scripts/gen-skill-docs.ts executes its top-level body, which regenerates the entire claude host (71 GENERATED files) at import time. catalog-trim.test.ts does that import from a PARALLEL shard — the same read-during-regeneration hazard class as #2532, invisible only because the regen is byte-identical on a fresh tree. Move it to the serial tree-mutating window. Co-Authored-By: Claude Fable 5 --- scripts/test-free-shards.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/test-free-shards.ts b/scripts/test-free-shards.ts index 4ac9c69cc5..4c0bc021e2 100755 --- a/scripts/test-free-shards.ts +++ b/scripts/test-free-shards.ts @@ -357,6 +357,8 @@ export const TREE_MUTATING: Record = { 'regenerates SKILL.md in place with --respect-detection (gbrain variant), then git-restores — readers see inflated skeletons mid-window', 'test/host-config.test.ts': 'golden tests read .agents/.factory artifacts produced by gen-skill-docs.test.ts, and its beforeAll generates them when missing (#2532) — must not race the parallel readers or run before the mutators window', + 'test/catalog-trim.test.ts': + 'imports scripts/gen-skill-docs.ts, whose top-level body regenerates the full claude host at import time (71 files; idempotent on a fresh tree, but a stale tree gets rewritten mid-window) — same hazard class as #2532', // Ratchet readers (measure the tree; need it quiet): 'test/parity-suite.test.ts': 'RATCHET READER — parity caps measure live SKILL.md/section bytes', 'test/skill-size-budget.test.ts': 'RATCHET READER — per-skill and corpus size budgets measure the live tree', From 890fcacde51d53984bba09ebc223dc7b63e6b84a Mon Sep 17 00:00:00 2001 From: Lucky Wenapere Date: Thu, 13 Aug 2026 03:00:33 +0100 Subject: [PATCH 006/126] fix(test): prepush hook test builds PATH with a POSIX-only separator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test/redact-prepush-hook.test.ts` shadows `git` with a stub by prepending a temp dir to PATH, built as `${stubDir}:${process.env.PATH}`. On Windows the separator is `;`, so that produces one unparseable entry, the stub is never found, and the REAL git runs — the diff succeeds, `gitStrict` never throws, and the hook exits 0 where the test expects 1. It fails as a wrong assertion rather than as a portability problem, which is what made it hard to place. Replace it with a `prependPath` helper mirroring the one already in test/gstack-brain-context-load.test.ts, which handles both platform details: `path.delimiter`, and a case-insensitive lookup of the existing env key — Windows commonly spells it `Path`, and adding a second `PATH` alongside an inherited `Path` leaves the winner up to the spawn implementation. On POSIX the helper resolves to `{ PATH: binDir + ":" + process.env.PATH }`, byte-identical to the expression it replaces, so behaviour there is unchanged. Fixing the separator alone does not make the test pass on Windows, and it cannot: the premise is that a signal-killed child yields `spawnSync` status === null, and Windows has no equivalent (a force-killed process reports a non-zero exit code). The stub is also a `#!/bin/sh` file named `git`, which Windows will not execute, since process creation resolves through PATHEXT and ignores the shebang. A Windows variant would assert the non-zero-exit branch instead — a different branch than the test name claims — so the test is gated with test.skipIf(process.platform === "win32"), matching test/session-runner-timeout.test.ts and test/setup-emoji-font.test.ts. Windows before: 14 pass, 1 fail. After: 14 pass, 1 skip, 0 fail (3 consecutive runs). Unchanged on POSIX, where it should still run and pass — worth confirming in CI, since I can only verify the Windows half here. Co-Authored-By: Claude Opus 5 --- test/redact-prepush-hook.test.ts | 80 ++++++++++++++++++++++---------- 1 file changed, 56 insertions(+), 24 deletions(-) diff --git a/test/redact-prepush-hook.test.ts b/test/redact-prepush-hook.test.ts index 489d22d383..1faed1d5c5 100644 --- a/test/redact-prepush-hook.test.ts +++ b/test/redact-prepush-hook.test.ts @@ -44,6 +44,24 @@ function runHook( return { code: r.status ?? 0, stderr: r.stderr ?? "" }; } +/** + * Env override that puts `binDir` first on the search path, for tests that + * shadow a real binary with a stub. + * + * Two portability details, both of which a hardcoded `PATH: "dir:" + ...` + * gets wrong (mirrors `prependPath` in test/gstack-brain-context-load.test.ts): + * - The separator is `;` on Windows, not `:`. Using the literal produces one + * unparseable entry, so the stub is never found and the REAL binary runs — + * a test that silently passes through rather than failing loudly. + * - Windows env keys are case-insensitive and commonly spelled `Path`. Adding + * a second `PATH` key alongside an inherited `Path` leaves which one wins up + * to the spawn implementation, so reuse whichever key already exists. + */ +function prependPath(binDir: string): Record { + const pathKey = Object.keys(process.env).find((k) => k.toLowerCase() === "path") || "PATH"; + return { [pathKey]: `${binDir}${path.delimiter}${process.env[pathKey] || ""}` }; +} + const ZERO = "0000000000000000000000000000000000000000"; // Assembled at runtime so the LITERAL never appears in a pushed diff — the @@ -147,30 +165,44 @@ describe("fail closed on unscannable diffs (#1946)", () => { expect(stderr).not.toContain("could not compute the pushed diff"); }); - test("a diff killed by a signal (null status — the maxBuffer/kill class) BLOCKS", () => { - // Stub git: probes delegate to the real git; the diff invocation kills - // itself, producing spawnSync status === null. This is the exact branch - // gitStrict's docstring names (oversized-diff overflow is delivered the - // same way) — pre-landing review flagged it as untested. - const realGit = Bun.which("git") || "/usr/bin/git"; - const stubDir = fs.mkdtempSync(path.join(os.tmpdir(), "prepush-stubgit-")); - try { - const stub = `#!/bin/sh\nif [ "$1" = "diff" ]; then kill -KILL $$; fi\nexec "${realGit}" "$@"\n`; - fs.writeFileSync(path.join(stubDir, "git"), stub); - fs.chmodSync(path.join(stubDir, "git"), 0o755); - - const base = git(["rev-parse", "HEAD"]); - const head = commit("clean.txt", "clean content\n", "clean commit"); - const { code, stderr } = runHook(`refs/heads/main ${head} refs/heads/main ${base}\n`, { - PATH: `${stubDir}:${process.env.PATH}`, - }); - expect(code).toBe(1); - expect(stderr).toContain("could not compute the pushed diff"); - expect(stderr).toContain("GSTACK_REDACT_PREPUSH=skip"); - } finally { - fs.rmSync(stubDir, { recursive: true, force: true }); - } - }); + // POSIX-only, for two independent reasons. `spawnSync` reports + // `status === null` only when a child dies from a signal, and Windows has no + // equivalent — a force-killed process surfaces a non-zero exit code there — so + // the branch this test names is unreachable. The stub below is also a + // `#!/bin/sh` file named `git`, which Windows will not execute at all, since + // process creation resolves commands through PATHEXT (.exe/.cmd/.bat) and + // ignores the shebang. A Windows variant would have to assert the non-zero + // exit path instead, i.e. a different branch than the name claims, so it is + // skipped rather than rewritten. Gate style follows + // test/session-runner-timeout.test.ts and test/setup-emoji-font.test.ts. + test.skipIf(process.platform === "win32")( + "a diff killed by a signal (null status — the maxBuffer/kill class) BLOCKS", + () => { + // Stub git: probes delegate to the real git; the diff invocation kills + // itself, producing spawnSync status === null. This is the exact branch + // gitStrict's docstring names (oversized-diff overflow is delivered the + // same way) — pre-landing review flagged it as untested. + const realGit = Bun.which("git") || "/usr/bin/git"; + const stubDir = fs.mkdtempSync(path.join(os.tmpdir(), "prepush-stubgit-")); + try { + const stub = `#!/bin/sh\nif [ "$1" = "diff" ]; then kill -KILL $$; fi\nexec "${realGit}" "$@"\n`; + fs.writeFileSync(path.join(stubDir, "git"), stub); + fs.chmodSync(path.join(stubDir, "git"), 0o755); + + const base = git(["rev-parse", "HEAD"]); + const head = commit("clean.txt", "clean content\n", "clean commit"); + const { code, stderr } = runHook( + `refs/heads/main ${head} refs/heads/main ${base}\n`, + prependPath(stubDir), + ); + expect(code).toBe(1); + expect(stderr).toContain("could not compute the pushed diff"); + expect(stderr).toContain("GSTACK_REDACT_PREPUSH=skip"); + } finally { + fs.rmSync(stubDir, { recursive: true, force: true }); + } + }, + ); }); describe("install UX surfaces (#1946 / eng review D3+D10)", () => { From de670f69c8693b0dca8eaab669ffe285e517633b Mon Sep 17 00:00:00 2001 From: H M Ibtihal Utsho Date: Fri, 14 Aug 2026 00:10:17 -0400 Subject: [PATCH 007/126] fix(artifacts): sync the decision store, which no allowlist glob matched gstack-decision-log enqueues projects//decisions.jsonl after every write, but none of the 16 managed globs matched it, so compute_paths_to_stage rejected every one at its "must match at least one allowlist glob" check. The writer and the syncer disagreed silently: enabling artifacts sync backed up learnings, plans, designs and timelines -- everything except the durable decision ledger -- and nothing reported a miss, because a dropped path prints exactly what a synced one does when the queue is otherwise empty. Add the three decisions.* globs and class them artifact so they also sync in artifacts-only mode. The test reads the heredocs out of the script rather than executing it: gstack-artifacts-init.test.ts drives the real script through #!/bin/bash shims and a colon-separated PATH, so it cannot run on Windows -- the platform where the companion slug bug bit. --- bin/gstack-artifacts-init | 11 ++++ test/artifacts-allowlist-decisions.test.ts | 66 ++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 test/artifacts-allowlist-decisions.test.ts diff --git a/bin/gstack-artifacts-init b/bin/gstack-artifacts-init index f99c96591b..9691c226ef 100755 --- a/bin/gstack-artifacts-init +++ b/bin/gstack-artifacts-init @@ -291,6 +291,14 @@ projects/*/*-design-*.md projects/*/*-test-plan-*.md projects/*/*-eng-review-test-plan-*.md projects/*/timeline.jsonl +# The decision store. gstack-decision-log enqueues projects//decisions.jsonl +# after EVERY write, but no glob above matched it, so compute_paths_to_stage rejected +# all of them at its "must match at least one allowlist glob" check -- a writer +# enqueueing a path the syncer is guaranteed to drop. Without these the durable +# decision ledger never leaves the machine, on any platform. +projects/*/decisions.jsonl +projects/*/decisions.active.json +projects/*/decisions.archive.jsonl retros/*.md developer-profile.json builder-journey.md @@ -318,6 +326,9 @@ cat > "$GSTACK_HOME/.brain-privacy-map.json" <<'EOF' {"pattern": "projects/*/*-design-*.md", "class": "artifact"}, {"pattern": "projects/*/*-test-plan-*.md", "class": "artifact"}, {"pattern": "projects/*/*-eng-review-test-plan-*.md", "class": "artifact"}, + {"pattern": "projects/*/decisions.jsonl", "class": "artifact"}, + {"pattern": "projects/*/decisions.active.json", "class": "artifact"}, + {"pattern": "projects/*/decisions.archive.jsonl", "class": "artifact"}, {"pattern": "retros/*.md", "class": "artifact"}, {"pattern": "builder-journey.md", "class": "artifact"}, {"pattern": "projects/*/timeline.jsonl", "class": "behavioral"}, diff --git a/test/artifacts-allowlist-decisions.test.ts b/test/artifacts-allowlist-decisions.test.ts new file mode 100644 index 0000000000..7908cd28d7 --- /dev/null +++ b/test/artifacts-allowlist-decisions.test.ts @@ -0,0 +1,66 @@ +import { describe, test, expect } from "bun:test"; +import * as fs from "fs"; +import * as path from "path"; + +const ROOT = path.resolve(import.meta.dir, ".."); +const INIT = fs.readFileSync(path.join(ROOT, "bin", "gstack-artifacts-init"), "utf-8"); + +/** Pull a quoted heredoc body out of gstack-artifacts-init by target filename. */ +function heredoc(target: string): string { + const re = new RegExp(`cat > "\\$GSTACK_HOME/${target}" <<'EOF'\\n([\\s\\S]*?)\\nEOF\\n`); + const m = INIT.match(re); + if (!m) throw new Error(`heredoc for ${target} not found in gstack-artifacts-init`); + return m[1]; +} + +/** fnmatch.fnmatchcase semantics, as compute_paths_to_stage applies them: + * `*` does not cross a path separator. */ +function globToRe(g: string): RegExp { + return new RegExp("^" + g.split("*").map((s) => s.replace(/[.]/g, "[.]")).join("[^/]*") + "$"); +} + +const DECISION_PATHS = [ + "projects/acme-widget/decisions.jsonl", + "projects/acme-widget/decisions.active.json", + "projects/acme-widget/decisions.archive.jsonl", +]; + +/** + * gstack-decision-log:40 enqueues projects//decisions.jsonl after EVERY write, + * but no managed glob matched it, so compute_paths_to_stage rejected all of them at + * its "must match at least one allowlist glob" check. The writer and the syncer + * disagreed silently: turning artifacts sync on backed up learnings, plans, designs + * and timelines -- everything EXCEPT the durable decision ledger -- and nothing + * anywhere reported a miss, because a dropped path prints exactly what a synced one + * does when the queue is otherwise empty. + * + * Source-level rather than end-to-end: gstack-artifacts-init.test.ts drives the real + * script through #!/bin/bash shims and a colon-separated PATH, so it cannot run on + * Windows -- which is the platform where this bug bit. + */ +describe("the artifacts allowlist covers the decision store", () => { + const globs = heredoc("\\.brain-allowlist") + .split("\n") + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("#")); + + test("every decisions.* path matches at least one allowlist glob", () => { + for (const p of DECISION_PATHS) { + expect({ p, matched: globs.some((g) => globToRe(g).test(p)) }).toEqual({ p, matched: true }); + } + }); + + test("decisions.* are class artifact, so they sync in artifacts-only mode too", () => { + const map = JSON.parse(heredoc("\\.brain-privacy-map\\.json")); + for (const p of DECISION_PATHS) { + const hit = map.find((e: { pattern: string; class: string }) => globToRe(e.pattern).test(p)); + expect({ p, cls: hit?.class }).toEqual({ p, cls: "artifact" }); + } + }); + + test("the allowlist still ends with the user-additions marker", () => { + // Additions below it survive re-init; a glob added above would be silently + // overwritten the next time gstack-artifacts-init runs. + expect(heredoc("\\.brain-allowlist").trimEnd()).toMatch(/# ---- USER ADDITIONS BELOW ----/); + }); +}); From dc8657006fa25e07cdeafa7e8d39a16566ed00f1 Mon Sep 17 00:00:00 2001 From: H M Ibtihal Utsho Date: Fri, 14 Aug 2026 00:10:17 -0400 Subject: [PATCH 008/126] fix(windows): resolve the project slug natively when gstack-slug cannot spawn bin/gstack-slug is a `#!/usr/bin/env bash` script with no file extension. Windows honors neither the shebang nor PATHEXT for an explicit path, so spawnSync fails ENOENT and resolveSlug returned its literal fallback, "unknown". Every decision on the machine was therefore filed under ~/.gstack/projects/unknown/ -- one bucket shared by every project -- while the bash-side Context Recovery preamble resolved the real slug, found no decisions.active.json there, and skipped through a bare `if [ -f ... ]` with no else. Nothing failed. Both decision bins (log and search) missed identically, so writes and searches stayed consistent with each other, and the only component that resolved correctly was silent by design. Measured on one machine: 62 decisions accumulated over 10 days and 170 skill runs, surfaced zero times. shell:true is not the fix here, unlike #1731 -- cmd.exe cannot run a bash script either. Nor is re-spawning through `bash`: on Windows that frequently resolves to WSL, whose $HOME and /mnt/c paths yield a different slug AND a different cache directory, trading one split store for another. Instead, port gstack-slug's own three steps (cache -> git remote -> basename), keeping its alphabet and its MSYS-form cache key so both paths agree. The fallback is win32-gated, so POSIX behaviour is byte-identical. Tests exercise the fallback on every platform (only the gating is win32-specific), so POSIX CI catches a regression that would otherwise surface only on a Windows user's disk, plus a static gate pinning the platform check. --- .github/workflows/windows-free-tests.yml | 3 + lib/bin-context.ts | 94 ++++++++++++++++++- test/bin-context-windows-slug.test.ts | 113 +++++++++++++++++++++++ 3 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 test/bin-context-windows-slug.test.ts diff --git a/.github/workflows/windows-free-tests.yml b/.github/workflows/windows-free-tests.yml index 3ac871e5d6..c46506a144 100644 --- a/.github/workflows/windows-free-tests.yml +++ b/.github/workflows/windows-free-tests.yml @@ -119,6 +119,9 @@ jobs: # Same diagnosability contract as free-tests.yml: a red lane must # carry the WHY (the runner's quiet console names files, not causes). + # (#2561 was written against the old hand-listed subset; its two new + # test files are pure-TS and flow into the --windows-only curation + # automatically, so no per-file entry is needed here.) - name: Upload shard logs on failure if: failure() uses: actions/upload-artifact@v4 diff --git a/lib/bin-context.ts b/lib/bin-context.ts index faa1c65a2f..28021b56ac 100644 --- a/lib/bin-context.ts +++ b/lib/bin-context.ts @@ -6,12 +6,102 @@ */ import { spawnSync } from "child_process"; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs"; +import { homedir } from "os"; +import { basename, join } from "path"; -/** Resolve the project slug via the `gstack-slug` helper (parses `SLUG=...`). */ +/** Keep the slug inside the [a-zA-Z0-9._-] alphabet gstack-slug promises (`tr -cd`). */ +function sanitizeSlug(s: string): string { + return s.replace(/[^a-zA-Z0-9._-]/g, ""); +} + +/** + * A Windows path in the MSYS form git-bash's `pwd` reports: + * `C:\Users\j\foo` → `/c/Users/j/foo`. gstack-slug keys its cache on THAT form + * (`tr '/' '_'`), so a native lookup must reproduce it exactly or it misses the very + * entry gstack-slug wrote and silently re-derives instead of staying consistent. + * Exported for the cache-key test; non-Windows paths pass through unchanged. + */ +export function toMsysPath(p: string): string { + const drive = p.match(/^([A-Za-z]):[\\/]/); + const body = (drive ? p.slice(2) : p).replace(/\\/g, "/"); + return drive ? `/${drive[1].toLowerCase()}${body}` : body; +} + +/** + * Native port of bin/gstack-slug's resolution order, used when that script cannot be + * spawned (see resolveSlug). Same three steps, same alphabet, same cache file — so + * this and the shell path always agree. They must: the bins WRITE using this, while + * the Context Recovery preamble READS using the script. + */ +export function slugFromEnvironment(gstackHome?: string, cwd: string = process.cwd()): string { + const home = gstackHome || process.env.GSTACK_HOME || join(homedir(), ".gstack"); + const cacheDir = join(home, "slug-cache"); + const cacheFile = join(cacheDir, toMsysPath(cwd).replace(/\//g, "_")); + + let slug = ""; + // 1. cached slug wins (guarantees consistency across sessions) + if (existsSync(cacheFile)) { + try { + slug = sanitizeSlug(readFileSync(cacheFile, "utf-8").trim()); + } catch { + slug = ""; + } + } + // 2. else derive from the git remote: [:/]/[.git] → owner-repo + if (!slug) { + const r = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8", cwd }); + const m = (r.stdout || "").trim().match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/); + if (m) slug = sanitizeSlug(m[1].replace(/\//g, "-")); + } + // 3. else the directory name + if (!slug) slug = sanitizeSlug(basename(cwd)); + if (!slug) return "unknown"; + + // 4. cache it, as gstack-slug does — atomic, and failures stay silent (`|| true`) + try { + mkdirSync(cacheDir, { recursive: true }); + const tmp = `${cacheFile}.tmp.${process.pid}`; + writeFileSync(tmp, slug, "utf-8"); + renameSync(tmp, cacheFile); + } catch { + // best-effort cache; a miss only costs a re-derive on the next call + } + return slug; +} + +/** Windows cannot exec an extensionless `#!/usr/bin/env bash` script (no shebang, no + * PATHEXT match for an explicit path), so gstack-slug spawns ENOENT there. */ +export const NEEDS_NATIVE_SLUG_ON_WINDOWS = process.platform === "win32"; + +/** + * Resolve the project slug via the `gstack-slug` helper (parses `SLUG=...`). + * + * On Windows that spawn fails ENOENT (see NEEDS_NATIVE_SLUG_ON_WINDOWS) and `r.stdout` + * is undefined — the same class of hazard as the gbrain shim spawns in lib/gbrain-exec.ts + * (#1731). Returning the literal "unknown" filed every decision under + * ~/.gstack/projects/unknown/ — one bucket shared by every project on the machine — + * while the bash-side Context Recovery preamble resolved the real slug, found no + * decisions.active.json there, and skipped through a bare `if [ -f … ]` with no else. + * + * Nothing failed, for ten days: BOTH decision bins (log and search) missed identically, + * so writes and searches stayed consistent with each other, and the only component that + * resolved correctly was silent by design. + * + * `shell: true` is NOT the fix here, unlike #1731: cmd.exe cannot run a bash script + * either. Nor is re-spawning through `bash` — on Windows that frequently resolves to + * WSL, whose $HOME and /mnt/c paths yield a different slug AND a different cache + * directory, trading one split store for another. + * + * POSIX behaviour is unchanged: the fallback is win32-only, where the previous result + * was unconditionally wrong and so has nothing to regress. + */ export function resolveSlug(slugBinPath: string): string { const r = spawnSync(slugBinPath, { encoding: "utf-8" }); const m = (r.stdout || "").match(/^SLUG=(.+)$/m); - return m ? m[1].trim() : "unknown"; + if (m) return m[1].trim(); + if (NEEDS_NATIVE_SLUG_ON_WINDOWS) return slugFromEnvironment(); + return "unknown"; } /** Current git branch, or undefined on detached HEAD / outside a repo. */ diff --git a/test/bin-context-windows-slug.test.ts b/test/bin-context-windows-slug.test.ts new file mode 100644 index 0000000000..47f05b8da4 --- /dev/null +++ b/test/bin-context-windows-slug.test.ts @@ -0,0 +1,113 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { spawnSync } from "child_process"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + toMsysPath, + slugFromEnvironment, + resolveSlug, + NEEDS_NATIVE_SLUG_ON_WINDOWS, +} from "../lib/bin-context"; + +const ROOT = path.resolve(import.meta.dir, ".."); +const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), "utf-8"); + +let tmp: string; +beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-slug-")); }); +afterEach(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }); + +/** + * Windows cannot exec bin/gstack-slug -- a `#!/usr/bin/env bash` script with no file + * extension -- so spawnSync fails ENOENT and resolveSlug used to return the literal + * string "unknown". Every decision on the machine landed in one shared + * ~/.gstack/projects/unknown/ bucket, while the bash-side Context Recovery preamble + * resolved the real slug and silently found nothing there. + * + * These exercise the native fallback on EVERY platform (it is only the *gating* that + * is win32-specific), so macOS/Linux CI catches a regression that would otherwise + * only ever surface on a Windows user's disk. + */ +describe("native slug fallback mirrors bin/gstack-slug", () => { + test("toMsysPath reproduces the git-bash cache key", () => { + // gstack-slug does: CACHE_KEY=$(printf '%s' "$(pwd)" | tr '/' '_') + // and git-bash `pwd` reports C:\Users\j\foo as /c/Users/j/foo. + expect(toMsysPath("C:\\Users\\j\\foo")).toBe("/c/Users/j/foo"); + expect(toMsysPath("D:/Work/Repo")).toBe("/d/Work/Repo"); + expect(toMsysPath("/already/posix")).toBe("/already/posix"); + // The cache FILENAME is the real contract: + expect(toMsysPath("C:\\Users\\j\\foo").replace(/\//g, "_")).toBe("_c_Users_j_foo"); + }); + + test("step 1: a cached slug wins over everything else", () => { + const cwd = path.join(tmp, "proj"); + fs.mkdirSync(cwd); + const cacheDir = path.join(tmp, "home", "slug-cache"); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync(path.join(cacheDir, toMsysPath(cwd).replace(/\//g, "_")), "cached-wins"); + expect(slugFromEnvironment(path.join(tmp, "home"), cwd)).toBe("cached-wins"); + }); + + test("step 2: derives owner-repo from the git remote, https and ssh alike", () => { + for (const [url, want] of [ + ["https://github.com/acme/Widget.git", "acme-Widget"], + ["git@github.com:acme/Widget.git", "acme-Widget"], + ["https://gitlab.com/acme/Widget", "acme-Widget"], + ] as const) { + const cwd = fs.mkdtempSync(path.join(tmp, "repo-")); + spawnSync("git", ["init", "-q"], { cwd }); + spawnSync("git", ["remote", "add", "origin", url], { cwd }); + expect(slugFromEnvironment(path.join(tmp, "home2"), cwd)).toBe(want); + } + }); + + test("step 3: falls back to the sanitized directory name", () => { + // `tr -cd 'a-zA-Z0-9._-'` DELETES disallowed characters rather than replacing them. + const cwd = path.join(tmp, "My Proj+v2"); + fs.mkdirSync(cwd); + expect(slugFromEnvironment(path.join(tmp, "home3"), cwd)).toBe("MyProjv2"); + }); + + test("the resolved slug is cached back, as the shell script does", () => { + const cwd = path.join(tmp, "cacheme"); + fs.mkdirSync(cwd); + const home = path.join(tmp, "home4"); + const slug = slugFromEnvironment(home, cwd); + const key = path.join(home, "slug-cache", toMsysPath(cwd).replace(/\//g, "_")); + expect(fs.existsSync(key)).toBe(true); + // no trailing newline: gstack-slug writes with printf '%s' + expect(fs.readFileSync(key, "utf-8")).toBe(slug); + }); + + test("never returns the empty string", () => { + expect(slugFromEnvironment(path.join(tmp, "h"), tmp).length).toBeGreaterThan(0); + }); +}); + +describe("the fallback stays win32-gated", () => { + // Static tripwire in the style of gbrain-spawn-windows-shell.test.ts: POSIX CI + // cannot observe the Windows branch at runtime, so pin the gate itself. Removing + // it would silently change macOS/Linux behaviour, which today is byte-identical. + test("NEEDS_NATIVE_SLUG_ON_WINDOWS is platform-gated", () => { + expect(read("lib/bin-context.ts")).toMatch( + /export const NEEDS_NATIVE_SLUG_ON_WINDOWS\s*=\s*process\.platform === "win32"/, + ); + expect(NEEDS_NATIVE_SLUG_ON_WINDOWS).toBe(process.platform === "win32"); + }); + + test("resolveSlug no longer returns a bare literal on a failed spawn", () => { + const src = read("lib/bin-context.ts"); + expect(src).not.toMatch(/return m \? m\[1\]\.trim\(\) : "unknown";/); + expect(src).toMatch(/if \(NEEDS_NATIVE_SLUG_ON_WINDOWS\) return slugFromEnvironment\(\);/); + }); + + test("a spawn that cannot run resolves to a real slug, not 'unknown'", () => { + // The exact production failure: the helper path does not exist / cannot exec. + const got = resolveSlug(path.join(tmp, "definitely-not-a-real-bin")); + if (NEEDS_NATIVE_SLUG_ON_WINDOWS) { + expect(got).not.toBe("unknown"); + } else { + expect(got).toBe("unknown"); // POSIX behaviour deliberately unchanged + } + }); +}); From 69c1b3d88afe5778045cf6503238eb38f27e703d Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 08:47:06 -0700 Subject: [PATCH 009/126] fix(security): guard brain-sync arithmetic against injected .brain-last-pull; sanitize _GBRAIN_HOST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-derived from PR #2588 under the generated-file screening rule (resolver hunks taken; SKILL.md files regenerated, not accepted). A poisoned .brain-last-pull could reach bash arithmetic ($(( ))) — a code-execution vector from a writable state file; the timestamp is now validated numeric before use. _GBRAIN_HOST from ~/.claude.json is clamped to hostname-safe characters before echo. Ship goldens refreshed to the regenerated output. Co-authored-by: sneakygriff <89592870+sneakygriff@users.noreply.github.com> Co-Authored-By: Claude Fable 5 --- SKILL.md | 3 ++- autoplan/SKILL.md | 3 ++- benchmark-models/SKILL.md | 3 ++- benchmark/SKILL.md | 3 ++- browse/SKILL.md | 3 ++- canary/SKILL.md | 3 ++- codex/SKILL.md | 3 ++- context-restore/SKILL.md | 3 ++- context-save/SKILL.md | 3 ++- cso/SKILL.md | 3 ++- design-consultation/SKILL.md | 3 ++- design-html/SKILL.md | 3 ++- design-review/SKILL.md | 3 ++- design-shotgun/SKILL.md | 3 ++- devex-review/SKILL.md | 3 ++- diagram/SKILL.md | 3 ++- document-generate/SKILL.md | 3 ++- document-release/SKILL.md | 3 ++- health/SKILL.md | 3 ++- investigate/SKILL.md | 3 ++- ios-clean/SKILL.md | 3 ++- ios-design-review/SKILL.md | 3 ++- ios-fix/SKILL.md | 3 ++- ios-qa/SKILL.md | 3 ++- ios-sync/SKILL.md | 3 ++- land-and-deploy/SKILL.md | 3 ++- landing-report/SKILL.md | 3 ++- learn/SKILL.md | 3 ++- make-pdf/SKILL.md | 3 ++- office-hours/SKILL.md | 3 ++- open-gstack-browser/SKILL.md | 3 ++- pair-agent/SKILL.md | 3 ++- plan-ceo-review/SKILL.md | 3 ++- plan-design-review/SKILL.md | 3 ++- plan-devex-review/SKILL.md | 3 ++- plan-eng-review/SKILL.md | 3 ++- plan-tune/SKILL.md | 3 ++- qa-only/SKILL.md | 3 ++- qa/SKILL.md | 3 ++- retro/SKILL.md | 3 ++- review/SKILL.md | 3 ++- scrape/SKILL.md | 3 ++- scripts/resolvers/preamble/generate-brain-sync-block.ts | 3 ++- setup-browser-cookies/SKILL.md | 3 ++- setup-deploy/SKILL.md | 3 ++- setup-gbrain/SKILL.md | 3 ++- ship/SKILL.md | 3 ++- skillify/SKILL.md | 3 ++- spec/SKILL.md | 3 ++- sync-gbrain/SKILL.md | 3 ++- test/fixtures/golden/claude-ship-SKILL.md | 3 ++- 51 files changed, 102 insertions(+), 51 deletions(-) diff --git a/SKILL.md b/SKILL.md index b7f1705676..7e2acdabfa 100644 --- a/SKILL.md +++ b/SKILL.md @@ -401,6 +401,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -414,7 +415,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/autoplan/SKILL.md b/autoplan/SKILL.md index f24a1cdcb2..66c0130512 100644 --- a/autoplan/SKILL.md +++ b/autoplan/SKILL.md @@ -536,6 +536,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -549,7 +550,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/benchmark-models/SKILL.md b/benchmark-models/SKILL.md index 9519c73f69..71608c5a03 100644 --- a/benchmark-models/SKILL.md +++ b/benchmark-models/SKILL.md @@ -405,6 +405,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -418,7 +419,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/benchmark/SKILL.md b/benchmark/SKILL.md index f0528c08f4..1c0e27fba2 100644 --- a/benchmark/SKILL.md +++ b/benchmark/SKILL.md @@ -405,6 +405,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -418,7 +419,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/browse/SKILL.md b/browse/SKILL.md index d977f363f0..5044f51790 100644 --- a/browse/SKILL.md +++ b/browse/SKILL.md @@ -403,6 +403,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -416,7 +417,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/canary/SKILL.md b/canary/SKILL.md index 8b28c47e6d..5a2e021586 100644 --- a/canary/SKILL.md +++ b/canary/SKILL.md @@ -528,6 +528,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -541,7 +542,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/codex/SKILL.md b/codex/SKILL.md index ff14f92c89..4470be50d5 100644 --- a/codex/SKILL.md +++ b/codex/SKILL.md @@ -531,6 +531,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -544,7 +545,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/context-restore/SKILL.md b/context-restore/SKILL.md index e3dc24782d..0481d71fc6 100644 --- a/context-restore/SKILL.md +++ b/context-restore/SKILL.md @@ -532,6 +532,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -545,7 +546,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/context-save/SKILL.md b/context-save/SKILL.md index 775b0de486..02d3db51b9 100644 --- a/context-save/SKILL.md +++ b/context-save/SKILL.md @@ -531,6 +531,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -544,7 +545,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/cso/SKILL.md b/cso/SKILL.md index 18f7321b0a..06c4b37cf9 100644 --- a/cso/SKILL.md +++ b/cso/SKILL.md @@ -534,6 +534,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -547,7 +548,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/design-consultation/SKILL.md b/design-consultation/SKILL.md index 241b5c3d88..c7c248acad 100644 --- a/design-consultation/SKILL.md +++ b/design-consultation/SKILL.md @@ -554,6 +554,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -567,7 +568,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/design-html/SKILL.md b/design-html/SKILL.md index 59178c131b..742358e34a 100644 --- a/design-html/SKILL.md +++ b/design-html/SKILL.md @@ -535,6 +535,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -548,7 +549,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/design-review/SKILL.md b/design-review/SKILL.md index 8e177dfe74..efd98527f0 100644 --- a/design-review/SKILL.md +++ b/design-review/SKILL.md @@ -532,6 +532,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -545,7 +546,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/design-shotgun/SKILL.md b/design-shotgun/SKILL.md index 2ba5556eaf..f2fc831bf4 100644 --- a/design-shotgun/SKILL.md +++ b/design-shotgun/SKILL.md @@ -549,6 +549,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -562,7 +563,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/devex-review/SKILL.md b/devex-review/SKILL.md index 1fe9f52d5e..571816f7d9 100644 --- a/devex-review/SKILL.md +++ b/devex-review/SKILL.md @@ -534,6 +534,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -547,7 +548,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/diagram/SKILL.md b/diagram/SKILL.md index 04e681ea1f..0fc2de22df 100644 --- a/diagram/SKILL.md +++ b/diagram/SKILL.md @@ -404,6 +404,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -417,7 +418,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/document-generate/SKILL.md b/document-generate/SKILL.md index 9eb9e92780..3d7338c996 100644 --- a/document-generate/SKILL.md +++ b/document-generate/SKILL.md @@ -534,6 +534,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -547,7 +548,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/document-release/SKILL.md b/document-release/SKILL.md index de8b965c17..e0a2b106eb 100644 --- a/document-release/SKILL.md +++ b/document-release/SKILL.md @@ -532,6 +532,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -545,7 +546,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/health/SKILL.md b/health/SKILL.md index cf1c3177c1..352cd3e048 100644 --- a/health/SKILL.md +++ b/health/SKILL.md @@ -530,6 +530,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -543,7 +544,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/investigate/SKILL.md b/investigate/SKILL.md index 2a7d241c9f..ebdc934a26 100644 --- a/investigate/SKILL.md +++ b/investigate/SKILL.md @@ -569,6 +569,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -582,7 +583,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/ios-clean/SKILL.md b/ios-clean/SKILL.md index 1d73d8f7bb..c0a36bfd69 100644 --- a/ios-clean/SKILL.md +++ b/ios-clean/SKILL.md @@ -532,6 +532,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -545,7 +546,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/ios-design-review/SKILL.md b/ios-design-review/SKILL.md index 8cdc8a0245..f61cf97105 100644 --- a/ios-design-review/SKILL.md +++ b/ios-design-review/SKILL.md @@ -534,6 +534,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -547,7 +548,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/ios-fix/SKILL.md b/ios-fix/SKILL.md index 6857d49af3..ec12406c2f 100644 --- a/ios-fix/SKILL.md +++ b/ios-fix/SKILL.md @@ -535,6 +535,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -548,7 +549,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/ios-qa/SKILL.md b/ios-qa/SKILL.md index 5540914be0..6e56ea4ede 100644 --- a/ios-qa/SKILL.md +++ b/ios-qa/SKILL.md @@ -538,6 +538,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -551,7 +552,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/ios-sync/SKILL.md b/ios-sync/SKILL.md index 0b1390c1c1..18c709d094 100644 --- a/ios-sync/SKILL.md +++ b/ios-sync/SKILL.md @@ -532,6 +532,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -545,7 +546,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/land-and-deploy/SKILL.md b/land-and-deploy/SKILL.md index 0e3d23509a..51bf460f33 100644 --- a/land-and-deploy/SKILL.md +++ b/land-and-deploy/SKILL.md @@ -527,6 +527,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -540,7 +541,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/landing-report/SKILL.md b/landing-report/SKILL.md index 4995332ac6..49b15e9932 100644 --- a/landing-report/SKILL.md +++ b/landing-report/SKILL.md @@ -529,6 +529,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -542,7 +543,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/learn/SKILL.md b/learn/SKILL.md index 85c094c82f..d3b82b52fe 100644 --- a/learn/SKILL.md +++ b/learn/SKILL.md @@ -530,6 +530,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -543,7 +544,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/make-pdf/SKILL.md b/make-pdf/SKILL.md index 1e181760ce..666bad78cf 100644 --- a/make-pdf/SKILL.md +++ b/make-pdf/SKILL.md @@ -440,6 +440,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -453,7 +454,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/office-hours/SKILL.md b/office-hours/SKILL.md index 9c7dc5cadc..c4b7254c05 100644 --- a/office-hours/SKILL.md +++ b/office-hours/SKILL.md @@ -565,6 +565,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -578,7 +579,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/open-gstack-browser/SKILL.md b/open-gstack-browser/SKILL.md index 6d05a6723b..559eaa3b65 100644 --- a/open-gstack-browser/SKILL.md +++ b/open-gstack-browser/SKILL.md @@ -403,6 +403,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -416,7 +417,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/pair-agent/SKILL.md b/pair-agent/SKILL.md index 63f301c720..af608c243a 100644 --- a/pair-agent/SKILL.md +++ b/pair-agent/SKILL.md @@ -530,6 +530,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -543,7 +544,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/plan-ceo-review/SKILL.md b/plan-ceo-review/SKILL.md index 65dd790e1d..3d5e9b9117 100644 --- a/plan-ceo-review/SKILL.md +++ b/plan-ceo-review/SKILL.md @@ -559,6 +559,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -572,7 +573,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/plan-design-review/SKILL.md b/plan-design-review/SKILL.md index 8536092476..525e5df68e 100644 --- a/plan-design-review/SKILL.md +++ b/plan-design-review/SKILL.md @@ -531,6 +531,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -544,7 +545,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/plan-devex-review/SKILL.md b/plan-devex-review/SKILL.md index 9dc9d8b052..447cd3a1f7 100644 --- a/plan-devex-review/SKILL.md +++ b/plan-devex-review/SKILL.md @@ -537,6 +537,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -550,7 +551,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/plan-eng-review/SKILL.md b/plan-eng-review/SKILL.md index 8672c94649..22afb1c103 100644 --- a/plan-eng-review/SKILL.md +++ b/plan-eng-review/SKILL.md @@ -535,6 +535,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -548,7 +549,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/plan-tune/SKILL.md b/plan-tune/SKILL.md index 7f3656d1a3..de473b9a25 100644 --- a/plan-tune/SKILL.md +++ b/plan-tune/SKILL.md @@ -540,6 +540,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -553,7 +554,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/qa-only/SKILL.md b/qa-only/SKILL.md index 1d58adcc73..eafa358de2 100644 --- a/qa-only/SKILL.md +++ b/qa-only/SKILL.md @@ -530,6 +530,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -543,7 +544,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/qa/SKILL.md b/qa/SKILL.md index ce3633067e..b119814764 100644 --- a/qa/SKILL.md +++ b/qa/SKILL.md @@ -536,6 +536,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -549,7 +550,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/retro/SKILL.md b/retro/SKILL.md index 03f3898ddd..5e4d58a1cc 100644 --- a/retro/SKILL.md +++ b/retro/SKILL.md @@ -547,6 +547,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -560,7 +561,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/review/SKILL.md b/review/SKILL.md index 2c6fd4b30b..263973dafe 100644 --- a/review/SKILL.md +++ b/review/SKILL.md @@ -532,6 +532,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -545,7 +546,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/scrape/SKILL.md b/scrape/SKILL.md index 8f73d1cbef..f3ba51a60b 100644 --- a/scrape/SKILL.md +++ b/scrape/SKILL.md @@ -404,6 +404,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -417,7 +418,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/scripts/resolvers/preamble/generate-brain-sync-block.ts b/scripts/resolvers/preamble/generate-brain-sync-block.ts index bbd18c2bcd..0b0a022a1f 100644 --- a/scripts/resolvers/preamble/generate-brain-sync-block.ts +++ b/scripts/resolvers/preamble/generate-brain-sync-block.ts @@ -103,6 +103,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -116,7 +117,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server \${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/setup-browser-cookies/SKILL.md b/setup-browser-cookies/SKILL.md index df6db6027e..fb6a056ed3 100644 --- a/setup-browser-cookies/SKILL.md +++ b/setup-browser-cookies/SKILL.md @@ -399,6 +399,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -412,7 +413,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/setup-deploy/SKILL.md b/setup-deploy/SKILL.md index d4eb170442..d0b72575fa 100644 --- a/setup-deploy/SKILL.md +++ b/setup-deploy/SKILL.md @@ -531,6 +531,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -544,7 +545,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/setup-gbrain/SKILL.md b/setup-gbrain/SKILL.md index 0cc751fbce..73786e36fa 100644 --- a/setup-gbrain/SKILL.md +++ b/setup-gbrain/SKILL.md @@ -530,6 +530,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -543,7 +544,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/ship/SKILL.md b/ship/SKILL.md index 1446dd74b2..593b76f83b 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -532,6 +532,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -545,7 +546,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/skillify/SKILL.md b/skillify/SKILL.md index 0bbacd8576..e22898eb59 100644 --- a/skillify/SKILL.md +++ b/skillify/SKILL.md @@ -529,6 +529,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -542,7 +543,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/spec/SKILL.md b/spec/SKILL.md index 0a9b1d17f6..f51fa9aad1 100644 --- a/spec/SKILL.md +++ b/spec/SKILL.md @@ -530,6 +530,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -543,7 +544,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/sync-gbrain/SKILL.md b/sync-gbrain/SKILL.md index 4637351c7a..45dc07ce8c 100644 --- a/sync-gbrain/SKILL.md +++ b/sync-gbrain/SKILL.md @@ -531,6 +531,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -544,7 +545,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/test/fixtures/golden/claude-ship-SKILL.md b/test/fixtures/golden/claude-ship-SKILL.md index 1446dd74b2..593b76f83b 100644 --- a/test/fixtures/golden/claude-ship-SKILL.md +++ b/test/fixtures/golden/claude-ship-SKILL.md @@ -532,6 +532,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -545,7 +546,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 From ea780fed61ef246ee21a843639706c99dcc698cc Mon Sep 17 00:00:00 2001 From: ShahriarLak Date: Mon, 10 Aug 2026 21:16:04 +0100 Subject: [PATCH 010/126] fix(sync): run gstack-brain-sync through bash, not cmd.exe, on Windows The brain-sync stage failed on EVERY Windows run with "is not recognized as an internal or external command", so /sync-gbrain always reported ERR brain-sync among otherwise green stages. #1731 gave these spawns shell: NEEDS_SHELL_ON_WINDOWS. That is correct for the gbrain.cmd shim and does nothing here: shell:true routes through cmd.exe, which resolves .cmd/.bat via PATHEXT but has no concept of a shebang, so an extension-less bash script is rejected outright. A .cmd shim needs a shell; a shebang script needs an interpreter. The two cases look identical and are not. The failure was quiet rather than loud. artifacts_sync_mode defaults to pushing curated artifacts to git, so a Windows user's learnings piled up uncommitted in ~/.gstack indefinitely while the sync report showed one red line out of four. New bashScriptInvocation() resolves Git for Windows' bash explicitly and passes the script as argv[0]. It prefers Git bash over a bare `bash` on PATH because WindowsApps ships a bash.exe that is the WSL launcher, which would read C:\... as a Linux path; GSTACK_BASH overrides for unusual installs; forward slashes because bash treats backslashes as escapes; and it returns null when no bash exists so the stage says so plainly instead of surfacing an unactionable spawn error. The #1731 tripwire asserted the shape that does not work, so it now asserts the opposite (never a raw spawnSync(brainSyncPath, ...)) and six unit tests cover the resolver. Verified on Windows: the stage now reports "OK brain-sync curated artifacts pushed (4.2s)" and the artifacts repo committed + pushed on its own. Affected-test set unchanged at 14 pre-existing failures before and after, with 6 new passing tests. --- bin/gstack-gbrain-sync.ts | 39 ++++++++---- lib/gbrain-exec.ts | 60 ++++++++++++++++++ test/gbrain-spawn-windows-shell.test.ts | 84 +++++++++++++++++++++++-- 3 files changed, 164 insertions(+), 19 deletions(-) diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index 4cf6709df8..cb4497d2df 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -41,7 +41,7 @@ import { ensureSourceRegistered, sourcePageCount, parseSourcesList, cycleComplet import { detectAutopilot, decideSourceRemove, decideCodeSync } from "../lib/gbrain-guards"; import { writeReceipt } from "../lib/egress-receipt"; import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status"; -import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "../lib/gbrain-exec"; +import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS, bashScriptInvocation } from "../lib/gbrain-exec"; import { repoPolicyTier as sharedRepoPolicyTier } from "../lib/gbrain-repo-policy-client"; import { checkOwnedStagingDir } from "../lib/staging-guard"; @@ -1245,18 +1245,31 @@ function runBrainSyncPush(args: CliArgs): StageResult { return { name: "brain-sync", ran: false, ok: true, duration_ms: 0, summary: "skipped (gstack-brain-sync not installed)" }; } - // #1731: gstack-brain-sync is a bash shebang script; Windows can't spawn it - // without a shell, which surfaced as "brain-sync exited undefined". - spawnSync(brainSyncPath, ["--discover-new"], { - stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"], - timeout: 60 * 1000, - shell: NEEDS_SHELL_ON_WINDOWS, - }); - const result = spawnSync(brainSyncPath, ["--once"], { - stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"], - timeout: 60 * 1000, - shell: NEEDS_SHELL_ON_WINDOWS, - }); + // gstack-brain-sync is a bash shebang script, so it needs an INTERPRETER, not + // a shell. #1731 gave it `shell: NEEDS_SHELL_ON_WINDOWS`, which is right for + // the gbrain.cmd shim and useless here: cmd.exe resolves .cmd/.bat via PATHEXT + // and rejects an extension-less shebang script outright ("is not recognized as + // an internal or external command"), so this stage failed on EVERY Windows run + // while looking like a single red line in an otherwise green report. See + // bashScriptInvocation. + const discover = bashScriptInvocation(brainSyncPath, ["--discover-new"]); + const once = bashScriptInvocation(brainSyncPath, ["--once"]); + if (!discover || !once) { + return { + name: "brain-sync", + ran: false, + ok: true, + duration_ms: Date.now() - t0, + summary: "skipped (no bash found; set GSTACK_BASH to your Git bash.exe)", + }; + } + + const stdio: "ignore"[] | ("ignore" | "inherit")[] = args.quiet + ? ["ignore", "ignore", "ignore"] + : ["ignore", "inherit", "inherit"]; + + spawnSync(discover.cmd, discover.argv, { stdio, timeout: 60 * 1000, shell: discover.shell }); + const result = spawnSync(once.cmd, once.argv, { stdio, timeout: 60 * 1000, shell: once.shell }); return { name: "brain-sync", diff --git a/lib/gbrain-exec.ts b/lib/gbrain-exec.ts index 0cb1ddc57b..188d84b8f6 100644 --- a/lib/gbrain-exec.ts +++ b/lib/gbrain-exec.ts @@ -136,6 +136,66 @@ export function buildGbrainEnv(opts: BuildGbrainEnvOptions = {}): NodeJS.Process */ export const NEEDS_SHELL_ON_WINDOWS = process.platform === "win32"; +/** Where Git for Windows puts bash, most-specific first. */ +const WINDOWS_BASH_CANDIDATES = [ + "C:\\Program Files\\Git\\bin\\bash.exe", + "C:\\Program Files\\Git\\usr\\bin\\bash.exe", + "C:\\Program Files (x86)\\Git\\bin\\bash.exe", +]; + +export interface ScriptInvocation { + cmd: string; + argv: string[]; + /** Always false: we resolve the interpreter ourselves rather than via cmd.exe. */ + shell: false; +} + +/** + * How to invoke a **bash shebang script** (`gstack-brain-sync`) on this platform. + * + * POSIX execs it directly — the shebang does the work. Windows cannot, and + * `shell: true` does NOT rescue it: that routes through cmd.exe, which resolves + * `.cmd`/`.bat` via PATHEXT but has no concept of a shebang, so an + * extension-less bash script comes back as *"is not recognized as an internal + * or external command"*. This is why #1731's `shell: NEEDS_SHELL_ON_WINDOWS` + * fix genuinely cured the `gbrain.cmd` shim while leaving the brain-sync stage + * failing on **every** run on Windows. The two cases look identical and are not: + * a `.cmd` shim needs a shell, a shebang script needs an interpreter. + * + * The consequence was quiet rather than loud. `artifacts_sync_mode` defaults to + * pushing curated artifacts to git, so a Windows user's learnings accumulated in + * `~/.gstack` and were never committed, while `/sync-gbrain` printed one red + * line among four green ones. + * + * Git for Windows' bash is preferred over a bare `bash` on PATH because + * WindowsApps ships a `bash.exe` that is the WSL launcher; if it wins PATH + * order it interprets `C:\...` as a Linux path and the script never sees the + * repo. `GSTACK_BASH` overrides everything for unusual installs. + * + * Returns `null` when no bash can be found, so the caller can say so plainly + * instead of surfacing a spawn error nobody can act on. + */ +export function bashScriptInvocation( + scriptPath: string, + args: string[], + opts: { platform?: string; exists?: (p: string) => boolean; env?: NodeJS.ProcessEnv } = {}, +): ScriptInvocation | null { + const platform = opts.platform ?? process.platform; + if (platform !== "win32") return { cmd: scriptPath, argv: args, shell: false }; + + const exists = opts.exists ?? existsSync; + const env = opts.env ?? process.env; + + const override = env.GSTACK_BASH?.trim(); + const candidates = [...(override ? [override] : []), ...WINDOWS_BASH_CANDIDATES]; + const bash = candidates.find((p) => exists(p)); + if (!bash) return null; + + // Forward slashes: bash treats backslashes as escapes, so a Windows path + // passed verbatim loses its separators. + return { cmd: bash, argv: [scriptPath.replace(/\\/g, "/"), ...args], shell: false }; +} + export interface SpawnGbrainOptions { /** Timeout in milliseconds. Defaults to 30s. */ timeout?: number; diff --git a/test/gbrain-spawn-windows-shell.test.ts b/test/gbrain-spawn-windows-shell.test.ts index d968d2f687..eb319684ec 100644 --- a/test/gbrain-spawn-windows-shell.test.ts +++ b/test/gbrain-spawn-windows-shell.test.ts @@ -2,6 +2,8 @@ import { describe, test, expect } from "bun:test"; import * as fs from "fs"; import * as path from "path"; +import { bashScriptInvocation } from "../lib/gbrain-exec"; + const ROOT = path.resolve(import.meta.dir, ".."); const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), "utf-8"); @@ -34,12 +36,82 @@ describe("#1731 gbrain spawns carry the Windows shell flag", () => { }); } - test("orchestrator brain-sync spawns carry the Windows shell flag", () => { + // NOT the brain-sync script. `shell: true` is right for the gbrain.cmd shim + // and wrong for a bash shebang script: cmd.exe resolves .cmd/.bat via PATHEXT + // and has no concept of a shebang, so gstack-brain-sync came back as "is not + // recognized as an internal or external command" on EVERY Windows run. It + // needs an interpreter, not a shell — see bashScriptInvocation. + test("orchestrator invokes brain-sync through bash, never a raw spawn", () => { const src = read("bin/gstack-gbrain-sync.ts"); - const brainSyncSpawns = src.match(/spawnSync\(brainSyncPath,/g)?.length ?? 0; - expect(brainSyncSpawns).toBe(2); - // Both spawnSync(brainSyncPath, ...) blocks must include the shell flag. - const withShell = src.match(/spawnSync\(brainSyncPath,[\s\S]*?shell:\s*NEEDS_SHELL_ON_WINDOWS/g)?.length ?? 0; - expect(withShell).toBe(2); + expect(src).toMatch(/bashScriptInvocation\(brainSyncPath, \["--discover-new"\]\)/); + expect(src).toMatch(/bashScriptInvocation\(brainSyncPath, \["--once"\]\)/); + // The old shape must not come back: it fails silently-ish on Windows. + expect(src).not.toMatch(/spawnSync\(brainSyncPath,/); + expect(src).not.toMatch(/spawnSync\(brainSyncPath,[\s\S]*?shell:\s*NEEDS_SHELL_ON_WINDOWS/); + }); +}); + +describe("bashScriptInvocation", () => { + const WIN_BASH = "C:\\Program Files\\Git\\bin\\bash.exe"; + + test("POSIX execs the script directly, no interpreter needed", () => { + const inv = bashScriptInvocation("/home/u/.claude/skills/gstack/bin/gstack-brain-sync", ["--once"], { + platform: "linux", + }); + expect(inv).toEqual({ + cmd: "/home/u/.claude/skills/gstack/bin/gstack-brain-sync", + argv: ["--once"], + shell: false, + }); + }); + + test("Windows routes through Git bash with the script as argv[0]", () => { + const inv = bashScriptInvocation("C:\\Users\\u\\.claude\\skills\\gstack\\bin\\gstack-brain-sync", ["--once"], { + platform: "win32", + exists: (p) => p === WIN_BASH, + env: {}, + }); + expect(inv?.cmd).toBe(WIN_BASH); + expect(inv?.argv[1]).toBe("--once"); + }); + + test("Windows forward-slashes the script path", () => { + // bash treats backslashes as escapes, so a verbatim Windows path loses its + // separators and the script is never found. + const inv = bashScriptInvocation("C:\\Users\\u\\bin\\gstack-brain-sync", [], { + platform: "win32", + exists: (p) => p === WIN_BASH, + env: {}, + }); + expect(inv?.argv[0]).toBe("C:/Users/u/bin/gstack-brain-sync"); + expect(inv?.argv[0]).not.toContain("\\"); + }); + + test("never asks for a shell — cmd.exe is what broke this", () => { + const inv = bashScriptInvocation("C:\\x\\gstack-brain-sync", [], { + platform: "win32", + exists: (p) => p === WIN_BASH, + env: {}, + }); + expect(inv?.shell).toBe(false); + }); + + test("GSTACK_BASH overrides the search for unusual installs", () => { + const custom = "D:\\tools\\git\\bin\\bash.exe"; + const inv = bashScriptInvocation("C:\\x\\gstack-brain-sync", [], { + platform: "win32", + exists: (p) => p === custom || p === WIN_BASH, + env: { GSTACK_BASH: custom }, + }); + expect(inv?.cmd).toBe(custom); + }); + + test("returns null when Windows has no bash, so the caller can say why", () => { + const inv = bashScriptInvocation("C:\\x\\gstack-brain-sync", [], { + platform: "win32", + exists: () => false, + env: {}, + }); + expect(inv).toBeNull(); }); }); From 4047e52bc6faf25d1a15e11727bfcf932dc6fe7b Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 08:54:06 -0700 Subject: [PATCH 011/126] fix(gbrain): quote cmd.exe arguments at a single gbrain invocation seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2471. With shell:true on Windows, node/bun join argv into one cmd.exe string without quoting, so a repo path with a space — the default C:\Users\First Last\ layout — split into two arguments and every gbrain call carrying a path silently targeted the wrong location (worst: `sources add --path`). All gbrain CLI invocations now build their (cmd, argv, shell) triple through gbrainInvocation(), which quotes risky arguments for cmd.exe's re-parse (embedded quotes doubled). The four direct spawn sites in lib/gbrain-sources.ts route through the seam; the #1731 static invariant is upgraded for seamed files (any direct "gbrain" opener is the violation) and kept as-is for lib/gbrain-local-status.ts. POSIX behavior unchanged (shell:false, passthrough argv). Co-Authored-By: Claude Fable 5 --- lib/gbrain-exec.ts | 42 +++++++++++-- lib/gbrain-sources.ts | 37 ++++++------ test/gbrain-spawn-windows-shell.test.ts | 79 ++++++++++++++++++++----- 3 files changed, 119 insertions(+), 39 deletions(-) diff --git a/lib/gbrain-exec.ts b/lib/gbrain-exec.ts index 188d84b8f6..f13ea4a88e 100644 --- a/lib/gbrain-exec.ts +++ b/lib/gbrain-exec.ts @@ -196,6 +196,33 @@ export function bashScriptInvocation( return { cmd: bash, argv: [scriptPath.replace(/\\/g, "/"), ...args], shell: false }; } +/** + * Quote one argument for cmd.exe's re-parse (#2471). With `shell: true` on + * Windows, Node/Bun JOIN the argv into a single cmd.exe string WITHOUT + * quoting, so any argument containing a space — the default + * `C:\Users\First Last\repo` home layout — splits into two arguments and the + * gbrain call silently targets the wrong path. Pass-through for the safe + * charset; everything else is double-quoted with embedded quotes doubled + * (cmd.exe's escape). POSIX callers never see this (shell is false there). + */ +export function windowsShellQuote(arg: string): string { + if (arg !== "" && /^[A-Za-z0-9_\-.:\\/=,@+]+$/.test(arg)) return arg; + return '"' + arg.replace(/"/g, '""') + '"'; +} + +/** + * The single seam for building a gbrain CLI invocation (#2471). Every + * spawnSync/execFileSync of the `gbrain` shim must construct its + * (cmd, argv, shell) triple here so the Windows quoting fix lives in exactly + * one place — a direct spawn of the literal "gbrain" string with a shell + * flag reopens the space-in-path split this exists to close. + */ +export function gbrainInvocation(args: string[]): { cmd: string; argv: string[]; shell: boolean } { + return NEEDS_SHELL_ON_WINDOWS + ? { cmd: "gbrain", argv: args.map(windowsShellQuote), shell: true } + : { cmd: "gbrain", argv: args, shell: false }; +} + export interface SpawnGbrainOptions { /** Timeout in milliseconds. Defaults to 30s. */ timeout?: number; @@ -219,13 +246,14 @@ export interface SpawnGbrainOptions { * `stderr` exactly as they would with `spawnSync` directly. */ export function spawnGbrain(args: string[], opts: SpawnGbrainOptions = {}): SpawnSyncReturns { - return spawnSync("gbrain", args, { + const inv = gbrainInvocation(args); + return spawnSync(inv.cmd, inv.argv, { encoding: "utf-8", timeout: opts.timeout ?? 30_000, cwd: opts.cwd, stdio: opts.stdio || ["ignore", "pipe", "pipe"], env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce }), - shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows + shell: inv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting) }); } @@ -254,11 +282,12 @@ export function spawnGbrainAsync( args: string[], opts: { stdio?: SpawnOptions["stdio"]; cwd?: string; baseEnv?: NodeJS.ProcessEnv } = {}, ): ChildProcess { - return spawn("gbrain", args, { + const inv = gbrainInvocation(args); + return spawn(inv.cmd, inv.argv, { stdio: opts.stdio || ["ignore", "pipe", "pipe"], cwd: opts.cwd, env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: false }), - shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows + shell: inv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting) }); } @@ -267,12 +296,13 @@ export function spawnGbrainAsync( * for callers that want to surface gbrain's stderr as the error message. */ export function execGbrainText(args: string[], opts: SpawnGbrainOptions = {}): string { - return execFileSync("gbrain", args, { + const inv = gbrainInvocation(args); + return execFileSync(inv.cmd, inv.argv, { encoding: "utf-8", timeout: opts.timeout ?? 30_000, cwd: opts.cwd, stdio: opts.stdio || ["ignore", "pipe", "pipe"], env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce }), - shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows + shell: inv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting) }); } diff --git a/lib/gbrain-sources.ts b/lib/gbrain-sources.ts index 826e905ac6..34b42319cf 100644 --- a/lib/gbrain-sources.ts +++ b/lib/gbrain-sources.ts @@ -12,7 +12,7 @@ import { execFileSync, spawnSync } from "child_process"; import { realpathSync } from "fs"; import { withErrorContext } from "./gstack-memory-helpers"; -import { execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec"; +import { execGbrainJson, gbrainInvocation } from "./gbrain-exec"; import { detectAutopilot, decideSourceRemove, @@ -117,12 +117,13 @@ function samePath(registered: string | undefined, requested: string): boolean { export function probeSource(id: string, env?: NodeJS.ProcessEnv): SourceState { let stdout: string; try { - stdout = execFileSync("gbrain", ["sources", "list", "--json"], { + const inv = gbrainInvocation(["sources", "list", "--json"]); + stdout = execFileSync(inv.cmd, inv.argv, { encoding: "utf-8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"], env, - shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows + shell: inv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting) }); } catch (err) { const e = err as NodeJS.ErrnoException & { stderr?: Buffer }; @@ -224,29 +225,28 @@ export async function ensureSourceRegistered( throw new Error(`refusing drift re-register of ${id}: ${decision.reason}`); } - const rm = spawnSync( - "gbrain", - ["sources", "remove", id, "--yes", "--confirm-destructive", ...decision.extraArgs], - { - encoding: "utf-8", - timeout: 30_000, - env, - shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows - }, - ); + const rmInv = gbrainInvocation(["sources", "remove", id, "--yes", "--confirm-destructive", ...decision.extraArgs]); + const rm = spawnSync(rmInv.cmd, rmInv.argv, { + encoding: "utf-8", + timeout: 30_000, + env, + shell: rmInv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting) + }); if (rm.status !== 0) { throw new Error(`gbrain sources remove ${id} failed: ${rm.stderr || rm.stdout || `exit ${rm.status}`}`); } } - // Add. + // Add. `path` is a user repo path — the #2471 space-in-path victim; the + // invocation seam quotes it for cmd.exe's re-parse. const addArgs = ["sources", "add", id, "--path", path]; if (federated) addArgs.push("--federated"); - const add = spawnSync("gbrain", addArgs, { + const addInv = gbrainInvocation(addArgs); + const add = spawnSync(addInv.cmd, addInv.argv, { encoding: "utf-8", timeout: 30_000, env, - shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows + shell: addInv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting) }); if (add.status !== 0) { throw new Error(`gbrain sources add ${id} failed: ${add.stderr || add.stdout || `exit ${add.status}`}`); @@ -267,12 +267,13 @@ export async function ensureSourceRegistered( export function sourcePageCount(id: string, env?: NodeJS.ProcessEnv): number | null { let stdout: string; try { - stdout = execFileSync("gbrain", ["sources", "list", "--json"], { + const inv = gbrainInvocation(["sources", "list", "--json"]); + stdout = execFileSync(inv.cmd, inv.argv, { encoding: "utf-8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"], env, - shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows + shell: inv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting) }); } catch { return null; diff --git a/test/gbrain-spawn-windows-shell.test.ts b/test/gbrain-spawn-windows-shell.test.ts index eb319684ec..76a8b68abc 100644 --- a/test/gbrain-spawn-windows-shell.test.ts +++ b/test/gbrain-spawn-windows-shell.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect } from "bun:test"; import * as fs from "fs"; import * as path from "path"; -import { bashScriptInvocation } from "../lib/gbrain-exec"; +import { bashScriptInvocation, gbrainInvocation, windowsShellQuote } from "../lib/gbrain-exec"; const ROOT = path.resolve(import.meta.dir, ".."); const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), "utf-8"); @@ -18,24 +18,30 @@ describe("#1731 gbrain spawns carry the Windows shell flag", () => { expect(src).toMatch(/export const NEEDS_SHELL_ON_WINDOWS\s*=\s*process\.platform === "win32"/); }); - // Every direct `gbrain` child spawn in these files must be matched by a - // shell:NEEDS_SHELL_ON_WINDOWS flag. Count openers vs flags as a cheap, - // refactor-resistant invariant. - const gbrainSpawnFiles = [ - "lib/gbrain-exec.ts", - "lib/gbrain-sources.ts", - "lib/gbrain-local-status.ts", - ]; - for (const rel of gbrainSpawnFiles) { - test(`${rel}: every gbrain spawn has shell:NEEDS_SHELL_ON_WINDOWS`, () => { + // #2471 upgraded the #1731 invariant for the seamed files: gbrain spawns + // there must build their (cmd, argv, shell) triple via gbrainInvocation() + // (which owns BOTH the shell flag and cmd.exe quoting), so a direct + // `spawn*("gbrain"` opener is itself the violation. + const seamedFiles = ["lib/gbrain-exec.ts", "lib/gbrain-sources.ts"]; + for (const rel of seamedFiles) { + test(`${rel}: gbrain spawns route through gbrainInvocation (no direct openers)`, () => { const src = read(rel); - const spawnOpeners = src.match(/(spawnSync|spawn|execFileSync)\("gbrain"/g)?.length ?? 0; - const shellFlags = src.match(/shell:\s*NEEDS_SHELL_ON_WINDOWS/g)?.length ?? 0; - expect(spawnOpeners).toBeGreaterThan(0); - expect(shellFlags).toBeGreaterThanOrEqual(spawnOpeners); + const directOpeners = src.match(/(spawnSync|spawn|execFileSync)\(\s*["']gbrain["']/g)?.length ?? 0; + expect(directOpeners).toBe(0); + expect(src).toContain("gbrainInvocation("); }); } + // Not-yet-seamed file: every direct gbrain spawn must still carry the + // #1731 shell flag. (Migrate to gbrainInvocation when next touched.) + test("lib/gbrain-local-status.ts: every gbrain spawn has shell:NEEDS_SHELL_ON_WINDOWS", () => { + const src = read("lib/gbrain-local-status.ts"); + const spawnOpeners = src.match(/(spawnSync|spawn|execFileSync)\("gbrain"/g)?.length ?? 0; + const shellFlags = src.match(/shell:\s*NEEDS_SHELL_ON_WINDOWS/g)?.length ?? 0; + expect(spawnOpeners).toBeGreaterThan(0); + expect(shellFlags).toBeGreaterThanOrEqual(spawnOpeners); + }); + // NOT the brain-sync script. `shell: true` is right for the gbrain.cmd shim // and wrong for a bash shebang script: cmd.exe resolves .cmd/.bat via PATHEXT // and has no concept of a shebang, so gstack-brain-sync came back as "is not @@ -115,3 +121,46 @@ describe("bashScriptInvocation", () => { expect(inv).toBeNull(); }); }); + +// #2471: with `shell: true` on Windows, node/bun JOIN argv into one cmd.exe +// string without quoting — a path with a space (`C:\Users\First Last\repo`) +// splits into two arguments and `gbrain sources add --path` targets the wrong +// directory. The invocation seam quotes every risky argument exactly once. +describe("#2471 gbrain invocation seam quotes for cmd.exe", () => { + test("safe charset passes through untouched", () => { + expect(windowsShellQuote("sources")).toBe("sources"); + expect(windowsShellQuote("--json")).toBe("--json"); + expect(windowsShellQuote("C:\\Users\\j\\repo")).toBe("C:\\Users\\j\\repo"); + }); + + test("a path with a space is double-quoted", () => { + expect(windowsShellQuote("C:\\Users\\First Last\\repo")).toBe('"C:\\Users\\First Last\\repo"'); + }); + + test("embedded quotes are doubled (cmd.exe escape)", () => { + expect(windowsShellQuote('we"ird')).toBe('"we""ird"'); + }); + + test("empty argument stays a quoted empty string, not vanishing", () => { + expect(windowsShellQuote("")).toBe('""'); + }); + + test("shell metacharacters are wrapped so cmd.exe cannot interpret them", () => { + for (const bad of ["a b", "a&b", "a|b", "a>b", "a { + if (process.platform === "win32") return; // the win32 half is the map+quote path above + const inv = gbrainInvocation(["sources", "add", "id", "--path", "/a dir/with space"]); + expect(inv).toEqual({ cmd: "gbrain", argv: ["sources", "add", "id", "--path", "/a dir/with space"], shell: false }); + }); + + test("no direct un-seamed gbrain spawn remains in gbrain-sources.ts", () => { + const src = read("lib/gbrain-sources.ts"); + expect(src).not.toMatch(/spawnSync\(\s*["']gbrain["']/); + expect(src).not.toMatch(/execFileSync\(\s*["']gbrain["']/); + expect(src).toContain("gbrainInvocation("); + }); +}); From 184cf84ca9c411406c3eec6940fd57f5085fb09c Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 09:00:55 -0700 Subject: [PATCH 012/126] fix(brain-sync): classify queue entries, rewrite surgically, re-push stranded commits Fixes #2549 (P0 data loss). Every drain exit previously truncated the WHOLE queue (six `: > "$QUEUE"` sites), which (a) destroyed privacy/mode-held entries while misattributing them as "no allowlisted changes", (b) destroyed entries enqueued concurrently during the drain, and (c) left push-failed commits stranded locally with nothing ever re-pushing them until unrelated new work arrived. Now: compute_paths_to_stage classifies every entry (stageable / retained privacy-held / dropped skipped-invalid-unmatched-missing); rewrite_queue re-reads the LIVE queue at mv time and removes only this drain's processed paths (retained + concurrent appends + unparseable lines survive; atomic tmp+mv); an unpushed-commit detector at run start re-pushes stranded local commits (receipted fail-closed; a receipt refusal skips the retry rather than wedging the drain; guards missing origin/; runs inside the existing lock). Status lines carry counts; full drop paths go to a 0600 sidecar (.brain-sync-drops.json) so filenames stay out of transcripts. --drop-queue remains the one intentional truncation. Matrix added: privacy retention, unmatched/missing counted drops + sidecar mode, unparseable-line preservation, surgical same-drain retention, push-fail commit retention + detector re-delivery on an EMPTY queue, receipt-refusal skip. 35/35 in test/brain-sync.test.ts. Co-Authored-By: Claude Fable 5 --- bin/gstack-brain-sync | 171 +++++++++++++++++++++++++++++++++++----- test/brain-sync.test.ts | 153 +++++++++++++++++++++++++++++++++++ 2 files changed, 304 insertions(+), 20 deletions(-) diff --git a/bin/gstack-brain-sync b/bin/gstack-brain-sync index 2fa6968692..d846664844 100755 --- a/bin/gstack-brain-sync +++ b/bin/gstack-brain-sync @@ -122,12 +122,23 @@ sys.exit(0) # Compute matched allowlisted, privacy-filtered path set from queue. # Output: newline-delimited relative paths that should be staged. +# +# #2549: every non-staged queue entry is CLASSIFIED, never silently discarded. +# When $2 is given, a JSON classification lands there: +# {"retained": [privacy/mode-held paths that stay queued], +# "dropped": {"skipped": [...], "invalid": [...], "unmatched": [...], "missing": [...]}} +# retained entries would sync if the user raises artifacts_sync_mode, so they +# stay in the queue; dropped classes can never sync (explicit skip, escape +# attempt, no allowlist glob, not on disk) and are removed WITH a counted +# status — the old behavior truncated the whole queue and reported every one +# of these, including privacy holds, as "no allowlisted changes". compute_paths_to_stage() { local mode="$1" - python3 - "$GSTACK_HOME" "$QUEUE" "$ALLOWLIST" "$PRIVACY_MAP" "$SKIP_FILE" "$mode" <<'PYEOF' + local class_file="${2:-}" + python3 - "$GSTACK_HOME" "$QUEUE" "$ALLOWLIST" "$PRIVACY_MAP" "$SKIP_FILE" "$mode" "$class_file" <<'PYEOF' import sys, json, os, fnmatch, glob -gstack_home, queue, allowlist_path, privacy_path, skip_path, mode = sys.argv[1:7] +gstack_home, queue, allowlist_path, privacy_path, skip_path, mode, class_file = sys.argv[1:8] def load_lines(path): try: @@ -195,29 +206,119 @@ def mode_allows(cls, mode): return True # full final = [] +classified = {"retained": [], "dropped": {"skipped": [], "invalid": [], "unmatched": [], "missing": []}} for p in sorted(queue_paths): if p in skip_lines: + classified["dropped"]["skipped"].append(p) continue # Must be under GSTACK_HOME root. Reject absolute + reject ../ escape. if p.startswith("/") or ".." in p.split("/"): + classified["dropped"]["invalid"].append(p) continue # Must match at least one allowlist glob. if not path_matches_any(p, allowlist_globs): + classified["dropped"]["unmatched"].append(p) continue - # Must survive privacy mode filter. + # Must survive privacy mode filter — held entries STAY QUEUED (retained): + # they would sync under a higher artifacts_sync_mode, and reporting them + # as "no allowlisted changes" was #2549's misattribution. cls = privacy_class(p, privacy_map) if not mode_allows(cls, mode): + classified["retained"].append(p) continue # Must exist on disk — can't stage what isn't there. if not os.path.exists(os.path.join(gstack_home, p)): + classified["dropped"]["missing"].append(p) continue final.append(p) +if class_file: + with open(class_file, "w") as f: + json.dump(classified, f) + for p in final: print(p) PYEOF } +# #2549: surgical queue rewrite — replaces every whole-queue truncation +# (`: > "$QUEUE"`). Re-reads the LIVE queue at rewrite time (a writer may have +# enqueued while we were staging/pushing — those entries must survive; the old +# truncation destroyed them) and keeps every line whose file is either +# retained (privacy/mode-held) or not part of this drain at all. Atomic +# tmp+mv in the same directory. Dropped-path detail goes to a 0600 sidecar so +# the status line can stay content-free (counts only). +rewrite_queue() { + local paths_file="$1" # staged (drained) paths, one per line + local class_file="$2" # classification JSON from compute_paths_to_stage + python3 - "$QUEUE" "$paths_file" "$class_file" "$GSTACK_HOME/.brain-sync-drops.json" <<'PYEOF' 2>/dev/null || true +import json, os, sys, time +queue, paths_file, class_file, drops_file = sys.argv[1:5] + +def lines(path): + try: + with open(path) as f: + return [l.rstrip("\r\n") for l in f if l.strip()] + except FileNotFoundError: + return [] + +staged = set(lines(paths_file)) +try: + with open(class_file) as f: + classified = json.load(f) +except Exception: + classified = {"retained": [], "dropped": {}} +retained = set(classified.get("retained", [])) +dropped = set() +for group in (classified.get("dropped", {}) or {}).values(): + dropped.update(group) +processed = staged | dropped + +kept = [] +for line in lines(queue): # LIVE re-read: concurrent enqueues survive (F3) + try: + p = json.loads(line).get("file") + except Exception: + kept.append(line) # unparseable line: keep, never destroy + continue + if not isinstance(p, str) or p in retained or p not in processed: + kept.append(line) + +tmp = queue + ".tmp." + str(os.getpid()) +with open(tmp, "w") as f: + for l in kept: + f.write(l + "\n") +os.replace(tmp, queue) + +if dropped: + fd = os.open(drops_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + json.dump({"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "dropped": classified.get("dropped", {})}, f) +PYEOF +} + +# Human-readable classification counts for status messages. +queue_summary() { + local class_file="$1" + python3 - "$class_file" <<'PYEOF' 2>/dev/null || echo "" +import json, sys +try: + with open(sys.argv[1]) as f: + c = json.load(f) +except Exception: + print(""); sys.exit(0) +d = c.get("dropped", {}) or {} +parts = [] +r = len(c.get("retained", [])) +if r: parts.append(f"{r} privacy-held retained") +for k in ("skipped", "unmatched", "missing", "invalid"): + n = len(d.get(k, [])) + if n: parts.append(f"{n} {k} dropped") +print("; ".join(parts)) +PYEOF +} + subcmd_once() { if ! sync_active; then # Silent no-op when feature not initialized / disabled. @@ -253,16 +354,42 @@ subcmd_once() { local mode mode=$("$CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) - local paths_file + # #2549 unpushed-commit detector: a prior drain may have COMMITTED but + # failed to push (auth blip, offline). The data was never lost — it sits in + # a local commit — but nothing re-pushed it until NEW changes arrived. + # Retry the push up front, inside the lock. Receipted fail-closed like + # every other push; a receipt REFUSAL skips the retry without blocking the + # rest of the drain (local staging must not wedge on receipt problems). + # Guards: origin/ may not exist yet (first sync, deleted remote). + local det_branch det_unpushed + det_branch=$(git -C "$GSTACK_HOME" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") + if [ -n "$det_branch" ] && git -C "$GSTACK_HOME" rev-parse --verify --quiet "origin/$det_branch" >/dev/null 2>&1; then + det_unpushed=$(git -C "$GSTACK_HOME" rev-list --count "origin/$det_branch..HEAD" 2>/dev/null || echo 0) + case "$det_unpushed" in ''|*[!0-9]*) det_unpushed=0 ;; esac + if [ "$det_unpushed" -gt 0 ]; then + local det_host + det_host=$(remote_host) + if GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$det_host" curated-memory-git-push "artifacts_sync_mode!=off" \ + bash -c 'git -C "$1" push origin HEAD 2>/dev/null' _ "$GSTACK_HOME"; then + date -u +%Y-%m-%dT%H:%M:%SZ > "$LAST_PUSH_FILE" + fi + fi + fi + + local paths_file class_file paths_file=$(mktemp /tmp/brain-sync-paths.XXXXXX) || { rm -rf "$lock_dir" 2>/dev/null; write_status "error" "mktemp failed"; exit 1; } - # Single trap covers both: lock cleanup AND tempfile cleanup. - trap 'rm -f "$paths_file" 2>/dev/null; rm -rf "$lock_dir" 2>/dev/null || true' EXIT INT TERM + class_file=$(mktemp /tmp/brain-sync-class.XXXXXX) || { rm -f "$paths_file"; rm -rf "$lock_dir" 2>/dev/null; write_status "error" "mktemp failed"; exit 1; } + # Single trap covers all: lock cleanup AND tempfile cleanup. + trap 'rm -f "$paths_file" "$class_file" 2>/dev/null; rm -rf "$lock_dir" 2>/dev/null || true' EXIT INT TERM - compute_paths_to_stage "$mode" > "$paths_file" + compute_paths_to_stage "$mode" "$class_file" > "$paths_file" if [ ! -s "$paths_file" ]; then - # Nothing to stage. Clear any stale queue entries and exit. - : > "$QUEUE" - write_status "idle" "no allowlisted changes in queue" + # Nothing stageable. Rewrite the queue (retained entries + concurrent + # appends survive; classified drops removed) instead of truncating it. + rewrite_queue "$paths_file" "$class_file" + local summary + summary=$(queue_summary "$class_file") + write_status "idle" "no stageable changes${summary:+ ($summary)}" exit 0 fi @@ -309,8 +436,9 @@ subcmd_once() { local msg="sync: $n file(s) | $ts" git -C "$GSTACK_HOME" -c user.email="gstack@localhost" -c user.name="gstack-brain-sync" \ commit -q -m "$msg" 2>/dev/null || { - # Nothing to commit (e.g. all files already committed). - : > "$QUEUE" + # Nothing to commit (e.g. all files already committed). The drained + # paths leave the queue; retained + concurrent entries survive (#2549). + rewrite_queue "$paths_file" "$class_file" write_status "idle" "queue drained but no new changes to commit" exit 0 } @@ -322,10 +450,12 @@ subcmd_once() { if echo "$push_err" | grep -qiE "auth|permission|403|401|forbidden"; then local hint hint=$(remote_auth_hint) - write_status "push_failed" "push failed: auth error. fix: $hint" + write_status "push_failed" "push failed: auth error; commit retained locally, will retry next run. fix: $hint" echo "BRAIN_SYNC: push failed: auth. fix: $hint" >&2 - # Queue cleared because the commit exists locally; next push will send it. - : > "$QUEUE" + # Drained paths leave the queue — they live in the local commit, which + # the run-start detector re-pushes next time (#2549). Retained + + # concurrent entries survive the rewrite. + rewrite_queue "$paths_file" "$class_file" exit 0 fi @@ -339,20 +469,21 @@ subcmd_once() { if git -C "$GSTACK_HOME" merge --no-edit "origin/$branch" >/dev/null 2>&1; then if GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$push_host" curated-memory-git-push "artifacts_sync_mode!=off" \ bash -c 'git -C "$1" push origin HEAD 2>/dev/null' _ "$GSTACK_HOME"; then - : > "$QUEUE" + rewrite_queue "$paths_file" "$class_file" date -u +%Y-%m-%dT%H:%M:%SZ > "$LAST_PUSH_FILE" write_status "ok" "pushed $n file(s) after rebase" exit 0 fi fi fi - write_status "push_failed" "push failed: $(printf '%s' "$push_err" | head -1)" - : > "$QUEUE" + # Commit exists locally; the run-start detector re-pushes it next time. + write_status "push_failed" "push failed: $(printf '%s' "$push_err" | head -1); commit retained locally, will retry next run" + rewrite_queue "$paths_file" "$class_file" exit 0 } - # Success: clear queue, update last-push. - : > "$QUEUE" + # Success: drained paths leave the queue (retained + concurrent survive). + rewrite_queue "$paths_file" "$class_file" date -u +%Y-%m-%dT%H:%M:%SZ > "$LAST_PUSH_FILE" write_status "ok" "pushed $n file(s)" exit 0 diff --git a/test/brain-sync.test.ts b/test/brain-sync.test.ts index a4587f8060..433c73e0a4 100644 --- a/test/brain-sync.test.ts +++ b/test/brain-sync.test.ts @@ -448,3 +448,156 @@ describe('gstack-brain-sync --discover-new', () => { expect(queue.trim()).toBe(''); }); }); + +// --------------------------------------------------------------- +// #2549 queue integrity: classified drops, privacy retention, +// surgical rewrite, unpushed-commit detector +// --------------------------------------------------------------- +describe('#2549 queue integrity', () => { + function initWithMode(mode: string) { + run(['gstack-artifacts-init', '--remote', bareRemote]); + run(['gstack-config', 'set', 'artifacts_sync_mode', mode]); + } + const queueText = () => fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8'); + const statusJson = () => JSON.parse(fs.readFileSync(path.join(tmpHome, '.brain-sync-status.json'), 'utf-8')); + + test('privacy-held entries are RETAINED and classified, not wiped as "no allowlisted changes"', () => { + // timeline.jsonl is class=behavioral; artifacts-only mode holds it. + initWithMode('artifacts-only'); + fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true }); + fs.writeFileSync(path.join(tmpHome, 'projects/p/timeline.jsonl'), '{"skill":"x","event":"started"}\n'); + run(['gstack-brain-enqueue', 'projects/p/timeline.jsonl']); + const r = run(['gstack-brain-sync', '--once']); + expect(r.status).toBe(0); + // The exact #2549 repro: the old code truncated the queue here and said + // "no allowlisted changes in queue". The entry must survive, and the + // status must attribute the hold honestly. + expect(queueText()).toContain('projects/p/timeline.jsonl'); + const s = statusJson(); + expect(s.status).toBe('idle'); + expect(s.message).toContain('privacy-held retained'); + expect(s.message).not.toContain('no allowlisted changes'); + }); + + test('unmatched and missing entries drop WITH counts and a 0600 drops sidecar', () => { + initWithMode('full'); + fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true }); + // Unmatched: no allowlist glob covers .txt scratch files. + fs.writeFileSync(path.join(tmpHome, 'projects/p/scratch.txt'), 'x\n'); + fs.appendFileSync(path.join(tmpHome, '.brain-queue.jsonl'), '{"file":"projects/p/scratch.txt"}\n'); + // Missing: allowlisted name that does not exist on disk. + fs.appendFileSync(path.join(tmpHome, '.brain-queue.jsonl'), '{"file":"projects/p/learnings.jsonl"}\n'); + const r = run(['gstack-brain-sync', '--once']); + expect(r.status).toBe(0); + expect(queueText()).not.toContain('scratch.txt'); + expect(queueText()).not.toContain('learnings.jsonl'); + const s = statusJson(); + expect(s.message).toContain('1 unmatched dropped'); + expect(s.message).toContain('1 missing dropped'); + const drops = path.join(tmpHome, '.brain-sync-drops.json'); + expect(fs.existsSync(drops)).toBe(true); + if (process.platform !== 'win32') { + expect(fs.statSync(drops).mode & 0o777).toBe(0o600); + } + const detail = JSON.parse(fs.readFileSync(drops, 'utf-8')); + expect(detail.dropped.unmatched).toContain('projects/p/scratch.txt'); + expect(detail.dropped.missing).toContain('projects/p/learnings.jsonl'); + }); + + test('an unparseable queue line is preserved, never destroyed', () => { + initWithMode('full'); + fs.appendFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'not json at all\n'); + const r = run(['gstack-brain-sync', '--once']); + expect(r.status).toBe(0); + expect(queueText()).toContain('not json at all'); + }); + + test('surgical rewrite: a synced entry leaves the queue while a held sibling survives the same drain', () => { + // Proves the rewrite is a live filtered rewrite, not a truncation: two + // entries drain in one --once, one stages+pushes, one is mode-held. + initWithMode('artifacts-only'); + fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true }); + fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","insight":"y","ts":"2026-01-01T00:00:00Z"}\n'); + fs.writeFileSync(path.join(tmpHome, 'projects/p/timeline.jsonl'), '{"skill":"x","event":"started"}\n'); + run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']); + run(['gstack-brain-enqueue', 'projects/p/timeline.jsonl']); + const r = run(['gstack-brain-sync', '--once']); + expect(r.status).toBe(0); + expect(queueText()).not.toContain('learnings.jsonl'); // synced, removed + expect(queueText()).toContain('timeline.jsonl'); // held, retained + const log = spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8' }); + expect(log.stdout).toMatch(/sync: 1 file/); + }); + + test('push failure retains the commit locally and the run-start detector re-pushes it', () => { + initWithMode('full'); + // Establish origin/main so the detector has a remote ref to compare. + fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true }); + fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"a","ts":"2026-01-01T00:00:00Z"}\n'); + run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']); + expect(run(['gstack-brain-sync', '--once']).status).toBe(0); + + // Reject the next push at the remote (pre-receive hook exits 1 with an + // auth-shaped message so the auth branch is exercised too). + const hook = path.join(bareRemote, 'hooks', 'pre-receive'); + fs.writeFileSync(hook, '#!/bin/sh\necho "403 forbidden" >&2\nexit 1\n'); + fs.chmodSync(hook, 0o755); + + fs.appendFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"b","ts":"2026-01-02T00:00:00Z"}\n'); + run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']); + const fail = run(['gstack-brain-sync', '--once']); + expect(fail.status).toBe(0); + const s = statusJson(); + expect(s.status).toBe('push_failed'); + expect(s.message).toContain('commit retained locally'); + // Drained path left the queue — it lives in the local commit now. + expect(queueText()).not.toContain('learnings.jsonl'); + // The commit exists locally, ahead of origin. + const ahead = git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim(); + expect(Number(ahead)).toBeGreaterThan(0); + + // Remote healthy again: an EMPTY-queue run must still deliver the + // stranded commit (the detector, not the drain, pushes it). + fs.rmSync(hook); + const retry = run(['gstack-brain-sync', '--once']); + expect(retry.status).toBe(0); + const log = spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8' }); + expect(log.stdout).toMatch(/sync: 1 file/); + expect(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim()).toBe('0'); + }); + + test('receipt refusal at the detector skips the retry without wedging the drain', () => { + if (process.platform === 'win32' || process.getuid?.() === 0) return; // chmod advisory there + initWithMode('full'); + fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true }); + fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"a","ts":"2026-01-01T00:00:00Z"}\n'); + run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']); + expect(run(['gstack-brain-sync', '--once']).status).toBe(0); + + // Strand a commit: reject pushes, drain once. + const hook = path.join(bareRemote, 'hooks', 'pre-receive'); + fs.writeFileSync(hook, '#!/bin/sh\nexit 1\n'); + fs.chmodSync(hook, 0o755); + fs.appendFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"b","ts":"2026-01-02T00:00:00Z"}\n'); + run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']); + expect(run(['gstack-brain-sync', '--once']).status).toBe(0); + fs.rmSync(hook); + + // Break receipts. The detector's retry must be SKIPPED (no wedge), and + // the run must still exit 0 with nothing else to do. + fs.mkdirSync(path.join(tmpHome, 'security'), { recursive: true }); + fs.chmodSync(path.join(tmpHome, 'security'), 0o500); + try { + const r = run(['gstack-brain-sync', '--once']); + expect(r.status).toBe(0); + // Commit still stranded (retry skipped, not attempted unreceipted). + expect(Number(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim())).toBeGreaterThan(0); + } finally { + fs.chmodSync(path.join(tmpHome, 'security'), 0o700); + } + + // Receipts healthy: detector delivers. + expect(run(['gstack-brain-sync', '--once']).status).toBe(0); + expect(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim()).toBe('0'); + }); +}); From 4f5e351143860cfc8e905169c214203aa58fe0a5 Mon Sep 17 00:00:00 2001 From: ShahriarLak Date: Fri, 31 Jul 2026 01:07:56 +0100 Subject: [PATCH 013/126] fix(gbrain): make --full do a full code walk, not a delta one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `runCodeImport()` walked with a bare `gbrain sync --strategy code --source X`. The strategy is right, but that walk is incremental: it only revisits files changed since the source's checkpoint. A file missed at the ORIGINAL import is therefore never revisited and stays out of the index indefinitely. The reindex-code pass below cannot rescue it. It re-chunks pages that already exist and never walks the filesystem — the same property the comment directly above already relies on when explaining why the walk has to run first. That fix landed one flag short: it made a fresh source get pages at all, but left `--full` unable to discover a file the first walk skipped. Net effect: `/sync-gbrain --full` did not perform a full walk, and re-running it never re-detected the gap. The failure is silent, which is what makes it expensive. Nothing errors, nothing warns, and the verdict block still reports OK while `gbrain search` and `gbrain code-def` answer out of a partial index. It reads as "gbrain is weak at code questions" rather than "the index is incomplete". Measured on two local code sources before and after this change, counting exported functions resolvable via `gbrain code-def`: one went from 61/201 (30%) to 180/201 (89%), importing 79 files that had no page at all; the other had whole source files missing entirely and reached 93%. Both had been serving search from a partial index for weeks. Scoped to `--full` so incremental runs stay fast. `--yes` because this spawns non-interactively and a full walk otherwise prompts to confirm import cost. Anyone can check their own brain without applying this: gbrain sync --source --strategy code --full --dry-run and compare "N file(s) would be imported" against that source's page_count. Worth knowing while doing so: the default strategy is markdown and --strategy is per-invocation, never persisted on the source, so dropping the flag reports strategy=markdown and a handful of files. --- bin/gstack-gbrain-sync.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index cb4497d2df..2624dd6ba9 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -995,7 +995,25 @@ async function runCodeImport(args: CliArgs): Promise { }; } - const walkResult = spawnGbrain(["sync", "--strategy", "code", "--source", sourceId], { + // `--full` must do a FULL walk, not a delta one. + // + // A bare `sync --strategy code` is incremental: it only revisits files that + // changed since the source's checkpoint. So a file missed at the ORIGINAL + // import is never revisited and stays invisible indefinitely — and the + // reindex-code pass below cannot rescue it, because it re-chunks pages that + // already exist and never walks the filesystem (the same property the comment + // above already relies on). + // + // The failure is silent: no error, no warning, and the verdict block still + // reports OK while `gbrain search` and `gbrain code-def` answer out of a + // partial index. It presents as "gbrain is weak at code questions" rather + // than "the index is incomplete", which is what makes it hard to spot. + // + // --yes because this is spawned non-interactively; a full walk otherwise + // prompts to confirm the import cost. + const walkArgs = ["sync", "--strategy", "code", "--source", sourceId]; + if (args.mode === "full") walkArgs.push("--full", "--yes"); + const walkResult = spawnGbrain(walkArgs, { stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"], timeout: codeTimeoutMs, baseEnv: gbrainEnv, @@ -1007,7 +1025,7 @@ async function runCodeImport(args: CliArgs): Promise { ran: true, ok: false, duration_ms: Date.now() - t0, - summary: `gbrain sync --strategy code --source ${sourceId} exited ${walkResult.status}`, + summary: `gbrain ${walkArgs.join(" ")} exited ${walkResult.status}`, detail: { source_id: sourceId, source_path: root, status: "failed" }, }; } From c024a5b347be8251c251fafad6c98d8d2c89d088 Mon Sep 17 00:00:00 2001 From: Stefan Andrei <89592870+sneakygriff@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:45:25 +0300 Subject: [PATCH 014/126] fix(brain-cache): honest 'missing' instead of fabricated-empty digests on gbrain failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A gbrain-unreachable failure in fetchRecentDecisions and fetchSalience used to be converted into a cached 'successful' empty digest ("_No prior skill runs recorded._" / "_No salient pages in last 14d._") that refreshEntity stamped with last_refresh. The false negative then survived every subsequent TTL cycle, indistinguishable from a genuine zero-rows result. Now failure returns null, so cmdGet's existing missing/stale-fallback machinery reports the true state — matching what fetchGoals and fetchSimplePage already do on failure. Also adds an Array.isArray guard in fetchRecentDecisions so a malformed payload ({pages: {}} etc.) classifies as failure instead of crashing refreshEntity mid-refresh; a genuinely empty pages array still renders the honest empty digest. Co-Authored-By: Claude Fable 5 --- bin/gstack-brain-cache | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/bin/gstack-brain-cache b/bin/gstack-brain-cache index f7694f33fd..3014836326 100755 --- a/bin/gstack-brain-cache +++ b/bin/gstack-brain-cache @@ -521,6 +521,21 @@ function fetchRecentDecisions(projectSlug: string | null): string | null { '--json', ]); if (!result?.pages) { + // F10 bug fix: this branch used to return the hardcoded + // "_No prior skill runs recorded._" string here, which is indistinguishable + // from a genuine zero-rows result. That silently converted a gbrain- + // unreachable FAILURE into a "successful" cached digest — refreshEntity() + // would write it and stamp last_refresh, so the false negative survived + // every subsequent TTL cycle forever. Returning null instead lets cmdGet's + // existing missing/stale-fallback machinery report the true state, exactly + // like every sibling fetcher (fetchGoals, fetchSimplePage) already does on + // failure. + return null; + } + // A malformed payload ({pages: {}} etc.) must classify as failure, not crash + // refreshEntity mid-refresh — same honest-missing polarity as the F10 fix. + if (!Array.isArray(result.pages)) return null; + if (result.pages.length === 0) { return `# Recent decisions (project: ${projectSlug})\n\n_No prior skill runs recorded._\n`; } const lines = result.pages.map((p) => `- ${p.title || p.slug}`); @@ -576,7 +591,17 @@ function fetchSalience(projectSlug: string | null): string | null { '--limit', '10', '--json', ]); - if (!result?.pages) return `# Recent salience\n\n_No salient pages in last 14d._\n`; + // F10 bug fix (sibling of fetchRecentDecisions above): a gbrain-unreachable + // failure used to render the identical hardcoded "no salient pages" string + // as a genuine empty result, which refreshEntity() then cached as if it + // were verified truth. Unlike recent-decisions there is no project-local + // fallback for salience — it is specifically gbrain's emotional-weight- + // ranked *brain* pages, not project decision/work data, and conflating the + // two would defeat the D9 privacy allowlist's purpose. So on failure we + // return null and let the cache report 'missing' (same as product.md, + // goals.md, etc. already do on this machine) instead of asserting a claim + // we have no way to verify. + if (!result?.pages) return null; // D9 privacy gate: strip entries outside the allowlist BEFORE rendering. // Sensitive personal content (family, therapy, reflection) is never written From c2cdf651767fee41353ccbe4faf551ef74d6aa05 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 09:04:36 -0700 Subject: [PATCH 015/126] fix(test): give the schema-mismatch rebuild test a load-proof budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebuild path refreshes every per-project entity against the real gbrain CLI; with an unreachable brain each spawn runs to its own timeout, and under machine load the stack exceeds bun's 5s default (observed 5.2-5.4s, identically on pre-#2587 binaries — a load flake, not a regression). 30s budget matches the sibling brain-sync suite's convention. Co-Authored-By: Claude Fable 5 --- test/brain-cache-roundtrip.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/brain-cache-roundtrip.test.ts b/test/brain-cache-roundtrip.test.ts index 060ae26f9e..972708657e 100644 --- a/test/brain-cache-roundtrip.test.ts +++ b/test/brain-cache-roundtrip.test.ts @@ -153,7 +153,12 @@ describe('brain-cache schema mismatch behavior', () => { // the file gets deleted by the rebuild step. State should be 'missing' or // 'stale-fallback' depending on whether the rebuild left a file behind. expect(['missing', 'cold-refreshed', 'stale-fallback']).toContain(result.state); - }); + }, 30000); + // ^ 30s: the schema-mismatch rebuild refreshes EVERY per-project entity, + // each spawning the real gbrain CLI (no mock here). With an unreachable + // brain each spawn runs to its own timeout, and under machine load the + // stack exceeds bun's 5s default — observed at 5.2-5.4s on a loaded box, + // identically on pre-fix binaries (load flake, not a code regression). }); describe('brain-cache state machine', () => { From 00d0115ac742333c64a138cd45fdafd3103114be Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 09:07:27 -0700 Subject: [PATCH 016/126] fix(memory-ingest): parse the current Codex response_item rollout shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2105. Codex rollout JSONL moved to { type: 'response_item', payload: { type: 'message', role, content: [...] } }; the parser's legacy payload.message branch never fired on it, so every Codex session imported as an empty shell (message_count: 0 — 243/243 sessions on the reporting machine). Both shapes now parse; non-message response_items (reasoning etc.) are ignored. parseTranscriptJsonl exported for direct unit tests (CLI path unchanged — import.meta.main guard). Note: #2104's staging-in-gitignored-tree half is already defended on main (--include-gitignored + GIT_CEILING_DIRECTORIES, #2144, plus the #2486 reconcile guard) — verified, no change needed; it moves to the close-only roster. Co-Authored-By: Claude Fable 5 --- bin/gstack-memory-ingest.ts | 16 ++++++++++-- test/gstack-memory-ingest.test.ts | 43 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/bin/gstack-memory-ingest.ts b/bin/gstack-memory-ingest.ts index 4aeba0b69e..5cbb535baf 100644 --- a/bin/gstack-memory-ingest.ts +++ b/bin/gstack-memory-ingest.ts @@ -543,7 +543,7 @@ interface ParsedSession { partial: boolean; } -function parseTranscriptJsonl(path: string): ParsedSession | null { +export function parseTranscriptJsonl(path: string): ParsedSession | null { // Best-effort tolerant parser. Handles truncated last lines (D10 partial-flag). let raw: string; try { @@ -619,7 +619,7 @@ function parseTranscriptJsonl(path: string): ParsedSession | null { const tool = rec?.name || rec?.tool || rec?.tool_call?.name || "tool"; bodyParts.push(`### Tool call: ${tool}`); } else if (isCodex && rec?.payload?.message) { - // Codex shape: each record has payload.message + // Legacy Codex shape: each record has payload.message const msg = rec.payload.message; const role = msg.role || "user"; const content = extractContentText(msg); @@ -627,6 +627,18 @@ function parseTranscriptJsonl(path: string): ParsedSession | null { bodyParts.push(`## ${role.charAt(0).toUpperCase() + role.slice(1)}\n\n${content}`); messageCount++; } + } else if (isCodex && rec?.type === "response_item" && rec?.payload?.type === "message") { + // Current Codex rollout shape (#2105): records are + // { type: 'response_item', payload: { type: 'message', role, content: [...] } }. + // The legacy payload.message branch never fires on these, which rendered + // every Codex session as an empty shell (message_count: 0, 243/243 on + // the reporting machine). Flatten payload.content like the Claude branch. + const role = rec.payload.role || "user"; + const content = extractContentText(rec.payload); + if (content) { + bodyParts.push(`## ${role.charAt(0).toUpperCase() + role.slice(1)}\n\n${content}`); + messageCount++; + } } } diff --git a/test/gstack-memory-ingest.test.ts b/test/gstack-memory-ingest.test.ts index 039beefad9..b2d0a7b425 100644 --- a/test/gstack-memory-ingest.test.ts +++ b/test/gstack-memory-ingest.test.ts @@ -818,3 +818,46 @@ exit 0 rmSync(home, { recursive: true, force: true }); }); }); + +// #2105: current Codex rollout records are +// { type: 'response_item', payload: { type: 'message', role, content: [...] } } +// — the legacy payload.message branch never fired on them, so every Codex +// session imported as an empty shell (message_count: 0, 243/243 on the +// reporting machine). +describe("#2105 codex response_item rollout shape", () => { + it("extracts messages from response_item records", async () => { + const { parseTranscriptJsonl } = await import("../bin/gstack-memory-ingest"); + const dir = mkdtempSync(join(tmpdir(), "ingest-2105-")); + const file = join(dir, "rollout-2026-06-01.jsonl"); + writeFileSync(file, [ + JSON.stringify({ type: "session_meta", payload: { id: "s1", cwd: "/tmp/x" }, timestamp: "2026-06-01T00:00:00Z" }), + JSON.stringify({ type: "response_item", payload: { type: "message", role: "user", content: [{ type: "input_text", text: "hello codex" }] } }), + JSON.stringify({ type: "response_item", payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: "hello human" }] } }), + // Non-message response_items must not count as messages. + JSON.stringify({ type: "response_item", payload: { type: "reasoning", summary: [] } }), + ].join("\n") + "\n"); + + const parsed = parseTranscriptJsonl(file)!; + expect(parsed).not.toBeNull(); + expect(parsed.agent).toBe("codex"); + expect(parsed.message_count).toBe(2); + expect(parsed.body).toContain("## User\n\nhello codex"); + expect(parsed.body).toContain("## Assistant\n\nhello human"); + rmSync(dir, { recursive: true, force: true }); + }); + + it("legacy payload.message shape still parses", async () => { + const { parseTranscriptJsonl } = await import("../bin/gstack-memory-ingest"); + const dir = mkdtempSync(join(tmpdir(), "ingest-2105-legacy-")); + const file = join(dir, "rollout-legacy.jsonl"); + writeFileSync(file, [ + JSON.stringify({ type: "session_meta", payload: { id: "s2", cwd: "/tmp/y" }, timestamp: "2026-06-01T00:00:00Z" }), + JSON.stringify({ payload: { message: { role: "user", content: "old shape" } } }), + ].join("\n") + "\n"); + + const parsed = parseTranscriptJsonl(file)!; + expect(parsed.message_count).toBe(1); + expect(parsed.body).toContain("## User\n\nold shape"); + rmSync(dir, { recursive: true, force: true }); + }); +}); From 9df6015130218ef79d1b6d360858f4ea3fad94a8 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 09:12:32 -0700 Subject: [PATCH 017/126] fix(test): refresh codex/factory ship goldens from post-#2588 regeneration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #2588 absorb refreshed all three ship goldens, but `bun run gen:skill-docs` regenerates the CLAUDE host only — the codex/factory goldens were copied from artifacts rendered before the resolver change and failed against a fresh external-host regen in the serial test phase. Re-rendered with --host codex / --host factory and re-copied. Co-Authored-By: Claude Fable 5 --- test/fixtures/golden/codex-ship-SKILL.md | 3 ++- test/fixtures/golden/factory-ship-SKILL.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index 642e15bc90..08eccf37a3 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -518,6 +518,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -531,7 +532,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index 14d070b525..a954fcebfe 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -520,6 +520,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -533,7 +534,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 From a118fd0c89affd1994a9a9e22837c684f5bc4888 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 09:19:12 -0700 Subject: [PATCH 018/126] fix(make-pdf): boolean flags no longer swallow the next positional argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2514. The parser treated any non-flag token after a flag as its value, so `$P generate --toc essay.md` ate essay.md as --toc's value and failed with "missing input" — the skill's own documented usage only worked when two boolean flags happened to be adjacent. BOOLEAN_FLAGS enumerates the no-value flags; value flags (--watermark, --to, --title, ...) are unchanged. main() now runs behind import.meta.main so tests import the parser directly. Co-Authored-By: Claude Fable 5 --- make-pdf/src/cli.ts | 22 +++++++++++++--- make-pdf/test/cli-args.test.ts | 47 ++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 make-pdf/test/cli-args.test.ts diff --git a/make-pdf/src/cli.ts b/make-pdf/src/cli.ts index 988e204449..b39ce6013e 100644 --- a/make-pdf/src/cli.ts +++ b/make-pdf/src/cli.ts @@ -20,7 +20,20 @@ interface ParsedArgs { flags: Record; } -function parseArgs(argv: string[]): ParsedArgs { +/** + * Flags that never take a value (#2514). The parser used to treat ANY + * following non-flag token as the flag's value, so the skill's own + * documented usage — `$P generate --cover --toc essay.md essay.pdf` — worked + * only by luck of flag adjacency, while `$P generate --toc essay.md` ate + * `essay.md` as --toc's value and failed with "missing input". + */ +export const BOOLEAN_FLAGS = new Set([ + "cover", "toc", "no-chapter-breaks", "no-confidential", + "page-numbers", "no-page-numbers", "tagged", "no-tagged", + "outline", "no-outline", "quiet", "verbose", "allow-network", +]); + +export function parseArgs(argv: string[]): ParsedArgs { const args = argv.slice(2); if (args.length === 0) { printUsage(); @@ -37,7 +50,7 @@ function parseArgs(argv: string[]): ParsedArgs { if (a.startsWith("--")) { const key = a.slice(2); const next = args[i + 1]; - if (next !== undefined && !next.startsWith("--")) { + if (!BOOLEAN_FLAGS.has(key) && next !== undefined && !next.startsWith("--")) { flags[key] = next; i++; } else { @@ -272,4 +285,7 @@ async function main(): Promise { } } -main(); +// Guarded so tests can import parseArgs/BOOLEAN_FLAGS without running the CLI. +if (import.meta.main) { + main(); +} diff --git a/make-pdf/test/cli-args.test.ts b/make-pdf/test/cli-args.test.ts new file mode 100644 index 0000000000..66fa69ed2d --- /dev/null +++ b/make-pdf/test/cli-args.test.ts @@ -0,0 +1,47 @@ +/** + * #2514: boolean flags (--toc, --cover, ...) must never swallow the next + * positional argument. The parser treated ANY following non-flag token as a + * flag value, so `$P generate --toc essay.md` ate essay.md as --toc's value + * and the skill's own documented invocations failed with "missing input". + */ + +import { describe, test, expect } from "bun:test"; +import { parseArgs, BOOLEAN_FLAGS } from "../src/cli"; + +// parseArgs slices argv from index 2 (node/bun + script path). +const parse = (...args: string[]) => parseArgs(["bun", "cli.ts", ...args]); + +describe("#2514 boolean flags do not swallow positionals", () => { + test("--toc before the input keeps the input positional", () => { + const r = parse("generate", "--toc", "essay.md"); + expect(r.command).toBe("generate"); + expect(r.flags.toc).toBe(true); + expect(r.positional).toEqual(["essay.md"]); + }); + + test("the skill's documented usage parses: --cover --toc essay.md essay.pdf", () => { + const r = parse("generate", "--cover", "--toc", "essay.md", "essay.pdf"); + expect(r.flags.cover).toBe(true); + expect(r.flags.toc).toBe(true); + expect(r.positional).toEqual(["essay.md", "essay.pdf"]); + }); + + test("value flags still consume their value", () => { + const r = parse("generate", "--watermark", "DRAFT", "memo.md"); + expect(r.flags.watermark).toBe("DRAFT"); + expect(r.positional).toEqual(["memo.md"]); + }); + + test("--to consumes its format value", () => { + const r = parse("generate", "--to", "html", "doc.md"); + expect(r.flags.to).toBe("html"); + expect(r.positional).toEqual(["doc.md"]); + }); + + test("every registered boolean flag is covered by the set", () => { + // The generate command's no-value flags per commands.ts + usage text. + for (const f of ["cover", "toc", "no-chapter-breaks", "quiet", "verbose", "allow-network"]) { + expect(BOOLEAN_FLAGS.has(f)).toBe(true); + } + }); +}); From 2c07418ee14825773a9ad7eee5048c98cbb5ceec Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 09:20:14 -0700 Subject: [PATCH 019/126] fix(repo-mode): probe GNU stat before BSD so Git Bash stops crashing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2195. On GNU coreutils `stat -f` SUCCEEDS (filesystem status, not a format string), so the BSD-first fallback chain never fell over — it fed multi-word filesystem output into the cache-age arithmetic and crashed under set -u on Windows Git Bash. GNU `stat -c` fails cleanly on BSD/macOS, making GNU-first deterministic on both; the mtime is numeric-validated before arithmetic as a last line of defense. Co-Authored-By: Claude Fable 5 --- bin/gstack-repo-mode | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bin/gstack-repo-mode b/bin/gstack-repo-mode index 0aabe378e7..a5c5a6ba64 100755 --- a/bin/gstack-repo-mode +++ b/bin/gstack-repo-mode @@ -44,7 +44,15 @@ fi CACHE_DIR="$HOME/.gstack/projects/$SLUG" CACHE_FILE="$CACHE_DIR/repo-mode.json" if [ -f "$CACHE_FILE" ]; then - CACHE_AGE=$(( $(date +%s) - $(stat -f %m "$CACHE_FILE" 2>/dev/null || stat -c %Y "$CACHE_FILE" 2>/dev/null || echo 0) )) + # GNU first (#2195): on GNU coreutils `stat -f` SUCCEEDS with filesystem + # status (not a format string), so the BSD-first fallback chain never fell + # over — it fed multi-word filesystem output into the arithmetic below and + # crashed under set -u on Windows Git Bash. `stat -c` fails cleanly on + # BSD/macOS, making GNU-first the deterministic order. Numeric-validate + # before arithmetic as the last line of defense. + CACHE_MTIME=$(stat -c %Y "$CACHE_FILE" 2>/dev/null || stat -f %m "$CACHE_FILE" 2>/dev/null || echo 0) + case "$CACHE_MTIME" in ''|*[!0-9]*) CACHE_MTIME=0 ;; esac + CACHE_AGE=$(( $(date +%s) - CACHE_MTIME )) if [ "$CACHE_AGE" -lt 604800 ]; then # 7 days in seconds MODE=$(grep -o '"mode":"[^"]*"' "$CACHE_FILE" | head -1 | cut -d'"' -f4) [ -n "$MODE" ] && echo "REPO_MODE=$(validate_mode "$MODE")" && exit 0 From bab192391fcd908885d35df82fb352caacd3ae1a Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 09:21:29 -0700 Subject: [PATCH 020/126] fix(retro): point the prior-retros context query at files /retro actually writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2552's live half. The gbrain context-query glob targeted ~/.gstack/projects//retros/*.md — a directory and extension nothing writes — so prior-retro recall was dead on every brain-aware run. /retro saves to .context/retros/*.json (repo-local); the query now reads that. The issue's second defect (quoted-tilde orphan sweep) is already fixed on main — the preamble sweeps with "$HOME/..." — verified, no change needed. Co-Authored-By: Claude Fable 5 --- retro/SKILL.md | 5 ++++- retro/SKILL.md.tmpl | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/retro/SKILL.md b/retro/SKILL.md index 5e4d58a1cc..f6fd85d122 100644 --- a/retro/SKILL.md +++ b/retro/SKILL.md @@ -18,7 +18,10 @@ gbrain: context_queries: - id: prior-retros kind: filesystem - glob: "~/.gstack/projects/{repo_slug}/retros/*.md" + # #2552: /retro writes .context/retros/*.json (repo-local; see the save + # step below) — the old ~/.gstack/.../retros/*.md glob matched a + # directory and extension nothing ever writes, so this query was dead. + glob: ".context/retros/*.json" sort: mtime_desc limit: 5 render_as: "## Prior retros for this project" diff --git a/retro/SKILL.md.tmpl b/retro/SKILL.md.tmpl index b60e0c3f38..338feb5315 100644 --- a/retro/SKILL.md.tmpl +++ b/retro/SKILL.md.tmpl @@ -23,7 +23,10 @@ gbrain: context_queries: - id: prior-retros kind: filesystem - glob: "~/.gstack/projects/{repo_slug}/retros/*.md" + # #2552: /retro writes .context/retros/*.json (repo-local; see the save + # step below) — the old ~/.gstack/.../retros/*.md glob matched a + # directory and extension nothing ever writes, so this query was dead. + glob: ".context/retros/*.json" sort: mtime_desc limit: 5 render_as: "## Prior retros for this project" From 0f70ea827c94df61ff0845bbdcb360a9f228f9dd Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 09:22:12 -0700 Subject: [PATCH 021/126] fix(sync-gbrain): remove the capability-check page file left in the user's repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2503. On worktree-pinned brains `gbrain put` materializes the checked page as _capability_check_.md in the current directory (the user's repo), and `gbrain delete` removes the page but not the file — every /sync-gbrain run left a stray file in the repo root. The check now deletes the materialized file explicitly after the page delete. Co-Authored-By: Claude Fable 5 --- sync-gbrain/SKILL.md | 4 ++++ sync-gbrain/SKILL.md.tmpl | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/sync-gbrain/SKILL.md b/sync-gbrain/SKILL.md index 45dc07ce8c..48f003b223 100644 --- a/sync-gbrain/SKILL.md +++ b/sync-gbrain/SKILL.md @@ -1104,6 +1104,10 @@ if [ -f ~/.gbrain/config.json ] && \ fi fi gbrain delete "$SLUG" 2>/dev/null || true +# #2503: on worktree-pinned brains `gbrain put` can materialize the page as +# .md in the CURRENT directory (the user's repo), and `gbrain delete` +# removes the page, not the file. Remove the litter explicitly. +rm -f "./${SLUG}.md" 2>/dev/null || true ``` Then update CLAUDE.md based on capability state: diff --git a/sync-gbrain/SKILL.md.tmpl b/sync-gbrain/SKILL.md.tmpl index fa8becfd50..11471deb11 100644 --- a/sync-gbrain/SKILL.md.tmpl +++ b/sync-gbrain/SKILL.md.tmpl @@ -336,6 +336,10 @@ if [ -f ~/.gbrain/config.json ] && \ fi fi gbrain delete "$SLUG" 2>/dev/null || true +# #2503: on worktree-pinned brains `gbrain put` can materialize the page as +# .md in the CURRENT directory (the user's repo), and `gbrain delete` +# removes the page, not the file. Remove the litter explicitly. +rm -f "./${SLUG}.md" 2>/dev/null || true ``` Then update CLAUDE.md based on capability state: From 9cd1e875ea70e7bd25342b519b4c69fa1af66e85 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 09:23:31 -0700 Subject: [PATCH 022/126] docs(browse): warn that hover scrolls and the daemon tab persists across sessions Fixes #2445. Both behaviors are by design but produced confidently wrong verification output: hovering a below-the-fold element scrolls the page before a "rest state" screenshot (exit 0, wrong section), and the daemon's tab survives sessions so a bare `reload` can act on whatever earlier work left open. The screenshot-evidence section now names both traps with the concrete guards (assert window.scrollY; always goto before verifying). Co-Authored-By: Claude Fable 5 --- browse/SKILL.md | 12 ++++++++++++ browse/SKILL.md.tmpl | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/browse/SKILL.md b/browse/SKILL.md index 5044f51790..5e06c5b39e 100644 --- a/browse/SKILL.md +++ b/browse/SKILL.md @@ -619,6 +619,18 @@ $B screenshot /tmp/bug.png # plain screenshot $B console # error log ``` +Two behaviors that silently invalidate screenshots (#2445 — designed, but +surprising): +- **`hover` scrolls its target into view.** Hovering anything below the fold + scrolls the page first, so a "rest state" shot taken afterwards captures + the wrong section with exit 0. Before a rest-state screenshot, hover only + something already visible, and assert position when it matters: + `$B js "window.scrollY"` should be `0` (or your intended offset). +- **The tab persists across sessions.** The daemon keeps its tab between your + sessions, so `reload` or `screenshot` without a preceding `goto` can act on + whatever page earlier work left open. Start verification passes with an + explicit `$B goto `, never a bare `reload`. + ### 5. Find all clickable elements (including non-ARIA) ```bash $B snapshot -C # finds divs with cursor:pointer, onclick, tabindex diff --git a/browse/SKILL.md.tmpl b/browse/SKILL.md.tmpl index 1da7698b2f..81a91775e1 100644 --- a/browse/SKILL.md.tmpl +++ b/browse/SKILL.md.tmpl @@ -65,6 +65,18 @@ $B screenshot /tmp/bug.png # plain screenshot $B console # error log ``` +Two behaviors that silently invalidate screenshots (#2445 — designed, but +surprising): +- **`hover` scrolls its target into view.** Hovering anything below the fold + scrolls the page first, so a "rest state" shot taken afterwards captures + the wrong section with exit 0. Before a rest-state screenshot, hover only + something already visible, and assert position when it matters: + `$B js "window.scrollY"` should be `0` (or your intended offset). +- **The tab persists across sessions.** The daemon keeps its tab between your + sessions, so `reload` or `screenshot` without a preceding `goto` can act on + whatever page earlier work left open. Start verification passes with an + explicit `$B goto `, never a bare `reload`. + ### 5. Find all clickable elements (including non-ARIA) ```bash $B snapshot -C # finds divs with cursor:pointer, onclick, tabindex From 1a1dab2c5a3a16f450664aa1299beafbd2caca34 Mon Sep 17 00:00:00 2001 From: Mike Laniak Date: Tue, 11 Aug 2026 17:40:15 -0500 Subject: [PATCH 023/126] fix(gitattributes): pin *.txt to LF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .gitattributes pins LF for every other text format in the repo (*.md, *.tmpl, *.yml, *.yaml, *.json, *.toml, *.sh, *.ts, extensionless scripts, even the hash-pinned diagram-render dist files). *.txt is the one text format left unpinned. On Windows with core.autocrlf=true, that means the two tracked .txt files are rewritten to CRLF at checkout and then read as permanently modified: gstack/llms.txt +174 bytes make-pdf/test/fixtures/combined-gate.expected.txt +20 bytes git status is never clean, and /gstack-upgrade's 'git stash' step saves a phantom stash on every upgrade — one that pops back to an empty diff. Co-Authored-By: Claude Opus 5 --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index e67042f0ab..10a4ff7fd1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,6 +8,7 @@ *.yaml text eol=lf *.json text eol=lf *.toml text eol=lf +*.txt text eol=lf # Bash scripts must always use LF — CRLF in bash scripts produces bizarre # "Bad interpreter" / "command not found" errors on Linux runners. From 0f38feee78748e45bc80289be690f6b843fe3487 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 09:00:34 -0700 Subject: [PATCH 024/126] fix(setup): install every skill runtime asset for the Claude host On a fresh Claude install, link_claude_skill_dirs installed only SKILL.md (+ sections/) per skill. Every skill that reads a sibling runtime file at .claude/skills// was broken out of the box: /review stopped at 'Read .claude/skills/review/checklist.md' (file never installed), and qa's templates/references, plan-devex-review's dx-hall-of-fame.md, gstack-upgrade's migrations/, and careful/freeze's bin/ hooks were all silently missing. Codex/Factory/OpenCode/Kiro installers already copied these; the primary host never did. Fix: a shared _link_skill_runtime_assets helper installs EVERYTHING a skill ships next to its SKILL.md, with an explicit exclusion list (F7): node_modules, dist, test, *.tmpl, hidden files. Exclusion-list polarity means a newly added asset installs by default instead of being silently dropped. Assets refresh unconditionally on re-run (rm + relink/copy), so Windows real-dir copies pick up changes after git pull. New free test runs the real installer functions against the live repo into a temp skills dir with a TWO-CLASS referenced-paths assertion (ENG-OV7): alias-relative refs (.claude/skills//) must exist under the install; repo-anchored refs (~/.claude/skills/gstack/) must exist in the tree modulo an explicit built-artifact allowlist (browse/design/ make-pdf dist + the compiled gstack-global-discover). Known-broken class-2 refs (#2250 bare bin names) are ratcheted: the test fails if they quietly start existing without the entry being removed. Fixes #2317 Fixes #2454 Co-Authored-By: Claude Fable 5 --- setup | 51 +++++- test/setup-claude-skill-assets.test.ts | 229 +++++++++++++++++++++++++ 2 files changed, 272 insertions(+), 8 deletions(-) create mode 100644 test/setup-claude-skill-assets.test.ts diff --git a/setup b/setup index 51be8f13ad..ab241bc7fc 100755 --- a/setup +++ b/setup @@ -694,6 +694,41 @@ fi # 3. Ensure ~/.gstack global state directory exists mkdir -p "$HOME/.gstack/projects" +# ─── Helper: link a skill's runtime assets into its installed dir ──────────── +# Installs EVERY runtime asset a skill ships next to its SKILL.md (#2317, +# #2454): review/checklist.md + specialists/, qa/templates + references, +# gstack-upgrade/migrations, careful/bin, freeze/bin, sections/, etc. +# Exclusion list rather than inclusion list (F7) so a new asset file is +# installed by default instead of silently dropped: +# - SKILL.md linked separately by the caller (name-aware) +# - node_modules dependency trees, never a runtime read +# - dist compiled binaries; skills reference them repo-anchored +# (~/.claude/skills/gstack/browse/dist/...), never +# alias-relative, and fresh clones haven't built them +# - test test fixtures +# - *.tmpl generator sources; the generated file is the asset +# - hidden files excluded by the glob (no dotglob) +# Shared so any flattened-skill installer can reuse it (the Claude path is +# the first consumer; codex/factory/opencode install from generated trees). +_link_skill_runtime_assets() { + local src_dir="$1" + local dst_dir="$2" + local asset asset_name + for asset in "$src_dir"/*; do + [ -e "$asset" ] || continue # empty-glob guard + asset_name="$(basename "$asset")" + case "$asset_name" in + SKILL.md|node_modules|dist|test|*.tmpl) continue ;; + esac + # Refresh unconditionally: rm the old entry (symlink OR real copy — the + # Windows install pattern) so re-runs after `git pull` pick up changes. + if [ -e "$dst_dir/$asset_name" ] || [ -L "$dst_dir/$asset_name" ]; then + rm -rf "$dst_dir/$asset_name" + fi + _link_or_copy "$asset" "$dst_dir/$asset_name" + done +} + # ─── Helper: link Claude skill subdirectories into a skills parent directory ── # Creates real directories (not symlinks) at the top level with a SKILL.md symlink # inside. This ensures Claude discovers them as top-level skills, not nested under @@ -732,14 +767,14 @@ link_claude_skill_dirs() { # Validate target isn't a symlink before creating the link if [ -L "$target/SKILL.md" ]; then rm "$target/SKILL.md"; fi _link_or_copy "$gstack_dir/$dir_name/SKILL.md" "$target/SKILL.md" - # Link the sections/ subdir for carved skills (v2 plan T9). The prefixed - # Claude skill dir otherwise holds only SKILL.md, so a runtime - # "Read sections/.md" 404s. Route through _link_or_copy so Windows - # gets a fresh copy (and re-copies on every ./setup, refreshing staleness). - if [ -d "$gstack_dir/$dir_name/sections" ]; then - if [ -e "$target/sections" ] || [ -L "$target/sections" ]; then rm -rf "$target/sections"; fi - _link_or_copy "$gstack_dir/$dir_name/sections" "$target/sections" - fi + # Link every runtime asset the skill ships next to its SKILL.md (#2317, + # #2454): sections/ for carved skills, review's checklist.md + + # specialists/, qa's templates/ + references/, gstack-upgrade's + # migrations/, careful/freeze's bin/, ... Without this, only SKILL.md + # landed and /review 404'd at "Read .claude/skills/review/checklist.md" + # on every fresh Claude install. Routes through _link_or_copy so Windows + # gets real copies refreshed on every ./setup. + _link_skill_runtime_assets "$gstack_dir/$dir_name" "$target" linked+=("$link_name") fi done diff --git a/test/setup-claude-skill-assets.test.ts b/test/setup-claude-skill-assets.test.ts new file mode 100644 index 0000000000..6e811bdca3 --- /dev/null +++ b/test/setup-claude-skill-assets.test.ts @@ -0,0 +1,229 @@ +/** + * Claude installer runtime-asset coverage (#2317 / #2454). + * + * `link_claude_skill_dirs` historically installed only SKILL.md (+ sections/) + * per skill, so every skill that reads a sibling runtime file at + * `.claude/skills//` — review's checklist.md + specialists/, qa's + * templates/ + references/, gstack-upgrade's migrations/, careful/freeze's + * bin/ — was broken on a fresh Claude install. This suite runs the REAL + * installer functions (extracted from `setup`) against the live repo into a + * temp skills dir and asserts the install is complete. + * + * Two-class referenced-paths assertion (eng review ENG-OV7): + * - Class 1 (alias-relative): a `.claude/skills//` reference + * in an INSTALLED SKILL.md must resolve under the install dir. These are + * runtime reads against the flattened alias — a miss is a broken skill. + * - Class 2 (repo-anchored): a `~/.claude/skills/gstack/` + * reference must exist in the source tree, EXCEPT built artifacts + * (browse/dist, design/dist, make-pdf/dist, the compiled + * bin/gstack-global-discover) — the free suite never builds binaries, so + * a naive "every path exists" either false-fails on dist or gets watered + * down to uselessness. + */ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8'); + +/** Built-at-setup artifacts: allowed to be absent from a fresh clone. */ +const BUILT_ARTIFACT_ALLOWLIST = [ + 'browse/dist/', + 'design/dist/', + 'make-pdf/dist/', + 'bin/gstack-global-discover', // compiled from bin/gstack-global-discover.ts at build time +]; + +/** + * Repo-anchored references that are KNOWN BROKEN on the current tree. + * Each entry must name the fix that removes it. An empty list is the goal — + * do not add entries without an issue + a scheduled fix. + */ +const KNOWN_BROKEN_CLASS2: Record = { + // #2250: setup-gbrain's docs call both scripts by bare name; only the .ts + // files exist. Fixed by the wave's c24 (PR #2409 re-derive) — remove these + // entries in that commit. + 'bin/gstack-memory-ingest': '#2250 — fixed by setup-gbrain .ts invocation-path commit', + 'bin/gstack-gbrain-sync': '#2250 — fixed by setup-gbrain .ts invocation-path commit', +}; + +/** Extract a named shell function body (through its closing brace) from setup. */ +function extractFn(name: string): string { + const start = SETUP_SRC.indexOf(`${name}() {`); + const end = SETUP_SRC.indexOf('\n}\n', start); + if (start < 0 || end < 0) throw new Error(`Could not locate ${name}() in setup`); + return SETUP_SRC.slice(start, end + 2); +} + +const installDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-claude-install-')); + +beforeAll(() => { + const script = [ + 'set -e', + 'IS_WINDOWS=0', + 'SKILL_PREFIX=0', + 'QUIET=1', + '_WINDOWS_COPY_NOTE_PRINTED=1', + extractFn('_link_or_copy'), + extractFn('_print_windows_copy_note_once'), + extractFn('_link_skill_runtime_assets'), + extractFn('link_claude_skill_dirs'), + `link_claude_skill_dirs "${ROOT}" "${installDir}"`, + ].join('\n'); + const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 60_000 }); + if (result.status !== 0) { + throw new Error(`installer functions failed: ${result.stderr}\n${result.stdout}`); + } +}); + +afterAll(() => { + // rmSync does not follow symlinks — the repo sources the links point at survive. + fs.rmSync(installDir, { recursive: true, force: true }); +}); + +function installedSkillDirs(): string[] { + return fs + .readdirSync(installDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .filter((name) => fs.existsSync(path.join(installDir, name, 'SKILL.md'))); +} + +describe('link_claude_skill_dirs installs every runtime asset (#2317, #2454)', () => { + test('review skill ships its full runtime asset set', () => { + const review = path.join(installDir, 'review'); + for (const asset of [ + 'checklist.md', + 'design-checklist.md', + 'greptile-triage.md', + 'TODOS-format.md', + 'specialists', + ]) { + expect(fs.existsSync(path.join(review, asset))).toBe(true); + } + // specialists/ resolves to real content, not an empty shell + const specialists = fs.readdirSync(path.join(review, 'specialists')); + expect(specialists.length).toBeGreaterThan(0); + expect(specialists).toContain('testing.md'); + }); + + test('the #2454 affected-skills table is fully installed', () => { + const expected: Array<[string, string]> = [ + ['qa', 'references'], + ['qa', 'templates'], + ['plan-devex-review', 'dx-hall-of-fame.md'], + ['gstack-upgrade', 'migrations'], + ['careful', 'bin'], + ['freeze', 'bin'], + ]; + for (const [skill, asset] of expected) { + expect(fs.existsSync(path.join(installDir, skill, asset))).toBe(true); + } + }); + + test('sections/ still installs for carved skills', () => { + expect(fs.existsSync(path.join(installDir, 'ship', 'sections'))).toBe(true); + expect( + fs.readdirSync(path.join(installDir, 'ship', 'sections')).length, + ).toBeGreaterThan(0); + }); + + test('exclusion list holds: no node_modules, dist, test, or .tmpl installed', () => { + for (const skill of installedSkillDirs()) { + const entries = fs.readdirSync(path.join(installDir, skill)); + expect(entries).not.toContain('node_modules'); + expect(entries).not.toContain('dist'); + expect(entries).not.toContain('test'); + const tmpl = entries.filter((e) => e.endsWith('.tmpl')); + expect(tmpl).toEqual([]); + } + }); + + test('hidden files are not installed', () => { + for (const skill of installedSkillDirs()) { + const hidden = fs + .readdirSync(path.join(installDir, skill)) + .filter((e) => e.startsWith('.')); + expect(hidden).toEqual([]); + } + }); +}); + +// --------------------------------------------------------------------------- +// Two-class referenced-paths assertion (ENG-OV7) +// --------------------------------------------------------------------------- + +interface Ref { + fromSkill: string; + skillName: string; + rel: string; +} + +const REF_RE = /~?\.claude\/skills\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_.\/-]+)/g; + +/** Placeholder-ish captures (globs, template vars, examples) are prose, not paths. */ +function isConcretePath(raw: string): boolean { + return !/[<>*$(){}|]/.test(raw) && !raw.includes('..'); +} + +function collectRefs(): Ref[] { + const refs: Ref[] = []; + for (const skill of installedSkillDirs()) { + const content = fs.readFileSync(path.join(installDir, skill, 'SKILL.md'), 'utf-8'); + for (const m of content.matchAll(REF_RE)) { + const rel = m[2].replace(/[.,:;/]+$/, ''); + if (!rel || !isConcretePath(rel)) continue; + refs.push({ fromSkill: skill, skillName: m[1], rel }); + } + } + return refs; +} + +describe('two-class referenced-paths (ENG-OV7)', () => { + test('class 1: alias-relative references resolve under the install dir', () => { + const missing: string[] = []; + for (const { fromSkill, skillName, rel } of collectRefs()) { + if (skillName === 'gstack') continue; // class 2 + // Prefix-mode prose may reference gstack-; the flat install dir + // is the unprefixed name. + const candidates = [skillName, skillName.replace(/^gstack-/, '')]; + const found = candidates.some((c) => fs.existsSync(path.join(installDir, c, rel))); + if (!found) missing.push(`${fromSkill}/SKILL.md → .claude/skills/${skillName}/${rel}`); + } + expect(missing).toEqual([]); + }); + + test('class 2: repo-anchored references exist in the tree (modulo built artifacts)', () => { + const missing: string[] = []; + for (const { fromSkill, skillName, rel } of collectRefs()) { + if (skillName !== 'gstack') continue; // class 1 + if (rel.startsWith('.')) continue; // runtime state markers (.feature-prompted-*, .git) + if (BUILT_ARTIFACT_ALLOWLIST.some((a) => rel === a || rel.startsWith(a))) continue; + if (KNOWN_BROKEN_CLASS2[rel]) continue; + if (!fs.existsSync(path.join(ROOT, rel))) { + missing.push(`${fromSkill}/SKILL.md → ~/.claude/skills/gstack/${rel}`); + } + } + expect(missing).toEqual([]); + }); + + test('the referenced-path scan actually sees the review checklist refs (self-check)', () => { + // Guard against the extraction regex silently rotting: the review skill is + // KNOWN to carry alias-relative refs; if the scanner stops seeing them the + // class-1 assertion is vacuous. + const class1 = collectRefs().filter((r) => r.skillName !== 'gstack'); + expect(class1.length).toBeGreaterThan(0); + expect(class1.some((r) => r.skillName === 'review' && r.rel === 'checklist.md')).toBe(true); + }); + + test('KNOWN_BROKEN_CLASS2 entries are still actually broken (ratchet)', () => { + // When a fix lands, its entry MUST be removed so the class-2 assertion + // guards the path again. + for (const rel of Object.keys(KNOWN_BROKEN_CLASS2)) { + expect(fs.existsSync(path.join(ROOT, rel))).toBe(false); + } + }); +}); From 663aca3b0519c30bedaa7e937f9b58a3a74e5c6b Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 09:05:52 -0700 Subject: [PATCH 025/126] fix(setup): alias skills install as rewritten copies, never symlinks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two back-compat alias dirs — _gstack-command (root router) and connect-chrome (→ open-gstack-browser) — symlinked the canonical SKILL.md verbatim, so each alias re-served the canonical frontmatter name:. Claude Code keys skills on that name and requires global uniqueness: the connect-chrome duplicate silently shadowed /open-gstack-browser (whichever readdir returned first won), and the _gstack-command duplicate could drop the ENTIRE personal-skills set — every /gstack command vanished until the user hand-deleted the alias dirs, and the next setup re-broke it. Fix: copy-then-rewrite. A shared _install_alias_skill_md helper reads the SOURCE SKILL.md and writes a fresh copy with name: rewritten to the alias dir's own name (_gstack-command / connect-chrome / gstack-connect-chrome). sed never edits in place: on Unix the old install was a symlink into the repo, and an in-place rewrite through it would have corrupted the generated source (eng review E2). bin/gstack-relink gets the same treatment for its root-alias helper, and its discovery loop now skips symlinked source dirs so the connect-chrome repo symlink can't re-mint the duplicate. Tests assert: installed aliases are NOT symlinks, carry their own unique names, all installed frontmatter names are globally unique, re-runs refresh cleanly, legacy symlinked aliases are replaced not written through, and the source files stay byte-intact. Fixes #2511 Fixes #2201 Co-Authored-By: Claude Fable 5 --- bin/gstack-relink | 13 +- setup | 53 +++++--- test/gen-skill-docs.test.ts | 5 +- test/relink.test.ts | 46 ++++++- test/setup-alias-name-uniqueness.test.ts | 164 +++++++++++++++++++++++ 5 files changed, 261 insertions(+), 20 deletions(-) create mode 100644 test/setup-alias-name-uniqueness.test.ts diff --git a/bin/gstack-relink b/bin/gstack-relink index dd2a681fa4..75d3337e2e 100755 --- a/bin/gstack-relink +++ b/bin/gstack-relink @@ -52,7 +52,13 @@ _link_root_skill_alias() { [ -f "$INSTALL_DIR/SKILL.md" ] || return 0 [ -L "$target" ] && rm -f "$target" mkdir -p "$target" - ln -snf "$INSTALL_DIR/SKILL.md" "$target/SKILL.md" + # Copy-then-rewrite, never a symlink (#2511): a symlinked alias re-serves + # the canonical `name: gstack`, Claude Code sees a duplicate skill name, + # and drops the ENTIRE personal-skills set. sed reads the source and writes + # a fresh copy — remove any prior symlink first so the redirect can never + # write through it into the generated source. + rm -f "$target/SKILL.md" + sed "1,/^---\$/ s/^name:[[:space:]].*/name: _gstack-command/" "$INSTALL_DIR/SKILL.md" > "$target/SKILL.md" } _link_root_skill_alias @@ -61,6 +67,11 @@ _link_root_skill_alias SKILL_COUNT=0 for skill_dir in "$INSTALL_DIR"/*/; do [ -d "$skill_dir" ] || continue + # Skip symlinked skill dirs (connect-chrome → open-gstack-browser): linking + # one under the symlink's basename would duplicate the canonical frontmatter + # name and collide in Claude Code's skill registry (#2201). setup owns the + # rewritten-copy alias for those. + [ -L "${skill_dir%/}" ] && continue skill=$(basename "$skill_dir") # Skip non-skill directories case "$skill" in bin|browse|design|docs|extension|lib|node_modules|scripts|test|.git|.github) continue ;; esac diff --git a/setup b/setup index ab241bc7fc..def88155ae 100755 --- a/setup +++ b/setup @@ -784,24 +784,40 @@ link_claude_skill_dirs() { fi } +# ─── Helper: install an alias SKILL.md as a rewritten COPY ─────────────────── +# Alias dirs (_gstack-command, connect-chrome) must NOT symlink the canonical +# SKILL.md: the alias then carries the canonical frontmatter name:, Claude Code +# sees two skills with the same name, and drops the ENTIRE personal-skills set +# (#2511, #2201). Copy-then-rewrite instead: sed reads the SOURCE and writes a +# fresh copy with name: set to the alias. It must never edit through an +# existing symlink — that would rewrite the generated source file itself. +_install_alias_skill_md() { + local src_skill_md="$1" + local dst_dir="$2" + local alias_name="$3" + [ -f "$src_skill_md" ] || return 0 + # Old installs left the alias as a whole-dir symlink — replace it. + if [ -L "$dst_dir" ]; then rm -f "$dst_dir"; fi + mkdir -p "$dst_dir" + # Remove any prior symlinked SKILL.md so the redirect below cannot write + # through it into the generated source. + rm -f "$dst_dir/SKILL.md" + sed "1,/^---\$/ s/^name:[[:space:]].*/name: $alias_name/" "$src_skill_md" > "$dst_dir/SKILL.md" +} + # Claude Code skips the repo-shaped ~/.claude/skills/gstack directory when # building the user-facing slash-command list. Keep the repo path for runtime -# assets, and add a separate thin wrapper whose frontmatter name remains -# `gstack` so `/gstack` can autocomplete. +# assets, and add a separate thin wrapper. Its frontmatter name is rewritten to +# `_gstack-command` (the dir name) so it never collides with the canonical +# `gstack` name (#2511). link_claude_root_skill_alias() { local gstack_dir="$1" local skills_dir="$2" local target="$skills_dir/_gstack-command" [ -f "$gstack_dir/SKILL.md" ] || return 0 - if [ -L "$target" ]; then - rm -f "$target" - fi - mkdir -p "$target" - if [ -L "$target/SKILL.md" ]; then rm "$target/SKILL.md"; fi - _link_or_copy "$gstack_dir/SKILL.md" "$target/SKILL.md" + _install_alias_skill_md "$gstack_dir/SKILL.md" "$target" "_gstack-command" echo " linked root skill alias: gstack" - _print_windows_copy_note_once } # ─── Helper: remove old unprefixed Claude skill entries ─────────────────────── @@ -1231,13 +1247,16 @@ if [ "$INSTALL_CLAUDE" -eq 1 ]; then GSTACK_SKILLS_DIR="$INSTALL_SKILLS_DIR" GSTACK_INSTALL_DIR="$SOURCE_GSTACK_DIR" "$GSTACK_RELINK" >/dev/null 2>&1 || true fi # Backwards-compat alias: /connect-chrome → /open-gstack-browser + # Rewritten copy, not a symlink: a symlinked alias re-serves the canonical + # name: open-gstack-browser, so one of the two silently shadows the other + # (#2201) — and duplicate names can drop the whole skill set (#2511). _OGB_LINK="$INSTALL_SKILLS_DIR/connect-chrome" + _OGB_ALIAS_NAME="connect-chrome" if [ "$SKILL_PREFIX" -eq 1 ]; then _OGB_LINK="$INSTALL_SKILLS_DIR/gstack-connect-chrome" + _OGB_ALIAS_NAME="gstack-connect-chrome" fi - if [ -L "$_OGB_LINK" ] || [ ! -e "$_OGB_LINK" ]; then - _link_or_copy "gstack/open-gstack-browser" "$_OGB_LINK" - fi + _install_alias_skill_md "$SOURCE_GSTACK_DIR/open-gstack-browser/SKILL.md" "$_OGB_LINK" "$_OGB_ALIAS_NAME" if [ "$LOCAL_INSTALL" -eq 1 ]; then log "gstack ready (project-local)." log " skills: $INSTALL_SKILLS_DIR" @@ -1299,13 +1318,17 @@ if [ "$INSTALL_CLAUDE" -eq 1 ]; then if [ -x "$GSTACK_RELINK" ]; then GSTACK_SKILLS_DIR="$INSTALL_SKILLS_DIR" GSTACK_INSTALL_DIR="$SOURCE_GSTACK_DIR" "$GSTACK_RELINK" >/dev/null 2>&1 || true fi + # Rewritten copy, not a symlink: a symlinked alias re-serves the + # canonical name: open-gstack-browser, so one of the two silently + # shadows the other (#2201) — and duplicate names can drop the whole + # skill set (#2511). _OGB_LINK="$INSTALL_SKILLS_DIR/connect-chrome" + _OGB_ALIAS_NAME="connect-chrome" if [ "$SKILL_PREFIX" -eq 1 ]; then _OGB_LINK="$INSTALL_SKILLS_DIR/gstack-connect-chrome" + _OGB_ALIAS_NAME="gstack-connect-chrome" fi - if [ -L "$_OGB_LINK" ] || [ ! -e "$_OGB_LINK" ]; then - _link_or_copy "gstack/open-gstack-browser" "$_OGB_LINK" - fi + _install_alias_skill_md "$SOURCE_GSTACK_DIR/open-gstack-browser/SKILL.md" "$_OGB_LINK" "$_OGB_ALIAS_NAME" log "gstack ready (claude)." log " browse: $BROWSE_BIN" fi diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 1c9bf1fb46..e75571e6fb 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -2435,7 +2435,10 @@ describe('setup script validation', () => { const fnEnd = setupContent.indexOf('# ─── Helper: remove old unprefixed Claude skill entries', fnStart); const fnBody = setupContent.slice(fnStart, fnEnd); expect(fnBody).toContain('_gstack-command'); - expect(fnBody).toContain('_link_or_copy "$gstack_dir/SKILL.md" "$target/SKILL.md"'); + // #2511: the alias must be a rewritten COPY (unique frontmatter name), + // never a verbatim symlink of the canonical SKILL.md. + expect(fnBody).toContain('_install_alias_skill_md "$gstack_dir/SKILL.md" "$target" "_gstack-command"'); + expect(fnBody).not.toContain('_link_or_copy "$gstack_dir/SKILL.md"'); const claudeSection = setupContent.slice( setupContent.indexOf('# 4. Install for Claude'), diff --git a/test/relink.test.ts b/test/relink.test.ts index 5e7ec809c5..5af335c50a 100644 --- a/test/relink.test.ts +++ b/test/relink.test.ts @@ -215,9 +215,15 @@ describe('gstack-relink (#578)', () => { const aliasSkill = path.join(aliasDir, 'SKILL.md'); expect(fs.lstatSync(aliasDir).isDirectory()).toBe(true); expect(fs.lstatSync(aliasDir).isSymbolicLink()).toBe(false); - expect(fs.lstatSync(aliasSkill).isSymbolicLink()).toBe(true); - expect(fs.readlinkSync(aliasSkill)).toBe(path.join(installDir, 'SKILL.md')); - expect(fs.readFileSync(aliasSkill, 'utf-8')).toContain('name: gstack'); + // #2511: the alias is a rewritten COPY, never a symlink. A symlinked + // alias re-serves the canonical `name: gstack`; Claude Code refuses + // duplicate skill names and drops the entire personal-skills set. + expect(fs.lstatSync(aliasSkill).isSymbolicLink()).toBe(false); + const aliasContent = fs.readFileSync(aliasSkill, 'utf-8'); + expect(aliasContent).toContain('name: _gstack-command'); + expect(aliasContent).not.toContain('name: gstack\n'); + // The rewrite happened on the COPY: the canonical source keeps its name. + expect(fs.readFileSync(path.join(installDir, 'SKILL.md'), 'utf-8')).toContain('name: gstack'); run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, { GSTACK_INSTALL_DIR: installDir, @@ -226,6 +232,40 @@ describe('gstack-relink (#578)', () => { expect(fs.existsSync(aliasSkill)).toBe(true); }); + // #2201: connect-chrome ships as a dir SYMLINK to open-gstack-browser. The + // discovery loop used to link it under its own basename while its SKILL.md + // carried `name: open-gstack-browser` — a duplicate name that silently + // shadows the real skill (readdir-order roulette). Symlinked source dirs + // must be skipped; setup owns the rewritten-copy alias. + test('symlinked skill dirs are skipped, so no duplicate frontmatter names (#2201)', () => { + setupMockInstall(['open-gstack-browser', 'qa']); + fs.symlinkSync( + path.join(installDir, 'open-gstack-browser'), + path.join(installDir, 'connect-chrome'), + ); + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + }); + run(`${path.join(installDir, 'bin', 'gstack-relink')}`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + }); + + expect(fs.existsSync(path.join(skillsDir, 'open-gstack-browser'))).toBe(true); + expect(fs.existsSync(path.join(skillsDir, 'connect-chrome'))).toBe(false); + + // No two installed SKILL.md files may share a frontmatter name. + const names: string[] = []; + for (const entry of fs.readdirSync(skillsDir)) { + const skillMd = path.join(skillsDir, entry, 'SKILL.md'); + if (!fs.existsSync(skillMd)) continue; + const m = fs.readFileSync(skillMd, 'utf-8').match(/^name:\s*(\S+)/m); + if (m) names.push(m[1]); + } + expect(new Set(names).size).toBe(names.length); + }); + // FIRST INSTALL: --no-prefix must create ONLY flat names, zero gstack-* pollution test('first install --no-prefix: only flat names exist, zero gstack-* entries', () => { setupMockInstall(['qa', 'ship', 'review', 'plan-ceo-review', 'gstack-upgrade']); diff --git a/test/setup-alias-name-uniqueness.test.ts b/test/setup-alias-name-uniqueness.test.ts new file mode 100644 index 0000000000..c05d1d6d69 --- /dev/null +++ b/test/setup-alias-name-uniqueness.test.ts @@ -0,0 +1,164 @@ +/** + * Alias name uniqueness (#2511 / #2201). + * + * setup installs two back-compat alias dirs — `_gstack-command` (root router) + * and `connect-chrome` (→ open-gstack-browser). Both used to symlink the + * canonical SKILL.md verbatim, so the alias carried the canonical frontmatter + * `name:`. Claude Code keys skills on that name and requires global + * uniqueness: the `connect-chrome` duplicate silently shadowed + * /open-gstack-browser (readdir-order roulette), and the `_gstack-command` + * duplicate could drop the ENTIRE personal-skills set. + * + * The fix is copy-then-rewrite: sed reads the SOURCE and writes a fresh copy + * with `name:` set to the alias dir's own name. Eng review E2 pinned the + * hazard this suite guards hardest: on Unix the old install path was a + * SYMLINK to the repo source, so an in-place sed through it would have + * corrupted the generated SKILL.md — the source files must stay byte-intact. + */ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8'); + +function extractFn(name: string): string { + const start = SETUP_SRC.indexOf(`${name}() {`); + const end = SETUP_SRC.indexOf('\n}\n', start); + if (start < 0 || end < 0) throw new Error(`Could not locate ${name}() in setup`); + return SETUP_SRC.slice(start, end + 2); +} + +const installDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-alias-install-')); + +const sourceRootSkill = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8'); +const sourceOgbSkill = fs.readFileSync( + path.join(ROOT, 'open-gstack-browser', 'SKILL.md'), + 'utf-8', +); + +beforeAll(() => { + const installOnce = [ + `link_claude_skill_dirs "${ROOT}" "${installDir}"`, + `link_claude_root_skill_alias "${ROOT}" "${installDir}"`, + // The connect-chrome back-compat alias, exactly as the install section does it. + `_install_alias_skill_md "${ROOT}/open-gstack-browser/SKILL.md" "${installDir}/connect-chrome" "connect-chrome"`, + ].join('\n'); + const script = [ + 'set -e', + 'IS_WINDOWS=0', + 'SKILL_PREFIX=0', + 'QUIET=1', + '_WINDOWS_COPY_NOTE_PRINTED=1', + extractFn('_link_or_copy'), + extractFn('_print_windows_copy_note_once'), + extractFn('_link_skill_runtime_assets'), + extractFn('link_claude_skill_dirs'), + extractFn('_install_alias_skill_md'), + extractFn('link_claude_root_skill_alias'), + // Run TWICE: the second pass proves re-runs refresh instead of corrupting + // (the historical failure mode was sed'ing through a symlink on re-run). + installOnce, + installOnce, + ].join('\n'); + const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 60_000 }); + if (result.status !== 0) { + throw new Error(`alias install failed: ${result.stderr}\n${result.stdout}`); + } +}, 30_000); + +afterAll(() => { + fs.rmSync(installDir, { recursive: true, force: true }); +}); + +function frontmatterName(skillMdPath: string): string | null { + const m = fs.readFileSync(skillMdPath, 'utf-8').match(/^name:\s*(\S+)/m); + return m ? m[1] : null; +} + +describe('alias installs are rewritten copies (#2511, #2201)', () => { + test('_gstack-command alias is NOT a symlink and carries its own name', () => { + const aliasDir = path.join(installDir, '_gstack-command'); + const aliasSkill = path.join(aliasDir, 'SKILL.md'); + expect(fs.lstatSync(aliasDir).isSymbolicLink()).toBe(false); + expect(fs.lstatSync(aliasSkill).isSymbolicLink()).toBe(false); + expect(frontmatterName(aliasSkill)).toBe('_gstack-command'); + }); + + test('connect-chrome alias is NOT a symlink and carries its own name', () => { + const aliasDir = path.join(installDir, 'connect-chrome'); + const aliasSkill = path.join(aliasDir, 'SKILL.md'); + expect(fs.lstatSync(aliasDir).isSymbolicLink()).toBe(false); + expect(fs.lstatSync(aliasSkill).isSymbolicLink()).toBe(false); + expect(frontmatterName(aliasSkill)).toBe('connect-chrome'); + }); + + test('alias body is the canonical content — only the name: line differs', () => { + const alias = fs.readFileSync( + path.join(installDir, '_gstack-command', 'SKILL.md'), + 'utf-8', + ); + expect(alias.replace(/^name:.*$/m, 'name: gstack')).toBe(sourceRootSkill); + + const ogbAlias = fs.readFileSync( + path.join(installDir, 'connect-chrome', 'SKILL.md'), + 'utf-8', + ); + expect(ogbAlias.replace(/^name:.*$/m, 'name: open-gstack-browser')).toBe(sourceOgbSkill); + }); + + test('the SOURCE files are byte-intact (E2: sed never wrote through a symlink)', () => { + expect(fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8')).toBe(sourceRootSkill); + expect( + fs.readFileSync(path.join(ROOT, 'open-gstack-browser', 'SKILL.md'), 'utf-8'), + ).toBe(sourceOgbSkill); + expect(frontmatterName(path.join(ROOT, 'SKILL.md'))).toBe('gstack'); + expect(frontmatterName(path.join(ROOT, 'open-gstack-browser', 'SKILL.md'))).toBe( + 'open-gstack-browser', + ); + }); + + test('every installed skill name is globally unique', () => { + const names: string[] = []; + for (const entry of fs.readdirSync(installDir)) { + const skillMd = path.join(installDir, entry, 'SKILL.md'); + if (!fs.existsSync(skillMd)) continue; + const name = frontmatterName(skillMd); + if (name) names.push(name); + } + expect(names.length).toBeGreaterThan(10); + const dupes = names.filter((n, i) => names.indexOf(n) !== i); + expect(dupes).toEqual([]); + }); + + test('a legacy symlinked alias is replaced, not written through', () => { + // Simulate a pre-fix install: alias SKILL.md is a symlink to the source. + const legacyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-alias-legacy-')); + try { + const aliasDir = path.join(legacyDir, '_gstack-command'); + fs.mkdirSync(aliasDir); + fs.symlinkSync(path.join(ROOT, 'SKILL.md'), path.join(aliasDir, 'SKILL.md')); + + const script = [ + 'set -e', + 'IS_WINDOWS=0', + extractFn('_link_or_copy'), + extractFn('_install_alias_skill_md'), + extractFn('link_claude_root_skill_alias'), + `link_claude_root_skill_alias "${ROOT}" "${legacyDir}"`, + ].join('\n'); + const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 }); + expect(result.status).toBe(0); + + const aliasSkill = path.join(aliasDir, 'SKILL.md'); + expect(fs.lstatSync(aliasSkill).isSymbolicLink()).toBe(false); + expect(frontmatterName(aliasSkill)).toBe('_gstack-command'); + // The source the legacy symlink pointed at is untouched. + expect(fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8')).toBe(sourceRootSkill); + } finally { + fs.rmSync(legacyDir, { recursive: true, force: true }); + } + }); +}); From 52006feac487b857ff0295db95a6d7cf9c7e21ee Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 09:08:13 -0700 Subject: [PATCH 026/126] fix(setup): Windows re-runs refresh installed skills for codex/factory/opencode hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows (Git Bash / MSYS2, no Developer Mode), _link_or_copy installs REAL directory copies. The install guards in link_codex_skill_dirs, link_factory_skill_dirs, link_opencode_skill_dirs, and create_agents_sidecar only ran the copy when the target was a symlink or missing — true on the first install, never again. Every subsequent ./setup after a git pull reported 'gstack ready (codex).' and exited 0 while silently refreshing nothing: users ran stale SKILL.md forever. (link_claude_skill_dirs already handled this; the other hosts never got the treatment.) Fix: all five guard sites bypass the symlink-or-missing check when IS_WINDOWS=1 — _link_or_copy rm -rf's the destination first, so the real-dir copy refreshes in place. Unix behavior is unchanged (symlinks still pass the guard via -L and serve updates without re-copying). The new bash-fixture test drives the REAL extracted functions through the install → upstream change → re-run cycle under IS_WINDOWS=1 (v1 must become v2), pins the sidecar-skip behavior, checks the Unix path stayed a symlink, and statically asserts the bypass at all five sites so factory/opencode can't regress. Registered in the Windows-safe curated list (KNOWN_WINDOWS_SAFE) so it actually runs on the windows-latest CI lane — the 'bin/' pattern hit is a fixture path segment, not a shebang spawn. Fixes #2444 Co-Authored-By: Claude Fable 5 --- scripts/test-free-shards.ts | 10 ++ setup | 26 +++- test/setup-windows-rerun-refresh.test.ts | 188 +++++++++++++++++++++++ 3 files changed, 219 insertions(+), 5 deletions(-) create mode 100644 test/setup-windows-rerun-refresh.test.ts diff --git a/scripts/test-free-shards.ts b/scripts/test-free-shards.ts index 4c0bc021e2..a164af2a3c 100755 --- a/scripts/test-free-shards.ts +++ b/scripts/test-free-shards.ts @@ -255,6 +255,16 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }> // pattern hit is a false positive — the point of these files is Windows // coverage, so auto-excluding them defeats the regression tests they carry. const KNOWN_WINDOWS_SAFE: Array<{ file: string; reason: string }> = [ + { + file: 'test/setup-windows-rerun-refresh.test.ts', + // Trips the "spawns bin/ shebang script" pattern via path.join(..., 'bin', + // 'tool.sh') fixture paths, but every spawn goes through spawnSync('bash', + // ['-c', ...]) — Git Bash executes it fine on windows-latest. This file IS + // the #2444 Windows regression coverage (IS_WINDOWS=1 copy-refresh path); + // excluding it here would keep the bug class unexercised on the one + // platform it bites. + reason: 'bin/ hits are fixture path segments; spawns bash explicitly — the IS_WINDOWS=1 refresh path must run on windows-latest', + }, { file: 'browse/test/file-permissions.test.ts', // Trips the POSIX-mode-bitmask pattern, but every `mode & 0o777` assertion diff --git a/setup b/setup index def88155ae..b4cbed373d 100755 --- a/setup +++ b/setup @@ -942,7 +942,11 @@ link_codex_skill_dirs() { [ "$skill_name" = "gstack" ] && continue target="$skills_dir/$skill_name" # Create or update symlink - if [ -L "$target" ] || [ ! -e "$target" ]; then + # #2444: on Windows the installed target is a REAL directory copy, so + # the symlink-or-missing guard skipped every re-run and SKILL.md never + # refreshed after `git pull`. IS_WINDOWS bypasses the guard — + # _link_or_copy rm -rf's the destination first, refreshing the copy. + if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]; then _link_or_copy "$skill_dir" "$target" linked+=("$skill_name") fi @@ -968,7 +972,9 @@ create_agents_sidecar() { local src="$SOURCE_GSTACK_DIR/$asset" local dst="$agents_gstack/$asset" if [ -d "$src" ] || [ -f "$src" ]; then - if [ -L "$dst" ] || [ ! -e "$dst" ]; then + # #2444: IS_WINDOWS bypass — real-dir copies never match -L, so re-runs + # skipped the refresh. _link_or_copy rm -rf's the destination first. + if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$dst" ] || [ ! -e "$dst" ]; then _link_or_copy "$src" "$dst" fi fi @@ -979,7 +985,9 @@ create_agents_sidecar() { local src="$SOURCE_GSTACK_DIR/$file" local dst="$agents_gstack/$file" if [ -f "$src" ]; then - if [ -L "$dst" ] || [ ! -e "$dst" ]; then + # #2444: IS_WINDOWS bypass — real-dir copies never match -L, so re-runs + # skipped the refresh. _link_or_copy rm -rf's the destination first. + if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$dst" ] || [ ! -e "$dst" ]; then _link_or_copy "$src" "$dst" fi fi @@ -1175,7 +1183,11 @@ link_factory_skill_dirs() { skill_name="$(basename "$skill_dir")" [ "$skill_name" = "gstack" ] && continue target="$skills_dir/$skill_name" - if [ -L "$target" ] || [ ! -e "$target" ]; then + # #2444: on Windows the installed target is a REAL directory copy, so + # the symlink-or-missing guard skipped every re-run and SKILL.md never + # refreshed after `git pull`. IS_WINDOWS bypasses the guard — + # _link_or_copy rm -rf's the destination first, refreshing the copy. + if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]; then _link_or_copy "$skill_dir" "$target" linked+=("$skill_name") fi @@ -1207,7 +1219,11 @@ link_opencode_skill_dirs() { skill_name="$(basename "$skill_dir")" [ "$skill_name" = "gstack" ] && continue target="$skills_dir/$skill_name" - if [ -L "$target" ] || [ ! -e "$target" ]; then + # #2444: on Windows the installed target is a REAL directory copy, so + # the symlink-or-missing guard skipped every re-run and SKILL.md never + # refreshed after `git pull`. IS_WINDOWS bypasses the guard — + # _link_or_copy rm -rf's the destination first, refreshing the copy. + if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]; then _link_or_copy "$skill_dir" "$target" linked+=("$skill_name") fi diff --git a/test/setup-windows-rerun-refresh.test.ts b/test/setup-windows-rerun-refresh.test.ts new file mode 100644 index 0000000000..24703a463e --- /dev/null +++ b/test/setup-windows-rerun-refresh.test.ts @@ -0,0 +1,188 @@ +/** + * Windows re-run refresh (#2444). + * + * On Windows, _link_or_copy installs REAL directory copies (no Developer + * Mode symlinks). The skill-linking guards `[ -L "$target" ] || [ ! -e + * "$target" ]` in link_codex_skill_dirs / link_factory_skill_dirs / + * link_opencode_skill_dirs / create_agents_sidecar therefore skipped every + * re-run: `./setup --host codex` reported "gstack ready" but never refreshed + * an already-installed SKILL.md after `git pull`. The fix bypasses the guard + * when IS_WINDOWS=1 — _link_or_copy rm -rf's the destination first, so the + * copy refreshes in place. + * + * The behavior fixture drives the REAL link_codex_skill_dirs / + * create_agents_sidecar functions (extracted from setup) against a fake + * install tree; the static block pins the bypass at all five guard sites so + * factory/opencode can't silently regress. + */ +import { describe, test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8'); + +function extractFn(name: string): string { + const start = SETUP_SRC.indexOf(`${name}() {`); + const end = SETUP_SRC.indexOf('\n}\n', start); + if (start < 0 || end < 0) throw new Error(`Could not locate ${name}() in setup`); + return SETUP_SRC.slice(start, end + 2); +} + +const WINDOWS_BYPASS = '[ "$IS_WINDOWS" -eq 1 ] || [ -L '; + +describe('setup: Windows re-run refresh — static guard sites (#2444)', () => { + test('all five install guards carry the IS_WINDOWS bypass', () => { + const sites = SETUP_SRC.split(WINDOWS_BYPASS).length - 1; + expect(sites).toBe(5); + }); + + test.each([ + 'link_codex_skill_dirs', + 'link_factory_skill_dirs', + 'link_opencode_skill_dirs', + 'create_agents_sidecar', + ])('%s bypasses the symlink-or-missing guard on Windows', (fn) => { + expect(extractFn(fn)).toContain(WINDOWS_BYPASS); + }); +}); + +interface RunResult { + status: number | null; + stdout: string; + stderr: string; +} + +/** Run the extracted installer functions against a fake tree. */ +function runInstaller( + isWindows: '0' | '1', + fns: string[], + invocation: string, + extraVars = '', +): RunResult { + const script = [ + 'set -e', + `IS_WINDOWS=${isWindows}`, + extraVars, + extractFn('_link_or_copy'), + ...fns.map(extractFn), + invocation, + ].join('\n'); + const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 15_000 }); + return { status: r.status, stdout: r.stdout, stderr: r.stderr }; +} + +describe('setup: Windows re-run refresh — behavior fixture (#2444)', () => { + test('IS_WINDOWS=1: link_codex_skill_dirs refreshes an already-installed skill', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rerun-')); + try { + const fake = path.join(tmp, 'gstack'); + const skills = path.join(tmp, 'skills'); + const demo = path.join(fake, '.agents', 'skills', 'gstack-demo'); + fs.mkdirSync(demo, { recursive: true }); + fs.mkdirSync(skills, { recursive: true }); + fs.writeFileSync(path.join(demo, 'SKILL.md'), 'v1-original\n'); + + // First run: installs the copy. + let r = runInstaller('1', ['link_codex_skill_dirs'], `link_codex_skill_dirs "${fake}" "${skills}"`); + expect(r.status).toBe(0); + const installed = path.join(skills, 'gstack-demo', 'SKILL.md'); + expect(fs.readFileSync(installed, 'utf-8')).toBe('v1-original\n'); + expect(fs.lstatSync(path.join(skills, 'gstack-demo')).isSymbolicLink()).toBe(false); + + // Upstream ships a change (the git pull). + fs.writeFileSync(path.join(demo, 'SKILL.md'), 'v2-UPDATED\n'); + + // Second run: pre-#2444 this was a silent no-op on Windows. + r = runInstaller('1', ['link_codex_skill_dirs'], `link_codex_skill_dirs "${fake}" "${skills}"`); + expect(r.status).toBe(0); + expect(fs.readFileSync(installed, 'utf-8')).toBe('v2-UPDATED\n'); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('IS_WINDOWS=1: create_agents_sidecar refreshes copied runtime assets', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rerun-sidecar-')); + try { + const fake = path.join(tmp, 'gstack'); + fs.mkdirSync(path.join(fake, 'bin'), { recursive: true }); + fs.writeFileSync(path.join(fake, 'bin', 'tool.sh'), 'v1\n'); + fs.writeFileSync(path.join(fake, 'ETHOS.md'), 'ethos-v1\n'); + + const vars = `SOURCE_GSTACK_DIR="${fake}"`; + let r = runInstaller('1', ['create_agents_sidecar'], `create_agents_sidecar "${fake}"`, vars); + expect(r.status).toBe(0); + const sidecarBin = path.join(fake, '.agents', 'skills', 'gstack', 'bin', 'tool.sh'); + const sidecarEthos = path.join(fake, '.agents', 'skills', 'gstack', 'ETHOS.md'); + expect(fs.readFileSync(sidecarBin, 'utf-8')).toBe('v1\n'); + expect(fs.readFileSync(sidecarEthos, 'utf-8')).toBe('ethos-v1\n'); + + fs.writeFileSync(path.join(fake, 'bin', 'tool.sh'), 'v2\n'); + fs.writeFileSync(path.join(fake, 'ETHOS.md'), 'ethos-v2\n'); + + r = runInstaller('1', ['create_agents_sidecar'], `create_agents_sidecar "${fake}"`, vars); + expect(r.status).toBe(0); + expect(fs.readFileSync(sidecarBin, 'utf-8')).toBe('v2\n'); + expect(fs.readFileSync(sidecarEthos, 'utf-8')).toBe('ethos-v2\n'); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('IS_WINDOWS=1: the gstack sidecar dir is still skipped by the skill loop', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rerun-skip-')); + try { + const fake = path.join(tmp, 'gstack'); + const skills = path.join(tmp, 'skills'); + const sidecar = path.join(fake, '.agents', 'skills', 'gstack'); + fs.mkdirSync(sidecar, { recursive: true }); + fs.mkdirSync(skills, { recursive: true }); + fs.writeFileSync(path.join(sidecar, 'SKILL.md'), 'sidecar\n'); + + const r = runInstaller('1', ['link_codex_skill_dirs'], `link_codex_skill_dirs "${fake}" "${skills}"`); + expect(r.status).toBe(0); + expect(fs.existsSync(path.join(skills, 'gstack'))).toBe(false); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); + +// On real Windows, `ln -snf` under Git Bash silently produces copies, so the +// Unix-mode symlink assertions are meaningless there — the same skip the +// _link_or_copy behavior matrix uses (test/setup-windows-fallback.test.ts). +describe.skipIf(process.platform === 'win32')( + 'setup: Unix path unchanged by the #2444 bypass', + () => { + test('IS_WINDOWS=0: installs a symlink and re-runs still refresh through it', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rerun-unix-')); + try { + const fake = path.join(tmp, 'gstack'); + const skills = path.join(tmp, 'skills'); + const demo = path.join(fake, '.agents', 'skills', 'gstack-demo'); + fs.mkdirSync(demo, { recursive: true }); + fs.mkdirSync(skills, { recursive: true }); + fs.writeFileSync(path.join(demo, 'SKILL.md'), 'v1-original\n'); + + let r = runInstaller('0', ['link_codex_skill_dirs'], `link_codex_skill_dirs "${fake}" "${skills}"`); + expect(r.status).toBe(0); + const target = path.join(skills, 'gstack-demo'); + expect(fs.lstatSync(target).isSymbolicLink()).toBe(true); + + // A symlink serves updates without any re-run at all… + fs.writeFileSync(path.join(demo, 'SKILL.md'), 'v2-UPDATED\n'); + expect(fs.readFileSync(path.join(target, 'SKILL.md'), 'utf-8')).toBe('v2-UPDATED\n'); + + // …and the re-run keeps it a symlink (guard still passes via -L). + r = runInstaller('0', ['link_codex_skill_dirs'], `link_codex_skill_dirs "${fake}" "${skills}"`); + expect(r.status).toBe(0); + expect(fs.lstatSync(target).isSymbolicLink()).toBe(true); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + }, +); From c84246845eb52798bc2127d71662a3a2ddca2f40 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 09:11:25 -0700 Subject: [PATCH 027/126] fix(uninstall): remove real-directory skill installs, gated on provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, setup installs skills as REAL directory copies (cp -R via _link_or_copy). gstack-uninstall's per-skill loop filtered on [ -L ], so every copy was skipped: --force exited 0 and printed 'gstack uninstalled.' while leaving ~52 gstack-* directories plus _gstack-command/ behind in ~/.claude/skills. The same filter also missed the standard Unix shape (real dir + symlinked SKILL.md), which was left as a dangling-symlink husk. Fix: the loop now handles all three install shapes. Symlink entries keep the existing readlink check. Real dirs with a SYMLINKED SKILL.md are removed when the link points into gstack (same semantics as setup's cleanup helpers). Real dirs with a REAL-FILE SKILL.md — the Windows copy shape — are removed ONLY when both provenance gates pass (F8): (a) the directory name is in gstack's skill inventory (source dir names, frontmatter names, gstack- prefixed variants, and the alias dirs), and (b) the SKILL.md carries the existing generated banner '\n'; + +function skillMd(name: string, withBanner = true): string { + return `---\nname: ${name}\ndescription: test\n---\n${withBanner ? BANNER : ''}# ${name}\n`; +} + +let tmpDir: string; +let mockHome: string; +let skillsDir: string; +let installRoot: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-copies-')); + mockHome = path.join(tmpDir, 'home'); + skillsDir = path.join(mockHome, '.claude', 'skills'); + installRoot = path.join(skillsDir, 'gstack'); + + // Mock install root: the source-of-truth skill dirs the inventory reads. + for (const skill of ['review', 'ship', 'qa']) { + fs.mkdirSync(path.join(installRoot, skill), { recursive: true }); + fs.writeFileSync(path.join(installRoot, skill, 'SKILL.md'), skillMd(skill)); + } + fs.writeFileSync(path.join(installRoot, 'SKILL.md'), skillMd('gstack')); + fs.mkdirSync(path.join(mockHome, '.gstack'), { recursive: true }); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function runUninstall(): { status: number | null; stdout: string; stderr: string } { + const r = spawnSync('bash', [UNINSTALL, '--force'], { + stdio: 'pipe', + encoding: 'utf-8', + env: { + ...process.env, + HOME: mockHome, + GSTACK_DIR: installRoot, + GSTACK_STATE_DIR: path.join(mockHome, '.gstack'), + }, + cwd: tmpDir, // not a git repo — per-project paths inert + timeout: 20_000, + }); + return { status: r.status, stdout: r.stdout, stderr: r.stderr }; +} + +/** Create a Windows-shape install entry: real dir + real-file SKILL.md. */ +function realDirEntry(name: string, content: string): string { + const dir = path.join(skillsDir, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'SKILL.md'), content); + return dir; +} + +describe('gstack-uninstall removes Windows real-dir copies (#2563)', () => { + test('inventory name + banner → removed (flat, prefixed, and alias forms)', () => { + const review = realDirEntry('review', skillMd('review')); + const prefixedShip = realDirEntry('gstack-ship', skillMd('gstack-ship')); + const alias = realDirEntry('_gstack-command', skillMd('_gstack-command')); + const ogbAlias = realDirEntry('connect-chrome', skillMd('connect-chrome')); + + const r = runUninstall(); + expect(r.status).toBe(0); + expect(fs.existsSync(review)).toBe(false); + expect(fs.existsSync(prefixedShip)).toBe(false); + expect(fs.existsSync(alias)).toBe(false); + expect(fs.existsSync(ogbAlias)).toBe(false); + expect(fs.existsSync(installRoot)).toBe(false); + }); + + test('name NOT in inventory → kept and listed to stderr, even with a banner', () => { + const foreign = realDirEntry('my-notes', skillMd('my-notes')); + + const r = runUninstall(); + expect(r.status).toBe(0); + expect(fs.existsSync(foreign)).toBe(true); + expect(r.stderr).toContain('my-notes'); + expect(r.stderr).toContain('left in place'); + }); + + test('no banner → kept and listed, even when the name collides with a gstack skill', () => { + // F8's name-collision row: a user's own hand-written ~/.claude/skills/ship. + const usersOwn = realDirEntry('ship', skillMd('ship', false)); + + const r = runUninstall(); + expect(r.status).toBe(0); + expect(fs.existsSync(usersOwn)).toBe(true); + expect(fs.readFileSync(path.join(usersOwn, 'SKILL.md'), 'utf-8')).toContain('name: ship'); + expect(r.stderr).toContain(path.join('skills', 'ship')); + }); + + test('real dir without any SKILL.md is untouched and unlisted', () => { + const plain = path.join(skillsDir, 'other-tool'); + fs.mkdirSync(plain, { recursive: true }); + + const r = runUninstall(); + expect(r.status).toBe(0); + expect(fs.existsSync(plain)).toBe(true); + expect(r.stderr).not.toContain('other-tool'); + }); + + test('a clean sweep reports the removed entries', () => { + realDirEntry('review', skillMd('review')); + const r = runUninstall(); + expect(r.status).toBe(0); + expect(r.stdout).toContain('claude/review'); + expect(r.stdout).toContain('gstack uninstalled.'); + }); +}); + +// symlinkSync needs Developer Mode on Windows runners; the Unix install shape +// can't be constructed there. The shape is Unix-only in practice anyway. +describe.skipIf(process.platform === 'win32')( + 'gstack-uninstall removes the Unix real-dir + symlinked-SKILL.md shape', + () => { + test('SKILL.md symlink pointing into gstack → removed', () => { + const dir = path.join(skillsDir, 'qa'); + fs.mkdirSync(dir, { recursive: true }); + fs.symlinkSync(path.join(installRoot, 'qa', 'SKILL.md'), path.join(dir, 'SKILL.md')); + + const r = runUninstall(); + expect(r.status).toBe(0); + expect(fs.existsSync(dir)).toBe(false); + }); + + test('SKILL.md symlink pointing elsewhere → kept and listed', () => { + // Target path must not contain "gstack" anywhere (the provenance match + // is a substring check, mirroring setup's cleanup helpers) — the suite + // tmpdir prefix does, so use a separate neutral tmpdir. + const neutral = fs.mkdtempSync(path.join(os.tmpdir(), 'other-skill-src-')); + const elsewhere = path.join(neutral, 'elsewhere.md'); + fs.writeFileSync(elsewhere, '# not ours\n'); + const dir = path.join(skillsDir, 'someone-elses'); + fs.mkdirSync(dir, { recursive: true }); + fs.symlinkSync(elsewhere, path.join(dir, 'SKILL.md')); + + try { + const r = runUninstall(); + expect(r.status).toBe(0); + expect(fs.existsSync(dir)).toBe(true); + expect(r.stderr).toContain('someone-elses'); + } finally { + fs.rmSync(neutral, { recursive: true, force: true }); + } + }); + }, +); + +describe('every installable skill SKILL.md carries the generated banner (ENG-OV10)', () => { + // The uninstall provenance gate is only sound if the banner is universal: + // a bannerless generated skill would be stranded on Windows forever. + test('all top-level skill SKILL.md files contain the AUTO-GENERATED banner', () => { + const missing: string[] = []; + for (const entry of fs.readdirSync(ROOT, { withFileTypes: true })) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + const md = path.join(ROOT, entry.name, 'SKILL.md'); + if (!fs.existsSync(md)) continue; + if (!fs.readFileSync(md, 'utf-8').includes(' - -