@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.
- package/CLAUDE.md +70 -56
- package/docs/ROADMAP.md +45 -15
- package/docs/specs/trigger-completion.md +145 -0
- package/nax/features/routing-persistence/prd.json +104 -0
- package/nax/features/routing-persistence/progress.txt +1 -0
- package/nax/features/trigger-completion/prd.json +150 -0
- package/nax/features/trigger-completion/progress.txt +7 -0
- package/nax/status.json +15 -16
- package/package.json +1 -1
- package/src/config/types.ts +3 -1
- package/src/execution/crash-recovery.ts +11 -0
- package/src/execution/executor-types.ts +1 -1
- package/src/execution/iteration-runner.ts +1 -0
- package/src/execution/lifecycle/run-setup.ts +4 -0
- package/src/execution/sequential-executor.ts +45 -7
- package/src/interaction/plugins/auto.ts +10 -1
- package/src/metrics/aggregator.ts +2 -1
- package/src/metrics/tracker.ts +26 -14
- package/src/metrics/types.ts +2 -0
- package/src/pipeline/event-bus.ts +14 -1
- package/src/pipeline/stages/completion.ts +20 -0
- package/src/pipeline/stages/execution.ts +62 -0
- package/src/pipeline/stages/review.ts +25 -1
- package/src/pipeline/stages/routing.ts +42 -8
- package/src/pipeline/subscribers/hooks.ts +32 -0
- package/src/pipeline/subscribers/interaction.ts +36 -1
- package/src/pipeline/types.ts +2 -0
- package/src/prd/types.ts +4 -0
- package/src/routing/content-hash.ts +25 -0
- package/src/routing/index.ts +3 -0
- package/src/routing/router.ts +3 -2
- package/src/routing/strategies/keyword.ts +2 -1
- package/src/routing/strategies/llm-prompts.ts +29 -28
- package/src/utils/git.ts +21 -0
- package/test/integration/routing/plugin-routing-core.test.ts +1 -1
- package/test/unit/execution/sequential-executor.test.ts +235 -0
- package/test/unit/interaction/auto-plugin.test.ts +162 -0
- package/test/unit/interaction-plugins.test.ts +308 -1
- package/test/unit/metrics/aggregator.test.ts +164 -0
- package/test/unit/metrics/tracker.test.ts +186 -0
- package/test/unit/pipeline/stages/completion-review-gate.test.ts +218 -0
- package/test/unit/pipeline/stages/execution-ambiguity.test.ts +311 -0
- package/test/unit/pipeline/stages/execution-merge-conflict.test.ts +218 -0
- package/test/unit/pipeline/stages/review.test.ts +201 -0
- package/test/unit/pipeline/stages/routing-idempotence.test.ts +139 -0
- package/test/unit/pipeline/stages/routing-initial-complexity.test.ts +321 -0
- package/test/unit/pipeline/stages/routing-persistence.test.ts +380 -0
- package/test/unit/pipeline/subscribers/hooks.test.ts +43 -4
- package/test/unit/pipeline/subscribers/interaction.test.ts +284 -2
- package/test/unit/prd-auto-default.test.ts +2 -2
- package/test/unit/routing/content-hash.test.ts +99 -0
- package/test/unit/routing/routing-stability.test.ts +1 -1
- package/test/unit/routing-core.test.ts +5 -5
- package/test/unit/routing-strategies.test.ts +1 -3
- 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
|
+
});
|