From 3e8b34d892bae426ba40f60adceec0030e03d110 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 16 Oct 2026 15:00:32 -0700 Subject: [PATCH] v1.91.106.0 fix: the 2026-10-15 issue wave (#3209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(designs): add fix wave 2026-10-15 plan (revision 3, approved) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(cso): invoke docker as argv0 docker so OrbStack's multicall binary dispatches executable() returns the canonical path, and OrbStack installs docker as a symlink to docker-tools, which dispatches on argv[0] and exits 127 with 'unsupported argv0 "docker-tools"'. Every Docker call failed, so doctor reported local-docker-isolation and every scanner missing and setup could not preload the qualified images. runProcess takes an argv0 option; docker.ts routes every Docker call through runDocker, which passes 'docker'. The native watchdog's execve calls pass "docker" as argv[0] too, so its container cleanup works on the same installs. The executed file is still the verified canonical path. Fixes #3187 * test(cso): cover Docker and watchdog invocation through a multicall binary A compiled fake that refuses any argv[0] but docker stands in for OrbStack's docker-tools. Both behavior tests fail on the previous code. Refs #3187 * fix(cso): bound the argv0 test's socket helper and probe images through OrbStack's multicall docker (#3187) Follow-up to #3188 by Bryan Stone (@aegixx): - the python3 spawnSync that binds the fake daemon socket gets a 30 s timeout; without it test/spawnsync-timeout-tripwire.test.ts fails. - reviewer test: dockerExactImagePresent, a production probe, admits an image when the endpoint's canonical executable is a multicall binary that refuses any argv0 other than "docker". It fails on 47d639a (returns false) and passes with #3188. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): own the conversation after a Claude Code 2.1.300 compaction Claude Code 2.1.300 keeps a compaction's last records in place and lists them in compactMetadata.preservedMessages. The segment's tail, which is also the boundary's logicalParentUuid, is written after the boundary under the compact summary, so the parent links form a cycle that the owned causal order never reaches. Every record after the compaction was dropped, and the publication guard denied each later phase entry with close_incomplete. Such a boundary now continues from the last preserved record that is a node of the file and not its own descendant. Boundaries without that metadata, with a foreign preserved uuid, or with nothing kept in place still supply no ownership. Co-Authored-By: Claude Opus 5.5 * fix(autoplan): report a compaction-explained unreachable component as unrecognized_shape (#3194) Follow-up to #3196 by vilano93. After the ownership walk, an owned node the walk never reached was silently dropped, and the phase guard answered the next phase entry with close_incomplete, which the user cannot satisfy. When the unreachable node's ancestry ends in a cycle through a compact boundary's logical parent, ownedCausalOrder now returns unrecognized_shape:compaction_unreachable, the guard's existing unverified disposition (allow with a visible warning and a guard-log row). Orphans whose ancestor is missing (not flushed yet) and other roots keep today's behaviour, so the fail-open surface does not widen beyond compaction. Each node is walked once. Gate-integrity edit: a close_incomplete denial becomes an unverified allow for that shape only. Negative controls kept: the existing guard, hook, guard-reasons and captured-fixture tests still deny (356 pass), and a new case with the boundary not yet flushed still denies close_incomplete. Intentional pin changes in #3196's own test file: the three "fails closed" cases (tail-only segment, missing preserved metadata, foreign preserved uuid) and the "without compactMetadata" case now expect unrecognized_shape:compaction_unreachable instead of close_incomplete or a silently cut transcript. Reviewer tests: three compactions in one session stay owned and pass the next phase entry; a 50k-record history with three compactions is decided in ~300 ms (budget 1000 ms), so descends needs no memo. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(lib): one echo sanitizer for external strings, with a Bash twin lib/sanitize-echo.ts and bin/gstack-sanitize-echo.sh flatten CR/LF/TAB to spaces, remove every other ASCII control byte (ESC included) and DEL, collapse runs of spaces, and cap the result at 200 bytes of valid UTF-8 with no leading or trailing space. Every external string gstack echoes into a message, a log line or a key=value state file goes through it (fix wave 2026-10-15, plan rev 3 DX F8). test/sanitize-echo.test.ts runs both twins on one table under bash, bash --posix and zsh, including a "\nstages=" injection, ESC sequences and UTF-8 characters cut by the cap. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(bun-version): judge the Bun floor under zsh and hold on an unreadable version (#3195) Reported by mochafreddo. _gstack_bun_below word-split $_gb_core, which zsh does not do: `[ -ne ]` errored and every error path read as "not below", so /gstack-upgrade Step 4 accepted Bun 1.3.2 in a zsh tool shell. - _gstack_bun_below copies the release's parsed fields into quoted positional parameters and compares with $((10#...)); no new _gb_* globals. It returns 2 when either side cannot be parsed. - gstack_bun_status prints malformed for an unparseable version or floor (never ok); gstack_bun_incoming_hold holds on it with bun-version-unreadable: ... (BUN_VERSION_UNREADABLE), the bun path and its output passed through gstack_sanitize_echo. setup's own malformed warning is unchanged. - /gstack-upgrade Step 4 runs the check through `bash -c` and stops on BUN_VERSION_UNREADABLE with a reinstall fix (never "bun upgrade"). - New result code BUN_VERSION_UNREADABLE with its troubleshooting entry; bin/gstack-doctor-components.sh regenerated from the table. test/bun-version-shells.test.ts: one version table (1.3.2, 1.3.3-canary.1, 1.3.3, 1.3.14, 1.4.2, 1.10.0, 01.04.02, 2.0.0, garbage) drives bash, bash --posix, zsh and the TypeScript twin's below(); `zsh -c '. bin/gstack-bun-version.sh; gstack_bun_status 1.3.2'` printed ok on 47d639a and prints too-old now. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * style(cso): format #3188's runDocker call so format:cso:check passes (#3187) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(cso): keep scanner JSON reports parseable after redaction (#3185) Reported in #3185. The scanner path splices redactions into raw JSON, and redactFindingSpans replaced a bare 10+ digit number ("max_memory_bytes":1145427776, an epoch) with an unquoted marker, so JSON.parse failed and every non-trivial Semgrep run was INVALID_OUTPUT. - lib/redact-engine.ts: redactJsonFindingSpans, a string- and escape-aware lexical tokenizer over one or more top-level values (NDJSON included). A span inside a number replaces that whole token (-, fraction, exponent) with the quoted marker, number by number; a span inside a string keeps the in-string marker, widened to whole escapes; a span touching several tokens, whitespace, a literal or a structural character returns null (withhold). Non-JSON text gets the plain splice. redactFindingSpans is unchanged for every other caller. - The scan operation's declared format rides the real capture path: scanner-executor (scan stdout, or the report file read for outputPath plans) -> DockerGroup.execCapture -> runProcess -> spliceRedaction, which redacts the concatenated stdout once and maps each interleaved run onto it. Version probes and stderr stay plain; marker-only withholding and the cross-stream views check are kept, and the output is a fixpoint of that check. Behavior change: a redacted number in a scanner report is now the string "" instead of an unquoted marker; non-JSON streams keep the unquoted form (test/cso-eval.test.ts unchanged). Intentional pin change: test/cso-scanner-release.test.ts pinned the report read as capture(['/bin/cat', plan.outputPath]); it now pins capture(['/bin/cat', plan.outputPath], plan.format), so the read also carries the declared JSON format (stricter, not weaker). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(browse): recover when the last tab is closed outside the CLI Closing the last tab outside browse (for example, closing the headed Chromium window) left the daemon wedged: the page 'close' handler removed the tab and set activeTabId to 0, and executeCommand called getActiveSession() first, so every later command (goto, status, even stop) failed with "No active page" until the daemon PID was killed by hand. closeTab() already opens a blank tab when the last tab closes; the external close path never did. Add BrowserManager.ensureActiveTab(), which reselects a remaining tab or opens one blank tab (concurrent callers share one recovery), and call it before resolving the active session in executeCommand. Fixes #3167 * fix(browse): status and stop answer after an external close; recovery says what it did (#3167) Follow-up to #3179 by aftabkh4n, which reopens a tab on demand. - BrowserManager.ensureCommandTab runs before every command: status, stop and restart never open a tab, so a daemon whose browser or context is gone can still report and be stopped; any other command gets an active tab. A failed recovery is BROWSE_TAB_UNRECOVERABLE carrying the restart command (cause passed through sanitizeEcho), and a command that ran on a newly opened tab prints a one-line warning first. - server.ts reads the session inside the read and write branches instead of before dispatch (2223 lines, cap 2224) and exposes browserManager and handleCommandInternal on __testInternals__ for the dispatch test. - meta-commands.ts: handleMetaCommand no longer reads the active session before its switch (only snapshot and resume used it), and status prints getCurrentUrl() so it works with zero tabs. This file is outside the lane's listed ownership; no other lane or #3190 touches browse/. - New result code BROWSE_TAB_UNRECOVERABLE and troubleshooting entries for it and for the old "No active page" wedge. Reviewer test browse/test/tab-external-close-dispatch.test.ts drives the real dispatch in a child process: status, text, goto and stop after an external close, and the closed-context case. On 47d639a (with only the test seam added) all five fail with "No active page". Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(troubleshooting): /cso INVALID_OUTPUT on Semgrep reports and OrbStack's argv0 refusal (#3185, #3187) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(session-update): name the reinstall fix when an unreadable Bun holds the auto-update (#3195) Item 11 makes every caller of gstack_bun_incoming_hold treat a Bun version it cannot read as a hold (bun-version-unreadable, BUN_VERSION_UNREADABLE). The session-start banner only knew bun-too-old, so the new reason fell through to "git pull did not finish ... gstack is not updating" with a pull-and-setup fix that does not help. - bin/gstack-session-update: a bun-version-unreadable banner case with the reinstall fix and the troubleshooting anchor; the comment that said a malformed version passes is corrected. Intentional pin change: test/session-update-stages.test.ts "supported or unparseable Bun (bun-dev (local build)) proceeds: setup decides" is split. 1.3.14 still proceeds; bun-dev (local build) now holds with BUN_VERSION_UNREADABLE, leaves the checkout and setup untouched, and the banner names the reinstall fix. The fixture installs bin/gstack-sanitize-echo.sh beside the floor helper, as a real install of this release does. The new case fails on 47d639a. Ownership exception granted by the coordinator for Lane A (bin/gstack-session-update is Lane B's file; PR 1b stacks on 1a). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(session-update): recover a conflicting autostash by its unmerged files The auto-updater fast-forwards with `merge --ff-only --autostash`. When the carried-over local edits conflict with the incoming revision, git 2.56 still exits 0 from that merge, pops the edits with conflict markers into the live checkout, and keeps the stash entry. The recovery only ran on a non-zero exit with "autostash" in stderr, so on git 2.56 it never ran: the updater logged UPDATED and ran setup over a half-merged tree, in the directory Claude Code runs hooks from. When it did run, it had two more problems: - `git checkout -- .` refuses unmerged paths ("error: path 'f' is unmerged", exit 1), so it left the markers in place anyway. - `git stash drop` dropped stash@{0}, whatever that was. refs/stash is shared with every worktree of the repository, and when refs/stash.lock is held git cannot store the autostash at all, so the top entry can be someone else's. Now the merge is judged by `git ls-files -u`, whatever its exit code. On unmerged files after the fast-forward, autostash_recover finds the autostash commit by the id git prints ("cannot store " first, then "Created autostash: ", with the merge run under LC_ALL=C and core.abbrev=40). A moved refs/stash is only the fallback. When that id is not a stash entry, it is anchored under refs/gstack-autostash/. The tree is reset to the fast-forwarded revision, which the hook check already passed, and the stash is kept. The log line is AUTOSTASH_CONFLICT_RECOVERED tree_reset=1 kept_stash= kept_ref=. The update then continues, so the regenerable patches are re-rendered and setup runs as for any other move. If the commit cannot be found, nothing is reset: the markers stay for a person, the run logs PULL_FAILED, and setup does not run. Files that are already unmerged before the update, from someone's own merge, rebase or stash pop, now hold the update with PULL_FAILED "unmerged files ...". Without that check, the recovery would take them for its own and reset them away. core.abbrev=40, not "no": "no" is accepted from git 2.31.0 (its release notes), and `merge --autostash` exists from 2.27.0, so "no" would fail every update on git 2.27-2.30. Files: - bin/gstack-session-update: autostash_recover, the unmerged-files check before the merge, the C-locale merge with its stdout kept for the id, and removal of the old grep-and-drop block. - test/session-update-autostash.test.ts: 7 cases in a new describe block. A conflicting pop leaves no markers, keeps the edits and still runs setup. A user's own stash entry survives. A held refs/stash.lock gets the edits anchored. With a git whose merge prints nothing, a moved refs/stash still finds the edits; with the lock held as well, nothing is reset. Pre-existing unmerged files hold the checkout. A non-conflicting edit rides over the update (control). Tests, on upstream 15ebd85cd: - 5 of the 7 new cases fail against the previous session-update; the unmerged-files guard and the control pass on both. - Two mutants are each caught by one new case: dropping the refs/stash fallback, and resetting when the edits cannot be found. - session-update-autostash, session-update-stages, session-update-hook-hold and team-mode pass 63/0 with `--timeout 60000`. At bun's 5 s default under heavy load (one reading: load average 169), the three shared files timed out 8 tests on this branch and 17 with the previous script. Co-Authored-By: Claude Opus 5.5 * fix(session-update): trust only git's autostash id; owe setup on a stop An independent review of the autostash recovery (Codex gpt-6-astra, high effort, verdict BLOCK high=1 med=2) found three defects on its rare paths. All three are fixed here, each with a case that failed first. - [high] The refs/stash fallback could erase local edits. When git's messages were unavailable, recovery took any change to refs/stash as its own autostash. refs/stash is shared with every worktree: if a sibling pushed a stash while refs/stash.lock kept git from storing this one, recovery took the sibling's entry and reset the checkout, erasing edits nothing held. Now only git's own printed id counts. A missing id resets nothing: the markers stay, the run logs PULL_FAILED. Recovery needs git's messages, so when the temp file for them cannot be created the update is held before the merge (HELD temp-file-unavailable), not merged blind. - [med] A stop after the checkout moved lost the setup it owed. The failure recorded only the pull reason. Once the person resolved the conflict, the next check saw an unchanged HEAD and no pending stage, logged UP_TO_DATE, and never set up the new revision. Now a failure after HEAD moved records `setup` as a pending stage, retried at once. The unmerged-files check holds it until the conflict is resolved, and the pull reason is the stage's reason, so the session-start line says what to resolve. - [med] An unreadable index read as "no conflicts". Both `git ls-files -u` checks discarded the exit status, so a failed read after a conflicting pop (which git exits 0 for) let setup run over the markers. Now a failed read stops the update with PULL_FAILED "cannot read the index", before the merge or after it. Files: - bin/gstack-session-update: autostash_recover drops the refs/stash fallback (and STASH_BEFORE). The temp-file hold. The ls-files exit status checked at both points. The pending setup stage on a failure after HEAD moved. - test/session-update-autostash.test.ts: a shim(fx, name, tool, body) helper; the fallback case now expects a refusal; the refusal case runs on through resolution to setup; three new cases (unreadable index before and after the merge, no temp file for git's messages). Tests, on upstream 15ebd85cd: - The 5 changed or new cases failed on the previous commit, each on its defect: refs/stash trusted and recovered; UP_TO_DATE with no setup after resolution; UPDATED over an unreadable index, before and after the merge; a blind merge with no temp file. - Against upstream's own session-update, 8 of the block's 10 cases fail. - session-update-autostash, session-update-stages, session-update-hook-hold and team-mode pass 66/0 with `--timeout 90000`. Co-Authored-By: Claude Opus 5.5 * fix(session-update): reap heartbeat and its sleeper on exit The lock heartbeat subshell left its sleep 300 child orphaned when the updater exited, so heartbeats accumulated across sessions. Track and reap the heartbeat job (with a job-table fallback for signals between fork and $!), give the subshell its own exit trap that kills and waits its sleeper, and map HUP/INT/TERM to exits so the EXIT trap always runs. Adds a fixture-driven regression test. * test(session-update): repin the lock trap and heartbeat to #3191's cleanup shape Intentional pin change: test/session-update-autostash.test.ts 'reclaim is TOCTOU-safe' and 'EXIT trap is ownership-checked' pinned the one-line EXIT trap and the 'while :; do sleep 300; ...' loop that #3191 (Jess Sullivan) replaces with cleanup_session_update and a 'sleep 300 & wait' heartbeat. The pins still assert the ownership-checked rm -rf, the EXIT trap, MYPID, and that the heartbeat is killed. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(session-update): move installs only through gstack-ff-update; extract hooks without tar (#3184) Item 2 (#3189, #3191 adopted in the previous commits) and item 3 of the fix-wave plan. bin/gstack-ff-update is now the only code that moves an install's tree. Inside an install-scoped lock (absolute git common dir, the updater's TTL and mv-aside reap, a heartbeat reaped on exit) it captures the working tree and the staged state through private indexes, carries edits that merge cleanly (as an autostash pop would), lets the regenerable SKILL.md/sections footprint take the target when it conflicts, and shelves any other conflicting edit, staged change or unmerged file into refs/gstack/rescue/ (first parent the pre-update HEAD, second the staged state; compare-and-create; gstack rescue identity). The move is a two-way read-tree on the private index, so a late edit or an untracked file in the way refuses with autostash-unrecoverable and changes nothing. It sources nothing so the held-install recovery can fetch it from the new revision. gstack-session-update calls it instead of merge --autostash, holds a checkout with unmerged files (unmerged-files:), and names shelved edits at session start at most once a day with inspect, export-the-delta and delete commands. The incoming revision is extracted for the hook check through a private index and checkout-index with core.symlinks=false, never tar (MSYS tar cannot create connect-chrome before its target), and the held reason carries the failing command's flattened, control-free stderr. Held lines print before the stage line, one case per reason with its anchor, every path shell-quoted, no setup fix while unmerged files exist. docs/troubleshooting.md gains the held-install recovery forms (Git Bash, PowerShell 5.1, trial and go-back) and entries for each new line. design: kept_ref is refs/gstack/rescue/, refs/stash is never touched, the quiet-merge cases become a pre-existing-ref refusal and its recovery, pre-existing unmerged files are a HELD unmerged-files, and the git shim pins the real binary through git --exec-path. Intentional pin changes in existing tests: - test/session-update-stages.test.ts: printed paths are shell-quoted; the bun-too-old fix runs gstack-ff-update instead of git pull. - test/session-update-hook-hold.test.ts: the order pin looks for 'bash "$FF_UPDATE"' instead of 'merge --ff-only'; the hook-check-unavailable fix no longer recommends git pull --autostash; installedUpdater copies gstack-ff-update beside the updater. Reported by wengweiling (#3184). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(gstack-upgrade): Step 4 fast-forwards through gstack-ff-update (#3189) Item 2 of the fix-wave plan, kept in its own commit so PR 1b can be rebased onto PR 1a (which edits only Step 4's Bun floor-check line). Step 4's `git pull --ff-only --autostash` had the same hole the auto-updater had: a conflicting autostash exits 0 and leaves conflict markers in the install, and the skill printed FF_OK. It now runs bin/gstack-ff-update . origin/main after the fetch and the floor check: exit 0 runs setup (a rescued line is relayed with its inspect, export and delete commands), exit 4 (not a fast-forward) is the only way into the gated reset fallback, and any other refusal stops with nothing changed. The footprint discard moves to the reset fallback, ahead of its stash, where the warning text already promised it. Intentional pin changes: - test/upgrade-template-pins.test.ts: the ff-before-reset pin looks for 'bash bin/gstack-ff-update . origin/main'. - test/gstack-upgrade-fresh-shell.test.ts: the C9 fence marker is 'bash bin/gstack-ff-update'; the gstack-like fixture ships the bin. - test/upgrade-setup-recovery.test.ts: the git-path fixtures ship gstack-ff-update (a stub where git itself is a stub). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(setup.ps1): run under Windows PowerShell 5.1's legacy argument passing (#3130) Item 4 of the fix-wave plan. Windows PowerShell 5.1 strips the double quotes inside native arguments, so the bash -c script setup.ps1 handed Git Bash arrived broken ("unexpected EOF while looking for matching"). The script now holds no double quote: `set -f; IFS=; mapfile -d '' -t argv < $1; rm -f -- $1; exec ./setup ${argv[@]}` (empty IFS and no globbing keep $1 and each argv entry whole). That form drops an empty argument, so setup.ps1 refuses one before anything runs (SETUP_PS1_EMPTY_ARGUMENT, with its troubleshooting entry). test/setup-ps1.test.ts runs the adversarial arguments under legacy passing (pwsh with $PSNativeCommandArgumentPassing = 'Legacy', and powershell.exe itself on Windows) with a space in TMPDIR, plus the empty-argument refusal. Intentional pin change: the pinned bash -c line in "passes arguments NUL-separated through a private file to a constant bash script" now names the quote-free script and asserts it holds no double quote. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(setup): register hooks at the $HOME path when CLAUDE_CONFIG_DIR links back to it A per-session CLAUDE_CONFIG_DIR whose skills symlink to ~/.claude/skills passed the split-brain guard, so its temporary path was written into the global settings.json and the hooks broke once the session directory was removed. Fall back to $HOME/.claude/skills/gstack whenever both spellings resolve to the same tree. Fixes #3180 * fix(setup): one sourced helper picks the hook root for setup and gstack-memorable (#3180) bin/gstack-memorable carried a verbatim copy of setup's old canonical-root block, so `gstack-memorable enable` under a per-session CLAUDE_CONFIG_DIR still baked the session path into settings.json after #3181 fixed setup. The choice now lives in bin/gstack-claude-root.sh (gstack_canonical_claude_root), sourced by both. Intentional test changes: - test/setup-hook-canonical-paths.test.ts: #3181's source-slicing block test is rewritten to source the helper (with a spawnSync timeout, which the sliced version lacked and the tripwire flags); the "honors CLAUDE_CONFIG_DIR" pin moves from setup to the helper, and a new pin requires both callers to source it. Adds a no-$HOME-install case. - test/setup-hook-syntax-gate.test.ts: the hook-section slice starts at the new assignment and the harness sources the helper. - test/setup-claude-code-migration.test.ts, test/team-mode.test.ts: the partial-checkout fixtures copy the new helper setup sources. Reviewer test: gstack-memorable enable under a per-session config dir that links back to ~/.claude registers the $HOME path (fails on main). Builds on #3181 by NetrioGit. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(setup): remove 1.31-era unprefixed Cursor skill folders (#3133) A 1.31 setup run from a checkout under ~/.cursor/skills linked unprefixed / folders there, each holding a SKILL.md symlink into the checkout. Current setup links gstack-/ folders, and the retired-skill prune only looks at gstack-* names, so the old folders stayed: Cursor loaded autoplan, review and the rest twice, or kept dangling links once the checkout moved. Run the Claude flat-name cleanup on ~/.cursor/skills as well. Cursor installs only gstack-* names, so no prefix gate is needed, and the helper removes only entries proven to link into gstack (#2119), so user-owned folders stay. It runs after linking, so a run that fails before the new links exist keeps the old folders. Reported by @andrewkjankowski in #3084. Fixes #3133 Co-Authored-By: Claude Opus 5.5 * fix(setup): name the skills dir in the flat-name cleanup line (#3133) `./setup --host cursor` now runs the flat-name cleanup on ~/.cursor/skills as well as ~/.claude/skills, so the "cleaned up old entries" line says which directory the folders were removed from. Reviewer test for #3152 (szsunyuan): a real dir whose SKILL.md links into gstack loses only that link and keeps the user's file; SKILL.md links into a sibling repo named gstack-fork, a relative link, and a dangling link outside any gstack checkout are all kept; the printed line names the removed folders and the Cursor skills dir. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(setup): install Chromium with `bun x`, and name exit 127 for what it is (#3131) A Bun installed with winget ships no `bunx`, so setup's `bunx playwright install chromium` exited 127 and was reported as "offline, proxy, or blocked download?". setup now runs `bun_cmd x playwright install chromium` through its existing bun resolver ($BUN_CMD), and bin/gstack-browser-ensure runs the bun it found on PATH with `x`. Exit 127 gets its own text naming the resolved bun, Bun from bun.sh, and the skip commands for Git Bash and PowerShell; other codes keep the network hint. New troubleshooting entry: #chromium-install-exit-127. Intentional test changes: - test/setup-playwright-best-effort.test.ts: the installer stub is now a `bun_cmd()` stub (was `bunx()`), its mark renamed bun-x-called and now records the args; the block under test calls bun_cmd. New winget-shaped cases (bun on PATH, no bunx anywhere) run setup's real bun_cmd. - test/gstack-browser-ensure.test.ts: the fixture is winget-shaped (the `bun` stub handles `x`, no `bunx` on PATH); the exit-7 pin now reads `/bun x playwright install chromium exited 7 (...)`. --help in gstack-browser-ensure prints the comment header only (3,24p). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ci(windows): prove setup.ps1 on PowerShell 5.1 and the held-install recovery natively (#3130, #3184) windows-setup job: - setup.ps1 joins the path filter (with bin/gstack-ff-update, bin/gstack-claude-root.sh, bin/gstack-browser-ensure, docs/troubleshooting.md and test/setup-ps1.test.ts). - powershell.exe must be Windows PowerShell 5; test/setup-ps1.test.ts runs with a space in TEMP and must report exactly 4 pass, 0 fail, 1 skip (the Linux-only missing-prerequisite case). - A copy of the built checkout under a path with a space installs through `powershell.exe -File setup.ps1 --host codex`. held-install-journey job (new, parallel): - origin's main is this PR's tree on top of the v1.91.101.0 tree (47d639abf); fix-branch adds one file. - A prefix-mode v1.91.101.0 install at the runner's ~/.claude path in three states (clean, dirty-conflicting, already-unmerged), with auto_upgrade=true and the throttle cleared: the old updater must log `HELD hook-check-unavailable`; the PowerShell recovery form, read verbatim from docs/troubleshooting.md and run under powershell.exe, must reach main with every local edit (only those) in one rescue ref. The dirty state runs it with its .\setup.ps1; the others replace that step with a marker. - The trial and go-back forms move the install to fix-branch (detached) and back to main. - Only then a later release whose symlink sorts before its target is pushed, and the new updater must log UPDATED and reach it. - Both jobs assert the exact list of cases that ran. The journey steps ran locally on Linux with pwsh in Legacy argument mode as powershell.exe, a git exec-path whose ../../../bin/bash.exe is bash, and a failing tar standing in for MSYS tar (all 9 cases passed); the windows-latest run itself happens on the PR. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(ff-update): a killed step-4 run fails the status check instead of the type check Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ci(windows): the held-install job runs without the bun cache step The cache key line is a secret-scanner false positive on added lines (env.kv); the fixture's own setup installs its packages either way. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(fixtures): register #3191's heartbeat fixture as free test/free-fixtures.test.ts failed on the frozen-branch Ubicloud run: test/fixtures/session-update-heartbeat.sh (adopted with #3191) was in no paid touchfile and not in FREE_FIXTURES. Its only consumer is test/session-update-heartbeat.test.ts. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(session-update): leave the bun-version-unreadable banner case to item 11 (PR 1a) PR 1a owns the bun-version-unreadable hold (BUN_VERSION_UNREADABLE), its banner case and its troubleshooting entry; this branch is rebased onto 1a. Drops the case, the docs entry and the banner test this branch had added. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(setup.ps1): resolve Git Bash in the shared order, with GSTACK_BASH as an alias and a BASH_UNAVAILABLE refusal Follows lib/script-invocation.ts (Lane C, PR 2): GSTACK_GIT_BASH is the override and GSTACK_BASH its deprecated alias at the same precedence; when both are set and differ GSTACK_GIT_BASH wins and one warning prints. An override that does not exist is refused, never skipped. Without one: the install git --exec-path belongs to (behind a scoop shim, or a portable Git), the folders around git.exe, %LOCALAPPDATA%\Programs\Git, %ProgramFiles%\Git, and last a bash on PATH whose uname -s reports MSYS; WSL's bash (System32, WindowsApps) is rejected. A miss lists every candidate with its rejection, then the PowerShell user-variable and ~/.claude/settings.json env recipes (BASH_UNAVAILABLE, #bash-unavailable). test/setup-ps1.test.ts gains a 14-row pwsh-on-Linux table with every location injected (override, alias, both set, missing override and alias, scoop shim, per-user, portable and machine-wide Git, MSYS PATH bash, WSL and non-MSYS rejections, missing); 11 rows fail on main. The rows mirror Lane C's table in test/script-invocation.test.ts; they are not wired to it, since that branch is not available here. The Windows job's expected skip count for this file moves from 1 to 15 (the table is Linux-only). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(setup.ps1): give the empty-argument refusal its result code (SETUP_PS1_EMPTY_ARGUMENT) Adds the row to lib/result-codes.ts (anchor #setup-ps1-empty-argument, which setup.ps1 already prints). bin/gstack-doctor-components.sh is regenerated from the table (generated; resolved by regeneration at merge). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ci(windows): silence SC2016 on the PowerShell version probe (the $ is PowerShell's, not bash's) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ci(windows-setup): give tar a POSIX path for the spaced checkout MSYS tar reads `D:\a\_temp/...` as host:path (rsh syntax) and fails with 'Cannot open: No such file or directory' before setup.ps1 runs. The copy target now goes through cygpath -u; setup.ps1 still gets the Windows form. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ci(windows-setup): dereference symlinks when copying the spaced checkout MSYS tar cannot extract connect-chrome, a symlink that precedes its target (the #3184 failure mode), so the copy died before setup.ps1 ran. tar -h stores the target instead. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(windows): one Git Bash resolver for gstack's shebang bins (#3197) Windows has no shebangs, and gstack had three conflicting bash lookups: the hooks fell back to a bare PATH `bash` (often WSL's launcher), the gbrain sync checked three fixed paths, and setup.ps1 walked up from git.exe. lib/script-invocation.ts is now the only TypeScript resolver: GSTACK_GIT_BASH (GSTACK_BASH as a deprecated alias, same precedence, warning when they differ, a missing value refused), bash next to `git --exec-path` (scoop, per-user, portable Git), the known install paths, then a PATH bash only when `uname -s` reports MSYS/MINGW. WSL launchers are rejected unprobed. A miss is BASH_UNAVAILABLE, listing every candidate with its rejection reason. Bun-shebang bins run under Bun (process.execPath, or the PATH bun inside a compiled binary). Hooks' runBin resolves through a state-root cache keyed by git.exe path and mtime, written by temp file plus rename, so the hot path spawns nothing extra; the override is never cached. wtreeFingerprint({cwd, timeoutMs}) returns {tree} | {error, kind} with GIT_OPTIONAL_LOCKS=0 and 40-64 hex ids; the consumers move next. Intentional pin change: test/gbrain-spawn-windows-shell.test.ts expects forward-slashed bash paths (WIN_BASH and the GSTACK_BASH override), the form the shared resolver returns because Bun's spawnSync on Windows returns ENOENT for a backslash exe path containing spaces. The copied-tree adapters in test/timeline-stop-hook-slug.test.ts and test/plan-tune-cathedral.test.ts list the two modules spawn-bin.ts now imports; the new fixture is registered in test/helpers/free-fixtures-data.ts. bin/gstack-doctor-components.sh is regenerated for the new result-code row. Reported by @exGeni. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(windows): fingerprint and gstack bins through the shared invocation (#3197) bin/gstack-wtree was spawned by path in lib/regen.ts, lib/gate-diff/git.ts, lib/restamp.ts and bin/gstack-evidence; gstack-slug in restamp, gate-diff and evidence; gstack-redact in the pregate secrets check; and a bare `bash -c` in regen, the lane selection manifest and gstack-evidence run. On Windows each one failed to launch. All now go through lib/script-invocation.ts, and each fingerprint consumer keeps its fail direction while saying why: - gstack-regen check: incomplete, `cannot fingerprint the working tree (gstack-wtree exited 1: )` instead of `(gstack-wtree failed)`; write keeps the registry label as its run id. - gstack-gate-diff: `+nowtree` candidate, with the cause. - gstack-restamp: RESTAMP_CONFLICT carrying the launch or exit detail (Windows printed `()` before). - gstack-evidence: records without wtree (grades STALE) and warns once; SHA-256 repositories now get a 64-hex fingerprint. lib/regen.ts keeps a string-returning wtreeFingerprint(repoRoot) wrapper for lib/pregate/context.ts and lib/pregate/index.ts (files #3190 changes); item 10b migrates them. test/script-invocation-tripwire.test.ts fails on a new by-path or bare-bash spawn under lib/, hosts/ or a Bun bin; five sites outside this change are listed with their owners and may only shrink. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): keep framework route paths from a selection manifest (#3176) laneFileSet parsed manifest output with `[\w.-]+` path segments, so `app/(shop)/[id]/page.test.ts` came back as `page.test.ts`. The selected test then matched no platform lane, its remote receipt obligation vanished and it fell back to a local run. manifestTestPaths keeps route segments whole (`(group)`, `[id]`, `[...slug]`, `[[lang]]`, `@slot`, `$param`, `+page`), reads every path-shaped token on a line (JSON arrays, quoted, `path:line`, parenthesized), and decides what is a test file with the import graph's own isTestFile, so a test-file rule change there (bats, #3177) reaches manifests without a second regex. A manifest that cannot start is "unknown" (widens), like a non-zero exit. This is the manifest-parser half of #3176 by @vocodestudios; the import-graph half (tsconfig aliases, Next.js route mapping) ships in a separate PR. test/pregate-manifest-paths.test.ts drives the CLI with a bracketed route test in the fixture's Windows-only lane and asserts its requires-remote obligation; it failed on main (requires_remote was []). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ci(windows): windows-pregate-e2e drives the #3197 paths natively A windows-latest job (Bun 1.4.2) builds a fixture repository and runs gstack-restamp --dry-run, gstack-regen check and write, gstack-evidence run and gstack-gate-diff from their TypeScript entry points, asserting a real tree fingerprint each time; times a question-log hook cold and warm and checks it cached its Git Bash resolution; and checks that a missing GSTACK_GIT_BASH refuses with BASH_UNAVAILABLE and lists the candidate. The journey runs twice: under the runner's Git for Windows and under a pinned PortableGit 2.56.0.2 (SHA-256 verified), found only through `git --exec-path`. gstack-pregate run joins it with item 10b. The workflow is registered in scripts/test-pr-profile.ts FREE_ONLY_PR_FILES (no paid case reads it), recorded as a non-behavioral HARNESS_VERSION decision in scripts/harness-version.json. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(setup.ps1): resolve Git Bash by the shared fixture table (#3197) test/setup-ps1.test.ts now drives setup.ps1 through every row of test/fixtures/windows-bash-resolution.json, the table that pins lib/script-invocation.ts, instead of a hand-mirrored copy. Two rows failed and are fixed here: - an override and alias that differ only in slash direction no longer print the differ warning; - the root git --exec-path names is also searched for usr\bin\bash.exe (Git for Windows on arm64). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(windows): isolate the no-bash case and launch pregate as the product does (#3197, #3176) windows-free-tests run 38813751242 failed two tests this layer added. Both were fixture defects on a real Windows host, not product defects: - gbrain-spawn-windows-shell "returns null when Windows has no bash" stubbed only `exists`. The resolver's PATH step asks the real Bun.which and found the runner's MSYS bash, which is correct. The case now also stubs `which` and `gitExecPath`, so it really means "no bash anywhere". - pregate-manifest-paths spawned the extensionless bin/gstack-pregate directly. CreateProcess cannot exec a shebang, so nothing ran and stdout was "". It now launches through lib/script-invocation's runScriptSync, gstack's own Windows invocation, so the case runs the manifest lane end to end on Windows instead of being excluded. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ci(windows): the missing-bash step survives the refusal it expects (#3197) GitHub runs `shell: bash` as `bash -eo pipefail`, and the step's own `set -uo pipefail` does not turn -e off. So when gstack-restamp refused, as the step expects, `OUT=$(...)` aborted the script before `CODE=$?` and before any output was printed: exit 1 after 0.5 s with nothing in the log (PR #3209, job 116729693492). The capture is now `OUT=$(...) && CODE=0 || CODE=$?`. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(pregate): the manifest-lane case expects the host's own remote lanes (#3176) Once 8b2f297cc made the case run on Windows, windows-free-tests run 38824886195 (shard 4) showed the expectation itself was Linux-only. pregate owes a lane remotely when its runner platform is not the host's (lib/pregate/workflows.ts). On a Windows host the fixture's Windows lane runs locally, and the Ubuntu `free-tests.yml/unit` lane is the remote obligation; that is the record Windows produced. The case now derives the owed lanes from the host the same way. On Windows it also requires `ran: node --test 'app/(shop)/[id]/page.test.js'`: there the route reaches the local run only through the manifest's whole path. With a cut manifest path (page.test.js) the local set is empty, checked through buildContext({ platform: 'win32' }) + selectLanes. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ci(windows-setup): expect 31 skipped setup.ps1 cases now that the shared table drives it Layer 2 drives setup.ps1 through the 16 shared rows of test/fixtures/windows-bash-resolution.json; like the 14 existing resolution rows they run under pwsh on Linux and skip on Windows. The case list is still asserted exactly (4 pass, 0 fail, 31 skip). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(ship-measure): give the CLI tests a private HOME (#3162) Every bun run test left ~/.gstack/projects/ship-measure-cli-*/evals and sweep-cli-fake-*/evals in the developer's real home, and the free-suite runner flagged them ("real home changed ... Give the writer a private HOME/GSTACK_HOME"). ship-measure's diagnostic-dir check calls getProjectEvalDir(), which builds the eval dir from os.homedir(), and these two CLI tests passed the real HOME while overriding only the state root. Both now set HOME and USERPROFILE to the test's own state dir. The sweep test also runs on the Windows lane, where os.homedir() reads USERPROFILE. test/ship-measure.test.ts:79 already gives its config test a private HOME the same way. getProjectEvalDir() itself is unchanged: whether eval history should follow GSTACK_HOME is left open in #3162. Fixes #3162 Co-Authored-By: Claude Opus 5.5 * test(ship-measure): prove the sweep CLI never writes under the parent HOME (#3162) Reviewer test for #3163 (adopted from y$un_, forrest.sun527): points the parent HOME at a sentinel with a stub global gstack-slug and asserts no .gstack dir appears there. Fails on 47d639a (the sentinel gets .gstack/projects/leaked-slug/evals); passes with #3163. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(redact): isolate fixture git from inherited hooks Fixture git was spawned with the parent environment, so a developer's core.hooksPath ran on the local push and rejected it (#2967). The helper blanks the global and system files and drops GIT_CONFIG_COUNT and GIT_CONFIG_PARAMETERS. A hooksPath set in the fixture repo still runs. * test(redact): blank GIT_CONFIG_GLOBAL with /dev/null on every platform (#2967) Follow-up to #3156 (adopted from Lockyer228): Git for Windows rejects GIT_CONFIG_GLOBAL=NUL (the repo convention is EMPTY_GLOBAL_GIT_CONFIG = '/dev/null' in lib/cso/process.ts), so both fixture helpers use /dev/null everywhere. Reviewer test: a hooksPath in the default global files under HOME/.gitconfig and XDG_CONFIG_HOME/git/config cannot reject the fixture push; it fails with the inherited environment (the 47d639a behavior) and passes with the isolated env. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: gstack-config compares whole root paths and prints a runnable recovery command - report_root_disagreement compared word-split values, so two variables naming the same directory with a space were reported as disagreeing. Compare each complete value instead. - The privacy-key recovery hint single-quoted the root and left the script path bare; an apostrophe or space made it unrunnable. Quote with printf %q. - On Bash 3.2 (macOS /bin/bash) a failed `.` under set -e exits before the `||` fallback, so a missing gstack-state-root.sh exited 1 silently and test/state-root-resolver-failure.test.ts failed there. Check readability first. - The merge fixture now lives under a path with a space and an apostrophe, executes the printed command, and asserts same-directory variables agree. Co-Authored-By: Claude Opus 5.5 * test(config): pin the %q-quoted recovery command and run it from a spaced install path Follow-up to #3186 (adopted from davidfragnoli-lang). Intentional pin change: the fix line's GSTACK_STATE_ROOT='' pin becomes the exact bash printf %q form of the legacy root (the PR loosened it to a prefix check; this restores an exact pin) and the original test name is kept. Reviewer test: copy bin/ to a path containing a space and an apostrophe; the printed fix: command runs cleanly and flips the merged value. Fails on 47d639a (unquoted $_SELF), passes with #3186. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(gstack-gbrain-sync): fail when a star gitignore already ignores the pin ensureGbrainSourceGitignored appends .gbrain-source even when git check-ignore already excludes that path. The extra line dirties a tracked ignore-everything gitignore. * fix(gstack-gbrain-sync): skip the pin line when git already ignores it ensureGbrainSourceGitignored asked only for a literal .gbrain-source line. An ignore-everything star already excludes the pin, and the extra line left the tracked gitignore dirty. * fix(gbrain-sync): only a tracked .gitignore can stand in for the pin line Follow-up to #3178 (adopted from KuroChen). The skip now requires `git -c core.excludesFile= check-ignore -v` to name a tracked .gitignore (not global excludes, not .git/info/exclude, not a negation), so a pin ignored only on this machine still gets the shared line teammates need (#1384). Tests: the two git init spawns get timeouts (they failed the spawnSync tripwire), fixtures isolate HOME/XDG_CONFIG_HOME, and the PR's star-gitignore fixture stages its .gitignore (intentional fixture change: the contract is a tracked file). Reviewer tests: global excludes only, info/exclude only, an untracked star .gitignore, and a re-include each still append; all three fail on the PR head. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(skill-end): log the completion event before the final drain (#3198) gstack-skill-end drained and pushed before it appended its own completed timeline event, and gstack-timeline-log backgrounded its enqueue, so a full-tier end boundary returned with its latest event unpushed. The completion log now runs before the drain and the logger waits for its enqueue (one config read and a spool write; it exits early when sync is off or the session is unattended). Every consent, secret-scan and queue-lock gate is unchanged; the stale "local-only, never sent anywhere" comment is replaced. Patch and regression test from Kkkkkuro (#3198): 2 pass / 5 fail on 47d639a, 8/8 with the fix. Reviewer test: with artifacts_sync_mode off the completion is written locally with no queue entry and no push. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): map Next.js route paths and tsconfig path aliases to their tests QUOTED excluded ( ) [ ] +, so a test naming app/(app)/page.tsx in a string never matched. resolveRef never read tsconfig compilerOptions.paths, so an import through @/ resolved to nothing. Both were PREGATE_NO_LANE false negatives in a Next.js App Router repo whose tests did name the files. Co-Authored-By: Claude Opus 5.5 * fix(pregate): TypeScript alias precedence, relative extends and a no-lane explanation Follow-up to #3176 (adopted from vocodestudios). Path aliases now follow TypeScript's choice: an exact pattern, else the wildcard with the longest prefix, targets tried in order. When the chosen pattern resolves nothing, every plausible reading (other matching patterns, baseUrl, the repo root) gains the edge, so uncertainty selects more tests, never fewer. Relative extends chains are followed up to MAX_TSCONFIG_EXTENDS (4) hops, with baseUrl and paths resolved against the config that declares them. Trailing commas are dropped by the JSONC tokenizer (never inside a string) instead of a whole-text regex. A touched file with no lane now gets explain lines naming the import specifiers in test files that resolved to no repo file, any extends hop that was not followed (with the limit), and the hint to declare the file under dependencies or report the resolver gap. The lines ride testsFor's provenance, which lanes already pushes to --explain. Reviewer tests: overlapping aliases in both key orders, exact vs wildcard, target fallback, the union case, a two-hop extends chain, a string containing ',]', and the depth-limit explanation. Five fail on the PR head; all 12 pass. The manifest-parser half of #3176's follow-up (lane-selection.ts) ships in PR 2. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(browse): reap PTY children on agent shutdown * fix(browse): reap a terminal-agent PTY's whole process group at shutdown (#3147) Follow-up to #3148 (adopted from Y4NN777). The PTY child leads its own session and process group; its identity (pgid, sid, leader start time) is recorded at spawn and stays checkable after the leader exits. Disposal schedules a group reap after PTY_SHUTDOWN_GRACE_MS that, unlike the direct-child SIGKILL fallback, is not cancelled when the leader exits, and shutdown reaps every pending group before exiting, so descendants that ignore SIGHUP no longer outlive the agent. Only proven members are signalled: same pgid and sid, started no earlier than the leader, and nothing at all when the leader's PID is held by a process with another start time. Linux only (/proc); other platforms keep the direct-child behavior. During the shutdown grace the server answers 503 and no new PTY is spawned. Test changes to the PR's own test (intentional): the agent is spawned with process.execPath and the PTY child traps INT TERM HUP, so the case proves the SIGKILL fallback instead of passing on the kernel hangup. Reviewer tests: a HUP-immune grandchild is dead within the grace plus 1 s; an early-exiting leader's surviving descendant is reaped; a session that completed before shutdown still has its group reaped; unit cases for a foreign group and a reused leader PID. All four integration cases fail on 47d639a; three fail on the PR head. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * build(deps): bump the github-actions group with 5 updates Bumps the github-actions group with 5 updates: | Package | From | To | | --- | --- | --- | | [actions/upload-artifact](https://github.com/actions/upload-artifact) | `7.0.1` | `7.0.2` | | [actions/download-artifact](https://github.com/actions/download-artifact) | `8.0.1` | `8.0.2` | | [actions/setup-node](https://github.com/actions/setup-node) | `7.0.0` | `7.1.0` | | [ossf/scorecard-action](https://github.com/ossf/scorecard-action) | `2.4.4` | `2.5.0` | | [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) | `4.38.2` | `4.38.3` | Updates `actions/upload-artifact` from 7.0.1 to 7.0.2 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/043fb46d1a93c77aae656e7c1c64a875d1fc6a0a...cf430e030ddbb5b0abf93d22962f4752f3646cd9) Updates `actions/download-artifact` from 8.0.1 to 8.0.2 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c...9000827ccba6bdab643e8b6fd33ac0654aef8333) Updates `actions/setup-node` from 7.0.0 to 7.1.0 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/820762786026740c76f36085b0efc47a31fe5020...949feb2413d6458794dcd2491c4babbbce0c15c1) Updates `ossf/scorecard-action` from 2.4.4 to 2.5.0 - [Release notes](https://github.com/ossf/scorecard-action/releases) - [Changelog](https://github.com/ossf/scorecard-action/blob/main/RELEASE.md) - [Commits](https://github.com/ossf/scorecard-action/compare/2d1146689b8cda280b9bc96326124645441f03bc...fa7117db83c52d787ae5a89f350999701505d78e) Updates `github/codeql-action/upload-sarif` from 4.38.2 to 4.38.3 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...24c54180a607b1449ed407dd24f251e4e9147c8d) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 7.0.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: actions/download-artifact dependency-version: 8.0.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: actions/setup-node dependency-version: 7.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: ossf/scorecard-action dependency-version: 2.5.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: github/codeql-action/upload-sarif dependency-version: 4.38.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(model-policy): track the upload-artifact v7.0.2 pin from #3175 Intentional pin change: test/model-policy-workflow.test.ts asserted actions/upload-artifact@043fb46d…; dependabot #3175 moves the workflow to cf430e03… (v7.0.2, verified against the upstream tag). The pin tracks the workflow. #3175's free-tests.yml hunk is left out of this PR (free-tests.yml is a #3190 file; it is re-bumped in PR 5). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(model-policy): its upload-artifact pin matches the eval workflows' pin Reviewer test for dependabot #3175: the model-policy workflow's single upload-artifact pin equals the pin evals.yml, evals-periodic.yml and windows-free-tests.yml use, so a group bump that misses one workflow (or the hard-coded pin above) is caught here. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * build(deps): hold Playwright minor/major bumps behind the windowsHide patch (#2923) Dependabot #2923 bumped playwright to ^1.64.0 and dropped the patchedDependencies entry keyed to playwright-core@1.62.1, which keeps windowsHide on Windows (#2160, #1989). The bun ecosystem now ignores only semver-minor and semver-major updates for playwright and playwright-core; patch updates still flow. The 1.64 migration (re-key the patch, the Dia/cookie qualification pins, the arm64 artifact name) is filed with owner Garry, revisit 2026-11-15. Test fails on 47d639a (no ignore rule). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(readme): drop the stale skill lists users pasted into CLAUDE.md (#3169) README's install prompt and troubleshooting block each carried a skill list for CLAUDE.md; both were stale (18 shipped skills missing) and they disagreed. Claude Code lists installed skills itself, so both now keep only the /browse rule, and the troubleshooting entry says to delete an old pasted list. New free test: any comma-separated skill list in README.md or CLAUDE.md must name exactly the shipped skills (with a negative control for the detector). Fails on 47d639a; passes now. Reported by the #3169 issue author. The matching doctor warning for pasted lists ships in PR 5. Intentional pin change: test/test-value-bar.test.ts no longer expects the README skill list to name /test-audit twice; it now expects that list to be gone (0 occurrences), the registration this commit removes on purpose. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore(osv): image-size has a fix; html-to-docx's ^1.0.0 pin blocks it (#2753) The image-size ledger said no fixed release exists. OSV lists 2.0.3 as fixed for GHSA-5p2g-fcmc-qvqq and GHSA-w3rx-r6r6-pgpr (checked against api.osv.dev), but html-to-docx@1.8.0 pins image-size ^1.0.0 and there is no fixed 1.x, so the fix is a cross-major override. Both entries now say so and name the upgrade trigger; the 2026-11-30 expiry is kept. New test checks the wording and that bun.lock still shows the stated pin (it fails on 47d639a). Correction first raised on #2753 by sonukapoor. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(ios-qa): forward GSTACK_IOS_LAUNCH_ENV to devicectl launches (#1796) Reimplements only the launch-environment half of #1796 (credit Bmathews721; the session-cache half is superseded by #3132's design and had state-root and symlink-write problems). devicectl.ts parses GSTACK_IOS_LAUNCH_ENV as a JSON object of string to string; launchEnvArgs() returns the --environment-variables argv (empty when unset or {}) and launchApp passes it before the bundle id. Invalid JSON, a non-object, a non-string value, a bad name or a key in the debug bridge's reserved GSTACK_ namespace refuses before any launch with the new result code IOS_LAUNCH_ENV_INVALID (the detail names keys and types, never values). launchEnvArgs is exported for the forced-relaunch path in tunnel-bootstrap.ts. Adds the result-code row (bin/gstack-doctor-components.sh regenerated by gen:skill-docs), a troubleshooting entry under its exact text, the pregate-no-lane entry's explain note, and the how-to section with an example. New test fails on 47d639a (module load: no parseLaunchEnv). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(deps): declare sharp and clear two transitive advisories sharp is imported in four files, including browse/src/screenshot-size-guard.ts, and test/dependency-security.test.ts asserts a >=0.35.4 floor on it and loads it. It was declared in no dependency field, reaching the tree only through @huggingface/transformers with the overrides pin forcing the version. Declare it at ^0.35.4, matching the floor that test already asserts. Upgrade @huggingface/transformers to ^4.3.0, which clears sprintf-js@1.1.3 and aligns the sharp override with what the parent declares: 4.2.0 declared sharp ^0.34.5 while the override pins 0.35.5, which is outside that range, and 4.3.0 declares ^0.35.4. Upgrade @anthropic-ai/claude-agent-sdk to 0.2.141, which clears @anthropic-ai/sdk@0.81.0. The existing overrides entry for sharp is left in place. * test(deps): sharp is declared and its override satisfies every asking range Reviewer test for #3145 (adopted from sonukapoor): sharp must be a declared dependency, and the pinned overrides.sharp must satisfy both that declaration and every lockfile package that depends on sharp (@huggingface/transformers included). Fails on 47d639a (sharp not declared; transformers 4.2.0 asked for ^0.34.5 against a 0.35.5 pin). Classifier smoke on the changed tree (transformers 4.3.0, onnxruntime-node 1.30.0, sharp 0.35.5, claude-agent-sdk 0.2.141): the real sidecar entry bundled for Node with a private state root, and the classifier in-process under Bun, each downloaded the TestSavantAI model fresh, reported testsavant: ok, and returned scores identical to 47d639a's tree on five inputs, including a >512-token input that exercises the truncation override. dependency-security, deps-smoke, overlay-sdk-cancel-eof, security-classifier and screenshot-size-guard pass; typecheck:test reports no new diagnostics. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(gbrain-sync): the no-bash skip names GSTACK_GIT_BASH and how to set it (#3197) The brain-sync stage's Windows skip line told users to set GSTACK_BASH. After the fix wave the canonical override is GSTACK_GIT_BASH, with GSTACK_BASH as a deprecated alias (plan correction 9). The summary is now the exported NO_BASH_SUMMARY: it names GSTACK_GIT_BASH first and GSTACK_BASH as the old name, and gives the two recipes that reach Claude Code (a PowerShell user environment variable plus a Claude Code restart, or the "env" block of ~/.claude/settings.json). Minimal on purpose: once PR 2 lands, this switches to lib/script-invocation.ts noBashMessage(). Until then lib/gbrain-exec.ts still reads only GSTACK_BASH, so this line is accurate only with PR 2 merged first. New test test/gbrain-sync-no-bash-message.test.ts fails on the previous commit (NO_BASH_SUMMARY not exported) and passes now. Handoff from Lane C (PR 2). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ios-qa): the forced relaunch carries GSTACK_IOS_LAUNCH_ENV too (#1796) bootstrapTunnel's relaunchApp (an app whose boot token an earlier daemon consumed is relaunched once with --terminate-existing) built its own devicectl argv and dropped GSTACK_IOS_LAUNCH_ENV, so an env-gated debug bridge vanished after the one relaunch the howto says carries it. It now uses launchEnvArgs(); an invalid value refuses the relaunch with launch_failed and the IOS_LAUNCH_ENV_INVALID detail before anything is terminated. Two new tests in ios-qa/daemon/test/devicectl-launch-env.test.ts drive bootstrapTunnel through a consumed-token device: both fail on the previous commit (relaunch argv lacks --environment-variables; the invalid value still terminates and relaunches) and pass now. ios-qa daemon: 167 pass. tunnel-bootstrap.ts was assigned to Lane D by the coordinator for this fix. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ci(version-gate): check the version queue only on PRs into main Stacked layer PRs carry no VERSION stamp by design (the release layer stamps once), so the queue check failed on any layer touching package.json. The queue and its collision rule only describe main; a PR into main is still checked exactly as before. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(browse): scope the SIGTERM PTY-reap case to POSIX signals (#3147) windows-free-tests run 38813751242 (shard 6) failed "SIGTERM reaps every active PTY child before terminal-agent exits" with "timed out waiting for pty-child.pid", serially and on retry. Diagnosis: invalid test expectation on Windows, not an agent defect the test can observe. The contract under test is the agent's graceful shutdown, its process.on('SIGTERM'/'SIGINT') cleanup in terminal-agent.ts. On Windows, Bun's subprocess.kill('SIGTERM') is libuv's TerminateProcess, so that handler never runs there; and the PTY fixture is an extension-less `#!/bin/bash` script, which CreateProcess cannot launch, so the child never starts (the timeout is at the pid file, after the port, token, grant and WebSocket all succeeded). The file's other integration cases were already Linux-scoped; this one now skips only on win32 and still runs on Linux and macOS. The pure ownership unit tests in the file keep running on Windows. Open, not covered by any test: whether a ConPTY child outlives a hard-terminated agent on Windows. Testing it needs a Windows-launchable terminal fixture, which BROWSE_TERMINAL_BINARY (one executable, fixed argv) cannot express without a product change. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(plan-review): bound the native outside voice without TaskOutput (#3182) Claude Code 2.1.277 removed TaskOutput, so the plan-review native fallback's preflight always failed and users without the Codex CLI never got a second voice. The fallback now uses primitives the host still enforces: a background `sleep 300` Bash timer (its exit re-invokes the parent; the 2.1.300 sleep block applies only to foreground commands), a background Plan dispatch whose completion arrives as a , and TaskStop for whichever task loses. Only a matching completed notification that beats the timer counts; interim ("is waiting on its own background work"), turn-limit, foreground and late results get no credit. The "[Subagent hand-back]" header (2.1.277) is treated as framing. The unavailable line now names its cause and fix, ends with OUTSIDE_VOICE_UNAVAILABLE (new result code + troubleshooting entry), and the review-log row carries unavailable_reason. The bounded-wait prose was tightened so plan-ceo-review and plan-eng-review stay under their existing size ceilings (no ratchet raise for this item). Intentional pin change: test/run-in-background-guidance.test.ts hasBoundedOutsideVoiceWait and the dispatch-contract cases now pin the timer/notification/TaskStop contract instead of TaskOutput's envelope. bin/gstack-doctor-components.sh is regenerated from lib/result-codes.ts. Reported by HuellintonMota in #3182. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(bin): gstack-ceo-plan-find locates the CEO plan for a branch (#280) /plan-ceo-review saves its scope decisions under the state root's ceo-plans/, but no plan review can find them: only /design-html reads that directory, newest file first. This helper is the CEO-plan sibling of gstack-design-doc-find, so /plan-eng-review can call one command instead of a glob in prose. It prints the newest top-level plan whose "Branch:" header names the branch, compared the way gstack-slug writes BRANCH (feat/x matches feat-x), else the newest plan, else nothing. Subdirectories are not read. A usage error exits 2. Co-Authored-By: Claude Opus 5.5 * fix(plan-eng-review): read the CEO plan in the Design Doc Check (#280) /plan-eng-review never looked in ceo-plans/, so an eng review after /plan-ceo-review ran without the scope that review accepted, deferred or skipped. capy-ai closed #414 asking for this focused fix, and TODOS.md scopes it as a gstack-ceo-plan-find sibling of gstack-design-doc-find. The Design Doc Check now prints "CEO plan found: " or "No CEO plan found" after the design doc line, and the prose says what to read the plan for. The data-not-instructions rule names the CEO plan too, since another agent may have written it. Only plan-eng-review changes: the shared {{DESIGN_DOC_DISCOVERY}} block and the branch-scoped review log are untouched. Reported by @chancheuklap in #280. main left plan-eng-review 4 bytes under its parity cap, so the +325 bytes raise maxSizeRatio to the measured 1.2196, noted the way earlier growth was. Fixes #280 Co-Authored-By: Claude Opus 5.5 * fix(plan-eng-review): disclose a CEO plan borrowed from another branch (#280) Review changes on top of #3159 (szsunyuan / y$un_): - gstack-ceo-plan-find keeps stdout a bare path or empty; when it falls back to another branch's plan it prints one stderr line, "CEO plan found (from branch , not ): ", with control characters stripped from the plan header ("unknown" when it has none). - The Design Doc Check tells an interactive reviewer to ask before using such a plan, and a spawned session to use it and quote the disclosure. - The Design Doc Check prose is tightened so plan-eng-review fits the owner-approved 1.2196 ceiling (measured 151,950 / 124,597 = 1.2195). Reviewer tests: test/ceo-plan-find-cross-branch.test.ts (5 of 7 fail on the Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> #3159 head). The design-doc finder's matching disclosure stays deferred. * fix(test): credit abutting section chunks whose edge bytes repeat (#3182) detectSectionReads trims the longest suffix/prefix overlap between consecutive Bash outputs. Two chunks that abut exactly (tail -c +N after a chunk ending at N-1) can share an edge byte by chance; the trim then drops a real byte and a fully printed section reads as incomplete. #3182's review-sections edit moved plan-ceo-review's 66,999-byte boundary onto "cur|rent", which turned the existing byte-range test red. The detector now also checks the plain concatenation; a gap still refuses. Detector/fixture defect, not a product change. New regression case in test/section-capture-native-tools.test.ts fails without the fix. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): fix wave 2026-10-15 release notes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.104.0 fix: the 2026-10-15 issue wave Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(qa): probe each documented input rule, not only its boundary values The paid gate's qa-functional-cli-report run (38829994639) probed zero and an unsafe integer but never a non-digit amount, so it missed the planted parseInt defect ("Amounts contain only ASCII digits") and reported no defect. The functional contract map's invalid-input row now asks for one probe per documented input rule (allowed characters, format, sign, range, arity). test/qa-functional-prompt.test.ts replays a captured CI Read of system-functional.md; its completed-method variant now feeds the current section bytes, so the validator check keeps its omitted/pending negative controls without pinning the old wording. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.106.0: restamp the fix wave above v1.91.105.0 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(release): keep the wave's dependency bumps through the main merge Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> Co-authored-by: Bryan Stone Co-authored-by: vilano93 <244097767+vilano93@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: aftabkh4n Co-authored-by: Benjamin D. Smith Co-authored-by: Jess Sullivan Co-authored-by: NetrioGit <5wngh@naver.com> Co-authored-by: y$un_ Co-authored-by: Lockyer <135391289+Lockyer228@users.noreply.github.com> Co-authored-by: davidfragnoli-lang <249433777+davidfragnoli-lang@users.noreply.github.com> Co-authored-by: KuroChen Co-authored-by: AV Co-authored-by: Y4NN777 <171065166+Y4NN777@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Sonu Kapoor --- .github/dependabot.yml | 10 + .github/workflows/arm-setup-smoke.yml | 2 +- .github/workflows/cso-runtime-images.yml | 10 +- .github/workflows/cso-runtime-promote.yml | 2 +- .../workflows/cso-runtime-qualification.yml | 2 +- .github/workflows/cso-scanner-images.yml | 8 +- .github/workflows/eval-sweep.yml | 4 +- .github/workflows/evals-marathon.yml | 16 +- .github/workflows/evals-periodic.yml | 42 +- .github/workflows/evals.yml | 26 +- .github/workflows/model-policy-freshness.yml | 2 +- .github/workflows/native-qualification.yml | 12 +- .github/workflows/scorecard.yml | 6 +- .github/workflows/version-gate.yml | 1 + .github/workflows/windows-free-tests.yml | 20 +- .github/workflows/windows-pregate-e2e.yml | 232 +++++++++ .github/workflows/windows-setup-e2e.yml | 320 ++++++++++++- .osv-scanner.toml | 17 +- CHANGELOG.md | 53 +++ README.md | 12 +- VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-browser-ensure | 18 +- bin/gstack-bun-version.sh | 56 ++- bin/gstack-ceo-plan-find | 50 ++ bin/gstack-claude-root.sh | 28 ++ bin/gstack-config | 11 +- bin/gstack-doctor-components.sh | 6 + bin/gstack-evidence | 22 +- bin/gstack-ff-update | 317 +++++++++++++ bin/gstack-gate-diff | 13 +- bin/gstack-gbrain-sync.ts | 36 +- bin/gstack-memorable | 9 +- bin/gstack-sanitize-echo.sh | 19 + bin/gstack-session-update | 219 +++++++-- bin/gstack-skill-end | 8 +- bin/gstack-timeline-log | 7 +- browse/src/browser-manager.ts | 45 ++ browse/src/meta-commands.ts | 11 +- browse/src/server.ts | 7 +- browse/src/terminal-agent.ts | 101 +++- .../test/tab-external-close-dispatch.test.ts | 107 +++++ browse/test/tab-external-close.test.ts | 80 ++++ .../terminal-agent-signal-cleanup.test.ts | 218 +++++++++ bun.lock | 61 +-- docs/autoplan-guard-troubleshooting.md | 5 + docs/designs/FIX_WAVE_2026_10_15.md | 207 ++++++++ docs/howto-ios-testing-with-gstack.md | 11 + docs/troubleshooting.md | 410 +++++++++++++++- gstack-upgrade/SKILL.md | 45 +- gstack-upgrade/SKILL.md.tmpl | 45 +- hosts/claude/hooks/spawn-bin.ts | 31 +- ios-qa/daemon/src/devicectl.ts | 41 +- ios-qa/daemon/src/tunnel-bootstrap.ts | 4 + .../daemon/test/devicectl-launch-env.test.ts | 111 +++++ lib/claude-journal-records.ts | 77 ++- lib/cso/docker.ts | 45 +- lib/cso/process.ts | 55 ++- lib/cso/scanner-executor.ts | 21 +- lib/cso/watchdog.c | 7 +- lib/gate-diff/git.ts | 11 - lib/gbrain-exec.ts | 47 +- lib/pregate/checks/secrets.ts | 3 +- lib/pregate/import-graph.ts | 195 +++++++- lib/pregate/lane-selection.ts | 24 +- lib/redact-engine.ts | 117 ++++- lib/regen.ts | 25 +- lib/restamp.ts | 9 +- lib/result-codes.ts | 34 ++ lib/sanitize-echo.ts | 31 ++ lib/script-invocation.ts | 259 ++++++++++ package.json | 7 +- plan-ceo-review/sections/review-sections.md | 63 ++- plan-devex-review/sections/review-sections.md | 60 +-- plan-eng-review/SKILL.md | 7 +- plan-eng-review/SKILL.md.tmpl | 7 +- plan-eng-review/sections/review-sections.md | 66 ++- qa/sections/system-functional.md | 2 +- scripts/harness-version.json | 2 +- scripts/resolvers/outside-voice-steps.ts | 71 ++- scripts/resolvers/qa.ts | 2 +- scripts/test-pr-profile.ts | 1 + setup | 36 +- setup.ps1 | 94 +++- ...oplan-compaction-preserved-segment.test.ts | 167 +++++++ test/bun-version-shells.test.ts | 114 +++++ test/ceo-plan-find-cross-branch.test.ts | 99 ++++ test/cso-docker-argv0.test.ts | 173 +++++++ test/cso-json-redaction.test.ts | 141 ++++++ test/cso-scanner-release.test.ts | 2 +- test/dependency-security.test.ts | 12 + test/design-doc-data-guard.test.ts | 6 + test/fixtures/module-size-ratchet.json | 3 +- test/fixtures/session-update-heartbeat.sh | 99 ++++ test/fixtures/windows-bash-resolution.json | 109 +++++ test/gbrain-source-gitignore.test.ts | 87 ++++ test/gbrain-spawn-windows-shell.test.ts | 11 +- test/gbrain-sync-no-bash-message.test.ts | 32 ++ test/gstack-browser-ensure.test.ts | 33 +- test/gstack-ceo-plan-find.test.ts | 119 +++++ test/gstack-ff-update.test.ts | 441 ++++++++++++++++++ test/gstack-memorable.test.ts | 11 + test/gstack-upgrade-fresh-shell.test.ts | 5 +- test/held-install-recovery.test.ts | 192 ++++++++ test/helpers/auq-sdk-capture.ts | 3 +- test/helpers/carve-guards.ts | 2 +- test/helpers/free-fixtures-data.ts | 2 + test/helpers/touchfiles-data.ts | 2 +- test/model-policy-workflow.test.ts | 9 +- test/osv-ignore-expiry.test.ts | 15 + test/outside-voice-native-deadline.test.ts | 84 ++++ test/plan-tune-cathedral.test.ts | 2 +- test/pregate-import-graph-paths.test.ts | 163 +++++++ test/pregate-manifest-paths.test.ts | 79 ++++ test/qa-functional-prompt.test.ts | 7 +- test/readme-skill-lists.test.ts | 57 +++ test/redact-prepush-rebase-force-push.test.ts | 66 ++- test/redact-prepush-scan-range.test.ts | 141 +++++- test/run-in-background-guidance.test.ts | 37 +- test/sanitize-echo.test.ts | 64 +++ test/script-invocation-tripwire.test.ts | 129 +++++ test/script-invocation.test.ts | 229 +++++++++ test/section-capture-native-tools.test.ts | 8 + test/session-update-autostash.test.ts | 303 +++++++++++- test/session-update-heartbeat.test.ts | 31 ++ test/session-update-held-banner.test.ts | 213 +++++++++ test/session-update-hook-hold.test.ts | 110 ++++- test/session-update-stages.test.ts | 48 +- test/setup-claude-code-migration.test.ts | 2 +- test/setup-cursor-legacy-skills.test.ts | 94 ++++ test/setup-hook-canonical-paths.test.ts | 89 +++- test/setup-hook-syntax-gate.test.ts | 3 +- test/setup-playwright-best-effort.test.ts | 67 ++- test/setup-ps1.test.ts | 176 ++++++- test/ship-measure-sweep.test.ts | 33 +- test/ship-measure.test.ts | 2 +- test/skill-end-completion-durability.test.ts | 153 ++++++ test/state-root-config-merge.test.ts | 21 +- test/team-mode.test.ts | 2 +- test/test-value-bar.test.ts | 3 +- test/timeline-stop-hook-slug.test.ts | 2 + test/upgrade-setup-recovery.test.ts | 2 + test/upgrade-template-pins.test.ts | 4 +- test/workflow-action-pins.test.ts | 11 + test/wtree-consumers.test.ts | 106 +++++ 145 files changed, 8254 insertions(+), 714 deletions(-) create mode 100644 .github/workflows/windows-pregate-e2e.yml create mode 100755 bin/gstack-ceo-plan-find create mode 100644 bin/gstack-claude-root.sh create mode 100755 bin/gstack-ff-update create mode 100644 bin/gstack-sanitize-echo.sh create mode 100644 browse/test/tab-external-close-dispatch.test.ts create mode 100644 browse/test/tab-external-close.test.ts create mode 100644 browse/test/terminal-agent-signal-cleanup.test.ts create mode 100644 docs/designs/FIX_WAVE_2026_10_15.md create mode 100644 ios-qa/daemon/test/devicectl-launch-env.test.ts create mode 100644 lib/sanitize-echo.ts create mode 100644 lib/script-invocation.ts create mode 100644 test/autoplan-compaction-preserved-segment.test.ts create mode 100644 test/bun-version-shells.test.ts create mode 100644 test/ceo-plan-find-cross-branch.test.ts create mode 100644 test/cso-docker-argv0.test.ts create mode 100644 test/cso-json-redaction.test.ts create mode 100755 test/fixtures/session-update-heartbeat.sh create mode 100644 test/fixtures/windows-bash-resolution.json create mode 100644 test/gbrain-sync-no-bash-message.test.ts create mode 100644 test/gstack-ceo-plan-find.test.ts create mode 100644 test/gstack-ff-update.test.ts create mode 100644 test/held-install-recovery.test.ts create mode 100644 test/outside-voice-native-deadline.test.ts create mode 100644 test/pregate-import-graph-paths.test.ts create mode 100644 test/pregate-manifest-paths.test.ts create mode 100644 test/readme-skill-lists.test.ts create mode 100644 test/sanitize-echo.test.ts create mode 100644 test/script-invocation-tripwire.test.ts create mode 100644 test/script-invocation.test.ts create mode 100644 test/session-update-heartbeat.test.ts create mode 100644 test/session-update-held-banner.test.ts create mode 100644 test/setup-cursor-legacy-skills.test.ts create mode 100644 test/skill-end-completion-durability.test.ts create mode 100644 test/wtree-consumers.test.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d6b53e48d..3fdfdc9f2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,6 +11,16 @@ updates: dependency-type: "production" development-dependencies: dependency-type: "development" + # The windowsHide patch (package.json patchedDependencies, #2160/#1989) is + # keyed to one playwright-core version; a minor or major bump drops it and + # brings back Windows console flashes. Patch updates still flow. The 1.64 + # migration (re-key the patch, the Dia/cookie qualification pins, the + # arm64 artifact name) is owned by Garry, revisit 2026-11-15 (#2923). + ignore: + - dependency-name: "playwright" + update-types: ["version-update:semver-minor", "version-update:semver-major"] + - dependency-name: "playwright-core" + update-types: ["version-update:semver-minor", "version-update:semver-major"] open-pull-requests-limit: 5 - package-ecosystem: "github-actions" diff --git a/.github/workflows/arm-setup-smoke.yml b/.github/workflows/arm-setup-smoke.yml index 014e9c513..9f7cc26fe 100644 --- a/.github/workflows/arm-setup-smoke.yml +++ b/.github/workflows/arm-setup-smoke.yml @@ -133,7 +133,7 @@ jobs: CONTAINER - name: Retain source seals and native smoke evidence if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 with: name: native-arm-setup-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ runner.temp }}/arm-evidence/ diff --git a/.github/workflows/cso-runtime-images.yml b/.github/workflows/cso-runtime-images.yml index 601d0c8c4..e0af153d7 100644 --- a/.github/workflows/cso-runtime-images.yml +++ b/.github/workflows/cso-runtime-images.yml @@ -46,7 +46,7 @@ jobs: - name: Record the requalification trigger inputs of this source commit run: bun run scripts/cso-requalification.ts triggers > requalification.json - if: github.event_name == 'workflow_dispatch' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: cso-requalification-triggers path: requalification.json @@ -276,7 +276,7 @@ jobs: --arg commit "$GITHUB_SHA" --arg workflow "$workflow" --arg sbom "$sbom_digest" \ --arg provenance "$provenance_digest" --argjson versions "$CSO_VERSIONS" \ '{schemaVersion:1,helperAbi:3,state:"staged",buildRevision:$buildRevision,runtimeId:$runtimeId,image:$image,platform:$platform,stack:$stack,versions:$versions,sourceCommit:$commit,workflow:$workflow,sbomDigest:$sbom,provenanceDigest:$provenance,verifiedProvenance:true,qualification:"pending native and private release gates; no automatic promotion"}' > staged-image.json - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: cso-staged-${{ matrix.stack }}-${{ matrix.arch }} path: | @@ -309,12 +309,12 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.4.2 - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: cso-staged-${{ matrix.stack }}-${{ matrix.arch }} path: staged-application - if: matrix.stack == 'rails' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: cso-staged-postgresql-${{ matrix.arch }} path: staged-postgresql @@ -366,7 +366,7 @@ jobs: bun run scripts/cso-native-evidence.ts --stack "$CSO_STACK" --platform "$CSO_PLATFORM" \ --junit native-tests.junit.xml --staged staged-application/staged-image.json \ --output qualification-evidence.json - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: cso-qualification-gates-${{ matrix.stack }}-${{ matrix.arch }} path: | diff --git a/.github/workflows/cso-runtime-promote.yml b/.github/workflows/cso-runtime-promote.yml index e3575309f..f8bc2f2ff 100644 --- a/.github/workflows/cso-runtime-promote.yml +++ b/.github/workflows/cso-runtime-promote.yml @@ -171,7 +171,7 @@ jobs: Validation: runtime promotion contracts, distribution contracts, compiled CSO helper build, candidate attestation verification, and exact committed-byte comparison. EOF gh pr create --base main --head "$branch" --title "Promote qualified CSO runtime catalog" --body-file pr-body.md - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 with: name: cso-runtime-catalog-candidate path: | diff --git a/.github/workflows/cso-runtime-qualification.yml b/.github/workflows/cso-runtime-qualification.yml index d86c22b23..c1d3a5a26 100644 --- a/.github/workflows/cso-runtime-qualification.yml +++ b/.github/workflows/cso-runtime-qualification.yml @@ -150,7 +150,7 @@ jobs: --evidence-root qualification-evidence \ --output runtime-catalog.proof.json rm runtime-catalog.proof.json - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 with: name: cso-qualified-runtime-statements path: | diff --git a/.github/workflows/cso-scanner-images.yml b/.github/workflows/cso-scanner-images.yml index 655b00f2a..a41713ddb 100644 --- a/.github/workflows/cso-scanner-images.yml +++ b/.github/workflows/cso-scanner-images.yml @@ -253,7 +253,7 @@ jobs: printf '%s\n' "$CSO_UPSTREAM" > upstream-evidence.json printf '%s\n' "$CSO_SBOM_GENERATOR" > sbom-generator-evidence.json sha256sum "$output" staged-profile.json version.sha256 declared-assets.json public-image.json sbom.spdx.json provenance-verification.json sbom-verification.json upstream-evidence.json sbom-generator-evidence.json upstream-base-*-verification.json sbom-generator-*-verification.json > evidence.sha256 - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: cso-scanner-profile-${{ matrix.scanner }}-${{ matrix.arch }} path: | @@ -290,7 +290,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.4.2 - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: pattern: cso-scanner-profile-* path: qualified-artifacts @@ -305,7 +305,7 @@ jobs: image_prefix="ghcr.io/${GITHUB_REPOSITORY,,}/cso-scanners/" bun run scripts/cso-scanner-catalog.ts assemble qualified-fragments lib/cso/scanner-images/catalog.json catalog-proposal.json --revision "$revision" --source-commit "$GITHUB_SHA" --workflow "$workflow" --image-prefix "$image_prefix" bun run scripts/cso-scanner-catalog.ts validate catalog-proposal.json - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: cso-scanner-catalog-proposal path: catalog-proposal.json @@ -330,7 +330,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.4.2 - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: cso-scanner-catalog-proposal path: promotion diff --git a/.github/workflows/eval-sweep.yml b/.github/workflows/eval-sweep.yml index a4e8bd926..4f0fca0ee 100644 --- a/.github/workflows/eval-sweep.yml +++ b/.github/workflows/eval-sweep.yml @@ -180,7 +180,7 @@ jobs: - name: Upload the sweep report if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: ship-measure-sweep-report path: | @@ -191,7 +191,7 @@ jobs: - name: Upload the sweep captures if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: ship-measure-sweep-captures include-hidden-files: true diff --git a/.github/workflows/evals-marathon.yml b/.github/workflows/evals-marathon.yml index 81cc72bc8..bc1dd63e2 100644 --- a/.github/workflows/evals-marathon.yml +++ b/.github/workflows/evals-marathon.yml @@ -109,7 +109,7 @@ jobs: echo "slices=$(jq -c '[range(1; .sliceCount + 1)]' /tmp/marathon-plan/manifest.json)" >> "$GITHUB_OUTPUT" echo "slice_timeouts=$(jq -ec '[0] + .plan.sliceCiTimeoutMinutes' /tmp/marathon-plan/manifest.json)" >> "$GITHUB_OUTPUT" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: marathon-plan path: /tmp/marathon-plan/manifest.json @@ -173,7 +173,7 @@ jobs: - name: Register gstack skills for PTY tests uses: ./.github/actions/register-gstack-skills - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: marathon-plan path: /tmp/marathon-plan @@ -215,7 +215,7 @@ jobs: - name: Upload slice results if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: marathon-slice-${{ matrix.slice }}-a${{ github.run_attempt }} path: /tmp/marathon-slice-results @@ -223,7 +223,7 @@ jobs: - name: Upload native capture evidence if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: native-captures-${{ env.EVALS_RUN_ID }} include-hidden-files: true @@ -237,7 +237,7 @@ jobs: - name: Upload shard logs on failure if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: marathon-logs-slice-${{ matrix.slice }}-a${{ github.run_attempt }} include-hidden-files: true @@ -270,12 +270,12 @@ jobs: with: bun-version: 1.4.2 - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: marathon-plan path: /tmp/marathon-report - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: pattern: marathon-slice-* path: /tmp/marathon-report @@ -314,7 +314,7 @@ jobs: - name: Upload the marathon report if: always() && (steps.reconcile.outputs.exit != '0' || needs.eval-slices.result != 'success') - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: marathon-report-a${{ github.run_attempt }} path: /tmp/marathon-report.md diff --git a/.github/workflows/evals-periodic.yml b/.github/workflows/evals-periodic.yml index eaa185d18..8abb9ba0c 100644 --- a/.github/workflows/evals-periodic.yml +++ b/.github/workflows/evals-periodic.yml @@ -163,7 +163,7 @@ jobs: echo "slice_timeouts=$(jq -ec '[0] + .plan.sliceCiTimeoutMinutes' /tmp/paid-plan/manifest.json)" } >> "$GITHUB_OUTPUT" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: paid-plan path: /tmp/paid-plan/manifest.json @@ -180,7 +180,7 @@ jobs: echo "slices=$(jq -c '[range(1; .sliceCount + 1)]' /tmp/gate-census-plan/manifest.json)" >> "$GITHUB_OUTPUT" echo "slice_timeouts=$(jq -ec '[0] + .plan.sliceCiTimeoutMinutes' /tmp/gate-census-plan/manifest.json)" >> "$GITHUB_OUTPUT" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: gate-census-plan path: /tmp/gate-census-plan/manifest.json @@ -227,7 +227,7 @@ jobs: - name: Upload canary results if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: schema-canary-a${{ github.run_attempt }} path: /tmp/schema-canary-results @@ -296,7 +296,7 @@ jobs: - name: Register gstack skills for PTY tests uses: ./.github/actions/register-gstack-skills - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: paid-plan path: /tmp/paid-plan @@ -338,7 +338,7 @@ jobs: - name: Upload slice results if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: paid-slice-${{ matrix.slice }}-a${{ github.run_attempt }} path: /tmp/paid-slice-results @@ -346,7 +346,7 @@ jobs: - name: Upload native capture evidence if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: native-captures-${{ env.EVALS_RUN_ID }} include-hidden-files: true @@ -362,7 +362,7 @@ jobs: # longer reds its runner, but its full log is the diagnosis evidence. - name: Upload shard logs if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: paid-logs-slice-${{ matrix.slice }}-a${{ github.run_attempt }} include-hidden-files: true @@ -409,7 +409,7 @@ jobs: fetch-depth: 0 persist-credentials: false - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: paid-plan path: ${{ runner.temp }}/paid-plan @@ -477,7 +477,7 @@ jobs: - name: Upload slice results if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: paid-slice-${{ matrix.slice }}-a${{ github.run_attempt }} path: ${{ runner.temp }}/paid-slice-results @@ -485,7 +485,7 @@ jobs: - name: Upload native capture evidence if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: native-captures-${{ env.EVALS_RUN_ID }} include-hidden-files: true @@ -499,7 +499,7 @@ jobs: - name: Upload shard logs if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: paid-logs-slice-${{ matrix.slice }}-a${{ github.run_attempt }} include-hidden-files: true @@ -559,7 +559,7 @@ jobs: - run: bun run build - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: gate-census-plan path: /tmp/gate-census-plan @@ -601,7 +601,7 @@ jobs: - name: Upload census results if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: gate-census-${{ matrix.slice }}-a${{ github.run_attempt }} path: /tmp/gate-census-results @@ -609,7 +609,7 @@ jobs: - name: Upload native capture evidence if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: native-captures-${{ env.EVALS_RUN_ID }} include-hidden-files: true @@ -647,24 +647,24 @@ jobs: with: bun-version: 1.4.2 - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: paid-plan path: /tmp/paid-report # One directory per attempt-scoped slice artifact (no merge): shard # records never overwrite each other and the first attempt decides. - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: pattern: paid-slice-* path: /tmp/paid-report - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: gate-census-plan path: /tmp/gate-census-report - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: pattern: gate-census-[0-9]* path: /tmp/gate-census-report @@ -705,7 +705,7 @@ jobs: - name: Upload trial outcomes for pass-rate history if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: trial-outcomes-periodic-a${{ github.run_attempt }} path: | @@ -715,7 +715,7 @@ jobs: - name: Upload gate census trial outcomes for pass-rate history if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: trial-outcomes-gate-census-a${{ github.run_attempt }} path: | @@ -824,7 +824,7 @@ jobs: - name: Upload the census report if: always() && (steps.reconcile.outputs.exit != '0' || steps.gate-reconcile.outputs.exit != '0' || steps.pass-rates.outputs.exit != '0' || needs.eval-slices.result != 'success' || !contains(fromJSON('["success","skipped"]'), needs.eval-codex-slices.result) || needs.gate-census.result != 'success') - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: census-report-a${{ github.run_attempt }} path: /tmp/census-report.md diff --git a/.github/workflows/evals.yml b/.github/workflows/evals.yml index 484cda7f5..716b6a22e 100644 --- a/.github/workflows/evals.yml +++ b/.github/workflows/evals.yml @@ -222,7 +222,7 @@ jobs: || echo "- Receipt recovery crashed; reuse stays off this run" >> "$GITHUB_STEP_SUMMARY" - name: Hand the receipt store to plan-slices - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: receipt-store path: /tmp/gstack-eval-input-cache @@ -291,7 +291,7 @@ jobs: - name: Download this PR's receipt store if: github.event_name == 'pull_request' || inputs.pr_receipts != '' continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: receipt-store path: /tmp/gstack-eval-input-cache @@ -329,7 +329,7 @@ jobs: if: github.event_name == 'pull_request' run: mkdir -p /tmp/paid-plan/store && cp -a /tmp/gstack-eval-input-cache/. /tmp/paid-plan/store/ - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: paid-plan path: | @@ -404,7 +404,7 @@ jobs: - name: Register gstack skills for PTY smokes uses: ./.github/actions/register-gstack-skills - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: paid-plan path: /tmp/paid-plan @@ -460,7 +460,7 @@ jobs: # attempt and never replace (or collide with) the first attempt's. - name: Upload slice results if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: paid-slice-${{ matrix.slice }}-a${{ github.run_attempt }} path: /tmp/paid-slice-results @@ -468,7 +468,7 @@ jobs: - name: Upload native capture evidence if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: native-captures-${{ env.EVALS_RUN_ID }} include-hidden-files: true @@ -486,7 +486,7 @@ jobs: # longer reds its runner, but its full log is the diagnosis evidence. - name: Upload shard logs if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: paid-logs-slice-${{ matrix.slice }}-a${{ github.run_attempt }} include-hidden-files: true @@ -528,7 +528,7 @@ jobs: with: bun-version: 1.4.2 - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: paid-plan path: /tmp/paid-report @@ -536,7 +536,7 @@ jobs: # One directory per attempt-scoped slice artifact (no merge): shard # records can never overwrite each other, and the report keeps the # first attempt's verdict. - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: pattern: paid-slice-* path: /tmp/paid-report @@ -583,7 +583,7 @@ jobs: - name: Upload reconciliation output for the comment job if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: report-verdict-a${{ github.run_attempt }} path: | @@ -596,7 +596,7 @@ jobs: - name: Upload trial outcomes for pass-rate history if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: trial-outcomes-pr-a${{ github.run_attempt }} path: /tmp/paid-report/trial-outcomes.jsonl @@ -622,12 +622,12 @@ jobs: # `issues` permission, not `pull-requests` (#1802 CI fix). issues: write steps: - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: paid-plan path: /tmp/paid-report - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: report-verdict-a${{ github.run_attempt }} path: /tmp/verdict diff --git a/.github/workflows/model-policy-freshness.yml b/.github/workflows/model-policy-freshness.yml index b55f4bf2b..84abdb087 100644 --- a/.github/workflows/model-policy-freshness.yml +++ b/.github/workflows/model-policy-freshness.yml @@ -41,7 +41,7 @@ jobs: run: bun scripts/model-policy-freshness.ts - name: Preserve evidence report if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 with: name: model-policy-freshness-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ runner.temp }}/model-policy-freshness.json diff --git a/.github/workflows/native-qualification.yml b/.github/workflows/native-qualification.yml index 176e881e5..fedf9c2e6 100644 --- a/.github/workflows/native-qualification.yml +++ b/.github/workflows/native-qualification.yml @@ -42,7 +42,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: bun-version: 1.4.2 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.18.0 - name: Install pinned dependencies @@ -54,7 +54,7 @@ jobs: run: ./.github/scripts/run-cookie-native-qualification.ps1 -OutputRoot "$env:RUNNER_TEMP" - name: Preserve qualification evidence if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 with: name: cookie-native-qualification path: ${{ runner.temp }}/cookie-native-qualification-*/ @@ -71,7 +71,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: bun-version: 1.4.2 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.18.0 - name: Configure git identity (required by tests that init temp repos) @@ -97,7 +97,7 @@ jobs: exit "$status" - name: Upload diagnostic logs if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 with: name: windows-native-diagnostics path: ${{ runner.temp }}/gstack-free-test-*.log @@ -118,7 +118,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: bun-version: 1.4.2 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 if: inputs.mode == 'dia-launch-comparison' with: node-version: 24.18.0 @@ -147,7 +147,7 @@ jobs: run: bun --no-env-file --no-install --no-macros --config=/dev/null .github/scripts/run-dia-native-qualification.ts --launch-comparison "$COMPARISON_RUNTIME" - name: Preserve only the sanitized qualification receipt if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 with: name: ${{ inputs.mode == 'dia-gui-readiness' && 'dia-gui-readiness' || inputs.mode == 'dia-launch-comparison' && format('dia-launch-comparison-{0}', matrix.runtime) || 'dia-native-qualification' }} path: ${{ runner.temp }}/dia-native-qualification.json diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 982f7cf45..601e3ebab 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -35,18 +35,18 @@ jobs: with: persist-credentials: false - - uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + - uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0 with: results_file: results.sarif results_format: sarif publish_results: true - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: scorecard-sarif path: results.sarif retention-days: 5 - - uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + - uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: sarif_file: results.sarif diff --git a/.github/workflows/version-gate.yml b/.github/workflows/version-gate.yml index 05aad51f7..e034aaa3e 100644 --- a/.github/workflows/version-gate.yml +++ b/.github/workflows/version-gate.yml @@ -2,6 +2,7 @@ name: Version Gate on: pull_request: + branches: [main] paths: - 'VERSION' - 'CHANGELOG.md' diff --git a/.github/workflows/windows-free-tests.yml b/.github/workflows/windows-free-tests.yml index 8420a7dd3..824ccfc48 100644 --- a/.github/workflows/windows-free-tests.yml +++ b/.github/workflows/windows-free-tests.yml @@ -61,7 +61,7 @@ jobs: run: | matrix=$(bun run scripts/test-free-shards.ts --windows-only --ci-plan "$RUNNER_TEMP/windows-plan.json" --shards 6) echo "matrix=$matrix" >> "$GITHUB_OUTPUT" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: windows-plan path: ${{ runner.temp }}/windows-plan.json @@ -84,11 +84,11 @@ jobs: with: bun-version: 1.4.2 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.18.0 - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: windows-plan path: ${{ runner.temp }} @@ -143,7 +143,7 @@ jobs: - name: Upload strict shard result if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: windows-result-${{ matrix.shard }} path: ${{ runner.temp }}/windows-results/*.json @@ -153,7 +153,7 @@ jobs: # not causes, so complete shard logs are kept on every run. - name: Upload full shard logs if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: windows-free-test-shard-logs-${{ matrix.shard }} path: | @@ -164,7 +164,7 @@ jobs: - name: Upload flake ledger if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: flake-ledger-windows-${{ matrix.shard }} path: ${{ runner.temp }}/flake-ledger.jsonl @@ -191,11 +191,11 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.4.2 - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: windows-plan path: ${{ runner.temp }} - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: pattern: windows-result-* merge-multiple: true @@ -214,7 +214,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.4.2 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.18.0 - name: Prepare the same inputs as a Windows shard @@ -240,7 +240,7 @@ jobs: if: always() shell: bash run: cp scripts/free-test-durations.json "$RUNNER_TEMP/free-test-durations-windows.json" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 if: always() with: name: free-test-durations-windows diff --git a/.github/workflows/windows-pregate-e2e.yml b/.github/workflows/windows-pregate-e2e.yml new file mode 100644 index 000000000..3c225d19b --- /dev/null +++ b/.github/workflows/windows-pregate-e2e.yml @@ -0,0 +1,232 @@ +name: Windows Pregate E2E + +# Native proof for #3197 on windows-latest: Bun on Windows cannot execute an +# extensionless shebang bin (bin/gstack-wtree, gstack-slug, gstack-redact), +# so every TypeScript caller goes through lib/script-invocation.ts, which +# hands bash bins to Git Bash and bun bins to Bun. This job drives the real +# CLIs from TypeScript entry points against a fixture repository: +# - the resolver finds Git Bash from `git --exec-path`; +# - gstack-restamp --dry-run fingerprints the tree and stamps; +# - gstack-regen check (scratch worktree from the fingerprint, the entry's +# command through `bash -c`) and write (the no-scratch path); +# - gstack-evidence records a content fingerprint, its command through bash -c; +# - gstack-gate-diff records a candidate with a tree id, not `+nowtree`; +# - a hook invocation is timed cold and warm (the cached resolution); +# - a missing GSTACK_GIT_BASH refuses with BASH_UNAVAILABLE, not `()`; +# - the same journey under a pinned, checksum-verified PortableGit found +# only through `git --exec-path`. +# `gstack-pregate run` joins this job in the follow-up that migrates the +# pregate consumers (item 10b). +# +# Runner: GitHub-hosted free windows-latest. ~4-6 min. + +on: + pull_request: + branches: [main] + paths: + - 'lib/script-invocation.ts' + - 'lib/regen.ts' + - 'lib/restamp.ts' + - 'lib/gbrain-exec.ts' + - 'lib/gate-diff/**' + - 'lib/pregate/**' + - 'lib/result-codes.ts' + - 'bin/gstack-wtree' + - 'bin/gstack-slug' + - 'bin/gstack-path-spelling.sh' + - 'bin/gstack-restamp' + - 'bin/gstack-regen' + - 'bin/gstack-evidence' + - 'bin/gstack-gate-diff' + - 'bin/gstack-redact' + - 'hosts/claude/hooks/**' + - '.github/workflows/windows-pregate-e2e.yml' + workflow_dispatch: + +concurrency: + group: windows-pregate-e2e-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +env: + PORTABLE_GIT_VERSION: 2.56.0.windows.2 + PORTABLE_GIT_FILE: PortableGit-2.56.0.2-64-bit.7z.exe + PORTABLE_GIT_SHA256: 075e158ef8e1f0ab80b347e245405d3eca735c2dc88fd8e032e137d0ca61f61b + +jobs: + windows-pregate: + runs-on: windows-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.2 + + - name: Configure git identity + run: | + git config --global user.email "windows-pregate-e2e@gstack.test" + git config --global user.name "Windows Pregate E2E" + git config --global init.defaultBranch main + git config --global commit.gpgsign false + shell: bash + + - name: Install dependencies + run: bun install --frozen-lockfile + shell: bash + + - name: Build the fixture repository + # A VERSION + CHANGELOG + ship policy + generated-file registry, a + # bare origin and a feature branch with an [Unreleased] entry. + run: | + set -euo pipefail + T="$RUNNER_TEMP/pregate-e2e" + rm -rf "$T"; mkdir -p "$T/home" + git init -q --bare -b main "$T/origin.git" + git init -q -b main "$T/work" + cd "$T/work" + mkdir -p generated .gstack + echo 1.2.3.0 > VERSION + printf '{ "name": "fixture", "version": "1.2.3" }\n' > package.json + printf '%s\n' "const fs=require('fs');const v=fs.readFileSync('VERSION','utf8').trim();fs.writeFileSync('generated/digest.md','# digest v'+v+'\\n');" > gen.js + node gen.js + printf '# Changelog\n\n## [1.2.3.0] - 2026-10-01\n\n- base release\n' > CHANGELOG.md + cat > .gstack/ship-policy.json <<'JSON' + { "$schema": "https://github.com/garrytan/gstack/docs/ship-policy.md#schema-v1", "bump": "patch", "queue_mode": "stamp-at-merge", "release_tool": "node gen.js", "release_outputs": ["generated/**"], "stamp_paths": [], "changelog": "CHANGELOG.md", "history": "squash-ok", "stamp_sensitive_gates": [] } + JSON + cat > .gstack/generated.json <<'JSON' + { "$schema": "https://github.com/garrytan/gstack/docs/pregate.md#generated-v1", "entries": [ { "id": "digest", "command": "node gen.js", "outputs": ["generated/**"], "inputs": ["VERSION", "gen.js"] } ] } + JSON + git add -A && git commit -qm base + git remote add origin "$T/origin.git" && git push -q origin main && git remote set-head origin main + git checkout -qb feature + echo feature > feature.txt + printf '# Changelog\n\n## [Unreleased]\n\n- feature change\n\n## [1.2.3.0] - 2026-10-01\n\n- base release\n' > CHANGELOG.md + git add -A && git commit -qm feature + shell: bash + + - name: The resolver finds the runner's Git Bash from git --exec-path + run: | + set -euo pipefail + git --version + OUT=$(bun -e 'import { resolveWindowsBash } from "./lib/script-invocation"; const r = resolveWindowsBash(); console.log(JSON.stringify(r)); process.exit(r.ok ? 0 : 1);') + echo "$OUT" + echo "$OUT" | grep -q '"source":"git --exec-path"' || (echo "Git Bash was not found through git --exec-path" && exit 1) + echo "$OUT" | grep -qiE '"bash":"[^"]*/(usr/)?bin/bash\.exe"' || (echo "the resolved bash is not a Git Bash bash.exe" && exit 1) + shell: bash + + - name: A hook invocation, timed cold and warm (cached Git Bash resolution) + run: | + set -euo pipefail + T="$RUNNER_TEMP/hook-timing" + rm -rf "$T"; mkdir -p "$T" + PAYLOAD='{"session_id":"e2e","hook_event_name":"PostToolUse","tool_name":"AskUserQuestion","tool_use_id":"TOOL_ID","tool_input":{"questions":[{"question":"Ship it?","options":[{"label":"Ship now (recommended)"},{"label":"Hold"}]}]},"tool_response":{"answers":[{"option_label":"Ship now"}]}}' + run_hook() { + local start end + start=$(date +%s%3N) + printf '%s' "${PAYLOAD/TOOL_ID/$1}" | (cd "$T" && GSTACK_STATE_ROOT="$T" GSTACK_QUESTION_LOG_NO_DERIVE=1 bun "$GITHUB_WORKSPACE/hosts/claude/hooks/question-log-hook.ts") + end=$(date +%s%3N) + echo $((end - start)) + } + COLD=$(run_hook toolu_cold) + test -f "$T/cache/windows-bash.json" || (echo "the hook did not cache its Git Bash resolution" && exit 1) + cat "$T/cache/windows-bash.json" + WARM=$(run_hook toolu_warm) + echo "question-log-hook: cold ${COLD} ms, warm ${WARM} ms" + echo "### question-log-hook on windows-latest: cold ${COLD} ms, warm ${WARM} ms" >> "$GITHUB_STEP_SUMMARY" + LOGS=$(find "$T/projects" -name question-log.jsonl | head -n 1) + test -n "$LOGS" || (echo "the hook wrote no question-log.jsonl" && exit 1) + test "$(wc -l < "$LOGS")" -eq 2 || (echo "expected two logged events" && exit 1) + test ! -s "$T/hook-errors.log" || { cat "$T/hook-errors.log"; echo "the hook logged errors"; exit 1; } + shell: bash + + - name: A missing GSTACK_GIT_BASH refuses with BASH_UNAVAILABLE and names every candidate + run: | + set -uo pipefail + T="$RUNNER_TEMP/pregate-e2e" + cd "$T/work" + OUT=$(GSTACK_HOME="$T/home" GSTACK_GIT_BASH='C:\no-such-git\bin\bash.exe' bun "$GITHUB_WORKSPACE/bin/gstack-restamp" --base main --allow-repo-commands --next --dry-run 2>&1) && CODE=0 || CODE=$? + echo "$OUT" | tail -n 3 + test "$CODE" -ne 0 || (echo "restamp passed without a bash" && exit 1) + echo "$OUT" | grep -q 'cannot fingerprint the working tree (gstack-wtree could not start: gstack: no Git Bash found' || (echo "the refusal does not say why" && exit 1) + echo "$OUT" | grep -q 'GSTACK_GIT_BASH C:/no-such-git/bin/bash.exe: does not exist' || (echo "the refusal does not list the override" && exit 1) + echo "$OUT" | grep -q '(BASH_UNAVAILABLE)' || (echo "the refusal has no result code" && exit 1) + shell: bash + + - name: Download the pinned PortableGit and verify its checksum + run: | + set -euo pipefail + PG="$RUNNER_TEMP/PortableGit" + curl -fsSL -o "$RUNNER_TEMP/$PORTABLE_GIT_FILE" "https://github.com/git-for-windows/git/releases/download/v${PORTABLE_GIT_VERSION}/${PORTABLE_GIT_FILE}" + echo "$PORTABLE_GIT_SHA256 $RUNNER_TEMP/$PORTABLE_GIT_FILE" | sha256sum -c - + "$RUNNER_TEMP/$PORTABLE_GIT_FILE" -o"$(cygpath -w "$PG")" -y + test -f "$PG/bin/bash.exe" || (echo "PortableGit has no bin/bash.exe" && exit 1) + "$PG/cmd/git.exe" --version + shell: bash + + - name: The resolver finds the PortableGit bash through git --exec-path alone + run: | + set -euo pipefail + PG=$(cygpath -u "$RUNNER_TEMP/PortableGit") + export PATH="$PG/cmd:$PATH" + git --version + WANT=$(cygpath -m "$PG/bin/bash.exe") + OUT=$(bun -e 'import { resolveWindowsBash } from "./lib/script-invocation"; const r = resolveWindowsBash(); console.log(JSON.stringify(r)); process.exit(r.ok ? 0 : 1);') + echo "$OUT" + echo "$OUT" | grep -qiF "\"bash\":\"$WANT\"" || (echo "expected $WANT" && exit 1) + echo "$OUT" | grep -q '"source":"git --exec-path"' || (echo "PortableGit was not found through git --exec-path" && exit 1) + shell: bash + + - name: The fixture journey (restamp, regen, evidence, gate-diff) under the runner's Git and the pinned PortableGit + run: | + set -euo pipefail + journey() { + G="$PWD" + T="$RUNNER_TEMP/pregate-e2e" + export GSTACK_HOME="$T/home" GSTACK_STATE_ROOT="$T/home" + cd "$T/work" + git reset -q --hard && git clean -fdq + rm -rf "$T/home/projects" + + OUT=$(bun "$G/bin/gstack-restamp" --base main --allow-repo-commands --next --dry-run 2>&1) || { echo "$OUT"; echo "gstack-restamp --dry-run failed"; exit 1; } + echo "$OUT" | tail -n 8 + echo "$OUT" | grep -q '^+1\.2\.4\.0$' || (echo "restamp did not stamp VERSION" && exit 1) + echo "$OUT" | grep -q '^+# digest v1\.2\.4\.0$' || (echo "restamp did not run the release tool" && exit 1) + echo "$OUT" | grep -q 'GSTACK_RESULT: skill=restamp status=complete' || (echo "restamp did not complete" && exit 1) + test -z "$(git status --porcelain)" || (echo "--dry-run wrote to the tree" && exit 1) + + OUT=$(bun "$G/bin/gstack-regen" check --allow-repo-commands 2>&1) || { echo "$OUT"; echo "gstack-regen check failed"; exit 1; } + echo "$OUT" + echo "$OUT" | grep -q '^regen digest: fresh' || (echo "the regen command did not run fresh in the scratch worktree" && exit 1) + echo "$OUT" | grep -qE 'GSTACK_RESULT: skill=regen status=complete run=[0-9a-f]{40}$' || (echo "regen check has no tree fingerprint" && exit 1) + OUT=$(bun "$G/bin/gstack-regen" write --allow-repo-commands 2>&1) || { echo "$OUT"; echo "gstack-regen write failed"; exit 1; } + echo "$OUT" | grep -qE 'GSTACK_RESULT: skill=regen status=complete run=[0-9a-f]{40}$' || (echo "regen write has no tree fingerprint" && exit 1) + + bun "$G/bin/gstack-evidence" run --label tests -- 'echo evidence-ok && test -f VERSION' + LEDGER=$(find "$T/home/projects" -name '*-evidence.jsonl' | head -n 1) + test -n "$LEDGER" || (echo "no evidence ledger written" && exit 1) + tail -n 1 "$LEDGER" + tail -n 1 "$LEDGER" | grep -qE '"exit":0,' || (echo "the evidence command did not exit 0 through bash -c" && exit 1) + tail -n 1 "$LEDGER" | grep -qE '"wtree":"[0-9a-f]{40}"' || (echo "evidence recorded no content fingerprint" && exit 1) + + set +e + OUT=$(bun "$G/bin/gstack-gate-diff" main 2>&1) + CODE=$? + set -e + echo "$OUT" | tail -n 3 + test "$CODE" -eq 0 || (echo "gstack-gate-diff exited $CODE" && exit 1) + ! echo "$OUT" | grep -q 'fingerprint unavailable' || (echo "gate-diff could not fingerprint the tree" && exit 1) + echo "$OUT" | grep -qE 'candidate=[0-9a-f]{12}\+[0-9a-f]{12} ' || (echo "gate-diff candidate has no tree id" && exit 1) + } + echo "::group::runner Git for Windows ($(git --version))" + (journey) + echo "::endgroup::" + echo "::group::pinned PortableGit $PORTABLE_GIT_VERSION" + PG_CMD=$(cygpath -u "$RUNNER_TEMP/PortableGit/cmd") + (export PATH="$PG_CMD:$PATH"; git --version; journey) + echo "::endgroup::" + shell: bash diff --git a/.github/workflows/windows-setup-e2e.yml b/.github/workflows/windows-setup-e2e.yml index 195c88466..6b2b928fe 100644 --- a/.github/workflows/windows-setup-e2e.yml +++ b/.github/workflows/windows-setup-e2e.yml @@ -18,10 +18,21 @@ name: Windows Setup E2E # had the false positive recovers with the documented # `git pull --ff-only --autostash && ./setup`. # +# setup.ps1 under Windows PowerShell 5.1 (powershell.exe, #3130): its cases +# in test/setup-ps1.test.ts run with a space in the temp path, and a real +# install runs from a checkout path with a space. The held-install job +# (#3184) builds a prefix-mode v1.91.101.0 install behind this PR's tree in +# three states (clean, dirty-conflicting, already-unmerged): the old updater +# must log `HELD hook-check-unavailable`, the PowerShell recovery form that +# docs/troubleshooting.md publishes must reach the PR's tree with every +# local edit in a rescue ref, the trial and go-back forms must move the +# install, and the new updater must then take a later release whose symlink +# sorts before its target. Each job asserts the exact list of cases it ran. +# # Separate from windows-free-tests.yml because that one runs a curated # unit-test subset; this one exercises the install path itself. # -# Runner: GitHub-hosted free windows-latest. ~6-10 min total. +# Runner: GitHub-hosted free windows-latest, two parallel jobs. ~10-25 min. on: pull_request: @@ -33,6 +44,9 @@ on: - 'scripts/build-cso-windows.ps1' - 'scripts/write-version-files.sh' - 'setup' + - 'setup.ps1' + - 'test/setup-ps1.test.ts' + - 'docs/troubleshooting.md' - 'browse/src/cli.ts' - 'browse/src/find-browse.ts' - 'browse/src/server.ts' @@ -46,6 +60,9 @@ on: - 'bin/gstack-launch-probe.sh' - 'bin/gstack-hook-check' - 'bin/gstack-session-update' + - 'bin/gstack-ff-update' + - 'bin/gstack-claude-root.sh' + - 'bin/gstack-browser-ensure' - 'bin/gstack-path-spelling.sh' - 'lib/path-spelling.ts' - 'hosts/claude/hooks/**' @@ -97,6 +114,34 @@ jobs: run: bun install --frozen-lockfile shell: bash + - name: setup.ps1 cases under Windows PowerShell 5.1 with a space in the temp path (#3130) + # test/setup-ps1.test.ts runs powershell.exe on Windows: argv forwarding + # with adversarial arguments, the empty-argument refusal, and pwsh. The + # skips are its Linux-only cases: a missing prerequisite, and the Git + # Bash resolution table, whose locations are injected into pwsh. + run: | + set -euo pipefail + : > "$RUNNER_TEMP/setup-cases" + # shellcheck disable=SC2016 + MAJOR=$(powershell.exe -NoProfile -NonInteractive -Command '$PSVersionTable.PSVersion.Major' | tr -d '\r') + test "$MAJOR" = 5 || (echo "powershell.exe is PowerShell '$MAJOR', not Windows PowerShell 5.1" && exit 1) + echo ps51 >> "$RUNNER_TEMP/setup-cases" + SPACED="$RUNNER_TEMP/tmp with space" + mkdir -p "$SPACED" + TEMP="$(cygpath -w "$SPACED")" + export TEMP TMP="$TEMP" + set +e + OUT=$(bun test test/setup-ps1.test.ts 2>&1) + CODE=$? + set -e + echo "$OUT" + test "$CODE" -eq 0 || (echo "test/setup-ps1.test.ts exited $CODE" && exit 1) + grep -qE '^ *4 pass$' <<< "$OUT" || (echo "expected exactly 4 executed setup.ps1 cases" && exit 1) + grep -qE '^ *0 fail$' <<< "$OUT" || (echo "a setup.ps1 case failed" && exit 1) + grep -qE '^ *31 skip$' <<< "$OUT" || (echo "expected exactly 31 skipped setup.ps1 cases (the Linux-only prerequisite case, the 14 pwsh-on-Linux Git Bash resolution rows and the 16 shared-table rows from test/fixtures/windows-bash-resolution.json)" && exit 1) + echo setup-ps1-cases >> "$RUNNER_TEMP/setup-cases" + shell: bash + - name: Run bun run build (the previously-broken path) # This is the regression gate. Bun's Windows shell parser rejected # multiple constructs the old inline build chain used; the wave @@ -165,6 +210,33 @@ jobs: echo "runtime root: $ROOT -> source $(cat "$ROOT/.source-path")" shell: bash + - name: .\setup.ps1 installs from a checkout path with a space under Windows PowerShell 5.1 (#3130) + # A copy of this built checkout (build outputs keep their mtimes, so + # setup does not rebuild; node_modules is reinstalled) under a path + # with a space, installed through powershell.exe into an isolated HOME. + # -h stores symlinks as their targets: MSYS tar cannot extract a + # symlink that precedes its target (connect-chrome, #3184). + run: | + set -euo pipefail + SPACED="$(cygpath -u "$RUNNER_TEMP")/gstack checkout" + mkdir -p "$SPACED" + tar --exclude=./node_modules -chf - . | tar -xpf - -C "$SPACED" + E2E_HOME="$RUNNER_TEMP/gstack-ps1-home" + mkdir -p "$E2E_HOME" + set +e + OUT=$(HOME="$E2E_HOME" USERPROFILE="$(cygpath -w "$E2E_HOME")" GSTACK_SKIP_PLAYWRIGHT=1 GSTACK_STRICT_BUILD=0 \ + powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$(cygpath -w "$SPACED/setup.ps1")" --host codex 2>&1) + CODE=$? + set -e + echo "$OUT" | tail -n 40 + test "$CODE" -eq 0 || (echo ".\\setup.ps1 exited $CODE" && exit 1) + ! grep -q 'unexpected EOF' <<< "$OUT" || (echo "bash read a broken -c script (#3130)" && exit 1) + ROOT="$E2E_HOME/.codex/skills/gstack" + test -f "$ROOT/.source-path" || (echo "MISSING: $ROOT/.source-path" && exit 1) + grep -q 'gstack checkout' "$ROOT/.source-path" || (echo "the runtime root does not come from the spaced checkout: $(cat "$ROOT/.source-path")" && exit 1) + echo setup-ps1-install >> "$RUNNER_TEMP/setup-cases" + shell: bash + - name: Installed browse runs a piped chain flow from an unrelated directory run: | set -euo pipefail @@ -297,3 +369,249 @@ jobs: echo "$DOCTOR" echo "$DOCTOR" | grep -qE '^warn +cso +' || (echo "doctor did not report the CSO failure" && exit 1) shell: bash + + - name: Every setup.ps1 case ran + run: | + set -euo pipefail + cat "$RUNNER_TEMP/setup-cases" + test "$(cat "$RUNNER_TEMP/setup-cases")" = "$(printf 'ps51\nsetup-ps1-cases\nsetup-ps1-install')" || (echo "a setup.ps1 case did not run" && exit 1) + shell: bash + + held-install-journey: + # #3184: MSYS tar cannot extract a symlink that precedes its target, so + # every v1.91.101.0 install on Windows holds every release. The Git Bash + # forms of the recovery run in test/held-install-recovery.test.ts; this + # job runs the PowerShell forms under powershell.exe, verbatim from + # docs/troubleshooting.md, against the runner's real ~/.claude install + # path ($HOME in PowerShell). GSTACK_E2E_POWERSHELL and + # GSTACK_E2E_UPDATER_PATH_PREFIX exist only to run these steps off Windows. + runs-on: windows-latest + timeout-minutes: 50 + env: + GSTACK_SKIP_PLAYWRIGHT: '1' + GSTACK_STRICT_BUILD: '0' + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.2 + + - name: Configure git identity + run: | + git config --global user.email "windows-setup-e2e@gstack.test" + git config --global user.name "Windows Setup E2E" + git config --global init.defaultBranch main + shell: bash + + - name: Build the held-install fixture (v1.91.101.0 behind this PR's tree) + # origin's main carries this PR's tree on top of the v1.91.101.0 + # release tree (commits built from the two trees, so the shallow + # checkout's history is never needed); fix-branch adds one file for + # the trial form. + run: | + set -euo pipefail + OLD_RELEASE=47d639abffa882b8cbb641d7d1dedf08ecada9ec # v1.91.101.0 + T="$RUNNER_TEMP/held" + rm -rf "$T" && mkdir -p "$T" + git cat-file -e "$OLD_RELEASE^{commit}" 2>/dev/null || git fetch -q --no-tags --depth=1 origin "$OLD_RELEASE" + c() { git commit-tree "$@"; } + HELD=$(c -m "v1.91.101.0" "$OLD_RELEASE^{tree}") + MAIN=$(c -p "$HELD" -m "this PR's tree" 'HEAD^{tree}') + export GIT_INDEX_FILE="$T/fix.index" + git read-tree "$MAIN" + git update-index --add --cacheinfo "100644,$(printf 'trial\n' | git hash-object -w --stdin),e2e-trial-marker" + FIX=$(c -p "$MAIN" -m "a fix branch to try" "$(git write-tree)") + unset GIT_INDEX_FILE + test "$(git show "$HELD:VERSION")" = 1.91.101.0 || (echo "the held tree is not v1.91.101.0" && exit 1) + git ls-tree "$MAIN" connect-chrome | grep -q '^120000 ' || (echo "this PR's tree has no connect-chrome symlink to reproduce the hold" && exit 1) + git init -q --bare -b main "$T/origin.git" + git push -q "$T/origin.git" "$MAIN:refs/heads/main" "$FIX:refs/heads/fix-branch" + printf 'HELD=%s\nMAIN=%s\nFIX=%s\n' "$HELD" "$MAIN" "$FIX" > "$T/vars" + : > "$T/cases" + cat > "$T/lib.sh" <<'LIB' + T="$RUNNER_TEMP/held" + . "$T/vars" + INSTALL="$HOME/.claude/skills/gstack" + STATE="$HOME/.gstack" + LOG="$STATE/analytics/session-update.log" + PS="${GSTACK_E2E_POWERSHELL:-powershell.exe}" + w() { if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s\n' "$1"; fi; } + die() { echo "FAIL: $*" >&2; [ ! -f "$LOG" ] || { echo "--- updater log" && cat "$LOG"; } >&2; exit 1; } + case_ok() { echo "$1" >> "$T/cases"; echo "CASE OK: $1"; } + # The command docs/troubleshooting.md publishes under . + form() { awk -v m="" '$0 == m { f = 1; next } f && /^```/ { if (o) exit; o = 1; next } f && o { print }' "$GITHUB_WORKSPACE/docs/troubleshooting.md"; } + # run_ps_form NAME [REPLACEMENT-FOR-.\setup.ps1] [BRANCH]: the form, + # verbatim, as a script under powershell.exe; prints its output. + run_ps_form() { + f=$(form "$1") + case "$f" in *'{ .\setup.ps1 }') ;; *) die "form $1 is missing or does not end in .\setup.ps1: $f" ;; esac + [ -z "${2:-}" ] || f="${f%'.\setup.ps1 }'}$2 }" + [ -z "${3:-}" ] || f=${f//''/$3} + printf '%s\r\nexit $LASTEXITCODE\r\n' "$f" > "$T/form.ps1" + "$PS" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$(w "$T/form.ps1")" 2>&1 + } + # run_updater PATTERN [SECONDS]: one session-start check with the + # throttle cleared, waiting for its background run to log PATTERN. + run_updater() { + rm -f "$STATE/.last-session-update" + n0=$( { cat "$LOG" 2>/dev/null || true; } | wc -l) + ( cd "$T" && PATH="${GSTACK_E2E_UPDATER_PATH_PREFIX:+$GSTACK_E2E_UPDATER_PATH_PREFIX:}$PATH" bash "$INSTALL/bin/gstack-session-update" ) || die "the updater exited $?" + i=0 + until tail -n +"$((n0 + 1))" "$LOG" 2>/dev/null | grep -E "$1" > "$T/updater-line"; do + i=$((i + 1)); [ "$i" -le "${2:-180}" ] || die "no updater log line matching '$1' within ${2:-180}s" + sleep 1 + done + cat "$T/updater-line" + i=0; while [ -d "$STATE/.setup-lock" ] && [ "$i" -lt 120 ]; do i=$((i + 1)); sleep 1; done + } + # install_state clean|dirty|unmerged: a prefix-mode v1.91.101.0 + # install one release behind origin's main. + install_state() { + rm -rf "$INSTALL" "$STATE" + mkdir -p "$(dirname "$INSTALL")" + git clone -q "$T/origin.git" "$INSTALL" + git -C "$INSTALL" reset -q --hard "$HELD" + GSTACK_SETUP_RUNNING=1 bash "$INSTALL/bin/gstack-config" set auto_upgrade true + GSTACK_SETUP_RUNNING=1 bash "$INSTALL/bin/gstack-config" set skill_prefix true + bash "$INSTALL/bin/gstack-patch-names" "$INSTALL" true >/dev/null + ! git -C "$INSTALL" diff --quiet -- SKILL.md '*/SKILL.md' || die "prefix mode left no footprint" + case "$1" in + dirty) + git -C "$INSTALL" diff -U0 "$HELD" "$MAIN" -- docs/troubleshooting.md > "$T/upstream.diff" + L=$(sed -n 's/^-\([^-]\)/\1/p' "$T/upstream.diff" | sed -n 1p) + [ -n "$L" ] || die "docs/troubleshooting.md has no upstream change to conflict with" + L="$L" awk '!d && $0 == ENVIRON["L"] { print $0 " LOCAL-EDIT"; d = 1; next } { print }' "$INSTALL/docs/troubleshooting.md" > "$T/edited" + cat "$T/edited" > "$INSTALL/docs/troubleshooting.md" + grep -q 'LOCAL-EDIT' "$INSTALL/docs/troubleshooting.md" || die "the local edit did not apply" + ;; + unmerged) + b=$(printf 'base\n' | git -C "$INSTALL" hash-object -w --stdin) + o=$(printf 'ours LOCAL-EDIT\n' | git -C "$INSTALL" hash-object -w --stdin) + t=$(printf 'theirs\n' | git -C "$INSTALL" hash-object -w --stdin) + git -C "$INSTALL" update-index --force-remove README.md + printf '100644 %s 1\tREADME.md\n100644 %s 2\tREADME.md\n100644 %s 3\tREADME.md\n' "$b" "$o" "$t" | git -C "$INSTALL" update-index --index-info + printf '<<<<<<< ours\nours LOCAL-EDIT\n=======\ntheirs\n>>>>>>> theirs\n' > "$INSTALL/README.md" + [ -n "$(git -C "$INSTALL" ls-files -u)" ] || die "the install has no unmerged files" + ;; + esac + } + # check_recovered STATE: at origin's main, nothing unmerged, and + # every local edit (only those) in one rescue ref. + check_recovered() { + [ "$(git -C "$INSTALL" rev-parse HEAD)" = "$MAIN" ] || die "$1: the install is at $(git -C "$INSTALL" rev-parse HEAD), not $MAIN" + [ -z "$(git -C "$INSTALL" ls-files -u)" ] || die "$1: unmerged files remain" + [ -f "$INSTALL/bin/gstack-ff-update" ] || die "$1: bin/gstack-ff-update is missing" + refs=$(git -C "$INSTALL" for-each-ref --format='%(refname)' refs/gstack/rescue) + if [ "$1" = clean ]; then + [ -z "$refs" ] || die "clean: a rescue ref was made: $refs" + return 0 + fi + [ "$(printf '%s\n' "$refs" | grep -c .)" = 1 ] || die "$1: expected one rescue ref, got: $refs" + file=docs/troubleshooting.md; [ "$1" != unmerged ] || file=README.md + git -C "$INSTALL" show "$refs:$file" > "$T/rescued" + grep -q 'LOCAL-EDIT' "$T/rescued" || die "$1: the rescue ref lacks the local edit to $file" + [ "$(git -C "$INSTALL" diff --name-only "$refs^" "$refs")" = "$file" ] || die "$1: the rescue ref holds more than the user's edit" + } + LIB + bash -n "$T/lib.sh" + shell: bash + + - name: 'Held clean and already-unmerged installs: v1.91.101.0 holds, the PowerShell recovery form recovers' + # The form's last step (.\setup.ps1) is replaced by a marker here; the + # dirty-conflicting state below runs it verbatim. + run: | + set -euo pipefail + . "$RUNNER_TEMP/held/lib.sh" + for state in clean unmerged; do + install_state "$state" + run_updater '^[^ ]+ HELD hook-check-unavailable' + case_ok "$state/v1.91.101.0-held" + CODE=0 + OUT=$(run_ps_form recovery-powershell 'Write-Output SETUP_WOULD_RUN') || CODE=$? + echo "$OUT" + [ "$CODE" -eq 0 ] || die "$state: the recovery form exited $CODE" + grep -q '^SETUP_WOULD_RUN' <<< "$OUT" || die "$state: the recovery form did not reach its setup step" + check_recovered "$state" + case_ok "$state/recovered" + done + shell: bash + + - name: 'Held dirty-conflicting install: the PowerShell recovery form, verbatim with .\setup.ps1' + run: | + set -euo pipefail + . "$RUNNER_TEMP/held/lib.sh" + install_state dirty + run_updater '^[^ ]+ HELD hook-check-unavailable' + case_ok dirty/v1.91.101.0-held + set +e + OUT=$(run_ps_form recovery-powershell) + CODE=$? + set -e + echo "$OUT" | tail -n 60 + [ "$CODE" -eq 0 ] || die "dirty: the recovery form exited $CODE" + check_recovered dirty + case_ok dirty/recovered-and-set-up + shell: bash + + - name: The PowerShell trial and go-back forms move the install + run: | + set -euo pipefail + . "$RUNNER_TEMP/held/lib.sh" + set +e + OUT=$(run_ps_form trial-powershell '' fix-branch) + CODE=$? + set -e + echo "$OUT" | tail -n 30 + [ "$CODE" -eq 0 ] || die "the trial form exited $CODE" + [ "$(git -C "$INSTALL" rev-parse HEAD)" = "$FIX" ] || die "the trial form did not check out fix-branch" + ! git -C "$INSTALL" symbolic-ref -q HEAD >/dev/null || die "the trial form left HEAD on a branch" + case_ok trial + set +e + OUT=$(run_ps_form goback-powershell) + CODE=$? + set -e + echo "$OUT" | tail -n 30 + [ "$CODE" -eq 0 ] || die "the go-back form exited $CODE" + [ "$(git -C "$INSTALL" symbolic-ref --short HEAD)" = main ] || die "the go-back form did not return to main" + [ "$(git -C "$INSTALL" rev-parse HEAD)" = "$MAIN" ] || die "main is not at $MAIN" + case_ok goback + shell: bash + + - name: The new updater takes a later release whose symlink sorts before its target + # Pushed only now (after recovery), so the hold above is + # connect-chrome's alone. The old updater's archive | tar extraction is + # what MSYS tar failed; the new one extracts without tar. + run: | + set -euo pipefail + . "$RUNNER_TEMP/held/lib.sh" + export GIT_INDEX_FILE="$T/synth.index" + git read-tree "$MAIN" + git update-index --add \ + --cacheinfo "120000,$(printf 'b-target' | git hash-object -w --stdin),e2e-synthetic/a-link" \ + --cacheinfo "100644,$(printf 'target\n' | git hash-object -w --stdin),e2e-synthetic/b-target" + SYNTH=$(git commit-tree -p "$MAIN" -m "a later release: a symlink sorted before its target" "$(git write-tree)") + unset GIT_INDEX_FILE + git push -q "$T/origin.git" "$SYNTH:refs/heads/main" + run_updater '^[^ ]+ (UPDATED|HELD|PULL_FAILED|SETUP_FAILED|MIGRATIONS_FAILED) ' 1800 + grep -q ' UPDATED ' "$T/updater-line" || die "the new updater did not log UPDATED" + [ "$(git -C "$INSTALL" rev-parse HEAD)" = "$SYNTH" ] || die "the install is not at the later release" + case_ok later-release/updated + shell: bash + + - name: Every held-install case ran + run: | + set -euo pipefail + cat "$RUNNER_TEMP/held/cases" + EXPECTED='clean/v1.91.101.0-held + clean/recovered + unmerged/v1.91.101.0-held + unmerged/recovered + dirty/v1.91.101.0-held + dirty/recovered-and-set-up + trial + goback + later-release/updated' + test "$(cat "$RUNNER_TEMP/held/cases")" = "$(printf '%s\n' "$EXPECTED" | sed 's/^ *//')" || (echo "a held-install case did not run" && exit 1) + shell: bash diff --git a/.osv-scanner.toml b/.osv-scanner.toml index 76ab7c36e..cac5e40a6 100644 --- a/.osv-scanner.toml +++ b/.osv-scanner.toml @@ -30,18 +30,21 @@ ignoreUntil = 2026-11-30T00:00:00Z [[IgnoredVulns]] id = "GHSA-5p2g-fcmc-qvqq" -# image-size 1.2.1 via html-to-docx@1.8.0 (pins ^1.0.0). No fixed release -# exists (FIXED VERSION = --; tracking: #2753). Exposure: image-size only parses images the -# user themselves embeds into their own generated .docx — no untrusted input -# path. Upgrade trigger: an image-size release with a fix, or html-to-docx -# moving off it. Tracking: #2753. -reason = "No fixed version exists; local-only input path (user's own docx images). Re-evaluate on expiry." +# image-size 1.2.1 via html-to-docx@1.8.0. image-size 2.0.3 (2026-09-14) +# fixes both advisories, but html-to-docx pins image-size ^1.0.0 and no fixed +# 1.x exists, so taking the fix is a cross-major override. Exposure: +# image-size only parses images the user themselves embeds into their own +# generated .docx; no untrusted input path. Upgrade trigger: html-to-docx +# moving off image-size 1.x, or a deliberate override to 2.x after checking +# html-to-docx's call site against 2.x's API with a docx-export smoke test. +# Tracking: #2753. +reason = "Fixed in image-size 2.0.3, but html-to-docx pins image-size ^1.0.0 (no fixed 1.x); local-only input path (user's own docx images). Re-evaluate on expiry." ignoreUntil = 2026-11-30T00:00:00Z [[IgnoredVulns]] id = "GHSA-w3rx-r6r6-pgpr" # Same package/node as GHSA-5p2g-fcmc-qvqq above; same rationale. -reason = "No fixed version exists; local-only input path (user's own docx images). Re-evaluate on expiry." +reason = "Fixed in image-size 2.0.3, but html-to-docx pins image-size ^1.0.0 (no fixed 1.x); local-only input path (user's own docx images). Re-evaluate on expiry." ignoreUntil = 2026-11-30T00:00:00Z [[IgnoredVulns]] diff --git a/CHANGELOG.md b/CHANGELOG.md index 487e88439..75563f124 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,58 @@ # Changelog +## [1.91.106.0] - 2026-10-16 + +**The 2026-10-15 issue wave is fixed, with every adopted contributor patch kept under its author.** Twenty-odd field reports and community PRs from the week after v1.91.101.0 (`docs/designs/FIX_WAVE_2026_10_15.md`): an `/autoplan` run locked by a Claude Code 2.1.300 compaction, `/cso` scans that could not finish, auto-updates that left conflict markers or held every Windows install, Windows pregate and restamp unable to run gstack's own scripts, plan reviews that lost their native second opinion on Claude Code 2.1.277+, and a batch of maintenance fixes. Each was reproduced with a test that failed first on v1.91.101.0. + +### What this means for you + +**Recovery and `/cso`** + +- **`/autoplan` continues after a Claude Code 2.1.300 auto-compaction.** The journal reader follows the compact boundary's preserved segment, so the phase guard no longer denies every phase with `close_incomplete` (#3194, adopted from #3196 by @vilano93). A compaction layout gstack does not read yet now allows with a visible warning and a decision-log row (`unrecognized_shape:compaction_unreachable`) instead of locking the run; an orphaned or not-yet-flushed record still denies. +- **`/cso` Semgrep reports parse after redaction.** A redacted bare number in a JSON report becomes a quoted marker, so the report is no longer discarded as `INVALID_OUTPUT` (#3185). Only scan stdout declared as JSON takes the JSON-aware path, and a finding that spans JSON structure still withholds the whole stream. Rerun scans that ended `INVALID_OUTPUT`. +- **`/cso` runs through OrbStack's Docker.** Docker and the watchdog invoke the trusted realpath with `argv0` `docker`, which OrbStack's multicall binary needs (#3187, adopted from #3188 by @aegixx). +- **The browse daemon recovers when every tab was closed outside browse.** The next command opens a blank tab and says so once (`open your page again with: browse goto `); `status`, `stop` and `restart` answer without opening one, and a context that cannot open a tab fails with `BROWSE_TAB_UNRECOVERABLE` (#3167, adopted from #3179 by @aftabkh4n). +- **The Bun floor check holds under zsh.** zsh now reports Bun 1.3.2 as too old instead of accepting it, so `/gstack-upgrade` holds with `BUN_TOO_OLD` until you run `bun upgrade`. A `bun --version` the check cannot parse holds with `BUN_VERSION_UNREADABLE` and asks you to reinstall Bun from https://bun.sh (#3195). + +**Install, update and Windows** + +- **Auto-update no longer leaves conflict markers in your install.** The updater and `/gstack-upgrade` share one helper, `bin/gstack-ff-update`: it saves your local edits in a rescue ref under `refs/gstack/rescue/`, moves the install with a two-way `read-tree` that refuses to overwrite anything it did not save, and prints once a day how to inspect (`git -C '' show --stat ''`), export and delete the rescue. Adopts #3189 by @BenjaminDSmithy and #3191 by @Jesssullivan. +- **Windows auto-update is no longer held on every release (#3184, reported by @wengweiling).** The hook check extracts the incoming release without `tar`, and the banner shows the hold instead of a stale "bun not found". If yours has been held since 1.91.89.0, run the recovery once (docs/troubleshooting.md#auto-update-held-install-recovery). Git Bash: + `cd ~/.claude/skills/gstack && git fetch origin main && d=$(mktemp -d) && git show FETCH_HEAD:bin/gstack-ff-update > "$d/gstack-ff-update" && bash "$d/gstack-ff-update" . FETCH_HEAD; s=$?; rm -rf "$d"; [ "$s" = 0 ] && ./setup` + Windows PowerShell: + `Set-Location -LiteralPath "$HOME\.claude\skills\gstack" -ErrorAction Stop; $gb = Join-Path (git --exec-path) '..\..\..\bin\bash.exe'; & $gb -c 'set -f; IFS=; git fetch origin main && d=$(mktemp -d) && git show FETCH_HEAD:bin/gstack-ff-update > $d/gstack-ff-update && bash $d/gstack-ff-update . FETCH_HEAD; s=$?; rm -rf -- $d; exit $s'; if ($LASTEXITCODE -eq 0) { .\setup.ps1 }` +- **`setup.ps1` works under Windows PowerShell 5.1 (#3130, reported by @gfl130902-maker).** Its `bash -c` script holds no double quotes, which 5.1 stripped; an empty argument is refused up front (`SETUP_PS1_EMPTY_ARGUMENT`). It now finds Git Bash the same way the hooks do, including next to `git --exec-path` on arm64. +- **Windows pregate, restamp, regen, gate-diff and evidence run gstack's own scripts again (#3197, reported by @exGeni).** Bun on Windows cannot execute an extensionless shebang script; every TypeScript caller now goes through `lib/script-invocation.ts`, which runs bash scripts through Git Bash. Git Bash outside `C:\Program Files\Git` (scoop, per-user, portable) is found next to `git --exec-path`. To pin one, in PowerShell: `[Environment]::SetEnvironmentVariable('GSTACK_GIT_BASH', 'C:\Program Files\Git\bin\bash.exe', 'User')`, then restart Claude Code. `GSTACK_BASH` still works as a deprecated alias; a path that does not exist is refused, and a non-MSYS `bash` on PATH (Cygwin, WSL) is no longer used. With no Git Bash, the error lists every place gstack looked and ends `(BASH_UNAVAILABLE)` (docs/troubleshooting.md#bash-unavailable). +- **Hooks are registered at your `$HOME` install when `CLAUDE_CONFIG_DIR` is a per-session directory that links back to it (#3180).** Adopts #3181 by @Brit-juho (commit author NetrioGit). +- **Cursor no longer loads every skill twice from 1.31-era unprefixed folders (#3133, first reported by @andrewkjankowski in #3084).** `./setup --host cursor` removes only folders whose `SKILL.md` links into gstack and names them. Adopts #3152 by @szsunyuan. +- **A missing `bunx` is no longer reported as a network failure (#3131, reported by @gfl130902-maker).** setup installs Chromium with `bun x`; exit 127 names the resolved Bun and how to skip the browser. +- **`gstack-evidence` records a content fingerprint in SHA-256 repositories.** + +**Plan reviews** + +- **Plan reviews get their native second opinion back on Claude Code 2.1.277+ (#3182, reported by @HuellintonMota).** Claude Code removed the TaskOutput tool, so `/plan-ceo-review`, `/plan-eng-review` and `/plan-devex-review` always reported "Outside voice unavailable" without Codex. The fallback now runs the Plan subagent in the background beside a five-minute timer; only a complete report that beats the timer counts. If you still see `(OUTSIDE_VOICE_UNAVAILABLE)`, the cause names what is missing (docs/troubleshooting.md#outside-voice-unavailable); the review-log row carries `unavailable_reason`. +- **`/plan-eng-review` reads the CEO plan (#280).** `gstack-ceo-plan-find` finds the `/plan-ceo-review` plan for this branch and the Design Doc Check reads its scope as data. A plan from another branch is disclosed (`CEO plan found (from branch , not ): `) and interactive reviewers are asked first. Thanks @szsunyuan (#3159). + +**Pregate, terminal agent and maintenance** + +- **Pregate finds tests in Next.js route groups and through tsconfig path aliases.** Repo paths with `( ) [ ] + @ $` stay whole in the selection manifest and map to their files, and `compilerOptions.paths` resolve the way TypeScript does (exact pattern, then the most specific wildcard, targets in order; relative `extends` up to 4 hops). Uncertainty selects more tests, never fewer. A file with no lane gets `--explain` lines naming the unresolved specifiers. Thanks @vocodestudios (#3176). +- **Terminal-agent shutdown no longer orphans PTY descendants that ignore SIGHUP (Linux).** Long-lived processes started from the sidebar terminal now end 3 seconds after the session closes; start them elsewhere if they must outlive it. Thanks @Y4NN777 (#3148, #3147). +- **`gstack-skill-end` writes its completion event before the final drain,** so a full-tier end boundary pushes its own latest event. Thanks @Kkkkkuro (#3198). +- **`gstack-config` compares whole root paths and prints a recovery command that runs** when a path contains a space or an apostrophe. Thanks @davidfragnoli-lang (#3186). +- **`gstack-gbrain-sync` no longer dirties a tracked `.gitignore` that already ignores the pin.** Thanks @KuroChen (#3178). +- **`GSTACK_IOS_LAUNCH_ENV`** passes a JSON object of environment variables to the app on every `gstack-ios-qa-daemon` launch: `export GSTACK_IOS_LAUNCH_ENV='{"DEBUG_BRIDGE":"1"}'`, then restart the daemon. An invalid value or a `GSTACK_` key refuses the launch with `IOS_LAUNCH_ENV_INVALID`. Thanks @Bmathews721 (split from #1796). +- **Functional `/qa` and `/qa-only` probe every documented input rule.** The contract map asks for one invalid-input probe per stated rule (allowed characters, format, sign, range, arity), so an amount documented as digits-only is tried with a non-digit, not only zero and an overflow. +- **README no longer asks you to paste a skill list into CLAUDE.md (#3169).** If you did, delete the "Available skills:" line; Claude Code lists installed skills itself. +- **Dependencies:** `sharp` is declared; `@huggingface/transformers` 4.3.0 (onnxruntime-node 1.30.0) and dev `@anthropic-ai/claude-agent-sdk` 0.2.141 clear two transitive advisories, with identical classifier scores. Thanks @sonukapoor (#3145). CI actions bumped (dependabot #3175); Dependabot holds Playwright minor and major bumps behind the Windows `windowsHide` patch. + +### For contributors + +- `lib/script-invocation.ts` is the one way a TypeScript caller runs a gstack script or bash (`resolveWindowsBash` from one fixture table, `test/fixtures/windows-bash-resolution.json`, which `setup.ps1`'s tests also drive); `test/script-invocation-tripwire.test.ts` fails on new by-path or bare-`bash` spawns. New `.github/workflows/windows-pregate-e2e.yml` exercises restamp, regen, evidence, gate-diff and the refusal on `windows-latest`. +- `lib/sanitize-echo.ts` and its Bash twin `bin/gstack-sanitize-echo.sh` are the one sanitizer for echoed external strings. +- New result codes, each with a troubleshooting entry: `BUN_VERSION_UNREADABLE`, `BROWSE_TAB_UNRECOVERABLE`, `SETUP_PS1_EMPTY_ARGUMENT`, `BASH_UNAVAILABLE`, `IOS_LAUNCH_ENV_INVALID`, `OUTSIDE_VOICE_UNAVAILABLE`. +- Gate-integrity edits: the `/autoplan` phase guard's fail-open surface grows by exactly the compaction-explained unreachable component (existing `unrecognized_shape` allow-with-warning); pregate selection can only grow for alias-using repos; the plan-eng-review prompt-size ceiling rises from 1.2170 to 1.2196 for #3159. +- Tests stop writing into your real home and stop inheriting your git hooks: ship-measure CLI tests get a private HOME (thanks @y$un_, #3163) and the redact pre-push fixtures ignore `core.hooksPath` (thanks @Lockyer228, #3156, #2967). + ## [1.91.105.0] - 2026-10-16 **The free suite stops needing its retry pass. diff --git a/README.md b/README.md index fadbffd52..b6de1e229 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ When qualified CSO runtime images are published, setup gives each automatic prel Open Claude Code and paste this. Claude does the rest. -> Install gstack: run **`git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack && cd ~/.claude/skills/gstack && ./setup`** then add a "gstack" section to CLAUDE.md that says to use the /browse skill from gstack for all web browsing, never use mcp\_\_claude-in-chrome\_\_\* tools, and lists the available skills: /office-hours, /plan-ceo-review, /plan-eng-review, /plan-design-review, /design-consultation, /design-shotgun, /design-html, /review, /deslop-shared-libs, /test-audit, /ship, /eval-plan, /land-and-deploy, /canary, /benchmark, /browse, /connect-chrome, /qa, /qa-only, /design-review, /scrape, /setup-browser-cookies, /setup-deploy, /setup-gbrain, /retro, /investigate, /document-release, /document-generate, /codex, /cso, /autoplan, /plan-devex-review, /devex-review, /careful, /freeze, /guard, /unfreeze, /gstack-upgrade, /learn. Then ask the user if they also want to add gstack to the current project so teammates get it. +> Install gstack: run **`git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack && cd ~/.claude/skills/gstack && ./setup`** then add a "gstack" section to CLAUDE.md that says to use the /browse skill from gstack for all web browsing and never use mcp\_\_claude-in-chrome\_\_\* tools. Claude Code lists the installed skills itself, so the section needs no skill list. Then ask the user if they also want to add gstack to the current project so teammates get it. ### Step 2: Team mode — auto-update for shared repos (recommended) @@ -834,19 +834,15 @@ a generated gstack SKILL.md and then edited it, that file is moved to `~/.gstack/backups/skills///SKILL.md` before gstack's is linked in, never deleted. -**Claude says it can't see the skills?** Make sure your project's `CLAUDE.md` has a gstack section. Add this: +**Claude says it can't see the skills?** Check that they are installed (`ls ~/.claude/skills`) and restart Claude Code; it lists every installed skill with its description, so CLAUDE.md does not need to enumerate them (a pasted list goes stale with each release). Your project's `CLAUDE.md` only needs the browsing rule: ``` ## gstack Use /browse from gstack for all web browsing. Never use mcp__claude-in-chrome__* tools. -Available skills: /office-hours, /plan-ceo-review, /plan-eng-review, /plan-design-review, -/design-consultation, /design-shotgun, /design-html, /review, /deslop-shared-libs, /test-audit, /ship, /eval-plan, /land-and-deploy, -/canary, /benchmark, /browse, /open-gstack-browser, /qa, /qa-only, /design-review, /scrape, -/setup-browser-cookies, /setup-deploy, /setup-gbrain, /sync-gbrain, /retro, /investigate, -/document-release, /document-generate, /codex, /cso, /autoplan, /pair-agent, /careful, /freeze, -/guard, /unfreeze, /gstack-upgrade, /learn. ``` +If an older install prompt left an "Available skills:" line in your CLAUDE.md, delete it. + ## License MIT. Free forever. Go build something. diff --git a/VERSION b/VERSION index 852dfdc70..71f208571 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.105.0 +1.91.106.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 2e819d448..e2b1b7dd5 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.105.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.106.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-browser-ensure b/bin/gstack-browser-ensure index 930ef83c2..a90f63346 100755 --- a/bin/gstack-browser-ensure +++ b/bin/gstack-browser-ensure @@ -9,8 +9,9 @@ # preflight in every browser skill (scripts/resolvers/browse.ts) runs this # helper instead, so the first browser skill run installs it. Probes a # Chromium launch through Playwright (node when present; Bun cannot launch -# Chromium on Windows) and, when the probe fails, runs `bunx playwright -# install chromium` under the same lock ./setup uses, then re-probes. +# Chromium on Windows) and, when the probe fails, runs `bun x playwright +# install chromium` (not bunx: a winget Bun ships none, #3131) under the same +# lock ./setup uses, then re-probes. # # --check probe only; never install (exit 1 when Chromium is unavailable) # --quiet print only the final line @@ -32,7 +33,7 @@ for _arg in "$@"; do --check) CHECK=1 ;; --quiet) QUIET=1 ;; --with-deps) WITH_DEPS="--with-deps" ;; - -h|--help) sed -n '3,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -h|--help) sed -n '3,24p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "gstack-browser-ensure: unknown argument '$_arg'" >&2; exit 2 ;; esac done @@ -78,7 +79,7 @@ fi probe [ "$PROBE_RC" -eq 0 ] && ok [ "$CHECK" -eq 1 ] && fail "Chromium did not launch via $ENGINE ($(first_line "$PROBE_OUT"))" "$ROOT/bin/gstack-browser-ensure (installs Chromium; ~115 MiB)" -command -v bun >/dev/null 2>&1 || fail "Chromium is not installed and bun is not on PATH to install it" "install Bun, then re-run $0" +BUN=$(command -v bun 2>/dev/null) || fail "Chromium is not installed and bun is not on PATH to install it" "install Bun, then re-run $0" # Same lock ./setup takes (#2136), so two installs never race on the cache. LOCK="${TMPDIR:-/tmp}/gstack-playwright-install.lock" @@ -102,17 +103,18 @@ log "gstack-browser-ensure: installing Playwright Chromium (one-time, ~115 MiB; started=$(date +%s) ( cd "$ROOT" - if [ -n "$OVERRIDE" ]; then PLAYWRIGHT_HOST_PLATFORM_OVERRIDE="$OVERRIDE" bunx playwright install $WITH_DEPS chromium - else bunx playwright install $WITH_DEPS chromium; fi + if [ -n "$OVERRIDE" ]; then PLAYWRIGHT_HOST_PLATFORM_OVERRIDE="$OVERRIDE" "$BUN" x playwright install $WITH_DEPS chromium + else "$BUN" x playwright install $WITH_DEPS chromium; fi ) &2 & pid=$! waited=0 while kill -0 "$pid" 2>/dev/null; do - if [ "$waited" -ge "$TIMEOUT" ]; then kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null; fail "bunx playwright install chromium exceeded ${TIMEOUT}s" "raise GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT= and re-run $0"; fi + if [ "$waited" -ge "$TIMEOUT" ]; then kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null; fail "$BUN x playwright install chromium exceeded ${TIMEOUT}s" "raise GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT= and re-run $0"; fi sleep 1; waited=$((waited + 1)) done wait "$pid"; rc=$? -[ "$rc" -eq 0 ] || fail "bunx playwright install chromium exited $rc (offline, proxy, or blocked download?)" "fix network access to the Playwright CDN, then re-run $0" +[ "$rc" -ne 127 ] || fail "$BUN x playwright install chromium exited 127 (command not found)" "install Bun from bun.sh (winget Bun ships no bunx), then re-run $0" +[ "$rc" -eq 0 ] || fail "$BUN x playwright install chromium exited $rc (offline, proxy, or blocked download?)" "fix network access to the Playwright CDN, then re-run $0" log "gstack-browser-ensure: Chromium installed in $(( $(date +%s) - started ))s" probe [ "$PROBE_RC" -eq 0 ] && ok diff --git a/bin/gstack-bun-version.sh b/bin/gstack-bun-version.sh index 900644f55..a8c769f9b 100644 --- a/bin/gstack-bun-version.sh +++ b/bin/gstack-bun-version.sh @@ -60,43 +60,57 @@ _gstack_bun_parse() { _gb_core="$((10#$_gb_maj)) $((10#$_gb_min)) $((10#$_gb_pat))" } -# _gstack_bun_below — true when version sorts below the -# release. Prerelease rule (semver): 1.3.3-canary.1 is below 1.3.3 and above -# 1.3.2, so a prerelease of the floor itself is refused. +# _gstack_bun_below — returns 0 when version sorts below +# the release, 1 when it does not, and 2 when either cannot be parsed (callers +# never read 2 as "not below"). Prerelease rule (semver): 1.3.3-canary.1 is +# below 1.3.3 and above 1.3.2, so a prerelease of the floor itself is refused. +# The release's fields are copied into quoted positional parameters before the +# second parse, so the comparison does not depend on word splitting (zsh does +# not split; #3195). _gstack_bun_below() { - _gstack_bun_parse "$2" || return 1 - set -- "$1" $_gb_core - _gstack_bun_parse "$1" || return 1 - set -- "$@" $_gb_core - [ "$5" -ne "$2" ] && { [ "$5" -lt "$2" ]; return; } - [ "$6" -ne "$3" ] && { [ "$6" -lt "$3" ]; return; } - [ "$7" -ne "$4" ] && { [ "$7" -lt "$4" ]; return; } + _gstack_bun_parse "$2" || return 2 + set -- "$1" "$_gb_maj" "$_gb_min" "$_gb_pat" + _gstack_bun_parse "$1" || return 2 + [ "$((10#$_gb_maj))" -ne "$((10#$2))" ] && { [ "$((10#$_gb_maj))" -lt "$((10#$2))" ]; return; } + [ "$((10#$_gb_min))" -ne "$((10#$3))" ] && { [ "$((10#$_gb_min))" -lt "$((10#$3))" ]; return; } + [ "$((10#$_gb_pat))" -ne "$((10#$4))" ] && { [ "$((10#$_gb_pat))" -lt "$((10#$4))" ]; return; } [ -n "$_gb_pre" ] } # gstack_bun_status [floor] — prints too-old, untested, ok or -# malformed. The optional floor lets the auto-updater apply the incoming -# release's floor with this release's comparison rule. +# malformed (the version or the floor cannot be parsed). The optional floor +# lets the auto-updater apply the incoming release's floor with this +# release's comparison rule. gstack_bun_status() { - if ! _gstack_bun_parse "$1"; then echo malformed - elif _gstack_bun_below "$1" "${2:-$GSTACK_BUN_FLOOR}"; then echo too-old - elif _gstack_bun_below "$1" "$GSTACK_BUN_TESTED"; then echo untested - else echo ok - fi + _gstack_bun_below "$1" "${2:-$GSTACK_BUN_FLOOR}" + case $? in + 0) echo too-old ;; + 1) _gstack_bun_below "$1" "$GSTACK_BUN_TESTED" + case $? in 0) echo untested ;; 1) echo ok ;; *) echo malformed ;; esac ;; + *) echo malformed ;; + esac } # gstack_bun_incoming_hold — the pre-advance check shared by # the auto-updater and /gstack-upgrade. Reads the floor from 's copy of # this file and checks the `bun` setup will run (first on PATH). Below that -# floor it prints the held reason and returns 0; otherwise it prints nothing -# and returns 1 (no bun, no floor in , or an unparseable version, which -# setup only warns on). The comparison rule is this release's. +# floor it prints the held reason and returns 0. A version it cannot compare +# also holds (bun-version-unreadable, BUN_VERSION_UNREADABLE), with the bun +# path and its output passed through gstack_sanitize_echo +# (bin/gstack-sanitize-echo.sh in ). Otherwise it prints nothing and +# returns 1 (no bun, or no floor in ). The comparison rule is this release's. gstack_bun_incoming_hold() { command -v bun >/dev/null 2>&1 || return 1 _gb_floor=$(git -C "$1" show "$2:bin/gstack-bun-version.sh" 2>/dev/null | sed -n 's/^GSTACK_BUN_FLOOR="\([0-9A-Za-z.+-]*\)".*/\1/p' | head -1) [ -n "$_gb_floor" ] || return 1 _gb_found=$(bun --version 2>/dev/null | head -1) - [ "$(gstack_bun_status "$_gb_found" "$_gb_floor")" = too-old ] || return 1 + case "$(gstack_bun_status "$_gb_found" "$_gb_floor")" in + too-old) ;; + malformed) + echo "bun-version-unreadable: could not read the version of Bun at $( (. "$1/bin/gstack-sanitize-echo.sh" && gstack_sanitize_echo "$(command -v bun)") 2>/dev/null) (bun --version printed '$( (. "$1/bin/gstack-sanitize-echo.sh" && gstack_sanitize_echo "$_gb_found") 2>/dev/null)'); gstack $(git -C "$1" show "$2:VERSION" 2>/dev/null | head -1 | tr -cd '0-9A-Za-z.+-') needs $_gb_floor or newer (BUN_VERSION_UNREADABLE)" + return 0 ;; + *) return 1 ;; + esac _gb_ver=$(git -C "$1" show "$2:VERSION" 2>/dev/null | head -1) echo "bun-too-old: found Bun $_gb_found at $(command -v bun); gstack ${_gb_ver:-update} needs $_gb_floor or newer" } diff --git a/bin/gstack-ceo-plan-find b/bin/gstack-ceo-plan-find new file mode 100755 index 000000000..56a592f32 --- /dev/null +++ b/bin/gstack-ceo-plan-find @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# gstack-ceo-plan-find — print the CEO plan a plan review should read. +# +# Usage: gstack-ceo-plan-find +# +# Prints one absolute path, or nothing when no CEO plan exists. Exit 0 either +# way; exit 2 on a usage error. A plan from another branch (precedence 2) is +# disclosed on stderr: "CEO plan found (from branch , not ): ". +# +# /plan-ceo-review saves each plan as +# $GSTACK_STATE_ROOT/projects//ceo-plans/-.md with a +# "Branch: | Mode: ..." header line. Precedence: +# 1. newest plan whose Branch line names , compared the way +# gstack-slug writes BRANCH ("/" becomes "-") +# 2. else the newest plan, disclosed on stderr +# Only top-level ceo-plans/*.md counts: the same set /design-html and +# /plan-ceo-review's prior-plans query read. +set -u + +if [ "$#" -ne 2 ] || [ -z "$1" ]; then + echo "usage: gstack-ceo-plan-find " >&2 + exit 2 +fi +SLUG=$1 +BRANCH=$2 + +eval "$("$(dirname "$0")/gstack-paths")"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}" + +plan_branch() { + awk '/^Branch: /{ sub(/^Branch: /, ""); sub(/\|.*/, ""); print; exit }' "$1" | tr '/' '-' | tr -cd 'a-zA-Z0-9._-' +} + +NEWEST="" +FOR_BRANCH="" +for plan in "$GSTACK_STATE_ROOT/projects/$SLUG/ceo-plans/"*.md; do + [ -f "$plan" ] || continue + if [ -z "$NEWEST" ] || [ "$plan" -nt "$NEWEST" ]; then NEWEST=$plan; fi + if [ -n "$BRANCH" ] && [ "$(plan_branch "$plan")" = "$BRANCH" ]; then + if [ -z "$FOR_BRANCH" ] || [ "$plan" -nt "$FOR_BRANCH" ]; then FOR_BRANCH=$plan; fi + fi +done + +PLAN=${FOR_BRANCH:-$NEWEST} +[ -n "$PLAN" ] || exit 0 +if [ -z "$FOR_BRANCH" ]; then + FROM=$(awk '/^Branch: /{ sub(/^Branch: /, ""); sub(/ *\|.*/, ""); print; exit }' "$PLAN" | LC_ALL=C tr -d '\000-\037\177') + echo "CEO plan found (from branch ${FROM:-unknown}, not ${BRANCH:-unknown}): $PLAN" >&2 +fi +printf '%s\n' "$PLAN" +exit 0 diff --git a/bin/gstack-claude-root.sh b/bin/gstack-claude-root.sh new file mode 100644 index 000000000..25a1926b8 --- /dev/null +++ b/bin/gstack-claude-root.sh @@ -0,0 +1,28 @@ +# shellcheck shell=bash +# gstack-claude-root.sh — sourced helper: the stable install root that Claude +# hook commands are registered at (setup's canonical-only rule). Used by setup +# and bin/gstack-memorable so both write the same path into settings.json. +# +# gstack_canonical_claude_root prints ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/gstack, +# or $HOME/.claude/skills/gstack when that +# install exists and the config-dir root is +# missing (the installer writes to $HOME) or +# resolves to the same tree (a per-session +# CLAUDE_CONFIG_DIR whose skills link back to +# $HOME, #3180: only the $HOME spelling +# outlives the session) +# +# Neither answer is the running tree, so canonical-only still holds. No side +# effects; safe under set -u. + +gstack_canonical_claude_root() { + local root="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/gstack" + local home_root="$HOME/.claude/skills/gstack" + if [ -x "$home_root/bin/gstack-session-update" ]; then + if [ ! -x "$root/bin/gstack-session-update" ] \ + || [ "$(CDPATH= cd -- "$root" && pwd -P)" = "$(CDPATH= cd -- "$home_root" && pwd -P)" ]; then + root="$home_root" + fi + fi + printf '%s\n' "$root" +} diff --git a/bin/gstack-config b/bin/gstack-config index 53122a6be..8e33aeb9a 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -27,7 +27,7 @@ # Docs: https://github.com/garrytan/gstack/blob/main/docs/state-root.md set -euo pipefail -. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +[ -r "$(dirname "$0")/gstack-state-root.sh" ] && . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } gstack_state_root_select STATE_DIR="$_gstack_sr_root" CONFIG_FILE="$STATE_DIR/config.yaml" @@ -621,15 +621,14 @@ _SELF="$(cd "$(dirname "$0")" && pwd)/gstack-config" # One line when the root-selecting variables name different directories. report_root_disagreement() { - local _v _val _set="" _distinct="" + local _v _val _set="" _first="" _disagrees=0 for _v in GSTACK_STATE_ROOT GSTACK_HOME GSTACK_STATE_DIR; do eval "_val=\${$_v:-}" [ -n "$_val" ] || continue _set="$_set $_v=$_val" - case " $_distinct " in *" $_val "*) ;; *) _distinct="$_distinct $_val" ;; esac + if [ -z "$_first" ]; then _first="$_val"; elif [ "$_val" != "$_first" ]; then _disagrees=1; fi done - set -- $_distinct - [ "$#" -gt 1 ] || return 0 + [ "$_disagrees" -eq 1 ] || return 0 echo "# note: root-selecting variables disagree:$_set; using $STATE_DIR ($_gstack_sr_var wins). Explain: $(dirname "$_SELF")/gstack-paths --explain ($_STATE_DOC)" } @@ -971,7 +970,7 @@ $_gstack_legacy_root" gstack_config_select "$KEY" if [ -n "$_gstack_cfg_root" ] && [ "$_gstack_cfg_root" != "$STATE_DIR" ] && [ "$_gstack_cfg_value" != "$SAFE_VALUE" ]; then echo "gstack-config: set $KEY in $CONFIG_FILE, but $KEY is still '$_gstack_cfg_value': $_gstack_cfg_root/config.yaml sets that more restrictive value, and for privacy keys the most restrictive value across state roots wins." >&2 - echo "fix: GSTACK_STATE_ROOT='$_gstack_cfg_root' $_SELF set $KEY $SAFE_VALUE ($_STATE_DOC)" >&2 + printf 'fix: GSTACK_STATE_ROOT=%q %q set %q %q (%s)\n' "$_gstack_cfg_root" "$_SELF" "$KEY" "$SAFE_VALUE" "$_STATE_DOC" >&2 fi # Auto-relink skills when prefix setting changes (skip during setup to avoid recursive call) if { [ "$KEY" = "skill_prefix" ] || [ "$KEY" = "disabled_skills" ]; } && [ -z "${GSTACK_SETUP_RUNNING:-}" ]; then diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 181410d40..6afe69f95 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -113,6 +113,7 @@ gstack_result_anchor() { RUNTIME_PIN_CONFLICT) printf '%s' 'capy-install-runtime-conflict' ;; BROWSER_UNAVAILABLE) printf '%s' 'browser-ensure-failed' ;; INSTALL_PREFLIGHT_FAILED) printf '%s' 'capy-install-preflight-failed' ;; + SETUP_PS1_EMPTY_ARGUMENT) printf '%s' 'setup-ps1-empty-argument' ;; POLICY_SOURCE_UNAVAILABLE) printf '%s' 'policy-source-unavailable' ;; POLICY_INVALID) printf '%s' 'policy-invalid' ;; POLICY_CONTAINMENT) printf '%s' 'policy-containment' ;; @@ -192,6 +193,11 @@ gstack_result_anchor() { REVISION_BELOW_MINIMUM) printf '%s' 'revision-below-minimum' ;; CAPY_WRAPPER_STALE) printf '%s' 'capy-wrapper-stale' ;; INSTALL_UNVERIFIED) printf '%s' 'install-unverified' ;; + BROWSE_TAB_UNRECOVERABLE) printf '%s' 'browse-tab-unrecoverable' ;; + BUN_VERSION_UNREADABLE) printf '%s' 'bun-version-unreadable' ;; + BASH_UNAVAILABLE) printf '%s' 'bash-unavailable' ;; + IOS_LAUNCH_ENV_INVALID) printf '%s' 'ios-launch-env-invalid' ;; + OUTSIDE_VOICE_UNAVAILABLE) printf '%s' 'outside-voice-unavailable' ;; *) return 1 ;; esac } diff --git a/bin/gstack-evidence b/bin/gstack-evidence index f56231557..20fb35cb2 100755 --- a/bin/gstack-evidence +++ b/bin/gstack-evidence @@ -57,6 +57,7 @@ import { buildBundle, checkAncestors, readBundle, renderVerification, verifyBund import { resultLine } from "../lib/result-codes"; import { isUnavailable, loadPolicyFromBase, resolveBaseBranch } from "../lib/ship-policy"; import { versionAtRef } from "../lib/restamp"; +import { bashCommandInvocation, runScriptSync, wtreeFingerprint } from "../lib/script-invocation"; const BIN_DIR = dirname(Bun.fileURLToPath(import.meta.url)); const LOG_MAX_BYTES = 2 * 1024 * 1024; @@ -195,15 +196,14 @@ function git(args: string[]): string | undefined { } } +let wtreeWarned = false; +/** Content fingerprint (SHA-1 or SHA-256 tree id); undefined grades STALE, with the reason printed once. */ function currentWtree(): string | undefined { - try { - const r = spawnSync(join(BIN_DIR, "gstack-wtree"), { encoding: "utf-8", timeout: 30000 }); - if (r.status !== 0) return undefined; - const out = (r.stdout || "").trim(); - return /^[0-9a-f]{40}$/.test(out) ? out : undefined; - } catch { - return undefined; - } + const fp = wtreeFingerprint({ cwd: process.cwd(), timeoutMs: 30000 }); + if ("tree" in fp) return fp.tree; + if (!wtreeWarned) warn(`working-tree fingerprint unavailable (${fp.error}) — evidence recorded without it (will grade STALE)`); + wtreeWarned = true; + return undefined; } function ledgerPath(): { dir: string; file: string; logsDir: string } { @@ -213,7 +213,7 @@ function ledgerPath(): { dir: string; file: string; logsDir: string } { if (home === ".gstack") throw new Error("no GSTACK_HOME/HOME — bookkeeping skipped"); // ONE gstack-slug spawn: its output carries both SLUG= and BRANCH= lines // (same branch→filename sanitization as reviews.jsonl). - const slugOut = spawnSync(join(BIN_DIR, "gstack-slug"), { encoding: "utf-8" }); + const slugOut = runScriptSync(join(BIN_DIR, "gstack-slug"), [], { encoding: "utf-8", timeout: 30000 }); const sm = (slugOut.stdout || "").match(/^SLUG=(.+)$/m); const bm = (slugOut.stdout || "").match(/^BRANCH=(.+)$/m); const slug = sm ? sm[1].trim() : "unknown"; @@ -373,7 +373,7 @@ async function cmdRun(argv: string[]): Promise { // Compound/piped commands pass as ONE string via bash -c; a multi-token argv // runs directly. The hashed command string is exact, no normalization. const commandString = cmdArgv.length === 1 ? cmdArgv[0] : cmdArgv.join(" "); - const spawnArgv = cmdArgv.length === 1 ? ["bash", "-c", cmdArgv[0]] : cmdArgv; + const shell = cmdArgv.length === 1 ? bashCommandInvocation(cmdArgv[0]) : null; const cmdSha = sha256(commandString); label = label.replace(/[^a-zA-Z0-9._-]/g, "_"); @@ -401,6 +401,8 @@ async function cmdRun(argv: string[]): Promise { warn(`scrubbed ${scrubbed.length} bun-injected dotenv var(s) from the child env: ${scrubbed.join(", ")} ` + `(GSTACK_EVIDENCE_KEEP_DOTENV=1 to keep them)`); } + if (shell && !shell.ok) throw new Error(shell.error); + const spawnArgv = shell?.ok ? [shell.cmd, ...shell.argv] : cmdArgv; proc = Bun.spawn(spawnArgv, { stdin: "inherit", stdout: "pipe", stderr: "pipe", env: spawnEnv }); } catch (e: any) { // Spawn failure (ENOENT on argv-direct form): record exit 127, propagate 127. diff --git a/bin/gstack-ff-update b/bin/gstack-ff-update new file mode 100755 index 000000000..877f27672 --- /dev/null +++ b/bin/gstack-ff-update @@ -0,0 +1,317 @@ +#!/usr/bin/env bash +# gstack-ff-update — fast-forward the gstack install at to +# without ever leaving conflict markers in it and without losing a +# local edit. The only code that moves an install's tree: the session-start +# auto-updater, /gstack-upgrade Step 4 and the held-install recovery commands +# (docs/troubleshooting.md#auto-update-held-install-recovery) call it. +# +# Self-contained on purpose: the recovery commands fetch this one file from +# the new revision (git show FETCH_HEAD:bin/gstack-ff-update) and run it in a +# private temp directory, so it sources nothing. The lock and the +# flatten-and-strip sanitizer are inlined; test/gstack-ff-update.test.ts pins +# them to their twins in bin/gstack-session-update. +# +# Inside an install-scoped lock (/gstack-ff.lock): +# 1. The working tree is captured as-is through a private index (tracked +# edits, unmerged and marker-laden files), and the real index's staged +# state separately; the real index is never touched while building them. +# 2. Edits to files the update does not change ride over it, as +# `merge --ff-only --autostash` did. Regenerable footprint files +# (SKILL.md, */SKILL.md, */sections/*.md, rewritten by prefix mode and +# gbrain blocks) ride over when the update leaves them alone and are +# replaced by the update's version otherwise; callers re-render them. +# 3. Any other edit that conflicts with the update, any staged change and +# any unmerged file is shelved into a rescue commit +# refs/gstack/rescue/: tree = the working tree as captured, first +# parent = the pre-update HEAD (so `diff ^ ` is only the +# person's delta), second parent = the staged state. It is stash-shaped, +# so `git stash show -p ` reads it too. +# 4. The tree moves with a two-way `read-tree -m -u ` on +# the private index, so git refuses to overwrite a file changed after the +# capture or an untracked file in the way; HEAD moves compare-and-swap. +# +# stdout, one line: already | ok | rescued unmerged=<0|1> +# refusals (stdout): : , and nothing in the install changed +# exit 2 usage: / target-unresolvable: / not-a-checkout: +# exit 3 autostash-unrecoverable: (edits kept where they are) +# exit 4 not-fast-forward: +# exit 5 index-unreadable: +# exit 75 ff-busy: (another update holds this install's lock) +# A rescue also prints the inspect, export and delete commands on stderr. + +set +e +export LC_ALL=C GIT_TERMINAL_PROMPT=0 + +# Twin: ff_flatten in bin/gstack-session-update (test-pinned). One line, no +# control characters or escape sequences, at most bytes. +ff_flatten() { + _ff_esc=$(printf '\033') + printf '%s' "$1" | tr '\n\r\t' ' ' | sed "s/${_ff_esc}\[[0-9;?]*[ -\/]*[@-~]//g" | tr -d '\000-\037\177' | head -c "${2:-200}" +} +ff_quote() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } +refuse() { printf '%s: %s\n' "$2" "$(ff_flatten "$3" 300)"; exit "$1"; } + +[ $# -eq 2 ] && [ -n "$1" ] && [ -n "$2" ] || refuse 2 usage "gstack-ff-update " +DIR=$1 REV=$2 +cd -- "$DIR" 2>/dev/null || refuse 2 not-a-checkout "cannot enter $DIR" +DIR=$(pwd -P) +g() { git -c core.fsmonitor=false -c core.splitIndex=false "$@"; } +[ "$(g rev-parse --is-inside-work-tree 2>/dev/null)" = true ] && [ -z "$(g rev-parse --show-prefix 2>/dev/null)" ] \ + || refuse 2 not-a-checkout "$DIR is not the top of a git checkout" +TARGET=$(g rev-parse -q --verify "$REV^{commit}" 2>/dev/null) || refuse 2 target-unresolvable "$REV does not name a commit in $DIR" +COMMON=$(g rev-parse --path-format=absolute --git-common-dir 2>/dev/null) \ + || COMMON=$(cd -- "$(g rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd -P) \ + || refuse 2 not-a-checkout "cannot locate the git directory of $DIR" +INDEX=$(g rev-parse --path-format=absolute --git-path index 2>/dev/null) \ + || INDEX="$(cd -- "$(dirname "$(g rev-parse --git-path index)")" && pwd -P)/index" + +# ── Install-scoped lock (twin of bin/gstack-session-update's lock) ── +# Staleness: a dead holder PID, or a heartbeat older than the TTL (a live +# holder freshens it every GSTACK_FF_HEARTBEAT_SECONDS, default 300 s). +LOCK_DIR="$COMMON/gstack-ff.lock" +LOCK_TTL_MINUTES=30 +MYPID=$$ +OWN_INDEX_LOCK="" TMP="" HB_PID="" +lock_is_expired() { + _hb="$LOCK_DIR/pid" + [ -f "$_hb" ] || _hb="$LOCK_DIR" + [ -n "$(find "$_hb" -maxdepth 0 -mmin +$LOCK_TTL_MINUTES 2>/dev/null)" ] +} +lock_try() { + mkdir "$LOCK_DIR" 2>/dev/null && return 0 + _lp=$(cat "$LOCK_DIR/pid" 2>/dev/null) + if lock_is_expired || { [ "${_lp:-0}" -gt 0 ] 2>/dev/null && ! kill -0 "$_lp" 2>/dev/null; }; then + mv "$LOCK_DIR" "$LOCK_DIR.reap.$$" 2>/dev/null || return 1 + rm -rf "$LOCK_DIR.reap.$$" 2>/dev/null + mkdir "$LOCK_DIR" 2>/dev/null && return 0 + fi + return 1 +} +cleanup_ff_update() { + for heartbeat in ${HB_PID:-$(jobs -p)}; do + kill "$heartbeat" 2>/dev/null + wait "$heartbeat" 2>/dev/null + done + [ -n "$OWN_INDEX_LOCK" ] && rm -f "$OWN_INDEX_LOCK" 2>/dev/null + [ -n "$TMP" ] && rm -rf "$TMP" 2>/dev/null + [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] && rm -rf "$LOCK_DIR" 2>/dev/null +} +_wait=${GSTACK_FF_LOCK_WAIT:-15} +case "$_wait" in ''|*[!0-9]*) _wait=15 ;; esac +until lock_try; do + [ "$_wait" -gt 0 ] || refuse 75 ff-busy "another gstack update is moving $DIR (lock $LOCK_DIR held by pid $(cat "$LOCK_DIR/pid" 2>/dev/null || echo unknown)); nothing was changed, retry in a minute" + sleep 1; _wait=$((_wait - 1)) +done +echo "$MYPID" > "$LOCK_DIR/pid" 2>/dev/null +trap cleanup_ff_update EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM +_beat=${GSTACK_FF_HEARTBEAT_SECONDS:-300} +case "$_beat" in ''|*[!0-9]*|0) _beat=300 ;; esac +( + cleanup_heartbeat() { + for sleeper in $(jobs -p); do + kill "$sleeper" 2>/dev/null + wait "$sleeper" 2>/dev/null + done + } + trap cleanup_heartbeat EXIT + trap 'exit 0' HUP INT TERM + while :; do + sleep "$_beat" & + wait "$!" || exit 0 + [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] || exit 0 + touch "$LOCK_DIR/pid" 2>/dev/null + done +) & +HB_PID=$! + +# ── Inside the lock ── +HEAD_OID=$(g rev-parse -q --verify 'HEAD^{commit}' 2>/dev/null) || refuse 2 not-a-checkout "$DIR has no HEAD commit" +TMP=$(mktemp -d "${TMPDIR:-/tmp}/gstack-ff-XXXXXX" 2>/dev/null) || refuse 3 autostash-unrecoverable "cannot create a temp directory under ${TMPDIR:-/tmp}; nothing was changed" +g ls-files -u -z > "$TMP/unmerged.raw" 2>"$TMP/err" || refuse 5 index-unreadable "cannot read the index of $DIR ($(head -c 200 "$TMP/err")); nothing was changed" +UNMERGED=$(tr '\0' '\n' < "$TMP/unmerged.raw") +# Nothing to move: an up-to-date install keeps its edits and staged state as they are. +if [ "$HEAD_OID" = "$TARGET" ] && [ -z "$UNMERGED" ]; then echo "already $TARGET"; exit 0; fi +g merge-base --is-ancestor "$HEAD_OID" "$TARGET" 2>/dev/null \ + || refuse 4 not-fast-forward "$TARGET is not a fast-forward of HEAD $HEAD_OID in $DIR (local commits or a diverged history); nothing was changed" + +# Exclusive real-index lock for the whole move: git commands wait or fail +# instead of racing it, and the result is installed by rename. +( set -C; : > "$INDEX.lock" ) 2>/dev/null \ + || refuse 3 autostash-unrecoverable "$INDEX.lock exists (another git command is running in $DIR); nothing was changed" +OWN_INDEX_LOCK="$INDEX.lock" + +is_footprint() { case "$1" in SKILL.md|*/SKILL.md|*/sections/*.md) return 0 ;; esac; return 1; } +# capture — the working tree as-is into a private index; prints its tree. +capture() { + if [ -f "$INDEX" ]; then cp "$INDEX" "$1" || return 1; else GIT_INDEX_FILE=$1 g read-tree "$HEAD_OID" || return 1; fi + GIT_INDEX_FILE=$1 g add -u -- . 2>>"$TMP/err" || return 1 + GIT_INDEX_FILE=$1 g update-index -q --refresh >/dev/null 2>&1 + GIT_INDEX_FILE=$1 g write-tree 2>>"$TMP/err" +} +# paths — changed paths between two trees, split into +# .user and .foot (NUL-separated). +paths() { + : > "$3.user"; : > "$3.foot" + g diff-tree -r -z --no-renames --name-only "$1" "$2" 2>/dev/null | while IFS= read -r -d '' _p; do + if is_footprint "$_p"; then printf '%s\0' "$_p" >> "$3.foot"; else printf '%s\0' "$_p" >> "$3.user"; fi + done +} +# entries — index-info lines giving 's entry for +# each listed path that differs (mode 0 removes it). +entries() { + [ -s "$3" ] || return 0 + xargs -0 git --literal-pathspecs diff-tree -r -z --no-renames "$1" "$2" -- < "$3" 2>/dev/null \ + | while IFS= read -r -d '' _meta && IFS= read -r -d '' _p; do + set -- $_meta + printf '%s %s\t%s\0' "$2" "$4" "$_p" + done +} +apply_entries() { [ -s "$2" ] || return 0; GIT_INDEX_FILE=$1 g update-index -z --index-info < "$2"; } +tree_without_footprint() { + paths "$HEAD_OID" "$2" "$TMP/fp-$1" + cp "$TMP/$1.idx" "$TMP/$1-user.idx" && entries "$2" "$HEAD_OID" "$TMP/fp-$1.foot" > "$TMP/fp-$1.info" \ + && apply_entries "$TMP/$1-user.idx" "$TMP/fp-$1.info" && GIT_INDEX_FILE="$TMP/$1-user.idx" g write-tree +} + +: > "$TMP/err" +WTREE=$(capture "$TMP/w.idx") || refuse 3 autostash-unrecoverable "cannot capture the working tree of $DIR ($(head -c 200 "$TMP/err")); nothing was changed" +# The staged state: stage 0 entries as they are; an unmerged path keeps its +# "ours" stage (else theirs, else base) so the tree can be written. +g ls-files -s -z > "$TMP/s.raw" 2>>"$TMP/err" || refuse 5 index-unreadable "cannot read the index of $DIR; nothing was changed" +TAB=$'\t' +: > "$TMP/s.info" +_cp="" _cl="" _cr=9 +while IFS= read -r -d '' _e; do + _p=${_e#*"$TAB"} + set -- ${_e%%"$TAB"*} + case "$3" in 0) _r=0 ;; 2) _r=1 ;; 3) _r=2 ;; *) _r=3 ;; esac + if [ "$_p" != "$_cp" ]; then [ -n "$_cp" ] && printf '%s\0' "$_cl" >> "$TMP/s.info"; _cp=$_p _cr=9; fi + [ "$_r" -lt "$_cr" ] && _cr=$_r _cl="$1 $2$TAB$_p" +done < "$TMP/s.raw" +[ -n "$_cp" ] && printf '%s\0' "$_cl" >> "$TMP/s.info" +GIT_INDEX_FILE="$TMP/s.idx" g read-tree --empty 2>/dev/null +apply_entries "$TMP/s.idx" "$TMP/s.info" 2>>"$TMP/err" || refuse 3 autostash-unrecoverable "cannot read the staged state of $DIR; nothing was changed" +STREE=$(GIT_INDEX_FILE="$TMP/s.idx" g write-tree 2>>"$TMP/err") || refuse 3 autostash-unrecoverable "cannot read the staged state of $DIR; nothing was changed" +cp "$TMP/w.idx" "$TMP/wu.idx"; WUSER=$(tree_without_footprint wu "$WTREE") || refuse 3 autostash-unrecoverable "cannot capture the working tree of $DIR; nothing was changed" +SUSER=$(tree_without_footprint s "$STREE") || refuse 3 autostash-unrecoverable "cannot read the staged state of $DIR; nothing was changed" +HTREE=$(g rev-parse "$HEAD_OID^{tree}") + +UNMERGED_USER=0 UNMERGED_FOOT="" +while IFS= read -r _u; do + [ -n "$_u" ] || continue + _p=$(printf '%s' "$_u" | cut -f2-) + if is_footprint "$_p"; then UNMERGED_FOOT=1; else UNMERGED_USER=1; fi +done < — " " of in , empty when absent. +lsentry() { git --literal-pathspecs ls-tree -r -z "$1" -- "$2" 2>/dev/null | tr '\0' '\n' | awk -F '\t' 'NR == 1 { split($1, a, " "); print a[1] " " a[3] }'; } +# carry_line — the index-info line that carries as captured; +# when the update also changed it, a clean three-way content merge of the +# edit onto the target (as an autostash pop would make), else return 1. +carry_line() { + _w=$(lsentry "$WTREE" "$1") + if git --literal-pathspecs diff-tree --quiet -r "$HEAD_OID" "$TARGET" -- "$1" >/dev/null 2>&1; then + printf '%s\t%s\0' "${_w:-0 $ZERO}" "$1"; return 0 + fi + _t=$(lsentry "$TARGET" "$1"); _h=$(lsentry "$HEAD_OID" "$1") + [ "$_w" = "$_t" ] && return 0 + for _e in "$_h" "$_w" "$_t"; do case "${_e%% *}" in 100644|100755) ;; *) return 1 ;; esac; done + g cat-file blob "${_h#* }" > "$TMP/m.base" && g cat-file blob "${_w#* }" > "$TMP/m.ours" && g cat-file blob "${_t#* }" > "$TMP/m.theirs" || return 1 + g merge-file -p -q "$TMP/m.ours" "$TMP/m.base" "$TMP/m.theirs" > "$TMP/m.out" 2>/dev/null || return 1 + _o=$(g hash-object -w "$TMP/m.out") || return 1 + _m=${_t%% *}; [ "${_w%% *}" = "${_h%% *}" ] || _m=${_w%% *} + printf '%s %s\t%s\0' "$_m" "$_o" "$1" +} + +paths "$HEAD_OID" "$WTREE" "$TMP/delta" +SHELVE=0 +[ "$UNMERGED_USER" = 1 ] && SHELVE=1 +[ "$SUSER" = "$HTREE" ] || SHELVE=1 +: > "$TMP/carry.user"; : > "$TMP/carry.foot" +if [ "$SHELVE" = 0 ]; then + while IFS= read -r -d '' _p; do + carry_line "$_p" >> "$TMP/carry.user" || { SHELVE=1; break; } + done < "$TMP/delta.user" +fi +# A footprint edit rides over when it merges cleanly; otherwise, or when it +# is unmerged, the target's version wins (callers re-render it). +while IFS= read -r -d '' _p; do + [ -n "$UNMERGED_FOOT" ] && printf '%s\n' "$UNMERGED" | cut -f2- | grep -qxF -- "$_p" && continue + carry_line "$_p" >> "$TMP/carry.foot" +done < "$TMP/delta.foot" +[ "$SHELVE" = 1 ] && [ "$WUSER" = "$HTREE" ] && [ "$SUSER" = "$HTREE" ] && [ "$UNMERGED_USER" = 0 ] && SHELVE=0 + +# The result: the target, plus the edits that ride over it. +GIT_INDEX_FILE="$TMP/r.idx" g read-tree "$TARGET" 2>>"$TMP/err" || refuse 3 autostash-unrecoverable "cannot read $TARGET; nothing was changed" +{ [ "$SHELVE" = 0 ] && cat "$TMP/carry.user"; cat "$TMP/carry.foot"; } > "$TMP/carry.info" +apply_entries "$TMP/r.idx" "$TMP/carry.info" || refuse 3 autostash-unrecoverable "cannot build the updated tree; nothing was changed" +RTREE=$(GIT_INDEX_FILE="$TMP/r.idx" g write-tree 2>>"$TMP/err") || refuse 3 autostash-unrecoverable "cannot build the updated tree; nothing was changed" + +RESCUE="" REF="" +if [ "$SHELVE" = 1 ]; then + _id() { GIT_AUTHOR_NAME='gstack rescue' GIT_AUTHOR_EMAIL='gstack@localhost' GIT_COMMITTER_NAME='gstack rescue' GIT_COMMITTER_EMAIL='gstack@localhost' "$@"; } + _label="" + [ "$UNMERGED_USER" = 1 ] && _label=" +Needs manual resolution: the install had unmerged files; their captured +content may contain conflict markers." + SCOMMIT=$(printf 'gstack rescue: staged state before updating to %s\n' "$TARGET" \ + | _id g commit-tree --no-gpg-sign "$SUSER" -p "$HEAD_OID" 2>>"$TMP/err") \ + || refuse 3 autostash-unrecoverable "cannot write the rescue commit ($(head -c 200 "$TMP/err")); nothing was changed" + RESCUE=$(printf 'gstack rescue: local edits that conflicted with %s\n\nPre-update HEAD: %s\nTarget: %s\nRestore out of the live install: git diff ^ > patch, then git apply -3 in a scratch checkout.\n%s\n' \ + "$TARGET" "$HEAD_OID" "$TARGET" "$_label" \ + | _id g commit-tree --no-gpg-sign "$WUSER" -p "$HEAD_OID" -p "$SCOMMIT" 2>>"$TMP/err") \ + || refuse 3 autostash-unrecoverable "cannot write the rescue commit ($(head -c 200 "$TMP/err")); nothing was changed" + REF="refs/gstack/rescue/$RESCUE" + if ! g update-ref -m "gstack-ff-update: rescue before $TARGET" "$REF" "$RESCUE" "" 2>/dev/null; then + [ "$(g rev-parse -q --verify "$REF" 2>/dev/null)" = "$RESCUE" ] \ + || refuse 3 autostash-unrecoverable "rescue ref $REF already exists and names another commit; nothing was changed" + fi +fi +unrescue() { [ -n "$REF" ] && g update-ref -d "$REF" "$RESCUE" 2>/dev/null; } + +# The working tree must still be exactly what was captured. +: > "$TMP/err" +RECHECK=$(capture "$TMP/w2.idx") +if [ "$RECHECK" != "$WTREE" ] || [ "$(g rev-parse -q --verify HEAD 2>/dev/null)" != "$HEAD_OID" ]; then + unrescue + refuse 3 autostash-unrecoverable "files in $DIR changed while the update ran; nothing was changed, your edits are where they were" +fi + +if ! GIT_INDEX_FILE="$TMP/w.idx" g read-tree -m -u "$WTREE" "$RTREE" 2>"$TMP/err"; then + unrescue + refuse 3 autostash-unrecoverable "git refused to update $DIR: $(head -c 200 "$TMP/err"); nothing was changed, your edits are where they were" +fi +if ! g update-ref -m "gstack-ff-update: fast-forward to $TARGET" HEAD "$TARGET" "$HEAD_OID" 2>"$TMP/err"; then + GIT_INDEX_FILE="$TMP/w.idx" g read-tree -m -u "$RTREE" "$WTREE" 2>/dev/null + unrescue + refuse 3 autostash-unrecoverable "HEAD of $DIR moved during the update; the working tree was put back" +fi +# The real index becomes the target with the carried edits unstaged. +cp "$TMP/w.idx" "$TMP/f.idx" +paths "$RTREE" "$TARGET" "$TMP/unstage" +cat "$TMP/unstage.user" "$TMP/unstage.foot" > "$TMP/unstage.all" +entries "$RTREE" "$TARGET" "$TMP/unstage.all" > "$TMP/unstage.info" +apply_entries "$TMP/f.idx" "$TMP/unstage.info" +GIT_INDEX_FILE="$TMP/f.idx" g update-index -q --refresh >/dev/null 2>&1 +cat "$TMP/f.idx" > "$INDEX.lock" && mv -f "$INDEX.lock" "$INDEX" && OWN_INDEX_LOCK="" + +if [ "$SHELVE" = 1 ] && [ -n "$REF" ]; then + _q=$(ff_quote "$DIR") + { + echo "gstack-ff-update: your local edits in $DIR conflicted with the update; they are saved in $REF and the install now runs $TARGET." + [ "$UNMERGED_USER" = 1 ] && echo " They include unmerged files: this rescue needs manual resolution." + echo " Inspect: git -C $_q show --stat '$REF'" + echo " Export: git -C $_q diff '$REF^' '$REF' > ~/gstack-local-edits.patch (then git apply -3 it in a scratch checkout, not in the install)" + echo " Delete: git -C $_q update-ref -d '$REF'" + } >&2 + echo "rescued $HEAD_OID $TARGET $REF unmerged=$UNMERGED_USER" +else + echo "ok $HEAD_OID $TARGET" +fi +exit 0 diff --git a/bin/gstack-gate-diff b/bin/gstack-gate-diff index 3544416c7..5df86f046 100755 --- a/bin/gstack-gate-diff +++ b/bin/gstack-gate-diff @@ -26,14 +26,14 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import { spawnSync } from 'node:child_process'; import { scanCandidate, parseGateIntegrityBlock, specDirectoryHoldsSpecs, type FileDiff, type CommitInfo } from '../lib/gate-diff/index'; import { commitInfo, commitsInRange, defaultBase, diffCommit, diffWorkingTree, firstParent, headSha, mergeBase, noBaseFix, readClaudeMd, repoRoot, - resolvesToCommit, treePaths, untrackedAsAdded, wtreeFingerprint, + resolvesToCommit, treePaths, untrackedAsAdded, } from '../lib/gate-diff/git'; import { renderDiagnostics, renderJsonl, renderJsonlBody, renderTable } from '../lib/gate-diff/render'; import { resolveStateRoot } from '../lib/state-root'; +import { runScriptSync, wtreeFingerprint } from '../lib/script-invocation'; const BIN_DIR = path.dirname(Bun.fileURLToPath(import.meta.url)); const USAGE = `usage: gstack-gate-diff [base-ref] [--format table|jsonl] [--commit ] [--help] @@ -79,7 +79,8 @@ function parseArgs(argv: string[]): Args | string { function projectSlug(cwd: string): string { try { - const r = spawnSync(path.join(BIN_DIR, 'gstack-slug'), [], { cwd, encoding: 'utf-8', timeout: 30_000 }); + const r = runScriptSync(path.join(BIN_DIR, 'gstack-slug'), [], { cwd, encoding: 'utf-8', timeout: 30_000 }); + if (r.error) console.error(`gstack-gate-diff: gstack-slug failed (${r.error.message}); artifact filed under projects/unknown`); const m = (r.stdout || '').match(/^SLUG=(.+)$/m); return m ? m[1].trim() : 'unknown'; } catch (e: any) { @@ -154,9 +155,9 @@ function main(argv: string[]): number { } files = [...diffWorkingTree(mb, root), ...untrackedAsAdded(root, root)]; commits = commitsInRange(mb, head, root); - const wtree = wtreeFingerprint(BIN_DIR, root); - if (!wtree) console.error('gstack-gate-diff: working-tree fingerprint unavailable (gstack-wtree failed); candidate recorded without it'); - candidate = `${mb.slice(0, 12)}+${wtree ? wtree.slice(0, 12) : 'nowtree'}`; + const fp = wtreeFingerprint({ cwd: root }); + if ('error' in fp) console.error(`gstack-gate-diff: working-tree fingerprint unavailable (${fp.error}); candidate recorded without it`); + candidate = `${mb.slice(0, 12)}+${'tree' in fp ? fp.tree.slice(0, 12) : 'nowtree'}`; specIsTestPath = specDirectoryHoldsSpecs(treePaths(root)); } diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index f35014d4e..47b9be292 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -30,7 +30,7 @@ */ import { existsSync, statSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, renameSync, realpathSync, readdirSync, appendFileSync } from "fs"; -import { join, dirname } from "path"; +import { join, dirname, basename, isAbsolute } from "path"; import { execSync, spawnSync } from "child_process"; import { homedir, hostname } from "os"; import { createHash } from "crypto"; @@ -1474,13 +1474,32 @@ async function runCodeImport(args: CliArgs): Promise { } /** - * Ensure `.gbrain-source` is listed in the consumer repo's `.gitignore`. + * Ensure `.gbrain-source` is ignored in the consumer repo. * - * Idempotent: only appends when the entry is not already present (matched on - * trimmed lines so a leading/trailing whitespace difference doesn't add a - * second copy). Wraps writes in try/catch so a read-only checkout or weird + * Idempotent. A trimmed `.gbrain-source` line is enough, and so is a + * pattern in a tracked `.gitignore` that already ignores the pin (an + * ignore-everything `*` covers it); appending a second line would dirty that + * tracked file. Global excludes and `.git/info/exclude` do not count: they + * are invisible to teammates' clones, which would then commit the pin + * (#1384). Wraps writes in try/catch so a read-only checkout or weird * perms logs a warning and lets the rest of the sync continue. */ +function trackedGitignoreIgnoresGbrainSource(root: string): boolean { + const check = spawnSync("git", ["-C", root, "-c", "core.excludesFile=", "check-ignore", "-v", "-z", "--stdin"], { + input: ".gbrain-source\0", + encoding: "utf-8", + timeout: 5_000, + }); + if (check.status !== 0) return false; + const [source = "", , pattern = ""] = check.stdout.split("\0"); + if (pattern.startsWith("!") || basename(source) !== ".gitignore" || isAbsolute(source)) return false; + const tracked = spawnSync("git", ["-C", root, "ls-files", "--error-unmatch", "--", `:(top,literal)${source}`], { + stdio: "ignore", + timeout: 5_000, + }); + return tracked.status === 0; +} + export function ensureGbrainSourceGitignored(root: string): void { const gitignorePath = join(root, ".gitignore"); try { @@ -1493,7 +1512,7 @@ export function ensureGbrainSourceGitignored(root: string): void { const alreadyIgnored = existing .split("\n") .some((line) => line.trim() === ".gbrain-source"); - if (alreadyIgnored) { + if (alreadyIgnored || trackedGitignoreIgnoresGbrainSource(root)) { return; } const sep = existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""; @@ -1643,6 +1662,9 @@ function runMemoryIngest(args: CliArgs, previous: SyncState, consent: Transcript }; } +export const NO_BASH_SUMMARY = + "skipped (no bash found; set GSTACK_GIT_BASH (old name: GSTACK_BASH) to your Git bash.exe. PowerShell: [Environment]::SetEnvironmentVariable('GSTACK_GIT_BASH', 'C:\\Program Files\\Git\\bin\\bash.exe', 'User'), then restart Claude Code; or add it to the \"env\" block of ~/.claude/settings.json)"; + function runBrainSyncPush(args: CliArgs): StageResult { const t0 = Date.now(); @@ -1670,7 +1692,7 @@ function runBrainSyncPush(args: CliArgs): StageResult { ran: false, ok: true, duration_ms: Date.now() - t0, - summary: "skipped (no bash found; set GSTACK_BASH to your Git bash.exe)", + summary: NO_BASH_SUMMARY, }; } diff --git a/bin/gstack-memorable b/bin/gstack-memorable index 482c66277..7c018fbed 100755 --- a/bin/gstack-memorable +++ b/bin/gstack-memorable @@ -37,6 +37,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" GSTACK_CONFIG="$SCRIPT_DIR/gstack-config" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +. "$(dirname "$0")/gstack-claude-root.sh" || { echo "$0: $(dirname "$0")/gstack-claude-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade" >&2; exit 1; } gstack_state_root_select STATE_DIR="$_gstack_sr_root" SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json}" @@ -53,12 +54,8 @@ VENDOR_OWN_RE='[Mm]emorable.*hook\s+user-prompt' # Canonical install root — the hook command MUST point at the stable install, # never at the tree this CLI happens to run from (setup's phantom-hooks rule). -# Copied from setup:2481-2489; TODO D24 extracts a shared helper. -CANONICAL_GSTACK_ROOT="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/gstack" -if [ ! -x "$CANONICAL_GSTACK_ROOT/bin/gstack-session-update" ] \ - && [ -x "$HOME/.claude/skills/gstack/bin/gstack-session-update" ]; then - CANONICAL_GSTACK_ROOT="$HOME/.claude/skills/gstack" -fi +# Setup registers through the same helper, so both write one path. +CANONICAL_GSTACK_ROOT="$(gstack_canonical_claude_root)" HOOK_CMD_PATH="$CANONICAL_GSTACK_ROOT/$HOOK_REL" # Mutations go through the CANONICAL hook manager so the code that registers # is the code that will run. Every verb falls back to this tree's copy when the diff --git a/bin/gstack-sanitize-echo.sh b/bin/gstack-sanitize-echo.sh new file mode 100644 index 000000000..1c73fd188 --- /dev/null +++ b/bin/gstack-sanitize-echo.sh @@ -0,0 +1,19 @@ +# shellcheck shell=bash +# gstack-sanitize-echo.sh — the Bash twin of lib/sanitize-echo.ts, the one +# sanitizer for an external string gstack echoes into a message, a log line or +# a key=value state file (command output, stderr, a version string, a path). +# Sourced, never executed. test/sanitize-echo.test.ts runs both twins, under +# bash, zsh and bash --posix, on one table. +# +# gstack_sanitize_echo [TEXT] — TEXT (stdin when no argument): CR, LF and TAB +# become spaces, every other ASCII control byte (ESC included) and DEL is +# removed, runs of spaces collapse, and at most 200 bytes of valid UTF-8 with +# no leading or trailing space are printed, then a newline. +gstack_sanitize_echo() { + { if [ "$#" -gt 0 ]; then printf '%s' "$1"; else cat; fi; } | + LC_ALL=C tr '\r\n\t' ' ' | LC_ALL=C tr -d '\000-\037\177' | LC_ALL=C tr -s ' ' | + LC_ALL=C sed -e 's/^ //' | head -c 200 | + LC_ALL=C sed -E -e "s/($(printf '[\360-\377][\200-\277][\200-\277]|[\340-\377][\200-\277]|[\300-\377]'))\$//" -e 's/ $//' | + LC_ALL=C tr -d '\n' + printf '\n' +} diff --git a/bin/gstack-session-update b/bin/gstack-session-update index 844ec6133..7166abc6d 100755 --- a/bin/gstack-session-update +++ b/bin/gstack-session-update @@ -9,7 +9,9 @@ # release's Bun floor (bin/gstack-bun-version.sh) and checks the `bun` setup # will run; below it the update is held with reason bun-too-old and nothing # changes (E1). Then it parse-checks every hook the incoming revision registers -# (bin/gstack-hook-check on a `git archive` of that revision): Claude Code runs +# (bin/gstack-hook-check on that revision, extracted through a private index +# with symlinks as plain files, never tar: MSYS tar cannot create a link +# before its target, #3184): Claude Code runs # hooks straight from this checkout, so an unparseable hook would block tool # calls the moment the checkout moved. The incoming revision's own checker # decides; the installed one decides only when that gives no verified report. @@ -18,6 +20,10 @@ # hook-check-unavailable. Updaters older than a check cannot protect their # first pull. # +# The fast-forward itself is bin/gstack-ff-update: edits that conflict with +# the release are shelved in refs/gstack/rescue/, never left as conflict +# markers, and the next session start names them (at most once a day). +# # Stages (B11): pull, setup and migrations are tracked separately. The # "just upgraded" marker is written only when every stage succeeded. A failed # stage is persisted in $STATE_DIR/session-update-pending and retried even @@ -46,6 +52,8 @@ LOCK_DIR="$STATE_DIR/.setup-lock" LOG_FILE="$STATE_DIR/analytics/session-update.log" PENDING_FILE="$STATE_DIR/session-update-pending" HOOK_CHECK="$(cd "$(dirname "$0")" && pwd)/gstack-hook-check" +FF_UPDATE="$(cd "$(dirname "$0")" && pwd)/gstack-ff-update" +RESCUE_FILE="$STATE_DIR/session-update-rescues" # Path spelling (Git Bash /c/x vs native C:/x) for the held-reason prefix strip. . "$(cd "$(dirname "$0")" && pwd)/gstack-path-spelling.sh" 2>/dev/null || { gstack_path_shell() { printf '%s\n' "$1"; }; gstack_path_native() { printf '%s\n' "$1"; }; } THROTTLE_SECONDS=3600 # 1 hour @@ -54,6 +62,13 @@ log_entry() { mkdir -p "$(dirname "$LOG_FILE")" echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $1" >> "$LOG_FILE" 2>/dev/null || true # best-effort: logging never blocks a session } +# Twin: ff_flatten in bin/gstack-ff-update (test-pinned). One line, no +# control characters or escape sequences, at most bytes. +ff_flatten() { + _ff_esc=$(printf '\033') + printf '%s' "$1" | tr '\n\r\t' ' ' | sed "s/${_ff_esc}\[[0-9;?]*[ -\/]*[@-~]//g" | tr -d '\000-\037\177' | head -c "${2:-200}" +} +sq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } # hook_report — sets _hr_out to the checker's report on # and returns 0 only when the report is verified and complete: exit 0 # with no fail line or exit 1 with one, every line `ok|missing ` or @@ -85,8 +100,12 @@ hook_report() { return 1 fi } -# hook_incoming_hold — parse-check the hooks of (extracted with -# git archive) before the live checkout moves. The incoming revision's own +# hook_incoming_hold — parse-check the hooks of before the live +# checkout moves. is extracted without tar and without symlinks: a +# private index (the real one is never touched) and checkout-index with +# core.symlinks=false, so a link becomes a plain file holding its target +# path, which the hook check never reads (test/session-update-hook-hold.test.ts +# keeps hooked paths and their imports free of symlinks). The incoming revision's own # bin/gstack-hook-check decides, so one checker bug in this install cannot hold # every later release; when it gives no verified report (crash, empty output, # unparseable), the installed checker decides. Prints the held reason and @@ -97,8 +116,9 @@ hook_incoming_hold() { _hc_dir=$(mktemp -d "${TMPDIR:-/tmp}/gstack-session-hooks-XXXXXX" 2>/dev/null) \ || { echo "hook-check-unavailable: cannot create a temp directory under ${TMPDIR:-/tmp}"; return 0; } _hc_out="" _hc_why="" - if ! git -C "$GSTACK_DIR" archive --format=tar "$1" 2>/dev/null | tar -xf - -C "$_hc_dir" 2>/dev/null; then - _hc_why="git archive of the incoming revision failed" + if ! ( export GIT_INDEX_FILE="$_hc_dir.index" + git -C "$GSTACK_DIR" read-tree "$1" && git -C "$GSTACK_DIR" -c core.symlinks=false checkout-index -a --prefix="$_hc_dir/" ) 2>"$_hc_dir.err"; then + _hc_why="cannot extract the incoming revision: $(ff_flatten "$(head -c 200 "$_hc_dir.err" 2>/dev/null)" 200)" elif [ -f "$_hc_dir/bin/gstack-hook-check" ] && hook_report "$_hc_dir/bin/gstack-hook-check" "$_hc_dir"; then _hc_out=$_hr_out if ! printf '%s\n' "$_hc_out" | grep -q '^fail '; then @@ -118,7 +138,7 @@ hook_incoming_hold() { fi fi _hc_fail=$(hook_hold_path "$(printf '%s\n' "$_hc_out" | sed -n 's/^fail [^ ]* //p' | head -1)") - rm -rf "$_hc_dir" "$_hc_dir.err" + rm -rf "$_hc_dir" "$_hc_dir.err" "$_hc_dir.index" if [ -n "$_hc_why" ]; then printf 'hook-check-unavailable: %s\n' "$_hc_why" | head -c 300 return 0 @@ -135,6 +155,12 @@ hook_hold_path() { done printf '%s\n' "$_hp" } +# rescue_note — remember a rescue for the session-start +# banner (shown at most once a day until the ref is deleted). +rescue_note() { + grep -q "^$1 " "$RESCUE_FILE" 2>/dev/null && return 0 + printf '%s %s -\n' "$1" "$2" >> "$RESCUE_FILE" 2>/dev/null +} pending_get() { sed -n "s/^$1=//p" "$PENDING_FILE" 2>/dev/null | head -1; } # pending_save — persist the P_* stage state; nothing pending removes the file. pending_save() { @@ -155,25 +181,64 @@ if [ "$AUTO" != "true" ]; then exit 0 fi -# ── A stage that did not finish: one line per session start, with the fix ── +# ── Session-start lines: a held update first, then a stage that did not +# finish, then edits a recovery shelved (daily). Each names its fix; every +# path in a printed command is shell-quoted. ── +_Q=$(sq "$GSTACK_DIR") +_WHERE="in a terminal; Git Bash on Windows" +# A printed instruction (the egress tripwire skips printf lines): nothing runs it here. +_FF_FIX=$( + printf "cd %s && git fetch origin && bash bin/gstack-ff-update . '@{upstream}' && ./setup" "$_Q") +_P_PULL="" _P_STAGES="" if [ -f "$PENDING_FILE" ]; then _P_STAGES="$(pending_get stages)"; _P_REASON="$(pending_get reason)"; _P_PULL="$(pending_get pull)" - if [ -n "$_P_STAGES" ]; then - _P_FIX="cd $GSTACK_DIR && ./setup" - case "$_P_REASON" in bun*) _P_FIX="install Bun (https://bun.sh), then $_P_FIX" ;; esac - echo "gstack auto-update: ${_P_STAGES} did not finish (${_P_REASON:-unknown reason}); installed skills may be out of date. gstack retries automatically. Fix now: $_P_FIX" - elif [ -n "$_P_PULL" ]; then - case "$_P_PULL" in - bun-too-old*) - echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed. Fix now: bun upgrade && cd $GSTACK_DIR && git pull --ff-only && ./setup" ;; - hook-does-not-parse*) - echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed, and your current hooks keep running. This is a gstack bug; report ${_P_PULL#hook-does-not-parse: } at https://github.com/garrytan/gstack/issues. gstack retries at the next check. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-hook-does-not-parse" ;; - hook-check-unavailable*) - echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed, and your current hooks keep running. gstack retries at the next check. Fix now: cd $GSTACK_DIR && git pull --ff-only --autostash && ./setup (setup checks every hook it registers). https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-hook-check-unavailable" ;; - *) - echo "gstack auto-update: git pull did not finish ($_P_PULL); gstack is not updating. Fix now: cd $GSTACK_DIR && git pull --ff-only && ./setup" ;; - esac - fi +fi +# Unmerged files in the install: no stage fix, only the rescue-first recovery. +_UNM=$(git -C "$GSTACK_DIR" ls-files -u 2>/dev/null | head -1 | cut -f2-) +if [ -n "$_UNM" ]; then + _P_STAGES="" + case "$_P_PULL" in unmerged-files*) ;; *) _P_PULL="unmerged-files: the install has unmerged files (first: $_UNM)" ;; esac +fi +case "$_P_PULL" in + "") ;; + bun-too-old*) + echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed. Fix now ($_WHERE): bun upgrade && $_FF_FIX" ;; + bun-version-unreadable*) + echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed. Fix now: reinstall Bun (curl -fsSL https://bun.sh/install | bash), check that bun --version prints a version, then cd $GSTACK_DIR && git pull --ff-only && ./setup. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#bun-version-unreadable" ;; + hook-does-not-parse*) + echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed, and your current hooks keep running. This is a gstack bug; report ${_P_PULL#hook-does-not-parse: } at https://github.com/garrytan/gstack/issues. gstack retries at the next check. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-hook-does-not-parse" ;; + hook-check-unavailable*) + echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed, and your current hooks keep running. gstack retries at the next check. Fix now ($_WHERE): $_FF_FIX (setup checks every hook it registers). https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-hook-check-unavailable" ;; + unmerged-files*) + echo "gstack auto-update: update held ($_P_PULL); an earlier merge, rebase or stash pop left conflicts in the install, and an update would bury them. Fix now ($_WHERE): $_FF_FIX (saves every edit, the unmerged files included, in a rescue ref before it updates). https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-unmerged-files" ;; + autostash-unrecoverable*) + echo "gstack auto-update: update held ($_P_PULL); your local edits are where they were and nothing was installed. Look first: git -C $_Q status --short. Fix now ($_WHERE): $_FF_FIX (once the cause above is gone). https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-autostash-unrecoverable" ;; + *) + echo "gstack auto-update: git pull did not finish ($_P_PULL); gstack is not updating. Fix now ($_WHERE): $_FF_FIX" ;; +esac +if [ -n "$_P_STAGES" ]; then + _P_FIX="cd $_Q && ./setup" + case "$_P_REASON" in bun*) _P_FIX="install Bun (https://bun.sh), then $_P_FIX" ;; esac + echo "gstack auto-update: ${_P_STAGES} did not finish (${_P_REASON:-unknown reason}); installed skills may be out of date. gstack retries automatically. Fix now: $_P_FIX" +fi +if [ -s "$RESCUE_FILE" ]; then + _TODAY=$(date -u +%Y-%m-%d) + : > "$RESCUE_FILE.tmp.$$" + while read -r _R_REF _R_UNM _R_SHOWN; do + [ -n "$_R_REF" ] && git -C "$GSTACK_DIR" rev-parse -q --verify "$_R_REF^{commit}" >/dev/null 2>&1 || continue + if [ "$_R_SHOWN" != "$_TODAY" ]; then + _R_FILES=$(git -C "$GSTACK_DIR" diff --name-only "$_R_REF^" "$_R_REF" 2>/dev/null) + _R_N=$(printf '%s\n' "$_R_FILES" | grep -c .) + _R_LIST=$(printf '%s\n' "$_R_FILES" | head -3 | paste -sd, - | sed 's/,/, /g') + [ "$_R_N" -gt 3 ] && _R_LIST="$_R_LIST and $((_R_N - 3)) more" + _R_NOTE="" + [ "$_R_UNM" = 1 ] && _R_NOTE=" It includes files that were already unmerged: it needs manual resolution." + echo "gstack auto-update: your local edits in the install ($(ff_flatten "$_R_LIST" 300)) conflicted with an update, so they were saved in $_R_REF and the install runs the update.$_R_NOTE Inspect ($_WHERE): git -C $_Q show --stat '$_R_REF'. To keep them, export only your changes and apply them in a scratch checkout, not in the install: git -C $_Q diff '$_R_REF^' '$_R_REF' > ~/gstack-local-edits.patch, then git apply -3 ~/gstack-local-edits.patch. Delete when done: git -C $_Q update-ref -d '$_R_REF'. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-rescued-edits" + _R_SHOWN=$_TODAY + fi + printf '%s %s %s\n' "$_R_REF" "$_R_UNM" "$_R_SHOWN" >> "$RESCUE_FILE.tmp.$$" + done < "$RESCUE_FILE" + if [ -s "$RESCUE_FILE.tmp.$$" ]; then mv -f "$RESCUE_FILE.tmp.$$" "$RESCUE_FILE"; else rm -f "$RESCUE_FILE.tmp.$$" "$RESCUE_FILE"; fi fi # ── Throttle: skip if checked recently ── @@ -257,25 +322,56 @@ fi # freshens the pidfile mtime every 5 minutes for as long as we still own # the lock (ownership re-checked each beat: if another updater reclaimed # and wrote its own pid, the loop exits instead of touching THEIR file). - ( while :; do sleep 300; [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] || exit 0; touch "$LOCK_DIR/pid" 2>/dev/null; done ) & - HB_PID=$! - # Clean up lock on exit — ownership-checked: after a TTL reclaim by another # updater, $LOCK_DIR belongs to the NEW holder, and an unconditional rm -rf # here would delete the live holder's lock (cascading reclaims). Remove the # lock ONLY while $LOCK_DIR/pid still contains MYPID; always stop the - # heartbeat. - trap 'kill "$HB_PID" 2>/dev/null; [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] && rm -rf "$LOCK_DIR" 2>/dev/null' EXIT + # heartbeat, including its sleeping child, and reap it before returning. + HB_PID="" + cleanup_session_update() { + # The job-table fallback covers a signal between fork and HB_PID=$!. + # This holder has no other background jobs at that point. + for heartbeat in ${HB_PID:-$(jobs -p)}; do + kill "$heartbeat" 2>/dev/null + wait "$heartbeat" 2>/dev/null + done + [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] && rm -rf "$LOCK_DIR" 2>/dev/null + } + trap cleanup_session_update EXIT + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM + + ( + cleanup_heartbeat() { + # Only this heartbeat's own background sleep belongs to this job table. + # Listing jobs also covers interruption before $! could be recorded. + for sleeper in $(jobs -p); do + kill "$sleeper" 2>/dev/null + wait "$sleeper" 2>/dev/null + done + } + trap cleanup_heartbeat EXIT + trap 'exit 0' HUP INT TERM + while :; do + sleep 300 & + wait "$!" || exit 0 + [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] || exit 0 + touch "$LOCK_DIR/pid" 2>/dev/null + done + ) & + HB_PID=$! # ── Fetch, check the incoming release's Bun floor, then fast-forward (E1) ── OLD_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null) UPDATE_URL=$(git -C "$GSTACK_DIR" remote get-url origin 2>/dev/null || echo "") UPDATE_HOST="${UPDATE_URL#*://}"; UPDATE_HOST="${UPDATE_HOST#*@}"; UPDATE_HOST="${UPDATE_HOST%%[/:]*}" - # --autostash: locally-patched TRACKED files are the NORM on installs, not - # the exception — skill-prefix mode rewrites frontmatter names and + # Locally-patched TRACKED files are the NORM on installs, not the + # exception — skill-prefix mode rewrites frontmatter names and # `gstack-config gbrain-refresh` renders brain blocks into SKILL.md. A bare # --ff-only refuses over those edits, so auto-upgrade wedged permanently - # (observed: 308 consecutive PULL_FAILED with the reason discarded, #2566). + # (observed: 308 consecutive PULL_FAILED with the reason discarded, #2566); + # bin/gstack-ff-update carries them over the release instead. # Capture stderr: the log must carry WHY a pull failed, never just the code. PULL_ERR_FILE=$(mktemp "${TMPDIR:-/tmp}/gstack-session-pull-XXXXXX" 2>/dev/null || echo "") GSTACK_HOME="$STATE_DIR" _receipted_git open session-update "${UPDATE_HOST:-unknown}" gstack-self-update-pull "auto_upgrade=true" \ @@ -283,7 +379,7 @@ fi PULL_EXIT=$? # The fast-forward targets the exact revision whose requirement was read. # Setup runs `bun` from this PATH, so that is the binary checked; a - # malformed version passes here because setup only warns on it. + # version it cannot read holds too (bun-version-unreadable, #3195). INCOMING="" HELD="" if [ "$PULL_EXIT" -eq 0 ]; then INCOMING=$(git -C "$GSTACK_DIR" rev-parse --verify '@{upstream}^{commit}' 2>"${PULL_ERR_FILE:-/dev/null}") || PULL_EXIT=1 @@ -294,9 +390,37 @@ fi if [ "$PULL_EXIT" -eq 0 ] && [ -z "$HELD" ] && [ "$INCOMING" != "$OLD_HEAD" ]; then HELD=$(hook_incoming_hold "$INCOMING") || HELD="" fi + if [ "$PULL_EXIT" -eq 0 ] && [ -z "$HELD" ] && [ -z "$PULL_ERR_FILE" ] && [ "$INCOMING" != "$OLD_HEAD" ]; then + # Recovering a conflicting autostash needs git's own messages; without a + # file to keep them in, hold rather than merge blind. + HELD="temp-file-unavailable: cannot create a temp file under ${TMPDIR:-/tmp}" + fi if [ "$PULL_EXIT" -eq 0 ] && [ -z "$HELD" ]; then - git -C "$GSTACK_DIR" merge --ff-only --autostash -q "$INCOMING" >/dev/null 2>"${PULL_ERR_FILE:-/dev/null}" - PULL_EXIT=$? + # An index that cannot be read is never "no conflicts". + if ! _UNMERGED=$(git -C "$GSTACK_DIR" ls-files -u 2>/dev/null); then + echo "cannot read the index of $GSTACK_DIR; nothing was moved" > "${PULL_ERR_FILE:-/dev/null}" + PULL_EXIT=1 + elif [ -n "$_UNMERGED" ]; then + # Someone's own merge, rebase or stash pop (or an older updater's + # conflicting autostash) left conflicts here. A background update never + # buries them; the banner gives the command that rescues them first. + HELD="unmerged-files: $(printf '%s\n' "$_UNMERGED" | cut -f2- | sort -u | wc -l | tr -d ' ') unmerged file(s) in the install (first: $(printf '%s\n' "$_UNMERGED" | head -1 | cut -f2-))" + else + # gstack-ff-update moves the tree to the revision the checks passed: + # conflicting edits are shelved in a rescue ref, never left as markers. + FF_OUT=$(bash "$FF_UPDATE" "$GSTACK_DIR" "$INCOMING" 2>/dev/null) + PULL_EXIT=$? + case "$PULL_EXIT:$FF_OUT" in + 0:rescued\ *) + set -- $FF_OUT + log_entry "AUTOSTASH_CONFLICT_RECOVERED tree_reset=1 kept_stash=${4##*/} kept_ref=$4 $5" + rescue_note "$4" "${5#unmerged=}" ;; + 0:*) ;; + 3:*) HELD=$FF_OUT PULL_EXIT=0 ;; + 75:*) log_entry "SKIP ff_busy $(ff_flatten "$FF_OUT" 200)"; rm -f "$PULL_ERR_FILE" 2>/dev/null; exit 0 ;; + *) printf '%s\n' "${FF_OUT:-bin/gstack-ff-update exited $PULL_EXIT}" > "${PULL_ERR_FILE:-/dev/null}" ;; + esac + fi fi NEW_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null) @@ -312,6 +436,7 @@ fi case "$P_NEXT" in ''|*[!0-9]*) P_NEXT=0 ;; esac if [ -n "$HELD" ]; then + HELD=$(ff_flatten "$HELD" 300) log_entry "HELD $HELD incoming=$INCOMING" P_PULL="$HELD" pending_save @@ -319,29 +444,25 @@ fi exit 0 fi if [ "$PULL_EXIT" -ne 0 ]; then - PULL_REASON=$(head -c 300 "$PULL_ERR_FILE" 2>/dev/null | tr '\n' ' ' | tr -s ' ') + PULL_REASON=$(ff_flatten "$(head -c 300 "$PULL_ERR_FILE" 2>/dev/null)" 300 | tr -s ' ') log_entry "PULL_FAILED exit=$PULL_EXIT reason=${PULL_REASON:-unknown}" P_PULL="exit $PULL_EXIT: $(printf '%s' "${PULL_REASON:-no error output}" | head -c 160)" - pending_save - # Autostash pop conflict leaves the stash behind and the tree half-merged. - # The local patches are REGENERABLE (prefix renames, gbrain blocks), so - # recover to a clean upstream tree and re-render them below rather than - # leaving conflict markers in a live install. - if grep -qi "autostash" "$PULL_ERR_FILE" 2>/dev/null; then - git -C "$GSTACK_DIR" checkout -q -- . 2>/dev/null - git -C "$GSTACK_DIR" stash drop -q 2>/dev/null - log_entry "AUTOSTASH_CONFLICT_RECOVERED tree_reset=1" - _PREFIX_CFG=$("$GSTACK_DIR/bin/gstack-config" get skill_prefix 2>/dev/null || echo false) - "$GSTACK_DIR/bin/gstack-patch-names" "$GSTACK_DIR" "$_PREFIX_CFG" >/dev/null 2>&1 || true # best-effort: regenerable prefix rewrite - "$GSTACK_DIR/bin/gstack-config" gbrain-refresh >/dev/null 2>&1 || true # best-effort: regenerable gbrain blocks + if [ -n "$NEW_HEAD" ] && [ "$NEW_HEAD" != "$OLD_HEAD" ]; then + # The checkout moved but setup did not run. Owe it, or once the person + # resolves the conflict the next check sees nothing new and never sets + # up this revision. The unmerged-files check holds it until then. + [ -n "$P_FROM" ] || P_FROM=$(git -C "$GSTACK_DIR" show "$OLD_HEAD:VERSION" 2>/dev/null || echo "unknown") + P_STAGES="${P_STAGES:-setup}" P_NEXT=0 P_REASON="$P_PULL" + log_entry "PENDING stages=$P_STAGES after a moved checkout" fi + pending_save rm -f "$PULL_ERR_FILE" 2>/dev/null exit 0 fi rm -f "$PULL_ERR_FILE" 2>/dev/null # Re-render local patches over the fresh tree (both tools are idempotent - # no-ops when the feature is unconfigured); the autostash pop usually - # preserves them, but a clean re-render costs nothing and self-heals. + # no-ops when the feature is unconfigured); gstack-ff-update usually + # carries them, but a clean re-render costs nothing and self-heals. _PREFIX_CFG=$("$GSTACK_DIR/bin/gstack-config" get skill_prefix 2>/dev/null || echo false) "$GSTACK_DIR/bin/gstack-patch-names" "$GSTACK_DIR" "$_PREFIX_CFG" >/dev/null 2>&1 || true # best-effort: regenerable prefix rewrite "$GSTACK_DIR/bin/gstack-config" gbrain-refresh >/dev/null 2>&1 || true # best-effort: regenerable gbrain blocks diff --git a/bin/gstack-skill-end b/bin/gstack-skill-end index 797a268af..dc0d10587 100755 --- a/bin/gstack-skill-end +++ b/bin/gstack-skill-end @@ -54,15 +54,17 @@ case "$TEL_START" in ''|*[!0-9]*) TEL_START="$_TEL_END" ;; esac _TEL_DUR=$(( _TEL_END - TEL_START )) [ -n "$SESSION_ID" ] && rm -f "$_GH/analytics/.pending-$SESSION_ID" 2>/dev/null || true +# Record completion before the final drain, including its durable enqueue. +# The normal sync gates still decide publication: full may sync timelines, +# artifacts-only retains them locally, and unattended never enqueues or drains. +"$_BIN/gstack-timeline-log" '{"skill":"'"$SKILL_NAME"'","event":"completed","branch":"'"$(_b=$(git branch --show-current 2>/dev/null | tr -cd 'a-zA-Z0-9._/-'); echo "${_b:-unknown}")"'","outcome":"'"$OUTCOME"'","duration_s":"'"$_TEL_DUR"'","session":"'"$SESSION_ID"'"}' 2>/dev/null || true + # Skill-end artifacts sync (drain + push; formerly its own inline fence). if [ "$_SESSION_KIND" != "unattended" ]; then "$_BIN/gstack-brain-sync" --discover-new 2>/dev/null || true "$_BIN/gstack-brain-sync" --once 2>/dev/null || true fi -# Session timeline: local-only, never sent anywhere. -"$_BIN/gstack-timeline-log" '{"skill":"'"$SKILL_NAME"'","event":"completed","branch":"'"$(_b=$(git branch --show-current 2>/dev/null | tr -cd 'a-zA-Z0-9._/-'); echo "${_b:-unknown}")"'","outcome":"'"$OUTCOME"'","duration_s":"'"$_TEL_DUR"'","session":"'"$SESSION_ID"'"}' 2>/dev/null || true - # Local analytics (gated on telemetry setting). if [ "$_TEL" != "off" ]; then echo '{"skill":"'"$SKILL_NAME"'","duration_s":"'"$_TEL_DUR"'","outcome":"'"$OUTCOME"'","browse":"'"$USED_BROWSE"'","session":"'"$SESSION_ID"'","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' >> "$_GH/analytics/skill-usage.jsonl" 2>/dev/null || true diff --git a/bin/gstack-timeline-log b/bin/gstack-timeline-log index 0f24f8a23..9bd6bc391 100755 --- a/bin/gstack-timeline-log +++ b/bin/gstack-timeline-log @@ -38,5 +38,8 @@ fi echo "$INPUT" >> "$GSTACK_HOME/projects/$SLUG/timeline.jsonl" -# gbrain-sync: enqueue for cross-machine sync (no-op if sync is off). -"$SCRIPT_DIR/gstack-brain-enqueue" "projects/$SLUG/timeline.jsonl" 2>/dev/null & +# gbrain-sync: enqueue for cross-machine sync (no-op if sync is off). Finish +# the enqueue before returning: gstack-skill-end drains right after this +# logger, and a backgrounded enqueue can let that drain miss the new event. +# The helper keeps its off/unattended gates and silent-failure behavior. +"$SCRIPT_DIR/gstack-brain-enqueue" "projects/$SLUG/timeline.jsonl" 2>/dev/null || true diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 711fb6c03..338c6dd6b 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -27,6 +27,8 @@ import { ensureProjectProfile } from './chromium-profiles'; import { launchWithXProtectHeal } from './xprotect-heal'; import { readPidStartTime, shouldSpawnXvfb, spawnFreeXvfb, xvfbInstallHint, type XvfbHandle } from './xvfb'; import { withCdpSession } from './cdp-bridge'; +import { resultLine } from '../../lib/result-codes'; +import { sanitizeEcho } from '../../lib/sanitize-echo'; import type { MemorySnapshot, MemoryStructureStats, MemoryTabSnapshot, MemoryProcess } from './memory-snapshot'; /** @@ -406,6 +408,7 @@ export class BrowserManager { private xvfb: XvfbHandle | null = null; private displayAllocation: Promise | null = null; private handoffPending: Promise | null = null; + private activeTabRecovery: Promise | null = null; private closing = false; private handoffPrevious: { browser: Browser; pages: Map; tabSessions: Map; activeTabId: number } | null = null; @@ -1312,6 +1315,48 @@ export class BrowserManager { } // ─── Session Access ──────────────────────────────────────── + /** + * Restore an active tab when every page was closed outside the CLI (for + * example, the user closed the headed window). The page 'close' handler is + * synchronous and cannot open a replacement, which left activeTabId at 0 and + * every later command, including status and stop, failing with "No active + * page". closeTab() already opens a blank tab for the last-tab case; this + * covers the external path. Concurrent callers share one recovery. Returns + * true when a new tab had to be opened. + */ + async ensureActiveTab(): Promise { + if (this.tabSessions.has(this.activeTabId)) return false; + const remaining = [...this.tabSessions.keys()]; + if (remaining.length > 0) { + this.activeTabId = remaining[remaining.length - 1]!; + return false; + } + if (!this.context) throw new Error('Browser not launched'); + this.activeTabRecovery ??= this.newTab() + .then(() => {}) + .finally(() => { this.activeTabRecovery = null; }); + await this.activeTabRecovery; + return true; + } + + /** + * Called before every command runs (#3167). status, stop and restart never + * open a tab, so a daemon whose browser or context is gone can still report + * and be stopped. Any other command gets an active tab: a failed recovery is + * BROWSE_TAB_UNRECOVERABLE with the restart command, and a command that runs + * on a newly opened tab gets the returned one-line warning. + */ + async ensureCommandTab(command: string): Promise { + if (BrowserManager.NO_TAB_RECOVERY.has(command)) return undefined; + try { + if (!(await this.ensureActiveTab())) return undefined; + } catch (err: any) { + throw new Error(resultLine('browse', 'BROWSE_TAB_UNRECOVERABLE', sanitizeEcho(err?.message ?? String(err)))); + } + return '[browse] warning: every tab was closed outside browse, so this command ran on a new blank tab; open your page again with: browse goto '; + } + private static readonly NO_TAB_RECOVERY = new Set(['status', 'stop', 'restart']); + /** Get the TabSession for the active tab. */ getActiveSession(): TabSession { const session = this.tabSessions.get(this.activeTabId); diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index b59247504..8cc9e2e6c 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -258,9 +258,8 @@ export async function handleMetaCommand( tokenInfo?: TokenInfo | null, opts?: MetaCommandOpts, ): Promise { - // Per-tab operations use the active session; global operations use bm directly - const session = bm.getActiveSession(); - + // Per-tab operations read the active session where they run, so status, + // stop and restart still answer when every tab was closed (#3167). switch (command) { // ─── Tabs ────────────────────────────────────────── case 'tabs': { @@ -403,13 +402,12 @@ export async function handleMetaCommand( // ─── Server Control ──────────────────────────────── case 'status': { - const page = bm.getPage(); const tabs = bm.getTabCount(); const mode = bm.getConnectionMode(); return [ `Status: healthy`, `Mode: ${mode}`, - `URL: ${page.url()}`, + `URL: ${bm.getCurrentUrl()}`, `Tabs: ${tabs}`, `PID: ${process.pid}`, ].join('\n'); @@ -720,7 +718,7 @@ export async function handleMetaCommand( // ─── Snapshot ───────────────────────────────────── case 'snapshot': { const isScoped = tokenInfo && tokenInfo.clientId !== 'root'; - const snapshotResult = await handleSnapshot(args, session, { + const snapshotResult = await handleSnapshot(args, bm.getActiveSession(), { splitForScoped: !!isScoped, }); // Scoped tokens get split format (refs outside envelope); root gets basic wrapping @@ -737,6 +735,7 @@ export async function handleMetaCommand( } case 'resume': { + const session = bm.getActiveSession(); bm.resume(); // Re-snapshot to capture current page state after human interaction const isScoped2 = tokenInfo && tokenInfo.clientId !== 'root'; diff --git a/browse/src/server.ts b/browse/src/server.ts index 048eb37af..3bcaeab35 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -739,6 +739,7 @@ export const __testInternals__ = { // need shutdown to fire. Without this, the second test's shutdown // returns early at the `if (isShuttingDown) return;` guard. resetShutdownState: () => { isShuttingDown = false; }, + get browserManager() { return browserManager; }, handleCommandInternal, // tab-external-close.test.ts drives real dispatch }; // ─── Parent-Process Watchdog ──────────────────────────────────────── @@ -1120,7 +1121,7 @@ async function handleCommandInternalImpl( try { let result: string; - const session = browserManager.getActiveSession(); + const tabWarning = await browserManager.ensureCommandTab(command); // Per-request warnings collected during hidden-element detection, // surfaced into the envelope the LLM sees. Carries across the read @@ -1128,6 +1129,7 @@ async function handleCommandInternalImpl( let hiddenContentWarnings: string[] = []; if (READ_COMMANDS.has(command)) { + const session = browserManager.getActiveSession(); const isScoped = tokenInfo && tokenInfo.clientId !== 'root'; // Hidden-element / ARIA-injection detection for every scoped // DOM-reading channel (text, html, links, forms, accessibility, @@ -1162,6 +1164,7 @@ async function handleCommandInternalImpl( result = await handleReadCommand(command, args, session, browserManager); } } else if (WRITE_COMMANDS.has(command)) { + const session = browserManager.getActiveSession(); result = await browserManager.failIfNavigationBlocked(session.getPage(), handleWriteCommand(command, args, session, browserManager)); } else if (META_COMMANDS.has(command)) { // Pass chain depth + executeCommand callback so chain routes subcommands @@ -1280,7 +1283,7 @@ async function handleCommandInternalImpl( console.warn('[browse] Failed to restore tab after command:', restoreErr.message); } } - return { status: 200, result }; + return { status: 200, result: tabWarning ? `${tabWarning}\n${result}` : result }; } catch (err: any) { // Restore original active tab even on error if (savedTabId !== null) { diff --git a/browse/src/terminal-agent.ts b/browse/src/terminal-agent.ts index 7ce237a3e..430ff56f2 100644 --- a/browse/src/terminal-agent.ts +++ b/browse/src/terminal-agent.ts @@ -31,7 +31,7 @@ import { findAvailablePort } from './port-allocator'; import { extractPtyCookie } from './pty-session-cookie'; import { allowedExtensionOrigin } from './extension-id'; import { - createPtyLifecycle, disposePtyProcess, ptyCompletionReason, + createPtyLifecycle, disposePtyProcess, ptyCompletionReason, PTY_SHUTDOWN_GRACE_MS, type PtyCompletion, type PtyLifecycle, } from './terminal-pty-lifecycle'; @@ -85,6 +85,8 @@ const sessionsById = new Map(); // the listener loop. export interface PtySession { proc: any | null; // Bun.Subprocess once spawned + /** Spawn-time identity of the PTY's process group; outlives the leader so shutdown can reap HUP-immune descendants. */ + group?: PtyGroupIdentity | null; lifecycle?: PtyLifecycle | null; completion?: PtyCompletion; disposed?: boolean; @@ -237,6 +239,64 @@ export function buildReplayPayload(session: PtySession): Buffer { } const sessions = new WeakMap(); // ws -> session +// Strong ownership index for shutdown. sessions is intentionally weak and +// sessionsById excludes legacy grants, so neither can enumerate every PTY +// child when the terminal-agent is replaced or its owner disappears. +const activeSessions = new Set(); +// Set once shutdown starts: no new connection or PTY spawn during the grace. +let shuttingDown = false; +// Groups of disposed sessions whose delayed reap has not run yet; shutdown reaps them too. +const pendingGroupReaps = new Set(); + +/** + * A PTY child is its own session and process-group leader (pgid = sid = + * pid). That identity, with the leader's start time, stays checkable after + * the leader exits, so descendants that ignore SIGHUP can still be found. + */ +export interface PtyGroupIdentity { pgid: number; sid: number; startTime: number } +interface ProcStat { state: string; pgrp: number; session: number; startTime: number } + +/** Linux /proc//stat fields after the parenthesised comm; null when absent (or not Linux). */ +export function readProcStat(pid: number, procRoot = '/proc'): ProcStat | null { + let raw: string; + try { raw = fs.readFileSync(path.join(procRoot, String(pid), 'stat'), 'utf8'); } catch { return null; } + const f = raw.slice(raw.lastIndexOf(')') + 2).split(' '); + const stat = { state: f[0] ?? '', pgrp: Number(f[2]), session: Number(f[3]), startTime: Number(f[19]) }; + return Number.isSafeInteger(stat.pgrp) && Number.isSafeInteger(stat.session) && Number.isSafeInteger(stat.startTime) ? stat : null; +} + +/** The group identity of a freshly spawned PTY child, or null unless it provably leads its own session. */ +export function ptyGroupIdentity(pid: number, procRoot = '/proc'): PtyGroupIdentity | null { + const stat = readProcStat(pid, procRoot); + if (!stat || stat.pgrp !== pid || stat.session !== pid) return null; + return { pgid: pid, sid: pid, startTime: stat.startTime }; +} + +/** + * Live processes proven to belong to the group: same pgid and sid, started + * no earlier than its leader. A live process holding the leader's PID with + * another start time means the number was reused, so nothing is ours. + */ +export function ownedGroupMembers(id: PtyGroupIdentity, procRoot = '/proc'): number[] { + const leader = readProcStat(id.pgid, procRoot); + if (leader && leader.startTime !== id.startTime) return []; + let entries: string[]; + try { entries = fs.readdirSync(procRoot); } catch { return []; } + const members: number[] = []; + for (const name of entries) { + if (!/^\d+$/.test(name)) continue; + const stat = readProcStat(Number(name), procRoot); + if (stat && stat.state !== 'Z' && stat.pgrp === id.pgid && stat.session === id.sid && stat.startTime >= id.startTime) members.push(Number(name)); + } + return members; +} + +/** SIGKILL every proven member of the group; returns how many were signalled. */ +export function reapOwnedGroup(id: PtyGroupIdentity, procRoot = '/proc', kill: (pid: number, signal: string) => void = process.kill): number { + const members = ownedGroupMembers(id, procRoot); + for (const pid of members) { try { kill(pid, 'SIGKILL'); } catch {} } + return members.length; +} /** Find claude on PATH. */ function findClaude(): string | null { @@ -365,6 +425,8 @@ function spawnClaude(cols: number, rows: number, lifecycle: PtyLifecycle) { /** Cleanup a PTY session: SIGINT, then SIGKILL after 3s. */ function disposeSession(session: PtySession): void { + if (session.disposed) return; + activeSessions.delete(session); // Suppress callbacks caused by explicit close, and cancel any drain deadline. session.disposed = true; session.lifecycle?.dispose(); @@ -373,6 +435,12 @@ function disposeSession(session: PtySession): void { session.proc = null; session.spawned = false; disposePtyProcess(proc); + // Unlike the direct-child fallback, the group reap is not cancelled when + // the leader exits: a descendant that ignores SIGHUP outlives it. + const group = session.group; + if (!group) return; + pendingGroupReaps.add(group); + setTimeout(() => { pendingGroupReaps.delete(group); reapOwnedGroup(group); }, PTY_SHUTDOWN_GRACE_MS).unref?.(); } function sendPtyCompletion(session: PtySession): void { @@ -456,7 +524,7 @@ async function internalHandler( * surfaced the error to the client (or will via the next frame). */ function maybeSpawnPty(ws: any, session: PtySession): boolean { - if (session.disposed || session.completion) return false; + if (shuttingDown || session.disposed || session.completion) return false; if (session.spawned) return true; session.spawned = true; let leftover = Buffer.alloc(0); @@ -510,6 +578,7 @@ function maybeSpawnPty(ws: any, session: PtySession): boolean { return false; } session.proc = proc; + session.group = ptyGroupIdentity(proc.pid); proc.exited.then( (code: number) => lifecycle.exited(code, proc.signalCode ?? null), () => lifecycle.exited(null, proc.signalCode ?? null, true), @@ -534,6 +603,7 @@ function buildServer(port: number) { idleTimeout: 0, // PTY connections are long-lived; default idleTimeout would kill them fetch(req, server) { + if (shuttingDown) return new Response('shutting down', { status: 503 }); const url = new URL(req.url); // /internal/grant — loopback-only handshake from parent server. @@ -748,6 +818,7 @@ function buildServer(port: number) { detached: false, detachTimer: null, }; + activeSessions.add(session); session.pingInterval = setInterval(() => { try { ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })); @@ -782,6 +853,7 @@ function buildServer(port: number) { detached: false, detachTimer: null, }; + activeSessions.add(session); sessions.set(ws, session); if (session.sessionId) sessionsById.set(session.sessionId, session); } @@ -1070,9 +1142,20 @@ async function main() { // Cleanup port file + agent record on exit. let cleaningUp = false; - const cleanup = () => { + const cleanup = async () => { if (cleaningUp) return; cleaningUp = true; + shuttingDown = true; + // disposePtyProcess sends SIGINT and schedules the direct child's SIGKILL + // fallback. Each PTY's process group, captured at spawn, is reaped after + // the same grace even when its leader exits first, so descendants that + // ignore SIGHUP do not outlive the agent. Awaiting here keeps both alive + // instead of process.exit(0) abandoning them. + const owned = [...activeSessions]; + const childExits = owned.map(session => session.proc?.exited).filter((exited): exited is Promise => !!exited); + for (const session of owned) disposeSession(session); + const groups = [...pendingGroupReaps]; + sessionsById.clear(); try { const releaseCleanup = acquireAgentStateLock(dir, 25); try { @@ -1083,10 +1166,16 @@ async function main() { } } finally { releaseCleanup(); } } catch {} + let childrenExited = childExits.length === 0; + void Promise.allSettled(childExits).then(() => { childrenExited = true; }); + const deadline = Date.now() + PTY_SHUTDOWN_GRACE_MS; + while (Date.now() < deadline && !(childrenExited && groups.every(group => ownedGroupMembers(group).length === 0))) await Bun.sleep(100); + for (const group of groups) reapOwnedGroup(group); + if (!childrenExited) await Promise.race([Promise.allSettled(childExits), Bun.sleep(250)]); process.exit(0); }; - process.on('SIGTERM', cleanup); - process.on('SIGINT', cleanup); + process.on('SIGTERM', () => { void cleanup(); }); + process.on('SIGINT', () => { void cleanup(); }); // The terminal agent is intentionally detached so it survives the short-lived // CLI launcher, but its real owner is the persistent browse server. If that @@ -1096,7 +1185,7 @@ async function main() { if (BROWSE_OWNER_PID > 0) { const ownerWatchdog = setInterval(() => { if (!BROWSE_OWNER_START_TIME || readAgentStartTime(BROWSE_OWNER_PID) !== BROWSE_OWNER_START_TIME - || readAgentRecord(dir)?.gen !== CURRENT_GEN) cleanup(); + || readAgentRecord(dir)?.gen !== CURRENT_GEN) void cleanup(); }, OWNER_WATCHDOG_MS); (ownerWatchdog as any)?.unref?.(); } diff --git a/browse/test/tab-external-close-dispatch.test.ts b/browse/test/tab-external-close-dispatch.test.ts new file mode 100644 index 000000000..62d9e61ef --- /dev/null +++ b/browse/test/tab-external-close-dispatch.test.ts @@ -0,0 +1,107 @@ +/** + * #3167 through the real command dispatch (server.ts handleCommandInternal on + * the module's own BrowserManager): after every tab is closed outside the CLI, + * status and stop answer without opening a tab, other commands run on a new + * tab and say so in one line, and a browser whose context is gone fails with + * BROWSE_TAB_UNRECOVERABLE and the restart command while status and stop keep + * working. Runs in a child process so `stop` and the module-level server state + * never touch this test process. + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +let dir = ''; +let steps: Record = {}; + +const SCRIPT = (root: string) => ` +import { __testInternals__ } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/server.ts'))}; +import { startTestServer } from ${JSON.stringify(path.resolve(import.meta.dir, 'test-server.ts'))}; +const fixture = startTestServer(0); +const bm = __testInternals__.browserManager; +const run = (command, args = []) => __testInternals__.handleCommandInternal({ command, args }, null); +const closeEveryTab = async () => { + while (bm.getTabCount() > 0) { + const page = bm.getPage(); + const closed = new Promise(resolve => page.once('close', resolve)); + await page.close(); + await closed; + } +}; +const steps = {}; +const record = async (name, command, args) => { const r = await run(command, args); steps[name] = { status: r.status, result: r.result, tabs: bm.getTabCount() }; }; +await bm.launch(); +await record('goto-first', 'goto', [fixture.url + '/basic.html']); +await closeEveryTab(); +await record('status', 'status'); +await record('text', 'text'); +await record('text-again', 'text'); +await closeEveryTab(); +await record('goto', 'goto', [fixture.url + '/basic.html']); +await closeEveryTab(); +await record('stop', 'stop'); +await record('restart-check', 'tabs'); +await bm.context.close(); +await record('closed-text', 'text'); +await record('closed-status', 'status'); +await record('closed-stop', 'stop'); +await Bun.write(${JSON.stringify(path.join(root, 'steps.json'))}, JSON.stringify(steps)); +fixture.server.stop(true); +await Promise.race([bm.close(), Bun.sleep(3000)]); +process.exit(0); +`; + +beforeAll(() => { + dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'browse-external-close-'))); + fs.writeFileSync(path.join(dir, 'drive.ts'), SCRIPT(dir)); + const r = spawnSync(process.execPath, [path.join(dir, 'drive.ts')], { + encoding: 'utf8', timeout: 90_000, + env: { ...process.env, GSTACK_HOME: path.join(dir, 'state'), CHROMIUM_PROFILE: path.join(dir, 'profile'), + BROWSE_STATE_FILE: path.join(dir, 'state', 'browse.json'), BROWSE_PARENT_PID: '0' }, + }); + if (!fs.existsSync(path.join(dir, 'steps.json'))) throw new Error(`driver failed (${r.status}): ${r.stderr.slice(-2000)}`); + steps = JSON.parse(fs.readFileSync(path.join(dir, 'steps.json'), 'utf8')); +}, 120_000); +afterAll(() => { if (dir) fs.rmSync(dir, { recursive: true, force: true }); }); + +const WARNING = '[browse] warning: every tab was closed outside browse, so this command ran on a new blank tab; open your page again with: browse goto '; + +describe('external close of the last tab, through command dispatch (#3167)', () => { + test('status answers without opening a tab', () => { + expect(steps.status).toMatchObject({ status: 200, tabs: 0 }); + expect(steps.status.result).toContain('Status: healthy'); + expect(steps.status.result).toContain('Tabs: 0'); + }); + + test('a text read after recovery runs on a new tab and shows the warning once', () => { + expect(steps.text.status).toBe(200); + expect(steps.text.tabs).toBe(1); + expect(steps.text.result.split('\n')[0]).toBe(WARNING); + expect(steps['text-again'].status).toBe(200); + expect(steps['text-again'].result).not.toContain(WARNING); + }); + + test('goto after an external close navigates a new tab', () => { + expect(steps.goto).toMatchObject({ status: 200, tabs: 1 }); + expect(steps.goto.result).toStartWith(`${WARNING}\n`); + expect(steps.goto.result).toContain('basic.html'); + }); + + test('stop answers without opening a tab', () => { + expect(steps.stop).toMatchObject({ status: 200, result: 'Server stopped', tabs: 0 }); + }); + + test('a closed context fails with the named error and restart command; status and stop still answer', () => { + expect(steps['closed-text'].status).toBe(500); + const error = JSON.parse(steps['closed-text'].result).error as string; + expect(error).toContain('browse: every tab was closed outside browse and a new one could not be opened'); + expect(error).toContain('fix: run `browse restart`'); + expect(error).toContain('(BROWSE_TAB_UNRECOVERABLE)'); + expect(error).not.toContain('\n['); + expect(steps['closed-status']).toMatchObject({ status: 200, tabs: 0 }); + expect(steps['closed-status'].result).toContain('Status: healthy'); + expect(steps['closed-stop']).toMatchObject({ status: 200, result: 'Server stopped' }); + }); +}); diff --git a/browse/test/tab-external-close.test.ts b/browse/test/tab-external-close.test.ts new file mode 100644 index 000000000..d84b3fba4 --- /dev/null +++ b/browse/test/tab-external-close.test.ts @@ -0,0 +1,80 @@ +/** + * Regression (#3167): closing the last tab outside the CLI (for example, the + * user closing the headed Chromium window) must not wedge the daemon. + * + * The page 'close' handler removes the tab and leaves activeTabId at 0, so + * getActiveSession() threw "No active page" for every later command, + * including status and stop. closeTab() already opens a blank tab for the + * last-tab case; ensureActiveTab() restores one for the external path before + * each command runs. + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { BrowserManager } from '../src/browser-manager'; + +// Per-file Chromium profile, scoped via hooks (see commands.test.ts). +const ORIGINAL_CHROMIUM_PROFILE = process.env.CHROMIUM_PROFILE; +let profileDir: string | undefined; +let bm: BrowserManager; + +beforeAll(async () => { + profileDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-test-profile-')); + process.env.CHROMIUM_PROFILE = profileDir; + bm = new BrowserManager(); + await bm.launch(); +}); + +afterAll(async () => { + await bm?.close(); + if (ORIGINAL_CHROMIUM_PROFILE === undefined) delete process.env.CHROMIUM_PROFILE; + else process.env.CHROMIUM_PROFILE = ORIGINAL_CHROMIUM_PROFILE; + if (profileDir) { + try { fs.rmSync(profileDir, { recursive: true, force: true }); } catch {} + } +}); + +const closeEveryTabExternally = async () => { + // Close the Playwright pages directly, bypassing closeTab(), the way a + // user closing the window does. + while (bm.getTabCount() > 0) { + const page = bm.getPage(); + const closed = new Promise((resolve) => page.once('close', () => resolve())); + await page.close(); + await closed; + } +}; + +describe('external close of the last tab', () => { + test('leaves no active session until a tab is restored', async () => { + await bm.ensureActiveTab(); + await closeEveryTabExternally(); + + expect(bm.getTabCount()).toBe(0); + expect(() => bm.getActiveSession()).toThrow('No active page'); + + await bm.ensureActiveTab(); + + expect(bm.getTabCount()).toBe(1); + expect(bm.getActiveSession().page.isClosed()).toBe(false); + }); + + test('concurrent commands restore exactly one tab', async () => { + await closeEveryTabExternally(); + + await Promise.all([bm.ensureActiveTab(), bm.ensureActiveTab(), bm.ensureActiveTab()]); + + expect(bm.getTabCount()).toBe(1); + }); + + test('keeps the existing active tab when one is open', async () => { + await bm.ensureActiveTab(); + const before = bm.getActiveSession(); + + await bm.ensureActiveTab(); + + expect(bm.getActiveSession()).toBe(before); + expect(bm.getTabCount()).toBe(1); + }); +}); diff --git a/browse/test/terminal-agent-signal-cleanup.test.ts b/browse/test/terminal-agent-signal-cleanup.test.ts new file mode 100644 index 000000000..d7ecdeaa3 --- /dev/null +++ b/browse/test/terminal-agent-signal-cleanup.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { GSTACK_EXTENSION_ID } from '../src/extension-id'; +import { ownedGroupMembers, ptyGroupIdentity, reapOwnedGroup, type PtyGroupIdentity } from '../src/terminal-agent'; +import { PTY_SHUTDOWN_GRACE_MS } from '../src/terminal-pty-lifecycle'; + +const AGENT_SCRIPT = path.join(import.meta.dir, '../src/terminal-agent.ts'); +const EXT_ORIGIN = `chrome-extension://${GSTACK_EXTENSION_ID}`; +const linux = process.platform === 'linux'; +// The agent's graceful shutdown is its SIGTERM/SIGINT handler (terminal-agent.ts +// cleanup). On Windows, Bun's subprocess.kill('SIGTERM') is TerminateProcess, so +// that handler never runs, and the PTY fixture is a bash shebang script that +// CreateProcess cannot launch: the signal-driven tests are POSIX-scoped. +const posixSignals = process.platform !== 'win32'; + +function waitForFile(file: string, timeoutMs = 5000): string { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const value = fs.readFileSync(file, 'utf8').trim(); + if (value) return value; + } catch {} + Bun.sleepSync(25); + } + throw new Error(`timed out waiting for ${path.basename(file)}`); +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + } catch (err: any) { + return err?.code === 'EPERM'; + } + // An orphan that was killed can linger as a zombie until its reaper runs. + try { return !/^\d+ \(.*\) Z /s.test(fs.readFileSync(`/proc/${pid}/stat`, 'utf8')); } catch { return true; } +} + +/** Start a real terminal-agent whose PTY runs `script`, open one session, and return the pids the script recorded. */ +async function withAgent(script: string[], pidFiles: string[], body: (ctx: { agent: any; pids: number[]; stateDir: string }) => Promise) { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-term-signal-')); + const stateFile = path.join(stateDir, 'browse.json'); + const terminalCli = path.join(stateDir, 'terminal-cli'); + let agent: any; + let ws: WebSocket | null = null; + const pids: number[] = []; + try { + fs.writeFileSync(stateFile, JSON.stringify({ token: 'test-browse-token' })); + fs.writeFileSync(terminalCli, ['#!/bin/bash', 'shift 2', ...script, ''].join('\n')); + fs.chmodSync(terminalCli, 0o755); + agent = Bun.spawn([process.execPath, 'run', AGENT_SCRIPT], { + env: { + ...process.env, + BROWSE_STATE_FILE: stateFile, + BROWSE_SERVER_PORT: '0', + BROWSE_TERMINAL_BINARY: terminalCli, + GSTACK_STATE_ROOT: stateDir, + GSTACK_TEST_PID_DIR: stateDir, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const port = Number(waitForFile(path.join(stateDir, 'terminal-port'))); + const internalToken = waitForFile(path.join(stateDir, 'terminal-internal-token')); + const attachToken = 'signal-cleanup-token-at-least-seventeen'; + const grant = await fetch(`http://127.0.0.1:${port}/internal/grant`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${internalToken}` }, + body: JSON.stringify({ token: attachToken, sessionId: 'signal-cleanup-session' }), + }); + expect(grant.status).toBe(200); + ws = new WebSocket(`ws://127.0.0.1:${port}/ws`, { headers: { Origin: EXT_ORIGIN, Cookie: `gstack_pty=${attachToken}` } } as any); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('websocket did not open')), 5000); + ws!.addEventListener('open', () => { clearTimeout(timer); resolve(); }); + ws!.addEventListener('error', () => { clearTimeout(timer); reject(new Error('websocket failed')); }); + }); + ws.send(new Uint8Array([10])); + for (const name of pidFiles) pids.push(Number(waitForFile(path.join(stateDir, name)))); + await body({ agent, pids, stateDir }); + } finally { + try { ws?.close(); } catch {} + try { agent?.kill?.('SIGKILL'); } catch {} + for (const pid of pids) if (isAlive(pid)) { try { process.kill(pid, 'SIGKILL'); } catch {} } + try { fs.rmSync(stateDir, { recursive: true, force: true }); } catch {} + } +} + +async function terminateAndWait(agent: any): Promise { + const started = Date.now(); + agent.kill('SIGTERM'); + await Promise.race([ + agent.exited, + Bun.sleep(PTY_SHUTDOWN_GRACE_MS + 3000).then(() => { throw new Error('terminal-agent did not exit'); }), + ]); + return started; +} + +/** Poll until every pid is dead or the deadline passes. */ +async function allDeadBy(pids: number[], deadline: number): Promise { + while (Date.now() < deadline) { + if (pids.every(pid => !isAlive(pid))) return true; + await Bun.sleep(50); + } + return pids.every(pid => !isAlive(pid)); +} + +const HUP_IMMUNE_GRANDCHILD = `( trap '' INT TERM HUP; echo "$BASHPID" > "$GSTACK_TEST_PID_DIR/grandchild.pid"; while :; do sleep 1; done ) &`; + +describe('terminal-agent shutdown reaps its PTY process groups (#3147)', () => { + test.skipIf(!posixSignals)('SIGTERM reaps every active PTY child before terminal-agent exits', async () => { + await withAgent([ + 'echo "$$" > "$GSTACK_TEST_PID_DIR/pty-child.pid"', + "trap '' INT TERM HUP", + 'while :; do sleep 1; done', + ], ['pty-child.pid'], async ({ agent, pids }) => { + expect(isAlive(pids[0]!)).toBe(true); + await terminateAndWait(agent); + expect(isAlive(pids[0]!)).toBe(false); + }); + }, 15_000); + + test.skipIf(!linux)('a HUP-immune grandchild is dead within the grace period plus one second', async () => { + await withAgent([ + 'echo "$$" > "$GSTACK_TEST_PID_DIR/pty-child.pid"', + HUP_IMMUNE_GRANDCHILD, + "trap '' INT TERM HUP", + 'while :; do sleep 1; done', + ], ['pty-child.pid', 'grandchild.pid'], async ({ agent, pids }) => { + expect(pids.every(isAlive)).toBe(true); + const started = await terminateAndWait(agent); + expect(await allDeadBy(pids, started + PTY_SHUTDOWN_GRACE_MS + 1000)).toBe(true); + }); + }, 15_000); + + test.skipIf(!linux)('a descendant that survives its early-exiting leader is still reaped at shutdown', async () => { + await withAgent([ + HUP_IMMUNE_GRANDCHILD, + 'while [ ! -s "$GSTACK_TEST_PID_DIR/grandchild.pid" ]; do sleep 0.05; done', + 'echo "$$" > "$GSTACK_TEST_PID_DIR/pty-child.pid"', + 'exit 0', + ], ['pty-child.pid', 'grandchild.pid'], async ({ agent, pids }) => { + const [leader, grandchild] = pids as [number, number]; + expect(await allDeadBy([leader], Date.now() + 3000)).toBe(true); + expect(isAlive(grandchild)).toBe(true); + const started = await terminateAndWait(agent); + expect(await allDeadBy([grandchild], started + PTY_SHUTDOWN_GRACE_MS + 1000)).toBe(true); + }); + }, 20_000); + + test.skipIf(!linux)('a session that already completed before shutdown still has its group reaped', async () => { + await withAgent([ + HUP_IMMUNE_GRANDCHILD, + 'while [ ! -s "$GSTACK_TEST_PID_DIR/grandchild.pid" ]; do sleep 0.05; done', + 'exec 0<&- 1>&- 2>&-', + 'echo "$$" > "$GSTACK_TEST_PID_DIR/pty-child.pid"', + 'exit 0', + ], ['pty-child.pid', 'grandchild.pid'], async ({ agent, pids }) => { + const [leader, grandchild] = pids as [number, number]; + expect(await allDeadBy([leader], Date.now() + 3000)).toBe(true); + // Past the drain grace the session is complete and disposed; its delayed group reap is still pending. + await Bun.sleep(PTY_SHUTDOWN_GRACE_MS + 300); + expect(isAlive(grandchild)).toBe(true); + const started = await terminateAndWait(agent); + expect(await allDeadBy([grandchild], started + PTY_SHUTDOWN_GRACE_MS + 1000)).toBe(true); + }); + }, 25_000); +}); + +describe('PTY process-group ownership', () => { + /** A fake /proc: pid → [state, pgrp, session, starttime]. */ + function fakeProc(rows: Record): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-fake-proc-')); + for (const [pid, [state, pgrp, session, start]] of Object.entries(rows)) { + fs.mkdirSync(path.join(root, pid)); + const fields = [state, '1', String(pgrp), String(session), ...Array(15).fill('0'), String(start)]; + fs.writeFileSync(path.join(root, pid, 'stat'), `${pid} (a b) ${fields.join(' ')} 0 0\n`); + } + fs.mkdirSync(path.join(root, 'self')); + return root; + } + + test('identity requires a session and group leader', () => { + const proc = fakeProc({ 100: ['S', 100, 100, 5000], 200: ['S', 150, 150, 5000] }); + try { + expect(ptyGroupIdentity(100, proc)).toEqual({ pgid: 100, sid: 100, startTime: 5000 }); + expect(ptyGroupIdentity(200, proc)).toBeNull(); + expect(ptyGroupIdentity(300, proc)).toBeNull(); + } finally { fs.rmSync(proc, { recursive: true, force: true }); } + }); + + test('after the leader exits, only same-group same-session descendants are signalled; a foreign group never is', () => { + const id: PtyGroupIdentity = { pgid: 100, sid: 100, startTime: 5000 }; + const proc = fakeProc({ + 101: ['S', 100, 100, 5100], + 102: ['Z', 100, 100, 5200], + 103: ['S', 300, 300, 5300], + 104: ['S', 104, 100, 5400], + 105: ['S', 100, 999, 5500], + }); + const signalled: number[] = []; + try { + expect(reapOwnedGroup(id, proc, pid => { signalled.push(pid); })).toBe(1); + expect(signalled).toEqual([101]); + } finally { fs.rmSync(proc, { recursive: true, force: true }); } + }); + + test('a reused leader PID (another start time) means nothing is signalled', () => { + const id: PtyGroupIdentity = { pgid: 100, sid: 100, startTime: 5000 }; + const proc = fakeProc({ 100: ['S', 100, 100, 9000], 101: ['S', 100, 100, 9100] }); + const signalled: number[] = []; + try { + expect(ownedGroupMembers(id, proc)).toEqual([]); + expect(reapOwnedGroup(id, proc, pid => { signalled.push(pid); })).toBe(0); + expect(signalled).toEqual([]); + } finally { fs.rmSync(proc, { recursive: true, force: true }); } + }); +}); diff --git a/bun.lock b/bun.lock index c6398e812..db2f7ae8d 100644 --- a/bun.lock +++ b/bun.lock @@ -5,17 +5,18 @@ "": { "name": "gstack", "dependencies": { - "@huggingface/transformers": "^4.2.0", + "@huggingface/transformers": "^4.3.0", "@ngrok/ngrok": "^1.7.0", "cross-spawn": "^7.0.6", "diff": "^9.0.0", "html-to-docx": "1.8.0", "marked": "^18.0.11", "playwright": "^1.62.1", + "sharp": "^0.35.4", "socks": "^2.8.9", }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "0.2.117", + "@anthropic-ai/claude-agent-sdk": "0.2.141", "@anthropic-ai/sdk": "^0.78.0", "@types/bun": "1.4.2", "prettier": "3.9.9", @@ -38,23 +39,23 @@ "sharp": "0.35.5", }, "packages": { - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.117", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.117", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.117", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.117", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.117", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.117", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.117", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.117", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.117" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-pVBss1Vu0w87nKCBhWtjMggSgCh6GVUtdRmuE58ZvXv0E2q0JcnUCQHehmn92BAW0+VCwPY8q/k7uKWkgwz/gA=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.141", "", { "dependencies": { "@anthropic-ai/sdk": "^0.93.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.141", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.141", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.141", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.141", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.141", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.141", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.141", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.141" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-AIBacMWGcZIUcXlUoObqjwJ6pmJI3BayAqPAFXuvSq3DHJXdiuZVs7l/zTB5l3nRhRv5cqSrI2XbiDeHgZWizw=="], - "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.117", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZeC/Lz8XMKQ5w+GmjTziPR8bSSarBtNCJMkMAYRT9ekNmyXSWXEwGLENe5TDDmtpzNNzAB1mQNuIYoqTsqgV3w=="], + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.141", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9HZ0ot6+FwOfQ1aeMqQLH4IJGMm/DcP08SysDxscVjBm6l2JjqleHohxi3zid0DurfGweqT+4x9GScJffwg55g=="], - "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.117", "", { "os": "darwin", "cpu": "x64" }, "sha512-DKyggGzzpDcr9S435xlpbpwkEYKZNbePSekug75tJclK8l4ddD9+M9BFgMiSUq9F1Zt53kUaRDihDu/cBKvkdQ=="], + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.141", "", { "os": "darwin", "cpu": "x64" }, "sha512-4iAdarJaQ+2R58s6QJswZCzUdz2WQmL5lYG7Y+FLzWbRSROFfcH0QYpmOqSaPXd2KRQhIJwEacqecDZd/Q1XKQ=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.117", "", { "os": "linux", "cpu": "arm64" }, "sha512-jyHmyZQavpPOe3zxBRX3KbdOAJ8JwZ8m/wMr5bhHhhcstugm/vJx6IIs7D44VvFjk+8sqdvR2ZrliL8PUcJL0g=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.141", "", { "os": "linux", "cpu": "arm64" }, "sha512-Jdf0ZEwJzOP8sE6rPqdJN+SxMb0/L8sxJg4twCv/7S+Qzk0hJtls+wxSi+0Tjh6EEMaNxJqEGc7S3fx99Wi99Q=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.117", "", { "os": "linux", "cpu": "arm64" }, "sha512-bJU5gEOmM4VCOn4h8vipOKgdhPATePQ23mMpvyVqtVyipWppHfOUfVkqXb+SrF/hfkNSMYxDuoKxbJ+MmKtGjg=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.141", "", { "os": "linux", "cpu": "arm64" }, "sha512-6H1AJ/AVaWNnV22kubUPkOTRzZFH0+qP9k7WlhriHMN9gtgZcVAsITMddDeGjQsQJMCAdhXFd6sgi7TM1LdeOQ=="], - "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.117", "", { "os": "linux", "cpu": "x64" }, "sha512-Zb5PXKrDNbQ1dyNYwxZMNL+F2Dhgjh9f9B21wZUJqkhJL69hRJwJyxO42HiNmB2zGCaTxQTyjPhLdB/eQJo74Q=="], + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.141", "", { "os": "linux", "cpu": "x64" }, "sha512-DVjp72f3HmrRYpbneWZZWIqkUht5kTZXS7wXGFiwzLz6eNYEgjjh+GcsnhIi8UOwZUtNiKUrjZnoP38ovFqV8A=="], - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.117", "", { "os": "linux", "cpu": "x64" }, "sha512-LIkKTAYZGugEVssAuWCPqlDWSqhVZAveNPNsfKLbuG1naIMCR04fUqil6i3d3mAAfk7FaS5D4IdHp45psi+GDw=="], + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.141", "", { "os": "linux", "cpu": "x64" }, "sha512-fTI1YuM4cxOa4nSgsyMAdB5ELizkWp+w5Ispo4JnnYtcczMAL4D9GBNjWPW0sUzKvjsJOUVim68SmWLWhUOpXQ=="], - "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.117", "", { "os": "win32", "cpu": "arm64" }, "sha512-uetggH3B83PiH0a9D/5MVXB5Hqnlr2DVajehwAP2x0Mt4DBd632ICnHpu6pnSP+vVkWgq3FgQlkHe91RfP+peA=="], + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.141", "", { "os": "win32", "cpu": "arm64" }, "sha512-Wm10J6kfbufbPGFELokiJ/7Y5Oqug4Uag3HXFsV8g7TWCpaItx/oqVaJoiGptuAtXQB7xGLQVTuk082wER+Y5w=="], - "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.117", "", { "os": "win32", "cpu": "x64" }, "sha512-TT4KngAokDTJSvQ2mrAP6ZRkXj50OLj7Tb1zZA4CnkmrrEidgs4KrMx7er1ZwoivngIvCekV9+TbtC9giknr5w=="], + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.141", "", { "os": "win32", "cpu": "x64" }, "sha512-IXuP29YJuWbR5Q6xOHrjFVGG54V2s1FC61UVNwEN5fpxL09MwPnbwtQL6fqgzt/U1MP7vWAwpXZriYAklkH/mg=="], "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.78.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w=="], @@ -64,11 +65,11 @@ "@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="], - "@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="], + "@huggingface/jinja": ["@huggingface/jinja@0.5.10", "", {}, "sha512-SgS1D1bglQ94ceD4ZCL6eayUDy9uV1xuyk61OjgSxU5GJh7upqZCKII0JoTYMc6wWsg3JUgaPRX0ElQzXPk3Cw=="], - "@huggingface/tokenizers": ["@huggingface/tokenizers@0.1.3", "", {}, "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA=="], + "@huggingface/tokenizers": ["@huggingface/tokenizers@0.2.0", "", {}, "sha512-LidMHe1FpcSYH4vcSjXooda34pC0M8m1gmDOv7SQi6Iv+ib1zX7J3sHBCC9/3FPCkn2k1b55FneVqMRxrr99pg=="], - "@huggingface/transformers": ["@huggingface/transformers@4.2.0", "", { "dependencies": { "@huggingface/jinja": "^0.5.6", "@huggingface/tokenizers": "^0.1.3", "onnxruntime-node": "1.24.3", "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c", "sharp": "^0.34.5" } }, "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ=="], + "@huggingface/transformers": ["@huggingface/transformers@4.3.0", "", { "dependencies": { "@huggingface/jinja": "^0.5.10", "@huggingface/tokenizers": "^0.2.0", "onnxruntime-node": "1.30.0", "onnxruntime-web": "1.31.0-dev.20260914-8d85527a0", "sharp": "^0.35.4" } }, "sha512-fL1A/WUZwouPrOlYxU5dzIwD2T5J781JiB2jDR8bFe5DwCj0Gfudq+NEXCMno49kQgajHA7xQkrRLJlqG1veEA=="], "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], @@ -234,8 +235,6 @@ "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], - "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], - "browser-split": ["browser-split@0.0.1", "", {}, "sha512-JhvgRb2ihQhsljNda3BI8/UcRHVzrVwo3Q+P8vDtSiyobXuFpuZ9mq+MbRGMnC22CjW3RrfXdg6j6ITX8M+7Ow=="], "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], @@ -274,8 +273,6 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], - "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], "dom-serializer": ["dom-serializer@0.2.2", "", { "dependencies": { "domelementtype": "^2.0.1", "entities": "^2.0.0" } }, "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g=="], @@ -306,8 +303,6 @@ "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], - "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], - "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], @@ -346,7 +341,7 @@ "global": ["global@4.4.0", "", { "dependencies": { "min-document": "^2.19.0", "process": "^0.11.10" } }, "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w=="], - "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], + "global-agent": ["global-agent@4.1.3", "", { "dependencies": { "globalthis": "^1.0.2", "matcher": "^4.0.0", "semver": "^7.3.5", "serialize-error": "^8.1.0" } }, "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g=="], "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], @@ -408,8 +403,6 @@ "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], - "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], - "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], @@ -420,7 +413,7 @@ "marked": ["marked@18.0.11", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw=="], - "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], + "matcher": ["matcher@4.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -454,11 +447,11 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "onnxruntime-common": ["onnxruntime-common@1.24.3", "", {}, "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA=="], + "onnxruntime-common": ["onnxruntime-common@1.30.0", "", {}, "sha512-7fdVWjAID1dVhH/G8qK3APARunV4VkBFoCQAP7qp4Wkab0mrorvmc+sqiT+mKXOzDqdjN5j+/Z9nb4gzNPWcyA=="], - "onnxruntime-node": ["onnxruntime-node@1.24.3", "", { "dependencies": { "adm-zip": "^0.5.16", "global-agent": "^3.0.0", "onnxruntime-common": "1.24.3" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg=="], + "onnxruntime-node": ["onnxruntime-node@1.30.0", "", { "dependencies": { "adm-zip": "^0.6.0", "global-agent": "^4.1.3", "onnxruntime-common": "1.30.0" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-twhs1C2C/BFkz1yc5OY0KIU2GUq6DURO7hD4bx5Q2Qy3nAMJwRXW8xU3NVczE29VA9lolLOYepoD8fjTGOfIqw=="], - "onnxruntime-web": ["onnxruntime-web@1.26.0-dev.20260416-b7804b056c", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw=="], + "onnxruntime-web": ["onnxruntime-web@1.31.0-dev.20260914-8d85527a0", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.31.0-dev.20260911-2a43ec07e", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-Iy7rtadoBgxS/LLvDr3QW38DB1PNXRnr0GJMcL0TAt7c9qjgVQl83UlGCVyeAnK2InpmW8Uc3PL8XIuqtDeF6g=="], "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], @@ -500,8 +493,6 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], - "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], @@ -512,11 +503,9 @@ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], - "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], - "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], + "serialize-error": ["serialize-error@8.1.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ=="], "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], @@ -542,8 +531,6 @@ "socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="], - "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], - "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "string-template": ["string-template@0.2.1", "", {}, "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw=="], @@ -558,7 +545,7 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], @@ -598,7 +585,7 @@ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - "@anthropic-ai/claude-agent-sdk/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="], + "@anthropic-ai/claude-agent-sdk/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.93.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA=="], "@oozcitak/infra/@oozcitak/util": ["@oozcitak/util@8.0.0", "", {}, "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw=="], @@ -620,7 +607,7 @@ "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], - "onnxruntime-web/onnxruntime-common": ["onnxruntime-common@1.24.0-dev.20251116-b39e144322", "", {}, "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw=="], + "onnxruntime-web/onnxruntime-common": ["onnxruntime-common@1.31.0-dev.20260911-2a43ec07e", "", {}, "sha512-gBuF6U32YErKIAt+yD7DeGBpjRxhJ1uJwako6ygjokSZULDU6Pd+KWComX8Tumk1hYVHRxjPj7mdMnD0ryAPOw=="], "send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], diff --git a/docs/autoplan-guard-troubleshooting.md b/docs/autoplan-guard-troubleshooting.md index 39f7f7294..ff7970f5b 100644 --- a/docs/autoplan-guard-troubleshooting.md +++ b/docs/autoplan-guard-troubleshooting.md @@ -67,6 +67,11 @@ The journal root has a shape the guard does not know, usually because Claude Code changed its journal format. The guard reads the journal twice and allows only when both reads agree. `unrecognized_shape:cwd_spelling` means the journal's directory is the same folder as the project, spelled differently. +`unrecognized_shape:compaction_unreachable` means records after a Claude Code +compaction hang from a compact boundary whose logical parent loops back +instead of reaching the session root, in a layout gstack does not read yet +(Claude Code 2.1.300's preserved segment is read). Earlier gstack versions +dropped those records and denied the next phase with `close_incomplete`. Please report the detail and version. diff --git a/docs/designs/FIX_WAVE_2026_10_15.md b/docs/designs/FIX_WAVE_2026_10_15.md new file mode 100644 index 000000000..74bc99d8a --- /dev/null +++ b/docs/designs/FIX_WAVE_2026_10_15.md @@ -0,0 +1,207 @@ + +# gstack fix wave (October 15, 2026): open PRs and issues, ordered by severity + +## URGENT, outside this plan + +- `bin/gstack-upgrade:50` compares a `C:/…` path with a `/c/…` path and refuses on every Git Bash install; `bin/gstack-doctor-check.sh:372-374,401` recommends that command, so it is part of the Windows recovery path (PR 1, item 3). The file belongs to draft #3190 (`capy/next-wave`, thread jam_01M4YA0TAXXGQ01VVWMW71091K). Owner: the #3190 thread. This wave reports it there with the fix (compare `gstack_path_shell` of both sides, `bin/gstack-path-spelling.sh`) and does not edit the file. + +## Implementation plan + +### Context + +Base: origin/main `47d639a` (v1.91.101.0). Input: every open PR (25) and open issue (≈70) on garrytan/gstack as of 2026-10-15, triaged in six read-only lanes on a Capy cloud machine. Each lane reviewed external PR diffs line by line as untrusted input (scope, egress, credentials, dependencies, CI permissions, weakened tests), checked each issue's claim against main's code, and reproduced with focused tests where cheap. Evidence: `~/.capy/work/fixwave/triage-{A..F}.md`. This is plan revision 3. Revision 1 was reviewed by six voices (CEO, DX, Eng; Claude Opus 5.5 and GPT-6.1 Sol each): 60 findings, no Critical. Revision 2 folded them in and was reviewed again by the same six voices: 37 findings, no Critical, no user challenge, every revision-1 finding confirmed addressed. This revision folds in the revision-2 findings. The Review record lists what each phase raised and how it was handled. + +Owner request (Garry, 2026-10-15): "do a fix wave on gstack — plan, autoplan, eli5 for approval — go through all PRs and issues, figure out what to fix here in a wave, be circumspect on all code written by others, order by severity and priority." Garry merges gstack; this wave opens PRs and never merges them. + +**In-flight work this wave must not collide with.** Garry's draft #3190 (`capy/next-wave`, claims v1.91.102.0) changes 110 files. None of this wave's items are fixed by it (each lane diffed its files against `origin/capy/next-wave`). Files both touch: `setup` (items 12, 13, 16: disjoint hunks), `bin/gstack-bun-version.sh` (item 11: disjoint function; #3190 adds new `gstack_bun_status` callers in the `_gb_*` namespace), `lib/result-codes.ts` and `docs/troubleshooting.md` (append-only), `lib/pregate/checks/lanes.ts` (item 9: one-line conflict), and generated SKILL.md/goldens (items 24, 25). `lib/pregate/lane-selection.ts`, `lib/pregate/import-graph.ts`, `bin/gstack-session-update`, `setup.ps1`, `lib/redact-engine.ts`, `lib/cso/**`, `browse/**`, `lib/regen.ts`, `lib/restamp.ts`, `lib/gate-diff/**`, `bin/gstack-evidence`, `gstack-upgrade/SKILL.md.tmpl` are untouched by #3190 (`git diff --name-only origin/main...origin/capy/next-wave`). + +**Security review result.** No external PR in the wave has a red flag: no new egress, telemetry, credential handling, postinstall scripts, or CI permission changes; #3145's lockfile adds no new packages; dependabot #3175's five action SHAs match upstream tags. Two PRs are rejected on safety grounds: #2923 drops the Playwright `patchedDependencies` entry that keeps Windows `windowsHide` (reintroduces console-flash #2160/#1989), and #1796 writes a bearer token through `writeFileSync`+`chmod` (follows a planted symlink), hard-codes `~/.gstack` against the state-root chain, and sends the cached bearer to a device address without checking which app listens. + +**How external code is adopted.** Commits are cherry-picked onto the wave branch with original authorship; our changes land as separate commits on top; release churn (VERSION, CHANGELOG, package.json version, e.g. #3189's `1.91.102.0` commit `3d1caac`) is dropped. Fork CI is **not** approved (it would run untrusted code with this repo's runner); the cherry-picked commits run on the wave branch's own CI. Every adopted PR gets at least one reviewer-written behavioral test in addition to the author's own; where that is impossible the PR body says "author test, reviewed". After Garry's review pass (never before: reporters would be running unreviewed code), each source issue gets a comment with exact commands to try the branch, labelled by shell: `cd "" && git checkout -- 'SKILL.md' '*/SKILL.md' '*/sections/*.md' && git fetch origin && git checkout --detach FETCH_HEAD && ./setup`, and to go back: `cd "" && git checkout main && ./setup`; it says auto-update pauses while a detached revision is checked out. Confirmations within 48 hours are recorded in the PR body (the reporters have the PS 5.1, MSYS tar, OrbStack, zsh, Cursor-1.31 and Claude Code 2.1.300 environments CI lacks); they are evidence, not a merge gate. The source PR is closed after merge with a comment naming what changed and why; issue reporters and PR authors are credited in the CHANGELOG. + +Repo constraints: SKILL.md is generated from `.tmpl`; new logic in `lib/*.ts` behind thin `bin/` entrypoints; `test/module-size-ratchet.test.ts`; state paths through `lib/state-root.ts`; result codes in `lib/result-codes.ts` with `docs/troubleshooting.md` anchors (`test/troubleshooting-anchors.test.ts`); every test subprocess has a timeout (`test/spawnsync-timeout-tripwire.test.ts`, which three adopted PRs fail today: #3188, #3178, #3181); parity and context-budget ratchets for template edits; ship is stamp-at-merge PATCH. + +Thesis: **people stuck this week get unstuck, including the installs that are already stuck; community fixes land with their authors credited; nothing a stranger wrote reaches main without an independent test.** + +### Release promises (the finish line) + +- **P1. No user is stuck, including already-stuck installs.** An /autoplan run survives a Claude Code 2.1.300 compaction; a conflicting autostash never leaves conflict markers in a live install and never silently hides a user's edit; a Windows install held by the tar bug and a macOS zsh user get a verified recovery path; `setup.ps1` runs under Windows PowerShell 5.1; `/cso` Semgrep returns findings; `/cso` works with OrbStack; the browse daemon recovers when its last tab is closed outside the CLI. +- **P2. Windows bugs are proven on Windows,** in CI on `windows-latest` (PowerShell 5.1, real MSYS tar, Git for Windows), not only by Linux emulation. +- **P3. Pregate selects the right tests** for bats repos and Next.js/tsconfig-alias repos without dropping a required platform receipt, and runs on Windows instead of refusing with a misleading message. +- **P4. Every new failure state says what happened and the one command that fixes it,** with a troubleshooting entry under its exact error text and a CHANGELOG "If you…" line for each affected cohort. +- **P5. The tracker is honest:** fixed issues are closed with file:line evidence on a dated follow-up; each deferred item has a reason, an owner and a revisit date. + +### Delivery shape + +Seven PRs with stated dependencies, each a PATCH release with its own slot from `bin/gstack-next-version` at ship time, each running `bun run test` once on its frozen tree (on Ubicloud; this machine has 4 cores). PR 1a ships first and depends on nothing that has never run before. + +| PR | Contents | Base | Depends on | Blockers it can have | +|---|---|---|---|---| +| 1a. Recovery, cross-platform | items 1, 5, 6, 7, 11 | main | — | its own tests | +| 1b. Recovery, install and Windows | items 2, 3, 4, 12, 13, 16, W | PR 1a's branch (`gh stack`) | 1a (Step 4 of `/gstack-upgrade` is edited by items 11 and 2) | the first run of the Windows job | +| 2. Windows script invocation | item 10a, item 8's manifest-parser half | main | — | its own Windows job file | +| 3. Maintenance | item 8's import-graph half, items 15, 19–23 | main | — | #3145 classifier smoke (split out if unavailable) | +| 4. Prompt changes | items 17, 14 | main | — | the free spike; paid gate cases and judges priced and approved first | +| 5. After #3190 | items 9, 10b, 24, 25, the doctor rows (D), the Windows curation move | `capy/next-wave` | #3190 | #3190 | +| 6. Census repair | item 18 | main, or folded into PR 5 if the repair edits `ship/` | — | evidence read first | + +Sibling PRs share no file except the integrator-owned append-only set (`lib/result-codes.ts`, `docs/troubleshooting.md`, `test/fixtures/module-size-ratchet.json`, `CHANGELOG.md`, `package.json` version line). Coordinator exception, recorded in each PR body: lane-check reports those files as intersections; the exception covers exactly that named set, and after any sibling or #3190 merges, the next PR merges main, resolves the appends, and restamps. Every other intersection is a blocker. For #3190 the exception also covers `setup` (PR 1b's hunks) and `bin/gstack-bun-version.sh` (PR 1a), backed by a receipt bound to both SHAs: the merged tree is computed once per pair (both orders recorded, the tree hash is the same), and on that tree `gen:skill-docs` freshness and the focused tests for both PRs' touched files run once. Files #3190 also changes that this wave needs (`lib/pregate/context.ts`, `lib/pregate/index.ts`, `bin/gstack-doctor-check.sh`, `bin/gstack-doctor-components.sh`, `lib/doctor-components.ts`, `scripts/lib/windows-curation.ts`, `.github/workflows/free-tests.yml`) are edited only in PR 5. + +This thread registers dated follow-ups: 2026-10-22 (post the closes below) and 2026-10-29 (if #3190 has not landed, this thread rebuilds PR 5 on main, resolving the `lanes.ts`, `bin/gstack-pregate`, `context.ts`/`index.ts` and doctor hunks itself, and tells the #3190 thread). + +Lanes and file ownership (each lane on a fresh machine, its own branch, bisected commits, no VERSION/CHANGELOG/package.json-version edits): +- Lane A (PR 1a): `lib/claude-journal-records.ts`, `lib/redact-engine.ts` and the scanner capture path in `lib/cso/**` (`process.ts`, `docker.ts`, `watchdog.c`, the executor), `browse/src/browser-manager.ts`, `browse/src/server.ts`, `bin/gstack-bun-version.sh` (`_gstack_bun_below` only, no new `_gb_*` globals), new `lib/sanitize-echo.ts` + its bash twin, `gstack-upgrade/SKILL.md.tmpl` (Step 4's floor check only). +- Lane B (PR 1b, on top of A): `bin/gstack-session-update`, new `bin/gstack-ff-update`, `bin/gstack-config`, `bin/gstack-memorable`, new `bin/gstack-claude-root.sh`, `setup` (hook-root, Cursor and Playwright hunks; the only PR that edits `setup`), `setup.ps1`, `bin/gstack-browser-ensure`, `gstack-upgrade/SKILL.md.tmpl` (Step 4's pull), `.github/workflows/windows-setup-e2e.yml`, `test/setup-ps1.test.ts`. +- Lane C (PR 2): new `lib/script-invocation.ts`, `hosts/claude/hooks/spawn-bin.ts`, `lib/gbrain-exec.ts`, `lib/regen.ts`, `lib/gate-diff/git.ts`, `bin/gstack-gate-diff`, `lib/restamp.ts`, `bin/gstack-evidence`, `lib/pregate/checks/secrets.ts`, `lib/pregate/lane-selection.ts` (manifest parser and `bash -c`; the only PR that edits it), new `.github/workflows/windows-pregate-e2e.yml`. +- Lane D (PR 3): `lib/pregate/import-graph.ts`, `browse/src/terminal-agent.ts`, `bin/gstack-skill-end`, `bin/gstack-timeline-log`, `bin/gstack-gbrain-sync.ts`, test-only files for #3163/#3156, `.github/workflows/*` action SHAs (not the two Windows job files), `.github/dependabot.yml`, `bun.lock` and dependency lines of `package.json` (#3145), README/CLAUDE.md skill lists, the OSV ledger comment, `ios-qa/daemon/src/devicectl.ts` and the ios-qa how-to. +- Lane E (PR 4): `scripts/resolvers/outside-voice-steps.ts`, the three plan-skill templates' outside-voice sections, `bin/gstack-ceo-plan-find`, `plan-eng-review/SKILL.md.tmpl`. +- Lane F (PR 5): `lib/pregate/checks/lanes.ts`, `bin/gstack-pregate`, `lib/pregate/context.ts`, `lib/pregate/index.ts`, the doctor files, `scripts/lib/windows-curation.ts`, `scripts/gen-skill-docs.ts` resolvers, the size-guard test. +- Lane G (PR 6): the census case and whatever its evidence names. + +Every item lands with a test that fails on `47d639a` first (named per item). No existing assertion is weakened; existing pins that change are named per item as intentional contract changes. + +### PR 1a and PR 1b — Recovery (High severity; ship first) + +PR 1a holds items 1, 5, 6, 7 and 11; PR 1b holds items 2, 3, 4, 12, 13, 16 and W (items 13 and 16 move here so that `setup` has one owner). + +**1. /autoplan locks after a Claude Code 2.1.300 auto-compaction — adopt #3196 (fixes #3194).** After compaction the `preservedSegment` boundary makes a parent cycle, the phase guard denies every phase entry with `close_incomplete`, and the run cannot continue. #3196 (vilano93) teaches `lib/claude-journal-records.ts` the boundary. Verified: `test/autoplan-compaction-preserved-segment.test.ts` fails 2/7 on main, passes on the PR; CI green; two compactions hold. Added: when owned nodes are unreachable from the root after the walk **and** a `compact_boundary` record (its logical parent or preserved list) explains the unreachable component, map it to the guard's existing `unrecognized_shape` disposition (allow with a visible warning and a decision-log row) rather than a false `close_incomplete`; orphaned or not-yet-flushed components keep today's behaviour, so the fail-open surface does not widen beyond compaction. Listed under the PR's gate-integrity edits. Negative controls: the existing denial tests and captured fixtures (`autoplan-publication-guard`, `-hook`, `guard-reasons`, `autoplan-publication-boundary-361c.json`) still deny. Reviewer test: a journal with a ~50k-record history and three compactions finishes inside the hook's time budget (memoize `descends` per walk if it does not). + +**2. A conflicting autostash leaves conflict markers in the live install — adopt #3189 and #3191 through one shared bin.** On git 2.55, `merge --ff-only --autostash` with a conflicting stash exits 0, leaves markers in tracked files of the install, and setup runs over them. #3189 (BenjaminDSmithy) detects unmerged files, restores the checked revision, and keeps the stash. Verified: 8/10 of its tests fail on main. Take commits `069b80b` and `8dc0078`; drop `3d1caac`. +- The fast-forward-plus-recovery moves into a new bash bin `bin/gstack-ff-update `, the only code that moves an install's tree, called by `bin/gstack-session-update`, by `/gstack-upgrade` Step 4 and by the held-install recovery command (item 3). Step 4 never reimplements recovery in prose. +- Transaction ownership: the bin takes an install-scoped lock at `$(git -C rev-parse --git-common-dir)/gstack-ff.lock` (the updater's TTL and reap-by-mv logic) before any merge, `update-ref` or reset; callers keep their own locks. Inside the lock it captures the whole working tree as-is (tracked edits, unmerged and marker-laden files, and anything written after an earlier stash) into a rescue commit through a private index, then resets to the target only if the working-tree hash still equals the captured one; otherwise it refuses with `autostash-unrecoverable:` and changes nothing. The regenerable footprint (`SKILL.md`, `*/SKILL.md`, `*/sections/*.md`, rewritten by `gstack-patch-names` in prefix mode) is discarded first, as Step 4 does today. +- Rescue refs are named by the rescue commit id (`refs/gstack/rescue/`), created compare-and-create, never overwriting an unrelated ref. Tests: Step 4 racing a background updater on one install with two state roots; a barrier-controlled edit written between capture and reset (refused, edit kept); same-second recoveries; a pre-existing ref. +- A recovery that shelved edits is never silent: the session-start banner names the files and the rescue ref, says the edits conflicted with the update, and makes inspection the default (`git -C '' show --stat `); restoring is guided out of the live tree (`git -C '' diff > ~/gstack-local-edits.patch`), never a bare `stash apply` into the install. The line repeats at most daily until the ref is deleted; the doctor lists rescue refs with the delete command (PR 5, item D). Ordinary success stays quiet. Test the background-to-foreground handoff. +- #3191 (Jesssullivan) reaps the lock heartbeat and its `sleep` child on exit (5/5 of its tests fail on main); update the two static pins in `test/session-update-autostash.test.ts` it breaks (intentional pin change). +- The #3189 test shim pins the real git binary once (resolving through any PATH wrapper) and fails fast with a named reason when it cannot, so no environment note is needed. + +**3. Windows auto-update is held on every release — fix #3184, plus the recovery path for held installs.** `hook_incoming_hold` (`bin/gstack-session-update:100`) extracts the incoming revision with `git archive | tar`; MSYS tar cannot create the `connect-chrome` symlink (it precedes its target), the failure is fail-closed since 1.91.89.0, and the banner shows a stale "bun not found" instead of the hold. +- Fix: extraction without tar and without symlinks, both commands in one subshell with a private index: `( export GIT_INDEX_FILE=/index; git read-tree ; git -c core.symlinks=false checkout-index -a --prefix=/tree/ )` (symlinks become plain files containing the target path, which the hook check never reads). Tests: a valid installed hook against an invalid incoming hook causes the hold (proving the incoming bytes were checked); the real index is byte-identical afterwards. A tripwire test asserts that no hooked path or hook-imported module in the tree is a symlink, so a future symlinked hook fails loudly instead of reading as missing. +- The held reason carries the first 200 bytes of the failing command's stderr with newlines flattened and control characters stripped (as `:322` does for `PULL_REASON`). Test: stderr containing `\nstages=x` and an ESC sequence. +- Banner: the hold line prints before the stage line; each new hold reason (`unmerged-files:`, `autostash-unrecoverable:`, `bun-version-unreadable:`, `hook-check-unavailable:` with stderr) has its own banner case naming the exact command and a troubleshooting anchor, and no `stages=setup` fix prints while unmerged entries exist. Every path in a printed command is shell-quoted; tests execute the emitted commands against isolated installs whose paths contain a space and an apostrophe. +- Already-held installs: their old updater cannot deliver the fix, and the old `/gstack-upgrade` Step 4 still has the autostash hole, so recovery runs the **new** `gstack-ff-update` fetched from the new revision. Published forms, each labelled with where it runs: in Git Bash, `cd "" && git fetch origin main && git show FETCH_HEAD:bin/gstack-ff-update > "${TMPDIR:-/tmp}/gstack-ff-update" && bash "${TMPDIR:-/tmp}/gstack-ff-update" . FETCH_HEAD && ./setup`; in Windows PowerShell 5.1, the same script run through Git's `bash.exe` followed by `.\setup.ps1`; in Claude Code, ask it to run the Git Bash form. Each form handles clean, dirty-conflicting, already-unmerged and prefix-mode installs (the bin discards the footprint and rescues everything else). `bin/gstack-upgrade`'s Git Bash refusal is reported to #3190 (URGENT block). The forms go into the CHANGELOG "If you…" block, the troubleshooting entry and the #3184 closing comment. +- Test that fails first: `test/session-update-hook-hold.test.ts` with an incoming revision containing a symlink sorted before its target and a PATH `tar` shim emulating MSYS; main logs `HELD hook-check-unavailable`, fixed code fast-forwards. + +**4. `setup.ps1` fails under Windows PowerShell 5.1 — fix #3130.** PS 5.1's legacy native-argument passing strips the double quotes inside the `bash -c` string at `setup.ps1:59`. Reproduced with pwsh `$PSNativeCommandArgumentPassing='Legacy'`. Fix: a `-c` string with no double quotes, `set -f; IFS=; mapfile -d '''' -t argv < $1; rm -f -- $1; exec ./setup ${argv[@]}`; validated in Legacy and Standard modes with a space in TMPDIR and adversarial args (`$(touch PWNED)`, `it's`, `"quoted"`, `;echo injected`, backticks, `*`, `a\"b\`), none executed, all forwarded exactly. An empty-string argument is refused in PowerShell before dispatch (`setup.ps1: argument N is empty; setup takes no empty arguments`). Test that fails first: a behavioral case in `test/setup-ps1.test.ts` under Legacy mode (exit 2 on main); on win32 the same test runs `powershell.exe`; the pinned-source assertion changes (intentional). + +**W. Windows CI proves items 3 and 4 natively.** The existing `windows-setup-e2e` job gains `setup.ps1` under `powershell.exe` (5.1) from a checkout path containing a space, `setup.ps1` in its path filter, and the held-install journey: a local bare origin whose `main` is this PR's HEAD plus a synthetic later commit containing `connect-chrome`; a prefix-mode v1.91.101.0 install in three states (clean, dirty-conflicting, already-unmerged); `auto_upgrade=true` with the throttle cleared, polling the updater log. Assertions: (1) the v1.91.101.0 updater logs `HELD hook-check-unavailable` (so the test is not vacuous); (2) the PowerShell recovery form reaches HEAD with every local edit present in a rescue ref; (3) the new updater then logs `UPDATED` for the synthetic commit. The job asserts executed counts, not skips. PR 2 adds its own job file for pregate and restamp. + +**5. `/cso` Semgrep always reports `INVALID_OUTPUT` — fix #3185.** The scanner path splices redactions into raw JSON; `redactFindingSpans` (`lib/redact-engine.ts:577-583`) replaces a bare 10+ digit number (`"max_memory_bytes":1145427776`, drizzle `"when"` epochs) with an unquoted placeholder, `JSON.parse` fails, and the report is discarded. A delimiter-only rule is not enough (it misses `-4155551234` and can misfire inside strings). Fix: the scan operation's declared output format is carried through the real capture path (executor, Docker capture, process boundary) for scan stdout only; version probes and diagnostic stderr stay plain. For JSON-declared plans, the tokenizer runs over the concatenated stdout (not per interleaved run), tolerates multiple top-level values and NDJSON, is string- and escape-aware, and replaces each number token a merged span touches (`-?digits`, fraction, exponent) with the quoted placeholder, number by number; a span that crosses a token boundary or a structural character withholds the stream. Spans inside JSON strings keep today's in-string replacement; non-JSON streams are unchanged. Marker-only withholding and cross-stream release checks are kept. Tests that fail first: `JSON.parse(redact('{"a":4155551234}'))` succeeds with the value redacted; a spliced Semgrep report with `"time":{"max_memory_bytes":…}` returns `status: 'complete'`. Also: negative, exponent, `[4155551234,4155551235]`, multi-line NDJSON, whitespace, punctuation-inside-string and escaped-quote cases; the real version-then-scan capture sequence with stderr interleaved; the output is a fixpoint of the views check at `lib/cso/process.ts:120`; a non-JSON stream keeps the unquoted form (`test/cso-eval.test.ts:1029,1071`). Behavior change listed. + +**6. `/cso` cannot use OrbStack's Docker — adopt #3188 (fixes #3187).** `executable()` returns the realpath and OrbStack's multicall binary dispatches on argv0. #3188 (aegixx) spawns the trusted canonical path with `argv0: 'docker'`, including in `watchdog.c`. Verified with a fake multicall binary; its test fails on main. Change: the python3 `spawnSync` at `test/cso-docker-argv0.test.ts:109` gets a timeout. The trust check still runs on the realpath. + +**7. The browse daemon wedges when its last tab is closed outside the CLI — adopt #3179 with changes (fixes #3167).** Reproduced on main: after an external close every command, `status` and `stop` included, throws "No active page". #3179 (aftabkh4n) reopens a page on demand. Changes: `status` and `stop` bypass recovery (so a closed headed context can still be stopped); a failed recovery maps to a named error carrying the restart command; a command that ran on a recreated tab prints a one-line warning. Reviewer tests: `status`, `goto` and `stop` after an external close; the closed-context case; a text read after recovery shows the warning. + +**11. The Bun floor check fails open under zsh — fix #3195 (promoted: zsh is macOS's default shell, so `/gstack-upgrade` Step 4 accepts Bun 1.3.2 there).** `_gstack_bun_below` relies on word-splitting `$_gb_core`; zsh does not split, `[ -ne ]` errors, and every error path reads as "not below". Fix: compare the already-parsed `_gb_maj`/`_gb_min`/`_gb_pat` (copied between the two parses, no new `_gb_*` globals) with `$((10#…))`; a parse failure returns a distinct status that every caller treats as a hold and prints `BUN_VERSION_UNREADABLE` (the version output and the bun path, both passed through the one flatten-and-strip sanitizer every echoed external string uses, its own fix and anchor, never "bun upgrade"); Step 4 runs the check through `bash -c`. One version table (1.3.2, 1.3.3-canary.1, 1.3.3, 1.3.14, 1.4.2, 1.10.0, 01.04.02, 2.0.0, garbage) drives bash, zsh, `bash --posix` and the TypeScript twin's `below()`. Test that fails first: `zsh -c '. bin/gstack-bun-version.sh; gstack_bun_status 1.3.2'` expects `too-old` (skipped without zsh); a source assertion that Step 4 invokes bash. + +**12. Setup bakes an ephemeral `CLAUDE_CONFIG_DIR` into global hooks — adopt #3181 with changes (fixes #3180).** #3181 (Brit-juho) registers hooks at `$HOME` when the config dir links back to it. Changes: `bin/gstack-memorable` has a verbatim copy of the old block, so `gstack_canonical_claude_root` moves into one sourced helper `bin/gstack-claude-root.sh` used by both; `resolve()`'s `spawnSync` gets a timeout (it fails the tripwire today); the PR's source-slicing test is rewritten to source the helper (intentional test change). Tests that fail first: `test/setup-hook-canonical-paths.test.ts` "per-session config dir linking back", plus a memorable case. + +**13. Cursor loads duplicate skills from 1.31-era unprefixed folders — adopt #3152 (fixes #3133; ships in PR 1b).** #3152 (szsunyuan) removes unprefixed Cursor folders proven generated and keeps user-owned ones. Its test fails on main, passes on the branch; CI green. Reviewer test: a user folder whose SKILL.md is a symlink to somewhere outside any gstack checkout is kept. Setup prints which folders it removed. + +**16. Missing `bunx` is reported as a network failure — fix #3131 (ships in PR 1b).** A winget Bun ships no `bunx`. Fix: `bun x` through setup's existing bun resolver and in `bin/gstack-browser-ensure`; rc 127 prints ` x playwright install chromium exited 127 (command not found); install Bun from bun.sh (winget Bun ships no bunx), or skip with ./setup --no-browser (Git Bash) or .\setup.ps1 --no-browser (PowerShell)`. Test that fails first: `test/setup-playwright-best-effort.test.ts` with a winget-shaped Bun (bun present, no bunx); the existing `bunx()` stub is renamed (intentional: the block under test changes). + +### PR 2 — Windows script invocation (High) + +**10a. Windows pregate and restamp refuse because Bun cannot spawn bash scripts — fix #3197 on main's call sites.** `bin/gstack-wtree` is spawned directly at `lib/regen.ts:133`, `lib/gate-diff/git.ts:84`, `lib/restamp.ts:512`, `bin/gstack-evidence:200`; `gstack-slug` at `restamp.ts:591`; `gstack-redact` at `lib/pregate/checks/secrets.ts:38`. gstack already has three conflicting Windows bash resolvers (`hosts/claude/hooks/spawn-bin.ts:27` falls back to a bare PATH `bash`, the WSL trap `lib/gbrain-exec.ts:211` warns about; `lib/gbrain-exec.ts:180`; `setup.ps1:31`). +- `lib/script-invocation.ts` becomes the only TypeScript resolver, used by hooks' `runBin`, `gbrain-exec`, regen, restamp, evidence, secrets and lane-selection's `bash -c`. Order: `GSTACK_GIT_BASH` (canonical, the name `setup.ps1` already reads), `GSTACK_BASH` as a deprecated alias at the same precedence in both `setup.ps1` and TypeScript; then bash under `git --exec-path` (resolves through scoop-style shims, per-user and portable Git); then the known install paths; then a PATH `bash` only when it reports MSYS/MINGW (System32 and WindowsApps `bash.exe`, i.e. WSL, rejected); else an actionable refusal that lists each candidate tried with its rejection reason and names `GSTACK_GIT_BASH`. The same text is used by the gbrain "no bash found" line and `setup.ps1`. Hooks' hot path stays spawn-free (`Bun.which` plus path arithmetic) with the resolved bash cached in the state root keyed by `git.exe` path and mtime; the Windows job times a hook invocation. Bun-shebang bins run as `process.execPath