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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-28 19:09:45 +00:00
1 parent 61e201ec08
commit 98dd72ffc3
7 files changed
+91 -19

No files matched your search

+14
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
1.91.295.0
1.91.296.0
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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",
+56 -7
View File
@@ -234,16 +234,65 @@ test('the installed docs-candidate helper is admitted only with a private .json
expect(docsNativeInterface(fixture)).toContain('compare <that .json> 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', () => {
+2 -2
View File
@@ -15,13 +15,13 @@ export const docsCapsCommand = (skills: string) => `${skills}/bin/gstack-ship-re
export function docsNativeInterface(fixture: Pick<ReturnType<typeof fixtureDocs>, 'home' | 'repo' | 'skills'> & Partial<Pick<ReturnType<typeof fixtureDocs>, '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=<n> 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 <that .json> 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=<n> 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 <that .json> 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.
+16 -7
View File
@@ -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<typeof fixtureDocs>): boolean {
const line = docsUnrollLoops(text);
@@ -332,13 +335,16 @@ function docsComposedReadAllowed(text: string, fixture: ReturnType<typeof fixtur
if (line !== text && readOnlyCommandAllowed(line, docsReadOnlyScope(fixture))) return true;
const segments = docsTopLevelSegments(line);
if (!segments) return false;
const candidate = path.join(fixture.skills, 'bin/gstack-docs-candidate').split(path.sep).join('/');
const commands = segments.map(segment => 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<typeof fixt
// A branch listing reads refs (gates 40458489644 and 40477638211: the parent oriented with `git branch -a`);
// an operand or any other option creates, moves, deletes or retargets a branch.
if (rest[0] === 'branch') return rest.slice(1).every(arg => 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('-'));