SLOPSHOPPER

ai-sdlc

AI-SDLC governance framework for Claude Code — action enforcement, telemetry, quality gates, and review agents

newprocesstimer
★ 424v0.31.2Apache-2.0updated 2026-10-26ai-sdlc-framework/ai-sdlc/ai-sdlc-plugin
A shopper browsing a rack in a slop shop
README

AI-SDLC Plugin

Claude Code plugin providing governance rules, review subagents, and MCP tools for the AI-SDLC framework.

Subagents (agents/)

Plugin subagents are .md files with YAML frontmatter declaring tool grants, the harness, and the agent role.

Harness note: Claude Code filters the Agent tool out of plugin subagent sessions one level deep — plugin subagents cannot spawn other subagents. The /ai-sdlc execute slash command (main session) spawns the developer + reviewers directly.

Developer

AgentHarnessDescription
developerclaude-codeImplements backlog tasks end-to-end: plan, code, verify, commit, push, open PR

Reviewers — Claude variants (default)

Run inside Claude Code sessions. Spawned by /ai-sdlc execute Step 7b.

AgentModelDescription
code-reviewersonnetCode quality review: bugs, logic errors, conventions
test-reviewersonnetTest coverage review: existence, quality, edge cases
security-revieweropusSecurity review: OWASP vulnerabilities, injection, secret exposure

Reviewers — Codex variants (cross-harness)

Shell out to codex exec internally. Use when the developer ran on Claude Code (cross-harness independence) or when Codex is preferred for cost/latency reasons.

Spawning: Agent(subagent_type='ai-sdlc:code-reviewer-codex') in a slash command body, or by choosing the -codex suffix in the /ai-sdlc execute harness selection step.

AgentHarnessDescription
code-reviewer-codexcodexCode quality review via Codex CLI (codex exec --model o4-mini)
test-reviewer-codexcodexTest coverage review via Codex CLI (codex exec --model o4-mini)

Why no security-reviewer-codex? Security review stays on Claude Opus (per feedback_subagent_model_selection.md) for its reasoning-heavy OWASP analysis. Codex variants are alternatives only for code/test review where o4-mini is adequate.

All Codex reviewer variants return the same JSON envelope as their Claude counterparts:

{
  "approved": true,
  "findings": [
    { "severity": "minor", "file": "src/foo.ts", "line": 42, "message": "..." }
  ],
  "summary": "Overall assessment in 1-2 sentences"
}

This makes harness selection transparent to the Step 8 verdict aggregator — no parsing changes needed.

Utility agents

AgentHarnessDescription
rebase-resolverclaude-codeResolves mechanical rebase conflicts (CHANGELOG, lock files, prettier drift)
refinement-reviewerclaude-codeStage B Definition-of-Ready evaluator (RFC-0011 Phase 2b semantic gates)

Slash Commands (commands/)

CommandDescription
/ai-sdlc execute <task-id>Full pipeline: worktree → developer → 3 reviewers → PR
/ai-sdlc orchestrator-tick, dispatch-worker, execute-parallel*Retired (AISDLC-763): one-paragraph pointers to cli-hierarchy.
/ai-sdlc review-prStandalone review pass on the current branch
/ai-sdlc rebase <pr>Mechanical rebase + re-sign of an open PR
/ai-sdlc triageIssue triage (DOR evaluation + PPA trust)
/ai-sdlc pipeline-statusPipeline status summary
/ai-sdlc cleanup [<task-id>]Remove stale worktrees

Skills (skills/)

SkillDescription
ai-sdlc-governanceAuto-loaded governance rules, blocked actions, and pre-commit checklist
decision-rubricFive-part rubric (problem → research → options → recommendation + counter-argument → question) for putting non-trivial design/policy decisions to the operator; applies to open questions from any work item (RFC, backlog task, GitHub/Jira/Linear issue)

Install topologies + path resolution (AISDLC-245.4, AISDLC-272, AISDLC-557)

Slash command bodies invoke @ai-sdlc/pipeline-cli CLIs and plugin-internal scripts. They must work across six distinct install topologies:

#TopologyCLAUDE_PLUGIN_DIRCLAUDE_PLUGIN_ROOTpipeline-cli location
1Remote marketplace install (bundled deps)Set — deps presentSet$CLAUDE_PLUGIN_DIR/node_modules/@ai-sdlc/pipeline-cli/
2Local marketplace install (no npm install)Set — deps missingSetSelf-heal via install-runtime-deps.sh, then probe cache
3Marketplace (env injection variant)UnsetSet — deps present$CLAUDE_PLUGIN_ROOT/node_modules/@ai-sdlc/pipeline-cli/
4Plugin cache probe (env unset)UnsetUnset~/.claude/plugins/cache/<mp>/ai-sdlc/<version>/node_modules/@ai-sdlc/pipeline-cli/ (read-only — never self-heals)
5Dogfood monorepo (this repo)UnsetUnset$(pwd)/pipeline-cli/ relative to repo root
6Self-location fallback (AISDLC-557, last resort)UnsetUnsetSelf-heal against the directory resolve-pipeline-cli.sh itself lives in

Why topology 2 exists: The local marketplace installer (/claude plugin install against a local marketplace.json) copies plugin files to ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/ but does NOT run npm install. So runtimeDependencies declared in plugin.json are never installed for local marketplace setups. The scripts/install-runtime-deps.sh self-heal script fills this gap.

