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,1033 +1,1033 @@
1
- /**
2
- * Quality Gate — structured post-completion review types and verdict evaluation.
3
- *
4
- * This module defines the interfaces for quality gate review verdicts and
5
- * implements the verdict evaluation logic used by the task-runner to decide
6
- * whether a task passes or needs fixes before `.DONE` creation.
7
- *
8
- * Verdict rules (from roadmap Phase 5a):
9
- * - Any `critical` finding → NEEDS_FIXES
10
- * - 3+ `important` findings → NEEDS_FIXES
11
- * - Only `suggestion` findings → PASS
12
- * - Any `status_mismatch` category → NEEDS_FIXES
13
- *
14
- * Fail-open behavior: malformed or missing verdict JSON → PASS
15
- * (prevents quality gate bugs from blocking task completion)
16
- *
17
- * @module quality-gate
18
- */
19
-
20
- import type { PassThreshold } from "./config-schema.ts";
21
- import { readFileSync, writeFileSync, existsSync } from "fs";
22
- import { join } from "path";
23
- import { spawnSync } from "child_process";
24
-
25
- // ── Verdict Interfaces ───────────────────────────────────────────────
26
-
27
- /** Severity levels for review findings, ordered by decreasing severity. */
28
- export type FindingSeverity = "critical" | "important" | "suggestion";
29
-
30
- /** Categories of review findings. */
31
- export type FindingCategory =
32
- | "missing_requirement"
33
- | "incorrect_implementation"
34
- | "incomplete_work"
35
- | "status_mismatch";
36
-
37
- /** A single finding from the quality gate review. */
38
- export interface ReviewFinding {
39
- /** Severity of the finding */
40
- severity: FindingSeverity;
41
- /** Category classifying what kind of issue was found */
42
- category: FindingCategory;
43
- /** Human-readable description of the issue */
44
- description: string;
45
- /** File path related to the finding (may be empty) */
46
- file: string;
47
- /** Specific fix instruction for the remediation agent */
48
- remediation: string;
49
- }
50
-
51
- /** STATUS.md checkbox reconciliation entry. */
52
- export interface StatusReconciliation {
53
- /** Original checkbox text from STATUS.md */
54
- checkbox: string;
55
- /** Actual state determined by review */
56
- actualState: "done" | "not_done" | "partial";
57
- /** Evidence supporting the state determination */
58
- evidence: string;
59
- }
60
-
61
- /** Overall quality gate verdict from the review agent. */
62
- export interface ReviewVerdict {
63
- /** Pass/fail verdict */
64
- verdict: "PASS" | "NEEDS_FIXES";
65
- /** Review agent confidence level */
66
- confidence: "high" | "medium" | "low";
67
- /** Brief overall assessment */
68
- summary: string;
69
- /** Individual findings from the review */
70
- findings: ReviewFinding[];
71
- /** STATUS.md checkbox reconciliation results */
72
- statusReconciliation: StatusReconciliation[];
73
- }
74
-
75
- // ── Verdict Evaluation ───────────────────────────────────────────────
76
-
77
- /** Reason why a verdict was determined to be NEEDS_FIXES. */
78
- export interface VerdictFailReason {
79
- /** Rule that triggered the failure */
80
- rule: "critical_finding" | "important_threshold" | "status_mismatch" | "verdict_says_needs_fixes";
81
- /** Human-readable explanation */
82
- detail: string;
83
- }
84
-
85
- /** Result of applying verdict rules to a parsed ReviewVerdict. */
86
- export interface VerdictEvaluation {
87
- /** Whether the task passes the quality gate */
88
- pass: boolean;
89
- /** Reasons for failure (empty array if pass is true) */
90
- failReasons: VerdictFailReason[];
91
- }
92
-
93
- /**
94
- * Apply verdict rules to determine pass/fail based on findings and threshold.
95
- *
96
- * Rules applied in order:
97
- * 1. Any finding with category `status_mismatch` → NEEDS_FIXES
98
- * 2. Any finding with severity `critical` → NEEDS_FIXES
99
- * 3. Threshold-dependent important finding count check
100
- * 4. If verdict itself says NEEDS_FIXES → respect it
101
- *
102
- * Threshold behavior:
103
- * - `no_critical`: PASS if no critical findings and no status mismatches
104
- * - `no_important`: PASS if no critical, fewer than 3 important, no status mismatches
105
- * - `all_clear`: PASS only if zero findings of any severity
106
- *
107
- * @param verdict - Parsed review verdict
108
- * @param threshold - Configured pass threshold
109
- * @returns Evaluation result with pass/fail and reasons
110
- */
111
- export function applyVerdictRules(
112
- verdict: ReviewVerdict,
113
- threshold: PassThreshold,
114
- ): VerdictEvaluation {
115
- const failReasons: VerdictFailReason[] = [];
116
-
117
- // Rule 1: Any status_mismatch category → NEEDS_FIXES
118
- const statusMismatches = verdict.findings.filter(
119
- (f) => f.category === "status_mismatch",
120
- );
121
- if (statusMismatches.length > 0) {
122
- failReasons.push({
123
- rule: "status_mismatch",
124
- detail: `${statusMismatches.length} status mismatch(es) found — checked boxes don't match actual work`,
125
- });
126
- }
127
-
128
- // Rule 2: Any critical finding → NEEDS_FIXES
129
- const criticals = verdict.findings.filter((f) => f.severity === "critical");
130
- if (criticals.length > 0) {
131
- failReasons.push({
132
- rule: "critical_finding",
133
- detail: `${criticals.length} critical finding(s)`,
134
- });
135
- }
136
-
137
- // Rule 3: Threshold-dependent important check
138
- const importants = verdict.findings.filter(
139
- (f) => f.severity === "important",
140
- );
141
-
142
- if (threshold === "no_important" && importants.length >= 3) {
143
- failReasons.push({
144
- rule: "important_threshold",
145
- detail: `${importants.length} important findings (threshold: fewer than 3 required for pass)`,
146
- });
147
- }
148
-
149
- if (threshold === "all_clear" && verdict.findings.length > 0) {
150
- // For all_clear, any finding of any severity blocks pass
151
- if (importants.length > 0 && failReasons.every((r) => r.rule !== "important_threshold")) {
152
- failReasons.push({
153
- rule: "important_threshold",
154
- detail: `${importants.length} important finding(s) (all_clear threshold: zero findings required)`,
155
- });
156
- }
157
- // Suggestions also block under all_clear — but we don't need a separate rule
158
- // since we'll catch it via the verdict_says_needs_fixes or the overall pass logic
159
- }
160
-
161
- // Rule 4: If the verdict itself says NEEDS_FIXES and we haven't already failed
162
- if (verdict.verdict === "NEEDS_FIXES" && failReasons.length === 0) {
163
- failReasons.push({
164
- rule: "verdict_says_needs_fixes",
165
- detail: `Review agent verdict: NEEDS_FIXES — ${verdict.summary}`,
166
- });
167
- }
168
-
169
- // For all_clear threshold: even suggestions-only should fail
170
- if (
171
- threshold === "all_clear" &&
172
- failReasons.length === 0 &&
173
- verdict.findings.length > 0
174
- ) {
175
- const suggestions = verdict.findings.filter(
176
- (f) => f.severity === "suggestion",
177
- );
178
- if (suggestions.length > 0) {
179
- failReasons.push({
180
- rule: "important_threshold",
181
- detail: `${suggestions.length} suggestion(s) found (all_clear threshold: zero findings required)`,
182
- });
183
- }
184
- }
185
-
186
- return {
187
- pass: failReasons.length === 0,
188
- failReasons,
189
- };
190
- }
191
-
192
- // ── Verdict Parsing ──────────────────────────────────────────────────
193
-
194
- /** Sentinel verdict returned when parsing fails (fail-open). */
195
- const FAIL_OPEN_VERDICT: ReviewVerdict = {
196
- verdict: "PASS",
197
- confidence: "low",
198
- summary: "Verdict could not be parsed — fail-open policy applied",
199
- findings: [],
200
- statusReconciliation: [],
201
- };
202
-
203
- /**
204
- * Parse a JSON string into a ReviewVerdict, with fail-open behavior.
205
- *
206
- * If the input is missing, empty, or malformed JSON, returns a PASS verdict
207
- * (fail-open) to prevent quality gate bugs from blocking task completion.
208
- *
209
- * Performs structural validation:
210
- * - `verdict` must be "PASS" or "NEEDS_FIXES"
211
- * - `findings` must be an array (defaults to [] if missing)
212
- * - `statusReconciliation` must be an array (defaults to [] if missing)
213
- * - Individual findings are validated and malformed entries are dropped
214
- *
215
- * @param jsonString - Raw JSON string from review agent output
216
- * @returns Parsed and validated ReviewVerdict (never throws)
217
- */
218
- export function parseVerdict(jsonString: string | undefined | null): ReviewVerdict {
219
- if (!jsonString || jsonString.trim() === "") {
220
- return { ...FAIL_OPEN_VERDICT, summary: "No verdict provided — fail-open policy applied" };
221
- }
222
-
223
- let raw: unknown;
224
- try {
225
- raw = JSON.parse(jsonString);
226
- } catch {
227
- return { ...FAIL_OPEN_VERDICT, summary: "Malformed JSON in verdict — fail-open policy applied" };
228
- }
229
-
230
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
231
- return { ...FAIL_OPEN_VERDICT, summary: "Verdict is not a JSON object — fail-open policy applied" };
232
- }
233
-
234
- const obj = raw as Record<string, unknown>;
235
-
236
- // Validate verdict field
237
- const verdict = obj.verdict;
238
- if (verdict !== "PASS" && verdict !== "NEEDS_FIXES") {
239
- return { ...FAIL_OPEN_VERDICT, summary: `Invalid verdict value "${String(verdict)}" — fail-open policy applied` };
240
- }
241
-
242
- // Parse confidence with fallback
243
- const validConfidence = ["high", "medium", "low"];
244
- const confidence = validConfidence.includes(obj.confidence as string)
245
- ? (obj.confidence as "high" | "medium" | "low")
246
- : "medium";
247
-
248
- // Parse summary with fallback
249
- const summary = typeof obj.summary === "string" ? obj.summary : "";
250
-
251
- // Parse and validate findings
252
- const findings = validateFindings(obj.findings);
253
-
254
- // Parse and validate statusReconciliation
255
- const statusReconciliation = validateReconciliations(obj.statusReconciliation);
256
-
257
- return {
258
- verdict,
259
- confidence,
260
- summary,
261
- findings,
262
- statusReconciliation,
263
- };
264
- }
265
-
266
- // ── Internal Validation Helpers ──────────────────────────────────────
267
-
268
- const VALID_SEVERITIES: FindingSeverity[] = ["critical", "important", "suggestion"];
269
- const VALID_CATEGORIES: FindingCategory[] = [
270
- "missing_requirement",
271
- "incorrect_implementation",
272
- "incomplete_work",
273
- "status_mismatch",
274
- ];
275
- const VALID_STATES = ["done", "not_done", "partial"];
276
-
277
- /**
278
- * Validate and normalize the findings array.
279
- * Drops individual entries that don't have minimum required fields.
280
- */
281
- function validateFindings(raw: unknown): ReviewFinding[] {
282
- if (!Array.isArray(raw)) return [];
283
-
284
- const validated: ReviewFinding[] = [];
285
- for (const item of raw) {
286
- if (typeof item !== "object" || item === null) continue;
287
- const f = item as Record<string, unknown>;
288
-
289
- // Severity is required and must be valid
290
- if (!VALID_SEVERITIES.includes(f.severity as FindingSeverity)) continue;
291
-
292
- // Category is required and must be valid
293
- if (!VALID_CATEGORIES.includes(f.category as FindingCategory)) continue;
294
-
295
- // Description is required
296
- if (typeof f.description !== "string" || f.description.trim() === "") continue;
297
-
298
- validated.push({
299
- severity: f.severity as FindingSeverity,
300
- category: f.category as FindingCategory,
301
- description: f.description as string,
302
- file: typeof f.file === "string" ? f.file : "",
303
- remediation: typeof f.remediation === "string" ? f.remediation : "",
304
- });
305
- }
306
-
307
- return validated;
308
- }
309
-
310
- /**
311
- * Validate and normalize the statusReconciliation array.
312
- * Drops individual entries that don't have minimum required fields.
313
- */
314
- function validateReconciliations(raw: unknown): StatusReconciliation[] {
315
- if (!Array.isArray(raw)) return [];
316
-
317
- const validated: StatusReconciliation[] = [];
318
- for (const item of raw) {
319
- if (typeof item !== "object" || item === null) continue;
320
- const r = item as Record<string, unknown>;
321
-
322
- if (typeof r.checkbox !== "string" || r.checkbox.trim() === "") continue;
323
- if (!VALID_STATES.includes(r.actualState as string)) continue;
324
-
325
- validated.push({
326
- checkbox: r.checkbox as string,
327
- actualState: r.actualState as "done" | "not_done" | "partial",
328
- evidence: typeof r.evidence === "string" ? r.evidence : "",
329
- });
330
- }
331
-
332
- return validated;
333
- }
334
-
335
- // ── Quality Gate Review Prompt ───────────────────────────────────────
336
-
337
- /** Information needed to build the quality gate review evidence package. */
338
- export interface QualityGateContext {
339
- /** Absolute path to task folder */
340
- taskFolder: string;
341
- /** Absolute path to PROMPT.md */
342
- promptPath: string;
343
- /** Task ID (e.g., "TP-034") */
344
- taskId: string;
345
- /** Project name from config */
346
- projectName: string;
347
- /** Pass threshold from config */
348
- passThreshold: PassThreshold;
349
- }
350
-
351
- /** Path where the quality gate verdict JSON file is written by the review agent. */
352
- export const VERDICT_FILENAME = "REVIEW_VERDICT.json";
353
-
354
- /**
355
- * Compute a robust diff range for the task's git changes.
356
- *
357
- * Strategy (in order):
358
- * 1. `git merge-base HEAD main` — ideal for topic branches
359
- * 2. `git merge-base HEAD origin/main` — fallback for detached/worktree checkouts
360
- * 3. `HEAD~N` where N = min(commit count, 50) — bounded fallback for repos
361
- * without a main branch or with shallow history
362
- * 4. Empty string (signals diff unavailable)
363
- */
364
- function computeDiffBase(cwd: string): string {
365
- const opts = { encoding: "utf-8" as const, cwd, timeout: 15000 };
366
-
367
- // Try merge-base with local main
368
- for (const ref of ["main", "origin/main", "master", "origin/master"]) {
369
- const result = spawnSync("git", ["merge-base", "HEAD", ref], opts);
370
- if (result.status === 0 && result.stdout.trim()) {
371
- return result.stdout.trim();
372
- }
373
- }
374
-
375
- // Fallback: count commits and use HEAD~N (bounded)
376
- const countResult = spawnSync("git", ["rev-list", "--count", "HEAD"], opts);
377
- if (countResult.status === 0) {
378
- const count = parseInt(countResult.stdout.trim(), 10);
379
- if (count > 1) {
380
- const n = Math.min(count - 1, 50);
381
- return `HEAD~${n}`;
382
- }
383
- }
384
-
385
- return "";
386
- }
387
-
388
- /**
389
- * Build the git diff for the entire task.
390
- *
391
- * Uses `computeDiffBase()` to find a robust baseline, then runs `git diff`
392
- * between that base and HEAD. Falls back gracefully when git is unavailable
393
- * or the repository has insufficient history.
394
- */
395
- function buildGitDiff(cwd: string): { diff: string; fileList: string } {
396
- try {
397
- const base = computeDiffBase(cwd);
398
- if (!base) {
399
- return { diff: "(git diff unavailable — could not determine base)", fileList: "(file list unavailable)" };
400
- }
401
-
402
- const range = `${base}..HEAD`;
403
-
404
- // Get file list of changed files
405
- const fileListResult = spawnSync("git", ["diff", "--name-only", range], {
406
- encoding: "utf-8",
407
- cwd,
408
- timeout: 30000,
409
- });
410
- const fileList = fileListResult.status === 0
411
- ? fileListResult.stdout.trim()
412
- : "";
413
-
414
- // Get full diff (truncated to avoid blowing up context)
415
- const diffResult = spawnSync("git", ["diff", range], {
416
- encoding: "utf-8",
417
- cwd,
418
- timeout: 30000,
419
- maxBuffer: 200 * 1024, // 200KB max
420
- });
421
- const diff = diffResult.status === 0
422
- ? diffResult.stdout.trim()
423
- : "(git diff unavailable)";
424
-
425
- return { diff, fileList };
426
- } catch {
427
- return { diff: "(git diff failed)", fileList: "(file list unavailable)" };
428
- }
429
- }
430
-
431
- /**
432
- * Generate the quality gate review prompt that instructs the review agent
433
- * to produce a structured JSON verdict.
434
- *
435
- * The prompt includes:
436
- * - PROMPT.md content (task requirements)
437
- * - STATUS.md content (declared progress)
438
- * - Git diff of all task changes
439
- * - File change list
440
- * - JSON schema for the verdict
441
- * - Instructions for fail criteria
442
- *
443
- * @param context - Task context for evidence building
444
- * @param cwd - Working directory for git commands
445
- * @returns Review prompt string
446
- */
447
- /**
448
- * Build threshold-specific verdict rule lines for the review prompt.
449
- *
450
- * This ensures the reviewer's instructions match the runtime behavior of
451
- * `applyVerdictRules()` — preventing false failures caused by the reviewer
452
- * emitting NEEDS_FIXES for findings that the runtime threshold would ignore.
453
- */
454
- function buildThresholdRules(threshold: PassThreshold): string[] {
455
- const rules: string[] = [];
456
-
457
- // Common rules — always apply
458
- rules.push(`- **NEEDS_FIXES** if any finding has category \`status_mismatch\` (checkbox claims work is done but it isn't)`);
459
- rules.push(`- **NEEDS_FIXES** if any finding has severity \`critical\``);
460
-
461
- // Threshold-specific rules
462
- switch (threshold) {
463
- case "no_critical":
464
- rules.push(`- **PASS** even if there are \`important\` or \`suggestion\` findings (threshold: \`no_critical\`)`);
465
- break;
466
- case "no_important":
467
- rules.push(`- **NEEDS_FIXES** if 3 or more findings have severity \`important\``);
468
- rules.push(`- **PASS** if only \`suggestion\`-level findings remain`);
469
- break;
470
- case "all_clear":
471
- rules.push(`- **NEEDS_FIXES** if ANY findings exist (including \`suggestion\`-level)`);
472
- break;
473
- }
474
-
475
- rules.push(`- **PASS** if no findings at all`);
476
- rules.push(``);
477
-
478
- return rules;
479
- }
480
-
481
- export function generateQualityGatePrompt(context: QualityGateContext, cwd: string): string {
482
- const statusPath = join(context.taskFolder, "STATUS.md");
483
- const verdictPath = join(context.taskFolder, VERDICT_FILENAME);
484
-
485
- // Read evidence files
486
- let promptContent = "(PROMPT.md not found)";
487
- try {
488
- if (existsSync(context.promptPath)) {
489
- promptContent = readFileSync(context.promptPath, "utf-8");
490
- }
491
- } catch { /* fail-open: proceed without */ }
492
-
493
- let statusContent = "(STATUS.md not found)";
494
- try {
495
- if (existsSync(statusPath)) {
496
- statusContent = readFileSync(statusPath, "utf-8");
497
- }
498
- } catch { /* fail-open: proceed without */ }
499
-
500
- const { diff, fileList } = buildGitDiff(cwd);
501
-
502
- // Truncate diff if too long (keep first 100KB)
503
- const maxDiffLen = 100 * 1024;
504
- const truncatedDiff = diff.length > maxDiffLen
505
- ? diff.slice(0, maxDiffLen) + "\n\n... (diff truncated at 100KB) ..."
506
- : diff;
507
-
508
- return [
509
- `# Quality Gate Review`,
510
- ``,
511
- `You are performing a structured post-completion quality gate review for task **${context.taskId}** in project **${context.projectName}**.`,
512
- ``,
513
- `Your job is to verify that the task was completed correctly by comparing the PROMPT requirements against the actual code changes and STATUS.md progress claims.`,
514
- ``,
515
- `## Task Requirements (PROMPT.md)`,
516
- ``,
517
- `\`\`\`markdown`,
518
- promptContent,
519
- `\`\`\``,
520
- ``,
521
- `## Declared Progress (STATUS.md)`,
522
- ``,
523
- `\`\`\`markdown`,
524
- statusContent,
525
- `\`\`\``,
526
- ``,
527
- `## Changed Files`,
528
- ``,
529
- `\`\`\``,
530
- fileList,
531
- `\`\`\``,
532
- ``,
533
- `## Git Diff`,
534
- ``,
535
- `\`\`\`diff`,
536
- truncatedDiff,
537
- `\`\`\``,
538
- ``,
539
- `## Instructions`,
540
- ``,
541
- `1. **Read the PROMPT.md requirements** carefully — identify every deliverable and acceptance criterion.`,
542
- `2. **Cross-check STATUS.md checkboxes** — verify each checked item actually has corresponding code/test changes in the diff.`,
543
- `3. **Review the git diff** — look for missing implementations, incorrect logic, incomplete work.`,
544
- `4. **Use tools** to read actual source files if the diff is unclear.`,
545
- `5. **Produce your verdict** as a JSON object written to the file specified below.`,
546
- ``,
547
- `## Verdict Rules`,
548
- ``,
549
- `Report ALL findings you discover with accurate severities. The runtime will`,
550
- `apply the configured pass threshold (\`${context.passThreshold}\`) to decide pass/fail.`,
551
- ``,
552
- `Use these rules to determine your verdict:`,
553
- ...buildThresholdRules(context.passThreshold),
554
- ``,
555
- `## Output Format`,
556
- ``,
557
- `Write a JSON file to: \`${verdictPath}\``,
558
- ``,
559
- `The JSON must conform to this schema:`,
560
- ``,
561
- `\`\`\`json`,
562
- `{`,
563
- ` "verdict": "PASS" | "NEEDS_FIXES",`,
564
- ` "confidence": "high" | "medium" | "low",`,
565
- ` "summary": "Brief overall assessment",`,
566
- ` "findings": [`,
567
- ` {`,
568
- ` "severity": "critical" | "important" | "suggestion",`,
569
- ` "category": "missing_requirement" | "incorrect_implementation" | "incomplete_work" | "status_mismatch",`,
570
- ` "description": "What is wrong",`,
571
- ` "file": "path/to/file.ts",`,
572
- ` "remediation": "Specific fix instruction"`,
573
- ` }`,
574
- ` ],`,
575
- ` "statusReconciliation": [`,
576
- ` {`,
577
- ` "checkbox": "Original checkbox text",`,
578
- ` "actualState": "done" | "not_done" | "partial",`,
579
- ` "evidence": "How you verified"`,
580
- ` }`,
581
- ` ]`,
582
- `}`,
583
- `\`\`\``,
584
- ``,
585
- `**IMPORTANT:** Write ONLY valid JSON to the verdict file. No markdown, no explanation — just the JSON object.`,
586
- ``,
587
- ].join("\n");
588
- }
589
-
590
- // ── Quality Gate Result ──────────────────────────────────────────────
591
-
592
- /** Result of a quality gate review cycle. */
593
- export interface QualityGateResult {
594
- /** Whether the task passed the quality gate */
595
- passed: boolean;
596
- /** Parsed verdict from the review agent (fail-open sentinel if parsing failed) */
597
- verdict: ReviewVerdict;
598
- /** Evaluation of verdict rules against threshold */
599
- evaluation: VerdictEvaluation;
600
- /** Number of review cycles consumed so far */
601
- cyclesUsed: number;
602
- /** Whether the gate was skipped because it's disabled */
603
- skipped: boolean;
604
- }
605
-
606
- /**
607
- * Read and evaluate the quality gate verdict file from the task folder.
608
- *
609
- * Handles all fail-open paths:
610
- * - Missing verdict file → synthetic PASS
611
- * - Malformed JSON → synthetic PASS
612
- * - Invalid verdict structure → synthetic PASS
613
- *
614
- * @param taskFolder - Absolute path to task folder
615
- * @param passThreshold - Configured pass threshold
616
- * @returns Evaluated quality gate result
617
- */
618
- export function readAndEvaluateVerdict(
619
- taskFolder: string,
620
- passThreshold: PassThreshold,
621
- ): { verdict: ReviewVerdict; evaluation: VerdictEvaluation } {
622
- const verdictPath = join(taskFolder, VERDICT_FILENAME);
623
-
624
- let rawJson: string | null = null;
625
- try {
626
- if (existsSync(verdictPath)) {
627
- rawJson = readFileSync(verdictPath, "utf-8");
628
- }
629
- } catch {
630
- // File read error → fail-open
631
- }
632
-
633
- const verdict = parseVerdict(rawJson);
634
- const evaluation = applyVerdictRules(verdict, passThreshold);
635
-
636
- return { verdict, evaluation };
637
- }
638
-
639
- // ── STATUS.md Reconciliation ─────────────────────────────────────────
640
-
641
- /** Result of applying status reconciliation to STATUS.md. */
642
- export interface ReconciliationResult {
643
- /** Number of checkboxes whose state was changed */
644
- changed: number;
645
- /** Number of reconciliation entries that matched but required no change */
646
- alreadyCorrect: number;
647
- /** Number of reconciliation entries that could not be matched to a checkbox */
648
- unmatched: number;
649
- /** Details of each action taken */
650
- actions: ReconciliationAction[];
651
- }
652
-
653
- /** A single reconciliation action applied (or skipped). */
654
- export interface ReconciliationAction {
655
- /** The checkbox text from the reconciliation entry */
656
- checkbox: string;
657
- /** What happened */
658
- outcome: "checked" | "unchecked" | "no_change" | "unmatched";
659
- /** Human-readable reason */
660
- reason: string;
661
- }
662
-
663
- /**
664
- * Normalize checkbox text for fuzzy matching.
665
- *
666
- * Strips markdown formatting, collapses whitespace, lowercases, and removes
667
- * leading punctuation/bullets. This allows reconciliation entries (which come
668
- * from the review agent's paraphrasing) to match STATUS.md checkboxes that
669
- * may differ in whitespace, casing, or minor formatting.
670
- */
671
- function normalizeCheckboxText(text: string): string {
672
- return text
673
- .replace(/\*\*|__|``|`/g, "") // strip bold/code formatting
674
- .replace(/\s+/g, " ") // collapse whitespace
675
- .replace(/^\s*[-*•]\s*/, "") // strip leading bullets
676
- .trim()
677
- .toLowerCase();
678
- }
679
-
680
- /**
681
- * Apply statusReconciliation entries to STATUS.md checkboxes.
682
- *
683
- * For each reconciliation entry:
684
- * - `done` → ensure checkbox is checked (`[x]`)
685
- * - `not_done` → ensure checkbox is unchecked (`[ ]`)
686
- * - `partial` → ensure checkbox is unchecked (`[ ]`) with "(partial)" annotation
687
- *
688
- * Matching strategy: normalize both the reconciliation `checkbox` text and the
689
- * STATUS.md checkbox text, then match by substring containment (reconciliation
690
- * text contained in STATUS line or vice versa). First match wins — duplicates
691
- * are logged as "unmatched" after the first match is consumed.
692
- *
693
- * Idempotency: if a checkbox already has the correct state, no change is made.
694
- * If no net changes occur, STATUS.md is not rewritten.
695
- *
696
- * @param statusPath - Absolute path to STATUS.md
697
- * @param reconciliations - Array of reconciliation entries from the verdict
698
- * @returns Summary of changes applied
699
- */
700
- export function applyStatusReconciliation(
701
- statusPath: string,
702
- reconciliations: StatusReconciliation[],
703
- ): ReconciliationResult {
704
- const result: ReconciliationResult = {
705
- changed: 0,
706
- alreadyCorrect: 0,
707
- unmatched: 0,
708
- actions: [],
709
- };
710
-
711
- if (!reconciliations || reconciliations.length === 0) {
712
- return result;
713
- }
714
-
715
- let content: string;
716
- try {
717
- if (!existsSync(statusPath)) {
718
- // No STATUS.md — mark all as unmatched
719
- for (const r of reconciliations) {
720
- result.unmatched++;
721
- result.actions.push({ checkbox: r.checkbox, outcome: "unmatched", reason: "STATUS.md not found" });
722
- }
723
- return result;
724
- }
725
- content = readFileSync(statusPath, "utf-8");
726
- } catch {
727
- for (const r of reconciliations) {
728
- result.unmatched++;
729
- result.actions.push({ checkbox: r.checkbox, outcome: "unmatched", reason: "STATUS.md unreadable" });
730
- }
731
- return result;
732
- }
733
-
734
- // Parse lines, identify checkbox lines with their indices
735
- const lines = content.split("\n");
736
- const checkboxRegex = /^(\s*-\s*\[)([ xX])(\]\s*)(.*)/;
737
-
738
- // Track which line indices have been consumed by a reconciliation match
739
- const consumed = new Set<number>();
740
-
741
- for (const recon of reconciliations) {
742
- const normalizedRecon = normalizeCheckboxText(recon.checkbox);
743
- if (!normalizedRecon) {
744
- result.unmatched++;
745
- result.actions.push({ checkbox: recon.checkbox, outcome: "unmatched", reason: "Empty checkbox text after normalization" });
746
- continue;
747
- }
748
-
749
- // Find the best matching checkbox line (first unconsumed match)
750
- let matchedIdx = -1;
751
- for (let i = 0; i < lines.length; i++) {
752
- if (consumed.has(i)) continue;
753
- const cbMatch = lines[i].match(checkboxRegex);
754
- if (!cbMatch) continue;
755
-
756
- const lineText = normalizeCheckboxText(cbMatch[4]);
757
- // Match if either contains the other (handles paraphrasing)
758
- if (lineText === normalizedRecon || lineText.includes(normalizedRecon) || normalizedRecon.includes(lineText)) {
759
- matchedIdx = i;
760
- break;
761
- }
762
- }
763
-
764
- if (matchedIdx === -1) {
765
- result.unmatched++;
766
- result.actions.push({ checkbox: recon.checkbox, outcome: "unmatched", reason: "No matching checkbox found in STATUS.md" });
767
- continue;
768
- }
769
-
770
- consumed.add(matchedIdx);
771
- const cbMatch = lines[matchedIdx].match(checkboxRegex)!;
772
- const currentlyChecked = cbMatch[2].toLowerCase() === "x";
773
- const currentText = cbMatch[4];
774
-
775
- // Determine desired state
776
- const shouldBeChecked = recon.actualState === "done";
777
- // partial → uncheck (conservative: don't claim done)
778
-
779
- if (shouldBeChecked && currentlyChecked) {
780
- // Already correct
781
- result.alreadyCorrect++;
782
- result.actions.push({ checkbox: recon.checkbox, outcome: "no_change", reason: "Already checked (done)" });
783
- } else if (!shouldBeChecked && !currentlyChecked) {
784
- // Already correct (unchecked for not_done or partial)
785
- // But if partial, might need annotation
786
- if (recon.actualState === "partial" && !currentText.includes("(partial)")) {
787
- // Add partial annotation
788
- lines[matchedIdx] = `${cbMatch[1]} ${cbMatch[3]}${currentText} (partial)`;
789
- result.changed++;
790
- result.actions.push({ checkbox: recon.checkbox, outcome: "unchecked", reason: "Added (partial) annotation" });
791
- } else {
792
- result.alreadyCorrect++;
793
- result.actions.push({ checkbox: recon.checkbox, outcome: "no_change", reason: `Already unchecked (${recon.actualState})` });
794
- }
795
- } else if (shouldBeChecked && !currentlyChecked) {
796
- // Need to check
797
- lines[matchedIdx] = `${cbMatch[1]}x${cbMatch[3]}${currentText}`;
798
- result.changed++;
799
- result.actions.push({ checkbox: recon.checkbox, outcome: "checked", reason: "Work done but box was unchecked" });
800
- } else {
801
- // currentlyChecked but should not be (not_done or partial)
802
- const annotation = recon.actualState === "partial" ? " (partial)" : "";
803
- const cleanText = currentText.replace(/\s*\(partial\)\s*$/, "");
804
- lines[matchedIdx] = `${cbMatch[1]} ${cbMatch[3]}${cleanText}${annotation}`;
805
- result.changed++;
806
- const outcomeReason = recon.actualState === "partial"
807
- ? "Unchecked — work partially done"
808
- : "Unchecked — work not done";
809
- result.actions.push({ checkbox: recon.checkbox, outcome: "unchecked", reason: outcomeReason });
810
- }
811
- }
812
-
813
- // Only rewrite if there were actual changes
814
- if (result.changed > 0) {
815
- try {
816
- writeFileSync(statusPath, lines.join("\n"), "utf-8");
817
- } catch {
818
- // Write failed — downgrade changes to unmatched for accuracy
819
- // (the in-memory result says "changed" but file wasn't updated)
820
- for (const action of result.actions) {
821
- if (action.outcome === "checked" || action.outcome === "unchecked") {
822
- action.outcome = "unmatched";
823
- action.reason += " (write failed)";
824
- result.changed--;
825
- result.unmatched++;
826
- }
827
- }
828
- }
829
- }
830
-
831
- return result;
832
- }
833
-
834
- // ── Remediation: Feedback & Fix Agent Prompt ─────────────────────────
835
-
836
- /** Path for the review feedback file written for the fix agent. */
837
- export const FEEDBACK_FILENAME = "REVIEW_FEEDBACK.md";
838
-
839
- /**
840
- * Generate a deterministic REVIEW_FEEDBACK.md from a NEEDS_FIXES verdict.
841
- *
842
- * Includes blocking findings based on the configured pass threshold:
843
- * - `no_critical` / `no_important`: critical + important findings only
844
- * - `all_clear`: critical + important + suggestion findings (all are blocking)
845
- *
846
- * The template is stable across runs so fix-agent prompts are reproducible.
847
- *
848
- * This file is intentionally staged as a task artifact (aligns with
849
- * roadmap 5e: REVIEW_FEEDBACK.md is part of the review audit trail).
850
- *
851
- * @param verdict - The NEEDS_FIXES review verdict
852
- * @param cycleNum - Current remediation cycle number (1-based)
853
- * @param maxCycles - Maximum review cycles configured
854
- * @param passThreshold - Configured pass threshold (determines which severities are blocking)
855
- * @returns Markdown content for REVIEW_FEEDBACK.md
856
- */
857
- export function generateFeedbackMd(
858
- verdict: ReviewVerdict,
859
- cycleNum: number,
860
- maxCycles: number,
861
- passThreshold: PassThreshold = "no_critical",
862
- ): string {
863
- const criticals = verdict.findings.filter(f => f.severity === "critical");
864
- const importants = verdict.findings.filter(f => f.severity === "important");
865
- const suggestions = verdict.findings.filter(f => f.severity === "suggestion");
866
- const mismatches = verdict.statusReconciliation.filter(r => r.actualState !== "done");
867
-
868
- // Under all_clear, suggestions are also blocking
869
- const includeSuggestions = passThreshold === "all_clear";
870
-
871
- const blockingLabel = includeSuggestions
872
- ? "critical, important, and suggestion"
873
- : "critical and important";
874
-
875
- const lines: string[] = [
876
- `# Review Feedback — Cycle ${cycleNum}/${maxCycles}`,
877
- ``,
878
- `**Verdict:** NEEDS_FIXES`,
879
- `**Confidence:** ${verdict.confidence}`,
880
- `**Summary:** ${verdict.summary}`,
881
- `**Pass Threshold:** \`${passThreshold}\``,
882
- ``,
883
- `> This file was generated by the quality gate. Address all ${blockingLabel}`,
884
- `> findings below, then the review will re-run automatically.`,
885
- ``,
886
- ];
887
-
888
- if (criticals.length > 0) {
889
- lines.push(`## Critical Findings (${criticals.length})`);
890
- lines.push(``);
891
- for (let i = 0; i < criticals.length; i++) {
892
- const f = criticals[i];
893
- lines.push(`### C${i + 1}: ${f.description}`);
894
- lines.push(``);
895
- lines.push(`- **Category:** ${f.category}`);
896
- if (f.file) lines.push(`- **File:** \`${f.file}\``);
897
- if (f.remediation) lines.push(`- **Remediation:** ${f.remediation}`);
898
- lines.push(``);
899
- }
900
- }
901
-
902
- if (importants.length > 0) {
903
- lines.push(`## Important Findings (${importants.length})`);
904
- lines.push(``);
905
- for (let i = 0; i < importants.length; i++) {
906
- const f = importants[i];
907
- lines.push(`### I${i + 1}: ${f.description}`);
908
- lines.push(``);
909
- lines.push(`- **Category:** ${f.category}`);
910
- if (f.file) lines.push(`- **File:** \`${f.file}\``);
911
- if (f.remediation) lines.push(`- **Remediation:** ${f.remediation}`);
912
- lines.push(``);
913
- }
914
- }
915
-
916
- if (includeSuggestions && suggestions.length > 0) {
917
- lines.push(`## Suggestion Findings (${suggestions.length})`);
918
- lines.push(``);
919
- lines.push(`> Under \`all_clear\` threshold, suggestions are also blocking.`);
920
- lines.push(``);
921
- for (let i = 0; i < suggestions.length; i++) {
922
- const f = suggestions[i];
923
- lines.push(`### S${i + 1}: ${f.description}`);
924
- lines.push(``);
925
- lines.push(`- **Category:** ${f.category}`);
926
- if (f.file) lines.push(`- **File:** \`${f.file}\``);
927
- if (f.remediation) lines.push(`- **Remediation:** ${f.remediation}`);
928
- lines.push(``);
929
- }
930
- }
931
-
932
- if (mismatches.length > 0) {
933
- lines.push(`## STATUS.md Reconciliation Issues (${mismatches.length})`);
934
- lines.push(``);
935
- for (const r of mismatches) {
936
- lines.push(`- **Checkbox:** ${r.checkbox}`);
937
- lines.push(` - **Actual state:** ${r.actualState}`);
938
- if (r.evidence) lines.push(` - **Evidence:** ${r.evidence}`);
939
- }
940
- lines.push(``);
941
- }
942
-
943
- const totalBlocking = criticals.length + importants.length
944
- + (includeSuggestions ? suggestions.length : 0) + mismatches.length;
945
-
946
- if (totalBlocking === 0) {
947
- lines.push(`## No blocking findings`);
948
- lines.push(``);
949
- lines.push(`The review returned NEEDS_FIXES but no blocking findings were extracted for threshold \`${passThreshold}\`.`);
950
- lines.push(`This may indicate a threshold or verdict-rule mismatch. Review the REVIEW_VERDICT.json for details.`);
951
- lines.push(``);
952
- }
953
-
954
- return lines.join("\n");
955
- }
956
-
957
- /**
958
- * Build the prompt for the fix agent that addresses quality gate findings.
959
- *
960
- * The fix agent is spawned in the same worktree as the task and receives
961
- * the REVIEW_FEEDBACK.md content along with task context. It should make
962
- * targeted code fixes and commit them.
963
- *
964
- * @param context - Quality gate context (task folder, IDs, etc.)
965
- * @param feedbackContent - Content of REVIEW_FEEDBACK.md
966
- * @param cycleNum - Current fix cycle number
967
- * @returns Prompt string for the fix agent
968
- */
969
- export function buildFixAgentPrompt(
970
- context: QualityGateContext,
971
- feedbackContent: string,
972
- cycleNum: number,
973
- ): string {
974
- const statusPath = join(context.taskFolder, "STATUS.md");
975
-
976
- let statusContent = "(STATUS.md not found)";
977
- try {
978
- if (existsSync(statusPath)) {
979
- statusContent = readFileSync(statusPath, "utf-8");
980
- }
981
- } catch { /* proceed without */ }
982
-
983
- let promptContent = "(PROMPT.md not found)";
984
- try {
985
- if (existsSync(context.promptPath)) {
986
- promptContent = readFileSync(context.promptPath, "utf-8");
987
- }
988
- } catch { /* proceed without */ }
989
-
990
- return [
991
- `# Quality Gate Remediation — Fix Cycle ${cycleNum}`,
992
- ``,
993
- `You are a fix agent addressing quality gate findings for task **${context.taskId}**.`,
994
- ``,
995
- `The quality gate review found issues that must be fixed before the task can be marked complete.`,
996
- `Your job is to make targeted, minimal fixes to address the critical and important findings below.`,
997
- ``,
998
- `## Rules`,
999
- ``,
1000
- `1. **Read REVIEW_FEEDBACK.md** below — it lists the blocking findings with specific remediation instructions.`,
1001
- `2. **Fix each finding** — make the minimal code change needed. Do NOT refactor unrelated code.`,
1002
- `3. **Commit your fixes** with message: \`fix(${context.taskId}): address quality gate findings (cycle ${cycleNum})\``,
1003
- `4. **Update STATUS.md** if any checkbox states were flagged as incorrect in the reconciliation section.`,
1004
- `5. **Do NOT create .DONE** — the quality gate will re-run automatically after you exit.`,
1005
- ``,
1006
- `## Task Context`,
1007
- ``,
1008
- `- **Task folder:** ${context.taskFolder}/`,
1009
- `- **PROMPT:** ${context.promptPath}`,
1010
- `- **STATUS:** ${statusPath}`,
1011
- ``,
1012
- `## Review Feedback`,
1013
- ``,
1014
- `\`\`\`markdown`,
1015
- feedbackContent,
1016
- `\`\`\``,
1017
- ``,
1018
- `## Original Task Requirements (PROMPT.md)`,
1019
- ``,
1020
- `\`\`\`markdown`,
1021
- promptContent,
1022
- `\`\`\``,
1023
- ``,
1024
- `## Current STATUS.md`,
1025
- ``,
1026
- `\`\`\`markdown`,
1027
- statusContent,
1028
- `\`\`\``,
1029
- ``,
1030
- `**IMPORTANT:** Focus only on fixing the blocking findings. Do not expand scope or create .DONE.`,
1031
- ``,
1032
- ].join("\n");
1033
- }
1
+ /**
2
+ * Quality Gate — structured post-completion review types and verdict evaluation.
3
+ *
4
+ * This module defines the interfaces for quality gate review verdicts and
5
+ * implements the verdict evaluation logic used by the task-runner to decide
6
+ * whether a task passes or needs fixes before `.DONE` creation.
7
+ *
8
+ * Verdict rules (from roadmap Phase 5a):
9
+ * - Any `critical` finding → NEEDS_FIXES
10
+ * - 3+ `important` findings → NEEDS_FIXES
11
+ * - Only `suggestion` findings → PASS
12
+ * - Any `status_mismatch` category → NEEDS_FIXES
13
+ *
14
+ * Fail-open behavior: malformed or missing verdict JSON → PASS
15
+ * (prevents quality gate bugs from blocking task completion)
16
+ *
17
+ * @module quality-gate
18
+ */
19
+
20
+ import type { PassThreshold } from "./config-schema.ts";
21
+ import { readFileSync, writeFileSync, existsSync } from "fs";
22
+ import { join } from "path";
23
+ import { spawnSync } from "child_process";
24
+
25
+ // ── Verdict Interfaces ───────────────────────────────────────────────
26
+
27
+ /** Severity levels for review findings, ordered by decreasing severity. */
28
+ export type FindingSeverity = "critical" | "important" | "suggestion";
29
+
30
+ /** Categories of review findings. */
31
+ export type FindingCategory =
32
+ | "missing_requirement"
33
+ | "incorrect_implementation"
34
+ | "incomplete_work"
35
+ | "status_mismatch";
36
+
37
+ /** A single finding from the quality gate review. */
38
+ export interface ReviewFinding {
39
+ /** Severity of the finding */
40
+ severity: FindingSeverity;
41
+ /** Category classifying what kind of issue was found */
42
+ category: FindingCategory;
43
+ /** Human-readable description of the issue */
44
+ description: string;
45
+ /** File path related to the finding (may be empty) */
46
+ file: string;
47
+ /** Specific fix instruction for the remediation agent */
48
+ remediation: string;
49
+ }
50
+
51
+ /** STATUS.md checkbox reconciliation entry. */
52
+ export interface StatusReconciliation {
53
+ /** Original checkbox text from STATUS.md */
54
+ checkbox: string;
55
+ /** Actual state determined by review */
56
+ actualState: "done" | "not_done" | "partial";
57
+ /** Evidence supporting the state determination */
58
+ evidence: string;
59
+ }
60
+
61
+ /** Overall quality gate verdict from the review agent. */
62
+ export interface ReviewVerdict {
63
+ /** Pass/fail verdict */
64
+ verdict: "PASS" | "NEEDS_FIXES";
65
+ /** Review agent confidence level */
66
+ confidence: "high" | "medium" | "low";
67
+ /** Brief overall assessment */
68
+ summary: string;
69
+ /** Individual findings from the review */
70
+ findings: ReviewFinding[];
71
+ /** STATUS.md checkbox reconciliation results */
72
+ statusReconciliation: StatusReconciliation[];
73
+ }
74
+
75
+ // ── Verdict Evaluation ───────────────────────────────────────────────
76
+
77
+ /** Reason why a verdict was determined to be NEEDS_FIXES. */
78
+ export interface VerdictFailReason {
79
+ /** Rule that triggered the failure */
80
+ rule: "critical_finding" | "important_threshold" | "status_mismatch" | "verdict_says_needs_fixes";
81
+ /** Human-readable explanation */
82
+ detail: string;
83
+ }
84
+
85
+ /** Result of applying verdict rules to a parsed ReviewVerdict. */
86
+ export interface VerdictEvaluation {
87
+ /** Whether the task passes the quality gate */
88
+ pass: boolean;
89
+ /** Reasons for failure (empty array if pass is true) */
90
+ failReasons: VerdictFailReason[];
91
+ }
92
+
93
+ /**
94
+ * Apply verdict rules to determine pass/fail based on findings and threshold.
95
+ *
96
+ * Rules applied in order:
97
+ * 1. Any finding with category `status_mismatch` → NEEDS_FIXES
98
+ * 2. Any finding with severity `critical` → NEEDS_FIXES
99
+ * 3. Threshold-dependent important finding count check
100
+ * 4. If verdict itself says NEEDS_FIXES → respect it
101
+ *
102
+ * Threshold behavior:
103
+ * - `no_critical`: PASS if no critical findings and no status mismatches
104
+ * - `no_important`: PASS if no critical, fewer than 3 important, no status mismatches
105
+ * - `all_clear`: PASS only if zero findings of any severity
106
+ *
107
+ * @param verdict - Parsed review verdict
108
+ * @param threshold - Configured pass threshold
109
+ * @returns Evaluation result with pass/fail and reasons
110
+ */
111
+ export function applyVerdictRules(
112
+ verdict: ReviewVerdict,
113
+ threshold: PassThreshold,
114
+ ): VerdictEvaluation {
115
+ const failReasons: VerdictFailReason[] = [];
116
+
117
+ // Rule 1: Any status_mismatch category → NEEDS_FIXES
118
+ const statusMismatches = verdict.findings.filter(
119
+ (f) => f.category === "status_mismatch",
120
+ );
121
+ if (statusMismatches.length > 0) {
122
+ failReasons.push({
123
+ rule: "status_mismatch",
124
+ detail: `${statusMismatches.length} status mismatch(es) found — checked boxes don't match actual work`,
125
+ });
126
+ }
127
+
128
+ // Rule 2: Any critical finding → NEEDS_FIXES
129
+ const criticals = verdict.findings.filter((f) => f.severity === "critical");
130
+ if (criticals.length > 0) {
131
+ failReasons.push({
132
+ rule: "critical_finding",
133
+ detail: `${criticals.length} critical finding(s)`,
134
+ });
135
+ }
136
+
137
+ // Rule 3: Threshold-dependent important check
138
+ const importants = verdict.findings.filter(
139
+ (f) => f.severity === "important",
140
+ );
141
+
142
+ if (threshold === "no_important" && importants.length >= 3) {
143
+ failReasons.push({
144
+ rule: "important_threshold",
145
+ detail: `${importants.length} important findings (threshold: fewer than 3 required for pass)`,
146
+ });
147
+ }
148
+
149
+ if (threshold === "all_clear" && verdict.findings.length > 0) {
150
+ // For all_clear, any finding of any severity blocks pass
151
+ if (importants.length > 0 && failReasons.every((r) => r.rule !== "important_threshold")) {
152
+ failReasons.push({
153
+ rule: "important_threshold",
154
+ detail: `${importants.length} important finding(s) (all_clear threshold: zero findings required)`,
155
+ });
156
+ }
157
+ // Suggestions also block under all_clear — but we don't need a separate rule
158
+ // since we'll catch it via the verdict_says_needs_fixes or the overall pass logic
159
+ }
160
+
161
+ // Rule 4: If the verdict itself says NEEDS_FIXES and we haven't already failed
162
+ if (verdict.verdict === "NEEDS_FIXES" && failReasons.length === 0) {
163
+ failReasons.push({
164
+ rule: "verdict_says_needs_fixes",
165
+ detail: `Review agent verdict: NEEDS_FIXES — ${verdict.summary}`,
166
+ });
167
+ }
168
+
169
+ // For all_clear threshold: even suggestions-only should fail
170
+ if (
171
+ threshold === "all_clear" &&
172
+ failReasons.length === 0 &&
173
+ verdict.findings.length > 0
174
+ ) {
175
+ const suggestions = verdict.findings.filter(
176
+ (f) => f.severity === "suggestion",
177
+ );
178
+ if (suggestions.length > 0) {
179
+ failReasons.push({
180
+ rule: "important_threshold",
181
+ detail: `${suggestions.length} suggestion(s) found (all_clear threshold: zero findings required)`,
182
+ });
183
+ }
184
+ }
185
+
186
+ return {
187
+ pass: failReasons.length === 0,
188
+ failReasons,
189
+ };
190
+ }
191
+
192
+ // ── Verdict Parsing ──────────────────────────────────────────────────
193
+
194
+ /** Sentinel verdict returned when parsing fails (fail-open). */
195
+ const FAIL_OPEN_VERDICT: ReviewVerdict = {
196
+ verdict: "PASS",
197
+ confidence: "low",
198
+ summary: "Verdict could not be parsed — fail-open policy applied",
199
+ findings: [],
200
+ statusReconciliation: [],
201
+ };
202
+
203
+ /**
204
+ * Parse a JSON string into a ReviewVerdict, with fail-open behavior.
205
+ *
206
+ * If the input is missing, empty, or malformed JSON, returns a PASS verdict
207
+ * (fail-open) to prevent quality gate bugs from blocking task completion.
208
+ *
209
+ * Performs structural validation:
210
+ * - `verdict` must be "PASS" or "NEEDS_FIXES"
211
+ * - `findings` must be an array (defaults to [] if missing)
212
+ * - `statusReconciliation` must be an array (defaults to [] if missing)
213
+ * - Individual findings are validated and malformed entries are dropped
214
+ *
215
+ * @param jsonString - Raw JSON string from review agent output
216
+ * @returns Parsed and validated ReviewVerdict (never throws)
217
+ */
218
+ export function parseVerdict(jsonString: string | undefined | null): ReviewVerdict {
219
+ if (!jsonString || jsonString.trim() === "") {
220
+ return { ...FAIL_OPEN_VERDICT, summary: "No verdict provided — fail-open policy applied" };
221
+ }
222
+
223
+ let raw: unknown;
224
+ try {
225
+ raw = JSON.parse(jsonString);
226
+ } catch {
227
+ return { ...FAIL_OPEN_VERDICT, summary: "Malformed JSON in verdict — fail-open policy applied" };
228
+ }
229
+
230
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
231
+ return { ...FAIL_OPEN_VERDICT, summary: "Verdict is not a JSON object — fail-open policy applied" };
232
+ }
233
+
234
+ const obj = raw as Record<string, unknown>;
235
+
236
+ // Validate verdict field
237
+ const verdict = obj.verdict;
238
+ if (verdict !== "PASS" && verdict !== "NEEDS_FIXES") {
239
+ return { ...FAIL_OPEN_VERDICT, summary: `Invalid verdict value "${String(verdict)}" — fail-open policy applied` };
240
+ }
241
+
242
+ // Parse confidence with fallback
243
+ const validConfidence = ["high", "medium", "low"];
244
+ const confidence = validConfidence.includes(obj.confidence as string)
245
+ ? (obj.confidence as "high" | "medium" | "low")
246
+ : "medium";
247
+
248
+ // Parse summary with fallback
249
+ const summary = typeof obj.summary === "string" ? obj.summary : "";
250
+
251
+ // Parse and validate findings
252
+ const findings = validateFindings(obj.findings);
253
+
254
+ // Parse and validate statusReconciliation
255
+ const statusReconciliation = validateReconciliations(obj.statusReconciliation);
256
+
257
+ return {
258
+ verdict,
259
+ confidence,
260
+ summary,
261
+ findings,
262
+ statusReconciliation,
263
+ };
264
+ }
265
+
266
+ // ── Internal Validation Helpers ──────────────────────────────────────
267
+
268
+ const VALID_SEVERITIES: FindingSeverity[] = ["critical", "important", "suggestion"];
269
+ const VALID_CATEGORIES: FindingCategory[] = [
270
+ "missing_requirement",
271
+ "incorrect_implementation",
272
+ "incomplete_work",
273
+ "status_mismatch",
274
+ ];
275
+ const VALID_STATES = ["done", "not_done", "partial"];
276
+
277
+ /**
278
+ * Validate and normalize the findings array.
279
+ * Drops individual entries that don't have minimum required fields.
280
+ */
281
+ function validateFindings(raw: unknown): ReviewFinding[] {
282
+ if (!Array.isArray(raw)) return [];
283
+
284
+ const validated: ReviewFinding[] = [];
285
+ for (const item of raw) {
286
+ if (typeof item !== "object" || item === null) continue;
287
+ const f = item as Record<string, unknown>;
288
+
289
+ // Severity is required and must be valid
290
+ if (!VALID_SEVERITIES.includes(f.severity as FindingSeverity)) continue;
291
+
292
+ // Category is required and must be valid
293
+ if (!VALID_CATEGORIES.includes(f.category as FindingCategory)) continue;
294
+
295
+ // Description is required
296
+ if (typeof f.description !== "string" || f.description.trim() === "") continue;
297
+
298
+ validated.push({
299
+ severity: f.severity as FindingSeverity,
300
+ category: f.category as FindingCategory,
301
+ description: f.description as string,
302
+ file: typeof f.file === "string" ? f.file : "",
303
+ remediation: typeof f.remediation === "string" ? f.remediation : "",
304
+ });
305
+ }
306
+
307
+ return validated;
308
+ }
309
+
310
+ /**
311
+ * Validate and normalize the statusReconciliation array.
312
+ * Drops individual entries that don't have minimum required fields.
313
+ */
314
+ function validateReconciliations(raw: unknown): StatusReconciliation[] {
315
+ if (!Array.isArray(raw)) return [];
316
+
317
+ const validated: StatusReconciliation[] = [];
318
+ for (const item of raw) {
319
+ if (typeof item !== "object" || item === null) continue;
320
+ const r = item as Record<string, unknown>;
321
+
322
+ if (typeof r.checkbox !== "string" || r.checkbox.trim() === "") continue;
323
+ if (!VALID_STATES.includes(r.actualState as string)) continue;
324
+
325
+ validated.push({
326
+ checkbox: r.checkbox as string,
327
+ actualState: r.actualState as "done" | "not_done" | "partial",
328
+ evidence: typeof r.evidence === "string" ? r.evidence : "",
329
+ });
330
+ }
331
+
332
+ return validated;
333
+ }
334
+
335
+ // ── Quality Gate Review Prompt ───────────────────────────────────────
336
+
337
+ /** Information needed to build the quality gate review evidence package. */
338
+ export interface QualityGateContext {
339
+ /** Absolute path to task folder */
340
+ taskFolder: string;
341
+ /** Absolute path to PROMPT.md */
342
+ promptPath: string;
343
+ /** Task ID (e.g., "TP-034") */
344
+ taskId: string;
345
+ /** Project name from config */
346
+ projectName: string;
347
+ /** Pass threshold from config */
348
+ passThreshold: PassThreshold;
349
+ }
350
+
351
+ /** Path where the quality gate verdict JSON file is written by the review agent. */
352
+ export const VERDICT_FILENAME = "REVIEW_VERDICT.json";
353
+
354
+ /**
355
+ * Compute a robust diff range for the task's git changes.
356
+ *
357
+ * Strategy (in order):
358
+ * 1. `git merge-base HEAD main` — ideal for topic branches
359
+ * 2. `git merge-base HEAD origin/main` — fallback for detached/worktree checkouts
360
+ * 3. `HEAD~N` where N = min(commit count, 50) — bounded fallback for repos
361
+ * without a main branch or with shallow history
362
+ * 4. Empty string (signals diff unavailable)
363
+ */
364
+ function computeDiffBase(cwd: string): string {
365
+ const opts = { encoding: "utf-8" as const, cwd, timeout: 15000 };
366
+
367
+ // Try merge-base with local main
368
+ for (const ref of ["main", "origin/main", "master", "origin/master"]) {
369
+ const result = spawnSync("git", ["merge-base", "HEAD", ref], opts);
370
+ if (result.status === 0 && result.stdout.trim()) {
371
+ return result.stdout.trim();
372
+ }
373
+ }
374
+
375
+ // Fallback: count commits and use HEAD~N (bounded)
376
+ const countResult = spawnSync("git", ["rev-list", "--count", "HEAD"], opts);
377
+ if (countResult.status === 0) {
378
+ const count = parseInt(countResult.stdout.trim(), 10);
379
+ if (count > 1) {
380
+ const n = Math.min(count - 1, 50);
381
+ return `HEAD~${n}`;
382
+ }
383
+ }
384
+
385
+ return "";
386
+ }
387
+
388
+ /**
389
+ * Build the git diff for the entire task.
390
+ *
391
+ * Uses `computeDiffBase()` to find a robust baseline, then runs `git diff`
392
+ * between that base and HEAD. Falls back gracefully when git is unavailable
393
+ * or the repository has insufficient history.
394
+ */
395
+ function buildGitDiff(cwd: string): { diff: string; fileList: string } {
396
+ try {
397
+ const base = computeDiffBase(cwd);
398
+ if (!base) {
399
+ return { diff: "(git diff unavailable — could not determine base)", fileList: "(file list unavailable)" };
400
+ }
401
+
402
+ const range = `${base}..HEAD`;
403
+
404
+ // Get file list of changed files
405
+ const fileListResult = spawnSync("git", ["diff", "--name-only", range], {
406
+ encoding: "utf-8",
407
+ cwd,
408
+ timeout: 30000,
409
+ });
410
+ const fileList = fileListResult.status === 0
411
+ ? fileListResult.stdout.trim()
412
+ : "";
413
+
414
+ // Get full diff (truncated to avoid blowing up context)
415
+ const diffResult = spawnSync("git", ["diff", range], {
416
+ encoding: "utf-8",
417
+ cwd,
418
+ timeout: 30000,
419
+ maxBuffer: 200 * 1024, // 200KB max
420
+ });
421
+ const diff = diffResult.status === 0
422
+ ? diffResult.stdout.trim()
423
+ : "(git diff unavailable)";
424
+
425
+ return { diff, fileList };
426
+ } catch {
427
+ return { diff: "(git diff failed)", fileList: "(file list unavailable)" };
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Generate the quality gate review prompt that instructs the review agent
433
+ * to produce a structured JSON verdict.
434
+ *
435
+ * The prompt includes:
436
+ * - PROMPT.md content (task requirements)
437
+ * - STATUS.md content (declared progress)
438
+ * - Git diff of all task changes
439
+ * - File change list
440
+ * - JSON schema for the verdict
441
+ * - Instructions for fail criteria
442
+ *
443
+ * @param context - Task context for evidence building
444
+ * @param cwd - Working directory for git commands
445
+ * @returns Review prompt string
446
+ */
447
+ /**
448
+ * Build threshold-specific verdict rule lines for the review prompt.
449
+ *
450
+ * This ensures the reviewer's instructions match the runtime behavior of
451
+ * `applyVerdictRules()` — preventing false failures caused by the reviewer
452
+ * emitting NEEDS_FIXES for findings that the runtime threshold would ignore.
453
+ */
454
+ function buildThresholdRules(threshold: PassThreshold): string[] {
455
+ const rules: string[] = [];
456
+
457
+ // Common rules — always apply
458
+ rules.push(`- **NEEDS_FIXES** if any finding has category \`status_mismatch\` (checkbox claims work is done but it isn't)`);
459
+ rules.push(`- **NEEDS_FIXES** if any finding has severity \`critical\``);
460
+
461
+ // Threshold-specific rules
462
+ switch (threshold) {
463
+ case "no_critical":
464
+ rules.push(`- **PASS** even if there are \`important\` or \`suggestion\` findings (threshold: \`no_critical\`)`);
465
+ break;
466
+ case "no_important":
467
+ rules.push(`- **NEEDS_FIXES** if 3 or more findings have severity \`important\``);
468
+ rules.push(`- **PASS** if only \`suggestion\`-level findings remain`);
469
+ break;
470
+ case "all_clear":
471
+ rules.push(`- **NEEDS_FIXES** if ANY findings exist (including \`suggestion\`-level)`);
472
+ break;
473
+ }
474
+
475
+ rules.push(`- **PASS** if no findings at all`);
476
+ rules.push(``);
477
+
478
+ return rules;
479
+ }
480
+
481
+ export function generateQualityGatePrompt(context: QualityGateContext, cwd: string): string {
482
+ const statusPath = join(context.taskFolder, "STATUS.md");
483
+ const verdictPath = join(context.taskFolder, VERDICT_FILENAME);
484
+
485
+ // Read evidence files
486
+ let promptContent = "(PROMPT.md not found)";
487
+ try {
488
+ if (existsSync(context.promptPath)) {
489
+ promptContent = readFileSync(context.promptPath, "utf-8");
490
+ }
491
+ } catch { /* fail-open: proceed without */ }
492
+
493
+ let statusContent = "(STATUS.md not found)";
494
+ try {
495
+ if (existsSync(statusPath)) {
496
+ statusContent = readFileSync(statusPath, "utf-8");
497
+ }
498
+ } catch { /* fail-open: proceed without */ }
499
+
500
+ const { diff, fileList } = buildGitDiff(cwd);
501
+
502
+ // Truncate diff if too long (keep first 100KB)
503
+ const maxDiffLen = 100 * 1024;
504
+ const truncatedDiff = diff.length > maxDiffLen
505
+ ? diff.slice(0, maxDiffLen) + "\n\n... (diff truncated at 100KB) ..."
506
+ : diff;
507
+
508
+ return [
509
+ `# Quality Gate Review`,
510
+ ``,
511
+ `You are performing a structured post-completion quality gate review for task **${context.taskId}** in project **${context.projectName}**.`,
512
+ ``,
513
+ `Your job is to verify that the task was completed correctly by comparing the PROMPT requirements against the actual code changes and STATUS.md progress claims.`,
514
+ ``,
515
+ `## Task Requirements (PROMPT.md)`,
516
+ ``,
517
+ `\`\`\`markdown`,
518
+ promptContent,
519
+ `\`\`\``,
520
+ ``,
521
+ `## Declared Progress (STATUS.md)`,
522
+ ``,
523
+ `\`\`\`markdown`,
524
+ statusContent,
525
+ `\`\`\``,
526
+ ``,
527
+ `## Changed Files`,
528
+ ``,
529
+ `\`\`\``,
530
+ fileList,
531
+ `\`\`\``,
532
+ ``,
533
+ `## Git Diff`,
534
+ ``,
535
+ `\`\`\`diff`,
536
+ truncatedDiff,
537
+ `\`\`\``,
538
+ ``,
539
+ `## Instructions`,
540
+ ``,
541
+ `1. **Read the PROMPT.md requirements** carefully — identify every deliverable and acceptance criterion.`,
542
+ `2. **Cross-check STATUS.md checkboxes** — verify each checked item actually has corresponding code/test changes in the diff.`,
543
+ `3. **Review the git diff** — look for missing implementations, incorrect logic, incomplete work.`,
544
+ `4. **Use tools** to read actual source files if the diff is unclear.`,
545
+ `5. **Produce your verdict** as a JSON object written to the file specified below.`,
546
+ ``,
547
+ `## Verdict Rules`,
548
+ ``,
549
+ `Report ALL findings you discover with accurate severities. The runtime will`,
550
+ `apply the configured pass threshold (\`${context.passThreshold}\`) to decide pass/fail.`,
551
+ ``,
552
+ `Use these rules to determine your verdict:`,
553
+ ...buildThresholdRules(context.passThreshold),
554
+ ``,
555
+ `## Output Format`,
556
+ ``,
557
+ `Write a JSON file to: \`${verdictPath}\``,
558
+ ``,
559
+ `The JSON must conform to this schema:`,
560
+ ``,
561
+ `\`\`\`json`,
562
+ `{`,
563
+ ` "verdict": "PASS" | "NEEDS_FIXES",`,
564
+ ` "confidence": "high" | "medium" | "low",`,
565
+ ` "summary": "Brief overall assessment",`,
566
+ ` "findings": [`,
567
+ ` {`,
568
+ ` "severity": "critical" | "important" | "suggestion",`,
569
+ ` "category": "missing_requirement" | "incorrect_implementation" | "incomplete_work" | "status_mismatch",`,
570
+ ` "description": "What is wrong",`,
571
+ ` "file": "path/to/file.ts",`,
572
+ ` "remediation": "Specific fix instruction"`,
573
+ ` }`,
574
+ ` ],`,
575
+ ` "statusReconciliation": [`,
576
+ ` {`,
577
+ ` "checkbox": "Original checkbox text",`,
578
+ ` "actualState": "done" | "not_done" | "partial",`,
579
+ ` "evidence": "How you verified"`,
580
+ ` }`,
581
+ ` ]`,
582
+ `}`,
583
+ `\`\`\``,
584
+ ``,
585
+ `**IMPORTANT:** Write ONLY valid JSON to the verdict file. No markdown, no explanation — just the JSON object.`,
586
+ ``,
587
+ ].join("\n");
588
+ }
589
+
590
+ // ── Quality Gate Result ──────────────────────────────────────────────
591
+
592
+ /** Result of a quality gate review cycle. */
593
+ export interface QualityGateResult {
594
+ /** Whether the task passed the quality gate */
595
+ passed: boolean;
596
+ /** Parsed verdict from the review agent (fail-open sentinel if parsing failed) */
597
+ verdict: ReviewVerdict;
598
+ /** Evaluation of verdict rules against threshold */
599
+ evaluation: VerdictEvaluation;
600
+ /** Number of review cycles consumed so far */
601
+ cyclesUsed: number;
602
+ /** Whether the gate was skipped because it's disabled */
603
+ skipped: boolean;
604
+ }
605
+
606
+ /**
607
+ * Read and evaluate the quality gate verdict file from the task folder.
608
+ *
609
+ * Handles all fail-open paths:
610
+ * - Missing verdict file → synthetic PASS
611
+ * - Malformed JSON → synthetic PASS
612
+ * - Invalid verdict structure → synthetic PASS
613
+ *
614
+ * @param taskFolder - Absolute path to task folder
615
+ * @param passThreshold - Configured pass threshold
616
+ * @returns Evaluated quality gate result
617
+ */
618
+ export function readAndEvaluateVerdict(
619
+ taskFolder: string,
620
+ passThreshold: PassThreshold,
621
+ ): { verdict: ReviewVerdict; evaluation: VerdictEvaluation } {
622
+ const verdictPath = join(taskFolder, VERDICT_FILENAME);
623
+
624
+ let rawJson: string | null = null;
625
+ try {
626
+ if (existsSync(verdictPath)) {
627
+ rawJson = readFileSync(verdictPath, "utf-8");
628
+ }
629
+ } catch {
630
+ // File read error → fail-open
631
+ }
632
+
633
+ const verdict = parseVerdict(rawJson);
634
+ const evaluation = applyVerdictRules(verdict, passThreshold);
635
+
636
+ return { verdict, evaluation };
637
+ }
638
+
639
+ // ── STATUS.md Reconciliation ─────────────────────────────────────────
640
+
641
+ /** Result of applying status reconciliation to STATUS.md. */
642
+ export interface ReconciliationResult {
643
+ /** Number of checkboxes whose state was changed */
644
+ changed: number;
645
+ /** Number of reconciliation entries that matched but required no change */
646
+ alreadyCorrect: number;
647
+ /** Number of reconciliation entries that could not be matched to a checkbox */
648
+ unmatched: number;
649
+ /** Details of each action taken */
650
+ actions: ReconciliationAction[];
651
+ }
652
+
653
+ /** A single reconciliation action applied (or skipped). */
654
+ export interface ReconciliationAction {
655
+ /** The checkbox text from the reconciliation entry */
656
+ checkbox: string;
657
+ /** What happened */
658
+ outcome: "checked" | "unchecked" | "no_change" | "unmatched";
659
+ /** Human-readable reason */
660
+ reason: string;
661
+ }
662
+
663
+ /**
664
+ * Normalize checkbox text for fuzzy matching.
665
+ *
666
+ * Strips markdown formatting, collapses whitespace, lowercases, and removes
667
+ * leading punctuation/bullets. This allows reconciliation entries (which come
668
+ * from the review agent's paraphrasing) to match STATUS.md checkboxes that
669
+ * may differ in whitespace, casing, or minor formatting.
670
+ */
671
+ function normalizeCheckboxText(text: string): string {
672
+ return text
673
+ .replace(/\*\*|__|``|`/g, "") // strip bold/code formatting
674
+ .replace(/\s+/g, " ") // collapse whitespace
675
+ .replace(/^\s*[-*•]\s*/, "") // strip leading bullets
676
+ .trim()
677
+ .toLowerCase();
678
+ }
679
+
680
+ /**
681
+ * Apply statusReconciliation entries to STATUS.md checkboxes.
682
+ *
683
+ * For each reconciliation entry:
684
+ * - `done` → ensure checkbox is checked (`[x]`)
685
+ * - `not_done` → ensure checkbox is unchecked (`[ ]`)
686
+ * - `partial` → ensure checkbox is unchecked (`[ ]`) with "(partial)" annotation
687
+ *
688
+ * Matching strategy: normalize both the reconciliation `checkbox` text and the
689
+ * STATUS.md checkbox text, then match by substring containment (reconciliation
690
+ * text contained in STATUS line or vice versa). First match wins — duplicates
691
+ * are logged as "unmatched" after the first match is consumed.
692
+ *
693
+ * Idempotency: if a checkbox already has the correct state, no change is made.
694
+ * If no net changes occur, STATUS.md is not rewritten.
695
+ *
696
+ * @param statusPath - Absolute path to STATUS.md
697
+ * @param reconciliations - Array of reconciliation entries from the verdict
698
+ * @returns Summary of changes applied
699
+ */
700
+ export function applyStatusReconciliation(
701
+ statusPath: string,
702
+ reconciliations: StatusReconciliation[],
703
+ ): ReconciliationResult {
704
+ const result: ReconciliationResult = {
705
+ changed: 0,
706
+ alreadyCorrect: 0,
707
+ unmatched: 0,
708
+ actions: [],
709
+ };
710
+
711
+ if (!reconciliations || reconciliations.length === 0) {
712
+ return result;
713
+ }
714
+
715
+ let content: string;
716
+ try {
717
+ if (!existsSync(statusPath)) {
718
+ // No STATUS.md — mark all as unmatched
719
+ for (const r of reconciliations) {
720
+ result.unmatched++;
721
+ result.actions.push({ checkbox: r.checkbox, outcome: "unmatched", reason: "STATUS.md not found" });
722
+ }
723
+ return result;
724
+ }
725
+ content = readFileSync(statusPath, "utf-8");
726
+ } catch {
727
+ for (const r of reconciliations) {
728
+ result.unmatched++;
729
+ result.actions.push({ checkbox: r.checkbox, outcome: "unmatched", reason: "STATUS.md unreadable" });
730
+ }
731
+ return result;
732
+ }
733
+
734
+ // Parse lines, identify checkbox lines with their indices
735
+ const lines = content.split("\n");
736
+ const checkboxRegex = /^(\s*-\s*\[)([ xX])(\]\s*)(.*)/;
737
+
738
+ // Track which line indices have been consumed by a reconciliation match
739
+ const consumed = new Set<number>();
740
+
741
+ for (const recon of reconciliations) {
742
+ const normalizedRecon = normalizeCheckboxText(recon.checkbox);
743
+ if (!normalizedRecon) {
744
+ result.unmatched++;
745
+ result.actions.push({ checkbox: recon.checkbox, outcome: "unmatched", reason: "Empty checkbox text after normalization" });
746
+ continue;
747
+ }
748
+
749
+ // Find the best matching checkbox line (first unconsumed match)
750
+ let matchedIdx = -1;
751
+ for (let i = 0; i < lines.length; i++) {
752
+ if (consumed.has(i)) continue;
753
+ const cbMatch = lines[i].match(checkboxRegex);
754
+ if (!cbMatch) continue;
755
+
756
+ const lineText = normalizeCheckboxText(cbMatch[4]);
757
+ // Match if either contains the other (handles paraphrasing)
758
+ if (lineText === normalizedRecon || lineText.includes(normalizedRecon) || normalizedRecon.includes(lineText)) {
759
+ matchedIdx = i;
760
+ break;
761
+ }
762
+ }
763
+
764
+ if (matchedIdx === -1) {
765
+ result.unmatched++;
766
+ result.actions.push({ checkbox: recon.checkbox, outcome: "unmatched", reason: "No matching checkbox found in STATUS.md" });
767
+ continue;
768
+ }
769
+
770
+ consumed.add(matchedIdx);
771
+ const cbMatch = lines[matchedIdx].match(checkboxRegex)!;
772
+ const currentlyChecked = cbMatch[2].toLowerCase() === "x";
773
+ const currentText = cbMatch[4];
774
+
775
+ // Determine desired state
776
+ const shouldBeChecked = recon.actualState === "done";
777
+ // partial → uncheck (conservative: don't claim done)
778
+
779
+ if (shouldBeChecked && currentlyChecked) {
780
+ // Already correct
781
+ result.alreadyCorrect++;
782
+ result.actions.push({ checkbox: recon.checkbox, outcome: "no_change", reason: "Already checked (done)" });
783
+ } else if (!shouldBeChecked && !currentlyChecked) {
784
+ // Already correct (unchecked for not_done or partial)
785
+ // But if partial, might need annotation
786
+ if (recon.actualState === "partial" && !currentText.includes("(partial)")) {
787
+ // Add partial annotation
788
+ lines[matchedIdx] = `${cbMatch[1]} ${cbMatch[3]}${currentText} (partial)`;
789
+ result.changed++;
790
+ result.actions.push({ checkbox: recon.checkbox, outcome: "unchecked", reason: "Added (partial) annotation" });
791
+ } else {
792
+ result.alreadyCorrect++;
793
+ result.actions.push({ checkbox: recon.checkbox, outcome: "no_change", reason: `Already unchecked (${recon.actualState})` });
794
+ }
795
+ } else if (shouldBeChecked && !currentlyChecked) {
796
+ // Need to check
797
+ lines[matchedIdx] = `${cbMatch[1]}x${cbMatch[3]}${currentText}`;
798
+ result.changed++;
799
+ result.actions.push({ checkbox: recon.checkbox, outcome: "checked", reason: "Work done but box was unchecked" });
800
+ } else {
801
+ // currentlyChecked but should not be (not_done or partial)
802
+ const annotation = recon.actualState === "partial" ? " (partial)" : "";
803
+ const cleanText = currentText.replace(/\s*\(partial\)\s*$/, "");
804
+ lines[matchedIdx] = `${cbMatch[1]} ${cbMatch[3]}${cleanText}${annotation}`;
805
+ result.changed++;
806
+ const outcomeReason = recon.actualState === "partial"
807
+ ? "Unchecked — work partially done"
808
+ : "Unchecked — work not done";
809
+ result.actions.push({ checkbox: recon.checkbox, outcome: "unchecked", reason: outcomeReason });
810
+ }
811
+ }
812
+
813
+ // Only rewrite if there were actual changes
814
+ if (result.changed > 0) {
815
+ try {
816
+ writeFileSync(statusPath, lines.join("\n"), "utf-8");
817
+ } catch {
818
+ // Write failed — downgrade changes to unmatched for accuracy
819
+ // (the in-memory result says "changed" but file wasn't updated)
820
+ for (const action of result.actions) {
821
+ if (action.outcome === "checked" || action.outcome === "unchecked") {
822
+ action.outcome = "unmatched";
823
+ action.reason += " (write failed)";
824
+ result.changed--;
825
+ result.unmatched++;
826
+ }
827
+ }
828
+ }
829
+ }
830
+
831
+ return result;
832
+ }
833
+
834
+ // ── Remediation: Feedback & Fix Agent Prompt ─────────────────────────
835
+
836
+ /** Path for the review feedback file written for the fix agent. */
837
+ export const FEEDBACK_FILENAME = "REVIEW_FEEDBACK.md";
838
+
839
+ /**
840
+ * Generate a deterministic REVIEW_FEEDBACK.md from a NEEDS_FIXES verdict.
841
+ *
842
+ * Includes blocking findings based on the configured pass threshold:
843
+ * - `no_critical` / `no_important`: critical + important findings only
844
+ * - `all_clear`: critical + important + suggestion findings (all are blocking)
845
+ *
846
+ * The template is stable across runs so fix-agent prompts are reproducible.
847
+ *
848
+ * This file is intentionally staged as a task artifact (aligns with
849
+ * roadmap 5e: REVIEW_FEEDBACK.md is part of the review audit trail).
850
+ *
851
+ * @param verdict - The NEEDS_FIXES review verdict
852
+ * @param cycleNum - Current remediation cycle number (1-based)
853
+ * @param maxCycles - Maximum review cycles configured
854
+ * @param passThreshold - Configured pass threshold (determines which severities are blocking)
855
+ * @returns Markdown content for REVIEW_FEEDBACK.md
856
+ */
857
+ export function generateFeedbackMd(
858
+ verdict: ReviewVerdict,
859
+ cycleNum: number,
860
+ maxCycles: number,
861
+ passThreshold: PassThreshold = "no_critical",
862
+ ): string {
863
+ const criticals = verdict.findings.filter(f => f.severity === "critical");
864
+ const importants = verdict.findings.filter(f => f.severity === "important");
865
+ const suggestions = verdict.findings.filter(f => f.severity === "suggestion");
866
+ const mismatches = verdict.statusReconciliation.filter(r => r.actualState !== "done");
867
+
868
+ // Under all_clear, suggestions are also blocking
869
+ const includeSuggestions = passThreshold === "all_clear";
870
+
871
+ const blockingLabel = includeSuggestions
872
+ ? "critical, important, and suggestion"
873
+ : "critical and important";
874
+
875
+ const lines: string[] = [
876
+ `# Review Feedback — Cycle ${cycleNum}/${maxCycles}`,
877
+ ``,
878
+ `**Verdict:** NEEDS_FIXES`,
879
+ `**Confidence:** ${verdict.confidence}`,
880
+ `**Summary:** ${verdict.summary}`,
881
+ `**Pass Threshold:** \`${passThreshold}\``,
882
+ ``,
883
+ `> This file was generated by the quality gate. Address all ${blockingLabel}`,
884
+ `> findings below, then the review will re-run automatically.`,
885
+ ``,
886
+ ];
887
+
888
+ if (criticals.length > 0) {
889
+ lines.push(`## Critical Findings (${criticals.length})`);
890
+ lines.push(``);
891
+ for (let i = 0; i < criticals.length; i++) {
892
+ const f = criticals[i];
893
+ lines.push(`### C${i + 1}: ${f.description}`);
894
+ lines.push(``);
895
+ lines.push(`- **Category:** ${f.category}`);
896
+ if (f.file) lines.push(`- **File:** \`${f.file}\``);
897
+ if (f.remediation) lines.push(`- **Remediation:** ${f.remediation}`);
898
+ lines.push(``);
899
+ }
900
+ }
901
+
902
+ if (importants.length > 0) {
903
+ lines.push(`## Important Findings (${importants.length})`);
904
+ lines.push(``);
905
+ for (let i = 0; i < importants.length; i++) {
906
+ const f = importants[i];
907
+ lines.push(`### I${i + 1}: ${f.description}`);
908
+ lines.push(``);
909
+ lines.push(`- **Category:** ${f.category}`);
910
+ if (f.file) lines.push(`- **File:** \`${f.file}\``);
911
+ if (f.remediation) lines.push(`- **Remediation:** ${f.remediation}`);
912
+ lines.push(``);
913
+ }
914
+ }
915
+
916
+ if (includeSuggestions && suggestions.length > 0) {
917
+ lines.push(`## Suggestion Findings (${suggestions.length})`);
918
+ lines.push(``);
919
+ lines.push(`> Under \`all_clear\` threshold, suggestions are also blocking.`);
920
+ lines.push(``);
921
+ for (let i = 0; i < suggestions.length; i++) {
922
+ const f = suggestions[i];
923
+ lines.push(`### S${i + 1}: ${f.description}`);
924
+ lines.push(``);
925
+ lines.push(`- **Category:** ${f.category}`);
926
+ if (f.file) lines.push(`- **File:** \`${f.file}\``);
927
+ if (f.remediation) lines.push(`- **Remediation:** ${f.remediation}`);
928
+ lines.push(``);
929
+ }
930
+ }
931
+
932
+ if (mismatches.length > 0) {
933
+ lines.push(`## STATUS.md Reconciliation Issues (${mismatches.length})`);
934
+ lines.push(``);
935
+ for (const r of mismatches) {
936
+ lines.push(`- **Checkbox:** ${r.checkbox}`);
937
+ lines.push(` - **Actual state:** ${r.actualState}`);
938
+ if (r.evidence) lines.push(` - **Evidence:** ${r.evidence}`);
939
+ }
940
+ lines.push(``);
941
+ }
942
+
943
+ const totalBlocking = criticals.length + importants.length
944
+ + (includeSuggestions ? suggestions.length : 0) + mismatches.length;
945
+
946
+ if (totalBlocking === 0) {
947
+ lines.push(`## No blocking findings`);
948
+ lines.push(``);
949
+ lines.push(`The review returned NEEDS_FIXES but no blocking findings were extracted for threshold \`${passThreshold}\`.`);
950
+ lines.push(`This may indicate a threshold or verdict-rule mismatch. Review the REVIEW_VERDICT.json for details.`);
951
+ lines.push(``);
952
+ }
953
+
954
+ return lines.join("\n");
955
+ }
956
+
957
+ /**
958
+ * Build the prompt for the fix agent that addresses quality gate findings.
959
+ *
960
+ * The fix agent is spawned in the same worktree as the task and receives
961
+ * the REVIEW_FEEDBACK.md content along with task context. It should make
962
+ * targeted code fixes and commit them.
963
+ *
964
+ * @param context - Quality gate context (task folder, IDs, etc.)
965
+ * @param feedbackContent - Content of REVIEW_FEEDBACK.md
966
+ * @param cycleNum - Current fix cycle number
967
+ * @returns Prompt string for the fix agent
968
+ */
969
+ export function buildFixAgentPrompt(
970
+ context: QualityGateContext,
971
+ feedbackContent: string,
972
+ cycleNum: number,
973
+ ): string {
974
+ const statusPath = join(context.taskFolder, "STATUS.md");
975
+
976
+ let statusContent = "(STATUS.md not found)";
977
+ try {
978
+ if (existsSync(statusPath)) {
979
+ statusContent = readFileSync(statusPath, "utf-8");
980
+ }
981
+ } catch { /* proceed without */ }
982
+
983
+ let promptContent = "(PROMPT.md not found)";
984
+ try {
985
+ if (existsSync(context.promptPath)) {
986
+ promptContent = readFileSync(context.promptPath, "utf-8");
987
+ }
988
+ } catch { /* proceed without */ }
989
+
990
+ return [
991
+ `# Quality Gate Remediation — Fix Cycle ${cycleNum}`,
992
+ ``,
993
+ `You are a fix agent addressing quality gate findings for task **${context.taskId}**.`,
994
+ ``,
995
+ `The quality gate review found issues that must be fixed before the task can be marked complete.`,
996
+ `Your job is to make targeted, minimal fixes to address the critical and important findings below.`,
997
+ ``,
998
+ `## Rules`,
999
+ ``,
1000
+ `1. **Read REVIEW_FEEDBACK.md** below — it lists the blocking findings with specific remediation instructions.`,
1001
+ `2. **Fix each finding** — make the minimal code change needed. Do NOT refactor unrelated code.`,
1002
+ `3. **Commit your fixes** with message: \`fix(${context.taskId}): address quality gate findings (cycle ${cycleNum})\``,
1003
+ `4. **Update STATUS.md** if any checkbox states were flagged as incorrect in the reconciliation section.`,
1004
+ `5. **Do NOT create .DONE** — the quality gate will re-run automatically after you exit.`,
1005
+ ``,
1006
+ `## Task Context`,
1007
+ ``,
1008
+ `- **Task folder:** ${context.taskFolder}/`,
1009
+ `- **PROMPT:** ${context.promptPath}`,
1010
+ `- **STATUS:** ${statusPath}`,
1011
+ ``,
1012
+ `## Review Feedback`,
1013
+ ``,
1014
+ `\`\`\`markdown`,
1015
+ feedbackContent,
1016
+ `\`\`\``,
1017
+ ``,
1018
+ `## Original Task Requirements (PROMPT.md)`,
1019
+ ``,
1020
+ `\`\`\`markdown`,
1021
+ promptContent,
1022
+ `\`\`\``,
1023
+ ``,
1024
+ `## Current STATUS.md`,
1025
+ ``,
1026
+ `\`\`\`markdown`,
1027
+ statusContent,
1028
+ `\`\`\``,
1029
+ ``,
1030
+ `**IMPORTANT:** Focus only on fixing the blocking findings. Do not expand scope or create .DONE.`,
1031
+ ``,
1032
+ ].join("\n");
1033
+ }