@nathapp/nax 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/nax.ts CHANGED
@@ -62,6 +62,7 @@ import { generateCommand } from "../src/cli/generate";
62
62
  import { diagnose } from "../src/commands/diagnose";
63
63
  import { logsCommand } from "../src/commands/logs";
64
64
  import { precheckCommand } from "../src/commands/precheck";
65
+ import { runsCommand } from "../src/commands/runs";
65
66
  import { unlockCommand } from "../src/commands/unlock";
66
67
  import { DEFAULT_CONFIG, findProjectDir, loadConfig, validateDirectory } from "../src/config";
67
68
  import { run } from "../src/execution";
@@ -685,7 +686,7 @@ program
685
686
  .option("-s, --story <id>", "Filter to specific story")
686
687
  .option("--level <level>", "Filter by log level (debug|info|warn|error)")
687
688
  .option("-l, --list", "List all runs in table format", false)
688
- .option("-r, --run <timestamp>", "Select specific run by timestamp")
689
+ .option("-r, --run <runId>", "Select run by run ID from central registry (global)")
689
690
  .option("-j, --json", "Output raw JSONL", false)
690
691
  .action(async (options) => {
691
692
  let workdir: string;
@@ -773,7 +774,24 @@ program
773
774
  });
774
775
 
775
776
  // ── runs ─────────────────────────────────────────────
776
- const runs = program.command("runs").description("Manage and view run history");
777
+ const runs = program
778
+ .command("runs")
779
+ .description("Show all registered runs from the central registry (~/.nax/runs/)")
780
+ .option("--project <name>", "Filter by project name")
781
+ .option("--last <N>", "Limit to N most recent runs (default: 20)")
782
+ .option("--status <status>", "Filter by status (running|completed|failed|crashed)")
783
+ .action(async (options) => {
784
+ try {
785
+ await runsCommand({
786
+ project: options.project,
787
+ last: options.last !== undefined ? Number.parseInt(options.last, 10) : undefined,
788
+ status: options.status,
789
+ });
790
+ } catch (err) {
791
+ console.error(chalk.red(`Error: ${(err as Error).message}`));
792
+ process.exit(1);
793
+ }
794
+ });
777
795
 
778
796
  runs
779
797
  .command("list")
