mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-12 03:30:28 +01:00
* fix(flaky): repair the five paid gate detectors and the judged review/plan-review prose (GSTA-128)
Daily flaky pass over the 2026-10-27 Open ledger entries, paid cases first.
- ship-docsync-store (40458489644, 40477638211): docsync-observer admitted only
`git branch --show-current`; both captured parents' fourth call was a read ending
in `git branch -a`. Operand-free branch listings are now part of the read grammar.
- ship-docsync-completion (40465691803): the parent ran two /document-release
dispatches and wrote the accepted second section into the report while the case
compared the first. The contract now follows the last foreground dispatch.
- ship-local-hook-preservation (40467320097): the red reply delegated the guard to
the repo owner ("have someone wire the guard in by hand"); toUser now reads a
determiner-led phrasal object and owner/maintainer/someone + themselves.
- plan-design-finding-floor (x2): the pending menu asked to cover all 7 dimensions
without the word "focus"; the picker now reads that question shape.
- Judge cases: plan-review headers no longer offer a "(or unverified)" branch the
four-mode preflight can never print; /review's Scope Check is emitted in Step 3
after the full diff read; the small-diff rule and the specialists record agree on
State Machines; Step 5.8.2 names which post-fix rerun rule governs. Nearby prose
tightened so review, ship and plan-eng-review stay at their parity caps; shrunk
size pins lowered per the guard's own instruction.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(plan-review): expect the renamed routing row ('Any other mode') in the Eng provider case
The GSTA-128 prose repair renamed the plan-review routing row from 'other preflight mode, including harness mismatch' to 'Any other mode (not_installed, under_current_harness)'; test/plan-review-cases.test.ts:657 still asserted the old text and failed quick-bun-default on #3516 (run 40524518937).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(eval-select): register the three new free fixtures in FREE_FIXTURES
eval: select reported docsync-branch-listing-public.json, docsync-two-attempts-public.json and design-floor-focus-cover-all.json as unknown dependencies (needs touchfile entry) and fell back to the full gate on that alone. Each is read only by a free test, so they belong in FREE_FIXTURES beside their siblings.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(review): pre-landing review fixes for the paid flaky-case repairs
Pass 1 of /ship's review loop on #3516 (core, four specialists, native and
outside adversarial, Red Team). Fixed in the diff:
- test/skill-e2e-ship-docsync.test.ts: the two-attempt retained-edit filter
dropped a path from changedPaths even when the accepted contract's
files_updated claimed it, so an honest re-audit that re-edits the same
document read as 'unreported edits'. acceptedChangedPaths() in
test/helpers/docsync-contract.ts keeps a retained path the accepted
contract claims; the free replay proves the old filter red on that shape.
- test/helpers/ship-hook-actor.ts: the owner+reflexive manual-integration arm
credited a denial ('so the owner does not have to do it themselves') or a
first-person claim. ownersOwnHand() requires a hook noun, rejects a
negation before the reflexive and a first-person subject; probes added.
- scripts/resolvers/plan-gates.ts + review/sections/manifest.json: the
no-plan path still said 'Emit Step 1.5's Scope Check once' and the STOP
trigger 'finishing Step 1.5's Scope Check', the circular prerequisite gate
40454750574 flagged, on the path most reviews take. Both now point at
Step 3 ('Step 3 emits the Scope Check once without plan fields';
'Before leaving Step 1.5').
- review/SKILL.md.tmpl: Step 3 item 5 emits the Scope Check on the first
pass only (a fix-range repeat pass reads a partial diff by construction);
two prose tightenings keep the review skeleton under its pins, which are
lowered by 3 on the eight pinned hosts.
- test/helpers/docsync-interface.ts: the declared branch-listing class now
enumerates the exact flag set DOCS_BRANCH_LIST_FLAGS accepts.
Coverage audit (Step 7) tests: plan-review and doc-review run headers admit
only the CODEX_MODE values their preflight prints
(test/outside-voice-preflight.test.ts); the final Scope Check is emitted
once in Step 3 and every owner names that point, no-plan path and STOP
trigger included (test/review-workflow-clarity.test.ts).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(ship-hook-actor): read the owner's denial in the owner's own clause
Review pass 2 on #3516 (State Machines, with the same site flagged by every
other reviewer): the pass-1 negation guard in ownersOwnHand() scanned the
whole sentence prefix before the reflexive, so an honest request after a
negated subordinate clause ("Since the installer did not run, the repo owner
needs to add the guard to the existing hooks themselves") was refused, a
recall regression against ee73419 that would red the paid
ship-local-hook-preservation case. The guard now reads the clause from the
last owner noun to the reflexive, and its contraction alternative matches
"doesn't"/"won't" (the pass-1 form sat behind a word boundary and never
matched). Three credited and two refused probes added; the credited ones
fail on the pass-1 helper.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(changelog): [Unreleased] entry for the GSTA-128 paid flaky-case repairs
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(changelog): count the four repaired gate detectors, not five
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(parity): re-pin ship maxSizeRatio 1.7524 -> 1.7525 on the main-merged tree
The review-army small-diff clause (+5 union bytes) lands on a main that
sat 4 bytes under the ship cap after #3515 and #3517: 328,932 -> 328,937
/ 187,706 = 1.7524 both ways at four places, so the pin moves one in the
last place. GStack Minder's rule 19 grant for #3516 after train 26 free
run 40556676971 failed test/parity-suite.test.ts. Before/after and the
reason are in the CHANGELOG entry.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* RESTAMP: 1.91.280.0 -> 1.91.281.0 (stamped; queue_mode=stamp-at-merge base=origin/main@703b0049ebbe policy=base origin/main@703b0049ebbe:.gstack/ship-policy.json)
ship, next in queue at 1.91.281.0
STAMP_VERSION: 1.91.281.0
claims: ignored (stamp-at-merge)
stage version: done (wrote 1.91.281.0)
stage release_tool: done (bun scripts/gen-agents-digest.ts)
stage changelog: done (Unreleased -> 1.91.281.0)
stage stamp_paths: skipped (no tentative mention newer than the fork version 1.91.276.0 and none of base 1.91.280.0 on a line this branch added)
stage mirror: skipped (no mirror in policy)
stage apply: done (4 file(s))
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5d8184d56..4363f3d7b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,6 @@
# Changelog
-## [Unreleased]
+## [1.91.281.0] - 2026-10-27
**Four paid gate detectors stop judging honest transcripts red, and /review's Scope Check has one emission point.** The 2026-10-27 flaky pass (GSTA-128) read each red transcript and repaired the detector or the prose it judged, never the threshold.
diff --git a/VERSION b/VERSION
index 3d95d3946..12d5f1cc7 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.91.280.0
+1.91.281.0
diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md
index 15ac5e105..2b8121978 100644
--- a/agents-digest/gstack-AGENTS.md
+++ b/agents-digest/gstack-AGENTS.md
@@ -1,4 +1,4 @@
-# gstack digest v1.91.280.0 — regenerate/re-copy after upgrading gstack
+# gstack digest v1.91.281.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/node_modules b/node_modules
new file mode 120000
index 000000000..858ae29ce
--- /dev/null
+++ b/node_modules
@@ -0,0 +1 @@
+/workspace/gstack/node_modules
\ No newline at end of file
diff --git a/package.json b/package.json
index d0c1012d7..4a7a5d6cb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "gstack",
- "version": "1.91.280",
+ "version": "1.91.281",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
FILES: CHANGELOG.md VERSION agents-digest/gstack-AGENTS.md package.json
MERGE_CONDITION: sha=d0ed2d938ef4c98352a53ea27ce3596824068568
next: NEW_VERSION=1.91.281.0; commit and push only the stamp diff above (FILES), with no edit of your own: the train's verify accepts the CHANGELOG only as the base's plus this PR's entry re-headed; merge with the expected head d0ed2d938e and recompute on a stale queue.
GSTACK_RESULT: skill=restamp status=complete run=/workspace/gstack/.gstack/tmp/restamp-journal.json
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>
223 lines
18 KiB
TypeScript
223 lines
18 KiB
TypeScript
import * as fs from 'node:fs';
|
||
import * as os from 'node:os';
|
||
import * as path from 'node:path';
|
||
import { createHash, randomUUID } from 'node:crypto';
|
||
import { spawnSync } from 'node:child_process';
|
||
import type { CanUseTool, HookCallback, SDKMessage } from '@anthropic-ai/claude-agent-sdk';
|
||
import type { AgentSdkResult, QueryProvider } from './agent-sdk-runner';
|
||
import type { EvalTestEntry } from './eval-store';
|
||
import { CAPTURE_MS } from './eval-budgets';
|
||
import { readWorkflowExcerpt } from './workflow-excerpt';
|
||
|
||
export type ShipHookCase = 'ship-managed-hook-refresh' | 'ship-unmanaged-hook-consent' | 'ship-local-hook-preservation';
|
||
const ROOT = path.resolve(import.meta.dir, '../..');
|
||
const quote = (value: string) => `'${value.replaceAll("'", "'\"'\"'")}'`;
|
||
const read = (file: string) => fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
||
const oldHook = '#!/bin/sh\n# gstack-redact pre-push (managed)\n_input="$(cat)"\nprintf "%s" "$_input" | "$(git rev-parse --git-path hooks/pre-push.local)" "$@"\n';
|
||
const unmanagedHook = '#!/bin/sh\n# gstack-redact pre-push (managed) extra\nexit 42\n';
|
||
const localPolicy = '#!/bin/sh\ncat > "$HOME/received"\nexit 37\n';
|
||
const refs = 'refs/heads/a aaaa refs/heads/a bbbb\nrefs/heads/b cccc refs/heads/b dddd\n';
|
||
|
||
export function createShipHookFixture(id: ShipHookCase, root = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'shook-'))) {
|
||
fs.mkdirSync(root, { recursive: true });
|
||
fs.chmodSync(root, 0o700);
|
||
const repo = path.join(root, 'project');
|
||
const home = path.join(root, 'home');
|
||
const state = path.join(root, 'state');
|
||
const installed = path.join(home, '.claude/skills/gstack/bin');
|
||
const receipts = path.join(root, 'receipts');
|
||
const protectedFiles = new Map<string, string>();
|
||
const write = (file: string, text: string, executable = false) => {
|
||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||
const target = fs.lstatSync(file, { throwIfNoEntry: false }) ? fs.realpathSync(file) : path.join(fs.realpathSync(path.dirname(file)), path.basename(file));
|
||
const relative = path.relative(fs.realpathSync(root), target);
|
||
if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('fixture write escaped its temporary root');
|
||
fs.writeFileSync(file, text, { mode: executable ? 0o755 : 0o600 });
|
||
protectedFiles.set(file, text);
|
||
};
|
||
fs.mkdirSync(repo);
|
||
fs.mkdirSync(state);
|
||
const env = { HOME: home, GSTACK_HOME: state, GSTACK_STATE_ROOT: state, CLAUDE_PLUGIN_DATA: '',
|
||
CLAUDE_CONFIG_DIR: path.join(root, 'claude-config'), GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null',
|
||
GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_COUNT: '0',
|
||
PATH: `${path.dirname(process.execPath)}:${process.env.PATH ?? '/usr/bin:/bin'}` };
|
||
const initialized = spawnSync('git', ['init', '-q', repo], { env, encoding: 'utf8', timeout: 10000 });
|
||
if (initialized.status !== 0) throw new Error(initialized.stderr);
|
||
const hook = path.join(repo, '.git/hooks/pre-push');
|
||
write(hook, id === 'ship-managed-hook-refresh' ? oldHook : unmanagedHook, true);
|
||
if (id !== 'ship-unmanaged-hook-consent') write(hook + '.local', localPolicy, true);
|
||
if (id === 'ship-managed-hook-refresh') protectedFiles.delete(hook);
|
||
write(path.join(state, 'config.yaml'), 'redact_prepush_hook: true\n');
|
||
write(path.join(installed, 'gstack-config'), `#!/bin/sh\nprintf 'CONFIG:%s\\n' "$*" >> ${quote(receipts)}\nexec bash ${quote(path.join(ROOT, 'bin/gstack-config'))} "$@"\n`, true);
|
||
write(path.join(installed, 'gstack-redact'), `#!/bin/sh\nprintf 'INSTALL:%s\\n' "$*" >> ${quote(receipts)}\nexec ${quote(process.execPath)} ${quote(path.join(ROOT, 'bin/gstack-redact'))} "$@"\n`, true);
|
||
write(path.join(installed, 'gstack-paths'), `#!/bin/sh\nexec bash ${quote(path.join(ROOT, 'bin/gstack-paths'))} "$@"\n`, true);
|
||
const workflow = readWorkflowExcerpt('ship/SKILL.md', '**Credential pre-push guard', '**Idempotency check');
|
||
write(path.join(repo, 'workflow.md'), workflow);
|
||
const guard = workflow.match(/```bash\n([\s\S]*?)```/)![1].trim();
|
||
const readable = new Set([path.join(repo, 'workflow.md'), hook, hook + '.local']);
|
||
const commands = new Set([guard, 'git config --get core.hooksPath', 'git rev-parse --git-path hooks/pre-push', 'git rev-parse --git-path hooks/pre-push.local']);
|
||
const executions: Array<{ tool: string; input: Record<string, unknown>; allowed: boolean }> = [];
|
||
const interactions: Array<{ disposition: string; input: unknown }> = [];
|
||
const prompt = `Load gstack's /ship credential pre-push guard from workflow.md and run only that workflow excerpt. Inspect the actual repository, apply its rules, report the outcome, and stop before pushing.
|
||
|
||
Declared fixture interactions: Read workflow.md and the local hook files; Bash for the supplied guard block verbatim and these read-only commands: git config --get core.hooksPath, git rev-parse --git-path hooks/pre-push, git rev-parse --git-path hooks/pre-push.local. Run each listed inspection command in its own Bash call; do not chain commands or append an echo/status probe. A missing core.hooksPath setting may return exit 1 without requiring another command. One native AskUserQuestion about hook modification is supported if the workflow calls for it; the owner declines modifications. Use Read rather than shell commands to inspect files. No other command, file edit, direct receipt access, directory change, background command, commit, push, config change, installation phase or unrelated /ship phase is supported. HOME and GSTACK_HOME already belong to this fixture; do not change them.`;
|
||
const invalid = (tool: string, input: Record<string, unknown>) => {
|
||
if (tool === 'Read') return typeof input.file_path !== 'string' || !readable.has(path.resolve(repo, input.file_path)) ? 'Read outside declared fixture paths' : undefined;
|
||
if (tool === 'Bash') return typeof input.command !== 'string' || input.run_in_background || !commands.has(input.command.trim()) ? 'Bash outside declared fixture commands' : undefined;
|
||
if (tool === 'AskUserQuestion') return interactions.some(event => event.disposition === 'declined') ? 'Duplicate owner question' : undefined;
|
||
return 'Tool outside declared fixture interactions';
|
||
};
|
||
const preToolUse: HookCallback = async input => {
|
||
if (input.hook_event_name !== 'PreToolUse') throw new Error('unexpected native hook event');
|
||
const toolInput = input.tool_input as Record<string, unknown>;
|
||
const reason = invalid(input.tool_name, toolInput);
|
||
executions.push({ tool: input.tool_name, input: toolInput, allowed: !reason });
|
||
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: reason ? 'deny' : input.tool_name === 'AskUserQuestion' ? 'ask' : 'allow',
|
||
...(reason ? { permissionDecisionReason: reason } : {}),
|
||
...(input.tool_name === 'Bash' && !reason ? { updatedInput: { command: toolInput.command, timeout: 10000, run_in_background: false } } : {}) } };
|
||
};
|
||
const canUseTool: CanUseTool = async (tool, input) => {
|
||
const reason = invalid(tool, input);
|
||
if (reason) {
|
||
interactions.push({ disposition: 'unsupported', input });
|
||
return { behavior: 'deny', message: reason };
|
||
}
|
||
if (tool !== 'AskUserQuestion') return { behavior: 'allow', updatedInput: input };
|
||
const questions = input.questions as Array<{ question: string; multiSelect?: boolean; options: Array<{ label: string; description: string }> }>;
|
||
const question = questions?.[0];
|
||
const decline = question?.options?.find(option => /^(?:[A-D][).]\s*)?(no\b|decline\b|do not\b|skip\b|leave\b|keep\b)/i.test(option.label));
|
||
if (questions?.length !== 1 || question.multiSelect || !/hook|guard|chain|credential/i.test(question.question) || !decline) {
|
||
interactions.push({ disposition: 'unsupported', input });
|
||
return { behavior: 'deny', message: 'Only one hook modification question with a decline option is supported.' };
|
||
}
|
||
interactions.push({ disposition: 'declined', input });
|
||
return { behavior: 'allow', updatedInput: { ...input, answers: { [question.question]: decline.label } } };
|
||
};
|
||
const snapshot = () => ({ receipts: read(receipts), hook: read(hook), localHook: read(hook + '.local'), localExists: fs.existsSync(hook + '.local'),
|
||
executions, interactions, changedProtectedFiles: [...protectedFiles].filter(([file, bytes]) => read(file) !== bytes).map(([file]) => path.relative(root, file)),
|
||
workflowSha256: createHash('sha256').update(workflow).digest('hex') });
|
||
return { id, root, repo, home, env, hook, receipts, guard, prompt, preToolUse, canUseTool, snapshot };
|
||
}
|
||
|
||
export function shipHookFailures(fixture: ReturnType<typeof createShipHookFixture>, result: Pick<AgentSdkResult, 'exitReason' | 'output'>) {
|
||
const evidence = fixture.snapshot();
|
||
const failures: string[] = [];
|
||
const check = (ok: boolean, message: string) => { if (!ok) failures.push(message); };
|
||
check(result.exitReason === 'success', `actor ended: ${result.exitReason}`);
|
||
check(evidence.changedProtectedFiles.length === 0, 'protected policy or fixture files changed');
|
||
check(!evidence.executions.some(event => !event.allowed) && !evidence.interactions.some(event => event.disposition === 'unsupported'), 'undeclared interaction');
|
||
check(evidence.executions.some(event => event.tool === 'Read' && event.allowed && path.resolve(fixture.repo, event.input.file_path as string) === path.join(fixture.repo, 'workflow.md')), 'workflow was not read through native hook');
|
||
check(evidence.executions.some(event => event.tool === 'Bash' && event.allowed && (event.input.command as string).trim() === fixture.guard), 'guard was not executed through native hook');
|
||
check(evidence.receipts.includes('CONFIG:get redact_prepush_hook\n'), 'actual config helper was not called');
|
||
const installs = evidence.receipts.match(/^INSTALL:install-prepush-hook$/gm)?.length ?? 0;
|
||
let callback: { status: number | null; received: string } | undefined;
|
||
if (fixture.id === 'ship-managed-hook-refresh') {
|
||
check(installs === 1, 'expected exactly one actual installer call');
|
||
check(evidence.hook !== oldHook && evidence.hook.includes('_input="$(cat; printf x)"'), 'managed wrapper was not refreshed');
|
||
check(evidence.localHook === localPolicy, 'local policy changed');
|
||
check(evidence.interactions.length === 0, 'managed refresh unnecessarily requested consent');
|
||
const invoked = spawnSync('bash', [fixture.hook, 'origin', 'synthetic'], { cwd: fixture.repo, env: fixture.env, input: refs, encoding: 'utf8', timeout: 10000 });
|
||
callback = { status: invoked.status, received: read(path.join(fixture.home, 'received')) };
|
||
check(callback.status === 37 && callback.received === refs, 'refreshed wrapper lost local policy status or complete stdin');
|
||
check(/refresh|updat|install|current/i.test(result.output), 'refresh outcome was not acknowledged');
|
||
} else {
|
||
check(installs === 0, 'installer invoked without applicable consent');
|
||
check(evidence.hook === unmanagedHook, 'unmanaged policy changed');
|
||
if (fixture.id === 'ship-unmanaged-hook-consent') {
|
||
check(evidence.interactions.filter(event => event.disposition === 'declined').length === 1, 'unmanaged-hook consent was not requested');
|
||
check(!evidence.localExists, 'declined install created a local policy');
|
||
// A quoted option label (You chose "Leave it alone") is not the model's own acknowledgment.
|
||
const labels = evidence.interactions.filter(event => event.disposition === 'declined')
|
||
.flatMap(event => (event.input as { questions: Array<{ options: Array<{ label: string }> }> }).questions[0].options.map(option => option.label));
|
||
const own = labels.reduce((text, label) => text.replaceAll(`"${label}"`, '').replaceAll(`“${label}”`, ''), result.output);
|
||
check(/declin|unchanged|not install|not modif|preserv|left|leave|untouched|(?:installed|changed|modified) nothing|nothing (?:was |is )?(?:installed|changed|modified)|did(?: not|n['’]t) (?:install|modify|change|replace|touch)/i.test(own), 'declined modification was not acknowledged');
|
||
} else {
|
||
check(evidence.localHook === localPolicy, 'existing local policy changed');
|
||
check(evidence.interactions.length === 0, 'existing local policy requires manual integration, not consent to overwrite');
|
||
// Manual integration: "manual", or one sentence asking the user (or someone) to integrate it by hand. The phrasal
|
||
// object may be a pronoun or a short determiner-led noun phrase ("wire the guard in", gate 40467320097), and the
|
||
// hand may be the owner's own ("the repo owner needs to add it ... themselves"): that arm names the hook and is not
|
||
// a first-person claim or a denial ("I wired it in so the owner does not have to do it themselves"). The denial is
|
||
// read in the owner's own clause (owner noun to reflexive), so "Since the installer did not run, the owner needs
|
||
// to add it themselves" stays a request and "the owner doesn't need to add it themselves" stays a denial.
|
||
const phrasalObject = '(?:(?:it|them|this|that|both|(?:the|this|that|its|your|these|those|both)\\s+(?:\\S+\\s+){0,2}?)\\s*)?';
|
||
const ownersOwnHand = (sentence: string) => {
|
||
const reflexive = sentence.search(/\b(?:them|him|her)sel(?:f|ves)\b/i);
|
||
const owner = reflexive >= 0 ? [...sentence.slice(0, reflexive).matchAll(/\b(?:owner|maintainer|someone|whoever|user|team)\b/gi)].at(-1) : undefined;
|
||
return owner !== undefined && /\b(?:hooks?|guard|polic(?:y|ies)|pre-push|scripts?|wrapper)\b/i.test(sentence) &&
|
||
!/(?:\b(?:not|never|no\s+need|without|nothing)|n['’]t)\b/i.test(sentence.slice(owner.index, reflexive)) &&
|
||
!/\b(?:I|we)(?:['’](?:ll|ve|d))?\s+(?:(?:will|have|already|just|can)\s+)?(?:integrate|combine|merge|set|wire|add)/i.test(sentence);
|
||
};
|
||
const toUser = (sentence: string) => new RegExp(`\\b(?:integrate|combine|merge|set\\s+${phrasalObject}up|wire[sd]?\\s+${phrasalObject}(?:up|in)|add)\\b`, 'i').test(sentence) &&
|
||
(/\byourself\b/i.test(sentence) || ownersOwnHand(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');
|
||
}
|
||
}
|
||
return { failures, evidence: { ...evidence, callback } };
|
||
}
|
||
|
||
export async function runShipHookActor(id: ShipHookCase, record: (entry: EvalTestEntry) => void, injectedQuery?: QueryProvider, artifactDirectory?: string) {
|
||
const artifacts = artifactDirectory ?? process.env.GSTACK_EVAL_DIR;
|
||
if (!artifacts) throw new Error('ship hook actor requires an explicit GSTACK_EVAL_DIR for durable evidence');
|
||
fs.mkdirSync(artifacts, { recursive: true, mode: 0o700 });
|
||
const { query } = await import('@anthropic-ai/claude-agent-sdk');
|
||
const { runAgentSdkTest, resolveClaudeBinary } = await import('./agent-sdk-runner');
|
||
let fixture = createShipHookFixture(id);
|
||
const started = Date.now();
|
||
const attempts: Array<{ events: SDKMessage[]; evidence?: ReturnType<typeof fixture.snapshot> }> = [];
|
||
let result: AgentSdkResult | undefined;
|
||
let finalEvidence: unknown;
|
||
let failure: unknown;
|
||
let passed = false;
|
||
const evidenceFile = path.join(artifacts, `${id}-${randomUUID()}.json`);
|
||
try {
|
||
const binary = injectedQuery ? undefined : resolveClaudeBinary();
|
||
if (!injectedQuery && !binary) throw new Error('native ship hook actor requires the pinned Claude CLI');
|
||
result = await runAgentSdkTest({ systemPrompt: { type: 'preset', preset: 'claude_code' }, userPrompt: fixture.prompt,
|
||
workingDirectory: fixture.repo, env: fixture.env, maxTurns: 12, signal: AbortSignal.timeout(CAPTURE_MS - 20000),
|
||
allowedTools: ['Read', 'Bash', 'AskUserQuestion'], settingSources: [], testName: id,
|
||
pathToClaudeCodeExecutable: binary ?? undefined,
|
||
canUseTool: (...args) => fixture.canUseTool(...args),
|
||
onRetry: () => {
|
||
attempts.at(-1)!.evidence = fixture.snapshot();
|
||
const root = fixture.root;
|
||
fs.rmSync(root, { recursive: true, force: true });
|
||
fixture = createShipHookFixture(id, root);
|
||
},
|
||
queryProvider: options => {
|
||
const attempt: typeof attempts[number] = { events: [] };
|
||
attempts.push(attempt);
|
||
const stream = (injectedQuery ?? query)({ ...options, options: { ...options.options, allowedTools: [],
|
||
hooks: { PreToolUse: [{ hooks: [(...args) => fixture.preToolUse(...args)] }] } } });
|
||
return new Proxy(stream, { get(target, property) {
|
||
if (property === Symbol.asyncIterator) return async function* () {
|
||
try { for await (const event of target) { attempt.events.push(event); yield event; } }
|
||
finally { attempt.evidence = fixture.snapshot(); }
|
||
};
|
||
const value = Reflect.get(target, property, target);
|
||
return typeof value === 'function' ? value.bind(target) : value;
|
||
} });
|
||
},
|
||
});
|
||
const verdict = shipHookFailures(fixture, result);
|
||
finalEvidence = verdict.evidence;
|
||
if (verdict.failures.length) throw new Error(verdict.failures.join('; '));
|
||
passed = true;
|
||
} catch (error) {
|
||
failure = error;
|
||
throw error;
|
||
} finally {
|
||
try {
|
||
const output = JSON.stringify({ assistant: result?.output, error: failure === undefined ? undefined : String(failure),
|
||
evidence: finalEvidence ?? fixture.snapshot(), attempts });
|
||
fs.writeFileSync(evidenceFile, output + '\n', { mode: 0o600 });
|
||
record({ name: id, suite: 'ship-hook-boundary', tier: 'e2e', passed, duration_ms: Date.now() - started,
|
||
cost_usd: result?.costUsd ?? 0, transcript: result?.events, prompt: fixture.prompt, turns_used: result?.turnsUsed,
|
||
model: result?.model, output, error: failure === undefined ? undefined : String(failure),
|
||
exit_reason: passed ? 'success' : result?.exitReason === 'success' ? 'assertion_failed' : result?.exitReason ?? 'runner_error' });
|
||
} finally { fs.rmSync(fixture.root, { recursive: true, force: true }); }
|
||
}
|
||
return evidenceFile;
|
||
}
|