@nathapp/nax 0.22.4 → 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.
Files changed (32) hide show
  1. package/bin/nax.ts +20 -2
  2. package/docs/tdd/strategies.md +97 -0
  3. package/nax/features/central-run-registry/prd.json +105 -0
  4. package/nax/features/diagnose/acceptance.test.ts +3 -1
  5. package/package.json +3 -3
  6. package/src/cli/diagnose.ts +1 -1
  7. package/src/cli/status-features.ts +55 -7
  8. package/src/commands/index.ts +1 -0
  9. package/src/commands/logs.ts +87 -17
  10. package/src/commands/runs.ts +220 -0
  11. package/src/config/schemas.ts +3 -0
  12. package/src/execution/crash-recovery.ts +30 -7
  13. package/src/execution/lifecycle/run-setup.ts +6 -1
  14. package/src/execution/runner.ts +8 -0
  15. package/src/execution/sequential-executor.ts +4 -0
  16. package/src/execution/status-writer.ts +42 -0
  17. package/src/pipeline/subscribers/events-writer.ts +121 -0
  18. package/src/pipeline/subscribers/registry.ts +73 -0
  19. package/src/version.ts +23 -0
  20. package/test/e2e/plan-analyze-run.test.ts +5 -0
  21. package/test/integration/cli/cli-diagnose.test.ts +3 -1
  22. package/test/integration/cli/cli-logs.test.ts +40 -17
  23. package/test/integration/execution/feature-status-write.test.ts +302 -0
  24. package/test/integration/execution/status-file-integration.test.ts +1 -1
  25. package/test/integration/execution/status-writer.test.ts +112 -0
  26. package/test/unit/cli-status-project-level.test.ts +283 -0
  27. package/test/unit/commands/logs.test.ts +63 -22
  28. package/test/unit/commands/runs.test.ts +303 -0
  29. package/test/unit/config/quality-commands-schema.test.ts +72 -0
  30. package/test/unit/execution/sfc-004-dead-code-cleanup.test.ts +89 -0
  31. package/test/unit/pipeline/subscribers/events-writer.test.ts +227 -0
  32. package/test/unit/pipeline/subscribers/registry.test.ts +149 -0
@@ -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
+ }
@@ -117,6 +117,9 @@ const QualityConfigSchema = z.object({
117
117
  typecheck: z.string().optional(),
118
118
  lint: z.string().optional(),
119
119
  test: z.string().optional(),
120
+ testScoped: z.string().optional(),
121
+ lintFix: z.string().optional(),
122
+ formatFix: z.string().optional(),
120
123
  }),
121
124
  forceExit: z.boolean().default(false),
122
125
  detectOpenHandles: z.boolean().default(true),
@@ -27,6 +27,8 @@ export interface CrashRecoveryContext {
27
27
  // BUG-017: Additional context for run.complete event on SIGTERM
28
28
  runId?: string;
29
29
  feature?: string;
30
+ // SFC-002: Feature directory for writing feature-level status on crash
31
+ featureDir?: string;
30
32
  getStartTime?: () => number;
31
33
  getTotalStories?: () => number;
32
34
  getStoriesCompleted?: () => number;
@@ -115,13 +117,14 @@ async function writeRunComplete(ctx: CrashRecoveryContext, exitReason: string):
115
117
  }
116
118
 
117
119
  /**
118
- * Update status.json to "crashed" state
120
+ * Update status.json to "crashed" state (both project-level and feature-level)
119
121
  */
120
122
  async function updateStatusToCrashed(
121
123
  statusWriter: StatusWriter,
122
124
  totalCost: number,
123
125
  iterations: number,
124
126
  signal: string,
127
+ featureDir?: string,
125
128
  ): Promise<void> {
126
129
  try {
127
130
  statusWriter.setRunStatus("crashed");
@@ -129,6 +132,14 @@ async function updateStatusToCrashed(
129
132
  crashedAt: new Date().toISOString(),
130
133
  crashSignal: signal,
131
134
  });
135
+
136
+ // Write feature-level status (SFC-002)
137
+ if (featureDir) {
138
+ await statusWriter.writeFeatureStatus(featureDir, totalCost, iterations, {
139
+ crashedAt: new Date().toISOString(),
140
+ crashSignal: signal,
141
+ });
142
+ }
132
143
  } catch (err) {
133
144
  console.error("[crash-recovery] Failed to update status.json:", err);
134
145
  }
