@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,218 @@
1
+ /**
2
+ * Unit tests for review-gate trigger wiring in completion stage (TC-004)
3
+ *
4
+ * Covers:
5
+ * - review-gate trigger fires after each story passes when enabled
6
+ * - review-gate is disabled by default
7
+ * - trigger responds abort → logs warning (non-blocking)
8
+ * - trigger responds approve → continues normally
9
+ */
10
+
11
+ import { afterEach, describe, expect, mock, test } from "bun:test";
12
+ import { mkdtempSync } from "fs";
13
+ import { tmpdir } from "os";
14
+ import type { NaxConfig } from "../../../../src/config";
15
+ import { InteractionChain } from "../../../../src/interaction/chain";
16
+ import type { InteractionPlugin, InteractionResponse } from "../../../../src/interaction/types";
17
+ import { _completionDeps } from "../../../../src/pipeline/stages/completion";
18
+ import type { PipelineContext } from "../../../../src/pipeline/types";
19
+ import type { PRD, UserStory } from "../../../../src/prd";
20
+
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+ // Save originals for restoration
23
+ // ─────────────────────────────────────────────────────────────────────────────
24
+
25
+ const originalCheckReviewGate = _completionDeps.checkReviewGate;
26
+
27
+ // ─────────────────────────────────────────────────────────────────────────────
28
+ // Helpers
29
+ // ─────────────────────────────────────────────────────────────────────────────
30
+
31
+ function makeChain(action: InteractionResponse["action"]): InteractionChain {
32
+ const chain = new InteractionChain({ defaultTimeout: 5000, defaultFallback: "abort" });
33
+ const plugin: InteractionPlugin = {
34
+ name: "test",
35
+ send: mock(async () => {}),
36
+ receive: mock(async (id: string): Promise<InteractionResponse> => ({
37
+ requestId: id,
38
+ action,
39
+ respondedBy: "user",
40
+ respondedAt: Date.now(),
41
+ })),
42
+ };
43
+ chain.register(plugin);
44
+ return chain;
45
+ }
46
+
47
+ function makeConfig(triggers: Record<string, unknown>): NaxConfig {
48
+ return {
49
+ autoMode: { defaultAgent: "test-agent" },
50
+ models: { fast: "claude-haiku-4-5", balanced: "claude-sonnet-4-5", powerful: "claude-opus-4-5" },
51
+ execution: {
52
+ sessionTimeoutSeconds: 60,
53
+ dangerouslySkipPermissions: false,
54
+ costLimit: 10,
55
+ maxIterations: 10,
56
+ rectification: { maxRetries: 3 },
57
+ },
58
+ interaction: {
59
+ plugin: "cli",
60
+ defaults: { timeout: 30000, fallback: "abort" as const },
61
+ triggers,
62
+ },
63
+ } as unknown as NaxConfig;
64
+ }
65
+
66
+ function makeStory(): UserStory {
67
+ return {
68
+ id: "US-001",
69
+ title: "Test Story",
70
+ description: "Test",
71
+ acceptanceCriteria: [],
72
+ tags: [],
73
+ dependencies: [],
74
+ status: "in-progress",
75
+ passes: false,
76
+ escalations: [],
77
+ attempts: 1,
78
+ };
79
+ }
80
+
81
+ function makePRD(): PRD {
82
+ return {
83
+ project: "test",
84
+ feature: "my-feature",
85
+ branchName: "test-branch",
86
+ createdAt: new Date().toISOString(),
87
+ updatedAt: new Date().toISOString(),
88
+ userStories: [makeStory()],
89
+ };
90
+ }
91
+
92
+ function makeCtx(config: NaxConfig, interaction?: InteractionChain): PipelineContext {
93
+ const tempDir = mkdtempSync(`${tmpdir()}/nax-test-`);
94
+ return {
95
+ config,
96
+ prd: makePRD(),
97
+ story: makeStory(),
98
+ stories: [makeStory()],
99
+ routing: { complexity: "simple", modelTier: "fast", testStrategy: "test-after", reasoning: "" },
100
+ workdir: tempDir,
101
+ featureDir: tempDir,
102
+ agentResult: { success: true, estimatedCost: 0.01, output: "", stderr: "", exitCode: 0, rateLimited: false },
103
+ hooks: {} as PipelineContext["hooks"],
104
+ interaction,
105
+ storyStartTime: new Date().toISOString(),
106
+ } as unknown as PipelineContext;
107
+ }
108
+
109
+ afterEach(() => {
110
+ mock.restore();
111
+ _completionDeps.checkReviewGate = originalCheckReviewGate;
112
+ });
113
+
114
+ // ─────────────────────────────────────────────────────────────────────────────
115
+ // review-gate trigger tests (via _completionDeps injection)
116
+ // ─────────────────────────────────────────────────────────────────────────────
117
+
118
+ describe("completionStage — review-gate trigger", () => {
119
+ test("calls review-gate trigger after story passes when enabled", async () => {
120
+ const { completionStage } = await import("../../../../src/pipeline/stages/completion");
121
+ _completionDeps.checkReviewGate = mock(async () => true);
122
+
123
+ const config = makeConfig({ "review-gate": { enabled: true } });
124
+ const chain = makeChain("approve");
125
+ const ctx = makeCtx(config, chain);
126
+
127
+ const result = await completionStage.execute(ctx);
128
+
129
+ expect(result.action).toBe("continue");
130
+ expect(_completionDeps.checkReviewGate).toHaveBeenCalledTimes(1);
131
+ });
132
+
133
+ test("does not call trigger when review-gate is disabled (default)", async () => {
134
+ const { completionStage } = await import("../../../../src/pipeline/stages/completion");
135
+ _completionDeps.checkReviewGate = mock(async () => true);
136
+
137
+ const config = makeConfig({});
138
+ const chain = makeChain("approve");
139
+ const ctx = makeCtx(config, chain);
140
+
141
+ const result = await completionStage.execute(ctx);
142
+
143
+ expect(result.action).toBe("continue");
144
+ expect(_completionDeps.checkReviewGate).not.toHaveBeenCalled();
145
+ });
146
+
147
+ test("does not fail pipeline when trigger responds abort", async () => {
148
+ const { completionStage } = await import("../../../../src/pipeline/stages/completion");
149
+ _completionDeps.checkReviewGate = mock(async () => false);
150
+
151
+ const config = makeConfig({ "review-gate": { enabled: true } });
152
+ const chain = makeChain("abort");
153
+ const ctx = makeCtx(config, chain);
154
+
155
+ const result = await completionStage.execute(ctx);
156
+
157
+ expect(result.action).toBe("continue");
158
+ expect(_completionDeps.checkReviewGate).toHaveBeenCalledTimes(1);
159
+ });
160
+
161
+ test("continues normally when trigger approves", async () => {
162
+ const { completionStage } = await import("../../../../src/pipeline/stages/completion");
163
+ _completionDeps.checkReviewGate = mock(async () => true);
164
+
165
+ const config = makeConfig({ "review-gate": { enabled: true } });
166
+ const chain = makeChain("approve");
167
+ const ctx = makeCtx(config, chain);
168
+
169
+ const result = await completionStage.execute(ctx);
170
+
171
+ expect(result.action).toBe("continue");
172
+ });
173
+
174
+ test("does not call trigger when no interaction chain", async () => {
175
+ const { completionStage } = await import("../../../../src/pipeline/stages/completion");
176
+ _completionDeps.checkReviewGate = mock(async () => true);
177
+
178
+ const config = makeConfig({ "review-gate": { enabled: true } });
179
+ const ctx = makeCtx(config); // no chain
180
+
181
+ const result = await completionStage.execute(ctx);
182
+
183
+ expect(result.action).toBe("continue");
184
+ expect(_completionDeps.checkReviewGate).not.toHaveBeenCalled();
185
+ });
186
+
187
+ test("passes correct context to checkReviewGate", async () => {
188
+ const { completionStage } = await import("../../../../src/pipeline/stages/completion");
189
+ _completionDeps.checkReviewGate = mock(async () => true);
190
+
191
+ const config = makeConfig({ "review-gate": { enabled: true } });
192
+ const chain = makeChain("approve");
193
+ const ctx = makeCtx(config, chain);
194
+
195
+ await completionStage.execute(ctx);
196
+
197
+ const callArgs = (_completionDeps.checkReviewGate as any).mock.calls[0];
198
+ expect(callArgs[0].featureName).toBe("my-feature");
199
+ expect(callArgs[0].storyId).toBe("US-001");
200
+ });
201
+
202
+ test("calls trigger for each story when multiple stories passed", async () => {
203
+ const { completionStage } = await import("../../../../src/pipeline/stages/completion");
204
+ _completionDeps.checkReviewGate = mock(async () => true);
205
+
206
+ const config = makeConfig({ "review-gate": { enabled: true } });
207
+ const chain = makeChain("approve");
208
+ const ctx = makeCtx(config, chain);
209
+
210
+ const story2 = makeStory();
211
+ story2.id = "US-002";
212
+ ctx.stories = [makeStory(), story2];
213
+
214
+ await completionStage.execute(ctx);
215
+
216
+ expect(_completionDeps.checkReviewGate).toHaveBeenCalledTimes(2);
217
+ });
218
+ });
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Unit tests for story-ambiguity trigger and isAmbiguousOutput helper (TC-004)
3
+ *
4
+ * Covers:
5
+ * - isAmbiguousOutput() detects all 6 keyword phrases (case-insensitive)
6
+ * - story-ambiguity trigger fires when output is ambiguous and trigger enabled
7
+ * - story-ambiguity is disabled by default
8
+ * - trigger responds abort → escalate
9
+ * - trigger responds approve → continue
10
+ * - Trigger does not fire when output is clear
11
+ */
12
+
13
+ import { afterEach, describe, expect, mock, test } from "bun:test";
14
+ import type { NaxConfig } from "../../../../src/config";
15
+ import { InteractionChain } from "../../../../src/interaction/chain";
16
+ import type { InteractionPlugin, InteractionResponse } from "../../../../src/interaction/types";
17
+ import { isAmbiguousOutput, _executionDeps } from "../../../../src/pipeline/stages/execution";
18
+ import type { PipelineContext } from "../../../../src/pipeline/types";
19
+ import type { PRD, UserStory } from "../../../../src/prd";
20
+
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+ // Save originals for restoration
23
+ // ─────────────────────────────────────────────────────────────────────────────
24
+
25
+ const originalGetAgent = _executionDeps.getAgent;
26
+ const originalCheckStoryAmbiguity = _executionDeps.checkStoryAmbiguity;
27
+ const originalIsAmbiguousOutput = _executionDeps.isAmbiguousOutput;
28
+
29
+ // ─────────────────────────────────────────────────────────────────────────────
30
+ // Helpers
31
+ // ─────────────────────────────────────────────────────────────────────────────
32
+
33
+ function makeChain(action: InteractionResponse["action"]): InteractionChain {
34
+ const chain = new InteractionChain({ defaultTimeout: 5000, defaultFallback: "abort" });
35
+ const plugin: InteractionPlugin = {
36
+ name: "test",
37
+ send: mock(async () => {}),
38
+ receive: mock(async (id: string): Promise<InteractionResponse> => ({
39
+ requestId: id,
40
+ action,
41
+ respondedBy: "user",
42
+ respondedAt: Date.now(),
43
+ })),
44
+ };
45
+ chain.register(plugin);
46
+ return chain;
47
+ }
48
+
49
+ function makeConfig(triggers: Record<string, unknown>): NaxConfig {
50
+ return {
51
+ autoMode: { defaultAgent: "test-agent" },
52
+ models: { fast: "claude-haiku-4-5", balanced: "claude-sonnet-4-5", powerful: "claude-opus-4-5" },
53
+ execution: {
54
+ sessionTimeoutSeconds: 60,
55
+ dangerouslySkipPermissions: false,
56
+ costLimit: 10,
57
+ maxIterations: 10,
58
+ rectification: { maxRetries: 3 },
59
+ },
60
+ interaction: {
61
+ plugin: "cli",
62
+ defaults: { timeout: 30000, fallback: "abort" as const },
63
+ triggers,
64
+ },
65
+ } as unknown as NaxConfig;
66
+ }
67
+
68
+ function makeStory(): UserStory {
69
+ return {
70
+ id: "US-001",
71
+ title: "Test Story",
72
+ description: "Test",
73
+ acceptanceCriteria: [],
74
+ tags: [],
75
+ dependencies: [],
76
+ status: "in-progress",
77
+ passes: false,
78
+ escalations: [],
79
+ attempts: 1,
80
+ };
81
+ }
82
+
83
+ function makePRD(): PRD {
84
+ return {
85
+ project: "test",
86
+ feature: "my-feature",
87
+ branchName: "test-branch",
88
+ createdAt: new Date().toISOString(),
89
+ updatedAt: new Date().toISOString(),
90
+ userStories: [makeStory()],
91
+ };
92
+ }
93
+
94
+ function makeAgent(output: string) {
95
+ return {
96
+ name: "test-agent",
97
+ capabilities: { supportedTiers: ["fast", "balanced", "powerful"] },
98
+ run: mock(async () => ({
99
+ success: true,
100
+ exitCode: 0,
101
+ output,
102
+ stderr: "",
103
+ rateLimited: false,
104
+ durationMs: 100,
105
+ estimatedCost: 0.01,
106
+ })),
107
+ };
108
+ }
109
+
110
+ function makeCtx(config: NaxConfig, interaction?: InteractionChain): PipelineContext {
111
+ return {
112
+ config,
113
+ prd: makePRD(),
114
+ story: makeStory(),
115
+ stories: [makeStory()],
116
+ routing: { complexity: "simple", modelTier: "fast", testStrategy: "test-after", reasoning: "" },
117
+ workdir: "/tmp/test",
118
+ prompt: "Do something",
119
+ hooks: {} as PipelineContext["hooks"],
120
+ interaction,
121
+ } as unknown as PipelineContext;
122
+ }
123
+
124
+ afterEach(() => {
125
+ mock.restore();
126
+ _executionDeps.getAgent = originalGetAgent;
127
+ _executionDeps.checkStoryAmbiguity = originalCheckStoryAmbiguity;
128
+ _executionDeps.isAmbiguousOutput = originalIsAmbiguousOutput;
129
+ });
130
+
131
+ // ─────────────────────────────────────────────────────────────────────────────
132
+ // isAmbiguousOutput helper tests
133
+ // ─────────────────────────────────────────────────────────────────────────────
134
+
135
+ describe("isAmbiguousOutput", () => {
136
+ test("detects 'unclear' keyword (case-insensitive)", () => {
137
+ expect(isAmbiguousOutput("This is unclear")).toBe(true);
138
+ expect(isAmbiguousOutput("This is UNCLEAR")).toBe(true);
139
+ expect(isAmbiguousOutput("This is UnClEaR")).toBe(true);
140
+ });
141
+
142
+ test("detects 'ambiguous' keyword", () => {
143
+ expect(isAmbiguousOutput("The requirement is ambiguous")).toBe(true);
144
+ expect(isAmbiguousOutput("The requirement is AMBIGUOUS")).toBe(true);
145
+ });
146
+
147
+ test("detects 'need clarification' phrase", () => {
148
+ expect(isAmbiguousOutput("I need clarification on this")).toBe(true);
149
+ expect(isAmbiguousOutput("I NEED CLARIFICATION on this")).toBe(true);
150
+ });
151
+
152
+ test("detects 'please clarify' phrase", () => {
153
+ expect(isAmbiguousOutput("Please clarify what you mean")).toBe(true);
154
+ expect(isAmbiguousOutput("PLEASE CLARIFY what you mean")).toBe(true);
155
+ });
156
+
157
+ test("detects 'which one' phrase", () => {
158
+ expect(isAmbiguousOutput("Which one should I use?")).toBe(true);
159
+ expect(isAmbiguousOutput("WHICH ONE should I use?")).toBe(true);
160
+ });
161
+
162
+ test("detects 'not sure which' phrase", () => {
163
+ expect(isAmbiguousOutput("I'm not sure which option to pick")).toBe(true);
164
+ expect(isAmbiguousOutput("I'm NOT SURE WHICH option to pick")).toBe(true);
165
+ });
166
+
167
+ test("returns false for clear output", () => {
168
+ expect(isAmbiguousOutput("Implementation complete")).toBe(false);
169
+ expect(isAmbiguousOutput("Tests passing")).toBe(false);
170
+ expect(isAmbiguousOutput("")).toBe(false);
171
+ });
172
+
173
+ test("returns false for empty string", () => {
174
+ expect(isAmbiguousOutput("")).toBe(false);
175
+ });
176
+
177
+ test("detects multiple keywords in same output", () => {
178
+ expect(isAmbiguousOutput("This is unclear and ambiguous")).toBe(true);
179
+ });
180
+ });
181
+
182
+ // ─────────────────────────────────────────────────────────────────────────────
183
+ // story-ambiguity trigger tests (via _executionDeps injection)
184
+ // ─────────────────────────────────────────────────────────────────────────────
185
+
186
+ describe("executionStage — story-ambiguity trigger", () => {
187
+ test("returns escalate when ambiguity detected and trigger responds abort", async () => {
188
+ const { executionStage } = await import("../../../../src/pipeline/stages/execution");
189
+ const agent = makeAgent("This is unclear about requirements");
190
+ _executionDeps.getAgent = mock(() => agent as ReturnType<typeof _executionDeps.getAgent>);
191
+ _executionDeps.checkStoryAmbiguity = mock(async () => false);
192
+
193
+ const config = makeConfig({ "story-ambiguity": { enabled: true } });
194
+ const chain = makeChain("abort");
195
+ const ctx = makeCtx(config, chain);
196
+
197
+ const result = await executionStage.execute(ctx);
198
+
199
+ expect(result.action).toBe("escalate");
200
+ expect((result as { reason?: string }).reason).toContain("ambiguity");
201
+ expect(_executionDeps.checkStoryAmbiguity).toHaveBeenCalledTimes(1);
202
+ });
203
+
204
+ test("returns continue when ambiguity detected but trigger approves", async () => {
205
+ const { executionStage } = await import("../../../../src/pipeline/stages/execution");
206
+ const agent = makeAgent("I need clarification on this");
207
+ _executionDeps.getAgent = mock(() => agent as ReturnType<typeof _executionDeps.getAgent>);
208
+ _executionDeps.checkStoryAmbiguity = mock(async () => true);
209
+
210
+ const config = makeConfig({ "story-ambiguity": { enabled: true } });
211
+ const chain = makeChain("approve");
212
+ const ctx = makeCtx(config, chain);
213
+
214
+ const result = await executionStage.execute(ctx);
215
+
216
+ expect(result.action).toBe("continue");
217
+ expect(_executionDeps.checkStoryAmbiguity).toHaveBeenCalledTimes(1);
218
+ });
219
+
220
+ test("does not call trigger when story-ambiguity is disabled (default)", async () => {
221
+ const { executionStage } = await import("../../../../src/pipeline/stages/execution");
222
+ const agent = makeAgent("This is unclear");
223
+ _executionDeps.getAgent = mock(() => agent as ReturnType<typeof _executionDeps.getAgent>);
224
+ _executionDeps.checkStoryAmbiguity = mock(async () => false);
225
+
226
+ const config = makeConfig({});
227
+ const chain = makeChain("abort");
228
+ const ctx = makeCtx(config, chain);
229
+
230
+ const result = await executionStage.execute(ctx);
231
+
232
+ expect(result.action).toBe("continue");
233
+ expect(_executionDeps.checkStoryAmbiguity).not.toHaveBeenCalled();
234
+ });
235
+
236
+ test("does not call trigger when output is clear", async () => {
237
+ const { executionStage } = await import("../../../../src/pipeline/stages/execution");
238
+ const agent = makeAgent("Implementation complete successfully");
239
+ _executionDeps.getAgent = mock(() => agent as ReturnType<typeof _executionDeps.getAgent>);
240
+ _executionDeps.checkStoryAmbiguity = mock(async () => false);
241
+
242
+ const config = makeConfig({ "story-ambiguity": { enabled: true } });
243
+ const chain = makeChain("abort");
244
+ const ctx = makeCtx(config, chain);
245
+
246
+ const result = await executionStage.execute(ctx);
247
+
248
+ expect(result.action).toBe("continue");
249
+ expect(_executionDeps.checkStoryAmbiguity).not.toHaveBeenCalled();
250
+ });
251
+
252
+ test("does not call trigger when no interaction chain", async () => {
253
+ const { executionStage } = await import("../../../../src/pipeline/stages/execution");
254
+ const agent = makeAgent("Which one should I use?");
255
+ _executionDeps.getAgent = mock(() => agent as ReturnType<typeof _executionDeps.getAgent>);
256
+ _executionDeps.checkStoryAmbiguity = mock(async () => false);
257
+
258
+ const config = makeConfig({ "story-ambiguity": { enabled: true } });
259
+ const ctx = makeCtx(config); // no chain
260
+
261
+ const result = await executionStage.execute(ctx);
262
+
263
+ expect(result.action).toBe("continue");
264
+ expect(_executionDeps.checkStoryAmbiguity).not.toHaveBeenCalled();
265
+ });
266
+
267
+ test("does not call trigger when agent session failed", async () => {
268
+ const { executionStage } = await import("../../../../src/pipeline/stages/execution");
269
+ _executionDeps.getAgent = mock(() => ({
270
+ name: "test-agent",
271
+ capabilities: { supportedTiers: ["fast", "balanced", "powerful"] },
272
+ run: mock(async () => ({
273
+ success: false,
274
+ exitCode: 1,
275
+ output: "This is unclear",
276
+ stderr: "Error occurred",
277
+ rateLimited: false,
278
+ durationMs: 100,
279
+ estimatedCost: 0.01,
280
+ })),
281
+ } as ReturnType<typeof _executionDeps.getAgent>));
282
+ _executionDeps.checkStoryAmbiguity = mock(async () => false);
283
+
284
+ const config = makeConfig({ "story-ambiguity": { enabled: true } });
285
+ const chain = makeChain("abort");
286
+ const ctx = makeCtx(config, chain);
287
+
288
+ const result = await executionStage.execute(ctx);
289
+
290
+ expect(result.action).toBe("escalate");
291
+ expect(_executionDeps.checkStoryAmbiguity).not.toHaveBeenCalled();
292
+ });
293
+
294
+ test("passes correct context to checkStoryAmbiguity", async () => {
295
+ const { executionStage } = await import("../../../../src/pipeline/stages/execution");
296
+ const agent = makeAgent("not sure which");
297
+ _executionDeps.getAgent = mock(() => agent as ReturnType<typeof _executionDeps.getAgent>);
298
+ _executionDeps.checkStoryAmbiguity = mock(async () => true);
299
+
300
+ const config = makeConfig({ "story-ambiguity": { enabled: true } });
301
+ const chain = makeChain("approve");
302
+ const ctx = makeCtx(config, chain);
303
+
304
+ await executionStage.execute(ctx);
305
+
306
+ const callArgs = (_executionDeps.checkStoryAmbiguity as any).mock.calls[0];
307
+ expect(callArgs[0].featureName).toBe("my-feature");
308
+ expect(callArgs[0].storyId).toBe("US-001");
309
+ expect(callArgs[0].reason).toContain("Agent output suggests ambiguity");
310
+ });
311
+ });