@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
@@ -402,6 +402,7 @@ describe("E2E: plan → analyze → run workflow", () => {
402
402
  featureDir,
403
403
  dryRun: false,
404
404
  useBatch: true, // Enable batching
405
+ statusFile: join(testDir, "nax", "status.json"),
405
406
  skipPrecheck: true, // Skip precheck for E2E test (no git repo in temp dir)
406
407
  });
407
408
 
@@ -479,6 +480,7 @@ describe("E2E: plan → analyze → run workflow", () => {
479
480
  feature: "simple-task",
480
481
  featureDir,
481
482
  dryRun: false,
483
+ statusFile: join(testDir, "nax", "status.json"),
482
484
  skipPrecheck: true, // Skip precheck for E2E test (no git repo in temp dir)
483
485
  });
484
486
 
@@ -560,6 +562,7 @@ describe("E2E: plan → analyze → run workflow", () => {
560
562
  feature: "fail-task",
561
563
  featureDir,
562
564
  dryRun: false,
565
+ statusFile: join(testDir, "nax", "status.json"),
563
566
  skipPrecheck: true, // Skip precheck for E2E test (no git repo in temp dir)
564
567
  });
565
568
 
@@ -623,6 +626,7 @@ describe("E2E: plan → analyze → run workflow", () => {
623
626
  feature: "rate-limit-task",
624
627
  featureDir,
625
628
  dryRun: false,
629
+ statusFile: join(testDir, "nax", "status.json"),
626
630
  skipPrecheck: true, // Skip precheck for E2E test (no git repo in temp dir)
627
631
  });
628
632
 
@@ -729,6 +733,7 @@ describe("E2E: plan → analyze → run workflow", () => {
729
733
  featureDir,
730
734
  dryRun: false,
731
735
  useBatch: true,
736
+ statusFile: join(testDir, "nax", "status.json"),
732
737
  skipPrecheck: true, // Skip precheck for E2E test (no git repo in temp dir)
733
738
  });
734
739
 
@@ -102,7 +102,9 @@ async function createStatusFile(dir: string, feature: string, overrides: Partial
102
102
  ...overrides,
103
103
  };
104
104
 
105
- await Bun.write(join(dir, ".nax-status.json"), JSON.stringify(status, null, 2));
105
+ // Ensure nax directory exists
106
+ mkdirSync(join(dir, "nax"), { recursive: true });
107
+ await Bun.write(join(dir, "nax", "status.json"), JSON.stringify(status, null, 2));
106
108
  }
107
109
 