@@ -0,0 +1,105 @@
1
+ {
2
+ "project": "nax",
3
+ "branchName": "feat/central-run-registry",
4
+ "feature": "central-run-registry",
5
+ "version": "0.24.0",
6
+ "description": "Global run index across all projects. Events file writer for machine-readable lifecycle events, registry subscriber that indexes every run to ~/.nax/runs/, nax runs CLI for cross-project run history, and nax logs --run <runId> for global log resolution.",
7
+ "userStories": [
8
+ {
9
+ "id": "CRR-000",
10
+ "title": "Events file writer subscriber",
11
+ "description": "New subscriber: src/pipeline/subscribers/events-writer.ts \u2014 wireEventsWriter(bus, project, feature, runId, workdir). Listens to run:started, story:started, story:completed, story:failed, run:completed, run:paused. Writes one JSON line per event to ~/.nax/events/<project>/events.jsonl (append mode). Each line: {\"ts\": ISO8601, \"event\": string, \"runId\": string, \"feature\": string, \"project\": string, \"storyId\"?: string, \"data\"?: object}. The run:completed event writes an 'on-complete' entry \u2014 the machine-readable signal that nax exited gracefully (fixes watchdog false crash reports). Best-effort: wrap all writes in try/catch, log warnings on failure, never throw or block the pipeline. Create ~/.nax/events/<project>/ on first write via mkdir recursive. Derive project name from path.basename(workdir). Wire in sequential-executor.ts alongside existing wireHooks/wireReporters/wireInteraction calls. Return UnsubscribeFn matching existing subscriber pattern.",
12
+ "complexity": "medium",
13
+ "status": "pending",
14
+ "acceptanceCriteria": [
15
+ "After a run, ~/.nax/events/<project>/events.jsonl exists with JSONL entries",
16
+ "Each line is valid JSON with ts, event, runId, feature, project fields",
17
+ "run:completed produces an entry with event=on-complete",
18
+ "story:started/completed/failed events include storyId",
19
+ "Write failure does not crash or block the nax run",
20
+ "Directory is created automatically on first write",
21
+ "wireEventsWriter is called in sequential-executor.ts",
22
+ "Returns UnsubscribeFn consistent with wireHooks/wireReporters pattern"
23
+ ],
24
+ "attempts": 0,
25
+ "priorErrors": [],
26
+ "priorFailures": [],
27
+ "escalations": [],
28
+ "dependencies": [],
29
+ "tags": [],
30
+ "storyPoints": 2
31
+ },
32
+ {
33
+ "id": "CRR-001",
34
+ "title": "Registry writer subscriber",
35
+ "description": "New subscriber: src/pipeline/subscribers/registry.ts \u2014 wireRegistry(bus, project, feature, runId, workdir). Listens to run:started. On event, creates ~/.nax/runs/<project>-<feature>-<runId>/meta.json with fields: {runId, project, feature, workdir, statusPath: join(workdir, 'nax/features', feature, 'status.json'), eventsDir: join(workdir, 'nax/features', feature, 'runs'), registeredAt: ISO8601}. meta.json schema exported as MetaJson interface. Written once, never updated. Best-effort: try/catch + warn log, never throw/block. Create ~/.nax/runs/ directory on first call via mkdir recursive. Derive project from path.basename(workdir). Wire in sequential-executor.ts alongside wireEventsWriter. Return UnsubscribeFn.",
36
+ "complexity": "medium",
37
+ "status": "pending",
38
+ "acceptanceCriteria": [
39
+ "After run start, ~/.nax/runs/<project>-<feature>-<runId>/meta.json exists",
40
+ "meta.json contains runId, project, feature, workdir, statusPath, eventsDir, registeredAt",
41
+ "statusPath points to <workdir>/nax/features/<feature>/status.json",
42
+ "eventsDir points to <workdir>/nax/features/<feature>/runs",
43
+ "MetaJson interface is exported from the module",
44
+ "Write failure does not crash or block the nax run",
45
+ "wireRegistry is called in sequential-executor.ts"
46
+ ],
47
+ "attempts": 0,
48
+ "priorErrors": [],
49
+ "priorFailures": [],
50
+ "escalations": [],
51
+ "dependencies": [],
52
+ "tags": [],
53
+ "storyPoints": 2
54
+ },
55
+ {
56
+ "id": "CRR-002",
57
+ "title": "nax runs CLI command",
58
+ "description": "New command: src/commands/runs.ts \u2014 runsCommand(options). Reads all ~/.nax/runs/*/meta.json, resolves each statusPath to read the live NaxStatusFile for current state. Displays a table sorted by registeredAt desc. Columns: RUN ID, PROJECT, FEATURE, STATUS, STORIES (passed/total), DURATION, DATE. Default limit: 20. Options: --project <name> (filter), --last <N> (limit), --status <running|completed|failed|crashed> (filter). If statusPath missing, show status as '[unavailable]'. Register in src/commands/index.ts and wire in bin/nax.ts CLI arg parser (match pattern of existing diagnose/logs/status commands). Use chalk for colored output. Green for completed, red for failed, yellow for running, dim for unavailable.",
59
+ "complexity": "medium",
60
+ "status": "pending",
61
+ "acceptanceCriteria": [
62
+ "nax runs displays a table of all registered runs sorted newest-first",
63
+ "nax runs --project <name> filters by project name",
64
+ "nax runs --last <N> limits output to N runs",
65
+ "nax runs --status <status> filters by run status",
66
+ "Runs with missing statusPath show '[unavailable]' status gracefully",
67
+ "Empty registry shows 'No runs found' message",
68
+ "Command is registered in CLI help output and bin/nax.ts"
69
+ ],
70
+ "attempts": 0,
71
+ "priorErrors": [],
72
+ "priorFailures": [],
73
+ "escalations": [],
74
+ "dependencies": [
75
+ "CRR-001"
76
+ ],
77
+ "tags": [],
78
+ "storyPoints": 3
79
+ },
80
+ {
81
+ "id": "CRR-003",
82
+ "title": "nax logs --run <runId> global resolution",
83
+ "description": "Enhance src/commands/logs.ts logsCommand: when --run <runId> is provided, scan ~/.nax/runs/*/meta.json for matching runId (exact or prefix match). Read eventsDir from meta.json, locate the JSONL log file in that directory (newest .jsonl file), then display using existing displayLogs/followLogs functions. Falls back to current behavior (local feature context) when --run is not specified. If runId not found in registry, throw error Run not found in registry: <runId>. If eventsDir or log file missing, show unavailable message. Update LogsOptions interface to accept run?: string. Update bin/nax.ts to pass --run option through.",
84
+ "complexity": "medium",
85
+ "status": "pending",
86
+ "acceptanceCriteria": [
87
+ "nax logs --run <runId> displays logs from the matching run regardless of cwd",
88
+ "Resolution uses ~/.nax/runs/*/meta.json to find eventsDir path",
89
+ "Unknown runId shows clear error message",
90
+ "Missing log files show unavailable message",
91
+ "Without --run flag, existing local behavior is unchanged",
92
+ "Partial runId matching works (prefix match on runId field)"
93
+ ],
94
+ "attempts": 0,
95
+ "priorErrors": [],
96
+ "priorFailures": [],
97
+ "escalations": [],
98
+ "dependencies": [
99
+ "CRR-001"
100
+ ],
101
+ "tags": [],
102
+ "storyPoints": 2
103
+ }
104
+ ]
105
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.23.0",
4
- "description": "AI Coding Agent Orchestrator loops until done",
3
+ "version": "0.24.0",
4
+ "description": "AI Coding Agent Orchestrator \u2014 loops until done",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "nax": "./bin/nax.ts"
@@ -5,4 +5,5 @@
5
5
  export { resolveProject, type ResolveProjectOptions, type ResolvedProject } from "./common";
6
6
  export { logsCommand, type LogsOptions } from "./logs";
7
7
  export { precheckCommand, type PrecheckOptions } from "./precheck";
8
+ export { runsCommand, type RunsOptions } from "./runs";
8
9
  export { unlockCommand, type UnlockOptions } from "./unlock";
@@ -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
+ }
@@ -5,8 +5,10 @@ import type { StoryMetrics } from "../metrics";
5
5
  import { pipelineEventBus } from "../pipeline/event-bus";
