@nathapp/nax 0.24.0 → 0.26.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 (55) hide show
  1. package/CLAUDE.md +70 -56
  2. package/docs/ROADMAP.md +45 -15
  3. package/docs/specs/trigger-completion.md +145 -0
  4. package/nax/features/routing-persistence/prd.json +104 -0
  5. package/nax/features/routing-persistence/progress.txt +1 -0
  6. package/nax/features/trigger-completion/prd.json +150 -0
  7. package/nax/features/trigger-completion/progress.txt +7 -0
  8. package/nax/status.json +15 -16
  9. package/package.json +1 -1
  10. package/src/config/types.ts +3 -1
  11. package/src/execution/crash-recovery.ts +11 -0
  12. package/src/execution/executor-types.ts +1 -1
  13. package/src/execution/iteration-runner.ts +1 -0
  14. package/src/execution/lifecycle/run-setup.ts +4 -0
  15. package/src/execution/sequential-executor.ts +45 -7
  16. package/src/interaction/plugins/auto.ts +10 -1
  17. package/src/metrics/aggregator.ts +2 -1
  18. package/src/metrics/tracker.ts +26 -14
  19. package/src/metrics/types.ts +2 -0
  20. package/src/pipeline/event-bus.ts +14 -1
  21. package/src/pipeline/stages/completion.ts +20 -0
  22. package/src/pipeline/stages/execution.ts +62 -0
  23. package/src/pipeline/stages/review.ts +25 -1
  24. package/src/pipeline/stages/routing.ts +42 -8
  25. package/src/pipeline/subscribers/hooks.ts +32 -0
  26. package/src/pipeline/subscribers/interaction.ts +36 -1
  27. package/src/pipeline/types.ts +2 -0
  28. package/src/prd/types.ts +4 -0
  29. package/src/routing/content-hash.ts +25 -0
  30. package/src/routing/index.ts +3 -0
  31. package/src/routing/router.ts +3 -2
  32. package/src/routing/strategies/keyword.ts +2 -1
  33. package/src/routing/strategies/llm-prompts.ts +29 -28
  34. package/src/utils/git.ts +21 -0
  35. package/test/integration/routing/plugin-routing-core.test.ts +1 -1
  36. package/test/unit/execution/sequential-executor.test.ts +235 -0
  37. package/test/unit/interaction/auto-plugin.test.ts +162 -0
  38. package/test/unit/interaction-plugins.test.ts +308 -1
  39. package/test/unit/metrics/aggregator.test.ts +164 -0
  40. package/test/unit/metrics/tracker.test.ts +186 -0
  41. package/test/unit/pipeline/stages/completion-review-gate.test.ts +218 -0
  42. package/test/unit/pipeline/stages/execution-ambiguity.test.ts +311 -0
  43. package/test/unit/pipeline/stages/execution-merge-conflict.test.ts +218 -0
  44. package/test/unit/pipeline/stages/review.test.ts +201 -0
  45. package/test/unit/pipeline/stages/routing-idempotence.test.ts +139 -0
  46. package/test/unit/pipeline/stages/routing-initial-complexity.test.ts +321 -0
  47. package/test/unit/pipeline/stages/routing-persistence.test.ts +380 -0
  48. package/test/unit/pipeline/subscribers/hooks.test.ts +43 -4
  49. package/test/unit/pipeline/subscribers/interaction.test.ts +284 -2
  50. package/test/unit/prd-auto-default.test.ts +2 -2
  51. package/test/unit/routing/content-hash.test.ts +99 -0
  52. package/test/unit/routing/routing-stability.test.ts +1 -1
  53. package/test/unit/routing-core.test.ts +5 -5
  54. package/test/unit/routing-strategies.test.ts +1 -3
  55. package/test/unit/utils/git.test.ts +50 -0
