@nathapp/nax 0.23.0 → 0.25.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 (49) hide show
  1. package/bin/nax.ts +20 -2
  2. package/docs/ROADMAP.md +33 -15
  3. package/docs/specs/trigger-completion.md +145 -0
  4. package/nax/features/central-run-registry/prd.json +105 -0
  5. package/nax/features/trigger-completion/prd.json +150 -0
  6. package/nax/features/trigger-completion/progress.txt +7 -0
  7. package/nax/status.json +14 -24
  8. package/package.json +2 -2
  9. package/src/commands/index.ts +1 -0
  10. package/src/commands/logs.ts +87 -17
  11. package/src/commands/runs.ts +220 -0
  12. package/src/config/types.ts +3 -1
  13. package/src/execution/crash-recovery.ts +11 -0
  14. package/src/execution/executor-types.ts +1 -1
  15. package/src/execution/lifecycle/run-setup.ts +4 -0
  16. package/src/execution/sequential-executor.ts +49 -7
  17. package/src/interaction/plugins/auto.ts +10 -1
  18. package/src/pipeline/event-bus.ts +14 -1
  19. package/src/pipeline/stages/completion.ts +20 -0
  20. package/src/pipeline/stages/execution.ts +62 -0
  21. package/src/pipeline/stages/review.ts +25 -1
  22. package/src/pipeline/subscribers/events-writer.ts +121 -0
  23. package/src/pipeline/subscribers/hooks.ts +32 -0
  24. package/src/pipeline/subscribers/interaction.ts +36 -1
  25. package/src/pipeline/subscribers/registry.ts +73 -0
  26. package/src/routing/router.ts +3 -2
  27. package/src/routing/strategies/keyword.ts +2 -1
  28. package/src/routing/strategies/llm-prompts.ts +29 -28
  29. package/src/utils/git.ts +21 -0
  30. package/test/integration/cli/cli-logs.test.ts +40 -17
  31. package/test/integration/routing/plugin-routing-core.test.ts +1 -1
  32. package/test/unit/commands/logs.test.ts +63 -22
  33. package/test/unit/commands/runs.test.ts +303 -0
  34. package/test/unit/execution/sequential-executor.test.ts +235 -0
  35. package/test/unit/interaction/auto-plugin.test.ts +162 -0
  36. package/test/unit/interaction-plugins.test.ts +308 -1
  37. package/test/unit/pipeline/stages/completion-review-gate.test.ts +218 -0
  38. package/test/unit/pipeline/stages/execution-ambiguity.test.ts +311 -0
  39. package/test/unit/pipeline/stages/execution-merge-conflict.test.ts +218 -0
  40. package/test/unit/pipeline/stages/review.test.ts +201 -0
  41. package/test/unit/pipeline/subscribers/events-writer.test.ts +227 -0
  42. package/test/unit/pipeline/subscribers/hooks.test.ts +43 -4
  43. package/test/unit/pipeline/subscribers/interaction.test.ts +284 -2
  44. package/test/unit/pipeline/subscribers/registry.test.ts +149 -0
  45. package/test/unit/prd-auto-default.test.ts +2 -2
  46. package/test/unit/routing/routing-stability.test.ts +1 -1
  47. package/test/unit/routing-core.test.ts +5 -5
  48. package/test/unit/routing-strategies.test.ts +1 -3
  49. package/test/unit/utils/git.test.ts +50 -0
@@ -32,11 +32,33 @@
32
32
 
33
33
  import { getAgent, validateAgentForTier } from "../../agents";
34
34
  import { resolveModel } from "../../config";
35
+ import { checkMergeConflict, checkStoryAmbiguity, isTriggerEnabled } from "../../interaction/triggers";
35
36
  import { getLogger } from "../../logger";
36
37
  import type { FailureCategory } from "../../tdd";
37
38
  import { runThreeSessionTdd } from "../../tdd";
39
+ import { detectMergeConflict } from "../../utils/git";
38
40
  import type { PipelineContext, PipelineStage, StageResult } from "../types";
39
41
 
