@pi-agents/orchid 0.1.0-beta.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 (163) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +246 -0
  4. package/agents/AGENTS-MANIFEST.md +42 -0
  5. package/agents/brain.md +42 -0
  6. package/agents/context-builder.md +46 -0
  7. package/agents/delegate.md +12 -0
  8. package/agents/dev-1.md +42 -0
  9. package/agents/oracle.md +73 -0
  10. package/agents/planner.md +55 -0
  11. package/agents/researcher.md +52 -0
  12. package/agents/reviewer.md +79 -0
  13. package/agents/scout.md +50 -0
  14. package/agents/tester.md +45 -0
  15. package/agents/worker.md +55 -0
  16. package/extensions/ralph.ts +1 -0
  17. package/extensions/reviewer-extension.ts +125 -0
  18. package/extensions/task-orchestrator.ts +28 -0
  19. package/package.json +63 -0
  20. package/prompts/gather-context-and-clarify.md +13 -0
  21. package/prompts/parallel-cleanup.md +59 -0
  22. package/prompts/parallel-context-build.md +53 -0
  23. package/prompts/parallel-handoff-plan.md +59 -0
  24. package/prompts/parallel-research.md +50 -0
  25. package/prompts/parallel-review.md +54 -0
  26. package/prompts/review-loop.md +41 -0
  27. package/skills/orchid/SKILL.md +214 -0
  28. package/skills/orchid/orchid-cleanup/SKILL.md +122 -0
  29. package/skills/orchid/orchid-converge/SKILL.md +124 -0
  30. package/skills/orchid/orchid-decompose/SKILL.md +201 -0
  31. package/skills/orchid/orchid-doctor/SKILL.md +162 -0
  32. package/skills/orchid/orchid-investigate/SKILL.md +102 -0
  33. package/skills/orchid/orchid-launch/SKILL.md +147 -0
  34. package/skills/ralph/SKILL.md +73 -0
  35. package/skills/subagents/pi-subagents/SKILL.md +813 -0
  36. package/src/index.ts +7 -0
  37. package/src/orchestrator/abort.ts +534 -0
  38. package/src/orchestrator/agent-bridge-extension.ts +1020 -0
  39. package/src/orchestrator/agent-host.ts +954 -0
  40. package/src/orchestrator/cleanup.ts +776 -0
  41. package/src/orchestrator/config-loader.ts +1412 -0
  42. package/src/orchestrator/config-schema.ts +690 -0
  43. package/src/orchestrator/config.ts +81 -0
  44. package/src/orchestrator/context-window.ts +66 -0
  45. package/src/orchestrator/diagnostic-reports.ts +475 -0
  46. package/src/orchestrator/diagnostics.ts +394 -0
  47. package/src/orchestrator/discovery.ts +1833 -0
  48. package/src/orchestrator/engine-worker.ts +415 -0
  49. package/src/orchestrator/engine.ts +5940 -0
  50. package/src/orchestrator/execution.ts +3104 -0
  51. package/src/orchestrator/extension.ts +5934 -0
  52. package/src/orchestrator/formatting.ts +785 -0
  53. package/src/orchestrator/git.ts +88 -0
  54. package/src/orchestrator/index.ts +28 -0
  55. package/src/orchestrator/lane-runner.ts +1787 -0
  56. package/src/orchestrator/mailbox.ts +780 -0
  57. package/src/orchestrator/merge.ts +3414 -0
  58. package/src/orchestrator/messages.ts +1062 -0
  59. package/src/orchestrator/migrations.ts +278 -0
  60. package/src/orchestrator/naming.ts +117 -0
  61. package/src/orchestrator/path-resolver.ts +275 -0
  62. package/src/orchestrator/persistence.ts +2625 -0
  63. package/src/orchestrator/process-registry.ts +452 -0
  64. package/src/orchestrator/quality-gate.ts +1085 -0
  65. package/src/orchestrator/resume.ts +3488 -0
  66. package/src/orchestrator/sessions.ts +57 -0
  67. package/src/orchestrator/settings-loader.ts +136 -0
  68. package/src/orchestrator/settings-tui.ts +2208 -0
  69. package/src/orchestrator/sidecar-telemetry.ts +267 -0
  70. package/src/orchestrator/supervisor.ts +4548 -0
  71. package/src/orchestrator/task-executor-core.ts +675 -0
  72. package/src/orchestrator/tmux-compat.ts +37 -0
  73. package/src/orchestrator/tool-allowlist-constants.ts +37 -0
  74. package/src/orchestrator/types.ts +4465 -0
  75. package/src/orchestrator/verification.ts +547 -0
  76. package/src/orchestrator/waves.ts +1564 -0
  77. package/src/orchestrator/workspace.ts +707 -0
  78. package/src/orchestrator/worktree.ts +2725 -0
  79. package/src/ralph/index.ts +825 -0
  80. package/src/subagents/agents/agent-management.ts +648 -0
  81. package/src/subagents/agents/agent-scope.ts +6 -0
  82. package/src/subagents/agents/agent-selection.ts +23 -0
  83. package/src/subagents/agents/agent-serializer.ts +86 -0
  84. package/src/subagents/agents/agents.ts +832 -0
  85. package/src/subagents/agents/chain-serializer.ts +137 -0
  86. package/src/subagents/agents/frontmatter.ts +29 -0
  87. package/src/subagents/agents/identity.ts +30 -0
  88. package/src/subagents/agents/skills.ts +632 -0
  89. package/src/subagents/extension/config.ts +16 -0
  90. package/src/subagents/extension/control-notices.ts +92 -0
  91. package/src/subagents/extension/doctor.ts +199 -0
  92. package/src/subagents/extension/fanout-child.ts +170 -0
  93. package/src/subagents/extension/index.ts +573 -0
  94. package/src/subagents/extension/schemas.ts +168 -0
  95. package/src/subagents/intercom/intercom-bridge.ts +379 -0
  96. package/src/subagents/intercom/result-intercom.ts +377 -0
  97. package/src/subagents/runs/background/async-execution.ts +712 -0
  98. package/src/subagents/runs/background/async-job-tracker.ts +310 -0
  99. package/src/subagents/runs/background/async-resume.ts +345 -0
  100. package/src/subagents/runs/background/async-status.ts +325 -0
  101. package/src/subagents/runs/background/completion-dedupe.ts +63 -0
  102. package/src/subagents/runs/background/notify.ts +108 -0
  103. package/src/subagents/runs/background/parallel-groups.ts +45 -0
  104. package/src/subagents/runs/background/result-watcher.ts +307 -0
  105. package/src/subagents/runs/background/run-id-resolver.ts +83 -0
  106. package/src/subagents/runs/background/run-status.ts +269 -0
  107. package/src/subagents/runs/background/stale-run-reconciler.ts +336 -0
  108. package/src/subagents/runs/background/subagent-runner.ts +1808 -0
  109. package/src/subagents/runs/background/top-level-async.ts +13 -0
  110. package/src/subagents/runs/foreground/chain-clarify.ts +1333 -0
  111. package/src/subagents/runs/foreground/chain-execution.ts +938 -0
  112. package/src/subagents/runs/foreground/execution.ts +918 -0
  113. package/src/subagents/runs/foreground/subagent-executor.ts +2527 -0
  114. package/src/subagents/runs/shared/completion-guard.ts +147 -0
  115. package/src/subagents/runs/shared/long-running-guard.ts +175 -0
  116. package/src/subagents/runs/shared/mcp-direct-tool-allowlist.ts +365 -0
  117. package/src/subagents/runs/shared/model-fallback.ts +103 -0
  118. package/src/subagents/runs/shared/nested-events.ts +819 -0
  119. package/src/subagents/runs/shared/nested-path.ts +52 -0
  120. package/src/subagents/runs/shared/nested-render.ts +115 -0
  121. package/src/subagents/runs/shared/parallel-utils.ts +109 -0
  122. package/src/subagents/runs/shared/pi-args.ts +220 -0
  123. package/src/subagents/runs/shared/pi-spawn.ts +115 -0
  124. package/src/subagents/runs/shared/run-history.ts +60 -0
  125. package/src/subagents/runs/shared/single-output.ts +164 -0
  126. package/src/subagents/runs/shared/subagent-control.ts +226 -0
  127. package/src/subagents/runs/shared/subagent-prompt-runtime.ts +170 -0
  128. package/src/subagents/runs/shared/worktree.ts +577 -0
  129. package/src/subagents/shared/artifacts.ts +98 -0
  130. package/src/subagents/shared/atomic-json.ts +16 -0
  131. package/src/subagents/shared/file-coalescer.ts +40 -0
  132. package/src/subagents/shared/fork-context.ts +76 -0
  133. package/src/subagents/shared/formatters.ts +133 -0
  134. package/src/subagents/shared/jsonl-writer.ts +81 -0
  135. package/src/subagents/shared/model-info.ts +78 -0
  136. package/src/subagents/shared/post-exit-stdio-guard.ts +85 -0
  137. package/src/subagents/shared/session-identity.ts +10 -0
  138. package/src/subagents/shared/session-tokens.ts +44 -0
  139. package/src/subagents/shared/settings.ts +397 -0
  140. package/src/subagents/shared/status-format.ts +49 -0
  141. package/src/subagents/shared/types.ts +822 -0
  142. package/src/subagents/shared/utils.ts +450 -0
  143. package/src/subagents/slash/prompt-template-bridge.ts +397 -0
  144. package/src/subagents/slash/slash-bridge.ts +174 -0
  145. package/src/subagents/slash/slash-commands.ts +528 -0
  146. package/src/subagents/slash/slash-live-state.ts +292 -0
  147. package/src/subagents/tui/render-helpers.ts +80 -0
  148. package/src/subagents/tui/render.ts +1358 -0
  149. package/templates/agents/local/supervisor.md +33 -0
  150. package/templates/agents/local/task-merger.md +27 -0
  151. package/templates/agents/local/task-reviewer.md +30 -0
  152. package/templates/agents/local/task-worker.md +34 -0
  153. package/templates/agents/supervisor-routing.md +92 -0
  154. package/templates/agents/supervisor.md +229 -0
  155. package/templates/agents/task-merger.md +214 -0
  156. package/templates/agents/task-reviewer.md +260 -0
  157. package/templates/agents/task-worker-segment.md +44 -0
  158. package/templates/agents/task-worker.md +557 -0
  159. package/templates/tasks/CONTEXT.md +30 -0
  160. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -0
  161. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
  162. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -0
  163. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -0