@@ -166,8 +177,8 @@ export function installCrashHandlers(ctx: CrashRecoveryContext): () => void {
166
177
  // Write run.complete event (BUG-017)
167
178
  await writeRunComplete(ctx, signal.toLowerCase());
168
179
 
169
- // Update status.json to crashed
170
- await updateStatusToCrashed(ctx.statusWriter, ctx.getTotalCost(), ctx.getIterations(), signal);
180
+ // Update status.json to crashed (SFC-002: include feature-level status)
181
+ await updateStatusToCrashed(ctx.statusWriter, ctx.getTotalCost(), ctx.getIterations(), signal, ctx.featureDir);
171
182
 
172
183
  // Stop heartbeat
173
184
  stopHeartbeat();
@@ -201,8 +212,14 @@ export function installCrashHandlers(ctx: CrashRecoveryContext): () => void {
201
212
  // Write fatal log with stack trace
202
213
  await writeFatalLog(ctx.jsonlFilePath, "uncaughtException", error);
203
214
 
204
- // Update status.json to crashed
205
- await updateStatusToCrashed(ctx.statusWriter, ctx.getTotalCost(), ctx.getIterations(), "uncaughtException");
215
+ // Update status.json to crashed (SFC-002: include feature-level status)
216
+ await updateStatusToCrashed(
217
+ ctx.statusWriter,
218
+ ctx.getTotalCost(),
219
+ ctx.getIterations(),
220
+ "uncaughtException",
221
+ ctx.featureDir,
222
+ );
206
223
 
207
224
  // Stop heartbeat
208
225
  stopHeartbeat();
@@ -228,8 +245,14 @@ export function installCrashHandlers(ctx: CrashRecoveryContext): () => void {
228
245
  // Write fatal log
229
246
  await writeFatalLog(ctx.jsonlFilePath, "unhandledRejection", error);
230
247
 
231
- // Update status.json to crashed
232
- await updateStatusToCrashed(ctx.statusWriter, ctx.getTotalCost(), ctx.getIterations(), "unhandledRejection");
248
+ // Update status.json to crashed (SFC-002: include feature-level status)
249
+ await updateStatusToCrashed(
250
+ ctx.statusWriter,
251
+ ctx.getTotalCost(),
252
+ ctx.getIterations(),
253
+ "unhandledRejection",
254
+ ctx.featureDir,
255
+ );
233
256
 
234
257
  // Stop heartbeat
235
258
  stopHeartbeat();
@@ -25,6 +25,7 @@ import { loadPlugins } from "../../plugins/loader";
25
25
  import type { PluginRegistry } from "../../plugins/registry";
26
26
  import type { PRD } from "../../prd";
27
27
  import { loadPRD } from "../../prd";
28
+ import { NAX_BUILD_INFO, NAX_COMMIT, NAX_VERSION } from "../../version";
28
29
  import { installCrashHandlers } from "../crash-recovery";
29
30
  import { acquireLock, hookCtx, releaseLock } from "../helpers";
30
31
  import { PidRegistry } from "../pid-registry";
@@ -36,6 +37,7 @@ export interface RunSetupOptions {
36
37
  config: NaxConfig;
37
38
  hooks: LoadedHooksConfig;
38
39
  feature: string;
40
+ featureDir?: string;
39
41
  dryRun: boolean;
40
42
  statusFile: string;
41
43
  logFilePath?: string;
@@ -117,6 +119,7 @@ export async function setupRun(options: RunSetupOptions): Promise<RunSetupResult
117
119
  // BUG-017: Pass context for run.complete event on SIGTERM
118
120
  runId: options.runId,
119
121
  feature: options.feature,
122
+ featureDir: options.featureDir,
120
123
  getStartTime: () => options.startTime,
121
124
  getTotalStories: options.getTotalStories,
122
125
  getStoriesCompleted: options.getStoriesCompleted,
@@ -173,12 +176,14 @@ export async function setupRun(options: RunSetupOptions): Promise<RunSetupResult
173
176
 
174
177
  // Log run start
175
178
  const routingMode = config.routing.llm?.mode ?? "hybrid";
176
- logger?.info("run.start", `Starting feature: ${feature}`, {
179
+ logger?.info("run.start", `Starting feature: ${feature} [nax ${NAX_BUILD_INFO}]`, {
177
180
  runId,
178
181
  feature,
179
182
  workdir,
180
183
  dryRun,
181
184
  routingMode,
185
+ naxVersion: NAX_VERSION,
186
+ naxCommit: NAX_COMMIT,
182
187
  });
183
188
 
184
189
  // Fire on-start hook
@@ -110,6 +110,7 @@ export async function run(options: RunOptions): Promise<RunResult> {
110
110
  config,
111
111
  hooks,
112
112
  feature,
113
+ featureDir,
113
114
  dryRun,
114
115
  statusFile,
115
116
  logFilePath,
@@ -307,6 +308,13 @@ export async function run(options: RunOptions): Promise<RunResult> {
307
308
 
308
309
  const { durationMs, runCompletedAt, finalCounts } = completionResult;
309
310
 
311
+ // ── Write feature-level status (SFC-002) ────────────────────────────────
312
+ if (featureDir) {
313
+ const finalStatus = isComplete(prd) ? "completed" : "failed";
314
+ statusWriter.setRunStatus(finalStatus);
315
+ await statusWriter.writeFeatureStatus(featureDir, totalCost, iterations);
316
+ }
317
+
310
318
  // ── Output run footer in headless mode ─────────────────────────────────
311
319
  if (headless && formatterMode !== "json") {
312
320
  const { outputRunFooter } = await import("./lifecycle/headless-formatter");
@@ -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,
@@ -6,6 +6,7 @@
6
6
  * write failure counter. Provides atomic status file writes via writeStatusFile.
7
7
  */
8
8
 
9
+ import { join } from "node:path";
9
10
  import type { NaxConfig } from "../config";
10
11
  import { getSafeLogger } from "../logger";
11
12
  import type { PRD } from "../prd";
@@ -136,4 +137,45 @@ export class StatusWriter {
136
137
  });
137
138
  }
138
139
  }
140
+
141
+ /**
142
+ * Write the current status snapshot to feature-level status.json file.
143
+ *
144
+ * Called on run completion, failure, or crash to persist the final state
145
+ * to <featureDir>/status.json. Uses the same NaxStatusFile schema as
146
+ * the project-level status file.
147
+ *
148
+ * No-ops if _prd has not been set.
149
+ * On failure, logs a warning/error but does not throw (non-fatal).
150
+ *
151
+ * @param featureDir - Feature directory (e.g., nax/features/auth-system)
152
+ * @param totalCost - Accumulated cost at this write point
153
+ * @param iterations - Loop iteration count at this write point
154
+ * @param overrides - Optional partial snapshot overrides (spread last)
155
+ */
156
+ async writeFeatureStatus(
157
+ featureDir: string,
158
+ totalCost: number,
159
+ iterations: number,
160
+ overrides: Partial<RunStateSnapshot> = {},
161
+ ): Promise<void> {
162
+ if (!this._prd) return;
163
+ const safeLogger = getSafeLogger();
164
+ const featureStatusPath = join(featureDir, "status.json");
165
+
166
+ try {
167
+ const base = this.getSnapshot(totalCost, iterations);
168
+ if (!base) {
169
+ throw new Error("Failed to get snapshot");
170
+ }
171
+ const state: RunStateSnapshot = { ...base, ...overrides };
172
+ await writeStatusFile(featureStatusPath, buildStatusSnapshot(state));
173
+ safeLogger?.debug("status-file", "Feature status written", { path: featureStatusPath });
174
+ } catch (err) {
175
+ safeLogger?.warn("status-file", "Failed to write feature status file (non-fatal)", {
176
+ path: featureStatusPath,
177
+ error: (err as Error).message,
178
+ });
179
+ }
180
+ }
139
181
  }
@@ -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
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Registry Writer Subscriber
3
+ *
4
+ * Creates ~/.nax/runs/<project>-<feature>-<runId>/meta.json on run:started.
5
+ * Provides a persistent record of each run with paths for status and events.
6
+ *
7
+ * Design:
8
+ * - Best-effort: all writes wrapped in try/catch; never throws or blocks
9
+ * - Directory created on first write via mkdir recursive
10
+ * - Written once on run:started, never updated
11
+ * - Returns UnsubscribeFn matching wireHooks/wireEventsWriter pattern
12
+ */
13
+
14
+ import { mkdir, writeFile } from "node:fs/promises";
15
+ import { homedir } from "node:os";
16
+ import { basename, join } from "node:path";
17
+ import { getSafeLogger } from "../../logger";
18
+ import type { PipelineEventBus } from "../event-bus";
19
+ import type { UnsubscribeFn } from "./hooks";
20
+
21
+ export interface MetaJson {
22
+ runId: string;
23
+ project: string;
24
+ feature: string;
25
+ workdir: string;
26
+ statusPath: string;
27
+ eventsDir: string;
28
+ registeredAt: string;
29
+ }
30
+
31
+ /**
32
+ * Wire registry writer to the pipeline event bus.
33
+ *
34
+ * Listens to run:started and writes meta.json to
35
+ * ~/.nax/runs/<project>-<feature>-<runId>/meta.json.
36
+ *
37
+ * @param bus - The pipeline event bus
38
+ * @param feature - Feature name
39
+ * @param runId - Current run ID
40
+ * @param workdir - Working directory (project name derived via basename)
41
+ * @returns Unsubscribe function
42
+ */
43
+ export function wireRegistry(bus: PipelineEventBus, feature: string, runId: string, workdir: string): UnsubscribeFn {
44
+ const logger = getSafeLogger();
45
+ const project = basename(workdir);
46
+ const runDir = join(homedir(), ".nax", "runs", `${project}-${feature}-${runId}`);
47
+ const metaFile = join(runDir, "meta.json");
48
+
49
+ const unsub = bus.on("run:started", (_ev) => {
50
+ (async () => {
51
+ try {
52
+ await mkdir(runDir, { recursive: true });
53
+ const meta: MetaJson = {
54
+ runId,
55
+ project,
56
+ feature,
57
+ workdir,
58
+ statusPath: join(workdir, "nax", "features", feature, "status.json"),
59
+ eventsDir: join(workdir, "nax", "features", feature, "runs"),
60
+ registeredAt: new Date().toISOString(),
61
+ };
62
+ await writeFile(metaFile, JSON.stringify(meta, null, 2));
63
+ } catch (err) {
64
+ logger?.warn("registry-writer", "Failed to write meta.json (non-fatal)", {
65
+ path: metaFile,
66
+ error: String(err),
67
+ });
68
+ }
69
+ })();
70
+ });
71
+
72
+ return unsub;
73
+ }
package/src/version.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Version and build info for nax.
3
+ *
4
+ * GIT_COMMIT is injected at build time via --define in the bun build script.
5
+ * When running from source (bun run dev), it falls back to "dev".
6
+ */
7
+
8
+ import pkg from "../package.json";
9
+
10
+ declare const GIT_COMMIT: string;
11
+
12
+ export const NAX_VERSION: string = pkg.version;
13
+
14
+ /** Short git commit hash, injected at build time. Falls back to "dev" from source. */
15
+ export const NAX_COMMIT: string = (() => {
16
+ try {
17
+ return GIT_COMMIT ?? "dev";
18
+ } catch {
19
+ return "dev";
20
+ }
21
+ })();
22
+
23
+ export const NAX_BUILD_INFO = `v${NAX_VERSION} (${NAX_COMMIT})`;