Files
gstack/bin/gstack-skill-contract
T
Garry Tanandcapy-ai[bot] 6a370bd7ba v1.94.6.0 feat(hooks): opt-in skill routing and contract Stop hooks with gstack-skill-contract (GST-1088 lane A) (#3912)
* feat(hooks): opt-in skill routing and contract Stop hooks, gstack-skill-contract and gstack-skill-route (GST-1088 lane A)

Both switches default off. UserPromptSubmit routing hook scores prompts against installed triggers; Stop hook blocks once when a contract skill ends without its outputs, failing open on every state error. setup gains --[no-]skill-routing and --[no-]skill-contracts mirroring the timeline gate.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(hooks): stop-hook test imports the state error line it pins (test-of-test ratchet)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(hooks): route only the four discovery targets; register the skill-contract fixture as free (GST-1088 lane A)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(hooks): review round 1 on the contract Stop hook: record after stop_hook_active, read last_assistant_message, per-agent completion, met runs stay met, installed-only SKILL.md loads, program-based check commands, safe specialist names, report rechecks the saved run (GST-1088 lane A)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(hooks): review round 2 notes: check commands behind wrappers count, the shell test builtin does not; met_after_block is final (GST-1088 lane A)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(hooks): one DEFAULT_MATCHERS table for the checker markers; N = 0 selects no specialists (GST-1088 lane A)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(hooks): lane C fixed markers: agent_dispatch prefers its marker line, parenthesized notes are not specialist names (GST-1088 lane A)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(hooks): an early exit (STATUS: BLOCKED, Nothing to review) meets the contract (GST-1088 lane A)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(hooks): early exit only from the final message, line-anchored (GST-1088 lane A)

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>
2026-11-03 06:31:35 +00:00

138 lines
6.8 KiB
TypeScript
Executable File

#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-skill-contract — check a skill's contract against a Claude Code
* transcript, list recorded misses, and acknowledge a waiver (GST-1088).
*
* Usage:
* gstack-skill-contract check --transcript <path> --skill <name> [--skill-file <SKILL.md>] [--json]
* gstack-skill-contract report [--json] [--session <id>]
* gstack-skill-contract waive <item> --reason "<why>" [--skill <name>]
* gstack-skill-contract --help
*
* check is a pure function of the transcript (lib/skill-contract.ts). waive is
* stateless: it validates its arguments and prints an acknowledgement; check
* finds the waiver by reading this Bash call in the transcript, and a waived
* item counts as a miss for follow. Exit codes are stable:
* 0 met (check) / done (report, waive) 1 unmet 2 usage
* 3 state error 4 unknown transcript shape 5 no contract 6 skill not loaded
* Each has an anchor in docs/skill-routing-and-contracts.md.
*/
import {
EXIT, StateError, UnknownShape, blockMessage, checkContract, isMiss, lookupContract, parseTranscript, readAllRecords,
readTranscriptFile, reportRows, type CheckResult,
} from '../lib/skill-contract';
import { installedSkillsDir } from '../lib/skill-route';
const USAGE = `Usage:
gstack-skill-contract check --transcript <path> --skill <name> [--skill-file <SKILL.md>] [--json]
gstack-skill-contract report [--json] [--session <id>]
gstack-skill-contract waive <item> --reason "<why>" [--skill <name>]
Exit: 0 met/done, 1 unmet, 2 usage, 3 state error, 4 unknown transcript shape, 5 no contract, 6 skill not loaded.
Docs: docs/skill-routing-and-contracts.md`;
const ITEM_ID = /^[a-z0-9][a-z0-9_.-]{0,63}$/;
function die(code: number, line: string): never {
process.stderr.write(`${line}\n`);
process.exit(code);
}
/** --name value or --name=value; a missing value is a usage error. */
function flag(args: string[], name: string): string | undefined {
for (let i = 0; i < args.length; i++) {
if (args[i] === name) {
const v = args[i + 1];
if (v === undefined || v.startsWith('--')) die(EXIT.USAGE, `SKILL_CONTRACT_USAGE: ${name} needs a value\n${USAGE}`);
return v;
}
if (args[i].startsWith(`${name}=`)) return args[i].slice(name.length + 1);
}
return undefined;
}
function printCheck(result: CheckResult, json: boolean, code: number): void {
if (json) {
process.stdout.write(`${JSON.stringify({ exit: code, ...result }, null, 2)}\n`);
return;
}
const head = result.met
? `SKILL_CONTRACT_MET ${result.skill} (contract v1)${result.waived.length ? ` with waivers: ${result.waived.join(', ')} (misses for follow)` : ''}`
: blockMessage(result);
const lines = result.items.map(i => ` ${i.status.padEnd(7)} ${i.id}: ${i.detail}${i.reason ? ` (waived: ${i.reason})` : ''}`);
process.stdout.write(`${[head, ...lines].join('\n')}\n`);
}
function check(args: string[]): never {
const transcriptPath = flag(args, '--transcript');
const skill = flag(args, '--skill');
const json = args.includes('--json');
if (!transcriptPath || !skill) die(EXIT.USAGE, `SKILL_CONTRACT_USAGE: check needs --transcript and --skill\n${USAGE}`);
const found = lookupContract(skill, installedSkillsDir(), flag(args, '--skill-file'));
if (!('contract' in found)) die(EXIT.NO_CONTRACT, `SKILL_CONTRACT_NO_CONTRACT ${skill}: ${found.error}`);
let result: CheckResult;
try {
result = checkContract(parseTranscript(readTranscriptFile(transcriptPath)), skill, found.contract);
} catch (e) {
if (e instanceof StateError) die(EXIT.STATE_ERROR, `SKILL_CONTRACT_STATE_ERROR ${e.file}: ${e.why}`);
if (e instanceof UnknownShape) die(EXIT.UNKNOWN_SHAPE, `SKILL_CONTRACT_UNKNOWN_SHAPE ${transcriptPath}: ${e.message}`);
throw e;
}
if (!result.loaded) die(EXIT.NOT_LOADED, `SKILL_CONTRACT_NOT_LOADED ${skill}: ${transcriptPath} never loads it`);
const code = result.met ? EXIT.MET : EXIT.UNMET;
printCheck(result, json, code);
process.exit(code);
}
function report(args: string[]): never {
const json = args.includes('--json');
const session = flag(args, '--session');
const { records, errors } = readAllRecords();
const rows = reportRows(records.filter(r => !session || r.session_id === session), (rec, run) => {
const found = lookupContract(run.skill, installedSkillsDir());
if (!('contract' in found)) return null;
try {
const again = checkContract(parseTranscript(readTranscriptFile(rec.transcript_path)), run.skill, found.contract, { loadLine: run.load_line });
return again.load ? again : null;
} catch {
return null;
}
});
const misses = rows.filter(isMiss);
const byItem = new Map<string, number>();
for (const row of misses) for (const id of [...row.missing, ...row.waived]) byItem.set(`${row.skill} ${id}`, (byItem.get(`${row.skill} ${id}`) ?? 0) + 1);
if (json) {
const items = [...byItem].map(([key, misses]) => ({ skill: key.split(' ')[0], item: key.split(' ')[1], misses }));
process.stdout.write(`${JSON.stringify({ runs: rows.length, misses, items, errors }, null, 2)}\n`);
process.exit(EXIT.MET);
}
const lines = [`SKILL_CONTRACT_REPORT runs=${rows.length} misses=${misses.length}`];
for (const [key, n] of [...byItem].sort((a, b) => b[1] - a[1])) lines.push(` ${key}: ${n}`);
for (const row of misses) {
lines.push(` ${row.session} ${row.skill} ${row.outcome} missing=${row.missing.join(',') || '-'} waived=${row.waived.join(',') || '-'}`);
}
for (const err of errors) lines.push(` unreadable record: ${err}`);
process.stdout.write(`${lines.join('\n')}\n`);
process.exit(EXIT.MET);
}
function waive(args: string[]): never {
const item = args[0];
const reason = (flag(args, '--reason') ?? '').trim();
const skill = flag(args, '--skill');
if (!item || item.startsWith('--') || !ITEM_ID.test(item)) die(EXIT.USAGE, `SKILL_CONTRACT_USAGE: waive needs an item id matching ${ITEM_ID}\n${USAGE}`);
if (!reason) die(EXIT.USAGE, `SKILL_CONTRACT_USAGE: waive needs --reason "<why the item does not apply>"\n${USAGE}`);
if (skill) {
const found = lookupContract(skill, installedSkillsDir());
if (!('contract' in found)) die(EXIT.NO_CONTRACT, `SKILL_CONTRACT_NO_CONTRACT ${skill}: ${found.error}`);
if (!found.contract.items.some(i => i.id === item)) die(EXIT.USAGE, `SKILL_CONTRACT_USAGE: ${skill}'s contract has no item ${item}`);
}
process.stdout.write(`SKILL_CONTRACT_WAIVED ${item}${skill ? ` (${skill})` : ''}: ${reason} (this session only; counted as a miss for follow)\n`);
process.exit(EXIT.MET);
}
const [cmd, ...rest] = process.argv.slice(2);
if (cmd === 'check') check(rest);
if (cmd === 'report') report(rest);
if (cmd === 'waive') waive(rest);
if (cmd === '--help' || cmd === '-h') { process.stdout.write(`${USAGE}\n`); process.exit(EXIT.MET); }
die(EXIT.USAGE, `SKILL_CONTRACT_USAGE: unknown command ${JSON.stringify(cmd ?? '')}\n${USAGE}`);