@nathapp/nax 0.40.1 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +1 -0
  2. package/bin/nax.ts +130 -11
  3. package/dist/nax.js +1520 -424
  4. package/package.json +8 -7
  5. package/src/acceptance/fix-generator.ts +4 -35
  6. package/src/acceptance/generator.ts +4 -27
  7. package/src/agents/acp/adapter.ts +642 -0
  8. package/src/agents/acp/cost.ts +79 -0
  9. package/src/agents/acp/index.ts +9 -0
  10. package/src/agents/acp/interaction-bridge.ts +126 -0
  11. package/src/agents/acp/parser.ts +166 -0
  12. package/src/agents/acp/spawn-client.ts +309 -0
  13. package/src/agents/acp/types.ts +22 -0
  14. package/src/agents/claude-complete.ts +3 -3
  15. package/src/agents/claude.ts +12 -2
  16. package/src/agents/registry.ts +83 -0
  17. package/src/agents/types-extended.ts +23 -0
  18. package/src/agents/types.ts +17 -0
  19. package/src/analyze/scanner.ts +16 -20
  20. package/src/cli/analyze.ts +6 -2
  21. package/src/cli/plan.ts +218 -129
  22. package/src/commands/precheck.ts +1 -1
  23. package/src/config/defaults.ts +1 -0
  24. package/src/config/runtime-types.ts +10 -0
  25. package/src/config/schema.ts +1 -0
  26. package/src/config/schemas.ts +6 -0
  27. package/src/config/types.ts +1 -0
  28. package/src/execution/executor-types.ts +6 -0
  29. package/src/execution/iteration-runner.ts +2 -0
  30. package/src/execution/lifecycle/acceptance-loop.ts +5 -2
  31. package/src/execution/lifecycle/run-initialization.ts +16 -4
  32. package/src/execution/lifecycle/run-setup.ts +4 -0
  33. package/src/execution/runner-completion.ts +11 -1
  34. package/src/execution/runner-execution.ts +8 -0
  35. package/src/execution/runner-setup.ts +4 -0
  36. package/src/execution/runner.ts +10 -0
  37. package/src/interaction/plugins/webhook.ts +10 -1
  38. package/src/pipeline/stages/execution.ts +33 -1
  39. package/src/pipeline/stages/routing.ts +18 -7
  40. package/src/pipeline/types.ts +10 -0
  41. package/src/prd/schema.ts +249 -0
  42. package/src/tdd/orchestrator.ts +7 -0
  43. package/src/tdd/rectification-gate.ts +6 -0
  44. package/src/tdd/session-runner.ts +15 -2
  45. package/src/utils/git.ts +30 -0
  46. package/src/verification/runners.ts +10 -1
@@ -11,9 +11,11 @@ import { fireHook } from "../hooks";
11
11
  import { getSafeLogger } from "../logger";
12
12
  import type { StoryMetrics } from "../metrics";
13
13
  import type { PipelineEventEmitter } from "../pipeline/events";
14
+ import type { AgentGetFn } from "../pipeline/types";
14
15
  import type { PluginRegistry } from "../plugins/registry";
15
16
  import { isComplete } from "../prd";
16
17
  import type { PRD } from "../prd";
18
+ import { autoCommitIfDirty } from "../utils/git";
17
19
  import { stopHeartbeat, writeExitSummary } from "./crash-recovery";
18
20
  import { hookCtx } from "./story-context";
19
21
 
@@ -42,6 +44,10 @@ export interface RunnerCompletionOptions {
42
44
  statusWriter: any;
43
45
  pluginRegistry: PluginRegistry;
44
46
  eventEmitter?: PipelineEventEmitter;
47
+ /** Protocol-aware agent resolver */
48
+ agentGetFn?: AgentGetFn;
49
+ /** Path to prd.json — required for acceptance fix story writes */
50
+ prdPath: string;
45
51
  }
46
52
 
