@nathapp/nax 0.37.0 → 0.38.1

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 (72) hide show
  1. package/dist/nax.js +3258 -2894
  2. package/package.json +4 -1
  3. package/src/agents/claude-complete.ts +72 -0
  4. package/src/agents/claude-execution.ts +189 -0
  5. package/src/agents/claude-interactive.ts +77 -0
  6. package/src/agents/claude-plan.ts +23 -8
  7. package/src/agents/claude.ts +64 -349
  8. package/src/analyze/classifier.ts +2 -1
  9. package/src/cli/config-descriptions.ts +206 -0
  10. package/src/cli/config-diff.ts +103 -0
  11. package/src/cli/config-display.ts +285 -0
  12. package/src/cli/config-get.ts +55 -0
  13. package/src/cli/config.ts +7 -618
  14. package/src/cli/prompts-export.ts +58 -0
  15. package/src/cli/prompts-init.ts +200 -0
  16. package/src/cli/prompts-main.ts +237 -0
  17. package/src/cli/prompts-tdd.ts +78 -0
  18. package/src/cli/prompts.ts +10 -541
  19. package/src/commands/logs-formatter.ts +201 -0
  20. package/src/commands/logs-reader.ts +171 -0
  21. package/src/commands/logs.ts +11 -362
  22. package/src/config/loader.ts +4 -15
  23. package/src/config/runtime-types.ts +448 -0
  24. package/src/config/schema-types.ts +53 -0
  25. package/src/config/types.ts +49 -486
  26. package/src/context/auto-detect.ts +2 -1
  27. package/src/context/builder.ts +3 -2
  28. package/src/execution/crash-heartbeat.ts +77 -0
  29. package/src/execution/crash-recovery.ts +23 -365
  30. package/src/execution/crash-signals.ts +149 -0
  31. package/src/execution/crash-writer.ts +154 -0
  32. package/src/execution/parallel-coordinator.ts +278 -0
  33. package/src/execution/parallel-executor-rectification-pass.ts +117 -0
  34. package/src/execution/parallel-executor-rectify.ts +135 -0
  35. package/src/execution/parallel-executor.ts +19 -211
  36. package/src/execution/parallel-worker.ts +148 -0
  37. package/src/execution/parallel.ts +5 -404
  38. package/src/execution/pid-registry.ts +3 -8
  39. package/src/execution/runner-completion.ts +160 -0
  40. package/src/execution/runner-execution.ts +221 -0
  41. package/src/execution/runner-setup.ts +82 -0
  42. package/src/execution/runner.ts +53 -202
  43. package/src/execution/timeout-handler.ts +100 -0
  44. package/src/hooks/runner.ts +11 -21
  45. package/src/metrics/tracker.ts +7 -30
  46. package/src/pipeline/runner.ts +2 -1
  47. package/src/pipeline/stages/completion.ts +0 -1
  48. package/src/pipeline/stages/context.ts +2 -1
  49. package/src/plugins/extensions.ts +225 -0
  50. package/src/plugins/loader.ts +2 -1
  51. package/src/plugins/types.ts +16 -221
  52. package/src/prd/index.ts +2 -1
  53. package/src/prd/validate.ts +41 -0
  54. package/src/precheck/checks-blockers.ts +15 -419
  55. package/src/precheck/checks-cli.ts +68 -0
  56. package/src/precheck/checks-config.ts +102 -0
  57. package/src/precheck/checks-git.ts +87 -0
  58. package/src/precheck/checks-system.ts +163 -0
  59. package/src/review/orchestrator.ts +19 -6
  60. package/src/review/runner.ts +17 -5
  61. package/src/routing/chain.ts +2 -1
  62. package/src/routing/loader.ts +2 -5
  63. package/src/tdd/orchestrator.ts +2 -1
  64. package/src/tdd/verdict-reader.ts +266 -0
  65. package/src/tdd/verdict.ts +6 -271
  66. package/src/utils/errors.ts +12 -0
  67. package/src/utils/git.ts +12 -5
  68. package/src/utils/json-file.ts +72 -0
  69. package/src/verification/executor.ts +2 -1
  70. package/src/verification/smart-runner.ts +23 -3
  71. package/src/worktree/manager.ts +9 -3
  72. package/src/worktree/merge.ts +3 -2
