@ferris1225/pi-subagents 4.1.8 → 4.1.11

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.
package/src/tools.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Thread controls and lookup tools around the subagent runtime:
3
- * subagent_control (steer/retarget/park/resume/fork), subagent_wait (in-turn
3
+ * subagent_control (resume), subagent_wait (in-turn
4
4
  * result lookup), subagent_status, and destructive subagent_stop.
5
5
  */
6
6
 
@@ -10,6 +10,7 @@ import { Text } from "@earendil-works/pi-tui";
10
10
  import { existsSync } from "node:fs";
11
11
  import { Type } from "typebox";
12
12
  import { DEFAULT_MAX_RESULT_LINES, loadConfig } from "./config.ts";
13
+ import { removeThreadRecord } from "./durable.ts";
13
14
  import { formatCompletionBlock, formatUsage, matchRunIds } from "./format.ts";
14
15
  import { emptyUsage } from "./rpc-run.ts";
15
16
  import {
@@ -46,15 +47,12 @@ function renderFirstLine(result: { content?: unknown }, label: string, theme: an
46
47
 
47
48
  export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime): void {
48
49
  const SubagentControlParams = Type.Object({
49
- action: StringEnum(["steer", "retarget", "park", "resume", "fork"] as const, {
50
+ action: StringEnum(["resume"] as const, {
50
51
  description: "Control operation for the logical sub-agent thread.",
51
52
  }),
52
53
  id: Type.Integer({ minimum: 1, description: "Stable run id shown by subagent dispatch/status output." }),
53
- instruction: Type.Optional(
54
- Type.String({ description: "Instruction queued by steer after the current child tool batch." }),
55
- ),
56
54
  objective: Type.Optional(
57
- Type.String({ description: "Replacement objective for retarget; optional appended objective for resume/fork. Omit on resume/fork to continue the current retained objective." }),
55
+ Type.String({ description: "Optional appended objective for resume. Omit to continue the current retained objective." }),
58
56
  ),
59
57
  });
60
58
 
@@ -62,21 +60,14 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
62
60
  name: "subagent_control",
63
61
  label: "Subagent Control",
64
62
  description: [
65
- "Control an existing sub-agent thread by stable run id.",
66
- "steer queues an instruction after the current tool batch while the top-level RPC child is active.",
67
- "retarget replaces the objective in that same active top-level child.",
68
- "Managed downstream documenter/reviewer/fix stages are controlled by the parent queue rather than its settled RPC control: use park or stop there, then resume with an objective to redirect retained context.",
69
- "park aborts to a stable checkpoint, terminates the child, preserves context, and releases its concurrency slot.",
63
+ "Resume an existing sub-agent thread by stable run id.",
70
64
  "resume restarts a parked, completed, or failed retained thread with the same run id and cumulative active time; omit objective to continue the current goal, or provide one to append it to retained context and make it the displayed current goal.",
71
- "fork copies a parked/completed/failed retained session branch into a new logical thread and run id; an isolated checkpoint must be settled and integrated first; omit objective to continue the current goal, or provide one to append a new branch goal.",
65
+ "Threads parked or interrupted by a shutdown/reload are restorable; use subagent_stop for destructive cancellation.",
72
66
  ].join(" "),
73
- promptSnippet: "Control a subagent thread: steer/retarget an active top-level child; park/stop a managed downstream stage; resume or fork retained context.",
67
+ promptSnippet: "Resume a parked or settled subagent thread with its retained context.",
74
68
  promptGuidelines: [
75
- "Use subagent_control steer to refine an active top-level RPC child without restarting it; the instruction is delivered after its current tool batch.",
76
- "Use subagent_control retarget only while that top-level child is active. During a managed downstream stage, park it and resume with a replacement objective instead.",
77
- "Use subagent_control park to checkpoint useful context while releasing the process/concurrency slot, and resume to continue the same run id later. Resume without objective keeps the current goal; resume with objective appends that goal to retained context.",
78
- "Use subagent_control fork only on a parked or settled retained thread; isolated work must settle and integrate before it can fork. Fork creates a new run id while leaving the source untouched.",
79
- "Use subagent_stop only for destructive cancellation; it retires that thread's retained session without retiring independent forks.",
69
+ "Use subagent_control resume to continue a parked/settled thread on the same run id. Resume without objective keeps the current goal; resume with objective appends that goal to retained context.",
70
+ "Use subagent_stop only for destructive cancellation; it retires that thread's retained session.",
80
71
  ],
81
72
  parameters: SubagentControlParams,
82
73
 
@@ -92,54 +83,6 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
92
83
 
93
84
  try {
94
85
  switch (params.action) {
95
- case "steer": {
96
- const instruction = nonBlank(params.instruction);
97
- if (!instruction) {
98
- return { content: [{ type: "text", text: "steer requires a non-blank instruction." }], details: {} };
99
- }
100
- if (!(["running", "steering"] as const).includes(thread.control.getPhase() as any)) {
101
- return { content: [{ type: "text", text: `Run #${thread.id} is ${thread.control.getPhase()}; only a running thread can be steered.` }], details: {} };
102
- }
103
- await thread.control.steer(instruction);
104
- return { content: [{ type: "text", text: `Queued steering instruction for run #${thread.id} after its current tool batch.` }], details: {} };
105
- }
106
- case "retarget": {
107
- const objective = nonBlank(params.objective);
108
- if (!objective) {
109
- return { content: [{ type: "text", text: "retarget requires a non-blank objective." }], details: {} };
110
- }
111
- const phase = thread.control.getPhase();
112
- if (thread.state === "queued" && phase === "queued") {
113
- thread.task = objective;
114
- thread.control.retargetPending(objective);
115
- monitor.setTask(thread.id, objective);
116
- monitor.setContinuationKind(thread.id, "retarget");
117
- return { content: [{ type: "text", text: `Updated queued run #${thread.id} to the replacement objective; no child was spawned by this control action.` }], details: {} };
118
- }
119
- if (!(["starting", "running", "steering", "interrupting", "retrying"] as const).includes(phase as any)) {
120
- return { content: [{ type: "text", text: `Run #${thread.id} is ${thread.state}; use resume with objective to restart retained context.` }], details: {} };
121
- }
122
- thread.task = objective;
123
- monitor.setTask(thread.id, objective);
124
- monitor.setContinuationKind(thread.id, "retarget");
125
- await thread.control.retarget(objective);
126
- return { content: [{ type: "text", text: `Retargeted run #${thread.id} in the same session; the aborted objective will not be delivered as a completion.` }], details: {} };
127
- }
128
- case "park": {
129
- if (thread.state === "parked") {
130
- return { content: [{ type: "text", text: `Run #${thread.id} is already parked.` }], details: {} };
131
- }
132
- const disposition = await thread.park();
133
- return disposition === "queued"
134
- ? {
135
- content: [{ type: "text", text: `Parked queued run #${thread.id}; it never spawned a child or empty session.` }],
136
- details: {},
137
- }
138
- : {
139
- content: [{ type: "text", text: `Parked run #${thread.id} at a stable checkpoint; its session is retained and concurrency slot released.` }],
140
- details: {},
141
- };
142
- }
143
86
  case "resume": {
144
87
  if (thread.retired) {
145
88
  return { content: [{ type: "text", text: `Run #${thread.id} was retired by subagent_stop and has no resumable session.` }], details: {} };
@@ -165,24 +108,6 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
165
108
  : "no prior child session existed, so only the logical run and objective are continued";
166
109
  return { content: [{ type: "text", text: `Resumed run #${thread.id}, ${mode}; ${context}, and cumulative active time is preserved. Completion will arrive automatically.` }], details: {}, terminate: true };
167
110
  }
168
- case "fork": {
169
- const objective = params.objective === undefined ? undefined : nonBlank(params.objective);
170
- if (params.objective !== undefined && !objective) {
171
- return { content: [{ type: "text", text: "fork objective must be non-blank when provided." }], details: {} };
172
- }
173
- const pending = await thread.fork(objective, ctx);
174
- if (pending.exitCode !== -1 || pending.runId === undefined) {
175
- return { content: [{ type: "text", text: getResultOutput(pending) }], details: {} };
176
- }
177
- return {
178
- content: [{
179
- type: "text",
180
- text: `Forked run #${thread.id} into new run #${pending.runId}; ${objective ? `appended branch objective: ${formatTaskSummary(objective, 80, false)}` : `continuing current objective: ${formatTaskSummary(thread.task, 80, false)}`}. Retained context is copied, the source is unchanged, and child completion will arrive automatically.`,
181
- }],
182
- details: { sourceRunId: thread.id, childRunId: pending.runId, result: pending },
183
- terminate: true,
184
- };
185
- }
186
111
  }
187
112
  } catch (error) {
188
113
  throw new Error(`Could not ${params.action} run #${thread.id}: ${error instanceof Error ? error.message : String(error)}`);
@@ -431,8 +356,6 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
431
356
  : undefined;
432
357
  const metadata = [
433
358
  activeThread?.isolation === "worktree" ? `worktree ${active.integrationStatus ?? activeThread.worktree?.state ?? "active"}` : undefined,
434
- activeThread?.forkedFromRunId !== undefined ? `forked from #${activeThread.forkedFromRunId}` : undefined,
435
- (activeThread?.forkChildRunIds.length ?? 0) > 0 ? `forks ${activeThread!.forkChildRunIds.map((id) => `#${id}`).join(",")}` : undefined,
436
359
  ].filter(Boolean).join(" · ");
437
360
  const stageStatus = activeChild
438
361
  ? monitor.summarize(activeChild)
@@ -444,8 +367,8 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
444
367
  text: parked
445
368
  ? `Run #${active.id} ${owner} is parked with retained${retainedStage ? ` ${retainedStage} stage` : ""} context${metadata ? ` (${metadata})` : ""}. Use subagent_control resume to restart it, or subagent_stop to retire it.`
446
369
  : managedDownstream
447
- ? `Run #${active.id} ${owner} is in a managed downstream stage (${stageStatus}${metadata ? ` · ${metadata}` : ""}). Use subagent_wait for its result, subagent_control park to checkpoint it, or subagent_stop to cancel it. Steer/retarget are unavailable until you park and resume the retained stage.`
448
- : `Run #${active.id} ${owner} is still active (${active.activity ?? statusLabel(active.status)}${metadata ? ` · ${metadata}` : ""}). Use subagent_wait to block for its result, subagent_control to steer/park it, or subagent_stop to cancel it.`,
370
+ ? `Run #${active.id} ${owner} is in a managed downstream stage (${stageStatus}${metadata ? ` · ${metadata}` : ""}). Use subagent_wait for its result or subagent_stop to cancel it.`
371
+ : `Run #${active.id} ${owner} is still active (${active.activity ?? statusLabel(active.status)}${metadata ? ` · ${metadata}` : ""}). Use subagent_wait to block for its result or subagent_stop to cancel it.`,
449
372
  },
450
373
  ],
451
374
  details: {},
@@ -462,8 +385,6 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
462
385
  const parts = [
463
386
  `#${run.id} ${monitor.summarize(run)}`,
464
387
  run.label,
465
- thread?.forkedFromRunId !== undefined ? `forked from #${thread.forkedFromRunId}` : undefined,
466
- (thread?.forkChildRunIds.length ?? 0) > 0 ? `forks ${thread!.forkChildRunIds.map((id) => `#${id}`).join(",")}` : undefined,
467
388
  run.activity ?? statusLabel(run.status),
468
389
  ].filter(Boolean);
469
390
  return `- ${parts.join(" · ")}`;
@@ -475,13 +396,8 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
475
396
  const retainedStage = run?.managedWorkflow && thread.agentName !== run.agent
476
397
  ? ` · retained stage ${thread.agentName}`
477
398
  : "";
478
- const relations = [
479
- thread.forkedFromRunId !== undefined ? `forked from #${thread.forkedFromRunId}` : undefined,
480
- thread.forkChildRunIds.length > 0 ? `forks ${thread.forkChildRunIds.map((id) => `#${id}`).join(",")}` : undefined,
481
- ].filter(Boolean);
482
- const relation = relations.length > 0 ? ` · ${relations.join(" · ")}` : "";
483
399
  const isolation = thread.isolation === "worktree" ? ` · worktree ${thread.worktree?.state ?? "active"}` : "";
484
- return `- #${thread.id} ${owner} · ${run?.label ?? runLabel(thread.task)} · parked${thread.sessionDir ? " · context retained" : " · not started"}${retainedStage}${isolation}${relation}`;
400
+ return `- #${thread.id} ${owner} · ${run?.label ?? runLabel(thread.task)} · parked${thread.sessionDir ? " · context retained" : " · not started"}${retainedStage}${isolation}`;
485
401
  });
486
402
  const completed = [...runtime.settledRuns.entries()].slice(-5);
487
403
  const completedLines = completed.map(([id, result]) => {
@@ -491,12 +407,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
491
407
  ? `${result.model ?? "?"} (main after ${result.modelFallbackFrom} failed)`
492
408
  : (result.model ?? "?");
493
409
  const isolation = result.isolation === "worktree" ? ` · worktree ${result.integrationStatus ?? "unknown"}` : "";
494
- const relations = [
495
- result.forkedFromRunId !== undefined ? `forked from #${result.forkedFromRunId}` : undefined,
496
- (result.forkChildRunIds?.length ?? 0) > 0 ? `forks ${result.forkChildRunIds!.map((childId) => `#${childId}`).join(",")}` : undefined,
497
- ].filter(Boolean);
498
- const relation = relations.length > 0 ? ` · ${relations.join(" · ")}` : "";
499
- return `- #${id} ${result.agent}${label ? ` · ${label}` : ""} · ${isFailedResult(result) ? "failed" : "completed"} · ${model}${isolation}${relation}${usage ? ` · ${usage}` : ""}`;
410
+ return `- #${id} ${result.agent}${label ? ` · ${label}` : ""} · ${isFailedResult(result) ? "failed" : "completed"} · ${model}${isolation}${usage ? ` · ${usage}` : ""}`;
500
411
  });
501
412
 
502
413
  const sections: string[] = [];
@@ -506,7 +417,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
506
417
  sections.push(parkedLines.length > 0 ? parkedLines.join("\n") : "(none)");
507
418
  sections.push(`### Finished this session (${runtime.settledRuns.size})`);
508
419
  sections.push(completedLines.length > 0 ? completedLines.join("\n") : "(none)");
509
- sections.push("Pass a run id to subagent_status for the full result, use subagent_control to steer/park/resume/fork, or subagent_wait for active work.");
420
+ sections.push("Pass a run id to subagent_status for the full result, use subagent_control to resume a settled thread, or subagent_wait for active work.");
510
421
  return { content: [{ type: "text", text: sections.join("\n\n") }], details: {} };
511
422
  },
512
423
 
@@ -551,7 +462,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
551
462
 
552
463
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
553
464
  // Start config I/O without yielding: every target below must be claimed
554
- // synchronously before a resume/fork preflight can cross its next await.
465
+ // synchronously before a resume preflight can cross its next await.
555
466
  const configPromise = loadConfig(runtime.configPath).catch(() => undefined);
556
467
  const completionResults: SingleResult[] = [];
557
468
  const candidateIds = params.all === true
@@ -560,7 +471,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
560
471
  ...[...runtime.threads.values()]
561
472
  .filter((thread) =>
562
473
  thread.lifecycleOperation !== undefined ||
563
- ["queued", "resuming", "running", "steering", "interrupting"].includes(thread.state),
474
+ ["queued", "resuming", "running", "interrupting"].includes(thread.state),
564
475
  )
565
476
  .map((thread) => thread.id),
566
477
  ])]
