diff --git a/docs/skill-routing-and-contracts.md b/docs/skill-routing-and-contracts.md index fd429cc66..4769d2d26 100644 --- a/docs/skill-routing-and-contracts.md +++ b/docs/skill-routing-and-contracts.md @@ -97,7 +97,7 @@ Items are checked against one run of the skill: from its last load to the end of A session loads /investigate, edits `src/auth.ts` and answers "Fixed: the null check was missing." with no report and no test run. The Stop hook blocks once with: - SKILL_CONTRACT_UNMET investigate (contract v1): missing debug-report, repro-after-fix. Fix: debug-report: print the DEBUG REPORT with real values for Symptom, Root cause, Fix, Evidence; repro-after-fix: run the test or repro again after your last edit and show its result. If an item truly does not apply, run "/bin/gstack-skill-contract" waive --reason "" (this session only; counted as a miss for follow). This blocks once. + SKILL_CONTRACT_UNMET investigate (contract v1): missing debug-report, repro-after-fix. Fix: debug-report: print the DEBUG REPORT with real values for Symptom, Root cause, Fix, Evidence; repro-after-fix: run the test or repro again after your last edit and show its result. If an item truly does not apply, run '/bin/gstack-skill-contract' waive --reason "" (this session only; counted as a miss for follow). This blocks once. The model runs `bun test`, prints the DEBUG REPORT, and stops. The record reads `met_after_block`. diff --git a/lib/skill-contract.ts b/lib/skill-contract.ts index 0a4193971..54560d61f 100644 --- a/lib/skill-contract.ts +++ b/lib/skill-contract.ts @@ -465,7 +465,7 @@ export function checkContract(t: Transcript, skill: string, contract: Contract, export function blockMessage(result: CheckResult, bin: string): string { const fixes = result.items.filter(i => i.status === 'missing').map(i => `${i.id}: ${i.fix}`).join('; '); return `SKILL_CONTRACT_UNMET ${result.skill} (contract v1): missing ${result.missing.join(', ')}. Fix: ${fixes}. ` + - `If an item truly does not apply, run "${bin}" waive --reason "" (this session only; counted as a miss for follow). This blocks once.`; + `If an item truly does not apply, run '${bin.replaceAll("'", "'\\''")}' waive --reason "" (this session only; counted as a miss for follow). This blocks once.`; } export type ContractLookup = diff --git a/test/fixtures/skill-contract/block-message.txt b/test/fixtures/skill-contract/block-message.txt index 9edc329f3..9e5b7c34f 100644 --- a/test/fixtures/skill-contract/block-message.txt +++ b/test/fixtures/skill-contract/block-message.txt @@ -1 +1 @@ -SKILL_CONTRACT_UNMET investigate (contract v1): missing debug-report, repro-after-fix. Fix: debug-report: print the DEBUG REPORT with real values for Symptom, Root cause, Fix, Evidence; repro-after-fix: run the test or repro again after your last edit and show its result. If an item truly does not apply, run "{bin}" waive --reason "" (this session only; counted as a miss for follow). This blocks once. +SKILL_CONTRACT_UNMET investigate (contract v1): missing debug-report, repro-after-fix. Fix: debug-report: print the DEBUG REPORT with real values for Symptom, Root cause, Fix, Evidence; repro-after-fix: run the test or repro again after your last edit and show its result. If an item truly does not apply, run '{bin}' waive --reason "" (this session only; counted as a miss for follow). This blocks once. diff --git a/test/skill-contract-stop-hook.test.ts b/test/skill-contract-stop-hook.test.ts index 5b644589c..e12a0c332 100644 --- a/test/skill-contract-stop-hook.test.ts +++ b/test/skill-contract-stop-hook.test.ts @@ -60,7 +60,7 @@ describe('block once, with the pinned text', () => { test('GST-1242: the block names the waive command by the path this install resolves, not a bare name off PATH', () => { const reason: string = JSON.parse(stop(transcript(J1_TRANSCRIPT)).stdout).reason; expect(reason).not.toMatch(/run gstack-skill-contract waive/); - const named = /run "([^"]+)" waive /.exec(reason)?.[1]; + const named = /run '([^']+)' waive /.exec(reason)?.[1]; expect(named).toBe(BIN); const { spawnSync } = require('node:child_process') as typeof import('node:child_process'); const waive = spawnSync(named!, ['waive', 'repro-after-fix', '--reason', 'docs-only change'], { env: hookEnv(state, skills), encoding: 'utf8', timeout: 20_000 }); diff --git a/test/skill-contract.test.ts b/test/skill-contract.test.ts index ac9103373..459603624 100644 --- a/test/skill-contract.test.ts +++ b/test/skill-contract.test.ts @@ -133,6 +133,18 @@ describe('checkContract is a pure function of the transcript', () => { expect(parseContract('contract:\n version: 1\n items: [{id: a, kind: selected_specialists_dispatched, marker: "Dispatching N specialists:"}]')).toHaveProperty('contract'); }); + test('GST-1242: the waive command in the block text is a literal path to bash, and the transcript reader counts it', () => { + const r = check(J1_TRANSCRIPT); + const bin = "/opt/it's gstack $(touch pwned)/bin/gstack-skill-contract"; + const cmd = /does not apply, run (.+) waive /.exec(blockMessage(r, bin))![1]; + const printed = spawnSync('bash', ['-c', `printf %s ${cmd}`], { cwd: tmpDir('gstack-contract-quote-'), encoding: 'utf8', timeout: 10_000 }); + expect(printed.stdout).toBe(bin); + expect(fs.existsSync(path.join(printed.stdout, '..', 'pwned'))).toBe(false); + const base = [...J1_TRANSCRIPT.slice(0, -1), say(DEBUG_REPORT)]; + const waived = check([...base, tool('w', 'Bash', { command: `${cmd} waive repro-after-fix --reason "docs-only change"` }), result('w', 'ack')]); + expect(waived).toMatchObject({ met: true, waived: ['repro-after-fix'] }); + }); + test('waivers come from waive Bash calls in the transcript; an errored waive or another skill\'s waive does not count', () => { const waive = (cmd: string, isError = false) => [tool('w', 'Bash', { command: cmd }), result('w', 'ack', isError)]; const base = [...J1_TRANSCRIPT.slice(0, -1), say(DEBUG_REPORT)];