@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/README.md +161 -476
- package/agents/cleaner.md +51 -52
- package/agents/documenter.md +39 -46
- package/agents/explorer.md +40 -50
- package/agents/reviewer.md +29 -31
- package/agents/worker.md +15 -34
- package/package.json +1 -1
- package/src/announcements.ts +8 -0
- package/src/background.ts +23 -5
- package/src/config.ts +4 -2
- package/src/dispatch.ts +645 -746
- package/src/durable.ts +336 -0
- package/src/format.ts +1 -8
- package/src/index.ts +7 -0
- package/src/monitor.ts +29 -30
- package/src/prompt.ts +22 -40
- package/src/rpc-run.ts +22 -228
- package/src/runtime.ts +71 -46
- package/src/session-fork.ts +7 -2
- package/src/setup.ts +25 -193
- package/src/spawn.ts +31 -28
- package/src/temp-hygiene.ts +194 -0
- package/src/thread-lifecycle.ts +1410 -1324
- package/src/tools.ts +21 -108
- package/src/widget.ts +3 -3
- package/src/workflow.ts +248 -0
- package/src/worktree.ts +144 -4
- package/src/fixloop.ts +0 -382
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 (
|
|
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(["
|
|
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: "
|
|
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
|
-
"
|
|
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
|
-
"
|
|
65
|
+
"Threads parked or interrupted by a shutdown/reload are restorable; use subagent_stop for destructive cancellation.",
|
|
72
66
|
].join(" "),
|
|
73
|
-
promptSnippet: "
|
|
67
|
+
promptSnippet: "Resume a parked or settled subagent thread with its retained context.",
|
|
74
68
|
promptGuidelines: [
|
|
75
|
-
"Use subagent_control
|
|
76
|
-
"Use
|
|
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
|
|
448
|
-
: `Run #${active.id} ${owner} is still active (${active.activity ?? statusLabel(active.status)}${metadata ? ` · ${metadata}` : ""}). Use subagent_wait to block for its result
|
|
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}
|
|
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
|
-
|
|
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
|
|
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
|
|
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", "
|
|
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", "
|
|
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
|
|
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
|
|
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.
|
|
219
|
-
*
|
|
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,
|
package/src/workflow.ts
ADDED
|
@@ -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
|
+
}
|