42
+ /**
43
+ * Detect if agent output contains ambiguity signals
44
+ * Checks for keywords that indicate the agent is unsure about the implementation
45
+ */
46
+ export function isAmbiguousOutput(output: string): boolean {
47
+ if (!output) return false;
48
+
49
+ const ambiguityKeywords = [
50
+ "unclear",
51
+ "ambiguous",
52
+ "need clarification",
53
+ "please clarify",
54
+ "which one",
55
+ "not sure which",
56
+ ];
57
+
58
+ const lowerOutput = output.toLowerCase();
59
+ return ambiguityKeywords.some((keyword) => lowerOutput.includes(keyword));
60
+ }
61
+
40
62
  /**
41
63
  * Determine the pipeline action for a failed TDD result, based on its failureCategory.
42
64
  *
@@ -172,6 +194,42 @@ export const executionStage: PipelineStage = {
172
194
 
173
195
  ctx.agentResult = result;
174
196
 
197
+ // merge-conflict trigger: detect CONFLICT markers in agent output
198
+ const combinedOutput = (result.output ?? "") + (result.stderr ?? "");
199
+ if (
200
+ _executionDeps.detectMergeConflict(combinedOutput) &&
201
+ ctx.interaction &&
202
+ isTriggerEnabled("merge-conflict", ctx.config)
203
+ ) {
204
+ const shouldProceed = await _executionDeps.checkMergeConflict(
205
+ { featureName: ctx.prd.feature, storyId: ctx.story.id },
206
+ ctx.config,
207
+ ctx.interaction,
208
+ );
209
+ if (!shouldProceed) {
210
+ logger.error("execution", "Merge conflict detected — aborting story", { storyId: ctx.story.id });
211
+ return { action: "fail", reason: "Merge conflict detected" };
212
+ }
213
+ }
214
+
215
+ // story-ambiguity trigger: detect ambiguity signals in agent output
216
+ if (
217
+ result.success &&
218
+ _executionDeps.isAmbiguousOutput(combinedOutput) &&
219
+ ctx.interaction &&
220
+ isTriggerEnabled("story-ambiguity", ctx.config)
221
+ ) {
222
+ const shouldContinue = await _executionDeps.checkStoryAmbiguity(
223
+ { featureName: ctx.prd.feature, storyId: ctx.story.id, reason: "Agent output suggests ambiguity" },
224
+ ctx.config,
225
+ ctx.interaction,
226
+ );
227
+ if (!shouldContinue) {
228
+ logger.warn("execution", "Story ambiguity detected — escalating story", { storyId: ctx.story.id });
229
+ return { action: "escalate", reason: "Story ambiguity detected — needs clarification" };
230
+ }
231
+ }
232
+
175
233
  if (!result.success) {
176
234
  logger.error("execution", "Agent session failed", {
177
235
  exitCode: result.exitCode,
@@ -199,4 +257,8 @@ export const executionStage: PipelineStage = {
199
257
  export const _executionDeps = {
200
258
  getAgent,
201
259
  validateAgentForTier,
260
+ detectMergeConflict,
261
+ checkMergeConflict,
262
+ isAmbiguousOutput,
263
+ checkStoryAmbiguity,
202
264
  };
@@ -6,10 +6,12 @@
6
6
  * @returns
7
7
  * - `continue`: Review passed
8
8
  * - `escalate`: Built-in check failed (lint/typecheck) — autofix stage handles retry
9
- * - `fail`: Plugin reviewer hard-failed
9
+ * - `escalate`: Plugin reviewer failed and security-review trigger responded non-abort
10
+ * - `fail`: Plugin reviewer hard-failed (no trigger, or trigger responded abort)
10
11
  */
11
12
 
12
13
  // RE-ARCH: rewrite
14
+ import { checkSecurityReview, isTriggerEnabled } from "../../interaction/triggers";
13
15
  import { getLogger } from "../../logger";
14
16
  import { reviewOrchestrator } from "../../review/orchestrator";
15
17
  import type { PipelineContext, PipelineStage, StageResult } from "../types";
