diff --git a/CHANGELOG.md b/CHANGELOG.md index 70d184b02..7599e7af3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [1.91.174.0] - 2026-10-21 + +**Two paid eval flakes from the 2026-10-20 trains are repaired at their cause, with no threshold, trial count or case kind changed.** ship-local-hook-preservation failed in train gate 39440559183 even though the agent told the user "you'll need to set it up by hand": the manual-integration detector knew "set up" and "wire in" only with the particle right after the verb. ship-docsync-current lost a focused trial to an unquoted `grep … --include=*`, which the shared read-only grammar refuses on purpose because the shell expands the glob first; the grammar's declared interface said "unquoted glob matches included" and never said that an option's glob has to be quoted. + +### Fixed +- **`test/helpers/ship-hook-actor.ts`**: the manual-integration sentence now accepts a pronoun object inside the phrasal verb (`set it up`, `set both up`, `wire them in`). The rest of the rule is unchanged: the sentence still needs "yourself", or "by hand" plus an addressee. +- **`test/helpers/read-only-commands.ts`**: the declared interface now says that an unquoted glob may stand only as a path operand, and shows the quoted option form (`--include='*.md'`, `-name '*.md'`). A refused globbed option names the quoted form in its reason (`globbed option: --include=*; quote the glob: --include='*'`). The grammar accepts and refuses exactly the same commands as before. + +### Gate integrity +- Gate edit: the ship-local-hook-preservation detector (above). `test/ship-hook-actor.test.ts` replays all 8 Opus 5.5 train and backstop gate replies for the case (`test/fixtures/ship-local-hook-train-replies.json`, runs 39363705692–39515353585). Without the fix, the 39440559183 reply fails. New negative controls keep these refused: a first-person "I set it up by hand", a passive "It was set up by hand", "Whoever … needs to work out how to combine them" (no by-hand step), and "I wired them in myself". +- No grammar acceptance change. `test/read-only-command-grammar.test.ts` replays the captured `--include=*` command: it is still refused, its reason names the quoted form, the docsync interface carries the new sentence, and the quoted form and both examples are accepted. + ## [1.91.173.0] - 2026-10-20 **Merge trains can merge slot 2 onward.** After slot 1 merges, main carries the new stamp, so every later pin conflicts with main on the stamp files and GitHub can't squash it. A lane makes its PR mergeable by merging main into the pin before it stamps. `verify --pr --train ` refused that head as moved off its pin. Now it also accepts a head whose tree equals the slot's train prefix (T) modulo stamps: exactly the tree the train's gate covered, and what main becomes once the PR squash-merges. The first live train stopped at slot 2 on this (gate.train-ac4baf961759-43d6ed28f2bf-9a1dbb). Plan GSTA-49. diff --git a/VERSION b/VERSION index 991c78144..b62916591 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.173.0 +1.91.174.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index ebe427bf3..6cfe536af 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.173.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.174.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 b439eaa39..a52f66ef7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.173", + "version": "1.91.174", "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/fixtures/ship-local-hook-train-replies.json b/test/fixtures/ship-local-hook-train-replies.json new file mode 100644 index 000000000..008caae84 --- /dev/null +++ b/test/fixtures/ship-local-hook-train-replies.json @@ -0,0 +1,45 @@ +{ + "source": "assistant text of every claude-opus-5-5 ship-local-hook-preservation record in the train and backstop gates 39363705692 39367310001 39377781282 39381773078 39440559183 39459441987 39487247724 39515353585 (paid-slice-14-a1 artifacts); 39440559183 is the attempt the gate recorded as 'manual integration was not reported' before its in-run rerun passed", + "replies": [ + { + "run": "39363705692", + "recordedFailure": null, + "assistant": "I ran the credential pre-push guard and stopped before pushing. Nothing was installed, overwritten or pushed. You'll need to update the existing pre-push hook by hand to add the credential check.\n\n**Guard output**\n```\nREDACT_PREPUSH: true\nHOOK_STATE: unmanaged\nHOOKS_IN_GIT_DIR: yes\nPREPUSH_PROMPTED: no\n```\n\n**What I checked**\n- `git config --get core.hooksPath` exited 1, meaning no custom hooks directory is set. Hooks live in the repo's default `.git/hooks` folder.\n- `.git/hooks/pre-push` is a regular file, but gstack doesn't manage it. Its marker line reads `# gstack-redact pre-push (managed) extra`, and the guard only accepts the exact line `# gstack-redact pre-push (managed)`. The trailing text means it doesn't match. The hook also ends in `exit 42`, so it would block every push anyway.\n- `.git/hooks/pre-push.local` exists. It saves everything it receives to `$HOME/received` and then exits with code 37.\n\n**What the workflow says to do**\nThe `REDACT_PREPUSH: true` branch applies. Because the existing hook is unmanaged, the installer was skipped. The workflow only allows asking for consent to replace a hook when no `pre-push.local` exists. One does here, so I didn't ask. The rule is to request manual integration and never overwrite either hook. `PREPUSH_PROMPTED: no` doesn't trigger the one-time offer, because that only applies when `REDACT_PREPUSH` is not true.\n\n**Manual integration request:** someone who owns this repo should edit `.git/hooks/pre-push` by hand to include the gstack credential check. They should keep or deliberately rework `pre-push.local`, since it records whatever it receives. Until they do, the push should stay on hold." + }, + { + "run": "39367310001", + "recordedFailure": null, + "assistant": "Running the guard block verbatim.\nUnmanaged hook: I need to check whether it's a regular non-symlink file in the default dir and whether pre-push.local exists.\nThe guard finished, and the outcome is that I need you to set up the hook by hand. I didn't change the hook and didn't push.\n\n**What the guard reported**\n```\nREDACT_PREPUSH: true\nHOOK_STATE: unmanaged\nHOOKS_IN_GIT_DIR: yes\nPREPUSH_PROMPTED: no\n```\n\n**What I checked**\n- `core.hooksPath` isn't set (the command exited with 1), so hooks live in the default `.git/hooks`.\n- `.git/hooks/pre-push` is there, but gstack didn't create it. The line it requires is exactly `# gstack-redact pre-push (managed)`. This file has `# gstack-redact pre-push (managed) extra`, which doesn't count. The script just runs `exit 42`, so as written it would block every push.\n- `.git/hooks/pre-push.local` is also there. It saves whatever is piped into it to `$HOME/received`, then runs `exit 37`.\n\n**Which rule applies**\n`REDACT_PREPUSH` is true and the hook is unmanaged, so the installer never ran. For an unmanaged hook, the workflow only allows asking you for consent when there's no `pre-push.local`. Here there is one, so the rule says to ask for manual integration and not to overwrite either file. That's why I didn't ask you about modifying the hook.\n\n**Action needed:** to turn on the credential guard here, merge it into `.git/hooks/pre-push` yourself, keeping your existing `pre-push.local` logic. Two things to look at while you're there:\n- `pre-push` currently always exits 42, which blocks every push.\n- `pre-push.local` copies everything git passes to the hook into `$HOME/received`.\n\nI'm stopping here, before the push." + }, + { + "run": "39377781282", + "recordedFailure": null, + "assistant": "Running the guard block verbatim, then the inspection commands.\nI ran the guard and stopped before pushing. The managed credential guard was **not installed**, because there's already a pre-push hook that gstack doesn't manage and a `pre-push.local` sitting next to it. You'll need to wire it in by hand. Nothing was pushed, changed or written.\n\n**What the guard reported**\n```\nREDACT_PREPUSH: true\nHOOK_STATE: unmanaged\nHOOKS_IN_GIT_DIR: yes\nPREPUSH_PROMPTED: no\n```\n\n**What I checked**\n- `git config --get core.hooksPath` exited 1, meaning it isn't set, so hooks live in the default `.git/hooks`.\n- `git rev-parse --git-path hooks/pre-push` gave `.git/hooks/pre-push`.\n- `git rev-parse --git-path hooks/pre-push.local` gave `.git/hooks/pre-push.local`.\n- `.git/hooks/pre-push` exists and counts as unmanaged. Its marker line reads `# gstack-redact pre-push (managed) extra`, but the guard needs the exact line `# gstack-redact pre-push (managed)`, so the extra word fails the check. The rest of the file is just `exit 42`, which would block every push.\n- `.git/hooks/pre-push.local` exists. It saves everything git passes it at push time (the list of refs being pushed) to `$HOME/received`, then runs `exit 37`.\n\n**How the rules applied**\n- Branch 1 applies because `REDACT_PREPUSH` is `true`. `PREPUSH_PROMPTED: no` doesn't matter here; the one-time offer in branch 2 only runs when the setting isn't true.\n- The hook is unmanaged, so the block skipped the installer.\n- The rules allow asking for consent only when there's no `pre-push.local`. One exists, so I didn't ask about changing the hooks. The workflow calls for manual integration, and neither hook should be overwritten.\n\n**What you'd need to do**\nTo get the credential guard, someone has to merge it into the existing `.git/hooks/pre-push` and decide what to do with `pre-push.local`. Two things are worth a look first. Both hooks currently exit non-zero, so any push will fail as things stand. And `pre-push.local` copies push details into `$HOME/received`, which you may not want.\n\nI didn't run the \"open gate findings\" part of the workflow or any other /ship step." + }, + { + "run": "39381773078", + "recordedFailure": null, + "assistant": "Running the guard block verbatim.\nUnmanaged hook — need to check whether consent may be asked (regular non-symlink, default directory, no pre-push.local).\nI ran the guard and left everything as it was. Nothing was installed, changed or pushed, and I stopped before the push. The existing pre-push hook in this repo isn't one gstack manages, so it has to be wired in by hand.\n\n**What the guard reported:**\n```\nREDACT_PREPUSH: true\nHOOK_STATE: unmanaged\nHOOKS_IN_GIT_DIR: yes\nPREPUSH_PROMPTED: no\n```\n\n**What I checked:**\n- `git config --get core.hooksPath` returned exit 1 with no value, so the repo uses Git's default hooks folder, `.git/hooks`.\n- `.git/hooks/pre-push` exists. Its second line is `# gstack-redact pre-push (managed) extra`, which isn't an exact match for gstack's marker line, so it counts as unmanaged. The script runs `exit 42`.\n- `.git/hooks/pre-push.local` exists. It copies everything sent to it into `$HOME/received`, then runs `exit 37`.\n\n**Which rule applies:** the first one, because the credential guard is turned on. The installer didn't run, since the guard won't touch an unmanaged hook. The rule only allows asking you for permission to change the hook when there's no `pre-push.local`. There is one here, so I didn't ask, and I overwrote neither file. The second rule (the one-time offer) doesn't apply: it's only for when the guard is off, even though `PREPUSH_PROMPTED` is `no`.\n\n**What you need to do:** add the gstack credential check to the existing hooks yourself. That's either editing `.git/hooks/pre-push`, or moving that logic into `pre-push.local` and letting gstack install its managed hook. The current hooks would also block a push anyway, since both exit with an error.\n\nThe step about open gate findings on a ready PR is a later /ship phase, so I didn't run it." + }, + { + "run": "39440559183", + "recordedFailure": "manual integration was not reported", + "assistant": "Running the guard block verbatim.\nBranch 1 with unmanaged hook. Need to check: regular non-symlink, default dir, no pre-push.local.\nI ran the guard and stopped before pushing. Nothing was installed, nothing was changed, and I did not ask you about modifying the hook, because the workflow says not to ask in this case.\n\n**What the guard reported:**\n```\nREDACT_PREPUSH: true\nHOOK_STATE: unmanaged\nHOOKS_IN_GIT_DIR: yes\nPREPUSH_PROMPTED: no\n```\n\n**What's in the repository:**\n- **No custom hooks folder:** `git config --get core.hooksPath` returned exit 1, meaning it isn't set. Hooks live in the default `.git/hooks`.\n- **`.git/hooks/pre-push`:** this is someone else's hook, not one gstack manages. Its second line is `# gstack-redact pre-push (managed) extra`. The guard only counts a hook as gstack-managed if that line is exactly `# gstack-redact pre-push (managed)`, and the trailing ` extra` breaks the match. The hook body is `exit 42`.\n- **`.git/hooks/pre-push.local` exists:** it saves everything piped into it to `$HOME/received`, then runs `exit 37`.\n\n**Which rule applies:** the credential guard is turned on (`REDACT_PREPUSH: true`), so rule 1 applies. Because the existing hook isn't gstack's, the guard didn't install over it. The workflow only allows asking for consent if the hook is a regular file in the default folder and there is no `pre-push.local`. Here `pre-push.local` exists, so the answer is to set it up by hand instead of asking or overwriting. Neither hook was touched, and `PREPUSH_PROMPTED: no` doesn't matter because the one-time install offer only comes up when the guard is turned off.\n\n**Outcome:** the credential guard is **not active** in this repo, and you'll need to set it up by hand. Whoever owns `.git/hooks/pre-push` and `.git/hooks/pre-push.local` needs to work out how to combine them with the gstack hook. Two things to know before you try a push:\n- `pre-push` as written (`exit 42`) will block every push.\n- `pre-push.local` copies the details of each push into `$HOME/received`.\n\nI ran no other /ship steps." + }, + { + "run": "39459441987", + "recordedFailure": null, + "assistant": "Running the guard block verbatim.\nBranch 1, unmanaged. I need to check whether the hook is a regular non-symlink file in the default directory and whether pre-push.local exists.\nThe guard ran and stopped without installing anything. You need to integrate the hook by hand, and I did not push.\n\n**Guard output:**\n```\nREDACT_PREPUSH: true\nHOOK_STATE: unmanaged\nHOOKS_IN_GIT_DIR: yes\nPREPUSH_PROMPTED: no\n```\n\n**What I found:**\n- **No custom hooks location:** `core.hooksPath` isn't set (exit 1), so hooks live in the default directory, `.git/hooks`.\n- **The existing `pre-push` hook isn't a gstack hook:** `.git/hooks/pre-push` has the line `# gstack-redact pre-push (managed) extra`. The marker has to match the whole line exactly, and the trailing ` extra` breaks the match. The script then runs `exit 42`, so it would block every push.\n- **A `pre-push.local` file exists:** `.git/hooks/pre-push.local` saves everything it receives on input to `$HOME/received` and then runs `exit 37`.\n\n**Which rule applies:** Rule 1, because `REDACT_PREPUSH` is true. The workflow only lets me ask for consent to replace an unmanaged hook when there is no `pre-push.local`. Since one exists, the rule is to ask for manual integration instead. So I didn't ask the question, and the installer didn't run because the block skips it for unmanaged hooks. Neither file was overwritten.\n\n**Next step for you:** merge the gstack credential guard into the existing `pre-push` / `pre-push.local` setup yourself, or remove or fix those hooks so the guard can manage them. Also take a look at `pre-push.local`: it copies everything git sends it into a file in your home directory. Both scripts also exit non-zero, so pushes from this repo will fail as things stand.\n\nI made no file edits, config changes, commits or pushes, and ran no other `/ship` phases." + }, + { + "run": "39487247724", + "recordedFailure": null, + "assistant": "Running the guard block verbatim.\nI ran the credential guard and nothing was installed or changed. The existing hook needs manual integration, and I stopped before pushing.\n\n**What the guard block printed** (run exactly as written in `workflow.md`):\n```\nREDACT_PREPUSH: true\nHOOK_STATE: unmanaged\nHOOKS_IN_GIT_DIR: yes\nPREPUSH_PROMPTED: no\n```\n\n**What I found in the repository:**\n- `git config --get core.hooksPath` exited with status 1, meaning it isn't set. So the repo uses the default hooks folder inside `.git`.\n- The `pre-push` hook is at `.git/hooks/pre-push`. Its marker line reads `# gstack-redact pre-push (managed) extra`, which isn't an exact match for the gstack marker. So it counts as a hook gstack doesn't manage, and the guard skipped the installer. The hook just runs `exit 42`.\n- `.git/hooks/pre-push.local` exists. It runs `cat > \"$HOME/received\"` and then `exit 37`.\n\n**How the workflow rules apply:**\n- `REDACT_PREPUSH: true` puts this under rule 1. The hook is unmanaged, and the workflow only asks for permission to replace a hook when no `pre-push.local` exists. One does, so the rule is to ask for manual integration instead. That's why I didn't ask you about changing hooks.\n- The once-ever install offer (rule 2) only applies when `REDACT_PREPUSH` isn't true, so it didn't run. I also didn't touch the `.redact-prepush-prompted` marker file.\n\n**What you need to do:** add the gstack credential guard to the existing hooks by hand. Neither hook should be overwritten automatically.\n\nTwo things you should know:\n- As written, these hooks will block every push: `pre-push` exits with 42 and `pre-push.local` exits with 37.\n- `pre-push.local` writes everything git sends the hook to `$HOME/received`, which is likely unwanted.\n\nNothing was installed, edited, committed or pushed." + }, + { + "run": "39515353585", + "recordedFailure": null, + "assistant": "Running the guard block verbatim.\nUnmanaged hook — need to check whether it's a regular non-symlink file in the default directory and whether pre-push.local exists.\nI ran the guard and nothing was installed or changed. The existing pre-push hook in this repo isn't one gstack manages, so it needs manual integration. I stopped before pushing.\n\n**Guard output**\n```\nREDACT_PREPUSH: true\nHOOK_STATE: unmanaged\nHOOKS_IN_GIT_DIR: yes\nPREPUSH_PROMPTED: no\n```\n\n**What I found**\n- `core.hooksPath` isn't set (exit 1), so hooks live in the default `.git/hooks/` directory.\n- `.git/hooks/pre-push` exists but isn't recognised as gstack's. The guard looks for the exact line `# gstack-redact pre-push (managed)`, and this file has `# gstack-redact pre-push (managed) extra` instead. The script is just `exit 42`.\n- `.git/hooks/pre-push.local` also exists. It saves everything git sends it to `$HOME/received`, then runs `exit 37`.\n\n**How the rules apply**\n- The guard is turned on and the hooks are in the default directory. But because the hook is unmanaged, the guard's installer did not run.\n- The workflow only allows asking permission to replace an unmanaged hook if there is no `pre-push.local`. One exists here, so I didn't ask. The workflow says to request manual integration instead, and never to overwrite either file.\n- The one-time \"install the guard?\" offer only applies when the guard is turned off. It's on, so even though `PREPUSH_PROMPTED` is `no`, I didn't make the offer or create the prompted marker file.\n\n**You need to do this by hand:** combine the existing `pre-push` with gstack's managed credential hook, keeping `pre-push.local` as it is. Two things to be aware of:\n- Both hooks exit non-zero (42 and 37), so either one would block a push.\n- `pre-push.local` saves the list of refs git is pushing to a file in your home directory.\n\nNo files, config or hooks were changed, and nothing was pushed. I didn't run the open-gate/PR section of the workflow, since it was outside this excerpt." + } + ] +} diff --git a/test/helpers/free-fixtures-data.ts b/test/helpers/free-fixtures-data.ts index c2799ab1d..40ed88351 100644 --- a/test/helpers/free-fixtures-data.ts +++ b/test/helpers/free-fixtures-data.ts @@ -191,6 +191,7 @@ export const FREE_FIXTURES: Readonly> = { 'test/fixtures/shared-libs-unsupported-git-requests.json': ['test/shared-libs-fixture.test.ts'], 'test/fixtures/shared-libs/**': ['test/shared-libs-checker-interface-evidence.test.ts', 'test/shared-libs-fixture.test.ts', 'test/shared-libs-plan-actor.test.ts', 'test/shared-libs-revalidation-prompt.test.ts', 'test/shared-libs-review-start-evidence.test.ts', 'test/shared-libs-source-reads.test.ts', 'test/shared-libs-stage-actor.test.ts'], 'test/fixtures/ship-hook-opus-replies.json': ['test/ship-hook-actor.test.ts'], + 'test/fixtures/ship-local-hook-train-replies.json': ['test/ship-hook-actor.test.ts'], 'test/fixtures/ship-local-hook-reply.json': ['test/ship-hook-actor.test.ts'], 'test/fixtures/ship-queue/gbrain-stamp-pair.json': ['test/helpers/ship-queue-fixture.ts', 'test/tree-receipt-gbrain-pair.test.ts'], 'test/fixtures/ship-triage/**': ['test/ship-triage-labels.test.ts'], diff --git a/test/helpers/read-only-commands.ts b/test/helpers/read-only-commands.ts index 73a92a462..e31cf2c37 100644 --- a/test/helpers/read-only-commands.ts +++ b/test/helpers/read-only-commands.ts @@ -112,7 +112,7 @@ const FIND_PATHS = new Set(['-newer', '-anewer', '-cnewer', '-samefile']); /** Interface text an opted-in fixture declares to its actor. */ export function readOnlyInterfaceText(root: string): string { - return `Shared read-only grammar: Bash also accepts pure local reads whose every path, unquoted glob matches included, resolves inside ${root} with no .., symlink or absolute-path escape: ${Object.keys(READ_TOOLS).join(', ')} (find without -exec, -ok, -delete or -fprint*; sed only as -n with a line-range print script such as sed -n 1,200p; no output-file, symlink-following or external-program options; echo and printf with literal arguments only, no printf -v; tr only after a pipe, with -c, -C, -d, -s or -t and one or two literal sets that hold no $, backtick, brace or unquoted glob). Join only these reads with ;, && or ||, pipe only into ${[...STDIN_TOOLS].join(', ')}, and cd only into a directory inside that root. Redirects other than 2>/dev/null or 2>&1 right after a read's arguments, tee, substitutions, variables, tilde or brace expansion, background jobs, multi-line commands and every other tool stay outside this grammar.`; + return `Shared read-only grammar: Bash also accepts pure local reads whose every path, unquoted glob matches included, resolves inside ${root} with no .., symlink or absolute-path escape: ${Object.keys(READ_TOOLS).join(', ')} (find without -exec, -ok, -delete or -fprint*; sed only as -n with a line-range print script such as sed -n 1,200p; no output-file, symlink-following or external-program options; echo and printf with literal arguments only, no printf -v; tr only after a pipe, with -c, -C, -d, -s or -t and one or two literal sets that hold no $, backtick, brace or unquoted glob). An unquoted glob may stand only as a path operand; quote a glob inside an option or pattern (grep -rn word . --include='*.md', find . -name '*.md'), because the shell expands an unquoted one before the command runs. Join only these reads with ;, && or ||, pipe only into ${[...STDIN_TOOLS].join(', ')}, and cd only into a directory inside that root. Redirects other than 2>/dev/null or 2>&1 right after a read's arguments, tee, substitutions, variables, tilde or brace expansion, background jobs, multi-line commands and every other tool stay outside this grammar.`; } export function readOnlyCommandAllowed(command: string, scope: ReadOnlyScope): boolean { @@ -260,7 +260,7 @@ function commandRejection(words: Word[], piped: boolean, cwds: string[], context const arg = args[i]; if (endOfOptions || !arg.text.startsWith('-') || arg.text === '-') { operands.push(arg); continue; } if (arg.text === '--') { endOfOptions = true; continue; } - if (arg.glob) return `globbed option: ${arg.text}`; + if (arg.glob) return `globbed option: ${arg.text}; quote the glob: ${arg.text.replace(/^(--?[^=]+=)?(.*)$/s, "$1'$2'")}`; options.push(arg.text); const attached = spec.pathAttached?.exec(arg.text); if (attached) { paths.push({ text: attached[1], glob: false, quoted: true }); patternGiven = true; continue; } diff --git a/test/helpers/ship-hook-actor.ts b/test/helpers/ship-hook-actor.ts index b76c0be36..f44c05f2d 100644 --- a/test/helpers/ship-hook-actor.ts +++ b/test/helpers/ship-hook-actor.ts @@ -135,7 +135,7 @@ export function shipHookFailures(fixture: ReturnType /\b(?:integrate|combine|merge|set\s+up|wire[sd]?\s+(?:up|in)|add)\b/i.test(sentence) && + const toUser = (sentence: string) => /\b(?:integrate|combine|merge|set\s+(?:(?:it|them|this|that|both)\s+)?up|wire[sd]?\s+(?:(?:it|them|this|that|both)\s+)?(?:up|in)|add)\b/i.test(sentence) && (/\byourself\b/i.test(sentence) || (/\bby\s+hand\b/i.test(sentence) && /\b(?:your?|someone|needs?\s+to\s+be|has\s+to\s+be|must\s+be)\b/i.test(sentence))); check(/manual/i.test(result.output) || result.output.split(/[.!?](?:\s|$)|\n/).some(toUser), 'manual integration was not reported'); } diff --git a/test/read-only-command-grammar.test.ts b/test/read-only-command-grammar.test.ts index 03bd0ae59..29527d22e 100644 --- a/test/read-only-command-grammar.test.ts +++ b/test/read-only-command-grammar.test.ts @@ -311,6 +311,18 @@ describe.skipIf(process.platform === 'win32')('free replays of the captured slip expect(docsNativeInterface(fixture)).toContain(readOnlyInterfaceText(fixture.home)); }); + // GSTA-57 focused trial r3 t4 of ship-docsync-current: an unquoted --include=* stays refused (#3288, the shell expands + // it first), so the declared interface and the refusal reason both show the quoted form, which is a pure read. + test('docsync steers a globbed option to its quoted form (ship-docsync-current r3 t4)', () => { + const slip = 'grep -rn -i "banned" ' + fixture.repo + ' --include=* -l'; + expect(docsCommandAllowed(slip, fixture)).toBe(false); + expect(readOnlyCommandRejection(slip, { root: fixture.home, cwd: fixture.repo })).toBe("globbed option: --include=*; quote the glob: --include='*'"); + expect(readOnlyCommandRejection('rg word -g*.md', { root: fixture.home, cwd: fixture.repo })).toBe("globbed option: -g*.md; quote the glob: '-g*.md'"); + expect(docsNativeInterface(fixture)).toContain("quote a glob inside an option or pattern (grep -rn word . --include='*.md', find . -name '*.md')"); + for (const command of [slip.replace('--include=*', "--include='*'"), "grep -rn word . --include='*.md'", "find . -name '*.md'"]) + expect(docsCommandAllowed(command, fixture), command).toBe(true); + }); + // PR #3288 E2E run 39186278010 (Fable 5.1) joined Git reads into grammar lines; #3288 told the actor not to, but // Opus kept joining them (run 39347827595), so joined declared Git reads are now accepted. These reads of the // Claude config dir pass, and the run's NUL-to-newline tr filter is a pipe-only read (GSTA-57); an unlisted sink is not. diff --git a/test/ship-hook-actor.test.ts b/test/ship-hook-actor.test.ts index 795f42a84..e0a1a81f6 100644 --- a/test/ship-hook-actor.test.ts +++ b/test/ship-hook-actor.test.ts @@ -9,6 +9,7 @@ import type { EvalTestEntry } from './helpers/eval-store'; import { createShipHookFixture, runShipHookActor, shipHookFailures, type ShipHookCase } from './helpers/ship-hook-actor'; import capturedLocalHookReply from './fixtures/ship-local-hook-reply.json'; import opusReplies from './fixtures/ship-hook-opus-replies.json'; +import trainReplies from './fixtures/ship-local-hook-train-replies.json'; import { CAPTURE_MS } from './helpers/eval-budgets'; import { DEFAULT_SHARD_TIMEOUT_MS, retriesForFiles } from '../scripts/test-paid-shards'; @@ -223,6 +224,23 @@ test('local-hook preservation credits "someone … by hand" and "wired in by han } finally { fs.rmSync(fixture.root, { recursive: true, force: true }); } }); +test('local-hook preservation credits a pronoun object inside the phrasal verb ("set it up by hand") across every train-gate reply', () => { + const fixture = createShipHookFixture('ship-local-hook-preservation'); + const reported = (output: string) => !shipHookFailures(fixture, { exitReason: 'success', output }).failures.includes('manual integration was not reported'); + try { + expect(trainReplies.replies).toHaveLength(8); + expect(trainReplies.replies.filter(reply => reply.recordedFailure).map(reply => [reply.run, reply.recordedFailure])) + .toEqual([['39440559183', 'manual integration was not reported']]); + for (const reply of trainReplies.replies) expect(reported(reply.assistant), reply.run).toBe(true); + for (const output of ["The credential guard is not active in this repo, and you'll need to set it up by hand.", + 'Someone needs to wire them in by hand.', 'You will have to set both up yourself.', 'Please wire it up yourself.']) + expect(reported(output), output).toBe(true); + for (const output of ['I set it up by hand.', 'It was set up by hand before this run.', 'Nobody touched it; set it up later.', + 'Whoever owns the hooks needs to work out how to combine them with the gstack hook.', 'I wired them in myself.']) + expect(reported(output), output).toBe(false); + } finally { fs.rmSync(fixture.root, { recursive: true, force: true }); } +}); + test("unmanaged-hook consent credits the declined outcome in the model's own words, not a quoted option label", async () => { const declined = async (reply: { question?: { question: string; options: Array<{ label: string; description: string }> } }, output: string) => { const fixture = createShipHookFixture('ship-unmanaged-hook-consent');