* 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#2317Fixes#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#2511Fixes#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#2250Fixes#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),
@mkyed (#2455), @ShahriarLak (#2299).
Fixes#2526Fixes#2455Fixes#2299
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(redact-prepush): don't re-scan commits a catch-up merge brought in
`remoteSha..localSha` is "everything new on this branch", which is not the
same as "everything new to the remote". Merge origin/main into a feature
branch and every commit main gained since that branch's last push becomes
an added line — content that is already published, already scanned, and
not this push's doing.
Two consequences, both observed:
· FALSE HIGH FINDINGS. A placeholder connection string in a fixture
someone else had already merged blocked an unrelated push as
db.url_with_password, telling the operator to rotate a credential
over a file they never touched. A guard that cries wolf on catch-up
merges is one people learn to bypass reflexively — which is exactly
how a real secret gets through.
· OVERSIZED SCANS. The SCAN_CHUNK_BYTES comment already records a
1,146,782-byte diff from "a feature branch catching up to a busy
main" blowing the engine's 1 MiB cap. Same root cause, treated there
as a size problem. Narrowing the range fixes the size too.
A two-dot range cannot express this: after merging main, neither the
remote tip nor the merge-base with main is an ancestor of the other, so
no single base excludes both.
The narrowed range is `rev-list localSha --not remoteSha --remotes`.
remoteSha STAYS the base — it is what git tells us the remote has, and is
authoritative in a way --remotes is not, since tracking refs can be
absent or stale. Using --remotes alone excludes nothing in a repo without
them, so every commit ever made reads as new. That is the same false
positive from the other direction, and it is what the existing test
"only NEW content is scanned (remote..local), not pre-existing" catches.
When excluding tracking refs changes nothing, this push has no catch-up
commits and the plain range already describes it exactly — so we defer to
it. That keeps every non-catch-up push on the original gitStrict diff
path, which is what #1946's fail-closed regression test exercises. A
narrowing that silently retired that test would be a worse trade than the
false positives it set out to fix.
Each commit is diffed alone. A merge's combined diff shows only content
present in no parent, so a secret introduced while resolving a conflict
is still caught while an ordinary merge contributes nothing.
Tests: 22/22 existing prepush tests still pass (two of them fail without
the remoteSha base and the defer-to-plain-range guard respectively —
verified by mutation). 5 new tests build real repositories on disk and
pin both directions: a catch-up merge no longer re-scans published
content, and secrets in new commits, in merge resolutions, and in
repos with no remote are all still scanned.
Absorbs PR #2592 by @Two-Six-Alpha-1115 (applied via git am -3; 5 new
tests pass in test/redact-prepush-scan-range.test.ts). Also narrows the
range for the rebased-force-push shape reported in #2573 — proven by the
follow-up regression test.
Co-authored-by: Scott <scott@peninsulaminerals.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(redact): parcel IDs are not phone numbers
A county tax-map parcel ID (APN) reads as a national-format phone number
to `pii.phone.e164` — the same collision class as the digit-only UUID
that `insideUuid` already guards. `12-3456789.000` matches, and so does
its normalized `123456789000`.
This is not a rare edge. Land, title and property-tax repos carry APNs
by the hundred; a single title branch pushed 2 MEDIUM findings, and the
same shape recurs in every fixture, mart and smoke in the domain. A
guardrail that cries wolf on the domain's primary identifier is one
people learn to wave through, which is how a real HIGH finding
eventually gets ignored.
The guard is deliberately narrow, in two tiers:
1. The DOTTED form is exempt on its own shape. No phone convention puts
a dot before a trailing 3-4 digit group after a 4-8 digit middle.
Hyphen-only variants (22-0001-000) are NOT shape-exempted — those
genuinely are phone-shaped.
2. A DIGITS-ONLY span is phone-shaped in isolation, so it earns the
exemption only by evidence: it must be the exact digit-normalization
of a punctuated APN within the surrounding window. Fixtures and marts
carry the pair; a real phone number has no such twin. This reads the
document's own evidence instead of guessing from digits.
Verified against the unmodified engine over inputs spanning every rule
family (AWS, PEM, GitHub PAT, email, IP, credit card, SSN, timestamp,
UUID, nine phone formats): exactly one behavior changed, the APN pair.
The new test pins both directions and was proven red under mutation —
stubbing the guard to `return true` (the dangerous blanket-exemption
failure) fails 12 of 15; `return false` fails 3.
Absorbs PR #2591 by @Two-Six-Alpha-1115 (applied via git am -3; 96 tests
pass across test/redact-parcel-id-false-positive.test.ts +
test/redact-engine.test.ts, and the pattern-lint / CLI / prepush-hook /
autoredact suites stay green).
Co-authored-by: Scott <scott@peninsulaminerals.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: prove the rebased force-push shape is scanned correctly (#2573)
#2573: after `git rebase origin/main`, the feature branch's remote tip
still exists locally (the pre-rebase tip) but is no longer an ancestor of
HEAD, so the old `remoteSha..localSha` range swept in every upstream
commit rebased onto — 1.14 MiB scanned instead of 0.27 MiB on the
reported repo, tripping the engine's 1 MiB cap and blocking the push
with engine.input_too_large (a HIGH that meant "the engine never ran",
not a finding).
The catch-up-merge narrowing (`rev-list localSha --not remoteSha
--remotes`) covers this shape too: the upstream commits are reachable
from origin/main's remote-tracking ref, which exists by construction —
you cannot have rebased onto origin/main without it. No residual gap
found; this lands the proof alone, end-to-end through the actual hook
binary with the real pre-push stdin protocol:
- fixture sanity: the pre-rebase tip exists locally, is NOT an ancestor,
and the OLD two-dot range would have swept in the upstream credential
- a clean rebased force-push passes — someone else's already-published
HIGH-shaped fixture no longer blocks it
- coverage is not narrowed: a HIGH in a rebased commit of our own still
blocks
- the scanned commit set is exactly the rebased own commits, so scan
size is proportional to OUR work, not to how busy main was
Analyzed non-gap, recorded in the test header: upstream commits in NO
remote-tracking ref cannot arise from the standard flow — rebasing onto
origin/<branch> requires the tracking ref, and rebasing onto a purely
local branch means the "upstream" content was never published, so
scanning it is correct.
Fixes#2573
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): ratchet four skeleton-size caps for the wave's preamble growth
The #2499 project-scoped-MCP jq entry-resolution adds ~340 bytes to every
brain-sync preamble block, and the wave's doc additions push four skills
3-91 bytes past their v1.64/v1.65 parity caps. Re-measured per the ratchet
protocol: plan-ceo-review 92,531 → cap 93,000; document-release 56,571 →
57,000; design-consultation 70,003 → 70,500; cso 75,891 → 76,400.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(todos): file the v1.67 fix-wave deferrals + ZeroEntropy sunset deadline
The wave plan's "Cut from this wave" list becomes a durable next-wave queue:
Windows omnibus mining, AskUserQuestion numbering redesign, typecheck infra,
Chromium profile migration, triggers-frontmatter decision, release-tag
upgrade semantics, and the 15-PR feature triage queue. ZeroEntropy's Sept 4
2026 shutdown is filed P1 (calendar-driven — gbrain's default embedding
provider).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* deps(browse): bump playwright + playwright-core to 1.62.1 (P0 #2554 vehicle)
Split from dependabot #2582 per plan OV3: this commit bumps ONLY
playwright (^1.58.2 -> ^1.62.1, lock resolves playwright@1.62.1 +
playwright-core@1.62.1 exactly). puppeteer-core, @huggingface/transformers,
marked, and socks are deliberately NOT bumped here — they land separately
(73b) gated on the ONNX sidecar smoke.
Why: bun.lock pinned playwright(-core)@1.58.2, whose Chromium build
macOS XProtect now kills on launch — browse is dead on macOS (#2554).
1.62.1 ships Chromium 151.0.7922.34 (headless shell v1234), which
launches clean.
Verification: bunx playwright install chromium (Chrome Headless Shell
151.0.7922.34 downloaded), then the full browse suite from browse/:
2016 pass / 32 skip / 2 fail across 129 files (133.9s). Both fails are
playwright-independent: data-platform.test.ts "rejects paths in cwd"
expects <cwd>/package.json to exist (browse/ has none; passes from repo
root, the shard runner's cwd — 15/15), and stealth-webdriver.test.ts
passes standalone (15/15) — a 5s-timeout flake under full-suite parallel
load.
Fixes the vehicle half of #2554 (self-heal lands next commit).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): XProtect launch-kill self-heal — classify, quarantine-clear, bounded reinstall (P0 #2554)
macOS XProtect definition updates can start SIGKILLing the exact Chromium
revision the lockfile pins (xprotectd killed revision 1208's headless shell
at spawn; the failure surfaced as a generic launch timeout). New
browse/src/xprotect-heal.ts heals it, once per process:
- Classifier (F9): positive signatures sourced from the #2554 report +
Playwright's launch-error format (signal=SIGKILL process-exit lines, and
launch timeout WITH a <launched> marker), negative-checked FIRST against
missing executable, spawn EACCES/EPERM, Linux sandbox denials, and plain
exitCode=1 crashes. darwin-gated.
- Heal (F4 one-shot, in-memory flag): clears com.apple.quarantine via
`xattr -dr` on chromium* revision dirs in the Playwright cache ONLY —
never a GSTACK_CHROMIUM_PATH bundle (probePoisonedChromiumBundle's scope
contract, double-gated at the call sites via usesCustomExecutable).
- Reinstall (E1/ENG-OV3): `bunx playwright install --force chromium` run
FROM THE GSTACK INSTALL ROOT — the root whose
node_modules/playwright-core/browsers.json pins the SAME chromium
revision our embedded playwright-core expects (a cwd-resolved bunx would
fetch latest and heal to the wrong revision). Bounded at 120s with a
process-GROUP SIGKILL on timeout; on any heal failure the caller gets the
ORIGINAL launch error + manual `bunx playwright install chromium`
guidance — the CLI never hangs.
- Verification (F9): post-install asserts the REGISTRY-derived executable
path exists (the revision dir playwright-core 1.62.1 expects), not merely
install exit 0.
- Logging (F11): every action emits one structured stderr line
([browse:xprotect-heal] JSON).
All three launch sites in browser-manager.ts (headless launch, headed
launchPersistentContext, handoff relaunch) route through
launchWithXProtectHeal with one post-heal retry. setup's
ensure_playwright_browser failure path gains the same quarantine-clear
(_clear_playwright_quarantine, Darwin-only, Playwright cache scope) before
its Chromium reinstall.
Tests: browse/test/xprotect-heal.test.ts — 33 pass (classifier both
polarities, one-shot guard incl. failed-heal consumption, custom-executable
scope, registry-revision expectation vs playwright-core browsers.json,
install-root revision matching, quarantine-clear scope, wrapper retry +
guidance surfacing). browser-manager unit/custom-chromium: 36 pass.
bridge-chromium-e2e real-launch smoke: 3 pass. setup-windows-fallback
ln-invariant: 9 pass. bash -n setup: clean.
Fixes#2554.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): daemon owns signal policy — handleSIG*:false at launch sites + SIGHUP shutdown (#2220)
Playwright's default handleSIGINT/handleSIGTERM/handleSIGHUP handlers close
Chromium the moment the DAEMON process receives a signal — which fights the
deliberate headless SIGTERM-ignore in server.ts (Claude Code's Bash sandbox
fires SIGTERM when the parent shell exits between tool invocations; the
daemon survives it by design, but Playwright's handler killed its browser
out from under it). All three flags are now false at all three launch sites
(headless launch, headed launchPersistentContext, handoff relaunch).
ENG-OV4: the daemon had NO process-level SIGHUP handler (only SIGINT and
the mode-aware SIGTERM handler), so flipping handleSIGHUP:false alone would
remove the ONLY Chromium cleanup on hangup. server.ts now routes SIGHUP to
activeShutdown — the same shutdown path SIGINT uses (closes Chromium,
releases ports, removes the state file).
Static tripwire (browse/test/launch-signal-flags.test.ts, house
grep-style): every chromium.launch/launchPersistentContext site must carry
the three flags (site count pinned at 3 so a NEW launch site trips it),
server.ts must keep the SIGHUP→activeShutdown route, and the deliberate
headless SIGTERM-ignore must still exist (the reason handleSIGTERM:false is
safe — pinned in the test's header comment).
Tests: launch-signal-flags 3 pass; browser-manager-unit 28 pass;
bridge-chromium-e2e real-launch smoke 3 pass.
Fixes#2220.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): absorb #2414 residuals — EPERM-alive liveness + Windows-dead test tripwires (re-derived)
Re-derive of PR #2414 (SYKhayyat) onto current main. Most of the PR already
landed in earlier waves: the tick-derived RESPAWN_GUARD_WINDOW_MS, the
spawnTerminalAgent windowsHide flag, the process-liveness regression tests,
and the browse/test import.meta.path sweep are all on main. Two pieces
remained:
1. isProcessAlive EPERM semantics (error-handling.ts): on the signal-0 path,
EPERM means the process EXISTS but we lack rights to signal it — that is
ALIVE. Returning false made callers that validate liveness before killing
(killAgentByRecord, the terminal-agent watchdog) skip the kill and respawn
around a survivor — the self-reinforcing one-leak-per-tick chain from
#2414/#2295. Matters for cross-user PID checks.
2. Six test/ files ADDED SINCE the PR reintroduced the exact Windows bug its
second commit fixed: `new URL(import.meta.url).pathname` yields
`/C:/Users/...` on Windows, so path.resolve prepends the cwd drive and
every tripwire ENOENTs instead of asserting anything (egress-receipt,
egress-lib, egress-receipt-wiring, gstack-egress-cli,
pty-skill-seeding-wiring, skill-census). All six now use
import.meta.path — Bun's absolute native path, identical arity.
The remaining #2414 piece — replacing the Windows tasklist probe with
signal-0 — lands as its own commit (#1952) on top of this shape.
Tests: the 6 touched test files 47 pass; process-liveness-windows +
error-handling 13 pass.
Re-derived from PR #2414 by @SYKhayyat. Fixes the residual of #2295.
Co-authored-by: SYKhayyat <shaulyoelkhayyat@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): isProcessAlive uses signal-0 on every platform — no more tasklist probe (#1952)
Replace the Windows tasklist shell-out in isProcessAlive with
process.kill(pid, 0), unifying all platforms on the POSIX idiom. Node maps
signal-0 to an OpenProcess existence check on Windows — and the Windows
daemon runs under Node (dist/server-node.mjs + bun-polyfill, the documented
oven-sh/bun#4253 fallback) — so the probe is portable.
Why the shell-out had to go, beyond the cosmetic conhost flash the watchdog
blinked into the foreground every 60s (#1952): a Bun.spawnSync that hits
its timeout still RETURNS with partial stdout, so the `.includes()` PID
match answered "dead" for LIVE processes under load — the false-negative
half of the #2414/#2295 leak chain. Signal 0 spawns nothing, cannot time
out, and is ~5 orders of magnitude faster (measurements in #2414). EPERM
still reports alive (process exists, we just can't signal it).
Layered on the post-#2414-absorb shape: test 3 in
process-liveness-windows.test.ts now asserts the probe is subprocess-free
on ANY platform (win32 exemption dropped), test 4's static tripwire loses
its error-handling.ts exemption (a `tasklist … PID eq` existence probe
anywhere in src/ now fails CI), and windows-spawn-hide.test.ts drops its
tasklist-in-error-handling needle (nothing spawns, which is stronger than
hiding the window).
Tests: process-liveness-windows + windows-spawn-hide + error-handling —
17 pass, 0 fail.
Fixes#1952.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): windowsHide sweep — flag every residual child_process site + full-census tripwire (#2160, #2415)
Add windowsHide:true at every remaining direct child_process call in
browse/src that could flash a console window on Windows:
- project-slug.ts (execSync gstack-slug)
- browser-skills.ts (cp.spawnSync git rev-parse)
- security-sidecar-client.ts (spawn — the LONG-LIVED Node sidecar, whose
missing flag parked a console window on the taskbar for the daemon's
whole lifetime)
- find-security-sidecar.ts (execFileSync node --version)
- meta-commands.ts (execSync git rev-parse in inbox + the osascript
activate call)
- browse-client.ts (cp.spawnSync git rev-parse)
- file-permissions.ts (execFileSync whoami.exe — Windows-only, ran bare)
- cli.ts (nodeSpawn osascript)
windows-spawn-hide.test.ts gains a SWEEP test on top of the existing
needles: it censuses EVERY child_process binding in src/ (static imports
incl. aliases, `await import()` / require destructures, and `import * as
cp` namespaces — 15 call sites across 10 files today) and fails CI on any
call without windowsHide within its options window. Exemptions carry
reasons — the one today is domain-skill-commands' interactive $EDITOR
spawn (stdio:'inherit'; CREATE_NO_WINDOW would detach a console editor
into an invisible console).
Tests: windows-spawn-hide 5 pass; file-permissions 19 pass; browse-client
28 pass; browser-skill-commands 29 pass (81/81 combined).
Fixes the app-side half of #2160; closes out #2415's residuals.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): fail-fast busy-daemon semantics — never auto-kill an alive pid, add --force-restart (#2219)
The CLI killed live-but-busy daemons: a heavy dev-mode page (cold-compiling
Next.js route, timed-out navigation still churning) kept the daemon from
answering /health longer than the old ~1s probe window (3 × 250ms), so the
connection-error path declared it dead, SIGTERMed a healthy process, and
every kill lost the session's tabs, cookies, and logins (reproduced 4/4 in
the #2219 report).
New contract (decision 9 / F10):
- probeHealthWithBackoff is budget-based: ~8s total
(HEALTH_PROBE_TOTAL_BUDGET_MS), 500ms intervals, each probe self-bounded
at 2s — sized to the observed busy windows.
- decideDaemonRestart (pure, exported, unit-tested) encodes the IRON RULE:
healthy-after-probe → retry the SAME daemon; alive+unhealthy →
"daemon busy — retry or --force-restart" + NONZERO exit, daemon untouched;
only a DEAD pid (or an explicit --force-restart) reaches kill+restart.
- --force-restart global flag (extractGlobalFlags): the one consent path
that replaces a live daemon, always announcing the state it costs.
- Wired at all three kill sites: sendCommand's connection-error branch,
ensureServer's stale-state path (which previously killServer'd any alive
pid whose single 2s health probe missed), and connect — which used to
"Kill ANY existing server" and now refuses to replace a healthy daemon
without 'browse disconnect' or --force-restart. pair-agent's internal
headed switch passes --force-restart explicitly (the mode switch is that
command's stated purpose), preserving its behavior.
E5 IRON RULE regression tests (busy-daemon-iron-rule.test.ts, real spawned
CLI + fake daemons + live sleep-pid stand-ins per the
busy-daemon-recovery.test.ts pattern): healthy daemon SURVIVES connect
(refused with guidance, pid alive, state file untouched); wedged-alive
daemon + plain command → busy report, nonzero exit, pid alive; wedged
daemon + --force-restart IS killed and a real replacement daemon serves the
command. Plus pure-function coverage of all four decision outcomes and the
~8s budget pin.
Tests: busy-daemon-iron-rule 8 pass (16.7s, includes a real daemon
lifecycle); busy-daemon-recovery + proxy-config + daemon-mismatch-refuse +
cli-lock + cli-start-final-healthcheck + cli-setsid-daemonize 39 pass.
Fixes#2219.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): `stop` on a dead daemon is success — never boots a daemon to stop it (#2254)
Two changes, one contract:
- Pre-server short-circuit: `browse stop` is handled BEFORE ensureServer().
No daemon state → "nothing to stop", exit 0. Stale state (dead pid AND
dead port) → clean the state file, exit 0. The old flow routed stop
through ensureServer(), which started a fresh daemon + Chromium
(multi-second boot, resource churn) purely so it could be told to shut
down — or crashed on the stale state.
- Reconnect branch: a connection error while sending `stop` where the pid
turns out dead (daemon died mid-flight, between the short-circuit check
and the send) is treated as SUCCESS — the desired end state (no daemon)
already holds — instead of the crash-restart path.
Integration tests (stop-dead-daemon.test.ts, real spawned CLI + scratch
BROWSE_STATE_FILE): stop with no state exits 0 and spawns nothing (a
spawned daemon would have written the state file); stop with a stale state
file (dead pid + verified-closed port) exits 0, cleans the state, and
spawns nothing.
Tests: stop-dead-daemon 2 pass; busy-daemon-iron-rule 8 pass;
busy-daemon-recovery 1 pass (11/11 combined).
Fixes#2254.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(upgrade): /gstack-upgrade stops a stale daemon — deferring to a busy one (#2551)
A browse daemon started before an upgrade keeps serving the OLD binary's
code after `git reset --hard` + `./setup` — the running process holds the
old executable, so users on the "new" version kept getting pre-upgrade
behavior (and config-mismatch refusals against the new CLI) until they
happened to stop it by hand.
New unconditional Step 4.8 in gstack-upgrade/SKILL.md.tmpl (+ regen, same
commit): compare the running daemon's recorded binaryVersion (the
readVersionHash git-SHA the server stamps into its state file) against the
freshly built browse/dist/.version.
- Stale + responsive → `browse stop` (graceful), telling the user
old→new hash; the next command boots a daemon on the new binary.
- Stale + BUSY → DEFER (decision 10): never kill a busy daemon during
upgrade. Print the old→new hash and the escape hatch —
`browse stop` when it finishes, or `browse --force-restart stop` now.
- Dead pid / matching hash / no state → silent no-op.
Tests: skill-validation + gen-skill-docs 731 pass after regen.
Fixes#2551.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): terminal-agent allocates from the fixed port scan range, not port:0 (#2314)
The terminal-agent bound `Bun.serve({ port: 0 })` and kept that OS-assigned
port for its whole (weeks-long) lifetime. `port: 0` draws from the OS
EPHEMERAL range (49152-65535 on macOS) — the exact pool every short-lived
`app.listen(0)` test server draws from — so the agent squatted ports that
test suites expected to receive and silently absorbed their traffic as
phantom 404s (two squatting daemons verified in the report).
Fix per decision 8: extract the main server's port allocation into
browse/src/port-allocator.ts (checkPortAvailable / isPortAvailable /
findAvailablePort + the 10000-60000 range constants and the actionable
sandbox-vs-occupied error formatters, all verbatim from server.ts) and make
BOTH long-lived listeners use it — server.ts's findPort is now a thin
findAvailablePort(BROWSE_PORT) wrapper, and terminal-agent's buildServer
takes a pre-allocated port from the same range. No terminal-port consumer
carries a range assumption (they read the port file), verified by grep.
Tests: terminal-agent-port-range (new — allocator stays inside
10000-60000 and below the 49152 ephemeral floor, explicit-port honored,
occupied-explicit throws, static tripwires pin no-port:0 in
terminal-agent.ts and the shared wrapper in server.ts) + findport +
terminal-agent-integration/session-routing/detach-reattach +
dual-listener: 67 pass, 0 fail.
Fixes#2314.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): capture daemon stdout/stderr to browse-daemon.log + Windows polyfill spawn fixes (re-derived from #2461)
The detached daemon's stdout/stderr were wired to 'ignore' on every
platform, so every console.error('[browse] FATAL: ...') from a Chromium
crash, uncaughtException, or unhandledRejection was discarded at the OS
level — a crash-and-respawn looked identical to every other dropped
session, with nothing on disk recording why. Both spawn paths now redirect
to <stateDir>/browse-daemon.log (append mode, accumulates across respawns):
the Unix path via an fd from openDaemonLogSink(), the Windows path by
opening the fd INSIDE the node -e launcher string (an fd opened in cli.ts
would not cross the spawn boundary). Unwritable state dir falls back to
'ignore' rather than failing the launch.
Capturing daemon output is what surfaced the PR's second fix, still valid
on current main: bun-polyfill.cjs's Bun.spawn/spawnSync called Node's
child_process with a bare command name, which Windows can't resolve without
PATHEXT lookup ("spawn bun ENOENT" from the terminal-agent respawn path).
Routed through cross-spawn on win32 (now a direct dependency; already in
the tree transitively via @modelcontextprotocol/sdk) — the PR verified
empirically that shell:true does NOT neutralize cmd.exe metacharacters
reachable via `$B skill run` arg passthrough, and that Node refuses .cmd
spawns without a shell (CVE-2024-27980), so cross-spawn's combined PATHEXT
resolution + argument escaping is the only correct shape. The PR's third
fix (resolveDisconnectCause throwing "browser?.process is not a function")
already landed on main via the #2085 typeof guard — not re-applied.
F6 log hygiene (daemon-log-hygiene.test.ts): needle tests pin the log
wiring on both spawn paths (and that stdio 'ignore','ignore','ignore'
never returns), that bun-polyfill stays on cross-spawn with no shell:true,
that NO console.* call in src/ passes a token value (interpolated or bare
arg), and that the page-content carrier modules (tab-session, buffers,
content-security, activity) stay console-free — so neither AUTH_TOKEN nor
unsanitized page-derived strings can reach browse-daemon.log.
Tests: daemon-log-hygiene + bun-polyfill + windows-spawn-hide +
cli-setsid-daemonize 21 pass; stop-dead-daemon + busy-daemon-iron-rule
(exercises a REAL daemon boot through the new log-fd wiring) 10 pass.
Re-derived from PR #2461 by @phuttimatebenchanakatkul.
Co-authored-by: phuttimatebenchanakatkul <phuttimatebenchanakatkul@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: raise gbrain version-probe timeout to 10s on Windows
On Windows the gbrain CLI is a .cmd shim that runs `bun run cli.ts`.
A cold spawn takes over the 2s timeout in resolveGbrainBin (warm runs
are ~700ms), so the probe times out, localEngineStatus classifies the
engine as "no-cli", and the 60s status cache then serves that false
negative to every skill preamble and sync run. /sync-gbrain skips the
memory stage with "gbrain CLI not on PATH" even though the CLI works.
Give the shim 10s of headroom, gated on NEEDS_SHELL_ON_WINDOWS so
POSIX keeps the cheap 2s probe. Applies to both resolveGbrainBin and
readGbrainVersion.
Observed on Windows 11, bun 1.3.14, gbrain 0.42.59.0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): remove the dead security shield + unfed /health.security (re-derived from #2557)
The sidebar's SEC shield has been dead UI since the PTY terminal rewrite:
nothing set its data-status, nothing unhid it, and the /health.security
field behind it read getStatus() off ~/.gstack/security/session-state.json
— a file whose ONLY writer (sidebar-agent.ts) was deleted with the chat
path. /health therefore reported a permanent 'inactive', or a stale
FALSE-GREEN 'protected' wherever an old state file survived on disk (a
single unit-test run was enough to plant one). A green shield sourced from
leftover state reads as "no threats detected" when the real state is "not
measured" — the same fail-open class as #2026.
Removed (dead surfaces only): the shield markup/CSS and the stale
sidepanel.js comment; the /health security field and server.ts's getStatus
import; getStatus / SecurityStatus / StatusDetail / SessionState /
read+writeSessionState (and security.ts's dead child_process import); the
session-state + getStatus unit tests — including the round-trip test that
wrote real fixture data into ~/.gstack and left /health green forever.
(The PR's security-sidepanel-dom.test.ts deletion already happened on main
via #2230; its resolveDisconnectCause guard landed via the #2085 typeof
fix. Neither re-applied.)
Kept, per ENG-OV9 — security.ts has LIVE consumers: the pure combiner
(combineVerdict + THRESHOLDS), canary utilities, and extractDomain stay;
server.ts's /pty-inject-scan L4 path (isSidecarAvailable + scanWithSidecar)
is untouched. browse/test/server-security-surface.test.ts pins BOTH
directions: the dead surface stays dead (no /health security field, no
getStatus import, no reader of the security session-state file, shield
markup gone) and the live half stays live (sidecar wiring in server.ts,
combiner/canary exports in security.ts, /health carries no token — the
v1.63 regression wall). A future re-feed from LIVE signals must update
that test deliberately rather than resurrect the state-file path.
F13 (same commit): CLAUDE.md's Sidebar security stack section, ARCHITECTURE.md's
prompt-injection Visibility + critical-constraint paragraphs, and
BROWSER.md's security section now describe the removed surfaces as history,
not live features.
Net -166 lines. Tests: server-security-surface + security +
security-adversarial(+fixes) + security-integration + server-auth 114 pass;
sidepanel-* + extension-token + extension-sender-auth 58 pass / 2 skip.
Re-derived from PR #2557 by @frederik-kaster-noygear.
Co-authored-by: Frederik Kaster <frederik.kaster@noygear.ai>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): capture browser-skill subprocess output via temp files, not pipes (core of #2559)
Under a loaded parent, the FIRST piped Bun.spawn in a process
intermittently yields an empty stderr even though the child wrote it and
exited 0 — measured identically with readers-attached-before-exit and with
a manual getReader() drain, so it's loss inside the async pipe plumbing,
not read ordering. It flaked `$B skill test` (bun test writes its banner to
stdout and the pass/fail summary to stderr, so a dropped stderr silently
degraded the result to just the banner) and would blank a skill's JSON
result on `$B skill run` while still reporting success.
New runToFiles() points the child's stdout/stderr at temp files via
Bun.file() (never raw fds — closing self-opened fds around a spawn tripped
Bun's fd bookkeeping into a stray epoll_ctl EBADF), awaits exit, then reads
the files: the kernel has flushed everything by child exit, so the
post-exit read is complete, and chatty children can't stall on a full pipe
buffer. Both handleTest and spawnSkill route through it (timeout + capped
read preserved via timeoutMs/maxStdoutBytes). Bun.spawnSync would also
capture reliably but would deadlock: a spawned skill calls back into this
same daemon on GSTACK_PORT.
The `tests passed for "<name>"` fallback is gone — a passing bun test
always prints a summary, so exit 0 with no output means the run was NOT
captured, and handleTest now throws instead of fabricating success. The
E2E assertion checks both stream halves (banner + summary + "Ran N tests")
instead of the loose alternation whose `tests passed` branch matched the
synthetic fallback vacuously. A static tripwire pins the structure:
runToFiles owns the module's ONLY Bun.spawn, and no site reads child
output via stdout:'pipe' / new Response(proc.stdout) / getReader().
Scope: the PR's repo-wide test-file sweep is deliberately not absorbed —
this is the core only, per the wave plan.
Tests: browser-skill-commands + browser-skills-e2e + browser-skill-write
74 pass, 0 fail.
Re-derived from PR #2559 by @frederik-kaster-noygear.
Co-authored-by: Frederik Kaster <frederik.kaster@noygear.ai>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(browse): allow Emulation.setEmulatedMedia on the CDP allowlist (re-derived from #2419)
Adds Emulation.setEmulatedMedia to the deny-default CDP allowlist:
tab-scoped, trusted output (returns an empty result — no page content).
Unlocks media type/feature overrides (prefers-color-scheme,
prefers-reduced-motion, prefers-contrast, forced-colors) via `$B cdp`, so
dark-mode and a11y CSS branches are testable without a headed toggle. Like
setUserAgentOverride, the override persists on the tab until cleared with
an empty features array — noted in the entry's justification.
Registry test pins the entry (allowed + tab scope + trusted output); the
PR's VERSION/CHANGELOG stamping is stripped per wave convention (versioning
happens at /ship).
Tests: cdp-allowlist 7 pass, 0 fail.
Re-derived from PR #2419 by @meshailabs.
Co-authored-by: meshailabs <devsupport@meshai.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): create node bundle output directory
* fix(deps): bun-patch playwright-core 1.62.1 — windowsHide at launch + taskkill (#2160, #1989)
The repo's first patchedDependencies entry. playwright-core's bundled
process launcher (lib/coreBundle.js in the 1.62.x layout) spawns browser
children without windowsHide — Node defaults it to FALSE for
child_process.spawn — so Chromium children could flash a console window on
Windows, and its force-kill path shells `taskkill /pid <pid> /T /F`
through cmd.exe with the same omission. Both sites now pass
windowsHide: true via patches/playwright-core@1.62.1.patch (generated with
`bun patch` / `bun patch --commit`).
Coherence verified end-to-end: rm -rf node_modules && bun install applies
the patch cleanly (both sites present in the reinstalled tree), and a real
chromium.launch() through the patched bundle works.
browse/test/playwright-core-patch.test.ts pins the three-legged invariant
statically — package.json's patchedDependencies key is VERSION-KEYED
against the installed playwright-core, the patch file exists and carries
both sites, bun.lock records the patch, and the installed bundle actually
has it applied — so a future playwright bump that forgets to re-target the
patch fails CI with the exact key to regenerate (revert pairing: dropping
the c25 bump requires dropping this patch too).
Tests: playwright-core-patch 4 pass, 0 fail.
Fixes#2160, #1989.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(preamble): probe AGENTS.md for skill routing; team-init resolves GSTACK_ROOT (#2500)
The HAS_ROUTING preamble probe only checked CLAUDE.md, so repos that route
skills via AGENTS.md (the cross-harness convention for Codex, Cursor, and
generic agent hosts) reported HAS_ROUTING: no and got nagged to create
CLAUDE.md. The probe now iterates CLAUDE.md and AGENTS.md.
gstack-team-init's required-mode enforcement (the CLAUDE.md verification
snippet and the generated .claude/hooks/check-gstack.sh) hardcoded
~/.claude/skills/gstack, false-blocking installs living at any other host's
global root or the migrated ~/.gstack/repos/gstack location. Both sites now
resolve the install root: GSTACK_ROOT env first, then every registered
host's globalRoot, then the migrated repo path. Install instructions keep
pointing at the canonical Claude location.
test/routing-probe.test.ts pins both: rendered-preamble assertions plus a
live execution of the extracted probe block (AGENTS.md-only repo => yes),
and a drift test that requires every hosts-registry globalRoot to appear in
team-init's probe list.
Re-derived from PR #2500 onto current code (the PR's 52-file regen was
discarded and regenerated here). Contributed by @gamerey43.
Fixes#2500
Co-authored-by: gamerey43 <gamerey43@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(resolvers): empty find must not fall through to cwd (#2483)
find ... | xargs ls -t runs ls with NO operands when find matches nothing —
GNU xargs still invokes the command once, and ls -t with no operands lists
the current directory. Three sites misfired on fresh installs (no ceo-plans /
checkpoints / plans yet), exactly where a wrong answer is least likely to be
recognized: review.ts's plan fallback silently adopted a random cwd .md as
"the plan", and Context Recovery listed unrelated cwd files as RECENT
ARTIFACTS / LATEST_CHECKPOINT.
All three now use xargs -r ls -t, mirroring the shape the sibling
bin/gstack-codex-session-import fix (#2482) landed with: -r pins the BSD
skip-on-empty behavior on GNU too, and BSD xargs accepts -r as a no-op.
test/empty-find-fallthrough.test.ts pins it four ways: no bare xargs ls -t
in scripts/ or bin/, both rendered Context Recovery sites guarded, a live
execution proving an empty checkpoints dir yields no checkpoint (not a decoy
cwd file), and a rendered-SKILL.md sweep.
Re-derived from PR #2483 onto current code. Contributed by @tranthanhnhatkhoa.
Fixes#2483
Co-authored-by: tranthanhnhatkhoa <tranthanhnhatkhoa@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(codex): retire deprecated web-search flag behind one CODEX_WEB_SEARCH_FLAG constant (#2525)
codex >=0.144 deprecates the legacy --enable-based web_search_cached
spelling (web search is on by default; --enable <FEATURE> now means
-c features.<name>=true, verified against codex 0.147.0's exec --help).
Every gstack codex invocation now passes -c 'web_search="cached"' instead.
The flag previously lived inline at 19 raw sites. Per ENG-OV11a the 10
template-inline sites (autoplan/SKILL.md.tmpl x4, codex/SKILL.md.tmpl x6)
convert to a shared {{CODEX_WEB_SEARCH_FLAG}} token first, so ONE resolver
constant (CODEX_WEB_SEARCH_FLAG in scripts/resolvers/constants.ts) now
covers all sites: review.ts x5, design.ts x3, the token resolver in
utility.ts, and the tool-map helper comment.
codex/SKILL.md.tmpl's web-search prose guarantee is corrected: the -c form
explicitly overrides a top-level web_search config (the legacy flag yielded
to it), and native codex review disables web search regardless of
configuration, so the flag is a no-op on the default Review path.
test/codex-web-search-flag.test.ts is the safety net: repo-wide grep
tripwires assert NO rendered SKILL.md/section/golden and NO source file
carries the deprecated spelling, and that the token resolves in rendered
output.
Fixes#2525
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(question-tuning): interpolate the absolute question-registry path (#2489)
The Question Tuning preamble pointed agents at a RELATIVE
scripts/question-registry.ts in the same sentence whose ${bin} path renders
absolute. Agents run with cwd in the USER'S project — the relative lookup
never resolves, silently fails, and the documented {skill}-{slug} fallback
fabricates a singleton question_id every time (one observed
/plan-eng-review session: 21/21 unregistered ids, so no per-question
preference can ever attach).
The resolver now interpolates ctx.paths.skillRoot the way sibling resolvers
interpolate bin paths: ~/.claude/skills/gstack/scripts/question-registry.ts
on Claude, $GSTACK_ROOT/scripts/question-registry.ts on env-var hosts.
test/question-tuning-registry-path.test.ts asserts the rendered path per
host, forbids the bare relative shape, and checks the target file exists in
the install tree.
Fixes#2489
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(resolvers): slug-canonical branch form in file-path positions (#2550, #1851)
Branch-name-to-filename had incompatible rules across writer and readers:
gstack-review-log WRITES <branch>-reviews.jsonl with the gstack-slug
canonical form (tr '/' '-' then tr -cd 'a-zA-Z0-9._-', bin/gstack-slug:178),
but Context Recovery PROBED it with raw $_BRANCH from git branch
--show-current — so for any branch containing a '/' the REVIEWS line never
fired (#1851's reader half of #1127). The probe now uses ${BRANCH:-unknown},
the canonical value the gstack-slug eval on the block's first line already
sets. review.ts's plan content-search BRANCH gains the missing tr -cd half
so it matches the same canonical pipeline.
Full audit of the 5 raw $_BRANCH interpolation sites in scripts/resolvers/
(E3): generate-context-recovery.ts:16 (reviews.jsonl path) -> canonical
BRANCH; :19/:21 (timeline.jsonl content greps) KEEP raw $_BRANCH because the
timeline writer (preamble's gstack-timeline-log call) stores the raw branch
in the "branch" field — slugging the reader would break that pairing;
generate-preamble-bash.ts:29 (display echo) and :97 (timeline data write)
keep raw by design. The *-$BRANCH-design-*.md family (review.ts:313 + 3
plan-review templates) is a consistent tr '/' '-' writer/reader pair and is
deliberately untouched.
test/branch-slug-hygiene.test.ts pins the discipline: a rendered-output
sweep forbids raw $_BRANCH adjacent to a path separator or as a filename
prefix in ANY generated SKILL.md/section, and a live round-trip on a
feat/slash branch proves gstack-review-log's write is found by the rendered
probe (with the raw-form shape as a negative control).
Reader-side fix folded from PR #1851. Contributed by @harjothkhara.
Fixes#2550Fixes#1127
Co-authored-by: harjothkhara <harjothkhara@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ship): review fix loop stays in one invocation, bounded at 3 cycles (#2391)
The pre-landing review committed its fixes, then STOPPED and told the user
to run /ship again — 5-10 manual invocations on a branch with a few
auto-fixable findings, violating /ship's fully-automated contract. There is
no user decision between those invocations; each rerun just repeats the
workflow until a review pass produces no fixes.
ship/sections/review-army.md.tmpl item 7 now makes the loop explicit: after
committing fixes, re-run the test suite (Step 5) and this review (Step 9
items 2-6) in the SAME invocation, repeating until one full pass applies
zero fixes, then continue to Step 12. Bounded at 3 fix cycles — a review
that will not converge STOPs with a report of which findings keep
reappearing (a genuine blocker), never with a rerun request.
test/ship-review-loop.test.ts asserts no rendered ship surface (section +
all three host goldens) carries the STOP-and-rerun shape and that the
bounded loop language renders.
Fixes#2391
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(codex): model round-trip probe — an unusable configured model fails fast with guidance (#2477)
The auth probe accepts 'auth exists' as readiness, but a ChatGPT account
with a stale model pin in ~/.codex/config.toml passes it and then EVERY
mode dies with an HTTP 400 ('The <model> model is not supported when using
Codex with a ChatGPT account') and no pointer to where the model came from
— one report burned ~40 minutes and four invocations plus a strings dump
of the binary before finding the one-line config fix.
bin/gstack-codex-probe gains _gstack_codex_model_probe: a short
codex exec 'reply OK' round trip with the configured model, gated behind
the cheap auth probe at all three preflight sites (codex Step 0.5, the
shared codexPreflight in scripts/resolvers/constants.ts — which grows a
model_unusable CODEX_MODE branch — and autoplan's availability chain).
Verdicts: MODEL_OK (cached 1h, keyed on config.toml + auth.json mtimes so
a pin edit or re-login re-probes immediately), MODEL_UNUSABLE (exit 1,
prints the rejection plus HINTs at the model= pin and the
[notice.model_migrations] table), MODEL_PROBE_INCONCLUSIVE (timeout or
transient: FAIL-OPEN so network luck never wedges codex mode).
The 'Model not supported (HTTP 400)' Error Handling entry already shipped
in v1.64.0.0; Step 0.5's prose now routes MODEL_UNUSABLE to it.
test/codex-model-probe.test.ts drives all four behaviors against a stubbed
codex binary (invocation-counted cache hit, hint content, fail-open
polarity, mtime invalidation).
Fixes#2477
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(review): skip nested codex spawns when already running under a Codex host (#2519)
/review executed inside a Codex host spawned the codex specialist passes
anyway — the same model reviewing itself, at multiplied cost (observed:
15M tokens for a single /review).
Detection per maintainer decision 7: a presence probe of the Codex session
env. A live Codex session exports CODEX_THREAD_ID and CODEX_SANDBOX into
every shell it spawns — verified during implementation against a live
`codex exec 'env | grep -i codex'` capture on codex 0.147.0
(CODEX_THREAD_ID, CODEX_SANDBOX=seatbelt, CODEX_SANDBOX_NETWORK_DISABLED=1,
CODEX_CI=1). The shared codexPreflight in scripts/resolvers/constants.ts
(consumed by all three review.ts army blocks: adversarial, codex plan
review, codex doc review) now yields CODEX_MODE=under_codex and instructs
exactly one printed notice — '[running under Codex — nested codex passes
skipped; set GSTACK_FORCE_CODEX_REVIEW=1 to force]'. The override env var
forces the nested passes for users who really want them. codex/SKILL.md.tmpl
Step 0.5 gains the same probe: /codex under a Codex host stops with a
one-line notice, since its whole value is a SECOND model's opinion.
test/codex-under-codex-detection.test.ts runs the rendered preflight bash
under all four env combinations (thread-id only, sandbox only, forced,
clean) and asserts the probe + notice render in the three preflight
consumers and the codex skill.
Fixes#2519
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(build): convert MSYS paths for Bun in the Windows server-bundle build (#2452)
browse/scripts/build-node-server.sh resolves GSTACK_DIR with pwd, which
under MSYS/Git Bash yields a /c/... style absolute path that Bun cannot
open ('FileNotFound opening root directory') — the Windows Node-server
bundle build died at the first bun build. Convert via cygpath -m on
MINGW/MSYS/CYGWIN before deriving SRC_DIR/DIST_DIR.
Re-derived from PR #2452, taking only the cygpath build half — the PR's
icacls principal-ambiguity half already landed on main
(browse/src/file-permissions.ts's SID-form principal). Verified the build
bug still exists on current code before absorbing (build-node-server.sh:10
had no conversion). Contributed by @chiragborse1.
Co-authored-by: chiragborse1 <chiragborse1@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): update four main-side gen-skill-docs assertions to the T6 contracts
Three contracts moved under this theme and the assertions pinned the old
shapes:
- The routing-probe assertion expected the single-file
'grep ... CLAUDE.md' shape; #2500 made the probe iterate CLAUDE.md AND
AGENTS.md, so it now asserts the for-loop + quoted $_RF shape.
- The three Claude-output Codex-path bans tripped on ~/.codex/config.toml,
which the shared codexPreflight's model_unusable branch (#2477) now
documents in rendered output. That path is the Codex CLI's own config
file — the same user-facing class as the already-exempt
~/.codex/sessions/ — so it is scrubbed before the host-path ban, with the
reasoning recorded next to the existing exemptions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sync-gbrain): dream pack-capability WARN anchors to the graph phase
Fixes#2341. classifyDreamOutcome matched the bare phrase "does not declare
this phase", but gbrain's only emitters are the CONTENT phases
(extract_atoms, synthesize_concepts) — which the default base packs
legitimately skip while resolve_symbol_edges still runs. Every base-pack
brain therefore got the pack-capability WARN with its wrong, costly
remediation ("switch schema packs"), masking real graph problems. The match
now anchors to the graph phase (resolve_symbol_edges/extract_code_symbols);
a base-pack run with a built graph is clean, and a resolved-0 run gets the
honest 0-edge diagnosis.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(setup): install office-hours into the external-host runtime roots
Fixes#2449. plan-eng-review's inline office-hours step reads
$GSTACK_ROOT/office-hours/SKILL.md, but the codex/factory/opencode runtime
roots never installed it — the documented path pointed at nothing on every
external-host install (Codex on Windows was the reported repro). Each
runtime-root creator now links its host-rendered gstack-office-hours
SKILL.md at office-hours/SKILL.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(gbrain-install): name the real fix when an npm-installed bun breaks the shim
Fixes#2487. `npm i -g bun` puts POSIX/cmd/ps1 shims on %PATH% but never
bun.exe — and the gbrain.exe shim that `bun link` generates resolves bun.exe
specifically, so link succeeds and every gbrain call dies with bun's
misleading "bun is not installed in %PATH%" (which suggests installing a
second parallel bun). The D19 validation failure paths now detect the
condition on Windows and print the actual remediation: bun's own
process.execPath IS the hidden bun.exe — add its directory to PATH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(ios-qa): document the bridge compatibility preflight and non-SwiftPM fallback
Re-derived from PR #2581 under the generated-file screening rule (template
hunk taken; SKILL.md regenerated). Prevents the agent from inventing project
wiring on apps the bridge doesn't support (ObservableObject-style or
non-SwiftPM apps): the preflight now names the compatibility check and the
manual fallback path.
Co-authored-by: Tim White <itstimwhite@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(deps): force adm-zip past CVE-2026-39244 via an override
Re-derived from PR #2485 as a resolution override rather than its direct-dep
bump: adm-zip reaches the tree only transitively (onnxruntime-node pins
^0.5.16), so a top-level copy at 0.6.0 would leave onnxruntime-node loading
the vulnerable 0.5.17 — which is exactly what the scanner PR's own lockfile
showed. The override forces every resolution to ^0.6.0.
Co-authored-by: anupamme <anupamme@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* deps: remove unused puppeteer-core; bump transformers/marked/socks
Completes the #2582 split (ENG-OV8). puppeteer-core had ZERO imports
repo-wide — a dead direct dependency whose only footprint was its CVE-prone
transitive chain (puppeteer-core > @puppeteer/browsers > proxy-agent >
get-uri > basic-ftp) and the pin test + basic-ftp override that existed
solely to guard it. Removing the dependency removes the surface: the
basic-ftp override and test/basic-ftp-security-pin.test.ts retire with it
(the lockfile resolves zero basic-ftp copies now). transformers ^4.2.0,
marked ^18.0.9, socks ^2.8.9 land per the dependabot group, gated on the
ONNX sidecar load+classify smoke passing with the bumped transformers
(28/28 sidecar+classifier+security tests green post-bump).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump the github-actions group across 1 directory with 10 updates
Bumps the github-actions group with 10 updates in the / directory:
| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `4` | `7` |
| [docker/login-action](https://github.com/docker/login-action) | `3` | `4` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3` | `4` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `6` | `7` |
| [actions/dependency-review-action](https://github.com/actions/dependency-review-action) | `4.9.0` | `5.0.0` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `4` | `7` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `4` | `8` |
| [oven-sh/setup-bun](https://github.com/oven-sh/setup-bun) | `1` | `2` |
| [actions/cache](https://github.com/actions/cache) | `4` | `6` |
| [google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml](https://github.com/google/osv-scanner-action) | `3adb4b14a2b0623876d18d863a498b785fb3752d` | `f4cfcc01edc9c8b756a9b873b7a623ca674da51e` |
Updates `actions/checkout` from 4 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)
Updates `docker/login-action` from 3 to 4
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)
Updates `docker/setup-buildx-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)
Updates `docker/build-push-action` from 6 to 7
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)
Updates `actions/dependency-review-action` from 4.9.0 to 5.0.0
- [Release notes](https://github.com/actions/dependency-review-action/releases)
- [Commits](https://github.com/actions/dependency-review-action/compare/2031cfc080254a8a887f58cffee85186f0e49e48...a1d282b36b6f3519aa1f3fc636f609c47dddb294)
Updates `actions/upload-artifact` from 4 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)
Updates `actions/download-artifact` from 4 to 8
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)
Updates `oven-sh/setup-bun` from 1 to 2
- [Release notes](https://github.com/oven-sh/setup-bun/releases)
- [Commits](https://github.com/oven-sh/setup-bun/compare/v1...v2)
Updates `actions/cache` from 4 to 6
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v6)
Updates `google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml` from 3adb4b14a2b0623876d18d863a498b785fb3752d to f4cfcc01edc9c8b756a9b873b7a623ca674da51e
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/3adb4b14a2b0623876d18d863a498b785fb3752d...f4cfcc01edc9c8b756a9b873b7a623ca674da51e)
* fix(test): scope rendered-output tripwires to repo sources; stop cdp-e2e's env leak
Two hermeticity holes surfaced by the wave's final gate. (1) The three T6
tripwires (branch-slug, codex-flag, empty-find) enumerated the whole tree
including the workspace-local .claude/ install, which is not generated
output and can carry dangling symlinks from unrelated sessions — one ENOENT
there failed all three. They now scan repo sources only. (2)
browse/test/cdp-e2e.test.ts mutated process.env.GSTACK_HOME at module scope
without restore; in one-process shard runs that leaks into every later test
file — observed baking cdp-e2e's temp render path into artifacts that
outlived it (53 dangling SKILL.md symlinks in a workspace install). The
original value is now restored in afterAll. The exact test that performed
the polluted relink remains unattributed; both known leak vectors are
closed and the workspace was repaired via an explicit gstack-relink.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): honest budget for the suite's one headed persistent-context launch
The launchHeaded/handoff parity test cold-launches a HEADED Chromium — 8-25s
on macOS, worse on the first launch of a freshly downloaded bundle (XProtect
scans it, the #2554 class) and under shard concurrency. bun's 5s default made
it the suite's most reliable false negative: it timed out identically on the
pre-wave baseline run of pristine main. 45s budget; passes 15/15.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): assemble redact fixtures at runtime — the guard caught its own wave
The pre-push redact guard BLOCKED this branch's first push: the wave's new
scan-range tests carried live-FORMAT fake credentials as literals (3 AWS key
shapes + a password-bearing DB URL), and the guard scans pushed diff bytes.
Same dogfood moment as the v1.64 wave, same rule: assemble the fixture at
runtime so the diff never carries a credential shape, never bypass the guard.
Runtime strings stay live-format for the hook under test. The guard works.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): sync ios-qa fixture mirrors with the #2585 DEBUG-guard templates
The #2585 absorb updated DebugBridgeTouch.m.template and
Package.swift.template but not their FixtureApp mirrors, failing the
template↔fixture parity gate. DebugBridgeTouch.m syncs byte-for-byte; the
fixture Package.swift takes only the template's new cSettings DEBUG define on
the Touch target (the fixture's own testTarget is fixture-only content the
parity normalization deliberately ignores — a naive full copy breaks the
XCTest invariant). 23/23 including the real swift build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(slug): env-override runs never persist to the cwd cache; cache is GSTACK_HOME-aware
Found while closing the wave's eval gate: a test exporting
GSTACK_PROJECT_SLUG from the repo root persisted the override into the cwd
slug cache, silently rebinding the ENTIRE repo's session state (evals,
decisions, timelines) to the test's slug for every later env-less run. The
escape hatch is per-invocation by contract — it no longer writes the cache.
The cache dir also hardcoded $HOME while lib/bin-context.ts's native port
(#2561) reads it GSTACK_HOME-aware, so temp-home test runs littered the real
~/.gstack (observed: 2,528 stale temp-cwd entries, swept). Writer and reader
now key the same GSTACK_HOME-aware cache; regression tests pin both
behaviors.
Also raises the cso --diff eval budget (240s/25t → 360s/40t):
transcript-verified, the wave's legitimately-grown audit session completes
the report and dies in closing telemetry at ~215s under the old budget; the
full-audit sibling already runs at 300s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): pin GSTACK_HOME in the slug walk-up cache tests
The cache dir became GSTACK_HOME-aware; these tests seed and assert cache
files under a temp HOME but spread the ambient env, so a sibling test
leaking process.env.GSTACK_HOME in a shared-process shard pointed the bin at
a different cache than the one under assertion (AC-2/AC-6 failed in shard
context, passed solo). The env now pins GSTACK_HOME to the temp home —
verified identical results with and without a simulated ambient leak.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): the cache-hygiene test strips ambient GSTACK_PROJECT_SLUG
Its env-less contract must be env-less: any ambient override leaking into a
shared-process shard flips the run into override mode, which correctly skips
the cache write the test asserts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): ratchet ship's skeleton cap for the v1.66.1 merge union
Merging main's v1.66.1.0 (evidence-ledger prose in ship's template) on top of
the wave's growth lands ship at 90,333 bytes, 333 over its cap. Re-measured
per the ratchet protocol: cap 90,800.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(brain-sync): throttle + bound the detector push; empty-queue fast path
Review-army findings on the #2549 detector. (1) The preamble runs --once at
every skill boundary, so an unthrottled retry paid a full network push
attempt per boundary in exactly the steady states it targets (offline,
broken auth) — a captive-portal push can block 30-75s against the header's
"<1s when idle" promise. Attempts now stamp .brain-last-push-attempt and
retry at most every 10 minutes; the push never prompts (GIT_TERMINAL_PROMPT=0)
and bounds stalled transfers via git's low-speed limits (portable — stock
macOS has no timeout binary). (2) Author-scoped: only gstack-brain-sync's own
commits retry; a user's manual commit in ~/.gstack rides along on real drains
as before, never auto-published by the detector. (3) Empty-queue fast path
exits before the compute/rewrite python spawns — the steady state is now
cheaper than the pre-wave truncation code. (4) The queue rewrite warns on
failure instead of silently letting the status claim a drain that didn't
happen, counts held unparseable lines, and collapses duplicate lines on
rewrite. Throttle + delivery matrix cases added (37/37).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(version-bump): JSON version-paths get the npm translation; honest recovery messages
Review-army findings. A repo whose package.json carries the legacy 4-digit
mirror and pins it via .gstack/version-path would get "1.67.0.1" written into
a manifest npm rejects forever, with no drift state to catch it (a JSON
source is self-consistent by construction) — the JSON branch now writes the
npm-valid translation, warns when translation occurred, and surfaces the
requested form. Lockfile-failure messages now match reality per failure
point: classify never reads lockfiles, so "re-run and repair" was a false
promise when package.json was written and only the lockfile threw. Both
malformed-version messages read MAJOR.MINOR.PATCH[.MICRO], matching the
3-digit contract this wave ships.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(extension): remove the orphaned security-banner block; repair two dead CSS tokens
Design-review findings. The 197-line .security-banner component (incl. its
keyframes) had no producer — no JS has created the element since the
chat-path rip, the same dead-hidden-security-UI class as the #2557 shield
this wave removed; a tombstone comment points at git history if the banner
UX returns. Two pre-existing token bugs in the mem-toast styles: --zinc-700
was never defined so the button hover computed to transparent (now carries a
fallback), and --font-sans doesn't exist (now --font-system, which :root
defines).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(brain-sync): detector pushes only when ALL unpushed commits are its own; lock released on every exit
The unpushed-commit detector's author check was existential: any bot-authored
commit in origin/<branch>..HEAD armed a push of HEAD, silently publishing
interleaved user-authored commits in ~/.gstack. Now the gate requires the
author-scoped count to equal the total unpushed count — one user commit
disables the autonomous retry entirely (user commits still ride along when a
real drain pushes). Detached HEAD is excluded (origin/HEAD usually resolves,
making the retry a 10-minutely doomed push).
The lock-release trap now installs immediately after lock acquisition instead
of after the empty-queue fast path — the steady state at every skill boundary
leaked the lock dir and relied on stale-PID detection, which PID reuse defeats.
An INT during the detector's network push is covered too.
Matrix test: interleaved user commit blocks the detector, then a real drain
delivers everything.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(version-bump): version-path and package-json-path pins cannot escape the repository
.gstack/version-path and .gstack/package-json-path are repo-controlled
content. A cloned repo pinning '../../victim.json' — or an in-repo symlink
pointing outside — turned a routine bump into an arbitrary file overwrite
outside the repository. assertRepoContained rejects absolute paths, lexical
.. escapes, and symlink escapes (deepest existing ancestor realpath'd, so a
not-yet-created VERSION file is checked through its parent). Lockfiles that
are symlinks resolving outside the repo are skipped with a warning instead
of written through.
Six containment tests including the not-over-broad control (subdirectory
pins keep working).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): port allocator range actually stays below the ephemeral floor; terminal-agent retries a raced bind
RANDOM_PORT_MAX was 60000 while the module header documents 49152-65535 as
the pool to avoid — ~22% of allocations landed back inside it, preserving
the phantom-404 squatting class for both the daemon and the weeks-lived
terminal-agent. The cap is now 49151 and the range test pins the true
property (< 49152) instead of the old <= 60000 tautology.
terminal-agent boot also re-allocates and retries up to 5 times when
Bun.serve throws in the probe-then-bind TOCTOU window — previously a
concurrent bind killed the boot with no retry via main().catch → exit 1.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(codex-probe): bash-native watchdog when no timeout binary exists; negative-cache the deterministic model 400
Stock macOS ships neither coreutils gtimeout nor timeout(1); the wrapper's
fallback ran the command unwrapped, so a hung codex exec blocked the probe
and the calling workflow indefinitely. The fallback now backgrounds the
command, TERMs it at the deadline, and mirrors timeout(1)'s exit-124
contract — with the watchdog's stdout detached so an early finish never
blocks a caller's $(...) capture on the orphaned sleep.
MODEL_UNUSABLE is now negative-cached for 15 minutes (same exit-1 + hints
from cache). The deterministic 400 is config-driven, so re-probing every
preflight charged the affected user a 30s round trip plus real tokens per
review section, forever. Editing config.toml — the fix — changes the cache
signature and re-probes immediately; MODEL_PROBE_INCONCLUSIVE stays uncached.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): xprotect heal resolves the install root via os.homedir and keeps guidance on a failed retry
With HOME unset, the global-install candidate became the RELATIVE path
.claude/skills/gstack under the daemon's cwd — often an untrusted repo being
QA'd, whose planted node_modules would then be where the heal runs the
playwright install (repo-controlled code execution). os.homedir() plus an
absolute-or-skip guard closes the class.
launchWithXProtectHeal also wraps the post-heal retry: a second classified
failure previously propagated raw, dropping the manual-remediation guidance
exactly when the automatic path had just proven insufficient.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(make-pdf): --strict and --confidential join BOOLEAN_FLAGS; the guard test derives the set from source
Both flags are read as '=== true' booleans but were missing from
BOOLEAN_FLAGS, so 'generate --strict essay.md' still ate essay.md as the
flag's value — the exact #2514 failure the set exists to prevent. The
completeness guard hardcoded six names and could not catch it; it now
derives every boolean read from cli.ts itself (direct reads plus
booleanFlag pairs), so the next boolean flag fails the suite until it
joins the set.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(todos): file the v1.67 adversarial-review residuals + coverage-audit test-gap backlog
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* v1.67.0.0: version bump (MINOR — full-tracker fix wave, pre-approved)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): v1.67.0.0 release summary + itemized changes with contributor credits
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(todos): mark the 2026-08-14 tracker-audit waves shipped in v1.67; re-file the four residuals
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(uninstall): provenance-gate the shape-2 and cursor sweeps; document the alias-name coupling
Three ways gstack-uninstall could touch a user's own skills:
- Shape 2 (real dir + symlinked SKILL.md) matched the link target against a
bare *gstack* substring, so a skill symlinked from ~/tools/gstack-fork/ was
wiped on uninstall. The gate now requires "gstack" as an anchored path
segment (gstack/*|*/gstack/*, same pattern as shape 1) AND the dir name in
gstack's skill inventory (parity with shape 3); anything else is listed to
stderr, never deleted.
- The new Cursor removals (~/.cursor/skills/gstack* and repo-local
.cursor/skills/gstack*) rm -rf'd any glob match with no provenance check,
so a hand-written ~/.cursor/skills/gstack-fork-notes was swept. Real dirs
now require the AUTO-GENERATED banner in SKILL.md; non-matching dirs are
kept and listed. Legacy codex/factory/kiro globs are untouched (tracked in
TODOS as a follow-up).
- The _INVENTORY seed list hardcodes alias names created by setup's
_install_alias_skill_md; both sites now carry mirrored keep-in-sync
comments so a renamed alias can't silently strand its dir.
The skipped-entry report moves to the end of the run so cursor skips are
listed alongside the Claude ones.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(redact): env.kv stops flagging cacheKey-style names; prepush exclusion scoped to the push remote
Two calibration/coverage fixes in the redaction guard:
- env.kv's zero-or-more-prefix regex fired on ANY identifier ending in a
credential suffix, so ordinary code (cacheKey:, sortKey:, partitionKey:,
hotkey:, even monkey:) with an 8+-char entropic value hit a MEDIUM confirm
prompt — a gate that cries wolf gets ignored. A name now only counts when
its shape is credential-semantic: suffix separated by _/-/. (api_key,
x-access-key, AUTH.TOKEN), a bare suffix (key:, token:), ALL-CAPS env style
(APIKEY=, MY_APIKEY=), or a camel compound with a credential prefix
(apiKey, authToken, clientSecret). The value stays capture group 1, so the
shape check lives in validate (isCredentialShapedEnvName), not the regex.
- gstack-redact-prepush's narrowing excluded commits reachable from ANY
remote (`--not --remotes`), so a secret that had only ever reached a
private/local-path remote was never scanned when later pushed to a PUBLIC
remote. The exclusion is now scoped to the push target
(`--remotes=<name>/*`) via the remote name git hands pre-push as $1 (the
installed wrapper already forwards "$@"); stdin/CLI invocations and URL
pushes without a configured name fall back to the historical all-remotes
behavior. #2592's catch-up-merge fix is unaffected: upstream commits come
from the same remote being pushed to.
New coverage: env.kv negative controls (cacheKey/sortKey/partitionKey/
hotkey/monkey/idempotencyKey) + positive controls for all four name shapes;
end-to-end hook tests proving a second-remote secret blocks a push to origin
while origin-published catch-up content still doesn't, plus both fallbacks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): honest probe budget, bounded daemon log, single refusal source, liveness + reinstall coverage
Five hardening items in the browse CLI and its tests:
- probeHealthWithBackoff's advertised ~8s budget could really run ~10s: the
final 2s probe could start 1ms before the deadline, and every call site
had JUST run a failed probe yet the loop re-probed immediately.
Iterations now start with the sleep and each probe's timeout clamps to
the remaining budget (isServerHealthy takes an injectable timeout).
- browse-daemon.log is append-mode across every respawn with no size cap,
so a crash-respawn loop fills the disk. The path is now built in one
place (daemonLogPath — the Unix fd path and the Windows launcher string
had two spellings) and daemon start rotates a >10MB log to
browse-daemon.log.1, single generation, matching the repo's 10MB
rotation convention. Rotation is exported + injectable and behaviorally
unit-tested.
- The two "healthy daemon already running" refusal blocks in connect had
already drifted (one lost the tabs/cookies/logins explainer) — extracted
refuseHeadedOverLiveDaemon as the single source.
- process-liveness: pinned the EPERM-means-alive contract (PID 1 on POSIX,
PID 4 on Windows — signalable-or-EPERM, both alive). A probe that reads
EPERM as dead is the false negative that leaked agents.
- runBoundedChromiumReinstall had zero coverage: now exercised end-to-end
against a stub bunx on a prepended PATH — exit 0, install-exit-N with
stderr tail, the detached group-kill timeout path (child of the child
dies too), and spawn-error.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(hooks): timeline Stop hook reads a 256KB tail instead of the whole file
The Stop hook runs on EVERY Claude Code turn machine-wide and re-read +
JSON-parsed the entire timeline each time, scaling to the 10MB size cap
(~100-300ms per turn of pure overhead). It now reads only the last 256KB
via fstat + positioned read, discarding the first partial line when the
window starts mid-file.
Semantics: a dangling "started" older than the last 256KB of appends
belongs to a session long gone — beyond repair interest. The window can
never fabricate a dangling entry ("completed" is always appended AFTER its
"started", so any started inside the window has its completion inside the
window too), so idempotency holds. The fail-open contract is unchanged:
exit 0 always, size cap kept, deadline re-checked before the write.
New test: a >256KB timeline where a recent dangling entry still gets
repaired while an old out-of-window dangler is left alone; all existing
fail-open cases pass unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(setup): Windows runtime-asset copies prune nested gitignored build output
_link_skill_runtime_assets' exclusion list filters DIRECT children only, so
the Windows cp -R real-copy path swept NESTED gitignored build output into
the installed skill dirs — concretely, ios-qa/scripts/gen-accessors-tool/
.build is 252MB per install. The IS_WINDOWS real-copy branch now prunes
nested node_modules/.build/dist post-copy (find -prune -exec rm -rf).
Scoped to _link_skill_runtime_assets ONLY: the generic _link_or_copy stays
untouched because runtime roots (browse/, design/) intentionally copy their
dist/ binaries. On Unix the assets are symlinks into the working tree, and
the prune is gated on the real-copy shape so it can never delete build
output from the repo through a link — both directions pinned in
test/setup-windows-rerun-refresh.test.ts with fixture trees.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(upgrade): migrations see the real install dir; stash can no longer resurrect stale renders
Two ways the v1.67 render-dirt cleanup was inert in the wired upgrade flow:
- Both migration runners invoked `bash "$migration"` without
GSTACK_INSTALL_DIR, so migrations that clean the INSTALL (v1.67.0.0.sh
defaults to ~/.claude/skills/gstack when unset) silently no-oped for
repo-local installs. setup now passes "$SOURCE_GSTACK_DIR" and the
/gstack-upgrade Step 4.75 runner passes the detected "$INSTALL_DIR".
- /gstack-upgrade Step 4 ran `git stash` BEFORE reset+setup, so the tree
was always clean by the time the migration ran, the legacy render dirt
landed in stash@{0}, and Step 4's own note then told the user to
`git stash pop` — restoring stale generated SKILL.md over the fresh
checkout permanently. Step 4 now discards the render footprint
(generated SKILL.md and sections/*.md modifications only, the same
classification as migrations/v1.67.0.0.sh) BEFORE stashing, so the stash
only ever carries real user changes; the stash-pop note says the render
dirt was discarded and regenerates. The migration stays for manual
git-pull flows.
Template change regenerated for all 3 hosts (claude tree checked in;
codex/factory trees are gitignored render outputs).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): stop --force-restart kills the live daemon directly instead of booting a fresh one
`browse stop --force-restart` on a live-but-busy daemon fell through the
stop short-circuit into ensureServer(), whose force-restart path kills the
daemon and then STARTS A FRESH ONE (daemon + Chromium, multi-second churn)
just so sendCommand('stop') can shut it down again — the #2254 churn in
force clothing. gstack-upgrade's Step 4.8 sends users down exactly this
path when a stale daemon is busy after an upgrade.
The stop short-circuit now handles it: live pid + --force-restart → kill
the daemon (tree-kill on Windows, TERM→KILL on POSIX), reap the orphaned
Chromium + clear profile locks, remove the state file, exit 0 — no server
is ever started. Pinned in stop-dead-daemon.test.ts: a wedged live "daemon"
is killed, the state file stays gone (a booted daemon would have rewritten
it), and no Starting/Restarting output appears.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(hooks): timeline repair counts started vs completed per key instead of set-masking
The dangling-event repair kept only the FIRST "started" entry per
skill+session key and treated "completed" as a set, so any key where one
run completed and another dangles was never repaired — and keys are not
unique per run: legacy entries with no session field all share the
bare-skill key, and the preamble's "$$-epoch" session ids collide within
the same second. One old completion masked every future dangler forever.
The hook now counts started vs completed per key and appends completions
for the DIFFERENCE. Idempotency holds by construction: the appended
completions balance the counts, so the next Stop appends nothing. Pinned
with the two-runs-one-dangling case plus a re-run no-op assertion; all
existing fail-open cases pass unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(setup): Windows refresh bypass no longer deletes a user's own skill dirs
The #2444 IS_WINDOWS refresh bypass (link_codex/factory/opencode/cursor
_skill_dirs) rm -rf's the destination before re-copying — and the host
skills dirs are SHARED namespaces, so the gstack* glob can land on a
user's OWN real directory (e.g. ~/.cursor/skills/gstack-notes). Every
./setup re-run silently deleted it — the ownership guard the comments
still claimed (#2142). The sidecar installers had the same shape against
a hand-written skill squatting on the canonical .../skills/gstack root,
and create_cursor_runtime_root wiped that root unconditionally on every
platform.
Same provenance model as bin/gstack-uninstall (#2563):
- _owned_for_windows_refresh: a real dir is only replaced when its
SKILL.md carries the AUTO-GENERATED banner; symlinks and missing
targets always pass. Non-matching dirs are kept and listed to stderr.
Wired into all four *_skill_dirs loops.
- _sidecar_root_user_owned: a root whose SKILL.md exists WITHOUT the
banner is the user's — create_agents_sidecar, create_cursor_sidecar,
and create_cursor_runtime_root skip it entirely instead of writing
into (or wiping) someone else's skill. A root with no SKILL.md stays
presumed ours (the documented install location; old/partial installs
look like that).
Pinned by a static census (every bypass site must carry its gate) plus
behavior fixtures: a bannerless user dir survives the Windows re-run
while a bannered install still refreshes, and a squatted sidecar root is
left untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(render): a failed brain-aware render can no longer vanish the installed skill set
Both render sites (setup's gbrain step and gstack-config gbrain-refresh)
ran `rm -rf` on the LIVE render dir BEFORE invoking gen:skill-docs:user.
Installed skills symlink into that dir (relink prefers it), so one
transient render failure — bun error, disk full, broken template — left
every brain-aware skill's SKILL.md symlink dangling: the whole skill set
vanished from Claude Code until a successful re-render.
Both sites now render into "$RENDER_DIR.tmp.$$" and swap it in only on
SUCCESS via a shared-contract _swap_in_render helper (mv old away, mv tmp
in, drop old — links into the live path stay valid because the path never
changes). The failure branch removes only the tmp dir and says so: the
previous render, and every link into it, stays fully intact. The
deliberate wipe on the gbrain-GONE path (stale render shadowing canonical
files) is unchanged.
Pinned in test/user-render-out-dir-install.test.ts: static shape (render
targets the TMP dir, never the live dir), _swap_in_render driven
behaviorally from BOTH files, and an end-to-end failure-branch fixture
proving a pre-existing render plus an installed symlink survive a failed
render.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test+docs: codex probe cache invalidation coverage, make-pdf --no-* structural pin, file the review-batch deferrals
- test/codex-model-probe.test.ts: the 1h TTL and the auth.json half of the
mtime signature had no coverage — a regression in either would silently
serve a stale MODEL_OK after re-login or forever. Added TTL-expiry
(backdated cache line re-probes) and auth.json-mtime invalidation cases,
mirroring the existing config.toml case.
- make-pdf/test/cli-args.test.ts: structural assertion derived from the
commands.ts registry — every --no-* flag must be in BOOLEAN_FLAGS, so a
new negation flag can't silently re-open #2514 (swallowing the next
positional).
- TODOS.md: filed five review-batch deferrals under the v1.67 queue with
rationale and effort: setup host-function dedup, cmd.exe %VAR% quoting in
gbrainInvocation (cross-spawn direction), make-pdf flag registry metadata
(derive BOOLEAN_FLAGS), legacy codex/factory/kiro uninstall provenance
gating (parity with the cursor gate), and cursor auto-detect breadth
(product call).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): package.json version check accepts the decision-11 npm translation
The bump wrote the npm-valid 3-digit manifest version for the first time
this release; the old assertion demanded byte-equality with the 4-digit
VERSION. Accept the translation plus the grandfathered pre-v1.67 mirror,
matching gstack-version-bump's own drift contract.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: sync project documentation with the v1.67.0.0 fix wave
Port range 10000-49151 + busy-vs-dead daemon semantics + XProtect launch
heal + browse-daemon.log in BROWSER.md/ARCHITECTURE.md; #2557 dead security
surface (shield, L4b Haiku, DeBERTa ensemble, canary injector) marked
removed in README/ARCHITECTURE per CLAUDE.md's do-not-redocument note;
runtime-asset installs + alias copies in CONTRIBUTING/CLAUDE.md; manual
uninstall fixed for asset-bearing dirs, alias copies, cursor/opencode
roots, and the timeline Stop hook; gbrain-refresh out-dir render path;
npm-valid package.json version translation documented in CLAUDE.md;
patches/ in the project tree; two CHANGELOG accuracy fixes (-272 net
lines, upgrade-time quarantine-clear) + release-summary em-dash polish.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(browse): findAvailablePort comment matches the 49151 range cap
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ci-image): the dependency layer carries patches/ — bun install needs the patch files the lock declares
bun.lock's patchedDependencies (playwright-core windowsHide) made
'bun install --frozen-lockfile' fail inside the image build: the Dockerfile
copied package.json + bun.lock but not patches/. The image-tag hash in all
three workflows (ci-image, evals, evals-periodic — kept in lockstep) now
includes patches/** so editing a patch rebuilds the layer instead of
serving a stale cache.
Verified: the exact COPY set (package.json + bun.lock + patches) installs
clean in a Linux container; without patches it reproduces the CI failure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(codex-probe): cache signature uses GNU-first stat with numeric validation
On GNU stat, -f means FILESYSTEM mode — the BSD-first form emitted a
multi-line filesystem block on Linux, so the cache signature never matched
its own cache line and the model-probe cache missed on every read (each
preflight re-paid the probe). Same class and same fix as #2195: GNU -c %Y
first, BSD -f %m fallback, non-numeric residue coerced to 0.
Verified: the probe test file passes 7/7 under real GNU stat in a Linux
container (it failed 2/7 on Linux CI before).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): first cross-platform run of the wave's tests — Linux tmp portability + Windows-lane truthfulness
Four platform holes from the lanes' first full run over the v1.67 tests:
- uninstall neutral-root fallback hardcoded /private/tmp (macOS-only) and
ENOENT'd on Linux CI, where the shard TMPDIR is the gstack-containing
path that forces the fallback — now realpath'd literal /tmp.
- uninstall's kept-and-listed assertion demanded a backslash path on
Windows while the bash uninstall prints POSIX paths — now
separator-insensitive.
- setup-rerun's IS_WINDOWS=0 sub-case and the iron rule's force-restart
consent path are Unix-shaped by construction (Git Bash ln -snf copies
without Developer Mode; the consent path boots a real replacement daemon
the browserless Windows lane cannot host) — gated off win32 with the
reasons in place; the Windows-relevant halves still run there.
- codex-under-codex-detection drives rendered bash under a hardcoded POSIX
PATH, so every case saw empty output on Windows — moved to
KNOWN_WINDOWS_INCOMPATIBLE with the run receipt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ci-image): stage patches/ into the narrow build context in all three workflows
The image builds from context .github/docker, into which a staging step
copies package.json + bun.lock — the previous fix added COPY patches to the
Dockerfile but not patches/ to that staging, so buildx failed computing the
COPY checksum ('/patches: not found'). All three workflows (ci-image, evals,
evals-periodic) stage identically, in lockstep with the shared tag hash.
Verified: a build over the exact staged context resolves both COPY layers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Stefan Andrei <89592870+sneakygriff@users.noreply.github.com>
Co-authored-by: Lucky Wenapere <luckydio10@gmail.com>
Co-authored-by: H M Ibtihal Utsho <ibtihal.utsho.ai@gmail.com>
Co-authored-by: ShahriarLak <shahriar.lak1@gmail.com>
Co-authored-by: Mike Laniak <mike.laniak@gmail.com>
Co-authored-by: Yuan Sun <forrest.sun527@gmail.com>
Co-authored-by: Greg Jackson <gregj64@gmail.com>
Co-authored-by: Sebastian Totté <sebastiantotte@gmail.com>
Co-authored-by: IDST UK <IDSTUK@users.noreply.github.com>
Co-authored-by: SomSamantray <SomSamantray@users.noreply.github.com>
Co-authored-by: Mateus Moraes <mmoraes@users.noreply.github.com>
Co-authored-by: Evgenii Lopatin <e75533@gmail.com>
Co-authored-by: Carrington Dennis <carrdenn3@gmail.com>
Co-authored-by: YR <work.yiftah.rottem@gmail.com>
Co-authored-by: ortonom <3261546+ortonom@users.noreply.github.com>
Co-authored-by: Scott <scott@peninsulaminerals.com>
Co-authored-by: SYKhayyat <shaulyoelkhayyat@gmail.com>
Co-authored-by: phuttimatebenchanakatkul <phuttimatebenchanakatkul@gmail.com>
Co-authored-by: vaston-viji <215998886+vaston-viji@users.noreply.github.com>
Co-authored-by: Frederik Kaster <frederik.kaster@noygear.ai>
Co-authored-by: meshailabs <devsupport@meshai.dev>
Co-authored-by: ming <silverchris@foxmail.com>
Co-authored-by: gamerey43 <gamerey43@users.noreply.github.com>
Co-authored-by: tranthanhnhatkhoa <tranthanhnhatkhoa@users.noreply.github.com>
Co-authored-by: harjothkhara <harjothkhara@users.noreply.github.com>
Co-authored-by: chiragborse1 <chiragborse1@users.noreply.github.com>
Co-authored-by: Tim White <itstimwhite@users.noreply.github.com>
Co-authored-by: anupamme <anupamme@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* fix(codex): use resume-compatible flags
* fix: V-001 security vulnerability
Automated security fix generated by Orbis Security AI
* docs: align prompt-injection thresholds to security.ts (v1.6.4.0 catch-up)
CLAUDE.md:290 and ARCHITECTURE.md:159 were missed when WARN was bumped
0.60 → 0.75 in d75402bb (v1.6.4.0, "cut Haiku classifier FP from 44% to
23%, gate now enforced", #1135). browse/src/security.ts:37 has WARN: 0.75
and BROWSER.md:743 was updated alongside that commit; CLAUDE.md and
ARCHITECTURE.md still read 0.60.
Also adds the SOLO_CONTENT_BLOCK: 0.92 entry to CLAUDE.md (already in
security.ts:50 and BROWSER.md:745, missing from CLAUDE.md's threshold
table).
No code change. No behavior change. Pure doc-vs-code alignment.
Verification:
$ grep -n "WARN" browse/src/security.ts CLAUDE.md ARCHITECTURE.md BROWSER.md
browse/src/security.ts:37: WARN: 0.75,
CLAUDE.md:290: - \`WARN: 0.75\` ...
ARCHITECTURE.md:159: ...>= \`WARN\` (0.75)...
BROWSER.md:743: - \`WARN: 0.75\` ...
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: Korean/CJK IME input and rendering in Sidebar Terminal
Fixes#1272
This commit addresses three separate Korean/CJK bugs in the Sidebar Terminal:
**Bug 1 - IME Input**: Korean text typed via IME composition was not
reaching the PTY correctly. Added compositionstart/compositionend event
listeners to suppress partial jamo fragments and only send the final
composed string.
**Bug 2a - Font Rendering**: Added CJK monospace font fallbacks
("Noto Sans Mono CJK KR", "Malgun Gothic") to both the xterm.js
fontFamily config and the CSS --font-mono variable. This ensures
consistent cell-width calculations for Korean characters.
**Bug 2b - UTF-8 Boundary Detection**: Added buffering logic to prevent
multi-byte UTF-8 characters (Korean is 3 bytes) from being split across
WebSocket chunks. This follows the same pattern as PR #1007 which fixed
the sidebar-agent path, but extends it to the terminal-agent path.
Special thanks to @ldybob for the excellent root cause analysis and
proposed solutions in issue #1272.
Tested on WSL2 + Windows 11 with Korean IME.
* fix(ship): tighten Plan Completion gate (VAS-449 remediation)
VAS-446 shipped with a PLAN.md acceptance criterion (domain-hq has
/docs/dashboard.md) silently skipped. /ship's Plan Completion subagent
existed at ship time (added in v1.4.1.0) but the gate let the failure
through. Four structural fixes:
1. Path concreteness rule: items naming a concrete filesystem path MUST
be classified DONE/NOT DONE via [ -f <path> ], never UNVERIFIABLE.
2. Validator detection: CONTENT-SHAPE items scan target repo's
package.json for validate-* scripts and run them before falling back
to UNVERIFIABLE.
3. Per-item UNVERIFIABLE confirmation: replaces blanket "I've checked
each one" with per-item Y/N/D loop. The blanket-confirm path is the
exact failure VAS-449 surfaced.
4. Subagent fail-closed: if Plan Completion subagent + inline fallback
both fail, surface explicit AskUserQuestion instead of silent pass.
Replaces the prior "Never block /ship on subagent failure" fail-open.
Locked in by test/ship-plan-completion-invariants.test.ts (5 assertions,
no LLM dependency, ~60ms).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(browse): bash.exe wrap for telemetry on Windows
reportAttemptTelemetry() in browse/src/security.ts calls spawn(bin, args)
where bin is the gstack-telemetry-log bash script. On Windows this fails
silently with ENOENT — CreateProcess can't dispatch on shebang lines.
Adopts v1.24.0.0's Bun.which + GSTACK_*_BIN override pattern (from
browse/src/claude-bin.ts:resolveClaudeCommand, introduced in #1252) for
resolving bash.exe. resolveBashBinary() honors GSTACK_BASH_BIN absolute-path
or PATH-resolvable override, falling back to Bun.which('bash') which finds
Git Bash on the standard Windows install.
buildTelemetrySpawnCommand() wraps the script invocation on win32 only;
POSIX path is bit-identical. Returns null when bash can't be resolved on
Windows so caller skips spawn — local attempts.jsonl audit trail keeps
working without surfacing a Windows-only failure.
8 new unit tests cover resolveBashBinary (POSIX bash, absolute override,
quote-stripping, BASH_BIN fallback, empty-PATH null) and buildTelemetrySpawnCommand
(POSIX pass-through, win32 bash wrap, win32 null on unresolvable, arg-array
immutability).
POSIX path is bit-identical — Bun.which('bash') on Linux/macOS returns the
same /bin/bash or /usr/bin/bash that the old hardcoded spawn relied on.
* fix(make-pdf): Bun.which-based binary resolution for browse + pdftotext on Windows
Extends v1.24.0.0's Bun.which + GSTACK_*_BIN override pattern (introduced in
browse/src/claude-bin.ts via #1252) to the two other binary resolvers in the
codebase: make-pdf/src/browseClient.ts:resolveBrowseBin and
make-pdf/src/pdftotext.ts:resolvePdftotext.
Same Windows quirks (fs.accessSync(X_OK) degrades to existence-check; `which`
isn't available outside Git Bash; bun --compile --outfile X emits X.exe), same
Bun.which-based fix shape, same env override convention.
Changes:
- GSTACK_BROWSE_BIN / GSTACK_PDFTOTEXT_BIN as the v1.24-aligned overrides;
BROWSE_BIN / PDFTOTEXT_BIN remain as back-compat aliases.
- Bun.which() replaces execFileSync('which', ...) for PATH lookup. Handles
Windows PATHEXT natively; no more `where`-vs-`which` branch.
- findExecutable(base) helper exported from each module, probes .exe/.cmd/.bat
after the bare-path miss on win32. Linux/macOS behavior is bit-identical
(isExecutable short-circuits before the win32 branch ever runs).
- macCandidates renamed posixCandidates (always was — /opt/homebrew, /usr/local,
/usr/bin). No Windows candidates added; Poppler installs scatter across
Scoop/Chocolatey/portable zips and guessing causes false positives.
- Error messages get a Windows install hint (scoop install poppler / oschwartz10612)
and `setx` example for GSTACK_*_BIN.
- Pre-existing test 'honors BROWSE_BIN when it points at a real executable'
was hardcoded /bin/sh — made cross-platform via a REAL_EXE constant
(cmd.exe on win32, /bin/sh on POSIX). Was a Windows-CI blocker on its own.
Coordination: PR #1094 (@BkashJEE) covered browseClient.ts independently with a
narrower scope; this PR's pdftotext + cross-platform tests + GSTACK_*_BIN naming
are additive. Either order of merge works.
Test plan:
- bun test make-pdf/test/browseClient.test.ts make-pdf/test/pdftotext.test.ts
on win32 — 29 pass, 0 fail (12 new assertions: findExecutable POSIX/win32/null,
resolveBrowseBin GSTACK_BROWSE_BIN + BROWSE_BIN + precedence + quote-strip,
same shape for resolvePdftotext + Windows install hint in error message).
- POSIX branch unchanged — fs.accessSync(X_OK) on Linux/macOS short-circuits
before any win32 logic runs, matching the v1.24 claude-bin.ts pattern.
* fix(browse): NTFS ACL hardening for Windows state files via icacls
gstack's ~/.gstack/ state directory holds bearer tokens, canary tokens, agent
queue contents (with prompt history), session state, security-decision logs,
and saved cookie bundles — all written with { mode: 0o600 } / 0o700. On Windows,
those mode bits are a silent no-op: Node's fs module doesn't translate POSIX
modes to NTFS ACLs, and inherited ACLs leave every "restricted" file readable
by other principals on the machine (verified via icacls — six ACEs, the
intended user is the LAST of six).
Threat model is non-trivial on:
- Self-hosted CI runners (different service account on the same Windows box
can read developer tokens, canary tokens, prompt history)
- Shared development machines (agencies, studios, lab environments)
- Multi-tenant servers with shared home directories
Orthogonal to v1.24.0.0's binary-resolution work — complementary at the write
side. v1.24's bin/gstack-paths resolves ~/.gstack/ correctly across plugin /
global / local installs; this PR ensures files written into those resolved
paths actually get the POSIX 0o600 semantic translated to NTFS.
The fix:
- New browse/src/file-permissions.ts (158 LOC, 5 public + 1 test-reset).
restrictFilePermissions / restrictDirectoryPermissions wrap chmod (POSIX)
or icacls /inheritance:r /grant:r <user>:(F) (Windows). writeSecureFile /
appendSecureFile / mkdirSecure are drop-in wrappers for the common patterns.
- 19 call sites converted across 9 source files: browser-manager.ts,
browser-skill-write.ts, cli.ts, config.ts, meta-commands.ts,
security-classifier.ts, security.ts (4 sites), server.ts (5 sites),
terminal-agent.ts (8 sites), tunnel-denial-log.ts.
- (OI)(CI) inheritance flags on directories mean files created via fs.write*
*inside* an mkdirSecure-created dir inherit the owner-only ACL automatically
— important for tunnel-denial-log.ts where appends use async fsp.appendFile.
Error handling: icacls failures (nonexistent path, missing icacls.exe, hardened
environments) log a one-shot warning to stderr and proceed. Once-per-process
gating prevents log spam if the condition persists. Filesystem stays
functional; the file just ends up with inherited ACLs.
Test plan:
- bun test browse/test/file-permissions.test.ts — 13 pass, 0 fail (POSIX
mode-bit assertions, Windows no-throw, mkdir idempotence, recursive
creation, Buffer payloads, append-creates-then-reapplies-once semantics)
- bun test browse/test/security.test.ts — 38 pass, 0 fail (existing security
test suite plus the bash-binary resolution tests added in fix#1119; the
converted writeFileSync/appendFileSync/mkdirSync sites in security.ts
integrate cleanly)
- Empirical icacls before/after on a real file — 6 ACEs → 1 ACE
- bun build typecheck on all modified files — clean (server.ts has a
pre-existing playwright-core/electron resolution issue unrelated to this PR)
POSIX behavior is bit-identical to old code — fs.chmodSync(path, 0o6XX) on the
helper's POSIX branch matches the inline { mode: 0o6XX } it replaces. Linux
and macOS see no behavior change.
Inviting pushback on three judgment calls (in PR description):
1. icacls vs npm library
2. ACL scope — just user, or user + SYSTEM?
3. Graceful degradation — once-per-process warn, not silent, not hard-fail.
* fix(browse): declare lastConsoleFlushed to restore console-log persistence
flushBuffers() references a `lastConsoleFlushed` cursor at server.ts:337
and assigns it at :344, but the `let lastConsoleFlushed = 0;`
declaration is missing — only the network and dialog siblings are
declared at lines 327-328.
Result: every 1-second flushBuffers tick (line 376) throws
`ReferenceError: lastConsoleFlushed is not defined`, gets swallowed by
the catch at line 369 ("[browse] Buffer flush failed: ..."), and the
console branch's append never runs. browse-console.log is never
written in any production deployment since this regressed.
Discovered by stress-testing the daemon with 15 concurrent CLIs against
cold state — the race surfaced the buffer-flush error spam in one
spawned daemon's stderr. Verified by running the daemon against a real
file:// page with console.log events: in-memory `browse console`
returns the entries, but `.gstack/browse-console.log` is never created
on disk.
Regression introduced by 1a100a2a "fix: eliminate duplicate command
sets in chain, improve flush perf and type safety" — the flush refactor
switched from `Bun.write` to `fs.appendFileSync` and added the
`lastConsoleFlushed` cursor pattern alongside its network/dialog
siblings, but missed the matching `let` declaration. Tests don't
currently exercise flushBuffers, so the regression shipped silently.
Fix:
- Declare `let lastConsoleFlushed = 0;` next to `lastNetworkFlushed`
and `lastDialogFlushed` (browse/src/server.ts:327)
- Add a source-level guard test
(browse/test/server-flush-trackers.test.ts) that fails any future
refactor that adds a fourth `last*Flushed` cursor without the
matching declaration. Same pattern as terminal-agent.test.ts and
dual-listener.test.ts — read source as text, assert invariant, no
daemon required.
Test plan:
- [x] New regression test fails on current main, passes with the fix
- [x] `bun run build` clean
- [x] Manual smoke: spawn daemon -> goto file:// page with
console.log -> wait 4s -> .gstack/browse-console.log now
exists with the expected entries (163 bytes vs zero before)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
* fix(browse): per-process state-file temp path to fix concurrent-write ENOENT
The daemon writes `.gstack/browse.json` via the standard atomic-rename
pattern: `writeFileSync(tmp, …) → renameSync(tmp, stateFile)`. Four
sites in server.ts use this pattern (initial daemon-startup state at
:2002, /tunnel/start handler at :1479, BROWSE_TUNNEL=1 inline tunnel
update at :2083, BROWSE_TUNNEL_LOCAL_ONLY=1 update at :2113), and all
four hard-code the same temp filename `${stateFile}.tmp`.
Under concurrent writers the shared filename races on the rename:
t0 Writer A: writeFileSync(stateFile + '.tmp', payloadA)
t1 Writer B: writeFileSync(stateFile + '.tmp', payloadB) // overwrites A
t2 Writer A: renameSync(stateFile + '.tmp', stateFile) // moves B's payload
t3 Writer B: renameSync(stateFile + '.tmp', stateFile) // ENOENT — file gone
Reproduced empirically with 15 concurrent CLIs against a fresh `.gstack/`:
[browse] Failed to start: ENOENT: no such file or directory,
rename '…/.gstack/browse.json.tmp' -> '…/.gstack/browse.json'
Pre-fix success rate: **0 / 15** under cold-start race.
Post-fix success rate: **15 / 15**, zero ENOENT.
Fix:
- New `tmpStatePath()` helper (server.ts:333) returns
`${stateFile}.tmp.${pid}.${randomBytes(4).toString('hex')}`
- All 4 call sites use `tmpStatePath()` instead of the shared literal
- Atomic rename still gives last-writer-wins semantics on the final
state.json content; only behavior change is that concurrent writers
no longer kill each other on the rename step
Source-level guard test (browse/test/server-tmp-state-path.test.ts)
locks two invariants: (1) no remaining `stateFile + '.tmp'` literals,
(2) every state-write `writeFileSync` call uses `tmpStatePath()`. Same
read-source-as-text pattern as terminal-agent.test.ts and
dual-listener.test.ts — no daemon required, runs in tier-1 free.
Test plan:
- [x] Targeted source-level guard test passes (3 / 0)
- [x] `bun run build` clean
- [x] Live regression: 15 concurrent CLIs against cold state →
15 / 15 healthy, 0 ENOENT (vs 0 / 15 pre-fix)
- [x] No `.tmp.*` orphans left behind after rename succeeds
- [x] Related test cluster (server-auth, dual-listener, cdp-mutex,
findport) — same pre-existing flakes as `main`, no new
regressions introduced
🤖 Generated with [Claude Code](https://claude.com/claude-code)
* fix(browse): clear refs when iframe auto-detaches in getActiveFrameOrPage
Asymmetric cleanup between two equivalent staleness conditions:
onMainFrameNavigated() → clearRefs() + activeFrame = null ✓
getActiveFrameOrPage() → activeFrame = null (refs NOT cleared) ✗
Both paths see the same staleness condition — refs were captured
against a frame that no longer exists. The main-frame path correctly
clears both pieces of state. The iframe-detach path nulls the frame
but leaves the refMap intact.
The lazy click-time check in `resolveRef` (tab-session.ts:97) partially
saves us — `entry.locator.count()` on a detached-frame locator throws
or returns 0, so the click errors out as "Ref X is stale". But the
user has no signal that frame context silently changed underfoot: the
next `snapshot` runs against `this.page` (main) while old iframe refs
still litter `refMap` with the same role+name keys. New refs collide
with stale ones, the resolver picks one at random, the user clicks
the wrong element.
TODOS.md line 816-820 documents "Detached frame auto-recovery" as a
shipped iframe-support feature in v0.12.1.0. This restores the
documented intent — the recovery should leave the session in a clean
state, not a half-cleared one.
Fix: 1 line — add `this.clearRefs()` next to `this.activeFrame = null`
inside the if-branch.
Test plan:
- [x] New regression test: 4/4 pass
- refs cleared when getActiveFrameOrPage detects detached iframe
- refs preserved when active frame is still attached (no regression)
- refs preserved when no frame set (page-level path untouched)
- matches onMainFrameNavigated symmetry — both paths reach the
same clean end state
- [x] `bun run build` clean
🤖 Generated with [Claude Code](https://claude.com/claude-code)
* fix(codex): resolve python for JSON parser
* fix: add fail-fast probe for base branch in ship step 12
* fix(plan-devex-review): remove contradictory plan-mode handshake
* fix(design): honor Retry-After header in variants 429 handler
Closes#1244.
The 429 handler in `generateVariant` discarded the `Retry-After` response
header and fell straight through to a local exponential schedule (2s/4s/8s).
In image-generation batches, that burns retry attempts inside the provider's
cooldown window and the request never recovers.
Now we parse `Retry-After` per RFC 7231 — both delta-seconds (`Retry-After: 5`)
and HTTP-date (`Retry-After: Fri, 31 Dec 1999 23:59:59 GMT`). Honored waits
are capped at 60s to bound stalls from hostile or buggy headers. Delta-seconds
are validated as digits-only (rejects `2abc`). When `Retry-After` is honored
(including 0 / past-date "retry now"), the next iteration's leading exponential
sleep is skipped so we don't double-wait. Invalid or missing headers fall
through to the existing exponential schedule unchanged.
Behavior matrix:
| Header | Behavior |
|---------------------------------|-------------------------------------------|
| Retry-After: 5 | wait 5s, skip leading on next attempt |
| Retry-After: 999999 | capped to 60s, skip leading |
| Retry-After: 2abc | invalid, fall through to exponential |
| Retry-After: 0 | wait 0, skip leading (retry immediately) |
| Retry-After: <past HTTP-date> | wait 0, skip leading |
| Retry-After: <future date> | wait diff capped at 60s, skip leading |
| no header | fall through to existing exponential |
`generateVariant` now accepts an optional `fetchFn` parameter (defaults to
`globalThis.fetch`) so tests can inject a stub. Production call sites are
unchanged.
Tests cover the five behavior buckets above, asserting both the 1st-to-2nd
call timing gap and call counts. All five pass in ~8s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(docs): correct per-skill symlink removal snippet in README uninstall
Closes#1130.
The manual-uninstall fallback in `## Uninstall` → `### Option 2` used
`find ~/.claude/skills -maxdepth 1 -type l`, which finds nothing on real
installs. Each `~/.claude/skills/<name>/` is a real directory, and only
`<name>/SKILL.md` inside it is a symlink into `gstack/`. The find never
matched, so the snippet silently removed nothing.
Replace with a directory walk that inspects each `<name>/SKILL.md`:
find ~/.claude/skills -mindepth 1 -maxdepth 1 -type d ! -name gstack
→ check $dir/SKILL.md is a symlink → readlink it
→ if target is gstack/* or */gstack/*: rm -f the link, rmdir the dir
(only if empty — preserves any user-added files)
Excludes the top-level `gstack/` dir from the walk; that's removed by
step 3 of the same uninstall block.
`bin/gstack-uninstall` (the script-mode path) already handles the layout
correctly via its own walk; only this manual fallback needed updating.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: reject partial browse client env integers
* fix(gemini-adapter): detect new ~/.gemini/oauth_creds.json auth path
gemini-cli >=0.30 stores OAuth credentials at ~/.gemini/oauth_creds.json
instead of the legacy ~/.config/gemini/ directory. The benchmark adapter's
availability check now succeeds for users on recent gemini-cli releases
who have authenticated via interactive login.
Both paths are accepted so users on older versions still work.
* fix(browser): add --no-sandbox for root user on Linux/WSL2
Chromium's sandbox can't initialize when running as root on Linux,
causing an immediate exit. Extend the existing CI/CONTAINER check to
also cover this case, keeping the Windows-safe `typeof getuid` guard.
* security: pass cwd to git via execFileSync, not interpolation through /bin/sh
`bin/gstack-memory-ingest.ts:632-643` ran `execSync(\`git -C ${JSON.stringify(cwd)}
remote get-url origin 2>/dev/null\`, ...)`. JSON.stringify escapes `"` and `\`
but not `$` or backticks, so a `cwd` of `"$(touch /tmp/marker)"` survived JSON
quoting and detonated under /bin/sh's command-substitution-inside-double-quotes.
`cwd` originates from transcript JSONL records under
`~/.claude/projects/<encoded-cwd>/<uuid>.jsonl` and
`~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`. The walker grabs the first
`.cwd` it sees per session. That's an untrusted surface in the gstack threat
model — the L1-L6 sidebar security stack exists exactly because agent
transcripts can carry attacker-influenced text. Two pivots above the local
same-uid bar: (a) prompt-injection appending `cwd="$(...)"` to the active
session log turns the next /sync-gbrain run into RCE under the user's uid;
(b) cross-machine transcript share (a colleague's `.claude/projects` snippet
untar'd into HOME, a documented gbrain dogfooding shape) → RCE on first sync.
Fix swaps the one execSync for `execFileSync("git", ["-C", cwd, "remote",
"get-url", "origin"], ...)`. No shell, argv passed directly to git. The same
module already uses execFileSync for `gbrainAvailable()` (line 762 pre-patch)
and `gbrainPutPage()` (line 816 pre-patch) — this single execSync was the
outlier.
Test: `gstack-memory-ingest security: untrusted cwd cannot trigger shell
substitution` plants a Claude-Code-shaped JSONL with cwd=`$(touch <marker>)`
and asserts the marker file is not created after `--incremental --quiet`.
Negative control: with the patch reverted, the test fails (marker created);
with the patch applied, it passes (18/18 in test/gstack-memory-ingest.test.ts).
* security: gate domain-skill auto-promote on classifier_score > 0
`browse/src/domain-skill-commands.ts:140` (handleSave) writes
`classifier_score: 0` with the comment "L4 deferred to load-time / sidebar-agent
fills this in on first prompt-injection load." But CLAUDE.md "Sidebar
architecture" documents that sidebar-agent.ts was ripped, and grep for
recordSkillUse + classifierFlagged callers across browse/src/ returns zero hits
outside the module under test.
Net effect: every quarantined skill that survives three benign uses without
flag (`recordSkillUse(... , classifierFlagged: false)` x3) auto-promotes to
`active` and lands in prompt context wrapped as UNTRUSTED on every subsequent
visit to that host. The L4 score that was supposed to gate the promotion was
never written — the production save path puts 0 on disk and nothing later
updates it.
Threat model: a domain-skill body authored by an agent under the influence of
a poisoned page (the new `gstackInjectToTerminal` PTY path runs no L1-L3
either) would lose its auto-promote barrier after three uses. The exploit
isn't single-step but the bar is exactly N=3 prompt-injection-shaped uses on
a hostile page, which is well within reach.
Fix adds a single condition to the auto-promote gate in `recordSkillUse`:
if (state === 'quarantined' && useCount >= PROMOTE_THRESHOLD &&
flagCount === 0 && current.classifier_score > 0) {
state = 'active';
}
`classifier_score` is set once at writeSkill and never updated. Production
saves it as 0 (handleSave), so the gate stays closed; existing tests that
explicitly pass `classifierScore: 0.1` still auto-promote (the auto-promote
path is preserved for the day L4 is rewired).
Manual promotion via `domain-skill promote-to-global` is unaffected (it goes
through `promoteToGlobal` which has its own state-machine guard at line 337+).
Test: new regression case `does NOT auto-promote when classifier_score is 0
(production handleSave shape)` plants a skill with classifierScore=0 (matches
domain-skill-commands.ts:140), runs three uses without flag, asserts the skill
stays quarantined and readSkill returns null. Negative control: revert the
patch, the test fails with `Received: "active"`. With the patch: 15/15 pass.
* fix(ship): port #1302 SKILL.md edits to .tmpl + resolver source
PR #1302 added Verification Mode + UNVERIFIABLE classification + per-item
confirmation gate to ship/SKILL.md, but only the generated SKILL.md was
edited — not the .tmpl source or scripts/resolvers/review.ts. The next
`bun run gen:skill-docs` run would have wiped the changes.
Port the same content into the resolver and .tmpl so regeneration produces
the intended output.
* ci(windows): extend free-tests lane to cover icacls + Bun.which resolvers from fix-wave PRs
Closes #1306/#1307/#1308 validation gap. The four newly-added test files
already have process.platform guards so they run safely on both POSIX and
Windows lanes — only platform-relevant assertions execute on each.
Tests added to the windows-latest lane:
- browse/test/file-permissions.test.ts (#1308 icacls + writeSecureFile)
- browse/test/security.test.ts (#1306 bash.exe wrap pure-function path)
- make-pdf/test/browseClient.test.ts (#1307 Bun.which browse resolver)
- make-pdf/test/pdftotext.test.ts (#1307 Bun.which pdftotext resolver)
* test(codex): live flag-semantics smoke for codex exec resume
Closes#1270's regex-only test gap. PR #1270 asserted that codex/SKILL.md's
`codex exec resume` invocation drops -C/-s and uses sandbox_mode config.
That regex catches the skill template regressing, but not codex CLI itself
flipping flag semantics again.
This test probes `codex exec resume --help` and asserts the surface gstack
relies on: -c/sandbox_mode is accepted, top-level -C is absent. Skips
silently when codex isn't on PATH, so dev machines without codex installed
never see it fail.
* chore: regen SKILL.md after fix wave
One regen commit at the end of the merge wave per the plan. plan-devex-review
loses the contradictory plan-mode handshake (#1333). review/SKILL.md picks up
the Verification Mode + UNVERIFIABLE classification additions that #1302
authored against ship/SKILL.md (same resolver shared between ship and review
modes).
* fix(server.ts): keep fs.writeFileSync for state-file writes
#1308's writeSecureFile wrapper added Windows icacls hardening for the
4 state-file write sites in server.ts, but #1310's regression test grep's
for fs.writeFileSync(tmpStatePath()) calls. The two changes are technically
compatible only if the test relaxes — keeping the test strict (the safer
choice for catching regressions on the cold-start race) means the 4 state-
file sites stay on fs.writeFileSync(..., { mode: 0o600 }).
POSIX 0o600 hardening is preserved on those 4 sites. Windows icacls
hardening still applies to all the other writeSecureFile call sites
#1308 added (auth.json, mkdirSecure, etc.).
Also refreshes golden baselines after #1302 / port + minor wording tweak
in scripts/resolvers/review.ts to keep gen-skill-docs.test.ts assertion
'Cite the specific file' satisfied.
* v1.30.0.0: fix wave — 21 community PRs + 2 closing fixes for Windows + codex CI gaps
Headline release. Browse stops dropping console logs, cold-start race
fixed, codex resume works without python3, Windows hardening (icacls +
Bun.which + bash.exe wrap), ship gate gets VAS-449 remediation, two
closing fixes that put icacls/Bun.which/codex flag semantics under CI.
* test(domain-skills): cover #1369 classifier_score=0 quarantine + score>0 promote path
The pre-existing T6 test seeded skills via writeSkill (which defaults
classifier_score to 0 until L4 is rewired) and then expected 3 uses to
auto-promote. PR #1369 added `current.classifier_score > 0` to the gate
specifically to block that path — a quarantined skill written under the
influence of a poisoned page would otherwise auto-promote after three
benign uses.
Updated test asserts both halves of the new contract:
- classifier_score=0 + 3 uses → stays quarantined (the security guarantee)
- classifier_score>0 + 3 more uses → promotes to active (unblock path)
Catches both regressions: the gate going away (would re-allow the bypass)
and the unblock path breaking (would silently quarantine all skills
forever once L4 is rewired).
---------
Co-authored-by: Jayesh Betala <jayesh.betala7@gmail.com>
Co-authored-by: orbisai0security <mediratta01.pally@gmail.com>
Co-authored-by: Bryce Alan <brycealan.eth@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Terry Carson YM <cym3118288@gmail.com>
Co-authored-by: Vasko Ckorovski <vckorovski@gmail.com>
Co-authored-by: Samuel Carson <samuel.carson@gmail.com>
Co-authored-by: Yashwant Kotipalli <yashwant7kotipalli@gmail.com>
Co-authored-by: Jasper Chen <jasperchen925@gmail.com>
Co-authored-by: Stefan Neamtu <stefan.neamtu@gmail.com>
Co-authored-by: 陈家名 <chenjiaming@kezaihui.com>
Co-authored-by: Abigail Atheryon <abi@atheryon.ai>
Co-authored-by: Furkan Köykıran <furkankoykiran@gmail.com>
Co-authored-by: gus <gustavoraularagon@gmail.com>
* feat(gbrain-sync): queue primitives + writer shims
Adds bin/gstack-brain-enqueue (atomic append to sync queue) and
bin/gstack-jsonl-merge (git merge driver, ts-sort with SHA-256 fallback).
Wires one backgrounded enqueue call into learnings-log, timeline-log,
review-log, and developer-profile --migrate. question-log and
question-preferences stay local per Codex v2 decision.
gstack-config gains gbrain_sync_mode (off/artifacts-only/full) and
gbrain_sync_mode_prompted keys, plus GSTACK_HOME env alignment so
tests don't leak into real ~/.gstack/config.yaml.
* feat(gbrain-sync): --once drain + secret scan + push
bin/gstack-brain-sync is the core sync binary. Subcommands: --once
(drain queue, allowlist-filter, privacy-class-filter, secret-scan
staged diff, commit with template, push with fetch+merge retry),
--status, --skip-file <path>, --drop-queue --yes, --discover-new
(cursor-based detection of artifact writes that skip the shim).
Secret regex families: AWS keys, GitHub tokens (ghp_/gho_/ghu_/ghs_/
ghr_/github_pat_), OpenAI sk-, PEM blocks, JWTs, bearer-token-in-JSON.
On hit: unstage, preserve queue, print remediation hint (--skip-file
or edit), exit clean. No daemon — invoked by preamble at skill
boundaries.
* feat(gbrain-sync): init, restore, uninstall, consumer registry
bin/gstack-brain-init: idempotent first-run. git init ~/.gstack/,
.gitignore=*, canonical .brain-allowlist + .brain-privacy-map.json,
pre-commit secret-scan hook (defense-in-depth), merge driver registration
via git config, gh repo create --private OR arbitrary --remote <url>,
initial push, ~/.gstack-brain-remote.txt for new-machine discovery,
GBrain consumer registration via HTTP POST.
bin/gstack-brain-restore: safe new-machine bootstrap. Refuses clobber
of existing allowlisted files, clones to staging, rsync-copies tracked
files, re-registers merge drivers (required — not cloned from remote),
rehydrates consumers.json, prompts for per-consumer tokens.
bin/gstack-brain-uninstall: clean off-ramp. Removes .git + .brain-*
files + consumers.json + config keys. Preserves user data (learnings,
plans, retros, profile). Optional --delete-remote for GitHub repos.
bin/gstack-brain-consumer + bin/gstack-brain-reader (symlink alias):
registry management. Internal 'consumer' term; user-facing 'reader'
per DX review decision.
* feat(gbrain-sync): preamble block — privacy gate + boundary sync
scripts/resolvers/preamble/generate-brain-sync-block.ts emits bash that
runs at every skill invocation:
- Detects ~/.gstack-brain-remote.txt on machines without local .git
and surfaces a restore-available hint (does NOT auto-run restore).
- Runs gstack-brain-sync --once at skill start to drain any pending
writes (and at skill end via prose instruction).
- Once-per-day auto-pull (cached via .brain-last-pull) for append-only
JSONL files.
- Emits BRAIN_SYNC: status line every skill run.
Also emits prose for the host LLM to fire the one-time privacy
stop-gate (full / artifacts-only / off) when gbrain is detected and
gbrain_sync_mode_prompted is false. Wired into preamble.ts composition.
* test(gbrain-sync): 27-test consolidated suite
test/brain-sync.test.ts covers:
- Config: validation, defaults, GSTACK_HOME env isolation
- Enqueue: no-op gates, skip list, concurrent atomicity, JSON escape
- JSONL merge driver: 3-way + ts-sort + SHA-256 fallback
- Init + sync: canonical file creation, merge driver registration,
push-reject + fetch+merge retry path
- Init refuses different remote (idempotency)
- Cross-machine restore round-trip (machine A write → machine B sees)
- Secret scan across all 6 regex families (AWS, GH, OpenAI, PEM, JWT,
bearer-JSON). --skip-file unblock remediation
- Uninstall removes sync config, preserves user data
- --discover-new idempotence via mtime+size cursor
Behaviors verified via integration smokes during implementation. Known
follow-up: bun-test 5s default timeout needs 30s wrapper for
spawnSync-heavy tests.
* docs(gbrain-sync): user guide + error lookup + README section
docs/gbrain-sync.md: setup walkthrough, privacy modes, cross-machine
workflow, secret protection, two-machine conflict handling, uninstall,
troubleshooting reference.
docs/gbrain-sync-errors.md: problem/cause/fix index for every
user-visible error. Patterned on Rust's error docs + Stripe's API
error reference.
README.md: 'Cross-machine memory with GBrain sync' section near the
top (discovery moment), plus docs-table entry.
* chore: bump version and changelog (v1.7.0.0)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: regenerate SKILL.md files for gbrain-sync preamble block
Re-runs bun run gen:skill-docs after adding generateBrainSyncBlock
to scripts/resolvers/preamble.ts in a2aa8a07. CI check-freshness
caught the drift. All 36 SKILL.md files regenerated with the new
skill-start bash block + privacy-gate prose + skill-end sync
instructions baked in.
* fix(test): session-awareness reads AskUserQuestion Format from a Tier 2+ SKILL.md
The test was reading ROOT/SKILL.md (browse skill, Tier 1) which never
contained '## AskUserQuestion Format' — that section is only emitted
for Tier 2+ skills by scripts/resolvers/preamble.ts. As a result the
agent was prompted with an empty format guide and only emitted
'RECOMMENDATION' intermittently, making the test flaky.
Pre-existing on main (same ROOT/SKILL.md shape there) — surfaced now
because the agent run didn't hit the RECOMMENDATION/recommend/option a
fallback strings in this particular attempt.
Fix: read from office-hours/SKILL.md (Tier 3, always has the section)
with a fallback that scans for the first top-level skill dir whose
SKILL.md contains the header. Future template moves won't break this
test again.
* feat(browse): domain-skills storage + state machine
New module browse/src/domain-skills.ts implements the per-site notes
the agent writes for itself, persisted as type:"domain" rows alongside
/learn's per-project learnings.
Three scopes layered: per-project default, global by explicit promotion.
Project-active shadows global for the same host.
State machine (T6 — codex outside-voice):
quarantined --3 uses w/o flag--> active(project) --promote--> global
^ |
+----- classifier flag during use
- Append-only JSONL with O_APPEND for atomic small writes
- Tolerant parser drops partial trailing line on read
- Tombstone for deletes (compactor cleans up later)
- Version log per (host, scope) enables rollback
- Hostname derived from active tab top-level origin (T3 confused-deputy fix)
- writeSkill rejects classifier_score >= 0.85 with structured error
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(browse): domain-skills storage + state machine
14 tests covering:
- T3 hostname normalization (lowercase, www. strip, port/path/query strip,
subdomain-exact preserved)
- T4 scope shadowing (per-project active shadows global for same host)
- T5 persistence (version monotonicity, tolerant parser drops partial line)
- T6 state machine (quarantined → active after N=3 uses, classifier-flag
blocks promotion, save-time score >= 0.85 rejected)
- Rollback by version log (restore prior body, advance version counter)
- Tombstone deletion (read returns null after delete)
All 14 pass in 27ms via bun test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(browse): $B domain-skill subcommands
Wire the domain-skills storage layer into the browse CLI as a META command:
$B domain-skill save save body from stdin or --from-file
(host derived from active tab — T3)
$B domain-skill list list all skills visible to current project
$B domain-skill show <host> print skill body
$B domain-skill edit <host> open in $EDITOR
$B domain-skill promote-to-global <host> cross-project promotion (T4)
$B domain-skill rollback <host> [--global] restore prior version
$B domain-skill rm <host> [--global] tombstone
Save path runs L1-L3 content filters from content-security.ts (importable
in compiled binary, unlike L4 ML classifier — see CLAUDE.md). The L4
classifier scan happens in sidebar-agent at prompt-injection load time.
Output is structured (problem + cause + suggested-action) per DX D7.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(browse): $B cdp escape hatch — deny-default allowlist + two-tier mutex
Codex T2: flip CDP posture to deny-default. Allowed methods enumerated in
cdp-allowlist.ts with (scope: tab|browser, output: trusted|untrusted,
justification) per entry.
Initial allowlist (~25 methods) covers:
- Accessibility tree extraction (read-only)
- DOM/CSS inspection (read-only)
- Performance metrics
- Tracing
- Emulation viewport/UA override
- Page screenshot/PDF capture (output is binary, no marker injection vector)
- Network.enable/disable (no bodies/cookies — those are exfil surfaces)
- Runtime.getProperties (NO evaluate/callFunctionOn — those would be RCE)
Page.navigate is INTENTIONALLY NOT allowed; agents use $B goto which
goes through the URL blocklist.
Codex T7: two-tier mutex. tab-scoped methods take per-tab lock; browser-
scoped take global lock that blocks all tab locks. 5s acquire timeout
yields CDPMutexAcquireTimeout (no silent hangs). All lock acquires use
try/finally so errors don't leak the lock.
Path A from spike: uses Playwright's newCDPSession() per page. No second
WebSocket, no need for --remote-debugging-port. CDPSession is cached
per page in a WeakMap and cleared on page close.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(browse): CDP allowlist + two-tier mutex
13 tests:
- Allowlist linter: every entry has 4 required fields, no duplicates,
justification length > 20 chars
- Deny-list verification: dangerous methods (Runtime.evaluate, Page.navigate,
Network.getResponseBody, Browser.close, Target.attachToTarget, etc.) are
NOT allowed (Codex T2 categories 4-7)
- Per-tab mutex serializes ops on same tab
- Per-tab mutex allows parallel ops across different tabs
- Global lock blocks tab locks; tab locks block global lock
- Acquire timeout yields CDPMutexAcquireTimeout (no silent hang)
- Timeout error names the tab id and the timeout budget
Also extends Network.disable justification to satisfy linter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(browse): telemetry signals + project-slug helper
Lightweight telemetry per DX D9: piggybacks on ~/.gstack/analytics/ pattern.
Hostname + aggregate counters only, no body content. GSTACK_TELEMETRY_OFF=1
silences. Fire-and-forget — never blocks calling path.
Signals fired so far:
- domain_skill_saved {host, scope, state, bytes}
- domain_skill_save_blocked {host, reason}
(domain_skill_fired and cdp_method_* fired in subsequent commits.)
Also extracts project-slug resolution into project-slug.ts so server.ts
and domain-skill-commands.ts share one cached lookup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(browse): sidebar prompt-context injection + CDP telemetry
server.ts spawnClaude now:
- Imports per-project domain skill matching the active tab's hostname
via readDomainSkill()
- Wraps the body in UNTRUSTED EXTERNAL CONTENT envelope (so the L4
classifier in sidebar-agent sees it at load time per Eng D4)
- Appends as <domain-skill source="..." host="..." version="..."> block
- Fires domain_skill_fired telemetry (host, source, version)
- Calls recordSkillUse fire-and-forget so the auto-promote-after-N=3
state machine advances on each successful prompt injection
System prompt also gets a one-liner introducing $B domain-skill commands
to agents (DX D4 start-of-task discoverability hint).
cdp-bridge.ts fires:
- cdp_method_denied (drives next allow-list growth)
- cdp_method_lock_acquire_ms (P50/P99 quantile observability)
- cdp_method_called (allowed methods)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(browse): telemetry module
3 tests covering:
- logTelemetry writes JSONL with ts injected
- GSTACK_TELEMETRY_OFF=1 silences all events
- logTelemetry never throws on disk failures
Uses GSTACK_HOME env var to redirect writes to a tmp dir; the telemetry
module reads HOME lazily so test mutations take effect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: domain-skills reference + error lookup table
docs/domain-skills.md mirrors the layered shape of docs/gbrain-sync.md
(DX D8): how agents use it, state machine, storage layout, security model
(L1-L3 + L4 layered defense), error reference table.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(readme): browser-harness-js plug + domain-skills section
New "Domain skills + raw CDP escape hatch" section under "The sprint"
covering both v1.8.0.0 features. Plugs browser-use/browser-harness-js
as the no-rails alternative for users who want raw CDP without gstack's
security stack.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump version and changelog (v1.8.0.0)
Branch-scoped bump on top of merged 1.7.0.0 base. CHANGELOG entry covers
the full v1.8.0.0 scope: $B domain-skill, $B cdp escape hatch, two-tier
mutex, telemetry signals, sidebar prompt-context injection. Includes
Codex outside-voice trail (7 of 20 findings resolved, 12 mooted by T1
scope drop).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* todos: 7 follow-ups from v1.8.0.0 review trail
P1: Self-authoring $B commands with out-of-process worker isolation
(Codex T1 deferred from v1.8.0.0 — needs real isolation design)
P2: Migrate /learn to SQLite (Codex T5 long-term primitive fix)
P2: Remove plan-mode handshake from /plan-devex-review (skill bug)
P3: GBrain skillpack publishing for domain-skills
P3: Replay/record demonstrated flows to domain-skills
P3: $B commands review batch-mode UX (alternative to inline approval)
P3: Heuristic command-gap watcher (DX D4 alternative C)
Each entry has the standard What/Why/Pros/Cons/Context/Effort/Priority/
Depends-on shape so anyone picking these up later has full context.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(browse): lazy GSTACK_HOME resolution in domain-skills
Module-level constants (GLOBAL_FILE, derived path) were evaluated at
module-load and cached. When E2E and unit tests run in the same Bun
test pass and set GSTACK_HOME differently, the second test sees the
first test's path. Switch to lazy gstackHome() / globalFile() / projectFile()
helpers so process.env mutations take effect.
Mirrors the pattern already used in telemetry.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(browse): E2E gate-tier tests for domain-skills + CDP
domain-skills-e2e.test.ts (4 tests):
- save derives host from active tab top-level origin (T3)
- save lands quarantined; list surfaces it
- readSkill returns null until 3 uses without flag promote to active (T6)
- save without an active page errors with structured guidance
cdp-e2e.test.ts (8 tests):
- Accessibility.getFullAXTree returns wrapped JSON (allowed, untrusted-output)
- Performance.getMetrics returns plain JSON (allowed, trusted-output)
- Runtime.evaluate DENIED with structured guidance (T2 RCE block)
- Page.navigate DENIED (must use $B goto for blocklist routing)
- Network.getResponseBody DENIED (exfil block)
- malformed JSON params surfaces clear error
- non Domain.method format surfaces clear error
- $B cdp help returns help text
Both files boot a real Chromium via BrowserManager.launch() and exercise
the dispatch handlers end-to-end. Total 12 E2E tests in <2s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: regenerate SKILL.md files with new $B commands
bun run gen:skill-docs picks up the domain-skill and cdp META_COMMANDS
entries added in commands.ts. Both top-level SKILL.md and browse/SKILL.md
now list the new commands in their Meta and Inspection tables.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(fixtures): regenerate ship SKILL.md golden baselines for v1.7.0.0
Pre-existing failures inherited from garrytan/gbrain-support: the GBrain
Sync preamble block (added in v1.7.0.0) appears in regenerated SKILL.md
output but the golden baselines in test/fixtures/golden/ were never
updated. Three failures fixed:
golden-file regression > Claude ship skill matches golden baseline
golden-file regression > Codex ship skill matches golden baseline
golden-file regression > Factory ship skill matches golden baseline
Goldens regenerated by copying the current ship/SKILL.md, codex
.agents/skills/gstack-ship/SKILL.md, and .factory/skills/gstack-ship/SKILL.md
files. Diff is the v1.7.0.0 GBrain Sync preamble block + privacy stop-gate
(no behavioral changes — just preamble text).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(brain-sync): bearer-token regex catches values with leading space
Pre-existing bug from v1.7.0.0: the bearer-token-json secret pattern
required values matching [A-Za-z0-9_./+=-]{16,}, which rejected the
"Bearer <token>" form because the literal space after "Bearer" wasn't
in the character class. Real Authorization headers use "Bearer <token>"
syntax, and the test fixture
'"authorization":"Bearer abcdef1234567890abcdef1234567890"'
sat unscanned despite being a leak-class secret.
One-character fix: add space to the value character class. Test
'gstack-brain-sync secret scan > blocks bearer-json' now passes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(brain-sync): GSTACK_HOME isolation test compares mtime, not content
Pre-existing flaky test: the GSTACK_HOME-overrides-real-config test asserted
the real ~/.gstack/config.yaml does NOT contain "gbrain_sync_mode: full"
after the test. That fails for any user whose real config legitimately has
that key set from prior usage — the test's invariant is "the command did
not modify the real file," not "the real file lacks any specific value."
Switch to mtime + content snapshot: capture both BEFORE running the command,
then verify both are unchanged after. Also add a positive assertion that
the tmpHome config DID get the new key.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(skill-validation): exempt deliberate large fixtures from 2MB limit
Pre-existing failure: the "git tracks no files larger than 2MB" test
caught browse/test/fixtures/security-bench-haiku-responses.json (28.8MB
of replay data committed in v1.6.4.0 for security benchmark gate tests).
The test exists to catch accidentally-committed binaries (Mach-O dist
binaries, etc), not to forbid all large files. Add an explicit
LARGE_FIXTURE_EXEMPTIONS allowlist so deliberate replay fixtures pass
the gate while accidental binaries still fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(skill-token): mint scoped tokens per skill spawn
Wraps token-registry.createToken/revokeToken with skill-specific
clientId encoding (skill:<name>:<spawn-id>) and read+write defaults.
Skill scripts get a per-spawn capability token bound to browser-driving
commands; the daemon root token never leaves the harness.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(browse-client): SDK for browser-skill scripts
Thin wrapper over POST /command with bearer auth. Resolves daemon
port + token from GSTACK_PORT + GSTACK_SKILL_TOKEN env vars first
(set by $B skill run when spawning), falls back to .gstack/browse.json
for standalone debug runs.
Convenience methods cover the read+write surface skills typically need:
goto, click, fill, text, html, snapshot, links, forms, accessibility,
attrs, media, data, scroll, press, type, select, wait, hover, screenshot.
Low-level command(cmd, args) escape hatch for anything else.
This is the canonical SDK source. Each browser-skill ships a sibling
copy at <skill>/_lib/browse-client.ts so each skill is fully portable
and version-pinned.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(browser-skills): 3-tier storage helpers
listBrowserSkills() walks project > global > bundled (first-wins),
parses SKILL.md frontmatter, no INDEX.json. readBrowserSkill() does
the same for a single name. tombstoneBrowserSkill() moves a skill
into .tombstones/<name>-<ts>/ for recoverability.
Frontmatter parser handles the subset browser-skills need: scalars
(host, description, trusted, version, source), string lists
(triggers), and arg-mapping lists ([{name, description}, ...]).
Quoted values handle colons; trusted defaults to false.
Bundled tier path is auto-detected from the binary install location;
project tier comes from git rev-parse; global is ~/.gstack/. All tier
paths are overridable for hermetic tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(browser-skills): \$B skill list/show/run/test/rm subcommands
handleSkillCommand dispatches to per-subcommand handlers; spawnSkill is
the load-bearing function that:
1. Mints a per-spawn scoped token (read+write only) bound to the
skill name + spawn-id.
2. Builds the spawn env:
- trusted: passes process.env minus GSTACK_TOKEN (defense in depth).
- untrusted: minimal allowlist (LANG, LC_ALL, TERM, TZ) + locked
PATH; explicitly drops anything matching TOKEN/KEY/SECRET/etc.
Also drops AWS_/AZURE_/GCP_/GOOGLE_APPLICATION_/ANTHROPIC_/OPENAI_/
GITHUB_/GH_/SSH_/GPG_/NPM_TOKEN/PYPI_ patterns.
3. Always injects GSTACK_PORT + GSTACK_SKILL_TOKEN last (cannot be
overridden by parent env).
4. Spawns bun run script.ts -- <args> with cwd=skillDir, captures
stdout (1MB cap), stderr, and timeout-kills past the deadline.
5. Revokes the token in finally{}, always.
list output prints the resolved tier inline so "why did it run that
one?" never becomes a debugging mystery (Codex finding #4 mitigation).
server.ts threads the listen port to meta-commands via MetaCommandOpts.daemonPort.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(browser-skills): bundled hackernews-frontpage reference skill
Smallest interesting browser-skill: scrapes HN front page, returns
30 stories as JSON. No auth, stable HTML, fully fixture-tested.
Files:
SKILL.md frontmatter + prose
script.ts exports parseStoriesFromHtml(html)
main: goto + html + parse + JSON.stringify
_lib/browse-client.ts vendored copy of the SDK
fixtures/hn-2026-04-26.html captured front page (5 stories)
script.test.ts 13 assertions against the fixture
The parser is a pure function over HTML so script.test.ts runs
without a daemon (just imports parseStoriesFromHtml and asserts).
This exercises every Phase 1 component end-to-end:
- browse-client SDK (script imports browse from ./_lib/)
- 3-tier lookup (hackernews-frontpage lives in the bundled tier)
- scoped tokens (read+write is enough for goto + html)
- spawn lifecycle (\$B skill run hackernews-frontpage)
- file-fixture testing (\$B skill test hackernews-frontpage)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(skill-validation): cover bundled browser-skills
Adds 7 assertions per bundled skill at <root>/browser-skills/<name>/:
- SKILL.md exists
- frontmatter parses with required fields (name/host/triggers/args)
- script.ts exists
- _lib/browse-client.ts exists and matches the canonical SDK byte-for-byte
- script.test.ts exists
- script.ts imports browse from ./_lib/browse-client
The byte-identical SDK check enforces the version-pinning contract:
when the canonical SDK at browse/src/browse-client.ts changes, every
bundled skill's _lib/ copy must be re-synced or this test fails.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(designs): add BROWSER_SKILLS_V1 design doc
Captures the 13 locked decisions, two-axis trust model (daemon-side
scoped tokens + process-side env access), 3-tier lookup, file
layout, and full responses to all 8 Codex outside-voice findings.
Includes Phase 2-4 sketches for future branches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(todos): replace self-authoring-\$B P1 with browser-skills phases
Phase 1 of the browser-skills design shipped on this branch (sidesteps
the in-daemon isolation problem the original P1 was blocked on). The
new entries enumerate the work that remains:
P1: Phase 2 (/scrape + /automate skill templates)
P2: Phase 3 (resolver injection at session start)
P2: Phase 4 (eval infra + fixture staleness + OS sandbox)
Cross-references docs/designs/BROWSER_SKILLS_V1.md for the full
architecture and the 8 Codex review findings + responses.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* release: v1.9.0.0 — browser-skills runtime
VERSION 1.8.0.0 → 1.9.0.0. CHANGELOG entry leads with what humans
can do today (hand-write deterministic browser scripts, run them in
200ms via \$B skill run). Notes explicitly that agent authoring
lands in next release; no fabricated perf numbers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(browser-skills-e2e): exercise dispatch with bundled hackernews-frontpage
Covers the full \$B skill list/show/test pipeline against the real
bundled reference skill (defaultTierPaths picks up <repo>/browser-skills/).
Verifies frontmatter shape, the three-tier walk surfaces the bundled
entry, and \$B skill test successfully runs the bundled script.test.ts
in a child bun process.
\$B skill run end-to-end against the live network is intentionally NOT
covered here (would be flaky against news.ycombinator.com); the spawn
lifecycle is exercised in browser-skill-commands.test.ts using inline
synthetic skills.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: regen SKILL.md to surface the skill META command
bun run gen:skill-docs picked up the new \`skill\` command from
COMMAND_DESCRIPTIONS in browse/src/commands.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* release: bump v1.9.0.0 → v1.13.0.0
Main shipped through v1.11.1.0 while this branch was in flight; v1.12.x
is presumed claimed by another in-flight branch. Use v1.13.0.0 as the
next available slot.
Updated VERSION, package.json, and the CHANGELOG header. Entry body
unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* release: bump v1.13.0.0 → v1.16.0.0
Main shipped v1.13.0.0 (claude outside-voice skill), v1.14.0.0
(sidebar REPL), and v1.15.0.0 (slim preamble + plan-mode E2E)
while this branch was in flight. Use v1.16.0.0 as the next
available slot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(browse-skills): atomic write helper for /skillify (D3)
stageSkill writes a candidate skill into ~/.gstack/.tmp/skillify-<spawnId>/
with restrictive perms. commitSkill does an atomic fs.renameSync into the
final tier path with realpath/lstat discipline (refuses symlinked staging
dirs, refuses to clobber existing skills). discardStaged is the cleanup
path for test failures and approval rejections, idempotent and bounded
to the per-spawn wrapper. validateSkillName enforces lowercase/digits/
dashes only, no path-escape characters.
Implements the D3 contract from the v1.19.0.0 plan review: never a
half-written skill on disk. Test fail or approval reject = rm -rf the
temp dir, no tombstone for never-approved skills.
Closes Codex finding #5 (atomic skill packaging) for Phase 2a.
34 unit assertions covering: stage validation, file-path escape rejection,
permission check, atomic rename, clobber refusal, symlink refusal, project
tier unresolved, idempotent discard, end-to-end happy + simulated test
failure + approval reject paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(scrape): /scrape <intent> skill template
One entry point for pulling page data. Three paths under the hood:
1. Match — agent reads $B skill list, semantically matches the user's
intent against each skill's triggers + description + host. Confident
match = $B skill run <name> in ~200ms.
2. Prototype — no match, drive the page with $B goto/text/html/links etc.
Return JSON, append a one-line "say /skillify" nudge.
3. Mutating refusal — verbs like submit/click/fill route to /automate
(Phase 2b P0); /scrape is read-only by contract.
Match decision lives in the agent, not the daemon. No new code in
browse/src/, no expanded daemon command surface, no new prompt-injection
blast radius.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(skillify): /skillify codifies last /scrape into permanent skill
The productivity multiplier. /scrape discovers the flow; /skillify writes
it as deterministic Playwright-via-browse-client code so the next /scrape
on the same intent runs in ~200ms.
11-step flow with three locked contracts from the v1.19.0.0 plan review:
D1 — Provenance guard. Walk back ≤10 agent turns for a clearly-bounded
/scrape result. Refuse with one specific message if cold. No silent
synthesis from chat fragments.
D2 — Synthesis input slice. Extract ONLY the final-attempt $B calls that
produced the JSON the user accepted, plus the user's intent string. Drop
failed selectors, drop unrelated chat, drop earlier-session content.
Closes Codex finding #6 by picking option (b) from the design doc:
re-prompt from agent's own context, not a structured recorder.
D3 — Atomic write. Stage to ~/.gstack/.tmp/skillify-<spawnId>/, run
$B skill test against the temp dir, only rename into the final tier path
on test pass + user approval. Test fail or approval reject = rm -rf the
temp dir entirely.
Default tier: global (~/.gstack/browser-skills/<name>/). --project flag
overrides to per-project. Generated test must include at least one ★★
assertion (parsed JSON has expected shape + non-empty key fields), not a
smoke ★ assertion.
Bun runtime distribution (Codex finding #7) carries over to Phase 4.
Documented in the skill's Limits section.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(browser-skills): gate-tier E2E for /scrape + /skillify (D4)
Five scenarios cover the productivity loop and the contracts locked
during the v1.19.0.0 plan review:
scrape-match-path — intent matching bundled hackernews-frontpage
routes via $B skill run, no prototype phase
scrape-prototype-path — no matching skill, drives $B against a local
file:// fixture, returns JSON, suggests
/skillify
skillify-happy-path — /scrape then /skillify; skill written to
~/.gstack/browser-skills/<name>/ with the
full file tree; SKILL.md prose body must
not contain conversation fragments (D2)
skillify-provenance-refusal — cold /skillify with no prior /scrape refuses
with the D1 message; nothing on disk (D1)
skillify-approval-reject — /scrape then /skillify but reject in the
approval gate; temp dir is removed, nothing
at the final tier path (D3)
All five gate-tier (~$0.50-$1.50 each, ~$5 total per CI run). Set EVALS=1
to enable. Uses local file:// fixtures so prototype + skillify scenarios
run deterministically without network.
Touchfiles registers all 5 entries with proper deps on scrape/**,
skillify/**, browse/src/browser-skill-write.ts, and the Phase 1 runtime
modules. The match-path test depends on the bundled hackernews-frontpage
skill so its touchfile includes browser-skills/hackernews-frontpage/**.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(browser-skills): TODOS Phase 2a + design doc D1-D4 decisions
TODOS.md:
- Narrows existing P1 (was "/scrape and /automate") to "/scrape and
/skillify" — the /scrape + /skillify wedge ships in this branch.
Codex finding #6 (synthesis) removed from Cons (resolved by D2);
finding #7 (Bun runtime) stays as the open carry-over.
- Adds new ## P0 above PACING_UPDATES_V0 for the /automate follow-up.
Same skillify pattern as /scrape, different trust profile (per-step
confirmation gate when running non-codified). Reuses /skillify and
the D3 helper as-is. Effort M.
BROWSER_SKILLS_V1.md:
- Phase table re-organized into 1, 2a, 2b, 3, 4. Phase 1 + Phase 2a
consolidate into v1.19.0.0 ship (the v1.16.0.0 branch-internal
bump never landed on main).
- New "Phase 2a" sub-section captures the four decisions locked
during /plan-eng-review:
D1 — provenance guard (≤10 turn walk-back, refuse if cold)
D2 — synthesis input slice (final-attempt $B calls only,
closes Codex finding #6)
D3 — atomic write discipline (temp-dir-then-rename via new
browse/src/browser-skill-write.ts helper)
D4 — full test scope (5 gate E2E + 1 unit + smoke)
- New "Phase 2b" sketch for /automate: same skillify machinery,
per-mutating-step confirmation gate, deferred to next branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* release: v1.16.0.0 -> v1.19.0.0 — browser-skills Phase 1 + 2a
Consolidates the v1.16.0.0 branch-internal bump (Phase 1 runtime, never
landed on main) with Phase 2a (/scrape + /skillify + atomic-write helper)
into one v1.19.0.0 ship per CLAUDE.md "Never orphan branch-internal
versions" rule.
Headline: Browser-skills land end-to-end. /scrape <intent> first call
drives the page; second call runs the codified script in 200ms.
The unified CHANGELOG entry covers:
- Phase 1 runtime: $B skill list/show/run/test/rm, scoped tokens,
3-tier storage, bundled hackernews-frontpage reference.
- Phase 2a: /scrape + /skillify gstack skills, browser-skill-write.ts
atomic helper, 5 gate-tier E2E + 34 unit assertions.
Numbers table updated: 5 new modules (+browser-skill-write), 2 new
gstack skills, 6 of 8 Codex outside-voice findings resolved (synthesis
#6 closed by D2; Bun runtime #7 + OS sandbox #1 stay deferred to Phase 4).
/automate (Phase 2b) is split out as P0 in TODOS for the next branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(commands): tighten descriptions for LLM-judge baseline pinning
The skill-llm-eval test "baseline score pinning" failed CI on three
retry attempts: judge gave command_reference.actionability=3, baseline
demands ≥4. Judge cited 8 specific gaps in COMMAND_DESCRIPTIONS.
This commit closes 7 of 8 by tightening the descriptions:
- press: documents that key names are case-sensitive Playwright keys,
shows modifier syntax (Shift+Enter, Control+A), links the full key
list. Removes the "is this case-sensitive?" guesswork.
- is: documents that <sel> accepts either a CSS selector OR an @ref
token from a prior snapshot, and that property values are case-
sensitive.
- scroll: documents that there is no --by/--to amount option, points
at `js window.scrollTo(0, N)` for pixel-precise scrolling.
- js / eval: clarifies that both run in the same JS sandbox, the
difference is just inline expr (js) vs file (eval).
- storage: clarifies sessionStorage is read-only via this command,
points at `js sessionStorage.setItem(...)` for the write path.
- chain: walks through how to invoke (pipe a JSON array of arrays to
$B chain), confirms it stops at the first error.
- cdp: explains how to discover allowed methods (read cdp-allowlist.ts)
+ shows a concrete example invocation.
- domain-skill: explains that the "classifier flag" is set automatically
by the L4 prompt-injection scan (agents do not set it manually);
enumerates the full lifecycle verbs.
The 8th gap (storage set syntax conflict) is also resolved as part of
the storage rewrite.
Two pipe-character bugs caught by the existing
`no command description contains pipe character` guard at
`test/gen-skill-docs.test.ts:595`: the chain example originally used
`echo '[...]' | $B chain` (literal pipe) and the cdp description used
`tab|browser` / `trusted|untrusted` (also literal pipes). Both rewritten
to keep markdown table cells intact.
Verification: 696/0 pass on skill-validation + gen-skill-docs after
regen across all hosts. The CI llm-judge eval will re-run against the
new SKILL.md and should hit actionability ≥4 reliably.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(browser): rewrite BROWSER.md as complete reference
Full rewrite covering the gstack browser surface as of v1.19.0.0. Up from
488 to 1,299 lines, 26 top-level sections.
Adds previously-undocumented subsystems:
- The productivity loop: /scrape + /skillify with D1 (provenance guard),
D2 (final-attempt-only synthesis), D3 (atomic-write discipline) contracts.
- Browser-skills runtime: anatomy, three-tier storage, scoped tokens, trust
model (capability + env axes), sibling SDK distribution, atomic-write
helper, bundled hackernews-frontpage reference.
- Domain-skills: per-site agent notes with quarantined → active → global
state machine and the L4-classifier auto-promotion gate.
- Pair-agent: dual-listener architecture, 26-command tunnel allowlist,
canDispatchOverTunnel pure gate, three token types (root, setup key,
scoped), denial log path + salt model.
- Security stack L1-L6: layer table, thresholds (BLOCK/WARN/LOG_ONLY/
SOLO_CONTENT_BLOCK), ensemble rule, classifier model paths, env knobs.
- Side Panel deep dive: Terminal pane (Claude PTY) as the primary surface
with Activity/Refs/Inspector as debug overlays, WS auth via
Sec-WebSocket-Protocol, gstackInjectToTerminal cross-pane plumbing.
- CDP escape hatch: $B cdp deny-default allowlist, $B inspect CSS inspector,
$B ux-audit page structure extraction.
- Meta commands previously undocumented: tabs/frames/state/watch/inbox/
tab-each, with usage and storage paths.
- Authentication: three token types with lifetimes, SSE session cookie,
PTY session cookie, token registry behavior.
- Full source map: 30+ file inventory of browse/src/ vs the old 11-file
list.
Preserves from before: architecture diagram, daemon lifecycle, snapshot
ref staleness, screenshot modes, goto file:// vs load-html semantics,
batch endpoint, JS await wrapping, env vars, performance numbers vs MCP,
Playwright acknowledgments, dev guide.
Cross-links to ARCHITECTURE.md, CLAUDE.md, docs/REMOTE_BROWSER_ACCESS.md,
docs/designs/BROWSER_SKILLS_V1.md, scrape/SKILL.md, skillify/SKILL.md,
TODOS.md so anyone landing on BROWSER.md can navigate to the load-bearing
companion docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(server): tab-ownership gate keys on tabPolicy, not isWrite
Browser-skill spawns hit `403: Tab not owned by your agent` on every
first run because the gate at server.ts:639 fired for any non-root
write, regardless of the token's tabPolicy. The bundled
hackernews-frontpage reference skill failed identically. Every
/skillify-generated skill failed identically. The user's natural
tabs have no claimed owner — by design — so any skill driving
them via `goto` (a write) was 403'd.
The intent in skill-token.ts:79 was always correct: `tabPolicy: 'shared'`
with the comment "skill scripts may switch tabs as needed." The
enforcement just ignored it.
Two surgical changes:
browser-manager.ts:checkTabAccess — gate now keys on options.ownOnly
only. Shared-policy tokens (skill spawns, default scoped clients) get
permissive access — root-equivalent for the tab gate. Own-only tokens
(pair-agent over the ngrok tunnel) still require ownership for every
read and write. isWrite stays in the signature for callers that want
to log or branch elsewhere; it no longer gates the decision.
server.ts:639 — gate predicate narrowed from
(WRITE_COMMANDS.has(command) || tokenInfo.tabPolicy === 'own-only')
to just
tokenInfo.tabPolicy === 'own-only'
The 'newtab' exemption stays. Shared tokens skip the gate entirely;
own-only tokens still hit it. Comment block above the gate updated to
document the new predicate intent.
Pair-agent isolation is intact. Tunnel tokens still default to
tabPolicy: 'own-only', still must `newtab` first to get a tab they
can drive, still can't dispatch any of the 23 commands outside the
tunnel allowlist.
The capability gate (scope checks) and rate limits already constrain
what local scoped clients can do; tab ownership was never a security
boundary for them — only for pair-agent. This release makes the
enforcement match the original design intent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(server): lock the shared-vs-own-only tab gate contract
The pre-fix tests at tab-isolation.test.ts:43,57 encoded the broken
behavior as the contract — they specifically asserted "scoped agent
cannot write to unowned tab," which was the exact failure mode that
broke browser-skills. They passed because they tested the wrong
invariant.
This commit replaces those tests with explicit shared-vs-own-only
coverage that documents what each policy actually means:
- Shared scoped agents (skill spawns, default scoped clients) can
read AND write any tab — unowned, their own, or another agent's.
The capability is gated by scope checks + rate limits, not by tab
ownership.
- Own-only scoped agents (pair-agent over tunnel) cannot read OR
write any tab they don't own. Pre-fix this case was conflated with
shared writes; now it's explicit.
9 unit assertions on checkTabAccess, up from 6. Each test names
the policy axis it's covering so a future refactor can't quietly
flip the contract.
Adds source-shape regression test 10a in server-auth.test.ts:
"tab gate predicate is own-only-scoped, not write-scoped." The
gate's `if (...)` line MUST contain `tabPolicy === 'own-only'` and
MUST NOT contain `WRITE_COMMANDS.has(command) ||`. If a future
refactor re-introduces the write-scoped gate, this fails immediately
in free-tier `bun test`.
Updates the marker for the existing newtab-excluded test to match
the new comment block ("Tab ownership check (own-only tokens /
pair-agent isolation)").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* release: v1.19.0.0 -> v1.20.0.0 — fix tab-ownership footgun
Patch release on top of v1.19.0.0. The shipping headline of v1.19.0.0
(/scrape + /skillify productivity loop) was broken on first run in any
session where the daemon already had a tab. Bundled
hackernews-frontpage failed identically. Every /skillify-generated
skill failed identically.
The fix narrows the tab-ownership gate from "any non-root write" to
"tabPolicy === 'own-only' only." Pair-agent isolation (the v1.6.0.0
threat model) is intact; local skill spawns get their original
behavior back.
VERSION: 1.19.0.0 -> 1.20.0.0
package.json version: synced.
CHANGELOG entry leads with the user-visible impact: the productivity
loop works again, no half-second-stalls of confused 403s. Includes
before/after metrics on the bundled reference skill and the broken-
contract pre-fix tests that hid the regression.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(claude): sharpen CHANGELOG rule — diff between main and ship
Codifies what was already implicit in the existing "Never orphan
branch-internal versions" + "Only document what shipped between main
and this change" sections, but with sharper language and concrete
NEVER examples.
The rule: a CHANGELOG entry is the diff between main and the shipping
branch — what users get when they upgrade. NOT how the branch got
there. Branch-internal version bumps, mid-branch bug fixes, plan
review outcomes, and patch narratives all belong in PR descriptions
and commit messages, not in CHANGELOG.
Adds explicit examples of phrasing to NEVER use:
- "v1.X had a bug that v1.Y fixes" (mentions a branch-internal version)
- "The shipping headline of v1.X was broken because..." (apologizes
for never-released state)
- "Pre-fix tests encoded the broken behavior" (contributor's victory
lap, not user benefit)
- "Two surgical edits, both in the dispatch path" (micro-narrative
of the patch)
The constructive replacement: describe the released system as a
property, not as a fix. "Browser-skills run end-to-end with the
expected tab-access semantics." If a property is worth calling out,
document it in the trust-model section, not as a "we fixed X" callout.
Pairs with feedback_no_shame_changelog and
feedback_changelog_harden_against_critics memories — entries should
read as a flex even to a hostile screenshotter, never admit prior
breakage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(changelog): consolidate v1.20.0.0 as the diff vs main
Rewrites the v1.20.0.0 entry to describe what users get when they
upgrade from main (v1.17.0.0) to this release: browser-skills
end-to-end. Drops all branch-internal narrative — Phase 1 / Phase 2a
labels, the v1.8.0.0 P1 history paragraph, the test-counts-by-phase
split, and the patch micro-narrative for the tab-policy semantics.
The previously-separate v1.19.0.0 entry (a branch-internal version
that never landed on main) collapses into v1.20.0.0 per the
"Never orphan branch-internal versions" rule.
Tab-access policies are now documented as a property of the trust
model: `'shared'` (skill spawns) is permissive, `'own-only'`
(pair-agent over the tunnel) is strict. No "fix" framing, no
mention of an intermediate state where it was broken.
Adds the BROWSER.md rewrite and the new tab-isolation +
server-auth source-shape regression tests to the itemized changes.
The reverse-chronological order remains: v1.20.0.0 → v1.17.0.0 →
v1.16.0.0 → v1.15.0.0 → ... Gaps (v1.18, v1.19) are fine — those
were branch-internal version numbers that never landed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>