@@ -0,0 +1,163 @@
1
+ /**
2
+ * System-level precheck implementations
3
+ */
4
+
5
+ import { existsSync, statSync } from "node:fs";
6
+ import type { NaxConfig } from "../config";
7
+ import type { Check } from "./types";
8
+
9
+ /** Check if dependencies are installed (language-aware). Detects: node_modules, target, venv, vendor */
10
+ export async function checkDependenciesInstalled(workdir: string): Promise<Check> {
11
+ const depPaths = [
12
+ { path: "node_modules" },
13
+ { path: "target" },
14
+ { path: "venv" },
15
+ { path: ".venv" },
16
+ { path: "vendor" },
17
+ ];
18
+
19
+ const found: string[] = [];
20
+ for (const { path } of depPaths) {
21
+ const fullPath = `${workdir}/${path}`;
22
+ if (existsSync(fullPath)) {
23
+ const stats = statSync(fullPath);
24
+ if (stats.isDirectory()) {
25
+ found.push(path);
26
+ }
27
+ }
28
+ }
29
+
30
+ const passed = found.length > 0;
31
+
32
+ return {
33
+ name: "dependencies-installed",
34
+ tier: "blocker",
35
+ passed,
36
+ message: passed ? `Dependencies found: ${found.join(", ")}` : "No dependency directories detected",
37
+ };
38
+ }
39
+
40
+ /** Check if test command works. Skips silently if command is null/false. */
41
+ export async function checkTestCommand(config: NaxConfig): Promise<Check> {
42
+ const testCommand = config.execution.testCommand || (config.quality?.commands?.test as string | undefined);
43
+
44
+ if (!testCommand || testCommand === null) {
45
+ return {
46
+ name: "test-command-works",
47
+ tier: "blocker",
48
+ passed: true,
49
+ message: "Test command not configured (skipped)",
50
+ };
51
+ }
52
+
53
+ const parts = testCommand.split(" ");
54
+ const [cmd, ...args] = parts;
55
+
56
+ try {
57
+ const proc = Bun.spawn([cmd, ...args, "--help"], {
58
+ stdout: "pipe",
59
+ stderr: "pipe",
60
+ });
61
+
62
+ const exitCode = await proc.exited;
63
+ const passed = exitCode === 0;
64
+
65
+ return {
66
+ name: "test-command-works",
67
+ tier: "blocker",
68
+ passed,
69
+ message: passed ? "Test command is available" : `Test command failed: ${testCommand}`,
70
+ };
71
+ } catch {
72
+ return {
73
+ name: "test-command-works",
74
+ tier: "blocker",
75
+ passed: false,
76
+ message: `Test command failed: ${testCommand}`,
77
+ };
78
+ }
79
+ }
80
+
81
+ /** Check if lint command works. Skips silently if command is null/false. */
82
+ export async function checkLintCommand(config: NaxConfig): Promise<Check> {
83
+ const lintCommand = config.execution.lintCommand;
84
+
85
+ if (!lintCommand || lintCommand === null) {
86
+ return {
87
+ name: "lint-command-works",
88
+ tier: "blocker",
89
+ passed: true,
90
+ message: "Lint command not configured (skipped)",
91
+ };
92
+ }
93
+
94
+ const parts = lintCommand.split(" ");
95
+ const [cmd, ...args] = parts;
96
+
97
+ try {
98
+ const proc = Bun.spawn([cmd, ...args, "--help"], {
99
+ stdout: "pipe",
100
+ stderr: "pipe",
101
+ });
102
+
103
+ const exitCode = await proc.exited;
104
+ const passed = exitCode === 0;
105
+
106
+ return {
107
+ name: "lint-command-works",
108
+ tier: "blocker",
109
+ passed,
110
+ message: passed ? "Lint command is available" : `Lint command failed: ${lintCommand}`,
111
+ };
112
+ } catch {
113
+ return {
114
+ name: "lint-command-works",
115
+ tier: "blocker",
116
+ passed: false,
117
+ message: `Lint command failed: ${lintCommand}`,
118
+ };
119
+ }
120
+ }
121
+
122
+ /** Check if typecheck command works. Skips silently if command is null/false. */
123
+ export async function checkTypecheckCommand(config: NaxConfig): Promise<Check> {
124
+ const typecheckCommand = config.execution.typecheckCommand;
125
+
126
+ if (!typecheckCommand || typecheckCommand === null) {
127
+ return {
128
+ name: "typecheck-command-works",
129
+ tier: "blocker",
130
+ passed: true,
131
+ message: "Typecheck command not configured (skipped)",
132
+ };
133
+ }
134
+
135
+ const parts = typecheckCommand.split(" ");
136
+ const [cmd, ...args] = parts;
137
+
138
+ try {
139
+ const proc = Bun.spawn([cmd, ...args, "--help"], {
140
+ stdout: "pipe",
141
+ stderr: "pipe",
142
+ });
143
+
144
+ const exitCode = await proc.exited;
145
+ const passed = exitCode === 0;
146
+
147
+ return {
148
+ name: "typecheck-command-works",
149
+ tier: "blocker",
150
+ passed,
151
+ message: passed
152
+ ? `Typecheck command is available: ${typecheckCommand}`
153
+ : `Typecheck command failed: ${typecheckCommand}`,
154
+ };
155
+ } catch {
156
+ return {
157
+ name: "typecheck-command-works",
158
+ tier: "blocker",
159
+ passed: false,
160
+ message: `Typecheck command failed: ${typecheckCommand}`,
161
+ };
162
+ }
163
+ }
@@ -12,17 +12,31 @@ import { spawn } from "bun";
12
12
  import type { NaxConfig } from "../config";
