mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-07 17:28:23 +01:00
* test: failing-first cases for the opt-in /autoplan stop gate (GST-1666) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat: opt-in /autoplan stop gate with GSTACK_GATE markers (GST-1666) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: keep the ceo-only section name out of lib/autoplan-stop-gate.ts (carve guard) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Anchor the runner match to a command position, treat a failed background launch as not pending, and read --ceo-only from a Skill call's command/skill fields and from the prompt before a Read load. Review-Finding: p1/outside-voice#1 reader=outside-voice Review-Finding: p1/outside-voice#2 reader=outside-voice Review-Finding: p1/outside-voice#3 reader=outside-voice Review-Finding: p1/adversarial-native#1 reader=adversarial-native Review-Finding: p1/red-team#1 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: name the /autoplan gate in skill-routing and the smoke section 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>
125 lines
6.1 KiB
TypeScript
Executable File
125 lines
6.1 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] [--autoplan]
|
|
* Print a Claude Code settings JSON that registers the named gates (the
|
|
* /investigate gate, the /plan-ceo-review gate, the /autoplan 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] [--autoplan]\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' },
|
|
],
|
|
},
|
|
'--autoplan': {
|
|
env: 'GSTACK_AUTOPLAN_GATE',
|
|
source: 'autoplan-gate',
|
|
events: [{ event: 'Stop', shim: 'autoplan-stop-gate-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);
|
|
}
|