From afd766600baca7f7a45d4a8b69f2547be56070ed Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 3 Nov 2026 06:43:26 +0000 Subject: [PATCH] fix(ship-queue, hooks): selector reads the checkout under review; config lists the hook switches; installs are not checks; one deferral per running agent set (GST-1088 follow-up) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-config | 8 +++- changelog.d/capy-gst-1088-lane-a-followup.md | 1 + docs/skill-routing-and-contracts.md | 4 +- .../claude/hooks/skill-contract-stop-hook.ts | 18 ++++++-- lib/skill-contract.ts | 44 +++++++++++++++---- scripts/lib/paid-select.ts | 7 ++- test/gstack-config-defaults.test.ts | 25 ++++++++--- test/pr-dependencies.test.ts | 14 ++++++ test/skill-contract-stop-hook.test.ts | 16 ++++--- test/skill-contract.test.ts | 5 ++- 10 files changed, 111 insertions(+), 31 deletions(-) create mode 100644 changelog.d/capy-gst-1088-lane-a-followup.md diff --git a/bin/gstack-config b/bin/gstack-config index c9e4f72fc..fde1fbe5c 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -344,6 +344,8 @@ lookup_default() { artifacts_sync_removals) echo "off" ;; # on | off — publish removals of synced files gone from disk plan_tune_hooks) echo "prompt" ;; # prompt | yes | no — controls ./setup plan-tune hook install timeline_stop_hook) echo "yes" ;; # yes | no — controls ./setup timeline Stop hook registration (#2677) + skill_routing) echo "off" ;; # on | off — ./setup registers the skill-routing UserPromptSubmit hook (docs/skill-routing-and-contracts.md) + skill_contracts) echo "off" ;; # on | off — ./setup registers the skill-contract Stop hook (docs/skill-routing-and-contracts.md) redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection redact_prepush_hook) echo "false" ;; @@ -1139,7 +1141,8 @@ $_gstack_legacy_root" ship_measure_judge_outputs ship_measure_ask_per_trial_usd ship_measure_budget_usd \ ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \ plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \ - model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model harness_start_hook; do + model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model harness_start_hook \ + skill_routing skill_contracts; do gstack_config_select "$KEY" VALUE="$_gstack_cfg_value" SOURCE="default" @@ -1164,7 +1167,8 @@ $_gstack_legacy_root" ship_measure_judge_outputs ship_measure_ask_per_trial_usd ship_measure_budget_usd \ ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \ plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \ - model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model harness_start_hook; do + model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model harness_start_hook \ + skill_routing skill_contracts; do printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")" done ;; diff --git a/changelog.d/capy-gst-1088-lane-a-followup.md b/changelog.d/capy-gst-1088-lane-a-followup.md new file mode 100644 index 000000000..04327a872 --- /dev/null +++ b/changelog.d/capy-gst-1088-lane-a-followup.md @@ -0,0 +1 @@ +Follow-ups to the skill routing and contract hooks (GST-1088). `gstack-ship-queue ready` no longer misreads files a PR adds as deleted when the installed gstack is older than the checkout: the paid-case selector now derives dependencies from the checkout under review. `gstack-config list` and `defaults` show `skill_routing` and `skill_contracts` (both off by default). The contract checker no longer counts installs, builds or version probes (`bun install`, `go build`, `node --version`, `make clean`) as a test run after an edit, and the Stop hook lets a stop through only once while the same Agent dispatches are still running, recording the run as `deferred`, then checks the contract if they are still running at the next stop. diff --git a/docs/skill-routing-and-contracts.md b/docs/skill-routing-and-contracts.md index f0eb59b84..ecb34374f 100644 --- a/docs/skill-routing-and-contracts.md +++ b/docs/skill-routing-and-contracts.md @@ -109,13 +109,13 @@ The hook checks the most recently loaded skill that declares a contract, and onl - the session is headless or unattended (`gstack-session-kind`), or - the turn does not end on a question to the user (a last paragraph ending in "?", an AskUserQuestion call, or `NEEDS_CONTEXT`). -So an interactive session that pauses to ask a question is never blocked. When Claude Code says a Stop hook already continued this turn (`stop_hook_active`), the hook never blocks: after its own block it records the result (`met_after_block` or `miss`). It also allows the stop while Agent dispatches are still running (no result yet, or launched in the background with no completion notice naming that agent), because their outputs may still arrive. Because Claude Code can fire Stop before the turn's last message reaches the transcript file, the hook reads the payload's `last_assistant_message` as the turn's final text. +So an interactive session that pauses to ask a question is never blocked. When Claude Code says a Stop hook already continued this turn (`stop_hook_active`), the hook never blocks: after its own block it records the result (`met_after_block` or `miss`). It also allows the stop once while Agent dispatches are still running (no result yet, or launched in the background with no completion notice naming that agent), because their outputs may still arrive, and records the run as `deferred`. If the same agents are still running at the next stop, they count as stalled and the contract is checked. Because Claude Code can fire Stop before the turn's last message reaches the transcript file, the hook reads the payload's `last_assistant_message` as the turn's final text. It blocks at most once per skill run. If the model stops again with the contract still unmet, the stop is allowed and the run is recorded as a miss. ### Records -Each Claude Code session has one record, `$GSTACK_STATE_ROOT/skill-contracts/.json`, written atomically (mode 0600) before any block. Each skill run in it has an outcome: `met`, `met_waived`, `blocked`, `met_after_block` or `miss`. The hook's decisions also go to `$GSTACK_STATE_ROOT/hooks/skill-contract.log`. +Each Claude Code session has one record, `$GSTACK_STATE_ROOT/skill-contracts/.json`, written atomically (mode 0600) before any block. Each skill run in it has an outcome: `met`, `met_waived`, `blocked`, `met_after_block`, `miss` or `deferred` (stopped while its agents were still running, not yet checked). The hook's decisions also go to `$GSTACK_STATE_ROOT/hooks/skill-contract.log`. ## Commands diff --git a/hosts/claude/hooks/skill-contract-stop-hook.ts b/hosts/claude/hooks/skill-contract-stop-hook.ts index f46da16a2..125cd6474 100755 --- a/hosts/claude/hooks/skill-contract-stop-hook.ts +++ b/hosts/claude/hooks/skill-contract-stop-hook.ts @@ -7,7 +7,9 @@ * * stdin -> switch on? -> transcript_path -> parse (+ last_assistant_message) * -> the most recently loaded skill that declares a contract (none: allow) - * -> Agent dispatches still running? allow (outputs may still arrive) + * -> Agent dispatches still running? allow once per set of running agents + * (outputs may still arrive); the same set still running at the next + * stop is stalled, so the contract is checked * -> final turn? (a STATUS line, a headless or unattended session, or no * pending user question) -> checkContract (lib/skill-contract.ts) * -> unmet and this run never blocked: record "blocked", then block with @@ -104,12 +106,22 @@ async function main(): Promise { if (!t) return trace({ event: 'no-contract-skill' }); const result = checkContract(transcript, t.skill, t.contract); if (!result.load) return trace({ event: 'no-contract-skill' }); - if (result.pendingAgents.length) return trace({ event: 'agents-running', skill: t.skill, agents: result.pendingAgents.length }); - if (!isFinalTurn(result)) return trace({ event: 'not-final-turn', skill: t.skill }); const file = recordPath(sessionId); const record = readRecord(file) ?? { version: 1 as const, session_id: sessionId, transcript_path: transcriptPath, runs: {} }; const key = runKey(t.skill, result.load); const prev = record.runs[key]; + const pending = [...result.pendingAgents].sort(); + if (pending.length && pending.join(',') !== (prev?.deferred_agents ?? []).join(',')) { + record.transcript_path = transcriptPath; + record.runs[key] = { + skill: t.skill, load_line: result.load.line, outcome: prev?.outcome ?? 'deferred', missing: prev?.missing ?? [], waived: prev?.waived ?? [], + ...(prev?.blocked_at ? { blocked_at: prev.blocked_at } : {}), deferred_agents: pending, updated_at: new Date().toISOString(), + }; + writeRecord(file, record); + return trace({ event: 'agents-running', skill: t.skill, agents: pending.length }); + } + if (pending.length) trace({ event: 'agents-stalled', skill: t.skill, agents: pending.length }); + if (!isFinalTurn(result)) return trace({ event: 'not-final-turn', skill: t.skill }); const { outcome, block } = nextOutcome(prev, result); if (block && stopActive) return trace({ event: 'stop-hook-active', skill: t.skill }); const missing = result.missing.join(',') || '-'; diff --git a/lib/skill-contract.ts b/lib/skill-contract.ts index bd93e34cb..301eb9f54 100644 --- a/lib/skill-contract.ts +++ b/lib/skill-contract.ts @@ -267,23 +267,48 @@ export function skillLoads(t: Transcript): SkillLoad[] { export const STATUS_RE = /^[ \t>*_-]*(?:\*\*|__)?(?:STATUS|Status)(?:\*\*|__)?[ \t]*(?:\*\*|__)?:[ \t]*(?:\*\*|__)?[ \t]*(DONE_WITH_CONCERNS|DONE|BLOCKED|NEEDS_CONTEXT)\b/m; const EDIT_TOOLS = /^(?:Edit|Write|MultiEdit|NotebookEdit)$/; const AGENT_TOOLS = /^(?:Agent|Task)$/; -const CHECK_RUNNER = /^(?:pytest|jest|vitest|mocha|rspec|phpunit|ctest|tox|nox|tsc|make|cargo|go|bun|bunx|npm|npx|pnpm|yarn|node|deno|python3?|ruby|rake|mvn|gradlew?|dotnet|swift|xcodebuild|curl|mix|bazel|just|dart|flutter|rails)$/i; -const CHECK_SCRIPT = /(?:^|[-_.])(?:tests?|spec|repro|check|verify|lint)(?:[-_.]|$)/i; +const TEST_RUNNER = /^(?:pytest|py\.test|jest|vitest|mocha|rspec|phpunit|ctest|tox|nox|tsc|rake|playwright|cypress)$/i; +const CHECK_SCRIPT = /(?:^|[-_.:])(?:tests?|spec|repro|check|verify|lint)(?:[-_.:]|$)/i; +const CHECK_VERB = /^(?:tests?|t|check|verify|spec|vet|clippy|nextest|lint|e2e)$/i; +const SUBCOMMAND_TOOL = /^(?:make|cargo|go|dotnet|swift|mix|bazel|just|dart|flutter|rails|mvn|gradlew?|xcodebuild|bun|npm|pnpm|yarn|deno)$/i; +const RUNS_PROGRAM = /^(?:cargo|go|dotnet|swift)$/i; +const PACKAGE_EXEC = /^(?:npx|bunx|pnpx)$/i; +const INTERPRETER = /^(?:node|python3?|ruby|php|perl|tsx|ts-node)$/i; +const FILE_INTERPRETER = /^(?:bun|deno)$/i; const COMMAND_WRAPPER = /^(?:bundle|uv|poetry|pipenv|docker|podman|xvfb-run|bash|sh|zsh)$/; const COMMAND_PREFIX = /^(?:env|sudo|time|nice|timeout|command|exec|\w+=\S*|-\S*|\d+[smh]?)$/; -const isCheckProgram = (word: string) => { const name = word.split(/[\\/]/).pop() ?? ''; return CHECK_RUNNER.test(name) || (name !== 'test' && CHECK_SCRIPT.test(name)); }; +const SCRIPT_ARG = /[\\/]|\.(?:[cm]?[jt]sx?|py|rb|php|pl|sh)$/; +const baseName = (word: string) => word.split(/[\\/]/).pop() ?? ''; +const isCheckScript = (word: string) => { const name = baseName(word); return name !== 'test' && CHECK_SCRIPT.test(name); }; + +/** One command's words (prefixes removed): does it run tests, a check, or a repro? Installs, builds and version probes do not. */ +function runsCheck(words: string[]): boolean { + const program = baseName(words[0] ?? ''); + const args = words.slice(1).filter(w => !w.startsWith('-')); + if (TEST_RUNNER.test(program) || isCheckScript(words[0] ?? '')) return true; + if (PACKAGE_EXEC.test(program)) return runsCheck(words.slice(1).filter(w => !w.startsWith('-'))); + if (COMMAND_WRAPPER.test(program)) return [1, 2, 3, 4, 5].some(i => i < words.length && runsCheck(words.slice(i))); + if (INTERPRETER.test(program)) return args.length > 0 || words.slice(1).some(w => /^-[ecm]$/.test(w)); + if (program === 'curl') return args.some(a => /^https?:\/\/|^localhost|^127\./.test(a)); + if (FILE_INTERPRETER.test(program) && args[0] && SCRIPT_ARG.test(args[0])) return true; + if (!SUBCOMMAND_TOOL.test(program) || !args[0]) return false; + const [verb, next] = args; + if (CHECK_VERB.test(verb) || isCheckScript(verb)) return true; + if (verb === 'run' || verb === 'exec' || verb === 'x') return RUNS_PROGRAM.test(program) || (!!next && (CHECK_VERB.test(next) || isCheckScript(next) || TEST_RUNNER.test(baseName(next)) || SCRIPT_ARG.test(next))); + return /^(?:yarn|pnpm)$/i.test(program) && TEST_RUNNER.test(verb); +} const SAFE_SPECIALIST = /^[A-Za-z0-9][A-Za-z0-9 _.-]{0,39}$/; /** - * A test or repro run: some segment of the command (split on && || ; | and newlines) starts with a test runner or a - * test, check or repro script, directly or behind a wrapper (bundle exec, uv run, docker compose run, xvfb-run, bash). + * A test or repro run: some segment of the command (split on && || ; | and newlines) runs a test runner, a test verb + * (`npm test`, `go test`, `make check`), a test, check or repro script, or code through an interpreter, directly or + * behind a wrapper (bundle exec, uv run, docker compose run, xvfb-run, bash). Installs, builds and version probes do not count. */ export function isCheckCommand(cmd: string): boolean { return cmd.split(/&&|\|\||[;|\n]/).some(segment => { const words = segment.trim().replace(/^[({]\s*/, '').split(/\s+/); while (words.length > 1 && COMMAND_PREFIX.test(words[0])) words.shift(); - if (isCheckProgram(words[0])) return true; - return COMMAND_WRAPPER.test(words[0].split(/[\\/]/).pop() ?? '') && words.slice(1, 6).some(isCheckProgram); + return runsCheck(words); }); } const WAIVE_RE = /gstack-skill-contract["']?\s+waive\s+["']?([A-Za-z0-9][A-Za-z0-9_.-]*)["']?/; @@ -495,8 +520,9 @@ export function lookupContract(skill: string, skillsDir: string, skillFile?: str return 'error' in parsed ? { skill, file, error: `${file}: ${parsed.error}`, reason: 'invalid' } : { skill, file, contract: parsed.contract }; } -export type Outcome = 'met' | 'met_waived' | 'blocked' | 'met_after_block' | 'miss'; -export interface RunRecord { skill: string; load_line: number; outcome: Outcome; missing: string[]; waived: string[]; blocked_at?: string; updated_at: string } +export type Outcome = 'met' | 'met_waived' | 'blocked' | 'met_after_block' | 'miss' | 'deferred'; +/** deferred_agents: the Agent dispatches still running at the last stop the hook let through unchecked. */ +export interface RunRecord { skill: string; load_line: number; outcome: Outcome; missing: string[]; waived: string[]; blocked_at?: string; deferred_agents?: string[]; updated_at: string } export interface SessionRecord { version: 1; session_id: string; transcript_path: string; runs: Record } export function recordsDir(env: NodeJS.ProcessEnv = process.env): string { diff --git a/scripts/lib/paid-select.ts b/scripts/lib/paid-select.ts index 192a4363a..2b2f75bae 100644 --- a/scripts/lib/paid-select.ts +++ b/scripts/lib/paid-select.ts @@ -10,7 +10,8 @@ import { isPaidTestFile } from '../../test/helpers/paid-test-set'; import { PERIODIC_CI_EXCLUDE } from '../../test/helpers/periodic-exclude-data'; import { FILE_RETRY_BUDGETS } from '../../test/helpers/eval-budgets'; import { OVERLAY_MIN_FILE_WALL_MS } from '../../test/helpers/overlay-case-policy'; -import { packageChangeOnlyVersion, prProfileFileCases, selectPrProfile, type PrProfileSelection } from '../test-pr-profile'; +import { PR_PROFILE_MAPS, packageChangeOnlyVersion, prProfileFileCases, selectPrProfile, type PrProfileSelection } from '../test-pr-profile'; +import { derivedDependencies } from '../pr-dependencies'; import { detectBaseBranch, getChangedFiles, selectTests, E2E_TOUCHFILES, E2E_TIERS, LLM_JUDGE_TOUCHFILES, GLOBAL_TOUCHFILES } from '../../test/helpers/touchfiles'; import { caseTestNamePattern, fileCaseRegistration, shardCaseId, shardFile, shardTrial } from './paid-cases'; import { DEFAULT_MAX_FILES_PER_SHARD, DEFAULT_SHARD_TIMEOUT_MS, DEFAULT_WITHIN_SHARD_CONCURRENCY, PAID_TIERS, ROOT, isOverlayTestFile, @@ -249,8 +250,10 @@ export function computePaidCaseSelection(options: { : selectTests(selectionFiles, table, GLOBAL_TOUCHFILES, { baseRef, cwd: rootDir }).selected; const selection = { e2e: select(E2E_TOUCHFILES), judges: select(LLM_JUDGE_TOUCHFILES) }; if (options.profile === 'full') return { selection, reason: all ? 'run-all' : 'diff' }; + // The changed files are paths in the checkout under review, which can be newer than the tree this selector runs from. + const readSource = (file: string) => { try { return fs.readFileSync(path.join(rootDir, file), 'utf8'); } catch { return ''; } }; const coverage = selectPrProfile({ selectedE2E: selection.e2e, selectedJudges: selection.judges, changedFiles: effectiveFiles, sourceAliases, - screenPolicy: env.EVALS_SCREEN_POLICY === 'narrow' ? 'narrow' : 'broad' }); + derived: derivedDependencies(PR_PROFILE_MAPS, rootDir), readSource, screenPolicy: env.EVALS_SCREEN_POLICY === 'narrow' ? 'narrow' : 'broad' }); if (coverage.needsFullValidation) { throw new Error(`PR profile requires full validation: ${coverage.missingCoverage.join(', ')}. Use --profile full and the relevant periodic cases.`); } diff --git a/test/gstack-config-defaults.test.ts b/test/gstack-config-defaults.test.ts index 0e7fd4269..57d7bad4b 100644 --- a/test/gstack-config-defaults.test.ts +++ b/test/gstack-config-defaults.test.ts @@ -28,7 +28,7 @@ * depend on receiving "" successfully. * * The reverse direction is pinned too: every key in the DEFAULTS table must - * be read by code (bin/, lib/, scripts/, browse/src/, setup, or a .tmpl), or be listed in + * be read by code (bin/, lib/, scripts/, browse/src/, hosts/, setup, or a .tmpl), or be listed in * PROSE_ONLY_KEYS with a reason. A documented setting no code reads is a * switch that does nothing, which is how a consent choice once went unread. */ @@ -120,7 +120,7 @@ function keysReadInTree(root = ROOT): string[] { */ const PROSE_ONLY_KEYS: Record = {}; -const READER_DIRS = ['bin', 'lib', 'scripts', 'browse/src']; +const READER_DIRS = ['bin', 'lib', 'scripts', 'browse/src', 'hosts']; const READER_FILES = ['setup']; /** @@ -176,7 +176,7 @@ function isReadInCorpus(arm: string, corpus: string): boolean { const re = new RegExp([ `(?:gstack-config|\\$\\{?[A-Z_]*CONF[A-Z_]*\\}?)["']?[ \\t]+(?:get|has)[ \\t]+["']?${k}`, `['"](?:get|has)['"]\\s*,\\s*['"]${k}`, - `(?:readConfigKey|readConfigKeyWithRoot|readGstackConfigYamlKey|configValue|configGet|gstack_read_config_key|gstack_config_select)\\(?[ \\t]*['"\`]?${k}`, + `(?:readConfigKey|readConfigKeyWithRoot|readGstackConfigYamlKey|readSkillSwitch|configValue|configGet|gstack_read_config_key|gstack_config_select)\\(?[ \\t]*['"\`]?${k}`, `\\^${k}${wildcard ? '' : ':'}`, ].join('|')); return re.test(corpus); @@ -226,7 +226,7 @@ describe('gstack-config defaults (gate, free)', () => { if (unread.length > 0) { throw new Error( `DEFAULTS keys with no reader: ${unread.join(', ')}. Fix one of two ways: ` + - `add the code that reads the key (gstack-config get/has in bin/, lib/, scripts/, setup or a .tmpl), ` + + `add the code that reads the key (gstack-config get/has in bin/, lib/, scripts/, hosts/, setup or a .tmpl), ` + `or add it to PROSE_ONLY_KEYS in ${SELF} with the reason it has no reader.`, ); } @@ -250,14 +250,15 @@ describe('gstack-config defaults (gate, free)', () => { "spawnSync(bin, ['get', 'key_three'], { timeout: 2000 })", "readConfigKey('key_four')", "readGstackConfigYamlKey('key_eight')", + "readSkillSwitch('key_nine')", 'grep -E "^key_five_${hash}:" "$F"', ].join('\n')); fs.writeFileSync(path.join(root, 'docs', 'prose.md'), 'gstack-config get key_six\n'); fs.writeFileSync(path.join(root, 'skill.md.tmpl'), 'gstack-config get key_seven\n'); const corpus = readerCorpus(root); - const read = ['key_one', 'key_two', 'key_three', 'key_four', 'key_five_*', 'key_six', 'key_seven', 'key_eight', 'key_one_more'] + const read = ['key_one', 'key_two', 'key_three', 'key_four', 'key_five_*', 'key_six', 'key_seven', 'key_eight', 'key_nine', 'key_one_more'] .filter((k) => isReadInCorpus(k, corpus)); - expect(read).toEqual(['key_one', 'key_two', 'key_three', 'key_four', 'key_five_*', 'key_seven', 'key_eight']); + expect(read).toEqual(['key_one', 'key_two', 'key_three', 'key_four', 'key_five_*', 'key_seven', 'key_eight', 'key_nine']); } finally { fs.rmSync(root, { recursive: true, force: true }); } @@ -320,6 +321,18 @@ describe('design_detector (auto|off, rejecting validator)', () => { }); }); +describe('skill_routing and skill_contracts (on|off, default off)', () => { + test('both default to off, and list and defaults enumerate them', () => { + for (const key of ['skill_routing', 'skill_contracts']) expect(get(key)).toEqual({ out: 'off', code: 0 }); + for (const verb of ['list', 'defaults']) { + const r = spawnSync('bash', [CONFIG_BIN, verb], { encoding: 'utf-8', timeout: 30_000, env: { ...process.env, GSTACK_STATE_ROOT: STATE } }); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/skill_routing:\s+off/); + expect(r.stdout).toMatch(/skill_contracts:\s+off/); + } + }); +}); + describe('design_detector_install_prompted (true|false, rejecting validator)', () => { const env = { ...process.env, GSTACK_STATE_ROOT: STATE }; test('defaults to false, rejects a typo with the file unchanged, round-trips true/false, and is enumerated', () => { diff --git a/test/pr-dependencies.test.ts b/test/pr-dependencies.test.ts index 95abcfb08..11098c14b 100644 --- a/test/pr-dependencies.test.ts +++ b/test/pr-dependencies.test.ts @@ -304,6 +304,20 @@ describe('deleted files (paths absent from the head tree)', () => { expect(real.referencers('bin/gstack-next-version')).toContain('review/SKILL.md.tmpl'); }); + test('a file the PR adds is read from the checkout under review, not from the selector\'s own tree (an older install)', () => { + const added = 'hosts/claude/hooks/zz-added-hook.ts'; + expect(fs.existsSync(path.join(ROOT, added))).toBe(false); + expect(real.referencers(added)).toContain('setup'); + const checkout = fixtureRepo({ setup: 'SETUP_HOOK=hosts/claude/hooks/zz-added-hook\n', [added]: 'export {};\n' }); + try { + const result = computePaidCaseSelection({ profile: 'pr', rootDir: checkout, env: { EVALS_BASE: 'HEAD' }, changedFiles: [added], packageVersionOnly: true }); + expect(result.coverage!.unknownFileLabels.filter(l => l.file === added)).toEqual([]); + expect(result.reason).not.toContain('deleted but still referenced'); + } finally { + fs.rmSync(checkout, { recursive: true, force: true }); + } + }); + test('a deleted file a full-gate input still references, or a deleted full-gate entry, restores the full gate', () => { expect(real.referencers('bin/gstack-install-registry.sh')).toContain('setup'); const referenced = pick('bin/gstack-install-registry.sh'); diff --git a/test/skill-contract-stop-hook.test.ts b/test/skill-contract-stop-hook.test.ts index 065fd0cec..c5a10b68f 100644 --- a/test/skill-contract-stop-hook.test.ts +++ b/test/skill-contract-stop-hook.test.ts @@ -135,16 +135,20 @@ describe('never blocks the wrong turn', () => { expect(JSON.parse(stop(file, { GSTACK_HEADLESS: '1' }).stdout).decision).toBe('block'); }); - test('Agent readers still running when Stop fires: allowed, nothing recorded', () => { + test('Agent readers still running when Stop fires: allowed once and recorded deferred; the same set still running next stop is checked', () => { const base = [slash('review'), say('Dispatching 2 specialists: Testing, Security. Skipped: Performance (scope not detected).')]; const background = [...base, tool('a1', 'Agent', { description: 'Testing specialist', prompt: 'review', run_in_background: true }), result('a1', 'Async agent launched in the background.'), say('Waiting for the specialists to finish.')]; - const unanswered = [...base, tool('a2', 'Agent', { description: 'Security specialist', prompt: 'review' })]; - for (const lines of [background, unanswered]) { - expect(stop(transcript(lines))).toEqual({ code: 0, stdout: '', stderr: '' }); - } - expect(fs.existsSync(path.join(state, 'skill-contracts'))).toBe(false); + const file = transcript(background); + expect(stop(file)).toEqual({ code: 0, stdout: '', stderr: '' }); + expect(Object.values(record().runs)[0]).toMatchObject({ outcome: 'deferred', deferred_agents: ['a1'] }); expect(fs.readFileSync(path.join(state, 'hooks', 'skill-contract.log'), 'utf8')).toContain('event=agents-running'); + fs.appendFileSync(file, `${say('Still waiting on the specialists.')}\n`); + expect(JSON.parse(stop(file).stdout).decision).toBe('block'); + expect(Object.values(record().runs)[0]).toMatchObject({ outcome: 'blocked' }); + expect(fs.readFileSync(path.join(state, 'hooks', 'skill-contract.log'), 'utf8')).toContain('event=agents-stalled'); + const unanswered = [...base, tool('a2', 'Agent', { description: 'Security specialist', prompt: 'review' })]; + expect(stop(transcript(unanswered, 'unanswered.jsonl'), {}, { session_id: 'sess-2' })).toEqual({ code: 0, stdout: '', stderr: '' }); }); test('no contract skill loaded: allowed', () => { diff --git a/test/skill-contract.test.ts b/test/skill-contract.test.ts index 79b6d8960..103c34fb3 100644 --- a/test/skill-contract.test.ts +++ b/test/skill-contract.test.ts @@ -176,7 +176,10 @@ describe('checkContract is a pure function of the transcript', () => { for (const cmd of ['bun test', 'cd repo && npm test', 'FOO=1 timeout 60 pytest -x', './scripts/run-tests.sh', 'node repro.js | tail -5', 'bin/gstack-verify', 'bundle exec rspec', 'uv run pytest', 'poetry run pytest', 'docker compose run app pytest', 'xvfb-run -a npm test', 'bash scripts/test.sh', 'mix test', 'bazel test //...']) expect([cmd, isCheckCommand(cmd)]).toEqual([cmd, true]); - for (const cmd of ['git commit -m wip --no-verify', 'git add test/a.test.ts', 'cat test/a.test.ts', 'git diff --check', 'ls go', 'test -f README.md', './scripts/fetch-latest.sh', 'bash scripts/deploy.sh']) + for (const cmd of ['npm test', 'npm run test:unit', 'yarn jest', 'go test ./...', 'cargo test', 'make check', 'python -m pytest', 'bun repro.ts', 'npx vitest run', 'curl -s http://localhost:3000/api']) + expect([cmd, isCheckCommand(cmd)]).toEqual([cmd, true]); + for (const cmd of ['git commit -m wip --no-verify', 'git add test/a.test.ts', 'cat test/a.test.ts', 'git diff --check', 'ls go', 'test -f README.md', './scripts/fetch-latest.sh', 'bash scripts/deploy.sh', + 'bun install', 'npm install lodash', 'bun add zod', 'go build ./...', 'go mod tidy', 'node --version', 'make clean', 'make', 'npm run build', 'curl --version']) expect([cmd, isCheckCommand(cmd)]).toEqual([cmd, false]); });