Files
gstack/bin/gstack-skill-gates
T
Garry Tanandcapy-ai[bot] bd36aa0b46 fix: skill gate hooks outlive the gate deadline, so a slow gate no longer fails open (#4072)
* skill gates: the hook timeout outlives the clamped gate deadline (GST-1615)

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

* test: #4045/#4047 narrow screens select the bin/gstack-config gate case once setup runs gstack-skill-gates (GST-1615)

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-06 13:22:18 +00:00

120 lines
5.9 KiB
TypeScript
Executable File

#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-skill-gates — the opt-in enforcement hooks' helper (GST-1461).
*
* gstack-skill-gates settings [--investigate] [--ceo-review]
* Print a Claude Code settings JSON that registers the named gates (the
* /investigate gate, the /plan-ceo-review gate) and turns their switches on, for \`claude -p --settings <file>\` or the Agent
* SDK's settings, so a headless run needs no settings.json edit.
* gstack-skill-gates hook-timeout
* Print the hook timeout in seconds (HOOK_TIMEOUT_S) that ./setup registers.
* gstack-skill-gates report [--session <id>] [--expect armed] [--json]
* Count fired, blocked, released, exhausted, observed and error markers per session
* from $GSTACK_STATE_ROOT/skill-gates/<session>/record.json.
*
* Exit codes: 0 ok · 1 usage (SKILL_GATES_USAGE) · 2 GATE_NOT_ARMED (--expect
* armed and a session never fired) · 3 GATE_ERRORED (a session recorded a hook
* error). Docs: docs/skill-gates.md.
*/
import * as fs from 'fs';
import * as path from 'path';
import { HOOK_TIMEOUT_S, gatesRoot, readGateRecord, recordPath, type GateHook, type GateRecord } from '../lib/skill-gates';
const ROOT = path.resolve(import.meta.dir, '..');
/** Claude Code runs a hook command through a shell: quote the path as gstack-settings-hook's gsQuoteCmd does. */
const shellCommand = (p: string) => (/[\s$`"\\]/.test(p) ? `"${p.replace(/[\\"$`]/g, ch => `\\${ch}`)}"` : p);
const USAGE = 'Usage: gstack-skill-gates settings [--investigate] [--ceo-review]\n gstack-skill-gates hook-timeout\n gstack-skill-gates report [--session <id>] [--expect armed] [--json]';
const HOOKS: Record<string, { env: string; source: GateHook; events: Array<{ event: string; shim: string; matcher?: string }> }> = {
'--investigate': {
env: 'GSTACK_INVESTIGATE_GATE',
source: 'investigate-gate',
events: [
{ event: 'PreToolUse', shim: 'investigate-gate-pre', matcher: 'Edit|Write|MultiEdit|NotebookEdit|Bash' },
{ event: 'PostToolUse', shim: 'investigate-gate-post', matcher: 'Bash' },
{ event: 'PostToolUseFailure', shim: 'investigate-gate-post-failure', matcher: 'Bash' },
{ event: 'Stop', shim: 'investigate-gate-stop' },
],
},
'--ceo-review': {
env: 'GSTACK_CEO_REVIEW_GATE',
source: 'ceo-review-gate',
events: [
{ event: 'Stop', shim: 'ceo-review-gate-stop' },
{ event: 'SubagentStop', shim: 'ceo-review-gate-subagent-stop' },
],
},
};
function usage(why: string): never {
process.stderr.write(`SKILL_GATES_USAGE: ${why}\n${USAGE}\n`);
process.exit(1);
}
const args = process.argv.slice(2);
if (args[0] === '--help' || args[0] === '-h') { process.stdout.write(`${USAGE}\n`); process.exit(0); }
if (args[0] === 'settings') {
const picked = args.slice(1);
if (!picked.length || picked.some(a => !HOOKS[a])) usage(`settings takes ${Object.keys(HOOKS).join(' ')}`);
const settings: { env: Record<string, string>; hooks: Record<string, unknown[]> } = { env: {}, hooks: {} };
for (const flag of picked) {
const h = HOOKS[flag];
settings.env[h.env] = 'on';
for (const e of h.events) {
(settings.hooks[e.event] ??= []).push({ ...(e.matcher ? { matcher: e.matcher } : {}), hooks: [{ type: 'command', command: shellCommand(path.join(ROOT, 'hosts', 'claude', 'hooks', e.shim)), timeout: HOOK_TIMEOUT_S }] });
}
}
process.stdout.write(`${JSON.stringify(settings, null, 2)}\n`);
process.exit(0);
}
if (args[0] === 'hook-timeout') { process.stdout.write(`${HOOK_TIMEOUT_S}\n`); process.exit(0); }
if (args[0] !== 'report') usage('expected settings, hook-timeout or report');
const flag = (name: string) => { const i = args.indexOf(name); return i >= 0 ? (args[i + 1] ?? usage(`${name} needs a value`)) : undefined; };
const session = flag('--session');
const expect = flag('--expect');
if (expect !== undefined && expect !== 'armed') usage('--expect takes armed');
const asJson = args.includes('--json');
const records: GateRecord[] = [];
if (session) {
const file = recordPath(session);
if (fs.existsSync(file)) records.push(readGateRecord(file, session));
} else {
let dirs: string[] = [];
try { dirs = fs.readdirSync(gatesRoot()); } catch { /* none yet */ }
for (const d of dirs.sort()) {
const file = path.join(gatesRoot(), d, 'record.json');
if (fs.existsSync(file)) records.push(readGateRecord(file, d));
}
}
const rows = records.flatMap(({ session_id: session, hooks }) => Object.entries(hooks).map(([hook, h]) => {
const runs = Object.values(h!.runs);
return {
session, hook, runs: runs.length, fired: runs.length,
blocked: runs.reduce((n, r) => n + r.blocked, 0), released: runs.filter(r => r.released_at).length,
exhausted: runs.filter(r => r.exhausted_at).length,
observed: runs.reduce((n, r) => n + r.observed, 0), errors: h!.errors.length, error_codes: [...new Set(h!.errors.map(e => e.code))],
skipped: Object.keys(h!.skipped ?? {}),
};
}));
if (asJson) process.stdout.write(`${JSON.stringify({ rows }, null, 2)}\n`);
else {
process.stdout.write(`skill gates: ${rows.length} session-hook row(s) under ${gatesRoot()}\n`);
for (const r of rows) {
process.stdout.write(` ${r.session} ${r.hook} fired=${r.fired} blocked=${r.blocked} released=${r.released} exhausted=${r.exhausted} observed=${r.observed} errors=${r.errors}${r.error_codes.length ? ` (${r.error_codes.join(',')})` : ''}${r.skipped.length ? ` skipped=${r.skipped.join(',')}` : ''}\n`);
}
}
if (rows.some(r => r.errors > 0)) {
process.stderr.write('GATE_ERRORED: a gate hook recorded an error; its stops and edits were allowed (docs/skill-gates.md#gate-errored)\n');
process.exit(3);
}
if (expect === 'armed' && (session ? !rows.some(r => r.fired > 0) : rows.length === 0 || rows.some(r => r.fired === 0))) {
process.stderr.write('GATE_NOT_ARMED: no fired marker for the expected session; the hooks never engaged (docs/skill-gates.md#gate-not-armed)\n');
process.exit(2);
}