@@ -29,6 +31,21 @@ export const reviewStage: PipelineStage = {
29
31
 
30
32
  if (!result.success) {
31
33
  if (result.pluginFailed) {
34
+ // security-review trigger: prompt before permanently failing
35
+ if (ctx.interaction && isTriggerEnabled("security-review", ctx.config)) {
36
+ const shouldContinue = await _reviewDeps.checkSecurityReview(
37
+ { featureName: ctx.prd.feature, storyId: ctx.story.id },
38
+ ctx.config,
39
+ ctx.interaction,
40
+ );
41
+ if (!shouldContinue) {
42
+ logger.error("review", `Plugin reviewer failed: ${result.failureReason}`, { storyId: ctx.story.id });
43
+ return { action: "fail", reason: `Review failed: ${result.failureReason}` };
44
+ }
45
+ logger.warn("review", "Security-review trigger escalated — retrying story", { storyId: ctx.story.id });
46
+ return { action: "escalate", reason: `Review failed: ${result.failureReason}` };
47
+ }
48
+
32
49
  logger.error("review", `Plugin reviewer failed: ${result.failureReason}`, { storyId: ctx.story.id });
33
50
  return { action: "fail", reason: `Review failed: ${result.failureReason}` };
34
51
  }
@@ -47,3 +64,10 @@ export const reviewStage: PipelineStage = {
47
64
  return { action: "continue" };
48
65
  },
49
66
  };
67
+
68
+ /**
69
+ * Swappable dependencies for testing (avoids mock.module() which leaks in Bun 1.x).
70
+ */
71
+ export const _reviewDeps = {
72
+ checkSecurityReview,
73
+ };
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Events Writer Subscriber
3
+ *
4
+ * Appends one JSON line per pipeline lifecycle event to
5
+ * ~/.nax/events/<project>/events.jsonl. Provides a machine-readable
6
+ * signal that nax exited gracefully (run:completed → event=on-complete),
7
+ * fixing watchdog false crash reports.
8
+ *
9
+ * Design:
10
+ * - Best-effort: all writes are wrapped in try/catch; never throws or blocks
11
+ * - Directory is created on first write via mkdir recursive
12
+ * - Returns UnsubscribeFn matching wireHooks/wireReporters pattern
13
+ */
14
+
15
+ import { appendFile, mkdir } from "node:fs/promises";
16
+ import { homedir } from "node:os";
17
+ import { basename, join } from "node:path";
18
+ import { getSafeLogger } from "../../logger";
19
+ import type { PipelineEventBus } from "../event-bus";
20
+ import type { UnsubscribeFn } from "./hooks";
21
+
22
+ interface EventLine {
23
+ ts: string;
24
+ event: string;
25
+ runId: string;
26
+ feature: string;
27
+ project: string;
28
+ storyId?: string;
29
+ data?: object;
30
+ }
31
+
32
+ /**
33
+ * Wire events file writer to the pipeline event bus.
34
+ *
35
+ * Listens to run:started, story:started, story:completed, story:failed,
36
+ * run:completed, run:paused and appends one JSONL entry per event.
37
+ *
38
+ * @param bus - The pipeline event bus
39
+ * @param feature - Feature name
40
+ * @param runId - Current run ID
41
+ * @param workdir - Working directory (project name derived via basename)
42
+ * @returns Unsubscribe function
43
+ */
44
+ export function wireEventsWriter(
45
+ bus: PipelineEventBus,
46
+ feature: string,
47
+ runId: string,
48
+ workdir: string,
49
+ ): UnsubscribeFn {
50
+ const logger = getSafeLogger();
51
+ const project = basename(workdir);
52
+ const eventsDir = join(homedir(), ".nax", "events", project);
53
+ const eventsFile = join(eventsDir, "events.jsonl");
54
+ let dirReady = false;
55
+
56
+ const write = (line: EventLine): void => {
57
+ (async () => {
58
+ try {
59
+ if (!dirReady) {
60
+ await mkdir(eventsDir, { recursive: true });
61
+ dirReady = true;
62
+ }
63
+ await appendFile(eventsFile, `${JSON.stringify(line)}\n`);
64
+ } catch (err) {
65
+ logger?.warn("events-writer", "Failed to write event line (non-fatal)", {
66
+ event: line.event,
67
+ error: String(err),
68
+ });
69
+ }
70
+ })();
71
+ };
72
+
73
+ const unsubs: UnsubscribeFn[] = [];
74
+
75
+ unsubs.push(
76
+ bus.on("run:started", (_ev) => {
77
+ write({ ts: new Date().toISOString(), event: "run:started", runId, feature, project });
78
+ }),
79
+ );
80
+
81
+ unsubs.push(
82
+ bus.on("story:started", (ev) => {
83
+ write({ ts: new Date().toISOString(), event: "story:started", runId, feature, project, storyId: ev.storyId });
84
+ }),
85
+ );
86
+
87
+ unsubs.push(
88
+ bus.on("story:completed", (ev) => {
89
+ write({ ts: new Date().toISOString(), event: "story:completed", runId, feature, project, storyId: ev.storyId });
90
+ }),
91
+ );
92
+
93
+ unsubs.push(
94
+ bus.on("story:failed", (ev) => {
95
+ write({ ts: new Date().toISOString(), event: "story:failed", runId, feature, project, storyId: ev.storyId });
96
+ }),
97
+ );
98
+
99
+ unsubs.push(
100
+ bus.on("run:completed", (_ev) => {
101
+ write({ ts: new Date().toISOString(), event: "on-complete", runId, feature, project });
102
+ }),
103
+ );
104
+
105
+ unsubs.push(
106
+ bus.on("run:paused", (ev) => {
107
+ write({
108
+ ts: new Date().toISOString(),
109
+ event: "run:paused",
110
+ runId,
111
+ feature,
112
+ project,
113
+ ...(ev.storyId !== undefined && { storyId: ev.storyId }),
114
+ });
115
+ }),
116
+ );
117
+
118
+ return () => {
119
+ for (const u of unsubs) u();
120
+ };
121
+ }
@@ -127,6 +127,38 @@ export function wireHooks(
127
127
  }),
