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

Claude Code plugin providing governance rules, review subagents, and MCP tools for the AI-SDLC framework.
agents/)Plugin subagents are .md files with YAML frontmatter declaring tool grants, the harness, and the agent role.
Harness note: Claude Code filters the
Agenttool out of plugin subagent sessions one level deep — plugin subagents cannot spawn other subagents. The/ai-sdlc executeslash command (main session) spawns the developer + reviewers directly.
| Agent | Harness | Description |
|---|---|---|
developer | claude-code | Implements backlog tasks end-to-end: plan, code, verify, commit, push, open PR |
Run inside Claude Code sessions. Spawned by /ai-sdlc execute Step 7b.
| Agent | Model | Description |
|---|---|---|
code-reviewer | sonnet | Code quality review: bugs, logic errors, conventions |
test-reviewer | sonnet | Test coverage review: existence, quality, edge cases |
security-reviewer | opus | Security review: OWASP vulnerabilities, injection, secret exposure |
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.
| Agent | Harness | Description |
|---|---|---|
code-reviewer-codex | codex | Code quality review via Codex CLI (codex exec --model o4-mini) |
test-reviewer-codex | codex | Test coverage review via Codex CLI (codex exec --model o4-mini) |
Why no
security-reviewer-codex? Security review stays on Claude Opus (perfeedback_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.
| Agent | Harness | Description |
|---|---|---|
rebase-resolver | claude-code | Resolves mechanical rebase conflicts (CHANGELOG, lock files, prettier drift) |
refinement-reviewer | claude-code | Stage B Definition-of-Ready evaluator (RFC-0011 Phase 2b semantic gates) |
commands/)| Command | Description |
|---|---|
/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-pr | Standalone review pass on the current branch |
/ai-sdlc rebase <pr> | Mechanical rebase + re-sign of an open PR |
/ai-sdlc triage | Issue triage (DOR evaluation + PPA trust) |
/ai-sdlc pipeline-status | Pipeline status summary |
/ai-sdlc cleanup [<task-id>] | Remove stale worktrees |
skills/)| Skill | Description |
|---|---|
ai-sdlc-governance | Auto-loaded governance rules, blocked actions, and pre-commit checklist |
decision-rubric | Five-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) |
Slash command bodies invoke @ai-sdlc/pipeline-cli CLIs and plugin-internal scripts. They must work across six distinct install topologies:
| # | Topology | CLAUDE_PLUGIN_DIR | CLAUDE_PLUGIN_ROOT | pipeline-cli location |
|---|---|---|---|---|
| 1 | Remote marketplace install (bundled deps) | Set — deps present | Set | $CLAUDE_PLUGIN_DIR/node_modules/@ai-sdlc/pipeline-cli/ |
| 2 | Local marketplace install (no npm install) | Set — deps missing | Set | Self-heal via install-runtime-deps.sh, then probe cache |
| 3 | Marketplace (env injection variant) | Unset | Set — deps present | $CLAUDE_PLUGIN_ROOT/node_modules/@ai-sdlc/pipeline-cli/ |
| 4 | Plugin cache probe (env unset) | Unset | Unset | ~/.claude/plugins/cache/<mp>/ai-sdlc/<version>/node_modules/@ai-sdlc/pipeline-cli/ (read-only — never self-heals) |
| 5 | Dogfood monorepo (this repo) | Unset | Unset | $(pwd)/pipeline-cli/ relative to repo root |
| 6 | Self-location fallback (AISDLC-557, last resort) | Unset | Unset | Self-heal against the directory resolve-pipeline-cli.sh itself lives in |
Why topology 2 exists: The local marketplace installer (
/claude plugin installagainst a localmarketplace.json) copies plugin files to~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/but does NOT runnpm install. SoruntimeDependenciesdeclared inplugin.jsonare never installed for local marketplace setups. Thescripts/install-runtime-deps.shself-heal script fills this gap.
Why topology 6 exists (AISDLC-557): a second adopter report found that when
CLAUDE_PLUGIN_DIRandCLAUDE_PLUGIN_ROOTare 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 inresolve-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 fromresolve-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.
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
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"
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.
/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:
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./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.
/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:
~/.ai-sdlc/signing-key.pem (run /ai-sdlc init-signing-key), with its public key listed in .ai-sdlc/trusted-reviewers.yaml.@ai-sdlc/orchestrator and @ai-sdlc/pipeline-cli resolvable from the repo (plugin install runs install-runtime-deps.sh, or pnpm add -D them).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.
/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:
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.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.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
See docs/operations/cross-harness-review.md for the bidirectional convention, cost/latency comparison, and Codex CLI prerequisites.
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.
# 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 testhooks/hierarchy/register.tsx 247 lines1// 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};
247hooks/hierarchy/executor.ts 407 lines1// 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}
407hooks/hierarchy/dispatch.ts 476 lines1// 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}
476hooks/hierarchy/planner.ts 169 lines1// 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