Why topology 6 exists (AISDLC-557): a second adopter report found that when CLAUDE_PLUGIN_DIR and CLAUDE_PLUGIN_ROOT are BOTH unset, self-heal was completely unreachable — topologies 1-3 are the only ones that ever attempt it, and topology 4 (cache probe) deliberately stays read-only (see the security note in resolve-pipeline-cli.sh — that's the PR #482 fix for the cache-WALK vulnerability, which is a different failure mode from this one). Topology 6 derives the plugin dir from resolve-pipeline-cli.sh's own on-disk location as a genuine last resort, so self-heal gets a chance to run even when neither env var made it through. This does NOT reintroduce the PR #482 vulnerability: topology 6 only ever targets the exact directory the currently-executing script lives in — no directory is walked, compared, or selected the way the removed cache-walk topology did.

Resolution algorithm

scripts/resolve-pipeline-cli.sh tries each topology in order and exits 0 with the path on the first match, or exits 1 with a clear actionable error naming the broken topology:

1. $CLAUDE_PLUGIN_DIR/node_modules/@ai-sdlc/pipeline-cli/bin exists → use it
2. $CLAUDE_PLUGIN_DIR set but deps missing → self-heal via install-runtime-deps.sh
3. $CLAUDE_PLUGIN_ROOT/node_modules/@ai-sdlc/pipeline-cli/bin exists → use it
4. ~/.claude/plugins/cache/*/ai-sdlc/*/node_modules/... exists → use highest version
5. $(pwd)/pipeline-cli/bin exists → use it (dogfood monorepo)
6. Self-location fallback: neither env var set → self-heal against the dir
   this script itself lives in, then retry (AISDLC-557, last resort)
7. Nothing found → exit 1 with actionable error + PIPELINE_CLI_BIN override hint

Usage in slash command bodies

Rule: never hardcode node pipeline-cli/bin/cli-XXX.mjs or node ai-sdlc-plugin/scripts/XXX.mjs in a slash command body. Use the portable preamble:

# PLUGIN_SCRIPTS_DIR — resolves plugin-internal scripts (compute-slug.mjs etc.):
# Must be set FIRST — resolve-pipeline-cli.sh lives under PLUGIN_SCRIPTS_DIR.
PLUGIN_SCRIPTS_DIR="${CLAUDE_PLUGIN_DIR:-${CLAUDE_PLUGIN_ROOT:-$(pwd)/ai-sdlc-plugin}}/scripts"

# PIPELINE_CLI_BIN — resolves across all 5 install topologies (AISDLC-272).
# Override: export PIPELINE_CLI_BIN=/path/to/pipeline-cli/bin to skip resolution.
if [ -z "${PIPELINE_CLI_BIN:-}" ]; then
  PIPELINE_CLI_BIN=$(bash "$PLUGIN_SCRIPTS_DIR/resolve-pipeline-cli.sh") || exit 1
fi

Then invoke CLIs as:

# pipeline-cli binary
node "$PIPELINE_CLI_BIN/cli-deps.mjs" preflight "$TASK_ID" ...

# plugin-internal script
node "$PLUGIN_SCRIPTS_DIR/compute-slug.mjs" "$TASK_FILE"

Manual self-heal (local marketplace installs)

If you installed via a local marketplace and @ai-sdlc/pipeline-cli is missing:

bash ~/.claude/plugins/cache/ai-sdlc-local/ai-sdlc/<version>/scripts/install-runtime-deps.sh

Or override PIPELINE_CLI_BIN in your shell before launching Claude Code:

export PIPELINE_CLI_BIN=/path/to/ai-sdlc/pipeline-cli/bin

Note on CLAUDE_PLUGIN_ROOT: for plugin-internal scripts already using ${CLAUDE_PLUGIN_ROOT} (e.g. sign-attestation.mjs invocations in /ai-sdlc execute Step 10.5 and /ai-sdlc rebase), leave those unchanged — Claude Code injects CLAUDE_PLUGIN_ROOT at session start and it is always available in the main session context.

Troubleshooting: pin bumped but runtime still stale after /plugin update (AISDLC-608)

Symptom: you (or an adopter) bump the plugin version — raising a runtimeDependencies pin in plugin.json, e.g. @ai-sdlc/pipeline-cli from >=0.24.0 to >=0.24.1 — run /plugin update, but the installed node_modules/@ai-sdlc/pipeline-cli stays on the already-installed 0.24.0. Tools keep behaving as if the fix that motivated the bump never shipped.

Why this happens: /plugin update swaps the plugin's files (including the new pin in plugin.json) but Claude Code does not invoke npm install as part of that swap, and it cannot be hooked directly (not exposed to plugins). The only lever the plugin has is its own SessionStart hook (hooks/session-start.js), which runs a version-convergence check (scripts/check-stale-runtime-deps.mjs, AISDLC-580) and self-heals automatically — but only the next time a session actually starts. Two things can additionally mask this:

  • A slow-but-reachable registry check. The stale-check's npm view round-trip is bounded by a timeout; a genuine timeout must not be mistaken for "confirmed up to date" — if you see a session-start banner reading ⚠ ai-sdlc could not confirm runtime deps are up to date (registry check timed out), that's exactly this case (AISDLC-608 AC-1). It is advisory, not a hard failure — use the recovery command below to force a definitive answer.
  • A mid-session /plugin update. Since only SessionStart can trigger the self-heal, a pin bump applied while a session is already running won't take effect until you reload or start a new session.

Recovery — force a clean re-resolve on demand:

bash "$CLAUDE_PLUGIN_ROOT/scripts/install-runtime-deps.sh" "$CLAUDE_PLUGIN_ROOT" --force

--force (alias --reinstall) unconditionally removes every managed @ai-sdlc/* runtime-dep directory and reinstalls against the pins currently declared in plugin.json — bypassing both the file-existence idempotence gate and the version-convergence check entirely, so it always converges regardless of what's currently installed. Reload or restart your Claude Code session afterward so the already-running MCP server / pipeline-cli process picks up the freshly installed version.

If you see a session-start banner reading ⚠ ai-sdlc runtime deps were stale — auto-upgrading now (...), no action is needed — the self-heal already ran; the banner is informational (AISDLC-608 AC-3). Run the --force command above only if a tool still behaves unexpectedly after that session starts.

Enforcement: ai-sdlc-plugin/commands/execute.test.mjs contains assertions (AISDLC-245.4 + AISDLC-272 suites) that scan the command body for bare node pipeline-cli/bin/... invocations and fail the test run if found. ai-sdlc-plugin/scripts/resolve-pipeline-cli.test.mjs tests each topology in isolation. When adding a new slash command, copy the path-resolution preamble above and add a similar regression test.

Attestation prerequisites for adopters (AISDLC-556)

/ai-sdlc execute writes reviewer verdicts and opens the PR, but a signed attestation only appears when ALL of the following are set up in the consuming repo. The signing key and the pre-push hook are not installed by default (the runtime dependency is, see item 2), so a fresh adopter repo can produce PRs with no attestation and no error:

  1. A signing key at ~/.ai-sdlc/signing-key.pem (run /ai-sdlc init-signing-key), with its public key listed in .ai-sdlc/trusted-reviewers.yaml.
  2. The signing runtime dependency: @ai-sdlc/orchestrator and @ai-sdlc/pipeline-cli resolvable from the repo (plugin install runs install-runtime-deps.sh, or pnpm add -D them).
  3. A pre-push hook that signs (AISDLC-555): install it with ai-sdlc init --with-attestation. Docs elsewhere describe the pre-push hook as the signer; that is true only after this step.

Step 10 (pipeline-cli/src/steps/10-finalize.ts) checks the signer it invokes and prints a warning naming the verdict file when none is reachable. It fails open: the pipeline continues and the PR simply carries no attestation.

verify-attestation CI cannot pass in a repo with no signer. Until the prerequisites above are in place, adopters must omit the verify-attestation check from their required checks.

Attestation pre-push hook — shipping + install (AISDLC-555)

/ai-sdlc execute Step 10 does not sign attestations inline; it writes reviewer verdicts to .ai-sdlc/verdicts/<task-id>.json and delegates the actual DSSE sign + commit to a git pre-push hook (AISDLC-133). Two things have to be true for that hook to ever fire in an adopter repo:

  1. The hook script has to ship somewhere the adopter can reach it. It does: ai-sdlc-plugin/scripts/check-attestation-sign.sh — installed alongside sign-attestation.mjs in every topology (marketplace cache, CLAUDE_PLUGIN_ROOT checkout, or this monorepo). It resolves the signer relative to its own on-disk directory (not $CLAUDE_PLUGIN_ROOT, not the worktree root), so it works even when a bare git push in a plain terminal never inherited any Claude Code env var. This monorepo's own dogfood .husky/pre-push is unaffected — it still calls the separate, unmodified scripts/check-attestation-sign.sh copy at the repo root directly.
  1. Something has to WRITE the hook into the adopter's repo. ai-sdlc init --with-attestation (or --add attestation) already does this — it was producing a .husky/pre-push block that checked ONLY the repo-relative ./scripts/check-attestation-sign.sh path, which never exists outside this monorepo. AISDLC-555 fixed the block (HUSKY_PREPUSH_SIGN_SNIPPET in orchestrator/src/cli/commands/init-templates.ts) to resolve the script the same way slash-command bodies do: repo-local copy first (dogfood back-compat), then $CLAUDE_PLUGIN_ROOT / $CLAUDE_PLUGIN_DIR (git push run inside a Claude Code session), then a read-only plugin-cache probe (bare terminal, matching the security posture of resolve-pipeline-cli.sh topology 4 — never self-heals from a user-writable cache dir).

Entry point. ai-sdlc init --with-attestation / --add attestation is the canonical, already-discoverable entry point — this is "whatever an adopter already runs" (task Scope item 3), not a new command to learn. applyFeatureSelection()'s hook-writing block in init-features.ts is the independently callable, reusable installer: it takes (projectDir, selection, flags, adapters), is idempotent (appendOnce keyed on the # ai-sdlc:attestation-sign-block sentinel — a second run is a no-op), and appends to rather than clobbers a pre-existing hook. AISDLC-560's init / doctor work should call this function (or invoke applyFeatureSelection with selection.attestation = true) rather than reimplementing hook-writing.

Husky vs. non-husky (AC #4). resolveHookTarget(projectDir, adapters) decides the target file:

  • package.json parses and does not declare a husky dependency → .git/hooks/pre-push — git's own native hook path, always executed regardless of husky configuration.
  • Everything else (no package.json, unreadable/malformed package.json, or husky IS declared) → .husky/pre-push — the pre-AISDLC-555 default. This fails open to the common case: most repos running ai-sdlc init --with-attestation are JS/TS projects that already use, or will shortly npm install and pick up, husky.

Either target is chmod 0755'd after every write/append — a freshly written hook that isn't executable is silently never run by git, which would have looked like AC #2 passing when it hadn't.

Verified end-to-end outside the monorepo (AC #5): a scratch git repo (no ai-sdlc git history, no inherited hooks) with a fake plugin install directory (built @ai-sdlc/orchestrator + @ai-sdlc/pipeline-cli, plus the two ai-sdlc-plugin/scripts/* files) reproduced the full round trip in two configurations: (a) CLAUDE_PLUGIN_ROOT set (the /ai-sdlc execute push path) and (b) neither env var set, with the fake plugin install placed under ~/.claude/plugins/cache/<marketplace>/ai-sdlc/<version>/ (the bare-terminal git push path). Both produced a committed v6 DSSE envelope from a verdict file present at push time, and a second run was a clean idempotent no-op.

ai-sdlc init — CI-safe by default (AISDLC-263)

ai-sdlc init runs the interactive feature wizard by default. When process.stdin is not a TTY (CI runners, agent bash sessions, Docker containers without -it, piped input), the wizard automatically falls through to --yes defaults — all features on — rather than hanging or throwing an unhandled error.

# These all work without hanging or prompting:
ai-sdlc init               # in any CI step / agent bash
ai-sdlc init < /dev/null   # explicit non-TTY simulation

When auto-fall-through fires, the CLI prints:

Non-TTY stdin detected — auto-accepting all feature defaults (equivalent to --yes).
Pass --yes explicitly to suppress this message.

To be explicit, pass --yes (recommended for CI scripts — makes intent clear and suppresses the auto-fall-through log line):

ai-sdlc init --yes

Cross-harness review

See docs/operations/cross-harness-review.md for the bidirectional convention, cost/latency comparison, and Codex CLI prerequisites.

MCP Server (mcp-server/)

The plugin bundles an MCP server (@ai-sdlc/plugin-mcp-server) exposing task management and verdict aggregation tools. See mcp-server/src/tools/ for the tool definitions.

Testing

# Agent definition tests (Node built-in runner)
node --test ai-sdlc-plugin/agents/agents.test.mjs

# Command body tests
node --test ai-sdlc-plugin/commands/execute.test.mjs

# Path resolution topology tests (AISDLC-272)
node --test ai-sdlc-plugin/scripts/resolve-pipeline-cli.test.mjs

# MCP server tests (Vitest)
pnpm --filter @ai-sdlc/plugin-mcp-server test
Source 4 files
hooks/hierarchy/register.tsx 247 lines
1// RFC-0058 phase 1 (AISDLC-1034): the hierarchy hooks module, executor role only.
2// Registered through hooks/hooks.json `modules`. It registers one `session.start` gate; the role hooks are
3// registered from inside it, so a session with no hierarchy role (or a host below the floor) gets none.
4// `$` is only ever spelled `$.noun.verb(...)` here (the validator refuses anything else).
5import {
6  ROTATION_KEY,
7  exists,
8  guard,
9  log,
10  pickRole,
11  executorSessionMeasure,
12  executorSessionReceive,
13  executorTurnComplete,
14  executorTurnStart,
15  resumeAfterClear,
16  safe,
17} from './executor.ts';
18import type { ExecutorState, Ports } from './executor.ts';
19import {
20  DEFAULT_TICK_MS,
21  DISPATCH_PRIMITIVES,
22  BOARD_KEY,
23  newDispatchState,
24  attempt,
25  refreshPaneBounded,
26  dispatchSessionMeasure,
27  dispatchSessionReceive,
28  dispatchUiRender,
29  tick,
30  writeModuleHealth,
31} from './dispatch.ts';
32import type { DispatchPorts, DispatchState } from './dispatch.ts';
33import {
34  plannerSessionMeasure,
35  plannerSessionReceive,
36  plannerTurnComplete,
37  plannerTurnStart,
38} from './planner.ts';
39import type { PlannerState } from './planner.ts';
40
41type Any = any;
42
43interface Router {
44  current: string;
45  executor: ExecutorState;
46  dispatch: DispatchState;
47  planner: PlannerState;
48}
49
50// The validator follows `$` only into functions declared at the top of the file or imported from the
51// module's own files, so the forwarders are plain top-level functions that call each role's handler
52// by name. They are the one place that decides which role receives an event.
53function onReceive(r: Router, $: Any, e: Any, next: Any): Any {
54  if (r.current === 'operator-dispatch') return dispatchSessionReceive(r.dispatch, $, e, next);
55  if (r.current === 'planner') return plannerSessionReceive(r.planner, $, e, next);
56  if (r.current === 'executor') return executorSessionReceive(r.executor, $, e, next);
57  return next(e);
58}
59function onTurnStart(r: Router, $: Any, e: Any, next: Any): Any {
60  if (r.current === 'planner') return plannerTurnStart(r.planner, $, e, next);
61  if (r.current === 'executor') return executorTurnStart(r.executor, $, e, next);
62  return next(e);
63}
64function onTurnComplete(r: Router, $: Any, e: Any, next: Any): Any {
65  if (r.current === 'planner') return plannerTurnComplete(r.planner, $, e, next);
66  if (r.current === 'executor') return executorTurnComplete(r.executor, $, e, next);
67  return next(e);
68}
69function onRender(r: Router, $: Any, e: Any, next: Any): Any {
70  return r.dispatch.enabled ? dispatchUiRender(r.dispatch, $, e, next) : next(e);
71}
72function onMeasure(r: Router, $: Any, e: Any, next: Any): Any {
73  if (r.current === 'operator-dispatch') return dispatchSessionMeasure(r.dispatch, $, e, next);
74  if (r.current === 'planner') return plannerSessionMeasure(r.planner, $, e, next);
75  if (r.current === 'executor') return executorSessionMeasure(r.executor, $, e, next);
76  return next(e);
77}
78
79export const register = (on: Any, options?: Any): void => {
80  // Each event is registered once per module (the validator is static and refuses a duplicate), and only
81  // when a role arms, so a session without a role registers nothing but the gate.
82  let wiredBase = false;
83  let wiredRender = false;
84  const router: Router = {
85    current: '',
86    executor: { active: false, pendingRotate: false, lastReply: '' },
87    dispatch: newDispatchState(options),
88    planner: {
89      active: false,
90      pendingRotate: false,
91      lastReply: '',
92      name: 'planner',
93      inTurn: false,
94    },
95  };
96  const state = router.executor;
97  const dispatch = router.dispatch;
98  const planner = router.planner;
99  const wire = (withRender: boolean): void => {
100    if (wiredBase) return wireRender(withRender);
101    wiredBase = true;
102    on('session.receive', ($: Any, e: Any, next: Any) => onReceive(router, $, e, next));
103    on('turn.start', ($: Any, e: Any, next: Any) => onTurnStart(router, $, e, next));
104    on('turn.complete', ($: Any, e: Any, next: Any) => onTurnComplete(router, $, e, next));
105    on('session.measure', ($: Any, e: Any, next: Any) => onMeasure(router, $, e, next));
106    wireRender(withRender);
107  };
108  const wireRender = (withRender: boolean): void => {
109    if (!withRender || wiredRender) return;
110    wiredRender = true;
111    on('ui.render', ($: Any, e: Any, next: Any) => onRender(router, $, e, next));
112  };
113  on('session.start', async ($: Any, e: Any, next: Any) => {
114    const p: Ports = {
115      storeGet: (k) => $.store.get(k),
116      storeSet: (k, v) => $.store.set(k, v),
117      runCommand: (a) => $.command.run(a),
118      compact: (a) => $.session.compact(a),
119      submit: (a) => $.prompt.submit(a),
120    };
121    // A record carried by a rotation whose new session never reached a hook is stale: clear it here so it
122    // cannot start an executor in an unrelated session. A live post-clear resume runs from the turn hooks.
123    if (!state.resumePending) await safe(() => p.storeSet(ROTATION_KEY, null));
124    const role = pickRole([
125      (await safe(() => $.env.get('HIERARCHY_ROLE'))).value,
126      (await safe(() => $.env.get('AI_SDLC_HIERARCHY_ROLE'))).value,
127      options?.role,
128    ]);
129    if (!role) return next(e);
130
131    const version = (await safe(() => $.session.version())).value;
132    // AISDLC-1061: the name keys the heartbeat file `cli-hierarchy status`, `up` and `doctor` read.
133    const named = (await safe(() => $.env.get('AI_SDLC_HIERARCHY_SESSION'))).value;
134    const sessionName = typeof named === 'string' && named ? named : role;
135    const versionText = typeof version === 'string' ? version : '';
136    const health = (h: { state: 'loaded' | 'refused'; reason?: string }): Promise<void> =>
137      writeModuleHealth((a) => $.process.run(a), options?.boardDir, sessionName, {
138        ...h,
139        ...(versionText ? { version: versionText } : {}),
140      });
141    // OQ-5: the validator refuses `typeof $.noun.verb`, so each primitive is probed by one call whose
142    // argument is a bare string, never an object with fields: a typed boundary rejects it before any
143    // action (no compaction, no blank turn), and only an absent primitive counts as missing. A host that
144    // does not validate would see no `text` / `instructions` field to act on. `session.measure` is an
145    // event; its read-only pull-side twin `$.session.usage` stands in for it.
146    const present: Record<string, boolean> =
147      role === 'operator-dispatch'
148        ? {
149            'clock.every': await exists(() => $.clock.every('probe'), 'every'),
150            'process.run': await exists(() => $.process.run('probe'), 'run'),
151            'session.send': await exists(() => $.session.send('probe'), 'send'),
152            'ui.open': await exists(() => $.ui.open('probe'), 'open'),
153            'session.measure': await exists(() => $.session.usage(), 'usage'),
154          }
155        : {
156            'prompt.submit': await exists(() => $.prompt.submit('probe'), 'submit'),
157            'command.run': await exists(() => $.command.run('probe'), 'run'),
158            'session.compact': await exists(() => $.session.compact('probe'), 'compact'),
159            'session.measure': await exists(() => $.session.usage(), 'usage'),
160            'session.send': await exists(() => $.session.send('probe'), 'send'),
161          };
162    const miss = guard(
163      version,
164      present,
165      role === 'operator-dispatch' ? DISPATCH_PRIMITIVES : undefined,
166    );
167    if (miss) {
168      await log(p, miss);
169      await health({ state: 'refused', reason: miss });
170      return next(e);
171    }
172
173    // OQ-4: informational only, never authority.
174    const identity = (await safe(() => $.session.identity())).value;
175    await log(
176      p,
177      `role=${role} tier=${identity ? 'managed' : 'user'} identity=${identity ? JSON.stringify(identity) : 'none'}`,
178    );
179    if (role === 'operator-dispatch') {
180      // AISDLC-1035: a model-less host. No prompt.submit port exists in the dispatch module.
181      wire(true);
182      router.current = 'operator-dispatch';
183      dispatch.enabled = true;
184      dispatch.sessionName = sessionName;
185      if (versionText) dispatch.hostVersion = versionText;
186      await health({ state: 'loaded' });
187      const tickMs = Number(options?.tickMs) > 0 ? Number(options.tickMs) : DEFAULT_TICK_MS;
188      const dp: DispatchPorts = {
189        storeGet: (k) => $.store.get(k),
190        storeSet: (k, v) => $.store.set(k, v),
191        runCommand: (a) => $.command.run(a),
192        runProcess: (a) => $.process.run(a),
193        send: (a) => $.session.send(a),
194        uiOpen: (a) => $.ui.open(a),
195      };
196      // AISDLC-1064: the tick is the function, the pane is a view. Register the timer before any UI or
197      // status call, log every step, and never await a primitive that can hang on the registration path.
198      let firstTick = true;
199      const run = async (): Promise<void> => {
200        if (firstTick) {
201          firstTick = false;
202          await log(p, `first tick ran at ${new Date().toISOString()}`);
203        }
204        await tick(dp, dispatch);
205      };
206      const timer = await attempt(() => $.clock.every({ ms: tickMs, run }));
207      await log(
208        p,
209        timer === 'ok' ? `tick registered every ${tickMs}` : `tick registration failed ${timer}`,
210      );
211      // A rotation keeps the last pane in $.store; show it until the first tick replaces it.
212      const kept = (await safe(() => dp.storeGet(BOARD_KEY))).value as string[] | undefined;
213      if (Array.isArray(kept)) dispatch.pane = kept;
214      // Fire and log: a UI open or a status call that never settles must not block the registration path.
215      void attempt(() => $.ui.open({ id: 'board', title: 'Dispatch board' })).then((r) =>
216        log(p, r === 'ok' ? 'ui.open ok' : `ui.open failed ${r}`),
217      );
218      void refreshPaneBounded(dp, dispatch).then((r) =>
219        log(p, r === 'ok' ? 'refreshPane ok' : `refreshPane failed ${r}`),
220      );
221      // The first tick runs at once so a pending rotate request is consumed within seconds of `up`.
222      if (timer === 'ok') void attempt(run);
223      return next(e);
224    }
225    if (role === 'planner') {
226      await health({ state: 'loaded' });
227      // The roster name keys the stored last reply; the launcher sets it, the role is the fallback.
228      planner.name = typeof named === 'string' && named ? named : 'planner';
229      wire(false);
230      router.current = 'planner';
231      planner.enabled = true;
232      if (planner.resumePending) await resumeAfterClear(p, planner);
233      await log(p, 'registered role=planner');
234      return next(e);
235    }
236    if (role !== 'executor') return next(e);
237    await health({ state: 'loaded' });
238    wire(false);
239    router.current = 'executor';
240    state.enabled = true;
241    // INTERIM: if a host fires session.start after a mod-run clear, resume from here as well.
242    if (state.resumePending) await resumeAfterClear(p, state);
243    await log(p, 'registered role=executor');
244    return next(e);
245  });
246};
247
hooks/hierarchy/executor.ts 407 lines
1// RFC-0058 phase 1 (AISDLC-1034): the executor role of the hierarchy hooks module.
2// A hooks module may import only relative files and "claude-code", has no process or fs, and `claude plugin
3// validate` refuses `$` passed around or reached through an optional chain: `$` is always spelled
4// `$.noun.verb(...)` at the call site. So the handlers build a `Ports` object of such closures and every
5// function here takes the ports (or plain values), never `$`.
6type Any = any;
7
8/** The host calls the executor role makes, each spelled `$.noun.verb(...)` in the handler that builds it. */
9export interface Ports {
10  storeGet: (key: string) => Promise<Any>;
11  storeSet: (key: string, value: unknown) => Promise<unknown>;
12  runCommand: (a: { command: string; args?: string }) => Promise<Any>;
13  compact: (a: { instructions: string }) => Promise<Any>;
14  submit: (a: { text: string }) => Promise<Any>;
15}
16
17/** Build the phase 0 spike passed on (AISDLC-1033); the module registers nothing below it. */
18export const VERSION_FLOOR = '2.1.307';
19/** Context size, in tokens, at which an executor rotates (the 120k executor threshold). */
20export const ROTATE_AT_TOKENS = 120_000;
21/** A slash command is run with `$.command.run` (the host refuses `$.prompt.submit` of a text starting with `/`). */
22export const RESUME_COMMAND = 'ai-sdlc:executor';
23/** A carried rotation record older than this is stale (a clear that never reached a new session). */
24export const ROTATION_TTL_MS = 10 * 60 * 1000;
25/** Longest last reply carried into the planner's resume command arguments. */
26export const LAST_REPLY_MAX_CHARS = 4000;
27/** A task with no turn activity for this long is treated as ended, so a lost verdict cannot mute wakes. */
28export const ACTIVE_STALE_MS = 30 * 60 * 1000;
29/** Printed as the last line of a reply by the executor command (Step 5 / idle path). */
30export const VERDICT_MARKER = '[executor] verdict reported';
31export const IDLE_MARKER = '[executor] idle';
32
33export const LOG_KEY = 'hierarchy/log';
34export const ROTATION_KEY = 'hierarchy/rotation';
35
36/** What differs between the roles' rotations; the function that rotates is shared (RFC-0058 OQ-1). */
37export interface RotationPlan {
38  role: string;
39  /** Slash command run in the new session after the clear. */
40  resume: string;
41  /** `handoff write --role <role>`, run first. */
42  handoff: { command: string; args: string };
43  /** Summary prompt for the compact fallback. */
44  summary: string;
45  /** Carry the last reply into the resume command's arguments (the planner prints it after a clear). */
46  replyAsArgs?: boolean;
47}
48
49export const EXECUTOR_PLAN: RotationPlan = {
50  role: 'executor',
51  resume: RESUME_COMMAND,
52  handoff: { command: 'ai-sdlc:hierarchy', args: 'handoff write --role executor' },
53  summary:
54    'Summarise for the hierarchy executor: its role (executor), that it holds no task, the board ' +
55    'directory, and that its next step is /ai-sdlc:executor. Drop everything about the finished task.',
56};
57export const SUMMARY_PROMPT = EXECUTOR_PLAN.summary;
58
59/** The primitives the executor role uses; `session.measure` is an event, so its pull-side twin `usage` stands in. */
60export const PRIMITIVES = [
61  'prompt.submit',
62  'command.run',
63  'session.compact',
64  'session.measure',
65  'session.send',
66] as const;
67
68/**
69 * The host's `$.session.version()` is a string on some builds and `{ version: { VERSION, BUILD_TIME, ... } }`
70 * on others (2.1.309). Read the known fields in order; never stringify the object (the build date matches too).
71 */
72export function versionString(v: unknown): string | null {
73  if (typeof v === 'string') return v;
74  if (typeof v === 'number') return String(v);
75  if (!v || typeof v !== 'object') return null;
76  const o = v as Any;
77  const inner = o.version;
78  if (inner && typeof inner === 'object' && typeof inner.VERSION === 'string') return inner.VERSION;
79  if (typeof inner === 'string') return inner;
80  if (typeof o.VERSION === 'string') return o.VERSION;
81  return null;
82}
83
84/** What a refusal line prints for the host value: the extracted string, else the object's keys, never `[object Object]`. */
85export function describeVersion(v: unknown): string {
86  const s = versionString(v);
87  if (s) return s;
88  if (v && typeof v === 'object') return `{${Object.keys(v).join(',')}}`;
89  return v == null ? 'unknown' : String(v);
90}
91
92export function parseVersion(v: unknown): number[] | null {
93  const m = /(\d+)\.(\d+)\.(\d+)/.exec(versionString(v) ?? '');
94  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
95}
96
97export function atLeast(version: unknown, floor: string): boolean {
98  const a = parseVersion(version);
99  const b = parseVersion(floor)!;
100  if (!a) return false;
101  for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
102  return true;
103}
104
105export async function safe<T>(fn: () => Promise<T> | T): Promise<{ ok: boolean; value?: T }> {
106  try {
107    return { ok: true, value: await fn() };
108  } catch {
109    return { ok: false };
110  }
111}
112
113/**
114 * False only when the primitive itself is absent: a TypeError "is not a function", or one reading the verb
115 * off a missing noun (`reading 'submit'`). The validator refuses `typeof $.noun.verb`, so the probe is a
116 * call with an argument of the wrong type (a bare string, never an object with fields); a host that rejects
117 * it, by throwing or by `{ drop }`, has the primitive and the probe did nothing.
118 */
119const PROBE_TIMEOUT_MS = 2_000;
120
121export async function exists(
122  call: () => Promise<unknown> | unknown,
123  verb: string,
124): Promise<boolean> {
125  try {
126    // AISDLC-1064: a probe that never settles still proves the primitive exists; do not wait on it.
127    let timer: any;
128    const limit = new Promise<void>((resolve) => {
129      timer = setTimeout(resolve, PROBE_TIMEOUT_MS);
130    });
131    await Promise.race([Promise.resolve().then(call), limit]);
132    clearTimeout(timer);
133    return true;
134  } catch (err) {
135    const m = err instanceof TypeError ? err.message : '';
136    return !(m.includes('is not a function') || m.includes(`reading '${verb}'`));
137  }
138}
139
140/** A `{ drop }` result means the host refused the call. */
141export function ran(r: { ok: boolean; value?: unknown }): boolean {
142  const v = r.value as Any;
143  return r.ok && !(v && typeof v === 'object' && 'drop' in v);
144}
145
146/** Append one line to the module log (`$.store`, since there is no file API) and echo it when possible. */
147export async function log(p: Pick<Ports, 'storeGet' | 'storeSet'>, line: string): Promise<void> {
148  const msg = `[hierarchy] ${line}`;
149  await safe(async () =>
150    p.storeSet(LOG_KEY, [...(((await p.storeGet(LOG_KEY)) as string[]) ?? []), msg]),
151  );
152  if (typeof console !== 'undefined') console.error(msg);
153}
154
155const ROLES = ['executor', 'operator-dispatch', 'planner'];
156
157/**
158 * The session's hierarchy role: the launch env (`HIERARCHY_ROLE`, or the `AI_SDLC_HIERARCHY_ROLE` the
159 * launcher sets), else the module option of the same name, else the role carried across a clear.
160 * INTERIM: a hooks module has no process or fs, so the env comes from `$.env.get` when the host offers it.
161 */
162export function pickRole(candidates: unknown[]): string | null {
163  for (const c of candidates) if (typeof c === 'string' && ROLES.includes(c)) return c;
164  return null;
165}
166
167/**
168 * OQ-5 guard. Returns null when the host is new enough and offers every primitive the role uses, else
169 * the one log line that names the miss and the floor. `present` maps each primitive to whether it exists.
170 */
171export function guard(
172  version: unknown,
173  present: Record<string, boolean>,
174  names: readonly string[] = PRIMITIVES,
175): string | null {
176  if (!atLeast(version, VERSION_FLOOR)) {
177    return `not registering: Claude Code ${describeVersion(version)} is below the floor ${VERSION_FLOOR} (session.version)`;
178  }
179  for (const name of names) {
180    if (!present[name]) {
181      return `not registering: primitive ${name} is missing (floor ${VERSION_FLOOR}, running ${describeVersion(version)})`;
182    }
183  }
184  return null;
185}
186
187/** Strip the cross-session envelope (and its backslash-escaped subagent form) down to the sender's text. */
188export function innerText(raw: string): string {
189  return raw.replace(/<\\?\/?(?:cross-session-message|agent-message)\b[^>]*>/g, '').trim();
190}
191
192export function senderOf(raw: string): string {
193  return /<cross-session-message\b[^>]*\bfrom="([^"]*)"/.exec(raw)?.[1] ?? '';
194}
195
196/** True when the reply's last line is the marker (a marker quoted mid-reply must not trigger rotation). */
197export function endsWithMarker(reply: string, marker: string): boolean {
198  return reply.trimEnd().endsWith(marker);
199}
200
201export const DISPATCH_NAME = /(?:^|-)operator-dispatch(?:-\d+)?$/;
202export const STATUS_NOISE = /^(status|ack|ping)\b/i;
203
204/** Tokens in the context from a `session.measure` payload. */
205export function measuredTokens(e: Any): number {
206  const direct = e?.contextTokens ?? e?.tokens ?? e?.totalTokens;
207  if (typeof direct === 'number') return direct;
208  const u = e?.usage ?? e ?? {};
209  return ['input', 'cacheWrite', 'cacheRead', 'output'].reduce(
210    (n, k) => n + (typeof u[k] === 'number' ? u[k] : 0),
211    0,
212  );
213}
214
215export interface ExecutorState {
216  /** Set by the session.start gate once the role, version and probes passed; every handler checks it. */
217  enabled?: boolean;
218  /** A task is in progress: from the first main-loop turn after a wake to its verdict or idle marker. */
219  active: boolean;
220  /** Epoch ms of the last main-loop turn event while a task runs; a stale task no longer mutes wakes. */
221  lastActivity?: number;
222  /** A mod-run clear ended the session and the resume command has not run in the new one yet. */
223  resumePending?: boolean;
224  /** A measure crossed the threshold while a task ran; rotate at the task's last turn. */
225  pendingRotate: boolean;
226  lastReply: string;
227}
228
229/**
230 * The one rotation (OQ-1): handoff write, then `clear` with the role, resume command and last reply in
231 * `$.store`; on `{ drop }` or a throw, `compact` with the hierarchy summary prompt; on a second failure,
232 * end the session so `cli-hierarchy up` starts a fresh one. Returns the path taken.
233 */
234export async function rotate(
235  p: Ports,
236  state: ExecutorState,
237  plan: RotationPlan = EXECUTOR_PLAN,
238): Promise<'clear' | 'compact' | 'exit'> {
239  const handoff = await safe(() => p.runCommand(plan.handoff));
240  if (!ran(handoff))
241    await log(p, `handoff write was refused; the ${plan.role} writes its own on resume`);
242  await p.storeSet(ROTATION_KEY, {
243    role: plan.role,
244    resume: plan.resume,
245    lastReply: state.lastReply,
246    replyAsArgs: plan.replyAsArgs === true,
247    at: Date.now(),
248  });
249  state.resumePending = true;
250  state.pendingRotate = false;
251  if (ran(await safe(() => p.runCommand({ command: 'clear' })))) {
252    await log(p, 'rotated with clear');
253    return 'clear';
254  }
255  if (ran(await safe(() => p.compact({ instructions: plan.summary })))) {
256    await p.storeSet(ROTATION_KEY, null);
257    state.resumePending = false;
258    await log(p, 'clear refused; rotated with compact');
259    return 'compact';
260  }
261  await p.storeSet(ROTATION_KEY, null);
262  state.resumePending = false;
263  await log(p, 'clear and compact refused; ending the session for cli-hierarchy up');
264  await safe(() => p.runCommand({ command: 'exit' }));
265  return 'exit';
266}
267
268/**
269 * Run the resume command in the fresh session after a mod-run clear. `session.start` does not fire after
270 * that clear (three live runs), so this runs from the first hook of the new session that does fire
271 * (`turn.start`, `session.measure`, `session.receive`) and from `session.start` if a host ever fires it.
272 */
273export async function resumeAfterClear(p: Ports, state: ExecutorState): Promise<void> {
274  if (!state.resumePending) return;
275  state.resumePending = false;
276  const carried = (await safe(() => p.storeGet(ROTATION_KEY))).value as Any;
277  await safe(() => p.storeSet(ROTATION_KEY, null));
278  if (!carried?.resume || Date.now() - Number(carried.at ?? 0) > ROTATION_TTL_MS) return;
279  state.lastReply = String(carried.lastReply ?? '');
280  const resume: { command: string; args?: string } = { command: carried.resume };
281  if (carried.replyAsArgs && state.lastReply) {
282    resume.args = `last-reply: ${state.lastReply.slice(0, LAST_REPLY_MAX_CHARS)}`;
283  }
284  const r = await safe(() => p.runCommand(resume));
285  if (ran(r)) state.active = true;
286  else await log(p, `resume after clear refused: ${JSON.stringify(r.value ?? null)}`);
287}
288
289/** True while a task is believed to run: set at a wake, cleared at the verdict, stale after ACTIVE_STALE_MS. */
290function taskRunning(state: ExecutorState): boolean {
291  if (!state.active) return false;
292  if (Date.now() - (state.lastActivity ?? 0) > ACTIVE_STALE_MS) state.active = false;
293  return state.active;
294}
295
296/** Event handlers, called by the module's single registration point (register.tsx) for the armed role. */
297export async function executorSessionReceive(
298  state: ExecutorState,
299  $: Any,
300  e: Any,
301  next: Any,
302): Promise<Any> {
303  const p: Ports = {
304    storeGet: (k) => $.store.get(k),
305    storeSet: (k, v) => $.store.set(k, v),
306    runCommand: (a) => $.command.run(a),
307    compact: (a) => $.session.compact(a),
308    submit: (a) => $.prompt.submit(a),
309  };
310  // A failure in this gating hook must never swallow a message: fall through to the model.
311  const handle = async () => {
312    if (!state.enabled) return next(e);
313    await resumeAfterClear(p, state);
314    const raw = String(e?.text ?? '');
315    const sender = senderOf(raw);
316    const text = innerText(raw);
317    if (!DISPATCH_NAME.test(sender)) return next(e);
318    if (/^enqueue\b/i.test(text)) {
319      if (!taskRunning(state)) {
320        const r = await safe(() => p.runCommand({ command: RESUME_COMMAND }));
321        if (ran(r)) {
322          state.active = true;
323          state.lastActivity = Date.now();
324        } else await log(p, `wake refused: ${JSON.stringify(r.value ?? null)}`);
325      }
326      return { consumed: 'hierarchy:enqueue' };
327    }
328    // `cli-hierarchy clear <name>` (RFC-0058 phase 4): the dispatch host delivers a rotate request.
329    if (/^rotate\b/i.test(text)) {
330      if (taskRunning(state)) state.pendingRotate = true;
331      else await rotate(p, state);
332      return { consumed: 'hierarchy:rotate' };
333    }
334    if (STATUS_NOISE.test(text)) return { consumed: 'hierarchy:status' };
335    return next(e);
336  };
337  return handle().catch(() => next(e));
338}
339
340export async function executorTurnStart(
341  state: ExecutorState,
342  $: Any,
343  e: Any,
344  next: Any,
345): Promise<Any> {
346  if (!state.enabled) return next(e);
347  const p: Ports = {
348    storeGet: (k) => $.store.get(k),
349    storeSet: (k, v) => $.store.set(k, v),
350    runCommand: (a) => $.command.run(a),
351    compact: (a) => $.session.compact(a),
352    submit: (a) => $.prompt.submit(a),
353  };
354  await resumeAfterClear(p, state);
355  if (!e?.agentId) {
356    state.active = true;
357    state.lastActivity = Date.now();
358  }
359  return next(e);
360}
361
362export async function executorTurnComplete(
363  state: ExecutorState,
364  $: Any,
365  e: Any,
366  next: Any,
367): Promise<Any> {
368  if (!state.enabled) return next(e);
369  const p: Ports = {
370    storeGet: (k) => $.store.get(k),
371    storeSet: (k, v) => $.store.set(k, v),
372    runCommand: (a) => $.command.run(a),
373    compact: (a) => $.session.compact(a),
374    submit: (a) => $.prompt.submit(a),
375  };
376  if (e?.agentId) return next(e);
377  const reply = String(e?.text ?? e?.reply ?? '');
378  if (reply) state.lastReply = reply;
379  state.lastActivity = Date.now();
380  const verdict = endsWithMarker(reply, VERDICT_MARKER);
381  if (verdict || endsWithMarker(reply, IDLE_MARKER)) state.active = false;
382  if (verdict || (state.pendingRotate && !taskRunning(state))) await rotate(p, state);
383  return next(e);
384}
385
386export async function executorSessionMeasure(
387  state: ExecutorState,
388  $: Any,
389  e: Any,
390  next: Any,
391): Promise<Any> {
392  if (!state.enabled) return next(e);
393  const p: Ports = {
394    storeGet: (k) => $.store.get(k),
395    storeSet: (k, v) => $.store.set(k, v),
396    runCommand: (a) => $.command.run(a),
397    compact: (a) => $.session.compact(a),
398    submit: (a) => $.prompt.submit(a),
399  };
400  await resumeAfterClear(p, state);
401  if (measuredTokens(e) >= ROTATE_AT_TOKENS) {
402    if (taskRunning(state)) state.pendingRotate = true;
403    else await rotate(p, state);
404  }
405  return next(e);
406}
407
hooks/hierarchy/dispatch.ts 476 lines
1// RFC-0058 phase 2 (AISDLC-1035): the dispatch role of the hierarchy hooks module, a model-less host (OQ-6).
2// It runs today's `cli-hierarchy tick` on `$.clock.every`, consumes verdict, PR-landed and status messages in
3// `session.receive`, renders the board pane and wakes executors with `$.session.send`. It NEVER submits a
4// prompt to its model: there is no `prompt.submit` port here, so no code path can. Same call-site rule as
5// executor.ts: `$` is only spelled `$.noun.verb(...)` in the handler that builds the ports.
6import { innerText, log, measuredTokens, safe, senderOf } from './executor.ts';
7import type { Ports } from './executor.ts';
8
9type Any = any;
10
11/** The primitives the dispatch role uses (the executor role's `prompt.submit` is deliberately absent). */
12export const DISPATCH_PRIMITIVES = [
13  'clock.every',
14  'process.run',
15  'session.send',
16  'ui.open',
17  'session.measure',
18] as const;
19
20/** Default interval of the tick timer, the current `nextWakeSec` floor of the dispatch loop. */
21export const DEFAULT_TICK_MS = 30_000;
22/** Context size, in tokens, at which the host rotates (120k, OQ-1); rarely reached because it has no model turns. */
23export const DISPATCH_ROTATE_AT_TOKENS = 120_000;
24
25const NODE = 'node';
26const HIERARCHY_BIN = 'pipeline-cli/bin/cli-hierarchy.mjs';
27const CI_BIN = 'pipeline-cli/bin/cli-ci.mjs';
28const DISPATCH_BIN = 'pipeline-cli/bin/cli-dispatch.mjs';
29const TASK_ID = /\b[A-Z][A-Z0-9]*-\d+(?:\.\d+)*\b/;
30const PLANNER_ADDRESSED = /^(?:to\s+planner|planner)\s*[:,-]\s*/i;
31
32export const BOARD_KEY = 'hierarchy/dispatch-board';
33
34/** The host calls the dispatch role makes, each spelled `$.noun.verb(...)` in the handler that builds it. */
35export interface DispatchPorts extends Omit<Ports, 'compact' | 'submit'> {
36  runProcess: (a: { command: string; args: string[] }) => Promise<Any>;
37  send: (a: { to: string; text: string }) => Promise<Any>;
38  uiOpen: (a: { id: string; title: string }) => Promise<Any>;
39}
40
41export interface DispatchOptions {
42  tickMs?: number;
43  plannerName?: string;
44  boardDir?: string;
45}
46
47export interface DispatchState {
48  enabled?: boolean;
49  /** The pane text `ui.render` returns; rebuilt on every tick and consumed event. */
50  pane: string[];
51  /** Recent event lines shown under the board summary (replaces the model-written tick notes). */
52  events: string[];
53  /** Blockers of the last tick, shown in the pane. */
54  blockers: string[];
55  plannerName: string;
56  boardDir?: string;
57  /** Roster name of this session; keys the module heartbeat file. */
58  sessionName?: string;
59  /** Host version recorded in the heartbeat. */
60  hostVersion?: string;
61  uiOpened?: boolean;
62  /** Last blockers text sent to the planner; a changed list is announced once. */
63  lastBlockers: string;
64  /** Text of the last tick failure escalated; the same failure is escalated once, not on every timer fire. */
65  lastTickFailure: string;
66  /** Number of in-flight ticks: a slow tick must not overlap the next. */
67  ticking: boolean;
68}
69
70export function newDispatchState(options?: DispatchOptions): DispatchState {
71  return {
72    pane: ['board: waiting for the first tick'],
73    events: [],
74    blockers: [],
75    plannerName: options?.plannerName ?? 'planner',
76    ...(options?.boardDir ? { boardDir: options.boardDir } : {}),
77    lastBlockers: '',
78    lastTickFailure: '',
79    ticking: false,
80  };
81}
82
83/** Writes the file named by argv[1] (creating its directory) with argv[2]. */
84const WRITE_SCRIPT =
85  "const fs=require('fs');fs.mkdirSync(require('path').dirname(process.argv[1]),{recursive:true});fs.writeFileSync(process.argv[1],process.argv[2])";
86
87/**
88 * AISDLC-1061: record whether the module loaded in `<board-dir>/handoff/module-<name>.json`, which
89 * `cli-hierarchy status`, `up` and `doctor` read. Never throws: a host without `process.run` just leaves
90 * the file absent, which those commands report as `absent`.
91 */
92export async function writeModuleHealth(
93  run: (a: { command: string; args: string[] }) => Promise<Any>,
94  boardDir: string | undefined,
95  name: string,
96  health: { state: 'loaded' | 'refused'; reason?: string; version?: string },
97): Promise<void> {
98  const file = `${boardDir ?? '.ai-sdlc/dispatch'}/handoff/module-${name}.json`;
99  const body = JSON.stringify({ ...health, at: new Date().toISOString() });
100  await safe(() => run({ command: NODE, args: ['-e', WRITE_SCRIPT, file, body] }));
101}
102
103const EVENT_LIMIT = 10;
104
105function note(state: DispatchState, line: string): void {
106  state.events = [...state.events, line].slice(-EVENT_LIMIT);
107}
108
109function boardArgs(state: DispatchState): string[] {
110  return state.boardDir ? ['--board-dir', state.boardDir] : [];
111}
112
113/** Run a pipeline-cli bin through `$.process.run`; never throws. stdout is returned verbatim. */
114export async function runCli(
115  p: DispatchPorts,
116  bin: string,
117  args: string[],
118): Promise<{ ok: boolean; stdout: string; stderr: string }> {
119  const r = await safe(() => p.runProcess({ command: NODE, args: [bin, ...args] }));
120  const v = (r.value ?? {}) as Any;
121  const code = v.exitCode ?? v.status ?? v.code ?? (r.ok ? 0 : 1);
122  return { ok: r.ok && code === 0, stdout: String(v.stdout ?? ''), stderr: String(v.stderr ?? '') };
123}
124
125function parseJson(text: string): Any {
126  try {
127    return JSON.parse(text.trim().split('\n').filter(Boolean).pop() ?? '');
128  } catch {
129    return null;
130  }
131}
132
133/** Build the pane from `cli-hierarchy status --json` (roster, queue, inflight, done, failed) and the blockers. */
134export function renderBoard(status: Any, blockers: string[], events: string[]): string[] {
135  const out: string[] = ['== roster =='];
136  const rows: Any[] = Array.isArray(status?.rows) ? status.rows : [];
137  if (rows.length === 0) out.push('(no sessions)');
138  for (const r of rows) {
139    out.push(
140      `${r.entry?.role ?? '?'} ${r.entry?.name ?? '?'} ${r.state ?? '?'}` +
141        (r.inflightTask ? ` inflight ${r.inflightTask}` : ''),
142    );
143  }
144  const b = status?.board ?? {};
145  out.push('== queue ==', `queued ${b.queued ?? 0}`);
146  out.push('== inflight ==', `inflight ${b.inflight ?? 0}`);
147  for (const r of rows) if (r.inflightTask) out.push(`${r.entry?.name}: ${r.inflightTask}`);
148  out.push('== done ==', `done ${b.done ?? 0}`);
149  out.push('== failed ==', `failed ${b.failed ?? 0}`);
150  out.push('== blockers ==');
151  const all = [
152    ...blockers,
153    ...((status?.blocked ?? []) as Any[]).map((x) => `${x.taskId}: ${x.reason}`),
154  ];
155  if (all.length === 0) out.push('none');
156  else out.push(...all);
157  if (events.length) out.push('== events ==', ...events);
158  return out;
159}
160
161async function currentStatus(p: DispatchPorts, state: DispatchState): Promise<Any> {
162  const r = await runCli(p, HIERARCHY_BIN, ['status', '--json', ...boardArgs(state)]);
163  return r.ok ? parseJson(r.stdout) : null;
164}
165
166/** Rebuild the pane from the board and persist it in `$.store` so a rotation keeps it. */
167export async function refreshPane(p: DispatchPorts, state: DispatchState): Promise<Any> {
168  const status = await currentStatus(p, state);
169  state.pane = renderBoard(status, state.blockers, state.events);
170  await safe(() => p.storeSet(BOARD_KEY, state.pane));
171  return status;
172}
173
174export const REFRESH_TIMEOUT_MS = 20_000;
175
176/** Run `fn`; resolve `ok` or the failure reason. Never throws. */
177export async function attempt(fn: () => Promise<Any> | Any): Promise<string> {
178  try {
179    await fn();
180    return 'ok';
181  } catch (err) {
182    return String((err as Any)?.message ?? err ?? 'rejected');
183  }
184}
185
186/** `refreshPane` bounded by a timeout so a hung `cli-hierarchy status` cannot wedge the pane. */
187export async function refreshPaneBounded(
188  p: DispatchPorts,
189  state: DispatchState,
190  ms: number = REFRESH_TIMEOUT_MS,
191): Promise<string> {
192  let timer: Any;
193  const limit = new Promise<string>((resolve) => {
194    timer = setTimeout(() => resolve(`timed out after ${ms} ms`), ms);
195  });
196  const work = attempt(async () => {
197    const status = await refreshPane(p, state);
198    if (status == null) throw new Error('status unavailable');
199  });
200  const r = await Promise.race([work, limit]);
201  clearTimeout(timer);
202  return r;
203}
204
205/** Wake one executor by roster name. The executor module consumes `enqueue ...` from the dispatch session. */
206export async function wake(p: DispatchPorts, name: string, taskId: string): Promise<boolean> {
207  const r = await safe(() => p.send({ to: name, text: `enqueue ${taskId}` }));
208  const v = r.value as Any;
209  return r.ok && !(v && typeof v === 'object' && 'drop' in v);
210}
211
212async function wakeIdleExecutors(
213  p: DispatchPorts,
214  state: DispatchState,
215  status: Any,
216  taskIds: string[],
217): Promise<void> {
218  const idle: string[] = ((status?.rows ?? []) as Any[])
219    .filter((r) => r.entry?.role === 'executor' && r.state === 'idle' && !r.inflightTask)
220    .map((r) => String(r.entry.name));
221  for (let i = 0; i < Math.min(idle.length, taskIds.length); i++) {
222    const ok = await wake(p, idle[i], taskIds[i]);
223    note(state, ok ? `woke ${idle[i]} for ${taskIds[i]}` : `wake of ${idle[i]} refused`);
224  }
225}
226
227/** Tell the planner something, by roster name. The dispatch model is never prompted. */
228export async function tellPlanner(p: DispatchPorts, state: DispatchState, text: string) {
229  const r = await safe(() => p.send({ to: state.plannerName, text }));
230  if (!r.ok) await log(p, `send to ${state.plannerName} failed`);
231}
232
233/** Write a board escalation file for a result the policy tables cannot classify; announce it once. */
234export async function escalate(
235  p: DispatchPorts,
236  state: DispatchState,
237  taskId: string,
238  message: string,
239): Promise<void> {
240  const r = await runCli(p, HIERARCHY_BIN, [
241    'escalation-file',
242    '--task-id',
243    taskId,
244    '--message',
245    message,
246    ...boardArgs(state),
247  ]);
248  const file = (parseJson(r.stdout) as Any)?.file ?? '(not written)';
249  note(state, `escalated ${taskId} to ${state.plannerName}`);
250  await tellPlanner(
251    p,
252    state,
253    `escalation for ${taskId}: ${message}. Failure record, log tail and PR check summary: ${file}`,
254  );
255}
256
257/** One tick: run `cli-hierarchy tick`, wake executors for ingested work, escalate, refresh the pane. */
258export async function tick(p: DispatchPorts, state: DispatchState): Promise<void> {
259  if (state.ticking) return;
260  state.ticking = true;
261  try {
262    if (state.sessionName) {
263      await writeModuleHealth(p.runProcess, state.boardDir, state.sessionName, {
264        state: 'loaded',
265        ...(state.hostVersion ? { version: state.hostVersion } : {}),
266      });
267    }
268    const rotation = await rotateRequested(p, state);
269    for (const name of rotation.forward) {
270      const sent = await safe(() => p.send({ to: name, text: 'rotate' }));
271      note(state, sent.ok ? `asked ${name} to rotate` : `rotate request to ${name} failed`);
272    }
273    if (rotation.self) {
274      await rotateHost(p, state);
275      return;
276    }
277    const r = await runCli(p, HIERARCHY_BIN, ['tick', ...boardArgs(state)]);
278    const result = parseJson(r.stdout) as Any;
279    if (!r.ok || !result || typeof result !== 'object') {
280      note(state, `tick failed: ${(r.stderr || r.stdout).split('\n')[0] || 'no output'}`);
281      const failure = `cli-hierarchy tick did not return a result: ${r.stderr || r.stdout}`.slice(
282        0,
283        500,
284      );
285      if (failure !== state.lastTickFailure) {
286        state.lastTickFailure = failure;
287        await escalate(p, state, 'TICK', failure);
288      }
289      await refreshPane(p, state);
290      return;
291    }
292    state.lastTickFailure = '';
293    state.blockers = Array.isArray(result.blockers) ? result.blockers.map(String) : [];
294    const ingested: string[] = ((result.ingested ?? []) as Any[]).flatMap((x) => x.tasks ?? []);
295    note(
296      state,
297      `tick: ${ingested.length} ingested, ${(result.verdicts ?? []).length} verdicts, ${result.wakeReason ?? ''}`.trim(),
298    );
299    for (const e of (result.escalations ?? []) as Any[]) {
300      await escalate(p, state, String(e.taskId ?? 'UNKNOWN'), String(e.message ?? ''));
301    }
302    for (const rep of (result.reports ?? []) as Any[]) {
303      await tellPlanner(
304        p,
305        state,
306        typeof rep === 'string' ? rep : String(rep.text ?? rep.message ?? JSON.stringify(rep)),
307      );
308    }
309    const blockersText = state.blockers.join('\n');
310    if (blockersText !== state.lastBlockers) {
311      state.lastBlockers = blockersText;
312      if (blockersText) await tellPlanner(p, state, `blockers changed:\n${blockersText}`);
313    }
314    // The old loop ran the PR reconciler and the CI sweep after every tick; `cli-ci sweep` throttles itself to 15 minutes.
315    const rec = await runCli(p, DISPATCH_BIN, ['reconcile', ...boardArgs(state)]);
316    if (!rec.ok) note(state, 'reconcile failed');
317    else if (rec.stdout.includes('route-to-planner')) {
318      await tellPlanner(p, state, `reconciler routed a PR to you:\n${rec.stdout.slice(0, 1500)}`);
319    }
320    const sweep = await runCli(p, CI_BIN, ['sweep', ...boardArgs(state)]);
321    if (!sweep.ok) note(state, 'cli-ci sweep failed');
322    const status = await refreshPane(p, state);
323    if (ingested.length) {
324      await wakeIdleExecutors(p, state, status, ingested);
325      await refreshPane(p, state);
326    }
327  } finally {
328    state.ticking = false;
329  }
330}
331
332/**
333 * Rotate requests that `cli-hierarchy clear` wrote; the CLI deletes them as it answers (the deletion is the
334 * acknowledgement). `self` is a request for this host; `forward` names the sessions to tell with `$.session.send`.
335 */
336export async function rotateRequested(
337  p: DispatchPorts,
338  state: DispatchState,
339): Promise<{ self: boolean; forward: string[] }> {
340  const r = await runCli(p, HIERARCHY_BIN, ['rotate-pending', ...boardArgs(state)]);
341  const out = r.ok ? (parseJson(r.stdout) as Any) : null;
342  const forward = Array.isArray(out?.forward)
343    ? (out.forward as unknown[]).filter((n): n is string => typeof n === 'string')
344    : [];
345  return { self: out?.pending === true, forward };
346}
347
348export type Classified =
349  | { kind: 'verdict'; taskId: string; text: string }
350  | { kind: 'landed'; pr: string; text: string }
351  | { kind: 'status'; text: string }
352  | { kind: 'planner'; text: string }
353  | { kind: 'unknown'; text: string };
354
355/** Classify a message by its text; the sender is recorded for the pane but never trusted for authority. */
356export function classify(text: string): Classified {
357  const planner = PLANNER_ADDRESSED.exec(text);
358  if (planner) return { kind: 'planner', text: text.slice(planner[0].length) };
359  if (/^verdict\b/i.test(text)) {
360    const id = TASK_ID.exec(text)?.[0];
361    if (id) return { kind: 'verdict', taskId: id, text };
362  }
363  if (/^(?:pr[- ]?landed|landed)\b/i.test(text)) {
364    const pr = /#?(\d+)/.exec(text)?.[1];
365    if (pr) return { kind: 'landed', pr, text };
366  }
367  if (/^(status|ack|ping|idle)\b/i.test(text)) return { kind: 'status', text };
368  return { kind: 'unknown', text };
369}
370
371/** Consume one message. Returns the label the hook reports as consumed; never prompts the model. */
372export async function handleMessage(
373  p: DispatchPorts,
374  state: DispatchState,
375  raw: string,
376): Promise<string> {
377  const sender = senderOf(raw) || 'unknown';
378  const text = innerText(raw);
379  const c = classify(text);
380  switch (c.kind) {
381    case 'planner':
382      await tellPlanner(p, state, `from ${sender}: ${c.text}`);
383      note(state, `forwarded ${sender} message to ${state.plannerName}`);
384      break;
385    case 'verdict': {
386      const r = await runCli(p, DISPATCH_BIN, [
387        'collect-verdicts',
388        '--include-failed',
389        'true',
390        ...boardArgs(state),
391      ]);
392      note(state, `verdict ${c.taskId} from ${sender}${r.ok ? '' : ' (collect failed)'}`);
393      // The tick is the policy applier for verdicts (clear, unblocking playbook); run it now, not at the next timer.
394      await tick(p, state);
395      return 'hierarchy:verdict';
396    }
397    case 'landed': {
398      const r = await runCli(p, DISPATCH_BIN, ['reconcile', '--pr', c.pr, ...boardArgs(state)]);
399      note(state, `PR #${c.pr} landed${r.ok ? '' : ' (reconcile failed)'}`);
400      break;
401    }
402    case 'status':
403      note(state, `status from ${sender}: ${c.text.slice(0, 80)}`);
404      break;
405    default: {
406      // Not a shape the tables know: write it for the planner instead of prompting the model.
407      const id = TASK_ID.exec(text)?.[0] ?? 'UNKNOWN';
408      await escalate(p, state, id, `unclassified message from ${sender}: ${text.slice(0, 300)}`);
409    }
410  }
411  await refreshPane(p, state);
412  return `hierarchy:${c.kind}`;
413}
414
415/** Rotate the host: handoff write, then clear. The host has no summary to lose, so there is no compact fallback. */
416export async function rotateHost(p: DispatchPorts, state: DispatchState): Promise<void> {
417  await safe(() =>
418    p.runCommand({ command: 'ai-sdlc:hierarchy', args: 'handoff write --role operator-dispatch' }),
419  );
420  await p.storeSet(BOARD_KEY, state.pane);
421  note(state, 'rotating');
422  await safe(() => p.runCommand({ command: 'clear' }));
423}
424
425/** Event handlers, called by the module's single registration point (register.tsx) for the armed role. */
426export async function dispatchSessionReceive(
427  state: DispatchState,
428  $: Any,
429  e: Any,
430  next: Any,
431): Promise<Any> {
432  const p: DispatchPorts = {
433    storeGet: (k) => $.store.get(k),
434    storeSet: (k, v) => $.store.set(k, v),
435    runCommand: (a) => $.command.run(a),
436    runProcess: (a) => $.process.run(a),
437    send: (a) => $.session.send(a),
438    uiOpen: (a) => $.ui.open(a),
439  };
440  if (!state.enabled) return next(e);
441  // A failure here must never prompt the model: consume the message and let the next tick show the state.
442  return handleMessage(p, state, String(e?.text ?? '')).then(
443    (consumed) => ({ consumed }),
444    () => ({ consumed: 'hierarchy:error' }),
445  );
446}
447
448export async function dispatchUiRender(
449  state: DispatchState,
450  _$: Any,
451  e: Any,
452  next: Any,
453): Promise<Any> {
454  if (!state.enabled) return next(e);
455  return { lines: state.pane, text: state.pane.join('\n') };
456}
457
458export async function dispatchSessionMeasure(
459  state: DispatchState,
460  $: Any,
461  e: Any,
462  next: Any,
463): Promise<Any> {
464  if (!state.enabled) return next(e);
465  const p: DispatchPorts = {
466    storeGet: (k) => $.store.get(k),
467    storeSet: (k, v) => $.store.set(k, v),
468    runCommand: (a) => $.command.run(a),
469    runProcess: (a) => $.process.run(a),
470    send: (a) => $.session.send(a),
471    uiOpen: (a) => $.ui.open(a),
472  };
473  if (measuredTokens(e) >= DISPATCH_ROTATE_AT_TOKENS) await rotateHost(p, state);
474  return next(e);
475}
476
hooks/hierarchy/planner.ts 169 lines
1// RFC-0058 phase 3 (AISDLC-1036): the planner role of the hierarchy hooks module.
2// Same constraints as executor.ts: `$` is only ever spelled `$.noun.verb(...)` at the call site, so each
3// handler builds a `Ports` object of closures and every function here takes the ports, never `$`.
4import {
5  ACTIVE_STALE_MS,
6  DISPATCH_NAME,
7  STATUS_NOISE,
8  innerText,
9  log,
10  measuredTokens,
11  rotate,
12  resumeAfterClear,
13  safe,
14  senderOf,
15} from './executor.ts';
16import type { ExecutorState, Ports, RotationPlan } from './executor.ts';
17
18type Any = any;
19
20/** Context size, in tokens, at which the planner rotates (OQ-1). */
21export const PLANNER_ROTATE_AT_TOKENS = 150_000;
22export const PLANNER_RESUME_COMMAND = 'ai-sdlc:planner';
23export const PLANNER_PLAN: RotationPlan = {
24  role: 'planner',
25  resume: PLANNER_RESUME_COMMAND,
26  handoff: { command: 'ai-sdlc:hierarchy', args: 'handoff write --role planner' },
27  summary:
28    'Summarise for the hierarchy planner: its role (planner), the board directory, the open ' +
29    'briefs and rulings, and that its next step is /ai-sdlc:planner followed by handoff read.',
30  replyAsArgs: true,
31};
32
33/** `$.store` key of a session's last reply, under its roster name; replaces LAST_MESSAGE.md (AISDLC-914). */
34export const lastReplyKey = (name: string): string => `hierarchy/last-reply/${name}`;
35
36export interface PlannerState extends ExecutorState {
37  /** Roster name of this session (the launcher's `AI_SDLC_HIERARCHY_SESSION`), else the role. */
38  name: string;
39  /** A main-thread turn is running: rotation waits for its `turn.complete`. */
40  inTurn: boolean;
41  /** Epoch ms of the last main-thread turn.start; a turn older than ACTIVE_STALE_MS no longer defers rotation. */
42  turnStartedAt?: number;
43}
44
45/** True while a main-thread turn is believed to run; stale after ACTIVE_STALE_MS so a lost turn.complete cannot block rotation. */
46function turnRunning(state: PlannerState): boolean {
47  if (!state.inTurn) return false;
48  if (Date.now() - (state.turnStartedAt ?? 0) > ACTIVE_STALE_MS) state.inTurn = false;
49  return state.inTurn;
50}
51
52/** The shared rotation with the planner's plan; the stored last reply is read back if memory lost it. */
53export async function rotatePlanner(
54  p: Ports,
55  state: PlannerState,
56  plan: RotationPlan = PLANNER_PLAN,
57): Promise<void> {
58  if (!state.lastReply) {
59    const stored = (await safe(() => p.storeGet(lastReplyKey(state.name)))).value;
60    if (typeof stored === 'string') state.lastReply = stored;
61  }
62  const path = await rotate(p, state, plan);
63  if (path === 'exit') await log(p, `${plan.role} rotation ended the session`);
64}
65
66/** Event handlers, called by the module's single registration point (register.tsx) for the armed role. */
67export async function plannerSessionReceive(
68  state: PlannerState,
69  $: Any,
70  e: Any,
71  next: Any,
72): Promise<Any> {
73  const p: Ports = {
74    storeGet: (k) => $.store.get(k),
75    storeSet: (k, v) => $.store.set(k, v),
76    runCommand: (a) => $.command.run(a),
77    compact: (a) => $.session.compact(a),
78    submit: (a) => $.prompt.submit(a),
79  };
80  // A failure in this gating hook must never swallow a message: fall through to the model.
81  const handle = async () => {
82    if (!state.enabled) return next(e);
83    await resumeAfterClear(p, state);
84    const raw = String(e?.text ?? '');
85    if (DISPATCH_NAME.test(senderOf(raw))) {
86      const text = innerText(raw);
87      // `cli-hierarchy clear planner` (RFC-0058 phase 4): rotate now, or at the end of the turn.
88      if (/^rotate\b/i.test(text)) {
89        if (turnRunning(state)) state.pendingRotate = true;
90        else await rotatePlanner(p, state, PLANNER_PLAN);
91        return { consumed: 'hierarchy:rotate' };
92      }
93      if (STATUS_NOISE.test(text)) return { consumed: 'hierarchy:status' };
94    }
95    return next(e);
96  };
97  return handle().catch(() => next(e));
98}
99
100export async function plannerTurnStart(
101  state: PlannerState,
102  $: Any,
103  e: Any,
104  next: Any,
105): Promise<Any> {
106  if (!state.enabled) return next(e);
107  const p: Ports = {
108    storeGet: (k) => $.store.get(k),
109    storeSet: (k, v) => $.store.set(k, v),
110    runCommand: (a) => $.command.run(a),
111    compact: (a) => $.session.compact(a),
112    submit: (a) => $.prompt.submit(a),
113  };
114  await resumeAfterClear(p, state);
115  if (!e?.agentId) {
116    state.inTurn = true;
117    state.turnStartedAt = Date.now();
118  }
119  return next(e);
120}
121
122export async function plannerTurnComplete(
123  state: PlannerState,
124  $: Any,
125  e: Any,
126  next: Any,
127): Promise<Any> {
128  if (!state.enabled) return next(e);
129  const p: Ports = {
130    storeGet: (k) => $.store.get(k),
131    storeSet: (k, v) => $.store.set(k, v),
132    runCommand: (a) => $.command.run(a),
133    compact: (a) => $.session.compact(a),
134    submit: (a) => $.prompt.submit(a),
135  };
136  if (e?.agentId) return next(e);
137  state.inTurn = false;
138  const reply = String(e?.text ?? e?.reply ?? '');
139  if (reply) {
140    state.lastReply = reply;
141    await safe(() => p.storeSet(lastReplyKey(state.name), reply));
142  }
143  if (state.pendingRotate) await rotatePlanner(p, state, PLANNER_PLAN);
144  return next(e);
145}
146
147export async function plannerSessionMeasure(
148  state: PlannerState,
149  $: Any,
150  e: Any,
151  next: Any,
152): Promise<Any> {
153  if (!state.enabled) return next(e);
154  const p: Ports = {
155    storeGet: (k) => $.store.get(k),
156    storeSet: (k, v) => $.store.set(k, v),
157    runCommand: (a) => $.command.run(a),
158    compact: (a) => $.session.compact(a),
159    submit: (a) => $.prompt.submit(a),
160  };
161  await resumeAfterClear(p, state);
162  if (measuredTokens(e) >= PLANNER_ROTATE_AT_TOKENS) {
163    // Never mid-turn: a measure that lands while the main thread runs waits for its turn.complete.
164    if (turnRunning(state)) state.pendingRotate = true;
165    else await rotatePlanner(p, state, PLANNER_PLAN);
166  }
167  return next(e);
168}
169