mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-14 12:27:18 +01:00
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>
This commit is contained in:
1 parent
9e809ab5b1
commit
4743132ca0
26 files changed
+1166
-172
No files matched your search
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* ceo-review-gate (GST-1461): with the ceo_review_gate switch on, a session
|
||||
* (or a subagent) that loaded /plan-ceo-review cannot end its final turn until
|
||||
* the review's output file, modified after the load, has a Findings or Top
|
||||
* risks heading followed by a numbered list of ranked risks (1. ...) with at
|
||||
* least one real item. Blocks are capped at 3 per run, then the stop goes
|
||||
* through with an exhausted marker. Every decision is a GSTACK_GATE marker in
|
||||
* the hook log, the session record and the hook's output; errors fail open.
|
||||
* Native Stop and SubagentStop payloads against a throwaway git repo.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { rankedRisksGap } from '../lib/ceo-review-gate';
|
||||
import { ROOT, hookEnv, result, runHook, say, skillsDir, tmpDir, tool, user } from './helpers/skill-contract-fixture';
|
||||
|
||||
const HOOK = path.join(ROOT, 'hosts', 'claude', 'hooks', 'ceo-review-gate.ts');
|
||||
const PLAN = '# Plan: faster checkout\n\nShip one-click checkout.\n';
|
||||
const RANKED = `${PLAN}\n## Findings\n\n1. Payment retries can double-charge when the provider times out (critical).\n2. No rollback path for the new schema.\n`;
|
||||
const LOAD_TIME = '2026-11-02T00:00:00.000Z';
|
||||
|
||||
let state = '', skills = '', repo = '', work = '';
|
||||
beforeEach(() => {
|
||||
state = tmpDir('gstack-cgate-state-');
|
||||
work = tmpDir('gstack-cgate-work-');
|
||||
repo = tmpDir('gstack-cgate-repo-');
|
||||
skills = skillsDir([]);
|
||||
git('init', '-q', '-b', 'main');
|
||||
git('config', 'core.autocrlf', 'false');
|
||||
fs.writeFileSync(path.join(repo, 'README.md'), '# fixture\n');
|
||||
fs.writeFileSync(path.join(repo, '.gitignore'), 'ignored/\n');
|
||||
git('add', '-A');
|
||||
commit('seed');
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const d of [state, skills, repo, work]) fs.rmSync(d, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function git(...args: string[]): string {
|
||||
const r = spawnSync('git', args, { cwd: repo, encoding: 'utf8', timeout: 10_000 });
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
|
||||
return r.stdout;
|
||||
}
|
||||
const commit = (msg: string) => git('-c', 'user.name=Gate fixture', '-c', 'user.email=fixture@gstack.test', 'commit', '-q', '-m', msg);
|
||||
const put = (file: string, body: string) => { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, body); return file; };
|
||||
const planFile = () => path.join(repo, 'plan.md');
|
||||
/** A sidechain (subagent) record, as Claude Code writes it to agent-<id>.jsonl. */
|
||||
const side = (line: string) => line.replace('"isSidechain":false', '"isSidechain":true');
|
||||
|
||||
const LOADED = [user('review my plan'), tool('s1', 'Skill', { skill: 'plan-ceo-review' }), result('s1', 'Launching skill: plan-ceo-review')];
|
||||
const READ_PLAN = () => [tool('r1', 'Read', { file_path: planFile() }), result('r1', PLAN)];
|
||||
const wrote = (file: string, id = 'w1') => [tool(id, 'Write', { file_path: file, content: '...' }), result(id, `File created successfully at: ${file}`)];
|
||||
const DONE = say('Review complete.');
|
||||
|
||||
function transcript(lines: string[], name = 'transcript.jsonl'): string {
|
||||
const file = path.join(work, name);
|
||||
fs.writeFileSync(file, `${lines.join('\n')}\n`);
|
||||
return file;
|
||||
}
|
||||
const baseEnv = (env: Record<string, string> = {}) => hookEnv(state, skills, { GSTACK_CEO_REVIEW_GATE: 'on', GSTACK_CEO_REVIEW_OUTPUT: '', ...env });
|
||||
function stop(lines: string[], extra: Record<string, unknown> = {}, env: Record<string, string> = {}) {
|
||||
const payload = { session_id: 'sess-1', transcript_path: transcript(lines), cwd: repo, hook_event_name: 'Stop', stop_hook_active: false, ...extra };
|
||||
return runHook(HOOK, JSON.stringify(payload), baseEnv(env));
|
||||
}
|
||||
function subagentStop(main: string[], agent: string[], agentId = 'a1b2c3', extra: Record<string, unknown> = {}) {
|
||||
const payload = {
|
||||
session_id: 'sess-1', transcript_path: transcript(main), cwd: repo, hook_event_name: 'SubagentStop', stop_hook_active: false,
|
||||
agent_id: agentId, agent_transcript_path: transcript(agent.map(side), `agent-${agentId}.jsonl`), ...extra,
|
||||
};
|
||||
return runHook(HOOK, JSON.stringify(payload), baseEnv());
|
||||
}
|
||||
const json = (stdout: string) => (stdout.trim() ? JSON.parse(stdout) : null);
|
||||
const log = () => {
|
||||
const f = path.join(state, 'hooks', 'ceo-review-gate.log');
|
||||
return fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : '';
|
||||
};
|
||||
const record = () => JSON.parse(fs.readFileSync(path.join(state, 'skill-gates', 'sess-1', 'record.json'), 'utf8'));
|
||||
const runs = () => Object.values(record().hooks['ceo-review-gate'].runs) as Array<Record<string, any>>;
|
||||
|
||||
describe('Stop is blocked until the output file ranks the top risks', () => {
|
||||
test('no output file: blocked with the marker first, the exact ask in the body, fired in systemMessage', () => {
|
||||
put(planFile(), PLAN);
|
||||
git('add', 'plan.md');
|
||||
commit('plan');
|
||||
const out = json(stop([...LOADED, ...READ_PLAN(), DONE]).stdout);
|
||||
expect(out.decision).toBe('block');
|
||||
const [first, ...body] = out.reason.split('\n');
|
||||
expect(first).toBe('GSTACK_GATE ceo-review-gate blocked session=sess-1 skill=plan-ceo-review via=stop files=none n=1');
|
||||
expect(body.join('\n')).toContain(`a '## Findings' section ranking the top risks as a numbered list (1. most severe ...) in the review's output file: ${planFile()}`);
|
||||
expect(out.reason).not.toMatch(/ceo_review_gate|GSTACK_CEO_REVIEW_GATE|gstack-config|--no-ceo/);
|
||||
expect(out.systemMessage).toMatch(/^GSTACK_GATE ceo-review-gate fired session=sess-1 skill=plan-ceo-review on=Stop$/m);
|
||||
expect(log().match(/ event=fired /g)?.length).toBe(1);
|
||||
expect(log()).toMatch(/event=blocked skill=plan-ceo-review via=stop files=none n=1/);
|
||||
expect(runs()[0]).toMatchObject({ skill: 'plan-ceo-review', blocked: 1 });
|
||||
});
|
||||
|
||||
test('a written file without a Findings heading is blocked and named as the place to write', () => {
|
||||
put(planFile(), PLAN);
|
||||
const out = json(stop([...LOADED, ...wrote(planFile()), DONE]).stdout);
|
||||
expect(out.decision).toBe('block');
|
||||
expect(out.reason.split('\n')[0]).toBe('GSTACK_GATE ceo-review-gate blocked session=sess-1 skill=plan-ceo-review via=stop files=plan.md n=1');
|
||||
expect(out.reason).toContain(`in the review's output file: ${planFile()}`);
|
||||
});
|
||||
|
||||
test('an unranked bullet list under Findings is blocked', () => {
|
||||
put(planFile(), `${PLAN}\n## Findings\n\n- Payment retries can double-charge.\n- No rollback path.\n`);
|
||||
expect(json(stop([...LOADED, ...wrote(planFile()), DONE]).stdout).decision).toBe('block');
|
||||
});
|
||||
|
||||
test('a numbered list of placeholders, or one that does not start at 1, is blocked', () => {
|
||||
put(planFile(), `${PLAN}\n## Findings\n\n1. [most severe risk]\n2. TBD\n`);
|
||||
expect(json(stop([...LOADED, ...wrote(planFile()), DONE]).stdout).decision).toBe('block');
|
||||
put(planFile(), `${PLAN}\n## Top risks\n\n2. Payment retries can double-charge.\n`);
|
||||
expect(json(stop([...LOADED, ...wrote(planFile()), DONE]).stdout).decision).toBe('block');
|
||||
});
|
||||
|
||||
test('a ranked list after the Findings section ends, under another heading, does not count', () => {
|
||||
put(planFile(), `${PLAN}\n## Findings\n\nSee below.\n\n## Next steps\n\n1. Ship it.\n`);
|
||||
expect(json(stop([...LOADED, ...wrote(planFile()), DONE]).stdout).decision).toBe('block');
|
||||
});
|
||||
|
||||
test('a skill file read after the plan is never named as the place to write, and a write into a skills tree does not release', () => {
|
||||
put(planFile(), PLAN);
|
||||
git('add', 'plan.md');
|
||||
commit('plan');
|
||||
const sections = put(path.join(work, '.claude', 'skills', 'gstack', 'plan-ceo-review', 'sections', 'review-sections.md'), RANKED);
|
||||
const lines = [...LOADED, ...READ_PLAN(), tool('r2', 'Read', { file_path: sections }), result('r2', '# Review sections'), DONE];
|
||||
const out = json(stop(lines).stdout);
|
||||
expect(out.decision).toBe('block');
|
||||
expect(out.reason).toContain(`in the review's output file: ${planFile()}`);
|
||||
expect(out.reason).not.toContain(sections);
|
||||
const after = json(stop([...lines.slice(0, -1), ...wrote(sections, 'w2'), DONE]).stdout);
|
||||
expect(after.decision).toBe('block');
|
||||
expect(after.reason).toContain(`in the review's output file: ${planFile()}`);
|
||||
});
|
||||
|
||||
test('a repo skills/ file holding a sample Findings list does not release the gate', () => {
|
||||
put(path.join(repo, 'skills', 'example', 'review-sections.md'), RANKED);
|
||||
expect(json(stop([...LOADED, DONE]).stdout).decision).toBe('block');
|
||||
});
|
||||
|
||||
test('a project that itself sits under a skills/ folder still has its written review found', () => {
|
||||
const project = path.join(work, 'skills', 'app');
|
||||
const file = put(path.join(project, 'review.md'), RANKED);
|
||||
const payload = { session_id: 'sess-1', transcript_path: transcript([...LOADED, ...wrote(file), DONE]), cwd: project, hook_event_name: 'Stop', stop_hook_active: false };
|
||||
expect(json(runHook(HOOK, JSON.stringify(payload), baseEnv()).stdout).decision).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a candidate whose mtime predates the load is blocked even with a ranked list', () => {
|
||||
put(planFile(), RANKED);
|
||||
const before = new Date(Date.parse(LOAD_TIME) - 60_000);
|
||||
fs.utimesSync(planFile(), before, before);
|
||||
const out = json(stop([...LOADED, ...wrote(planFile()), DONE]).stdout);
|
||||
expect(out.decision).toBe('block');
|
||||
expect(out.reason.split('\n')[0]).toContain('files=plan.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('released when a candidate is met', () => {
|
||||
test('met via a Write target: released file=<basename> rides systemMessage with fired, and later stops are silent', () => {
|
||||
const file = put(path.join(work, 'plans', 'review.md'), RANKED);
|
||||
const lines = [...LOADED, ...wrote(file), DONE];
|
||||
const out = json(stop(lines).stdout);
|
||||
expect(out.decision).toBeUndefined();
|
||||
expect(out.systemMessage).toMatch(/^GSTACK_GATE ceo-review-gate fired session=sess-1 skill=plan-ceo-review on=Stop$/m);
|
||||
expect(out.systemMessage).toMatch(/^GSTACK_GATE ceo-review-gate released session=sess-1 skill=plan-ceo-review file=review\.md blocked=0$/m);
|
||||
expect(runs()[0].released_at).toBeString();
|
||||
expect(runs()[0].file).toBe('review.md');
|
||||
expect(stop(lines).stdout).toBe('');
|
||||
expect(log().match(/ event=released /g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
test('met via an untracked repo .md written through Bash (no Write in the transcript); an ignored file does not count', () => {
|
||||
put(path.join(repo, 'ignored', 'review.md'), RANKED);
|
||||
const lines = [...LOADED, tool('b1', 'Bash', { command: "cat >> docs/ceo-review.md <<'EOF'\n...\nEOF" }), result('b1', ''), DONE];
|
||||
expect(json(stop(lines).stdout).decision).toBe('block');
|
||||
put(path.join(repo, 'docs', 'ceo-review.md'), '# Review\n\n### Top risks (ranked)\n\n1) **Double charge** on provider timeout.\n');
|
||||
const out = json(stop(lines, { stop_hook_active: true }).stdout);
|
||||
expect(out.decision).toBeUndefined();
|
||||
expect(out.systemMessage).toMatch(/released session=sess-1 skill=plan-ceo-review file=ceo-review\.md blocked=1$/m);
|
||||
});
|
||||
|
||||
test('met via a tracked repo .md modified after the load', () => {
|
||||
put(planFile(), PLAN);
|
||||
git('add', 'plan.md');
|
||||
commit('plan');
|
||||
fs.appendFileSync(planFile(), '\n## Findings\n\n1. Double charge on provider timeout.\n');
|
||||
expect(json(stop([...LOADED, ...READ_PLAN(), DONE]).stdout).systemMessage).toContain('released session=sess-1 skill=plan-ceo-review file=plan.md');
|
||||
});
|
||||
|
||||
test('met via GSTACK_CEO_REVIEW_OUTPUT, which is also the place the block names', () => {
|
||||
const file = path.join(work, 'out', 'ceo.md');
|
||||
const blocked = json(stop([...LOADED, DONE], {}, { GSTACK_CEO_REVIEW_OUTPUT: file }).stdout);
|
||||
expect(blocked.decision).toBe('block');
|
||||
expect(blocked.reason).toContain(`in the review's output file: ${file}`);
|
||||
put(file, RANKED);
|
||||
const out = json(stop([...LOADED, DONE], {}, { GSTACK_CEO_REVIEW_OUTPUT: file }).stdout);
|
||||
expect(out.decision).toBeUndefined();
|
||||
expect(out.systemMessage).toContain('released session=sess-1 skill=plan-ceo-review file=ceo.md');
|
||||
});
|
||||
|
||||
test('met via a ceo-plans file under the state root project dir', () => {
|
||||
put(path.join(state, 'projects', 'ceo-fixture', 'ceo-plans', '2026-11-05-checkout.md'), RANKED);
|
||||
const out = json(stop([...LOADED, DONE], {}, { GSTACK_PROJECT_SLUG: 'ceo-fixture' }).stdout);
|
||||
expect(out.systemMessage).toContain('released session=sess-1 skill=plan-ceo-review file=2026-11-05-checkout.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('final turns only', () => {
|
||||
test('an interactive stop that asks the user a question is not blocked; headless counts as final', () => {
|
||||
const ask = say('I found two directions. Which mode do you want: expansion or hold?');
|
||||
expect(stop([...LOADED, ask]).stdout).toBe('');
|
||||
expect(log()).toBe('');
|
||||
expect(json(stop([...LOADED, ask], {}, { GSTACK_HEADLESS: '1', CLAUDE_CODE_ENTRYPOINT: 'sdk-cli' }).stdout).decision).toBe('block');
|
||||
});
|
||||
|
||||
test('last_assistant_message counts as the final text when the file does not hold it yet', () => {
|
||||
expect(stop([...LOADED], { last_assistant_message: 'Which mode do you want: expansion or hold?' }).stdout).toBe('');
|
||||
});
|
||||
|
||||
test('no /plan-ceo-review load, or the switch off: no output and no log', () => {
|
||||
expect(stop([user('hi'), DONE]).stdout).toBe('');
|
||||
expect(stop([...LOADED, DONE], {}, { GSTACK_CEO_REVIEW_GATE: 'off' }).stdout).toBe('');
|
||||
expect(log()).toBe('');
|
||||
});
|
||||
|
||||
test('a fresh load starts a new run with its own count', () => {
|
||||
stop([...LOADED, DONE]);
|
||||
const again = [...LOADED, DONE, user('again'), tool('s2', 'Skill', { skill: 'plan-ceo-review' }), result('s2', 'Launching skill: plan-ceo-review'), DONE];
|
||||
expect(json(stop(again).stdout).reason.split('\n')[0]).toEndWith('n=1');
|
||||
expect(runs()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the cap: three blocks, then exhausted and allowed', () => {
|
||||
test('blocks n=1..3 (stop_hook_active does not skip the check), the fourth stop is allowed with exhausted', () => {
|
||||
const lines = [...LOADED, DONE];
|
||||
for (const n of [1, 2, 3]) {
|
||||
const out = json(stop(lines, { stop_hook_active: n > 1 }).stdout);
|
||||
expect(out.decision).toBe('block');
|
||||
expect(out.reason.split('\n')[0]).toEndWith(`n=${n}`);
|
||||
}
|
||||
const fourth = json(stop(lines, { stop_hook_active: true }).stdout);
|
||||
expect(fourth.decision).toBeUndefined();
|
||||
expect(fourth.systemMessage).toMatch(/^GSTACK_GATE ceo-review-gate exhausted session=sess-1 skill=plan-ceo-review blocked=3$/m);
|
||||
expect(runs()[0].exhausted_at).toBeString();
|
||||
expect(stop(lines, { stop_hook_active: true }).stdout).toBe('');
|
||||
expect(log().match(/ event=blocked /g)?.length).toBe(3);
|
||||
expect(log().match(/ event=exhausted /g)?.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SubagentStop gates only a subagent that loaded /plan-ceo-review', () => {
|
||||
test('an unrelated subagent finishing is never blocked, even when the main session loaded the skill', () => {
|
||||
const r = subagentStop([...LOADED], [user('find the checkout files'), tool('g1', 'Glob', { pattern: 'src/*.ts' }), result('g1', ''), say('Found them.')]);
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).toBe('');
|
||||
expect(log()).toBe('');
|
||||
});
|
||||
|
||||
test('a CEO review subagent is blocked, then released once it writes the ranked list', () => {
|
||||
const file = path.join(work, 'agent-review.md');
|
||||
const agent = [user('Run a CEO review of the plan'), tool('s9', 'Skill', { skill: 'plan-ceo-review' }), result('s9', 'Launching skill: plan-ceo-review'), say('Done reviewing.')];
|
||||
const first = json(subagentStop([user('kick off a review')], agent).stdout);
|
||||
expect(first.decision).toBe('block');
|
||||
expect(first.reason.split('\n')[0]).toBe('GSTACK_GATE ceo-review-gate blocked session=sess-1 skill=plan-ceo-review via=stop files=none n=1');
|
||||
expect(first.systemMessage).toContain('fired session=sess-1 skill=plan-ceo-review on=SubagentStop');
|
||||
put(file, RANKED);
|
||||
const done = json(subagentStop([user('kick off a review')], [...agent.slice(0, 3), ...wrote(file, 'w9'), say('Done reviewing.')], 'a1b2c3', { stop_hook_active: true }).stdout);
|
||||
expect(done.decision).toBeUndefined();
|
||||
expect(done.systemMessage).toContain('released session=sess-1 skill=plan-ceo-review file=agent-review.md blocked=1');
|
||||
expect(runs()[0].agent).toBe('agent-a1b2c3');
|
||||
});
|
||||
|
||||
test('a SubagentStop with no agent transcript path stays silent', () => {
|
||||
const payload = { session_id: 'sess-1', transcript_path: transcript([...LOADED, DONE]), cwd: repo, hook_event_name: 'SubagentStop', stop_hook_active: false };
|
||||
expect(runHook(HOOK, JSON.stringify(payload), baseEnv()).stdout).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('errors fail open with an error marker', () => {
|
||||
test('a payload that is not JSON: exit 0, no decision, error marker on stderr and in the log', () => {
|
||||
const r = runHook(HOOK, 'not json', baseEnv());
|
||||
expect(r.code).toBe(0);
|
||||
expect(json(r.stdout).decision).toBeUndefined();
|
||||
expect(r.stderr).toMatch(/^GSTACK_GATE ceo-review-gate error session=unknown code=GATE_BAD_PAYLOAD why=/);
|
||||
expect(json(r.stdout).systemMessage).toMatch(/^GSTACK_GATE ceo-review-gate error session=unknown code=GATE_BAD_PAYLOAD/);
|
||||
expect(log()).toContain('event=error');
|
||||
});
|
||||
|
||||
test('an unreadable transcript: GATE_TRANSCRIPT in systemMessage and the record, the stop goes through', () => {
|
||||
const r = runHook(HOOK, JSON.stringify({ session_id: 'sess-1', transcript_path: path.join(work, 'missing.jsonl'), cwd: repo, hook_event_name: 'Stop' }), baseEnv());
|
||||
expect(r.code).toBe(0);
|
||||
const out = json(r.stdout);
|
||||
expect(out.decision).toBeUndefined();
|
||||
expect(out.systemMessage).toMatch(/^GSTACK_GATE ceo-review-gate error session=sess-1 code=GATE_TRANSCRIPT why=/);
|
||||
expect(record().hooks['ceo-review-gate'].errors[0].code).toBe('GATE_TRANSCRIPT');
|
||||
});
|
||||
|
||||
test('a hook past its deadline fails open with GATE_TIMEOUT', () => {
|
||||
const r = stop([...LOADED, DONE], {}, { GSTACK_SKILL_GATE_DEADLINE_MS: '1' });
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).not.toContain('"block"');
|
||||
expect(log()).toMatch(/event=error code=GATE_TIMEOUT why="timed out after 1 ms"/);
|
||||
});
|
||||
|
||||
test('a corrupt session record: GATE_STATE, no block', () => {
|
||||
put(path.join(state, 'skill-gates', 'sess-1', 'record.json'), '{not json');
|
||||
const out = json(stop([...LOADED, DONE]).stdout);
|
||||
expect(out.decision).toBeUndefined();
|
||||
expect(out.systemMessage).toMatch(/code=GATE_STATE/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rankedRisksGap reads the markdown, not a code sample of it', () => {
|
||||
test('names each gap, and a ranked list under a nested subheading still counts', () => {
|
||||
expect(rankedRisksGap(PLAN)).toBe('no Findings or Top risks heading');
|
||||
expect(rankedRisksGap(`${PLAN}\n## Top risks\n\n- a bullet\n`)).toBe('no numbered list starting at 1 under its Findings or Top risks heading');
|
||||
expect(rankedRisksGap(`${PLAN}\n## Findings\n\n1. TBD\n`)).toBe('its numbered list holds only placeholders');
|
||||
expect(rankedRisksGap(RANKED)).toBeNull();
|
||||
expect(rankedRisksGap(`${PLAN}\n## Findings\n\n### Payments\n\n1. Retries can double-charge.\n`)).toBeNull();
|
||||
});
|
||||
|
||||
test('a Findings heading and list inside a fenced block do not count', () => {
|
||||
const fenced = `${PLAN}\n\`\`\`md\n## Findings\n\n1. Example risk.\n\`\`\`\n`;
|
||||
expect(rankedRisksGap(fenced)).toBe('no Findings or Top risks heading');
|
||||
});
|
||||
});
|
||||
@@ -265,6 +265,42 @@ describe('Bash writes are caught by the tree check, put back and reported', () =
|
||||
expect(git('status', '--porcelain')).toBe('');
|
||||
});
|
||||
|
||||
test('a fix committed on a branch behind the baseline HEAD is undone: that branch goes back to its own tip (fc#1)', () => {
|
||||
const mainTip = git('rev-parse', 'HEAD');
|
||||
git('checkout', '-q', '-b', 'feature');
|
||||
fs.writeFileSync(path.join(repo, 'src', 'other.ts'), 'export const other = 2;\n');
|
||||
git('add', '-A');
|
||||
commit('feature');
|
||||
const featureTip = git('rev-parse', 'HEAD');
|
||||
const { after } = bash(LOADED, "git checkout -q main && sed -i 's/a - b/a + b/' src/total.ts && git -c user.name=x -c user.email=x@x.test commit -qam fix");
|
||||
const out = json(after.stdout);
|
||||
expect(out.decision).toBe('block');
|
||||
expect(out.reason).toContain(`moved the branch back to ${mainTip.trim().slice(0, 12)}`);
|
||||
expect(git('rev-parse', 'main')).toBe(mainTip);
|
||||
expect(git('rev-parse', 'feature')).toBe(featureTip);
|
||||
expect(git('rev-parse', '--abbrev-ref', 'HEAD').trim()).toBe('main');
|
||||
expect(read('src/total.ts')).toBe(SRC);
|
||||
expect(read('src/other.ts')).toBe('export const other = 1;\n');
|
||||
expect(git('status', '--porcelain')).toBe('');
|
||||
});
|
||||
|
||||
test('amending the tip of a branch behind the baseline HEAD is undone to that tip, keeping its own commit (fc#1)', () => {
|
||||
fs.writeFileSync(path.join(repo, 'src', 'other.ts'), 'export const other = 3;\n');
|
||||
git('add', '-A');
|
||||
commit('main second');
|
||||
const mainTip = git('rev-parse', 'HEAD');
|
||||
git('checkout', '-q', '-b', 'feature');
|
||||
fs.writeFileSync(path.join(repo, 'README.md'), '# fixture, feature\n');
|
||||
git('add', '-A');
|
||||
commit('feature');
|
||||
const { after } = bash(LOADED, "git checkout -q main && sed -i 's/a - b/a + b/' src/total.ts && git -c user.name=x -c user.email=x@x.test commit -q --amend -a --no-edit");
|
||||
expect(json(after.stdout).decision).toBe('block');
|
||||
expect(git('rev-parse', 'main')).toBe(mainTip);
|
||||
expect(read('src/total.ts')).toBe(SRC);
|
||||
expect(read('src/other.ts')).toBe('export const other = 3;\n');
|
||||
expect(git('status', '--porcelain')).toBe('');
|
||||
});
|
||||
|
||||
test('amending the baseline HEAD with a fix moves the branch back to the baseline commit, not its parent', () => {
|
||||
fs.writeFileSync(path.join(repo, 'src', 'other.ts'), 'export const other = 2;\n');
|
||||
git('add', '-A');
|
||||
@@ -376,6 +412,67 @@ describe('Bash writes are caught by the tree check, put back and reported', () =
|
||||
});
|
||||
});
|
||||
|
||||
describe('a rollback restores bytes, index and mode exactly, and reads attributes from the baseline', () => {
|
||||
test('two dirty files that differ only in line endings keep their own bytes (ov#1)', () => {
|
||||
fs.writeFileSync(path.join(repo, '.gitattributes'), '*.sh text\n');
|
||||
fs.writeFileSync(path.join(repo, 'a.sh'), 'echo 0\n');
|
||||
fs.writeFileSync(path.join(repo, 'b.sh'), 'echo 0\n');
|
||||
git('add', '-A');
|
||||
commit('scripts');
|
||||
fs.writeFileSync(path.join(repo, 'a.sh'), 'echo 1\n');
|
||||
fs.writeFileSync(path.join(repo, 'b.sh'), 'echo 1\r\n');
|
||||
const { after } = bash(LOADED, "printf 'echo 2\\n' > a.sh && printf 'echo 2\\n' > b.sh");
|
||||
expect(json(after.stdout).decision).toBe('block');
|
||||
expect(read('a.sh')).toBe('echo 1\n');
|
||||
expect(read('b.sh')).toBe('echo 1\r\n');
|
||||
});
|
||||
|
||||
test('a rejected edit that was staged leaves the index at the baseline too (ov#2)', () => {
|
||||
const { after } = bash(LOADED, "sed -i 's/a - b/a + b/' src/total.ts && git add src/total.ts");
|
||||
expect(json(after.stdout).decision).toBe('block');
|
||||
expect(read('src/total.ts')).toBe(SRC);
|
||||
expect(git('show', ':src/total.ts')).toBe(SRC);
|
||||
expect(git('status', '--porcelain')).toBe('');
|
||||
});
|
||||
|
||||
test('a file staged at the baseline gets its staged entry back, not HEAD (ov#2)', () => {
|
||||
const staged = '// staged note\n' + SRC;
|
||||
fs.writeFileSync(path.join(repo, 'src', 'total.ts'), staged);
|
||||
git('add', 'src/total.ts');
|
||||
const { after } = bash(LOADED, "sed -i 's/a - b/a + b/' src/total.ts && git add src/total.ts");
|
||||
expect(json(after.stdout).decision).toBe('block');
|
||||
expect(read('src/total.ts')).toBe(staged);
|
||||
expect(git('show', ':src/total.ts')).toBe(staged);
|
||||
});
|
||||
|
||||
test('a change staged at the baseline whose worktree matches HEAD keeps its staged entry', () => {
|
||||
const staged = '// staged only\n' + SRC;
|
||||
fs.writeFileSync(path.join(repo, 'src', 'total.ts'), staged);
|
||||
git('add', 'src/total.ts');
|
||||
fs.writeFileSync(path.join(repo, 'src', 'total.ts'), SRC);
|
||||
const { after } = bash(LOADED, "sed -i 's/a - b/a + b/' src/total.ts");
|
||||
expect(json(after.stdout).decision).toBe('block');
|
||||
expect(read('src/total.ts')).toBe(SRC);
|
||||
expect(git('show', ':src/total.ts')).toBe(staged);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32')('a file clean at the baseline keeps its own permissions (ov#3)', () => {
|
||||
fs.chmodSync(path.join(repo, 'src', 'total.ts'), 0o600);
|
||||
const { after } = bash(LOADED, "sed -i 's/a - b/a + b/' src/total.ts");
|
||||
expect(json(after.stdout).decision).toBe('block');
|
||||
expect(read('src/total.ts')).toBe(SRC);
|
||||
expect(fs.statSync(path.join(repo, 'src', 'total.ts')).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
test('a new worktree .gitattributes cannot mark source generated (ov#4)', () => {
|
||||
const { after } = bash(LOADED, "printf '*.ts linguist-generated\\n' > .gitattributes && sed -i 's/a - b/a + b/' src/total.ts");
|
||||
expect(json(after.stdout).decision).toBe('block');
|
||||
expect(read('src/total.ts')).toBe(SRC);
|
||||
const r = pre(LOADED, 'Edit', { file_path: path.join(repo, 'src', 'total.ts'), old_string: 'a - b', new_string: 'a + b' }, 'tu9');
|
||||
expect(json(r.stdout).hookSpecificOutput.permissionDecision).toBe('deny');
|
||||
});
|
||||
});
|
||||
|
||||
describe('errors fail open with an error marker', () => {
|
||||
test('a payload that is not JSON: exit 0, no decision, error marker on stderr and in the log', () => {
|
||||
const r = runHook(HOOK, 'not json', hookEnv(state, skills, { GSTACK_INVESTIGATE_GATE: 'on' }));
|
||||
|
||||
@@ -365,5 +365,5 @@ describe('setup: registers the hooks that parse, refuses the rest, exits non-zer
|
||||
expect(r.out).toContain('keeps running the broken file until it is fixed');
|
||||
expect(r.out).not.toContain('previous install is still active');
|
||||
expect(registeredCommands(fx)).toEqual(before);
|
||||
});
|
||||
}, 30_000); // Two setup hook passes, each resolving every gate switch.
|
||||
});
|
||||
@@ -34,6 +34,8 @@ const GATES = {
|
||||
skill_contracts: 'GSTACK_SKILL_CONTRACTS Stop "$2/hosts/claude/hooks/skill-contract-stop-hook" gstack-skill-contract 10 "contract check"',
|
||||
investigate_gate: 'GSTACK_INVESTIGATE_GATE PreToolUse "$2/hosts/claude/hooks/investigate-gate-pre" gstack-investigate-gate 10 "investigate gate" "Edit|Write|MultiEdit|NotebookEdit|Bash"'
|
||||
+ ' PostToolUse "$2/hosts/claude/hooks/investigate-gate-post" Bash PostToolUseFailure "$2/hosts/claude/hooks/investigate-gate-post-failure" Bash Stop "$2/hosts/claude/hooks/investigate-gate-stop" ""',
|
||||
ceo_review_gate: 'GSTACK_CEO_REVIEW_GATE Stop "$2/hosts/claude/hooks/ceo-review-gate-stop" gstack-ceo-review-gate 10 "ceo review gate" ""'
|
||||
+ ' SubagentStop "$2/hosts/claude/hooks/ceo-review-gate-subagent-stop" ""',
|
||||
};
|
||||
|
||||
/** Run one gate once from the checkout at root: mode is the flag value ("" = none). */
|
||||
@@ -191,13 +193,13 @@ describe('SWITCH_DEFAULT is the one default (a flip PR changes one line)', () =>
|
||||
expect(config()).toBe('');
|
||||
}
|
||||
|
||||
test('pin: skill_routing defaults to off today (a flip PR changes lib/skill-route.ts line 40 and this pin)', () => {
|
||||
expect(original.split('\n')[39]).toBe(" skill_routing: 'off',");
|
||||
test('pin: skill_routing defaults to off today (a flip PR changes lib/skill-route.ts line 41 and this pin)', () => {
|
||||
expect(original.split('\n')[40]).toBe(" skill_routing: 'off',");
|
||||
expect(SWITCH_DEFAULT.skill_routing).toBe('off');
|
||||
});
|
||||
|
||||
test('pin: skill_contracts defaults to off today (a flip PR changes lib/skill-route.ts line 41 and this pin)', () => {
|
||||
expect(original.split('\n')[40]).toBe(" skill_contracts: 'off',");
|
||||
test('pin: skill_contracts defaults to off today (a flip PR changes lib/skill-route.ts line 42 and this pin)', () => {
|
||||
expect(original.split('\n')[41]).toBe(" skill_contracts: 'off',");
|
||||
expect(SWITCH_DEFAULT.skill_contracts).toBe('off');
|
||||
});
|
||||
|
||||
@@ -278,3 +280,53 @@ describe('setup: the investigate_gate switch (GST-1461) registers one shim per e
|
||||
for (const [event] of EVENTS) expect(ours(event)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup: the ceo_review_gate switch (GST-1461) registers Stop and SubagentStop', () => {
|
||||
const EVENTS: Array<[string, string]> = [
|
||||
['Stop', 'ceo-review-gate-stop'],
|
||||
['SubagentStop', 'ceo-review-gate-subagent-stop'],
|
||||
];
|
||||
const ours = (event: string) => registered(event).filter(e => e.hooks.some(h => h.command.includes('ceo-review-gate')));
|
||||
|
||||
test('flags parse, appear in --help and the typo list, and each shim has a KNOWN_HOOKS row with its event', () => {
|
||||
for (const flag of ['--ceo-review-gate)', '--no-ceo-review-gate)', '--ceo-review-gate=*)']) expect(setupSrc).toContain(` ${flag}`);
|
||||
expect(setupSrc).toContain('--ceo-review-gate / --no-ceo-review-gate');
|
||||
expect(setupSrc).toContain('--ceo-review-gate --no-ceo-review-gate');
|
||||
for (const [event, shim] of EVENTS) {
|
||||
expect(setupSrc).toContain(`_hook_command_path hosts/claude/hooks/${shim} ||`);
|
||||
const row = settingsHookSrc.slice(settingsHookSrc.indexOf(`"${shim}":`), settingsHookSrc.indexOf('}', settingsHookSrc.indexOf(`"${shim}":`)));
|
||||
expect(row).toContain('source: "gstack-ceo-review-gate"');
|
||||
expect(row).toContain(`event: "${event}"`);
|
||||
expect(row).toContain('matcher: ""');
|
||||
}
|
||||
});
|
||||
|
||||
test('pin: ceo_review_gate defaults to off, so with no flag, env or config nothing registers', () => {
|
||||
expect(SWITCH_DEFAULT.ceo_review_gate).toBe('off');
|
||||
expect(gate('', {}, 0, ROOT, 'ceo_review_gate')).toEqual({ code: 0, out: '' });
|
||||
for (const [event] of EVENTS) expect(ours(event)).toEqual([]);
|
||||
});
|
||||
|
||||
test('flag on: persists and registers both events; a re-run is quiet; off removes both', () => {
|
||||
const first = gate('on', {}, 0, ROOT, 'ceo_review_gate');
|
||||
expect(first.code).toBe(0);
|
||||
expect(first.out).toContain('registered Stop hook: ceo review gate (on via flag; turn off: gstack-config set ceo_review_gate off, or ./setup --no-ceo-review-gate');
|
||||
expect(first.out).toContain('registered SubagentStop hook: ceo review gate');
|
||||
expect(config()).toMatch(/^ceo_review_gate: on$/m);
|
||||
for (const [event, shim] of EVENTS) {
|
||||
const entries = ours(event) as Array<{ matcher?: string; hooks: Array<{ command: string; timeout?: number }> }>;
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].matcher ?? undefined).toBeUndefined();
|
||||
expect(entries[0].hooks[0].command).toEndWith(`hosts/claude/hooks/${shim}`);
|
||||
expect(entries[0].hooks[0].timeout).toBe(10);
|
||||
}
|
||||
expect(gate('', {}, 0, ROOT, 'ceo_review_gate')).toEqual({ code: 0, out: '' });
|
||||
gate('off', {}, 0, ROOT, 'ceo_review_gate');
|
||||
for (const [event] of EVENTS) expect(ours(event)).toEqual([]);
|
||||
});
|
||||
|
||||
test('gstack-config knows the key and its default', () => {
|
||||
const r = spawnSync(path.join(ROOT, 'bin', 'gstack-config'), ['defaults'], { env: { PATH: process.env.PATH ?? '', HOME: home, GSTACK_STATE_ROOT: state }, encoding: 'utf8', timeout: 30_000 });
|
||||
expect(r.stdout).toMatch(/^ {2}ceo_review_gate: +off$/m);
|
||||
});
|
||||
});
|
||||
@@ -11,10 +11,10 @@ import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { bunIsolationArgs } from '../lib/bun-isolation';
|
||||
import { ROOT, hookEnv, result, runHook, tmpDir, tool, user } from './helpers/skill-contract-fixture';
|
||||
import { ROOT, hookEnv, result, runHook, say, tmpDir, tool, user } from './helpers/skill-contract-fixture';
|
||||
|
||||
const ADVISORY = ['investigate', 'plan-ceo-review'];
|
||||
const GATE_NAMES = /investigate[-_]gate|GSTACK_GATE|skill-gates/;
|
||||
const GATE_NAMES = /investigate[-_]gate|ceo[-_]review[-_]gate|GSTACK_GATE|skill-gates/;
|
||||
|
||||
function filesUnder(dir: string): string[] {
|
||||
return fs.readdirSync(dir, { recursive: true }).map(String).filter(f => fs.statSync(path.join(dir, f)).isFile()).map(f => path.join(dir, f));
|
||||
@@ -64,3 +64,28 @@ describe('advisory skill text is byte-identical with the gate on', () => {
|
||||
expect(digest(skills)).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the /plan-ceo-review gate leaves its skill text alone', () => {
|
||||
test('gen:skill-docs --dry-run with ceo_review_gate on (env and config) finds plan-ceo-review fresh', () => {
|
||||
fs.writeFileSync(path.join(state, 'config.yaml'), 'ceo_review_gate: on\n');
|
||||
const r = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(ROOT, 'scripts', 'gen-skill-docs.ts'), '--dry-run'], {
|
||||
cwd: ROOT, env: { ...process.env, GSTACK_STATE_ROOT: state, GSTACK_CEO_REVIEW_GATE: 'on' }, encoding: 'utf8', timeout: 120_000,
|
||||
});
|
||||
expect(r.stdout).toContain('FRESH: plan-ceo-review/SKILL.md');
|
||||
expect(r.stdout).not.toMatch(/STALE: plan-ceo-review\//);
|
||||
});
|
||||
|
||||
test('an armed CEO run that blocks a stop writes nothing under the installed skills dir', () => {
|
||||
const skills = path.join(home, '.claude', 'skills');
|
||||
fs.mkdirSync(path.join(skills, 'plan-ceo-review'), { recursive: true });
|
||||
fs.copyFileSync(path.join(ROOT, 'plan-ceo-review', 'SKILL.md'), path.join(skills, 'plan-ceo-review', 'SKILL.md'));
|
||||
const before = digest(skills);
|
||||
const transcript = path.join(home, 't.jsonl');
|
||||
fs.writeFileSync(transcript, `${[user('review my plan'), tool('s1', 'Skill', { skill: 'plan-ceo-review' }), result('s1', 'Launching skill: plan-ceo-review'), say('Review complete.')].join('\n')}\n`);
|
||||
const r = runHook(path.join(ROOT, 'hosts', 'claude', 'hooks', 'ceo-review-gate.ts'),
|
||||
JSON.stringify({ session_id: 'sess-adv', transcript_path: transcript, cwd: home, hook_event_name: 'Stop', stop_hook_active: false }),
|
||||
hookEnv(state, skills, { HOME: home, GSTACK_CEO_REVIEW_GATE: 'on', GSTACK_CEO_REVIEW_OUTPUT: '' }));
|
||||
expect(JSON.parse(r.stdout).decision).toBe('block');
|
||||
expect(digest(skills)).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,45 @@ describe('settings', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('--ceo-review registers Stop and SubagentStop on the ceo-review-gate shims with no matcher and turns its switch on', () => {
|
||||
const r = cli('settings', '--ceo-review');
|
||||
expect(r.status).toBe(0);
|
||||
const s = JSON.parse(r.stdout);
|
||||
expect(s.env).toEqual({ GSTACK_CEO_REVIEW_GATE: 'on' });
|
||||
expect(Object.keys(s.hooks).sort()).toEqual(['Stop', 'SubagentStop']);
|
||||
for (const [event, shim] of [['Stop', 'ceo-review-gate-stop'], ['SubagentStop', 'ceo-review-gate-subagent-stop']]) {
|
||||
expect(s.hooks[event]).toHaveLength(1);
|
||||
expect(s.hooks[event][0].matcher).toBeUndefined();
|
||||
const cmd = s.hooks[event][0].hooks[0].command;
|
||||
expect(cmd).toBe(path.join(ROOT, 'hosts', 'claude', 'hooks', shim));
|
||||
expect(fs.statSync(cmd).mode & 0o111).not.toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('--investigate --ceo-review together: both switches on, Stop runs both gates', () => {
|
||||
const r = cli('settings', '--investigate', '--ceo-review');
|
||||
expect(r.status).toBe(0);
|
||||
const s = JSON.parse(r.stdout);
|
||||
expect(s.env).toEqual({ GSTACK_INVESTIGATE_GATE: 'on', GSTACK_CEO_REVIEW_GATE: 'on' });
|
||||
expect(Object.keys(s.hooks).sort()).toEqual(['PostToolUse', 'PostToolUseFailure', 'PreToolUse', 'Stop', 'SubagentStop']);
|
||||
expect(s.hooks.Stop.map((e: { hooks: Array<{ command: string }> }) => path.basename(e.hooks[0].command))).toEqual(['investigate-gate-stop', 'ceo-review-gate-stop']);
|
||||
});
|
||||
|
||||
test('a root containing a space: each command still runs its shim through sh -c', () => {
|
||||
const root = path.join(state, 'gstack root');
|
||||
fs.mkdirSync(path.join(root, 'bin'), { recursive: true });
|
||||
fs.copyFileSync(BIN, path.join(root, 'bin', 'gstack-skill-gates'));
|
||||
for (const dir of ['lib', 'hosts']) fs.symlinkSync(path.join(ROOT, dir), path.join(root, dir));
|
||||
const env = { PATH: process.env.PATH ?? '', HOME: state, GSTACK_STATE_ROOT: state };
|
||||
const r = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(root, 'bin', 'gstack-skill-gates'), 'settings', '--ceo-review'], { env, encoding: 'utf8', timeout: 20_000 });
|
||||
expect(r.status).toBe(0);
|
||||
const cmd = JSON.parse(r.stdout).hooks.Stop[0].hooks[0].command;
|
||||
expect(cmd).toContain('gstack root');
|
||||
const sh = spawnSync('sh', ['-c', cmd], { env, input: '', encoding: 'utf8', timeout: 20_000 });
|
||||
expect(sh.stderr).not.toMatch(/not found|No such file/);
|
||||
expect(sh.status).toBe(0);
|
||||
});
|
||||
|
||||
test('no hook named is a usage error', () => {
|
||||
const r = cli('settings');
|
||||
expect(r.status).toBe(1);
|
||||
@@ -55,7 +94,18 @@ describe('report', () => {
|
||||
seed('s1', { blocked: 2, released_at: '2026-11-05T00:01:00Z' });
|
||||
const r = cli('report', '--json');
|
||||
expect(r.status).toBe(0);
|
||||
expect(JSON.parse(r.stdout).rows).toEqual([{ session: 's1', hook: 'investigate-gate', runs: 1, fired: 1, blocked: 2, released: 1, observed: 0, errors: 0, error_codes: [], skipped: [] }]);
|
||||
expect(JSON.parse(r.stdout).rows).toEqual([{ session: 's1', hook: 'investigate-gate', runs: 1, fired: 1, blocked: 2, released: 1, exhausted: 0, observed: 0, errors: 0, error_codes: [], skipped: [] }]);
|
||||
});
|
||||
|
||||
test('counts a ceo-review-gate run that was exhausted after its three blocks', () => {
|
||||
const dir = path.join(state, 'skill-gates', 's5');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const run = { skill: 'plan-ceo-review', load_line: 3, fired_at: '2026-11-05T00:00:00Z', blocked: 3, observed: 0, exhausted_at: '2026-11-05T00:03:00Z' };
|
||||
fs.writeFileSync(path.join(dir, 'record.json'), JSON.stringify({ version: 1, session_id: 's5', transcript_path: '/t', hooks: { 'ceo-review-gate': { runs: { 'plan-ceo-review@3': run }, errors: [], skipped: {} } } }));
|
||||
const r = cli('report', '--json');
|
||||
expect(r.status).toBe(0);
|
||||
expect(JSON.parse(r.stdout).rows).toEqual([{ session: 's5', hook: 'ceo-review-gate', runs: 1, fired: 1, blocked: 3, released: 0, exhausted: 1, observed: 0, errors: 0, error_codes: [], skipped: [] }]);
|
||||
expect(cli('report').stdout).toContain('s5 ceo-review-gate fired=1 blocked=3 released=0 exhausted=1 observed=0 errors=0');
|
||||
});
|
||||
|
||||
test('--expect armed exits 2 GATE_NOT_ARMED for a session with no record or no fired run', () => {
|
||||
|
||||
Reference in new issue
Block a user