47
53
  /**
@@ -67,7 +73,7 @@ export async function runCompletionPhase(options: RunnerCompletionOptions): Prom
67
73
  const acceptanceResult = await runAcceptanceLoop({
68
74
  config: options.config,
69
75
  prd: options.prd,
70
- prdPath: "", // Not needed for this extraction
76
+ prdPath: options.prdPath,
71
77
  workdir: options.workdir,
72
78
  featureDir: options.featureDir,
73
79
  hooks: options.hooks,
@@ -79,6 +85,7 @@ export async function runCompletionPhase(options: RunnerCompletionOptions): Prom
79
85
  pluginRegistry: options.pluginRegistry,
80
86
  eventEmitter: options.eventEmitter,
81
87
  statusWriter: options.statusWriter,
88
+ agentGetFn: options.agentGetFn,
82
89
  });
83
90
 
84
91
  Object.assign(options, {
@@ -153,6 +160,9 @@ export async function runCompletionPhase(options: RunnerCompletionOptions): Prom
153
160
  durationMs,
154
161
  );
155
162
 
163
+ // Commit status.json and any other nax runtime files left dirty at run end
164
+ await autoCommitIfDirty(options.workdir, "run.complete", "run-summary", options.feature);
165
+
156
166
  return {
157
167
  durationMs,
158
168
  runCompletedAt,
@@ -10,6 +10,7 @@ import type { LoadedHooksConfig } from "../hooks";
10
10
  import { getSafeLogger } from "../logger";
11
11
  import type { StoryMetrics } from "../metrics";
12
12
  import type { PipelineEventEmitter } from "../pipeline/events";
13
+ import type { AgentGetFn } from "../pipeline/types";
13
14
  import type { PluginRegistry } from "../plugins/registry";
14
15
  import type { PRD } from "../prd";
15
16
  import { tryLlmBatchRoute } from "../routing/batch-route";
@@ -17,6 +18,7 @@ import { clearCache as clearLlmCache, routeBatch as llmRouteBatch } from "../rou
17
18
  import { precomputeBatchPlan } from "./batching";
18
19
  import { getAllReadyStories } from "./helpers";
19
20
  import type { ParallelExecutorOptions, ParallelExecutorResult } from "./parallel-executor";
21
+ import type { PidRegistry } from "./pid-registry";
20
22
 
21
23
  /**
22
24
  * Options for the execution phase.
@@ -42,6 +44,10 @@ export interface RunnerExecutionOptions {
42
44
  headless: boolean;
43
45
  parallel?: number;
44
46
  runParallelExecution?: (options: ParallelExecutorOptions, prd: PRD) => Promise<ParallelExecutorResult>;
47
+ /** Protocol-aware agent resolver — created once in runner.ts from createAgentRegistry(config) */
48
+ agentGetFn?: AgentGetFn;
49
+ /** PID registry for crash recovery — passed to agent.run() to register child processes. */
50
+ pidRegistry?: PidRegistry;
45
51
  }
46
52
 
47
53
  /**
@@ -198,6 +204,8 @@ export async function runExecutionPhase(
198
204
  runId: options.runId,
199
205
  startTime: options.startTime,
200
206
  batchPlan,
207
+ agentGetFn: options.agentGetFn,
208
+ pidRegistry: options.pidRegistry,
201
209
  },
202
210
  prd,
203
211
  );
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { NaxConfig } from "../config";
9
9
  import type { LoadedHooksConfig } from "../hooks";
10
+ import type { AgentGetFn } from "../pipeline/types";
10
11
 
11
12
  /**
12
13
  * Options for the setup phase.
@@ -31,6 +32,8 @@ export interface RunnerSetupOptions {
31
32
  getIterations: () => number;
32
33
  getStoriesCompleted: () => number;
33
34
  getTotalStories: () => number;
35
+ /** Protocol-aware agent resolver — created from createAgentRegistry(config) in runner.ts */
36
+ agentGetFn?: AgentGetFn;
34
37
  }
35
38
 
36
39
  /**
@@ -76,6 +79,7 @@ export async function runSetupPhase(options: RunnerSetupOptions): Promise<Runner
76
79
  // BUG-017: Pass getters for run.complete event on SIGTERM
77
80
  getStoriesCompleted: options.getStoriesCompleted,
78
81
  getTotalStories: options.getTotalStories,
82
+ agentGetFn: options.agentGetFn,
79
83
  });
80
84
 
81
85
  return setupResult;
@@ -13,6 +13,7 @@
13
13
  * - runner-completion.ts: Acceptance loop, hooks, metrics
14
14
  */
