mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-07 17:28:23 +01:00
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:
1 parent
e4b1e34552
commit
ca5dbfc7e0
17 files changed
+736
-36
No files matched your search
+3
-2
@@ -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
|
||||
;;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,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.
|
||||
|
||||
Executable
+11
@@ -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
|
||||
Executable
+120
@@ -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));
|
||||
@@ -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
@@ -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
@@ -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`);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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)"');
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user