128
128
  );
129
129
 
130
+ // run:resumed → on-resume
131
+ unsubs.push(
132
+ bus.on("run:resumed", (ev) => {
133
+ safe("on-resume", () => fireHook(hooks, "on-resume", hookCtx(feature, { status: "running" }), workdir));
134
+ }),
135
+ );
136
+
137
+ // story:completed → on-session-end (passed)
138
+ unsubs.push(
139
+ bus.on("story:completed", (ev) => {
140
+ safe("on-session-end (completed)", () =>
141
+ fireHook(hooks, "on-session-end", hookCtx(feature, { storyId: ev.storyId, status: "passed" }), workdir),
142
+ );
143
+ }),
144
+ );
145
+
146
+ // story:failed → on-session-end (failed)
147
+ unsubs.push(
148
+ bus.on("story:failed", (ev) => {
149
+ safe("on-session-end (failed)", () =>
150
+ fireHook(hooks, "on-session-end", hookCtx(feature, { storyId: ev.storyId, status: "failed" }), workdir),
151
+ );
152
+ }),
153
+ );
154
+
155
+ // run:errored → on-error
156
+ unsubs.push(
157
+ bus.on("run:errored", (ev) => {
158
+ safe("on-error", () => fireHook(hooks, "on-error", hookCtx(feature, { reason: ev.reason }), workdir));
159
+ }),
160
+ );
161
+
130
162
  return () => {
131
163
  for (const u of unsubs) u();
132
164
  };
@@ -19,7 +19,7 @@ import type { NaxConfig } from "../../config";
19
19
  import type { InteractionChain } from "../../interaction/chain";
20
20
  import { executeTrigger, isTriggerEnabled } from "../../interaction/triggers";
21
21
  import { getSafeLogger } from "../../logger";
22
- import type { PipelineEventBus } from "../event-bus";
22
+ import type { PipelineEventBus, StoryFailedEvent } from "../event-bus";
23
23
  import type { UnsubscribeFn } from "./hooks";
24
24
 