108
110
  /**
@@ -16,6 +16,7 @@ import { join } from "node:path";
16
16
 
17
17
  const TEST_WORKSPACE = join(import.meta.dir, "../../..", "tmp", "cli-logs-test");
18
18
  const NAX_BIN = join(import.meta.dir, "..", "..", "..", "bin", "nax.ts");
19
+ const REGISTRY_DIR = join(TEST_WORKSPACE, "registry");
19
20
 
20
21
  function setupTestProject(featureName: string): string {
21
22
  const projectDir = join(TEST_WORKSPACE, `project-${Date.now()}`);
@@ -24,6 +25,7 @@ function setupTestProject(featureName: string): string {
24
25
  const runsDir = join(featureDir, "runs");
25
26
 
26
27
  mkdirSync(runsDir, { recursive: true });
28
+ mkdirSync(REGISTRY_DIR, { recursive: true });
27
29
 
28
30
  writeFileSync(join(naxDir, "config.json"), JSON.stringify({ feature: featureName }));
29
31
 
@@ -62,7 +64,24 @@ function setupTestProject(featureName: string): string {
62
64
  },
63
65
  ];
64
66
 
65
- writeFileSync(join(runsDir, "2026-02-27T12-00-00.jsonl"), logs.map((l) => JSON.stringify(l)).join("\n"));
67
+ const runId = "2026-02-27T12-00-00";
68
+ writeFileSync(join(runsDir, `${runId}.jsonl`), logs.map((l) => JSON.stringify(l)).join("\n"));
69
+
70
+ // Create matching registry entry so --run <runId> resolves via registry
71
+ const entryDir = join(REGISTRY_DIR, `testproject-${featureName}-${runId}`);
72
+ mkdirSync(entryDir, { recursive: true });
73
+ writeFileSync(
74
+ join(entryDir, "meta.json"),
75
+ JSON.stringify({
76
+ runId,
77
+ project: "testproject",
78
+ feature: featureName,
79
+ workdir: projectDir,
80
+ statusPath: join(projectDir, "nax", "features", featureName, "status.json"),
81
+ eventsDir: runsDir,
82
+ registeredAt: "2026-02-27T12:00:00.000Z",
83
+ }),
84
+ );
66
85
 
67
86
  return projectDir;
68
87
  }
@@ -75,11 +94,15 @@ function cleanup(dir: string) {
75
94
 
76
95
  const CMD_TIMEOUT_MS = 15_000; // 15s per command — fast-fail instead of waiting full 60s
77
96
 
78
- function runNaxCommand(args: string[], cwd?: string): Promise<{ stdout: string; stderr: string; exitCode: number }> {
97
+ function runNaxCommand(
98
+ args: string[],
99
+ cwd?: string,
100
+ extraEnv?: Record<string, string>,
101
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
79
102
  return new Promise((resolve, reject) => {
80
103
  const proc = spawn("bun", ["run", NAX_BIN, ...args], {
81
104
  cwd: cwd || process.cwd(),
82
- env: process.env,
105
+ env: { ...process.env, ...extraEnv },
83
106
  });
84
107
 
85
108
  let stdout = "";
@@ -156,22 +179,24 @@ describe("nax logs CLI integration", () => {
156
179
  });
157
180
 
158
181
  describe("--run flag", () => {
159
- test("nax logs --run <timestamp> selects specific run", async () => {
160
- const result = await runNaxCommand(["logs", "--run", "2026-02-27T12-00-00", "-d", projectDir]);
182
+ const registryEnv = { NAX_RUNS_DIR: REGISTRY_DIR };
183
+
184
+ test("nax logs --run <runId> displays logs from matching registry entry", async () => {
185
+ const result = await runNaxCommand(["logs", "--run", "2026-02-27T12-00-00"], undefined, registryEnv);
161
186
 
162
187
  expect(result.exitCode).toBe(0);
163
188
  expect(result.stdout).toContain("run-001");
164
189
  });
165
190
 
166
191
  test("nax logs -r is shorthand for --run", async () => {
167
- const result = await runNaxCommand(["logs", "-r", "2026-02-27T12-00-00", "-d", projectDir]);
192
+ const result = await runNaxCommand(["logs", "-r", "2026-02-27T12-00-00"], undefined, registryEnv);
168
193
 
169
194
  expect(result.exitCode).toBe(0);
170
195
  expect(result.stdout).toContain("run-001");
171
196
  });
172
197
 
173
- test("nax logs --run fails when run not found", async () => {
174
- const result = await runNaxCommand(["logs", "--run", "2026-01-01T00-00-00", "-d", projectDir]);
198
+ test("nax logs --run fails when run not found in registry", async () => {
199
+ const result = await runNaxCommand(["logs", "--run", "2026-01-01T00-00-00"], undefined, registryEnv);
175
200
 
176
201
  expect(result.exitCode).not.toBe(0);
177
202
  expect(result.stderr).toContain("not found");
@@ -256,6 +281,7 @@ describe("nax logs CLI integration", () => {
256
281
  // We verify the process starts and produces stdout output.
257
282
  const proc = spawn("bun", ["run", NAX_BIN, "logs", "--follow", "-d", projectDir], {
258
283
  cwd: process.cwd(),
284
+ env: process.env,
259
285
  });
260
286
 
261
287
  let started = false;
@@ -275,6 +301,7 @@ describe("nax logs CLI integration", () => {
275
301
  test("nax logs -f is shorthand for --follow", async () => {
276
302
  const proc = spawn("bun", ["run", NAX_BIN, "logs", "-f", "-d", projectDir], {
277
303
  cwd: process.cwd(),
304
+ env: process.env,
278
305
  });
279
306
 
280
307
  let started = false;
@@ -306,15 +333,11 @@ describe("nax logs CLI integration", () => {
306
333
  });
307
334
 
308
335
  test("--run + --story", async () => {
309
- const result = await runNaxCommand([
310
- "logs",
311
- "--run",
312
- "2026-02-27T12-00-00",
313
- "--story",
314
- "US-001",
315
- "-d",
316
- projectDir,
317
- ]);
336
+ const result = await runNaxCommand(
337
+ ["logs", "--run", "2026-02-27T12-00-00", "--story", "US-001"],
338
+ undefined,
339
+ { NAX_RUNS_DIR: REGISTRY_DIR },
340
+ );
318
341
 
319
342
  expect(result.exitCode).toBe(0);
320
343
  expect(result.stdout).toContain("US-001");
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Feature-Level Status File Writing Tests (SFC-002)
3
+ *
4
+ * Tests for writing feature-level status.json files on run end.
5
+ * Verifies all three acceptance criteria:
6
+ * - Status 'completed' after successful run
7
+ * - Status 'failed' after unsuccessful run
8
+ * - Status 'crashed' after crash (simulated)
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { mkdtemp, rm } from "node:fs/promises";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import type { NaxConfig } from "../../../src/config";
17
+ import type { NaxStatusFile } from "../../../src/execution/status-file";
18
+ import { StatusWriter, type StatusWriterContext } from "../../../src/execution/status-writer";
19
+ import type { PRD, UserStory } from "../../../src/prd";
20
+
21
+ // ============================================================================
22
+ // Helpers
23
+ // ============================================================================
24
+
25
+ function makeStory(id: string, status: UserStory["status"] = "pending"): UserStory {
26
+ return {
27
+ id,
28
+ title: `Story ${id}`,
29
+ description: `Description for ${id}`,
30
+ acceptanceCriteria: ["AC-1"],
31
+ tags: [],
32
+ dependencies: [],
33
+ status,
34
+ passes: status === "passed",
35
+ escalations: [],
36
+ attempts: 0,
37
+ };
38
+ }
39
+
40
+ function makePrd(count = 1, storyStatus: UserStory["status"] = "pending"): PRD {
41
+ return {
42
+ project: "test-project",
43
+ feature: "test-feature",
44
+ branchName: "feat/test",
45
+ createdAt: "2026-02-25T10:00:00.000Z",
46
+ updatedAt: "2026-02-25T10:00:00.000Z",
47
+ userStories: Array.from({ length: count }, (_, i) => makeStory(`US-00${i + 1}`, storyStatus)),
48
+ };
49
+ }
50
+
51
+ function makeConfig(costLimit = 5.0): NaxConfig {
52
+ return {
53
+ execution: {
54
+ costLimit,
55
+ maxIterations: 10,
56
+ maxStoriesPerFeature: 50,
57
+ iterationDelayMs: 0,
58
+ },
59
+ } as unknown as NaxConfig;
60
+ }
61
+
62
+ function makeCtx(overrides: Partial<StatusWriterContext> = {}): StatusWriterContext {
63
+ return {
64
+ runId: "run-test-001",
65
+ feature: "auth-feature",
66
+ startedAt: "2026-02-25T10:00:00.000Z",
67
+ dryRun: false,
68
+ startTimeMs: Date.now() - 1000,
69
+ pid: process.pid,
70
+ ...overrides,
71
+ };
72
+ }
73
+
74
+ function readFeatureStatus(featureDir: string): NaxStatusFile {
75
+ const path = join(featureDir, "status.json");
76
+ const content = readFileSync(path, "utf8");
77
+ return JSON.parse(content) as NaxStatusFile;
78
+ }
79
+
80
+ // ============================================================================
81
+ // Acceptance Criteria Tests
82
+ // ============================================================================
83
+
84
+ describe("SFC-002: Feature-level status writing — Acceptance Criteria", () => {
85
+ let tmpDir: string;
86
+
87
+ beforeEach(async () => {
88
+ tmpDir = await mkdtemp(join(tmpdir(), "sfc-002-test-"));
89
+ });
90
+
91
+ afterEach(async () => {
92
+ await rm(tmpDir, { recursive: true, force: true });
93
+ });
94
+
95
+ // ── AC-1: After a completed run, status is 'completed' ─────────────────
96
+ test("AC-1: After completed run, feature status.json has status 'completed'", async () => {
97
+ const featureDir = join(tmpDir, "nax", "features", "auth-system");
98
+ const sw = new StatusWriter(join(tmpDir, "status.json"), makeConfig(), makeCtx());
99
+
100
+ // Simulate successful run: all stories completed
101
+ const prd = makePrd(3, "passed");
102
+ sw.setPrd(prd);
103
+ sw.setRunStatus("completed");
104
+
105
+ // Write feature-level status
106
+ await sw.writeFeatureStatus(featureDir, 2.5, 5);
107
+
108
+ // Verify status.json exists in feature directory
109
+ const statusPath = join(featureDir, "status.json");
110
+ expect(existsSync(statusPath)).toBe(true);
111
+
112
+ // Verify status is 'completed'
113
+ const status = readFeatureStatus(featureDir);
114
+ expect(status.run.status).toBe("completed");
115
+ expect(status.progress.total).toBe(3);
116
+ expect(status.progress.passed).toBe(3);
117
+ });
118
+
119
+ // ── AC-2: After a failed run, status is 'failed' ───────────────────────
120
+ test("AC-2: After failed run, feature status.json has status 'failed'", async () => {
121
+ const featureDir = join(tmpDir, "nax", "features", "auth-system");
122
+ const sw = new StatusWriter(join(tmpDir, "status.json"), makeConfig(), makeCtx());
123
+
124
+ // Simulate failed run: some stories failed
125
+ const prd = makePrd(3);
126
+ prd.userStories[0].status = "passed";
127
+ prd.userStories[1].status = "failed";
128
+ prd.userStories[2].status = "pending";
129
+
130
+ sw.setPrd(prd);
131
+ sw.setRunStatus("failed");
132
+
133
+ // Write feature-level status
134
+ await sw.writeFeatureStatus(featureDir, 1.0, 3);
135
+
136
+ // Verify status is 'failed'
137
+ const status = readFeatureStatus(featureDir);
138
+ expect(status.run.status).toBe("failed");
139
+ expect(status.progress.passed).toBe(1);
140
+ expect(status.progress.failed).toBe(1);
141
+ expect(status.progress.pending).toBe(1);
142
+ });
143
+
144
+ // ── AC-3: After a crash, status is 'crashed' ───────────────────────────
145
+ test("AC-3: After crash, feature status.json has status 'crashed' with crash metadata", async () => {
146
+ const featureDir = join(tmpDir, "nax", "features", "auth-system");
147
+ const sw = new StatusWriter(join(tmpDir, "status.json"), makeConfig(), makeCtx());
148
+
149
+ const prd = makePrd(2);
150
+ sw.setPrd(prd);
151
+ sw.setRunStatus("crashed");
152
+
153
+ const crashTime = new Date().toISOString();
154
+ await sw.writeFeatureStatus(featureDir, 0.5, 1, {
155
+ crashedAt: crashTime,
156
+ crashSignal: "SIGTERM",
157
+ });
158
+
159
+ // Verify status is 'crashed' with metadata
160
+ const status = readFeatureStatus(featureDir);
161
+ expect(status.run.status).toBe("crashed");
162
+ expect(status.run.crashedAt).toBe(crashTime);
163
+ expect(status.run.crashSignal).toBe("SIGTERM");
164
+ });
165
+
166
+ // ── AC-4: Uses same NaxStatusFile schema as project-level ──────────────
167
+ test("AC-4: Feature status.json uses same NaxStatusFile schema as project-level", async () => {
168
+ const projectStatusPath = join(tmpDir, "status.json");
169
+ const featureDir = join(tmpDir, "nax", "features", "auth-system");
170
+
171
+ const sw = new StatusWriter(projectStatusPath, makeConfig(), makeCtx());
172
+ const prd = makePrd(2, "passed");
173
+ sw.setPrd(prd);
174
+ sw.setRunStatus("completed");
175
+
176
+ // Write both project and feature status
177
+ await sw.update(2.0, 4);
178
+ await sw.writeFeatureStatus(featureDir, 2.0, 4);
179
+
180
+ // Read both files
181
+ const projectStatus = JSON.parse(readFileSync(projectStatusPath, "utf8")) as NaxStatusFile;
182
+ const featureStatus = readFeatureStatus(featureDir);
183
+
184
+ // Verify same schema structure and version
185
+ expect(projectStatus.version).toBe(1);
186
+ expect(featureStatus.version).toBe(1);
187
+
188
+ // Verify key fields are present in both
189
+ expect(projectStatus.run).toBeDefined();
190
+ expect(featureStatus.run).toBeDefined();
191
+ expect(projectStatus.progress).toBeDefined();
192
+ expect(featureStatus.progress).toBeDefined();
193
+ expect(projectStatus.cost).toBeDefined();
194
+ expect(featureStatus.cost).toBeDefined();
195
+ expect(projectStatus.iterations).toBeDefined();
196
+ expect(featureStatus.iterations).toBeDefined();
197
+
198
+ // Verify status values match
199
+ expect(projectStatus.run.status).toBe(featureStatus.run.status);
200
+ expect(projectStatus.cost.spent).toBe(featureStatus.cost.spent);
201
+ expect(projectStatus.progress.total).toBe(featureStatus.progress.total);
202
+ });
203
+ });
204
+
205
+ // ============================================================================
206
+ // Edge Cases and Error Handling
207
+ // ============================================================================
208
+
209
+ describe("Feature status writing — edge cases", () => {
210
+ let tmpDir: string;
211
+
212
+ beforeEach(async () => {
213
+ tmpDir = await mkdtemp(join(tmpdir(), "sfc-002-edge-"));
214
+ });
215
+
216
+ afterEach(async () => {
217
+ await rm(tmpDir, { recursive: true, force: true });
218
+ });
219
+
220
+ test("creates nested feature directory if it doesn't exist", async () => {
221
+ const featureDir = join(tmpDir, "nax", "features", "deeply", "nested", "feature");
222
+ const sw = new StatusWriter(join(tmpDir, "status.json"), makeConfig(), makeCtx());
223
+ sw.setPrd(makePrd());
224
+ sw.setRunStatus("completed");
225
+
226
+ await sw.writeFeatureStatus(featureDir, 1.0, 1);
227
+
228
+ expect(existsSync(join(featureDir, "status.json"))).toBe(true);
229
+ });
230
+
231
+ test("overwrites existing feature status file on subsequent writes", async () => {
232
+ const featureDir = join(tmpDir, "nax", "features", "auth-system");
233
+ const sw = new StatusWriter(join(tmpDir, "status.json"), makeConfig(), makeCtx());
234
+ sw.setPrd(makePrd());
235
+
236
+ // First write: running
237
+ sw.setRunStatus("running");
238
+ await sw.writeFeatureStatus(featureDir, 1.0, 1);
239
+ let status = readFeatureStatus(featureDir);
240
+ expect(status.run.status).toBe("running");
241
+
242
+ // Second write: completed
243
+ sw.setRunStatus("completed");
244
+ await sw.writeFeatureStatus(featureDir, 2.0, 2);
245
+ status = readFeatureStatus(featureDir);
246
+ expect(status.run.status).toBe("completed");
247
+ expect(status.cost.spent).toBe(2.0);
248
+ });
249
+
250
+ test("feature status includes accurate progress counts", async () => {
251
+ const featureDir = join(tmpDir, "nax", "features", "auth-system");
252
+ const sw = new StatusWriter(join(tmpDir, "status.json"), makeConfig(), makeCtx());
253
+
254
+ // Create PRD with mixed statuses
255
+ const prd = makePrd(10);
256
+ prd.userStories[0].status = "passed";
257
+ prd.userStories[1].status = "passed";
258
+ prd.userStories[2].status = "failed";
259
+ prd.userStories[3].status = "paused";
260
+ prd.userStories[4].status = "blocked";
261
+ // 5-9 remain pending
262
+
263
+ sw.setPrd(prd);
264
+ sw.setRunStatus("failed");
265
+ await sw.writeFeatureStatus(featureDir, 1.5, 2);
266
+
267
+ const status = readFeatureStatus(featureDir);
268
+ expect(status.progress.total).toBe(10);
269
+ expect(status.progress.passed).toBe(2);
270
+ expect(status.progress.failed).toBe(1);
271
+ expect(status.progress.paused).toBe(1);
272
+ expect(status.progress.blocked).toBe(1);
273
+ expect(status.progress.pending).toBe(5);
274
+ });
275
+
276
+ test("feature status reflects cost limit from config", async () => {
277
+ const featureDir = join(tmpDir, "nax", "features", "auth-system");
278
+ const config = makeConfig(10.0); // $10 limit
279
+ const sw = new StatusWriter(join(tmpDir, "status.json"), config, makeCtx());
280
+
281
+ sw.setPrd(makePrd());
282
+ sw.setRunStatus("completed");
283
+ await sw.writeFeatureStatus(featureDir, 5.0, 1);
284
+
285
+ const status = readFeatureStatus(featureDir);
286
+ expect(status.cost.limit).toBe(10.0);
287
+ expect(status.cost.spent).toBe(5.0);
288
+ });
289
+
290
+ test("feature status with no cost limit shows null", async () => {
291
+ const featureDir = join(tmpDir, "nax", "features", "auth-system");
292
+ const config = makeConfig(Number.POSITIVE_INFINITY); // No limit
293
+ const sw = new StatusWriter(join(tmpDir, "status.json"), config, makeCtx());
294
+
295
+ sw.setPrd(makePrd());
296
+ sw.setRunStatus("completed");
297
+ await sw.writeFeatureStatus(featureDir, 3.0, 1);
298
+
299
+ const status = readFeatureStatus(featureDir);
300
+ expect(status.cost.limit).toBeNull();
301
+ });
302
+ });
@@ -82,7 +82,7 @@ function createTestConfig(): NaxConfig {
82
82
  return {
83
83
  ...DEFAULT_CONFIG,
84
84
  autoMode: { ...DEFAULT_CONFIG.autoMode, defaultAgent: "mock" },
85
- execution: { ...DEFAULT_CONFIG.execution, maxIterations: 20, maxStoriesPerFeature: 500 },
85
+ execution: { ...DEFAULT_CONFIG.execution, maxIterations: 20, maxStoriesPerFeature: 500, iterationDelayMs: 0 },
86
86
  review: { ...DEFAULT_CONFIG.review, enabled: false },
87
87
  acceptance: { ...DEFAULT_CONFIG.acceptance, enabled: false },
88
88
  };
@@ -333,3 +333,115 @@ describe("StatusWriter.update BUG-2 failure counter", () => {
333
333
  await rm(dir, { recursive: true, force: true });
334
334
  });
335
335
  });
336
+
337
+ // ============================================================================
338
+ // writeFeatureStatus — feature-level status writes (SFC-002)
339
+ // ============================================================================
340
+
341
+ describe("StatusWriter.writeFeatureStatus", () => {
342
+ let tmpDir: string;
343
+
344
+ beforeEach(async () => {
345
+ tmpDir = await mkdtemp(join(tmpdir(), "sw-feature-test-"));
346
+ });
347
+
348
+ afterEach(async () => {
349
+ await rm(tmpDir, { recursive: true, force: true });
350
+ });
351
+
352
+ test("no-op when prd not yet set", async () => {
353
+ const featureDir = join(tmpDir, "features", "auth-feature");
354
+ const sw = new StatusWriter(join(tmpDir, "status.json"), makeConfig(), makeCtx());
355
+ await sw.writeFeatureStatus(featureDir, 0, 0);
356
+ expect(existsSync(join(featureDir, "status.json"))).toBe(false);
357
+ });
358
+
359
+ test("writes feature status.json in feature directory", async () => {
360
+ const featureDir = join(tmpDir, "features", "auth-feature");
361
+ const statusPath = join(featureDir, "status.json");
362
+ const sw = new StatusWriter(join(tmpDir, "status.json"), makeConfig(), makeCtx());
363
+ sw.setPrd(makePrd());
364
+ sw.setRunStatus("completed");
365
+ await sw.writeFeatureStatus(featureDir, 2.5, 5);
366
+
367
+ expect(existsSync(statusPath)).toBe(true);
368
+ const content = JSON.parse(readFileSync(statusPath, "utf8")) as NaxStatusFile;
369
+ expect(content.version).toBe(1);
370
+ expect(content.run.status).toBe("completed");
371
+ expect(content.cost.spent).toBe(2.5);
372
+ expect(content.iterations).toBe(5);
373
+ });
374
+
375
+ test("writes feature status with 'completed' status after successful run", async () => {
376
+ const featureDir = join(tmpDir, "features", "auth-feature");
377
+ const statusPath = join(featureDir, "status.json");
378
+ const sw = new StatusWriter(join(tmpDir, "status.json"), makeConfig(), makeCtx());
379
+ sw.setPrd(makePrd(3));
380
+ sw.setRunStatus("completed");
381
+ await sw.writeFeatureStatus(featureDir, 1.0, 1);
382
+
383
+ const content = JSON.parse(readFileSync(statusPath, "utf8")) as NaxStatusFile;
384
+ expect(content.run.status).toBe("completed");
385
+ expect(content.progress.total).toBe(3);
386
+ });
387
+
388
+ test("writes feature status with 'failed' status after unsuccessful run", async () => {
389
+ const featureDir = join(tmpDir, "features", "auth-feature");
390
+ const statusPath = join(featureDir, "status.json");
391
+ const sw = new StatusWriter(join(tmpDir, "status.json"), makeConfig(), makeCtx());
392
+ sw.setPrd(makePrd(2));
393
+ sw.setRunStatus("failed");
394
+ await sw.writeFeatureStatus(featureDir, 0.5, 2);
395
+
396
+ const content = JSON.parse(readFileSync(statusPath, "utf8")) as NaxStatusFile;
397
+ expect(content.run.status).toBe("failed");
398
+ });
399
+
400
+ test("writes feature status with 'crashed' status on crash with overrides", async () => {
401
+ const featureDir = join(tmpDir, "features", "auth-feature");
402
+ const statusPath = join(featureDir, "status.json");
403
+ const sw = new StatusWriter(join(tmpDir, "status.json"), makeConfig(), makeCtx());
404
+ sw.setPrd(makePrd());
405
+ sw.setRunStatus("crashed");
406
+ const crashTime = new Date().toISOString();
407
+ await sw.writeFeatureStatus(featureDir, 1.0, 2, {
408
+ crashedAt: crashTime,
409
+ crashSignal: "SIGTERM",
410
+ });
411
+
412
+ const content = JSON.parse(readFileSync(statusPath, "utf8")) as NaxStatusFile;
413
+ expect(content.run.status).toBe("crashed");
414
+ expect(content.run.crashedAt).toBe(crashTime);
415
+ expect(content.run.crashSignal).toBe("SIGTERM");
416
+ });
417
+
418
+ test("fails gracefully when feature directory cannot be created", async () => {
419
+ const invalidFeatureDir = "/root/cannot/create/here/feature";
420
+ const sw = new StatusWriter(join(tmpDir, "status.json"), makeConfig(), makeCtx());
421
+ sw.setPrd(makePrd());
422
+ // Should not throw — failure is logged, not re-thrown
423
+ await expect(sw.writeFeatureStatus(invalidFeatureDir, 0, 0)).resolves.toBeUndefined();
424
+ });
425
+
426
+ test("uses same schema as project-level status file", async () => {
427
+ const projectStatusPath = join(tmpDir, "status.json");
428
+ const featureDir = join(tmpDir, "features", "auth-feature");
429
+ const featureStatusPath = join(featureDir, "status.json");
430
+
431
+ const sw = new StatusWriter(projectStatusPath, makeConfig(), makeCtx());
432
+ sw.setPrd(makePrd(2));
433
+ sw.setRunStatus("completed");
434
+ await sw.update(2.0, 4);
435
+ await sw.writeFeatureStatus(featureDir, 2.0, 4);
436
+
437
+ const projectContent = JSON.parse(readFileSync(projectStatusPath, "utf8")) as NaxStatusFile;
438
+ const featureContent = JSON.parse(readFileSync(featureStatusPath, "utf8")) as NaxStatusFile;
439
+
440
+ // Verify both have same schema version and structure
441
+ expect(projectContent.version).toBe(featureContent.version);
442
+ expect(projectContent.version).toBe(1);
443
+ expect(projectContent.run.status).toBe(featureContent.run.status);
444
+ expect(projectContent.cost.spent).toBe(featureContent.cost.spent);
445
+ expect(projectContent.progress.total).toBe(featureContent.progress.total);
446
+ });
447
+ });