@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
@@ -6,13 +6,23 @@
6
6
  */
7
7
 
8
8
  import { existsSync, readdirSync } from "node:fs";
9
+ import { readdir } from "node:fs/promises";
10
+ import { homedir } from "node:os";
9
11
  import { join } from "node:path";
10
12
  import chalk from "chalk";
11
13
  import type { LogEntry, LogLevel } from "../logger/types";
12
14
  import { type FormattedEntry, formatLogEntry, formatRunSummary } from "../logging/formatter";
13
15
  import type { RunSummary, VerbosityMode } from "../logging/types";
16
+ import type { MetaJson } from "../pipeline/subscribers/registry";
14
17
  import { type ResolveProjectOptions, resolveProject } from "./common";
15
18
 
19
+ /**
20
+ * Swappable dependencies for testing (project convention: _deps over mock.module).
21
+ */
22
+ export const _deps = {
23
+ getRunsDir: () => process.env.NAX_RUNS_DIR ?? join(homedir(), ".nax", "runs"),
24
+ };
25
+
16
26
  /**
17
27
  * Options for logs command
18
28
  */
@@ -43,12 +53,84 @@ const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
43
53
  error: 3,
44
54
  };
45
55
 
56
+ /**
57
+ * Resolve log file path for a runId from the central registry (~/.nax/runs/).
58
+ *
59
+ * Scans all ~/.nax/runs/*\/meta.json entries for an exact or prefix match on runId.
60
+ * Returns the path to the matching run's JSONL file, or null if eventsDir/file is unavailable.
61
+ * Throws if the runId is not found in the registry at all.
62
+ *
63
+ * @param runId - Full or prefix run ID to look up
64
+ * @returns Absolute path to the JSONL log file, or null if unavailable
65
+ */
66
+ async function resolveRunFileFromRegistry(runId: string): Promise<string | null> {
67
+ const runsDir = _deps.getRunsDir();
68
+
69
+ let entries: string[];
70
+ try {
71
+ entries = await readdir(runsDir);
72
+ } catch {
73
+ throw new Error(`Run not found in registry: ${runId}`);
74
+ }
75
+
76
+ let matched: MetaJson | null = null;
77
+ for (const entry of entries) {
78
+ const metaPath = join(runsDir, entry, "meta.json");
79
+ try {
80
+ const meta: MetaJson = await Bun.file(metaPath).json();
81
+ if (meta.runId === runId || meta.runId.startsWith(runId)) {
82
+ matched = meta;
83
+ break;
84
+ }
85
+ } catch {
86
+ // skip unreadable meta.json entries
87
+ }
88
+ }
89
+
90
+ if (!matched) {
91
+ throw new Error(`Run not found in registry: ${runId}`);
92
+ }
93
+
94
+ if (!existsSync(matched.eventsDir)) {
95
+ console.log(`Log directory unavailable for run: ${runId}`);
96
+ return null;
97
+ }
98
+
99
+ const files = readdirSync(matched.eventsDir)
100
+ .filter((f) => f.endsWith(".jsonl") && f !== "latest.jsonl")
101
+ .sort()
102
+ .reverse();
103
+
104
+ if (files.length === 0) {
105
+ console.log(`No log files found for run: ${runId}`);
106
+ return null;
107
+ }
108
+
109
+ // Look for the specific run file by runId, fall back to newest
110
+ const specificFile = files.find((f) => f === `${matched.runId}.jsonl`);
111
+ return join(matched.eventsDir, specificFile ?? files[0]);
112
+ }
113
+
46
114
  /**
47
115
  * Display logs with filtering and formatting
48
116
  *
49
117
  * @param options - Command options
50
118
  */
