mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-07 17:28:23 +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
+3
-2
@@ -348,6 +348,7 @@ lookup_default() {
|
||||
skill_routing) bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$(dirname "$0")/gstack-skill-route" --default skill_routing ;; # on | off — the skill-routing UserPromptSubmit hook; default: SWITCH_DEFAULT in lib/skill-route.ts (docs/skill-routing-and-contracts.md)
|
||||
skill_contracts) bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$(dirname "$0")/gstack-skill-route" --default skill_contracts ;; # on | off — the skill-contract Stop hook; default: SWITCH_DEFAULT in lib/skill-route.ts (docs/skill-routing-and-contracts.md)
|
||||
investigate_gate) bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$(dirname "$0")/gstack-skill-route" --default investigate_gate ;; # on | off — the /investigate enforcement hooks; default: SWITCH_DEFAULT in lib/skill-route.ts (docs/skill-gates.md)
|
||||
ceo_review_gate) bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$(dirname "$0")/gstack-skill-route" --default ceo_review_gate ;; # on | off — the /plan-ceo-review enforcement hooks; default: SWITCH_DEFAULT in lib/skill-route.ts (docs/skill-gates.md)
|
||||
|
||||
redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection
|
||||
redact_prepush_hook) echo "false" ;;
|
||||
@@ -1144,7 +1145,7 @@ $_gstack_legacy_root"
|
||||
ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \
|
||||
plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \
|
||||
model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model harness_start_hook \
|
||||
skill_routing skill_contracts investigate_gate; do
|
||||
skill_routing skill_contracts investigate_gate ceo_review_gate; do
|
||||
gstack_config_select "$KEY"
|
||||
VALUE="$_gstack_cfg_value"
|
||||
SOURCE="default"
|
||||
@@ -1170,7 +1171,7 @@ $_gstack_legacy_root"
|
||||
ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \
|
||||
plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \
|
||||
model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model harness_start_hook \
|
||||
skill_routing skill_contracts investigate_gate; do
|
||||
skill_routing skill_contracts investigate_gate ceo_review_gate; do
|
||||
printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")"
|
||||
done
|
||||
;;
|
||||
|
||||
@@ -133,7 +133,9 @@ var KNOWN_HOOKS = {
|
||||
"investigate-gate-pre": { source: "gstack-investigate-gate", event: "PreToolUse", matcher: "Edit|Write|MultiEdit|NotebookEdit|Bash", relpath: "hosts/claude/hooks/investigate-gate-pre" },
|
||||
"investigate-gate-post": { source: "gstack-investigate-gate", event: "PostToolUse", matcher: "Bash", relpath: "hosts/claude/hooks/investigate-gate-post" },
|
||||
"investigate-gate-post-failure": { source: "gstack-investigate-gate", event: "PostToolUseFailure", matcher: "Bash", relpath: "hosts/claude/hooks/investigate-gate-post-failure" },
|
||||
"investigate-gate-stop": { source: "gstack-investigate-gate", event: "Stop", matcher: "", relpath: "hosts/claude/hooks/investigate-gate-stop" }
|
||||
"investigate-gate-stop": { source: "gstack-investigate-gate", event: "Stop", matcher: "", relpath: "hosts/claude/hooks/investigate-gate-stop" },
|
||||
"ceo-review-gate-stop": { source: "gstack-ceo-review-gate", event: "Stop", matcher: "", relpath: "hosts/claude/hooks/ceo-review-gate-stop" },
|
||||
"ceo-review-gate-subagent-stop": { source: "gstack-ceo-review-gate", event: "SubagentStop", matcher: "", relpath: "hosts/claude/hooks/ceo-review-gate-subagent-stop" }
|
||||
};
|
||||
function gsHadBashPrefix(c) { return String(c == null ? "" : c).trim().indexOf("bash ") === 0; }
|
||||
function gsStripWrap(c) {
|
||||
@@ -492,8 +494,8 @@ case "$ACTION" in
|
||||
exit 1
|
||||
fi
|
||||
case "$EVENT" in
|
||||
SessionStart|PreToolUse|PostToolUse|PostToolUseFailure|UserPromptSubmit|Stop|Notification) ;;
|
||||
*) echo "invalid --event '$EVENT'; must be one of SessionStart|PreToolUse|PostToolUse|PostToolUseFailure|UserPromptSubmit|Stop|Notification" >&2; exit 1 ;;
|
||||
SessionStart|PreToolUse|PostToolUse|PostToolUseFailure|UserPromptSubmit|Stop|SubagentStop|Notification) ;;
|
||||
*) echo "invalid --event '$EVENT'; must be one of SessionStart|PreToolUse|PostToolUse|PostToolUseFailure|UserPromptSubmit|Stop|SubagentStop|Notification" >&2; exit 1 ;;
|
||||
esac
|
||||
DIFF_ONLY=""
|
||||
ENSURE=""
|
||||
|
||||
+18
-7
@@ -2,12 +2,12 @@
|
||||
/**
|
||||
* gstack-skill-gates — the opt-in enforcement hooks' helper (GST-1461).
|
||||
*
|
||||
* gstack-skill-gates settings --investigate
|
||||
* Print a Claude Code settings JSON that registers the /investigate gate
|
||||
* and turns its switch on, for \`claude -p --settings <file>\` or the Agent
|
||||
* 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, observed and error markers per session
|
||||
* 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
|
||||
@@ -19,7 +19,9 @@ import * as path from 'path';
|
||||
import { gatesRoot, readGateRecord, recordPath, type GateHook, type GateRecord } from '../lib/skill-gates';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const USAGE = 'Usage: gstack-skill-gates settings --investigate\n gstack-skill-gates report [--session <id>] [--expect armed] [--json]';
|
||||
/** 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',
|
||||
@@ -31,6 +33,14 @@ const HOOKS: Record<string, { env: string; source: GateHook; events: Array<{ eve
|
||||
{ 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 {
|
||||
@@ -49,7 +59,7 @@ if (args[0] === 'settings') {
|
||||
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: path.join(ROOT, 'hosts', 'claude', 'hooks', e.shim), timeout: 10 }] });
|
||||
(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`);
|
||||
@@ -81,6 +91,7 @@ const rows = records.flatMap(({ session_id: session, hooks }) => Object.entries(
|
||||
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 ?? {}),
|
||||
};
|
||||
@@ -90,7 +101,7 @@ 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} observed=${r.observed} errors=${r.errors}${r.error_codes.length ? ` (${r.error_codes.join(',')})` : ''}${r.skipped.length ? ` skipped=${r.skipped.join(',')}` : ''}\n`);
|
||||
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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* Usage:
|
||||
* gstack-skill-route --explain "<prompt>" [--json] [--threshold <n>]
|
||||
* gstack-skill-route --default <skill_routing|skill_contracts|investigate_gate>
|
||||
* gstack-skill-route --default <skill_routing|skill_contracts|investigate_gate|ceo_review_gate>
|
||||
*
|
||||
* Prints the switch state, the threshold, every candidate skill with its
|
||||
* score and best trigger, the route the hook would take and the exact line it
|
||||
@@ -16,13 +16,13 @@ import {
|
||||
ROUTE_THRESHOLD, SWITCH_DEFAULT, hintLine, installedSkillsDir, readSkillSwitch, routePrompt, scanInstalledSkills,
|
||||
} from '../lib/skill-route';
|
||||
|
||||
const USAGE = 'Usage: gstack-skill-route --explain "<prompt>" [--json] [--threshold <n>]\n gstack-skill-route --default <skill_routing|skill_contracts|investigate_gate>';
|
||||
const USAGE = 'Usage: gstack-skill-route --explain "<prompt>" [--json] [--threshold <n>]\n gstack-skill-route --default <skill_routing|skill_contracts|investigate_gate|ceo_review_gate>';
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === '--help' || args[0] === '-h') { process.stdout.write(`${USAGE}\n`); process.exit(0); }
|
||||
if (args[0] === '--default') {
|
||||
const name = args[1] ?? '';
|
||||
if (!Object.hasOwn(SWITCH_DEFAULT, name)) {
|
||||
process.stderr.write(`SKILL_ROUTE_USAGE: --default needs skill_routing, skill_contracts or investigate_gate\n${USAGE}\n`);
|
||||
process.stderr.write(`SKILL_ROUTE_USAGE: --default needs skill_routing, skill_contracts, investigate_gate or ceo_review_gate\n${USAGE}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
process.stdout.write(`${SWITCH_DEFAULT[name as keyof typeof SWITCH_DEFAULT]}\n`);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
bump: minor
|
||||
---
|
||||
**An opt-in /plan-ceo-review gate that holds "rank the top risks before you finish" (GST-1461).** With `./setup --ceo-review-gate` (or `GSTACK_CEO_REVIEW_GATE=on`, or `gstack-config set ceo_review_gate on`), a session or review subagent that loaded /plan-ceo-review can't end its final turn until the review's output file, changed after the skill loaded, has a `Findings` or `Top risks` heading followed by a numbered list of real risks starting at 1. The gate looks at `GSTACK_CEO_REVIEW_OUTPUT`, the files the run wrote, changed or untracked markdown in the repository (so a review written through a Bash heredoc counts) and the project's `ceo-plans` directory. A stop that asks you a question is never blocked, an unrelated subagent is never touched, and after 3 blocks the next stop goes through with an `exhausted` marker. Decisions are `GSTACK_GATE ceo-review-gate` markers (`fired`, `blocked via=stop`, `released file=…`, `exhausted`, `error`) in the session record, `hooks/ceo-review-gate.log` and the transcript; errors fail open. `gstack-skill-gates settings --ceo-review` (alone or with `--investigate`) prints a `--settings` JSON for headless runs, and the headless smoke now plays a CEO review too. Off by default; the /plan-ceo-review skill text is unchanged. The /investigate gate's rollback is also tighter: a fix committed on a branch behind the baseline is undone to that branch's own tip; a restored file gets its own bytes back (two files that differ only in line endings no longer share a backup), keeps its permissions and gets its index entry back, so a rejected edit that was `git add`ed isn't left staged and a change you had only staged survives an edit to that file being put back; `linguist-generated` exemptions come from the baseline commit, so a new `.gitattributes` can't exempt source; and `gstack-skill-gates settings` quotes hook paths, so a checkout whose path has a space still runs its hooks. Docs: docs/skill-gates.md.
|
||||
+48
-19
@@ -5,26 +5,27 @@ gstack's advisory skills say what a careful agent does; a gate makes one rule ho
|
||||
| Gate | Switch | What it holds |
|
||||
|---|---|---|
|
||||
| /investigate | `investigate_gate` | After /investigate loads, changes to tracked source wait until the session states a root cause |
|
||||
| /plan-ceo-review | `ceo_review_gate` | After /plan-ceo-review loads, the final stop (of the session or of a review subagent) waits until the review's output file ranks the top risks |
|
||||
|
||||
## Turn a gate on
|
||||
|
||||
The switch resolves like the contract hook's, and the first one set wins:
|
||||
|
||||
1. The `./setup` flag: `--investigate-gate` / `--no-investigate-gate` (also `--investigate-gate=on|off`). A flag is saved to config.
|
||||
2. The environment: `GSTACK_INVESTIGATE_GATE=on|off`. Not saved.
|
||||
3. Config: `gstack-config set investigate_gate on|off`.
|
||||
4. The default, `SWITCH_DEFAULT` in `lib/skill-route.ts`: off.
|
||||
1. The `./setup` flag: `--investigate-gate` / `--no-investigate-gate` (also `--investigate-gate=on|off`), or `--ceo-review-gate` / `--no-ceo-review-gate` (also `--ceo-review-gate=on|off`). A flag is saved to config.
|
||||
2. The environment: `GSTACK_INVESTIGATE_GATE=on|off` or `GSTACK_CEO_REVIEW_GATE=on|off`. Not saved.
|
||||
3. Config: `gstack-config set investigate_gate on|off` or `gstack-config set ceo_review_gate on|off`.
|
||||
4. The default, `SWITCH_DEFAULT` in `lib/skill-route.ts`: off for both.
|
||||
|
||||
Setup registers the gate's four shims in `~/.claude/settings.json` when the switch is on and removes them when it is off. The hooks also read the switch on every call, so `gstack-config set investigate_gate off` takes effect on the next tool call.
|
||||
Setup registers a gate's shims in `~/.claude/settings.json` when its switch is on (four for /investigate; Stop and SubagentStop for /plan-ceo-review) and removes them when it is off. The hooks also read the switch on every call, so `gstack-config set investigate_gate off` takes effect on the next tool call or stop.
|
||||
|
||||
### Headless runs
|
||||
|
||||
For `claude -p` or an eval harness, skip settings.json:
|
||||
|
||||
gstack-skill-gates settings --investigate > gates.json
|
||||
gstack-skill-gates settings --investigate --ceo-review > gates.json
|
||||
claude -p "<task>" --settings gates.json
|
||||
|
||||
The JSON registers the hooks from this checkout and sets `GSTACK_INVESTIGATE_GATE=on` through its `env` block. Under the Agent SDK, pass the same JSON as settings or include `user` in `settingSources` after running setup; with neither, or with `--bare`, no hook loads and no `fired` marker appears. Check every session with `gstack-skill-gates report --session <id> --expect armed` ([exit codes](#exit-codes)).
|
||||
Name one gate or both. The JSON registers their hooks from this checkout and sets `GSTACK_INVESTIGATE_GATE=on` and `GSTACK_CEO_REVIEW_GATE=on` through its `env` block. Under the Agent SDK, pass the same JSON as settings or include `user` in `settingSources` after running setup; with neither, or with `--bare`, no hook loads and no `fired` marker appears. Check every session with `gstack-skill-gates report --session <id> --expect armed` ([exit codes](#exit-codes)).
|
||||
|
||||
## The /investigate gate
|
||||
|
||||
@@ -33,9 +34,9 @@ Once a session loads /investigate (the Skill tool, a typed `/investigate`, or a
|
||||
- **Edit, Write, MultiEdit and NotebookEdit** on a tracked source file are denied before they run.
|
||||
- **Bash** can't be judged from its command line (`sed -i`, a redirect, `tee`, `python -c`, `perl -pi`, `node -e`, `git apply`, a heredoc, a build step that rewrites a file). So the gate compares the tree instead. When it arms it pins a baseline: the repository root, HEAD, its refs and stash list, and the content of every tracked file that differs from HEAD. Every later Edit/Write/Bash hook call, every PostToolUse and PostToolUseFailure on Bash, and Stop compare the tree with that baseline under a session lock. A tracked source file whose content changed is put back and the call or stop is blocked with the files named. Because every check uses the same baseline, a write from a background command or a parallel call is caught at the next check whichever call made it.
|
||||
|
||||
A change is not a fix write, and is folded into the baseline, when the file's new content is its blob in HEAD (`git checkout -- f`, `git stash`), in a commit that existed before the baseline (`git checkout <branch>`, `git bisect`), in a commit a remote-tracking ref holds (`git pull`, `git merge origin/main`), or in a stash entry that existed before (`git stash pop`). A commit made after the baseline that touches a gated file is undone with `git reset --soft` to its newest ancestor that existed before (or that a remote-tracking ref holds), so a fix committed on another branch moves that branch back to its own tip. An amend or rebase of commits from before the baseline on the same branch is undone to the baseline HEAD; on a different branch the gate blocks but leaves the commit in place rather than move that branch. The commit stays in the reflog, those index entries are reset, the files are put back, and the block names the commit's other changes still staged. A commit pushed to a remote counts as upstream, so a fix pushed in the same command is not undone.
|
||||
A change is not a fix write, and is folded into the baseline, when the file's new content is its blob in HEAD (`git checkout -- f`, `git stash`), in a commit that existed before the baseline (`git checkout <branch>`, `git bisect`), in a commit a remote-tracking ref holds (`git pull`, `git merge origin/main`), or in a stash entry that existed before (`git stash pop`). A commit made after the baseline that touches a gated file is undone with `git reset --soft` to its newest ancestor that existed before (or that a remote-tracking ref holds), so a fix committed on another branch moves that branch back to its own tip, including a branch behind the baseline HEAD (`git checkout main && <fix> && git commit` from a feature branch moves main back, and the feature branch is untouched). An amend or rebase of commits from before the baseline is undone to that branch's tip when the gate armed: the baseline HEAD on the same branch, or the other branch's own recorded tip. A branch created after the baseline goes back to that newest older ancestor. The commit stays in the reflog, those index entries are reset, the files are put back, and the block names the commit's other changes still staged. A commit pushed to a remote counts as upstream, so a fix pushed in the same command is not undone.
|
||||
|
||||
**What counts as source.** A file tracked by git that is not a test path (gstack-diff-scope's test rule plus `fixtures/`), a doc (`.md`, `.mdx`, `.rst`, `.txt`, `.adoc`), a lockfile (`bun.lock`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `Cargo.lock`, `go.sum`, `uv.lock`, `poetry.lock`, `Gemfile.lock` and the like), a `linguist-generated` path, or a tracked symlink. New untracked files are never blocked, so a repro script, a new failing test and temporary logging in a scratch file all work. Changes to excluded files are recorded as `observed`, never reverted.
|
||||
**What counts as source.** A file tracked by git that is not a test path (gstack-diff-scope's test rule plus `fixtures/`), a doc (`.md`, `.mdx`, `.rst`, `.txt`, `.adoc`), a lockfile (`bun.lock`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `Cargo.lock`, `go.sum`, `uv.lock`, `poetry.lock`, `Gemfile.lock` and the like), a `linguist-generated` path (read from the baseline commit's attributes, so a `.gitattributes` written after the gate armed exempts nothing), or a tracked symlink. New untracked files are never blocked, so a repro script, a new failing test and temporary logging in a scratch file all work. Changes to excluded files are recorded as `observed`, never reverted.
|
||||
|
||||
**What releases it.** A line in the assistant's own reply, after at least one Read, Grep, Glob or Bash call since the load:
|
||||
|
||||
@@ -43,10 +44,36 @@ A change is not a fix write, and is folded into the baseline, when the file's ne
|
||||
|
||||
That is the `Root cause:` label of the skill's DEBUG REPORT. A placeholder (`[what was actually wrong]`, `TBD`) or a hedge (`unknown`, `unclear`, `still investigating`, `see below`, `n/a`) does not count, and neither does the text inside a tool result or a file. The block text names the form, and the rejected value when there was one. After the release the gate does nothing for the rest of that /investigate run; a new load arms it again.
|
||||
|
||||
**Restoring is careful.** The gate never writes through a symlink: it refuses a parent that is not a real directory inside the repository and replaces the path with a rename, so a tracked file swapped for a link to `~/.bashrc` comes back as the file and the link's target is untouched. Files clean at the baseline come back through `git cat-file --filters` (line endings and LFS smudging intact); files dirty at the baseline come back from a raw copy in the session directory. Before a file is put back, the content it replaced is copied into the session directory and the block names that copy, so an edit made in an editor or another session at the same time is never lost. A path that cannot be put back (it is now a directory, the disk is read-only) gives [GATE_RESTORE_FAILED](#gate-restore-failed).
|
||||
**Restoring is careful.** The gate never writes through a symlink: it refuses a parent that is not a real directory inside the repository and replaces the path with a rename, so a tracked file swapped for a link to `~/.bashrc` comes back as the file and the link's target is untouched. Files clean at the baseline come back through `git cat-file --filters` (line endings and LFS smudging intact); files dirty at the baseline come back from a raw copy in the session directory, keyed by the sha256 of its bytes. A file clean at the baseline keeps its current permissions (the tree's mode only when the file is gone); a file dirty at the baseline gets its baseline mode back. Each restored file's index entry goes back to the baseline's, including a change that was only staged, so a rejected edit that was `git add`ed is not left staged and staged work is not dropped. Before a file is put back, the content it replaced is copied into the session directory and the block names that copy, so an edit made in an editor or another session at the same time is never lost. A path that cannot be put back (it is now a directory, the disk is read-only) gives [GATE_RESTORE_FAILED](#gate-restore-failed).
|
||||
|
||||
**Known gaps.** Paths marked assume-unchanged or skip-worktree, untracked files that shadow a tracked module, and ignored directories are outside the check. While the gate is armed it also puts back an edit you make to tracked source in your editor, since it cannot tell who changed the file.
|
||||
|
||||
## The /plan-ceo-review gate
|
||||
|
||||
Once a session loads /plan-ceo-review (the Skill tool, a typed `/plan-ceo-review`, or a Read of its installed SKILL.md), its final stop is held until the review's output ranks the top risks. Only a final stop counts, by the contract hook's rule (`isFinalTurn` in `lib/skill-contract.ts`): a STATUS line other than `NEEDS_CONTEXT`, or a turn that does not end waiting on the user. A stop that asks the user a question (a closing `?`, `AskUserQuestion`, `STATUS: NEEDS_CONTEXT`) in an interactive session is never blocked; in a session no human answers (`gstack-session-kind` says `headless` or `unattended`) it counts as final.
|
||||
|
||||
**What meets it.** One output file, modified after the load (its mtime is later than the load record's transcript timestamp), with a markdown heading whose text contains `Findings` or `Top risks` (any level, any case), followed inside that section by a numbered list that starts at 1 and has at least one item that is not a placeholder (`[risk]`, `TBD`, `...`). A bullet list, a list starting at 2, a list under the next same-level heading, or a section of placeholders does not count:
|
||||
|
||||
## Findings
|
||||
|
||||
1. Payment retries can double-charge when the provider times out.
|
||||
2. No rollback path for the new orders schema.
|
||||
|
||||
**Which files count**, in order, stopping at the first one that meets it:
|
||||
|
||||
1. `GSTACK_CEO_REVIEW_OUTPUT`, when set (relative paths resolve against the session's cwd).
|
||||
2. Files the run wrote with Write, Edit or MultiEdit.
|
||||
3. Markdown files in the git repository at the session's cwd that differ from HEAD or are untracked and not ignored.
|
||||
4. `$GSTACK_STATE_ROOT/projects/<slug>/ceo-plans/*.md`, with the slug from `bin/gstack-slug`.
|
||||
|
||||
Bash commands are never parsed: a review written with a heredoc or `tee` is found by the repository and ceo-plans scans.
|
||||
|
||||
**The block.** Its reason starts with the `blocked via=stop` marker and says exactly what to write and where: a `## Findings` section ranking the top risks as a numbered list, in `GSTACK_CEO_REVIEW_OUTPUT`, else the last markdown file the run wrote, else the last plan file it read (never a file in a `skills/` directory, judged from the repository root so a project that itself sits under a `skills/` folder still counts, such as the review's own methodology sections; such a file never releases the gate either), plus each file checked and why it fell short. It never names the off switch.
|
||||
|
||||
**The cap.** A run is blocked at most 3 times. The next final stop without ranked risks goes through with an `exhausted` marker and `exhausted_at` in the record. `stop_hook_active` (Claude Code sets it once a Stop hook has blocked the turn) does not skip the check; the cap ends the repeats. After `released` or `exhausted` the gate does nothing for the rest of that run; a new load starts a new run.
|
||||
|
||||
**Subagents.** The SubagentStop shim reads the payload's `agent_transcript_path` (Claude Code 2.1 sends it with `agent_id`) and gates the subagent only when that subagent's own transcript loaded /plan-ceo-review, so an Explore or test subagent finishing in a session that ran the review is never touched. Its records are sidechain records, parsed as such. A subagent always ends on a final turn (no human answers it), and its run is counted under `agent` in the record. A payload without the path, or with an empty one, is left alone.
|
||||
|
||||
## Markers
|
||||
|
||||
Every decision is one marker line:
|
||||
@@ -55,18 +82,19 @@ Every decision is one marker line:
|
||||
|
||||
| Event | When | Fields |
|
||||
|---|---|---|
|
||||
| `fired` | the gate armed for a skill run (once per run) | `skill`, `on` (the hook event), `root` (the repository folder name) |
|
||||
| `blocked` | each refusal | `skill`, `via=edit|bash`, `files`, `n` (blocks so far in this run) |
|
||||
| `released` | the root cause was stated (once per run) | `skill`, `cause` (first 12 hex of the cause's sha256), `blocked` |
|
||||
| `fired` | the gate armed for a skill run (once per run; for /plan-ceo-review, its first final stop) | `skill`, `on` (the hook event), `root` (the repository folder name, /investigate only) |
|
||||
| `blocked` | each refusal | `skill`, `via=edit|bash|stop`, `files` (for `via=stop`, the candidate files checked, or `none`), `n` (blocks so far in this run) |
|
||||
| `released` | the root cause was stated, or an output file ranks the top risks (once per run) | `skill`, `cause` (first 12 hex of the cause's sha256) or `file` (the output file's name), `blocked` |
|
||||
| `exhausted` | /plan-ceo-review only: a fourth final stop after 3 blocks, which goes through | `skill`, `blocked` |
|
||||
| `observed` | an excluded file changed | `skill`, `files` |
|
||||
| `skipped` | the gate could not arm (once per session and reason) | `skill`, `reason=no-git|no-head` |
|
||||
| `error` | a hook error; the call or stop went through | `code`, `why` |
|
||||
|
||||
Where each one lands:
|
||||
|
||||
- **Session record** (the count source): `$GSTACK_STATE_ROOT/skill-gates/<session id>/record.json`, mode 0600, written atomically under the session lock. Per gate and skill run it holds `fired_at`, `released_at`, `cause`, `blocked`, `observed`, plus the gate's `errors` and `skipped` reasons. `gstack-skill-gates report` reads these.
|
||||
- **Trace log**: `$GSTACK_STATE_ROOT/hooks/investigate-gate.log`, one line per marker in the contract hook's shape (`<time> session=<id> event=<event> key=value ...`). It rotates at 1 MiB, so count from the record.
|
||||
- **Transcript**: a block puts the marker on the first line of its reason, which Claude Code shows the model (a denied tool's `tool_result`, or a `hook_blocking_error` attachment for PostToolUse and Stop). `fired`, `released` and `error` ride `systemMessage`, which the transcript records as a `hook_system_message` attachment and the model never sees, so counting them does not change the session.
|
||||
- **Session record** (the count source): `$GSTACK_STATE_ROOT/skill-gates/<session id>/record.json`, mode 0600, written atomically under the session lock. Per gate and skill run it holds `fired_at`, `released_at`, `cause` or `file`, `blocked`, `observed`, `exhausted_at`, `agent` (a subagent's run), plus the gate's `errors` and `skipped` reasons. `gstack-skill-gates report` reads these.
|
||||
- **Trace log**: `$GSTACK_STATE_ROOT/hooks/investigate-gate.log` and `hooks/ceo-review-gate.log`, one line per marker in the contract hook's shape (`<time> session=<id> event=<event> key=value ...`). It rotates at 1 MiB, so count from the record.
|
||||
- **Transcript**: a block puts the marker on the first line of its reason, which Claude Code shows the model (a denied tool's `tool_result`, or a `hook_blocking_error` attachment for PostToolUse, Stop and SubagentStop). `fired`, `released`, `exhausted` and `error` ride `systemMessage`, which the transcript records as a `hook_system_message` attachment and the model never sees, so counting them does not change the session.
|
||||
|
||||
The session directory also holds the baseline and the raw copies of files that were dirty when the gate armed. Session directories untouched for 7 days are deleted the next time a gate arms.
|
||||
|
||||
@@ -78,17 +106,18 @@ A gate never blocks because it broke. On any error it writes an `error` marker w
|
||||
| Code | Cause | Fix |
|
||||
|---|---|---|
|
||||
| <a id="gate-bad-payload"></a>`GATE_BAD_PAYLOAD` | the hook input is not a JSON object with `session_id` and `transcript_path`, or is over 1 MiB | a Claude Code version change; update gstack |
|
||||
| <a id="gate-transcript"></a>`GATE_TRANSCRIPT` | the transcript is missing, over 32 MiB, or not a shape the parser knows | see [skill-routing-and-contracts.md](skill-routing-and-contracts.md#exit-4-unknown-shape) |
|
||||
| <a id="gate-transcript"></a>`GATE_TRANSCRIPT` | the transcript is missing, over 32 MiB, or not a shape the parser knows, or the /plan-ceo-review load record has no timestamp | see [skill-routing-and-contracts.md](skill-routing-and-contracts.md#exit-4-unknown-shape) |
|
||||
| <a id="gate-timeout"></a>`GATE_TIMEOUT` | the hook ran past its 5 second deadline (`GSTACK_SKILL_GATE_DEADLINE_MS` changes it) | a very large dirty tree or a slow disk |
|
||||
| <a id="gate-git"></a>`GATE_GIT` | a git command failed or HEAD stopped resolving | run the named git command in the repository |
|
||||
| <a id="gate-state"></a>`GATE_STATE` | the session record or baseline could not be read or written | make `$GSTACK_STATE_ROOT` writable, or delete `skill-gates/<session id>/` |
|
||||
| <a id="gate-locked"></a>`GATE_LOCKED` | another hook held the session lock for over 3 seconds | transient; a lock older than 30 seconds is taken over |
|
||||
| <a id="gate-slug"></a>`GATE_SLUG` | /plan-ceo-review: `bin/gstack-slug` failed, so the ceo-plans directory could not be checked | run `gstack-slug` in the repository; `GSTACK_PROJECT_SLUG` pins it |
|
||||
| <a id="gate-restore-failed"></a>`GATE_RESTORE_FAILED` | a changed file could not be put back | the block lists restored and failed files; run `git diff HEAD -- <files>` and fix them by hand |
|
||||
| <a id="gate-unexpected"></a>`GATE_UNEXPECTED` | any other throw | report it with the trace log line |
|
||||
|
||||
## Exit codes
|
||||
|
||||
`gstack-skill-gates report [--session <id>] [--expect armed] [--json]` lists each session's gate counts.
|
||||
`gstack-skill-gates report [--session <id>] [--expect armed] [--json]` lists each session's gate counts: runs, `fired`, `blocked`, `released`, `exhausted`, `observed` and `errors`.
|
||||
|
||||
| Exit | Code | Meaning |
|
||||
|---|---|---|
|
||||
@@ -99,4 +128,4 @@ A gate never blocks because it broke. On any error it writes an `error` marker w
|
||||
|
||||
## Headless smoke
|
||||
|
||||
`bun scripts/skill-gates-smoke.ts` runs the real Claude Code CLI against a local stand-in for the Anthropic Messages API that plays a scripted session: load /investigate, read the file, try an Edit, try `sed -i`, state the root cause, edit. No model is called and nothing is spent. It fetches the pinned CLI (`CLAUDE_CODE_VERSION` in the script, verified at 2.1.318) with npm into a temp directory at run time and never stores it; `--claude <path>` uses one you have. It checks the record, the trace log, the transcript attachments, that `systemMessage` markers never reach a request, and that the `sed -i` write was put back. Run it after a Claude Code upgrade.
|
||||
`bun scripts/skill-gates-smoke.ts` runs the real Claude Code CLI against a local stand-in for the Anthropic Messages API that plays two scripted sessions. The first loads /investigate, reads the file, tries an Edit, tries `sed -i`, states the root cause and edits. The second loads /plan-ceo-review, writes `plan.md` with no Findings and ends its turn (the Stop is blocked), then appends a ranked Findings section through a Bash heredoc and ends again (released). No model is called and nothing is spent. It fetches the pinned CLI (`CLAUDE_CODE_VERSION` in the script, verified at 2.1.318) with npm into a temp directory at run time and never stores it; `--claude <path>` uses one you have. It checks the record, the trace log, the transcript attachments, that `systemMessage` markers never reach a request, that the `sed -i` write was put back, and that the CEO review was released by `plan.md`. Run it after a Claude Code upgrade.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Skill routing and skill contracts
|
||||
|
||||
Two Claude Code hooks that help a model actually run gstack's skills (GST-1088). Each has one default, `SWITCH_DEFAULT` in `lib/skill-route.ts`; `gstack-config defaults` prints it. With both off nothing changes. The opt-in gates that hold one skill rule mid-run (the /investigate gate) are in [skill-gates.md](skill-gates.md).
|
||||
Two Claude Code hooks that help a model actually run gstack's skills (GST-1088). Each has one default, `SWITCH_DEFAULT` in `lib/skill-route.ts`; `gstack-config defaults` prints it. With both off nothing changes. The opt-in gates that hold one skill rule mid-run (the /investigate and /plan-ceo-review gates) are in [skill-gates.md](skill-gates.md).
|
||||
|
||||
- **Skill routing** (`skill_routing`, a UserPromptSubmit hook). When a prompt matches an installed skill's `triggers:`, the hook adds one line of context naming that skill, for example "this request fits /investigate. Run /investigate with the Skill tool before you start".
|
||||
- **Skill contracts** (`skill_contracts`, a Stop hook). A skill can declare the outputs that must exist before it is done. When the model tries to stop without them, the hook blocks once and names what is missing.
|
||||
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bash shim for one Claude Code hook event of the /plan-ceo-review gate
|
||||
# (GST-1461): Claude Code runs hook commands through /bin/sh, so this wrapper
|
||||
# runs hosts/claude/hooks/ceo-review-gate.ts with bun. Registered by
|
||||
# ./setup --ceo-review-gate for Stop; one basename per event so the
|
||||
# KNOWN_HOOKS identity table keeps one event per row.
|
||||
#
|
||||
# FAIL-OPEN: every failure path (bun missing, script crash) still exits 0.
|
||||
GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)" || exit 0
|
||||
bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$HERE/ceo-review-gate.ts" || true
|
||||
exit 0
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bash shim for one Claude Code hook event of the /plan-ceo-review gate
|
||||
# (GST-1461): Claude Code runs hook commands through /bin/sh, so this wrapper
|
||||
# runs hosts/claude/hooks/ceo-review-gate.ts with bun. Registered by
|
||||
# ./setup --ceo-review-gate for SubagentStop; one basename per event so the
|
||||
# KNOWN_HOOKS identity table keeps one event per row.
|
||||
#
|
||||
# FAIL-OPEN: every failure path (bun missing, script crash) still exits 0.
|
||||
GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)" || exit 0
|
||||
bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$HERE/ceo-review-gate.ts" || true
|
||||
exit 0
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
/**
|
||||
* ceo-review-gate — opt-in enforcement for /plan-ceo-review (GST-1461). Off
|
||||
* unless ceo_review_gate is on (env GSTACK_CEO_REVIEW_GATE, then config, then
|
||||
* SWITCH_DEFAULT). Registered through two shims: ceo-review-gate-stop (Stop)
|
||||
* and ceo-review-gate-subagent-stop (SubagentStop).
|
||||
*
|
||||
* Stop: payload transcript_path (+ last_assistant_message)
|
||||
* SubagentStop: payload agent_transcript_path, sidechain records included,
|
||||
* so only a subagent that itself loaded /plan-ceo-review is gated
|
||||
* -> the last /plan-ceo-review load (none: nothing)
|
||||
* -> Stop only: a final turn? (lib/skill-contract.ts isFinalTurn; a
|
||||
* question to a human in an interactive session is never blocked)
|
||||
* -> released or exhausted for this run? nothing
|
||||
* -> first final stop of the run: marker fired
|
||||
* -> an output file ranks the top risks (lib/ceo-review-gate.ts): released
|
||||
* -> 3 blocks already: exhausted, the stop goes through
|
||||
* -> otherwise blocked via=stop, the reason names what to write and where
|
||||
*
|
||||
* stop_hook_active (a Stop hook already blocked this turn) does not skip the
|
||||
* check: the cap ends repeats. Markers are counted in the session record and
|
||||
* traced in $GSTACK_STATE_ROOT/hooks/ceo-review-gate.log; a block carries its
|
||||
* marker as the reason's first line, the others ride systemMessage. Errors
|
||||
* fail open (lib/skill-gates.ts runGateHook). Docs: docs/skill-gates.md.
|
||||
*/
|
||||
import * as path from 'path';
|
||||
import { runBin, sessionKind } from './spawn-bin';
|
||||
import { SKILL, checkOutput } from '../../../lib/ceo-review-gate';
|
||||
import { StateError, UnknownShape, isFinalTurn, parseTranscript, readTranscriptFile, sameSkill, skillLoads, turnEnd, withFinalMessage } from '../../../lib/skill-contract';
|
||||
import { readSkillSwitch } from '../../../lib/skill-route';
|
||||
import {
|
||||
GateError, blockWith, hookRecord, note, pruneSessionDirs, readGateRecord, recordPath, remainingMs, runGateHook, sessionDir, systemMessage,
|
||||
withSessionLock, writeGateRecord, type GatePayload, type HookOutput,
|
||||
} from '../../../lib/skill-gates';
|
||||
|
||||
const HOOK = 'ceo-review-gate';
|
||||
const MAX_BLOCKS = 3;
|
||||
const SESSION_KIND_TIMEOUT_MS = 1000;
|
||||
const SLUG_TIMEOUT_MS = 2000;
|
||||
|
||||
function slug(cwd: string): string {
|
||||
const r = runBin('gstack-slug', ['--get', 'SLUG'], { cwd, encoding: 'utf8', timeout: Math.min(SLUG_TIMEOUT_MS, remainingMs()), windowsHide: true });
|
||||
const value = String(r.stdout ?? '').trim();
|
||||
if (r.status !== 0 || !value) throw new GateError('GATE_SLUG', `gstack-slug failed: ${String(r.stderr ?? r.error ?? '').trim().split('\n')[0] || `exit ${r.status}`}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function handle(p: GatePayload): HookOutput | undefined {
|
||||
const sub = p.event === 'SubagentStop';
|
||||
const file = sub ? p.raw.agent_transcript_path : p.transcriptPath;
|
||||
if (typeof file !== 'string' || !file) return undefined;
|
||||
let raw: string;
|
||||
let t;
|
||||
try {
|
||||
raw = readTranscriptFile(file);
|
||||
t = withFinalMessage(parseTranscript(raw, { includeSidechain: sub }), p.raw.last_assistant_message);
|
||||
} catch (e) {
|
||||
if (e instanceof StateError || e instanceof UnknownShape) throw new GateError('GATE_TRANSCRIPT', e.message);
|
||||
throw e;
|
||||
}
|
||||
const load = skillLoads(t).filter(l => sameSkill(l.skill, SKILL)).pop();
|
||||
if (!load) return undefined;
|
||||
if (!sub && !isFinalTurn(turnEnd(t), () => sessionKind(Math.min(SESSION_KIND_TIMEOUT_MS, remainingMs())))) return undefined;
|
||||
let since = Number.NaN;
|
||||
try { since = Date.parse(JSON.parse(raw.split('\n')[load.line - 1]).timestamp); } catch { /* checked below */ }
|
||||
if (Number.isNaN(since)) throw new GateError('GATE_TRANSCRIPT', `the /plan-ceo-review load on line ${load.line} has no timestamp`);
|
||||
const agent = sub ? path.basename(file, '.jsonl') : undefined;
|
||||
const key = `${SKILL}@${agent ? `${agent}:` : ''}${load.line}`;
|
||||
const dir = sessionDir(p.sessionId);
|
||||
return withSessionLock(dir, () => {
|
||||
const recFile = recordPath(p.sessionId);
|
||||
const rec = readGateRecord(recFile, p.sessionId);
|
||||
rec.transcript_path = p.transcriptPath;
|
||||
const h = hookRecord(rec, HOOK);
|
||||
let run = h.runs[key];
|
||||
if (run?.released_at || run?.exhausted_at) return undefined;
|
||||
const save = () => writeGateRecord(recFile, rec);
|
||||
const sys: string[] = [];
|
||||
if (!run) {
|
||||
run = h.runs[key] = { skill: SKILL, load_line: load.line, fired_at: new Date().toISOString(), blocked: 0, observed: 0, ...(agent ? { agent } : {}) };
|
||||
save();
|
||||
sys.push(note(HOOK, 'fired', p.sessionId, { skill: SKILL, on: p.event }));
|
||||
pruneSessionDirs(dir);
|
||||
}
|
||||
const check = checkOutput(t, load, { cwd: p.cwd, since, envOutput: process.env.GSTACK_CEO_REVIEW_OUTPUT, slug: () => slug(p.cwd) });
|
||||
if (check.met) {
|
||||
run.released_at = new Date().toISOString();
|
||||
run.file = path.basename(check.met);
|
||||
save();
|
||||
sys.push(note(HOOK, 'released', p.sessionId, { skill: SKILL, file: run.file, blocked: run.blocked }));
|
||||
return systemMessage(sys);
|
||||
}
|
||||
if (run.blocked >= MAX_BLOCKS) {
|
||||
run.exhausted_at = new Date().toISOString();
|
||||
save();
|
||||
sys.push(note(HOOK, 'exhausted', p.sessionId, { skill: SKILL, blocked: run.blocked }));
|
||||
return systemMessage(sys);
|
||||
}
|
||||
run.blocked++;
|
||||
save();
|
||||
const files = check.checked.map(c => path.basename(c.file));
|
||||
const marker = note(HOOK, 'blocked', p.sessionId, { skill: SKILL, via: 'stop', files: files.join(',') || 'none', n: run.blocked });
|
||||
const body = [
|
||||
`/plan-ceo-review is not done: its output does not rank the top risks yet. Before you stop, write a '## Findings' section ranking the top risks as a numbered list (1. most severe ...) in the review's output file: ${check.dest}`,
|
||||
'Each item names one concrete risk, most severe first; a placeholder such as [risk] or TBD does not count. A heading that says "Top risks" works too.',
|
||||
...check.checked.map(c => `Checked ${c.file}: ${c.why}.`),
|
||||
...check.missing.map(f => `${f} does not exist yet.`),
|
||||
].join('\n');
|
||||
return blockWith(`${marker}\n${body}`, sys);
|
||||
});
|
||||
}
|
||||
|
||||
runGateHook(HOOK, () => readSkillSwitch('ceo_review_gate').on, handle).finally(() => process.exit(0));
|
||||
@@ -134,7 +134,6 @@ function handle(p: GatePayload): HookOutput | undefined {
|
||||
...(check.saved.length ? [`The content it replaced is saved: ${check.saved.map(s => `${s.path} at ${s.file}`).join(', ')}.`] : []),
|
||||
...(check.reset ? [`It also moved the branch back to ${check.reset.slice(0, 12)}; the new commit stays in the reflog.`] : []),
|
||||
...(check.staged.length ? [`The commit's other changes are still staged: ${check.staged.join(', ')}.`] : []),
|
||||
...(check.kept.length ? [`The new commit rewrote history from before /investigate loaded, on a different branch, so the gate did not undo it: ${check.kept.join(', ')} stay committed.`] : []),
|
||||
...(failed.length ? [`These could not be put back and are in an unknown state: ${failed.join(', ')}. Run git diff HEAD -- ${failed.join(' ')} to see them.`] : []),
|
||||
howToRelease(),
|
||||
].join('\n');
|
||||
@@ -154,7 +153,7 @@ function handle(p: GatePayload): HookOutput | undefined {
|
||||
if (target) {
|
||||
const abs = path.resolve(p.cwd, target);
|
||||
const rel = path.relative(base!.root, fs.existsSync(path.dirname(abs)) ? path.join(fs.realpathSync(path.dirname(abs)), path.basename(abs)) : abs).split(path.sep).join('/');
|
||||
if (!rel.startsWith('..') && !path.isAbsolute(rel) && isGatedTracked(base!.root, rel)) {
|
||||
if (!rel.startsWith('..') && !path.isAbsolute(rel) && isGatedTracked(base!.root, rel, base!.head)) {
|
||||
return block('edit', [rel], `This session loaded /investigate and has not stated a root cause, so edits to tracked source (${rel}) are blocked.\n${howToRelease(cause.rejected)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,12 +28,12 @@
|
||||
* - Every decision is one line in $GSTACK_STATE_ROOT/hooks/skill-contract.log.
|
||||
* Docs: docs/skill-routing-and-contracts.md.
|
||||
*/
|
||||
import { binPath, runBin } from './spawn-bin';
|
||||
import { binPath, sessionKind } from './spawn-bin';
|
||||
import { appendHookTrace, installedSkillsDir, readSkillSwitch, readStdinBounded } from '../../../lib/skill-route';
|
||||
import {
|
||||
AGENT_STALL_MS, StateError, UnknownShape, blockMessage, bumpCanary, checkContract, lookupContract, nextOutcome, parseTranscript,
|
||||
AGENT_STALL_MS, StateError, UnknownShape, blockMessage, bumpCanary, checkContract, isFinalTurn, lookupContract, nextOutcome, parseTranscript,
|
||||
readRecord, readTranscriptFile, recordPath, runKey, sameSkill, skillLoads, stateErrorLine, withFinalMessage, writeRecord,
|
||||
type CheckResult, type Contract, type Transcript,
|
||||
type Contract, type Transcript,
|
||||
} from '../../../lib/skill-contract';
|
||||
|
||||
export const DEADLINE_MS = 5000;
|
||||
@@ -70,15 +70,6 @@ function target(t: Transcript): { skill: string; contract: Contract } | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A STATUS line, a session no human answers, or a turn that does not end on a question. */
|
||||
function isFinalTurn(result: CheckResult): boolean {
|
||||
if (result.statusLine && result.statusLine !== 'NEEDS_CONTEXT') return true;
|
||||
if (!result.pendingQuestion) return true;
|
||||
const r = runBin('gstack-session-kind', [], { encoding: 'utf8', timeout: SESSION_KIND_TIMEOUT_MS, windowsHide: true });
|
||||
const kind = String(r.stdout ?? '').trim();
|
||||
return kind === 'headless' || kind === 'unattended';
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
setTimeout(() => { failOpen('<hook>', `timed out after ${DEADLINE_MS} ms`); process.exit(0); }, DEADLINE_MS);
|
||||
if (!readSkillSwitch('skill_contracts').on) return;
|
||||
@@ -111,7 +102,7 @@ async function main(): Promise<void> {
|
||||
const record = readRecord(file) ?? { version: 1 as const, session_id: sessionId, transcript_path: transcriptPath, runs: {} };
|
||||
const key = runKey(t.skill, result.load);
|
||||
const prev = record.runs[key];
|
||||
if (!isFinalTurn(result)) return trace({ event: 'not-final-turn', skill: t.skill });
|
||||
if (!isFinalTurn(result, () => sessionKind(SESSION_KIND_TIMEOUT_MS))) return trace({ event: 'not-final-turn', skill: t.skill });
|
||||
const pending = [...result.pendingAgents].sort();
|
||||
if (pending.length) {
|
||||
const sameSet = pending.join(',') === (prev?.deferred_agents ?? []).join(',') && !!prev?.deferred_at;
|
||||
|
||||
@@ -41,6 +41,12 @@ export function runBin(name: string, args: string[], opts: SpawnSyncOptions) {
|
||||
});
|
||||
}
|
||||
|
||||
/** gstack-session-kind's verdict (unattended, spawned, headless or interactive), or '' when it fails. */
|
||||
export function sessionKind(timeoutMs: number): string {
|
||||
const r = runBin('gstack-session-kind', [], { encoding: 'utf8', timeout: timeoutMs, windowsHide: true });
|
||||
return String(r.stdout ?? '').trim();
|
||||
}
|
||||
|
||||
/** Claude Code's timeout for a gstack hook, as setup registers it (`--timeout 5`). */
|
||||
export const HOOK_TIMEOUT_MS = 5000;
|
||||
/** Kept back from HOOK_TIMEOUT_MS so a hook logs and exits before Claude Code kills it. */
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* ceo-review-gate — the output check behind the /plan-ceo-review enforcement
|
||||
* hook (GST-1461). Docs: docs/skill-gates.md.
|
||||
*
|
||||
* A review is met when one candidate output file, modified after the skill
|
||||
* loaded, has a heading whose text contains "Findings" or "Top risks" (any
|
||||
* level) and, inside that section, a numbered list that starts at 1 with at
|
||||
* least one item that is not a placeholder: the ranked top risks. Candidates,
|
||||
* in order: GSTACK_CEO_REVIEW_OUTPUT; files the run wrote with Write, Edit or
|
||||
* MultiEdit; changed or untracked (not ignored) .md files in the git repo at
|
||||
* cwd; $GSTACK_STATE_ROOT/projects/<slug>/ceo-plans/*.md. Bash commands are
|
||||
* never parsed: a file written through the shell is found by the repo and
|
||||
* ceo-plans scans. A file in a skills/ directory (judged from the repo root,
|
||||
* else cwd) is never a candidate or the place the block names: the review's
|
||||
* own methodology sections are not its output.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { git, repoRoot } from './investigate-gate';
|
||||
import { isPlaceholder, sameSkill, skillLoads, type SkillLoad, type Transcript } from './skill-contract';
|
||||
import { remainingMs } from './skill-gates';
|
||||
import { resolveStateRoot } from './state-root';
|
||||
|
||||
export const SKILL = 'plan-ceo-review';
|
||||
const MAX_FILE_BYTES = 4 * 1024 * 1024;
|
||||
const MAX_SCANNED = 200;
|
||||
const WRITE_TOOLS = /^(?:Write|Edit|MultiEdit)$/;
|
||||
const HEADING = /^ {0,3}(#{1,6})[ \t]+(.*?)[ \t#]*$/;
|
||||
const RISK_HEADING = /findings|top risks/i;
|
||||
const ITEM = /^ {0,3}(\d{1,9})[.)][ \t]+(.*)$/;
|
||||
const FENCE = /^ {0,3}(`{3,}|~{3,})/;
|
||||
const SKILL_TREE = /(?:^|[\\/])skills[\\/]/;
|
||||
|
||||
/** Why markdown does not rank the top risks, or null when it does. */
|
||||
export function rankedRisksGap(md: string): string | null {
|
||||
let level = 0;
|
||||
let started = false;
|
||||
let heading = false;
|
||||
let list = false;
|
||||
let fence: string | null = null;
|
||||
for (const line of md.split(/\r?\n/)) {
|
||||
const f = FENCE.exec(line);
|
||||
if (f) { fence = fence === null ? f[1][0] : f[1][0] === fence ? null : fence; continue; }
|
||||
if (fence) continue;
|
||||
const h = HEADING.exec(line);
|
||||
if (h) {
|
||||
if (level && h[1].length > level) continue;
|
||||
level = RISK_HEADING.test(h[2]) ? h[1].length : 0;
|
||||
heading ||= level > 0;
|
||||
started = false;
|
||||
continue;
|
||||
}
|
||||
if (!level) continue;
|
||||
const item = ITEM.exec(line);
|
||||
if (!item) continue;
|
||||
if (!started && Number(item[1]) !== 1) { level = 0; continue; }
|
||||
started = true;
|
||||
list = true;
|
||||
if (!isPlaceholder(item[2].replace(/^[\s*_`]+|[\s*_`]+$/g, ''))) return null;
|
||||
}
|
||||
if (!heading) return 'no Findings or Top risks heading';
|
||||
if (!list) return 'no numbered list starting at 1 under its Findings or Top risks heading';
|
||||
return 'its numbered list holds only placeholders';
|
||||
}
|
||||
|
||||
export interface OutputCheck {
|
||||
/** the first candidate that ranks the top risks */
|
||||
met: string | null;
|
||||
/** every existing candidate looked at, with why it fell short */
|
||||
checked: Array<{ file: string; why: string }>;
|
||||
/** the candidates that do not exist (GSTACK_CEO_REVIEW_OUTPUT, a written file since removed) */
|
||||
missing: string[];
|
||||
/** where the block tells the model to write */
|
||||
dest: string;
|
||||
}
|
||||
|
||||
export interface OutputContext { cwd: string; since: number; envOutput: string | undefined; slug: () => string }
|
||||
|
||||
function changedMarkdown(root: string | null, since: number): string[] {
|
||||
if (!root) return [];
|
||||
const list = (args: string[]) => git(root, args, { allowFail: true }).out.toString('utf8').split('\0').filter(Boolean);
|
||||
const rels = [...new Set([...list(['diff', 'HEAD', '--name-only', '-z', '--no-renames', '--', '*.md']), ...list(['ls-files', '-o', '--exclude-standard', '-z', '--', '*.md'])])];
|
||||
return newest(rels.map(r => path.join(root, r)), since);
|
||||
}
|
||||
|
||||
function newest(files: string[], since: number): string[] {
|
||||
const dated: Array<[string, number]> = [];
|
||||
for (const f of files) {
|
||||
try { const st = fs.statSync(f); if (st.isFile() && st.mtimeMs > since) dated.push([f, st.mtimeMs]); } catch { /* removed since */ }
|
||||
}
|
||||
return dated.sort((a, b) => b[1] - a[1]).slice(0, MAX_SCANNED).map(([f]) => f);
|
||||
}
|
||||
|
||||
function ceoPlans(slug: string, since: number): string[] {
|
||||
const dir = path.join(resolveStateRoot(), 'projects', slug, 'ceo-plans');
|
||||
let names: string[] = [];
|
||||
try { names = fs.readdirSync(dir).filter(n => n.endsWith('.md')); } catch { return []; }
|
||||
return newest(names.map(n => path.join(dir, n)), since);
|
||||
}
|
||||
|
||||
/** Check the candidates of one /plan-ceo-review run, source by source, stopping at the first met file. */
|
||||
export function checkOutput(t: Transcript, load: SkillLoad, ctx: OutputContext): OutputCheck {
|
||||
const next = skillLoads(t).find(l => l.at > load.at && sameSkill(l.skill, SKILL));
|
||||
const run = t.events.slice(load.at + 1, next?.at);
|
||||
const paths = (re: RegExp) => run.flatMap(e => (e.kind === 'tool_use' && re.test(e.name) && typeof e.input.file_path === 'string' ? [path.resolve(ctx.cwd, e.input.file_path)] : []));
|
||||
const env = ctx.envOutput?.trim() ? path.resolve(ctx.cwd, ctx.envOutput.trim()) : undefined;
|
||||
const repo = repoRoot(ctx.cwd);
|
||||
const inSkillTree = (f: string) => SKILL_TREE.test(path.relative(repo ?? ctx.cwd, f));
|
||||
const written = paths(WRITE_TOOLS).filter(f => !inSkillTree(f));
|
||||
const reviewed = paths(/^Read$/).filter(f => f.endsWith('.md') && !inSkillTree(f)).pop();
|
||||
const dest = env ?? written.filter(f => f.endsWith('.md')).pop() ?? reviewed ?? 'the plan file you reviewed';
|
||||
const sources: Array<() => string[]> = [() => (env ? [env] : []), () => written, () => changedMarkdown(repo, ctx.since).filter(f => !inSkillTree(f)), () => ceoPlans(ctx.slug(), ctx.since)];
|
||||
const result: OutputCheck = { met: null, checked: [], missing: [], dest };
|
||||
const seen = new Set<string>();
|
||||
for (const source of sources) {
|
||||
for (const file of source()) {
|
||||
if (seen.has(file)) continue;
|
||||
seen.add(file);
|
||||
remainingMs();
|
||||
let st: fs.Stats;
|
||||
try { st = fs.statSync(file); } catch { result.missing.push(file); continue; }
|
||||
if (!st.isFile()) { result.checked.push({ file, why: 'not a file' }); continue; }
|
||||
if (st.mtimeMs <= ctx.since) { result.checked.push({ file, why: 'not changed since /plan-ceo-review loaded' }); continue; }
|
||||
if (st.size > MAX_FILE_BYTES) { result.checked.push({ file, why: `over ${MAX_FILE_BYTES} bytes` }); continue; }
|
||||
const why = rankedRisksGap(fs.readFileSync(file, 'utf8'));
|
||||
if (why === null) { result.met = file; return result; }
|
||||
result.checked.push({ file, why });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
+71
-41
@@ -16,6 +16,7 @@
|
||||
import { spawnSync } from 'child_process';
|
||||
import { createHash } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { isPlaceholder, labelValue, type SkillLoad, type Transcript } from './skill-contract';
|
||||
import { GateError, remainingMs } from './skill-gates';
|
||||
@@ -29,8 +30,8 @@ const HEDGE = /^(?:unknown|unclear|not (?:sure|known|yet known|clear)|undetermin
|
||||
const EVIDENCE_TOOLS = /^(?:Read|Grep|Glob|Bash|LS|NotebookRead|WebFetch)$/;
|
||||
export const EDIT_TOOLS = /^(?:Edit|Write|MultiEdit|NotebookEdit)$/;
|
||||
|
||||
function git(root: string, args: string[], opts: { input?: string; allowFail?: boolean } = {}): { ok: boolean; out: Buffer } {
|
||||
const r = spawnSync('git', args, { cwd: root, input: opts.input, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' }, timeout: Math.min(GIT_TIMEOUT_MS, remainingMs()), maxBuffer: 64 * 1024 * 1024 });
|
||||
export function git(root: string, args: string[], opts: { input?: string; allowFail?: boolean; env?: Record<string, string> } = {}): { ok: boolean; out: Buffer } {
|
||||
const r = spawnSync('git', args, { cwd: root, input: opts.input, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', ...opts.env }, timeout: Math.min(GIT_TIMEOUT_MS, remainingMs()), maxBuffer: 64 * 1024 * 1024 });
|
||||
const ok = r.status === 0;
|
||||
if (!ok && !opts.allowFail) {
|
||||
const why = r.error ? r.error.message : String(r.stderr ?? '').trim().split('\n')[0];
|
||||
@@ -61,15 +62,50 @@ function treeEntries(root: string, rev: string, paths: string[]): Map<string, Tr
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function generatedPaths(root: string, paths: string[]): Set<string> {
|
||||
/** Paths rev's attributes mark linguist-generated, read through a scratch index so a worktree .gitattributes cannot exempt source (git before 2.40 has no check-attr --source). */
|
||||
function generatedPaths(root: string, rev: string, paths: string[]): Set<string> {
|
||||
const out = new Set<string>();
|
||||
for (let i = 0; i < paths.length; i += 500) {
|
||||
const f = nulList(git(root, ['check-attr', '-z', 'linguist-generated', '--', ...paths.slice(i, i + 500)]).out);
|
||||
for (let j = 0; j + 2 < f.length + 1; j += 3) if (f[j + 2] === 'set' || f[j + 2] === 'true') out.add(f[j]);
|
||||
if (!paths.length) return out;
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-attr-'));
|
||||
try {
|
||||
const env = { GIT_INDEX_FILE: path.join(dir, 'index') };
|
||||
git(root, ['read-tree', rev], { env });
|
||||
for (let i = 0; i < paths.length; i += 500) {
|
||||
const f = nulList(git(root, ['check-attr', '--cached', '-z', 'linguist-generated', '--', ...paths.slice(i, i + 500)], { env }).out);
|
||||
for (let j = 0; j + 2 < f.length + 1; j += 3) if (f[j + 2] === 'set' || f[j + 2] === 'true') out.add(f[j]);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type IndexEntry = { mode: string; oid: string };
|
||||
/** Stage-0 index entries; a conflicted path maps to undefined (left alone on restore). */
|
||||
function indexEntries(root: string, paths: string[]): Map<string, IndexEntry | undefined> {
|
||||
const out = new Map<string, IndexEntry | undefined>();
|
||||
for (let i = 0; i < paths.length; i += 500) {
|
||||
for (const rec of nulList(git(root, ['ls-files', '-s', '-z', '--', ...paths.slice(i, i + 500)]).out)) {
|
||||
const m = /^(\d+) ([0-9a-f]+) (\d)\t(.*)$/s.exec(rec);
|
||||
if (m) out.set(m[4], m[3] === '0' && !out.has(m[4]) ? { mode: m[1], oid: m[2] } : undefined);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Copy a file's raw bytes into the session dir under their sha256, the key a restore reads back. */
|
||||
function saveBytes(blobs: string, file: string): string {
|
||||
const bytes = fs.readFileSync(file);
|
||||
const key = createHash('sha256').update(bytes).digest('hex');
|
||||
const dest = path.join(blobs, key);
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.mkdirSync(blobs, { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(dest, bytes, { mode: 0o600 });
|
||||
fs.chmodSync(dest, 0o600);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/** The content id of a worktree path: its git blob id (filters applied), symlink:<target>, dir, or null when absent. */
|
||||
function worktreeIds(root: string, paths: string[]): Map<string, string | null> {
|
||||
const ids = new Map<string, string | null>();
|
||||
@@ -100,40 +136,39 @@ export function excludedReason(rel: string, mode?: string, generated = false): E
|
||||
}
|
||||
|
||||
/** Whether a repo-relative path is tracked (in HEAD or the index) and gated. */
|
||||
export function isGatedTracked(root: string, rel: string): boolean {
|
||||
export function isGatedTracked(root: string, rel: string, rev: string): boolean {
|
||||
const ls = text(git(root, ['ls-files', '-s', '-z', '--', rel]).out).split('\0').filter(Boolean);
|
||||
const entry = ls.find(l => l.endsWith(`\t${rel}`));
|
||||
if (!entry) return false;
|
||||
return excludedReason(rel, entry.split(' ')[0], generatedPaths(root, [rel]).has(rel)) === null;
|
||||
return excludedReason(rel, entry.split(' ')[0], generatedPaths(root, rev, [rel]).has(rel)) === null;
|
||||
}
|
||||
|
||||
interface Entry { id: string | null; excluded: boolean; mode: number }
|
||||
export interface Baseline { version: 1; root: string; head: string; branch?: string; taken_at: number; refs: string[]; stashes: string[]; entries: Record<string, Entry> }
|
||||
interface Entry { id: string | null; excluded: boolean; mode: number; backup?: string; index?: IndexEntry | null }
|
||||
export interface Baseline { version: 1; root: string; head: string; branch?: string; tips?: Record<string, string>; taken_at: number; refs: string[]; stashes: string[]; entries: Record<string, Entry> }
|
||||
|
||||
function snapshot(root: string, blobs: string, head: string): Baseline {
|
||||
const changed = changedVsHead(root);
|
||||
const changed = [...new Set([...changedVsHead(root), ...nulList(git(root, ['diff', '--cached', 'HEAD', '--name-only', '-z', '--no-renames']).out)])];
|
||||
const ids = worktreeIds(root, changed);
|
||||
const tree = changed.length ? treeEntries(root, head, changed) : new Map<string, TreeEntry>();
|
||||
const generated = changed.length ? generatedPaths(root, changed) : new Set<string>();
|
||||
const generated = generatedPaths(root, head, changed);
|
||||
const staged = indexEntries(root, changed);
|
||||
const entries: Record<string, Entry> = {};
|
||||
for (const p of changed) {
|
||||
const id = ids.get(p) ?? null;
|
||||
const abs = path.join(root, p);
|
||||
const excluded = excludedReason(p, tree.get(p)?.mode, generated.has(p)) !== null;
|
||||
let mode = 0o644;
|
||||
let backup: string | undefined;
|
||||
if (id && /^[0-9a-f]+$/.test(id)) {
|
||||
mode = fs.statSync(abs).mode & 0o777;
|
||||
if (!excluded && !fs.existsSync(path.join(blobs, id))) {
|
||||
fs.mkdirSync(blobs, { recursive: true, mode: 0o700 });
|
||||
fs.copyFileSync(abs, path.join(blobs, id));
|
||||
fs.chmodSync(path.join(blobs, id), 0o600);
|
||||
}
|
||||
if (!excluded) backup = saveBytes(blobs, abs);
|
||||
}
|
||||
entries[p] = { id, excluded, mode };
|
||||
entries[p] = { id, excluded, mode, ...(backup ? { backup } : {}), index: staged.has(p) ? staged.get(p) : null };
|
||||
}
|
||||
const refs = nulList(git(root, ['for-each-ref', '--format=%(objectname)%00']).out).map(s => s.trim()).filter(Boolean);
|
||||
const stashes = text(git(root, ['log', '-g', '--format=%H', 'refs/stash'], { allowFail: true }).out).split('\n').filter(Boolean);
|
||||
return { version: 1, root, head, branch: branchOf(root), taken_at: Date.now(), refs: [...new Set([head, ...refs])], stashes, entries };
|
||||
const tips = Object.fromEntries(text(git(root, ['for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads']).out).split('\n').filter(Boolean).map(l => l.split(' ')));
|
||||
return { version: 1, root, head, branch: branchOf(root), tips, taken_at: Date.now(), refs: [...new Set([head, ...refs])], stashes, entries };
|
||||
}
|
||||
|
||||
export function armBaseline(root: string, blobs: string): Baseline | null {
|
||||
@@ -181,14 +216,14 @@ function safeRestore(root: string, rel: string, content: Buffer | null, mode: nu
|
||||
fs.renameSync(tmp, abs);
|
||||
}
|
||||
|
||||
export interface CheckResult { baseline: Baseline; violations: string[]; observed: string[]; restored: string[]; failed: Array<{ path: string; why: string }>; reset: string | null; staged: string[]; kept: string[]; saved: Array<{ path: string; file: string }> }
|
||||
export interface CheckResult { baseline: Baseline; violations: string[]; observed: string[]; restored: string[]; failed: Array<{ path: string; why: string }>; reset: string | null; staged: string[]; saved: Array<{ path: string; file: string }> }
|
||||
|
||||
/** Compare the tree with the baseline, put back fix writes, and return the next baseline. */
|
||||
export function checkTree(base: Baseline, blobs: string): CheckResult {
|
||||
const root = base.root;
|
||||
const head = headOf(root);
|
||||
if (!head) throw new GateError('GATE_GIT', 'HEAD no longer resolves');
|
||||
const result: CheckResult = { baseline: base, violations: [], observed: [], restored: [], failed: [], reset: null, staged: [], kept: [], saved: [] };
|
||||
const result: CheckResult = { baseline: base, violations: [], observed: [], restored: [], failed: [], reset: null, staged: [], saved: [] };
|
||||
let target = head;
|
||||
const merged: string[] = [];
|
||||
const upstream = (c: string) => existedBefore(root, base, c) || onRemote(root, c);
|
||||
@@ -200,11 +235,9 @@ export function checkTree(base: Baseline, blobs: string): CheckResult {
|
||||
target = parents[0];
|
||||
}
|
||||
}
|
||||
let rewrote = false;
|
||||
if (target !== head && target !== base.head && git(root, ['merge-base', '--is-ancestor', target, base.head], { allowFail: true }).ok) {
|
||||
if (branchOf(root) === base.branch) target = base.head;
|
||||
else rewrote = true;
|
||||
}
|
||||
const branch = branchOf(root);
|
||||
const tip = branch === base.branch ? base.head : branch ? base.tips?.[branch] : undefined;
|
||||
if (tip && target !== head && target !== tip && git(root, ['merge-base', '--is-ancestor', target, tip], { allowFail: true }).ok) target = tip;
|
||||
const newCommit = target !== head;
|
||||
const moved = target !== base.head;
|
||||
const committed = newCommit ? nulList(git(root, ['diff', '--name-only', '-z', '--no-renames', target, head]).out) : [];
|
||||
@@ -215,7 +248,7 @@ export function checkTree(base: Baseline, blobs: string): CheckResult {
|
||||
const refTree = moved ? treeEntries(root, target, cand) : baseTree;
|
||||
const headTree = newCommit ? treeEntries(root, head, cand) : refTree;
|
||||
const mergedTrees = merged.map(m => treeEntries(root, m, cand));
|
||||
const generated = generatedPaths(root, cand);
|
||||
const generated = generatedPaths(root, base.head, cand);
|
||||
const fix = new Set<string>();
|
||||
for (const p of cand) {
|
||||
const baseEntry = base.entries[p];
|
||||
@@ -229,8 +262,7 @@ export function checkTree(base: Baseline, blobs: string): CheckResult {
|
||||
fix.add(p);
|
||||
}
|
||||
result.violations = [...fix].sort();
|
||||
if (rewrote) result.kept = committed.filter(p => fix.has(p));
|
||||
else if (newCommit && committed.some(p => fix.has(p))) {
|
||||
if (newCommit && committed.some(p => fix.has(p))) {
|
||||
git(root, ['reset', '-q', '--soft', target]);
|
||||
git(root, ['reset', '-q', target, '--', ...committed.filter(p => fix.has(p))], { allowFail: true });
|
||||
result.reset = target;
|
||||
@@ -239,24 +271,22 @@ export function checkTree(base: Baseline, blobs: string): CheckResult {
|
||||
for (const p of result.violations) {
|
||||
const baseEntry = base.entries[p];
|
||||
const curId = ids.get(p) ?? null;
|
||||
if (result.kept.includes(p) || curId === (baseEntry ? baseEntry.id : refTree.get(p)!.oid)) continue;
|
||||
if (curId === (baseEntry ? baseEntry.id : refTree.get(p)!.oid)) continue;
|
||||
try {
|
||||
if (curId && /^[0-9a-f]+$/.test(curId)) {
|
||||
const keep = path.join(blobs, curId);
|
||||
if (!fs.existsSync(keep)) {
|
||||
fs.mkdirSync(blobs, { recursive: true, mode: 0o700 });
|
||||
fs.copyFileSync(path.join(root, p), keep);
|
||||
fs.chmodSync(keep, 0o600);
|
||||
}
|
||||
result.saved.push({ path: p, file: keep });
|
||||
}
|
||||
if (curId && /^[0-9a-f]+$/.test(curId)) result.saved.push({ path: p, file: path.join(blobs, saveBytes(blobs, path.join(root, p))) });
|
||||
if (baseEntry) {
|
||||
if (baseEntry.id === null) safeRestore(root, p, null, 0);
|
||||
else if (baseEntry.id.startsWith('symlink:')) safeRestore(root, p, null, 0, baseEntry.id.slice(8));
|
||||
else safeRestore(root, p, fs.readFileSync(path.join(blobs, baseEntry.id)), baseEntry.mode);
|
||||
else safeRestore(root, p, fs.readFileSync(path.join(blobs, baseEntry.backup ?? baseEntry.id)), baseEntry.mode);
|
||||
} else {
|
||||
safeRestore(root, p, git(root, ['cat-file', '--filters', `${target}:${p}`]).out, refTree.get(p)!.mode === '100755' ? 0o755 : 0o644);
|
||||
const cur = fs.lstatSync(path.join(root, p), { throwIfNoEntry: false });
|
||||
const kept = cur?.isFile() ? cur.mode & 0o777 : undefined;
|
||||
safeRestore(root, p, git(root, ['cat-file', '--filters', `${target}:${p}`]).out, kept ?? (refTree.get(p)!.mode === '100755' ? 0o755 : 0o644));
|
||||
if (kept !== undefined) fs.chmodSync(path.join(root, p), kept);
|
||||
}
|
||||
const staged = baseEntry ? baseEntry.index : refTree.get(p);
|
||||
if (staged === null) git(root, ['update-index', '--force-remove', '--', p]);
|
||||
else if (staged) git(root, ['update-index', '--add', '--cacheinfo', staged.mode, staged.oid, p]);
|
||||
result.restored.push(p);
|
||||
} catch (e) {
|
||||
result.failed.push({ path: p, why: e instanceof Error ? e.message : String(e) });
|
||||
|
||||
+29
-12
@@ -170,10 +170,11 @@ function blockText(content: unknown): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Main-thread events of a Claude Code transcript JSONL. Sidechain (subagent) records are skipped; unparseable
|
||||
* lines (a partial last line) are skipped. Throws UnknownShape when the file is not a transcript this parser knows.
|
||||
* Main-thread events of a Claude Code transcript JSONL. Sidechain (subagent) records are skipped unless
|
||||
* includeSidechain is set (a subagent's own agent-<id>.jsonl holds only sidechain records); unparseable lines (a
|
||||
* partial last line) are skipped. Throws UnknownShape when the file is not a transcript this parser knows.
|
||||
*/
|
||||
export function parseTranscript(text: string): Transcript {
|
||||
export function parseTranscript(text: string, opts: { includeSidechain?: boolean } = {}): Transcript {
|
||||
const events: TranscriptEvent[] = [];
|
||||
let sessionId: string | null = null;
|
||||
let lines = 0;
|
||||
@@ -192,7 +193,7 @@ export function parseTranscript(text: string): Transcript {
|
||||
const content = rec.message.content;
|
||||
if (typeof content !== 'string' && !Array.isArray(content)) throw new UnknownShape(`line ${line}: message content is neither text nor a block list`);
|
||||
messages++;
|
||||
if (rec.isSidechain === true) continue;
|
||||
if (rec.isSidechain === true && !opts.includeSidechain) continue;
|
||||
if (typeof rec.sessionId === 'string') sessionId = rec.sessionId;
|
||||
const blocks = typeof content === 'string' ? [{ type: 'text', text: content }] : content;
|
||||
for (const block of blocks) {
|
||||
@@ -466,11 +467,7 @@ export function checkContract(t: Transcript, skill: string, contract: Contract,
|
||||
const results = new Map<string, ToolResult>();
|
||||
for (const e of run) if (e.kind === 'tool_result') results.set(e.id, e);
|
||||
const waived = load ? waivers(run, skill, results) : new Map<string, string>();
|
||||
const lastPrompt = events.map(e => e.kind).lastIndexOf('prompt');
|
||||
const turn = events.slice(lastPrompt + 1).filter(e => e.kind === 'text' || e.kind === 'tool_use');
|
||||
const final = turn[turn.length - 1];
|
||||
const finalText = [...turn].reverse().find(e => e.kind === 'text') as { text: string } | undefined;
|
||||
const statusLine = finalText ? STATUS_RE.exec(finalText.text)?.[1] ?? null : null;
|
||||
const { statusLine, pendingQuestion, finalText } = turnEnd(t);
|
||||
const exitMarker = new RegExp(`^[ \\t>*_-]*${markerPattern(DEFAULT_MATCHERS.earlyExit).source}`, 'im');
|
||||
const endedEarly = !!finalText && run.includes(finalText as TranscriptEvent) && exitMarker.test(finalText.text);
|
||||
const earlyExit = !load ? null : statusLine === 'BLOCKED' ? 'STATUS: BLOCKED' : endedEarly ? DEFAULT_MATCHERS.earlyExit : null;
|
||||
@@ -481,9 +478,6 @@ export function checkContract(t: Transcript, skill: string, contract: Contract,
|
||||
const reason = waived.get(item.id);
|
||||
return reason ? { id: item.id, kind: item.kind, status: 'waived', detail: r.detail, fix: r.fix, reason } : { id: item.id, kind: item.kind, status: 'missing', detail: r.detail, fix: r.fix };
|
||||
}) : [];
|
||||
const lastParagraph = (finalText?.text ?? '').trim().split(/\n\s*\n/).pop() ?? '';
|
||||
const pendingQuestion = statusLine === 'NEEDS_CONTEXT' || (final !== undefined && isToolUse(final) && /AskUserQuestion$/.test(final.name)) ||
|
||||
(final !== undefined && final.kind === 'text' && /\?[\s*_)"']*$/.test(lastParagraph));
|
||||
const pendingAgents = run.flatMap((e, i) => {
|
||||
if (!isToolUse(e) || !AGENT_TOOLS.test(e.name)) return [];
|
||||
const result = results.get(e.id);
|
||||
@@ -503,6 +497,29 @@ export function checkContract(t: Transcript, skill: string, contract: Contract,
|
||||
};
|
||||
}
|
||||
|
||||
export interface TurnEnd { statusLine: string | null; pendingQuestion: boolean; finalText?: { text: string } }
|
||||
|
||||
/** How the last turn ends: its last text's STATUS line, and whether it waits on the user (NEEDS_CONTEXT, an AskUserQuestion call, or a closing question). */
|
||||
export function turnEnd(t: Transcript): TurnEnd {
|
||||
const lastPrompt = t.events.map(e => e.kind).lastIndexOf('prompt');
|
||||
const turn = t.events.slice(lastPrompt + 1).filter(e => e.kind === 'text' || e.kind === 'tool_use');
|
||||
const final = turn[turn.length - 1];
|
||||
const finalText = [...turn].reverse().find(e => e.kind === 'text') as { text: string } | undefined;
|
||||
const statusLine = finalText ? STATUS_RE.exec(finalText.text)?.[1] ?? null : null;
|
||||
const lastParagraph = (finalText?.text ?? '').trim().split(/\n\s*\n/).pop() ?? '';
|
||||
const pendingQuestion = statusLine === 'NEEDS_CONTEXT' || (final !== undefined && isToolUse(final) && /AskUserQuestion$/.test(final.name)) ||
|
||||
(final !== undefined && final.kind === 'text' && /\?[\s*_)"']*$/.test(lastParagraph));
|
||||
return { statusLine, pendingQuestion, finalText };
|
||||
}
|
||||
|
||||
/** A final turn: a STATUS line other than NEEDS_CONTEXT, a turn that does not wait on the user, or a session no human answers. sessionKind runs only in the last case. */
|
||||
export function isFinalTurn(end: Pick<TurnEnd, 'statusLine' | 'pendingQuestion'>, sessionKind: () => string): boolean {
|
||||
if (end.statusLine && end.statusLine !== 'NEEDS_CONTEXT') return true;
|
||||
if (!end.pendingQuestion) return true;
|
||||
const kind = sessionKind();
|
||||
return kind === 'headless' || kind === 'unattended';
|
||||
}
|
||||
|
||||
/** The one agent-facing block text (gate rev 2, E1). It never names the off switch: that is for humans, in the docs and setup. */
|
||||
export function blockMessage(result: CheckResult, bin: string): string {
|
||||
const fixes = result.items.filter(i => i.status === 'missing').map(i => `${i.id}: ${i.fix}`).join('; ');
|
||||
|
||||
+5
-1
@@ -18,7 +18,7 @@ import { atomicWriteSync } from './fs-atomic';
|
||||
import { resolveStateRoot } from './state-root';
|
||||
import { appendHookTrace, readStdinBounded, type TraceHook } from './skill-route';
|
||||
|
||||
export type GateHook = 'investigate-gate';
|
||||
export type GateHook = 'investigate-gate' | 'ceo-review-gate';
|
||||
export type GateEvent = 'fired' | 'blocked' | 'released' | 'observed' | 'exhausted' | 'skipped' | 'error';
|
||||
export const GATE_DOC = 'docs/skill-gates.md';
|
||||
export const DEADLINE_MS = 5000;
|
||||
@@ -55,6 +55,10 @@ export interface GateRun {
|
||||
fired_at: string;
|
||||
released_at?: string;
|
||||
cause?: string;
|
||||
/** the output file that met the gate (ceo-review-gate) */
|
||||
file?: string;
|
||||
/** the subagent transcript's basename, for a run gated at SubagentStop */
|
||||
agent?: string;
|
||||
blocked: number;
|
||||
observed: number;
|
||||
exhausted_at?: string;
|
||||
|
||||
+4
-2
@@ -28,11 +28,12 @@ const MAX_TRIGGER_CHARS = 200;
|
||||
const TRACE_MAX_BYTES = 1024 * 1024;
|
||||
const FAILURE_SESSIONS_KEPT = 64;
|
||||
|
||||
export type SwitchName = 'skill_routing' | 'skill_contracts' | 'investigate_gate';
|
||||
export type SwitchName = 'skill_routing' | 'skill_contracts' | 'investigate_gate' | 'ceo_review_gate';
|
||||
export const SWITCH_ENV: Record<SwitchName, string> = {
|
||||
skill_routing: 'GSTACK_SKILL_ROUTING',
|
||||
skill_contracts: 'GSTACK_SKILL_CONTRACTS',
|
||||
investigate_gate: 'GSTACK_INVESTIGATE_GATE',
|
||||
ceo_review_gate: 'GSTACK_CEO_REVIEW_GATE',
|
||||
};
|
||||
|
||||
/** Each switch's one default. setup, gstack-config and gstack-skill-route read it through `gstack-skill-route --default <switch>`; the hooks read it here. */
|
||||
@@ -40,6 +41,7 @@ export const SWITCH_DEFAULT: Record<SwitchName, 'on' | 'off'> = {
|
||||
skill_routing: 'off',
|
||||
skill_contracts: 'off',
|
||||
investigate_gate: 'off',
|
||||
ceo_review_gate: 'off',
|
||||
};
|
||||
|
||||
export function normalizeSwitch(raw: string | null | undefined): 'on' | 'off' | null {
|
||||
@@ -280,7 +282,7 @@ export function readStdinBounded(capBytes: number, ms: number, stream: NodeJS.Re
|
||||
});
|
||||
}
|
||||
|
||||
export type TraceHook = 'skill-route' | 'skill-contract' | 'investigate-gate';
|
||||
export type TraceHook = 'skill-route' | 'skill-contract' | 'investigate-gate' | 'ceo-review-gate';
|
||||
|
||||
export function traceLogPath(hook: TraceHook, env: NodeJS.ProcessEnv = process.env): string {
|
||||
return path.join(resolveStateRoot(env), 'hooks', `${hook}.log`);
|
||||
|
||||
+118
-57
@@ -6,6 +6,9 @@
|
||||
* stand-in for the Anthropic Messages API that plays a scripted session, so
|
||||
* no model is called and nothing is spent. Then it checks the GSTACK_GATE
|
||||
* markers in the session record, the hook log and the transcript JSONL.
|
||||
* Two scenarios share the server: the /investigate gate (Edit and sed -i
|
||||
* blocked until a root cause) and the /plan-ceo-review gate (Stop blocked
|
||||
* until the output file ranks the top risks, written through Bash).
|
||||
*
|
||||
* bun scripts/skill-gates-smoke.ts [--claude <path to claude>] [--keep]
|
||||
*
|
||||
@@ -24,11 +27,10 @@ const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const args = process.argv.slice(2);
|
||||
const keep = args.includes('--keep');
|
||||
const work = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gates-smoke-')));
|
||||
const home = path.join(work, 'home');
|
||||
const repo = path.join(work, 'repo');
|
||||
const state = path.join(work, 'state');
|
||||
const SRC = 'export const total = (a: number, b: number) => a - b;\n';
|
||||
const FIXED = 'export const total = (a: number, b: number) => a + b;\n';
|
||||
const PLAN = '# Plan: one-click checkout\n\nShip one-click checkout behind a flag.\n';
|
||||
const FINDINGS = '\n## Findings\n\n1. Payment retries can double-charge when the provider times out.\n2. No rollback path for the new orders schema.\n';
|
||||
|
||||
function run(cmd: string, argv: string[], cwd: string, timeout = 120_000): string {
|
||||
const r = spawnSync(cmd, argv, { cwd, encoding: 'utf8', timeout });
|
||||
@@ -45,19 +47,73 @@ function claudeBin(): string {
|
||||
return path.join(dir, 'node_modules', '.bin', 'claude');
|
||||
}
|
||||
|
||||
const file = (rel: string) => path.join(repo, rel);
|
||||
/** The scripted session: one assistant turn per main-loop request, chosen by how many assistant turns the request already holds. */
|
||||
const SCRIPT: Array<Array<Record<string, unknown>>> = [
|
||||
[{ type: 'text', text: 'Loading /investigate.' }, { type: 'tool_use', id: 'toolu_s1', name: 'Skill', input: { skill: 'investigate' } }],
|
||||
[{ type: 'tool_use', id: 'toolu_r1', name: 'Read', input: { file_path: file('src/total.ts') } }],
|
||||
[{ type: 'tool_use', id: 'toolu_e1', name: 'Edit', input: { file_path: file('src/total.ts'), old_string: 'a - b', new_string: 'a + b' } }],
|
||||
[{ type: 'tool_use', id: 'toolu_b1', name: 'Bash', input: { command: "sed -i 's/a - b/a + b/' src/total.ts", description: 'Patch through the shell' } }],
|
||||
[{ type: 'text', text: 'Root cause: total() subtracts b instead of adding it (src/total.ts:1).' }, { type: 'tool_use', id: 'toolu_r2', name: 'Read', input: { file_path: file('src/total.ts') } }],
|
||||
[{ type: 'tool_use', id: 'toolu_e2', name: 'Edit', input: { file_path: file('src/total.ts'), old_string: 'a - b', new_string: 'a + b' } }],
|
||||
[{ type: 'text', text: 'Fixed total().' }],
|
||||
type Block = Record<string, unknown>;
|
||||
interface Dirs { home: string; repo: string; state: string }
|
||||
interface Seen { code: number; transcript: string; record: Record<string, any> | null; log: string; requests: string; repo: string }
|
||||
interface Scenario { name: string; hook: string; flag: string; skill: string; prompt: string; seed: (repo: string) => void; script: (repo: string) => Block[][]; checks: (s: Seen) => Array<[string, boolean]> }
|
||||
|
||||
const SCENARIOS: Scenario[] = [
|
||||
{
|
||||
name: 'investigate', hook: 'investigate-gate', flag: '--investigate', skill: 'investigate', prompt: 'total() returns the wrong value; investigate and fix it',
|
||||
seed: repo => { fs.mkdirSync(path.join(repo, 'src'), { recursive: true }); fs.writeFileSync(path.join(repo, 'src/total.ts'), SRC); },
|
||||
script: repo => [
|
||||
[{ type: 'text', text: 'Loading /investigate.' }, { type: 'tool_use', id: 'toolu_s1', name: 'Skill', input: { skill: 'investigate' } }],
|
||||
[{ type: 'tool_use', id: 'toolu_r1', name: 'Read', input: { file_path: path.join(repo, 'src/total.ts') } }],
|
||||
[{ type: 'tool_use', id: 'toolu_e1', name: 'Edit', input: { file_path: path.join(repo, 'src/total.ts'), old_string: 'a - b', new_string: 'a + b' } }],
|
||||
[{ type: 'tool_use', id: 'toolu_b1', name: 'Bash', input: { command: "sed -i 's/a - b/a + b/' src/total.ts", description: 'Patch through the shell' } }],
|
||||
[{ type: 'text', text: 'Root cause: total() subtracts b instead of adding it (src/total.ts:1).' }, { type: 'tool_use', id: 'toolu_r2', name: 'Read', input: { file_path: path.join(repo, 'src/total.ts') } }],
|
||||
[{ type: 'tool_use', id: 'toolu_e2', name: 'Edit', input: { file_path: path.join(repo, 'src/total.ts'), old_string: 'a - b', new_string: 'a + b' } }],
|
||||
[{ type: 'text', text: 'Fixed total().' }],
|
||||
],
|
||||
checks: s => {
|
||||
const run0 = s.record ? Object.values(s.record.hooks['investigate-gate']?.runs ?? {})[0] as Record<string, unknown> | undefined : undefined;
|
||||
return [
|
||||
['claude -p exited 0', s.code === 0],
|
||||
['record: one run, fired', !!run0 && typeof run0.fired_at === 'string'],
|
||||
['record: blocked twice (Edit, then Bash sed -i)', run0?.blocked === 2],
|
||||
['record: released after the Root cause line', typeof run0?.released_at === 'string'],
|
||||
['record: no hook errors', (s.record?.hooks['investigate-gate']?.errors ?? []).length === 0],
|
||||
['log: fired, blocked via=edit, blocked via=bash, released', /event=fired/.test(s.log) && /event=blocked .*via=edit/.test(s.log) && /event=blocked .*via=bash/.test(s.log) && /event=released/.test(s.log)],
|
||||
['transcript: deny reason carries the blocked via=edit marker (tool_result)', /"tool_result"[^\n]*GSTACK_GATE investigate-gate blocked [^"]*via=edit/.test(s.transcript)],
|
||||
['transcript: PostToolUse block carries the blocked via=bash marker (hook_blocking_error)', /hook_blocking_error[^\n]*GSTACK_GATE investigate-gate blocked [^"]*via=bash/.test(s.transcript)],
|
||||
['transcript: fired and released ride hook_system_message', /hook_system_message[^\n]*GSTACK_GATE investigate-gate fired/.test(s.transcript) && /hook_system_message[^\n]*GSTACK_GATE investigate-gate released/.test(s.transcript)],
|
||||
['requests: fired/released systemMessage markers never reach the model', !/investigate-gate (fired|released)/.test(s.requests)],
|
||||
['requests: the model saw both block reasons', /investigate-gate blocked [^"]*via=edit/.test(s.requests) && /investigate-gate blocked [^"]*via=bash/.test(s.requests)],
|
||||
['repo: the sed -i write was put back, then the released Edit applied', fs.readFileSync(path.join(s.repo, 'src/total.ts'), 'utf8') === FIXED],
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'plan-ceo-review', hook: 'ceo-review-gate', flag: '--ceo-review', skill: 'plan-ceo-review', prompt: 'review plan.md as a CEO and write the review into it',
|
||||
seed: repo => fs.writeFileSync(path.join(repo, 'README.md'), '# smoke\n'),
|
||||
script: repo => [
|
||||
[{ type: 'text', text: 'Loading /plan-ceo-review.' }, { type: 'tool_use', id: 'toolu_c1', name: 'Skill', input: { skill: 'plan-ceo-review' } }],
|
||||
[{ type: 'tool_use', id: 'toolu_w1', name: 'Write', input: { file_path: path.join(repo, 'plan.md'), content: PLAN } }],
|
||||
[{ type: 'text', text: 'Review complete.' }],
|
||||
[{ type: 'tool_use', id: 'toolu_b2', name: 'Bash', input: { command: `cat >> plan.md <<'EOF'\n${FINDINGS}EOF`, description: 'Append the ranked findings' } }],
|
||||
[{ type: 'text', text: 'Findings added to plan.md.' }],
|
||||
],
|
||||
checks: s => {
|
||||
const h = s.record?.hooks['ceo-review-gate'];
|
||||
const run0 = h ? Object.values(h.runs ?? {})[0] as Record<string, unknown> | undefined : undefined;
|
||||
return [
|
||||
['claude -p exited 0', s.code === 0],
|
||||
['record: one run, fired', !!run0 && typeof run0.fired_at === 'string'],
|
||||
['record: blocked once (Stop with no Findings)', run0?.blocked === 1],
|
||||
['record: released by plan.md after the Bash heredoc', typeof run0?.released_at === 'string' && run0?.file === 'plan.md'],
|
||||
['record: no hook errors', (h?.errors ?? []).length === 0],
|
||||
['log: fired, blocked via=stop files=plan.md, released file=plan.md', /event=fired/.test(s.log) && /event=blocked .*via=stop files=plan\.md n=1/.test(s.log) && /event=released .*file=plan\.md/.test(s.log)],
|
||||
['transcript: the Stop block carries the blocked via=stop marker (hook_blocking_error)', /hook_blocking_error[^\n]*GSTACK_GATE ceo-review-gate blocked [^"]*via=stop/.test(s.transcript)],
|
||||
['transcript: fired and released ride hook_system_message', /hook_system_message[^\n]*GSTACK_GATE ceo-review-gate fired/.test(s.transcript) && /hook_system_message[^\n]*GSTACK_GATE ceo-review-gate released/.test(s.transcript)],
|
||||
['requests: fired/released systemMessage markers never reach the model', !/ceo-review-gate (fired|released)/.test(s.requests)],
|
||||
['requests: the model saw the block reason and where to write', /ceo-review-gate blocked [^"]*via=stop/.test(s.requests) && s.requests.includes("a '## Findings' section ranking the top risks")],
|
||||
['repo: plan.md ends with the ranked Findings', fs.readFileSync(path.join(s.repo, 'plan.md'), 'utf8') === PLAN + FINDINGS],
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function sse(content: Array<Record<string, unknown>>, model: string): string {
|
||||
function sse(content: Block[], model: string): string {
|
||||
const ev = (name: string, data: unknown) => `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
let out = ev('message_start', { type: 'message_start', message: { id: `msg_smoke_${Math.random().toString(36).slice(2)}`, type: 'message', role: 'assistant', model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 1 } } });
|
||||
content.forEach((b, i) => {
|
||||
@@ -71,18 +127,21 @@ function sse(content: Array<Record<string, unknown>>, model: string): string {
|
||||
return out + ev('message_stop', { type: 'message_stop' });
|
||||
}
|
||||
|
||||
async function main(): Promise<number> {
|
||||
const claude = claudeBin();
|
||||
const version = run(claude, ['--version'], work).trim();
|
||||
fs.mkdirSync(path.join(home, '.claude', 'skills', 'investigate'), { recursive: true });
|
||||
fs.copyFileSync(path.join(ROOT, 'investigate', 'SKILL.md'), path.join(home, '.claude', 'skills', 'investigate', 'SKILL.md'));
|
||||
fs.mkdirSync(path.join(repo, 'src'), { recursive: true });
|
||||
fs.writeFileSync(file('src/total.ts'), SRC);
|
||||
run('git', ['init', '-q', '-b', 'main'], repo);
|
||||
run('git', ['add', '-A'], repo);
|
||||
run('git', ['-c', 'user.name=Smoke fixture', '-c', 'user.email=smoke@gstack.test', 'commit', '-q', '-m', 'seed'], repo);
|
||||
const settingsFile = path.join(work, 'settings.json');
|
||||
fs.writeFileSync(settingsFile, run(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(ROOT, 'bin', 'gstack-skill-gates'), 'settings', '--investigate'], work));
|
||||
const readIf = (file: string) => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '');
|
||||
|
||||
/** One scenario: its own HOME, repo and state root, one claude -p run against the scripted server. */
|
||||
async function play(claude: string, sc: Scenario): Promise<[number, number]> {
|
||||
const dirs: Dirs = { home: path.join(work, sc.name, 'home'), repo: path.join(work, sc.name, 'repo'), state: path.join(work, sc.name, 'state') };
|
||||
fs.mkdirSync(path.join(dirs.home, '.claude', 'skills', sc.skill), { recursive: true });
|
||||
fs.copyFileSync(path.join(ROOT, sc.skill, 'SKILL.md'), path.join(dirs.home, '.claude', 'skills', sc.skill, 'SKILL.md'));
|
||||
fs.mkdirSync(dirs.repo, { recursive: true });
|
||||
sc.seed(dirs.repo);
|
||||
run('git', ['init', '-q', '-b', 'main'], dirs.repo);
|
||||
run('git', ['add', '-A'], dirs.repo);
|
||||
run('git', ['-c', 'user.name=Smoke fixture', '-c', 'user.email=smoke@gstack.test', 'commit', '-q', '-m', 'seed'], dirs.repo);
|
||||
const settingsFile = path.join(work, sc.name, 'settings.json');
|
||||
fs.writeFileSync(settingsFile, run(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(ROOT, 'bin', 'gstack-skill-gates'), 'settings', sc.flag], work));
|
||||
const script = sc.script(dirs.repo);
|
||||
|
||||
const requests: string[] = [];
|
||||
const server = Bun.serve({ port: 0, hostname: '127.0.0.1', async fetch(req) {
|
||||
@@ -91,52 +150,54 @@ async function main(): Promise<number> {
|
||||
if (!new URL(req.url).pathname.endsWith('/messages')) return Response.json({ input_tokens: 1 });
|
||||
const body = JSON.parse(raw || '{}');
|
||||
const main = Array.isArray(body.tools) && body.tools.length > 0;
|
||||
const content = main ? (SCRIPT[body.messages.filter((m: { role: string }) => m.role === 'assistant').length] ?? [{ type: 'text', text: 'Done.' }]) : [{ type: 'text', text: 'ok' }];
|
||||
const content = main ? (script[body.messages.filter((m: { role: string }) => m.role === 'assistant').length] ?? [{ type: 'text', text: 'Done.' }]) : [{ type: 'text', text: 'ok' }];
|
||||
if (!body.stream) return Response.json({ id: 'msg_smoke', type: 'message', role: 'assistant', model: body.model, content, stop_reason: 'end_turn', usage: { input_tokens: 1, output_tokens: 1 } });
|
||||
return new Response(sse(content, body.model), { headers: { 'content-type': 'text/event-stream' } });
|
||||
} });
|
||||
|
||||
const env = {
|
||||
PATH: `${path.dirname(process.execPath)}:${process.env.PATH ?? ''}`, HOME: home, GSTACK_STATE_ROOT: state,
|
||||
PATH: `${path.dirname(process.execPath)}:${process.env.PATH ?? ''}`, HOME: dirs.home, GSTACK_STATE_ROOT: dirs.state,
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${server.port}`, ANTHROPIC_API_KEY: 'sk-smoke-not-a-key',
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', DISABLE_AUTOUPDATER: '1',
|
||||
};
|
||||
const proc = Bun.spawn([claude, '-p', 'total() returns the wrong value; investigate and fix it', '--settings', settingsFile, '--permission-mode', 'bypassPermissions', '--output-format', 'json', '--max-turns', '20'], { cwd: repo, env, stdout: 'pipe', stderr: 'pipe' });
|
||||
const proc = Bun.spawn([claude, '-p', sc.prompt, '--settings', settingsFile, '--permission-mode', 'bypassPermissions', '--output-format', 'json', '--max-turns', '20'], { cwd: dirs.repo, env, stdout: 'pipe', stderr: 'pipe' });
|
||||
const killer = setTimeout(() => proc.kill(), 180_000);
|
||||
const [out, err, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
|
||||
clearTimeout(killer);
|
||||
server.stop(true);
|
||||
|
||||
const transcripts = fs.existsSync(path.join(home, '.claude', 'projects')) ? fs.readdirSync(path.join(home, '.claude', 'projects'), { recursive: true }).map(String).filter(f => f.endsWith('.jsonl')) : [];
|
||||
const transcript = transcripts.length ? fs.readFileSync(path.join(home, '.claude', 'projects', transcripts[0]), 'utf8') : '';
|
||||
const records = fs.existsSync(path.join(state, 'skill-gates')) ? fs.readdirSync(path.join(state, 'skill-gates')) : [];
|
||||
const record = records.length ? JSON.parse(fs.readFileSync(path.join(state, 'skill-gates', records[0], 'record.json'), 'utf8')) : null;
|
||||
const run0 = record ? Object.values(record.hooks['investigate-gate']?.runs ?? {})[0] as Record<string, unknown> | undefined : undefined;
|
||||
const log = fs.existsSync(path.join(state, 'hooks', 'investigate-gate.log')) ? fs.readFileSync(path.join(state, 'hooks', 'investigate-gate.log'), 'utf8') : '';
|
||||
const markerLines = [...new Set(transcript.match(/GSTACK_GATE investigate-gate [a-z]+ [^"\\]*/g) ?? [])];
|
||||
const later = requests.join('\n');
|
||||
|
||||
const checks: Array<[string, boolean]> = [
|
||||
['claude -p exited 0', code === 0],
|
||||
['record: one run, fired', !!run0 && typeof run0.fired_at === 'string'],
|
||||
['record: blocked twice (Edit, then Bash sed -i)', run0?.blocked === 2],
|
||||
['record: released after the Root cause line', typeof run0?.released_at === 'string'],
|
||||
['record: no hook errors', (record?.hooks['investigate-gate']?.errors ?? []).length === 0],
|
||||
['log: fired, blocked via=edit, blocked via=bash, released', /event=fired/.test(log) && /event=blocked .*via=edit/.test(log) && /event=blocked .*via=bash/.test(log) && /event=released/.test(log)],
|
||||
['transcript: deny reason carries the blocked via=edit marker (tool_result)', /"tool_result"[^\n]*GSTACK_GATE investigate-gate blocked [^"]*via=edit/.test(transcript)],
|
||||
['transcript: PostToolUse block carries the blocked via=bash marker (hook_blocking_error)', /hook_blocking_error[^\n]*GSTACK_GATE investigate-gate blocked [^"]*via=bash/.test(transcript)],
|
||||
['transcript: fired and released ride hook_system_message', /hook_system_message[^\n]*GSTACK_GATE investigate-gate fired/.test(transcript) && /hook_system_message[^\n]*GSTACK_GATE investigate-gate released/.test(transcript)],
|
||||
['requests: fired/released systemMessage markers never reach the model', !/investigate-gate (fired|released)/.test(later)],
|
||||
['requests: the model saw both block reasons', /investigate-gate blocked [^"]*via=edit/.test(later) && /investigate-gate blocked [^"]*via=bash/.test(later)],
|
||||
['repo: the sed -i write was put back, then the released Edit applied', fs.readFileSync(file('src/total.ts'), 'utf8') === FIXED],
|
||||
];
|
||||
process.stdout.write(`SMOKE: claude ${version} (fake Messages API, ${requests.length} requests, $0)\n`);
|
||||
for (const line of markerLines) process.stdout.write(` transcript marker: ${line}\n`);
|
||||
for (const [name, ok] of checks) process.stdout.write(`${ok ? 'PASS' : 'FAIL'} ${name}\n`);
|
||||
const projects = path.join(dirs.home, '.claude', 'projects');
|
||||
const transcripts = fs.existsSync(projects) ? fs.readdirSync(projects, { recursive: true }).map(String).filter(f => f.endsWith('.jsonl')) : [];
|
||||
const records = fs.existsSync(path.join(dirs.state, 'skill-gates')) ? fs.readdirSync(path.join(dirs.state, 'skill-gates')) : [];
|
||||
const seen: Seen = {
|
||||
code, repo: dirs.repo, requests: requests.join('\n'),
|
||||
transcript: transcripts.length ? readIf(path.join(projects, transcripts[0])) : '',
|
||||
record: records.length ? JSON.parse(readIf(path.join(dirs.state, 'skill-gates', records[0], 'record.json'))) : null,
|
||||
log: readIf(path.join(dirs.state, 'hooks', `${sc.hook}.log`)),
|
||||
};
|
||||
const checks = sc.checks(seen);
|
||||
process.stdout.write(`SCENARIO: ${sc.name} (${requests.length} requests)\n`);
|
||||
for (const line of new Set(seen.transcript.match(new RegExp(`GSTACK_GATE ${sc.hook} [a-z]+ [^"\\\\]*`, 'g')) ?? [])) process.stdout.write(` transcript marker: ${line}\n`);
|
||||
for (const [name, ok] of checks) process.stdout.write(`${ok ? 'PASS' : 'FAIL'} ${sc.name}: ${name}\n`);
|
||||
const failed = checks.filter(([, ok]) => !ok).length;
|
||||
if (failed) process.stdout.write(`claude stdout: ${out.slice(0, 2000)}\nclaude stderr: ${err.slice(0, 2000)}\nwork dir kept: ${work}\n`);
|
||||
if (failed) process.stdout.write(`claude stdout: ${out.slice(0, 2000)}\nclaude stderr: ${err.slice(0, 2000)}\n`);
|
||||
return [failed, checks.length];
|
||||
}
|
||||
|
||||
async function main(): Promise<number> {
|
||||
const claude = claudeBin();
|
||||
const version = run(claude, ['--version'], work).trim();
|
||||
process.stdout.write(`SMOKE: claude ${version} (fake Messages API, $0)\n`);
|
||||
let failed = 0;
|
||||
let total = 0;
|
||||
for (const sc of SCENARIOS) {
|
||||
const [f, n] = await play(claude, sc);
|
||||
failed += f;
|
||||
total += n;
|
||||
}
|
||||
if (failed) process.stdout.write(`work dir kept: ${work}\n`);
|
||||
else if (!keep) fs.rmSync(work, { recursive: true, force: true });
|
||||
process.stdout.write(`SMOKE_RESULT: ${failed ? 'fail' : 'pass'} checks=${checks.length} failed=${failed}\n`);
|
||||
process.stdout.write(`SMOKE_RESULT: ${failed ? 'fail' : 'pass'} scenarios=${SCENARIOS.length} checks=${total} failed=${failed}\n`);
|
||||
return failed ? 1 : 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,10 @@ Claude Code hooks (docs/skill-routing-and-contracts.md):
|
||||
After /investigate loads, block changes to tracked source
|
||||
(Edit, Write and Bash) until a root cause is stated
|
||||
(docs/skill-gates.md). Saved as investigate_gate.
|
||||
--ceo-review-gate / --no-ceo-review-gate
|
||||
After /plan-ceo-review loads, block its final stop (and a
|
||||
review subagent's) until the output file ranks the top
|
||||
risks (docs/skill-gates.md). Saved as ceo_review_gate.
|
||||
|
||||
Model selection:
|
||||
--model <id> Codex model profile override. Otherwise reads Codex config.
|
||||
@@ -198,6 +202,7 @@ TIMELINE_STOP_HOOK_MODE="" # "" = resolve from env/config; "yes"/"no" = explici
|
||||
SKILL_ROUTING_MODE="" # "" = resolve from env/config; "on"/"off" = explicit (GST-1088)
|
||||
SKILL_CONTRACTS_MODE="" # "" = resolve from env/config; "on"/"off" = explicit (GST-1088)
|
||||
INVESTIGATE_GATE_MODE="" # "" = resolve from env/config; "on"/"off" = explicit (GST-1461)
|
||||
CEO_REVIEW_GATE_MODE="" # "" = resolve from env/config; "on"/"off" = explicit (GST-1461)
|
||||
MODEL_OVERRIDE=""
|
||||
MODEL_OVERRIDE_SET=0
|
||||
CLAUDE_MODEL_ARG=""
|
||||
@@ -212,7 +217,7 @@ _setup_unknown_option() {
|
||||
for flag in --host --model --claude-model --loadout --global --local --prefix --no-prefix --team --no-team \
|
||||
--plan-tune-hooks --no-plan-tune-hooks --timeline-stop-hook --no-timeline-stop-hook \
|
||||
--skill-routing --no-skill-routing --skill-contracts --no-skill-contracts \
|
||||
--investigate-gate --no-investigate-gate \
|
||||
--investigate-gate --no-investigate-gate --ceo-review-gate --no-ceo-review-gate \
|
||||
--refresh-registered --status --no-browser --no-harness-auto --quiet --help; do
|
||||
d="$(awk -v a="${bad%%=*}" -v b="$flag" 'BEGIN {
|
||||
n = length(a); m = length(b)
|
||||
@@ -264,6 +269,9 @@ while [ $# -gt 0 ]; do
|
||||
--investigate-gate) INVESTIGATE_GATE_MODE="on"; shift ;;
|
||||
--no-investigate-gate) INVESTIGATE_GATE_MODE="off"; shift ;;
|
||||
--investigate-gate=*) INVESTIGATE_GATE_MODE="${1#--investigate-gate=}"; shift ;;
|
||||
--ceo-review-gate) CEO_REVIEW_GATE_MODE="on"; shift ;;
|
||||
--no-ceo-review-gate) CEO_REVIEW_GATE_MODE="off"; shift ;;
|
||||
--ceo-review-gate=*) CEO_REVIEW_GATE_MODE="${1#--ceo-review-gate=}"; shift ;;
|
||||
--refresh-registered) REFRESH_REGISTERED=1; shift ;;
|
||||
--no-browser) export GSTACK_SKIP_PLAYWRIGHT=1 GSTACK_SKIP_FONTS=1; shift ;;
|
||||
--no-harness-auto) HARNESS_AUTO=0; shift ;;
|
||||
@@ -4977,6 +4985,14 @@ _skill_hook_gate investigate_gate "$INVESTIGATE_GATE_MODE" GSTACK_INVESTIGATE_GA
|
||||
PostToolUse "$INVESTIGATE_GATE_POST" Bash \
|
||||
PostToolUseFailure "$INVESTIGATE_GATE_POST_FAILURE" Bash \
|
||||
Stop "$INVESTIGATE_GATE_STOP" ""
|
||||
# The /plan-ceo-review gate (GST-1461) holds the final stop of a session or a
|
||||
# review subagent: Stop plus SubagentStop, one shim each.
|
||||
CEO_REVIEW_GATE_STOP="$(_hook_command_path hosts/claude/hooks/ceo-review-gate-stop || true)"
|
||||
CEO_REVIEW_GATE_SUBAGENT_STOP="$(_hook_command_path hosts/claude/hooks/ceo-review-gate-subagent-stop || true)"
|
||||
_skill_hook_gate ceo_review_gate "$CEO_REVIEW_GATE_MODE" GSTACK_CEO_REVIEW_GATE Stop \
|
||||
"$CEO_REVIEW_GATE_STOP" gstack-ceo-review-gate 10 \
|
||||
"after /plan-ceo-review loads, its final stop waits for ranked top risks in the output file" "" \
|
||||
SubagentStop "$CEO_REVIEW_GATE_SUBAGENT_STOP" ""
|
||||
|
||||
# Also tear down plan-tune + timeline hooks on --no-team (matches the existing pattern).
|
||||
# Tag-only remove-source misses untagged entries (Claude Code strips
|
||||
|
||||
@@ -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