13
13
  import { getSafeLogger } from "../logger";
14
14
  import type { PluginRegistry } from "../plugins";
15
+ import { errorMessage } from "../utils/errors";
15
16
  import { runReview } from "./runner";
16
17
  import type { ReviewConfig, ReviewResult } from "./types";
17
18
 
19
+ /**
20
+ * Injectable dependencies for getChangedFiles() — allows tests to intercept
21
+ * spawn calls without requiring the git binary.
22
+ *
23
+ * @internal
24
+ */
25
+ export const _orchestratorDeps = { spawn };
26
+
18
27
  async function getChangedFiles(workdir: string, baseRef?: string): Promise<string[]> {
19
28
  try {
20
29
  const diffArgs = ["diff", "--name-only"];
21
30
  const [stagedProc, unstagedProc, baseProc] = [
22
- spawn({ cmd: ["git", ...diffArgs, "--cached"], cwd: workdir, stdout: "pipe", stderr: "pipe" }),
23
- spawn({ cmd: ["git", ...diffArgs], cwd: workdir, stdout: "pipe", stderr: "pipe" }),
31
+ _orchestratorDeps.spawn({ cmd: ["git", ...diffArgs, "--cached"], cwd: workdir, stdout: "pipe", stderr: "pipe" }),
32
+ _orchestratorDeps.spawn({ cmd: ["git", ...diffArgs], cwd: workdir, stdout: "pipe", stderr: "pipe" }),
24
33
  baseRef
25
- ? spawn({ cmd: ["git", ...diffArgs, `${baseRef}...HEAD`], cwd: workdir, stdout: "pipe", stderr: "pipe" })
34
+ ? _orchestratorDeps.spawn({
35
+ cmd: ["git", ...diffArgs, `${baseRef}...HEAD`],
36
+ cwd: workdir,
37
+ stdout: "pipe",
38
+ stderr: "pipe",
39
+ })
26
40
  : null,
27
41
  ];
28
42
 
@@ -73,8 +87,7 @@ export class ReviewOrchestrator {
73
87
  const reviewers = plugins.getReviewers();
74
88
  if (reviewers.length > 0) {
75
89
  // Use the story's start ref if available to capture auto-committed changes
76
- // biome-ignore lint/suspicious/noExplicitAny: baseRef injected into config for pipeline use
77
- const baseRef = (executionConfig as any)?.storyGitRef;
90
+ const baseRef = executionConfig?.storyGitRef;
78
91
  const changedFiles = await getChangedFiles(workdir, baseRef);
79
92
  const pluginResults: ReviewResult["pluginReviewers"] = [];
80
93
 
@@ -108,7 +121,7 @@ export class ReviewOrchestrator {
108
121
  };
109
122
  }
110
123
  } catch (error) {
111
- const errorMsg = error instanceof Error ? error.message : String(error);
124
+ const errorMsg = errorMessage(error);
112
125
  logger?.warn("review", `Plugin reviewer threw error: ${reviewer.name}`, { error: errorMsg });
113
126
  pluginResults.push({ name: reviewer.name, passed: false, output: "", error: errorMsg });
114
127
  builtIn.pluginReviewers = pluginResults;
@@ -7,8 +7,17 @@
7
7
  import { spawn } from "bun";
8
8
  import type { ExecutionConfig } from "../config/schema";
9
9
  import { getSafeLogger } from "../logger";
10
+ import { errorMessage } from "../utils/errors";
10
11
  import type { ReviewCheckName, ReviewCheckResult, ReviewConfig, ReviewResult } from "./types";
11
12
 
13
+ /**
14
+ * Injectable dependencies for runner internals — allows tests to intercept
15
+ * Bun.file and spawn calls without mock.module().
16
+ *
17
+ * @internal
18
+ */
19
+ export const _reviewRunnerDeps = { spawn, file: Bun.file };
20
+
12
21
  /** Default commands for each check type */
13
22
  const DEFAULT_COMMANDS: Record<ReviewCheckName, string> = {
14
23
  typecheck: "bun run typecheck",
@@ -21,7 +30,7 @@ const DEFAULT_COMMANDS: Record<ReviewCheckName, string> = {
21
30
  */
22
31
  async function loadPackageJson(workdir: string): Promise<Record<string, unknown> | null> {
23
32
  try {
24
- const file = Bun.file(`${workdir}/package.json`);
33
+ const file = _reviewRunnerDeps.file(`${workdir}/package.json`);
25
34
  const content = await file.text();
26
35
  return JSON.parse(content);
27
36
  } catch {
@@ -80,6 +89,9 @@ async function resolveCommand(
80
89
  /** Default timeout for review checks (lint, typecheck). BUG-039. */
81
90
  const REVIEW_CHECK_TIMEOUT_MS = 120_000;
82
91
 
92
+ /** Grace period between SIGTERM and SIGKILL for review check cleanup. */
93
+ const SIGKILL_GRACE_PERIOD_MS = 5_000;
94
+
83
95
  /**
84
96
  * Run a single review check with a hard timeout.
85
97
  *
@@ -95,7 +107,7 @@ async function runCheck(check: ReviewCheckName, command: string, workdir: string
95
107
  const args = parts.slice(1);
96
108
 
97
109
  // Spawn the process
98
- const proc = spawn({
110
+ const proc = _reviewRunnerDeps.spawn({
99
111
  cmd: [executable, ...args],
100
112
  cwd: workdir,
101
113
  stdout: "pipe",
@@ -117,7 +129,7 @@ async function runCheck(check: ReviewCheckName, command: string, workdir: string
117
129
  } catch {
118
130
  /* already exited */
119
131
  }
120
- }, 5000);
132
+ }, SIGKILL_GRACE_PERIOD_MS);
121
133
  }, REVIEW_CHECK_TIMEOUT_MS);
122
134
 
123
135
  // Wait for completion
@@ -154,7 +166,7 @@ async function runCheck(check: ReviewCheckName, command: string, workdir: string
154
166
  command,
155
167
  success: false,
156
168
  exitCode: -1,
157
- output: error instanceof Error ? error.message : String(error),
169
+ output: errorMessage(error),
158
170
  durationMs: Date.now() - startTime,
159
171
  };
160
172
  }