25
25
  /**
@@ -62,6 +62,41 @@ export function wireInteraction(
62
62
  );
63
63
  }
64
64
 
65
+ // story:failed (countsTowardEscalation=true) → executeTrigger("max-retries")
66
+ if (interactionChain && isTriggerEnabled("max-retries", config)) {
67
+ unsubs.push(
68
+ bus.on("story:failed", (ev: StoryFailedEvent) => {
69
+ if (!ev.countsTowardEscalation) {
70
+ return;
71
+ }
72
+
73
+ executeTrigger(
74
+ "max-retries",
75
+ {
76
+ featureName: ev.feature ?? "",
77
+ storyId: ev.storyId,
78
+ iteration: ev.attempts ?? 0,
79
+ },
80
+ config,
81
+ interactionChain,
82
+ )
83
+ .then((response) => {
84
+ if (response.action === "abort") {
85
+ logger?.warn("interaction-subscriber", "max-retries abort requested", {
86
+ storyId: ev.storyId,
87
+ });
88
+ }
89
+ })
90
+ .catch((err) => {
91
+ logger?.warn("interaction-subscriber", "max-retries trigger failed", {
92
+ storyId: ev.storyId,
93
+ error: String(err),
94
+ });
95
+ });
96
+ }),
97
+ );
98
+ }
99
+
65
100
  return () => {
66
101
  for (const u of unsubs) u();
67
102
  };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Registry Writer Subscriber
3
+ *
4
+ * Creates ~/.nax/runs/<project>-<feature>-<runId>/meta.json on run:started.
5
+ * Provides a persistent record of each run with paths for status and events.
6
+ *
7
+ * Design:
8
+ * - Best-effort: all writes wrapped in try/catch; never throws or blocks
9
+ * - Directory created on first write via mkdir recursive
10
+ * - Written once on run:started, never updated
11
+ * - Returns UnsubscribeFn matching wireHooks/wireEventsWriter pattern
12
+ */
13
+
14
+ import { mkdir, writeFile } from "node:fs/promises";
15
+ import { homedir } from "node:os";
16
+ import { basename, join } from "node:path";
17
+ import { getSafeLogger } from "../../logger";
18
+ import type { PipelineEventBus } from "../event-bus";
19
+ import type { UnsubscribeFn } from "./hooks";
20
+
21
+ export interface MetaJson {
22
+ runId: string;
23
+ project: string;
24
+ feature: string;
25
+ workdir: string;
26
+ statusPath: string;
27
+ eventsDir: string;
28
+ registeredAt: string;
29
+ }
30
+
31
+ /**
32
+ * Wire registry writer to the pipeline event bus.
33
+ *
34
+ * Listens to run:started and writes meta.json to
35
+ * ~/.nax/runs/<project>-<feature>-<runId>/meta.json.
36
+ *
37
+ * @param bus - The pipeline event bus
38
+ * @param feature - Feature name
39
+ * @param runId - Current run ID
40
+ * @param workdir - Working directory (project name derived via basename)
41
+ * @returns Unsubscribe function
42
+ */
43
+ export function wireRegistry(bus: PipelineEventBus, feature: string, runId: string, workdir: string): UnsubscribeFn {
44
+ const logger = getSafeLogger();
45
+ const project = basename(workdir);
46
+ const runDir = join(homedir(), ".nax", "runs", `${project}-${feature}-${runId}`);
47
+ const metaFile = join(runDir, "meta.json");
48
+
49
+ const unsub = bus.on("run:started", (_ev) => {
50
+ (async () => {
51
+ try {
52
+ await mkdir(runDir, { recursive: true });
53
+ const meta: MetaJson = {
54
+ runId,
55
+ project,
56
+ feature,
57
+ workdir,
58
+ statusPath: join(workdir, "nax", "features", feature, "status.json"),
59
+ eventsDir: join(workdir, "nax", "features", feature, "runs"),
60
+ registeredAt: new Date().toISOString(),
61
+ };
62
+ await writeFile(metaFile, JSON.stringify(meta, null, 2));
63
+ } catch (err) {
64
+ logger?.warn("registry-writer", "Failed to write meta.json (non-fatal)", {
65
+ path: metaFile,
66
+ error: String(err),
67
+ });
68
+ }
69
+ })();
70
+ });
71
+
72
+ return unsub;
73
+ }
@@ -152,7 +152,7 @@ const LITE_TAGS = ["ui", "layout", "cli", "integration", "polyglot"];
152
152
  * - 'auto' → existing heuristic logic, plus:
153
153
  * if tags include ui/layout/cli/integration/polyglot → three-session-tdd-lite
154
154
  * if security/public-api/complex/expert → three-session-tdd
155
- * otherwise → three-session-tdd-lite (test-after deprecated from auto mode)
155
+ * simpletest-after, medium → three-session-tdd-lite (BUG-045)
156
156
  *
157
157
  * @param complexity - Pre-classified complexity level
158
158
  * @param title - Story title
@@ -201,7 +201,8 @@ export function determineTestStrategy(
201
201
  return hasLiteTag ? "three-session-tdd-lite" : "three-session-tdd";
202
202
  }
203
203
 
204
- // Simple/mediumthree-session-tdd-lite (FEAT-013: test-after deprecated from auto mode)
204
+ // BUG-045: simple test-after (low overhead), medium tdd-lite (sweet spot)
205
+ if (complexity === "simple") return "test-after";
205
206
  return "three-session-tdd-lite";
206
207
  }
207
208
 
