From 4743132ca027d487fb02bb5f8ce94f2816ff8e7c Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 5 Nov 2026 13:27:04 -0800 Subject: [PATCH] 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> --- bin/gstack-config | 5 +- bin/gstack-settings-hook | 8 +- bin/gstack-skill-gates | 25 +- bin/gstack-skill-route | 6 +- changelog.d/capy-gst-1461-ceo-review-gate.md | 4 + docs/skill-gates.md | 67 +++- docs/skill-routing-and-contracts.md | 2 +- hosts/claude/hooks/ceo-review-gate-stop | 12 + .../hooks/ceo-review-gate-subagent-stop | 12 + hosts/claude/hooks/ceo-review-gate.ts | 113 ++++++ hosts/claude/hooks/investigate-gate.ts | 3 +- .../claude/hooks/skill-contract-stop-hook.ts | 17 +- hosts/claude/hooks/spawn-bin.ts | 6 + lib/ceo-review-gate.ts | 131 +++++++ lib/investigate-gate.ts | 112 +++--- lib/skill-contract.ts | 41 ++- lib/skill-gates.ts | 6 +- lib/skill-route.ts | 6 +- scripts/skill-gates-smoke.ts | 175 +++++++--- setup | 18 +- test/ceo-review-gate.test.ts | 329 ++++++++++++++++++ test/investigate-gate.test.ts | 97 ++++++ test/setup-hook-syntax-gate.test.ts | 2 +- test/setup-skill-hooks-gate.test.ts | 60 +++- test/skill-gates-advisory-bytes.test.ts | 29 +- test/skill-gates.test.ts | 52 ++- 26 files changed, 1166 insertions(+), 172 deletions(-) create mode 100644 changelog.d/capy-gst-1461-ceo-review-gate.md create mode 100755 hosts/claude/hooks/ceo-review-gate-stop create mode 100755 hosts/claude/hooks/ceo-review-gate-subagent-stop create mode 100755 hosts/claude/hooks/ceo-review-gate.ts create mode 100644 lib/ceo-review-gate.ts create mode 100644 test/ceo-review-gate.test.ts diff --git a/bin/gstack-config b/bin/gstack-config index 405af9934..cf0c46e77 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -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 ;; diff --git a/bin/gstack-settings-hook b/bin/gstack-settings-hook index 568acc315..d48292718 100755 --- a/bin/gstack-settings-hook +++ b/bin/gstack-settings-hook @@ -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="" diff --git a/bin/gstack-skill-gates b/bin/gstack-skill-gates index 00df006d7..602e5db6a 100755 --- a/bin/gstack-skill-gates +++ b/bin/gstack-skill-gates @@ -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 \` 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 \` or the Agent * SDK's settings, so a headless run needs no settings.json edit. * gstack-skill-gates report [--session ] [--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//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 ] [--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 ] [--expect armed] [--json]'; const HOOKS: Record }> = { '--investigate': { env: 'GSTACK_INVESTIGATE_GATE', @@ -31,6 +33,14 @@ const HOOKS: Record 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`); } } diff --git a/bin/gstack-skill-route b/bin/gstack-skill-route index cce63f63a..0b5263b2e 100755 --- a/bin/gstack-skill-route +++ b/bin/gstack-skill-route @@ -4,7 +4,7 @@ * * Usage: * gstack-skill-route --explain "" [--json] [--threshold ] - * gstack-skill-route --default + * gstack-skill-route --default * * 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 "" [--json] [--threshold ]\n gstack-skill-route --default '; +const USAGE = 'Usage: gstack-skill-route --explain "" [--json] [--threshold ]\n gstack-skill-route --default '; 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`); diff --git a/changelog.d/capy-gst-1461-ceo-review-gate.md b/changelog.d/capy-gst-1461-ceo-review-gate.md new file mode 100644 index 000000000..14a823a6e --- /dev/null +++ b/changelog.d/capy-gst-1461-ceo-review-gate.md @@ -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. diff --git a/docs/skill-gates.md b/docs/skill-gates.md index 880062495..f3eabda3c 100644 --- a/docs/skill-gates.md +++ b/docs/skill-gates.md @@ -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 "" --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 --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 --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 `, `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 `, `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 && && 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//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//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 (`