fix(skill-contracts): the waive hint single-quotes the install path so bash runs it literally (GST-1242)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-03 16:36:17 +00:00
1 parent f91e8950b6
commit f48e5192ad
5 files changed
+16 -4

No files matched your search

+1 -1
View File
@@ -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 "<gstack>/bin/gstack-skill-contract" waive <item> --reason "<why>" (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 '<gstack>/bin/gstack-skill-contract' waive <item> --reason "<why>" (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`.
+1 -1
View File
@@ -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 <item> --reason "<why>" (this session only; counted as a miss for follow). This blocks once.`;
`If an item truly does not apply, run '${bin.replaceAll("'", "'\\''")}' waive <item> --reason "<why>" (this session only; counted as a miss for follow). This blocks once.`;
}
export type ContractLookup =
+1 -1
View File
@@ -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 <item> --reason "<why>" (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 <item> --reason "<why>" (this session only; counted as a miss for follow). This blocks once.
+1 -1
View File
@@ -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 <item>/.exec(reason)?.[1];
const named = /run '([^']+)' waive <item>/.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 });
+12
View File
@@ -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 <item>/.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)];