From 0000b49ebebe29dc5d9f6a4c84c12830688ad46f Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 1 Nov 2026 17:07:12 -0800 Subject: [PATCH 01/13] v1.92.48.0 fix(windows): review-scope summary runs gstack-review-read through Git Bash; jsonl-merge writes UTF-8 with LF (#3541, #3659) (#3714) * fix(windows): review-scope summary launches gstack-review-read through Git Bash; jsonl-merge writes UTF-8 with LF (#3541, #3659) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 13 +++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-jsonl-merge | 6 ++- bin/gstack-review-scope | 5 +- package.json | 2 +- scripts/lib/windows-curation.ts | 2 + test/jsonl-merge-encoding.test.ts | 41 ++++++++++++++++ test/review-scope-windows-launch.test.ts | 60 ++++++++++++++++++++++++ test/script-invocation-tripwire.test.ts | 6 ++- 10 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 test/jsonl-merge-encoding.test.ts create mode 100644 test/review-scope-windows-launch.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dab48b0c1..8b7b4b6d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.92.48.0] - 2026-11-01 + +**On Windows, `gstack-review-scope summary` works again, and the JSONL merge driver no longer fails on non-English text or rewrites files with CRLF.** Before, the summary started the bash script `gstack-review-read` directly, which Windows cannot run, so it always refused with an empty "gstack-review-read failed:" message and /ship could not write its review-loop file (#3541). The `jsonl-append` merge driver wrote its result with Python's `print()`, so a line the console code page could not encode (Cyrillic text under cp1250, for example) made it exit 1 and git reported a conflict with no markers, and every merge on Windows turned LF files into CRLF (#3659). + +### Fixed +- **`gstack-review-scope summary`** runs `gstack-review-read` through `lib/script-invocation.ts` (Git Bash on Windows). When the helper cannot start, the refusal names the launch error instead of an empty string. +- **`gstack-jsonl-merge`** writes UTF-8 bytes with LF line endings on every platform and code page. + +### Tests +- `test/review-scope-windows-launch.test.ts` runs the summary against a seeded review log through the same launcher; it runs on windows-latest. +- `test/jsonl-merge-encoding.test.ts` merges a Cyrillic line with `PYTHONIOENCODING=cp1252` and checks the bytes are UTF-8 with no CR. It failed before the fix on Linux too. +- `test/script-invocation-tripwire.test.ts` now also catches a bin spawned through `path.join(path.dirname(...import.meta.url...), ...)` or `import.meta.dir`, the shape that hid #3541. `bin/gstack-ship-receipt`'s `gstack-session-kind` spawn is listed as a known site until its own follow-up. + ## [1.92.47.0] - 2026-11-01 - **`/ship` Step 5 runs its test lanes one at a time on machines with 4 or fewer cores, and `--lanes serial|parallel` picks the mode by hand** (`gstack-ship-preflight`, GST-851). Preflight used to start every `--lane` at once. On garrytan/gstack-evals#115 six lanes (a 1,451-test bun suite, tsc, a Docker tier and an arm tarball trial) started within 60 ms of each other on a 4-core, 16 GB machine, and running lanes together has taken machines down before (gstack-evals #101, #106, #107). Now, with no flag, preflight runs one lane at a time when `os.availableParallelism()` is 4 or less and all of them at once above that. `--lanes serial` or `--lanes parallel` overrides the default, and any other value, or `--lanes` with no value, is a usage error (exit 2). A new `LANES: serial|parallel (--lanes| cores)` line before the `LANE` lines says which mode ran and why. Each lane still prints its own `LANE` line with its own `tests_ran` count, in the order the lanes were given. Serial runs take the sum of the lane times instead of the longest one. There is no ship-policy key for this: a new key needs key versioning and a `min_gstack_version` bump, and the core-count default already covers the 4-core case. diff --git a/VERSION b/VERSION index f0f525626..c8b62ecd0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.47.0 +1.92.48.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index f4e17a48a..7572774f2 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.47.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.48.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-jsonl-merge b/bin/gstack-jsonl-merge index ce439d2ac..4a7074186 100755 --- a/bin/gstack-jsonl-merge +++ b/bin/gstack-jsonl-merge @@ -91,8 +91,12 @@ for path in paths: sys.exit(1) # Timestamp-ordered entries first (group 0), then hash-ordered (group 1). +# UTF-8 bytes and LF on every platform: print() encodes with the stdout code +# page and writes CRLF on Windows (#3659). +out = sys.stdout.buffer for line, _ in sorted(seen.items(), key=lambda item: item[1]): - print(line) + out.write(line.encode('utf-8') + b'\n') +out.flush() PYEOF _PYEXIT=$? diff --git a/bin/gstack-review-scope b/bin/gstack-review-scope index 3d4c843b6..fb2f2388e 100755 --- a/bin/gstack-review-scope +++ b/bin/gstack-review-scope @@ -23,6 +23,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; import { resultLineWithDocs } from '../lib/result-codes'; +import { runScriptSync } from '../lib/script-invocation'; import { isUnavailable, loadPolicyFromBase, policyUnavailableLine, resolveBaseBranch } from '../lib/ship-policy'; import { EXIT as POST_EXIT, runPost } from '../lib/gstack-post'; import { @@ -237,8 +238,8 @@ function cmdRender(rest: string[]): number { function cmdSummary(rest: string[], json: boolean): number { const since = flag(rest, '--since'); if (!since || !Number.isFinite(Date.parse(since))) return refuse('summary', 'REVIEW_SCOPE_INPUT', '--since (the invocation\'s first Step 9 entry) is required'); - const read = spawnSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-review-read'), [], { encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 }); - if (read.status !== 0) return refuse('summary', 'REVIEW_SCOPE_INPUT', `gstack-review-read failed: ${(read.stderr ?? '').trim().split('\n')[0] ?? ''}`); + const read = runScriptSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-review-read'), [], { encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 }); + if (read.status !== 0) return refuse('summary', 'REVIEW_SCOPE_INPUT', `gstack-review-read failed: ${read.error?.message ?? (read.stderr ?? '').trim().split('\n')[0] ?? ''}`); const rows: Record[] = []; for (const line of (read.stdout ?? '').split('---CONFIG---')[0]!.split('\n')) { if (!line.trim() || line === 'NO_REVIEWS') continue; diff --git a/package.json b/package.json index 6ede975a2..d1c96934b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.47", + "version": "1.92.48", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/scripts/lib/windows-curation.ts b/scripts/lib/windows-curation.ts index 04e37cac3..46331d6fd 100644 --- a/scripts/lib/windows-curation.ts +++ b/scripts/lib/windows-curation.ts @@ -402,6 +402,8 @@ export const KNOWN_WINDOWS_SAFE: Array<{ file: string; reason: string }> = [ { file: 'test/cso-windows-docker.test.ts', reason: '/cso docker.exe discovery under known-folder roots (real path, no reparse points) and the native-transport-unsupported outcome; pure win32 path logic' }, { file: 'test/copilot-windows-bash.test.ts', reason: 'the Copilot glossary line that runs bash blocks in Git for Windows Bash; reads host config only' }, { file: 'test/ship-hook-windows-paths.test.ts', reason: 'runs bin/ helpers through explicit bash and Bun argv with forward-slash paths; never executes a shebang; the path-spelling simulation is skipIf win32' }, + { file: 'test/review-scope-windows-launch.test.ts', reason: '#3541: runs gstack-review-scope through lib/script-invocation.ts, which must reach the bash gstack-review-read through Git Bash (red at main: windows-free-shard 3, run 41228091417); os.tmpdir state only' }, + { file: 'test/jsonl-merge-encoding.test.ts', reason: '#3659: runs gstack-jsonl-merge through lib/script-invocation.ts (Git Bash on win32); the CRLF half of the bug only reproduces with a Windows Python' }, { file: 'test/gstack-wtree-windows-paths.test.ts', reason: '#3101: runs bin/gstack-wtree through explicit bash in an os.tmpdir linked worktree with forward-slash paths; never executes a shebang; the drive-letter simulation is skipIf win32' }, { file: 'test/setup-hook-syntax-gate.test.ts', reason: '#3088/#3100: runs bin/gstack-hook-check and the setup hook sections through explicit bash with forward-slash paths; never executes a shebang. Its Bun drive-path spelling is the Windows bug it guards' }, { file: 'test/bun-config-shell-bins.test.ts', reason: '#3415: runs the record bins through lib/script-invocation.ts (Git Bash on Windows) with MSYS path conversion off, the case it guards; its bun and cygpath stand-ins and simulated-OSTYPE cases are skipIf win32' }, diff --git a/test/jsonl-merge-encoding.test.ts b/test/jsonl-merge-encoding.test.ts new file mode 100644 index 000000000..3728e9a23 --- /dev/null +++ b/test/jsonl-merge-encoding.test.ts @@ -0,0 +1,41 @@ +/** + * #3659: gstack-jsonl-merge read its inputs as UTF-8 but wrote them with + * print(), so a redirected stdout on a legacy code page (cp1250 on the + * reporter's Windows) raised UnicodeEncodeError on a Cyrillic line, and any + * Windows Python rewrote LF as CRLF. The driver must write UTF-8 with LF + * whatever the platform and code page. PYTHONIOENCODING=cp1252 reproduces the + * legacy code page on every OS. + * + * Windows-safe by construction: the driver launches through + * lib/script-invocation.ts (Git Bash on win32), files live under os.tmpdir(). + */ +import { afterEach, beforeEach, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { runScriptSync } from '../lib/script-invocation'; + +const ROOT = path.resolve(import.meta.dir, '..'); +let tmp = ''; + +beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-jsonl-enc-'))); }); +afterEach(() => fs.rmSync(tmp, { recursive: true, force: true })); + +test('a line the stdout code page cannot encode merges as UTF-8 with LF endings (#3659)', () => { + const base = '{"ts":"2026-01-01T00:00:00Z","note":"base"}\n'; + const ours = base + '{"ts":"2026-01-02T00:00:00Z","note":"ours"}\n'; + const theirsLine = '{"ts":"2026-01-03T00:00:00Z","note":"привет"}'; + const p = (n: string) => path.join(tmp, n); + fs.writeFileSync(p('base'), base); + fs.writeFileSync(p('ours'), ours); + fs.writeFileSync(p('theirs'), base + theirsLine + '\n'); + const posix = (f: string) => f.replace(/\\/g, '/'); + const r = runScriptSync(path.join(ROOT, 'bin', 'gstack-jsonl-merge'), [posix(p('base')), posix(p('ours')), posix(p('theirs'))], { + env: { ...process.env, PYTHONIOENCODING: 'cp1252', PYTHONUTF8: '0' }, encoding: 'utf8', timeout: 60_000, + }); + expect(r.error).toBeUndefined(); + expect(r.status, r.stderr).toBe(0); + const bytes = fs.readFileSync(p('ours')); + expect(bytes.includes(0x0d)).toBe(false); + expect(bytes.toString('utf8')).toBe(ours + theirsLine + '\n'); +}); diff --git a/test/review-scope-windows-launch.test.ts b/test/review-scope-windows-launch.test.ts new file mode 100644 index 000000000..68c077bd5 --- /dev/null +++ b/test/review-scope-windows-launch.test.ts @@ -0,0 +1,60 @@ +/** + * #3541: `gstack-review-scope summary` reads the review log through + * gstack-review-read, a bash script. Spawned by path it never starts on + * Windows (no shebangs), so summary always refused with an empty + * "gstack-review-read failed:" and /ship could not write its review-loop file. + * + * Windows-safe by construction: both bins launch through + * lib/script-invocation.ts (Git Bash on win32), state lives under os.tmpdir(). + */ +import { afterEach, beforeEach, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { runScriptSync } from '../lib/script-invocation'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const SLUG = 'review-scope-launch'; + +let tmp = '', repo = '', state = '', env: NodeJS.ProcessEnv = {}; + +beforeEach(() => { + tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rs-launch-'))); + repo = path.join(tmp, 'repo'); + state = path.join(tmp, 'state'); + fs.mkdirSync(repo); + fs.mkdirSync(path.join(state, 'projects', SLUG), { recursive: true }); + const gitconfig = path.join(tmp, 'gitconfig'); + fs.writeFileSync(gitconfig, ''); + env = { + ...process.env, HOME: tmp, USERPROFILE: tmp, GSTACK_STATE_ROOT: state, GSTACK_HOME: state, GSTACK_PROJECT_SLUG: SLUG, + GIT_CONFIG_GLOBAL: gitconfig, GIT_CONFIG_NOSYSTEM: '1', GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_INDEX_FILE: undefined, + }; + const git = (...args: string[]) => { + const r = spawnSync('git', args, { cwd: repo, env, encoding: 'utf8', timeout: 30_000 }); + expect(r.status, r.stderr).toBe(0); + }; + git('init', '-q', '-b', 'feat'); + fs.writeFileSync(path.join(repo, 'a.txt'), 'a\n'); + git('add', 'a.txt'); + git('-c', 'user.email=fixture@example.test', '-c', 'user.name=Fixture', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'fixture'); +}); + +afterEach(() => fs.rmSync(tmp, { recursive: true, force: true })); + +test('summary reads the review log through gstack-review-read (#3541)', () => { + const rows = [ + { skill: 'review', via: 'ship', timestamp: '2026-10-26T10:00:00Z', review_scope: 'frozen', findings: [{ fingerprint: 'a:1:x', blocking: true, action: 'fixed' }] }, + { skill: 'review', via: 'ship', timestamp: '2026-10-26T10:30:00Z', review_scope: 'frozen', findings: [] }, + ]; + fs.writeFileSync(path.join(state, 'projects', SLUG, 'feat-reviews.jsonl'), rows.map(r => JSON.stringify(r)).join('\n') + '\n'); + const out = path.join(tmp, 'review-loop.json'); + const r = runScriptSync(path.join(ROOT, 'bin', 'gstack-review-scope'), ['summary', '--since', '2026-10-26T09:00:00Z', '--out', out, '--json'], { + cwd: repo, env, encoding: 'utf8', timeout: 120_000, + }); + expect(r.error).toBeUndefined(); + expect(r.status, r.stderr).toBe(0); + const summary = JSON.parse(fs.readFileSync(out, 'utf8')); + expect(summary).toMatchObject({ passes: 2, blocking_found: 1, fixed: 1, scope: 'frozen' }); +}, 150_000); diff --git a/test/script-invocation-tripwire.test.ts b/test/script-invocation-tripwire.test.ts index 3b14106d8..f7e0a2b2d 100644 --- a/test/script-invocation-tripwire.test.ts +++ b/test/script-invocation-tripwire.test.ts @@ -18,7 +18,7 @@ import * as path from 'node:path'; const ROOT = path.resolve(import.meta.dir, '..'); -const SPAWN_BIN_BY_PATH = /\b(?:spawn|spawnSync|execFile|execFileSync)\(\s*(?:path\.)?join\(\s*(?:BIN_DIR|binDir|BIN|bin|binRoot|BIN_ROOT)\b/; +const SPAWN_BIN_BY_PATH = /\b(?:spawn|spawnSync|execFile|execFileSync)\(\s*(?:path\.)?join\(\s*(?:(?:BIN_DIR|binDir|BIN|bin|binRoot|BIN_ROOT)\b|(?:path\.)?dirname\(|import\.meta\.dir\b)/; const SPAWN_BARE_BASH = /\b(?:spawn|spawnSync|execFile|execFileSync|execSync)\(\s*['"]bash['"]|Bun\.spawn(?:Sync)?\(\s*\[\s*['"]bash['"]|\[\s*['"]bash['"]\s*,\s*['"]-l?c['"]/; const COMMENT = /^\s*(?:\/\/|\*|\/\*)/; /** `const X = path.resolve(…, 'bin', 'gstack-…')`: a bin path held in a constant, then spawned by name (#3190's gstack-config). */ @@ -34,6 +34,8 @@ const KNOWN_SITES: Record = { 'bin/gstack-issue-links': 'outside this wave: the gstack-issue-guard spawn moves to runScriptSync', // Found when the bin-constant rule was added (fix wave PR 5, item 10b). 'bin/gstack-brain-cache': 'outside this wave: the gstack-config salience read moves to runScriptSync', + // Found when the scanner learned the import.meta.url bin path (#3541). + 'bin/gstack-ship-receipt': 'the gstack-session-kind spawn moves to runScriptSync in the #3541 ship-receipt follow-up', 'lib/autoplan-admission.ts': 'returns before the spawn on win32; the bin/gstack-lease touch moves to runScriptSync with that bin\'s owner', }; @@ -88,6 +90,8 @@ describe('#3197: shebang bins and bash go through lib/script-invocation.ts', () test('the scanner catches both shapes', () => { expect(SPAWN_BIN_BY_PATH.test("spawnSync(path.join(BIN_DIR, 'gstack-wtree'), [], { timeout: 1 })")).toBe(true); expect(SPAWN_BIN_BY_PATH.test('spawnSync(join(BIN_DIR, "gstack-slug"), { timeout: 1 })')).toBe(true); + expect(SPAWN_BIN_BY_PATH.test("spawnSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-review-read'), [], { timeout: 1 })")).toBe(true); + expect(SPAWN_BIN_BY_PATH.test("spawnSync(join(import.meta.dir, 'gstack-slug'), [], { timeout: 1 })")).toBe(true); expect(SPAWN_BARE_BASH.test("spawnSync('bash', ['-c', cmd], { timeout: 1 })")).toBe(true); expect(SPAWN_BARE_BASH.test('const argv = ["bash", "-c", command];')).toBe(true); expect(SPAWN_BARE_BASH.test("spawnSync(process.execPath, [script], { timeout: 1 })")).toBe(false); From 7ab354af949e7566244866c191e6191a4af2ad64 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 1 Nov 2026 17:22:20 -0800 Subject: [PATCH 02/13] fix(hooks): the timeline Stop hook bounds its stdin read (#3628) (#3715) A host that writes the Stop payload and keeps stdin open held the hook for as long as the pipe stayed open. Read for at most 500 ms, stop at the first complete JSON payload, and await the repair before exiting 0. Reported in #3628 (shahab, mashtii85); the read is written fresh on main. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 10 ++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- hosts/claude/hooks/timeline-stop-hook.ts | 45 ++++++++++++---- package.json | 2 +- scripts/lib/windows-curation.ts | 1 + test/timeline-stop-hook-stdin.test.ts | 65 ++++++++++++++++++++++++ 7 files changed, 114 insertions(+), 13 deletions(-) create mode 100644 test/timeline-stop-hook-stdin.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b7b4b6d1..55324ca81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.92.49.0] - 2026-11-01 + +**The timeline Stop hook returns within its 2-second budget even when the host keeps stdin open.** The hook read its payload with a blocking read that waits for end of input, so a host that writes the Stop payload and leaves the pipe open held the hook for as long as the pipe stayed open. A local run with the pipe held for 8 seconds took 8 seconds, past the hook's 2-second budget and on the way to Claude Code's 15-second hook window (#3628, reported on Windows). Rewritten from PR #3628 by shahab (mashtii85). + +### Fixed +- **`hosts/claude/hooks/timeline-stop-hook.ts`** reads stdin for at most 500 ms and stops at the first complete JSON payload, then repairs the timeline as before. The hook still exits 0 on every path. + +### Tests +- `test/timeline-stop-hook-stdin.test.ts` (runs on windows-latest): a complete payload on a pipe that never closes returns within budget and still writes the repair; an open pipe with no data returns within budget. Both failed before the fix (10 s, killed). + ## [1.92.48.0] - 2026-11-01 **On Windows, `gstack-review-scope summary` works again, and the JSONL merge driver no longer fails on non-English text or rewrites files with CRLF.** Before, the summary started the bash script `gstack-review-read` directly, which Windows cannot run, so it always refused with an empty "gstack-review-read failed:" message and /ship could not write its review-loop file (#3541). The `jsonl-append` merge driver wrote its result with Python's `print()`, so a line the console code page could not encode (Cyrillic text under cp1250, for example) made it exit 1 and git reported a conflict with no markers, and every merge on Windows turned LF files into CRLF (#3659). diff --git a/VERSION b/VERSION index c8b62ecd0..aa31f95fa 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.48.0 +1.92.49.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 7572774f2..766082e63 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.48.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.49.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/hosts/claude/hooks/timeline-stop-hook.ts b/hosts/claude/hooks/timeline-stop-hook.ts index a71255200..d5b2d42e4 100755 --- a/hosts/claude/hooks/timeline-stop-hook.ts +++ b/hosts/claude/hooks/timeline-stop-hook.ts @@ -59,6 +59,8 @@ import { readVersionedSlugCache } from '../../../lib/bin-context'; import { logHookError as sharedLogHookError } from './hook-log'; const DEADLINE_MS = 2000; +/** A host may keep stdin open after its payload (#3628); never wait for EOF past this. */ +const STDIN_DEADLINE_MS = 500; const REPAIR_SLUG_TIMEOUT_MS = 30_000; const LOCK_TTL_MS = 60_000; const MISS_TTL_MS = 5 * 60_000; @@ -161,9 +163,35 @@ function slugOverride(): string | undefined { return slug && slug !== '.' && slug !== '..' ? slug : undefined; } -function readStopCwd(): string { +/** The Stop payload: returns at EOF, at the first complete JSON object, or at the deadline. */ +function readStdinBounded(ms: number): Promise { + return new Promise((resolve) => { + let buf = ''; + const done = () => { + clearTimeout(timer); + process.stdin.removeAllListeners('data'); + process.stdin.destroy(); + resolve(buf); + }; + const timer = setTimeout(done, ms); + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk: string) => { + buf += chunk; + try { + JSON.parse(buf); + done(); + } catch { + // incomplete payload: keep reading until EOF or the deadline + } + }); + process.stdin.on('end', done); + process.stdin.on('error', done); + }); +} + +async function readStopCwd(): Promise { try { - const stdin = fs.readFileSync(0, 'utf8'); + const stdin = await readStdinBounded(STDIN_DEADLINE_MS); if (stdin.trim()) { const payload = JSON.parse(stdin) as { cwd?: string }; if (payload.cwd && fs.existsSync(payload.cwd)) return payload.cwd; @@ -209,12 +237,12 @@ function backgroundRepair(cwd: string): void { }); } -function main(): void { +async function main(): Promise { if (process.argv[2] === '--repair' && process.argv[3]) { backgroundRepair(process.argv[3]); return; } - const cwd = readStopCwd(); + const cwd = await readStopCwd(); const slug = slugOverride() ?? readVersionedSlugCache(resolveStateRoot(), cwd); if (!slug) { spawnRepair(cwd); @@ -318,9 +346,6 @@ function repairTimeline(slug: string, deadline: number): void { } } -try { - main(); -} catch (err) { - logHookError(`unexpected: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`); -} -process.exit(0); +main() + .catch((err) => logHookError(`unexpected: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`)) + .finally(() => process.exit(0)); diff --git a/package.json b/package.json index d1c96934b..0005fa92f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.48", + "version": "1.92.49", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/scripts/lib/windows-curation.ts b/scripts/lib/windows-curation.ts index 46331d6fd..1f3aa5474 100644 --- a/scripts/lib/windows-curation.ts +++ b/scripts/lib/windows-curation.ts @@ -408,6 +408,7 @@ export const KNOWN_WINDOWS_SAFE: Array<{ file: string; reason: string }> = [ { file: 'test/setup-hook-syntax-gate.test.ts', reason: '#3088/#3100: runs bin/gstack-hook-check and the setup hook sections through explicit bash with forward-slash paths; never executes a shebang. Its Bun drive-path spelling is the Windows bug it guards' }, { file: 'test/bun-config-shell-bins.test.ts', reason: '#3415: runs the record bins through lib/script-invocation.ts (Git Bash on Windows) with MSYS path conversion off, the case it guards; its bun and cygpath stand-ins and simulated-OSTYPE cases are skipIf win32' }, { file: 'test/path-spelling.test.ts', reason: 'sources bin/gstack-path-spelling.sh through explicit bash and checks lib/path-spelling.ts; on Windows it exercises the real Git Bash spellings' }, + { file: 'test/timeline-stop-hook-stdin.test.ts', reason: '#3628: the hook runs through Bun argv with a stdin pipe held open; the return-within-budget timing must meet real Windows process startup' }, { file: 'test/state-root-parity.test.ts', reason: 'runs the bash twin and lib/state-root.ts over an env table with PATH empty; no shebang execution, raw-string comparison is platform-neutral' }, { file: 'test/generator-eexist.test.ts', reason: 'E4: runs the generators through Bun argv with the Windows EEXIST emulation preload; no shebang execution' }, diff --git a/test/timeline-stop-hook-stdin.test.ts b/test/timeline-stop-hook-stdin.test.ts new file mode 100644 index 000000000..00db0e523 --- /dev/null +++ b/test/timeline-stop-hook-stdin.test.ts @@ -0,0 +1,65 @@ +/** + * #3628: a host that keeps the Stop hook's stdin open after writing its payload + * held the hook for as long as the pipe stayed open (fs.readFileSync(0) waits + * for EOF), well past the hook's 2 s budget and toward the host's 15 s RPC + * window. The hook must return within its budget and still repair the timeline. + * + * Windows-safe by construction: the hook runs through Bun argv + * (process.execPath), state lives under os.tmpdir(). + */ +import { afterEach, beforeEach, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { bunIsolationArgs } from '../lib/bun-isolation'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const HOOK_TS = path.join(ROOT, 'hosts', 'claude', 'hooks', 'timeline-stop-hook.ts'); +const SLUG = 'stop-hook-stdin'; +const BUDGET_MS = 2_000; +const STARTUP_ALLOWANCE_MS = process.platform === 'win32' ? 3_000 : 1_500; + +let home = '', project = '', timeline = ''; + +beforeEach(() => { + home = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-stop-stdin-home-'))); + project = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-stop-stdin-proj-'))); + fs.mkdirSync(path.join(home, 'projects', SLUG), { recursive: true }); + timeline = path.join(home, 'projects', SLUG, 'timeline.jsonl'); + fs.writeFileSync(timeline, JSON.stringify({ skill: 'review', event: 'started', session: '1-1' }) + '\n'); +}); + +afterEach(() => { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(project, { recursive: true, force: true }); +}); + +async function runHookHoldingStdin(payload: string | null): Promise<{ code: number | null; ms: number }> { + const started = performance.now(); + const child = Bun.spawn([process.execPath, ...bunIsolationArgs(), HOOK_TS], { + stdin: 'pipe', stdout: 'ignore', stderr: 'ignore', + env: { ...process.env, GSTACK_HOME: home, GSTACK_STATE_ROOT: home, GSTACK_PROJECT_SLUG: SLUG }, + }); + if (payload !== null) { child.stdin.write(payload); child.stdin.flush(); } + const kill = setTimeout(() => child.kill(), 10_000); + const code = await child.exited; + clearTimeout(kill); + const ms = performance.now() - started; + try { child.stdin.end(); } catch { /* already closed */ } + return { code, ms }; +} + +const repairs = () => fs.readFileSync(timeline, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)).filter(e => e.source === 'stop-hook'); + +test('a complete payload on a pipe that never closes: returns within budget and repairs (#3628)', async () => { + const r = await runHookHoldingStdin(JSON.stringify({ hook_event_name: 'Stop', cwd: project })); + expect(r.code).toBe(0); + expect(r.ms).toBeLessThan(BUDGET_MS + STARTUP_ALLOWANCE_MS); + expect(repairs()).toHaveLength(1); +}, 20_000); + +test('an open pipe with no data: returns within budget (#3628)', async () => { + const r = await runHookHoldingStdin(null); + expect(r.code).toBe(0); + expect(r.ms).toBeLessThan(BUDGET_MS + STARTUP_ALLOWANCE_MS); +}, 20_000); From 90a170c941ac2a661ba39b4cb0168ea9c96acaa7 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 1 Nov 2026 17:35:47 -0800 Subject: [PATCH 03/13] v1.92.50.0 wave(autoplan): #3781 #3743 #3751 #3762 (#3788) * fix(eval-plan): per-arm pilot projection and --seat-metered arms (#3653) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): guard tolerates Claude Code's cwd-reset footer and recovers after an unreadable init (#3651) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (journal reader strips the cwd-reset footer; an init with no text block no longer locks the session) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: journal reader skips a failed or unreadable init, as invocation() does Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(autoplan): --ceo-only runs the CEO review alone with zero questions (GST-537) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(eval-plan): all-seat pilot, single-attempt arm and --seat-metered CLI wiring Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(eval-plan): summed deviation adds arm deviations, matching the summed band (arms share rows) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (test pins an unpriced seat-metered arm) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (seat-metered declaration fixed per --out, price row still required, a real charge settles as spent) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: classify autoplan-ceo-only in the Codex host metadata Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: a seat-metered arm that settles a charge is projected as a priced arm Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: select autoplan-ceo-only on the skills it runs; say it needs Claude Code Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(skills): /eval-plan --seat-metered and per-arm projection Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(eval-plan): skill prose stays under its context budget and carries no $N token Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: run autoplan-ceo-only in the marathon lane Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(skills): a seat-metered arm leaves the seat set once it settles a charge Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: changelog entry for /autoplan --ceo-only Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(autoplan): init_failed covers unreadable init output and the cwd-reset line (#3651) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: count the autoplan-ceo-only paid file in the shard inventory Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(autoplan): #3651 tests pass the test-code type ratchet Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(dx): CAP finding titles carry a one-line description (GBRA-223 claim 2) The DX error contract told both autoplan DX voices to title capped findings exactly CAP4::, so gates, tasks and reconcile saw a bare code. The title is now CAP4:: ; the grader already parses that shape. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): reconcile pairs same-file findings that carry no line (GBRA-223 claim 1) Plan reviews name files, rarely lines, and reconcile associated by location only when both findings had a line, so findings both voices raised on one file were filed new and native-only. A same-file pair with a missing line is now associated as unverified (basis file), never confirmed; a CAP code no longer counts in title comparison. DRIVER.md names submit --reconcile. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): open Critical and High findings reach the gate as apply items (GBRA-223 claim 3) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): a spend cap binds on every unattended host; --estimate-usd persists; DRIVER names --usage-usd (GBRA-223 claim 4) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): run binds complete api results, names a truncated response, --max-tokens; DRIVER documents run (GBRA-223 claim 6) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(autoplan): carry an item-by-item answer when the later gate asks nothing new (GBRA-223 claim 7) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(dx): trim the compact error contract so plan-devex-review stays under its size pin Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (p1b applies finding fixes, api attempts reserve no host default, cross-file title pairing, paired severity counted) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: review fixes (priced api reservations, no file-only twins, two-pass pairing, partial replies keep p1-dependent defaults open, outside refusal degrades) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * P7: record whose words answered a gate (--words-by agent:), never carry an agent's own words, import each row under its own answerer (GEVA-2) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: review pass 2 (p1b needs --rec-hash after recommend; api calls send the output limit they reserve for) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: fragment for the /autoplan field-report wave (GST-672) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: captain record notes --words-by agent attribution Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: follow finding apply items in approval-class and Captain flow tests; bind run-api limit to DEFAULT_MAX_TOKENS Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * wave autoplan: lower the gemini plan-devex-review pin to its exact size after #3781's DX trim Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): CAP titles on different paths never match by title in reconcile or confirm rounds (wave review) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): a truncated api call settles its cost and a retry is admitted afresh against the cap Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): an applied finding stays open until the revised plan is reviewed; a shared location alone never twins two findings Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(approval): a plan gate answered in an agent's own words is not owner approval Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(gate): a new answer replaces the earlier answer's authorship and words Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): a failed api call with no usage keeps its attempt open; reservations price the bound prompt (wave fix-check) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): a spend cap fails closed: only a call refused before sending keeps its attempt; a sent call with unknown usage settles at its reservation Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(autoplan): the 16384 output default applies to both providers; changelog for the wave's integrator fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(autoplan): how run settles a failed api call; changelog scopes it to the native voice Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): a spend cap fails closed on every exit of run; an unpriced model reserves the default once a cap is set Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(autoplan): every sent call settles; an unpriced model under a cap reserves the default Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(autoplan): DRIVER and help say a cap reserves in any session kind and how run settles a sent call Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 40 ++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- autoplan/DRIVER.md | 22 +- autoplan/SKILL.md | 1 + autoplan/bin/guard-journal.ts | 10 +- autoplan/bin/phase-publication-hook.ts | 21 +- autoplan/sections/ceo-only.md | 41 ++++ autoplan/sections/ceo-only.md.tmpl | 39 ++++ autoplan/sections/manifest.json | 6 + bin/gstack-autoplan | 66 ++++-- bin/gstack-autoplan-snapshot.ts | 2 +- bin/gstack-ceo-mode-handoff | 10 +- bin/gstack-eval-plan | 10 +- bin/gstack-outside-voice | 18 +- docs/agent-ready-errors.md | 4 +- docs/autoplan-guard-troubleshooting.md | 7 + docs/captain.md | 2 +- docs/skills.md | 8 +- docs/troubleshooting.md | 14 ++ docs/unattended.md | 87 ++++++- eval-plan/SKILL.md | 16 +- eval-plan/SKILL.md.tmpl | 16 +- lib/approval-citations.ts | 1 + lib/approval-record.ts | 7 +- lib/autoplan-budget.ts | 28 ++- lib/autoplan-carry.ts | 20 +- lib/autoplan-confirm.ts | 17 +- lib/autoplan-gate.ts | 44 +++- lib/autoplan-reconcile.ts | 36 ++- lib/autoplan-run.ts | 68 +++++- lib/autoplan-state.ts | 4 +- lib/dx-error-contract.ts | 8 +- lib/eval-plan.ts | 71 ++++-- lib/gate-list.ts | 23 +- lib/gate-outcomes.ts | 5 + lib/headless-artifacts.ts | 6 +- lib/outside-voice-api.ts | 16 +- lib/outside-voice-runner.ts | 5 +- package.json | 2 +- plan-devex-review/dx-hall-of-fame.md | 4 +- plan-devex-review/sections/review-sections.md | 2 +- scripts/resolvers/autoplan-driver.ts | 12 +- test/approval-classes.test.ts | 26 ++- test/approval-record.test.ts | 12 +- test/autoplan-answer-invalidation.test.ts | 34 +-- test/autoplan-budget.test.ts | 38 +++- test/autoplan-capy-e2e.test.ts | 18 +- test/autoplan-carry.test.ts | 80 ++++++- test/autoplan-ceo-only.test.ts | 123 ++++++++++ test/autoplan-gate-cost.test.ts | 4 +- test/autoplan-gate-judgment.test.ts | 112 ++++++++- test/autoplan-pairing.test.ts | 59 ++++- test/autoplan-publication-guard.test.ts | 63 ++++++ test/autoplan-run-api.test.ts | 213 ++++++++++++++++++ test/autoplan-run-recovery.test.ts | 3 +- test/capy-captain-flow.test.ts | 7 +- test/dx-error-contract-grader.test.ts | 4 +- test/dx-error-contract.test.ts | 2 + test/eng-finding-retry-budget.test.ts | 2 +- test/fixtures/context-budget.json | 2 +- test/fixtures/skill-md-size-allowlist.json | 18 +- test/helpers/autoplan-ceo-only-evidence.ts | 96 ++++++++ test/helpers/autoplan-run-fixture.ts | 10 + test/helpers/carve-guards.ts | 2 +- test/helpers/touchfiles-data.ts | 8 + test/outside-runner-contract.test.ts | 43 ++++ test/pricing-eval-plan.test.ts | 102 +++++++++ test/skill-e2e-autoplan-ceo-only.test.ts | 108 +++++++++ test/touchfiles.test.ts | 6 +- 70 files changed, 1769 insertions(+), 249 deletions(-) create mode 100644 autoplan/sections/ceo-only.md create mode 100644 autoplan/sections/ceo-only.md.tmpl create mode 100644 test/autoplan-ceo-only.test.ts create mode 100644 test/autoplan-run-api.test.ts create mode 100644 test/helpers/autoplan-ceo-only-evidence.ts create mode 100644 test/skill-e2e-autoplan-ceo-only.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 55324ca81..33db702f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # Changelog +## [1.92.50.0] - 2026-11-01 + +### Fixed +- **`/autoplan` no longer locks a Claude Code session after a snapshot init run as `cd && …` (#3651).** When that `cd` leaves the session's folder, Claude Code adds a `Shell cwd was reset to …` line to the command's output. The guard now removes exactly that one line before reading the init or checkpoint JSON; any other extra text still fails. An init whose output still can't be read now counts as a failed init with a "re-run" hint, so a later clean init in the same session works again. Before, every later phase read was denied with code `evidence` until a new session. + +### Fixed +- **`/eval-plan` pilots with a subscription arm can now be approved, and the cost projection is per arm (#3653).** `gstack-eval-plan pilot start --seat-metered ` declares an arm that runs on a subscription with no extra charge (for example `claude -p` on a seat). Its attempts hold none of the spend cap, settle as `unknown` (an arm that settles a real charge is projected like any priced arm), and get their own `PILOT_SEAT_METERED:` line, so the verdict covers the arms that cost money. `pilot report` now projects each arm from its own attempts (one `PILOT_ARM:` line each) and adds them up. Before, it averaged all arms together: a priced arm's cost was multiplied across arms with no price, and two arms at different prices got a band so wide that a pilot that fit the cap was told it exceeded it. The skill also says to stop a seat-metered run at its first quota or rate-limit error, with no retry (idea from #3273 by @merit-blake). + +**`/autoplan --ceo-only` runs the CEO review by itself and asks nothing from start to finish (GST-537).** Garry asked for a CEO review that "just automatically does the right thing", and the planning eval needed a gstack CEO arm that a harness can run without a simulated user. Before, /autoplan always ran the Eng review too and stopped at a final approval question, and standalone /plan-ceo-review asks for the mode, offers to chain to the next skill and adds a Codex voice. + +### Added +- **`/autoplan --ceo-only`** (`autoplan/sections/ceo-only.md`): runs Phase 0, the outside-reviewer preflight and the CEO phase only. Every question takes its recommendation and gets an audit-trail row; consent and destructive options take the conservative choice. The review mode comes from plan-ceo-review's 0E rule. User challenges are never applied; each one is listed at the top of `/ceo-review.md`, with scope added, conservative choices, findings and the audit trail. The run ends with `GSTACK_RESULT: skill=autoplan status=reviewed run=` and approves nothing. +- **`--no-outside`** with `--ceo-only` skips the outside voice, so one model does the whole review. +- **`gstack-ceo-mode-handoff --ceo-only`** prints `Auto-selected review mode → (CEO-only run: Step 0E's rule)` instead of crediting a saved preference. +- **`status=reviewed`** joins the `GSTACK_RESULT` statuses (`docs/unattended.md`, "CEO-only runs"). + +### For contributors +- The paid case `autoplan-ceo-only` fails if the mode asks anything, skips a later phase's file, or leaves new files in HOME outside its folders. It takes 6 to 16 minutes, so it runs in the weekly marathon lane, not on autoplan PRs. The mode needs Claude Code for now. + +**/autoplan fixes from a field report (GBRA-223): findings without a line number pair across voices, open Critical and High findings reach the gate, a spend cap binds on any host, `run` works with the API runner, and an earlier item-by-item answer carries forward.** OpenClaw's /autoplan run on a real plan hit six problems at once. We reproduced each on main with a failing test first (GST-672). A follow-up from GEVA-2 adds a record of whose words answered a gate. + +### Fixed +- **Plan reviews that name a file but no line now pair across voices.** A same-file pair, with a line missing on either side, is associated as `unverified` instead of both findings counting as new. Title matching ignores a leading DX cap code (`CAP4::`) and never pairs findings on two different files. The same-file fallback runs only after every title and line match is placed, and a same-file pair never merges two findings into one gate item. +- **DX cap findings carry real titles.** `CAP4` and `CAP6` findings are titled `CAP:: `, so the gate shows the problem and not only the item name. +- **Every open Critical or High finding becomes a gate item.** `a` applies the reviewer's fix to the plan and is recommended. `b` carries it into the build as a blocking task. `rec` and `p1b` apply the fix. An unanswered item keeps the plan as written unless the reply is `p1b`, and a partial reply never locks that choice early. A finding carried with `b` stays open, so a rerun that raises it again asks again. +- **A spend cap binds on any host.** With `--spend-cap` set and no `--estimate-usd`, each host-subagent attempt reserves the default $5 per voice, on Capy or off it, in any session kind, and so does an API call whose model has no price. `next --estimate-usd ` sets the reservation for the rest of the run. `run` reserves each API call's priced worst case (prompt size plus the output limit) and settles it at its real token cost, so `--spend-cap 0` refuses before any call. +- **`gstack-autoplan run` with `--native-runner api --runner api` reaches the gate.** Results are checked as autoplan results, so they bind. A response cut off at the output limit is named `truncated` and interrupts the run instead of binding a partial review. The default output limit is 16384 tokens for both providers (OpenAI counts reasoning tokens toward it), `--max-tokens ` sets it, and an outside result refused at submit leaves the run native-only instead of aborting it. +- **An earlier item-by-item answer carries forward when nothing changed.** When every item on the new gate is carried by origin, the owner's `p1` choice is replayed and their tokens are recorded verbatim as the basis. Any new or changed item still goes to the owner. +- **A spend cap stays closed when a call fails.** For the native voice, a call cut off at its output limit is settled at what it cost, and a retry with a larger `--max-tokens` is admitted against the cap afresh. A call that timed out, dropped, was cancelled or had its result refused after it was sent is settled at its usage, or its full reservation when the usage is unknown, because the provider may have billed it. A call still in flight when the process died is charged its reservation by the next `run`, which then sends a fresh attempt. A call refused before anything was sent (no API key, for example) charges nothing, and the rerun reuses its reservation. A failed outside call is still counted at its full reservation while the phase continues native-only. +- **A finding you chose to fix stays open until the revised plan is reviewed.** If the reviewer raises it again after a revise that missed it, it comes back to the gate instead of being treated as settled. A withdrawn fix leaves its finding open. +- **Two findings that only sit near each other are no longer merged.** A user challenge one line away from an unrelated Critical no longer hides the Critical. Findings are merged only when their titles match or the parent says they are the same problem. +- **A plan gate answered in an agent's own words is not owner approval.** `gstack-ship-receipt` rejects a plan-sha approval citation whose gate was answered with `--words-by agent:`. +- **A later gate answer replaces the earlier answer's authorship.** When someone else answers again, the earlier answerer's words, `words_by` and carry fields are cleared, so the approval record names who actually answered. + +### Added +- **`gstack-autoplan answer --words-by agent:`** records that the words came from the answering agent itself, as `answer_words` and `words_by`, never as `owner_reply`. The default stays `owner`. A later gate never carries those words forward, and `gstack-approvals import` lists that row under the agent, while `--by` names only the rows that hold the owner's words. + +### Tests +- New failing-first tests in test/autoplan-pairing.test.ts, test/autoplan-gate-judgment.test.ts, test/autoplan-budget.test.ts, test/autoplan-run-api.test.ts (a local fake provider, no key or network), test/autoplan-carry.test.ts and test/approval-record.test.ts. + ## [1.92.49.0] - 2026-11-01 **The timeline Stop hook returns within its 2-second budget even when the host keeps stdin open.** The hook read its payload with a blocking read that waits for end of input, so a host that writes the Stop payload and leaves the pipe open held the hook for as long as the pipe stayed open. A local run with the pipe held for 8 seconds took 8 seconds, past the hook's 2-second budget and on the way to Claude Code's 15-second hook window (#3628, reported on Windows). Rewritten from PR #3628 by shahab (mashtii85). diff --git a/VERSION b/VERSION index aa31f95fa..89ada282a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.49.0 +1.92.50.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 766082e63..beefb3031 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.49.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.50.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/autoplan/DRIVER.md b/autoplan/DRIVER.md index 875ea1068..ff551298e 100644 --- a/autoplan/DRIVER.md +++ b/autoplan/DRIVER.md @@ -57,28 +57,28 @@ line yourself; gstack never writes the plan. ### Commands -- `gstack-autoplan next --out --plan [scope flags] [--light] [--spend-cap ] [--max-rounds ] [--scaffold]` - first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice; --max-rounds caps review rounds per phase (default 2 unattended), and an unattended Capy run reserves 5.00 per host attempt unless --estimate-usd says otherwise, so --spend-cap binds. +- `gstack-autoplan next --out --plan [scope flags] [--light] [--spend-cap ] [--max-rounds ] [--estimate-usd ] [--scaffold]` + first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice; --max-rounds caps review rounds per phase (default 2 unattended); a run with --spend-cap, in any session kind (or any unattended Capy run), reserves 5.00 per voice for each attempt, so the cap binds; --estimate-usd sets that reservation and the run keeps it for later phases. +- `gstack-autoplan run --out --plan --native-model --outside-model --native-runner api --runner api [--max-tokens ] [--spend-cap ]` + hosts without subagents: one command calls both models directly (ANTHROPIC_API_KEY, OPENAI_API_KEY) and loops next/submit to the gate; a response cut off at the output limit is reported truncated, so raise --max-tokens (default 16384 for both providers; OpenAI counts reasoning tokens toward it); each api call reserves its priced worst case (a model with no price reserves the per-voice default under a cap), a native call that went out but bound no result settles at its usage, or its full reservation when the usage is unknown, and a failed outside call stays counted at its reservation. - `gstack-autoplan next --out [--redispatch ]` later calls: open the next phase or reprint open attempts; never redispatches on its own. - `gstack-autoplan submit --out --phase

--voice native|outside --record --model [--attempt ]` bind a free-form review record (canonical fence, one findings table or list, or `No findings.`). - `gstack-autoplan submit ... --record --attest "" --attempt ` bind a record with no INPUT: line as receipt: attested (a wrong INPUT: always refuses). -- `gstack-autoplan submit --out

--phase

--voice native|outside --result --model --attempt ` - bind a canonical result (the RESULT FORMAT below). +- `gstack-autoplan submit --out

--phase

--voice native|outside --result --model --attempt [--usage-usd ] [--reconcile ]` + bind a canonical result (the RESULT FORMAT below); `--usage-usd` settles the attempt at what the reviewer actually cost (else it stays priced unknown at its reservation); on the submit that closes the phase, `--reconcile` takes your own pairing of findings both voices raised, one `{"outside_id","native_id","disposition","resolution"}` row each, over the structural one. - `gstack-autoplan submit --out

--phase

--voice outside --unavailable|--failed ""` close the phase native-only when no outside voice can run. -- `gstack-autoplan answer --out

--gate-rev --reply "" --by human|agent: [--owner-reply |-] [--rec-hash ]` - answer the gate with the reply grammar below; an agent answer stores the owner's words verbatim. +- `gstack-autoplan answer --out --gate-rev --reply "" --by human|agent: [--owner-reply |-] [--words-by agent:] [--rec-hash ]` + answer the gate with the reply grammar below; an agent answer stores the owner's words verbatim, and passes --words-by agent: whenever the words are its own (the default is owner). - `gstack-autoplan recommend --out --gate-rev --item --option --basis ""` change one recommendation before relay; then render and relay again (a `rec` needs the new `--rec-hash`). - `gstack-autoplan revise --out --plan --incorporates ` bind the amended plan; reopens every phase whose reviewed plan changed, Eng last. -- `gstack-autoplan revise --out --settle ` - discharge a packaging or ordering change with no plan edit; reopens nothing. -- `gstack-autoplan revise --out --withdraw ` - drop a pending amendment instead of applying it. +- `gstack-autoplan revise --out --settle | --withdraw ` + --settle discharges a packaging or ordering change with no plan edit and reopens nothing; --withdraw drops a pending amendment instead of applying it. - `gstack-autoplan close --out --phase

--deferred "" --by human|agent:` close a phase accepted-with-findings: its open findings go to tasks.jsonl, and Critical, urgent or user-challenge findings become gate items; also the recovery from `STOP: reason=budget_exhausted`. - `gstack-autoplan budget --out

[--spend-cap ] [--max-rounds ] --by human|agent:` @@ -111,7 +111,7 @@ A reply binds to the `gate_rev` you read (`--gate-rev`). Bare `yes`/`no` is unpa - `rec`: every item takes its recommendation, plan changes included (each prints APPLY:); `all` and `do it` are aliases. - `as-is`: keep every direction; no plan change. -- `d3b uc1a`: one