@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,235 @@
1
+ /**
2
+ * Unit tests for cost trigger wiring in sequential-executor.ts (TC-001)
3
+ *
4
+ * Covers: checkCostExceeded abort/skip/continue, checkCostWarning at 80%/100%
5
+ * threshold, and isTriggerEnabled guard (no interaction plugin = today behavior).
6
+ */
7
+
8
+ import { afterEach, describe, expect, mock, test } from "bun:test";
9
+ import type { NaxConfig } from "../../../src/config";
10
+ import { InteractionChain } from "../../../src/interaction/chain";
11
+ import type { InteractionPlugin, InteractionResponse } from "../../../src/interaction/types";
12
+ import { checkCostExceeded, checkCostWarning, checkPreMerge, isTriggerEnabled } from "../../../src/interaction/triggers";
13
+
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+ // Helpers
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+
18
+ function makeChain(action: InteractionResponse["action"]): InteractionChain {
19
+ const chain = new InteractionChain({ defaultTimeout: 5000, defaultFallback: "escalate" });
20
+ const plugin: InteractionPlugin = {
21
+ name: "test",
22
+ send: mock(async () => {}),
23
+ receive: mock(async (id: string): Promise<InteractionResponse> => ({
24
+ requestId: id,
25
+ action,
26
+ respondedBy: "user",
27
+ respondedAt: Date.now(),
28
+ })),
29
+ };
30
+ chain.register(plugin);
31
+ return chain;
32
+ }
33
+
34
+ function makeConfig(triggers: Record<string, unknown>): NaxConfig {
35
+ return {
36
+ interaction: {
37
+ plugin: "cli",
38
+ defaults: { timeout: 30000, fallback: "escalate" as const },
39
+ triggers,
40
+ },
41
+ } as unknown as NaxConfig;
42
+ }
43
+
44
+ afterEach(() => {
45
+ mock.restore();
46
+ });
47
+
48
+ // ─────────────────────────────────────────────────────────────────────────────
49
+ // isTriggerEnabled — no interaction plugin = today behavior
50
+ // ─────────────────────────────────────────────────────────────────────────────
51
+
52
+ describe("isTriggerEnabled — no interaction plugin configured", () => {
53
+ test("returns false when cost-exceeded is not in triggers", () => {
54
+ const config = makeConfig({});
55
+ expect(isTriggerEnabled("cost-exceeded", config)).toBe(false);
56
+ });
57
+
58
+ test("returns false when cost-warning is not in triggers", () => {
59
+ const config = makeConfig({});
60
+ expect(isTriggerEnabled("cost-warning", config)).toBe(false);
61
+ });
62
+
63
+ test("returns false when trigger explicitly disabled", () => {
64
+ const config = makeConfig({ "cost-exceeded": { enabled: false } });
65
+ expect(isTriggerEnabled("cost-exceeded", config)).toBe(false);
66
+ });
67
+
68
+ test("returns true when trigger is boolean true", () => {
69
+ const config = makeConfig({ "cost-warning": true });
70
+ expect(isTriggerEnabled("cost-warning", config)).toBe(true);
71
+ });
72
+
73
+ test("returns true when trigger is enabled:true object", () => {
74
+ const config = makeConfig({ "cost-exceeded": { enabled: true } });
75
+ expect(isTriggerEnabled("cost-exceeded", config)).toBe(true);
76
+ });
77
+ });
78
+
79
+ // ─────────────────────────────────────────────────────────────────────────────
80
+ // checkCostExceeded — 100% threshold responses
81
+ // ─────────────────────────────────────────────────────────────────────────────
82
+
83
+ describe("checkCostExceeded — abort response exits with cost-limit", () => {
84
+ const context = { featureName: "feature-x", cost: 1.0, limit: 1.0 };
85
+
86
+ test("returns false when trigger responds abort", async () => {
87
+ const config = makeConfig({ "cost-exceeded": { enabled: true } });
88
+ const chain = makeChain("abort");
89
+ const result = await checkCostExceeded(context, config, chain);
90
+ expect(result).toBe(false);
91
+ });
92
+
93
+ test("returns true (proceed past limit) when trigger responds skip", async () => {
94
+ const config = makeConfig({ "cost-exceeded": { enabled: true } });
95
+ const chain = makeChain("skip");
96
+ const result = await checkCostExceeded(context, config, chain);
97
+ expect(result).toBe(true);
98
+ });
99
+
100
+ test("returns true (proceed past limit) when trigger responds approve/continue", async () => {
101
+ const config = makeConfig({ "cost-exceeded": { enabled: true } });
102
+ const chain = makeChain("approve");
103
+ const result = await checkCostExceeded(context, config, chain);
104
+ expect(result).toBe(true);
105
+ });
106
+
107
+ test("returns true without prompting when trigger is disabled (today behavior preserved)", async () => {
108
+ const config = makeConfig({});
109
+ const chain = makeChain("abort");
110
+ const result = await checkCostExceeded(context, config, chain);
111
+ // Trigger disabled: checkCostExceeded returns true (caller decides to exit)
112
+ expect(result).toBe(true);
113
+ });
114
+ });
115
+
116
+ // ─────────────────────────────────────────────────────────────────────────────
117
+ // checkCostWarning — 80% threshold
118
+ // ─────────────────────────────────────────────────────────────────────────────
119
+
120
+ describe("checkCostWarning — 80% threshold", () => {
121
+ const context = { featureName: "feature-x", cost: 0.8, limit: 1.0 };
122
+
123
+ test("returns 'escalate' when trigger responds approve", async () => {
124
+ const config = makeConfig({ "cost-warning": { enabled: true } });
125
+ const chain = makeChain("approve");
126
+ const result = await checkCostWarning(context, config, chain);
127
+ expect(result).toBe("escalate");
128
+ });
129
+
130
+ test("returns 'continue' when trigger responds skip", async () => {
131
+ const config = makeConfig({ "cost-warning": { enabled: true } });
132
+ const chain = makeChain("skip");
133
+ const result = await checkCostWarning(context, config, chain);
134
+ expect(result).toBe("continue");
135
+ });
136
+
137
+ test("returns 'continue' when trigger responds abort", async () => {
138
+ const config = makeConfig({ "cost-warning": { enabled: true } });
139
+ const chain = makeChain("abort");
140
+ const result = await checkCostWarning(context, config, chain);
141
+ expect(result).toBe("continue");
142
+ });
143
+
144
+ test("returns 'continue' without prompting when trigger is disabled", async () => {
145
+ const config = makeConfig({});
146
+ const chain = makeChain("approve");
147
+ const result = await checkCostWarning(context, config, chain);
148
+ expect(result).toBe("continue");
149
+ });
150
+ });
151
+
152
+ // ─────────────────────────────────────────────────────────────────────────────
153
+ // Threshold guard logic (mirrors executor warningSent guard)
154
+ // ─────────────────────────────────────────────────────────────────────────────
155
+
156
+ describe("cost-warning threshold guard logic", () => {
157
+ function shouldFireWarning(
158
+ totalCost: number,
159
+ costLimit: number,
160
+ triggerCfg: boolean | { enabled: boolean; threshold?: number } | undefined,
161
+ warningSent: boolean,
162
+ ): boolean {
163
+ if (warningSent) return false;
164
+ const threshold = typeof triggerCfg === "object" ? (triggerCfg.threshold ?? 0.8) : 0.8;
165
+ return totalCost >= costLimit * threshold;
166
+ }
167
+
168
+ test("does not fire when cost is below 80% of limit", () => {
169
+ expect(shouldFireWarning(7.9, 10, { enabled: true }, false)).toBe(false);
170
+ });
171
+
172
+ test("fires when cost is exactly at 80% of limit", () => {
173
+ expect(shouldFireWarning(8.0, 10, { enabled: true }, false)).toBe(true);
174
+ });
175
+
176
+ test("fires when cost is between 80% and 100% of limit", () => {
177
+ expect(shouldFireWarning(9.5, 10, { enabled: true }, false)).toBe(true);
178
+ });
179
+
180
+ test("fires when cost is at 100% of limit", () => {
181
+ expect(shouldFireWarning(10.0, 10, { enabled: true }, false)).toBe(true);
182
+ });
183
+
184
+ test("does not fire a second time if warningSent is true", () => {
185
+ expect(shouldFireWarning(9.0, 10, { enabled: true }, true)).toBe(false);
186
+ });
187
+
188
+ test("uses custom threshold when provided in trigger config", () => {
189
+ // threshold: 0.9 means fires at 90% not 80%
190
+ expect(shouldFireWarning(8.5, 10, { enabled: true, threshold: 0.9 }, false)).toBe(false);
191
+ expect(shouldFireWarning(9.0, 10, { enabled: true, threshold: 0.9 }, false)).toBe(true);
192
+ });
193
+
194
+ test("defaults to 0.8 when trigger config is a boolean", () => {
195
+ expect(shouldFireWarning(7.9, 10, true, false)).toBe(false);
196
+ expect(shouldFireWarning(8.0, 10, true, false)).toBe(true);
197
+ });
198
+ });
199
+
200
+ // ─────────────────────────────────────────────────────────────────────────────
201
+ // checkPreMerge — pre-merge trigger before run:completed
202
+ // ─────────────────────────────────────────────────────────────────────────────
203
+
204
+ describe("checkPreMerge — approve/abort responses", () => {
205
+ const context = { featureName: "feature-x", totalStories: 3, cost: 0.5 };
206
+
207
+ test("returns false when trigger responds abort", async () => {
208
+ const config = makeConfig({ "pre-merge": { enabled: true } });
209
+ const chain = makeChain("abort");
210
+ const result = await checkPreMerge(context, config, chain);
211
+ expect(result).toBe(false);
212
+ });
213
+
214
+ test("returns true when trigger responds approve", async () => {
215
+ const config = makeConfig({ "pre-merge": { enabled: true } });
216
+ const chain = makeChain("approve");
217
+ const result = await checkPreMerge(context, config, chain);
218
+ expect(result).toBe(true);
219
+ });
220
+
221
+ test("returns false when trigger responds skip (non-approve = abort run)", async () => {
222
+ const config = makeConfig({ "pre-merge": { enabled: true } });
223
+ const chain = makeChain("skip");
224
+ const result = await checkPreMerge(context, config, chain);
225
+ expect(result).toBe(false);
226
+ });
227
+
228
+ test("returns true without prompting when trigger is disabled", async () => {
229
+ const config = makeConfig({});
230
+ const chain = makeChain("abort");
231
+ const result = await checkPreMerge(context, config, chain);
232
+ // Trigger disabled: checkPreMerge returns true (proceed normally)
233
+ expect(result).toBe(true);
234
+ });
235
+ });
@@ -0,0 +1,162 @@
1
+ /**
2
+ * AutoInteractionPlugin unit tests (TC-006)
3
+ *
4
+ * Tests LLM decision path via _deps.callLlm mock.
5
+ * No real claude CLI is invoked.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
9
+ import { _deps, AutoInteractionPlugin } from "../../../src/interaction/plugins/auto";
10
+ import type { InteractionRequest } from "../../../src/interaction/types";
11
+
12
+ // Save original so we can restore in afterEach
13
+ const originalCallLlm = _deps.callLlm;
14
+
15
+ function makeRequest(id: string, overrides: Partial<InteractionRequest> = {}): InteractionRequest {
16
+ return {
17
+ id,
18
+ type: "confirm",
19
+ featureName: "test-feature",
20
+ stage: "review",
21
+ summary: "Should we proceed?",
22
+ fallback: "continue",
23
+ createdAt: Date.now(),
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ describe("AutoInteractionPlugin._deps.callLlm", () => {
29
+ let plugin: AutoInteractionPlugin;
30
+
31
+ beforeEach(async () => {
32
+ plugin = new AutoInteractionPlugin();
33
+ await plugin.init({ confidenceThreshold: 0.7 });
34
+ });
35
+
36
+ afterEach(() => {
37
+ mock.restore();
38
+ _deps.callLlm = originalCallLlm;
39
+ });
40
+
41
+ test("LLM returns approve → response.action is approve", async () => {
42
+ _deps.callLlm = mock(async () => ({
43
+ action: "approve" as const,
44
+ confidence: 0.9,
45
+ reasoning: "safe to proceed",
46
+ }));
47
+
48
+ const response = await plugin.decide(makeRequest("req-approve"));
49
+
50
+ expect(response).not.toBeUndefined();
51
+ expect(response?.action).toBe("approve");
52
+ expect(response?.respondedBy).toBe("auto-ai");
53
+ expect(response?.requestId).toBe("req-approve");
54
+ });
55
+
56
+ test("LLM returns reject → response.action is reject", async () => {
57
+ _deps.callLlm = mock(async () => ({
58
+ action: "reject" as const,
59
+ confidence: 0.85,
60
+ reasoning: "potential issue",
61
+ }));
62
+
63
+ const response = await plugin.decide(makeRequest("req-reject"));
64
+
65
+ expect(response).not.toBeUndefined();
66
+ expect(response?.action).toBe("reject");
67
+ });
68
+
69
+ test("confidence < threshold → returns undefined (escalates to human)", async () => {
70
+ _deps.callLlm = mock(async () => ({
71
+ action: "approve" as const,
72
+ confidence: 0.5, // below default threshold of 0.7
73
+ reasoning: "not confident",
74
+ }));
75
+
76
+ const response = await plugin.decide(makeRequest("req-low-conf"));
77
+
78
+ expect(response).toBeUndefined();
79
+ });
80
+
81
+ test("custom threshold: confidence exactly at threshold → response returned", async () => {
82
+ const highThresholdPlugin = new AutoInteractionPlugin();
83
+ await highThresholdPlugin.init({ confidenceThreshold: 0.8 });
84
+
85
+ _deps.callLlm = mock(async () => ({
86
+ action: "approve" as const,
87
+ confidence: 0.8, // exactly at threshold
88
+ reasoning: "borderline",
89
+ }));
90
+
91
+ const response = await highThresholdPlugin.decide(makeRequest("req-threshold"));
92
+
93
+ // Confidence (0.8) is NOT less than threshold (0.8), so response is returned
94
+ expect(response).not.toBeUndefined();
95
+ expect(response?.action).toBe("approve");
96
+ });
97
+
98
+ test("custom threshold: confidence below threshold → returns undefined", async () => {
99
+ const highThresholdPlugin = new AutoInteractionPlugin();
100
+ await highThresholdPlugin.init({ confidenceThreshold: 0.9 });
101
+
102
+ _deps.callLlm = mock(async () => ({
103
+ action: "approve" as const,
104
+ confidence: 0.8, // below 0.9 threshold
105
+ reasoning: "not confident enough",
106
+ }));
107
+
108
+ const response = await highThresholdPlugin.decide(makeRequest("req-below-threshold"));
109
+
110
+ expect(response).toBeUndefined();
111
+ });
112
+
113
+ test("security-review trigger → always returns undefined (hardcoded block)", async () => {
114
+ // _deps.callLlm is NOT set — if it were called, it would throw (null)
115
+ // This verifies the security-review check runs before any LLM call
116
+ _deps.callLlm = mock(async () => {
117
+ throw new Error("callLlm should not be invoked for security-review");
118
+ });
119
+
120
+ const request = makeRequest("req-sec", {
121
+ metadata: { trigger: "security-review", safety: "red" },
122
+ });
123
+
124
+ const response = await plugin.decide(request);
125
+
126
+ expect(response).toBeUndefined();
127
+ // Verify LLM was never called
128
+ expect((_deps.callLlm as ReturnType<typeof mock>).mock.calls).toHaveLength(0);
129
+ });
130
+
131
+ test("LLM throws → returns undefined (error escalates to human)", async () => {
132
+ _deps.callLlm = mock(async () => {
133
+ throw new Error("LLM unavailable");
134
+ });
135
+
136
+ const response = await plugin.decide(makeRequest("req-error"));
137
+
138
+ expect(response).toBeUndefined();
139
+ });
140
+
141
+ test("LLM returns choose with value → value is propagated", async () => {
142
+ _deps.callLlm = mock(async () => ({
143
+ action: "choose" as const,
144
+ value: "option-b",
145
+ confidence: 0.95,
146
+ reasoning: "best option",
147
+ }));
148
+
149
+ const response = await plugin.decide(
150
+ makeRequest("req-choose", {
151
+ type: "choose",
152
+ options: [
153
+ { key: "a", label: "Option A" },
154
+ { key: "b", label: "Option B" },
155
+ ],
156
+ }),
157
+ );
158
+
159
+ expect(response?.action).toBe("choose");
160
+ expect(response?.value).toBe("option-b");
161
+ });
162
+ });