15
15
 
16
+ import { createAgentRegistry } from "../agents/registry";
16
17
  import type { NaxConfig } from "../config";
17
18
  import type { LoadedHooksConfig } from "../hooks";
18
19
  import { fireHook } from "../hooks";
@@ -116,6 +117,10 @@ export async function run(options: RunOptions): Promise<RunResult> {
116
117
 
117
118
  const logger = getSafeLogger();
118
119
 
120
+ // Create protocol-aware agent registry (ACP wiring — ACP-003/registry-wiring)
121
+ const registry = createAgentRegistry(config);
122
+ const agentGetFn = registry.getAgent.bind(registry);
123
+
119
124
  // Declare prd before crash handler setup to avoid TDZ if SIGTERM arrives during setup
120
125
  // biome-ignore lint/suspicious/noExplicitAny: PRD type initialized during setup
121
126
  let prd: any | undefined;
@@ -137,6 +142,7 @@ export async function run(options: RunOptions): Promise<RunResult> {
137
142
  skipPrecheck,
138
143
  headless,
139
144
  formatterMode,
145
+ agentGetFn,
140
146
  getTotalCost: () => totalCost,
141
147
  getIterations: () => iterations,
142
148
  // BUG-017: Pass getters for run.complete event on SIGTERM
@@ -170,6 +176,8 @@ export async function run(options: RunOptions): Promise<RunResult> {
170
176
  headless,
171
177
  parallel,
172
178
  runParallelExecution: _runnerDeps.runParallelExecution ?? undefined,
179
+ agentGetFn,
180
+ pidRegistry,
173
181
  },
174
182
  prd,
175
183
  pluginRegistry,
@@ -198,6 +206,7 @@ export async function run(options: RunOptions): Promise<RunResult> {
198
206
  hooks,
199
207
  feature,
200
208
  workdir,
209
+ prdPath,
201
210
  statusFile,
202
211
  logFilePath,
203
212
  runId,
@@ -214,6 +223,7 @@ export async function run(options: RunOptions): Promise<RunResult> {
214
223
  statusWriter,
215
224
  pluginRegistry,
216
225
  eventEmitter,
226
+ agentGetFn,
217
227
  });
218
228
 
219
229
  const { durationMs } = completionResult;
@@ -10,6 +10,15 @@ import type { Server } from "node:http";
10
10
  import { z } from "zod";
11
11
  import type { InteractionPlugin, InteractionRequest, InteractionResponse } from "../types";
12
12
 
13
+ /**
14
+ * Injectable sleep for WebhookInteractionPlugin.receive() polling loop.
15
+ * Replace in tests to avoid real backoff delays.
16
+ * @internal
17
+ */
18
+ export const _webhookPluginDeps = {
19
+ sleep: (ms: number): Promise<void> => Bun.sleep(ms),
20
+ };
21
+
13
22
  /** Webhook plugin configuration */
14
23
  interface WebhookConfig {
15
24
  /** Webhook URL to POST requests to */
@@ -119,7 +128,7 @@ export class WebhookInteractionPlugin implements InteractionPlugin {
119
128
  this.pendingResponses.delete(requestId);
120
129
  return response;
121
130
  }
122
- await Bun.sleep(backoffMs);
131
+ await _webhookPluginDeps.sleep(backoffMs);
123
132
  // Exponential backoff: double interval up to max
124
133
  backoffMs = Math.min(backoffMs * 2, maxBackoffMs);
125
134
  }
@@ -107,7 +107,7 @@ export const executionStage: PipelineStage = {
107
107
  const logger = getLogger();
108
108
 
109
109
  // HARD FAILURE: No agent available — cannot proceed without an agent
110
- const agent = _executionDeps.getAgent(ctx.config.autoMode.defaultAgent);
110
+ const agent = (ctx.agentGetFn ?? _executionDeps.getAgent)(ctx.config.autoMode.defaultAgent);
111
111
  if (!agent) {
112
112
  return {
113
113
  action: "fail",
@@ -133,6 +133,7 @@ export const executionStage: PipelineStage = {
133
133
  config: ctx.config,
134
134
  workdir: ctx.workdir,
135
135
  modelTier: ctx.routing.modelTier,
136
+ featureName: ctx.prd.feature,
136
137
  contextMarkdown: ctx.contextMarkdown,
137
138
  constitution: ctx.constitution?.content,
138
139
  dryRun: false,
@@ -217,6 +218,37 @@ export const executionStage: PipelineStage = {
217
218
  modelDef: resolveModel(ctx.config.models[ctx.routing.modelTier]),
218
219
  timeoutSeconds: ctx.config.execution.sessionTimeoutSeconds,
219
220
  dangerouslySkipPermissions: ctx.config.execution.dangerouslySkipPermissions,
221
+ pidRegistry: ctx.pidRegistry,
222
+ featureName: ctx.prd.feature,
223
+ storyId: ctx.story.id,
224
+ // No sessionRole for single-session strategies (no role suffix in session name)
225
+ interactionBridge: (() => {
226
+ const plugin = ctx.interaction?.getPrimary();
227
+ if (!plugin) return undefined;
228
+ const QUESTION_PATTERNS = [/\?/, /\bwhich\b/i, /\bshould i\b/i, /\bunclear\b/i, /\bplease clarify\b/i];
229
+ return {
230
+ detectQuestion: async (text: string) => QUESTION_PATTERNS.some((p) => p.test(text)),
231
+ onQuestionDetected: async (text: string) => {
232
+ const requestId = `ix-acp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
233
+ await plugin.send({
234
+ id: requestId,
235
+ type: "input",
236
+ featureName: ctx.prd.feature,
237
+ storyId: ctx.story.id,
238
+ stage: "execution",
239
+ summary: text,
240
+ fallback: "continue",
241
+ createdAt: Date.now(),
242
+ });
243
+ try {
244
+ const response = await plugin.receive(requestId, 120_000);
245
+ return response.value ?? "continue";
246
+ } catch {
247
+ return "continue";
248
+ }
249
+ },
250
+ };
251
+ })(),
220
252
  });
221
253
 
222
254
  ctx.agentResult = result;
@@ -44,7 +44,13 @@ import type { PipelineContext, PipelineStage, RoutingResult, StageResult } from
44
44
  * Used as the default implementation in _routingDeps.runDecompose.
45
45
  * In production, replace with an LLM-backed adapter.
46
46
  */
47
- async function runDecompose(story: UserStory, prd: PRD, config: NaxConfig, _workdir: string): Promise<DecomposeResult> {
47
+ async function runDecompose(
48
+ story: UserStory,
49
+ prd: PRD,
50
+ config: NaxConfig,
51
+ _workdir: string,
52
+ agentGetFn?: import("../types").AgentGetFn,
53
+ ): Promise<DecomposeResult> {
48
54
  const naxDecompose = config.decompose;
49
55
  const builderConfig: BuilderDecomposeConfig = {
50
56
  maxSubStories: naxDecompose?.maxSubstories ?? 5,
@@ -52,10 +58,15 @@ async function runDecompose(story: UserStory, prd: PRD, config: NaxConfig, _work
52
58
  maxRetries: naxDecompose?.maxRetries ?? 2,
53
59
  };
54
60
 
55
- // Stub adapter replaced in tests via _routingDeps injection.
61
+ // Resolve the default agent adapter for LLM-backed decompose.
62
+ // Falls back to agent.complete() with JSON mode — works with both CLI and ACP adapters.
63
+ const agent = (agentGetFn ?? getAgent)(config.autoMode.defaultAgent);
64
+ if (!agent) {
65
+ throw new Error(`[decompose] Agent "${config.autoMode.defaultAgent}" not found — cannot decompose`);
66
+ }
56
67
  const adapter = {
57
- async decompose(_prompt: string): Promise<string> {
58
- throw new Error("[decompose] No LLM adapter configured for story decomposition");
68
+ async decompose(prompt: string): Promise<string> {
69
+ return agent.complete(prompt, { jsonMode: true });
59
70
  },
60
71
  };
61
72
 
@@ -71,7 +82,7 @@ export const routingStage: PipelineStage = {
71
82
 
72
83
  // Resolve agent adapter for LLM routing (shared with execution)
73
84
  const agentName = ctx.config.execution?.agent ?? "claude";
74
- const adapter = _routingDeps.getAgent(agentName);
85
+ const adapter = (ctx.agentGetFn ?? _routingDeps.getAgent)(agentName);
75
86
 
76
87
  // Staleness detection (RRP-003):
77
88
  // - story.routing absent → cache miss (no prior routing)
@@ -173,7 +184,7 @@ export const routingStage: PipelineStage = {
173
184
  `Story ${ctx.story.id} is oversized (${acCount} ACs) but decompose is disabled — continuing with original`,
174
185
  );
175
186
  } else if (decomposeConfig.trigger === "auto") {
176
- const result = await _routingDeps.runDecompose(ctx.story, ctx.prd, ctx.config, ctx.workdir);
187
+ const result = await _routingDeps.runDecompose(ctx.story, ctx.prd, ctx.config, ctx.workdir, ctx.agentGetFn);
177
188
  if (result.validation.valid) {
178
189
  _routingDeps.applyDecomposition(ctx.prd, result);
179
190
  if (ctx.prdPath) {
@@ -193,7 +204,7 @@ export const routingStage: PipelineStage = {
193
204
  ctx.interaction!,
194
205
  );
195
206
  if (action === "decompose") {
196
- const result = await _routingDeps.runDecompose(ctx.story, ctx.prd, ctx.config, ctx.workdir);
207
+ const result = await _routingDeps.runDecompose(ctx.story, ctx.prd, ctx.config, ctx.workdir, ctx.agentGetFn);
197
208
  if (result.validation.valid) {
198
209
  _routingDeps.applyDecomposition(ctx.prd, result);
199
210
  if (ctx.prdPath) {
@@ -8,6 +8,7 @@ import type { AgentResult } from "../agents/types";
8
8
  import type { NaxConfig } from "../config/schema";
9
9
  import type { ConstitutionResult } from "../constitution/types";
10
10
  import type { BuiltContext } from "../context/types";
11
+ import type { PidRegistry } from "../execution/pid-registry";
11
12
  import type { HooksConfig } from "../hooks/types";
12
13
  import type { InteractionChain } from "../interaction/chain";
13
14
  import type { StoryMetrics } from "../metrics/types";
@@ -52,6 +53,8 @@ export interface RoutingResult {
52
53
  * };
53
54
  * ```
54
55
  */
56
+ export type AgentGetFn = (name: string) => import("../agents/types").AgentAdapter | undefined;
57
+
55
58
  export interface PipelineContext {
56
59
  /** Ngent configuration */
57
60
  config: NaxConfig;
@@ -73,6 +76,13 @@ export interface PipelineContext {
73
76
  hooks: HooksConfig;
74
77
  /** Plugin registry (optional, for plugin-provided extensions) */
75
78
  plugins?: PluginRegistry;
79
+ /**
80
+ * Protocol-aware agent resolver. When set (ACP mode), returns AcpAgentAdapter;
81
+ * falls back to standalone getAgent (CLI mode) when absent.
82
+ */
83
+ agentGetFn?: AgentGetFn;
84
+ /** PID registry for crash recovery — passed through to agent.run() for child process registration. */
85
+ pidRegistry?: PidRegistry;
76
86
  /** Interaction chain (optional, for human-in-the-loop triggers) */
77
87
  interaction?: InteractionChain;
78
88
  /** Constitution result (set by constitutionStage) */
@@ -0,0 +1,249 @@
1
+ /**
2
+ * PRD JSON Validation and Schema Enforcement
3
+ *
4
+ * Validates and normalizes LLM-generated PRD JSON output before writing to disk.
5
+ */
6
+
7
+ import type { Complexity, TestStrategy } from "../config";
8
+ import type { PRD, UserStory } from "./types";
9
+ import { validateStoryId } from "./validate";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Constants
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const VALID_COMPLEXITY: Complexity[] = ["simple", "medium", "complex", "expert"];
16
+ const VALID_TEST_STRATEGIES: TestStrategy[] = [
17
+ "test-after",
18
+ "tdd-simple",
19
+ "three-session-tdd",
20
+ "three-session-tdd-lite",
21
+ ];
22
+
23
+ /** Pattern matching ST001 → ST-001 style IDs (prefix letters + digits, no separator) */
24
+ const STORY_ID_NO_SEPARATOR = /^([A-Za-z]+)(\d+)$/;
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Public API
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Extract JSON from a markdown code block.
32
+ *
33
+ * Handles:
34
+ * ```json ... ```
35
+ * ``` ... ```
36
+ *
37
+ * Returns the input unchanged if no code block is detected.
38
+ */
39
+ export function extractJsonFromMarkdown(text: string): string {
40
+ const match = text.match(/```(?:json)?\s*\n([\s\S]*?)\n?\s*```/);
41
+ if (match) {
42
+ return match[1] ?? text;
43
+ }
44
+ return text;
45
+ }
46
+
47
+ /**
48
+ * Strip trailing commas before closing braces/brackets to handle a common LLM quirk.
49
+ * e.g. `{"a":1,}` → `{"a":1}`
50
+ */
51
+ function stripTrailingCommas(text: string): string {
52
+ return text.replace(/,\s*([}\]])/g, "$1");
53
+ }
54
+
55
+ /**
56
+ * Normalize a story ID: convert e.g. ST001 → ST-001.
57
+ * Leaves IDs that already have separators unchanged.
58
+ */
59
+ function normalizeStoryId(id: string): string {
60
+ const match = id.match(STORY_ID_NO_SEPARATOR);
61
+ if (match) {
62
+ return `${match[1]}-${match[2]}`;
63
+ }
64
+ return id;
65
+ }
66
+
67
+ /**
68
+ * Normalize complexity string (case-insensitive) to a valid Complexity value.
69
+ * Returns null if no match found.
70
+ */
71
+ function normalizeComplexity(raw: string): Complexity | null {
72
+ const lower = raw.toLowerCase() as Complexity;
73
+ if ((VALID_COMPLEXITY as string[]).includes(lower)) {
74
+ return lower;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ /**
80
+ * Validate a single story from raw LLM output.
81
+ * Returns a normalized UserStory or throws with field-level error.
82
+ */
83
+ function validateStory(raw: unknown, index: number, allIds: Set<string>): UserStory {
84
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
85
+ throw new Error(`[schema] story[${index}] must be an object`);
86
+ }
87
+
88
+ const s = raw as Record<string, unknown>;
89
+
90
+ // id
91
+ const rawId = s.id;
92
+ if (rawId === undefined || rawId === null || rawId === "") {
93
+ throw new Error(`[schema] story[${index}].id is required and must be non-empty`);
94
+ }
95
+ if (typeof rawId !== "string") {
96
+ throw new Error(`[schema] story[${index}].id must be a string`);
97
+ }
98
+ const id = normalizeStoryId(rawId);
99
+ validateStoryId(id);
100
+
101
+ // title
102
+ const title = s.title;
103
+ if (!title || typeof title !== "string" || title.trim() === "") {
104
+ throw new Error(`[schema] story[${index}].title is required and must be non-empty`);
105
+ }
106
+
107
+ // description
108
+ const description = s.description;
109
+ if (!description || typeof description !== "string" || description.trim() === "") {
110
+ throw new Error(`[schema] story[${index}].description is required and must be non-empty`);
111
+ }
112
+
113
+ // acceptanceCriteria
114
+ const ac = s.acceptanceCriteria;
115
+ if (!Array.isArray(ac) || ac.length === 0) {
116
+ throw new Error(`[schema] story[${index}].acceptanceCriteria is required and must be a non-empty array`);
117
+ }
118
+ for (let i = 0; i < ac.length; i++) {
119
+ if (typeof ac[i] !== "string") {
120
+ throw new Error(`[schema] story[${index}].acceptanceCriteria[${i}] must be a string`);
121
+ }
122
+ }
123
+
124
+ // complexity — accept from routing.complexity (PRD format) or top-level complexity (legacy)
125
+ const routing = typeof s.routing === "object" && s.routing !== null ? (s.routing as Record<string, unknown>) : {};
126
+ const rawComplexity = routing.complexity ?? s.complexity;
127
+ if (rawComplexity === undefined || rawComplexity === null) {
128
+ throw new Error(
129
+ `[schema] story[${index}] missing complexity. Set routing.complexity to one of: ${VALID_COMPLEXITY.join(", ")}`,
130
+ );
131
+ }
132
+ if (typeof rawComplexity !== "string") {
133
+ throw new Error(`[schema] story[${index}].routing.complexity must be a string`);
134
+ }
135
+ const complexity = normalizeComplexity(rawComplexity);
136
+ if (complexity === null) {
137
+ throw new Error(
138
+ `[schema] story[${index}].routing.complexity "${rawComplexity}" is invalid. Valid values: ${VALID_COMPLEXITY.join(", ")}`,
139
+ );
140
+ }
141
+
142
+ // testStrategy — accept from routing.testStrategy or top-level testStrategy
143
+ const rawTestStrategy = routing.testStrategy ?? s.testStrategy;
144
+ const testStrategy: TestStrategy =
145
+ rawTestStrategy !== undefined && (VALID_TEST_STRATEGIES as unknown[]).includes(rawTestStrategy)
146
+ ? (rawTestStrategy as TestStrategy)
147
+ : "tdd-simple";
148
+
149
+ // dependencies
150
+ const rawDeps = s.dependencies;
151
+ const dependencies: string[] = Array.isArray(rawDeps) ? (rawDeps as string[]) : [];
152
+
153
+ // Validate dependency references (against already-known IDs)
154
+ for (const dep of dependencies) {
155
+ if (!allIds.has(dep)) {
156
+ throw new Error(`[schema] story[${index}].dependencies references unknown story ID "${dep}"`);
157
+ }
158
+ }
159
+
160
+ // tags
161
+ const rawTags = s.tags;
162
+ const tags: string[] = Array.isArray(rawTags) ? (rawTags as string[]) : [];
163
+
164
+ return {
165
+ id,
166
+ title: title.trim(),
167
+ description: description.trim(),
168
+ acceptanceCriteria: ac as string[],
169
+ tags,
170
+ dependencies,
171
+ // Force runtime state — never trust LLM output
172
+ status: "pending",
173
+ passes: false,
174
+ attempts: 0,
175
+ escalations: [],
176
+ routing: {
177
+ complexity,
178
+ testStrategy,
179
+ reasoning: "validated from LLM output",
180
+ },
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Parse raw string input, handling markdown wrapping and trailing commas.
186
+ * Throws with parse error context on failure.
187
+ */
188
+ function parseRawString(text: string): unknown {
189
+ const extracted = extractJsonFromMarkdown(text);
190
+ const cleaned = stripTrailingCommas(extracted);
191
+
192
+ try {
193
+ return JSON.parse(cleaned);
194
+ } catch (err) {
195
+ const parseErr = err as SyntaxError;
196
+ throw new Error(`[schema] Failed to parse JSON: ${parseErr.message}`, { cause: parseErr });
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Validate and normalize the JSON output from the planning LLM.
202
+ *
203
+ * @param raw - Raw LLM output (string or already-parsed object)
204
+ * @param feature - Feature name for auto-fill
205
+ * @param branch - Branch name for auto-fill
206
+ * @returns Validated PRD object
207
+ */
208
+ export function validatePlanOutput(raw: unknown, feature: string, branch: string): PRD {
209
+ // Parse string input
210
+ const parsed: unknown = typeof raw === "string" ? parseRawString(raw) : raw;
211
+
212
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
213
+ throw new Error("[schema] PRD output must be a JSON object");
214
+ }
215
+
216
+ const obj = parsed as Record<string, unknown>;
217
+
218
+ // Validate top-level userStories
219
+ const rawStories = obj.userStories;
220
+ if (!Array.isArray(rawStories) || rawStories.length === 0) {
221
+ throw new Error("[schema] userStories is required and must be a non-empty array");
222
+ }
223
+
224
+ // First pass: collect all story IDs (after normalization) for dependency validation
225
+ const allIds = new Set<string>();
226
+ for (const story of rawStories) {
227
+ if (typeof story === "object" && story !== null && !Array.isArray(story)) {
228
+ const s = story as Record<string, unknown>;
229
+ const rawId = s.id;
230
+ if (typeof rawId === "string" && rawId !== "") {
231
+ allIds.add(normalizeStoryId(rawId));
232
+ }
233
+ }
234
+ }
235
+
236
+ // Second pass: full validation
237
+ const userStories: UserStory[] = rawStories.map((story, index) => validateStory(story, index, allIds));
238
+
239
+ const now = new Date().toISOString();
240
+
241
+ return {
242
+ project: typeof obj.project === "string" && obj.project !== "" ? obj.project : feature,
243
+ feature,
244
+ branchName: branch,
245
+ createdAt: typeof obj.createdAt === "string" ? obj.createdAt : now,
246
+ updatedAt: now,
247
+ userStories,
248
+ };
249
+ }
@@ -28,6 +28,8 @@ export interface ThreeSessionTddOptions {
28
28
  config: NaxConfig;
29
29
  workdir: string;
30
30
  modelTier: ModelTier;
31
+ /** Feature name — used for ACP session naming (nax-<hash>-<feature>-<story>-<role>) */
32
+ featureName?: string;
31
33
  contextMarkdown?: string;
32
34
  constitution?: string;
33
35
  dryRun?: boolean;
@@ -45,6 +47,7 @@ export async function runThreeSessionTdd(options: ThreeSessionTddOptions): Promi
45
47
  config,
46
48
  workdir,
47
49
  modelTier,
50
+ featureName,
48
51
  contextMarkdown,
49
52
  constitution,
50
53
  dryRun = false,
@@ -135,6 +138,7 @@ export async function runThreeSessionTdd(options: ThreeSessionTddOptions): Promi
135
138
  lite,
136
139
  lite,
137
140
  constitution,
141
+ featureName,
138
142
  );
139
143
  sessions.push(session1);
140
144
  }
@@ -240,6 +244,7 @@ export async function runThreeSessionTdd(options: ThreeSessionTddOptions): Promi
240
244
  lite,
241
245
  lite,
242
246
  constitution,
247
+ featureName,
243
248
  );
244
249
  sessions.push(session2);
245
250
 
@@ -269,6 +274,7 @@ export async function runThreeSessionTdd(options: ThreeSessionTddOptions): Promi
269
274
  contextMarkdown,
270
275
  lite,
271
276
  logger,
277
+ featureName,
272
278
  );
273
279
 
274
280
  // Session 3: Verifier
@@ -286,6 +292,7 @@ export async function runThreeSessionTdd(options: ThreeSessionTddOptions): Promi
286
292
  false,
287
293
  false,
288
294
  constitution,
295
+ featureName,
289
296
  );
290
297
  sessions.push(session3);
291
298
 
@@ -34,6 +34,7 @@ export async function runFullSuiteGate(
34
34
  contextMarkdown: string | undefined,
35
35
  lite: boolean,
36
36
  logger: ReturnType<typeof getLogger>,
37
+ featureName?: string,
37
38
  ): Promise<boolean> {
38
39
  const rectificationEnabled = config.execution.rectification?.enabled ?? false;
39
40
  if (!rectificationEnabled) return false;
@@ -67,6 +68,7 @@ export async function runFullSuiteGate(
67
68
  rectificationConfig,
68
69
  testCmd,
69
70
  fullSuiteTimeout,
71
+ featureName,
70
72
  );
71
73
  }
72
74
 
@@ -118,6 +120,7 @@ async function runRectificationLoop(
118
120
  rectificationConfig: NonNullable<NaxConfig["execution"]["rectification"]>,
119
121
  testCmd: string,
120
122
  fullSuiteTimeout: number,
123
+ featureName?: string,
121
124
  ): Promise<boolean> {
122
125
  const rectificationState: RectificationState = {
123
126
  attempt: 0,
@@ -156,6 +159,9 @@ async function runRectificationLoop(
156
159
  modelDef: resolveModel(config.models[implementerTier]),
157
160
  timeoutSeconds: config.execution.sessionTimeoutSeconds,
158
161
  dangerouslySkipPermissions: config.execution.dangerouslySkipPermissions,
162
+ featureName,
163
+ storyId: story.id,
164
+ sessionRole: "implementer",
159
165
  });
160
166
 
161
167
  if (!rectifyResult.success && rectifyResult.pid) {