mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-07 17:28:23 +01:00
* feat: retire /investigate after a no-gain eval (GST-1717)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(skill-start): core routing offer drops every non-core rule line (GST-1717)
The retired /investigate rule became 'Bugs/errors → fix them directly', which no
longer matches '→ invoke /', so proactive=core leaked it into the offered section.
Match any '- … → ' rule line.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* revert(review): keep review/checklist.md at its no-loss-pinned bytes (GST-1717)
The pin names replay GSTA-224 H1; a reviewer checklist edit merges only after a
passing no-loss replay (crew rule 15). The /investigate flake line stays until a
replayed repin.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: repair free-suite fallout from retiring /investigate (GST-1717)
CI run 42340956269 at 83a8a9a: drop fixtures, ledger rows, allowlist entries and
counts for the retired skill and its deleted paid files; record the harness
change as non-behavioral; the test-of-test detector accepts path.join(ROOT, 'bin');
uncovered-prompt fixtures use a tracked prompt, since an untracked path now
reads as a deleted one.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(relink): back up a retired skill's real SKILL.md before cleaning it (GST-1717)
With the skill's source gone, the customized-file check never ran, so a
banner-carrying local copy of investigate/SKILL.md was deleted instead of moved
to the backup root (red-team review, pass 1).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(qm): restore the review checklist's flake-line rewrite (GST-1717)
review/checklist.md keeps its pinned /investigate line, so the QM edition needs
its replaceText rule back; qm/ stays fresh against the generator.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(changelog): name the setup flag break, the relink backup and the pinned checklist line (GST-1717)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(setup,relink): back up a retired skill's real SKILL.md instead of deleting it (GST-1717)
Scoped fix round under grant a-360dd62bd46a (outside-voice blocking finding and
adversarial #1, #4). setup's prune compares a copied host entry with the render
before removing that render, and backs up a copy that differs or whose render is
gone; relink no longer takes the marker's rm -rf for a skill whose source is
gone, so a real SKILL.md is backed up and the user's files stay. setup reports a
failed remove-source of the retired gate hooks on stderr.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(pr-profile): a changed prompt counts as deleted only when it is gone from disk (GST-1717)
An untracked prompt file on disk still needs prompt validation (targeted review
finding); harness change recorded as non-behavioral.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(changelog): exact backup wording, the hook-removal warning and GST-1744 (GST-1717)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(setup): one backup folder per host for a retired skill's copies (GST-1717)
Under grant a-c95576b8b1f9 (fix-check#1): every copy host moved its
gstack-investigate/SKILL.md to the same backup path, so a later host's copy
overwrote an earlier host's edits. A sandboxed ./setup --host factory run showed
gen-skill-docs removes the retired render before the prune, so every real copy
is backed up; each host now gets its own folder.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(changelog): say exactly what setup backs up for a retired skill (GST-1717)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(changelog): end the two colon sentences prose-lint flagged (GST-1717)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(retired-skills): name plan-eng-review once behind a pregate-literal waiver (GST-1717)
pregate's literals check read the GSTACK_RETIRED_SKILLS change as an old
value still in the tests. plan-eng-review is still retired; investigate was
appended. The fixture names it once, with the waiver.
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>
115 lines
5.6 KiB
TypeScript
Executable File
115 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 [--ceo-review] [--autoplan]
|
|
* Print a Claude Code settings JSON that registers the named gates (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 [--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 }> }> = {
|
|
'--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);
|
|
}
|