Files
gstack/bin/gstack-skill-gates
T
Garry Tanandcapy-ai[bot] 4743132ca0 plan-ceo-review gate: hold the final stop until ranked Findings are written (GST-1461) (#4047)
* investigate gate: undo gated commits on branches behind the baseline (GST-1461 fc#1)

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

* plan-ceo-review gate: hold Stop/SubagentStop until ranked Findings are written (GST-1461)

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

* test: unit-test rankedRisksGap so lib/ceo-review-gate.ts has a pregate lane (GST-1461)

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

* fix: plan-ceo-review gate never names or accepts a file inside a skills/ tree (GST-1461)

Review-Finding: p1/red-team#1 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: gstack-skill-gates settings quotes hook paths for the shell (GST-1461 ov#5)

Review-Finding: bin/gstack-skill-gates:60:outside-voice reader=outside-voice
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: investigate gate restores raw bytes, index and mode; attributes come from the baseline (GST-1461 ov#1-4)

Review-Finding: lib/investigate-gate.ts:126:outside-voice reader=outside-voice
Review-Finding: lib/investigate-gate.ts:254:outside-voice reader=outside-voice
Review-Finding: lib/investigate-gate.ts:256:outside-voice reader=outside-voice
Review-Finding: lib/investigate-gate.ts:64:outside-voice reader=outside-voice
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: investigate gate baseline includes staged-only changes so a rollback keeps them (GST-1461)

Review-Finding: p2/outside-voice#1 reader=outside-voice
Review-Finding: p2/adversarial-native#6 reader=adversarial-native
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: plan-ceo-review gate judges skills/ paths from the repo root and filters the repo scan too (GST-1461)

Review-Finding: p2/outside-voice#2 reader=outside-voice
Review-Finding: p2/adversarial-native#2 reader=adversarial-native
Review-Finding: p2/adversarial-native#3 reader=adversarial-native
Review-Finding: p2/red-team#2 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: skill-gates restore modes and skills/ exclusion match the code (GST-1461)

Review-Finding: p2/adversarial-native#7 reader=adversarial-native
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: changelog entry covers the investigate gate rollback fixes (GST-1461)

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

* ci: retrigger pull_request checks after stale dirty state

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-05 21:27:04 +00:00

116 lines
5.6 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 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 { 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 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: 10 }] });
}
}
process.stdout.write(`${JSON.stringify(settings, null, 2)}\n`);
process.exit(0);
}
if (args[0] !== 'report') usage('expected settings 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);
}