@@ -0,0 +1,1020 @@
1
+ /**
2
+ * Agent Bridge Extension — Minimal agent-side tools for Runtime V2
3
+ *
4
+ * Loaded into worker/reviewer/merger Pi agent processes to provide
5
+ * structured communication back to the supervisor and lane-runner
6
+ * without requiring agents to hand-roll JSON via bash/write.
7
+ *
8
+ * Tools:
9
+ * - notify_supervisor: send a reply or acknowledgment to supervisor
10
+ * - escalate_to_supervisor: escalate a blocker or ambiguity
11
+ * - request_segment_expansion: request runtime segment expansion via file IPC
12
+ *
13
+ * This extension is intentionally minimal and protocol-focused.
14
+ * It does NOT own:
15
+ * - wait_for_review (deferred to persistent reviewer work)
16
+ *
17
+ * File I/O only — writes to the agent's outbox directory.
18
+ * The lane-runner or engine polls outbox and surfaces to supervisor.
19
+ *
20
+ * @module orchid/agent-bridge-extension
21
+ * @since TP-106
22
+ */
23
+
24
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
25
+ import { Type } from "@earendil-works/pi-ai";
26
+ import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from "fs";
27
+ import { join, dirname } from "path";
28
+ import { spawn as nodeSpawn } from "child_process";
29
+ import { resolvePiCliPath, resolveTaskplaneAgentTemplate } from "./path-resolver.ts";
30
+ import { loadPiSettingsPackages, filterExcludedExtensions } from "./settings-loader.ts";
31
+ import { randomBytes } from "crypto";
32
+ import { buildExpansionRequestId, type SegmentExpansionRequest } from "./types.ts";
33
+
34
+ /**
35
+ * Resolve the outbox directory from environment variables.
36
+ *
37
+ * The lane-runner sets TASKPLANE_OUTBOX_DIR when launching workers
38
+ * with the bridge extension. Falls back to .pi/bridge-outbox/ in cwd.
39
+ */
40
+ function resolveOutboxDir(): string {
41
+ if (process.env.TASKPLANE_OUTBOX_DIR) return process.env.TASKPLANE_OUTBOX_DIR;
42
+
43
+ const batchId = process.env.ORCH_BATCH_ID;
44
+ const agentId = process.env.TASKPLANE_AGENT_ID;
45
+ if (batchId && agentId) {
46
+ return join(process.cwd(), ".pi", "mailbox", batchId, agentId, "outbox");
47
+ }
48
+
49
+ return join(process.cwd(), ".pi", "bridge-outbox");
50
+ }
51
+
52
+ /**
53
+ * Write a message to the agent's outbox.
54
+ */
55
+ function writeOutbox(
56
+ type: "reply" | "escalate",
57
+ content: string,
58
+ replyTo?: string,
59
+ ): { id: string } {
60
+ const outboxDir = resolveOutboxDir();
61
+ mkdirSync(outboxDir, { recursive: true });
62
+
63
+ const contentBytes = Buffer.byteLength(content, "utf8");
64
+ if (contentBytes > 4096) {
65
+ throw new Error(`Outbox message exceeds 4096 bytes (${contentBytes})`);
66
+ }
67
+
68
+ const timestamp = Date.now();
69
+ const nonce = randomBytes(3).toString("hex").slice(0, 5);
70
+ const id = `${timestamp}-${nonce}`;
71
+
72
+ const message = {
73
+ id,
74
+ batchId: process.env.ORCH_BATCH_ID || "unknown",
75
+ from: process.env.TASKPLANE_AGENT_ID || "agent",
76
+ to: "supervisor",
77
+ timestamp,
78
+ type,
79
+ content,
80
+ expectsReply: type === "escalate",
81
+ replyTo: replyTo || null,
82
+ };
83
+
84
+ const tmpPath = join(outboxDir, `${id}.msg.json.tmp`);
85
+ const finalPath = join(outboxDir, `${id}.msg.json`);
86
+ writeFileSync(tmpPath, JSON.stringify(message, null, 2) + "\n", "utf-8");
87
+ renameSync(tmpPath, finalPath);
88
+
89
+ return { id };
90
+ }
91
+
92
+ const REPO_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
93
+ const AUTONOMY_PATTERN = /^(interactive|supervised|autonomous)$/;
94
+
95
+ function resolveActiveSegmentId(): string | null {
96
+ const raw = (
97
+ process.env.TASKPLANE_ACTIVE_SEGMENT_ID ||
98
+ process.env.TASKPLANE_SEGMENT_ID ||
99
+ ""
100
+ ).trim();
101
+ if (!raw || raw === "null" || raw === "(none / whole-task execution)") return null;
102
+ return raw;
103
+ }
104
+
105
+ function resolveTaskId(fromSegmentId: string): string {
106
+ const envTaskId = process.env.TASKPLANE_TASK_ID?.trim();
107
+ if (envTaskId) return envTaskId;
108
+ const idx = fromSegmentId.indexOf("::");
109
+ if (idx > 0) return fromSegmentId.slice(0, idx);
110
+ const folder = process.env.TASKPLANE_TASK_FOLDER || "";
111
+ const name = folder.split(/[\\/]/).filter(Boolean).at(-1) || "";
112
+ const match = name.match(/^[A-Z]+-\d+/);
113
+ return match ? match[0] : "unknown";
114
+ }
115
+
116
+ function resolveSupervisorAutonomy(): "interactive" | "supervised" | "autonomous" {
117
+ const value = (process.env.TASKPLANE_SUPERVISOR_AUTONOMY || "autonomous").trim().toLowerCase();
118
+ if (AUTONOMY_PATTERN.test(value)) {
119
+ return value as "interactive" | "supervised" | "autonomous";
120
+ }
121
+ return "autonomous";
122
+ }
123
+
124
+ function writeSegmentExpansionRequest(request: SegmentExpansionRequest): string {
125
+ const outboxDir = resolveOutboxDir();
126
+ mkdirSync(outboxDir, { recursive: true });
127
+
128
+ const filename = `segment-expansion-${request.requestId}.json`;
129
+ const finalPath = join(outboxDir, filename);
130
+ const tempPath = `${finalPath}.tmp`;
131
+
132
+ try {
133
+ writeFileSync(tempPath, JSON.stringify(request, null, 2) + "\n", "utf-8");
134
+ renameSync(tempPath, finalPath);
135
+ } catch (err) {
136
+ try {
137
+ if (existsSync(tempPath)) unlinkSync(tempPath);
138
+ } catch {
139
+ /* cleanup */
140
+ }
141
+ throw new Error(
142
+ `Failed to write segment expansion request: ${err instanceof Error ? err.message : String(err)}`,
143
+ );
144
+ }
145
+
146
+ return finalPath;
147
+ }
148
+
149
+ /**
150
+ * TP-186 — Death-spiral guard helper for `review_step`.
151
+ *
152
+ * Inspects STATUS.md to determine whether the worker has prematurely set the
153
+ * given step's section heading to `**Status:** ✅ Complete`. The guard fires
154
+ * for `code` and `test` review types only — plan reviews fire BEFORE
155
+ * implementation, when an empty STATUS is correct.
156
+ *
157
+ * Returns `true` ONLY if the step's section explicitly carries the
158
+ * `**Status:** ✅ Complete` line. The top-of-file (task-level) `**Status:**`
159
+ * field does not trip this guard because it is not inside any `### Step N:`
160
+ * section. All-checkboxes-checked is also NOT a trigger — it is the normal
161
+ * pre-code-review state.
162
+ *
163
+ * Fenced code blocks (delimited by ``` or ~~~) inside a step's body are
164
+ * skipped during the scan (TP-189-A3). This avoids a false-positive
165
+ * refusal when a step documents the literal `**Status:** ✅ Complete`
166
+ * pattern as part of its own instructions — a legitimate authoring case
167
+ * that doesn't represent an actual completion claim.
168
+ *
169
+ * Designed to fail-open: any I/O error or a missing step heading returns
170
+ * `false` (the review proceeds). The prompt-side Recovery Recipe is the
171
+ * primary defense; this guard is a hard backstop, not a gatekeeper.
172
+ *
173
+ * @param statusPath absolute path to the worker's STATUS.md
174
+ * @param stepNum the step number being reviewed
175
+ * @returns true iff the step is marked Complete in STATUS.md
176
+ */
177
+ export function isStepMarkedComplete(statusPath: string, stepNum: number): boolean {
178
+ let content: string;
179
+ try {
180
+ content = readFileSync(statusPath, "utf-8");
181
+ } catch {
182
+ return false;
183
+ }
184
+
185
+ const lines = content.split(/\r?\n/);
186
+ const stepHeadingRe = new RegExp(`^###\\s+Step\\s+${stepNum}\\b`);
187
+ const nextStepHeadingRe = /^###\s+Step\s+\d+\b/;
188
+ // TP-189-A3: track fenced-code-block state per CommonMark semantics.
189
+ // A fence opens with 3+ backticks OR 3+ tildes optionally followed by
190
+ // an info string (e.g., ```javascript). A fence CLOSES only when a
191
+ // matching delimiter (same char, length >= opener length) is seen on
192
+ // a line by itself — the closer line MUST NOT contain trailing
193
+ // non-whitespace text. This distinction matters: ```javascript
194
+ // inside an open fence is content, not a closer; mistreating it as a
195
+ // closer would let `**Status:** ✅ Complete` later in the same code
196
+ // block trip the guard. Tracking opener char + length also avoids
197
+ // premature close on `~~~` inside a backtick fence (or vice versa).
198
+ const openerRe = /^\s*(`{3,}|~{3,})(.*)$/;
199
+ let inSection = false;
200
+ let fenceOpener: { char: string; length: number } | null = null;
201
+ for (const line of lines) {
202
+ if (!inSection) {
203
+ if (stepHeadingRe.test(line)) inSection = true;
204
+ continue;
205
+ }
206
+ // Step boundaries are recognized only OUTSIDE a fenced block.
207
+ // (A `### Step N:` line inside a code-fence sample is content,
208
+ // not a real heading.)
209
+ if (fenceOpener === null && nextStepHeadingRe.test(line)) break;
210
+ // Detect fence delimiter lines.
211
+ const fenceMatch = line.match(openerRe);
212
+ if (fenceMatch) {
213
+ const delim = fenceMatch[1];
214
+ const trailing = fenceMatch[2] ?? "";
215
+ const char = delim[0]; // "`" or "~"
216
+ const length = delim.length;
217
+ if (fenceOpener === null) {
218
+ // Opening: any trailing text is the info string — allowed.
219
+ // CommonMark forbids backticks in a backtick info string,
220
+ // but rejecting that case here only risks false negatives
221
+ // (i.e., not opening a fence we should have); the worst-
222
+ // case impact is a real Status line being inspected as if
223
+ // outside a fence — which is the safe default.
224
+ fenceOpener = { char, length };
225
+ continue;
226
+ }
227
+ // Already inside a fence — a line counts as a closer ONLY if:
228
+ // 1. delimiter char matches the opener,
229
+ // 2. delimiter length >= opener length,
230
+ // 3. nothing follows the delimiter except whitespace.
231
+ const trailingIsWhitespace = /^\s*$/.test(trailing);
232
+ if (char === fenceOpener.char && length >= fenceOpener.length && trailingIsWhitespace) {
233
+ fenceOpener = null;
234
+ continue;
235
+ }
236
+ // Else: this line is content INSIDE the open fence (e.g.,
237
+ // ```javascript inside a 4-backtick fence, or a non-matching
238
+ // tilde delimiter). Fall through to the inFence skip below.
239
+ }
240
+ // Skip lines inside an open fenced code block.
241
+ if (fenceOpener !== null) continue;
242
+ // Match a literal status line within this step's section.
243
+ // Examples that should match:
244
+ // **Status:** ✅ Complete
245
+ // **Status:** ✅ Complete (note ...)
246
+ if (/^\s*\*\*Status:\*\*\s*✅\s*Complete\b/.test(line)) {
247
+ return true;
248
+ }
249
+ }
250
+ return false;
251
+ }
252
+
253
+ export default function (pi: ExtensionAPI) {
254
+ pi.registerTool({
255
+ name: "notify_supervisor",
256
+ label: "Notify Supervisor",
257
+ description:
258
+ "Send a reply or acknowledgment to the supervisor. " +
259
+ "Use this to confirm you've received a steering message, " +
260
+ "report a status update, or share a discovery.",
261
+ promptSnippet: "notify_supervisor(content, replyTo?) — send reply to supervisor",
262
+ promptGuidelines: [
263
+ "Use notify_supervisor to acknowledge steering messages or share status updates.",
264
+ "Keep content concise (max 4KB).",
265
+ "Include replyTo with the message ID you're responding to, if applicable.",
266
+ ],
267
+ parameters: Type.Object({
268
+ content: Type.String({
269
+ description: "Reply content (max 4KB)",
270
+ }),
271
+ replyTo: Type.Optional(
272
+ Type.String({
273
+ description: "Message ID being replied to (from a steering message)",
274
+ }),
275
+ ),
276
+ }),
277
+ async execute(_toolCallId, params) {
278
+ try {
279
+ const result = writeOutbox("reply", params.content, params.replyTo);
280
+ return {
281
+ content: [
282
+ {
283
+ type: "text" as const,
284
+ text: `✅ Reply sent to supervisor (ID: ${result.id})`,
285
+ },
286
+ ],
287
+ details: undefined,
288
+ };
289
+ } catch (err) {
290
+ return {
291
+ content: [
292
+ {
293
+ type: "text" as const,
294
+ text: `❌ Failed to send reply: ${err instanceof Error ? err.message : String(err)}`,
295
+ },
296
+ ],
297
+ details: undefined,
298
+ };
299
+ }
300
+ },
301
+ });
302
+
303
+ pi.registerTool({
304
+ name: "escalate_to_supervisor",
305
+ label: "Escalate to Supervisor",
306
+ description:
307
+ "Escalate a blocker, ambiguity, or question to the supervisor. " +
308
+ "Use this when you're stuck, confused, or need guidance before proceeding.",
309
+ promptSnippet: "escalate_to_supervisor(content) — escalate blocker to supervisor",
310
+ promptGuidelines: [
311
+ "Use escalate_to_supervisor when you're blocked and need human/supervisor guidance.",
312
+ "Clearly describe what you're stuck on and what options you see.",
313
+ "The supervisor will respond via a steering message.",
314
+ ],
315
+ parameters: Type.Object({
316
+ content: Type.String({
317
+ description: "Description of the blocker or question (max 4KB)",
318
+ }),
319
+ }),
320
+ async execute(_toolCallId, params) {
321
+ try {
322
+ const result = writeOutbox("escalate", params.content);
323
+ return {
324
+ content: [
325
+ {
326
+ type: "text" as const,
327
+ text: `⚠️ Escalation sent to supervisor (ID: ${result.id}). Continue working on other items while waiting for guidance.`,
328
+ },
329
+ ],
330
+ details: undefined,
331
+ };
332
+ } catch (err) {
333
+ return {
334
+ content: [
335
+ {
336
+ type: "text" as const,
337
+ text: `❌ Failed to escalate: ${err instanceof Error ? err.message : String(err)}`,
338
+ },
339
+ ],
340
+ details: undefined,
341
+ };
342
+ }
343
+ },
344
+ });
345
+
346
+ const activeSegmentId = resolveActiveSegmentId();
347
+ if (activeSegmentId) {
348
+ /**
349
+ * Worker RPC for requesting runtime segment expansion.
350
+ *
351
+ * Contract summary:
352
+ * - accepts requested repo IDs + rationale (+ optional placement/edges)
353
+ * - validates request shape and repo ID rules
354
+ * - writes `.pi/mailbox/{batchId}/{agentId}/outbox/segment-expansion-{requestId}.json`
355
+ * - returns acknowledgment payload (`accepted`, `requestId`, `message`)
356
+ */
357
+ pi.registerTool({
358
+ name: "request_segment_expansion",
359
+ label: "Request Segment Expansion",
360
+ description:
361
+ "Request additional repository segments for the current task at runtime. " +
362
+ "Writes a request file to the worker outbox for engine processing.",
363
+ promptSnippet: "request_segment_expansion(requestedRepoIds, rationale, placement?, edges?)",
364
+ promptGuidelines: [
365
+ "Use this when runtime discovery reveals additional repos are needed.",
366
+ "Do not wait for approval; continue current segment work after requesting.",
367
+ "requestedRepoIds must be non-empty, unique, and match /^[a-z0-9][a-z0-9._-]*$/.",
368
+ "In supervised/interactive autonomy, this tool returns accepted: false (V1 guard).",
369
+ ],
370
+ parameters: Type.Object({
371
+ requestedRepoIds: Type.Array(Type.String({ description: "Repo ID to add" }), {
372
+ description: "Repo IDs to add as new segments",
373
+ }),
374
+ rationale: Type.String({
375
+ description: "Why these repos are needed",
376
+ }),
377
+ placement: Type.Optional(
378
+ Type.Union([Type.Literal("after-current"), Type.Literal("end")], {
379
+ description: "Where to place new segments: after-current (default) or end",
380
+ }),
381
+ ),
382
+ edges: Type.Optional(
383
+ Type.Array(
384
+ Type.Object({
385
+ from: Type.String({ description: "Source repo ID" }),
386
+ to: Type.String({ description: "Destination repo ID" }),
387
+ }),
388
+ {
389
+ description: "Optional ordering edges between requested repos",
390
+ },
391
+ ),
392
+ ),
393
+ }),
394
+ async execute(_toolCallId, params) {
395
+ const autonomy = resolveSupervisorAutonomy();
396
+ if (autonomy !== "autonomous") {
397
+ const rejected = {
398
+ accepted: false,
399
+ requestId: null,
400
+ message: "Segment expansion requires autonomous supervisor mode",
401
+ };
402
+ return {
403
+ content: [{ type: "text" as const, text: JSON.stringify(rejected) }],
404
+ details: rejected,
405
+ };
406
+ }
407
+
408
+ const requestedRepoIds = Array.isArray(params.requestedRepoIds)
409
+ ? params.requestedRepoIds.map((id) => String(id).trim()).filter(Boolean)
410
+ : [];
411
+ const rejections: Array<{ repoId: string; reason: string }> = [];
412
+
413
+ if (requestedRepoIds.length === 0) {
414
+ rejections.push({ repoId: "", reason: "requestedRepoIds must be a non-empty array" });
415
+ } else {
416
+ const seen = new Set<string>();
417
+ for (const repoId of requestedRepoIds) {
418
+ if (!REPO_ID_PATTERN.test(repoId)) {
419
+ rejections.push({ repoId, reason: "invalid repo ID format" });
420
+ continue;
421
+ }
422
+ if (seen.has(repoId)) {
423
+ rejections.push({ repoId, reason: "duplicate repo ID in request" });
424
+ continue;
425
+ }
426
+ seen.add(repoId);
427
+ }
428
+ }
429
+
430
+ if (rejections.length > 0) {
431
+ const rejected = {
432
+ accepted: false,
433
+ requestId: null,
434
+ message: "Segment expansion request rejected by tool validation",
435
+ rejections,
436
+ };
437
+ return {
438
+ content: [{ type: "text" as const, text: JSON.stringify(rejected) }],
439
+ details: rejected,
440
+ };
441
+ }
442
+
443
+ const requestId = buildExpansionRequestId();
444
+ const now = Date.now();
445
+ const request: SegmentExpansionRequest = {
446
+ requestId,
447
+ taskId: resolveTaskId(activeSegmentId),
448
+ fromSegmentId: activeSegmentId as SegmentExpansionRequest["fromSegmentId"],
449
+ requestedRepoIds,
450
+ rationale: String(params.rationale ?? "").trim(),
451
+ placement: params.placement === "end" ? "end" : "after-current",
452
+ edges: Array.isArray(params.edges)
453
+ ? params.edges
454
+ .filter((edge): edge is { from: string; to: string } =>
455
+ Boolean(edge && typeof edge.from === "string" && typeof edge.to === "string"),
456
+ )
457
+ .map((edge) => ({ from: edge.from.trim(), to: edge.to.trim() }))
458
+ .filter((edge) => edge.from.length > 0 && edge.to.length > 0)
459
+ : [],
460
+ timestamp: now,
461
+ };
462
+
463
+ try {
464
+ writeSegmentExpansionRequest(request);
465
+ const accepted = {
466
+ accepted: true,
467
+ requestId,
468
+ message: "Segment expansion request accepted",
469
+ };
470
+ return {
471
+ content: [{ type: "text" as const, text: JSON.stringify(accepted) }],
472
+ details: accepted,
473
+ };
474
+ } catch (err) {
475
+ const failed = {
476
+ accepted: false,
477
+ requestId: null,
478
+ message: err instanceof Error ? err.message : String(err),
479
+ };
480
+ return {
481
+ content: [{ type: "text" as const, text: JSON.stringify(failed) }],
482
+ details: failed,
483
+ };
484
+ }
485
+ },
486
+ });
487
+ }
488
+
489
+ // ── review_step Tool (TP-117) ─────────────────────────────────────
490
+ // Spawns a reviewer subprocess to evaluate work at step boundaries.
491
+ // The reviewer runs as a separate Pi process, writes feedback to
492
+ // .reviews/, and this tool returns the verdict to the worker.
493
+
494
+ /**
495
+ * Load the reviewer system prompt from base template + local override.
496
+ * Uses resolveTaskplaneAgentTemplate (path-resolver.ts) for all platform support (TP-157).
497
+ */
498
+ function loadReviewerPrompt(): string {
499
+ let basePrompt =
500
+ "You are a code reviewer. Read the request and write your review to the specified output file.";
501
+ try {
502
+ const templatePath = resolveTaskplaneAgentTemplate("task-reviewer");
503
+ if (existsSync(templatePath)) {
504
+ const raw = readFileSync(templatePath, "utf-8");
505
+ const fmEnd = raw.indexOf("---", 4);
506
+ if (fmEnd > 0) basePrompt = raw.slice(fmEnd + 3).trim();
507
+ }
508
+ } catch {
509
+ /* fall through to default */
510
+ }
511
+ // Local override
512
+ const localPaths = [
513
+ join(process.cwd(), ".pi", "agents", "task-reviewer.md"),
514
+ join(process.cwd(), "agents", "task-reviewer.md"),
515
+ ];
516
+ for (const p of localPaths) {
517
+ try {
518
+ if (!existsSync(p)) continue;
519
+ const raw = readFileSync(p, "utf-8");
520
+ const fmEnd = raw.indexOf("---", 4);
521
+ if (fmEnd > 0) {
522
+ const localBody = raw.slice(fmEnd + 3).trim();
523
+ if (localBody) basePrompt += "\n\n---\n\n## Project-Specific Guidance\n\n" + localBody;
524
+ }
525
+ break;
526
+ } catch {
527
+ continue;
528
+ }
529
+ }
530
+ return basePrompt;
531
+ }
532
+
533
+ function reviewerStatePath(taskFolder: string): string {
534
+ return process.env.TASKPLANE_REVIEWER_STATE_PATH || join(taskFolder, ".reviewer-state.json");
535
+ }
536
+
537
+ function writeReviewerState(
538
+ taskFolder: string,
539
+ state: {
540
+ status: "running" | "done" | "error";
541
+ elapsedMs: number;
542
+ toolCalls: number;
543
+ contextPct: number;
544
+ costUsd: number;
545
+ lastTool: string;
546
+ inputTokens: number;
547
+ outputTokens: number;
548
+ cacheReadTokens: number;
549
+ cacheWriteTokens: number;
550
+ updatedAt: number;
551
+ reviewType?: string;
552
+ reviewStep?: number;
553
+ },
554
+ ): void {
555
+ const filePath = reviewerStatePath(taskFolder);
556
+ const tmpPath = filePath + ".tmp";
557
+ writeFileSync(tmpPath, JSON.stringify(state, null, 2) + "\n", "utf-8");
558
+ renameSync(tmpPath, filePath);
559
+ }
560
+
561
+ function removeReviewerState(taskFolder: string): void {
562
+ const filePath = reviewerStatePath(taskFolder);
563
+ if (!existsSync(filePath)) return;
564
+ try {
565
+ unlinkSync(filePath);
566
+ } catch {
567
+ /* best effort */
568
+ }
569
+ }
570
+
571
+ /**
572
+ * Spawn a reviewer Pi subprocess and wait for it to complete.
573
+ * Returns the process exit code.
574
+ */
575
+ function spawnReviewer(
576
+ prompt: string,
577
+ systemPrompt: string,
578
+ cwd: string,
579
+ taskFolder: string,
580
+ reviewType?: string,
581
+ reviewStep?: number,
582
+ ): Promise<number> {
583
+ // Pre-clean stale reviewer state from prior interrupted review
584
+ removeReviewerState(taskFolder);
585
+ return new Promise((resolve) => {
586
+ // Read reviewer config from env vars set by lane-runner from runnerConfig.reviewer.
587
+ // Empty string means inherit from session default (no flag passed to pi CLI).
588
+ const reviewerModel = process.env.TASKPLANE_REVIEWER_MODEL || "";
589
+ const reviewerThinking = process.env.TASKPLANE_REVIEWER_THINKING || "";
590
+ // Fall back to the schema default reviewer tool list (read-only + bash/grep).
591
+ // Must match config-schema.ts reviewer.tools default to avoid capability expansion.
592
+ const reviewerTools = process.env.TASKPLANE_REVIEWER_TOOLS || "read,bash,grep,find,ls";
593
+
594
+ const cliPath = resolvePiCliPath();
595
+ const args = [
596
+ cliPath,
597
+ "--mode",
598
+ "rpc",
599
+ "--no-session",
600
+ "--no-extensions",
601
+ "--no-skills",
602
+ "--tools",
603
+ reviewerTools,
604
+ "--system-prompt",
605
+ systemPrompt,
606
+ ];
607
+ if (reviewerModel) args.push("--model", reviewerModel);
608
+ if (reviewerThinking) args.push("--thinking", reviewerThinking);
609
+
610
+ // TP-180: Forward user-installed extensions to reviewer agent
611
+ // Use TASKPLANE_STATE_ROOT (canonical project root) for settings resolution,
612
+ // falling back to cwd (which may be a worktree without .pi/settings.json).
613
+ const settingsRoot = process.env.TASKPLANE_STATE_ROOT || cwd;
614
+ const reviewerPackages = loadPiSettingsPackages(settingsRoot);
615
+ // Apply reviewer-specific exclusions from config (JSON array via env)
616
+ let reviewerExclusions: string[] = [];
617
+ try {
618
+ const rawExclude = process.env.TASKPLANE_REVIEWER_EXCLUDE_EXTENSIONS;
619
+ if (rawExclude) {
620
+ const parsed = JSON.parse(rawExclude);
621
+ if (Array.isArray(parsed)) {
622
+ reviewerExclusions = parsed.filter((v: unknown): v is string => typeof v === "string");
623
+ }
624
+ }
625
+ } catch {
626
+ /* ignore malformed */
627
+ }
628
+ const filteredReviewerPackages = filterExcludedExtensions(reviewerPackages, reviewerExclusions);
629
+ for (const pkg of filteredReviewerPackages) {
630
+ args.push("-e", pkg);
631
+ }
632
+ const proc = nodeSpawn(process.execPath, args, {
633
+ shell: false,
634
+ cwd,
635
+ stdio: ["pipe", "pipe", "pipe"],
636
+ env: { ...process.env },
637
+ });
638
+
639
+ const startedAt = Date.now();
640
+ let inputTokens = 0;
641
+ let outputTokens = 0;
642
+ let cacheReadTokens = 0;
643
+ let cacheWriteTokens = 0;
644
+ let costUsd = 0;
645
+ let toolCalls = 0;
646
+ let lastTool = "";
647
+ let contextPct = 0;
648
+ let stdoutBuf = "";
649
+ let finalized = false;
650
+
651
+ const emitState = (status: "running" | "done" | "error") => {
652
+ try {
653
+ writeReviewerState(taskFolder, {
654
+ status,
655
+ elapsedMs: Date.now() - startedAt,
656
+ toolCalls,
657
+ contextPct,
658
+ costUsd,
659
+ lastTool,
660
+ inputTokens,
661
+ outputTokens,
662
+ cacheReadTokens,
663
+ cacheWriteTokens,
664
+ updatedAt: Date.now(),
665
+ reviewType,
666
+ reviewStep,
667
+ });
668
+ } catch {
669
+ /* best effort */
670
+ }
671
+ };
672
+
673
+ // Write initial "running" state immediately so dashboard shows
674
+ // the reviewer sub-row before the first message_end arrives.
675
+ emitState("running");
676
+
677
+ const closeStdin = () => {
678
+ setTimeout(() => {
679
+ try {
680
+ proc.stdin?.end();
681
+ } catch {
682
+ /* ignore */
683
+ }
684
+ }, 100);
685
+ };
686
+
687
+ const finalize = (code: number) => {
688
+ if (finalized) return;
689
+ finalized = true;
690
+ emitState(code === 0 ? "done" : "error");
691
+ resolve(code);
692
+ };
693
+
694
+ const handleEvent = (event: any) => {
695
+ if (!event || typeof event.type !== "string") return;
696
+ switch (event.type) {
697
+ case "message_end": {
698
+ const usage = event.message?.usage;
699
+ if (usage) {
700
+ inputTokens += usage.input || 0;
701
+ outputTokens += usage.output || 0;
702
+ cacheReadTokens += usage.cacheRead || 0;
703
+ cacheWriteTokens += usage.cacheWrite || 0;
704
+ if (usage.cost) {
705
+ costUsd +=
706
+ typeof usage.cost === "object"
707
+ ? usage.cost.total || 0
708
+ : typeof usage.cost === "number"
709
+ ? usage.cost
710
+ : 0;
711
+ }
712
+ }
713
+ emitState("running");
714
+ break;
715
+ }
716
+ case "tool_execution_start": {
717
+ toolCalls++;
718
+ const toolName = event.toolName || "tool";
719
+ const argPreview =
720
+ typeof event.args === "string"
721
+ ? event.args.slice(0, 80)
722
+ : event.args && typeof Object.values(event.args)[0] === "string"
723
+ ? String(Object.values(event.args)[0]).slice(0, 80)
724
+ : "";
725
+ lastTool = argPreview ? `${toolName}: ${argPreview}` : toolName;
726
+ emitState("running");
727
+ break;
728
+ }
729
+ case "response": {
730
+ const pct = event.success === true ? event.data?.contextUsage?.percent : undefined;
731
+ if (typeof pct === "number" && Number.isFinite(pct)) {
732
+ contextPct = pct;
733
+ }
734
+ break;
735
+ }
736
+ case "agent_end": {
737
+ closeStdin();
738
+ break;
739
+ }
740
+ }
741
+ };
742
+
743
+ // Send prompt immediately
744
+ proc.stdin?.write(JSON.stringify({ type: "prompt", message: prompt }) + "\n");
745
+
746
+ proc.stdout?.on("data", (chunk: Buffer | string) => {
747
+ stdoutBuf += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
748
+ let idx = -1;
749
+ while ((idx = stdoutBuf.indexOf("\n")) >= 0) {
750
+ let line = stdoutBuf.slice(0, idx);
751
+ stdoutBuf = stdoutBuf.slice(idx + 1);
752
+ if (line.endsWith("\r")) line = line.slice(0, -1);
753
+ if (!line.trim()) continue;
754
+ let event: any;
755
+ try {
756
+ event = JSON.parse(line);
757
+ } catch {
758
+ continue;
759
+ }
760
+ handleEvent(event);
761
+ }
762
+ });
763
+
764
+ proc.on("close", (code) => finalize(code ?? 1));
765
+ proc.on("error", () => finalize(1));
766
+
767
+ // Timeout: 10 minutes
768
+ setTimeout(
769
+ () => {
770
+ try {
771
+ proc.kill("SIGTERM");
772
+ } catch {
773
+ /* ignore */
774
+ }
775
+ },
776
+ 10 * 60 * 1000,
777
+ );
778
+ });
779
+ }
780
+
781
+ pi.registerTool({
782
+ name: "review_step",
783
+ label: "Review Step",
784
+ description:
785
+ "Spawn a reviewer agent to evaluate your work on a step. " +
786
+ "Returns APPROVE, REVISE, RETHINK, or UNAVAILABLE. " +
787
+ "Use at step boundaries based on the task's review level.",
788
+ promptSnippet: "review_step(step, type, baseline?) — spawn reviewer for a step",
789
+ promptGuidelines: [
790
+ "Call review_step at step boundaries based on the task's Review Level (from STATUS.md header).",
791
+ "Review Level 0: skip all reviews. Level 1: plan review. Level 2: plan + code review. Level 3: plan + code + test.",
792
+ "Skip reviews for Step 0 (Preflight) and the final documentation step.",
793
+ "For code reviews: capture HEAD commit before starting a step with `git rev-parse HEAD` and pass as baseline.",
794
+ "On REVISE: read the review file in .reviews/ for feedback, fix issues, then proceed.",
795
+ "On RETHINK: reconsider your approach.",
796
+ ],
797
+ parameters: Type.Object({
798
+ step: Type.Number({ description: "Step number to review" }),
799
+ type: Type.Union([Type.Literal("plan"), Type.Literal("code")], {
800
+ description: 'Review type: "plan" or "code"',
801
+ }),
802
+ baseline: Type.Optional(
803
+ Type.String({
804
+ description: "Git commit SHA for code review diff baseline",
805
+ }),
806
+ ),
807
+ }),
808
+ async execute(_toolCallId, params) {
809
+ const { step: stepNum, type: reviewType, baseline } = params;
810
+ const cwd = process.cwd();
811
+
812
+ // Find task folder and paths
813
+ const taskFolder = process.env.TASKPLANE_TASK_FOLDER || cwd;
814
+ const statusPath = process.env.TASKPLANE_STATUS_PATH || join(taskFolder, "STATUS.md");
815
+ const promptPath = process.env.TASKPLANE_PROMPT_PATH || join(taskFolder, "PROMPT.md");
816
+ const reviewsDir = process.env.TASKPLANE_REVIEWS_DIR || join(taskFolder, ".reviews");
817
+ if (!existsSync(reviewsDir)) mkdirSync(reviewsDir, { recursive: true });
818
+
819
+ // ── TP-186 death-spiral guard ─────────────────────────────────
820
+ // Refuse to spawn a code/test reviewer on a step that is already
821
+ // marked `**Status:** ✅ Complete` in STATUS.md. The worker has
822
+ // violated the Order of Operations contract; the only safe path
823
+ // is to revert STATUS first, then re-call review_step. Plan
824
+ // reviews are exempt because they fire BEFORE implementation.
825
+ if (reviewType !== "plan" && isStepMarkedComplete(statusPath, stepNum)) {
826
+ const taskIdMatch = statusPath.match(/[\\/]([A-Z]{2,}-\d+)[^\\/]*[\\/]STATUS\.md$/);
827
+ const taskId = taskIdMatch ? taskIdMatch[1] : "<TASK-ID>";
828
+ const refusal = [
829
+ `REFUSED: Step ${stepNum} is already marked \`**Status:** ✅ Complete\` in STATUS.md.`,
830
+ `Per the Order of Operations rule, code review must run BEFORE you mark a step Complete.`,
831
+ `Follow the Recovery Recipe in the worker prompt:`,
832
+ ` 1. Revert the step's Status to \`🟨 In Progress\` in STATUS.md`,
833
+ ` 2. Commit: chore(${taskId}): revert premature step-${stepNum} completion`,
834
+ ` 3. Re-call review_step(step=${stepNum}, type="${reviewType}", baseline=<sha>)`,
835
+ ].join("\n");
836
+ return { content: [{ type: "text" as const, text: refusal }], details: undefined };
837
+ }
838
+
839
+ // Read review counter from STATUS.md
840
+ let reviewCounter = 0;
841
+ try {
842
+ const statusContent = readFileSync(statusPath, "utf-8");
843
+ const rcMatch = statusContent.match(/\*\*Review Counter:\*\*\s*(\d+)/);
844
+ if (rcMatch) reviewCounter = parseInt(rcMatch[1]);
845
+ } catch {
846
+ /* default 0 */
847
+ }
848
+
849
+ reviewCounter++;
850
+ const num = String(reviewCounter).padStart(3, "0");
851
+ const outputPath = join(reviewsDir, `R${num}-${reviewType}-step${stepNum}.md`);
852
+
853
+ // Find step name from PROMPT.md
854
+ let stepName = `Step ${stepNum}`;
855
+ try {
856
+ const promptFiles = [join(taskFolder, "PROMPT.md")];
857
+ for (const pf of promptFiles) {
858
+ if (!existsSync(pf)) continue;
859
+ const content = readFileSync(pf, "utf-8");
860
+ const stepMatch = content.match(new RegExp(`###\\s+Step\\s+${stepNum}[:\\s]+(.+)`));
861
+ if (stepMatch) {
862
+ stepName = stepMatch[1].trim();
863
+ break;
864
+ }
865
+ }
866
+ } catch {
867
+ /* use default */
868
+ }
869
+
870
+ // Generate review request prompt
871
+ const projectName = process.env.TASKPLANE_PROJECT_NAME || "project";
872
+ const diffCmd = baseline ? `git diff ${baseline}..HEAD` : `git diff`;
873
+ const diffNamesCmd = baseline
874
+ ? `git diff ${baseline}..HEAD --name-only`
875
+ : `git diff --name-only`;
876
+
877
+ let reviewPrompt: string;
878
+ if (reviewType === "plan") {
879
+ reviewPrompt = [
880
+ `# Review Request: Plan Review`,
881
+ ``,
882
+ `You are reviewing an implementation plan for a ${projectName} task.`,
883
+ `You have full tool access — use \`read\` to examine files and \`bash\` to run commands.`,
884
+ ``,
885
+ `## Task Context`,
886
+ `- **Task PROMPT:** ${promptPath}`,
887
+ `- **Task STATUS:** ${statusPath}`,
888
+ `- **Step being planned:** Step ${stepNum}: ${stepName}`,
889
+ ``,
890
+ `## Instructions`,
891
+ `1. Read the PROMPT.md for full requirements`,
892
+ `2. Read STATUS.md for progress so far`,
893
+ `3. Evaluate the plan for this step`,
894
+ ``,
895
+ `## Output`,
896
+ `Write your review to: \`${outputPath}\``,
897
+ ].join("\n");
898
+ } else {
899
+ reviewPrompt = [
900
+ `# Review Request: Code Review`,
901
+ ``,
902
+ `You are reviewing code changes for a ${projectName} task.`,
903
+ `You have full tool access — use \`read\` to examine files and \`bash\` to run commands.`,
904
+ ``,
905
+ `## Task Context`,
906
+ `- **Task PROMPT:** ${promptPath}`,
907
+ `- **Task STATUS:** ${statusPath}`,
908
+ `- **Step reviewed:** Step ${stepNum}: ${stepName}`,
909
+ ...(baseline ? [`- **Baseline commit:** ${baseline}`] : []),
910
+ ``,
911
+ `## Instructions`,
912
+ `1. Run \`${diffNamesCmd}\` to see changed files`,
913
+ `2. Run \`${diffCmd}\` for the full diff`,
914
+ `3. Read changed files for context`,
915
+ ``,
916
+ `## Output`,
917
+ `Write your review to: \`${outputPath}\``,
918
+ ].join("\n");
919
+ }
920
+
921
+ try {
922
+ const systemPrompt = loadReviewerPrompt();
923
+ const exitCode = await spawnReviewer(
924
+ reviewPrompt,
925
+ systemPrompt,
926
+ cwd,
927
+ taskFolder,
928
+ reviewType,
929
+ stepNum,
930
+ );
931
+
932
+ // Update review counter in STATUS.md
933
+ try {
934
+ const status = readFileSync(statusPath, "utf-8");
935
+ const updated = status.replace(
936
+ /\*\*Review Counter:\*\*\s*\d+/,
937
+ `**Review Counter:** ${reviewCounter}`,
938
+ );
939
+ writeFileSync(statusPath, updated);
940
+ } catch {
941
+ /* best effort */
942
+ }
943
+
944
+ // Read review output and extract verdict
945
+ if (existsSync(outputPath)) {
946
+ const reviewContent = readFileSync(outputPath, "utf-8");
947
+ const verdictMatch = reviewContent.match(/###?\s*Verdict[:\s]*(APPROVE|REVISE|RETHINK)/i);
948
+ let verdict = verdictMatch ? verdictMatch[1].toUpperCase() : "UNKNOWN";
949
+ if (verdict === "UNKNOWN") {
950
+ const lower = reviewContent.toLowerCase();
951
+ if (lower.includes("approve") && !lower.includes("do not approve")) verdict = "APPROVE";
952
+ else if (lower.includes("revise") || lower.includes("changes requested")) verdict = "REVISE";
953
+ else if (lower.includes("rethink")) verdict = "RETHINK";
954
+ }
955
+
956
+ // Log review in STATUS.md execution log
957
+ try {
958
+ const status = readFileSync(statusPath, "utf-8");
959
+ const logEntry = `| ${new Date().toISOString().slice(0, 16).replace("T", " ")} | Review R${num} | ${reviewType} Step ${stepNum}: ${verdict} |\n`;
960
+ writeFileSync(statusPath, status.trimEnd() + "\n" + logEntry);
961
+ } catch {
962
+ /* best effort */
963
+ }
964
+
965
+ removeReviewerState(taskFolder);
966
+
967
+ const reviewFile = `.reviews/R${num}-${reviewType}-step${stepNum}.md`;
968
+ if (verdict === "APPROVE") {
969
+ return { content: [{ type: "text" as const, text: `APPROVE` }], details: undefined };
970
+ } else if (verdict === "REVISE") {
971
+ const summaryMatch = reviewContent.match(/###?\s*Summary[:\s]*([\s\S]*?)(?=###|$)/i);
972
+ const details = summaryMatch ? summaryMatch[1].trim().slice(0, 500) : "See review file.";
973
+ return {
974
+ content: [
975
+ { type: "text" as const, text: `REVISE: ${details}\n\nFull review: ${reviewFile}` },
976
+ ],
977
+ details: undefined,
978
+ };
979
+ } else if (verdict === "RETHINK") {
980
+ return {
981
+ content: [
982
+ { type: "text" as const, text: `RETHINK — reconsider approach. See ${reviewFile}` },
983
+ ],
984
+ details: undefined,
985
+ };
986
+ } else {
987
+ return {
988
+ content: [
989
+ { type: "text" as const, text: `Review complete (verdict unclear). See ${reviewFile}` },
990
+ ],
991
+ details: undefined,
992
+ };
993
+ }
994
+ } else {
995
+ removeReviewerState(taskFolder);
996
+ return {
997
+ content: [
998
+ {
999
+ type: "text" as const,
1000
+ text: `UNAVAILABLE — reviewer exited (code ${exitCode}) but produced no output.`,
1001
+ },
1002
+ ],
1003
+ details: undefined,
1004
+ };
1005
+ }
1006
+ } catch (err) {
1007
+ removeReviewerState(taskFolder);
1008
+ return {
1009
+ content: [
1010
+ {
1011
+ type: "text" as const,
1012
+ text: `UNAVAILABLE — reviewer failed: ${err instanceof Error ? err.message : String(err)}`,
1013
+ },
1014
+ ],
1015
+ details: undefined,
1016
+ };
1017
+ }
1018
+ },
1019
+ });
1020
+ }