51
119
  export async function logsCommand(options: LogsOptions): Promise<void> {
120
+ // When --run <runId> is provided, resolve via central registry
121
+ if (options.run) {
122
+ const runFile = await resolveRunFileFromRegistry(options.run);
123
+ if (!runFile) {
124
+ return;
125
+ }
126
+ if (options.follow) {
127
+ await followLogs(runFile, options);
128
+ } else {
129
+ await displayLogs(runFile, options);
130
+ }
131
+ return;
132
+ }
133
+
52
134
  // Resolve project directory
53
135
  const resolved = resolveProject({ dir: options.dir });
54
136
  const naxDir = join(resolved.projectDir, "nax");
@@ -77,8 +159,8 @@ export async function logsCommand(options: LogsOptions): Promise<void> {
77
159
  return;
78
160
  }
79
161
 
80
- // Determine which run to display
81
- const runFile = await selectRunFile(runsDir, options.run);
162
+ // Determine which run to display (latest by default — --run handled above via registry)
163
+ const runFile = await selectRunFile(runsDir);
82
164
 
83
165
  if (!runFile) {
84
166
  throw new Error("No runs found for this feature");
@@ -95,9 +177,9 @@ export async function logsCommand(options: LogsOptions): Promise<void> {
95
177
  }
96
178
 
97
179
  /**
98
- * Select which run file to display
180
+ * Select which run file to display (always returns the latest run)
99
181
  */
100
- async function selectRunFile(runsDir: string, runTimestamp?: string): Promise<string | null> {
182
+ async function selectRunFile(runsDir: string): Promise<string | null> {
101
183
  const files = readdirSync(runsDir)
102
184
  .filter((f) => f.endsWith(".jsonl") && f !== "latest.jsonl")
103
185
  .sort()
@@ -107,19 +189,7 @@ async function selectRunFile(runsDir: string, runTimestamp?: string): Promise<st
107
189
  return null;
108
190
  }
109
191
 
110
- // If no specific run requested, use latest
111
- if (!runTimestamp) {
112
- return join(runsDir, files[0]);
113
- }
114
-
115
- // Find matching run by partial timestamp
116
- const matchingFile = files.find((f) => f.startsWith(runTimestamp));
117
-
118
- if (!matchingFile) {
119
- throw new Error(`Run not found: ${runTimestamp}`);
120
- }
121
-
122
- return join(runsDir, matchingFile);
192
+ return join(runsDir, files[0]);
123
193
  }
124
194
 
125
195
  /**
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Runs Command
3
+ *
4
+ * Reads all ~/.nax/runs/*\/meta.json entries and displays a table of runs
5
+ * sorted by registeredAt descending. Resolves each statusPath to read the
6
+ * live NaxStatusFile for current state. Falls back to '[unavailable]' if
7
+ * the status file is missing or unreadable.
8
+ *
9
+ * Usage:
10
+ * nax runs [--project <name>] [--last <N>] [--status <status>]
11
+ */
12
+
13
+ import { readdir } from "node:fs/promises";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+ import chalk from "chalk";
17
+ import type { NaxStatusFile } from "../execution/status-file";
18
+ import type { MetaJson } from "../pipeline/subscribers/registry";
19
+
20
+ const DEFAULT_LIMIT = 20;
21
+
22
+ /**
23
+ * Swappable dependencies for testing (project convention: _deps over mock.module).
24
+ */
25
+ export const _deps = {
26
+ getRunsDir: () => join(homedir(), ".nax", "runs"),
27
+ };
28
+
29
+ export interface RunsOptions {
30
+ /** Filter by project name */
31
+ project?: string;
32
+ /** Limit number of runs displayed (default: 20) */
33
+ last?: number;
34
+ /** Filter by run status */
35
+ status?: "running" | "completed" | "failed" | "crashed";
36
+ }
37
+
38
+ interface RunRow {
39
+ runId: string;
40
+ project: string;
41
+ feature: string;
42
+ status: string;
43
+ passed: number;
44
+ total: number;
45
+ durationMs: number;
46
+ registeredAt: string;
47
+ }
48
+
49
+ /**
50
+ * Format duration in milliseconds to human-readable string.
51
+ */
52
+ function formatDuration(ms: number): string {
53
+ if (ms <= 0) return "-";
54
+ const minutes = Math.floor(ms / 60000);
55
+ const seconds = Math.floor((ms % 60000) / 1000);
56
+ if (minutes === 0) return `${seconds}s`;
57
+ return `${minutes}m ${seconds}s`;
58
+ }
59
+
60
+ /**
61
+ * Format ISO date to short local date string.
62
+ */
63
+ function formatDate(iso: string): string {
64
+ try {
65
+ const d = new Date(iso);
66
+ return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
67
+ } catch {
68
+ return iso;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Color a status string for terminal output.
74
+ */
75
+ function colorStatus(status: string): string {
76
+ switch (status) {
77
+ case "completed":
78
+ return chalk.green(status);
79
+ case "failed":
80
+ return chalk.red(status);
81
+ case "running":
82
+ return chalk.yellow(status);
83
+ case "[unavailable]":
84
+ return chalk.dim(status);
85
+ default:
86
+ return chalk.dim(status);
87
+ }
88
+ }
89
+
90
+ /** Regex that matches ANSI escape sequences. */
91
+ const ANSI_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
92
+
93
+ /** Strip ANSI escape codes to compute visible string length. */
94
+ function visibleLength(str: string): number {
95
+ return str.replace(ANSI_RE, "").length;
96
+ }
97
+
98
+ /**
99
+ * Pad a string to a fixed width (left-aligned).
100
+ */
101
+ function pad(str: string, width: number): string {
102
+ const padding = Math.max(0, width - visibleLength(str));
103
+ return str + " ".repeat(padding);
104
+ }
105
+
106
+ /**
107
+ * Display all registered runs from ~/.nax/runs/ as a table.
108
+ *
109
+ * @param options - Filter and limit options
110
+ */
111
+ export async function runsCommand(options: RunsOptions = {}): Promise<void> {
112
+ const runsDir = _deps.getRunsDir();
113
+
114
+ let entries: string[];
115
+ try {
116
+ entries = await readdir(runsDir);
117
+ } catch {
118
+ console.log("No runs found.");
119
+ return;
120
+ }
121
+
122
+ const rows: RunRow[] = [];
123
+
124
+ for (const entry of entries) {
125
+ const metaPath = join(runsDir, entry, "meta.json");
126
+ let meta: MetaJson;
127
+ try {
128
+ meta = await Bun.file(metaPath).json();
129
+ } catch {
130
+ continue;
131
+ }
132
+
133
+ // Apply project filter early
134
+ if (options.project && meta.project !== options.project) continue;
135
+
136
+ // Read live status file
137
+ let statusData: NaxStatusFile | null = null;
138
+ try {
139
+ statusData = await Bun.file(meta.statusPath).json();
140
+ } catch {
141
+ // statusPath missing or unreadable — handled gracefully below
142
+ }
143
+
144
+ const runStatus = statusData ? statusData.run.status : "[unavailable]";
145
+
146
+ // Apply status filter
147
+ if (options.status && runStatus !== options.status) continue;
148
+
149
+ rows.push({
150
+ runId: meta.runId,
151
+ project: meta.project,
152
+ feature: meta.feature,
153
+ status: runStatus,
154
+ passed: statusData?.progress.passed ?? 0,
155
+ total: statusData?.progress.total ?? 0,
156
+ durationMs: statusData?.durationMs ?? 0,
157
+ registeredAt: meta.registeredAt,
158
+ });
159
+ }
160
+
161
+ // Sort newest first
162
+ rows.sort((a, b) => new Date(b.registeredAt).getTime() - new Date(a.registeredAt).getTime());
163
+
164
+ // Apply limit
165
+ const limit = options.last ?? DEFAULT_LIMIT;
166
+ const displayed = rows.slice(0, limit);
167
+
168
+ if (displayed.length === 0) {
169
+ console.log("No runs found.");
170
+ return;
171
+ }
172
+
173
+ // Column widths (minimum per header)
174
+ const COL = {
175
+ runId: Math.max(6, ...displayed.map((r) => r.runId.length)),
176
+ project: Math.max(7, ...displayed.map((r) => r.project.length)),
177
+ feature: Math.max(7, ...displayed.map((r) => r.feature.length)),
178
+ status: Math.max(6, ...displayed.map((r) => visibleLength(r.status))),
179
+ stories: 7,
180
+ duration: 8,
181
+ date: 11,
182
+ };
183
+
184
+ // Header
185
+ const header = [
186
+ pad(chalk.bold("RUN ID"), COL.runId),
187
+ pad(chalk.bold("PROJECT"), COL.project),
188
+ pad(chalk.bold("FEATURE"), COL.feature),
189
+ pad(chalk.bold("STATUS"), COL.status),
190
+ pad(chalk.bold("STORIES"), COL.stories),
191
+ pad(chalk.bold("DURATION"), COL.duration),
192
+ chalk.bold("DATE"),
193
+ ].join(" ");
194
+
195
+ console.log();
196
+ console.log(header);
197
+ console.log(
198
+ chalk.dim("-".repeat(COL.runId + COL.project + COL.feature + COL.status + COL.stories + COL.duration + 11 + 12)),
199
+ );
200
+
201
+ for (const row of displayed) {
202
+ const colored = colorStatus(row.status);
203
+ const line = [
204
+ pad(row.runId, COL.runId),
205
+ pad(row.project, COL.project),
206
+ pad(row.feature, COL.feature),
207
+ pad(colored, COL.status + (colored.length - visibleLength(colored))),
208
+ pad(`${row.passed}/${row.total}`, COL.stories),
209
+ pad(formatDuration(row.durationMs), COL.duration),
210
+ formatDate(row.registeredAt),
211
+ ].join(" ");
212
+ console.log(line);
213
+ }
214
+
215
+ console.log();
216
+ if (rows.length > limit) {
217
+ console.log(chalk.dim(`Showing ${limit} of ${rows.length} runs. Use --last <N> to see more.`));
218
+ console.log();
219
+ }
220
+ }
@@ -309,7 +309,9 @@ export interface InteractionConfig {
309
309
  fallback: "continue" | "skip" | "escalate" | "abort";
310
310
  };
311
311
  /** Enable/disable built-in triggers */
312
- triggers: Partial<Record<string, boolean | { enabled: boolean; fallback?: string; timeout?: number }>>;
312
+ triggers: Partial<
313
+ Record<string, boolean | { enabled: boolean; fallback?: string; timeout?: number; threshold?: number }>
314
+ >;
313
315
  }
314
316
 
315
317
  /** Test coverage context config */
@@ -32,6 +32,8 @@ export interface CrashRecoveryContext {
32
32
  getStartTime?: () => number;
33
33
  getTotalStories?: () => number;
34
34
  getStoriesCompleted?: () => number;
35
+ /** Optional callback to emit run:errored event (fire-and-forget) */
36
+ emitError?: (reason: string) => void;
35
37
  }
36
38
 
37
39
  /**
@@ -171,6 +173,9 @@ export function installCrashHandlers(ctx: CrashRecoveryContext): () => void {
171
173
  await ctx.pidRegistry.killAll();
172
174
  }
173
175
 
176
+ // Emit run:errored event (fire-and-forget)
177
+ ctx.emitError?.(signal.toLowerCase());
178
+
174
179
  // Write fatal log
175
180
  await writeFatalLog(ctx.jsonlFilePath, signal);
176
181
 
@@ -209,6 +214,9 @@ export function installCrashHandlers(ctx: CrashRecoveryContext): () => void {
209
214
  await ctx.pidRegistry.killAll();
210
215
  }
211
216
 
217
+ // Emit run:errored event (fire-and-forget)
218
+ ctx.emitError?.("uncaughtException");
219
+
212
220
  // Write fatal log with stack trace
213
221
  await writeFatalLog(ctx.jsonlFilePath, "uncaughtException", error);
214
222
 
@@ -242,6 +250,9 @@ export function installCrashHandlers(ctx: CrashRecoveryContext): () => void {
242
250
  await ctx.pidRegistry.killAll();
243
251
  }
244
252
 
253
+ // Emit run:errored event (fire-and-forget)
254
+ ctx.emitError?.("unhandledRejection");
255
+
245
256
  // Write fatal log
246
257
  await writeFatalLog(ctx.jsonlFilePath, "unhandledRejection", error);
247
258
 
@@ -40,7 +40,7 @@ export interface SequentialExecutionResult {
40
40
  storiesCompleted: number;
41
41
  totalCost: number;
42
42
  allStoryMetrics: StoryMetrics[];
43
- exitReason: "completed" | "cost-limit" | "max-iterations" | "stalled" | "no-stories";
43
+ exitReason: "completed" | "cost-limit" | "max-iterations" | "stalled" | "no-stories" | "pre-merge-aborted";
44
44
  }
45
45
 
46
46
  /**
@@ -21,6 +21,7 @@ import { fireHook } from "../../hooks";
21
21
  import type { InteractionChain } from "../../interaction";
22
22
  import { initInteractionChain } from "../../interaction";
23
23
  import { getSafeLogger } from "../../logger";
24
+ import { pipelineEventBus } from "../../pipeline/event-bus";
24
25
  import { loadPlugins } from "../../plugins/loader";
25
26
  import type { PluginRegistry } from "../../plugins/registry";
26
27
  import type { PRD } from "../../prd";
@@ -123,6 +124,9 @@ export async function setupRun(options: RunSetupOptions): Promise<RunSetupResult
123
124
  getStartTime: () => options.startTime,
124
125
  getTotalStories: options.getTotalStories,
125
126
  getStoriesCompleted: options.getStoriesCompleted,
127
+ emitError: (reason: string) => {
128
+ pipelineEventBus.emit({ type: "run:errored", reason, feature: options.feature });
129
+ },
126
130
  });
127
131
 
128
132
  // Load PRD (before try block so it's accessible in finally for onRunEnd)
@@ -1,12 +1,15 @@
1
1
  /** Sequential Story Executor (ADR-005, Phase 4) — main execution loop. */
2
2
 
3
+ import { checkCostExceeded, checkCostWarning, checkPreMerge, isTriggerEnabled } from "../interaction/triggers";
3
4
  import { getSafeLogger } from "../logger";
4
5
  import type { StoryMetrics } from "../metrics";
5
6
  import { pipelineEventBus } from "../pipeline/event-bus";
6
7
  import { runPipeline } from "../pipeline/runner";
7
8
  import { postRunPipeline } from "../pipeline/stages";
9
+ import { wireEventsWriter } from "../pipeline/subscribers/events-writer";
8
10
  import { wireHooks } from "../pipeline/subscribers/hooks";
9
11
  import { wireInteraction } from "../pipeline/subscribers/interaction";
12
+ import { wireRegistry } from "../pipeline/subscribers/registry";
10
13
  import { wireReporters } from "../pipeline/subscribers/reporters";
11
14
  import type { PipelineContext } from "../pipeline/types";
12
15
  import { generateHumanHaltSummary, isComplete, isStalled, loadPRD } from "../prd";
@@ -33,11 +36,14 @@ export async function executeSequential(
33
36
  0,
34
37
  ];
35
38
  const allStoryMetrics: StoryMetrics[] = [];
39
+ let warningSent = false;
36
40
 
37
41
  pipelineEventBus.clear();
38
42
  wireHooks(pipelineEventBus, ctx.hooks, ctx.workdir, ctx.feature);
39
43
  wireReporters(pipelineEventBus, ctx.pluginRegistry, ctx.runId, ctx.startTime);
40
44
  wireInteraction(pipelineEventBus, ctx.interactionChain, ctx.config);
45
+ wireEventsWriter(pipelineEventBus, ctx.feature, ctx.runId, ctx.workdir);
46
+ wireRegistry(pipelineEventBus, ctx.feature, ctx.runId, ctx.workdir);
41
47
 
42
48
  const buildResult = (exitReason: SequentialExecutionResult["exitReason"]): SequentialExecutionResult => ({
43
49
  prd,
@@ -65,6 +71,17 @@ export async function executeSequential(
65
71
  prdDirty = false;
66
72
  }
67
73
  if (isComplete(prd)) {
74
+ // pre-merge trigger: prompt before completing the run
75
+ if (ctx.interactionChain && isTriggerEnabled("pre-merge", ctx.config)) {
76
+ const shouldProceed = await checkPreMerge(
77
+ { featureName: ctx.feature, totalStories: prd.userStories.length, cost: totalCost },
78
+ ctx.config,
79
+ ctx.interactionChain,
80
+ );
81
+ if (!shouldProceed) {
82
+ return buildResult("pre-merge-aborted");
83
+ }
84
+ }
68
85
  pipelineEventBus.emit({
69
86
  type: "run:completed",
70
87
  totalStories: 0,
@@ -87,13 +104,24 @@ export async function executeSequential(
87
104
  if (!ctx.useBatch) lastStoryId = selection.story.id;
88
105
 
89
106
  if (totalCost >= ctx.config.execution.costLimit) {
90
- pipelineEventBus.emit({
91
- type: "run:paused",
92
- reason: `Cost limit reached: $${totalCost.toFixed(2)}`,
93
- storyId: selection.story.id,
94
- cost: totalCost,
95
- });
96
- return buildResult("cost-limit");
107
+ const shouldProceed =
108
+ ctx.interactionChain && isTriggerEnabled("cost-exceeded", ctx.config)
109
+ ? await checkCostExceeded(
110
+ { featureName: ctx.feature, cost: totalCost, limit: ctx.config.execution.costLimit },
111
+ ctx.config,
112
+ ctx.interactionChain,
113
+ )
114
+ : false;
115
+ if (!shouldProceed) {
116
+ pipelineEventBus.emit({
117
+ type: "run:paused",
118
+ reason: `Cost limit reached: $${totalCost.toFixed(2)}`,
119
+ storyId: selection.story.id,
120
+ cost: totalCost,
121
+ });
122
+ return buildResult("cost-limit");
123
+ }
124
+ pipelineEventBus.emit({ type: "run:resumed", feature: ctx.feature });
97
125
  }
98
126
 
99
127
  pipelineEventBus.emit({
@@ -114,6 +142,20 @@ export async function executeSequential(
114
142
  iter.prdDirty,
115
143
  ];
116
144
 
145
+ if (ctx.interactionChain && isTriggerEnabled("cost-warning", ctx.config) && !warningSent) {
146
+ const costLimit = ctx.config.execution.costLimit;
147
+ const triggerCfg = ctx.config.interaction?.triggers?.["cost-warning"];
148
+ const threshold = typeof triggerCfg === "object" ? (triggerCfg.threshold ?? 0.8) : 0.8;
149
+ if (totalCost >= costLimit * threshold) {
150
+ await checkCostWarning(
151
+ { featureName: ctx.feature, cost: totalCost, limit: costLimit },
152
+ ctx.config,
153
+ ctx.interactionChain,
154
+ );
155
+ warningSent = true;
156
+ }
157
+ }
158
+
117
159
  if (iter.prdDirty) {
118
160
  prd = await loadPRD(ctx.prdPath);
119
161
  prdDirty = false;
@@ -38,6 +38,14 @@ interface DecisionResponse {
38
38
  reasoning: string;
39
39
  }
40
40
 
41
+ /**
42
+ * Module-level deps for testability (_deps pattern).
43
+ * Override callLlm in tests to avoid spawning the claude CLI.
44
+ */
45
+ export const _deps = {
46
+ callLlm: null as ((request: InteractionRequest) => Promise<DecisionResponse>) | null,
47
+ };
48
+
41
49
  /**
42
50
  * Auto plugin for AI-powered interaction responses
43
51
  */
@@ -80,7 +88,8 @@ export class AutoInteractionPlugin implements InteractionPlugin {
80
88
  }
81
89
 
82
90
  try {
83
- const decision = await this.callLlm(request);
91
+ const callFn = _deps.callLlm ?? this.callLlm.bind(this);
92
+ const decision = await callFn(request);
84
93
 
85
94
  // Check confidence threshold
86
95
  if (decision.confidence < (this.config.confidenceThreshold ?? 0.7)) {
@@ -135,6 +135,17 @@ export interface StoryPausedEvent {
135
135
  cost: number;
136
136
  }
137
137
 
138
+ export interface RunResumedEvent {
139
+ type: "run:resumed";
140
+ feature: string;
141
+ }
142
+
143
+ export interface RunErroredEvent {
144
+ type: "run:errored";
145
+ reason: string;
146
+ feature?: string;
147
+ }
148
+
138
149
  /** Discriminated union of all pipeline events. */
139
150
  export type PipelineEvent =
140
151
  | StoryStartedEvent
@@ -150,7 +161,9 @@ export type PipelineEvent =
150
161
  | HumanReviewRequestedEvent
151
162
  | RunStartedEvent
152
163
  | RunPausedEvent
153
- | StoryPausedEvent;
164
+ | StoryPausedEvent
165
+ | RunResumedEvent
166
+ | RunErroredEvent;
154
167
 
155
168
  export type PipelineEventType = PipelineEvent["type"];
156
169
 
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { appendProgress } from "../../execution/progress";
16
+ import { checkReviewGate, isTriggerEnabled } from "../../interaction/triggers";
16
17
  import { getLogger } from "../../logger";
17
18
  import { collectBatchMetrics, collectStoryMetrics } from "../../metrics";
18
19
  import { countStories, markStoryPassed, savePRD } from "../../prd";
@@ -72,6 +73,18 @@ export const completionStage: PipelineStage = {
72
73
  modelTier: ctx.routing?.modelTier,
73
74
  testStrategy: ctx.routing?.testStrategy,
74
75
  });
76
+
77
+ // review-gate trigger: check if story needs re-review after passing
78
+ if (ctx.interaction && isTriggerEnabled("review-gate", ctx.config)) {
79
+ const shouldContinue = await _completionDeps.checkReviewGate(
80
+ { featureName: ctx.prd.feature, storyId: completedStory.id },
81
+ ctx.config,
82
+ ctx.interaction,
83
+ );
84
+ if (!shouldContinue) {
85
+ logger.warn("completion", "Story marked for re-review", { storyId: completedStory.id });
86
+ }
87
+ }
75
88
  }
76
89
 
77
90
  // Save PRD
@@ -89,3 +102,10 @@ export const completionStage: PipelineStage = {
89
102
  return { action: "continue" };
90
103
  },
91
104
  };
105
+
106
+ /**
107
+ * Swappable dependencies for testing (avoids mock.module() which leaks in Bun 1.x).
108
+ */
109
+ export const _completionDeps = {
110
+ checkReviewGate,
111
+ };