pi-legion 4.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +168 -1
  2. package/README.md +101 -44
  3. package/agents/centurion.md +20 -16
  4. package/agents/scout.md +3 -1
  5. package/agents/worker.md +5 -7
  6. package/docs/contracts/declarative-phase-machine-v5.md +128 -0
  7. package/docs/contracts/subagent-renewal-v1.md +260 -0
  8. package/docs/headless-child-contract.md +6 -3
  9. package/docs/inprocess-supervisor.md +70 -24
  10. package/docs/legion-v2-contract.md +9 -0
  11. package/docs/spawn-mode-visibility-v1.md +4 -1
  12. package/index.ts +598 -44
  13. package/package.json +2 -2
  14. package/src/agents/agents.ts +293 -36
  15. package/src/agents/overrides.ts +3 -1
  16. package/src/agents/settings.ts +6 -18
  17. package/src/extension/child-guard.ts +25 -1
  18. package/src/extension/legion-tools.ts +66 -21
  19. package/src/extension/notify.ts +136 -4
  20. package/src/extension/playbook.ts +2 -2
  21. package/src/extension/runtime.ts +56 -1
  22. package/src/extension/summary.ts +13 -4
  23. package/src/extension/timing.ts +8 -4
  24. package/src/legion/phase-conditions.ts +164 -0
  25. package/src/legion/phase-machine.ts +403 -0
  26. package/src/legion/state-machine.ts +219 -115
  27. package/src/runs/args.ts +32 -18
  28. package/src/runs/extension-inheritance.ts +220 -0
  29. package/src/runs/orchestrator.ts +137 -44
  30. package/src/runs/progress-signal.ts +137 -0
  31. package/src/runs/store-lookup.ts +270 -0
  32. package/src/shared/types.ts +88 -3
  33. package/src/supervisor/child-context.ts +19 -0
  34. package/src/supervisor/inprocess-supervisor.ts +29 -5
  35. package/src/supervisor/routing-supervisor.ts +2 -1
  36. package/src/supervisor/rpc-supervisor.ts +2 -2
  37. package/src/tui/tree-view.ts +56 -0
  38. package/src/tui/viz/palette.ts +0 -3
  39. package/agents/advisor.md +0 -110
  40. package/agents/designer.md +0 -87
  41. package/agents/planner.md +0 -53
  42. package/agents/prototype.md +0 -72
  43. package/agents/reviewer.md +0 -113
@@ -38,6 +38,7 @@ import {
38
38
  isTerminalPhase,
39
39
  type LegionPhase,
40
40
  } from "../legion/state-machine.ts";
41
+ import { isTerminalIn, type PhaseMachine } from "../legion/phase-machine.ts";
41
42
  import { upsertUsage } from "../legion/usage.ts";
42
43
 
43
44
  const POSTMAN_INTERVAL_MS = 2_000;
@@ -66,6 +67,8 @@ export interface LegionPostmanOptions {
66
67
  resumeChild?: (node: LegionNode, mail: LegionMailCommand) => Promise<void>;
67
68
  now?: () => number;
68
69
  intervalMs?: number;
70
+ /** #298: the team root's declared phase machine (absent = built-in). */
71
+ machine?: PhaseMachine;
69
72
  }
70
73
 
71
74
  /** Poll and deliver mail only for children hosted by this process. */
