@nathapp/nax 0.23.0 → 0.25.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 (49) hide show
  1. package/bin/nax.ts +20 -2
  2. package/docs/ROADMAP.md +33 -15
  3. package/docs/specs/trigger-completion.md +145 -0
  4. package/nax/features/central-run-registry/prd.json +105 -0
  5. package/nax/features/trigger-completion/prd.json +150 -0
  6. package/nax/features/trigger-completion/progress.txt +7 -0
  7. package/nax/status.json +14 -24
  8. package/package.json +2 -2
  9. package/src/commands/index.ts +1 -0
  10. package/src/commands/logs.ts +87 -17
  11. package/src/commands/runs.ts +220 -0
  12. package/src/config/types.ts +3 -1
  13. package/src/execution/crash-recovery.ts +11 -0
  14. package/src/execution/executor-types.ts +1 -1
  15. package/src/execution/lifecycle/run-setup.ts +4 -0
  16. package/src/execution/sequential-executor.ts +49 -7
  17. package/src/interaction/plugins/auto.ts +10 -1
  18. package/src/pipeline/event-bus.ts +14 -1
  19. package/src/pipeline/stages/completion.ts +20 -0
  20. package/src/pipeline/stages/execution.ts +62 -0
  21. package/src/pipeline/stages/review.ts +25 -1
  22. package/src/pipeline/subscribers/events-writer.ts +121 -0
  23. package/src/pipeline/subscribers/hooks.ts +32 -0
  24. package/src/pipeline/subscribers/interaction.ts +36 -1
  25. package/src/pipeline/subscribers/registry.ts +73 -0
  26. package/src/routing/router.ts +3 -2
  27. package/src/routing/strategies/keyword.ts +2 -1
  28. package/src/routing/strategies/llm-prompts.ts +29 -28
  29. package/src/utils/git.ts +21 -0
  30. package/test/integration/cli/cli-logs.test.ts +40 -17
  31. package/test/integration/routing/plugin-routing-core.test.ts +1 -1
  32. package/test/unit/commands/logs.test.ts +63 -22
  33. package/test/unit/commands/runs.test.ts +303 -0
  34. package/test/unit/execution/sequential-executor.test.ts +235 -0
  35. package/test/unit/interaction/auto-plugin.test.ts +162 -0
  36. package/test/unit/interaction-plugins.test.ts +308 -1
  37. package/test/unit/pipeline/stages/completion-review-gate.test.ts +218 -0
  38. package/test/unit/pipeline/stages/execution-ambiguity.test.ts +311 -0
  39. package/test/unit/pipeline/stages/execution-merge-conflict.test.ts +218 -0
  40. package/test/unit/pipeline/stages/review.test.ts +201 -0
  41. package/test/unit/pipeline/subscribers/events-writer.test.ts +227 -0
  42. package/test/unit/pipeline/subscribers/hooks.test.ts +43 -4
  43. package/test/unit/pipeline/subscribers/interaction.test.ts +284 -2
  44. package/test/unit/pipeline/subscribers/registry.test.ts +149 -0
  45. package/test/unit/prd-auto-default.test.ts +2 -2
  46. package/test/unit/routing/routing-stability.test.ts +1 -1
  47. package/test/unit/routing-core.test.ts +5 -5
  48. package/test/unit/routing-strategies.test.ts +1 -3
  49. package/test/unit/utils/git.test.ts +50 -0
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Unit tests for security-review trigger wiring in review stage (TC-003)
3
+ *
4
+ * Covers:
5
+ * - Plugin reviewer failure with no trigger → always fail
6
+ * - Plugin reviewer failure + trigger abort → fail
7
+ * - Plugin reviewer failure + trigger non-abort → escalate
8
+ * - Built-in check failure → escalate (unchanged)
9
+ */
10
+
11
+ import { afterEach, describe, expect, mock, test } from "bun:test";
12
+ import type { NaxConfig } from "../../../../src/config";
13
+ import { InteractionChain } from "../../../../src/interaction/chain";
14
+ import type { InteractionPlugin, InteractionResponse } from "../../../../src/interaction/types";
15
+ import { _reviewDeps, reviewStage } from "../../../../src/pipeline/stages/review";
16
+ import type { PipelineContext } from "../../../../src/pipeline/types";
17
+ import type { PRD, UserStory } from "../../../../src/prd";
18
+
19
+ // ─────────────────────────────────────────────────────────────────────────────
20
+ // Helpers
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+
23
+ const originalCheckSecurityReview = _reviewDeps.checkSecurityReview;
24
+
25
+ function makeChain(action: InteractionResponse["action"]): InteractionChain {
26
+ const chain = new InteractionChain({ defaultTimeout: 5000, defaultFallback: "abort" });
27
+ const plugin: InteractionPlugin = {
28
+ name: "test",
29
+ send: mock(async () => {}),
30
+ receive: mock(async (id: string): Promise<InteractionResponse> => ({
31
+ requestId: id,
32
+ action,
33
+ respondedBy: "user",
34
+ respondedAt: Date.now(),
35
+ })),
36
+ };
37
+ chain.register(plugin);
38
+ return chain;
39
+ }
40
+
41
+ function makeConfig(triggers: Record<string, unknown>): NaxConfig {
42
+ return {
43
+ review: { enabled: true },
44
+ interaction: {
45
+ plugin: "cli",
46
+ defaults: { timeout: 30000, fallback: "abort" as const },
47
+ triggers,
48
+ },
49
+ } as unknown as NaxConfig;
50
+ }
51
+
52
+ function makeStory(overrides?: Partial<UserStory>): UserStory {
53
+ return {
54
+ id: "US-001",
55
+ title: "Test Story",
56
+ description: "Test",
57
+ acceptanceCriteria: [],
58
+ tags: [],
59
+ dependencies: [],
60
+ status: "in-progress",
61
+ passes: false,
62
+ escalations: [],
63
+ attempts: 1,
64
+ ...overrides,
65
+ };
66
+ }
67
+
68
+ function makePRD(): PRD {
69
+ return {
70
+ project: "test",
71
+ feature: "my-feature",
72
+ branchName: "test-branch",
73
+ createdAt: new Date().toISOString(),
74
+ updatedAt: new Date().toISOString(),
75
+ userStories: [makeStory()],
76
+ };
77
+ }
78
+
79
+ function makeCtx(overrides: Partial<PipelineContext>): PipelineContext {
80
+ return {
81
+ config: makeConfig({}),
82
+ prd: makePRD(),
83
+ story: makeStory(),
84
+ stories: [makeStory()],
85
+ routing: { complexity: "simple", modelTier: "fast", testStrategy: "test-after", reasoning: "" },
86
+ workdir: "/tmp/test",
87
+ hooks: {} as PipelineContext["hooks"],
88
+ ...overrides,
89
+ } as unknown as PipelineContext;
90
+ }
91
+
92
+ afterEach(() => {
93
+ mock.restore();
94
+ _reviewDeps.checkSecurityReview = originalCheckSecurityReview;
95
+ });
96
+
97
+ // ─────────────────────────────────────────────────────────────────────────────
98
+ // Plugin reviewer failure — no trigger configured (today behavior)
99
+ // ─────────────────────────────────────────────────────────────────────────────
100
+
101
+ describe("reviewStage — plugin failure, no trigger", () => {
102
+ test("returns fail when plugin reviewer fails and trigger not enabled", async () => {
103
+ const reviewResult = { success: false, pluginFailed: true, failureReason: "semgrep found issues", builtIn: { totalDurationMs: 0 } };
104
+ const orchestratorMock = mock(async () => reviewResult);
105
+ // biome-ignore lint/suspicious/noExplicitAny: test-only import override
106
+ const { reviewOrchestrator } = await import("../../../../src/review/orchestrator");
107
+ const original = reviewOrchestrator.review;
108
+ reviewOrchestrator.review = orchestratorMock as typeof reviewOrchestrator.review;
109
+
110
+ const ctx = makeCtx({ config: makeConfig({}) });
111
+ const result = await reviewStage.execute(ctx);
112
+
113
+ expect(result.action).toBe("fail");
114
+ reviewOrchestrator.review = original;
115
+ });
116
+ });
117
+
118
+ // ─────────────────────────────────────────────────────────────────────────────
119
+ // Plugin reviewer failure — trigger wired via _reviewDeps
120
+ // ─────────────────────────────────────────────────────────────────────────────
121
+
122
+ describe("reviewStage — security-review trigger via _reviewDeps", () => {
123
+ test("returns fail when trigger responds abort (checkSecurityReview returns false)", async () => {
124
+ _reviewDeps.checkSecurityReview = mock(async () => false);
125
+
126
+ const reviewResult = { success: false, pluginFailed: true, failureReason: "semgrep critical", builtIn: { totalDurationMs: 0 } };
127
+ const { reviewOrchestrator } = await import("../../../../src/review/orchestrator");
128
+ const original = reviewOrchestrator.review;
129
+ reviewOrchestrator.review = mock(async () => reviewResult) as typeof reviewOrchestrator.review;
130
+
131
+ const chain = makeChain("abort");
132
+ const ctx = makeCtx({
133
+ config: makeConfig({ "security-review": { enabled: true } }),
134
+ interaction: chain,
135
+ });
136
+ const result = await reviewStage.execute(ctx);
137
+
138
+ expect(result.action).toBe("fail");
139
+ expect(_reviewDeps.checkSecurityReview).toHaveBeenCalledTimes(1);
140
+ reviewOrchestrator.review = original;
141
+ });
142
+
143
+ test("returns escalate when trigger responds non-abort (checkSecurityReview returns true)", async () => {
144
+ _reviewDeps.checkSecurityReview = mock(async () => true);
145
+
146
+ const reviewResult = { success: false, pluginFailed: true, failureReason: "semgrep warning", builtIn: { totalDurationMs: 0 } };
147
+ const { reviewOrchestrator } = await import("../../../../src/review/orchestrator");
148
+ const original = reviewOrchestrator.review;
149
+ reviewOrchestrator.review = mock(async () => reviewResult) as typeof reviewOrchestrator.review;
150
+
151
+ const chain = makeChain("approve");
152
+ const ctx = makeCtx({
153
+ config: makeConfig({ "security-review": { enabled: true } }),
154
+ interaction: chain,
155
+ });
156
+ const result = await reviewStage.execute(ctx);
157
+
158
+ expect(result.action).toBe("escalate");
159
+ expect(_reviewDeps.checkSecurityReview).toHaveBeenCalledTimes(1);
160
+ reviewOrchestrator.review = original;
161
+ });
162
+
163
+ test("does not call trigger when no interaction chain present", async () => {
164
+ _reviewDeps.checkSecurityReview = mock(async () => true);
165
+
166
+ const reviewResult = { success: false, pluginFailed: true, failureReason: "semgrep error", builtIn: { totalDurationMs: 0 } };
167
+ const { reviewOrchestrator } = await import("../../../../src/review/orchestrator");
168
+ const original = reviewOrchestrator.review;
169
+ reviewOrchestrator.review = mock(async () => reviewResult) as typeof reviewOrchestrator.review;
170
+
171
+ const ctx = makeCtx({
172
+ config: makeConfig({ "security-review": { enabled: true } }),
173
+ // no interaction
174
+ });
175
+ const result = await reviewStage.execute(ctx);
176
+
177
+ expect(result.action).toBe("fail");
178
+ expect(_reviewDeps.checkSecurityReview).not.toHaveBeenCalled();
179
+ reviewOrchestrator.review = original;
180
+ });
181
+
182
+ test("built-in check failure still returns escalate (unchanged behavior)", async () => {
183
+ _reviewDeps.checkSecurityReview = mock(async () => false);
184
+
185
+ const reviewResult = { success: false, pluginFailed: false, failureReason: "lint failed", builtIn: { totalDurationMs: 0 } };
186
+ const { reviewOrchestrator } = await import("../../../../src/review/orchestrator");
187
+ const original = reviewOrchestrator.review;
188
+ reviewOrchestrator.review = mock(async () => reviewResult) as typeof reviewOrchestrator.review;
189
+
190
+ const ctx = makeCtx({
191
+ config: makeConfig({ "security-review": { enabled: true } }),
192
+ interaction: makeChain("abort"),
193
+ });
194
+ const result = await reviewStage.execute(ctx);
195
+
196
+ expect(result.action).toBe("escalate");
197
+ // security-review trigger should NOT fire for built-in check failures
198
+ expect(_reviewDeps.checkSecurityReview).not.toHaveBeenCalled();
199
+ reviewOrchestrator.review = original;
200
+ });
201
+ });
@@ -0,0 +1,227 @@
1
+ import { readFile, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
5
+ import { PipelineEventBus } from "../../../../src/pipeline/event-bus";
6
+ import { wireEventsWriter } from "../../../../src/pipeline/subscribers/events-writer";
7
+
8
+ // Minimal UserStory stub for event payloads
9
+ const stubStory = { id: "US-001", title: "Test story" } as never;
10
+
11
+ describe("wireEventsWriter", () => {
12
+ let workdir: string;
13
+ let eventsFile: string;
14
+
15
+ beforeEach(() => {
16
+ // Use a temp workdir with a known basename so we can locate the events file
17
+ workdir = join(tmpdir(), `nax-evtest-${Date.now()}`);
18
+ const project = workdir.split("/").pop()!;
19
+ eventsFile = join(process.env.HOME ?? tmpdir(), ".nax", "events", project, "events.jsonl");
20
+ });
21
+
22
+ afterEach(async () => {
23
+ // Clean up the events file written under ~/.nax/events/<project>/
24
+ const project = workdir.split("/").pop()!;
25
+ const dir = join(process.env.HOME ?? tmpdir(), ".nax", "events", project);
26
+ await rm(dir, { recursive: true, force: true });
27
+ mock.restore();
28
+ });
29
+
30
+ async function readLines(): Promise<object[]> {
31
+ // Small delay to let async fire-and-forget writes finish
32
+ await Bun.sleep(50);
33
+ const text = await readFile(eventsFile, "utf8");
34
+ return text
35
+ .trim()
36
+ .split("\n")
37
+ .filter(Boolean)
38
+ .map((l) => JSON.parse(l));
39
+ }
40
+
41
+ test("returns an UnsubscribeFn", () => {
42
+ const bus = new PipelineEventBus();
43
+ const unsub = wireEventsWriter(bus, "my-feature", "run-001", workdir);
44
+ expect(typeof unsub).toBe("function");
45
+ unsub();
46
+ });
47
+
48
+ test("creates events.jsonl and writes run:started line", async () => {
49
+ const bus = new PipelineEventBus();
50
+ wireEventsWriter(bus, "feat-a", "run-abc", workdir);
51
+
52
+ bus.emit({ type: "run:started", feature: "feat-a", totalStories: 3, workdir });
53
+
54
+ const lines = await readLines();
55
+ expect(lines.length).toBe(1);
56
+ const line = lines[0] as Record<string, unknown>;
57
+ expect(line.event).toBe("run:started");
58
+ expect(line.runId).toBe("run-abc");
59
+ expect(line.feature).toBe("feat-a");
60
+ expect(typeof line.project).toBe("string");
61
+ expect(typeof line.ts).toBe("string");
62
+ // ts must be valid ISO8601
63
+ expect(() => new Date(line.ts as string).toISOString()).not.toThrow();
64
+ });
65
+
66
+ test("each line has required fields: ts, event, runId, feature, project", async () => {
67
+ const bus = new PipelineEventBus();
68
+ wireEventsWriter(bus, "feat-b", "run-xyz", workdir);
69
+
70
+ bus.emit({ type: "run:started", feature: "feat-b", totalStories: 1, workdir });
71
+
72
+ const lines = await readLines();
73
+ const line = lines[0] as Record<string, unknown>;
74
+ for (const field of ["ts", "event", "runId", "feature", "project"]) {
75
+ expect(line[field]).toBeDefined();
76
+ }
77
+ });
78
+
79
+ test("run:completed writes event=on-complete", async () => {
80
+ const bus = new PipelineEventBus();
81
+ wireEventsWriter(bus, "feat-c", "run-001", workdir);
82
+
83
+ bus.emit({
84
+ type: "run:completed",
85
+ totalStories: 1,
86
+ passedStories: 1,
87
+ failedStories: 0,
88
+ durationMs: 100,
89
+ totalCost: 0.01,
90
+ });
91
+
92
+ const lines = await readLines();
93
+ const line = lines[0] as Record<string, unknown>;
94
+ expect(line.event).toBe("on-complete");
95
+ });
96
+
97
+ test("story:started includes storyId", async () => {
98
+ const bus = new PipelineEventBus();
99
+ wireEventsWriter(bus, "feat-d", "run-001", workdir);
100
+
101
+ bus.emit({
102
+ type: "story:started",
103
+ storyId: "US-042",
104
+ story: stubStory,
105
+ workdir,
106
+ });
107
+
108
+ const lines = await readLines();
109
+ const line = lines[0] as Record<string, unknown>;
110
+ expect(line.event).toBe("story:started");
111
+ expect(line.storyId).toBe("US-042");
112
+ });
113
+
114
+ test("story:completed includes storyId", async () => {
115
+ const bus = new PipelineEventBus();
116
+ wireEventsWriter(bus, "feat-d", "run-001", workdir);
117
+
118
+ bus.emit({
119
+ type: "story:completed",
120
+ storyId: "US-042",
121
+ story: stubStory,
122
+ passed: true,
123
+ durationMs: 500,
124
+ });
125
+
126
+ const lines = await readLines();
127
+ const line = lines[0] as Record<string, unknown>;
128
+ expect(line.event).toBe("story:completed");
129
+ expect(line.storyId).toBe("US-042");
130
+ });
131
+
132
+ test("story:failed includes storyId", async () => {
133
+ const bus = new PipelineEventBus();
134
+ wireEventsWriter(bus, "feat-d", "run-001", workdir);
135
+
136
+ bus.emit({
137
+ type: "story:failed",
138
+ storyId: "US-042",
139
+ story: stubStory,
140
+ reason: "tests failed",
141
+ countsTowardEscalation: true,
142
+ });
143
+
144
+ const lines = await readLines();
145
+ const line = lines[0] as Record<string, unknown>;
146
+ expect(line.event).toBe("story:failed");
147
+ expect(line.storyId).toBe("US-042");
148
+ });
149
+
150
+ test("run:paused is written", async () => {
151
+ const bus = new PipelineEventBus();
152
+ wireEventsWriter(bus, "feat-e", "run-001", workdir);
153
+
154
+ bus.emit({ type: "run:paused", reason: "cost limit", cost: 5.0 });
155
+
156
+ const lines = await readLines();
157
+ const line = lines[0] as Record<string, unknown>;
158
+ expect(line.event).toBe("run:paused");
159
+ });
160
+
161
+ test("multiple events produce multiple JSONL lines", async () => {
162
+ const bus = new PipelineEventBus();
163
+ wireEventsWriter(bus, "feat-f", "run-multi", workdir);
164
+
165
+ bus.emit({ type: "run:started", feature: "feat-f", totalStories: 2, workdir });
166
+ bus.emit({ type: "story:started", storyId: "US-001", story: stubStory, workdir });
167
+ bus.emit({ type: "story:completed", storyId: "US-001", story: stubStory, passed: true, durationMs: 100 });
168
+ bus.emit({
169
+ type: "run:completed",
170
+ totalStories: 1,
171
+ passedStories: 1,
172
+ failedStories: 0,
173
+ durationMs: 200,
174
+ });
175
+
176
+ const lines = await readLines();
177
+ expect(lines.length).toBe(4);
178
+ });
179
+
180
+ test("write failure does not throw or crash", async () => {
181
+ const bus = new PipelineEventBus();
182
+ // Use a workdir whose project name would conflict with a file (simulate write error)
183
+ // by pointing to an invalid path — we patch mkdir to throw
184
+ const originalMkdir = (await import("node:fs/promises")).mkdir;
185
+
186
+ let callCount = 0;
187
+ const _fsMod = await import("node:fs/promises");
188
+ // Inject a failing write by pointing to a path that won't be writable
189
+ // We rely on the subscriber catching the error gracefully
190
+ const badWorkdir = "/dev/null/nonexistent-project";
191
+ const badBus = new PipelineEventBus();
192
+ wireEventsWriter(badBus, "feat-err", "run-err", badWorkdir);
193
+
194
+ // Should not throw
195
+ expect(() => {
196
+ badBus.emit({ type: "run:started", feature: "feat-err", totalStories: 0, workdir: badWorkdir });
197
+ }).not.toThrow();
198
+
199
+ // Give async time to attempt write and swallow error
200
+ await Bun.sleep(50);
201
+ // No assertion on file existence — the point is no crash/throw
202
+ });
203
+
204
+ test("unsubscribe stops further writes", async () => {
205
+ const bus = new PipelineEventBus();
206
+ const unsub = wireEventsWriter(bus, "feat-g", "run-unsub", workdir);
207
+
208
+ bus.emit({ type: "run:started", feature: "feat-g", totalStories: 1, workdir });
209
+ await Bun.sleep(50);
210
+
211
+ unsub();
212
+
213
+ bus.emit({
214
+ type: "run:completed",
215
+ totalStories: 1,
216
+ passedStories: 1,
217
+ failedStories: 0,
218
+ durationMs: 100,
219
+ });
220
+
221
+ await Bun.sleep(50);
222
+ const lines = await readLines();
223
+ // Only the first event should be written
224
+ expect(lines.length).toBe(1);
225
+ expect((lines[0] as Record<string, unknown>).event).toBe("run:started");
226
+ });
227
+ });
@@ -11,11 +11,14 @@ describe("wireHooks", () => {
11
11
  const bus = new PipelineEventBus();
12
12
  wireHooks(bus, EMPTY_HOOKS, "/tmp", "test-feature");
13
13
 
14
- // Check subscriptions are registered
15
- const events = ["run:started", "story:started", "story:completed", "story:failed", "story:paused", "run:paused", "run:completed"] as const;
16
- for (const ev of events) {
14
+ // Single-subscriber events
15
+ const singleSubEvents = ["run:started", "story:started", "story:paused", "run:paused", "run:completed", "run:resumed", "run:errored"] as const;
16
+ for (const ev of singleSubEvents) {
17
17
  expect(bus.subscriberCount(ev)).toBe(1);
18
18
  }
19
+ // story:completed and story:failed each have 2: on-story-complete/fail + on-session-end
20
+ expect(bus.subscriberCount("story:completed")).toBe(2);
21
+ expect(bus.subscriberCount("story:failed")).toBe(2);
19
22
  });
20
23
 
21
24
  test("returns unsubscribe function that removes all subscriptions", () => {
@@ -24,7 +27,7 @@ describe("wireHooks", () => {
24
27
 
25
28
  unsub();
26
29
 
27
- const events = ["run:started", "story:started", "story:completed"] as const;
30
+ const events = ["run:started", "story:started", "story:completed", "run:resumed", "run:errored"] as const;
28
31
  for (const ev of events) {
29
32
  expect(bus.subscriberCount(ev)).toBe(0);
30
33
  }
@@ -42,4 +45,40 @@ describe("wireHooks", () => {
42
45
  bus.emit({ type: "story:completed", storyId: "US-001", story: { id: "US-001" } as any, passed: true, durationMs: 100 }),
43
46
  ).not.toThrow();
44
47
  });
48
+
49
+ test("on-resume: run:resumed event triggers on-resume hook (fire-and-forget, no throw)", () => {
50
+ const bus = new PipelineEventBus();
51
+ wireHooks(bus, EMPTY_HOOKS, "/tmp", "test-feature");
52
+
53
+ expect(() =>
54
+ bus.emit({ type: "run:resumed", feature: "test-feature" }),
55
+ ).not.toThrow();
56
+ });
57
+
58
+ test("on-session-end: story:completed triggers on-session-end with status passed (fire-and-forget, no throw)", () => {
59
+ const bus = new PipelineEventBus();
60
+ wireHooks(bus, EMPTY_HOOKS, "/tmp", "test-feature");
61
+
62
+ expect(() =>
63
+ bus.emit({ type: "story:completed", storyId: "US-001", story: { id: "US-001" } as any, passed: true, durationMs: 100 }),
64
+ ).not.toThrow();
65
+ });
66
+
67
+ test("on-session-end: story:failed triggers on-session-end with status failed (fire-and-forget, no throw)", () => {
68
+ const bus = new PipelineEventBus();
69
+ wireHooks(bus, EMPTY_HOOKS, "/tmp", "test-feature");
70
+
71
+ expect(() =>
72
+ bus.emit({ type: "story:failed", storyId: "US-001", story: { id: "US-001" } as any, reason: "test failure", countsTowardEscalation: true }),
73
+ ).not.toThrow();
74
+ });
75
+
76
+ test("on-error: run:errored event triggers on-error hook (fire-and-forget, no throw)", () => {
77
+ const bus = new PipelineEventBus();
78
+ wireHooks(bus, EMPTY_HOOKS, "/tmp", "test-feature");
79
+
80
+ expect(() =>
81
+ bus.emit({ type: "run:errored", reason: "SIGTERM", feature: "test-feature" }),
82
+ ).not.toThrow();
83
+ });
45
84
  });