@@ -11,11 +11,14 @@ describe("wireHooks", () => {
11
11
  const bus = new PipelineEventBus();
12
12
  wireHooks(bus, EMPTY_HOOKS, "/tmp", "test-feature");
13
13
 
14
- // Check subscriptions are registered
15
- const events = ["run:started", "story:started", "story:completed", "story:failed", "story:paused", "run:paused", "run:completed"] as const;
16
- for (const ev of events) {
14
+ // Single-subscriber events
15
+ const singleSubEvents = ["run:started", "story:started", "story:paused", "run:paused", "run:completed", "run:resumed", "run:errored"] as const;
16
+ for (const ev of singleSubEvents) {
17
17
  expect(bus.subscriberCount(ev)).toBe(1);
18
18
  }
19
+ // story:completed and story:failed each have 2: on-story-complete/fail + on-session-end
20
+ expect(bus.subscriberCount("story:completed")).toBe(2);
21
+ expect(bus.subscriberCount("story:failed")).toBe(2);
19
22
  });
20
23
 
21
24
  test("returns unsubscribe function that removes all subscriptions", () => {
@@ -24,7 +27,7 @@ describe("wireHooks", () => {
24
27
 
25
28
  unsub();
26
29
 
27
- const events = ["run:started", "story:started", "story:completed"] as const;
30
+ const events = ["run:started", "story:started", "story:completed", "run:resumed", "run:errored"] as const;
28
31
  for (const ev of events) {
29
32
  expect(bus.subscriberCount(ev)).toBe(0);
30
33
  }
@@ -42,4 +45,40 @@ describe("wireHooks", () => {
42
45
  bus.emit({ type: "story:completed", storyId: "US-001", story: { id: "US-001" } as any, passed: true, durationMs: 100 }),
43
46
  ).not.toThrow();
44
47
  });
48
+
49
+ test("on-resume: run:resumed event triggers on-resume hook (fire-and-forget, no throw)", () => {
50
+ const bus = new PipelineEventBus();
51
+ wireHooks(bus, EMPTY_HOOKS, "/tmp", "test-feature");
52
+
53
+ expect(() =>
54
+ bus.emit({ type: "run:resumed", feature: "test-feature" }),
55
+ ).not.toThrow();
56
+ });
57
+
58
+ test("on-session-end: story:completed triggers on-session-end with status passed (fire-and-forget, no throw)", () => {
59
+ const bus = new PipelineEventBus();
60
+ wireHooks(bus, EMPTY_HOOKS, "/tmp", "test-feature");
61
+
62
+ expect(() =>
63
+ bus.emit({ type: "story:completed", storyId: "US-001", story: { id: "US-001" } as any, passed: true, durationMs: 100 }),
64
+ ).not.toThrow();
65
+ });
66
+
67
+ test("on-session-end: story:failed triggers on-session-end with status failed (fire-and-forget, no throw)", () => {
68
+ const bus = new PipelineEventBus();
69
+ wireHooks(bus, EMPTY_HOOKS, "/tmp", "test-feature");
70
+
71
+ expect(() =>
72
+ bus.emit({ type: "story:failed", storyId: "US-001", story: { id: "US-001" } as any, reason: "test failure", countsTowardEscalation: true }),
73
+ ).not.toThrow();
74
+ });
75
+
76
+ test("on-error: run:errored event triggers on-error hook (fire-and-forget, no throw)", () => {
77
+ const bus = new PipelineEventBus();
78
+ wireHooks(bus, EMPTY_HOOKS, "/tmp", "test-feature");
79
+
80
+ expect(() =>
81
+ bus.emit({ type: "run:errored", reason: "SIGTERM", feature: "test-feature" }),
82
+ ).not.toThrow();
83
+ });
45
84
  });
@@ -1,8 +1,9 @@
1
1
  // RE-ARCH: keep
2
- import { describe, expect, test } from "bun:test";
2
+ import { describe, expect, test, beforeEach, afterEach } from "bun:test";
3
3
  import { wireInteraction } from "../../../../src/pipeline/subscribers/interaction";
4
- import { PipelineEventBus } from "../../../../src/pipeline/event-bus";
4
+ import { PipelineEventBus, type StoryFailedEvent } from "../../../../src/pipeline/event-bus";
5
5
  import { DEFAULT_CONFIG } from "../../../../src/config";
6
+ import type { UserStory } from "../../../../src/prd";
6
7
 
7
8
  describe("wireInteraction", () => {
8
9
  test("no subscriptions when interactionChain is null", () => {
@@ -29,3 +30,284 @@ describe("wireInteraction", () => {
29
30
  unsub(); // should not throw
30
31
  });
31
32
  });
33
+
34
+ describe("wireInteraction - max-retries trigger", () => {
35
+ let bus: PipelineEventBus;
36
+ let mockChain: any;
37
+ let mockLogger: any;
38
+ let loggedWarnings: Array<{ context: string; message: string; data: any }> = [];
39
+
40
+ beforeEach(() => {
41
+ bus = new PipelineEventBus();
42
+ mockChain = {
43
+ prompt: async () => ({ action: "skip" }),
44
+ };
45
+ loggedWarnings = [];
46
+ });
47
+
48
+ afterEach(() => {
49
+ bus.clear();
50
+ });
51
+
52
+ function createStoryFailedEvent(
53
+ overrides: Partial<StoryFailedEvent> = {},
54
+ ): StoryFailedEvent {
55
+ const story: UserStory = {
56
+ id: "US-001",
57
+ title: "Test Story",
58
+ description: "Test",
59
+ acceptanceCriteria: [],
60
+ };
61
+ return {
62
+ type: "story:failed",
63
+ storyId: "story-1",
64
+ story,
65
+ reason: "Test failed",
66
+ countsTowardEscalation: true,
67
+ feature: "test-feature",
68
+ attempts: 3,
69
+ ...overrides,
70
+ };
71
+ }
72
+
73
+ test("no subscription when max-retries trigger is disabled", () => {
74
+ const config = {
75
+ ...DEFAULT_CONFIG,
76
+ interaction: {
77
+ ...DEFAULT_CONFIG.interaction,
78
+ triggers: { "max-retries": { enabled: false } },
79
+ },
80
+ } as any;
81
+ wireInteraction(bus, mockChain, config);
82
+ expect(bus.subscriberCount("story:failed")).toBe(0);
83
+ });
84
+
85
+ test("no subscription when interactionChain is null", () => {
86
+ const config = {
87
+ ...DEFAULT_CONFIG,
88
+ interaction: {
89
+ ...DEFAULT_CONFIG.interaction,
90
+ triggers: { "max-retries": { enabled: true } },
91
+ },
92
+ } as any;
93
+ wireInteraction(bus, null, config);
94
+ expect(bus.subscriberCount("story:failed")).toBe(0);
95
+ });
96
+
97
+ test("fires max-retries trigger when countsTowardEscalation=true", async () => {
98
+ const config = {
99
+ ...DEFAULT_CONFIG,
100
+ interaction: {
101
+ ...DEFAULT_CONFIG.interaction,
102
+ triggers: { "max-retries": { enabled: true } },
103
+ },
104
+ } as any;
105
+
106
+ let triggerCalled = false;
107
+ mockChain.prompt = async (request: any) => {
108
+ triggerCalled = true;
109
+ expect(request.id).toContain("trigger-max-retries");
110
+ return { action: "skip" };
111
+ };
112
+
113
+ wireInteraction(bus, mockChain, config);
114
+ bus.emit(createStoryFailedEvent({ countsTowardEscalation: true }));
115
+
116
+ // Give async handler time to execute
117
+ await Bun.sleep(10);
118
+ expect(triggerCalled).toBe(true);
119
+ });
120
+
121
+ test("does NOT fire max-retries trigger when countsTowardEscalation=false", async () => {
122
+ const config = {
123
+ ...DEFAULT_CONFIG,
124
+ interaction: {
125
+ ...DEFAULT_CONFIG.interaction,
126
+ triggers: { "max-retries": { enabled: true } },
127
+ },
128
+ } as any;
129
+
130
+ let triggerCalled = false;
131
+ mockChain.prompt = async () => {
132
+ triggerCalled = true;
133
+ return { action: "skip" };
134
+ };
135
+
136
+ wireInteraction(bus, mockChain, config);
137
+ bus.emit(createStoryFailedEvent({ countsTowardEscalation: false }));
138
+
139
+ await Bun.sleep(10);
140
+ expect(triggerCalled).toBe(false);
141
+ });
142
+
143
+ test("passes correct context to executeTrigger", async () => {
144
+ const config = {
145
+ ...DEFAULT_CONFIG,
146
+ interaction: {
147
+ ...DEFAULT_CONFIG.interaction,
148
+ triggers: { "max-retries": { enabled: true } },
149
+ },
150
+ } as any;
151
+
152
+ let capturedRequest: any;
153
+ mockChain.prompt = async (request: any) => {
154
+ capturedRequest = request;
155
+ return { action: "skip" };
156
+ };
157
+
158
+ wireInteraction(bus, mockChain, config);
159
+ bus.emit(
160
+ createStoryFailedEvent({
161
+ storyId: "story-42",
162
+ feature: "auth-feature",
163
+ attempts: 5,
164
+ countsTowardEscalation: true,
165
+ }),
166
+ );
167
+
168
+ await Bun.sleep(10);
169
+ expect(capturedRequest?.featureName).toBe("auth-feature");
170
+ expect(capturedRequest?.storyId).toBe("story-42");
171
+ // Verify the request ID contains the trigger name
172
+ expect(capturedRequest?.id).toContain("trigger-max-retries");
173
+ });
174
+
175
+ test("handles abort response with warning", async () => {
176
+ const config = {
177
+ ...DEFAULT_CONFIG,
178
+ interaction: {
179
+ ...DEFAULT_CONFIG.interaction,
180
+ triggers: { "max-retries": { enabled: true } },
181
+ },
182
+ } as any;
183
+
184
+ let loggedAbort = false;
185
+ const originalLogger = console.warn;
186
+ console.warn = ((context: string, message: string, data: any) => {
187
+ if (message === "max-retries abort requested") {
188
+ loggedAbort = true;
189
+ }
190
+ }) as any;
191
+
192
+ mockChain.prompt = async () => {
193
+ return { action: "abort" };
194
+ };
195
+
196
+ try {
197
+ wireInteraction(bus, mockChain, config);
198
+ bus.emit(createStoryFailedEvent({ countsTowardEscalation: true }));
199
+ await Bun.sleep(10);
200
+ // Note: actual logging behavior depends on getSafeLogger implementation
201
+ } finally {
202
+ console.warn = originalLogger;
203
+ }
204
+ });
205
+
206
+ test("handles skip response (default)", async () => {
207
+ const config = {
208
+ ...DEFAULT_CONFIG,
209
+ interaction: {
210
+ ...DEFAULT_CONFIG.interaction,
211
+ triggers: { "max-retries": { enabled: true } },
212
+ },
213
+ } as any;
214
+
215
+ let skipCalled = false;
216
+ mockChain.prompt = async () => {
217
+ skipCalled = true;
218
+ return { action: "skip" };
219
+ };
220
+
221
+ wireInteraction(bus, mockChain, config);
222
+ bus.emit(createStoryFailedEvent({ countsTowardEscalation: true }));
223
+
224
+ await Bun.sleep(10);
225
+ expect(skipCalled).toBe(true);
226
+ });
227
+
228
+ test("handles escalate response (treated as skip)", async () => {
229
+ const config = {
230
+ ...DEFAULT_CONFIG,
231
+ interaction: {
232
+ ...DEFAULT_CONFIG.interaction,
233
+ triggers: { "max-retries": { enabled: true } },
234
+ },
235
+ } as any;
236
+
237
+ let escalateCalled = false;
238
+ mockChain.prompt = async () => {
239
+ escalateCalled = true;
240
+ return { action: "escalate" };
241
+ };
242
+
243
+ wireInteraction(bus, mockChain, config);
244
+ bus.emit(createStoryFailedEvent({ countsTowardEscalation: true }));
245
+
246
+ await Bun.sleep(10);
247
+ expect(escalateCalled).toBe(true);
248
+ });
249
+
250
+ test("catches trigger execution errors gracefully", async () => {
251
+ const config = {
252
+ ...DEFAULT_CONFIG,
253
+ interaction: {
254
+ ...DEFAULT_CONFIG.interaction,
255
+ triggers: { "max-retries": { enabled: true } },
256
+ },
257
+ } as any;
258
+
259
+ mockChain.prompt = async () => {
260
+ throw new Error("Trigger failed");
261
+ };
262
+
263
+ wireInteraction(bus, mockChain, config);
264
+ // Should not throw
265
+ bus.emit(createStoryFailedEvent({ countsTowardEscalation: true }));
266
+ await Bun.sleep(10);
267
+ });
268
+
269
+ test("handles missing feature field", async () => {
270
+ const config = {
271
+ ...DEFAULT_CONFIG,
272
+ interaction: {
273
+ ...DEFAULT_CONFIG.interaction,
274
+ triggers: { "max-retries": { enabled: true } },
275
+ },
276
+ } as any;
277
+
278
+ let capturedRequest: any;
279
+ mockChain.prompt = async (request: any) => {
280
+ capturedRequest = request;
281
+ return { action: "skip" };
282
+ };
283
+
284
+ wireInteraction(bus, mockChain, config);
285
+ bus.emit(createStoryFailedEvent({ feature: undefined, countsTowardEscalation: true }));
286
+
287
+ await Bun.sleep(10);
288
+ expect(capturedRequest?.featureName).toBe("");
289
+ });
290
+
291
+ test("unsubscribes correctly", async () => {
292
+ const config = {
293
+ ...DEFAULT_CONFIG,
294
+ interaction: {
295
+ ...DEFAULT_CONFIG.interaction,
296
+ triggers: { "max-retries": { enabled: true } },
297
+ },
298
+ } as any;
299
+
300
+ let triggerCalled = false;
301
+ mockChain.prompt = async () => {
302
+ triggerCalled = true;
303
+ return { action: "skip" };
304
+ };
305
+
306
+ const unsub = wireInteraction(bus, mockChain, config);
307
+ unsub();
308
+
309
+ bus.emit(createStoryFailedEvent({ countsTowardEscalation: true }));
310
+ await Bun.sleep(10);
311
+ expect(triggerCalled).toBe(false);
312
+ });
313
+ });
@@ -257,7 +257,7 @@ describe("Router Tags Defensive Fallback (BUG-004)", () => {
257
257
 
258
258
  expect(result.complexity).toBe("simple");
259
259
  expect(result.modelTier).toBe("fast");
260
- expect(result.testStrategy).toBe("three-session-tdd-lite");
260
+ expect(result.testStrategy).toBe("test-after");
261
261
  });
262
262
 
263
263
  test("routeTask handles null tags gracefully", () => {
@@ -271,7 +271,7 @@ describe("Router Tags Defensive Fallback (BUG-004)", () => {
271
271
 
272
272
  expect(result.complexity).toBe("simple");
273
273
  expect(result.modelTier).toBe("fast");
274
- expect(result.testStrategy).toBe("three-session-tdd-lite");
274
+ expect(result.testStrategy).toBe("test-after");
275
275
  });
276
276
 
277
277
  test("routeTask with undefined tags does not crash on spread operation", () => {
@@ -0,0 +1,99 @@
1
+ /**
2
+ * computeStoryContentHash — RRP-003
3
+ *
4
+ * AC-2: helper function computes a hash of title+description+ACs+tags
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { computeStoryContentHash } from "../../../src/routing";
9
+ import type { UserStory } from "../../../src/prd/types";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Helpers
13
+ // ---------------------------------------------------------------------------
14
+
15
+ function makeStory(overrides?: Partial<UserStory>): UserStory {
16
+ return {
17
+ id: "US-001",
18
+ title: "Add login page",
19
+ description: "Users can log in with email and password",
20
+ acceptanceCriteria: ["Shows email field", "Shows password field", "Submits form"],
21
+ tags: ["auth", "ui"],
22
+ dependencies: [],
23
+ status: "pending",
24
+ passes: false,
25
+ escalations: [],
26
+ attempts: 0,
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // AC-2: computeStoryContentHash exists and returns a string
33
+ // ---------------------------------------------------------------------------
34
+
35
+ describe("computeStoryContentHash", () => {
36
+ test("returns a non-empty string", () => {
37
+ const story = makeStory();
38
+ const hash = computeStoryContentHash(story);
39
+ expect(typeof hash).toBe("string");
40
+ expect(hash.length).toBeGreaterThan(0);
41
+ });
42
+
43
+ test("same story content produces the same hash (deterministic)", () => {
44
+ const story1 = makeStory();
45
+ const story2 = makeStory();
46
+ expect(computeStoryContentHash(story1)).toBe(computeStoryContentHash(story2));
47
+ });
48
+
49
+ test("different title produces different hash", () => {
50
+ const base = makeStory();
51
+ const changed = makeStory({ title: "Add registration page" });
52
+ expect(computeStoryContentHash(base)).not.toBe(computeStoryContentHash(changed));
53
+ });
54
+
55
+ test("different description produces different hash", () => {
56
+ const base = makeStory();
57
+ const changed = makeStory({ description: "Users can log in via OAuth" });
58
+ expect(computeStoryContentHash(base)).not.toBe(computeStoryContentHash(changed));
59
+ });
60
+
61
+ test("different acceptanceCriteria produces different hash", () => {
62
+ const base = makeStory();
63
+ const changed = makeStory({
64
+ acceptanceCriteria: ["Shows email field", "Shows password field"],
65
+ });
66
+ expect(computeStoryContentHash(base)).not.toBe(computeStoryContentHash(changed));
67
+ });
68
+
69
+ test("different tags produces different hash", () => {
70
+ const base = makeStory();
71
+ const changed = makeStory({ tags: ["auth", "api"] });
72
+ expect(computeStoryContentHash(base)).not.toBe(computeStoryContentHash(changed));
73
+ });
74
+
75
+ test("story ID, status, and attempts do NOT affect the hash (only content fields)", () => {
76
+ const base = makeStory({ id: "US-001", status: "pending", attempts: 0 });
77
+ const differentMeta = makeStory({ id: "US-099", status: "in-progress", attempts: 3 });
78
+ expect(computeStoryContentHash(base)).toBe(computeStoryContentHash(differentMeta));
79
+ });
80
+
81
+ test("empty acceptanceCriteria and tags produce a valid hash", () => {
82
+ const story = makeStory({ acceptanceCriteria: [], tags: [] });
83
+ const hash = computeStoryContentHash(story);
84
+ expect(typeof hash).toBe("string");
85
+ expect(hash.length).toBeGreaterThan(0);
86
+ });
87
+
88
+ test("adding an AC changes the hash", () => {
89
+ const before = makeStory({ acceptanceCriteria: ["AC1", "AC2"] });
90
+ const after = makeStory({ acceptanceCriteria: ["AC1", "AC2", "AC3 — new"] });
91
+ expect(computeStoryContentHash(before)).not.toBe(computeStoryContentHash(after));
92
+ });
93
+
94
+ test("adding a tag changes the hash", () => {
95
+ const before = makeStory({ tags: ["backend"] });
96
+ const after = makeStory({ tags: ["backend", "security"] });
97
+ expect(computeStoryContentHash(before)).not.toBe(computeStoryContentHash(after));
98
+ });
99
+ });
@@ -88,7 +88,7 @@ describe("BUG-031: keyword classifier stability across retries", () => {
88
88
 
89
89
  const result = keywordStrategy.route(story, ctx);
90
90
  expect(result!.complexity).toBe("simple");
91
- expect(result!.testStrategy).toBe("three-session-tdd-lite");
91
+ expect(result!.testStrategy).toBe("test-after");
92
92
  });
93
93
 
94
94
  test("complexity is driven by title and tags only (not description)", () => {
@@ -86,8 +86,8 @@ describe("classifyComplexity", () => {
86
86
  });
87
87
 
88
88
  describe("determineTestStrategy", () => {
89
- test("simple → three-session-tdd-lite", () => {
90
- expect(determineTestStrategy("simple", "Fix typo", "Fix a typo", [])).toBe("three-session-tdd-lite");
89
+ test("simple → test-after (BUG-045)", () => {
90
+ expect(determineTestStrategy("simple", "Fix typo", "Fix a typo", [])).toBe("test-after");
91
91
  });
92
92
 
93
93
  test("complex → three-session-tdd", () => {
@@ -161,11 +161,11 @@ describe("determineTestStrategy", () => {
161
161
  });
162
162
 
163
163
  describe("routeTask", () => {
164
- test("routes simple task to fast model with three-session-tdd-lite", () => {
164
+ test("routes simple task to fast model with test-after (BUG-045)", () => {
165
165
  const result = routeTask("Fix typo", "Fix a typo", ["Typo fixed"], [], DEFAULT_CONFIG);
166
166
  expect(result.complexity).toBe("simple");
167
167
  expect(result.modelTier).toBe("fast");
168
- expect(result.testStrategy).toBe("three-session-tdd-lite");
168
+ expect(result.testStrategy).toBe("test-after");
169
169
  });
170
170
 
171
171
  test("routes security task to powerful with three-session-tdd", () => {
@@ -262,7 +262,7 @@ describe("routeTask", () => {
262
262
 
263
263
  test("default config (strategy='auto') routes simple to three-session-tdd-lite", () => {
264
264
  const simpleResult = routeTask("Fix typo", "Fix a typo", ["Typo fixed"], [], DEFAULT_CONFIG);
265
- expect(simpleResult.testStrategy).toBe("three-session-tdd-lite");
265
+ expect(simpleResult.testStrategy).toBe("test-after");
266
266
 
267
267
  const complexResult = routeTask(
268
268
  "Auth refactor",
@@ -235,7 +235,7 @@ describe("keywordStrategy", () => {
235
235
  expect(decision).not.toBeNull();
236
236
  expect(decision!.complexity).toBe("simple");
237
237
  expect(decision!.modelTier).toBe("fast");
238
- expect(decision!.testStrategy).toBe("three-session-tdd-lite");
238
+ expect(decision!.testStrategy).toBe("test-after");
239
239
  });
240
240
 
241
241
  test("classifies complex story with security keywords", () => {
@@ -352,8 +352,6 @@ describe("LLM Routing Strategy - Prompt Building", () => {
352
352
  expect(prompt).toContain("fast: Simple changes");
353
353
  expect(prompt).toContain("balanced: Standard features");
354
354
  expect(prompt).toContain("powerful: Complex architecture");
355
- expect(prompt).toContain("test-after: Write implementation first");
356
- expect(prompt).toContain("three-session-tdd: Separate test-writer");
357
355
  });
358
356
 
359
357
  test("buildBatchPrompt formats multiple stories", () => {
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Unit tests for git utility functions (TC-003)
3
+ *
4
+ * Covers: detectMergeConflict helper
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { detectMergeConflict } from "../../../src/utils/git";
9
+
10
+ describe("detectMergeConflict", () => {
11
+ test("returns true when output contains uppercase CONFLICT", () => {
12
+ expect(detectMergeConflict("CONFLICT (content): Merge conflict in src/foo.ts")).toBe(true);
13
+ });
14
+
15
+ test("returns true when output contains lowercase conflict", () => {
16
+ expect(detectMergeConflict("Auto-merging failed due to conflict in file")).toBe(true);
17
+ });
18
+
19
+ test("returns true for typical git merge CONFLICT output", () => {
20
+ const output = [
21
+ "Auto-merging src/index.ts",
22
+ "CONFLICT (content): Merge conflict in src/index.ts",
23
+ "Automatic merge failed; fix conflicts and then commit the result.",
24
+ ].join("\n");
25
+ expect(detectMergeConflict(output)).toBe(true);
26
+ });
27
+
28
+ test("returns true for git rebase CONFLICT output", () => {
29
+ const output = "CONFLICT (modify/delete): src/bar.ts deleted in HEAD";
30
+ expect(detectMergeConflict(output)).toBe(true);
31
+ });
32
+
33
+ test("returns false when output has no conflict markers", () => {
34
+ expect(detectMergeConflict("All changes committed successfully.")).toBe(false);
35
+ });
36
+
37
+ test("returns false for empty string", () => {
38
+ expect(detectMergeConflict("")).toBe(false);
39
+ });
40
+
41
+ test("returns false for unrelated git output", () => {
42
+ const output = "3 files changed, 10 insertions(+), 2 deletions(-)";
43
+ expect(detectMergeConflict(output)).toBe(false);
44
+ });
45
+
46
+ test("returns true when CONFLICT appears in stderr portion of combined output", () => {
47
+ const combined = "stdout: commit abc123\nstderr: CONFLICT detected in merge";
48
+ expect(detectMergeConflict(combined)).toBe(true);
49
+ });
50
+ });