taskplane 0.28.4 → 0.28.6

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