@@ -117,7 +117,8 @@ function determineTestStrategy(
117
117
  return "three-session-tdd";
118
118
  }
119
119
 
120
- // FEAT-013: test-after deprecated from auto mode — use three-session-tdd-lite as default
120
+ // BUG-045: simple → test-after (low overhead), medium tdd-lite (sweet spot)
121
+ if (complexity === "simple") return "test-after";
121
122
  return "three-session-tdd-lite";
122
123
  }
123
124
 
@@ -5,8 +5,9 @@
5
5
  * for LLM-based routing decisions.
6
6
  */
7
7
 
8
- import type { Complexity, ModelTier, NaxConfig, TestStrategy } from "../../config";
8
+ import type { Complexity, ModelTier, NaxConfig, TddStrategy, TestStrategy } from "../../config";
9
9
  import type { UserStory } from "../../prd/types";
10
+ import { determineTestStrategy } from "../router";
10
11
  import type { RoutingDecision } from "../strategy";
11
12
 
12
13
  /**
@@ -34,18 +35,13 @@ Tags: ${tags.join(", ")}
34
35
  - balanced: Standard features, moderate logic, straightforward tests. 30-90 min.
35
36
  - powerful: Complex architecture, security-critical, multi-file refactors, novel algorithms. >90 min.
36
37
 
37
- ## Available Test Strategies
38
- - test-after: Write implementation first, add tests after. For straightforward work.
39
- - three-session-tdd: Separate test-writer → implementer → verifier sessions. For complex/critical work where test design matters.
40
-
41
38
  ## Rules
42
- - Default to the CHEAPEST option that will succeed.
43
- - three-session-tdd ONLY when: (a) security/auth logic, (b) complex algorithms, (c) public API contracts that consumers depend on.
44
- - Simple barrel exports, re-exports, or index files are ALWAYS test-after + fast, regardless of keywords.
39
+ - Default to the CHEAPEST tier that will succeed.
40
+ - Simple barrel exports, re-exports, or index files are ALWAYS simple + fast.
45
41
  - A story touching many files doesn't automatically mean complex — copy-paste refactors are simple.
46
42
 
47
43
  Respond with ONLY this JSON (no markdown, no explanation):
48
- {"complexity":"simple|medium|complex|expert","modelTier":"fast|balanced|powerful","testStrategy":"test-after|three-session-tdd","reasoning":"<one line>"}`;
44
+ {"complexity":"simple|medium|complex|expert","modelTier":"fast|balanced|powerful","reasoning":"<one line>"}`;
49
45
  }
50
46
 
51
47
  /**
@@ -77,18 +73,13 @@ ${storyBlocks}
77
73
  - balanced: Standard features, moderate logic, straightforward tests. 30-90 min.
78
74
  - powerful: Complex architecture, security-critical, multi-file refactors, novel algorithms. >90 min.
79
75
 
80
- ## Available Test Strategies
81
- - test-after: Write implementation first, add tests after. For straightforward work.
82
- - three-session-tdd: Separate test-writer → implementer → verifier sessions. For complex/critical work where test design matters.
83
-
84
76
  ## Rules
85
- - Default to the CHEAPEST option that will succeed.
86
- - three-session-tdd ONLY when: (a) security/auth logic, (b) complex algorithms, (c) public API contracts that consumers depend on.
87
- - Simple barrel exports, re-exports, or index files are ALWAYS test-after + fast, regardless of keywords.
77
+ - Default to the CHEAPEST tier that will succeed.
78
+ - Simple barrel exports, re-exports, or index files are ALWAYS simple + fast.
88
79
  - A story touching many files doesn't automatically mean complex — copy-paste refactors are simple.
89
80
 
90
81
  Respond with ONLY a JSON array (no markdown, no explanation):
91
- [{"id":"US-001","complexity":"simple|medium|complex|expert","modelTier":"fast|balanced|powerful","testStrategy":"test-after|three-session-tdd","reasoning":"<one line>"}]`;
82
+ [{"id":"US-001","complexity":"simple|medium|complex|expert","modelTier":"fast|balanced|powerful","reasoning":"<one line>"}]`;
92
83
  }
93
84
 
94
85
  /**
@@ -99,33 +90,43 @@ Respond with ONLY a JSON array (no markdown, no explanation):
99
90
  * @returns Validated routing decision
100
91
  * @throws Error if validation fails
101
92
  */
