taskplane 0.22.18 → 0.23.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.
@@ -665,8 +665,12 @@ export async function pollPrCiStatus(
665
665
  /**
666
666
  * Merge a PR via gh CLI after CI passes.
667
667
  *
668
- * Tries squash merge first (cleanest for integration PRs), then falls
669
- * back to regular merge if squash is not allowed by repo rules.
668
+ * Uses regular merge (preserves per-commit history from orch branches).
669
+ * Falls back to squash if regular merge is not allowed by repo rules.
670
+ *
671
+ * Regular merge is preferred because squash collapses all branch commits
672
+ * into one, which loses per-task attribution and can silently drop
673
+ * commits made by other agents between push and merge.
670
674
  *
671
675
  * @param orchBranch - The branch the PR was created from
672
676
  * @param deps - CI deps (runCommand for gh CLI)
@@ -678,15 +682,7 @@ export function mergePr(
678
682
  orchBranch: string,
679
683
  deps: CiDeps,
680
684
  ): { success: boolean; detail: string } {
681
- // Try squash merge first
682
- const squashResult = deps.runCommand("gh", [
683
- "pr", "merge", orchBranch, "--squash", "--delete-branch",
684
- ]);
685
- if (squashResult.ok) {
686
- return { success: true, detail: "PR merged (squash) and remote branch deleted." };
687
- }
688
-
689
- // Squash not allowed — try regular merge
685
+ // Try regular merge first (preserves per-commit history)
690
686
  const mergeResult = deps.runCommand("gh", [
691
687
  "pr", "merge", orchBranch, "--merge", "--delete-branch",
692
688
  ]);
@@ -694,9 +690,17 @@ export function mergePr(
694
690
  return { success: true, detail: "PR merged and remote branch deleted." };
695
691
  }
696
692
 
693
+ // Regular merge not allowed — try squash as fallback
694
+ const squashResult = deps.runCommand("gh", [
695
+ "pr", "merge", orchBranch, "--squash", "--delete-branch",
696
+ ]);
697
+ if (squashResult.ok) {
698
+ return { success: true, detail: "PR merged (squash) and remote branch deleted." };
699
+ }
700
+
697
701
  return {
698
702
  success: false,
699
- detail: `PR merge failed: ${mergeResult.stderr || squashResult.stderr}`,
703
+ detail: `PR merge failed: ${squashResult.stderr || mergeResult.stderr}`,
700
704
  };
701
705
  }
702
706
 
@@ -0,0 +1,553 @@
1
+ /**
2
+ * Task Executor Core — Headless execution semantics for Runtime V2
3
+ *
4
+ * This module owns the deterministic task execution logic that was
5
+ * previously embedded inside the Pi extension host (task-runner.ts).
6
+ * It has NO dependency on Pi's ExtensionAPI, ExtensionContext, UI
7
+ * widgets, session lifecycle, TMUX, or TASK_AUTOSTART.
8
+ *
9
+ * Consumers:
10
+ * - task-runner.ts (deprecated /task compatibility wrapper)
11
+ * - lane-runner.ts (Runtime V2 headless lane execution, TP-105)
12
+ *
13
+ * Design rules:
14
+ * 1. No Pi imports. No ExtensionContext. No ctx.ui.
15
+ * 2. File I/O is explicit (path parameters, not cwd inference).
16
+ * 3. All functions are independently testable.
17
+ * 4. STATUS.md and .DONE semantics are preserved exactly.
18
+ *
19
+ * @module taskplane/task-executor-core
20
+ * @since TP-103
21
+ */
22
+
23
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
24
+ import { dirname, basename, resolve, join } from "path";
25
+ import { spawnSync } from "child_process";
26
+
27
+ // ── Types ────────────────────────────────────────────────────────────
28
+
29
+ /**
30
+ * Parsed step information from PROMPT.md or STATUS.md.
31
+ *
32
+ * Re-exported from the core so consumers don't need to import
33
+ * task-runner.ts for the type definition.
34
+ */
35
+ export interface StepInfo {
36
+ number: number;
37
+ name: string;
38
+ status: "not-started" | "in-progress" | "complete";
39
+ checkboxes: { text: string; checked: boolean }[];
40
+ totalChecked: number;
41
+ totalItems: number;
42
+ }
43
+
44
+ /**
45
+ * Parsed task metadata from PROMPT.md.
46
+ *
47
+ * This is the core's view of a task — independent of the orchestrator's
48
+ * ParsedTask which carries additional scheduling/routing metadata.
49
+ */
50
+ export interface CoreParsedTask {
51
+ taskId: string;
52
+ taskName: string;
53
+ reviewLevel: number;
54
+ size: string;
55
+ steps: StepInfo[];
56
+ contextDocs: string[];
57
+ taskFolder: string;
58
+ promptPath: string;
59
+ }
60
+
61
+ /**
62
+ * Parsed STATUS.md data.
63
+ */
64
+ export interface ParsedStatus {
65
+ steps: StepInfo[];
66
+ reviewCounter: number;
67
+ iteration: number;
68
+ }
69
+
70
+ // ── PROMPT.md Parsing ────────────────────────────────────────────────
71
+
72
+ /**
73
+ * Parse a PROMPT.md file into structured task metadata.
74
+ *
75
+ * Pure function — no file I/O. Caller provides content and path.
76
+ *
77
+ * @param content - Raw PROMPT.md content
78
+ * @param promptPath - Absolute path to the PROMPT.md file (used to derive taskFolder)
79
+ * @returns Parsed task metadata
80
+ */
81
+ export function parsePromptMd(content: string, promptPath: string): CoreParsedTask {
82
+ const text = content.replace(/\r\n/g, "\n");
83
+ const taskFolder = dirname(resolve(promptPath));
84
+
85
+ // Task ID and name
86
+ let taskId = "", taskName = "";
87
+ const titleMatch = text.match(/^#\s+(?:Task:\s*)?(\S+-\d+)\s*[-–:]\s*(.+)/m);
88
+ if (titleMatch) { taskId = titleMatch[1]; taskName = titleMatch[2].trim(); }
89
+ else { taskId = basename(taskFolder); taskName = taskId; }
90
+
91
+ // Review level
92
+ let reviewLevel = 0;
93
+ const rlMatch = text.match(/##\s+Review Level[:\s]*(\d)/);
94
+ if (rlMatch) reviewLevel = parseInt(rlMatch[1]);
95
+
96
+ // Size
97
+ let size = "M";
98
+ const sizeMatch = text.match(/\*\*Size:\*\*\s*(\w+)/);
99
+ if (sizeMatch) size = sizeMatch[1];
100
+
101
+ // Steps
102
+ const steps: StepInfo[] = [];
103
+ const stepRegex = /###\s+Step\s+(\d+):\s*(.+)/g;
104
+ const positions: { number: number; name: string; start: number }[] = [];
105
+ let m;
106
+ while ((m = stepRegex.exec(text)) !== null) {
107
+ positions.push({ number: parseInt(m[1]), name: m[2].trim(), start: m.index });
108
+ }
109
+ for (let i = 0; i < positions.length; i++) {
110
+ const section = text.slice(positions[i].start, i + 1 < positions.length ? positions[i + 1].start : text.length);
111
+ const checkboxes: { text: string; checked: boolean }[] = [];
112
+ const cbRegex = /^\s*-\s*\[([ xX])\]\s*(.*)/gm;
113
+ let cb;
114
+ while ((cb = cbRegex.exec(section)) !== null) {
115
+ checkboxes.push({ text: cb[2].trim(), checked: cb[1].toLowerCase() === "x" });
116
+ }
117
+ steps.push({
118
+ number: positions[i].number, name: positions[i].name,
119
+ status: "not-started", checkboxes,
120
+ totalChecked: checkboxes.filter(c => c.checked).length,
121
+ totalItems: checkboxes.length,
122
+ });
123
+ }
124
+
125
+ // Context docs
126
+ const contextDocs: string[] = [];
127
+ const ctxMatch = text.match(/##\s+Context to Read First\s*\n+([\s\S]*?)(?=\n##\s|$)/);
128
+ if (ctxMatch) {
129
+ const pathRegex = /`([^\s`]+\.(?:md|yaml|json|go|ts|js))`/g;
130
+ let pm;
131
+ while ((pm = pathRegex.exec(ctxMatch[1])) !== null) contextDocs.push(pm[1]);
132
+ }
133
+
134
+ return { taskId, taskName, reviewLevel, size, steps, contextDocs, taskFolder, promptPath };
135
+ }
136
+
137
+ // ── STATUS.md Parsing ────────────────────────────────────────────────
138
+
139
+ /**
140
+ * Parse a STATUS.md file into structured execution state.
141
+ *
142
+ * Pure function — no file I/O. Caller provides content.
143
+ *
144
+ * @param content - Raw STATUS.md content
145
+ * @returns Parsed status with steps, review counter, and iteration
146
+ */
147
+ export function parseStatusMd(content: string): ParsedStatus {
148
+ const text = content.replace(/\r\n/g, "\n");
149
+ const steps: StepInfo[] = [];
150
+ let currentStep: StepInfo | null = null;
151
+ let reviewCounter = 0, iteration = 0;
152
+
153
+ for (const line of text.split("\n")) {
154
+ const rcMatch = line.match(/\*\*Review Counter:\*\*\s*(\d+)/);
155
+ if (rcMatch) reviewCounter = parseInt(rcMatch[1]);
156
+ const itMatch = line.match(/\*\*Iteration:\*\*\s*(\d+)/);
157
+ if (itMatch) iteration = parseInt(itMatch[1]);
158
+
159
+ const stepMatch = line.match(/^###\s+Step\s+(\d+):\s*(.+)/);
160
+ if (stepMatch) {
161
+ if (currentStep) {
162
+ currentStep.totalChecked = currentStep.checkboxes.filter(c => c.checked).length;
163
+ currentStep.totalItems = currentStep.checkboxes.length;
164
+ steps.push(currentStep);
165
+ }
166
+ currentStep = { number: parseInt(stepMatch[1]), name: stepMatch[2].trim(), status: "not-started", checkboxes: [], totalChecked: 0, totalItems: 0 };
167
+ continue;
168
+ }
169
+ if (currentStep) {
170
+ const ss = line.match(/\*\*Status:\*\*\s*(.*)/);
171
+ if (ss) {
172
+ const s = ss[1];
173
+ if (s.includes("✅") || s.toLowerCase().includes("complete")) currentStep.status = "complete";
174
+ else if (s.includes("🟨") || s.toLowerCase().includes("progress")) currentStep.status = "in-progress";
175
+ }
176
+ const cb = line.match(/^\s*-\s*\[([ xX])\]\s*(.*)/);
177
+ if (cb) currentStep.checkboxes.push({ text: cb[2].trim(), checked: cb[1].toLowerCase() === "x" });
178
+ }
179
+ }
180
+ if (currentStep) {
181
+ currentStep.totalChecked = currentStep.checkboxes.filter(c => c.checked).length;
182
+ currentStep.totalItems = currentStep.checkboxes.length;
183
+ steps.push(currentStep);
184
+ }
185
+ return { steps, reviewCounter, iteration };
186
+ }
187
+
188
+ // ── STATUS.md Generation ─────────────────────────────────────────────
189
+
190
+ /**
191
+ * Generate an initial STATUS.md from a parsed task.
192
+ *
193
+ * @param task - Parsed task (from parsePromptMd or orchestrator ParsedTask)
194
+ * @returns Complete STATUS.md content string
195
+ */
196
+ export function generateStatusMd(task: { taskId: string; taskName: string; reviewLevel: number; size: string; steps: StepInfo[] }): string {
197
+ const now = new Date().toISOString().slice(0, 10);
198
+ const lines: string[] = [
199
+ `# ${task.taskId}: ${task.taskName} — Status`, "",
200
+ `**Current Step:** Not Started`,
201
+ `**Status:** 🔵 Ready for Execution`,
202
+ `**Last Updated:** ${now}`,
203
+ `**Review Level:** ${task.reviewLevel}`,
204
+ `**Review Counter:** 0`,
205
+ `**Iteration:** 0`,
206
+ `**Size:** ${task.size}`, "", "---", "",
207
+ ];
208
+ for (const step of task.steps) {
209
+ lines.push(`### Step ${step.number}: ${step.name}`, `**Status:** ⬜ Not Started`, "");
210
+ for (const cb of step.checkboxes) lines.push(`- [ ] ${cb.text}`);
211
+ lines.push("", "---", "");
212
+ }
213
+ lines.push(
214
+ "## Reviews", "", "| # | Type | Step | Verdict | File |", "|---|------|------|---------|------|", "", "---", "",
215
+ "## Discoveries", "", "| Discovery | Disposition | Location |", "|-----------|-------------|----------|", "", "---", "",
216
+ "## Execution Log", "", "| Timestamp | Action | Outcome |", "|-----------|--------|---------|",
217
+ `| ${now} | Task staged | STATUS.md auto-generated by task-runner |`, "", "---", "",
218
+ "## Blockers", "", "*None*", "", "---", "", "## Notes", "", "*Reserved for execution notes*",
219
+ );
220
+ return lines.join("\n");
221
+ }
222
+
223
+ // ── STATUS.md Mutation ───────────────────────────────────────────────
224
+
225
+ /**
226
+ * Update a metadata field in STATUS.md.
227
+ *
228
+ * Matches `**Field:** value` patterns and replaces the value.
229
+ *
230
+ * @param statusPath - Absolute path to STATUS.md
231
+ * @param field - Field name (e.g., "Status", "Current Step")
232
+ * @param value - New value
233
+ */
234
+ export function updateStatusField(statusPath: string, field: string, value: string): void {
235
+ let content = readFileSync(statusPath, "utf-8").replace(/\r\n/g, "\n");
236
+ const pattern = new RegExp(`(\\*\\*${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\*\\*\\s*)(.+)`);
237
+ if (pattern.test(content)) {
238
+ content = content.replace(pattern, `$1${value}`);
239
+ } else {
240
+ content = content.replace(/(\*\*[^*]+:\*\*\s*.+\n)/, `$1**${field}:** ${value}\n`);
241
+ }
242
+ writeFileSync(statusPath, content);
243
+ }
244
+
245
+ /**
246
+ * Update a step's status in STATUS.md.
247
+ *
248
+ * @param statusPath - Absolute path to STATUS.md
249
+ * @param stepNum - Step number to update
250
+ * @param status - New status
251
+ */
252
+ export function updateStepStatus(statusPath: string, stepNum: number, status: "not-started" | "in-progress" | "complete"): void {
253
+ let content = readFileSync(statusPath, "utf-8").replace(/\r\n/g, "\n");
254
+ const emoji = status === "complete" ? "✅ Complete" : status === "in-progress" ? "🟨 In Progress" : "⬜ Not Started";
255
+ const lines = content.split("\n");
256
+ let inTarget = false;
257
+ for (let i = 0; i < lines.length; i++) {
258
+ const sm = lines[i].match(/^###\s+Step\s+(\d+):/);
259
+ if (sm) inTarget = parseInt(sm[1]) === stepNum;
260
+ if (inTarget && lines[i].match(/^\*\*Status:\*\*/)) {
261
+ lines[i] = `**Status:** ${emoji}`;
262
+ break;
263
+ }
264
+ }
265
+ writeFileSync(statusPath, lines.join("\n"));
266
+ }
267
+
268
+ /**
269
+ * Append a row to a named table section in STATUS.md.
270
+ *
271
+ * @param statusPath - Absolute path to STATUS.md
272
+ * @param sectionName - Section heading (e.g., "Execution Log", "Reviews")
273
+ * @param row - Markdown table row to append
274
+ */
275
+ export function appendTableRow(statusPath: string, sectionName: string, row: string): void {
276
+ let content = readFileSync(statusPath, "utf-8").replace(/\r\n/g, "\n");
277
+ const lines = content.split("\n");
278
+ let insertIdx = -1, inSection = false, lastTableRow = -1;
279
+ for (let i = 0; i < lines.length; i++) {
280
+ if (lines[i].match(new RegExp(`^##\\s+${sectionName}`))) {
281
+ inSection = true;
282
+ continue;
283
+ }
284
+ if (inSection) {
285
+ if (lines[i].match(/^##\s/) || lines[i].trim() === "---") {
286
+ insertIdx = lastTableRow >= 0 ? lastTableRow + 1 : i;
287
+ break;
288
+ }
289
+ if (lines[i].startsWith("|") && !lines[i].match(/^\|[\s-|]+\|$/)) {
290
+ lastTableRow = i;
291
+ }
292
+ }
293
+ }
294
+ if (insertIdx === -1) {
295
+ insertIdx = lastTableRow >= 0 ? lastTableRow + 1 : lines.length;
296
+ }
297
+ lines.splice(insertIdx, 0, row);
298
+ writeFileSync(statusPath, lines.join("\n"));
299
+ }
300
+
301
+ /**
302
+ * Log an execution event to the Execution Log table in STATUS.md.
303
+ */
304
+ export function logExecution(statusPath: string, action: string, outcome: string): void {
305
+ const ts = new Date().toISOString().slice(0, 16).replace("T", " ");
306
+ appendTableRow(statusPath, "Execution Log", `| ${ts} | ${action} | ${outcome} |`);
307
+ }
308
+
309
+ /**
310
+ * Log a review entry to the Reviews table in STATUS.md.
311
+ */
312
+ export function logReview(statusPath: string, num: string, type: string, stepNum: number, verdict: string, file: string): void {
313
+ appendTableRow(statusPath, "Reviews", `| ${num} | ${type} | Step ${stepNum} | ${verdict} | ${file} |`);
314
+ }
315
+
316
+ /**
317
+ * Sanitize steering message content for safe injection into a markdown table row.
318
+ * Collapses newlines, escapes pipe characters, and truncates to 200 chars.
319
+ */
320
+ export function sanitizeSteeringContent(content: string): string {
321
+ let s = content.replace(/\r?\n/g, " / ").replace(/\|/g, "\\|");
322
+ if (s.length > 200) s = s.slice(0, 197) + "...";
323
+ return s;
324
+ }
325
+
326
+ // ── Step Completion Logic ────────────────────────────────────────────
327
+
328
+ /**
329
+ * Determine whether a parsed step is complete.
330
+ *
331
+ * A step is complete when its status is explicitly "complete" OR
332
+ * when all checkboxes are checked (with at least one checkbox present).
333
+ *
334
+ * @param step - Parsed step info (or undefined)
335
+ * @returns true if the step should be considered complete
336
+ */
337
+ export function isStepComplete(step: StepInfo | undefined): boolean {
338
+ if (!step) return false;
339
+ if (step.status === "complete") return true;
340
+ return step.totalChecked === step.totalItems && step.totalItems > 0;
341
+ }
342
+
343
+ /**
344
+ * Determine whether a step is "low-risk" and should skip reviews.
345
+ *
346
+ * Low-risk steps: Step 0 (Preflight) and the final step (Delivery/Docs).
347
+ *
348
+ * @param stepNumber - The 0-based step number
349
+ * @param totalSteps - Total number of steps in the task
350
+ * @returns true if the step should skip plan and code reviews
351
+ */
352
+ export function isLowRiskStep(stepNumber: number, totalSteps: number): boolean {
353
+ if (totalSteps <= 0) return false;
354
+ const lastStepIndex = totalSteps - 1;
355
+ return stepNumber === 0 || stepNumber === lastStepIndex;
356
+ }
357
+
358
+ // ── Review Helpers ───────────────────────────────────────────────────
359
+
360
+ /**
361
+ * Extract a review verdict from review file content.
362
+ *
363
+ * Searches for standard verdict patterns (APPROVE, REVISE, RETHINK)
364
+ * with fallback to non-standard formats.
365
+ *
366
+ * @param reviewContent - Raw content of a review output file
367
+ * @returns Uppercase verdict string
368
+ */
369
+ export function extractVerdict(reviewContent: string): string {
370
+ // Primary: standard format "### Verdict: APPROVE|REVISE|RETHINK"
371
+ const match = reviewContent.match(/###?\s*Verdict[:\s]*(APPROVE|REVISE|RETHINK)/i);
372
+ if (match) return match[1].toUpperCase();
373
+
374
+ // Tolerate non-standard verdict formats
375
+ const lower = reviewContent.toLowerCase();
376
+ if (lower.includes("changes requested") || lower.includes("request changes") || lower.includes("needs revision")) return "REVISE";
377
+ if (lower.includes("approve") && !lower.includes("do not approve") && !lower.includes("cannot approve")) return "APPROVE";
378
+ if (lower.includes("rethink") || lower.includes("re-think")) return "RETHINK";
379
+
380
+ return "UNKNOWN";
381
+ }
382
+
383
+ // ── Git Helpers ──────────────────────────────────────────────────────
384
+
385
+ /**
386
+ * Get the current HEAD commit SHA (short form).
387
+ *
388
+ * @returns Short commit SHA or empty string on failure
389
+ */
390
+ export function getHeadCommitSha(): string {
391
+ try {
392
+ const result = spawnSync("git", ["rev-parse", "--short", "HEAD"], {
393
+ encoding: "utf-8",
394
+ timeout: 5000,
395
+ });
396
+ return result.status === 0 ? (result.stdout || "").trim() : "";
397
+ } catch {
398
+ return "";
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Find the git commit SHA where a specific step was completed.
404
+ *
405
+ * Workers commit at step boundaries with messages like:
406
+ * feat(TP-048): complete Step N — description
407
+ *
408
+ * @param stepNumber - Step number to search for
409
+ * @param taskId - Task ID prefix in commit message
410
+ * @param since - Optional base commit to search from
411
+ * @returns Commit SHA if found, or empty string
412
+ */
413
+ export function findStepBoundaryCommit(stepNumber: number, taskId: string, since?: string): string {
414
+ try {
415
+ const args = ["log", "--oneline", "--grep", `complete Step ${stepNumber}`, "--grep", taskId, "--all-match", "-1", "--format=%H"];
416
+ if (since) args.push(`${since}..HEAD`);
417
+ const result = spawnSync("git", args, {
418
+ encoding: "utf-8",
419
+ timeout: 5000,
420
+ });
421
+ return result.status === 0 ? (result.stdout || "").trim() : "";
422
+ } catch {
423
+ return "";
424
+ }
425
+ }
426
+
427
+ // ── Review Request Generation ────────────────────────────────────────
428
+
429
+ /**
430
+ * Standards resolution config shape (minimal, no TaskConfig dependency).
431
+ */
432
+ export interface StandardsConfig {
433
+ docs: string[];
434
+ rules: string[];
435
+ }
436
+
437
+ /**
438
+ * Resolve which standards apply to a task based on its area.
439
+ *
440
+ * @param globalStandards - Project-level standards
441
+ * @param overrides - Per-area overrides keyed by area name
442
+ * @param taskAreas - Task area definitions keyed by area name
443
+ * @param taskFolder - Absolute task folder path
444
+ * @returns Resolved standards for this task
445
+ */
446
+ export function resolveStandards(
447
+ globalStandards: StandardsConfig,
448
+ overrides: Record<string, Partial<StandardsConfig>>,
449
+ taskAreas: Record<string, { path: string;[key: string]: any }>,
450
+ taskFolder: string,
451
+ ): StandardsConfig {
452
+ const normalizedFolder = taskFolder.replace(/\\/g, "/");
453
+ for (const [areaName, areaCfg] of Object.entries(taskAreas)) {
454
+ const areaPath = areaCfg.path.replace(/\\/g, "/");
455
+ if (normalizedFolder.includes(areaPath)) {
456
+ const override = overrides[areaName];
457
+ if (override) {
458
+ return {
459
+ docs: override.docs ?? globalStandards.docs,
460
+ rules: override.rules ?? globalStandards.rules,
461
+ };
462
+ }
463
+ break;
464
+ }
465
+ }
466
+ return { docs: globalStandards.docs, rules: globalStandards.rules };
467
+ }
468
+
469
+ /**
470
+ * Generate a review request document for a plan or code review.
471
+ *
472
+ * @param type - Review type (plan or code)
473
+ * @param stepNum - Step number being reviewed
474
+ * @param stepName - Step name
475
+ * @param taskPromptPath - Path to the task's PROMPT.md
476
+ * @param taskFolder - Path to the task folder
477
+ * @param projectName - Project name from config
478
+ * @param standards - Resolved standards for this task
479
+ * @param outputPath - Path where the reviewer should write output
480
+ * @param stepBaselineCommit - Optional baseline commit for code review diffs
481
+ * @returns Complete review request markdown content
482
+ */
483
+ export function generateReviewRequest(
484
+ type: "plan" | "code",
485
+ stepNum: number,
486
+ stepName: string,
487
+ taskPromptPath: string,
488
+ taskFolder: string,
489
+ projectName: string,
490
+ standards: StandardsConfig,
491
+ outputPath: string,
492
+ stepBaselineCommit?: string,
493
+ ): string {
494
+ const standardsDocs = standards.docs.map(d => ` - ${d}`).join("\n");
495
+ const standardsRules = standards.rules.map(r => `- ${r}`).join("\n");
496
+ const statusPath = join(taskFolder, "STATUS.md");
497
+
498
+ if (type === "plan") {
499
+ return [
500
+ `# Review Request: Plan Review`, "",
501
+ `You are reviewing an implementation plan for a ${projectName} task.`,
502
+ `You have full tool access — use \`read\` to examine files and \`bash\` to run commands.`, "",
503
+ `## Task Context`, "",
504
+ `- **Task PROMPT:** ${taskPromptPath}`,
505
+ `- **Task STATUS:** ${statusPath}`,
506
+ `- **Step being planned:** Step ${stepNum}: ${stepName}`, "",
507
+ `## Instructions`, "",
508
+ `1. Read the PROMPT.md for full requirements`,
509
+ `2. Read STATUS.md for progress so far`,
510
+ `3. Check relevant source files for existing patterns:`,
511
+ standardsDocs, "",
512
+ `## Project Standards`, "", standardsRules, "",
513
+ `## Output`, "",
514
+ `Write your review to: \`${outputPath}\``,
515
+ ].join("\n");
516
+ }
517
+
518
+ const diffCmd = stepBaselineCommit ? `git diff ${stepBaselineCommit}..HEAD --name-only` : `git diff --name-only`;
519
+ const diffFullCmd = stepBaselineCommit ? `git diff ${stepBaselineCommit}..HEAD` : `git diff`;
520
+
521
+ return [
522
+ `# Review Request: Code Review`, "",
523
+ `You are reviewing code changes for a ${projectName} task.`,
524
+ `You have full tool access — use \`read\` to examine files and \`bash\` to run commands.`, "",
525
+ `## Task Context`, "",
526
+ `- **Task PROMPT:** ${taskPromptPath}`,
527
+ `- **Task STATUS:** ${statusPath}`,
528
+ `- **Step reviewed:** Step ${stepNum}: ${stepName}`,
529
+ ...(stepBaselineCommit ? [`- **Step baseline commit:** ${stepBaselineCommit}`] : []),
530
+ "",
531
+ `## Instructions`, "",
532
+ `1. Run \`${diffCmd}\` to see files changed in this step`,
533
+ ` Then \`${diffFullCmd}\` for the full diff`,
534
+ ` **Important:** The worker commits code via checkpoints, so plain \`git diff\` may show nothing.`,
535
+ ` Always use the baseline commit range above to see all step changes.`,
536
+ `2. Read changed files in full for context`,
537
+ `3. Check neighboring files for pattern consistency`,
538
+ `4. Check standards:`,
539
+ standardsDocs, "",
540
+ `## Project Standards`, "", standardsRules, "",
541
+ `## Output`, "",
542
+ `Write your review to: \`${outputPath}\``,
543
+ ].join("\n");
544
+ }
545
+
546
+ // ── Display Helpers ──────────────────────────────────────────────────
547
+
548
+ /**
549
+ * Convert a kebab-case name to Title Case for display.
550
+ */
551
+ export function displayName(name: string): string {
552
+ return name.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
553
+ }