fix: /autoplan gate makes each phase publish in order after its report (GST-1814) (#4135)

* test: failing-first replays of the three E2 autoplan gate sequences (GST-1814)

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

* fix: /autoplan gate refuses a phase publish out of order or before its report; per-step Stop budget that never exhausts before the first report (GST-1814)

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

* fix: pre-landing review fixes

Before scope is known, CEO publishes first; an exhausted or released run no longer refuses publishes.

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

* fix: the publish check reads the transcript only up to the call it decides

Review-Finding: p1v/outside-voice#1 reader=outside-voice
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: changelog fragment notes that a released or exhausted run no longer refuses publishes

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 18:43:11 +00:00
1 parent fb663a2bdd
commit ddbf1c9a47
13 files changed
+398 -66

No files matched your search

+2 -1
View File
@@ -136,7 +136,8 @@ var KNOWN_HOOKS = {
"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" },
"autoplan-stop-gate-stop": { source: "gstack-autoplan-gate", event: "Stop", matcher: "", relpath: "hosts/claude/hooks/autoplan-stop-gate-stop" }
"autoplan-stop-gate-stop": { source: "gstack-autoplan-gate", event: "Stop", matcher: "", relpath: "hosts/claude/hooks/autoplan-stop-gate-stop" },
"autoplan-stop-gate-publish": { source: "gstack-autoplan-gate", event: "PreToolUse", matcher: "Bash", relpath: "hosts/claude/hooks/autoplan-stop-gate-publish" }
};
function gsHadBashPrefix(c) { return String(c == null ? "" : c).trim().indexOf("bash ") === 0; }
function gsStripWrap(c) {
+1 -1
View File
@@ -36,7 +36,7 @@ const HOOKS: Record<string, { env: string; source: GateHook; events: Array<{ eve
'--autoplan': {
env: 'GSTACK_AUTOPLAN_GATE',
source: 'autoplan-gate',
events: [{ event: 'Stop', shim: 'autoplan-stop-gate-stop' }],
events: [{ event: 'Stop', shim: 'autoplan-stop-gate-stop' }, { event: 'PreToolUse', shim: 'autoplan-stop-gate-publish', matcher: 'Bash' }],
},
};
@@ -0,0 +1 @@
**The opt-in /autoplan gate now makes each phase publish in order, right after its own report (GST-1814).** In GEVA-2's E2 eval (3 told Opus 5.5 sessions, 1 exposed) every failure was in the report step: one session used all 3 Stop blocks on `missing=ceo` before doing any work and then published every phase with no report, one published Eng's report first and back-filled CEO and DX, and one published everything with no reports and back-filled one report per block. With `autoplan_gate` on, `true autoplan-published <phase>` is now refused before it runs unless that phase is the next one due and its report was sent since the previous publish; the refusal starts `Next due: the Phase N (<name>) report.` A publish with no report or out of order never counts at Stop either, so a later report can't rescue it. Stop blocks now count against the step they name (3 per step, reset as the run advances), blocks before the run's first phase report don't count, and 12 blocks end any run; once a run is released or exhausted, its publishes are no longer refused. Setup registers one more hook for the gate (PreToolUse on Bash; its shim starts bun only for the publish command). Still off by default, and nothing changes with the switch off. Docs: docs/skill-gates.md#the-autoplan-gate.
+13 -11
View File
@@ -5,7 +5,7 @@ gstack's advisory skills say what a careful agent does; a gate makes one rule ho
| Gate | Switch | What it holds |
|---|---|---|
| /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) |
| /autoplan | `autoplan_gate` | After /autoplan loads, each phase publishes only in phase order after its own report (GST-1814), and the session's final stop waits until every applicable phase has published and the Final Approval Gate is written (GST-1666) |
## Turn a gate on
@@ -16,7 +16,7 @@ The switch resolves like the contract hook's, and the first one set wins:
3. Config: `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 both.
Setup registers a gate's shims in `~/.claude/settings.json` when its switch is on (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 ceo_review_gate off` takes effect on the next stop.
Setup registers a gate's shims in `~/.claude/settings.json` when its switch is on (Stop and SubagentStop for /plan-ceo-review; Stop, and PreToolUse on Bash for the publish check, for /autoplan) and removes them when it is off. The hooks also read the switch on every call, so `gstack-config set ceo_review_gate off` takes effect on the next stop.
### Headless runs
@@ -66,18 +66,20 @@ Once a session loads /autoplan (the Skill tool, a typed `/autoplan`, or a Read o
**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.
2. Every applicable phase published, in phase order, 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 counts only when that report came after the previous no-op and every earlier applicable phase already counted. A no-op with no report, one for a phase that is not due yet, or a failed one does not count, and a later report does not make it count: the phase publishes again after its report.
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.
**The publish check (GST-1814).** In GEVA-2's E2 eval (3 told Opus 5.5 sessions, 1 exposed) every failure was in the report step: sessions ran the publish no-ops with no reports, published Eng's report before CEO's, and wrote reports only after Stop blocks. So the gate also checks each `true autoplan-published <phase>` before it runs (PreToolUse on Bash; the `autoplan-stop-gate-publish` shim hands only that command to bun). It refuses the call, with a `blocked via=publish phase=<phase> why=no-report|out-of-order missing=<due phase>` marker as the reason's first line, when the phase is not the next applicable phase to publish, or when no report for it was sent since the previous publish no-op (the report in the same message counts). The reason's next line is `Next due: the Phase N (<name>) report.`, then what to send. A phase that already counted, a phase scope makes inapplicable, a runner-mode run and a subagent's call are never refused; before scope is known CEO's must come first (it always applies), then only the report and forward order are checked; after the run is released or exhausted no publish is refused. Claude Code can run the hook before it writes the call to the transcript, so the hook waits up to 2 seconds for the call's own record; if it never appears the call runs with a `skipped reason=unsettled` marker and the Stop check still applies.
**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 block.** Its reason starts with the `blocked via=stop missing=<step> published=<phases>` marker; for a phase step the next line is `Next due: the Phase N (<name>) report.` Then it 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 publish no-ops that did not count and why (`no report before it`, `out of order: <phase> was due`), 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.
**The cap and the record.** A Stop block counts against the step it names, at most 3 per step; the count resets when the step advances, and blocks before the run's first phase report count against no step (E2 spent all 3 blocks of the old per-run cap on `missing=ceo` before any work, GST-1814). After 3 blocks at one step, or 12 blocks in the run whatever the step, 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`, `published`, `step` and `step_blocks` as of its last final stop and its `denied` publish count, 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
@@ -87,16 +89,16 @@ Every decision is one marker line:
| Event | When | Fields |
|---|---|---|
| `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) |
| `blocked` | each refusal | `skill`, `via=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) |
| `fired` | the gate armed for a skill run (once per run; for /plan-ceo-review, its first final stop; for /autoplan, its first final stop or refused publish) | `skill`, `on` (the hook event) |
| `blocked` | each refusal | `skill`, `via=stop` or `via=publish` (/autoplan's publish check), `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), `phase` and `why` (`via=publish`: the refused phase and `no-report` or `out-of-order`), `n` (stop blocks, or refused publishes for `via=publish`, so far in this run) |
| `released` | an output file ranks the top risks, or the /autoplan run is complete (once per run) | `skill`, `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` |
| `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` |
| `exhausted` | /plan-ceo-review: a fourth final stop after 3 blocks; /autoplan: a final stop after 3 blocks at one step once a report exists, or after 12 in the run; it goes through | `skill`, `missing` (/autoplan), `blocked` |
| `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|unsettled` (`unsettled`: /autoplan's publish check never saw its call in the transcript, so the call ran) |
| `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`, `file`, `blocked`, `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.
- **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`, `file`, `blocked`, `exhausted_at`, `agent` (a subagent's run), `phases`, `published`, `step`, `step_blocks` and `denied` (/autoplan), plus the gate's `errors` and `skipped` reasons. `gstack-skill-gates report` reads these.
- **Trace log**: `$GSTACK_STATE_ROOT/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 `hook_blocking_error` attachment for 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.
@@ -139,4 +141,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 /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 second 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, 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 two scripted sessions. The first 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 second loads /autoplan, runs Phase 0 scope, tries to publish Eng early and CEO with no report (both refused at PreToolUse), 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, and that the CEO review was released by `plan.md`. Run it after a Claude Code upgrade.
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Bash shim for the /autoplan gate's PreToolUse hook on Bash (GST-1814): only a
# publish no-op (true autoplan-published <phase>) reaches
# hosts/claude/hooks/autoplan-stop-gate.ts; every other Bash call returns at
# once without starting bun. Registered by ./setup --autoplan-gate for
# PreToolUse with matcher Bash.
#
# 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
PAYLOAD="$(cat)" || exit 0
case "$PAYLOAD" in *autoplan-published*) ;; *) exit 0 ;; esac
printf '%s' "$PAYLOAD" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$HERE/autoplan-stop-gate.ts" || true
exit 0
+74 -27
View File
@@ -2,7 +2,16 @@
/**
* 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).
* SWITCH_DEFAULT). Registered through two shims: autoplan-stop-gate-stop (Stop)
* and autoplan-stop-gate-publish (PreToolUse on Bash).
*
* PreToolUse: a Bash `true autoplan-published <phase>` in the session that
* loaded /autoplan, once the transcript holds that call (else it runs)
* -> a released or exhausted run? it runs
* -> lib/autoplan-stop-gate.ts publishCheck: the phase is the one due and
* its report was sent since the previous publish no-op? it runs
* -> otherwise denied: blocked via=publish why=<no-report|out-of-order>,
* the reason names the report due next (GST-1814)
*
* Stop: payload transcript_path (+ last_assistant_message), re-read until it
* holds that final message (lib/skill-gates.ts settledTranscript)
@@ -13,37 +22,34 @@
* -> 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
* -> every applicable phase published in order and the gate written: released
* -> budget spent: exhausted, the stop goes through. Blocks count against
* the step they name (3 per step, reset when the step advances) only
* once the run has sent a phase report; 12 blocks end any run
* -> 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:
* block or denial 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 { isFinalTurn, sameSkill, skillLoads, turnEnd } from '../../../lib/skill-contract';
import { PUBLISHED, SKILL, checkRun, missingStep, publishCheck, type Phase } from '../../../lib/autoplan-stop-gate';
import { isFinalTurn, sameSkill, skillLoads, turnEnd, type SkillLoad } from '../../../lib/skill-contract';
import { readSkillSwitch } from '../../../lib/skill-route';
import {
blockWith, hookRecord, note, pruneSessionDirs, readGateRecord, recordPath, remainingMs, runGateHook, sessionDir, settledTranscript, systemMessage,
withSessionLock, writeGateRecord, type GatePayload, type HookOutput,
blockWith, denyTool, hookRecord, note, pruneSessionDirs, readGateRecord, recordPath, remainingMs, runGateHook, sessionDir, settledTranscript, systemMessage,
transcriptWithToolUse, withSessionLock, writeGateRecord, type GatePayload, type GateRun, type HookOutput,
} from '../../../lib/skill-gates';
const HOOK = 'autoplan-gate';
const MAX_BLOCKS = 3;
const MAX_STEP_BLOCKS = 3;
const MAX_RUN_BLOCKS = 12;
const SESSION_KIND_TIMEOUT_MS = 1000;
function handle(p: GatePayload): HookOutput | undefined {
if (p.event && p.event !== 'Stop') return undefined;
const { t } = settledTranscript(p.transcriptPath, p.raw.last_assistant_message);
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;
/** Under the session lock: the run's record (created with a fired marker on its first decision), and a save. */
function withRun<T>(p: GatePayload, load: SkillLoad, fn: (run: GateRun | undefined, open: (sys: string[]) => GateRun, save: () => void, h: ReturnType<typeof hookRecord>, key: string) => T): T {
const key = `${SKILL}@${load.line}`;
const dir = sessionDir(p.sessionId);
return withSessionLock(dir, () => {
@@ -51,21 +57,59 @@ function handle(p: GatePayload): HookOutput | undefined {
const rec = readGateRecord(recFile, p.sessionId);
rec.transcript_path = p.transcriptPath;
const h = hookRecord(rec, HOOK);
const save = () => writeGateRecord(recFile, rec);
const open = (sys: string[]) => {
if (h.runs[key]) return h.runs[key];
sys.push(note(HOOK, 'fired', p.sessionId, { skill: SKILL, on: p.event || 'Stop' }));
pruneSessionDirs(dir);
return (h.runs[key] = { skill: SKILL, load_line: load.line, fired_at: new Date().toISOString(), blocked: 0, observed: 0 });
};
return fn(h.runs[key], open, () => writeGateRecord(recFile, rec), h, key);
});
}
function onPublish(p: GatePayload): HookOutput | undefined {
const input = p.raw.tool_input as Record<string, unknown> | undefined;
const phase = PUBLISHED.exec(String(input?.command ?? ''))?.[1] as Phase | undefined;
if (p.raw.tool_name !== 'Bash' || !phase || p.raw.agent_id) return undefined;
const t = transcriptWithToolUse(p.transcriptPath, String(p.raw.tool_use_id ?? ''));
if (!t) return systemMessage([note(HOOK, 'skipped', p.sessionId, { skill: SKILL, reason: 'unsettled', phase })]);
const load = skillLoads(t).filter(l => sameSkill(l.skill, SKILL)).pop();
if (!load) return undefined;
const check = checkRun(t, load);
const refusal = publishCheck(check, phase);
if (!refusal) return undefined;
return withRun(p, load, (existing, open, save) => {
if (existing?.released_at || existing?.exhausted_at) return undefined;
const sys: string[] = [];
const run = open(sys);
run.phases = check.phases ?? undefined;
run.published = check.published;
run.denied = (run.denied ?? 0) + 1;
save();
const marker = note(HOOK, 'blocked', p.sessionId, { skill: SKILL, via: 'publish', phase, why: refusal.why, missing: refusal.due, n: run.denied });
return denyTool(`${marker}\n${refusal.reason}`, sys);
});
}
function handle(p: GatePayload): HookOutput | undefined {
if (p.event === 'PreToolUse') return onPublish(p);
if (p.event && p.event !== 'Stop') return undefined;
const { t } = settledTranscript(p.transcriptPath, p.raw.last_assistant_message);
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;
return withRun(p, load, (existing, open, save, h, key) => {
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;
if (existing?.released_at || existing?.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);
}
const run = open(sys);
run.phases = check.phases ?? undefined;
run.published = check.published;
if (!check.missing) {
@@ -74,13 +118,16 @@ function handle(p: GatePayload): HookOutput | undefined {
sys.push(note(HOOK, 'released', p.sessionId, { skill: SKILL, phases: check.published.join(','), blocked: run.blocked }));
return systemMessage(sys);
}
if (run.blocked >= MAX_BLOCKS) {
const counting = check.reported.length > 0;
if (run.step !== check.missing) { run.step = check.missing; run.step_blocks = 0; }
if ((counting && (run.step_blocks ?? 0) >= MAX_STEP_BLOCKS) || run.blocked >= MAX_RUN_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++;
if (counting) run.step_blocks = (run.step_blocks ?? 0) + 1;
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 = [
+65 -11
View File
@@ -8,7 +8,9 @@
* 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
* "true autoplan-published <phase>". A no-op counts only when that report came
* after the previous no-op and every earlier applicable phase already counted
* (GST-1814); publishCheck refuses the others before they run. 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
@@ -26,7 +28,7 @@ export const PHASE_NAME: Record<Phase, string> = { ceo: 'CEO', design: 'Design',
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*$/;
export 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$/;
@@ -40,8 +42,14 @@ export interface RunCheck {
phases: Phase[] | null;
/** phases whose driver section was Read */
entered: Phase[];
/** phases that published, in the order they did */
/** phases that published, in phase order, each after its own report */
published: Phase[];
/** publish no-ops that did not count: no report since the previous no-op, or not the phase due */
uncounted: Array<{ phase: Phase; why: 'no-report' | 'out-of-order'; due?: Phase }>;
/** phases with a report line anywhere in the run */
reported: Phase[];
/** phases with a report line since the last successful publish no-op */
reportedSinceLast: Phase[];
gate: boolean;
missing: Missing | null;
/** Agent or Task calls launched in the background that have not reported back */
@@ -99,15 +107,23 @@ export function checkRun(t: Transcript, load: SkillLoad): RunCheck {
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 reportsIn = (events: TranscriptEvent[]) => PHASE_ORDER.filter(p => {
const said = new RegExp(`Phase\\s+${PHASE_NUMBER[p].replace('.', '\\.')}(?![\\d.])[^\\n]*?\\bcomplete\\b`, 'i');
return events.some(e => e.kind === 'text' && said.test(e.text));
});
const published: Phase[] = [];
const uncounted: RunCheck['uncounted'] = [];
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);
if (!published.includes(p)) {
if (!reportsIn(run.slice(from, at)).includes(p)) uncounted.push({ phase: p, why: 'no-report' });
else if (p !== dueNext(phases, published, p)) uncounted.push({ phase: p, why: 'out-of-order', due: dueNext(phases, published, p) });
else published.push(p);
}
from = at + 1;
}
@@ -132,7 +148,43 @@ export function checkRun(t: Transcript, load: SkillLoad): RunCheck {
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 };
return { mode, phases, entered, published, uncounted, reported: reportsIn(run), reportedSinceLast: reportsIn(run.slice(from)), gate, missing, pendingAgents };
}
/**
* The phase that may publish next: the first applicable phase not yet published.
* Before scope is known, CEO (which always applies) comes first; after it any
* phase later in PHASE_ORDER than every published one may (returned as `p`
* itself), so an unread scope never refuses a later phase.
*/
function dueNext(phases: Phase[] | null, published: Phase[], p: Phase): Phase | undefined {
if (phases) return phases.find(q => !published.includes(q));
if (!published.includes('ceo')) return 'ceo';
return published.every(q => PHASE_ORDER.indexOf(q) < PHASE_ORDER.indexOf(p)) ? p : published.at(-1);
}
const label = (p: Phase) => `Phase ${PHASE_NUMBER[p]} (${PHASE_NAME[p]})`;
const dueLine = (p: Phase) => `Next due: the ${label(p)} report.`;
const howToPublish = (p: Phase) => `its report as text starting "**Phase ${PHASE_NUMBER[p]} complete.**" (autoplan/sections/phase-close.md step 6) in a message whose only tool call is: true autoplan-published ${p}`;
/**
* The refusal for a publish no-op of `p` about to run, or null to let it run:
* a phase publishes only when it is the phase due and its report was sent since
* the previous publish no-op (the same message counts). Republishing a phase
* that already counted, runner mode, and a phase scope makes inapplicable pass.
*/
export function publishCheck(c: RunCheck, p: Phase): { why: 'no-report' | 'out-of-order'; due: Phase; reason: string } | null {
if (c.mode === 'runner' || c.published.includes(p) || (c.phases && !c.phases.includes(p))) return null;
const due = dueNext(c.phases, c.published, p) ?? p;
if (due !== p) {
const before = (c.phases ? c.phases.slice(0, c.phases.indexOf(p)).filter(q => !c.published.includes(q)) : [due]).map(label);
return { why: 'out-of-order', due, reason: [dueLine(due),
`/autoplan publishes phases in order (${(c.phases ?? PHASE_ORDER).join(', ')}): ${label(p)} cannot publish before ${before.join(' and ')}.`,
`When ${label(due)}'s review is finished, send ${howToPublish(due)}`].join('\n') };
}
if (c.reportedSinceLast.includes(p)) return null;
return { why: 'no-report', due, reason: [dueLine(p),
`${label(p)} cannot publish without its report. Send ${howToPublish(p)}`].join('\n') };
}
/** The block text for a run's missing step: what is not done and the next thing to do. */
@@ -142,9 +194,11 @@ export function missingStep(c: RunCheck): string {
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.`;
const uncounted = c.uncounted.length
? [`Not counted: ${c.uncounted.map(u => `true autoplan-published ${u.phase} (${u.why === 'no-report' ? 'no report before it' : `out of order: ${u.due ? label(u.due) : 'another phase'} was due`})`).join(', ')}.`]
: [];
const step = c.entered.includes(m) || c.reported.includes(m) || c.uncounted.some(u => u.phase === m)
? `/autoplan is not done: ${label(m)} 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}`
: `/autoplan is not done: ${label(m)} has not run. Continue the pipeline at its driver Read (autoplan/sections/${m}-phase.md) and run it through its close in this turn, then send its report starting "**Phase ${PHASE_NUMBER[m]} complete.**" in a message whose only tool call is: true autoplan-published ${m}`;
return [dueLine(m), step, ...uncounted].join('\n');
}
+27
View File
@@ -93,6 +93,28 @@ export function settledTranscript(file: string, finalMessage: unknown, opts: { i
}
}
/**
* A PreToolUse gate's transcript once it holds the tool call being decided,
* cut after that call (the text sent with it precedes it), or null when the call is not
* there within the settle window: the caller lets the call run (fail open).
*/
export function transcriptWithToolUse(file: string, toolUseId: string): Transcript | null {
if (!toolUseId) return null;
const until = Date.now() + Math.min(SETTLE_MS, remainingMs() / 2);
try {
for (;;) {
const t = parseTranscript(readTranscriptFile(file));
const at = t.events.findIndex(e => e.kind === 'tool_use' && e.id === toolUseId);
if (at >= 0) return { ...t, events: t.events.slice(0, at + 1) };
if (Date.now() >= until) return null;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, SETTLE_POLL_MS);
}
} catch (e) {
if (e instanceof StateError || e instanceof UnknownShape) throw new GateError('GATE_TRANSCRIPT', e.message);
throw e;
}
}
export const cleanSessionId = (sid: string) => sid.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64) || 'unknown';
const markerValue = (v: string | number) => String(v).replace(/\s+/g, '_').slice(0, 240);
@@ -113,6 +135,11 @@ export interface GateRun {
/** the run's applicable phases and the phases it published (autoplan-gate) */
phases?: string[];
published?: string[];
/** autoplan-gate: the missing step the last counted Stop blocks were at, and how many (the per-step budget) */
step?: string;
step_blocks?: number;
/** autoplan-gate: publish no-ops refused at PreToolUse */
denied?: number;
blocked: number;
observed: number;
exhausted_at?: string;
+9 -3
View File
@@ -8,8 +8,9 @@
* markers in the session record, the hook log and the transcript JSONL.
* Two scenarios share the server: 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).
* /autoplan gate (Eng's publish before CEO and a report-less CEO publish
* refused at PreToolUse, then 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]
*
@@ -87,6 +88,8 @@ const SCENARIOS: Scenario[] = [
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 3 complete.**\nOutside review: unavailable. Native subagent: completed: 1 issue.\nPassing to the Final Approval Gate.' }, { type: 'tool_use', id: 'toolu_d1', name: 'Bash', input: { command: 'true autoplan-published eng', description: 'Publish Phase 3 early' } }],
[{ type: 'tool_use', id: 'toolu_d2', name: 'Bash', input: { command: 'true autoplan-published ceo', description: 'Publish Phase 1 with no report' } }],
[{ 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' } }],
@@ -100,13 +103,16 @@ const SCENARIOS: Scenario[] = [
['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: two publishes refused (Eng early, CEO with no report), the reported in-order ones never', run0?.denied === 2],
['log: refused via=publish out-of-order for eng and no-report for ceo', /event=blocked .*via=publish phase=eng why=out-of-order missing=ceo n=1/.test(s.log) && /event=blocked .*via=publish phase=ceo why=no-report missing=ceo n=2/.test(s.log)],
['requests: the model saw each refusal name the CEO report as due', (s.requests.match(/Next due: the Phase 1 \(CEO\) report\./g) ?? []).length >= 2],
['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')],
['requests: the model saw the block reason naming Phase 3', /autoplan-gate blocked [^"]*missing=eng/.test(s.requests) && s.requests.includes('Next due: the Phase 3 (Eng) report.')],
];
},
},
+5 -2
View File
@@ -5025,11 +5025,14 @@ _skill_hook_gate ceo_review_gate "$CEO_REVIEW_GATE_MODE" GSTACK_CEO_REVIEW_GATE
"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.
# loaded /autoplan (Stop), and refuses a phase's publish no-op until that phase
# is due and its report is sent (PreToolUse on Bash, GST-1814): one shim each.
AUTOPLAN_GATE_STOP="$(_hook_command_path hosts/claude/hooks/autoplan-stop-gate-stop || true)"
AUTOPLAN_GATE_PUBLISH="$(_hook_command_path hosts/claude/hooks/autoplan-stop-gate-publish || 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" ""
"after /autoplan loads, each phase publishes in order after its report, and its final stop waits until every applicable phase published and the gate is written" "" \
PreToolUse "$AUTOPLAN_GATE_PUBLISH" Bash
# Also tear down plan-tune + timeline hooks on --no-team (matches the existing pattern).
# Tag-only remove-source misses untagged entries (Claude Code strips
+159
View File
@@ -12,6 +12,7 @@
* fail open. Native Stop payloads against throwaway transcripts.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { checkRun } from '../lib/autoplan-stop-gate';
@@ -276,3 +277,161 @@ describe('a --ceo-only run is checked at its own end line', () => {
expect(check([user('Run /autoplan PLAN.md'), ...readLoad.slice(1)])).toMatchObject({ mode: 'full', missing: 'eng' });
});
});
describe('E2 replays (GST-1814): each phase publishes in order after its own report, and blocks before the first report never exhaust the run', () => {
const DXRUN = [...LOADED, ...init({ dxRequired: true })];
const WORKED = [...DXRUN, ...enter('ceo'), ...enter('dx'), ...enter('eng')];
const noopAs = (p: Phase, id: string) => [tool(id, 'Bash', { command: `true autoplan-published ${p}` }), result(id, '')];
function publishTry(lines: string[], p: Phase, id = `try-${p}`, env: Record<string, string> = {}) {
const payload = { session_id: 'sess-1', transcript_path: transcript([...lines, tool(id, 'Bash', { command: `true autoplan-published ${p}` })]), cwd: work,
hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_use_id: id, tool_input: { command: `true autoplan-published ${p}` } };
return runHook(HOOK, JSON.stringify(payload), baseEnv(env));
}
const denied = (r: { stdout: string }) => json(r.stdout)?.hookSpecificOutput;
test('sequence 1: stops before any work never exhaust the budget, each names the CEO report as due, and report-less publishes are refused', () => {
const idle = [...DXRUN, say('Starting the CEO review now.')];
for (let n = 1; n <= 5; n++) {
const out = json(stop(idle).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=${n}`);
expect(out.reason.split('\n')[1]).toBe('Next due: the Phase 1 (CEO) report.');
}
expect(runs()[0].exhausted_at).toBeUndefined();
for (const p of ['ceo', 'dx', 'eng'] as Phase[]) {
const d = denied(publishTry(idle, p));
expect(d).toMatchObject({ hookEventName: 'PreToolUse', permissionDecision: 'deny' });
expect(d.permissionDecisionReason).toContain('Next due: the Phase 1 (CEO) report.');
}
const bare = [...idle, ...noop('ceo'), ...noop('dx'), ...noop('eng'), DONE];
const out = json(stop(bare).stdout);
expect(out.reason.split('\n')[0]).toEndWith('missing=ceo published=none n=6');
expect(out.reason).toContain('Not counted: true autoplan-published ceo (no report before it), true autoplan-published dx (no report before it), true autoplan-published eng (no report before it).');
});
test('sequence 1: once a report exists, three blocks at the same step exhaust it', () => {
const partial = [...DXRUN, ...phase('ceo'), DONE];
for (let n = 1; n <= 3; n++) expect(json(stop(partial).stdout).decision).toBe('block');
expect(json(stop(partial).stdout).systemMessage).toBe('GSTACK_GATE autoplan-gate exhausted session=sess-1 skill=autoplan missing=dx blocked=3');
});
test('an exhausted run no longer refuses publishes', () => {
const partial = [...DXRUN, ...phase('ceo'), DONE];
const early = [...DXRUN, ...phase('ceo'), report('eng')];
expect(denied(publishTry(early, 'eng'))?.permissionDecision).toBe('deny');
for (let n = 1; n <= 3; n++) expect(json(stop(partial).stdout).decision).toBe('block');
expect(json(stop(partial).stdout).systemMessage).toContain('autoplan-gate exhausted');
expect(denied(publishTry(early, 'eng'))).toBeUndefined();
expect(runs()[0].denied).toBe(1);
});
test('sequence 1: the 12-block ceiling ends a run that never reports', () => {
const idle = [...DXRUN, DONE];
for (let n = 1; n <= 12; n++) expect(json(stop(idle).stdout).decision).toBe('block');
expect(json(stop(idle).stdout).systemMessage).toBe('GSTACK_GATE autoplan-gate exhausted session=sess-1 skill=autoplan missing=ceo blocked=12');
});
test('sequence 1: progress resets the per-step budget, so a run that advances each block is never exhausted mid-pipeline', () => {
const steps = [[...DXRUN, ...phase('ceo'), DONE], [...DXRUN, ...phase('ceo'), ...phase('dx'), DONE], [...DXRUN, ...phase('ceo'), ...phase('dx'), ...phase('eng'), DONE]];
for (const s of steps) for (let n = 0; n < 3; n++) expect(json(stop(s).stdout).decision).toBe('block');
const out = json(stop([...DXRUN, ...phase('ceo'), ...phase('dx'), ...phase('eng'), ...GATE, DONE]).stdout);
expect(out.systemMessage).toBe('GSTACK_GATE autoplan-gate released session=sess-1 skill=autoplan phases=ceo,dx,eng blocked=9');
});
test('sequence 2: Eng\'s report first is refused at publish, and back-filling CEO and DX does not make the early Eng count', () => {
const d = denied(publishTry([...WORKED, report('eng')], 'eng'));
expect(d.permissionDecision).toBe('deny');
expect(d.permissionDecisionReason.split('\n').slice(0, 2)).toEqual(['GSTACK_GATE autoplan-gate blocked session=sess-1 skill=autoplan via=publish phase=eng why=out-of-order missing=ceo n=1', 'Next due: the Phase 1 (CEO) report.']);
expect(d.permissionDecisionReason).toContain('Phase 3 (Eng) cannot publish before Phase 1 (CEO) and Phase 2.5 (DX)');
expect(d.permissionDecisionReason).toContain('true autoplan-published ceo');
const backfilled = [...WORKED, ...publish('eng'), ...publish('ceo'), ...publish('dx'), ...GATE, DONE];
expect(check(backfilled)).toMatchObject({ published: ['ceo', 'dx'], missing: 'eng', uncounted: [{ phase: 'eng', why: 'out-of-order' }] });
const out = json(stop(backfilled).stdout);
expect(out.reason.split('\n')[0]).toBe('GSTACK_GATE autoplan-gate blocked session=sess-1 skill=autoplan via=stop missing=eng published=ceo,dx n=1');
expect(out.reason.split('\n')[1]).toBe('Next due: the Phase 3 (Eng) report.');
expect(out.reason).toContain('true autoplan-published eng (out of order: Phase 1 (CEO) was due)');
});
test('before scope is known, CEO publishes first and later phases follow in forward order', () => {
const d = denied(publishTry([...LOADED, report('eng')], 'eng'));
expect(d.permissionDecisionReason.split('\n').slice(0, 2)).toEqual(['GSTACK_GATE autoplan-gate blocked session=sess-1 skill=autoplan via=publish phase=eng why=out-of-order missing=ceo n=1', 'Next due: the Phase 1 (CEO) report.']);
expect(d.permissionDecisionReason).toContain('Phase 3 (Eng) cannot publish before Phase 1 (CEO).');
expect(check([...LOADED, ...publish('eng')])).toMatchObject({ phases: null, published: [], uncounted: [{ phase: 'eng', why: 'out-of-order', due: 'ceo' }] });
expect(denied(publishTry([...LOADED, report('ceo')], 'ceo'))).toBeUndefined();
expect(denied(publishTry([...LOADED, ...phase('ceo'), report('eng')], 'eng'))).toBeUndefined();
});
test('a report written after the publish call in the same message does not count for it', () => {
const lines = [...DXRUN, ...enter('ceo'), tool('late', 'Bash', { command: 'true autoplan-published ceo' }), report('ceo')];
const payload = { session_id: 'sess-1', transcript_path: transcript(lines), cwd: work, hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_use_id: 'late', tool_input: { command: 'true autoplan-published ceo' } };
const d = denied(runHook(HOOK, JSON.stringify(payload), baseEnv()));
expect(d.permissionDecisionReason.split('\n')[0]).toBe('GSTACK_GATE autoplan-gate blocked session=sess-1 skill=autoplan via=publish phase=ceo why=no-report missing=ceo n=1');
});
test('sequence 3: publishes with no report are refused one by one, naming the report each one needs', () => {
const d = denied(publishTry(WORKED, 'ceo'));
expect(d.permissionDecision).toBe('deny');
expect(d.permissionDecisionReason.split('\n')[1]).toBe('Next due: the Phase 1 (CEO) report.');
expect(d.permissionDecisionReason).toContain('Phase 1 (CEO) cannot publish without its report');
expect(d.permissionDecisionReason).toContain('**Phase 1 complete.**');
const bare = [...WORKED, ...noop('ceo'), ...noop('dx'), ...noop('eng')];
expect(check([...bare, DONE])).toMatchObject({ published: [], missing: 'ceo' });
expect(denied(publishTry(bare, 'dx')).permissionDecisionReason.split('\n')[1]).toBe('Next due: the Phase 1 (CEO) report.');
expect(publishTry([...bare, report('ceo')], 'ceo').stdout.trim()).toBe('');
expect(log()).toMatch(/event=blocked skill=autoplan via=publish phase=ceo why=no-report missing=ceo n=1/);
expect(runs()[0]).toMatchObject({ denied: 2, blocked: 0 });
});
test('negative control: a run that reports and publishes every phase in order is never refused and releases with zero blocks', () => {
let lines = [...DXRUN];
for (const p of ['ceo', 'dx', 'eng'] as Phase[]) {
lines = [...lines, ...enter(p), report(p)];
expect(publishTry(lines, p, `p-${p}`).stdout.trim()).toBe('');
lines = [...lines, ...noop(p)];
}
const out = json(stop([...lines, ...GATE, DONE]).stdout);
expect(out.decision).toBeUndefined();
expect(out.systemMessage).toMatch(/^GSTACK_GATE autoplan-gate released session=sess-1 skill=autoplan phases=ceo,dx,eng blocked=0$/m);
expect(runs()[0].denied ?? 0).toBe(0);
});
test('negative control: republishing an already published phase is allowed and changes nothing', () => {
expect(publishTry([...LOADED, ...init(), ...phase('ceo')], 'ceo', 'again').stdout.trim()).toBe('');
expect(check([...LOADED, ...init(), ...phase('ceo'), ...noopAs('ceo', 'again'), DONE])).toMatchObject({ published: ['ceo'], uncounted: [] });
});
test('the publish check never refuses with the switch off, outside /autoplan, for other commands, in runner mode or in a subagent', () => {
const off = publishTry(WORKED, 'eng', 'x', { GSTACK_AUTOPLAN_GATE: 'off' });
expect(off).toMatchObject({ code: 0, stdout: '' });
expect(fs.existsSync(path.join(state, 'skill-gates'))).toBe(false);
expect(publishTry([user('hi')], 'eng').stdout.trim()).toBe('');
const other = { session_id: 'sess-1', transcript_path: transcript([...WORKED, tool('ls', 'Bash', { command: 'ls' })]), cwd: work, hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_use_id: 'ls', tool_input: { command: 'ls' } };
expect(runHook(HOOK, JSON.stringify(other), baseEnv()).stdout.trim()).toBe('');
const runner = [...LOADED, tool('n1', 'Bash', { command: '~/.claude/skills/gstack/bin/gstack-autoplan next --out /tmp/ap' }), result('n1', 'ok')];
expect(publishTry(runner, 'eng').stdout.trim()).toBe('');
const sub = { session_id: 'sess-1', transcript_path: transcript(WORKED), cwd: work, hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_use_id: 'sub', agent_id: 'agent-1', tool_input: { command: 'true autoplan-published eng' } };
expect(runHook(HOOK, JSON.stringify(sub), baseEnv()).stdout.trim()).toBe('');
});
test('the PreToolUse shim hands only a publish no-op to the hook, and fails open without bun', () => {
const shim = path.join(ROOT, 'hosts', 'claude', 'hooks', 'autoplan-stop-gate-publish');
const run = (input: string, env: Record<string, string> = baseEnv()) => spawnSync(shim, [], { input, env, encoding: 'utf8', timeout: 20_000 });
const noBun = { PATH: '/usr/bin:/bin' };
const ls = run(JSON.stringify({ session_id: 'sess-1', transcript_path: '/nonexistent', hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_use_id: 'ls', tool_input: { command: 'ls' } }), noBun);
expect(ls).toMatchObject({ status: 0, stdout: '' });
const withBun = { ...baseEnv(), PATH: `${path.dirname(process.execPath)}:${process.env.PATH ?? ''}` };
expect(run('{"tool_input":{"command":"ls"}}', withBun)).toMatchObject({ status: 0, stdout: '' });
const payload = { session_id: 'sess-1', transcript_path: transcript([...WORKED, tool('s1', 'Bash', { command: 'true autoplan-published eng' })]), cwd: work, hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_use_id: 's1', tool_input: { command: 'true autoplan-published eng' } };
const r = run(JSON.stringify(payload), withBun);
expect(r.status).toBe(0);
expect(json(r.stdout).hookSpecificOutput.permissionDecision).toBe('deny');
expect(run(JSON.stringify(payload), noBun)).toMatchObject({ status: 0, stdout: '' });
});
test('a publish whose own tool call never reaches the transcript is let through (fail open)', () => {
const payload = { session_id: 'sess-1', transcript_path: transcript(WORKED), cwd: work, hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_use_id: 'unseen', tool_input: { command: 'true autoplan-published eng' } };
const r = runHook(HOOK, JSON.stringify(payload), baseEnv());
expect(r.code).toBe(0);
expect(json(r.stdout)?.hookSpecificOutput).toBeUndefined();
});
});
+17 -3
View File
@@ -36,7 +36,8 @@ const GATES = {
skill_contracts: 'GSTACK_SKILL_CONTRACTS Stop "$2/hosts/claude/hooks/skill-contract-stop-hook" gstack-skill-contract 10 "contract check"',
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" ""',
autoplan_gate: 'GSTACK_AUTOPLAN_GATE Stop "$2/hosts/claude/hooks/autoplan-stop-gate-stop" gstack-autoplan-gate "$SKILL_GATE_HOOK_TIMEOUT" "autoplan gate" ""'
+ ' PreToolUse "$2/hosts/claude/hooks/autoplan-stop-gate-publish" Bash',
};
/** Run one gate once from the checkout at root: mode is the flag value ("" = none). */
@@ -329,8 +330,9 @@ describe('setup: the ceo_review_gate switch (GST-1461) registers Stop and Subage
});
});
describe('setup: the autoplan_gate switch (GST-1666) registers Stop', () => {
describe('setup: the autoplan_gate switch (GST-1666) registers Stop, and PreToolUse on Bash for the publish check (GST-1814)', () => {
const ours = () => registered('Stop').filter(e => e.hooks.some(h => h.command.includes('autoplan-stop-gate')));
const publish = () => registered('PreToolUse').filter(e => e.hooks.some(h => h.command.includes('autoplan-stop-gate'))) as Array<{ matcher?: string; hooks: Array<{ command: string; timeout?: number }> }>;
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}`);
@@ -342,15 +344,22 @@ describe('setup: the autoplan_gate switch (GST-1666) registers Stop', () => {
expect(row).toContain('source: "gstack-autoplan-gate"');
expect(row).toContain('event: "Stop"');
expect(row).toContain('matcher: ""');
expect(setupSrc).toContain('_hook_command_path hosts/claude/hooks/autoplan-stop-gate-publish ||');
expect(setupSrc).toContain(' PreToolUse "$AUTOPLAN_GATE_PUBLISH" Bash');
const pub = settingsHookSrc.slice(settingsHookSrc.indexOf('"autoplan-stop-gate-publish":'), settingsHookSrc.indexOf('}', settingsHookSrc.indexOf('"autoplan-stop-gate-publish":')));
expect(pub).toContain('source: "gstack-autoplan-gate"');
expect(pub).toContain('event: "PreToolUse"');
expect(pub).toContain('matcher: "Bash"');
});
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([]);
expect(publish()).toEqual([]);
});
test('flag on: persists and registers Stop; a re-run is quiet; off removes it', () => {
test('flag on: persists and registers Stop and PreToolUse; a re-run is quiet; off removes both', () => {
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');
@@ -360,9 +369,14 @@ describe('setup: the autoplan_gate switch (GST-1666) registers Stop', () => {
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(publish()).toHaveLength(1);
expect(publish()[0].matcher).toBe('Bash');
expect(publish()[0].hooks[0].command).toEndWith('hosts/claude/hooks/autoplan-stop-gate-publish');
expect(publish()[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([]);
expect(publish()).toEqual([]);
});
test('gstack-config knows the key and its default', () => {
+11 -7
View File
@@ -44,17 +44,21 @@ describe('settings', () => {
}
});
test('--autoplan registers Stop on the autoplan-stop-gate shim with no matcher and turns its switch on (GST-1666)', () => {
test('--autoplan registers Stop (no matcher, GST-1666) and PreToolUse on Bash (the publish check, GST-1814) and turns its switch on', () => {
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(Object.keys(s.hooks)).toEqual(['Stop', 'PreToolUse']);
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);
expect(s.hooks.PreToolUse).toHaveLength(1);
expect(s.hooks.PreToolUse[0].matcher).toBe('Bash');
for (const [event, shim] of [['Stop', 'autoplan-stop-gate-stop'], ['PreToolUse', 'autoplan-stop-gate-publish']]) {
const cmd = s.hooks[event][0].hooks[0].command;
expect(cmd).toBe(path.join(ROOT, 'hosts', 'claude', 'hooks', shim));
expect(fs.statSync(cmd).mode & 0o111).not.toBe(0);
}
});
test('--ceo-review --autoplan together: both switches on, Stop runs both gates', () => {
@@ -62,7 +66,7 @@ describe('settings', () => {
expect(r.status).toBe(0);
const s = JSON.parse(r.stdout);
expect(s.env).toEqual({ GSTACK_CEO_REVIEW_GATE: 'on', GSTACK_AUTOPLAN_GATE: 'on' });
expect(Object.keys(s.hooks).sort()).toEqual(['Stop', 'SubagentStop']);
expect(Object.keys(s.hooks).sort()).toEqual(['PreToolUse', 'Stop', 'SubagentStop']);
expect(s.hooks.Stop.map((e: { hooks: Array<{ command: string }> }) => path.basename(e.hooks[0].command))).toEqual(['ceo-review-gate-stop', 'autoplan-stop-gate-stop']);
});
@@ -94,7 +98,7 @@ describe('hook timeout covers the gate deadline (GST-1615)', () => {
expect(r.status).toBe(0);
const hooks = JSON.parse(r.stdout).hooks as Record<string, Array<{ hooks: Array<{ timeout: number }> }>>;
const timeouts = Object.values(hooks).flatMap(entries => entries.flatMap(e => e.hooks.map(h => h.timeout)));
expect(timeouts).toHaveLength(3);
expect(timeouts).toHaveLength(4);
for (const t of timeouts) {
expect(t * 1000).toBeGreaterThanOrEqual(DEADLINE_MAX_MS + HOOK_MARGIN_MS);
expect(t).toBe(HOOK_TIMEOUT_S);