fix(gbrain-install): --dry-run no longer requires the network (fixes flaky bun test) - #2540
Closed
CarringtonCreative wants to merge 1 commit into
Closed
Conversation
`gstack-gbrain-install --dry-run` prints a plan and exits without cloning, but it ran the GitHub reachability check first. The check is gated on VALIDATE_ONLY, and --dry-run sets DRY_RUN, so every dry run made a live request to github.com that nothing downstream needed. When that curl lost a race for sockets or DNS it called fail(), which exits 3, and the dry run reported "cannot reach https://github.com" on a machine that was online. Reproducible outside any test runner: 60 concurrent --dry-run invocations against temp HOME/GSTACK_HOME failed 9 times, ~15%. With the guard, 0 of 60. This is what made `bun test` non-deterministic. The three `gstack-gbrain-install D5 detect-first` tests each call --dry-run and assert exit 0, so a full suite -- which spawns plenty of concurrent processes -- lost whichever of them happened to be running when the curl failed. That matches every symptom: green in isolation, a different one of the three failing each time, sub-second failures rather than timeouts, and 3 of 7 clean-clone suite runs red before this change. Real installs keep the fail-fast offline check; only the path that never clones skips it. Validation on a clean clone: the documented Tier 1 gate run 10 times, 0 failures, against a 43% failure rate on the same clone before the change. Refs garrytan#2536 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Merging to
After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here |
9 tasks
garrytan
added a commit
that referenced
this pull request
Aug 17, 2026
…ls, brain-sync integrity, 31 community PRs credited (#2604) * fix(test): host-config goldens self-provision .agents/.factory artifacts 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 <noreply@anthropic.com> * fix(test): exempt the live repo tree from hermetic-wiring's operator-~/.claude ban 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * fix(gen-skill-docs): throw when a template contains {{PREAMBLE}} twice 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 <noreply@anthropic.com> * fix(test): classify catalog-trim.test.ts as tree-mutating 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 <noreply@anthropic.com> * fix(test): prepush hook test builds PATH with a POSIX-only separator `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 <noreply@anthropic.com> * fix(artifacts): sync the decision store, which no allowlist glob matched gstack-decision-log enqueues projects/<slug>/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. * 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. * fix(security): guard brain-sync arithmetic against injected .brain-last-pull; sanitize _GBRAIN_HOST 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 <noreply@anthropic.com> * 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. * fix(gbrain): quote cmd.exe arguments at a single gbrain invocation seam 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 <noreply@anthropic.com> * 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/<branch>; 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 <noreply@anthropic.com> * fix(gbrain): make --full do a full code walk, not a delta one `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 <id> --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. * fix(brain-cache): honest 'missing' instead of fabricated-empty digests on gbrain failure 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 <noreply@anthropic.com> * fix(test): give the schema-mismatch rebuild test a load-proof budget 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 <noreply@anthropic.com> * fix(memory-ingest): parse the current Codex response_item rollout shape 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 <noreply@anthropic.com> * fix(test): refresh codex/factory ship goldens from post-#2588 regeneration 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 <noreply@anthropic.com> * fix(make-pdf): boolean flags no longer swallow the next positional argument 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 <noreply@anthropic.com> * fix(repo-mode): probe GNU stat before BSD so Git Bash stops crashing 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 <noreply@anthropic.com> * fix(retro): point the prior-retros context query at files /retro actually writes Fixes #2552's live half. The gbrain context-query glob targeted ~/.gstack/projects/<slug>/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 <noreply@anthropic.com> * fix(sync-gbrain): remove the capability-check page file left in the user's repo Fixes #2503. On worktree-pinned brains `gbrain put` materializes the checked page as _capability_check_<pid>.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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * fix(gitattributes): pin *.txt to LF .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 <noreply@anthropic.com> * 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/<name>/<file> 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/<name>/<path>) must exist under the install; repo-anchored refs (~/.claude/skills/gstack/<path>) 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 <noreply@anthropic.com> * fix(setup): alias skills install as rewritten copies, never symlinks 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 <noreply@anthropic.com> * fix(setup): Windows re-runs refresh installed skills for codex/factory/opencode hosts 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 <noreply@anthropic.com> * fix(uninstall): remove real-directory skill installs, gated on provenance 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 '<!-- AUTO-GENERATED from' (ENG-OV10: every pre-v1.67 copy already carries it; a NEW marker would refuse to delete legitimate old installs, recreating the bug). Anything failing a gate is listed to stderr and never deleted — a user's own skill that happens to share a name with a gstack skill survives. Tests: a fake-tree fixture covers removed/kept/listed for every shape (including the F8 name-collision row), and a census test asserts every installable skill's generated SKILL.md carries the banner so the gate can't strand a bannerless skill. Registered in the Windows-safe curated list — the copy shape is exactly what windows-latest exercises. Fixes #2563 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(setup): wire --host cursor through the full install path './setup --host cursor' was accepted by the flag parser and then did nothing: no INSTALL_CURSOR branch existed, so the script built binaries, printed no 'ready' line, and installed zero skills — Cursor users had no way to install gstack at all. Full install slice, re-derived from PR #2547 by @szsunyuan onto the current installers: generate .cursor/ skill docs (host config already existed), create a minimal ~/.cursor/skills/gstack runtime root (root SKILL.md + bin/lib/browse assets + review checklist pair + ETHOS.md + supabase config — bin and lib travel together because bin scripts import ../lib), link the generated gstack-* skills, and plant the repo-local .cursor/skills/gstack sidecar WITHOUT ever wiping the generated SKILL.md files it shares a directory with (link-before-sidecar ordering keeps the generation fallback alive). Auto mode detects Cursor via the cursor binary or the ~/.cursor footprint. gstack-uninstall removes ~/.cursor/skills/gstack* and per-project .cursor/skills/gstack* — and never rmdir's .cursor itself, where Cursor stores user rules. Re-derivation deltas from the PR: the link guards carry the #2444 IS_WINDOWS bypass (re-runs refresh real-dir copies), lib/ and supabase/config.sh ride along like every other runtime root, and the hosts/cursor.ts sidecar field is omitted (HostConfig no longer carries one — sidecar behavior lives in setup). Fixes #1358 Co-authored-by: Yuan Sun <forrest.sun527@gmail.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): include command in add-event dedup key (#2382) Fixes #2382. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(setup): render the gbrain :user variant to an out-dir — global installs stay git-clean On a global-git install with gbrain, ./setup and 'gstack-config gbrain-refresh' ran gen:skill-docs:user IN PLACE inside the install checkout, rewriting ~16 TRACKED SKILL.md files. The checkout stayed permanently dirty, every /gstack-upgrade 'git stash' saved a redundant snapshot of generated content, and the growing stash list invited a 'git stash pop' that would lay stale instruction markdown from an older gstack over the current version — a quiet wrong-rules failure mode. Fix, wired through machinery that already existed (gen-skill-docs --out-dir + the symlink install layer): brain-aware SKILL.md now renders into the untracked ~/.gstack/render/claude, and both Claude installers serve the render when present — setup's link_claude_skill_dirs prefers $GSTACK_HOME/render/claude/<skill>/SKILL.md, and bin/gstack-relink does the same so a later config change can't silently flip skills back to the blockless canonical source. setup wipes and rebuilds the render each run, repoints installed skills after a successful render, and removes a stale render (re-linking canonical) when gbrain is gone. gbrain-refresh renders to the out-dir and repoints via relink; its 'this dirties the install's git tree' caveat is retired because it no longer does. A one-time upgrade migration (gstack-upgrade/migrations/v1.67.0.0.sh, F12) restores the legacy dirt: unstaged modifications to SKILL.md / sections/ *.md files in the install checkout are git-checkout'd back to canonical; anything outside that footprint (user edits, untracked files, staged work) is left alone and reported. Idempotent, non-fatal, symlinked installs skipped. Tests: render-preference behavior for both installers, static pins that every executable :user invocation carries --out-dir and the caveat text is gone, migration fixture (restore/leave/idempotent/no-op matrix), and the existing out-dir render test now asserts 'git status --porcelain' gains zero new entries across a full :user render. Fixes #2569 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(redact): close the remaining #1946 fail-opens — detection coverage + one-time consent Two of #1946's reported gaps were still open after the v1.64 fail-closed work (the git-error and oversized-diff paths in bin/gstack-redact-prepush are already strict, chunked, and pinned by tests): 1. Detection fail-open: env.kv required an UPPERCASE name with an '=' assignment, so 'api_key=…', 'apiKey: "…"', and 'password: …' — the most common real config shapes — produced NO finding at all. The pattern is now case-insensitive, accepts ':' (YAML/JSON) as well as '=' assignment, and handles quoted JSON keys. It stays MEDIUM and entropy-gated per the calibration rule (a generic net that cries wolf gets bypassed), with pinned cases for each closed shape plus the placeholder/entropy negatives. 2. Install fail-open: nothing ever offered the guard, so a plain 'git push' scanned nothing and users believing themselves protected weren't. setup now asks ONCE for consent on a real interactive terminal (maintainer decision 6): an explicit answer is recorded to the existing redact_prepush_hook key and never re-asked; a timeout or non-interactive run changes nothing and keeps the hint-only posture. Default stays FALSE, and setup still never installs the hook itself — /ship owns the per-repo install (the wrong-repo invariant is pinned by the existing 'setup carries the hint only' test). Tests: per-shape pattern cases, prompt gating statics (key-absence + TTY + timed default-N read), timeout-persists-nothing, non-interactive stays hint-only with no key write, and recorded-answer-is-silent behavior runs. Contributes to #1946 (the pre-push guard's fail-closed scan paths landed in earlier releases; this closes the coverage and consent gaps it names). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(hooks): Stop hook closes dangling timeline entries — fail-open The preamble writes event:'started' to the project timeline at every skill start, but the matching 'completed' write lives in prose at the END of the skill workflow — unenforceable. An interrupted session, a context blowout, or an agent that simply stops leaked started > completed forever, and the leak was unrepairable after the fact (observed live in #2553). New hosts/claude/hooks/timeline-stop-hook (+ .ts, question-log-hook shim pattern): on Claude Code's Stop event it appends event:'completed' with outcome 'unknown' and source 'stop-hook' for every 'started' entry in the project timeline that has no matching completion. setup registers it via gstack-settings-hook add-event (Stop was already an accepted event) under its own source tag, idempotently; --no-team and gstack-uninstall remove it. FAIL-OPEN contract (F5), pinned by tests: ALWAYS exits 0 — corrupt timeline (bad lines skipped individually, valid ones still repaired), missing timeline, garbage/empty stdin, bun missing from PATH (the shim '|| true's), and an over-cap timeline (10MB skip) all repair nothing and block nothing; errors land in ~/.gstack/hook-errors.log best-effort. The write path is append-only with a ~2s internal budget, and a second Stop is a no-op (already-closed entries never re-close). Correlation is project-scoped by design — the preamble's session id is shell-local, so a concurrent same-project session's entry may close early as a traceable source:'stop-hook' row rather than a silent leak; the header documents the trade-off. Fixes #2553 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios-qa: guard DebugBridgeTouch.m on DEBUG, not just TARGET_OS_IOS DebugBridgeTouch.m and its header both promise the code is DEBUG-only and never shipped: "Uses these private UIKit selectors (DEBUG-only; never shipped to App Store)" "DEBUG-only — never link in Release." Nothing enforced it. The only guard was `#if TARGET_OS_IOS`, so a Release build for iOS compiled the entire implementation in, private API and all. Measured on a real app (an iOS Release build, `nm -j` on the app binary): DebugBridge symbols 15 IOHIDEventCreateDigitizer 2 AXSSetAutomationEnabled 1 symbol, 2 strings IOKit.framework 4 strings including +[DebugBridgeTouch sendTapAtPoint:inWindow:] and _OBJC_CLASS_$_DebugBridgeTouch. That is a Guideline 2.5.1 private-API exposure in a shippable binary, and it fails Package.swift's own stated CI invariant: nm -j build/Release/<binary> | grep -q DebugBridge && exit 1 WHY THE EXISTING GUARD DOES NOT COVER THIS Package.swift documents the protection as `.when(configuration: .debug)` on the consuming target's dependency. That works for SwiftPM consumers. It cannot be expressed by an app that integrates DebugBridge as a local package inside an .xcodeproj: Xcode's Filters column under Frameworks, Libraries, and Embedded Content offers platform conditions only — iOS, macOS, visionOS — never build configuration. So for xcodeproj consumers the documented guard silently does nothing, which is precisely the case that was measured. The Swift targets were already safe: all four .swift files are `#if DEBUG` guarded and Package.swift defines DEBUG for them via swiftSettings. Only the Objective-C target, the one that actually links private API, was unguarded. THE FIX 1. DebugBridgeTouch.m.template now branches `#if !defined(DEBUG)` first and emits nothing at all in Release, falling through to the existing iOS and non-iOS branches only in Debug. 2. Package.swift.template declares DEBUG explicitly for the ObjC target: cSettings: [.define("DEBUG", .when(configuration: .debug))] The two Swift targets already did this. Relying on SwiftPM's implicit DEBUG for C-family targets is not worth betting a private-API exposure on. VERIFIED, by compiling the generated file for iOS both ways: xcrun -sdk iphoneos clang -c DebugBridgeTouch.m -arch arm64 ... Release (no -DDEBUG) 0 DebugBridge symbols, 0 private-API symbols, 448 B Debug (-DDEBUG=1) 7 DebugBridge symbols, 6 private-API symbols, 13104 B The harness is unchanged in Debug. Release now emits an empty translation unit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(ios-qa): bridges search front-most presented content first A presented sheet sits AFTER the screen it covers in window.subviews, so the elements walk emitted the covered screen first — a client taking the first match for a label activated a control the user cannot reach, and the agent saw a success (measured on a real app: the sheet's 'Create' button ranked 210th behind 35+ covered-screen entries). Menus, alerts and action sheets were worse: each gets its OWN UIWindow, so keying off isKeyWindow missed them entirely — absent from /elements, dropped from /screenshot, untappable via /tap. Re-derived from PR #2397 by @IDSTUK onto the current bridge templates (the SwiftUI tap-reliability rework had moved underneath the PR): ScreenshotBridgeImpl gains orderedWindows(in:) (visible windows front-most first by windowLevel then insertion order, PassThroughWindow overlays still filtered), frontmostWindow(), and searchRoots() (per window, the top-most presented view controller's view before the window itself). /elements walks those roots in order through the existing shared visited-set + budget, so overlapping roots emit each view once at its front-most position; /tap targets frontmostWindow() for both the accessibility-activation and synthesized-touch paths; /type and /swipe search the roots in order; /screenshot composites every window back-to-front at the existing 1x scale. The two now-dead private activeScene/activeKeyWindow copies in ElementsBridgeImpl and MutationBridgeImpl are removed. Fixture mirror synced byte-for-byte; verified with a full 'xcodebuild build -scheme FixtureApp-Package -destination generic/platform=iOS Simulator' (BUILD SUCCEEDED, DEBUG guard from the previous commit included). Co-authored-by: IDST UK <IDSTUK@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(setup-gbrain): invoke gstack-memory-ingest/gstack-gbrain-sync via bun run + .ts /setup-gbrain's transcript-ingest steps told the agent to run bin/gstack-memory-ingest and bin/gstack-gbrain-sync by BARE name. Neither exists — only the .ts files ship (mode 644, no bin alias) — so the agent dutifully reported 'script missing at install root' and the ingest/full- sync steps dead-ended on every host (hit live under Codex; the Claude render carries the same text). All four template sites (probe, silent-bulk, post-answer full sync, the preamble-hook incremental mention) and the four memory.md reference-doc sites now use the repo's established form: 'bun run <path>/gstack-memory- ingest.ts …' / 'bun run <path>/gstack-gbrain-sync.ts …' — matching what sync-gbrain already does. Generated SKILL.md regenerated from the template in the same commit. Re-derived from PR #2409 by @SomSamantray per the wave's screening rule (the PR edited the generated SKILL.md directly; the generated file must come from gen:skill-docs). The contributor's structural test rides along as-is: bare-invocation regexes with negative .ts lookahead and backslash- continuation coverage pin every site, so the drift can't return. The referenced-paths ratchet in test/setup-claude-skill-assets.test.ts drops its two #2250 known-broken entries — the class-2 assertion now guards these paths again. Verified against #2250's site list (template lines 690/735/784-area, all covered) plus a fresh grep: zero bare invocations remain in the template or memory.md; the one prose mention ('gstack-memory-ingest now persists…') is not an invocation and stays. Fixes #2250 Fixes #2393 Co-authored-by: SomSamantray <SomSamantray@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): update four main-side assertions to the T3 installer contracts Integration drift from the T3 lane: three static assertions pinned the OLD implementation shapes that T3 legitimately replaced — the gbrain-refresh branch no longer self-documents a reset --hard cycle (#2569 renders to an untracked out-dir instead; the test now pins THAT), setup's regen block renamed to the render form (re-anchored, same exit-code-propagation invariant), and sections/ linking generalized into _link_skill_runtime_assets (the _link_or_copy routing assertion moved into the helper). Fourth: the uninstall neutral-target test asserted against os.tmpdir(), which reads $TMPDIR at call time — a shard neighbor can leave it gstack-containing, making the "neutral" symlink target match the provenance substring; the test now falls back to a fixed neutral root and asserts neutrality explicitly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: whitelist engine-locked at all three gbrain-usable gates (#2456) #2194 taught the classifier to report a PGLite lock held by a live \`gbrain serve\` as engine-locked instead of broken-config, but none of the three "is gbrain usable?" gates accepted the new status — so the symptom moved from a wrong error to a quieter wrong suppression: gbrain-refresh stripped GBRAIN_CONTEXT_LOAD / GBRAIN_SAVE_RESULTS blocks out of every generated SKILL.md after every upgrade, on the RECOMMENDED /setup-gbrain default (PGLite + local-stdio MCP spawns gbrain serve at session start). engine-locked is the same class as timeout (#1964): the engine is installed and healthy, a legitimate holder has the lock. All three gates now agree: - bin/gstack-gbrain-detect --is-ok exits 0 on engine-locked - bin/gstack-config gbrain-refresh case arm renders instead of suppressing - scripts/gen-skill-docs.ts --respect-detection treats it as detected Test mirrors the existing timeout case in test/gbrain-detection-override.test.ts (engine-locked renders brain blocks; the sibling no-cli case still proves suppression works). Applies the reporter's patch + test from the issue. Fixes #2456 Co-authored-by: Mateus Moraes <mmoraes@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: detect bearer-token thin clients via host MCP registration (#2520) The #2051 thin-client fix keys detection on the remote_mcp marker in ~/.gbrain/config.json — but that marker is only written by the OAuth path (gbrain init --mcp-only). Bearer-token installs (gbrain connect <url> --token, gbrain's own recommended default for local/personal use) never touch config.json, so they fell through to the local probe, failed against the dead-or-absent local engine, and landed on missing-config / broken-db / broken-config / engine-locked — silently suppressing brain blocks for a fully-working remote brain. New evidence source: hasRemoteOnlyGbrainMcp() reads ~/.claude.json MCP registrations (user scope AND project scope) with the same classification rules as gstack-gbrain-detect's tier-3 fallback. File-read only — no subprocess, no network (a classifier network probe is the #1964 pathology). Wired at two sites in freshClassify: - missing-config branch: a bearer thin client may never have run a local init; if the host's only gbrain registration is remote-HTTP, that registration IS the brain → thin-client. - post-probe-failure demotion: broken-db / broken-config / engine-locked reclassify to thin-client when the only gbrain registration is remote. A local-stdio sibling registration blocks the demotion (federation guard: a user running a local engine plus a remote team brain keeps precise local statuses). "timeout" is excluded — already usable, and may be a genuinely healthy slow local engine. 7 new unit tests in test/gbrain-local-status.test.ts: user-scope, project- scope, engine-locked/broken-db demotion, federation guard, no-registration discriminator, end-to-end --is-ok gate (35 pass total in the file). Root-cause analysis by @d-danielsun in #2520. Fixes #2520 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: resolve GBRAIN_HOME with gbrain's parent-dir semantics (#2521) gstack treated GBRAIN_HOME as the config directory; gbrain's configDir() treats it as the PARENT and always appends `.gbrain` itself (the contract is explicit in gbrain's source: GBRAIN_HOME=/tmp/x → /tmp/x/.gbrain/ config.json). With GBRAIN_HOME set, gstack classified engine status from a file gbrain never reads — the probe's two halves (file checks vs the spawned `gbrain sources list`) looked at DIFFERENT installs, so any resulting status was arbitrary: missing-config/broken-config against healthy installs, or a thin-client marker gstack saw that gbrain itself reported as "No brain configured". New shared resolver `gbrainConfigDir()` in lib/gbrain-exec.ts is the single source of truth. All seven gstack sites route through the contract: - lib/gbrain-local-status.ts gbrainConfigPath (the classifier's file half) - bin/gstack-gbrain-detect GBRAIN_CONFIG + readRemoteMcpUrl - lib/gbrain-exec.ts buildGbrainEnv (the probe's DATABASE_URL seed — fixing only the classifier would have left the split-brain in the spawn half, flagged by the reporter) - lib/gbrain-guards.ts gbrainHome (clones-dir + autopilot-lock paths) - lib/gstack-memory-helpers.ts gbrainConfigPath (engine-tier fallback) - bin/gstack-gbrain-install pre-doctor config check (shell) Unit tests cover GBRAIN_HOME set (config found at $GBRAIN_HOME/.gbrain), the old flat layout explicitly NOT read (both classifier and buildGbrainEnv), and unset (~/.gbrain unchanged). Existing fixtures that encoded the deviant flat layout are updated to gbrain's contract. Root-cause analysis by @d-danielsun in #2521. Deviation from the 3-site plan spec: the same deviant resolution existed in four more sites (buildGbrainEnv, gbrain-guards, memory-helpers, gbrain-install); fixing only three would have left gstack disagreeing with itself as well as with gbrain, so the whole class moved to the shared resolver in one change. Fixes #2521 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: read project-scoped MCP registrations in gbrain detection (#2499) Claude Code registers MCP servers at two scopes in ~/.claude.json: user scope (.mcpServers) and project scope (.projects["/abs/path"].mcpServers — what `claude mcp add` WITHOUT --scope user writes). Every gbrain detection site read only user scope, so a correctly configured project-scoped brain was invisible: brain-aware blocks suppressed, remote-mode artifacts sync never recognised, and detectEndpointHash fell through to the 'local' literal — two different project-scoped brains hashed identically, so switching between them never invalidated the cache, the exact scenario the function's docstring says it exists to catch. Nothing errored; the features just quietly were not there. Two sites fixed: - scripts/resolvers/preamble/generate-brain-sync-block.ts: the shared detection block (rendered into every tier-2+ SKILL.md) now resolves the gbrain entry ONCE into _GBRAIN_MCP_ENTRY — user scope first, then the nearest-ancestor project entry for $PWD that actually carries a gbrain server (longest matching key with a path-boundary check: /a/repo never matches /a/repo2; a nested project WITHOUT gbrain doesn't shadow its parent's registration). _GBRAIN_MCP_TYPE and _GBRAIN_HOST extract from the resolved entry, so claude.json is parsed once per skill start. All SKILL.md files regenerated in this commit; the ship golden fixtures and three carve-guard skeleton caps (plan-eng-review, plan-devex-review, office-hours; ~1.5KB rendered growth per skill) are refreshed with measured values. - bin/gstack-brain-cache detectEndpointHash: same resolution order in TS (user scope, else nearest-ancestor project entry by cwd, both path separators for Windows keys). Tests: rendered-output tests in test/gen-skill-docs.test.ts pin the regenerated block (static markers + a FUNCTIONAL run of the exact rendered lines against a fixture ~/.claude.json with only a project-scoped registration, plus an outside-cwd discriminator); detectEndpointHash unit tests in test/brain-cache-roundtrip.test.ts cover project-scope resolve, path-boundary, nearest-ancestor distinct hashes, and user-scope precedence. Root-cause analysis by @samporter-31 in #2499. Fixes #2499 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: /sync-gbrain respects an existing valid .gbrain-source pin (#2417) /sync-gbrain always derived a new worktree-scoped source ID, even when the repository already carried a valid .gbrain-source pin created through the native GBrain source workflow — silently bypassing the selected source boundary, registering a duplicate federated source, and routing later dream/cycle checks to the wrong source. Now a local pin is reused when it passes the fail-closed identity checks: the ID is syntactically valid, the source is registered, and the registered path realpath-resolves to the current checkout (so a stale or copied dotfile can't redirect a sync into another repo's source). A confirmed pin is treated as user-managed — synced and attached without add/remove, legacy migration, or federation changes. Dry-run stays spawn-free (reads only the local marker for previews). Missing, invalid, stale, or unreadable pins fall back to the existing generated source ID. Absorbs PR #2417 by @exGeni (applied via git am -3; 42 tests pass in test/gstack-gbrain-sync.test.ts including the new pin-respecting coverage: spawn-free dry-run, symlink-equivalent registered paths, non-dry-run sync/attach with no add/remove, dream routing, unreadable markers, config-backed env use). Co-authored-by: Evgenii Lopatin <e75533@gmail.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: gstack-gbrain-install --dry-run no longer requires the network (#2540) The GitHub reachability probe (curl --head, 10s max) was gated only on --validate-only, so a --dry-run — which prints a plan and exits without ever cloning — could fail with exit 3 "cannot reach https://github.com" whenever the curl lost a race for sockets/DNS. Reproducible at ~15% by running 60 dry-runs concurrently, and the cause of intermittent red in the D5 detect-first tests, which call this exact path. The probe now also skips under --dry-run: requiring the network for a plan-print buys nothing and costs a real failure mode. Real installs still fail fast when offline rather than hanging git clone. Absorbs PR #2540 by @CarringtonCreative (applied via git am -3; 26 tests pass across test/gbrain-detect-install.test.ts + test/egress-receipt-wiring.test.ts). Fixes the offline/flake half of #2536. Co-authored-by: Carrington Dennis <carrdenn3@gmail.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: accept 3-digit semver + package.json version sources (#2501) Two version-source shapes failed CLOSED in a way that silently disabled /ship's queue-collision check: 1. A --version-path / .gstack/version-path target that is a package.json was read as raw text: the whitespace strip turned the JSON into '{"name":"frontend",... which parseVersion rejected, so every read — local, `git show`, and rival PRs' claims through the GitHub/GitLab Contents APIs — fell back to 0.0.0.0 and competing claims were dropped as "malformed". 2. parseVersion required exactly four components, so gstack-next-version exited 2 on EVERY invocation in a 3-digit repo. That CLI IS the queue-collision check; /ship then took its documented offline path of naive local arithmetic, two branches cut from the same base picked the same version, and git merged the duplicate without a conflict. New lib/version-source.ts holds the shared semantics so both CLIs agree by construction: parseVersion accepts 3- or 4-digit (3 pads the micro slot for uniform comparison), versionWidth/fmtVersion keep a 3-digit repo 3-digit through bumping and formatting, micro coerces to patch on 3-digit repos (with a warning in the output), and extractVersion reads a .json version-path as JSON (.version) from any byte source. gstack-version-bump treats a package.json version-path as that repo's single source of truth (written in place, DRIFT_* states can't arise — no second file to drift from). Detection is by shape, not new configuration. Scope per the wave plan's version-tooling end-state spec (decision 11, ENG-OV1): this is the READING capability + 3-digit acceptance ONLY. gstack's own VERSION file stays the 4-digit source of truth; nothing here flips authority to package.json. The PR's bundled fix for the .gstack/version-path pin being ignored by classify's base read lands separately (#2462) — these tests drive the JSON version-path through the explicit --version-path flag. Re-derived from PR #2501 by @YiftahR (73 tests pass across test/gstack-version-bump.test.ts, test/gstack-next-version.test.ts, test/ship-version-sync.test.ts). Fixes #2501 Co-authored-by: YR <work.yiftah.rottem@gmail.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: write/repair sync npm lockfiles' version fields (#2567) npm records the package version twice in its lockfiles — top-level `version` and, in lockfileVersion >= 2, `packages[""].version` (the entry describing the root package itself) — and `npm install` keeps both in step. gstack-version-bump write/repair updated VERSION + package.json but left the lockfile behind, so every /ship bump in an npm repo drifted one field per release until someone ran npm, dirtying the tree on the next `npm install` far from the cause. write and repair now mirror the version into package-lock.json AND npm-shrinkwrap.json (which shares the format and, when present, is what npm actually honors) as a pure JSON edit — no npm spawn, no dependency-tree churn, dependency entries untouched. Per the wave plan's version-tooling end-state spec (decision 11): synced ONLY when the file already exists, never created (gstack itself is bun-only). A failed manifest/lockfile write keeps the existing exit-3 half-write semantics so classify reports DRIFT_STALE_PKG on re-run instead of hiding the drift. Tests: 5 new cases in test/gstack-version-bump.test.ts — both lockfile version fields synced with deps untouched, repair heals a stale lockfile, lockfileVersion 1 (no packages map) doesn't crash, npm-shrinkwrap.json synced without inventing a package-lock.json, malformed lockfile exits 3 loudly (26 pass total in the file). Re-derived from PR #2568 by @ortonom under decision 11. Fixes #2567 Co-authored-by: ortonom <3261546+ortonom@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: subdirectory manifests + npm-valid version mirror (#2531) Two gaps in gstack-version-bump's manifest handling, resolved to the wave plan's version-tooling end-state spec (decision 11): 1. Subdirectory manifests. A repo whose only Node package lives in web/, app/, or frontend/ has no ROOT package.json, so join(cwd, "package.json") reported pkgExists:false and every bump silently wrote VERSION alone — leaving the manifest to be bumped by hand, which is exactly the drift this tool exists to prevent, in the one layout where it silently did nothing. All three subcommands now resolve the manifest as --package-json-path → .gstack/package-json-path → ./package.json (mirroring resolveVersionPath). 2. npm-valid mirror. VERSION is 4-digit MAJOR.MINOR.PATCH.MICRO; npm's semver is 3-component and rejects a fourth, so mirroring the raw form breaks `npm ci` in any repo npm actually manages. The manifest and its lockfiles now carry the npm-valid 3-digit translation (1.67.0.0 → 1.67.0) via npmVersion() in lib/version-source.ts. VERSION stays the 4-digit source of truth. classify judges drift against the TRANSLATED form — a correctly-synced `0.1.25` no longer reads as eternal drift against `0.1.25.0` — and grandfathers the pre-v1.67 1:1 four-digit mirror as in-sync (flagging it DRIFT_UNEXPECTED would hard-stop /ship on every existing repo on upgrade day; the next write migrates the manifest to the translated form). Lockfiles are synced beside the resolved manifest — including beside a pinned JSON version-path — and only when they already exist. classify output gains pkgPath and expectedPkgVersion for observability; write/repair report packageJsonPath + packageJsonVersion. The /ship Step 12 prose (ship/SKILL.md.tmpl) documents the resolution chain and the translation; SKILL.md files regenerated and ship golden fixtures refreshed in this commit. Tests: subdirectory pin + --package-json-path override, translated-form classify (FRESH/ALREADY_BUMPED, no false drift), grandfathered 1:1 mirror, genuine divergence still drifts, repair to the npm-valid form (33 pass in test/gstack-version-bump.test.ts; 526 pass across the five affected files including goldens and parity). Re-derived from PR #2531 by @CarringtonCreative on top of the 3-digit/ JSON version-source work, under decision 11 (which resolves the PR's lockfile-gated translation in favor of an unconditional npm-valid mirror). Co-authored-by: Carrington Dennis <carrdenn3@gmail.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: git-based version allocator when the PR queue is unreachable (#2545) When the host query (gh/glab) failed, gstack-next-version returned offline:true with an EMPTY claim set, and /ship's documented fallback was local BUMP_LEVEL arithmetic. Local arithmetic cannot see a sibling's claim, so the fallback allocated a version another open PR already held — observed in a downstream repo where two merged PRs both read v0.1.57.0 (and an audit found four such duplicate pairs over three weeks). New fetchGitClaimed() degrades the QUEUE VIEW without degrading the ALLOCATION: git already knows what the API was asked for. It reads every remote-tracking branch's pinned version file (through extractVersion, so JSON version-paths resolve on remote refs too and each branch's own digit width is preserved) plus the versions already shipped in the base's last 400 commit subjects (3- or 4-digit; the cap announces itself in warnings when it truncates). The fallback runs only when the host told us nothing — the online path is untouched — and the output gains a load-bearing `fallback: "git" | null` field that /ship can branch on, plus explicit warnings for both the recovered-from-git and the nothing-found cases. Tests: end-to-end stub-gh offline contract (fallback:'git' + a valid version + the warning), sibling-claim discovery from remote-tracking refs, the pick advancing past the sibling's claim, shipped-subject scanning, JSON version-path claims on remote refs, and non-repo degradation to a warning (45 pass in test/gstack-next-version.test.ts). Re-derived from PR #2545 by @CarringtonCreative under the wave plan's version-tooling end-state spec; the PR's own VERSION/CHANGELOG stamping is stripped (release stamping happens at /ship time, not per commit). Co-authored-by: Carrington Dennis <carrdenn3@gmail.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: version-bump honors the .gstack/version-path pin in versionRel (#2462) cmdClassify's current-version read already resolved the .gstack/version-path pin, but versionRel — the repo-relative path fed to `git show origin/<base>:<path>` — was derived from the CLI flag alone (`argVal(args, "--version-path") ?? "VERSION"`). In a pinned repo with no explicit flag, base and current therefore read DIFFERENT files: current from the pinned file, base from the root VERSION. On a repo with no root VERSION, the base always read 0.0.0.0 — and the pinned-JSON handling never engaged, so a pinned package.json was read as raw text (currentVersion 0.0.0.0) and `write` would have overwritten the manifest with a bare version string. New resolveVersionRel() resolves the pin's REPO-RELATIVE form once (flag → .gstack/version-path first line → "VERSION"); classify, write, and repair all derive both the relative and absolute paths from it, so base and current reads can no longer diverge. The old resolveVersionPath (which returned an absolute path `git show` cannot use) is folded in. Unit tests (the ENG-OV6 spec case plus write/repair coverage): pin set + no flag → classify reads base AND current from the SAME pinned file (plain-text sub/VERSION and pinned frontend/package.json, both against a real git base with NO root VERSION anywhere), write updates the pinned manifest in place without inventing a root VERSION, repair treats the pinned JSON as single-source, and the explicit flag still overrides the pin (38 pass in test/gstack-version-bump.test.ts). Re-spec'd per ENG-OV6 from the report in #2462 (the originally-filed classify-read hypothesis was already handled; the live bug was the :138 versionRel derivation). Same fix shape independently identified in PR #2501 by @YiftahR. Fixes #2462 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: diff-scope glob coverage, honest exit contract, dirty-tree visibility (#2526, #2455, #2299) Three silent-skip classes in bin/gstack-diff-scope, each of which quietly disabled scope-gated reviewers in /ship and /review: 1. Pattern gaps (#2526, #2455). `*/api/*` required a path segment BEFORE api/, so a root-level api/ layout (Vercel serverless, Next.js pages/api at root) never set SCOPE_API — 63 serverless functions in the reporter's payments repo, none ever classified, the API-contract specialist silently skipped on every payment PR (it found a CRITICAL when run by hand). Same for root-level migrations/. And the Rails data_migrate gem's db/data/ data migrations — arbitrary Ruby run unattended against production data — fell through to plain BACKEND, so the [NEVER_GATE] data-migration specialist never got the chance to run. Added: api/*, migrations/*, db/data/*, data_migrations/*. 2. All-false was indistinguishable from "could not look" (#2526). New contract: empty change set → all false exit 0; >=1 match → flags exit 0; changed files with ZERO matches → SCOPE_ERROR=unmatched + the unmatched paths as comment lines + exit 2 (a new top-level layout now trips loudly instead of invisibly disabling reviewers); unresolvable base ref (shallow CI checkout) → SCOPE_ERROR=no_base + exit 2 instead of a green that means "we could not look". Every output line stays a shell-safe assignment or comment for sourcing consumers, which tolerate the nonzero exit today (source ... || true / eval). 3. Uncommitted work was invisible (#2299). /ship detects scope in Step 9, BEFORE it commits in Step 15, so the common start-work-then-ship flow ran the classifier against an empty diff and skipped every reviewer. The change set is now the UNION of committed diff + working tree + untracked files. Also from #2299: the single first-match-wins case made the nine flags mutually exclusive (Button.test.jsx set FRONTEND but not TESTS; util.test.ts the opposite) — each category now gets its own case, with BACKEND deliberately still excluding frontend component/view files. And file listing is NUL-safe (git diff -z), so non-ASCII paths no longer defeat extension globs via octal quoting. Deliberate behavior change (flagged in #2299): with independent flags, a backend test file sets BACKEND and TESTS, which can trip the security specialist's SCOPE_BACKEND gate on test-only PRs — errs toward more review, not less. Table-driven tests cover every glob class (root api/, nested api/, controllers, openapi, root/nested/prisma/db-migrate/db-data migrations, dual-category test files, auth, prompts, docs, plain classes), the four-state exit contract, dirty-tree + untracked visibility, and the non-ASCII path case (39 pass in test/diff-scope.test.ts). Fixes shaped by the reporters' patches: @grant-ship-it (#2526),…
Owner
|
Thank you — this was absorbed on main (credited in the v1.6x CHANGELOG entries; roster in PR #2604). Closing. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the
bun testnon-determinism in #2536. One-line guard, plus a comment explaining why it's there.The bug
gstack-gbrain-install --dry-runprints a plan and exits without cloning anything — but it makes a live request to github.com first:The check is gated on
VALIDATE_ONLY.--dry-runsetsDRY_RUN. So the dry-run path hits the network for a result nothing downstream uses — both dry-run branchesexit 0before any clone.When that curl loses a race for sockets or DNS,
fail()exits 3 and a dry run reports "cannot reach https://github.com" on a machine that is online.Direct proof, no test runner involved
60 concurrent
--dry-runinvocations against tempHOME/GSTACK_HOME:cannot reach https://github.comWhy this made the suite flaky
The three
gstack-gbrain-install D5 detect-firsttests each callrun(INSTALL, ['--dry-run'])and assertexpect(r.status).toBe(0). A fullbun testspawns many concurrent processes, the curl occasionally loses, and whichever D5 test was running goes red.Every symptom lines up: green in isolation (16/16, repeatedly), a different one of the three failing each run, sub-second failures rather than timeouts, and the observed exit code of exactly 3.
Validation
The documented Tier 1 gate, on a clean clone:
Ten consecutive clean runs against a 43% per-run failure rate is roughly a 0.4% coincidence.
Scope
Real installs are unchanged — they still get the fail-fast offline check rather than a hanging
git clone. Only the path that never clones skips it.I first blamed a
PATHleak and proposed defaultingSAFE_PATHin the test helper. That was wrong, and I've retracted it in #2536 — it would also have broken these tests on any machine where bun lives at~/.bun/bin, whichSAFE_PATHexcludes.Not addressed here
telemetry > connects to Supabase when config existsappeared once in my first sample and looks like the same shape — a real network call inside the free suite. I haven't investigated it and make no claim about it; noting it in case it's worth a look.Environment: macOS (Darwin 25.5.0), bun 1.3.13, clean clone at
94993f7.🤖 Generated with Claude Code