@@ -607,10 +518,10 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
607
518
  const wasResuming = previousState === "resuming";
608
519
  const wasActive =
609
520
  thread.lifecycleOperation !== undefined ||
610
- ["queued", "resuming", "running", "steering", "interrupting"].includes(previousState);
521
+ ["queued", "resuming", "running", "interrupting"].includes(previousState);
611
522
  const stopVersion = ++thread.lifecycleVersion;
612
523
  // Stop-all claims every target before the first await. This invalidates
613
- // all concurrent resume/fork preflights as one synchronous operation.
524
+ // all concurrent resume preflights as one synchronous operation.
614
525
  thread.lifecycleOperation = "stop";
615
526
  thread.retired = true;
616
527
  thread.retireOnSettle = true;
@@ -692,7 +603,6 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
692
603
  stoppedResult = prior
693
604
  ? {
694
605
  ...prior,
695
- parked: undefined,
696
606
  exitCode: 1,
697
607
  stopReason: "aborted",
698
608
  errorMessage: stopMessage,
@@ -756,6 +666,9 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
756
666
  if (stoppedResult) completionResults.push(stoppedResult);
757
667
  monitor.removeRun(runId);
758
668
  runtime.retireThreadSession(thread);
669
+ // The destructive retire removes the durable record with the session;
670
+ // an id never resurrects after subagent_stop.
671
+ await removeThreadRecord(runtime.configPath, runId).catch(() => undefined);
759
672
  if (thread.lifecycleVersion === stopVersion && thread.lifecycleOperation === "stop") {
760
673
  thread.lifecycleOperation = undefined;
761
674
  }
package/src/widget.ts CHANGED
@@ -98,7 +98,7 @@ function runPrimaryLine(
98
98
  // A chain child shows its role in the chain plus a task-derived label; the
99
99
  // templated fix brief itself would only repeat the parent review's content.
100
100
  const continuation = run.parentRunId === undefined
101
- ? continuationLabel(run.continuationKind, run.forkedFromRunId)
101
+ ? continuationLabel(run.continuationKind)
102
102
  : undefined;
103
103
  const taskSource = run.parentRunId !== undefined
104
104
  ? [run.relationLabel, run.label].filter((part): part is string => Boolean(part)).join(" · ")
@@ -215,8 +215,8 @@ function runActivityLine(run: RunView, theme: Theme, width: number, indent: stri
215
215
 
216
216
  /** Render active runs as compact workflow-aware trees. Stable managed parents
217
217
  * retain their stage timeline while the current internal child supplies exact
218
- * model/thinking/activity telemetry. Fork labels include their source id; other
219
- * control ids remain available through status. */
218
+ * model/thinking/activity telemetry. Control ids remain available through
219
+ * status. */
220
220
  export function formatActiveRunLines(
221
221
  runs: readonly RunView[],
222
222
  theme: Theme,
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Managed workflow policy and handoff formatting.
3
+ *
4
+ * Successful top-level worker/cleaner runs continue through an independent
5
+ * code review gate. A passing gate may run one conditional documentation
6
+ * sync; a failing gate is delivered to the main agent, which owns the fix
7
+ * decision — the runtime never edits code on a reviewer's behalf. Reviewers
8
+ * classify documentation drift explicitly, so the low-cost final documenter
9
+ * runs only when needed (or conservatively when an older/custom reviewer
10
+ * omits the marker). Direct passing gates use the same policy. Top-level
11
+ * documenters are explicit standalone writing tasks. Internal steps are
12
+ * launched by dispatch directly, so they never re-enter this policy or wake
13
+ * the main agent mid-chain.
14
+ */
15
+
16
+ import { isWriteCapableAgent, type AgentConfig } from "./agents.ts";
17
+ import { getResultOutput, isFailedResult, reviewVerdict, type SingleResult } from "./spawn.ts";
18
+ import { formatUsageCompact, sumUsage } from "./monitor.ts";
19
+
20
+ export interface WorkflowAgentAvailability {
21
+ cleaner: boolean;
22
+ documenter: boolean;
23
+ reviewer: boolean;
24
+ writer: boolean;
25
+ }
26
+
27
+ export function workflowAgentAvailability(
28
+ agents: readonly Pick<AgentConfig, "name" | "tools">[],
29
+ ): WorkflowAgentAvailability {
30
+ const names = new Set(agents.map((agent) => agent.name));
31
+ return {
32
+ cleaner: names.has("cleaner"),
33
+ documenter: names.has("documenter"),
34
+ reviewer: names.has("reviewer"),
35
+ writer: agents.some(isWriteCapableAgent),
36
+ };
37
+ }
38
+
39
+ export type DocumentationDisposition = "clean" | "needed";
40
+
41
+ /** Only the last standalone documentation disposition line counts. Inline
42
+ * examples and prose are ignored so a prompt echo cannot suppress a needed
43
+ * conservative sync. */
44
+ export function documentationDisposition(output: string): DocumentationDisposition | undefined {
45
+ const lines = output.split("\n");
46
+ for (let index = lines.length - 1; index >= 0; index--) {
47
+ const match = /^\s*DOCUMENTATION:\s*(CLEAN|NEEDED)\s*$/i.exec(lines[index]);
48
+ if (match) return match[1].toUpperCase() === "CLEAN" ? "clean" : "needed";
49
+ }
50
+ return undefined;
51
+ }
52
+
53
+ export type ManagedWorkflowKind = "post-writer" | "review-pass-sync";
54
+
55
+ export interface ManagedWorkflowPlan {
56
+ kind: ManagedWorkflowKind;
57
+ initialRelation: string;
58
+ }
59
+
60
+ /** Conservative pre-run check used to reserve one shared-repository lane
61
+ * around a complete writer workflow or a reviewer that needs a stable diff.
62
+ * The actual result is classified again by getManagedWorkflowPlan before a
63
+ * downstream child starts. */
64
+ export function canStartManagedWorkflow(
65
+ agent: Pick<AgentConfig, "name" | "tools">,
66
+ availability: WorkflowAgentAvailability,
67
+ ): boolean {
68
+ // Every shared write-capable role—including custom agents—owns the repository
69
+ // lane even when no downstream role is enabled. Otherwise its edits can race
70
+ // a managed writer's documentation snapshot.
71
+ if (isWriteCapableAgent(agent)) return true;
72
+ if (agent.name === "reviewer") {
73
+ // Hold a stable diff snapshot against every discoverable writer even when
74
+ // this review is advisory. Classification happens only after the read-only
75
+ // child returns, too late to acquire the lane safely.
76
+ return availability.writer;
77
+ }
78
+ return false;
79
+ }
80
+
81
+ /** Classify only healthy top-level results. In particular, a reviewer without a
82
+ * machine verdict is advisory and cannot start any write-capable child; a
83
+ * failing gate is not a workflow at all — its findings are delivered to the
84
+ * main agent, which decides the fixes. */
85
+ export function getManagedWorkflowPlan(
86
+ result: SingleResult,
87
+ availability: WorkflowAgentAvailability,
88
+ advisoryReview = false,
89
+ ): ManagedWorkflowPlan | undefined {
90
+ if (result.dispatchFailed || isFailedResult(result)) return undefined;
91
+ if (result.agent === "worker" || result.agent === "cleaner") {
92
+ if (!availability.documenter && !availability.reviewer) return undefined;
93
+ return {
94
+ kind: "post-writer",
95
+ initialRelation: result.agent === "cleaner" ? "initial cleanup" : "initial implementation",
96
+ };
97
+ }
98
+ // A top-level documenter is already an explicit docs/comments write task. It
99
+ // owns the writer lane but delivers directly without an automatic code gate.
100
+ if (result.agent === "documenter") return undefined;
101
+ if (result.agent !== "reviewer") return undefined;
102
+ // An advisory dispatch never chains: the caller asked for a report, so even
103
+ // a stray gate verdict must be delivered rather than acted on.
104
+ if (advisoryReview) return undefined;
105
+
106
+ const output = getResultOutput(result);
107
+ // The pass stands as the code gate. Run the conditional documenter only for
108
+ // explicit drift or when an older/custom reviewer omitted the marker.
109
+ if (
110
+ reviewVerdict(output) === "pass" &&
111
+ availability.documenter &&
112
+ documentationDisposition(output) !== "clean"
113
+ ) {
114
+ return { kind: "review-pass-sync", initialRelation: "pre-documentation review" };
115
+ }
116
+ return undefined;
117
+ }
118
+
119
+ /** Build the single documentation handoff selected after the review gate
120
+ * settles or as the reviewer-disabled fallback. The top-level writer's report
121
+ * and the terminal gate review are leads; the pending diff stays authoritative.
122
+ * At most one of the two is undefined in every managed flow. */
123
+ export function buildFinalDocumenterBrief(
124
+ lastWriterResult?: SingleResult,
125
+ finalReviewResult?: SingleResult,
126
+ ): string {
127
+ const reportSections = [
128
+ ...(lastWriterResult
129
+ ? [
130
+ `The last writer (${lastWriterResult.agent}) reported:`,
131
+ `---`,
132
+ getResultOutput(lastWriterResult),
133
+ `---`,
134
+ ``,
135
+ ]
136
+ : []),
137
+ ...(finalReviewResult
138
+ ? [
139
+ `The final gate review reported:`,
140
+ `---`,
141
+ getResultOutput(finalReviewResult),
142
+ `---`,
143
+ ``,
144
+ ]
145
+ : []),
146
+ ];
147
+ return [
148
+ `Final documentation sync: the review gate settled and you are the last managed stage before delivery.`,
149
+ ``,
150
+ ...reportSections,
151
+ `Inspect the actual git diff and relevant implementation; the reports are only leads.`,
152
+ `Apply every documentation note the reviews recorded, then synchronize stale README/docs, examples, API comments, docstrings,`,
153
+ `and explanatory comments with the behavior that will be committed.`,
154
+ `Change documentation surfaces only; make zero edits when the diff creates no documentation drift.`,
155
+ `The workflow delivers directly after you; no fresh reviewer runs.`,
156
+ `Report exact documentation/comment paths changed, or state explicitly that no sync was needed.`,
157
+ ].join("\n");
158
+ }
159
+
160
+ /**
161
+ * One step of a managed workflow as delivered: the run id (so the condensed
162
+ * summary can point at per-run detail via subagent_status), the result, and
163
+ * the human-readable role within the workflow ("initial implementation",
164
+ * "final review", "final documentation sync"). runId is optional only for
165
+ * synthetic steps that never spawned a child.
166
+ */
167
+ export interface ChainStep {
168
+ runId?: number;
169
+ result: SingleResult;
170
+ relation: string;
171
+ }
172
+
173
+ export interface ManagedWorkflowOutcome {
174
+ kind: ManagedWorkflowKind;
175
+ steps: ChainStep[];
176
+ }
177
+
178
+ function workflowResultStatus(result: SingleResult): string {
179
+ if (isFailedResult(result)) return "failed";
180
+ if (result.agent === "reviewer") {
181
+ const verdict = reviewVerdict(getResultOutput(result));
182
+ return verdict ? verdict.toUpperCase() : "NO_VERDICT";
183
+ }
184
+ return "completed";
185
+ }
186
+
187
+ function workflowStepLine(step: ChainStep): string {
188
+ const id = step.runId !== undefined ? `#${step.runId} ` : "";
189
+ return `- ${id}${step.result.agent} · ${step.relation} · ${workflowResultStatus(step.result)}`;
190
+ }
191
+
192
+ function appendWorkflowFooter(lines: string[], steps: readonly ChainStep[]): void {
193
+ const total = sumUsage(steps.map((step) => step.result.usage));
194
+ const usage = formatUsageCompact(total);
195
+ lines.push("", `Totals: ${steps.length} run${steps.length === 1 ? "" : "s"}${usage ? ` · ${usage}` : ""}`);
196
+ const ids = steps.filter((step) => step.runId !== undefined).map((step) => `#${step.runId}`);
197
+ lines.push(`Per-run details: subagent_status ${ids.join(" ")}`);
198
+ }
199
+
200
+ /** One clear final delivery for post-writer and direct reviewer → documenter workflows. */
201
+ export function formatManagedWorkflowSummary(
202
+ steps: readonly ChainStep[],
203
+ terminalResult: SingleResult = steps[steps.length - 1]!.result,
204
+ ): string {
205
+ const route = steps.map((step) => step.result.agent).join(" → ");
206
+ const lines = [`## Managed workflow: ${route} — final ${workflowResultStatus(terminalResult)}`, "", ...steps.map(workflowStepLine)];
207
+ appendWorkflowFooter(lines, steps);
208
+ return lines.join("\n");
209
+ }
210
+
211
+ export interface GateBriefOptions {
212
+ /** A conditional final documenter is available after the gate settles.
213
+ * Documentation drift is then routed to it as non-gating notes instead of
214
+ * failing the code gate. */
215
+ documenterPending: boolean;
216
+ }
217
+
218
+ /** Build the code gate that runs directly after a top-level writer, before any
219
+ * documentation. Reports carry intent; the actual pending diff remains
220
+ * authoritative. */
221
+ export function buildFinalReviewBrief(
222
+ initialResult: SingleResult,
223
+ options: GateBriefOptions,
224
+ ): string {
225
+ return [
226
+ `Fresh code gate for a managed ${initialResult.agent} workflow.`,
227
+ ``,
228
+ `The top-level ${initialResult.agent}'s full report:`,
229
+ `---`,
230
+ getResultOutput(initialResult),
231
+ `---`,
232
+ ``,
233
+ `Run \`git status\` and \`git diff\` and inspect the actual pending code; the report is context, not proof.`,
234
+ `Remain read-only. Attach a concrete fix instruction to EVERY gate finding: what to change, where, and how to verify the fix`,
235
+ `— the report returns to the main agent, which uses your instructions to drive the fix.`,
236
+ ...(options.documenterPending
237
+ ? [
238
+ `A conditional documentation sync is available AFTER this gate, so documentation drift is not a code-gate finding:`,
239
+ `record it under "## Documentation notes" and emit the standalone line DOCUMENTATION: NEEDED,`,
240
+ `or DOCUMENTATION: CLEAN when no documentation update is needed.`,
241
+ ]
242
+ : [
243
+ `No documenter is pending, so documentation drift is an ordinary gate finding.`,
244
+ ]),
245
+ `This is an acceptance gate, not an advisory audit. End with exactly one standalone machine verdict line:`,
246
+ `VERDICT: REVIEW_PASS when no finding remains, otherwise VERDICT: REVIEW_FAIL.`,
247
+ ].join("\n");
248
+ }