@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
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Routing Stage — RRP-002: initialComplexity written on first classify, never overwritten
3
+ *
4
+ * AC-1: StoryRouting interface gains initialComplexity?: Complexity field
5
+ * AC-2: Routing stage writes initialComplexity when story.routing is first created
6
+ * AC-3: Escalation path never overwrites initialComplexity
7
+ */
8
+
9
+ import { afterEach, describe, expect, mock, test } from "bun:test";
10
+ import { DEFAULT_CONFIG } from "../../../../src/config/defaults";
11
+ import type { NaxConfig } from "../../../../src/config";
12
+ import type { PRD, UserStory } from "../../../../src/prd";
13
+ import type { PipelineContext } from "../../../../src/pipeline/types";
14
+ import type { StoryRouting } from "../../../../src/prd/types";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ function makeStory(overrides?: Partial<UserStory>): UserStory {
21
+ return {
22
+ id: "US-001",
23
+ title: "Test Story",
24
+ description: "Test description",
25
+ acceptanceCriteria: [],
26
+ tags: [],
27
+ dependencies: [],
28
+ status: "in-progress",
29
+ passes: false,
30
+ escalations: [],
31
+ attempts: 0,
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ function makePRD(story: UserStory): PRD {
37
+ return {
38
+ project: "test-project",
39
+ feature: "test-feature",
40
+ branchName: "feat/test",
41
+ createdAt: new Date().toISOString(),
42
+ updatedAt: new Date().toISOString(),
43
+ userStories: [story],
44
+ };
45
+ }
46
+
47
+ function makeConfig(): NaxConfig {
48
+ return {
49
+ ...DEFAULT_CONFIG,
50
+ tdd: {
51
+ ...DEFAULT_CONFIG.tdd,
52
+ greenfieldDetection: false,
53
+ },
54
+ };
55
+ }
56
+
57
+ function makeCtx(story: UserStory, overrides?: Partial<PipelineContext>): PipelineContext & { prdPath: string } {
58
+ const prd = makePRD(story);
59
+ return {
60
+ config: makeConfig(),
61
+ prd,
62
+ story,
63
+ stories: [story],
64
+ routing: {
65
+ complexity: "simple",
66
+ modelTier: "fast",
67
+ testStrategy: "test-after",
68
+ reasoning: "test",
69
+ },
70
+ workdir: "/tmp/nax-routing-initial-complexity-test",
71
+ hooks: { hooks: {} },
72
+ prdPath: "/tmp/nax-routing-initial-complexity-test/nax/prd.json",
73
+ ...overrides,
74
+ } as PipelineContext & { prdPath: string };
75
+ }
76
+
77
+ const FRESH_ROUTING_RESULT = {
78
+ complexity: "medium" as const,
79
+ modelTier: "balanced" as const,
80
+ testStrategy: "three-session-tdd" as const,
81
+ reasoning: "classified by routeStory",
82
+ };
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // AC-2: initialComplexity written on first classify (story.routing undefined)
86
+ // ---------------------------------------------------------------------------
87
+
88
+ describe("routingStage - initialComplexity set on first classification", () => {
89
+ let origRoutingDeps: typeof import("../../../../src/pipeline/stages/routing")["_routingDeps"];
90
+
91
+ afterEach(() => {
92
+ mock.restore();
93
+ if (origRoutingDeps) {
94
+ const { _routingDeps } = require("../../../../src/pipeline/stages/routing");
95
+ Object.assign(_routingDeps, origRoutingDeps);
96
+ }
97
+ });
98
+
99
+ test("story.routing.initialComplexity is set to classified complexity on first classify", async () => {
100
+ const { routingStage, _routingDeps } = await import(
101
+ "../../../../src/pipeline/stages/routing"
102
+ );
103
+
104
+ origRoutingDeps = { ..._routingDeps };
105
+
106
+ _routingDeps.routeStory = mock(() => Promise.resolve({ ...FRESH_ROUTING_RESULT }));
107
+ _routingDeps.isGreenfieldStory = mock(() => Promise.resolve(false));
108
+ _routingDeps.savePRD = mock(() => Promise.resolve());
109
+
110
+ const story = makeStory({ routing: undefined });
111
+ const ctx = makeCtx(story);
112
+
113
+ await routingStage.execute(ctx as Parameters<typeof routingStage.execute>[0]);
114
+
115
+ // initialComplexity must equal the classified complexity
116
+ expect(ctx.story.routing?.initialComplexity).toBe(FRESH_ROUTING_RESULT.complexity);
117
+ });
118
+
119
+ test("story.routing.initialComplexity matches complexity on first classify", async () => {
120
+ const { routingStage, _routingDeps } = await import(
121
+ "../../../../src/pipeline/stages/routing"
122
+ );
123
+
124
+ origRoutingDeps = { ..._routingDeps };
125
+
126
+ const expertRouting = {
127
+ complexity: "expert" as const,
128
+ modelTier: "powerful" as const,
129
+ testStrategy: "three-session-tdd" as const,
130
+ reasoning: "complex feature",
131
+ };
132
+
133
+ _routingDeps.routeStory = mock(() => Promise.resolve({ ...expertRouting }));
134
+ _routingDeps.isGreenfieldStory = mock(() => Promise.resolve(false));
135
+ _routingDeps.savePRD = mock(() => Promise.resolve());
136
+
137
+ const story = makeStory({ routing: undefined });
138
+ const ctx = makeCtx(story);
139
+
140
+ await routingStage.execute(ctx as Parameters<typeof routingStage.execute>[0]);
141
+
142
+ expect(ctx.story.routing?.initialComplexity).toBe("expert");
143
+ expect(ctx.story.routing?.complexity).toBe("expert");
144
+ });
145
+
146
+ test("initialComplexity is written to PRD passed to savePRD on first classify", async () => {
147
+ const { routingStage, _routingDeps } = await import(
148
+ "../../../../src/pipeline/stages/routing"
149
+ );
150
+
151
+ origRoutingDeps = { ..._routingDeps };
152
+
153
+ const savedPRDs: PRD[] = [];
154
+ _routingDeps.routeStory = mock(() => Promise.resolve({ ...FRESH_ROUTING_RESULT }));
155
+ _routingDeps.isGreenfieldStory = mock(() => Promise.resolve(false));
156
+ _routingDeps.savePRD = mock((prd: PRD) => {
157
+ savedPRDs.push(prd);
158
+ return Promise.resolve();
159
+ });
160
+
161
+ const story = makeStory({ routing: undefined });
162
+ const ctx = makeCtx(story);
163
+
164
+ await routingStage.execute(ctx as Parameters<typeof routingStage.execute>[0]);
165
+
166
+ expect(savedPRDs).toHaveLength(1);
167
+ const savedStory = savedPRDs[0].userStories.find((s) => s.id === story.id);
168
+ expect(savedStory?.routing?.initialComplexity).toBe(FRESH_ROUTING_RESULT.complexity);
169
+ });
170
+ });
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // AC-3: Escalation path never overwrites initialComplexity
174
+ // ---------------------------------------------------------------------------
175
+
176
+ describe("routingStage - initialComplexity never overwritten after first classify", () => {
177
+ let origRoutingDeps: typeof import("../../../../src/pipeline/stages/routing")["_routingDeps"];
178
+
179
+ afterEach(() => {
180
+ mock.restore();
181
+ if (origRoutingDeps) {
182
+ const { _routingDeps } = require("../../../../src/pipeline/stages/routing");
183
+ Object.assign(_routingDeps, origRoutingDeps);
184
+ }
185
+ });
186
+
187
+ test("initialComplexity is preserved when story.routing already exists (escalation path)", async () => {
188
+ const { routingStage, _routingDeps } = await import(
189
+ "../../../../src/pipeline/stages/routing"
190
+ );
191
+
192
+ origRoutingDeps = { ..._routingDeps };
193
+
194
+ // Story has routing with initialComplexity from first classify and escalated modelTier
195
+ const escalatedRouting: StoryRouting = {
196
+ complexity: "simple",
197
+ initialComplexity: "simple",
198
+ modelTier: "powerful", // escalated from "fast"
199
+ testStrategy: "three-session-tdd",
200
+ reasoning: "escalated after failure",
201
+ };
202
+
203
+ _routingDeps.routeStory = mock(() =>
204
+ Promise.resolve({
205
+ complexity: "expert",
206
+ modelTier: "powerful",
207
+ testStrategy: "three-session-tdd",
208
+ reasoning: "re-classified",
209
+ }),
210
+ );
211
+ _routingDeps.complexityToModelTier = mock(() => "fast" as const);
212
+ _routingDeps.isGreenfieldStory = mock(() => Promise.resolve(false));
213
+ _routingDeps.savePRD = mock(() => Promise.resolve());
214
+
215
+ const story = makeStory({ routing: escalatedRouting });
216
+ const ctx = makeCtx(story);
217
+
218
+ await routingStage.execute(ctx as Parameters<typeof routingStage.execute>[0]);
219
+
220
+ // initialComplexity must remain "simple" — never overwritten by escalation
221
+ expect(ctx.story.routing?.initialComplexity).toBe("simple");
222
+ });
223
+
224
+ test("only modelTier changes during escalation, initialComplexity stays the same", async () => {
225
+ const { routingStage, _routingDeps } = await import(
226
+ "../../../../src/pipeline/stages/routing"
227
+ );
228
+
229
+ origRoutingDeps = { ..._routingDeps };
230
+
231
+ const routingAfterFirstClassify: StoryRouting = {
232
+ complexity: "medium",
233
+ initialComplexity: "medium", // set on first classify
234
+ modelTier: "powerful", // escalated tier
235
+ testStrategy: "three-session-tdd",
236
+ reasoning: "persisted from first classify, escalated",
237
+ };
238
+
239
+ _routingDeps.routeStory = mock(() =>
240
+ Promise.resolve({
241
+ complexity: "complex",
242
+ modelTier: "balanced",
243
+ testStrategy: "three-session-tdd",
244
+ reasoning: "fresh",
245
+ }),
246
+ );
247
+ _routingDeps.complexityToModelTier = mock(() => "balanced" as const);
248
+ _routingDeps.isGreenfieldStory = mock(() => Promise.resolve(false));
249
+ _routingDeps.savePRD = mock(() => Promise.resolve());
250
+
251
+ const story = makeStory({ routing: routingAfterFirstClassify });
252
+ const ctx = makeCtx(story);
253
+
254
+ await routingStage.execute(ctx as Parameters<typeof routingStage.execute>[0]);
255
+
256
+ // initialComplexity unchanged
257
+ expect(ctx.story.routing?.initialComplexity).toBe("medium");
258
+ // modelTier uses the escalated value
259
+ expect(ctx.routing.modelTier).toBe("powerful");
260
+ });
261
+
262
+ test("initialComplexity absent on story.routing with no initialComplexity is not touched (backward compat)", async () => {
263
+ const { routingStage, _routingDeps } = await import(
264
+ "../../../../src/pipeline/stages/routing"
265
+ );
266
+
267
+ origRoutingDeps = { ..._routingDeps };
268
+
269
+ // Legacy routing without initialComplexity (backward compat)
270
+ const legacyRouting: StoryRouting = {
271
+ complexity: "simple",
272
+ testStrategy: "test-after",
273
+ reasoning: "legacy persisted routing",
274
+ };
275
+
276
+ _routingDeps.routeStory = mock(() =>
277
+ Promise.resolve({
278
+ complexity: "medium",
279
+ modelTier: "balanced",
280
+ testStrategy: "three-session-tdd",
281
+ reasoning: "re-classified",
282
+ }),
283
+ );
284
+ _routingDeps.complexityToModelTier = mock(() => "fast" as const);
285
+ _routingDeps.isGreenfieldStory = mock(() => Promise.resolve(false));
286
+ _routingDeps.savePRD = mock(() => Promise.resolve());
287
+
288
+ const story = makeStory({ routing: legacyRouting });
289
+ const ctx = makeCtx(story);
290
+
291
+ await routingStage.execute(ctx as Parameters<typeof routingStage.execute>[0]);
292
+
293
+ // Should not have written initialComplexity onto an existing routing object
294
+ expect(ctx.story.routing?.initialComplexity).toBeUndefined();
295
+ });
296
+ });
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // AC-1: StoryRouting interface exposes initialComplexity field
300
+ // ---------------------------------------------------------------------------
301
+
302
+ describe("StoryRouting - initialComplexity field exists on type", () => {
303
+ test("StoryRouting accepts initialComplexity as optional Complexity field", () => {
304
+ const routing: StoryRouting = {
305
+ complexity: "medium",
306
+ testStrategy: "test-after",
307
+ reasoning: "test",
308
+ initialComplexity: "medium",
309
+ };
310
+ expect(routing.initialComplexity).toBe("medium");
311
+ });
312
+
313
+ test("StoryRouting is valid without initialComplexity (optional field)", () => {
314
+ const routing: StoryRouting = {
315
+ complexity: "simple",
316
+ testStrategy: "test-after",
317
+ reasoning: "test",
318
+ };
319
+ expect(routing.initialComplexity).toBeUndefined();
320
+ });
321
+ });
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Routing Stage — RRP-001: Persist initial routing to prd.json on first classification
3
+ *
4
+ * AC-1, AC-2, AC-3: Tests for persistence behavior, cached routing, and escalation.
5
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
8
+ import { DEFAULT_CONFIG } from "../../../../src/config/defaults";
9
+ import type { NaxConfig } from "../../../../src/config";
10
+ import type { PRD, UserStory } from "../../../../src/prd";
11
+ import type { PipelineContext } from "../../../../src/pipeline/types";
12
+ import type { StoryRouting } from "../../../../src/prd/types";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Helpers
16
+ // ---------------------------------------------------------------------------
17
+
18
+ function makeStory(overrides?: Partial<UserStory>): UserStory {
19
+ return {
20
+ id: "US-001",
21
+ title: "Test Story",
22
+ description: "Test description",
23
+ acceptanceCriteria: [],
24
+ tags: [],
25
+ dependencies: [],
26
+ status: "in-progress",
27
+ passes: false,
28
+ escalations: [],
29
+ attempts: 0,
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ function makePRD(story: UserStory): PRD {
35
+ return {
36
+ project: "test-project",
37
+ feature: "test-feature",
38
+ branchName: "feat/test",
39
+ createdAt: new Date().toISOString(),
40
+ updatedAt: new Date().toISOString(),
41
+ userStories: [story],
42
+ };
43
+ }
44
+
45
+ function makeConfig(): NaxConfig {
46
+ return {
47
+ ...DEFAULT_CONFIG,
48
+ tdd: {
49
+ ...DEFAULT_CONFIG.tdd,
50
+ greenfieldDetection: false,
51
+ },
52
+ };
53
+ }
54
+
55
+ function makeCtx(story: UserStory, overrides?: Partial<PipelineContext>): PipelineContext & { prdPath: string } {
56
+ const prd = makePRD(story);
57
+ return {
58
+ config: makeConfig(),
59
+ prd,
60
+ story,
61
+ stories: [story],
62
+ routing: {
63
+ complexity: "simple",
64
+ modelTier: "fast",
65
+ testStrategy: "test-after",
66
+ reasoning: "test",
67
+ },
68
+ workdir: "/tmp/nax-routing-test",
69
+ hooks: { hooks: {} },
70
+ prdPath: "/tmp/nax-routing-test/nax/prd.json",
71
+ ...overrides,
72
+ } as PipelineContext & { prdPath: string };
73
+ }
74
+
75
+ const FRESH_ROUTING_RESULT = {
76
+ complexity: "medium" as const,
77
+ modelTier: "balanced" as const,
78
+ testStrategy: "three-session-tdd" as const,
79
+ reasoning: "classified by routeStory",
80
+ };
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // AC-1 & AC-5: savePRD is called on first classification (story.routing undefined)
84
+ // ---------------------------------------------------------------------------
85
+
86
+ describe("routingStage - first classification persists routing to prd.json", () => {
87
+ let origRoutingDeps: typeof import("../../../../src/pipeline/stages/routing")["_routingDeps"];
88
+ let savePRDCallArgs: Array<[PRD, string]>;
89
+
90
+ beforeEach(() => {
91
+ savePRDCallArgs = [];
92
+ });
93
+
94
+ afterEach(() => {
95
+ mock.restore();
96
+ // Restore original deps after each test
97
+ if (origRoutingDeps) {
98
+ const { _routingDeps } = require("../../../../src/pipeline/stages/routing");
99
+ Object.assign(_routingDeps, origRoutingDeps);
100
+ }
101
+ });
102
+
103
+ test("calls savePRD with updated prd when story.routing is undefined", async () => {
104
+ const { routingStage, _routingDeps } = await import(
105
+ "../../../../src/pipeline/stages/routing"
106
+ );
107
+
108
+ origRoutingDeps = { ..._routingDeps };
109
+
110
+ _routingDeps.routeStory = mock(() => Promise.resolve({ ...FRESH_ROUTING_RESULT }));
111
+ _routingDeps.isGreenfieldStory = mock(() => Promise.resolve(false));
112
+ _routingDeps.savePRD = mock((prd: PRD, path: string) => {
113
+ savePRDCallArgs.push([prd, path]);
114
+ return Promise.resolve();
115
+ });
116
+
117
+ const story = makeStory({ routing: undefined });
118
+ const ctx = makeCtx(story);
119
+
120
+ await routingStage.execute(ctx as Parameters<typeof routingStage.execute>[0]);
121
+
122
+ // savePRD must have been called exactly once
123
+ expect(savePRDCallArgs).toHaveLength(1);
124
+ });
125
+
126
+ test("persists correct prdPath to savePRD on first classification", async () => {
127
+ const { routingStage, _routingDeps } = await import(
128
+ "../../../../src/pipeline/stages/routing"
129
+ );
130
+
131
+ origRoutingDeps = { ..._routingDeps };
132
+
133
+ _routingDeps.routeStory = mock(() => Promise.resolve({ ...FRESH_ROUTING_RESULT }));
134
+ _routingDeps.isGreenfieldStory = mock(() => Promise.resolve(false));
135
+ _routingDeps.savePRD = mock((prd: PRD, path: string) => {
136
+ savePRDCallArgs.push([prd, path]);
137
+ return Promise.resolve();
138
+ });
139
+
140
+ const story = makeStory({ routing: undefined });
141
+ const prdPath = "/tmp/nax-routing-test/nax/prd.json";
142
+ const ctx = makeCtx(story, { prdPath } as Partial<PipelineContext>);
143
+
144
+ await routingStage.execute(ctx as Parameters<typeof routingStage.execute>[0]);
145
+
146
+ const [, savedPath] = savePRDCallArgs[0];
147
+ expect(savedPath).toBe(prdPath);
148
+ });
149
+
150
+ test("story.routing is populated on prd after fresh classification", async () => {
151
+ const { routingStage, _routingDeps } = await import(
152
+ "../../../../src/pipeline/stages/routing"
153
+ );
154
+
155
+ origRoutingDeps = { ..._routingDeps };
156
+
157
+ _routingDeps.routeStory = mock(() => Promise.resolve({ ...FRESH_ROUTING_RESULT }));
158
+ _routingDeps.isGreenfieldStory = mock(() => Promise.resolve(false));
159
+ _routingDeps.savePRD = mock((prd: PRD, path: string) => {
160
+ savePRDCallArgs.push([prd, path]);
161
+ return Promise.resolve();
162
+ });
163
+
164
+ const story = makeStory({ routing: undefined });
165
+ const ctx = makeCtx(story);
166
+
167
+ await routingStage.execute(ctx as Parameters<typeof routingStage.execute>[0]);
168
+
169
+ // The story in the PRD passed to savePRD must have routing populated
170
+ const [savedPrd] = savePRDCallArgs[0];
171
+ const savedStory = savedPrd.userStories.find((s) => s.id === story.id);
172
+ expect(savedStory?.routing).toBeDefined();
173
+ expect(savedStory?.routing?.complexity).toBe(FRESH_ROUTING_RESULT.complexity);
174
+ expect(savedStory?.routing?.testStrategy).toBe(FRESH_ROUTING_RESULT.testStrategy);
175
+ });
176
+
177
+ test("ctx.story.routing is set to fresh classification result", async () => {
178
+ const { routingStage, _routingDeps } = await import(
179
+ "../../../../src/pipeline/stages/routing"
180
+ );
181
+
182
+ origRoutingDeps = { ..._routingDeps };
183
+
184
+ _routingDeps.routeStory = mock(() => Promise.resolve({ ...FRESH_ROUTING_RESULT }));
185
+ _routingDeps.isGreenfieldStory = mock(() => Promise.resolve(false));
186
+ _routingDeps.savePRD = mock(() => Promise.resolve());
187
+
188
+ const story = makeStory({ routing: undefined });
189
+ const ctx = makeCtx(story);
190
+
191
+ await routingStage.execute(ctx as Parameters<typeof routingStage.execute>[0]);
192
+
193
+ // story.routing must be populated so a crash+resume finds it
194
+ expect(ctx.story.routing).toBeDefined();
195
+ expect(ctx.story.routing?.complexity).toBe(FRESH_ROUTING_RESULT.complexity);
196
+ expect(ctx.story.routing?.testStrategy).toBe(FRESH_ROUTING_RESULT.testStrategy);
197
+ });
198
+ });
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // AC-2: No re-classification when story.routing already exists
202
+ // ---------------------------------------------------------------------------
203
+
204
+ describe("routingStage - skips savePRD when story.routing already set", () => {
205
+ let origRoutingDeps: typeof import("../../../../src/pipeline/stages/routing")["_routingDeps"];
206
+ let savePRDCallCount: number;
207
+
208
+ beforeEach(() => {
209
+ savePRDCallCount = 0;
210
+ });
211
+
212
+ afterEach(() => {
213
+ mock.restore();
214
+ if (origRoutingDeps) {
215
+ const { _routingDeps } = require("../../../../src/pipeline/stages/routing");
216
+ Object.assign(_routingDeps, origRoutingDeps);
217
+ }
218
+ });
219
+
220
+ test("does NOT call savePRD when story.routing is already populated", async () => {
221
+ const { routingStage, _routingDeps } = await import(
222
+ "../../../../src/pipeline/stages/routing"
223
+ );
224
+
225
+ origRoutingDeps = { ..._routingDeps };
226
+
227
+ const existingRouting: StoryRouting = {
228
+ complexity: "simple",
229
+ testStrategy: "test-after",
230
+ reasoning: "persisted from prior run",
231
+ };
232
+
233
+ _routingDeps.routeStory = mock(() =>
234
+ Promise.resolve({
235
+ complexity: "medium",
236
+ modelTier: "balanced",
237
+ testStrategy: "three-session-tdd",
238
+ reasoning: "re-classified",
239
+ }),
240
+ );
241
+ _routingDeps.isGreenfieldStory = mock(() => Promise.resolve(false));
242
+ _routingDeps.savePRD = mock(() => {
243
+ savePRDCallCount++;
244
+ return Promise.resolve();
245
+ });
246
+
247
+ const story = makeStory({ routing: existingRouting });
248
+ const ctx = makeCtx(story);
249
+
250
+ await routingStage.execute(ctx as Parameters<typeof routingStage.execute>[0]);
251
+
252
+ expect(savePRDCallCount).toBe(0);
253
+ });
254
+
255
+ test("uses persisted complexity/testStrategy (not re-classified values) when story.routing exists", async () => {
256
+ const { routingStage, _routingDeps } = await import(
257
+ "../../../../src/pipeline/stages/routing"
258
+ );
259
+
260
+ origRoutingDeps = { ..._routingDeps };
261
+
262
+ const existingRouting: StoryRouting = {
263
+ complexity: "simple",
264
+ testStrategy: "test-after",
265
+ reasoning: "persisted",
266
+ };
267
+
268
+ _routingDeps.routeStory = mock(() =>
269
+ Promise.resolve({
270
+ complexity: "expert",
271
+ modelTier: "powerful",
272
+ testStrategy: "three-session-tdd",
273
+ reasoning: "fresh",
274
+ }),
275
+ );
276
+ _routingDeps.isGreenfieldStory = mock(() => Promise.resolve(false));
277
+ _routingDeps.savePRD = mock(() => Promise.resolve());
278
+
279
+ const story = makeStory({ routing: existingRouting });
280
+ const ctx = makeCtx(story);
281
+
282
+ await routingStage.execute(ctx as Parameters<typeof routingStage.execute>[0]);
283
+
284
+ // Should use persisted values, not re-classified ones
285
+ expect(ctx.routing.complexity).toBe("simple");
286
+ expect(ctx.routing.testStrategy).toBe("test-after");
287
+ });
288
+ });
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // AC-3: Escalation still overwrites modelTier/testStrategy (not protected)
292
+ // ---------------------------------------------------------------------------
293
+
294
+ describe("routingStage - escalation overwrites modelTier even after persistence", () => {
295
+ let origRoutingDeps: typeof import("../../../../src/pipeline/stages/routing")["_routingDeps"];
296
+
297
+ afterEach(() => {
298
+ mock.restore();
299
+ if (origRoutingDeps) {
300
+ const { _routingDeps } = require("../../../../src/pipeline/stages/routing");
301
+ Object.assign(_routingDeps, origRoutingDeps);
302
+ }
303
+ });
304
+
305
+ test("uses escalated modelTier from story.routing when explicitly set", async () => {
306
+ const { routingStage, _routingDeps } = await import(
307
+ "../../../../src/pipeline/stages/routing"
308
+ );
309
+
310
+ origRoutingDeps = { ..._routingDeps };
311
+
312
+ // Story has routing with escalated modelTier (set by handleTierEscalation)
313
+ const escalatedRouting: StoryRouting = {
314
+ complexity: "simple",
315
+ modelTier: "powerful", // escalated from "fast"
316
+ testStrategy: "three-session-tdd",
317
+ reasoning: "escalated",
318
+ };
319
+
320
+ _routingDeps.routeStory = mock(() =>
321
+ Promise.resolve({
322
+ complexity: "simple",
323
+ modelTier: "fast",
324
+ testStrategy: "test-after",
325
+ reasoning: "fresh",
326
+ }),
327
+ );
328
+ _routingDeps.complexityToModelTier = mock(() => "fast" as const);
329
+ _routingDeps.isGreenfieldStory = mock(() => Promise.resolve(false));
330
+ _routingDeps.savePRD = mock(() => Promise.resolve());
331
+
332
+ const story = makeStory({ routing: escalatedRouting });
333
+ const ctx = makeCtx(story);
334
+
335
+ await routingStage.execute(ctx as Parameters<typeof routingStage.execute>[0]);
336
+
337
+ // escalated modelTier must take priority (BUG-032)
338
+ expect(ctx.routing.modelTier).toBe("powerful");
339
+ });
340
+
341
+ test("savePRD is NOT called during escalation (routing already persisted)", async () => {
342
+ const { routingStage, _routingDeps } = await import(
343
+ "../../../../src/pipeline/stages/routing"
344
+ );
345
+
346
+ origRoutingDeps = { ..._routingDeps };
347
+
348
+ let savePRDCalled = false;
349
+
350
+ const escalatedRouting: StoryRouting = {
351
+ complexity: "simple",
352
+ modelTier: "powerful",
353
+ testStrategy: "three-session-tdd",
354
+ reasoning: "escalated",
355
+ };
356
+
357
+ _routingDeps.routeStory = mock(() =>
358
+ Promise.resolve({
359
+ complexity: "simple",
360
+ modelTier: "fast",
361
+ testStrategy: "test-after",
362
+ reasoning: "fresh",
363
+ }),
364
+ );
365
+ _routingDeps.complexityToModelTier = mock(() => "fast" as const);
366
+ _routingDeps.isGreenfieldStory = mock(() => Promise.resolve(false));
367
+ _routingDeps.savePRD = mock(() => {
368
+ savePRDCalled = true;
369
+ return Promise.resolve();
370
+ });
371
+
372
+ const story = makeStory({ routing: escalatedRouting });
373
+ const ctx = makeCtx(story);
374
+
375
+ await routingStage.execute(ctx as Parameters<typeof routingStage.execute>[0]);
376
+
377
+ // Routing already exists — no need to re-persist
378
+ expect(savePRDCalled).toBe(false);
379
+ });
380
+ });