feat: opt-in /autoplan stop gate with GSTACK_GATE markers (GST-1666) (#4100)

* test: failing-first cases for the opt-in /autoplan stop gate (GST-1666)

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

* feat: opt-in /autoplan stop gate with GSTACK_GATE markers (GST-1666)

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

* fix: keep the ceo-only section name out of lib/autoplan-stop-gate.ts (carve guard)

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

* fix: pre-landing review fixes

Anchor the runner match to a command position, treat a failed background launch as not pending, and read --ceo-only from a Skill call's command/skill fields and from the prompt before a Read load.

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

* docs: name the /autoplan gate in skill-routing and the smoke section

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:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-07 00:57:41 +00:00
1 parent e4b1e34552
commit ca5dbfc7e0
17 files changed
+736 -36

No files matched your search

+3 -2
View File
@@ -349,6 +349,7 @@ lookup_default() {
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)
autoplan_gate) bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$(dirname "$0")/gstack-skill-route" --default autoplan_gate ;; # on | off — the /autoplan stop gate; 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" ;;
@@ -1145,7 +1146,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 ceo_review_gate; do
skill_routing skill_contracts investigate_gate ceo_review_gate autoplan_gate; do
gstack_config_select "$KEY"
VALUE="$_gstack_cfg_value"
SOURCE="default"
@@ -1171,7 +1172,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 ceo_review_gate; do
skill_routing skill_contracts investigate_gate ceo_review_gate autoplan_gate; do
printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")"
done
;;
+2 -1
View File
@@ -135,7 +135,8 @@ var KNOWN_HOOKS = {
"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" },
"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" }
"ceo-review-gate-subagent-stop": { source: "gstack-ceo-review-gate", event: "SubagentStop", matcher: "", relpath: "hosts/claude/hooks/ceo-review-gate-subagent-stop" },
"autoplan-stop-gate-stop": { source: "gstack-autoplan-gate", event: "Stop", matcher: "", relpath: "hosts/claude/hooks/autoplan-stop-gate-stop" }
};
function gsHadBashPrefix(c) { return String(c == null ? "" : c).trim().indexOf("bash ") === 0; }
function gsStripWrap(c) {
+8 -3
View File
@@ -2,9 +2,9 @@
/**
* gstack-skill-gates — the opt-in enforcement hooks' helper (GST-1461).
*
* gstack-skill-gates settings [--investigate] [--ceo-review]
* gstack-skill-gates settings [--investigate] [--ceo-review] [--autoplan]
* 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
* /investigate gate, the /plan-ceo-review gate, the /autoplan gate) and turns their switches on, for \`claude -p --settings <file>\` or the Agent
* SDK's settings, so a headless run needs no settings.json edit.
* gstack-skill-gates hook-timeout
* Print the hook timeout in seconds (HOOK_TIMEOUT_S) that ./setup registers.
@@ -23,7 +23,7 @@ import { HOOK_TIMEOUT_S, gatesRoot, readGateRecord, recordPath, type GateHook, t
const ROOT = path.resolve(import.meta.dir, '..');
/** Claude Code runs a hook command through a shell: quote the path as gstack-settings-hook's gsQuoteCmd does. */
const shellCommand = (p: string) => (/[\s$`"\\]/.test(p) ? `"${p.replace(/[\\"$`]/g, ch => `\\${ch}`)}"` : p);
const USAGE = 'Usage: gstack-skill-gates settings [--investigate] [--ceo-review]\n gstack-skill-gates hook-timeout\n gstack-skill-gates report [--session <id>] [--expect armed] [--json]';
const USAGE = 'Usage: gstack-skill-gates settings [--investigate] [--ceo-review] [--autoplan]\n gstack-skill-gates hook-timeout\n gstack-skill-gates report [--session <id>] [--expect armed] [--json]';
const HOOKS: Record<string, { env: string; source: GateHook; events: Array<{ event: string; shim: string; matcher?: string }> }> = {
'--investigate': {
env: 'GSTACK_INVESTIGATE_GATE',
@@ -43,6 +43,11 @@ const HOOKS: Record<string, { env: string; source: GateHook; events: Array<{ eve
{ event: 'SubagentStop', shim: 'ceo-review-gate-subagent-stop' },
],
},
'--autoplan': {
env: 'GSTACK_AUTOPLAN_GATE',
source: 'autoplan-gate',
events: [{ event: 'Stop', shim: 'autoplan-stop-gate-stop' }],
},
};
function usage(why: string): never {
+3 -3
View File
@@ -4,7 +4,7 @@
*
* Usage:
* gstack-skill-route --explain "<prompt>" [--json] [--threshold <n>]
* gstack-skill-route --default <skill_routing|skill_contracts|investigate_gate|ceo_review_gate>
* gstack-skill-route --default <skill_routing|skill_contracts|investigate_gate|ceo_review_gate|autoplan_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|ceo_review_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|autoplan_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, investigate_gate or ceo_review_gate\n${USAGE}\n`);
process.stderr.write(`SKILL_ROUTE_USAGE: --default needs skill_routing, skill_contracts, investigate_gate, ceo_review_gate or autoplan_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 /autoplan gate that holds the session until the pipeline finishes (GST-1666).** In GEVA-2's G admission eval, Opus 5.5 told to run /autoplan with no human cost about 20 times a plain session and showed none of autoplan's phases in 23 sessions: nothing kept a session going until every phase was done, so it could stop after any one. With `./setup --autoplan-gate` (or `GSTACK_AUTOPLAN_GATE=on`, or `gstack-config set autoplan_gate on`), a session that loaded /autoplan can't end its final turn until Phase 0 ran scope, every applicable phase published its report (the `Phase N complete` text, then `true autoplan-published <phase>`), and the Final Approval Gate is written. A `--ceo-only` run needs Phase 1 and its `GSTACK_RESULT` end line. The block names the next step. A stop that asks you a question, a stop waiting on a native reviewer still running, and a run driven through the `gstack-autoplan` runner are never held, and after 3 blocks the next stop goes through with an `exhausted` marker. Each decision is a `GSTACK_GATE autoplan-gate` marker, and the session record keeps the run's applicable and published phases, so an eval can read phase exposure from the record instead of the model's wording; `gstack-skill-gates report --expect armed` confirms the gate engaged, and `gstack-skill-gates settings --autoplan` arms it in headless runs. Off by default; autoplan's text and un-gated runs are unchanged. Docs: docs/skill-gates.md#the-autoplan-gate.
+38 -15
View File
@@ -6,26 +6,27 @@ gstack's advisory skills say what a careful agent does; a gate makes one rule ho
|---|---|---|
| /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 |
| /autoplan | `autoplan_gate` | After /autoplan loads, the session's final stop waits until every applicable phase has published its report and the Final Approval Gate is written (GST-1666) |
## 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`), 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.
1. The `./setup` flag: `--investigate-gate` / `--no-investigate-gate` (also `--investigate-gate=on|off`), `--ceo-review-gate` / `--no-ceo-review-gate` (also `--ceo-review-gate=on|off`), or `--autoplan-gate` / `--no-autoplan-gate` (also `--autoplan-gate=on|off`). A flag is saved to config.
2. The environment: `GSTACK_INVESTIGATE_GATE=on|off`, `GSTACK_CEO_REVIEW_GATE=on|off` or `GSTACK_AUTOPLAN_GATE=on|off`. Not saved.
3. Config: `gstack-config set investigate_gate on|off`, `gstack-config set ceo_review_gate on|off` or `gstack-config set autoplan_gate on|off`.
4. The default, `SWITCH_DEFAULT` in `lib/skill-route.ts`: off for all three.
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.
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; Stop for /autoplan) 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 --ceo-review > gates.json
gstack-skill-gates settings --investigate --ceo-review --autoplan > gates.json
claude -p "<task>" --settings gates.json
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)).
Name any of the gates. The JSON registers their hooks from this checkout and sets each named gate's switch (`GSTACK_INVESTIGATE_GATE=on`, `GSTACK_CEO_REVIEW_GATE=on`, `GSTACK_AUTOPLAN_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
@@ -74,6 +75,28 @@ Bash commands are never parsed: a review written with a heredoc or `tee` is foun
**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.
## The /autoplan gate
Opus 5.5 told to run /autoplan in a one-shot session with no human spent about 20 times a plain session and still showed none of autoplan's phases in an eval (GEVA-2's G admission, 0 of 23): nothing held the session until the pipeline finished, and a session could stop after any phase. Autoplan's own publication guard (`autoplan/bin/phase-publication-hook`) only denies entering a phase before the previous one published; this gate holds the stop. It never changes autoplan's text or what an un-gated run does.
Once a session loads /autoplan (the Skill tool, a typed `/autoplan`, or a Read of its installed SKILL.md), its final stop is held until the run is complete. Final stops are judged as for /plan-ceo-review: a stop that asks the user in an interactive session is never held, and in a session no human answers it counts as final.
**What meets it.** One run is the transcript from the load to the next /autoplan load. A full run is complete when:
1. Phase 0 ran scope: a successful `gstack-autoplan-snapshot init` (its `scope` field) or `scope` result with `uiRequired` and `dxRequired`. It decides the applicable phases: CEO and Eng always, Design when `uiRequired`, DX when `dxRequired`.
2. Every applicable phase published, as `sections/phase-close.md` step 6 says: assistant text with its `Phase N complete` line, then a successful Bash no-op `true autoplan-published <phase>`. A no-op with no report since the previous one, or a failed one, does not count.
3. The Phase 4 Final Approval Gate is written: a successful `gstack-gate render` call, or a Write or Edit of a `gate.json`.
A `--ceo-only` run needs Phase 1 published and the `GSTACK_RESULT: skill=autoplan status=reviewed` end line from `sections/ceo-only.md`.
Claude Code can fire Stop before it writes the turn's last lines to the transcript (the smoke run saw a scope call and a phase publication land about 200 ms late), so the hook re-reads the transcript until it holds the stop's `last_assistant_message`, for at most 2 seconds or half its deadline, and judges what is there then.
**What it never holds.** A stop while a native reviewer launched in the background has not reported back (autoplan's "Waiting for <agent ID>" barrier); the next final stop after the notification is checked. A run that drives the `gstack-autoplan` runner (`next`, `submit`, `answer` or `export`), which keeps its own record (docs/unattended.md): it gets one `skipped reason=runner` marker and is never blocked.
**The block.** Its reason starts with the `blocked via=stop missing=<step> published=<phases>` marker and names the next step: run Phase 0 (`missing=scope`), continue at a phase's driver Read when it has not started, finish a started phase's close and publish its report (`missing=ceo|design|dx|eng`), write and render the gate (`missing=gate`), or end a `--ceo-only` run (`missing=end`). It lists the applicable and published phases and never names the off switch.
**The cap and the record.** As for /plan-ceo-review: at most 3 blocks per run, then the next final stop goes through with `exhausted missing=<step>`; `stop_hook_active` does not skip the check. The session record keeps each run's `phases` and `published` as of its last final stop, so `gstack-skill-gates report --session <id> --expect armed --json` says per session whether /autoplan armed, how often it was held, and whether it was released or exhausted. An eval can read exposure from that record instead of from the model's wording.
## Markers
Every decision is one marker line:
@@ -82,18 +105,18 @@ Every decision is one marker line:
| Event | When | Fields |
|---|---|---|
| `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` |
| `fired` | the gate armed for a skill run (once per run; for /plan-ceo-review and /autoplan, 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 /plan-ceo-review's `via=stop`, the candidate files checked, or `none`), `missing` and `published` (/autoplan: the next missing step and the phases published so far), `n` (blocks so far in this run) |
| `released` | the root cause was stated, an output file ranks the top risks, or the /autoplan run is complete (once per run) | `skill`, `cause` (first 12 hex of the cause's sha256), `file` (the output file's name) or `phases` (the phases /autoplan published), `blocked` |
| `exhausted` | /plan-ceo-review and /autoplan: a fourth final stop after 3 blocks, which goes through | `skill`, `missing` (/autoplan), `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` |
| `skipped` | the gate could not arm (once per session and reason), or an /autoplan run drives the gstack-autoplan runner (once per run) | `skill`, `reason=no-git|no-head|runner` |
| `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` 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.
- **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), `phases` and `published` (/autoplan), plus the gate's `errors` and `skipped` reasons. `gstack-skill-gates report` reads these.
- **Trace log**: `$GSTACK_STATE_ROOT/hooks/investigate-gate.log`, `hooks/ceo-review-gate.log` and `hooks/autoplan-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.
@@ -137,4 +160,4 @@ Tested with Claude Code 2.1.318 through `claude -p --settings` only: a Stop hook
## 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 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.
`bun scripts/skill-gates-smoke.ts` runs the real Claude Code CLI against a local stand-in for the Anthropic Messages API that plays three 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). The third loads /autoplan, runs Phase 0 scope, publishes Phase 1 and ends its turn (the Stop is blocked with `missing=eng`), then publishes Phase 3, writes `gate.json` 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 -1
View File
@@ -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 and /plan-ceo-review gates) 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, /plan-ceo-review and /autoplan 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.
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Bash shim for the /autoplan gate's Stop hook (GST-1666): Claude Code runs
# hook commands through /bin/sh, so this wrapper runs
# hosts/claude/hooks/autoplan-stop-gate.ts with bun. Registered by
# ./setup --autoplan-gate for Stop.
#
# 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/autoplan-stop-gate.ts" || true
exit 0
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* autoplan-stop-gate — opt-in enforcement for /autoplan (GST-1666). Off unless
* autoplan_gate is on (env GSTACK_AUTOPLAN_GATE, then config, then
* SWITCH_DEFAULT). Registered through one shim: autoplan-stop-gate-stop (Stop).
*
* Stop: payload transcript_path (+ last_assistant_message), re-read until it
* holds that final message (at most 2 s; Stop can beat the CLI's flush)
* -> the last /autoplan load (none: nothing)
* -> a final turn? (lib/skill-contract.ts isFinalTurn; a question to a
* human in an interactive session is never blocked)
* -> a native reviewer launched in the background has not reported: nothing
* -> released, exhausted or skipped for this run? nothing
* -> a run that drives the gstack-autoplan runner: skipped reason=runner
* -> first final stop of the run: marker fired
* -> every applicable phase published and the gate written (lib/autoplan-stop-gate.ts): released
* -> 3 blocks already: exhausted, the stop goes through
* -> otherwise blocked via=stop missing=<step>, the reason names the next step
*
* Markers are counted in the session record (with the run's phases and
* publications) and traced in $GSTACK_STATE_ROOT/hooks/autoplan-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 { sessionKind } from './spawn-bin';
import { SKILL, checkRun, missingStep } from '../../../lib/autoplan-stop-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 = 'autoplan-gate';
const MAX_BLOCKS = 3;
const SESSION_KIND_TIMEOUT_MS = 1000;
const SETTLE_MS = 2000;
const SETTLE_POLL_MS = 50;
/**
* The transcript once it holds the turn's final message. Claude Code can run
* Stop before it flushes the turn's last lines (the smoke run saw the scope
* call and a phase publication land about 200 ms after Stop fired), so a
* stale read would block a finished run. Re-read until the final text is
* there, for at most SETTLE_MS or half the deadline, then judge what exists.
*/
function settledTranscript(p: GatePayload) {
const until = Date.now() + Math.min(SETTLE_MS, remainingMs() / 2);
for (;;) {
const t = parseTranscript(readTranscriptFile(p.transcriptPath));
const settled = withFinalMessage(t, p.raw.last_assistant_message);
if (settled === t || Date.now() >= until) return settled;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, SETTLE_POLL_MS);
}
}
function handle(p: GatePayload): HookOutput | undefined {
if (p.event && p.event !== 'Stop') return undefined;
let t;
try {
t = settledTranscript(p);
} 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 (!isFinalTurn(turnEnd(t), () => sessionKind(Math.min(SESSION_KIND_TIMEOUT_MS, remainingMs())))) return undefined;
const check = checkRun(t, load);
if (check.pendingAgents.length) return undefined;
const key = `${SKILL}@${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);
const save = () => writeGateRecord(recFile, rec);
if (check.mode === 'runner') {
if (h.skipped[key]) return undefined;
h.skipped[key] = new Date().toISOString();
save();
return systemMessage([note(HOOK, 'skipped', p.sessionId, { skill: SKILL, reason: 'runner' })]);
}
let run = h.runs[key];
if (run?.released_at || run?.exhausted_at) return undefined;
const sys: string[] = [];
if (!run) {
run = h.runs[key] = { skill: SKILL, load_line: load.line, fired_at: new Date().toISOString(), blocked: 0, observed: 0 };
sys.push(note(HOOK, 'fired', p.sessionId, { skill: SKILL, on: p.event || 'Stop' }));
pruneSessionDirs(dir);
}
run.phases = check.phases ?? undefined;
run.published = check.published;
if (!check.missing) {
run.released_at = new Date().toISOString();
save();
sys.push(note(HOOK, 'released', p.sessionId, { skill: SKILL, phases: check.published.join(','), 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, missing: check.missing, blocked: run.blocked }));
return systemMessage(sys);
}
run.blocked++;
save();
const marker = note(HOOK, 'blocked', p.sessionId, { skill: SKILL, via: 'stop', missing: check.missing, published: check.published.join(',') || 'none', n: run.blocked });
const body = [
missingStep(check),
`Applicable phases: ${check.phases?.join(', ') ?? 'unknown until Phase 0 runs scope'}. Published so far: ${check.published.join(', ') || 'none'}.`,
'Phase notifications are progress updates: keep going in this turn instead of stopping.',
].join('\n');
return blockWith(`${marker}\n${body}`, sys);
});
}
runGateHook(HOOK, () => readSkillSwitch('autoplan_gate').on, handle).finally(() => process.exit(0));
+150
View File
@@ -0,0 +1,150 @@
/**
* autoplan-stop-gate — the run check behind the opt-in /autoplan stop gate
* (GST-1666). Docs: docs/skill-gates.md.
*
* One /autoplan run is the transcript from its load to the next /autoplan load.
* A full run is met when Phase 0 ran scope (a snapshot init or scope result),
* every phase that scope makes applicable (CEO and Eng always, Design when
* uiRequired, DX when dxRequired) published its report, and the Phase 4 Final
* Approval Gate is written. A phase publishes as phase-close.md step 6 says:
* assistant text with its "Phase N complete" line, then a successful Bash no-op
* "true autoplan-published <phase>". The gate is a successful gstack-gate render
* or a Write of a gate.json. A --ceo-only run is met when Phase 1 published and
* its text holds the "GSTACK_RESULT: skill=autoplan status=reviewed" end line.
* A run that drives the gstack-autoplan runner is mode runner: the runner keeps
* its own record, so the gate leaves it alone.
*/
import type { SkillLoad, Transcript, TranscriptEvent } from './skill-contract';
import { sameSkill, skillLoads } from './skill-contract';
export const SKILL = 'autoplan';
export type Phase = 'ceo' | 'design' | 'dx' | 'eng';
export const PHASE_ORDER: readonly Phase[] = ['ceo', 'design', 'dx', 'eng'];
export const PHASE_NUMBER: Record<Phase, string> = { ceo: '1', design: '2', dx: '2.5', eng: '3' };
export const PHASE_NAME: Record<Phase, string> = { ceo: 'CEO', design: 'Design', dx: 'DX', eng: 'Eng' };
/** The next step a run is missing: Phase 0 scope, a phase, the Final Approval Gate, or the --ceo-only end line. */
export type Missing = 'scope' | Phase | 'gate' | 'end';
const SNAPSHOT = /gstack-autoplan-snapshot(?:\.ts)?["']?\s+(init|scope)(?:\s|$)/;
const PUBLISHED = /^\s*true\s+autoplan-published\s+(ceo|design|dx|eng)\s*$/;
const PHASE_READ = /(?:^|[\\/])autoplan[\\/]sections[\\/](ceo|design|dx|eng)-phase\.md$/;
const GATE_RENDER = /gstack-gate["']?\s+render(?:\s|$)/;
const GATE_FILE = /(?:^|[\\/])gate\.json$/;
const RUNNER = /(?:^|[;&|(\n])\s*(?:\w+=\S*\s+)*(?:bun\s+(?:-\S+\s+)*)?["']?(?:[^\s"';&|]*[\\/])?gstack-autoplan(?:\.ts)?["']?\s+(?:next|submit|answer|export)(?:\s|$)/;
const CEO_ONLY_END = /GSTACK_RESULT:\s*skill=autoplan\s+status=reviewed\b/;
const AGENT_TOOLS = /^(?:Agent|Task)$/;
export interface RunCheck {
mode: 'full' | 'ceo-only' | 'runner';
/** the applicable phases in order, or null when Phase 0 has not run scope */
phases: Phase[] | null;
/** phases whose driver section was Read */
entered: Phase[];
/** phases that published, in the order they did */
published: Phase[];
gate: boolean;
missing: Missing | null;
/** Agent or Task calls launched in the background that have not reported back */
pendingAgents: string[];
}
type ToolUse = Extract<TranscriptEvent, { kind: 'tool_use' }>;
type ToolResult = Extract<TranscriptEvent, { kind: 'tool_result' }>;
function jsonObject(text: string): Record<string, any> | null {
for (const candidate of [text.trim(), ...text.split('\n').map(l => l.trim()).filter(l => l.startsWith('{')).reverse()]) {
try {
const v = JSON.parse(candidate);
if (v && typeof v === 'object' && !Array.isArray(v)) return v;
} catch { /* not this one */ }
}
return null;
}
function scopePhases(scope: unknown): Phase[] | null {
if (!scope || typeof scope !== 'object') return null;
const { uiRequired, dxRequired } = scope as Record<string, unknown>;
if (typeof uiRequired !== 'boolean' || typeof dxRequired !== 'boolean') return null;
return PHASE_ORDER.filter(p => p === 'ceo' || p === 'eng' || (p === 'design' && uiRequired) || (p === 'dx' && dxRequired));
}
function isCeoOnly(load: SkillLoad, events: TranscriptEvent[]): boolean {
const e = events[load.at];
if (e?.kind === 'prompt') return /<command-args>[^<]*--ceo-only\b/.test(e.text);
if (e?.kind === 'tool_use' && [e.input.args, e.input.command, e.input.skill].some(v => /--ceo-only\b/.test(String(v ?? '')))) return true;
const asked = events.slice(0, load.at).findLast(p => p.kind === 'prompt');
return asked?.kind === 'prompt' && /--ceo-only\b/.test(asked.text);
}
/** Check one /autoplan run: what it did, and the first step it is missing. */
export function checkRun(t: Transcript, load: SkillLoad): RunCheck {
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 results = new Map<string, ToolResult>();
for (const e of run) if (e.kind === 'tool_result') results.set(e.id, e);
const ok = (u: ToolUse) => { const r = results.get(u.id); return !!r && !r.isError && !r.text.includes('<tool_use_error>'); };
const uses = run.filter((e): e is ToolUse => e.kind === 'tool_use');
const bash = uses.filter(u => u.name === 'Bash' && typeof u.input.command === 'string' && ok(u));
const mode: RunCheck['mode'] = bash.some(u => RUNNER.test(u.input.command)) ? 'runner' : isCeoOnly(load, t.events) ? 'ceo-only' : 'full';
let phases: Phase[] | null = null;
for (const u of bash) {
const m = SNAPSHOT.exec(u.input.command);
if (!m) continue;
const out = jsonObject(results.get(u.id)!.text);
phases = scopePhases(m[1] === 'init' ? out?.scope : out) ?? phases;
}
if (mode === 'ceo-only') phases = ['ceo'];
const entered = [...new Set(uses.flatMap(u => (u.name === 'Read' && ok(u) ? [PHASE_READ.exec(String(u.input.file_path ?? ''))?.[1] as Phase | undefined] : []).filter((p): p is Phase => !!p)))];
const published: Phase[] = [];
let from = 0;
for (const u of bash) {
const m = PUBLISHED.exec(u.input.command);
if (!m) continue;
const p = m[1] as Phase;
const at = run.indexOf(u);
const said = new RegExp(`Phase\\s+${PHASE_NUMBER[p].replace('.', '\\.')}(?![\\d.])[^\\n]*?\\bcomplete\\b`, 'i');
if (run.slice(from, at).some(e => e.kind === 'text' && said.test(e.text)) && !published.includes(p)) published.push(p);
from = at + 1;
}
const gate = uses.some(u => ok(u) && ((u.name === 'Bash' && typeof u.input.command === 'string' && GATE_RENDER.test(u.input.command)) ||
((u.name === 'Write' || u.name === 'Edit') && GATE_FILE.test(String(u.input.file_path ?? '')))));
const pendingAgents = uses.flatMap(u => {
if (!AGENT_TOOLS.test(u.name)) return [];
const r = results.get(u.id);
if (!r) return [u.id];
if (!ok(u)) return [];
if (u.input.run_in_background !== true && !/async_launched|"?isAsync"?\s*:\s*true|async agent launched/i.test(r.text)) return [];
const ids = [u.id, ...[...r.text.matchAll(/\b(?:agent|task)[_ -]?id["']?\s*[:=]\s*["']?([\w-]+)/gi)].map(m => m[1])];
const notified = run.slice(run.indexOf(r) + 1).some(e => e.kind === 'meta' && ids.some(id => e.text.includes(id)));
return notified ? [] : [u.id];
});
let missing: Missing | null = null;
if (mode !== 'runner') {
if (!phases) missing = 'scope';
else missing = phases.find(p => !published.includes(p)) ?? null;
if (!missing && mode === 'full' && !gate) missing = 'gate';
if (!missing && mode === 'ceo-only' && !run.some(e => e.kind === 'text' && CEO_ONLY_END.test(e.text))) missing = 'end';
}
return { mode, phases, entered, published, gate, missing, pendingAgents };
}
/** The block text for a run's missing step: what is not done and the next thing to do. */
export function missingStep(c: RunCheck): string {
const m = c.missing;
if (m === 'scope') return '/autoplan is not done: Phase 0 has not run scope, so no review phase has started. Run Phase 0 now (bun "<SNAPSHOT_TOOL>" init ...), then run every applicable phase in order in this turn.';
if (m === 'gate') return '/autoplan is not done: every applicable phase published, but the Phase 4 Final Approval Gate is not written. Read autoplan/sections/tasks-aggregator.md, write $_AP/gate.json and run gstack-gate render on it, then present the gate.';
if (m === 'end') return '/autoplan --ceo-only is not done: Phase 1 published, but the run has not finished. Write $_AP/ceo-review.md as the --ceo-only section says and end with: GSTACK_RESULT: skill=autoplan status=reviewed run=<absolute $_AP>';
if (!m) return '';
const label = `Phase ${PHASE_NUMBER[m]} (${PHASE_NAME[m]})`;
if (c.entered.includes(m)) {
return `/autoplan is not done: ${label} has not published its report. Finish its close procedure (autoplan/sections/phase-close.md), then send its report starting "**Phase ${PHASE_NUMBER[m]} complete.**" in a message whose only tool call is the Bash no-op: true autoplan-published ${m}`;
}
return `/autoplan is not done: ${label} has not run. Continue the pipeline at its driver Read (autoplan/sections/${m}-phase.md) and run it through its close in this turn.`;
}
+4 -1
View File
@@ -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' | 'ceo-review-gate';
export type GateHook = 'investigate-gate' | 'ceo-review-gate' | 'autoplan-gate';
export type GateEvent = 'fired' | 'blocked' | 'released' | 'observed' | 'exhausted' | 'skipped' | 'error';
export const GATE_DOC = 'docs/skill-gates.md';
export const DEADLINE_MS = 5000;
@@ -76,6 +76,9 @@ export interface GateRun {
file?: string;
/** the subagent transcript's basename, for a run gated at SubagentStop */
agent?: string;
/** the run's applicable phases and the phases it published (autoplan-gate) */
phases?: string[];
published?: string[];
blocked: number;
observed: number;
exhausted_at?: string;
+4 -2
View File
@@ -28,12 +28,13 @@ 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' | 'ceo_review_gate';
export type SwitchName = 'skill_routing' | 'skill_contracts' | 'investigate_gate' | 'ceo_review_gate' | 'autoplan_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',
autoplan_gate: 'GSTACK_AUTOPLAN_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. */
@@ -42,6 +43,7 @@ export const SWITCH_DEFAULT: Record<SwitchName, 'on' | 'off'> = {
skill_contracts: 'off',
investigate_gate: 'off',
ceo_review_gate: 'off',
autoplan_gate: 'off',
};
export function normalizeSwitch(raw: string | null | undefined): 'on' | 'off' | null {
@@ -282,7 +284,7 @@ export function readStdinBounded(capBytes: number, ms: number, stream: NodeJS.Re
});
}
export type TraceHook = 'skill-route' | 'skill-contract' | 'investigate-gate' | 'ceo-review-gate';
export type TraceHook = 'skill-route' | 'skill-contract' | 'investigate-gate' | 'ceo-review-gate' | 'autoplan-gate';
export function traceLogPath(hook: TraceHook, env: NodeJS.ProcessEnv = process.env): string {
return path.join(resolveStateRoot(env), 'hooks', `${hook}.log`);
+35 -3
View File
@@ -6,9 +6,11 @@
* 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).
* Three scenarios share the server: the /investigate gate (Edit and sed -i
* blocked until a root cause), the /plan-ceo-review gate (Stop blocked
* until the output file ranks the top risks, written through Bash) and the
* /autoplan gate (Stop blocked after Phase 1 until Eng publishes and the
* Final Approval Gate is written).
*
* bun scripts/skill-gates-smoke.ts [--claude <path to claude>] [--keep]
*
@@ -30,6 +32,7 @@ const work = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gates
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 AP_PLAN = '# Plan: retry the fetch helper\n\n## Implementation plan\n\nAdd one retry with backoff to src/fetch.ts.\n\n## Review record\n\nNone yet.\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 {
@@ -111,6 +114,35 @@ const SCENARIOS: Scenario[] = [
];
},
},
{
name: 'autoplan', hook: 'autoplan-gate', flag: '--autoplan', skill: 'autoplan', prompt: 'run /autoplan on PLAN.md',
seed: repo => fs.writeFileSync(path.join(repo, 'PLAN.md'), AP_PLAN),
script: repo => [
[{ type: 'text', text: 'Loading /autoplan.' }, { type: 'tool_use', id: 'toolu_a1', name: 'Skill', input: { skill: 'autoplan' } }],
[{ type: 'tool_use', id: 'toolu_a2', name: 'Bash', input: { command: `bun --no-env-file --config=/dev/null "${path.join(ROOT, 'bin', 'gstack-autoplan-snapshot.ts')}" scope PLAN.md`, description: 'Phase 0 scope' } }],
[{ type: 'text', text: '**Phase 1 complete.**\nOutside review: unavailable. Native subagent: completed: 2 issues.\nPassing to Phase 3.' }, { type: 'tool_use', id: 'toolu_a3', name: 'Bash', input: { command: 'true autoplan-published ceo', description: 'Publish Phase 1' } }],
[{ type: 'text', text: 'Review complete.' }],
[{ type: 'text', text: '**Phase 3 complete.**\nOutside review: unavailable. Native subagent: completed: 1 issue.\nPassing to the Final Approval Gate.' }, { type: 'tool_use', id: 'toolu_a4', name: 'Bash', input: { command: 'true autoplan-published eng', description: 'Publish Phase 3' } }],
[{ type: 'tool_use', id: 'toolu_a5', name: 'Write', input: { file_path: path.join(repo, '.gstack', 'tmp', 'autoplan', 'gate.json'), content: '{"items":[]}\n' } }],
[{ type: 'text', text: 'The Final Approval Gate is ready.' }],
],
checks: s => {
const h = s.record?.hooks['autoplan-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 after Phase 1)', run0?.blocked === 1],
['record: released with phases ceo,eng published', typeof run0?.released_at === 'string' && JSON.stringify(run0?.published) === '["ceo","eng"]' && JSON.stringify(run0?.phases) === '["ceo","eng"]'],
['record: no hook errors', (h?.errors ?? []).length === 0],
['log: fired, blocked missing=eng published=ceo, released phases=ceo,eng', /event=fired/.test(s.log) && /event=blocked .*via=stop missing=eng published=ceo n=1/.test(s.log) && /event=released .*phases=ceo,eng/.test(s.log)],
['transcript: the Stop block carries the blocked via=stop marker (hook_blocking_error)', /hook_blocking_error[^\n]*GSTACK_GATE autoplan-gate blocked [^"]*via=stop/.test(s.transcript)],
['transcript: fired and released ride hook_system_message', /hook_system_message[^\n]*GSTACK_GATE autoplan-gate fired/.test(s.transcript) && /hook_system_message[^\n]*GSTACK_GATE autoplan-gate released/.test(s.transcript)],
['requests: fired/released systemMessage markers never reach the model', !/autoplan-gate (fired|released)/.test(s.requests)],
['requests: the model saw the block reason naming Phase 3', /autoplan-gate blocked [^"]*missing=eng/.test(s.requests) && s.requests.includes('Phase 3 (Eng) has not run')],
];
},
},
];
function sse(content: Block[], model: string): string {
+15 -1
View File
@@ -68,6 +68,10 @@ Claude Code hooks (docs/skill-routing-and-contracts.md):
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.
--autoplan-gate / --no-autoplan-gate
After /autoplan loads, block its final stop until every
applicable phase published and the Final Approval Gate is
written (docs/skill-gates.md). Saved as autoplan_gate.
Model selection:
--model <id> Codex model profile override. Otherwise reads Codex config.
@@ -203,6 +207,7 @@ SKILL_ROUTING_MODE="" # "" = resolve from env/config; "on"/"off" = explici
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)
AUTOPLAN_GATE_MODE="" # "" = resolve from env/config; "on"/"off" = explicit (GST-1666)
MODEL_OVERRIDE=""
MODEL_OVERRIDE_SET=0
CLAUDE_MODEL_ARG=""
@@ -217,7 +222,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 --ceo-review-gate --no-ceo-review-gate \
--investigate-gate --no-investigate-gate --ceo-review-gate --no-ceo-review-gate --autoplan-gate --no-autoplan-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)
@@ -272,6 +277,9 @@ while [ $# -gt 0 ]; do
--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 ;;
--autoplan-gate) AUTOPLAN_GATE_MODE="on"; shift ;;
--no-autoplan-gate) AUTOPLAN_GATE_MODE="off"; shift ;;
--autoplan-gate=*) AUTOPLAN_GATE_MODE="${1#--autoplan-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 ;;
@@ -5011,6 +5019,12 @@ _skill_hook_gate ceo_review_gate "$CEO_REVIEW_GATE_MODE" GSTACK_CEO_REVIEW_GATE
"$CEO_REVIEW_GATE_STOP" gstack-ceo-review-gate "$SKILL_GATE_HOOK_TIMEOUT" \
"after /plan-ceo-review loads, its final stop waits for ranked top risks in the output file" "" \
SubagentStop "$CEO_REVIEW_GATE_SUBAGENT_STOP" ""
# The /autoplan gate (GST-1666) holds the final stop of the session that
# loaded /autoplan: Stop only, one shim.
AUTOPLAN_GATE_STOP="$(_hook_command_path hosts/claude/hooks/autoplan-stop-gate-stop || true)"
_skill_hook_gate autoplan_gate "$AUTOPLAN_GATE_MODE" GSTACK_AUTOPLAN_GATE Stop \
"$AUTOPLAN_GATE_STOP" gstack-autoplan-gate "$SKILL_GATE_HOOK_TIMEOUT" \
"after /autoplan loads, its final stop waits until every applicable phase published and the gate is written" ""
# Also tear down plan-tune + timeline hooks on --no-team (matches the existing pattern).
# Tag-only remove-source misses untagged entries (Claude Code strips
+278
View File
@@ -0,0 +1,278 @@
/**
* autoplan-stop-gate (GST-1666): with the autoplan_gate switch on, a session
* that loaded /autoplan cannot end its final turn until every phase its scope
* makes applicable has published its report (the "**Phase N complete.**" text
* followed by the Bash no-op "true autoplan-published <phase>") and the Phase 4
* Final Approval Gate is written. A --ceo-only run needs Phase 1 and its
* GSTACK_RESULT end line instead. A stop that asks the user, or that waits on a
* native reviewer still running, is never held; a run driven through the
* gstack-autoplan runner is skipped. 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 payloads against throwaway transcripts.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { checkRun } from '../lib/autoplan-stop-gate';
import { bunIsolationArgs } from '../lib/bun-isolation';
import { parseTranscript, skillLoads } from '../lib/skill-contract';
import { ROOT, hookEnv, meta, result, runHook, say, skillsDir, slash, tmpDir, tool, user } from './helpers/skill-contract-fixture';
const HOOK = path.join(ROOT, 'hosts', 'claude', 'hooks', 'autoplan-stop-gate.ts');
const SKILLS = '/root/.claude/skills/gstack';
const SNAP = path.join(SKILLS, 'bin', 'gstack-autoplan-snapshot.ts');
const PLAN = '/work/repo/PLAN.md';
const NUMBER = { ceo: '1', design: '2', dx: '2.5', eng: '3' } as const;
type Phase = keyof typeof NUMBER;
let state = '', skills = '', work = '';
beforeEach(() => {
state = tmpDir('gstack-apgate-state-');
work = tmpDir('gstack-apgate-work-');
skills = skillsDir([]);
});
afterEach(() => {
for (const d of [state, skills, work]) fs.rmSync(d, { recursive: true, force: true });
});
const LOADED = [slash('autoplan', 'PLAN.md')];
const init = (scope: { uiRequired?: boolean; dxRequired?: boolean } = {}) => [
tool('i1', 'Bash', { command: `bun "${SNAP}" init "${PLAN}" "${PLAN}" "/work/repo/.gstack/tmp/autoplan/restore.md"` }),
result('i1', JSON.stringify({ sourcePlan: PLAN, activePlan: PLAN, scope: { activePlan: PLAN, uiRequired: false, dxRequired: false, ...scope } })),
];
const enter = (phase: Phase) => [
tool(`e-${phase}`, 'Read', { file_path: `${SKILLS}/autoplan/sections/${phase}-phase.md` }),
result(`e-${phase}`, '# phase driver'),
];
const report = (phase: Phase) => say(`**Phase ${NUMBER[phase]} complete.**\nOutside review: unavailable. Native subagent: completed: 3 issues.\nConsensus: N/A (voice coverage missing).\nPassing to the next phase.`);
const noop = (phase: Phase) => [tool(`p-${phase}`, 'Bash', { command: `true autoplan-published ${phase}` }), result(`p-${phase}`, '')];
const publish = (phase: Phase) => [report(phase), ...noop(phase)];
const phase = (p: Phase) => [...enter(p), ...publish(p)];
const GATE = [tool('g1', 'Bash', { command: '~/.claude/skills/gstack/bin/gstack-gate render "$_AP/gate.json"' }), result('g1', 'p1: approve as-is (recommended)')];
const DONE = say('The plan is reviewed and ready for your approval.');
const FULL = [...LOADED, ...init(), ...phase('ceo'), ...phase('eng'), ...GATE, DONE];
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_AUTOPLAN_GATE: 'on', ...env });
function stop(lines: string[], extra: Record<string, unknown> = {}, env: Record<string, string> = {}) {
const payload = { session_id: 'sess-1', transcript_path: transcript(lines), cwd: work, hook_event_name: 'Stop', stop_hook_active: false, ...extra };
return runHook(HOOK, JSON.stringify(payload), baseEnv(env));
}
const json = (stdout: string) => (stdout.trim() ? JSON.parse(stdout) : null);
const log = () => {
const f = path.join(state, 'hooks', 'autoplan-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['autoplan-gate'].runs) as Array<Record<string, any>>;
const check = (lines: string[]) => {
const t = parseTranscript(`${lines.join('\n')}\n`);
return checkRun(t, skillLoads(t).pop()!);
};
describe('checkRun reads what the run did', () => {
test('a complete run: scope, every applicable phase published in order, the gate written', () => {
expect(check(FULL)).toMatchObject({ mode: 'full', phases: ['ceo', 'eng'], entered: ['ceo', 'eng'], published: ['ceo', 'eng'], gate: true, missing: null, pendingAgents: [] });
});
test('scope decides the phases: UI and DX scope add Design and DX in order', () => {
const c = check([...LOADED, ...init({ uiRequired: true, dxRequired: true }), ...phase('ceo'), ...phase('eng'), ...GATE, DONE]);
expect(c.phases).toEqual(['ceo', 'design', 'dx', 'eng']);
expect(c.missing).toBe('design');
});
test('"Phase 2.5 complete" publishes DX, never Design', () => {
const lines = [...LOADED, ...init({ uiRequired: true, dxRequired: true }), ...phase('ceo'), report('dx'), ...noop('design'), ...noop('dx'), DONE];
expect(check(lines).published).toEqual(['ceo']);
expect(check([...LOADED, ...init({ dxRequired: true }), ...phase('ceo'), ...phase('dx'), DONE]).published).toEqual(['ceo', 'dx']);
});
test('a scope command result counts as scope too', () => {
const scope = [tool('s1', 'Bash', { command: `bun "${SNAP}" scope "${PLAN}"` }), result('s1', JSON.stringify({ activePlan: PLAN, uiRequired: false, dxRequired: true }))];
expect(check([...LOADED, ...scope, DONE]).phases).toEqual(['ceo', 'dx', 'eng']);
});
test('a no-op with no completion report before it, or a failed no-op, is not a publication', () => {
expect(check([...LOADED, ...init(), ...enter('ceo'), ...noop('ceo'), DONE]).published).toEqual([]);
const failed = [report('ceo'), tool('p-ceo', 'Bash', { command: 'true autoplan-published ceo' }), result('p-ceo', 'denied', true)];
expect(check([...LOADED, ...init(), ...enter('ceo'), ...failed, DONE]).published).toEqual([]);
});
test('a second /autoplan load starts a new run: the earlier run\'s publications do not count', () => {
const c = check([...FULL, user('again'), ...LOADED, ...init(), DONE]);
expect(c.published).toEqual([]);
expect(c.missing).toBe('ceo');
});
});
describe('Stop is held until the run has published every applicable phase and written the gate', () => {
test('a run that skips Eng is blocked with the marker first and the next step named', () => {
const out = json(stop([...LOADED, ...init(), ...phase('ceo'), DONE]).stdout);
expect(out.decision).toBe('block');
const [first, ...body] = out.reason.split('\n');
expect(first).toBe('GSTACK_GATE autoplan-gate blocked session=sess-1 skill=autoplan via=stop missing=eng published=ceo n=1');
expect(body.join('\n')).toContain('Phase 3 (Eng) has not run');
expect(body.join('\n')).toContain('autoplan/sections/eng-phase.md');
expect(out.reason).not.toMatch(/autoplan_gate|GSTACK_AUTOPLAN_GATE|gstack-config|--no-autoplan/);
expect(out.systemMessage).toMatch(/^GSTACK_GATE autoplan-gate fired session=sess-1 skill=autoplan on=Stop$/m);
expect(log()).toMatch(/event=blocked skill=autoplan via=stop missing=eng published=ceo n=1/);
expect(runs()[0]).toMatchObject({ skill: 'autoplan', blocked: 1, phases: ['ceo', 'eng'], published: ['ceo'] });
});
test('a phase that ran but never published its report is blocked at its close', () => {
const out = json(stop([...LOADED, ...init(), ...enter('ceo'), say('CEO review done.'), DONE]).stdout);
expect(out.decision).toBe('block');
expect(out.reason.split('\n')[0]).toBe('GSTACK_GATE autoplan-gate blocked session=sess-1 skill=autoplan via=stop missing=ceo published=none n=1');
expect(out.reason).toContain('Phase 1 (CEO) has not published its report');
expect(out.reason).toContain('true autoplan-published ceo');
});
test('a run that never ran Phase 0 scope is blocked at scope', () => {
const out = json(stop([...LOADED, say('Here is my review of the plan: it looks good.')]).stdout);
expect(out.decision).toBe('block');
expect(out.reason.split('\n')[0]).toBe('GSTACK_GATE autoplan-gate blocked session=sess-1 skill=autoplan via=stop missing=scope published=none n=1');
expect(out.reason).toContain('Phase 0');
});
test('every phase published but no Final Approval Gate: blocked at the gate', () => {
const out = json(stop([...LOADED, ...init(), ...phase('ceo'), ...phase('eng'), DONE]).stdout);
expect(out.decision).toBe('block');
expect(out.reason.split('\n')[0]).toBe('GSTACK_GATE autoplan-gate blocked session=sess-1 skill=autoplan via=stop missing=gate published=ceo,eng n=1');
expect(out.reason).toContain('gate.json');
});
test('a gate.json written with the Write tool counts as the gate', () => {
const wrote = [tool('w1', 'Write', { file_path: '/work/repo/.gstack/tmp/autoplan/run/gate.json', content: '{}' }), result('w1', 'File created successfully')];
expect(json(stop([...LOADED, ...init(), ...phase('ceo'), ...phase('eng'), ...wrote, DONE]).stdout).decision).toBeUndefined();
});
test('a complete run is released, recorded with its phases, and a later stop does nothing', () => {
const out = json(stop(FULL).stdout);
expect(out.decision).toBeUndefined();
expect(out.systemMessage).toMatch(/^GSTACK_GATE autoplan-gate fired session=sess-1 skill=autoplan on=Stop$/m);
expect(out.systemMessage).toMatch(/^GSTACK_GATE autoplan-gate released session=sess-1 skill=autoplan phases=ceo,eng blocked=0$/m);
expect(runs()[0]).toMatchObject({ skill: 'autoplan', blocked: 0, published: ['ceo', 'eng'] });
expect(runs()[0].released_at).toBeString();
expect(stop(FULL).stdout.trim()).toBe('');
});
test('blocked, then completed: released with the block count', () => {
expect(json(stop([...LOADED, ...init(), ...phase('ceo'), DONE]).stdout).decision).toBe('block');
const out = json(stop(FULL).stdout);
expect(out.decision).toBeUndefined();
expect(out.systemMessage).toBe('GSTACK_GATE autoplan-gate released session=sess-1 skill=autoplan phases=ceo,eng blocked=1');
});
test('three blocks, then the fourth stop goes through as exhausted', () => {
const partial = [...LOADED, ...init(), ...phase('ceo'), DONE];
for (let n = 1; n <= 3; n++) expect(json(stop(partial).stdout).reason.split('\n')[0]).toEndWith(`n=${n}`);
const out = json(stop(partial).stdout);
expect(out.decision).toBeUndefined();
expect(out.systemMessage).toBe('GSTACK_GATE autoplan-gate exhausted session=sess-1 skill=autoplan missing=eng blocked=3');
expect(stop(partial).stdout.trim()).toBe('');
});
test('a Stop that fires before the CLI flushes the turn waits for the final message instead of judging a stale transcript', async () => {
const file = transcript(LOADED);
const payload = { session_id: 'sess-1', transcript_path: file, cwd: work, hook_event_name: 'Stop', stop_hook_active: false, last_assistant_message: 'The plan is reviewed and ready for your approval.' };
const proc = Bun.spawn([process.execPath, ...bunIsolationArgs(), HOOK], { stdin: new Blob([JSON.stringify(payload)]), env: baseEnv(), stdout: 'pipe', timeout: 20_000 });
await Bun.sleep(300);
fs.appendFileSync(file, `${[...init(), ...phase('ceo'), ...phase('eng'), ...GATE, DONE].join('\n')}\n`);
const out = json(await new Response(proc.stdout).text());
expect(await proc.exited).toBe(0);
expect(out.decision).toBeUndefined();
expect(out.systemMessage).toMatch(/^GSTACK_GATE autoplan-gate released session=sess-1 skill=autoplan phases=ceo,eng blocked=0$/m);
});
});
describe('what the gate never holds', () => {
test('the switch off (the default): nothing', () => {
const r = stop([...LOADED, DONE], {}, { GSTACK_AUTOPLAN_GATE: 'off' });
expect(r.code).toBe(0);
expect(r.stdout.trim()).toBe('');
expect(fs.existsSync(path.join(state, 'skill-gates'))).toBe(false);
});
test('a session that never loaded /autoplan: nothing', () => {
expect(stop([user('plan something'), DONE]).stdout.trim()).toBe('');
});
test('a stop that asks the user in an interactive session is not held', () => {
expect(stop([...LOADED, ...init(), ...phase('ceo'), say('Phase 1 is done. Do you want me to continue to the Eng review?')]).stdout.trim()).toBe('');
});
test('a stop waiting on a native reviewer that is still running is not held; once it reports, the stop is', () => {
const launched = [...LOADED, ...init(), ...enter('ceo'),
tool('a1', 'Agent', { description: 'CEO review', prompt: 'review', run_in_background: true }), result('a1', 'Async agent launched. agentId: agent-77'),
say('Waiting for agent-77.')];
expect(stop(launched).stdout.trim()).toBe('');
const reported = [...launched, meta('<task-notification>agent-77 completed</task-notification>'), say('The CEO review is in.')];
expect(json(stop(reported).stdout).decision).toBe('block');
});
test('a run driven through the gstack-autoplan runner is skipped once, never blocked', () => {
const runner = [...LOADED, tool('n1', 'Bash', { command: '~/.claude/skills/gstack/bin/gstack-autoplan next --out /tmp/ap --plan PLAN.md' }), result('n1', 'GSTACK_RESULT: skill=autoplan status=awaiting_result run=/tmp/ap'), DONE];
const out = json(stop(runner).stdout);
expect(out.decision).toBeUndefined();
expect(out.systemMessage).toBe('GSTACK_GATE autoplan-gate skipped session=sess-1 skill=autoplan reason=runner');
expect(stop(runner).stdout.trim()).toBe('');
});
test('a command that only mentions the runner (a docs search, an echo) is not runner mode', () => {
for (const command of ['rg "gstack-autoplan next --out" docs/unattended.md', 'echo skipping gstack-autoplan next']) {
expect(check([...LOADED, tool('n1', 'Bash', { command }), result('n1', ''), DONE])).toMatchObject({ mode: 'full', missing: 'scope' });
}
expect(check([...LOADED, tool('n1', 'Bash', { command: ". '/x/launch.env' && gstack-autoplan next --out /tmp/ap" }), result('n1', ''), DONE]).mode).toBe('runner');
});
test('a background reviewer whose launch failed is not pending: the stop is held', () => {
const denied = [...LOADED, ...init(), ...enter('ceo'),
tool('a1', 'Agent', { description: 'CEO review', prompt: 'review', run_in_background: true }), result('a1', 'Permission denied', true),
say('The reviewer could not start.')];
expect(check(denied).pendingAgents).toEqual([]);
expect(json(stop(denied).stdout).decision).toBe('block');
});
test('an unreadable transcript fails open with an error marker', () => {
const payload = { session_id: 'sess-1', transcript_path: path.join(work, 'missing.jsonl'), cwd: work, hook_event_name: 'Stop' };
const r = runHook(HOOK, JSON.stringify(payload), baseEnv());
expect(r.code).toBe(0);
expect(json(r.stdout).decision).toBeUndefined();
expect(json(r.stdout).systemMessage).toMatch(/^GSTACK_GATE autoplan-gate error session=sess-1 code=GATE_TRANSCRIPT /);
});
});
describe('a --ceo-only run is checked at its own end line', () => {
const CEO_ONLY = [slash('autoplan', 'PLAN.md --ceo-only')];
const END = say('Phases 2, 2.5, 3 and 4 skipped (CEO-only run).\nGSTACK_RESULT: skill=autoplan status=reviewed run=/work/repo/.gstack/tmp/autoplan/run');
test('Phase 1 published and the end line: released with phases=ceo', () => {
const out = json(stop([...CEO_ONLY, ...init(), ...phase('ceo'), END]).stdout);
expect(out.decision).toBeUndefined();
expect(out.systemMessage).toMatch(/^GSTACK_GATE autoplan-gate released session=sess-1 skill=autoplan phases=ceo blocked=0$/m);
});
test('Phase 1 published without the end line: blocked at the end line', () => {
const out = json(stop([...CEO_ONLY, ...init(), ...phase('ceo'), DONE]).stdout);
expect(out.reason.split('\n')[0]).toBe('GSTACK_GATE autoplan-gate blocked session=sess-1 skill=autoplan via=stop missing=end published=ceo n=1');
expect(out.reason).toContain('GSTACK_RESULT: skill=autoplan status=reviewed');
});
test('the end line without Phase 1 published: blocked at Phase 1', () => {
expect(json(stop([...CEO_ONLY, ...init(), ...enter('ceo'), END]).stdout).reason.split('\n')[0]).toContain('missing=ceo');
});
test('--ceo-only in a Skill call\'s command or skill field, or in the prompt before a Read load, is a ceo-only run', () => {
const rest = [...init(), ...phase('ceo'), END];
for (const input of [{ command: 'autoplan --ceo-only' }, { skill: 'autoplan --ceo-only' }]) {
expect(check([user('review my plan'), tool('s1', 'Skill', input), result('s1', 'loaded'), ...rest])).toMatchObject({ mode: 'ceo-only', missing: null });
}
const readLoad = [user('Run /autoplan PLAN.md --ceo-only'), tool('r1', 'Read', { file_path: `${SKILLS}/autoplan/SKILL.md` }), result('r1', '# autoplan'), ...rest];
expect(check(readLoad)).toMatchObject({ mode: 'ceo-only', missing: null });
expect(check([user('Run /autoplan PLAN.md'), ...readLoad.slice(1)])).toMatchObject({ mode: 'full', missing: 'eng' });
});
});
+47 -4
View File
@@ -38,6 +38,7 @@ const GATES = {
+ ' 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 "$SKILL_GATE_HOOK_TIMEOUT" "ceo review gate" ""'
+ ' SubagentStop "$2/hosts/claude/hooks/ceo-review-gate-subagent-stop" ""',
autoplan_gate: 'GSTACK_AUTOPLAN_GATE Stop "$2/hosts/claude/hooks/autoplan-stop-gate-stop" gstack-autoplan-gate "$SKILL_GATE_HOOK_TIMEOUT" "autoplan gate" ""',
};
/** Run one gate once from the checkout at root: mode is the flag value ("" = none). */
@@ -197,13 +198,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 41 and this pin)', () => {
expect(original.split('\n')[40]).toBe(" skill_routing: 'off',");
test('pin: skill_routing defaults to off today (a flip PR changes lib/skill-route.ts line 42 and this pin)', () => {
expect(original.split('\n')[41]).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 42 and this pin)', () => {
expect(original.split('\n')[41]).toBe(" skill_contracts: 'off',");
test('pin: skill_contracts defaults to off today (a flip PR changes lib/skill-route.ts line 43 and this pin)', () => {
expect(original.split('\n')[42]).toBe(" skill_contracts: 'off',");
expect(SWITCH_DEFAULT.skill_contracts).toBe('off');
});
@@ -335,6 +336,48 @@ describe('setup: the ceo_review_gate switch (GST-1461) registers Stop and Subage
});
});
describe('setup: the autoplan_gate switch (GST-1666) registers Stop', () => {
const ours = () => registered('Stop').filter(e => e.hooks.some(h => h.command.includes('autoplan-stop-gate')));
test('flags parse, appear in --help and the typo list, and the shim has a KNOWN_HOOKS row with its event', () => {
for (const flag of ['--autoplan-gate)', '--no-autoplan-gate)', '--autoplan-gate=*)']) expect(setupSrc).toContain(` ${flag}`);
expect(setupSrc).toContain('--autoplan-gate / --no-autoplan-gate');
expect(setupSrc).toContain('--autoplan-gate --no-autoplan-gate');
expect(setupSrc).toContain('_hook_command_path hosts/claude/hooks/autoplan-stop-gate-stop ||');
expect(setupSrc).toContain('"$AUTOPLAN_GATE_STOP" gstack-autoplan-gate "$SKILL_GATE_HOOK_TIMEOUT" \\');
const row = settingsHookSrc.slice(settingsHookSrc.indexOf('"autoplan-stop-gate-stop":'), settingsHookSrc.indexOf('}', settingsHookSrc.indexOf('"autoplan-stop-gate-stop":')));
expect(row).toContain('source: "gstack-autoplan-gate"');
expect(row).toContain('event: "Stop"');
expect(row).toContain('matcher: ""');
});
test('pin: autoplan_gate defaults to off, so with no flag, env or config nothing registers', () => {
expect(SWITCH_DEFAULT.autoplan_gate).toBe('off');
expect(gate('', {}, 0, ROOT, 'autoplan_gate')).toEqual({ code: 0, out: '' });
expect(ours()).toEqual([]);
});
test('flag on: persists and registers Stop; a re-run is quiet; off removes it', () => {
const first = gate('on', {}, 0, ROOT, 'autoplan_gate');
expect(first.code).toBe(0);
expect(first.out).toContain('registered Stop hook: autoplan gate (on via flag; turn off: gstack-config set autoplan_gate off, or ./setup --no-autoplan-gate');
expect(config()).toMatch(/^autoplan_gate: on$/m);
const entries = ours() 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/autoplan-stop-gate-stop');
expect(entries[0].hooks[0].timeout).toBe(HOOK_TIMEOUT_S);
expect(gate('', {}, 0, ROOT, 'autoplan_gate')).toEqual({ code: 0, out: '' });
gate('off', {}, 0, ROOT, 'autoplan_gate');
expect(ours()).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}autoplan_gate: +off$/m);
});
});
describe('setup: both skill gates register the hook timeout lib/skill-gates.ts derives (GST-1615)', () => {
test('source: one SKILL_GATE_HOOK_TIMEOUT from gstack-skill-gates hook-timeout, passed to both gates', () => {
expect(gateTimeoutLine).toBe('SKILL_GATE_HOOK_TIMEOUT="$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$SOURCE_GSTACK_DIR/bin/gstack-skill-gates" hook-timeout 2>/dev/null || true)"');
+13
View File
@@ -58,6 +58,19 @@ describe('settings', () => {
}
});
test('--autoplan registers Stop on the autoplan-stop-gate shim with no matcher and turns its switch on (GST-1666)', () => {
const r = cli('settings', '--autoplan');
expect(r.status).toBe(0);
const s = JSON.parse(r.stdout);
expect(s.env).toEqual({ GSTACK_AUTOPLAN_GATE: 'on' });
expect(Object.keys(s.hooks)).toEqual(['Stop']);
expect(s.hooks.Stop).toHaveLength(1);
expect(s.hooks.Stop[0].matcher).toBeUndefined();
const cmd = s.hooks.Stop[0].hooks[0].command;
expect(cmd).toBe(path.join(ROOT, 'hosts', 'claude', 'hooks', 'autoplan-stop-gate-stop'));
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);