@@ -166,7 +178,7 @@ async function runCheck(check: ReviewCheckName, command: string, workdir: string
166
178
  */
167
179
  async function getUncommittedFilesImpl(workdir: string): Promise<string[]> {
168
180
  try {
169
- const proc = spawn({
181
+ const proc = _reviewRunnerDeps.spawn({
170
182
  cmd: ["git", "diff", "--name-only", "HEAD"],
171
183
  cwd: workdir,
172
184
  stdout: "pipe",
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { getSafeLogger } from "../logger";
9
9
  import type { UserStory } from "../prd/types";
10
+ import { errorMessage } from "../utils/errors";
10
11
  import type { RoutingContext, RoutingDecision, RoutingStrategy } from "./strategy";
11
12
 
12
13
  /**
@@ -51,7 +52,7 @@ export class StrategyChain {
51
52
  logger?.error("routing", `Plugin router "${strategy.name}" failed`, {
52
53
  strategyName: strategy.name,
53
54
  storyId: story.id,
54
- error: error instanceof Error ? error.message : String(error),
55
+ error: errorMessage(error),
55
56
  });
56
57
  // Continue to next strategy
57
58
  }
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { resolve } from "node:path";
8
+ import { errorMessage } from "../utils/errors";
8
9
  import { validateModulePath } from "../utils/path-security";
9
10
  import type { RoutingStrategy } from "./strategy";
10
11
 
@@ -56,10 +57,6 @@ export async function loadCustomStrategy(strategyPath: string, workdir: string):
56
57
 
57
58
  return strategy as RoutingStrategy;
58
59
  } catch (error) {
59
- throw new Error(
60
- `Failed to load custom routing strategy from ${absolutePath}: ${
61
- error instanceof Error ? error.message : String(error)
62
- }`,
63
- );
60
+ throw new Error(`Failed to load custom routing strategy from ${absolutePath}: ${errorMessage(error)}`);
64
61
  }
65
62
  }
@@ -13,6 +13,7 @@ import { resolveModel } from "../config";
13
13
  import { isGreenfieldStory } from "../context/greenfield";
14
14
  import { getLogger } from "../logger";
15
15
  import type { UserStory } from "../prd";
16
+ import { errorMessage } from "../utils/errors";
16
17
  import { captureGitRef } from "../utils/git";
17
18
  import { executeWithTimeout } from "../verification";
18
19
  import { runFullSuiteGate } from "./rectification-gate";
@@ -379,7 +380,7 @@ export async function runThreeSessionTdd(options: ThreeSessionTddOptions): Promi
379
380
  } catch (error) {
380
381
  logger.error("tdd", "Failed to rollback git changes after TDD failure", {
381
382
  storyId: story.id,
382
- error: error instanceof Error ? error.message : String(error),
383
+ error: errorMessage(error),
383
384
  });
384
385
  }
385
386
  }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Verdict file reading, validation, and coercion
3
+ */
4
+
5
+ import { unlink } from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { getLogger } from "../logger";
8
+ import type { VerifierVerdict } from "./verdict";
9
+
10
+ /** File name written by the verifier agent */
11
+ export const VERDICT_FILE = ".nax-verifier-verdict.json";
12
+
13
+ /**
14
+ * Validate that a parsed object has the required fields for a VerifierVerdict.
15
+ * Returns true if the object appears to be a valid verdict.
16
+ */
17
+ export function isValidVerdict(obj: unknown): obj is VerifierVerdict {
18
+ if (!obj || typeof obj !== "object") return false;
19
+ const v = obj as Record<string, unknown>;
20
+
21
+ // Required top-level fields
22
+ if (v.version !== 1) return false;
23
+ if (typeof v.approved !== "boolean") return false;
24
+
25
+ // tests sub-object
26
+ if (!v.tests || typeof v.tests !== "object") return false;
27
+ const tests = v.tests as Record<string, unknown>;
28
+ if (typeof tests.allPassing !== "boolean") return false;
29
+ if (typeof tests.passCount !== "number") return false;
30
+ if (typeof tests.failCount !== "number") return false;
31
+
32
+ // testModifications sub-object
33
+ if (!v.testModifications || typeof v.testModifications !== "object") return false;
34
+ const mods = v.testModifications as Record<string, unknown>;
35
+ if (typeof mods.detected !== "boolean") return false;
36
+ if (!Array.isArray(mods.files)) return false;
37
+ if (typeof mods.legitimate !== "boolean") return false;
38
+ if (typeof mods.reasoning !== "string") return false;
39
+
40
+ // acceptanceCriteria sub-object
41
+ if (!v.acceptanceCriteria || typeof v.acceptanceCriteria !== "object") return false;
42
+ const ac = v.acceptanceCriteria as Record<string, unknown>;
43
+ if (typeof ac.allMet !== "boolean") return false;
44
+ if (!Array.isArray(ac.criteria)) return false;
45
+
46
+ // quality sub-object
47
+ if (!v.quality || typeof v.quality !== "object") return false;
48
+ const quality = v.quality as Record<string, unknown>;
49
+ if (!["good", "acceptable", "poor"].includes(quality.rating as string)) return false;
50
+ if (!Array.isArray(quality.issues)) return false;
51
+
52
+ // fixes and reasoning
53
+ if (!Array.isArray(v.fixes)) return false;
54
+ if (typeof v.reasoning !== "string") return false;
55
+
56
+ return true;
57
+ }
58
+
59
+ /**
60
+ * Coerce a free-form verdict object into the expected VerifierVerdict schema.
61
+ * Maps common agent-improvised patterns (verdict:"PASS", verification_summary, etc.)
62
+ * to the structured format. Returns null if too malformed to coerce.
63
+ */
64
+ export function coerceVerdict(obj: Record<string, unknown>): VerifierVerdict | null {
65
+ try {
66
+ // Determine approval status
67
+ const verdictStr = String(obj.verdict ?? "").toUpperCase();
68
+ const approved =
69
+ verdictStr === "PASS" ||
70
+ verdictStr === "APPROVED" ||
71
+ verdictStr.startsWith("VERIFIED") ||
72
+ verdictStr.includes("ALL ACCEPTANCE CRITERIA MET") ||
73
+ obj.approved === true;
74
+
75
+ // Parse test results from verification_summary or top-level
76
+ let passCount = 0;
77
+ let failCount = 0;
78
+ let allPassing = approved;
79
+ const summary = obj.verification_summary as Record<string, unknown> | undefined;
80
+ if (summary?.test_results && typeof summary.test_results === "string") {
81
+ // Parse "45/45 PASS" or "42/45 PASS" patterns
82
+ const match = (summary.test_results as string).match(/(\d+)\/(\d+)/);
83
+ if (match) {
84
+ passCount = Number.parseInt(match[1], 10);
85
+ const total = Number.parseInt(match[2], 10);
86
+ failCount = total - passCount;
87
+ allPassing = failCount === 0;
88
+ }
89
+ }
90
+ // Also check top-level tests object (partial schema compliance)
91
+ if (obj.tests && typeof obj.tests === "object") {
92
+ const t = obj.tests as Record<string, unknown>;
93
+ if (typeof t.passCount === "number") passCount = t.passCount;
94
+ if (typeof t.failCount === "number") failCount = t.failCount;
95
+ if (typeof t.allPassing === "boolean") allPassing = t.allPassing;
96
+ }
97
+
98
+ // Parse acceptance criteria from acceptance_criteria_review or acceptanceCriteria
99
+ const criteria: Array<{ criterion: string; met: boolean; note?: string }> = [];
100
+ let allMet = approved;
101
+ const acReview = obj.acceptance_criteria_review as Record<string, unknown> | undefined;
102
+ if (acReview) {
103
+ for (const [key, val] of Object.entries(acReview)) {
104
+ if (key.startsWith("criterion") && val && typeof val === "object") {
105
+ const c = val as Record<string, unknown>;
106
+ const met = String(c.status ?? "").toUpperCase() === "SATISFIED" || c.met === true;
107
+ criteria.push({
108
+ criterion: String(c.name ?? c.criterion ?? key),
109
+ met,
110
+ note: c.evidence ? String(c.evidence).slice(0, 200) : undefined,
111
+ });
112
+ if (!met) allMet = false;
113
+ }
114
+ }
115
+ }
116
+ // Also check top-level acceptanceCriteria
117
+ if (obj.acceptanceCriteria && typeof obj.acceptanceCriteria === "object") {
118
+ const ac = obj.acceptanceCriteria as Record<string, unknown>;
119
+ if (typeof ac.allMet === "boolean") allMet = ac.allMet;
120
+ if (Array.isArray(ac.criteria)) {
121
+ for (const c of ac.criteria) {
122
+ if (c && typeof c === "object") {
123
+ criteria.push(c as { criterion: string; met: boolean; note?: string });
124
+ }
125
+ }
126
+ }
127
+ }
128
+ // Parse summary AC count like "4/4 SATISFIED"
129
+ if (criteria.length === 0 && summary?.acceptance_criteria && typeof summary.acceptance_criteria === "string") {
130
+ const acMatch = (summary.acceptance_criteria as string).match(/(\d+)\/(\d+)/);
131
+ if (acMatch) {
132
+ const met = Number.parseInt(acMatch[1], 10);
133
+ const total = Number.parseInt(acMatch[2], 10);
134
+ allMet = met === total;
135
+ }
136
+ }
137
+
138
+ // Parse quality
139
+ let rating: "good" | "acceptable" | "poor" = "acceptable";
140
+ const qualityStr = summary?.code_quality
141
+ ? String(summary.code_quality).toLowerCase()
142
+ : obj.quality && typeof obj.quality === "object"
143
+ ? String((obj.quality as Record<string, unknown>).rating ?? "acceptable").toLowerCase()
144
+ : "acceptable";
145
+ if (qualityStr === "high" || qualityStr === "good") rating = "good";
146
+ else if (qualityStr === "low" || qualityStr === "poor") rating = "poor";
147
+
148
+ // Build coerced verdict
149
+ return {
150
+ version: 1,
151
+ approved,
152
+ tests: { allPassing, passCount, failCount },
153
+ testModifications: {
154
+ detected: false,
155
+ files: [],
156
+ legitimate: true,
157
+ reasoning: "Not assessed in free-form verdict",
158
+ },
159
+ acceptanceCriteria: { allMet, criteria },
160
+ quality: { rating, issues: [] },
161
+ fixes: Array.isArray(obj.fixes) ? (obj.fixes as string[]) : [],
162
+ reasoning:
163
+ typeof obj.reasoning === "string"
164
+ ? obj.reasoning
165
+ : typeof obj.overall_status === "string"
166
+ ? (obj.overall_status as string)
167
+ : summary?.overall_status
168
+ ? String(summary.overall_status)
169
+ : `Coerced from free-form verdict: ${verdictStr}`,
170
+ };
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Read the verifier verdict file from the workdir.
178
+ *
179
+ * Returns the parsed VerifierVerdict when the file exists and is valid.
180
+ * Attempts tolerant coercion if the file doesn't match the strict schema.
181
+ * Returns null if:
182
+ * - File does not exist
183
+ * - File is not valid JSON
184
+ * - Required fields are missing and coercion fails
185
+ *
186
+ * Never throws.
187
+ */
188
+ export async function readVerdict(workdir: string): Promise<VerifierVerdict | null> {
189
+ const logger = getLogger();
190
+ const verdictPath = path.join(workdir, VERDICT_FILE);
191
+
192
+ try {
193
+ const file = Bun.file(verdictPath);
194
+ const exists = await file.exists();
195
+ if (!exists) {
196
+ return null;
197
+ }
198
+
199
+ // Read as text first so we can log raw content on parse failure
200
+ let rawText: string;
201
+ try {
202
+ rawText = await file.text();
203
+ } catch (readErr) {
204
+ logger.warn("tdd", "Failed to read verifier verdict file", {
205
+ path: verdictPath,
206
+ error: String(readErr),
207
+ });
208
+ return null;
209
+ }
210
+
211
+ let parsed: unknown;
212
+ try {
213
+ parsed = JSON.parse(rawText);
214
+ } catch (parseErr) {
215
+ logger.warn("tdd", "Verifier verdict file is not valid JSON — ignoring", {
216
+ path: verdictPath,
217
+ error: String(parseErr),
218
+ rawContent: rawText.slice(0, 1000),
219
+ });
220
+ return null;
221
+ }
222
+
223
+ if (isValidVerdict(parsed)) {
224
+ return parsed;
225
+ }
226
+
227
+ // Strict validation failed — attempt tolerant coercion
228
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
229
+ const coerced = coerceVerdict(parsed as Record<string, unknown>);
230
+ if (coerced) {
231
+ logger.info("tdd", "Coerced free-form verdict to structured format", {
232
+ path: verdictPath,
233
+ approved: coerced.approved,
234
+ passCount: coerced.tests.passCount,
235
+ failCount: coerced.tests.failCount,
236
+ });
237
+ return coerced;
238
+ }
239
+ }
240
+
241
+ logger.warn("tdd", "Verifier verdict file missing required fields and coercion failed — ignoring", {
242
+ path: verdictPath,
243
+ content: JSON.stringify(parsed).slice(0, 500),
244
+ });
245
+ return null;
246
+ } catch (err) {
247
+ logger.warn("tdd", "Failed to read verifier verdict file — ignoring", {
248
+ path: verdictPath,
249
+ error: String(err),
250
+ });
251
+ return null;
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Delete the verifier verdict file from the workdir.
257
+ * Ignores all errors (file may not exist, permissions, etc.).
258
+ */
259
+ export async function cleanupVerdict(workdir: string): Promise<void> {
260
+ const verdictPath = path.join(workdir, VERDICT_FILE);
261
+ try {
262
+ await unlink(verdictPath);
263
+ } catch {
264
+ // Intentionally ignored — file may not exist or already be deleted
265
+ }
266
+ }