@@ -78,6 +81,7 @@ export class LegionPostman {
78
81
  private readonly sendRootMessage: (text: string) => void;
79
82
  private readonly sendSelfMessage: (text: string) => void;
80
83
  private readonly resumeChild?: LegionPostmanOptions["resumeChild"];
84
+ private readonly machine?: PhaseMachine;
81
85
  private readonly now: () => number;
82
86
  private readonly intervalMs: number;
83
87
  private timer: ReturnType<typeof setInterval> | undefined;
@@ -94,6 +98,7 @@ export class LegionPostman {
94
98
  this.sendRootMessage = options.sendRootMessage ?? (() => {});
95
99
  this.sendSelfMessage = options.sendSelfMessage ?? (() => {});
96
100
  this.resumeChild = options.resumeChild;
101
+ this.machine = options.machine;
97
102
  this.now = options.now ?? Date.now;
98
103
  this.intervalMs = options.intervalMs ?? POSTMAN_INTERVAL_MS;
99
104
  }
@@ -295,9 +300,23 @@ export class LegionPostman {
295
300
  return false;
296
301
  }
297
302
 
303
+ /**
304
+ * Terminal check that respects the team's own machine (#298).
305
+ *
306
+ * The old code asked `isTerminalPhase`, which only knew the three built-in
307
+ * terminal names. Under a declared machine a phase is terminal because the
308
+ * DECLARATION says so (`terminal: true` or no exits), so a custom name would
309
+ * otherwise be treated as non-terminal and the auto-finish paths would act on
310
+ * a team that had already finished.
311
+ */
312
+ private isTerminalPhaseOf(phase: string): boolean {
313
+ if (this.machine) return isTerminalIn(this.machine, phase);
314
+ return isTerminalPhase(phase);
315
+ }
316
+
298
317
  private failTeamIfAllChildrenFailed(timestamp: number): void {
299
318
  const node = getNode(this.db, this.nodeId);
300
- if (!node?.phase || !isPhase(node.phase) || isTerminalPhase(node.phase)) return;
319
+ if (!node?.phase || this.isTerminalPhaseOf(node.phase)) return;
301
320
  const children = getChildren(this.db, node.id);
302
321
  if (!children.length || children.some((child) => child.status !== "failed")) return;
303
322
  const decision = evaluatePhaseTransition({
@@ -340,7 +359,7 @@ export class LegionPostman {
340
359
 
341
360
  private checkOwnPhaseTimeout(timestamp: number): void {
342
361
  const node = getNode(this.db, this.nodeId);
343
- if (!node?.phase || !isPhase(node.phase) || isTerminalPhase(node.phase)) return;
362
+ if (!node?.phase || this.isTerminalPhaseOf(node.phase)) return;
344
363
  const timeout = checkPhaseTimeout({
345
364
  phase: node.phase,
346
365
  phaseSince: node.phaseSince,
@@ -377,6 +396,8 @@ export interface RegisterLegionToolsOptions {
377
396
  db: DatabaseSync;
378
397
  nodeId: string;
379
398
  role?: string;
399
+ /** #298: the team root's declared phase machine (absent = built-in). */
400
+ machine?: PhaseMachine;
380
401
  settings?: LegionSettings;
381
402
  eventBus?: Pick<LegionEventBus, "emit">;
382
403
  cwd?: string;
@@ -468,7 +489,15 @@ export function registerLegionTools(pi: ExtensionAPI, options: RegisterLegionToo
468
489
  label: "Legion phase",
469
490
  description: "Request a validated centurion phase transition.",
470
491
  parameters: Type.Object({
471
- to: Type.Union(PHASES.map((value) => Type.Literal(value))),
492
+ // #298: the phase list is declared per team, so it cannot be a
493
+ // compile-time enum here. `requestLegionPhase` validates the value
494
+ // against the team's own machine and refuses anything unknown with
495
+ // the list of phases that DO exist.
496
+ to: Type.String({
497
+ description:
498
+ 'Target phase. Default machine: planning, implementing, reviewing, verifying. ' +
499
+ '`done`/`failed`/`aborted` are controlled by the parent or supervisor.',
500
+ }),
472
501
  note: Type.Optional(Type.String()),
473
502
  evidence: Type.Optional(Type.String()),
474
503
  }),
@@ -483,6 +512,7 @@ export function registerLegionTools(pi: ExtensionAPI, options: RegisterLegionToo
483
512
  now: options.now,
484
513
  runCommand: options.runCommand,
485
514
  sendSelfMessage: options.sendSelfMessage,
515
+ ...(options.machine ? { machine: options.machine } : {}),
486
516
  });
487
517
  return result.ok ? toolOk(result.message) : toolError(result.message, result.code ?? "INVALID_PARAMS");
488
518
  },
@@ -523,6 +553,8 @@ export function formatTree(nodes: readonly LegionNode[]): string {
523
553
  export interface PhaseRequestOptions {
524
554
  note?: string;
525
555
  evidence?: string;
556
+ /** #298: the team's declared phase machine, when its card has one. */
557
+ machine?: PhaseMachine;
526
558
  cwd: string;
527
559
  runDir?: string;
528
560
  settings?: LegionSettings;
@@ -533,18 +565,22 @@ export interface PhaseRequestOptions {
533
565
  }
534
566
 
535
567
  export type PhaseRequestResult =
536
- | { ok: true; phase: LegionPhase; message: string }
568
+ | { ok: true; phase: string; message: string }
537
569
  | { ok: false; message: string; code?: string };
538
570
 
539
571
  export async function requestLegionPhase(
540
572
  db: DatabaseSync,
541
573
  nodeId: string,
542
- to: LegionPhase,
574
+ to: string,
543
575
  options: PhaseRequestOptions,
544
576
  ): Promise<PhaseRequestResult> {
545
577
  const now = options.now ?? Date.now;
546
578
  const self = getNode(db, nodeId);
547
579
  if (!self || self.role !== "centurion") return { ok: false, code: "INVALID_PARAMS", message: "legion_phase is centurion-only" };
580
+ // #298: the team's own machine decides which phases exist. `options.machine`
581
+ // carries the declaration from the node's role card; absent means the built-in
582
+ // four-phase pipeline.
583
+ const machine = options.machine;
548
584
  if (to === "done" || to === "failed" || to === "aborted") {
549
585
  return { ok: false, code: "INVALID_PARAMS", message: `${to} is controlled by the parent or supervisor` };
550
586
  }
@@ -552,30 +588,39 @@ export async function requestLegionPhase(
552
588
  const children = getChildren(db, nodeId);
553
589
  const proposedContractPath = path.resolve(self.worktreePath ?? options.cwd, ".pi-legion", "contract.md");
554
590
  let contractText: string | undefined;
555
- if (self.phase === "planning" && to === "implementing") {
556
- try { contractText = fs.readFileSync(proposedContractPath, "utf8"); } catch { contractText = undefined; }
557
- }
558
- const latestReviewer = children
559
- .filter((child) => child.role === "reviewer" && child.status === "settled" && child.sessionFile)
560
- .sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id))
561
- .at(-1);
562
- const reviewerVerdict = latestReviewer?.sessionFile
563
- ? extractVerdict(parseSessionFile(latestReviewer.sessionFile).lastTurnOutput ?? "")
591
+ try { contractText = fs.readFileSync(proposedContractPath, "utf8"); } catch { contractText = undefined; }
592
+ // #298: NO role names here any more. The old code scanned for a settled
593
+ // `role === "reviewer"` and for failed `role === "worker"` children, which made
594
+ // those roles load-bearing: a custom machine could declare any phases it liked
595
+ // and still be judged by the fixed pipeline's cast list.
596
+ //
597
+ // Now the question is asked structurally — "the newest settled child that
598
+ // reported a verdict" and "failures recorded before this phase began" — so any
599
+ // role can play any part.
600
+ const settledWithSession = children
601
+ .filter((child) => child.status === "settled" && child.sessionFile)
602
+ .sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id));
603
+ const latestVerdictChild = settledWithSession.at(-1);
604
+ const lastVerdict = latestVerdictChild?.sessionFile
605
+ ? extractVerdict(parseSessionFile(latestVerdictChild.sessionFile).lastTurnOutput ?? "")
564
606
  : null;
565
- const reviewerVerdictOk = reviewerVerdict?.ok;
566
- const failedWorkerHandledIds = self.phase === "implementing" && self.reworkCount > 0 && self.phaseSince !== null
567
- ? children.filter((child) => child.role === "worker" && child.status === "failed" && child.updatedAt < self.phaseSince!).map((child) => child.id)
607
+ const verdictOk = lastVerdict?.ok;
608
+ const failedHandledIds = self.phase !== null && self.reworkCount > 0 && self.phaseSince !== null
609
+ ? children.filter((child) => child.status === "failed" && child.updatedAt < self.phaseSince!).map((child) => child.id)
568
610
  : [];
611
+ const contractPresent = Boolean(contractText?.trim());
569
612
  const decision = evaluatePhaseTransition({
570
- from: isPhase(self.phase) ? self.phase : null,
613
+ from: self.phase ?? null,
571
614
  to,
572
615
  reworkCount: self.reworkCount,
573
- contractPath: contractText?.trim() ? proposedContractPath : self.contractPath,
616
+ contractPath: contractPresent ? proposedContractPath : self.contractPath,
617
+ contractPresent,
574
618
  children: children.map(({ id, role, status }) => ({ id, role, status })),
575
- failedWorkerHandledIds,
576
- reviewerVerdictOk,
619
+ failedWorkerHandledIds: failedHandledIds,
620
+ reviewerVerdictOk: verdictOk,
577
621
  verificationPassed: readVerificationResult(self),
578
622
  actorRole: self.role,
623
+ ...(machine ? { machine } : {}),
579
624
  }, now);
580
625
 
581
626
  if (!decision.allowed) {
@@ -18,12 +18,57 @@
18
18
  * still starts a new turn immediately.
19
19
  */
20
20
 
21
+ import {
22
+ PROGRESS_MIN_FILES_CHANGED,
23
+ PROGRESS_MIN_TOOL_RESULTS,
24
+ } from "../runs/progress-signal.ts";
21
25
  import { isTransientConnectionFailure } from "../shared/session.ts";
22
26
 
23
27
  export const SUBAGENT_NOTIFY_TYPE = "subagent-notify";
24
28
 
25
29
  export type CompletionStatus = "completed" | "failed" | "stopped" | "running";
26
30
 
31
+ /**
32
+ * D2/D2-a progress measurement, as carried by a renewal proposal.
33
+ *
34
+ * Exactly W1's `ProgressMeasure` minus `progressed` (D2-a): the renderer
35
+ * re-derives the verdict from `PROGRESS_MIN_TOOL_RESULTS` /
36
+ * `PROGRESS_MIN_FILES_CHANGED`, so a notice can never claim progress the
37
+ * measurer would not, and the thresholds have one home. No extra fields, so
38
+ * a caller can pass a `ProgressMeasure` straight through.
39
+ */
40
+ export interface RenewalProgress {
41
+ /** New `toolResult` rows since the renewal baseline (a tool returned). */
42
+ readonly toolResults: number;
43
+ /** New assistant messages since the baseline (shown, never decides — D2-a). */
44
+ readonly assistantMessages: number;
45
+ /** Files created/modified in the worktree; absent when not measurable. */
46
+ readonly filesChanged?: number;
47
+ }
48
+
49
+ /**
50
+ * D3: structured renewal proposal (subagent-renewal contract v1, W3).
51
+ *
52
+ * **Terminal paths only (D7).** A renewal proposal requires that the child is
53
+ * actually dead and its session file survived — so ONLY the path that observes
54
+ * a terminated child may populate it. `wait { timeoutMs }` and
55
+ * `collect { timeoutMs }` timeouts mean the parent gave up waiting while the
56
+ * child is still alive (`inprocess-supervisor` just resolves
57
+ * `{settled:false, reason:"timeout"}`): those call sites must leave `renewal`
58
+ * undefined, and then this field's absence keeps every renewal line out of the
59
+ * notice. Renewing a live child would spend a fresh turn budget on a child
60
+ * that never lost one. Locked by test "A3/D7".
61
+ */
62
+ export interface RenewalProposal {
63
+ /** D2 measurement at the moment of termination. */
64
+ readonly progress: RenewalProgress;
65
+ /** D6: how many renewals already used. */
66
+ readonly used: number;
67
+ readonly max: number;
68
+ /** The exact command that renews this child (D4: always `continue`). */
69
+ readonly command: string;
70
+ }
71
+
27
72
  export interface CompletionInput {
28
73
  name: string;
29
74
  agent?: string;
@@ -34,6 +79,13 @@ export interface CompletionInput {
34
79
  sessionFile?: string;
35
80
  acceptance?: { status: string; level?: string };
36
81
  recycled?: boolean;
82
+ /**
83
+ * D3: structured renewal proposal, present only when the renewal contract
84
+ * fires. Only a **real termination** populates it — `wait`/`collect`
85
+ * timeouts describe a still-alive child and must pass nothing here (D7);
86
+ * see `RenewalProposal` for the full premise.
87
+ */
88
+ renewal?: RenewalProposal | undefined;
37
89
  }
38
90
 
39
91
  export interface CompletionNotice {
@@ -160,6 +212,83 @@ export function previewOutput(
160
212
  return `${trimmed.slice(0, max)}\n…(preview truncated${where})`;
161
213
  }
162
214
 
215
+ /**
216
+ * D2-a verdict for a rendered proposal, recomputed from W1's exported
217
+ * thresholds so the notice and the measurer cannot disagree: 3 new tool
218
+ * results, or 1 changed file. Assistant messages are displayed but never
219
+ * decide — a model repeating itself grows that count forever, and that is
220
+ * exactly the case where a renewal must not be offered.
221
+ */
222
+ function countsAsProgress(progress: RenewalProgress): boolean {
223
+ if (progress.toolResults >= PROGRESS_MIN_TOOL_RESULTS) return true;
224
+ return (
225
+ progress.filesChanged !== undefined &&
226
+ progress.filesChanged >= PROGRESS_MIN_FILES_CHANGED
227
+ );
228
+ }
229
+
230
+ /**
231
+ * D3: the trailing renewal block, as notice lines.
232
+ *
233
+ * Three shapes (the measurement line always leads — D3 asks for the judgement
234
+ * basis *and* the command, and "this child produced 47 tool results before it
235
+ * died" is the whole argument):
236
+ *
237
+ * progressed: 47 tool results, 12 assistant messages, 6 files changed
238
+ * renew: subagent({ action: "continue", name: "W2", message: "<what to continue>" })
239
+ * renewals: 1/2
240
+ *
241
+ * … no measurable progress since spawn — not offering a renewal (A2)
242
+ * … renewals exhausted (2/2) — split the task instead of renewing (D6/A5)
243
+ *
244
+ * `used/max` is "renewals consumed so far", matching the exhausted line.
245
+ * An unmeasurable `filesChanged` drops out instead of printing `undefined`.
246
+ */
247
+ export function formatRenewalBlock(renewal: RenewalProposal): string[] {
248
+ const bits = [
249
+ `${renewal.progress.toolResults} tool results`,
250
+ `${renewal.progress.assistantMessages} assistant messages`,
251
+ ];
252
+ if (renewal.progress.filesChanged !== undefined) {
253
+ bits.push(`${renewal.progress.filesChanged} files changed`);
254
+ }
255
+ const lines = [`progressed: ${bits.join(", ")}`];
256
+ // A2/D2: renewing a child that moved no output forward just extends the
257
+ // hang by one full turn budget — the exact reason the role timeout exists.
258
+ if (!countsAsProgress(renewal.progress)) {
259
+ lines.push("no measurable progress since spawn — not offering a renewal");
260
+ return lines;
261
+ }
262
+ // D6/A5: the budget is a hard ceiling; past it the split is wrong, not the
263
+ // budget. Say so instead of offering a fourth attempt.
264
+ if (renewal.used >= renewal.max) {
265
+ lines.push(
266
+ `renewals exhausted (${renewal.used}/${renewal.max}) — split the task instead of renewing`,
267
+ );
268
+ return lines;
269
+ }
270
+ // D4/A8: renewal *is* `continue`; the caller hands us the finished command
271
+ // so the parent needs one line of typing, not a re-derivation.
272
+ lines.push(`renew: ${sanitizeNoticeField(renewal.command)}`);
273
+ lines.push(`renewals: ${renewal.used}/${renewal.max}`);
274
+ return lines;
275
+ }
276
+
277
+ /**
278
+ * The renewal block for a notice, or `undefined` when there is nothing to say.
279
+ *
280
+ * A completed child is never a renewal candidate (D1 condition 1): if the turn
281
+ * produced a verdict it is not a budget casualty, and offering `continue`
282
+ * would relaunch finished work.
283
+ */
284
+ function renewalLinesFor(
285
+ status: CompletionStatus,
286
+ renewal: RenewalProposal | undefined,
287
+ ): string[] | undefined {
288
+ if (!renewal || status === "completed") return undefined;
289
+ return formatRenewalBlock(renewal);
290
+ }
291
+
163
292
  export function formatCompletionNotice(input: CompletionInput): CompletionNotice {
164
293
  const status = completionStatusOf(
165
294
  input.execution.status,
@@ -185,7 +314,7 @@ export function formatCompletionNotice(input: CompletionInput): CompletionNotice
185
314
  if (input.outputFile) previewHints.sessionFile = input.outputFile;
186
315
  else if (input.sessionFile) previewHints.sessionFile = input.sessionFile;
187
316
  const preview = previewOutput(input.output, PREVIEW_CHARS, previewHints);
188
- const content = [
317
+ const lines: Array<string | undefined> = [
189
318
  `Background task ${status}: **${label}**`,
190
319
  "",
191
320
  `execution: ${input.execution.status}${reason}`,
@@ -198,9 +327,12 @@ export function formatCompletionNotice(input: CompletionInput): CompletionNotice
198
327
  input.recycled === false
199
328
  ? undefined
200
329
  : "Child retired. Resume from the session file if you need this child again.",
201
- ]
202
- .filter((line) => line !== undefined)
203
- .join("\n");
330
+ ];
331
+ // D3: the renewal proposal trails the failure body — appended, never woven
332
+ // in, so an unrenewable notice is byte-identical to what it was before.
333
+ const renewalLines = renewalLinesFor(status, input.renewal);
334
+ if (renewalLines) lines.push("", ...renewalLines);
335
+ const content = lines.filter((line) => line !== undefined).join("\n");
204
336
  return {
205
337
  content,
206
338
  display: true,
@@ -9,11 +9,11 @@ export const PARENT_PLAYBOOK = [
9
9
  "1. Call the `subagent` tool immediately with the matching role from the roster in this prompt. Do not substitute bash/curl/web fetch for a search or research role.",
10
10
  ' One child: subagent({ agent: "<role>", task: "...", name?: "scout-a" }) // optional name= registers that handle (not auto scout-N)',
11
11
  " Parallel: subagent({ tasks: [{ agent, task }, { agent, task }] })",
12
- "2. Prefer tasks[] for independent parallel work. Children are in-process by default; a separate `pi` process is used only when the launch needs a worktree (centurion) or the role pins `spawnMode: rpc`. No pane or tab is created.",
12
+ "2. Prefer tasks[] for independent parallel work. Children are in-process by default; a separate `pi` process is used only when the launch needs a worktree — and since only a centurion may hold one, that is the only launch that goes over rpc. No pane or tab is created.",
13
13
  "3. Then return control. A completion message wakes this session when idle; if it is still working, the notice waits until the current turn ends. Use `wait` with `name` or `all: true` when you need blocking results, and `collect` to inspect a child's session JSONL. Esc / wait interrupt only ends the parent turn's wait — children keep running and settle still notifies; only an explicit `stop` or session exit retires them. A wait interrupted by Esc returns `interrupted (parent turn ended; children still running)`. In `pi --print` or JSON mode, session shutdown waits for live async children to settle (up to each child's turn timeout) before retiring them. Use same-turn `wait`/`collect` or `async: false` when that print turn must include the child's result.",
14
14
  "4. Later control is only through `subagent({ action, name?, message? })`; `status` with no name (or `all: true`) overviews live children, with `name` for one child; use `stop` to gracefully retire one child or all tracked children (`force: true` for hard retire when soft stop is insufficient). A `wait` timeout does not stop children — the result lists decisions (`extend` / `steer_again` / `stop` / `force_stop`) for the parent to choose. If a child exits oddly (Model not found, missing tools, memory refused, …), call `subagent({ action: \"diagnose\" })` or ask the user to run `/legion doctor` / `/legion doctor models` — children run without host extensions unless the role inherits them, so a provider package the child needs must be passed explicitly. Do not tell a child to message or prompt the parent directly.",
15
15
  "5. A collect timeout with a live child is a progress signal, not a verdict. Check status or session JSONL before deciding to steer or resume.",
16
- "6. Isolation belongs to centurion. For N parallel isolated writers, dispatch N centurions (each owns one worktree); members stay `worktree: false` in-process. Passing `worktree: true` on a non-centurion warns once and is forced false.",
16
+ "6. Isolation belongs to centurion: each centurion owns one worktree, and members stay `worktree: false` in-process. Passing `worktree: true` on a non-centurion warns once and is forced false.",
17
17
  "7. Launch with `verify: [\"npm run typecheck\", \"npm test\"]` when the project has a mechanical verifier. The extension runs those commands itself in the child worktree at collect time; the child does not need to paste test output.",
18
18
  "Utility agents (`utility: true`, e.g. scout) can be dispatched by ANY role via the separate `vexillatio` tool — read-only helpers, always leaf, no depth budget. Its request shape matches `subagent`: either `vexillatio({ task, agent? })` or `vexillatio({ tasks: [{ agent, task }, …] })` (no top-level `task` needed; passing both only labels the parent run). The `subagent` tool stays for the command chain (worker/centurion).",
19
19
  "Forbidden: direct bash access to legion.db, or launching child Pi processes by hand (use the subagent tool).",
@@ -36,6 +36,7 @@ import { progressFromSessionFile } from "../shared/progress.ts";
36
36
  import { readProcRss } from "../supervisor/proc-memory.ts";
37
37
  import { isTransientConnectionFailure } from "../shared/session.ts";
38
38
  import { isTerminalState, liveness } from "./liveness.ts";
39
+ import type { RenewalProposal } from "./notify.ts";
39
40
  import {
40
41
  DEFAULTS,
41
42
  type AgentKind,
@@ -54,6 +55,7 @@ import { getSubtree } from "../legion/nodes.ts";
54
55
  import { attachUsageTotals } from "../legion/usage.ts";
55
56
  import { maxEventId } from "../legion/events.ts";
56
57
  import type { TreeNodeSnapshot } from "../tui/tree-view.ts";
58
+ import type { PhaseMachine } from "../legion/phase-machine.ts";
57
59
 
58
60
  export interface CollectSnapshot {
59
61
  execution: { status: string; reason?: string };
@@ -149,6 +151,25 @@ export interface SessionRuntimeDeps {
149
151
  getTreeNodes?: () => readonly TreeNodeSnapshot[] | undefined;
150
152
  /** Optional name-to-tree mapping for live RPC jobs backed by M2 ChildRecord.nodeId. */
151
153
  getTreeIdentity?: (name: string) => LegionTreeIdentity | undefined;
154
+ /**
155
+ * #298: the phase machine for a role, so the tree can show a team's own
156
+ * workflow. Injected rather than imported because only the host knows the
157
+ * resolved agent catalog (card `phases:` plus any team override).
158
+ */
159
+ getPhaseMachine?: (role: string | null | undefined) => PhaseMachine | undefined;
160
+ /**
161
+ * Renewal proposal for a finishing child (subagent-renewal-v1 D3).
162
+ *
163
+ * Injected rather than imported: the only builder lives in `index.ts`, which
164
+ * already imports this module — a direct import would be a cycle. Returning
165
+ * `undefined` is the correct answer for every path that must NOT offer a
166
+ * renewal (D5 role opt-out, D1 completed, D7 still running, no baseline), so a
167
+ * host that never supplies this hook simply gets today's notice verbatim.
168
+ */
169
+ getRenewalProposal?: (
170
+ name: string,
171
+ execution: { status: string; reason?: string },
172
+ ) => RenewalProposal | undefined;
152
173
  /** Open the live activity viewer for a node selected in the resident status widget. */
153
174
  onStatusNodeClick?: (nodeId: string) => void;
154
175
  /** Above-input strip style; the extension uses "railway", the default stays "tree". */
@@ -809,7 +830,7 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
809
830
  for (const nodeId of eventOverrides.keys()) if (!tree.has(nodeId)) eventOverrides.delete(nodeId);
810
831
  }
811
832
  const nodes = [...tree.values()].map((node) => ({ ...node }));
812
- return mergeSpawnModes(nodes);
833
+ return mergePhaseMachines(mergeSpawnModes(nodes));
813
834
  };
814
835
 
815
836
  /**
@@ -834,6 +855,32 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
834
855
  return nodes;
835
856
  };
836
857
 
858
+ /**
859
+ * #298: attach the declaring team's machine to the node that owns it.
860
+ *
861
+ * Only a team root declares `phases:`, so children inherit it downward: showing
862
+ * an empty machine on a worker would suggest it has none, when in fact it is
863
+ * governed by its lead's. The nearest ancestor that resolves a machine wins.
864
+ */
865
+ const mergePhaseMachines = (nodes: TreeNodeSnapshot[]): TreeNodeSnapshot[] => {
866
+ const byId = new Map(nodes.map((node) => [node.id, node]));
867
+ for (const node of nodes) {
868
+ let cursor: TreeNodeSnapshot | undefined = node;
869
+ let hops = 0;
870
+ while (cursor && hops < 64) {
871
+ const machine = deps.getPhaseMachine?.(cursor.role);
872
+ if (machine) {
873
+ node.phaseMachine = machine;
874
+ break;
875
+ }
876
+ const parentId: string | null = cursor.parentId ?? null;
877
+ cursor = parentId ? byId.get(parentId) : undefined;
878
+ hops += 1;
879
+ }
880
+ }
881
+ return nodes;
882
+ };
883
+
837
884
  const storedNodeForJob = (
838
885
  job: TrackedJob,
839
886
  nodes: readonly TreeNodeSnapshot[],
@@ -1466,6 +1513,14 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
1466
1513
  recycled: shouldRecycleAfterCollect(
1467
1514
  snapshot.execution.status,
1468
1515
  ),
1516
+ // D3: only the terminating path attaches a proposal. The
1517
+ // hook itself returns undefined for every case that must not
1518
+ // offer one (completed / still-running / opted out / no
1519
+ // baseline), so "absent" is meaningful, not a missing wire.
1520
+ ...(() => {
1521
+ const renewal = deps.getRenewalProposal?.(job.name, snapshot.execution);
1522
+ return renewal === undefined ? {} : { renewal };
1523
+ })(),
1469
1524
  },
1470
1525
  triggerTurn,
1471
1526
  });
@@ -17,6 +17,7 @@ import {
17
17
  RunStore,
18
18
  pickChildByName,
19
19
  } from "../runs/store.ts";
20
+ import { resolveChildAcrossStores, unknownChildMessage } from "../runs/store-lookup.ts";
20
21
  import type {
21
22
  ChildRecord,
22
23
  ExecutionStatus,
@@ -715,14 +716,22 @@ function summaryCompletions(
715
716
  .map((name) => ({ value: all ? `--all ${name}` : name, label: name }));
716
717
  }
717
718
 
719
+ /**
720
+ * Miss reporting for `/legion summary <name>` (#288).
721
+ *
722
+ * Was a hand-rolled sentence telling the caller to "pass the same cwd used for
723
+ * launch" -- advice that the cross-store lookup had already made obsolete, and
724
+ * advice the caller could not act on anyway (they do not know that cwd).
725
+ *
726
+ * Now it runs the same lookup `status` uses and prints `unknownChildMessage`,
727
+ * so every named operation reports a miss identically and lists what WAS found.
728
+ */
718
729
  function notifyUnknownChild(
719
730
  ctx: ExtensionCommandContext,
720
731
  name: string,
721
732
  ): void {
722
- ctx.ui.notify(
723
- `unknown child: ${name} (no run under ${ctx.cwd}/.pi-subagents). Child records are scoped to the cwd they were launched from — pass the same cwd used for launch.`,
724
- "error",
725
- );
733
+ const { searched } = resolveChildAcrossStores({ name, cwd: ctx.cwd });
734
+ ctx.ui.notify(unknownChildMessage({ name, cwd: ctx.cwd, searched }), "error");
726
735
  }
727
736
 
728
737
  /** Build the `/legion summary [--all] [<name>]` handler. */
@@ -18,6 +18,7 @@ import {
18
18
  pickChildByName,
19
19
  } from "../runs/store.ts";
20
20
  import { sendSlashText, type LegionSubcommand } from "./slash.ts";
21
+ import { resolveChildAcrossStores, unknownChildMessage } from "../runs/store-lookup.ts";
21
22
  import {
22
23
  aggregateSubagentRuns,
23
24
  formatDuration,
@@ -318,14 +319,17 @@ function timingCompletions(
318
319
  }));
319
320
  }
320
321
 
322
+ /**
323
+ * Miss reporting for `/legion timing <name>` (#288). Same cross-store lookup and
324
+ * same message as `status` and `summary`, so a miss reads identically everywhere
325
+ * and reports what was actually searched instead of advising an unknowable cwd.
326
+ */
321
327
  function notifyUnknownChild(
322
328
  ctx: ExtensionCommandContext,
323
329
  name: string,
324
330
  ): void {
325
- ctx.ui.notify(
326
- `unknown child: ${name} (no run under ${ctx.cwd}/.pi-subagents). Child records are scoped to the cwd they were launched from — pass the same cwd used for launch.`,
327
- "error",
328
- );
331
+ const { searched } = resolveChildAcrossStores({ name, cwd: ctx.cwd });
332
+ ctx.ui.notify(unknownChildMessage({ name, cwd: ctx.cwd, searched }), "error");
329
333
  }
330
334
 
331
335
  function findTimingChild(