6
6
  import { runPipeline } from "../pipeline/runner";
7
7
  import { postRunPipeline } from "../pipeline/stages";
8
+ import { wireEventsWriter } from "../pipeline/subscribers/events-writer";
8
9
  import { wireHooks } from "../pipeline/subscribers/hooks";
9
10
  import { wireInteraction } from "../pipeline/subscribers/interaction";
11
+ import { wireRegistry } from "../pipeline/subscribers/registry";
10
12
  import { wireReporters } from "../pipeline/subscribers/reporters";
11
13
  import type { PipelineContext } from "../pipeline/types";
12
14
  import { generateHumanHaltSummary, isComplete, isStalled, loadPRD } from "../prd";
@@ -38,6 +40,8 @@ export async function executeSequential(
38
40
  wireHooks(pipelineEventBus, ctx.hooks, ctx.workdir, ctx.feature);
39
41
  wireReporters(pipelineEventBus, ctx.pluginRegistry, ctx.runId, ctx.startTime);
40
42
  wireInteraction(pipelineEventBus, ctx.interactionChain, ctx.config);
43
+ wireEventsWriter(pipelineEventBus, ctx.feature, ctx.runId, ctx.workdir);
44
+ wireRegistry(pipelineEventBus, ctx.feature, ctx.runId, ctx.workdir);
41
45
 
42
46
  const buildResult = (exitReason: SequentialExecutionResult["exitReason"]): SequentialExecutionResult => ({
43
47
  prd,
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Events Writer Subscriber
3
+ *
4
+ * Appends one JSON line per pipeline lifecycle event to
5
+ * ~/.nax/events/<project>/events.jsonl. Provides a machine-readable
6
+ * signal that nax exited gracefully (run:completed → event=on-complete),
7
+ * fixing watchdog false crash reports.
8
+ *
9
+ * Design:
10
+ * - Best-effort: all writes are wrapped in try/catch; never throws or blocks
11
+ * - Directory is created on first write via mkdir recursive
12
+ * - Returns UnsubscribeFn matching wireHooks/wireReporters pattern
13
+ */
14
+
15
+ import { appendFile, mkdir } from "node:fs/promises";
16
+ import { homedir } from "node:os";
17
+ import { basename, join } from "node:path";
18
+ import { getSafeLogger } from "../../logger";
19
+ import type { PipelineEventBus } from "../event-bus";
20
+ import type { UnsubscribeFn } from "./hooks";
21
+
22
+ interface EventLine {
23
+ ts: string;
24
+ event: string;
25
+ runId: string;
26
+ feature: string;
27
+ project: string;
28
+ storyId?: string;
29
+ data?: object;
30
+ }
31
+
32
+ /**
33
+ * Wire events file writer to the pipeline event bus.
34
+ *
35
+ * Listens to run:started, story:started, story:completed, story:failed,
36
+ * run:completed, run:paused and appends one JSONL entry per event.
37
+ *
38
+ * @param bus - The pipeline event bus
39
+ * @param feature - Feature name
40
+ * @param runId - Current run ID
41
+ * @param workdir - Working directory (project name derived via basename)
42
+ * @returns Unsubscribe function
43
+ */
44
+ export function wireEventsWriter(
45
+ bus: PipelineEventBus,
46
+ feature: string,
47
+ runId: string,
48
+ workdir: string,
49
+ ): UnsubscribeFn {
50
+ const logger = getSafeLogger();
51
+ const project = basename(workdir);
52
+ const eventsDir = join(homedir(), ".nax", "events", project);
53
+ const eventsFile = join(eventsDir, "events.jsonl");
54
+ let dirReady = false;
55
+
56
+ const write = (line: EventLine): void => {
57
+ (async () => {
58
+ try {
59
+ if (!dirReady) {
60
+ await mkdir(eventsDir, { recursive: true });
61
+ dirReady = true;
62
+ }
63
+ await appendFile(eventsFile, `${JSON.stringify(line)}\n`);
64
+ } catch (err) {
65
+ logger?.warn("events-writer", "Failed to write event line (non-fatal)", {
66
+ event: line.event,
67
+ error: String(err),
68
+ });
69
+ }
70
+ })();
71
+ };
72
+
73
+ const unsubs: UnsubscribeFn[] = [];
74
+
75
+ unsubs.push(
76
+ bus.on("run:started", (_ev) => {
77
+ write({ ts: new Date().toISOString(), event: "run:started", runId, feature, project });
78
+ }),
79
+ );
80
+
81
+ unsubs.push(
82
+ bus.on("story:started", (ev) => {
83
+ write({ ts: new Date().toISOString(), event: "story:started", runId, feature, project, storyId: ev.storyId });
84
+ }),
85
+ );
86
+
87
+ unsubs.push(
88
+ bus.on("story:completed", (ev) => {
89
+ write({ ts: new Date().toISOString(), event: "story:completed", runId, feature, project, storyId: ev.storyId });
90
+ }),
91
+ );
92
+
93
+ unsubs.push(
94
+ bus.on("story:failed", (ev) => {
95
+ write({ ts: new Date().toISOString(), event: "story:failed", runId, feature, project, storyId: ev.storyId });
96
+ }),
97
+ );
98
+
99
+ unsubs.push(
100
+ bus.on("run:completed", (_ev) => {
101
+ write({ ts: new Date().toISOString(), event: "on-complete", runId, feature, project });
102
+ }),
103
+ );
104
+
105
+ unsubs.push(
106
+ bus.on("run:paused", (ev) => {
107
+ write({
108
+ ts: new Date().toISOString(),
109
+ event: "run:paused",
110
+ runId,
111
+ feature,
112
+ project,
113
+ ...(ev.storyId !== undefined && { storyId: ev.storyId }),
114
+ });
115
+ }),
116
+ );
117
+
118
+ return () => {
119
+ for (const u of unsubs) u();
120
+ };
121
+ }