102
- export function validateRoutingDecision(parsed: Record<string, unknown>, config: NaxConfig): RoutingDecision {
103
- // Validate required fields
104
- if (!parsed.complexity || !parsed.modelTier || !parsed.testStrategy || !parsed.reasoning) {
93
+ export function validateRoutingDecision(
94
+ parsed: Record<string, unknown>,
95
+ config: NaxConfig,
96
+ story?: UserStory,
97
+ ): RoutingDecision {
98
+ // Validate required fields (testStrategy no longer required from LLM — derived via BUG-045)
99
+ if (!parsed.complexity || !parsed.modelTier || !parsed.reasoning) {
105
100
  throw new Error(`Missing required fields in LLM response: ${JSON.stringify(parsed)}`);
106
101
  }
107
102
 
108
103
  // Validate field values
109
104
  const validComplexities: Complexity[] = ["simple", "medium", "complex", "expert"];
110
- const validTestStrategies: TestStrategy[] = ["test-after", "three-session-tdd"];
111
105
 
112
106
  if (!validComplexities.includes(parsed.complexity as Complexity)) {
113
107
  throw new Error(`Invalid complexity: ${parsed.complexity}`);
114
108
  }
115
109
 
116
- if (!validTestStrategies.includes(parsed.testStrategy as TestStrategy)) {
117
- throw new Error(`Invalid testStrategy: ${parsed.testStrategy}`);
118
- }
119
-
120
110
  // Validate modelTier exists in config
121
111
  if (!config.models[parsed.modelTier as string]) {
122
112
  throw new Error(`Invalid modelTier: ${parsed.modelTier} (not in config.models)`);
123
113
  }
124
114
 
115
+ // BUG-045: Derive testStrategy from determineTestStrategy() — single source of truth.
116
+ // LLM decides complexity; testStrategy is a policy decision, not a judgment call.
117
+ const tddStrategy: TddStrategy = config.tdd?.strategy ?? "auto";
118
+ const testStrategy = determineTestStrategy(
119
+ parsed.complexity as Complexity,
120
+ story?.title ?? "",
121
+ story?.description ?? "",
122
+ story?.tags ?? [],
123
+ tddStrategy,
124
+ );
125
+
125
126
  return {
126
127
  complexity: parsed.complexity as Complexity,
127
128
  modelTier: parsed.modelTier as ModelTier,
128
- testStrategy: parsed.testStrategy as TestStrategy,
129
+ testStrategy,
129
130
  reasoning: parsed.reasoning as string,
130
131
  };
131
132
  }
@@ -155,7 +156,7 @@ export function stripCodeFences(text: string): string {
155
156
  export function parseRoutingResponse(output: string, story: UserStory, config: NaxConfig): RoutingDecision {
156
157
  const jsonText = stripCodeFences(output);
157
158
  const parsed = JSON.parse(jsonText);
158
- return validateRoutingDecision(parsed, config);
159
+ return validateRoutingDecision(parsed, config, story);
159
160
  }
160
161
 
161
162
  /**
@@ -201,7 +202,7 @@ export function parseBatchResponse(
201
202
  }
202
203
 
203
204
  // Validate entry directly (no re-serialization needed)
204
- const decision = validateRoutingDecision(entry, config);
205
+ const decision = validateRoutingDecision(entry, config, story);
205
206
  decisions.set(entry.id, decision);
206
207
  }
207
208
 
package/src/utils/git.ts CHANGED
@@ -105,3 +105,24 @@ export async function hasCommitsForStory(workdir: string, storyId: string, maxCo
105
105
  return false;
106
106
  }
107
107
  }
108
+
109
+ /**
110
+ * Detect if git operation output contains merge conflict markers.
111
+ *
112
+ * Git outputs "CONFLICT" in uppercase for merge/rebase conflicts.
113
+ * Also checks lowercase "conflict" for edge cases.
114
+ *
115
+ * @param output - Combined stdout/stderr output from a git operation
116
+ * @returns true if output contains CONFLICT markers
117
+ *
118
+ * @example
119
+ * ```typescript
120
+ * const hasConflict = detectMergeConflict(agentOutput);
121
+ * if (hasConflict) {
122
+ * // fire merge-conflict trigger
123
+ * }
124
+ * ```
125
+ */
126
+ export function detectMergeConflict(output: string): boolean {
127
+ return output.includes("CONFLICT") || output.includes("conflict");
128
+ }