Repository navigation
fix(ship): version-bump skipped subdirectory manifests and wrote npm-invalid versions - #2531
Conversation
…invalid versions Two ways `gstack-version-bump` left a release half-done, both silent. 1. It only ever looked at ./package.json. A repo whose only Node package lives in a subdirectory (web/, app/, frontend/) has no root manifest, so classify reported pkgExists:false and every bump wrote VERSION alone -- leaving the manifest to be edited by hand, which is the exact drift this tool exists to prevent, in the one layout where it silently did nothing. The path now resolves --package-json-path -> .gstack/package-json-path -> ./package.json, so a subdirectory package is covered by a one-line pin. 2. VERSION is 4-digit MAJOR.MINOR.PATCH.MICRO and npm's semver is 3-component: npm rejects the fourth. Mirroring VERSION 1:1 into an npm-managed manifest writes a version npm will not install. When a package-lock.json sits beside the manifest -- proof npm actually manages it -- the MICRO is now dropped (0.1.26.0 -> 0.1.26) and the lockfile's two version fields are mirrored too (root and packages[""], which npm keeps in step and nothing else in a release does, so a lockfile left behind drifts one field per bump until someone runs npm). Pure JSON edit, no npm spawn, no dependency-tree churn. Without a lockfile nothing validates the field, so the historical 1:1 mirror is preserved -- gstack's own package.json carries 1.60.1.0 and keeps doing so. classifyState needed an expectedPkg parameter for (2): it defaults to VERSION, but for an npm-managed manifest it is the 3-component truncation. Otherwise a correctly-synced 0.1.27 reads as drift against 0.1.27.0 forever and every classify returns DRIFT. Step 12 of the ship skill documents the new behaviour; the three ship-skill golden fixtures are updated to match the regenerated hosts. 7 new tests covering both sides, including the negatives: the pin, the override, both lockfile fields, the correctly-synced-is-not-drift case, repair, and the no-lockfile 4-digit path that must not change. 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 |
|
Filed the golden-fixture first-run failure I mentioned above as #2532 — it reproduces on pristine Correcting one thing I wrote in the description: I attributed it to |
…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),…
|
Thank you — this was absorbed on main (credited in the v1.6x CHANGELOG entries; roster in PR #2604). Closing. |
… and 21 issues closed with receipts (#2632) * fix(plan-tune): reject never-ask on one-way ids at --write --check already ignored those prefs; --write still stored them and --stats counted them as a working NEVER_ASK. Refuse the write and count leftover on-disk prefs as INERT_ONE_WAY. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix: gstack-config get returns "" with exit 0 for keys that have no default Skill preambles read configuration with VAR=$(gstack-config get <key> 2>/dev/null || echo "<default>") and that fallback only fires on a non-zero exit. lookup_default ended in a catch-all that echoed "" and returned 0, so for any key missing from the table VAR came back empty and the default written right there in the preamble was unreachable. The skill then branched on a value it never specified: "skip entirely if QUESTION_TUNING is false", reached with QUESTION_TUNING="". Four keys that skills actually read had no entry and took that path: question_tuning -> callers assume "false" repo_mode -> callers assume "unknown" team_mode -> callers assume "false" transcript_ingest_mode -> callers assume "off" Each default above is the value the call sites already substitute in their own `|| echo` fallback, so this only makes reachable what was already intended. The catch-all now returns non-zero. That is deliberately scoped to the unknown-key arm alone: keys whose default is intentionally empty still exit 0, because "" is their real answer and their callers depend on it -- cross_project_learnings ("unset triggers the first-time prompt"), redact_repo_visibility ("empty falls through to gh/glab detection"), salience_allowlist, user_slug_at_*. Making every empty answer an error would have broken those. test/gstack-config-defaults.test.ts pins the class rather than the four instances: it parses the case arms and asserts every `gstack-config get <key>` site in the tree is covered, so adding a read without a default fails CI. It also pins the exit-code contract in both directions. Verified failing against the pre-fix script, where it names exactly those four keys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(redact): a typo'd subcommand no longer exits 0 having done nothing main() recognised exactly two subcommands and let everything else fall through to the stdin scan. On empty stdin that prints "(no findings)" and exits 0, so: $ gstack-redact install-prepush-hooks # plural typo gstack-redact scan — repo UNKNOWN (no findings) $ echo $? 0 No hook was installed, and the operator has every reason to believe the credential guard is armed. A guard that silently no-ops must never exit 0. Two smaller faults in the same dispatch, both of which lead people here: - There was no --help handler, so `gstack-redact --help` fell through to the scanner. Piping a credential to it scanned the secret and exited 3. - With no piped input and no --from-file, readInput() blocks on readSync(fd 0) until an EOF that an interactive terminal never sends. That prints nothing at all, so it reads as a hang rather than as "this is a filter, feed it". Now: --help/-h/help prints usage and exits 0; an unrecognised positional prints the offender and exits 1; a TTY with nothing piped in prints usage instead of blocking. "scan" stays accepted, because the human output header reads "gstack-redact scan — repo …" and that is what people type. Usage errors exit 1, deliberately not 2 or 3. Those mean MEDIUM and HIGH findings and callers gate dispatch on them, so a usage error exiting 2 would be read as "medium findings — prompt the user". A test pins that. Tests: 4 written failing first, then fixed. Full suite 7,722 pass / 0 fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(browse): one ambiguous ref no longer kills the whole annotated screenshot `snapshot -a` exits 1 with "Selector matched multiple elements" on most real pages, so /qa, /canary and /land-and-deploy silently produce reports whose screenshots do not exist. Plain `screenshot <path>` is unaffected. Refs are built as getByRole(role, {name}) and disambiguated with .nth() when role+name repeats. That disambiguation cannot fire for a node with NO accessible name: the locator degrades to getByRole(role) with no name filter, and the count driving .nth() is taken from the FILTERED aria snapshot while getByRole matches the unfiltered DOM. Measured on a live page: the tree surfaced 2 unnamed paragraphs, the DOM had 9. Landmarks (banner/main/contentinfo) and paragraphs are correctly unnamed per ARIA, so this is the common case rather than an edge case. boundingBox() then hits Playwright strict mode, and the catch allowlisted only timeout/closed/Target/Execution-context messages — so the strict-mode error was re-thrown and aborted every remaining annotation. Two changes: - `.first()` before boundingBox(), so an ambiguous ref draws a box on its first match instead of aborting. The heatmap path below has always tolerated this via a bare `catch {}`; annotate was the only path that could be killed outright. - the catch no longer re-throws on unrecognised messages. A box we cannot measure is a box we do not draw, never a reason to lose the rest of the page. Set BROWSE_DEBUG to see what was skipped. Also: `-o` passed without `-a`/`-H` was silently ignored (exit 0, no file), which reads as "screenshots are broken" rather than "you forgot a flag". It now warns and points at `browse screenshot <path>`. Verified by rebuilding both ways against the same page with 51 refs present: before — "Selector matched multiple elements", no file written after — exit 0, 229KB PNG Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(version-bump): missing or empty VERSION no longer repairs a fabricated 0.0.0.0 into package.json repair now fails with exit 2 when the VERSION file is absent or empty instead of folding to DEFAULT ("0.0.0.0") — which passed VERSION_RE and regressed package.json below where it started. classify gains an additive versionFileExists field so /ship can tell a real 0.0.0.0 from a fabricated one. Re-derived from PR #2612 under the generated-file screening rule. Fixes #2600 (repair half; the path-configurability half landed in v1.67 via #2531). Contributed by @Lockyer228 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(memory-ingest): --probe counts post-attribution, through the same gate --bulk uses probeMode previously stat'd every walked file, so setup-gbrain gated its silent bulk ingest on pre-filter counts that the write path would never ingest (#2394). The attribution decision now lives in ONE shared gate (sessionIsAttributable — cheap-parse: cwd extraction + memoized resolveGitRemote, never a full page build) used by BOTH probeMode and preparePages, so the two stages' post-attribution counts are structurally identical. ProbeReport gains skipped_unattributed; the probe prints what it excluded and --include-unattributed restores raw counts. The parity is pinned at the prepare stage (probe post-attribution == transcripts reaching import), deliberately NOT == final written. Re-derived from PR #2612 under the generated-file screening rule; the shared-gate design and the remote memo are additions from the plan review. Fixes #2394. Contributed by @Lockyer228 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(browse): allow CPU and network throttling for performance measurement Adds Emulation.setCPUThrottlingRate and Network.emulateNetworkConditions to CDP_ALLOWLIST. Motivation: diagnosing a real "uploads take 1-2 minutes" report, the only machine available was a fast developer workstation. Client-side processing measured 1.4s where the user experienced minutes, so the conclusion had to be reached arithmetically rather than observed. Throttling would have let the measurement reproduce the reporter's conditions directly. Both fit the existing posture rather than widening it: - Emulation already allows setDeviceMetricsOverride, clearDeviceMetricsOverride and setUserAgentOverride, which are equally mutating and scoped to the tab. - Neither method reads page content. setCPUThrottlingRate affects only timing; emulateNetworkConditions constrains traffic rather than inspecting it, so no request bodies, headers or cookies are exposed. Both are output: 'trusted' because they return no page-derived data. scope 'tab' for both, matching the surrounding Emulation entries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(session-update): lock pidfile records the live holder; hard TTL bounds every wedge (#2613) echo $$ inside the backgrounded subshell recorded the PARENT hook's PID — which exits immediately — so every subsequent session judged the lock stale and rm -rf'd a LIVE holder's lock, letting concurrent updaters run over each other. The pidfile now records ${BASHPID:-$(sh -c 'echo $PPID')} (macOS bash 3.2 has no BASHPID; the sh child's PPID is exactly this subshell). Staleness is now two independent detectors: PID liveness (as before, but against the real holder), and a 30-minute hard TTL on the heartbeat mtime — reclaimed regardless of kill -0, so a recycled PID or hung holder can't wedge the lock forever. The holder touches the pidfile after the pull and after setup, so a legitimately-slow run keeps itself alive. Empty and missing pidfiles are respected inside the TTL window (the mkdir→echo race) and reclaimed past it. Fixes #2613. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(browse): explicit windowsHide on every Bun.spawn site + census tripwire (#2575 residual) Bun.spawn sites were structurally outside the windowsHide census (it swept child_process bindings only). The runtime was already safe — native Bun hides consoles by default and bun-polyfill.cjs defaults windowsHide !== false since #2523/#2539 — but implicit defaults are exactly what regress silently. Every Bun.spawn/spawnSync in browse/src now carries the explicit flag (harmless on unix-only sites like Xvfb/xattr/open), and a second SWEEP in windows-spawn-hide.test.ts fails CI on any new flagless Bun.spawn site. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gbrain): brain worktree advances on the daily sync — no more silently stale brains (#2516) The daily pull refreshed only ~/.gstack itself, never the detached worktree at ~/.gstack-brain-worktree that gbrain actually indexes — so after setup the brain served stale pages forever unless setup-gbrain/sync-gbrain happened to run. brain-sync --once now advances the worktree once per 24h behind an ATTEMPT stamp (.brain-worktree-last-advance — a persistently-failing advance warns once a day, not at every skill boundary), inside the existing run lock and before any ingest step touches the worktree. The new gstack-gbrain-source-wireup --advance-only is built for the unattended cadence: git-only (no gbrain prereqs), pins every operation to the managed worktree (refuses paths that are not worktrees of the artifacts repo), refuses dirty worktrees, and never runs the force-remove recovery — a cron path must not be able to delete local changes. A static pin keeps the force-remove out. docs/gbrain-sync.md stops overclaiming the old cadence. Fixes #2516. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(memory-ingest): honor the per-remote deny/read-only trust policy (#2392) Transcript ingest now respects the same trust store as code import — the gate existed only in gstack-gbrain-sync's runCodeImport, so memory-ingest happily ingested transcripts from deny-listed repos. preparePages filters prepared transcript pages through ONE batch policy lookup (new 'get --batch' verb on bin/gstack-gbrain-repo-policy — the script owns URL normalization; the client adds repoPolicyTierBatch, one spawn for all distinct remotes, so large corpora never pay a 10s-timeout subprocess per remote). Outcomes match code-import semantics: read-only → clean skip (skipped_policy_readonly), deny → counted refusal (skipped_policy_deny), corrupted/unreadable store → HARD ERROR before any write (state, staging, egress receipt, and import all untouched) with the recovery command named — policy corruption must never read as successful ingestion. Artifacts are never policy-filtered (their git_remote is a project slug, not a remote). Fixes #2392. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(config): repo_mode keeps its empty no-default semantics (#2611 follow-up) The ported defaults table synthesized repo_mode → "unknown", but EMPTY is load-bearing for that key: gstack-repo-mode treats any non-empty answer as a user override and skips its own repo classification — the synthesized default turned the classifier into dead code (REPO_MODE=unknown everywhere; caught by test/gstack-repo-mode.test.ts via the wave's cross-agent blame protocol). repo_mode joins the empty-is-real carve-outs (empty output, exit 0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pair-agent): consent before killing a healthy headless daemon The pair-agent headed switch spawned 'connect --force-restart' unconditionally — auto-killing a live headless daemon (open tabs, cookies, logins) in direct contradiction of the iron rule it sits beside ('only an explicit --force-restart may kill a live daemon'). The CLI now captures daemon liveness BEFORE ensureServer (which can itself boot a fresh daemon) and relaunches only when the user passed --force-restart to pair-agent; otherwise it prints the tab count and continues against the existing daemon. The /pair-agent skill gains a matching one-way-door consent question (template half rides the wave's template block). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gbrain-status): MCP scoping is per-project, and project-local beats user scope hasRemoteOnlyGbrainMcp scanned EVERY project's mcpServers in ~/.claude.json, so one project's remote gbrain registration reclassified broken local engines as thin-client machine-wide. It now reads user scope plus only the cwd's nearest-ancestor project key. The precedence itself was verified empirically and hermetically (fake HOME + CLAUDE_CONFIG_DIR fixtures, claude 2.1.233): with both scopes defining gbrain, 'claude mcp get gbrain' reports Scope: Local config — PROJECT-LOCAL WINS. Both in-repo consumers assumed the opposite; brain-cache's endpoint resolution flips to nearest-ancestor-project-first, and the stale user-first pin in brain-cache-roundtrip now pins the verified precedence. (The user-first jq in the brain-sync preamble resolver gets the same swap in the template block.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(slug): gstack-slug matches remote-slug's owner-repo canonical form (live misfile bug) Found live during this wave's CEO review: bin/gstack-slug emitted SLUG=garrytan for this garrytan/gstack worktree while remote-slug correctly gave garrytan-gstack — decisions, timeline, ceo-plans, and learnings were filing into the wrong project store (observed polluting Context Recovery with another repo's decisions). Root cause: a stray empty ~/.git directory made the walk-up crown $HOME as the outermost project root; the remote lookup ran only against that root, failed silently, and the basename fallback cached 'garrytan' sticky. NOT worktree-specific — any strong marker on a non-repo ancestor triggered it. Fix: the walk now finds the outermost ancestor whose .git actually resolves an origin remote and derives owner-repo with remote-slug's byte-identical parse; marker-only ancestors keep anchoring the basename fallback but can no longer shadow a real remote. A new cache self-heal recomputes the poisoned shape (cached == basename of a marker root while a remote-bearing repo exists below), preserving legit #2212 stickiness. Nested-repo walk-up, no-remote and non-git fallbacks, and the SLUG=/BRANCH= eval contract are unchanged, pinned by a 10-case parity suite. Store migration for pre-fix data is tracked in TODOS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(brain-sync): per-record spool dir — the enqueue/drain race dies structurally Producers appended lines to .brain-queue.jsonl while the drain re-read and os.replace'd it; the in-code comment admitted a lockless append between the re-read and the replace was lost. Locks and rename-rotation designs were both reviewed and rejected (each retained a tail race); the shipped design is a maildir-style spool: one FILE per record in .brain-queue.d/ (tmp + atomic rename), the drain snapshots filenames, processes, and deletes exactly what it snapshotted. Writer and drainer never share an inode — nothing to race. Semantics: at-least-once (a crash between process and unlink re-drains; downstream content-hash dedup absorbs duplicates); retained (privacy-held) records keep their files; unparseable records are kept + warned, never destroyed. Legacy .brain-queue.jsonl migrates atomically on the next drain (crash-leftover .migrating files recovered too); status/drop-queue count both surfaces; discover-new writes spool records and advances its cursor per-record-written. The preamble's queue-depth line switches to spool count in this wave's template block. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bin-context): native slug fallback walks up like bash gstack-slug slugFromEnvironment derived the slug from the INNERMOST repo's origin while bash gstack-slug walks to the outermost project root — nested/vendored repos split their stores across the bash/native boundary (win32 hits the native path constantly). The native fallback now ports _outermost_project_root faithfully (strong/weak markers, outermost-strong-wins, 64-depth cap, fixed-point termination) plus the full resolution order: env override → walk-up → sticky cache with the #1125 self-heal → remote get-url → basename. Twelve mirrored scenarios drive BOTH implementations against the same fixtures and pin identical slugs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(next-version): git fallback queries the live remote, never mutates, and keeps 3-digit width The degraded path counted every remote-tracking ref on every remote — stale experiment branches and second remotes inflated version allocation, and a failed base read flipped 3-digit repos to 4-digit slots. Now: ls-remote --heads origin first (GIT_TERMINAL_PROMPT=0, 5s timeout, zero local ref mutation); on failure, local refs/remotes/origin ONLY with an explicit stale-refs warning; a failed base read zeroes at the LOCAL version file's width so a 3-digit repo allocates 0.0.1, not 0.0.1.0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(setup): hooks register the global-install path and re-point stale ones Registering hooks from a dev worktree baked that worktree's absolute path into settings.json — deleting the worktree left a dead hook erroring on every session stop, and the presence-only dedup (list-sources | grep) could never re-point it. setup's hook paths now route through _hook_install_path (global install preferred, source dir fallback), and the new ensure-event verb on gstack-settings-hook compares the registered command payload against canonical: identical → no write, different → single atomic replacement (never zero or two registrations). The plan-tune hooks had the same stale pattern and get the same fix without re-triggering their consent prompt. Also hardened: bun 1.3.13 turns an uncaught sync fs error in bun -e into a SILENT exit 0 — the registrar's write path now catches, prints, and exits 1, so a failed update can never report fake-green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(preamble): learnings capture is unconditional at completion (#2402) 43 of 44 learnings entries came from explicit /learn — the completion-status prose read 'if you discovered a durable project quirk... log it', which models treated as optional. The step now ALWAYS runs: review the session for durable learnings, log each one, and state 'No durable learnings this session' explicitly when the review comes up empty — an empty result, never a skipped step. Re-derived from PR #2612 under the generated-file screening rule. Fixes #2402. Contributed by @Lockyer228 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(scrape): untrusted-content warning on the page-fetching skills (#2441) /scrape and /skillify consumed page content with zero injection guidance — the CHANGELOG claimed coverage the skills didn't have. The warning now lives in ONE exported const (UNTRUSTED_CONTENT_WARNING in resolvers/browse.ts), embedded in the browse COMMAND_REFERENCE as before AND injected standalone into both skills via the new {{UNTRUSTED_CONTENT_WARNING}} token — single source, wording can never drift between surfaces. Re-derived from PR #2612 under the generated-file screening rule. (Structural isolation for skillify-generated code is tracked as its own TODO.) Fixes #2441. Contributed by @Lockyer228 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): checklist paths resolve from the installed skill root (#2518) /review Step 2 read .claude/skills/review/checklist.md — a path relative to the TARGET repo, which only resolves in gstack's own checkout. Every checklist/greptile-triage/TODOS-format reference (six across five templates — two more than the issue named, same class) now uses the installed-root form ~/.claude/skills/gstack/review/... that the templates' other references already use. The install-root class itself (non-default install dirs) is #1882, deliberately its own PR. Fixes #2518. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pair-agent): one-way-door consent question before a daemon relaunch (template half) The skill flow now checks daemon liveness before Step 4 and asks an explicit one-way-door question (tabs/cookies/logins are lost) before passing --force-restart — never proceeding on a vague reply. Pairs with the CLI-half commit that stopped pair-agent auto-killing live daemons. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(codex): resume does not amortize the ~21K session prelude (#2387) Measured (#2387): every codex exec call pays Codex's session prelude, and a resumed call came in slightly ABOVE a fresh one — resume buys continuity, never token savings. The skill now says so where the resume flow lives: prefer one codex call per skill, batch questions into it. Fixes #2387. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(upgrade): fast-forward first; reset --hard only behind a proved-safe gate (#2517) /gstack-upgrade went straight to stash + reset --hard origin/main. Now it tries git pull --ff-only --autostash first (the same policy session-update's auto-upgrade uses). The destructive fallback runs unprompted ONLY when both git status --porcelain AND git rev-list origin/main..HEAD are empty — a clean tree with unpushed local commits is NOT safe, reset destroys them. Anything else requires an explicit one-way-door confirmation that lists every dirty file and unpushed commit being discarded. Fixes #2517. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(preamble): brain-sync block counts the spool queue and resolves MCP project-first Two resolver halves deferred from earlier wave commits: the queue-depth line counts .brain-queue.d/*.json spool records (plus legacy lines until the drain migrates them), and GBRAIN_MCP_ENTRY_JQ swaps its operands to nearest-ancestor-project-first — matching the empirically verified Claude Code precedence (project-local beats user scope) instead of the backwards user-first assumption. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: regenerate SKILL.md docs + golden fixtures (single regen for the template block) Pure generator output for the six template/resolver commits above (learnings capture, untrusted-content warning, review paths, pair-agent consent, codex resume note, upgrade ff-only, brain-sync block) — bun run gen:skill-docs + --host codex + --host factory, with the three ship golden fixtures refreshed per the documented procedure. The three sidecar-path pins in gen-skill-docs.test.ts move to the new installed-root/$GSTACK_ROOT contract (#2518). Restores template freshness; full suite green from here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: TODOS.md — strike the six wave-fixed residuals, add two follow-ups The v1.67 adversarial-review residuals section shrinks to the one item the wave couldn't reach (iOS tap routing — needs real-device verification). New entries: skillify structural isolation (a prose warning is not a boundary for page-derived generated code) and the slug store migration (pre-fix sessions on stray-marker machines filed data under the degraded slug; post-fix reads go to the correct store, so history needs a merge/alias). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: align cross-cutting pins with the wave's contracts Three suites pinned pre-wave behavior: browse's gstack-config test asserted the old unknown-key ''/exit-0 shape (#2611 made it exit 1); the Windows-paths suite pinned O_APPEND enqueue atomicity (the spool design satisfies the same invariant via tmp + os.replace, one file per record — pinned in its new form); and nine carve-guard skeleton ceilings absorbed the #2402 unconditional-learnings prose (~450B per skill), bumped with measured values per the guard's own protocol. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: re-anchor the referenced-path scanner self-check to the gstack-rooted review refs The self-check pinned the review checklist as a class-1 alias-relative ref; #2518 moved those refs to the installed gstack root (class 2). The guard now proves the scanner sees them in their new class, so the class-2 assertion can't go vacuous. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: pin the wave's prose-tier behaviors (ship coverage-audit gap closure) The coverage audit found one regression-shaped gap: nothing pinned that the upgrade template's ff-only pull precedes the gated reset --hard (#2517) — a future template edit reverting to reset-first would fail nothing. Pinned: the ordering, the FF_OK gate, and the unpushed-commits check. Also pinned the two minor gaps: the {{UNTRUSTED_CONTENT_WARNING}} injection points in scrape/skillify (#2441) and brain-uninstall's spool-dir cleanup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: pre-landing review round — 8 auto-fixes + 8 accepted findings hardened The ship review army (4 specialists + red-team + checklist, 29 findings) produced 8 mechanical auto-fixes and 11 decisions; the accepted set: - win32 slug parity completed: lib/bin-context.ts gains the remote-first outermost walk + degraded-cache self-heal the bash side got this wave — the two implementations now agree on the stray-marker live-bug shape, pinned by shared fixtures (multi-specialist 9/10 finding). - probe honors the plan's bounded-read decision: 256KB prefix, extraction semantics mirrored from parseTranscriptJsonl so probe/prepare can never diverge on the same file (>1MB transcript test). - policy normalize parity: bash normalize() now matches canonicalizeRemote on .git/-trailing and uppercase-.GIT shapes (7-shape corpus pinned two ways) — a deny for those shapes could previously slip the transcript gate. - session-update reclaim is TOCTOU-safe (atomic mv-aside on both branches). - settings-hook: unparseable settings.json errors instead of being replaced with {}; ensure-event keys on (event, source) so matcher changes update in place — never zero or two registrations. - dot-only slug guard at both parse sites (hostile 'url = ..' can't escape projects/); enqueue tmp-file janitor (1h TTL, inside the drain lock); brain-sync .migrating never clobbered; drop-queue/status count .migrating; snapshot -o warning correct + surfaced in diff mode; version-bump test order-dependence removed; uninstall clears the advance stamp. Deferred with record: slug heal-probe cost sentinel (P3 TODO), FF_OK conflation (noted, misdiagnosis-only). 270 pass / 0 fail across the 10 touched suites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: adversarial round — the P0 finalize fail-safe and 12 hardened findings Three adversarial passes (Claude fresh-context, Codex chaos, Codex structured with P1 gate) on the full wave diff. Multi-source findings, all fixed: - P0: finalize_queue is now explicit-delete-only — a record is unlinked ONLY when classification proves it staged or dropped; a classifier crash, a missing class file, or a malformed pulled .brain-privacy-map.json (which previously nuked the whole snapshotted queue, remotely triggerable) now retains everything, warns, and re-drains next run. load_privacy_map treats corrupt maps as retain-all, never as empty. - next-version cannot silently drop a live claim: unreadable advertised refs get a targeted --depth=1 fetch + retry; still-unreadable claims surface as UNKNOWN warnings instead of duplicate-version silence. - session-update lock: ownership-checked EXIT trap (a TTL-reclaimed holder can no longer delete the new holder's lock) + a 5-min background heartbeat so a legitimately-slow pull/setup is never reclaimed while alive. - ensure-event collapses ALL same-(event,source) duplicates to one canonical entry; unique per-process tmp path; setup call sites surface (not swallow) the hardened refusals. - memory-ingest: --limit counts only policy-permitted pages (denied records no longer starve permitted ones); --probe applies the same policy filter as --bulk (skipped_policy_* fields on the report). - version-bump repair accepts a genuine literal 0.0.0.0 VERSION file. - slug heal restricted to the stray-.git shape — package.json-anchored wrapper roots keep their legit sticky identity (#2212 preserved). - brain-sync: idle fast path sees leftover .migrating records; unparseable spool records quarantine instead of warning forever; migration comment stops overclaiming the transition-window race. - CDP throttling justifications document override persistence (callers own restoration), pinned in the allowlist test. Deferred with record: deny retroactivity for already-ingested pages (P2 TODO, same semantics as the code-import gate); legacy-migration tail race (transition-window, requires pre-spool writers). 288 pass / 0 fail across the 10 touched suites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: regenerate SKILL.md docs + goldens (Windows-separator jq fix) Pure generator output for the brain-sync block's jq ancestor match now accepting backslash-formed Windows project keys — previously project-scoped brains were invisible on Windows while the TS scope resolvers saw them. Golden ship fixtures refreshed per the documented procedure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: codex verify-pass residuals — chunked cwd read, post-filter partial count, migrating depth The verify re-review passed the P1 gate (0 P1s) and left three residuals, all applied: transcriptCwdFromPrefix reads in chunks until one complete record (4MB cap) so a giant first prompt can't truncate mid-JSON and break probe/bulk parity; partial_pages derives from the FINAL prepared set instead of the whole scanned corpus; the preamble queue-depth line counts leftover .brain-queue.jsonl.migrating records like the status path does (regen + goldens included). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: bump version and changelog (v1.68.0.0) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: update project documentation for v1.68.0.0 BROWSER.md: fix the $B cdp example (positional JSON params, not --json; depth is the real CDP param) and add the new perf-throttling examples (Emulation.setCPUThrottlingRate, Network.emulateNetworkConditions) with their clear-override counterparts. USING_GBRAIN_WITH_GSTACK.md: the state-files table row for the sync queue now names the maildir-style spool dir .brain-queue.d/ that replaced .brain-queue.jsonl this release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: align memory-pipeline probe pins with the #2394 stage-count contract The paid-tier E2E pinned the pre-fix contract (probe headline = raw discovered). Probe now counts post-attribution — the same gate --bulk uses — with an explicit unattributed-skip line. Adds the --include-unattributed companion pin so all 9 fixtures stay accounted for. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(next-version): batch missing-tip fetches — one bounded round trip, never a per-branch crawl The targeted-fetch retry for branches whose advertised tip has no local object ran ONE git fetch per branch (10s cap each). On a shallow clone against a busy remote that crawls the network for minutes — CI's shard deadline killed the free suite mid-file. Missing tips now collect into a single batched shallow fetch (15s cap); refs still missing after the batch (one unservable ref fails the whole transfer) get a capped per-branch retry, and anything past the cap warns as an UNKNOWN claim instead of fetching. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(next-version): pin the batched fetch + make the offline-contract tests hermetic Two new G2 pins: N unfetched claim branches resolve with exactly ONE fetch spawn (PATH-shimmed git counts invocations), and one unservable ref no longer poisons the batch — live claims resolve via the bounded retry while only the ghost warns UNKNOWN. The #2545 offline-contract tests now run the CLI in a local fixture repo instead of the repo's own checkout: the checkout path did a live ls-remote against the real origin (operator-network-dependent, and the CI shard-deadline hang). The online-contract test gains a succeeding gh stub, so fallback:null is asserted deterministically instead of only when the operator happens to be authed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(redact-cli): derive the synthetic AWS-key fixture — no contiguous credential literal in source The CI quality gate scans every ADDED diff line with the redact engine, so the #2610 port's raw fixture literals failed the very gate they exist to test. The fixture is now assembled at runtime; the scanner still receives the identical bytes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(next-version): pin the fixture's host via origin-URL sniff — kills the last environment dependence The hermetic offline-contract fixture had no origin remote, so detectHost() fell through to auth probes: a machine with glab authed passed via the gitlab path while a bare CI runner read host:unknown (offline stays false there) and failed. The fixture now pushes to a local bare origin at a path containing github.com — the URL sniff pins host:github identically everywhere, asserted explicitly in both tests, with every git call still local. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: y$un_ <forrest.sun527@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: benjamin beres <benjamin.beres@bienpreter.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ricky <ricky@kinokostudio.com.hk> Co-authored-by: Connex Client Access <paul@paulkortman.com> Co-authored-by: henbima <henbima@gmail.com>
Two ways
gstack-version-bumpleft a release half-done, both silent. Found while shipping two versions of a real project through/ship.1. Subdirectory manifests were skipped entirely
The tool only ever looked at
./package.json. A repo whose only Node package lives inweb/,app/, orfrontend/has no root manifest, soclassifyreportedpkgExists: falseand every bump wroteVERSIONalone — leaving the manifest to be edited by hand. That's the exact drift this tool exists to prevent, in the one layout where it silently did nothing.The path now resolves
--package-json-path→.gstack/package-json-path→./package.json, mirroring the existingresolveVersionPath. A subdirectory package is covered by a one-line pin.2. It wrote versions npm rejects
VERSIONis 4-digitMAJOR.MINOR.PATCH.MICRO. npm's semver is 3-component and rejects the fourth, so mirroring 1:1 into an npm-managed manifest writes a version npm will not install.When a
package-lock.jsonsits beside the manifest — proof npm actually manages it — the MICRO is now dropped (0.1.26.0→0.1.26), and the lockfile's two version fields are mirrored as well: the document root andpackages[""], the entry describing the root package itself. npm keeps both in step and nothing else in a release does, so a lockfile left behind drifts one field per bump until someone runs npm. Pure JSON edit — no npm spawn, no dependency-tree churn.Without a lockfile nothing validates the field, so the historical 1:1 mirror is preserved. gstack's own
package.jsoncarries1.60.1.0and keeps doing so.This required an
expectedPkgparameter onclassifyState. It defaults toVERSION— but for an npm-managed manifest it's the 3-component truncation. Without that, a correctly-synced0.1.27reads as drift against0.1.27.0forever and everyclassifyreturnsDRIFT.Tests
7 new cases in two blocks, covering the negatives as well as the happy paths:
bun test test/gstack-version-bump.test.ts— 21/21bun test browse/test/ test/ make-pdf/test/with thetestscript's ignores) — exit 0, zero failures on a clean fork clone at94993f7test:e2e,test:evals, ~$4). Per CONTRIBUTING they needEVALS=1and can't nest inside Claude Code. Flagging rather than letting silence imply coverage.Note on the golden fixtures
Step 12 of the ship skill documents the new behaviour, so all three ship-skill goldens are updated.
Heads-up for whoever reviews: on a first run in a fresh clone,
host-config.test.tsreports the Codex and Factory goldens as failing. That's a test-ordering artifact, not a real failure —.agents/and.factory/are untracked generated files, andgen-skill-docs.test.tsregenerates them afterhost-config.test.tshas already compared them. A second run is green. Happy to file that separately if it's worth fixing.I verified the updated fixtures are byte-identical to what a clean clone generates, so no local-environment rendering rode along.
🤖 Generated with Claude Code