From 98dd72ffc3f6bbce4fd5548593c6c87592e17ed8 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 28 Oct 2026 12:09:45 -0700 Subject: [PATCH] v1.91.296.0 fix(flaky): ship-docsync gates accept the docs helper's --help and git tag listings in a line of reads (GSTA-139 S1) (#3542) * fix(flaky): docsync detectors admit the candidate's --help and git tag listings in a line of reads (GSTA-139 S1) Gate edit. Five red ship-docsync-current/store parents (screen 40558235014, gates 40600731931 and 40643690306, screen 40627127462 and its rerun) each had exactly one refused Bash line: four joined gstack-docs-candidate --help onto a line of reads, one put git tag in one. v1.91.185.0 tried prompt clarity for the --help join and the slip recurred four times. The --help path prints usage and returns before touching Git or a file, so it now stands in a line of reads like a Git read (never pipe-fed, never after cd); operand-free git tag listings join the branch listings #3516 admitted. The fixture interface states both. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): docsync gate accepts the candidate's --help and git tag listings in a line of reads 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 | 14 +++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- package.json | 2 +- test/docsync-command-grammar.test.ts | 63 ++++++++++++++++++++++++---- test/helpers/docsync-interface.ts | 4 +- test/helpers/docsync-observer.ts | 23 ++++++---- 7 files changed, 91 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc9780e57..d12ecffa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.91.296.0] - 2026-10-28 + +**The docsync gate stops failing ship-docsync-current and ship-docsync-store when the agent asks the docs helper for its usage in the middle of a line of reads.** Five red paid trials since 2026-10-27 each failed on a single refused line. In four, the agent ended a line of reads with `gstack-docs-candidate --help` (screen 40558235014, gate 40600731931, and screen 40627127462 twice). In the fifth, gate 40643690306, the line included `git tag`. v1.91.185.0 tried to stop the first slip by moving the "own Bash call" rule into the sentence that names `--help`, and the slip came back four times. The helper's `--help` prints its usage and returns before it touches Git or any file, so refusing it protected nothing. The 2026-10-27 backstop red (40515158924, `toContain`) ran on a tree from before #3516, which had already fixed it. + +### Fixed +- **`test/helpers/docsync-observer.ts`**: a line of reads may now include the installed candidate's `--help`, either alone or after `snapshot` or `compare`, optionally with `2>&1` or `2>/dev/null`. It counts as a neutral read, the same way a joined Git read does, but never when it is fed by a pipe and never in a line that also runs `cd`. `git tag`, `git tag -l` and `git tag --list` are now accepted as Git reads, alongside the branch listings #3516 added. A tag operand or any other option is still refused. +- **`test/helpers/docsync-interface.ts`**: the fixture interface declares both changes. It names the candidate's `--help` as the one exception to the own-call rule, and it adds tag listings to the Git read list. + +### Tests +- `test/docsync-command-grammar.test.ts` replays the five captured lines, which were refused before this change, and checks that each is now accepted, while the same line with `; git add app.ts` appended is still refused. Negative controls that stay refused: pipe-fed, `| sh`, `>` redirect and `cd` forms; `snapshot` and `compare` joined to reads; `-h`, `--version` and `--help snapshot`; the `~` and foreign helper paths; the lifecycle helpers' `--help` joined to reads; and 13 tag writes or filtered listings. + +### Gate integrity +- This is a gate edit, because the docs detector now accepts more lines. The test that pinned the joined `--help` as refused now expects it to be accepted. Focused paid trials at e967634: ship-docsync-current PASS ($0.84; its agent did join `--help` onto a line of reads) and ship-docsync-store PASS ($0.84). + ## [1.91.295.0] - 2026-10-28 **Deleting or renaming a test no longer fails pregate with PREGATE_NO_LANE.** A PR that removed a test file, or renamed one, failed `lanes` because the removed path had no test lane of its own, so cleanup PRs needed a hand-written `dependencies` entry to pass (#3529). Pregate now accounts for a removed test the way it accounts for an edit. A renamed test runs under its new name, a deleted test runs the remaining tests of the files it exercised, and either one runs the tests of any file that still names it, such as a curation list. Every waived test is printed on its own line, so the reason is visible in the gate output. Removing any other file still needs a lane, as before. diff --git a/VERSION b/VERSION index c7e4e95c1..ecfb77db2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.295.0 +1.91.296.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 358c63a00..738d6cf05 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.295.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.296.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/package.json b/package.json index 3532e7b54..a90776814 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.295", + "version": "1.91.296", "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/test/docsync-command-grammar.test.ts b/test/docsync-command-grammar.test.ts index 8b24c1e11..c11cc4823 100644 --- a/test/docsync-command-grammar.test.ts +++ b/test/docsync-command-grammar.test.ts @@ -234,16 +234,65 @@ test('the installed docs-candidate helper is admitted only with a private .json expect(docsNativeInterface(fixture)).toContain('compare and --help commands'); }); -// Train gate 39904966815 (ship-docsync-completion, attempt 1) and GSTA-63's focused run joined the candidate's -// --help onto a line of reads. The join stays refused; the own-call rule now sits in the sentence naming --help. -test('a candidate --help joined to reads stays refused, and the interface says so where it names the command', () => { +// Train gate 39904966815 and GSTA-63's focused run joined the candidate's --help onto a line of reads; v1.91.185.0 moved the +// own-call rule into the sentence naming --help and the slip came back four times (JOINED_READ_LINES below). The --help only +// prints usage, so it now joins a line of reads like a Git read; every other own-call command stays alone. +test('a candidate --help joined to reads is a read, and the interface names it as the one exception to the own-call rule', () => { const skills = fixture.skills.split(path.sep).join('/'); - const joined = `git diff; git diff --cached; cat options.ts; ls -la handbook handbook/reference handbook/reference/commands; ${skills}/bin/gstack-docs-candidate --help`; - expect(docsCommandAllowed(joined, fixture)).toBe(false); - expect(docsCommandAllowed(`${skills}/bin/gstack-docs-candidate --help`, fixture)).toBe(true); + const candidate = `${skills}/bin/gstack-docs-candidate`; + const joined = `git diff; git diff --cached; cat options.ts; ls -la handbook handbook/reference handbook/reference/commands; ${candidate} --help`; + expect(docsCommandAllowed(joined, fixture)).toBe(true); + for (const command of [`${candidate} --help`, `${candidate} --help 2>&1`, `git status; ${candidate} snapshot --help`, `${candidate} compare --help 2>/dev/null; git log --oneline -3`, + `${candidate} --help | head -20`]) expect(docsCommandAllowed(command, fixture), command).toBe(true); + const record = path.join(fixture.home, 'c.json'); + for (const command of [ + `git status; ${candidate} snapshot --out ${record} --audit-id a --mode edit --base main`, `git status; ${candidate} compare ${record}`, + `${candidate} --help; git add app.ts`, `${candidate} --help > ${path.join(fixture.home, 'usage.md')}`, `${candidate} --help | sh`, + `echo x | ${candidate} --help`, `git status | ${candidate} --help`, `cd ${fixture.repo} && ${candidate} --help`, `git status; ${candidate} --help snapshot`, + `git status; ${candidate} --version`, `git status; ${candidate} -h`, `git status; ${candidate} --help --exec rm`, + `git status; ~/.claude/skills/gstack/bin/gstack-docs-candidate --help`, `git status; ${path.join(path.dirname(fixture.home), 'bin/gstack-docs-candidate')} --help`, + `git status; ${skills}/bin/gstack-skill-start --help`, `git status; ${skills}/bin/gstack-skill-end --help`, + ]) expect(docsCommandAllowed(command, fixture), command).toBe(false); const sentence = docsNativeInterface(fixture).split(/(?<=\.)\s+(?=[A-Z])/).find(part => part.includes('gstack-docs-candidate snapshot')); expect(sentence).toContain('as its own Bash call, with nothing joined before or after it'); - expect(sentence).toContain('a --help is no exception'); + expect(sentence).not.toContain('a --help is no exception'); + expect(docsNativeInterface(fixture)).toContain(`So may the installed ${candidate} --help, alone or after snapshot or compare, because it only prints usage (the one exception)`); +}); + +// Five red ship-docsync parents, each red only on 'command outside declared docs observation interface', and each with exactly +// one refused line (every other Bash call of the five captures replays clean): four joined the candidate's --help onto a line of +// reads, and gate 40643690306 put `git tag` in one. {home} is the fixture home; the sha is the capture file's sha256 prefix. +const JOINED_READ_LINES: Array<[source: string, scenario: 'current' | 'store', line: string]> = [ + ['screen 40558235014 slice 5 @ 70cedc3d, capture c73fb9d0b1b0', 'current', + 'git diff main HEAD; cat personal-note.txt; ls -la; find handbook; {home}/.claude/skills/gstack/bin/gstack-docs-candidate --help'], + ['gate 40600731931 slice 4 @ 5fef38a5, capture 606503d8b78a', 'store', + 'cat handbook/reference/commands/widget.md.tmpl; echo ---; cat CHANGELOG.md TODOS.md VERSION package.json AGENTS.md; echo ---; cat personal-note.txt; echo ---; find handbook; {home}/.claude/skills/gstack/bin/gstack-docs-candidate --help'], + ['screen 40627127462 slice 5 @ 76a155d4, capture 67fa4dea377a', 'current', + 'git diff main...HEAD; cat handbook/reference/commands/widget.md.tmpl; ls -la handbook handbook/reference handbook/reference/commands; cat personal-note.txt | head -3; {home}/.claude/skills/gstack/bin/gstack-docs-candidate --help'], + ['screen 40627127462 rerun 1 @ 76a155d4, capture 53d06fea1a89', 'current', + 'git diff main HEAD; git rev-parse origin/main; {home}/.claude/skills/gstack/bin/gstack-docs-candidate --help 2>&1'], + ['gate 40643690306 slice 4 @ c188a78c, capture 6ac1902bd83f', 'store', + 'git rev-parse HEAD origin/main; git merge-base main HEAD; git tag; cat {home}/fixture-interface.md | head -5; ls {home}'], +]; + +test.each(JOINED_READ_LINES)('captured joined read line from %s is a declared read', (_source, scenario, line) => { + const owned = fixtureDocs(scenario); + try { + const command = line.replaceAll('{home}', owned.home); + const publish = path.join(owned.home, 'fixture-publish.ts'); + expect(docsCommandAllowed(command, owned, [publish])).toBe(true); + expect(docsToolFailures(nativeResult(command, 'usage: gstack-docs-candidate snapshot ...'), owned, [publish], true)).toEqual([]); + expect(docsCommandAllowed(`${command}; git add app.ts`, owned, [publish])).toBe(false); + } finally { owned.clean(); } +}); + +test('operand-free tag listings are reads, and every tag write stays refused', () => { + for (const listing of ['git tag', 'git tag -l', 'git tag --list', 'git status; git tag; git log --oneline -3']) + expect(docsCommandAllowed(listing, fixture), listing).toBe(true); + for (const write of ['git tag v1', 'git tag -a v1 -m release', 'git tag -d v1', 'git tag -f v1 HEAD~1', 'git tag -s v1', 'git tag -v v1', 'git tag -l "v*"', + 'git tag --contains HEAD', 'git tag -n', 'git tag --sort=-v:refname', 'git tag -l v1', 'git tag; git tag v2', 'git tag | sh']) + expect(docsCommandAllowed(write, fixture), write).toBe(false); + expect(docsNativeInterface(fixture)).toContain('tag listings with no operand and only -l/--list'); }); test('the installed lifecycle helpers are admitted for their own --help, which starts nothing', () => { diff --git a/test/helpers/docsync-interface.ts b/test/helpers/docsync-interface.ts index 41370692d..607dd68ae 100644 --- a/test/helpers/docsync-interface.ts +++ b/test/helpers/docsync-interface.ts @@ -15,13 +15,13 @@ export const docsCapsCommand = (skills: string) => `${skills}/bin/gstack-ship-re export function docsNativeInterface(fixture: Pick, 'home' | 'repo' | 'skills'> & Partial, 'env'>>, scripts: string[] = [], transport = false, insert = false, file?: string): string { const skills = fixture.skills.split(path.sep).join('/'); const state = fixture.env?.GSTACK_HOME ?? path.join(fixture.home, 'state'); - return `Fixture observation interface (applies to parent and every child; ${file ? `saved at ${file}: each child prompt tells the child to Read that file first instead of copying this text` : 'include this interface in child prompts'}): Bash may execute only separate literal pwd, ls, cat, stat, sha256sum and Git read commands (status, diff, show, log, ls-files, ls-tree, check-ignore, rev-parse, merge-base, hash-object without -w, branch listings with no operand and only --show-current, -a/--all, -r/--remotes, -v/--verbose, -l/--list, --no-color or --no-abbrev (no --merged, --sort, --format or --contains), describe with only --tags, --always, --abbrev= or --exact-match and at most one revision). Bash may also run each of the following as its own Bash call, with nothing joined before or after it by ;, &&, || or a pipe (a --help is no exception, so never add one to a line of reads): the exact generated Preamble block with its spawned prefix, literal installed gstack-skill-start/gstack-skill-end commands for document-release (start requires GSTACK_SESSION_KIND=spawned) or their own --help, or the literal installed ${skills}/bin/gstack-docs-candidate snapshot (its --out a private .json under ${fixture.home}), compare and --help commands, and /ship Step 14.5's three own-tree housekeeping commands exactly as written, each on its own line: the shared .gstack/tmp exclude line (the one &&-joined housekeeping line this interface admits) + return `Fixture observation interface (applies to parent and every child; ${file ? `saved at ${file}: each child prompt tells the child to Read that file first instead of copying this text` : 'include this interface in child prompts'}): Bash may execute only separate literal pwd, ls, cat, stat, sha256sum and Git read commands (status, diff, show, log, ls-files, ls-tree, check-ignore, rev-parse, merge-base, hash-object without -w, branch listings with no operand and only --show-current, -a/--all, -r/--remotes, -v/--verbose, -l/--list, --no-color or --no-abbrev (no --merged, --sort, --format or --contains), tag listings with no operand and only -l/--list, describe with only --tags, --always, --abbrev= or --exact-match and at most one revision). Bash may also run each of the following as its own Bash call, with nothing joined before or after it by ;, &&, || or a pipe (one exception follows the read grammar below): the exact generated Preamble block with its spawned prefix, literal installed gstack-skill-start/gstack-skill-end commands for document-release (start requires GSTACK_SESSION_KIND=spawned) or their own --help, or the literal installed ${skills}/bin/gstack-docs-candidate snapshot (its --out a private .json under ${fixture.home}), compare and --help commands, and /ship Step 14.5's three own-tree housekeeping commands exactly as written, each on its own line: the shared .gstack/tmp exclude line (the one &&-joined housekeeping line this interface admits) ${DOCS_TMP_EXCLUDE} then ${DOCS_AUDIT_RESET} and the cap gate, which reads origin/main's policy and whose DOCS_CAP lines and exit status decide it ${docsCapsCommand(skills)} -(no --json, no other base, no other gstack-ship-receipt subcommand). ${readOnlyInterfaceText(fixture.home)} The Git reads above may join such a line too, but not in a line with cd. A for loop over literal words (a lowercase loop variable; words without quotes, globs or $) whose body uses only such reads counts as those reads, one pass per word, wherever it sits in the line. Every read stays off actor-state.json and off ${docsConfigDir(fixture.home)} except its settings.json: that directory holds Claude's credentials and every agent's session transcript, so judge a child only by the output it returns (its echoed SESSION_KIND line included) and by the repository. The additional scripts below also run each as its own Bash call; no own-call command ever joins such a line. Otherwise no shell composition, custom interpreters, arbitrary scripts, inline eval or memory-mapped writes. The only additional scripts are ${scripts.length ? scripts.join(', ') : 'none'}.${insert ? ` To insert one saved private Markdown artifact into another (a saved section file into a report), Bash may also run the single literal command cat SOURCE.md >> TARGET.md with absolute paths under ${fixture.home}.` : ''} Read/Glob/Grep remain available; Read skill and section files with Read (offset/limit for ranges), because Bash output over 30KB becomes a truncated preview. A private JSON/Markdown artifact (the snapshot --out file, saved child output, a section or report file) lives directly under ${fixture.home} or in a new directory you create there, never inside the repository, ${skills}, ${docsConfigDir(fixture.home)}, ${state} (gstack's own state directory, not a place for your files) or ${fixture.home}/remote.git. Use Write/Edit for permitted docs, those private artifacts and Step 14.5's receipt record ${fixture.repo}/${DOCS_AUDIT_RECORD} (the only permitted write under the repository besides the document, excluded from git by the line above); do not rewrite installed skills, config, actor state or scripts. No effects outside the owned fixture. The owner preserves evidence and cleans up. Missing observer coverage blocks acceptance; the Linux kernel monitor covers syscall writes in the product tree, not hostile processes or arbitrary external destinations. +(no --json, no other base, no other gstack-ship-receipt subcommand). ${readOnlyInterfaceText(fixture.home)} The Git reads above may join such a line too, but not in a line with cd. So may the installed ${skills}/bin/gstack-docs-candidate --help, alone or after snapshot or compare, because it only prints usage (the one exception); neither a joined Git read nor that --help may be fed by a pipe. A for loop over literal words (a lowercase loop variable; words without quotes, globs or $) whose body uses only such reads counts as those reads, one pass per word, wherever it sits in the line. Every read stays off actor-state.json and off ${docsConfigDir(fixture.home)} except its settings.json: that directory holds Claude's credentials and every agent's session transcript, so judge a child only by the output it returns (its echoed SESSION_KIND line included) and by the repository. The additional scripts below also run each as its own Bash call; no other own-call command ever joins such a line. Otherwise no shell composition, custom interpreters, arbitrary scripts, inline eval or memory-mapped writes. The only additional scripts are ${scripts.length ? scripts.join(', ') : 'none'}.${insert ? ` To insert one saved private Markdown artifact into another (a saved section file into a report), Bash may also run the single literal command cat SOURCE.md >> TARGET.md with absolute paths under ${fixture.home}.` : ''} Read/Glob/Grep remain available; Read skill and section files with Read (offset/limit for ranges), because Bash output over 30KB becomes a truncated preview. A private JSON/Markdown artifact (the snapshot --out file, saved child output, a section or report file) lives directly under ${fixture.home} or in a new directory you create there, never inside the repository, ${skills}, ${docsConfigDir(fixture.home)}, ${state} (gstack's own state directory, not a place for your files) or ${fixture.home}/remote.git. Use Write/Edit for permitted docs, those private artifacts and Step 14.5's receipt record ${fixture.repo}/${DOCS_AUDIT_RECORD} (the only permitted write under the repository besides the document, excluded from git by the line above); do not rewrite installed skills, config, actor state or scripts. No effects outside the owned fixture. The owner preserves evidence and cleans up. Missing observer coverage blocks acceptance; the Linux kernel monitor covers syscall writes in the product tree, not hostile processes or arbitrary external destinations. The working directory for parent and child Bash calls is already ${fixture.repo}. Run Git reads directly, for example: git status, git diff --cached, git merge-base main HEAD, git rev-parse HEAD. Do not use Git global options such as -C, -c, --git-dir or --work-tree, and do not prepend cd or another shell wrapper. The literal git subcommand must immediately follow git; an absolute owned repository path does not make git -C an allowed command. diff --git a/test/helpers/docsync-observer.ts b/test/helpers/docsync-observer.ts index b97312e2b..8125fbe1e 100644 --- a/test/helpers/docsync-observer.ts +++ b/test/helpers/docsync-observer.ts @@ -322,9 +322,12 @@ function docsUnrollLoops(text: string): string | null { /** * A read-only line the shared grammar cannot see whole: loops are checked as - * their unrolled passes, and each declared Git read joined to grammar reads - * (never after a cd, never fed by a pipe) is checked literally and then stands - * in the line as a neutral read for the grammar. + * their unrolled passes, and each declared Git read or installed candidate + * --help joined to grammar reads (never after a cd, never fed by a pipe) is + * checked literally and then stands in the line as a neutral read for the grammar. + * The candidate's --help prints its usage and returns before it touches Git or a + * file (bin/gstack-docs-candidate main), so joining it reads nothing more (gates + * 40600731931 and 40627127462, screen 40558235014: the parent ended a line of reads with it). */ function docsComposedReadAllowed(text: string, fixture: ReturnType): boolean { const line = docsUnrollLoops(text); @@ -332,13 +335,16 @@ function docsComposedReadAllowed(text: string, fixture: ReturnType line.slice(segment.start, segment.end).trim()); - const git = commands.flatMap((command, index) => /^git(?:\s|$)/.test(command) ? [index] : []); - if (!git.length || commands.some(command => /^cd(?:\s|$)/.test(command))) return false; + const declared = commands.flatMap((command, index) => /^git(?:\s|$)/.test(command) || command.startsWith(`${candidate} `) ? [index] : []); + if (!declared.length || commands.some(command => /^cd(?:\s|$)/.test(command))) return false; let neutral = line; - for (const index of git.reverse()) { + for (const index of declared.reverse()) { const read = unwrapHarmlessRead(commands[index]!, fixture); - if (segments[index - 1]?.op === '|' || read === null || !/^git\s/.test(read) || !literalDocsCommandAllowed(read, fixture, [])) return false; + const help = read !== null && /^(?:(?:snapshot|compare) )?--help$/.test(read.slice(candidate.length + 1)); + if (segments[index - 1]?.op === '|' || read === null || !(/^git\s/.test(read) || (read.startsWith(`${candidate} `) && help)) + || !literalDocsCommandAllowed(read, fixture, [])) return false; neutral = `${neutral.slice(0, segments[index]!.start)} pwd ${neutral.slice(segments[index]!.end)}`; } return readOnlyCommandAllowed(neutral, docsReadOnlyScope(fixture)); @@ -384,6 +390,9 @@ function literalDocsCommandAllowed(text: string, fixture: ReturnType DOCS_BRANCH_LIST_FLAGS.test(arg)); + // A tag listing reads refs the same way (gate 40643690306: the parent ended a line of reads with `git tag`); + // an operand or any other option creates, deletes, signs or verifies a tag. + if (rest[0] === 'tag') return rest.slice(1).every(arg => arg === '-l' || arg === '--list'); if (rest[0] === 'describe') { const operands = rest.slice(1).filter(arg => !DOCS_DESCRIBE_FLAGS.test(arg)); return operands.length <= 1 && operands.every(arg => !arg.startsWith('-'));