@nathapp/nax 0.22.1 → 0.22.3

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/nax/status.json CHANGED
@@ -1,27 +1,28 @@
1
1
  {
2
2
  "version": 1,
3
3
  "run": {
4
- "id": "run-2026-03-05T02-37-04-540Z",
5
- "feature": "nax-compliance",
6
- "startedAt": "2026-03-05T02:37:04.540Z",
7
- "status": "stalled",
4
+ "id": "run-2026-03-07T06-14-21-018Z",
5
+ "feature": "status-file-consolidation",
6
+ "startedAt": "2026-03-07T06:14:21.018Z",
7
+ "status": "completed",
8
8
  "dryRun": false,
9
- "pid": 814245
9
+ "pid": 217461
10
10
  },
11
11
  "progress": {
12
- "total": 1,
13
- "passed": 0,
14
- "failed": 1,
12
+ "total": 4,
13
+ "passed": 4,
14
+ "failed": 0,
15
15
  "paused": 0,
16
16
  "blocked": 0,
17
17
  "pending": 0
18
18
  },
19
19
  "cost": {
20
20
  "spent": 0,
21
- "limit": 8
21
+ "limit": 3
22
22
  },
23
23
  "current": null,
24
- "iterations": 3,
25
- "updatedAt": "2026-03-05T02:38:46.469Z",
26
- "durationMs": 101929
24
+ "iterations": 0,
25
+ "updatedAt": "2026-03-07T06:19:54.528Z",
26
+ "durationMs": 1000,
27
+ "lastHeartbeat": "2026-03-07T06:19:34.987Z"
27
28
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.22.1",
3
+ "version": "0.22.3",
4
4
  "description": "AI Coding Agent Orchestrator \u2014 loops until done",
5
5
  "type": "module",
6
6
  "bin": {
@@ -147,6 +147,12 @@ export function installCrashHandlers(ctx: CrashRecoveryContext): () => void {
147
147
 
148
148
  // Signal handler
149
149
  const handleSignal = async (signal: NodeJS.Signals) => {
150
+ // Hard deadline: force exit if any async operation hangs (FIX-H5)
151
+ const hardDeadline = setTimeout(() => {
152
+ process.exit(128 + getSignalNumber(signal));
153
+ }, 10_000);
154
+ if (hardDeadline.unref) hardDeadline.unref();
155
+
150
156
  logger?.error("crash-recovery", `Received ${signal}, shutting down...`, { signal });
151
157
 
152
158
  // Kill all spawned agent processes
@@ -166,6 +172,7 @@ export function installCrashHandlers(ctx: CrashRecoveryContext): () => void {
166
172
  // Stop heartbeat
167
173
  stopHeartbeat();
168
174
 
175
+ clearTimeout(hardDeadline);
169
176
  // Exit cleanly
170
177
  process.exit(128 + getSignalNumber(signal));
171
178
  };
@@ -26,7 +26,7 @@ import type { PluginRegistry } from "../../plugins/registry";
26
26
  import type { PRD } from "../../prd";
27
27
  import { loadPRD } from "../../prd";
28
28
  import { installCrashHandlers } from "../crash-recovery";
29
- import { acquireLock, hookCtx } from "../helpers";
29
+ import { acquireLock, hookCtx, releaseLock } from "../helpers";
30
30
  import { PidRegistry } from "../pid-registry";
31
31
  import { StatusWriter } from "../status-writer";
32
32
 
@@ -37,7 +37,7 @@ export interface RunSetupOptions {
37
37
  hooks: LoadedHooksConfig;
38
38
  feature: string;
39
39
  dryRun: boolean;
40
- statusFile?: string;
40
+ statusFile: string;
41
41
  logFilePath?: string;
42
42
  runId: string;
43
43
  startedAt: string;
@@ -157,48 +157,56 @@ export async function setupRun(options: RunSetupOptions): Promise<RunSetupResult
157
157
  throw new LockAcquisitionError(workdir);
158
158
  }
159
159
 
160
- // Load plugins (before try block so it's accessible in finally)
161
- const globalPluginsDir = path.join(os.homedir(), ".nax", "plugins");
162
- const projectPluginsDir = path.join(workdir, "nax", "plugins");
163
- const configPlugins = config.plugins || [];
164
- const pluginRegistry = await loadPlugins(globalPluginsDir, projectPluginsDir, configPlugins, workdir);
165
-
166
- // Log plugins loaded
167
- logger?.info("plugins", `Loaded ${pluginRegistry.plugins.length} plugins`, {
168
- plugins: pluginRegistry.plugins.map((p) => ({ name: p.name, version: p.version, provides: p.provides })),
169
- });
160
+ // Everything after lock acquisition is wrapped in try-catch to ensure
161
+ // the lock is released if any setup step fails (FIX-H16)
162
+ try {
163
+ // Load plugins (before try block so it's accessible in finally)
164
+ const globalPluginsDir = path.join(os.homedir(), ".nax", "plugins");
165
+ const projectPluginsDir = path.join(workdir, "nax", "plugins");
166
+ const configPlugins = config.plugins || [];
167
+ const pluginRegistry = await loadPlugins(globalPluginsDir, projectPluginsDir, configPlugins, workdir);
168
+
169
+ // Log plugins loaded
170
+ logger?.info("plugins", `Loaded ${pluginRegistry.plugins.length} plugins`, {
171
+ plugins: pluginRegistry.plugins.map((p) => ({ name: p.name, version: p.version, provides: p.provides })),
172
+ });
170
173
 
171
- // Log run start
172
- const routingMode = config.routing.llm?.mode ?? "hybrid";
173
- logger?.info("run.start", `Starting feature: ${feature}`, {
174
- runId,
175
- feature,
176
- workdir,
177
- dryRun,
178
- routingMode,
179
- });
174
+ // Log run start
175
+ const routingMode = config.routing.llm?.mode ?? "hybrid";
176
+ logger?.info("run.start", `Starting feature: ${feature}`, {
177
+ runId,
178
+ feature,
179
+ workdir,
180
+ dryRun,
181
+ routingMode,
182
+ });
180
183
 
181
- // Fire on-start hook
182
- await fireHook(hooks, "on-start", hookCtx(feature), workdir);
184
+ // Fire on-start hook
185
+ await fireHook(hooks, "on-start", hookCtx(feature), workdir);
183
186
 
184
- // Initialize run: check agent, reconcile state, validate limits
185
- const { initializeRun } = await import("./run-initialization");
186
- const initResult = await initializeRun({
187
- config,
188
- prdPath,
189
- workdir,
190
- dryRun,
191
- });
192
- prd = initResult.prd;
193
- const counts = initResult.storyCounts;
187
+ // Initialize run: check agent, reconcile state, validate limits
188
+ const { initializeRun } = await import("./run-initialization");
189
+ const initResult = await initializeRun({
190
+ config,
191
+ prdPath,
192
+ workdir,
193
+ dryRun,
194
+ });
195
+ prd = initResult.prd;
196
+ const counts = initResult.storyCounts;
194
197
 
195
- return {
196
- statusWriter,
197
- pidRegistry,
198
- cleanupCrashHandlers,
199
- pluginRegistry,
200
- prd,
201
- storyCounts: counts,
202
- interactionChain,
203
- };
198
+ return {
199
+ statusWriter,
200
+ pidRegistry,
201
+ cleanupCrashHandlers,
202
+ pluginRegistry,
203
+ prd,
204
+ storyCounts: counts,
205
+ interactionChain,
206
+ };
207
+ } catch (error) {
208
+ // Release lock before re-throwing so the directory isn't permanently locked
209
+ await releaseLock(workdir);
210
+ throw error;
211
+ }
204
212
  }
@@ -49,22 +49,38 @@ export async function acquireLock(workdir: string): Promise<boolean> {
49
49
  if (exists) {
50
50
  // Read lock data
51
51
  const lockContent = await lockFile.text();
52
- const lockData = JSON.parse(lockContent);
53
- const lockPid = lockData.pid;
54
-
55
- // Check if the process is still alive
56
- if (isProcessAlive(lockPid)) {
57
- // Process is alive, lock is valid
58
- return false;
52
+ let lockData: { pid: number };
53
+ try {
54
+ lockData = JSON.parse(lockContent);
55
+ } catch {
56
+ // Corrupt/unparseable lock file — treat as stale and delete
57
+ const logger = getSafeLogger();
58
+ logger?.warn("execution", "Corrupt lock file detected, removing", {
59
+ lockPath,
60
+ });
61
+ const fs = await import("node:fs/promises");
62
+ await fs.unlink(lockPath).catch(() => {});
63
+ // Fall through to create a new lock
64
+ lockData = undefined as unknown as { pid: number };
59
65
  }
60
66
 
61
- // Process is dead, remove stale lock
62
- const logger = getSafeLogger();
63
- logger?.warn("execution", "Removing stale lock", {
64
- pid: lockPid,
65
- });
66
- const fs = await import("node:fs/promises");
67
- await fs.unlink(lockPath).catch(() => {});
67
+ if (lockData) {
68
+ const lockPid = lockData.pid;
69
+
70
+ // Check if the process is still alive
71
+ if (isProcessAlive(lockPid)) {
72
+ // Process is alive, lock is valid
73
+ return false;
74
+ }
75
+
76
+ // Process is dead, remove stale lock
77
+ const logger = getSafeLogger();
78
+ logger?.warn("execution", "Removing stale lock", {
79
+ pid: lockPid,
80
+ });
81
+ const fs = await import("node:fs/promises");
82
+ await fs.unlink(lockPath).catch(() => {});
83
+ }
68
84
  }
69
85
 
70
86
  // Create lock file atomically using exclusive create (O_CREAT | O_EXCL)
@@ -180,8 +180,7 @@ async function executeParallelBatch(
180
180
  }
181
181
 
182
182
  // Execute stories in parallel with concurrency limit
183
- const executing: Promise<void>[] = [];
184
- let activeCount = 0;
183
+ const executing = new Set<Promise<void>>();
185
184
 
186
185
  for (const { story, worktreePath } of worktreeSetup) {
187
186
  const routing = routeTask(story.title, story.description, story.acceptanceCriteria, story.tags, config);
@@ -205,19 +204,13 @@ async function executeParallelBatch(
205
204
  }
206
205
  })
207
206
  .finally(() => {
208
- activeCount--;
209
- // BUG-4 fix: Remove completed promise from executing array
210
- const index = executing.indexOf(executePromise);
211
- if (index > -1) {
212
- executing.splice(index, 1);
213
- }
207
+ executing.delete(executePromise);
214
208
  });
215
209
 
216
- executing.push(executePromise);
217
- activeCount++;
210
+ executing.add(executePromise);
218
211
 
219
212
  // Wait if we've hit the concurrency limit
220
- if (activeCount >= maxConcurrency) {
213
+ if (executing.size >= maxConcurrency) {
221
214
  await Promise.race(executing);
222
215
  }
223
216
  }
@@ -148,7 +148,7 @@ export async function handlePipelineFailure(
148
148
  });
149
149
 
150
150
  if (ctx.story.attempts !== undefined && ctx.story.attempts >= ctx.config.execution.rectification.maxRetries) {
151
- pipelineEventBus.emit({
151
+ await pipelineEventBus.emitAsync({
152
152
  type: "human-review:requested",
153
153
  storyId: ctx.story.id,
154
154
  reason: pipelineResult.reason || "Max retries exceeded",
@@ -47,8 +47,8 @@ export interface RunOptions {
47
47
  parallel?: number;
48
48
  /** Optional event emitter for TUI integration */
49
49
  eventEmitter?: PipelineEventEmitter;
50
- /** Path to write a machine-readable JSON status file. Omit to skip writing. */
51
- statusFile?: string;
50
+ /** Path to write a machine-readable JSON status file */
51
+ statusFile: string;
52
52
  /** Path to JSONL log file (for crash recovery) */
53
53
  logFilePath?: string;
54
54
  /** Formatter verbosity mode for headless stdout (default: "normal") */
@@ -99,6 +99,9 @@ export async function run(options: RunOptions): Promise<RunResult> {
99
99
 
100
100
  const logger = getSafeLogger();
101
101
 
102
+ // Declare prd before crash handler setup to avoid TDZ if SIGTERM arrives during setupRun
103
+ let prd: Awaited<ReturnType<typeof import("./lifecycle/run-setup").setupRun>>["prd"] | undefined;
104
+
102
105
  // ── Execute initial setup phase ──────────────────────────────────────────────
103
106
  const { setupRun } = await import("./lifecycle/run-setup");
104
107
  const setupResult = await setupRun({
@@ -120,7 +123,7 @@ export async function run(options: RunOptions): Promise<RunResult> {
120
123
  getIterations: () => iterations,
121
124
  // BUG-017: Pass getters for run.complete event on SIGTERM
122
125
  getStoriesCompleted: () => storiesCompleted,
123
- getTotalStories: () => countStories(prd).total,
126
+ getTotalStories: () => (prd ? countStories(prd).total : 0),
124
127
  });
125
128
 
126
129
  const {
@@ -131,7 +134,7 @@ export async function run(options: RunOptions): Promise<RunResult> {
131
134
  storyCounts: counts,
132
135
  interactionChain,
133
136
  } = setupResult;
134
- let prd = setupResult.prd;
137
+ prd = setupResult.prd;
135
138
 
136
139
  try {
137
140
  // ── Output run header in headless mode ─────────────────────────────────
@@ -50,7 +50,7 @@ export interface StatusWriterContext {
50
50
  * await sw.update(totalCost, iterations);
51
51
  */
52
52
  export class StatusWriter {
53
- private readonly statusFile: string | undefined;
53
+ private readonly statusFile: string;
54
54
  private readonly costLimit: number | null;
55
55
  private readonly ctx: StatusWriterContext;
56
56
 
@@ -60,7 +60,7 @@ export class StatusWriter {
60
60
  private _currentStory: RunStateSnapshot["currentStory"] = null;
61
61
  private _consecutiveWriteFailures = 0; // BUG-2: Track consecutive write failures
62
62
 
63
- constructor(statusFile: string | undefined, config: NaxConfig, ctx: StatusWriterContext) {
63
+ constructor(statusFile: string, config: NaxConfig, ctx: StatusWriterContext) {
64
64
  this.statusFile = statusFile;
65
65
  this.costLimit = config.execution.costLimit === Number.POSITIVE_INFINITY ? null : config.execution.costLimit;
66
66
  this.ctx = ctx;
@@ -107,7 +107,7 @@ export class StatusWriter {
107
107
  /**
108
108
  * Write the current status to disk (atomic via .tmp + rename).
109
109
  *
110
- * No-ops if statusFile was not provided or _prd has not been set.
110
+ * No-ops if _prd has not been set.
111
111
  * On failure, logs a warning/error and increments the BUG-2 failure counter.
112
112
  * Counter resets to 0 on next successful write.
113
113
  *
@@ -116,7 +116,7 @@ export class StatusWriter {
116
116
  * @param overrides - Optional partial snapshot overrides (spread last)
117
117
  */
118
118
  async update(totalCost: number, iterations: number, overrides: Partial<RunStateSnapshot> = {}): Promise<void> {
119
- if (!this.statusFile || !this._prd) return;
119
+ if (!this._prd) return;
120
120
  const safeLogger = getSafeLogger();
121
121
  try {
122
122
  const base = this.getSnapshot(totalCost, iterations);
@@ -133,9 +133,11 @@ export const acceptanceStage: PipelineStage = {
133
133
  stderr: "pipe",
134
134
  });
135
135
 
136
- const exitCode = await proc.exited;
137
- const stdout = await new Response(proc.stdout).text();
138
- const stderr = await new Response(proc.stderr).text();
136
+ const [exitCode, stdout, stderr] = await Promise.all([
137
+ proc.exited,
138
+ new Response(proc.stdout).text(),
139
+ new Response(proc.stderr).text(),
140
+ ]);
139
141
 
140
142
  // Combine stdout and stderr for parsing
141
143
  const output = `${stdout}\n${stderr}`;
@@ -113,9 +113,11 @@ interface CommandResult {
113
113
  async function runCommand(cmd: string, cwd: string): Promise<CommandResult> {
114
114
  const parts = cmd.split(/\s+/);
115
115
  const proc = Bun.spawn(parts, { cwd, stdout: "pipe", stderr: "pipe" });
116
- const exitCode = await proc.exited;
117
- const stdout = await new Response(proc.stdout).text();
118
- const stderr = await new Response(proc.stderr).text();
116
+ const [exitCode, stdout, stderr] = await Promise.all([
117
+ proc.exited,
118
+ new Response(proc.stdout).text(),
119
+ new Response(proc.stderr).text(),
120
+ ]);
119
121
  return { exitCode, output: `${stdout}\n${stderr}` };
120
122
  }
121
123
 
@@ -98,6 +98,8 @@ async function callLlmOnce(modelTier: string, prompt: string, config: NaxConfig,
98
98
  reject(new Error(`LLM call timeout after ${timeoutMs}ms`));
99
99
  }, timeoutMs);
100
100
  });
101
+ // Prevent unhandled rejection if timer fires between race resolution and clearTimeout
102
+ timeoutPromise.catch(() => {});
101
103
 
102
104
  const outputPromise = (async () => {
103
105
  const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
@@ -116,17 +118,16 @@ async function callLlmOnce(modelTier: string, prompt: string, config: NaxConfig,
116
118
  return result;
117
119
  } catch (err) {
118
120
  clearTimeout(timeoutId);
119
- try {
120
- proc.stdout.cancel();
121
- } catch {
122
- // ignore cancel errors
123
- }
124
- try {
125
- proc.stderr.cancel();
126
- } catch {
127
- // ignore cancel errors
128
- }
121
+ // Silence the floating outputPromise BEFORE killing the process.
122
+ // proc.kill() causes piped streams to error → Response.text() rejects →
123
+ // outputPromise rejects. The .catch() must be attached first to prevent
124
+ // an unhandled rejection that crashes nax via crash-recovery.
125
+ outputPromise.catch(() => {});
129
126
  proc.kill();
127
+ // DO NOT call proc.stdout.cancel() / proc.stderr.cancel() here.
128
+ // The streams are locked by Response.text() readers. Per Web Streams spec,
129
+ // cancel() on a locked stream returns a rejected Promise (not a sync throw),
130
+ // which becomes an unhandled rejection. Let proc.kill() handle cleanup.
130
131
  throw err;
131
132
  }
132
133
  }
@@ -34,8 +34,16 @@ async function drainWithDeadline(proc: Subprocess, deadlineMs: number): Promise<
34
34
  if (o !== EMPTY) out += o;
35
35
  if (e !== EMPTY) out += (out ? "\n" : "") + e;
36
36
  } catch (error) {
37
- // Streams may already be destroyed - this is expected after kill
38
- // No logger available in this utility function context
37
+ // Expected: streams destroyed after kill (e.g. TypeError from closed ReadableStream)
38
+ const isExpectedStreamError =
39
+ error instanceof TypeError ||
40
+ (error instanceof Error && /abort|cancel|close|destroy|locked/i.test(error.message));
41
+ if (!isExpectedStreamError) {
42
+ const { getSafeLogger } = await import("../logger");
43
+ getSafeLogger()?.debug("executor", "Unexpected error draining process output", {
44
+ error: error instanceof Error ? error.message : String(error),
45
+ });
46
+ }
39
47
  }
40
48
  return out;
41
49
  }
@@ -93,15 +101,19 @@ export async function executeWithTimeout(
93
101
  const timeoutMs = timeoutSeconds * 1000;
94
102
 
95
103
  let timedOut = false;
104
+ const timer = { id: undefined as ReturnType<typeof setTimeout> | undefined };
96
105
 
97
- const timeoutPromise = (async () => {
98
- await Bun.sleep(timeoutMs);
99
- timedOut = true;
100
- })();
106
+ const timeoutPromise = new Promise<void>((resolve) => {
107
+ timer.id = setTimeout(() => {
108
+ timedOut = true;
109
+ resolve();
110
+ }, timeoutMs);
111
+ });
101
112
 
102
113
  const processPromise = proc.exited;
103
114
 
104
115
  const raceResult = await Promise.race([processPromise, timeoutPromise]);
116
+ clearTimeout(timer.id);
105
117
 
106
118
  if (timedOut) {
107
119
  const pid = proc.pid;
@@ -283,9 +283,9 @@ describe("acquireLock and releaseLock", () => {
283
283
  // Create invalid JSON lock file
284
284
  await Bun.write(lockPath, "not valid json");
285
285
 
286
- // Should fail to acquire but not crash
286
+ // Should treat corrupt lock as stale and acquire successfully
287
287
  const acquired = await acquireLock(testDir);
288
- expect(acquired).toBe(false);
288
+ expect(acquired).toBe(true);
289
289
  });
290
290
 
291
291
  test("handles release when lock file doesn't exist", async () => {
@@ -3,12 +3,11 @@
3
3
  * Integration Tests: Status File — runner + CLI (T2)
4
4
  *
5
5
  * Verifies:
6
- * - RunOptions.statusFile?: string exists
7
- * - Status file written at all 4 write points (dry-run path)
8
- * - Status file NOT written when statusFile omitted
6
+ * - RunOptions.statusFile: string is required
7
+ * - Status file always written at all 4 write points (dry-run path)
9
8
  * - Valid JSON at each stage, NaxStatusFile schema correct
10
9
  * - completed status, progress counts, null current at end
11
- * - --status-file CLI option wiring (type-level)
10
+ * - CLI automatically computes statusFile to <workdir>/nax/status.json
12
11
  */
13
12
 
14
13
  import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
@@ -53,13 +52,13 @@ class MockAgentAdapter implements AgentAdapter {
53
52
  return [this.binary];
54
53
  }
55
54
  async run(_o: AgentRunOptions): Promise<AgentResult> {
56
- return { success: true, exitCode: 0, output: "", durationMs: 10, estimatedCost: 0 };
55
+ return { success: true, exitCode: 0, output: "", durationMs: 10, estimatedCost: 0, rateLimited: false };
57
56
  }
58
57
  async plan(_o: PlanOptions): Promise<PlanResult> {
59
- return { specContent: "# Feature\n", success: true };
58
+ return { specContent: "# Feature\n" };
60
59
  }
61
60
  async decompose(_o: DecomposeOptions): Promise<DecomposeResult> {
62
- return { stories: [], success: true };
61
+ return { stories: [] };
63
62
  }
64
63
  }
65
64
 
@@ -143,7 +142,7 @@ async function runWithStatus(feature: string, storyCount = 1, extraOpts: Partial
143
142
  // RunOptions type-level checks
144
143
  // ============================================================================
145
144
  describe("RunOptions.statusFile", () => {
146
- it("is optional (can be omitted)", () => {
145
+ it("is required", () => {
147
146
  const opts: RunOptions = {
148
147
  prdPath: "/tmp/prd.json",
149
148
  workdir: "/tmp",
@@ -151,52 +150,25 @@ describe("RunOptions.statusFile", () => {
151
150
  hooks: { hooks: {} },
152
151
  feature: "test",
153
152
  dryRun: true,
153
+ statusFile: "/tmp/nax/status.json",
154
154
  };
155
- expect(opts.statusFile).toBeUndefined();
156
- });
157
-
158
- it("accepts a string value", () => {
159
- const opts: RunOptions = {
160
- prdPath: "/tmp/prd.json",
161
- workdir: "/tmp",
162
- config: createTestConfig(),
163
- hooks: { hooks: {} },
164
- feature: "test",
165
- dryRun: true,
166
- statusFile: "/tmp/nax-status.json",
167
- };
168
- expect(opts.statusFile).toBe("/tmp/nax-status.json");
155
+ expect(opts.statusFile).toBe("/tmp/nax/status.json");
169
156
  });
170
157
  });
171
158
 
172
159
  // ============================================================================
173
- // No status file when option omitted (regression test)
160
+ // Status file is always written when provided
174
161
  // ============================================================================
175
- describe("status file not written when omitted", () => {
162
+ describe("status file always written when provided", () => {
176
163
  let tmpDir: string;
177
164
  afterEach(async () => {
178
165
  if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true });
179
166
  });
180
167
 
181
- it("does not create any .json file in tmpDir", async () => {
182
- const setup = await setupDir("no-sf", 1);
168
+ it("writes status file to provided path during dry-run", async () => {
169
+ const { setup, statusFilePath } = await runWithStatus("sf-always-written", 1);
183
170
  tmpDir = setup.tmpDir;
184
- const before = await fs.readdir(setup.tmpDir);
185
-
186
- await run({
187
- prdPath: setup.prdPath,
188
- workdir: setup.tmpDir,
189
- config: createTestConfig(),
190
- hooks: { hooks: {} },
191
- feature: "no-sf",
192
- featureDir: setup.featureDir,
193
- dryRun: true, // no statusFile
194
- skipPrecheck: true,
195
- });
196
-
197
- const after = await fs.readdir(setup.tmpDir);
198
- const newJson = after.filter((f) => f.endsWith(".json") && !before.includes(f));
199
- expect(newJson).toHaveLength(0);
171
+ expect(nodeFs.existsSync(statusFilePath)).toBe(true);
200
172
  });
201
173
  });
202
174
 
@@ -299,28 +271,19 @@ describe("status file written during dry-run", () => {
299
271
  });
300
272
 
301
273
  // ============================================================================
302
- // CLI --status-file wiring (type check only)
274
+ // CLI status file wiring (type check only)
303
275
  // ============================================================================
304
- describe("CLI --status-file wiring", () => {
305
- it("RunOptions.statusFile is passed through correctly", () => {
306
- const withFile: RunOptions = {
307
- prdPath: "/tmp/prd.json",
308
- workdir: "/tmp",
309
- config: createTestConfig(),
310
- hooks: { hooks: {} },
311
- feature: "test",
312
- dryRun: false,
313
- statusFile: "/tmp/status.json",
314
- };
315
- const withoutFile: RunOptions = {
276
+ describe("CLI auto-computed status file", () => {
277
+ it("RunOptions.statusFile is required and always provided", () => {
278
+ const opts: RunOptions = {
316
279
  prdPath: "/tmp/prd.json",
317
280
  workdir: "/tmp",
318
281
  config: createTestConfig(),
319
282
  hooks: { hooks: {} },
320
283
  feature: "test",
321
284
  dryRun: false,
285
+ statusFile: "/tmp/nax/status.json",
322
286
  };
323
- expect(withFile.statusFile).toBe("/tmp/status.json");
324
- expect(withoutFile.statusFile).toBeUndefined();
287
+ expect(opts.statusFile).toBe("/tmp/nax/status.json");
325
288
  });
326
289
  });
@@ -77,14 +77,10 @@ function makeCtx(overrides: Partial<StatusWriterContext> = {}): StatusWriterCont
77
77
  // ============================================================================
78
78
 
79
79
  describe("StatusWriter construction", () => {
80
- test("constructs without error when statusFile is defined", () => {
80
+ test("constructs without error with statusFile path", () => {
81
81
  expect(() => new StatusWriter("/tmp/status.json", makeConfig(), makeCtx())).not.toThrow();
82
82
  });
83
83
 
84
- test("constructs without error when statusFile is undefined (no-op mode)", () => {
85
- expect(() => new StatusWriter(undefined, makeConfig(), makeCtx())).not.toThrow();
86
- });
87
-
88
84
  test("costLimit Infinity → stored as null in snapshot", async () => {
89
85
  const dir = await mkdtemp(join(tmpdir(), "sw-test-"));
90
86
  const path = join(dir, "status.json");
@@ -214,13 +210,6 @@ describe("StatusWriter.getSnapshot", () => {
214
210
  // ============================================================================
215
211
 
216
212
  describe("StatusWriter.update no-op guards", () => {
217
- test("no-op when statusFile is undefined (even with prd set)", async () => {
218
- const sw = new StatusWriter(undefined, makeConfig(), makeCtx());
219
- sw.setPrd(makePrd());
220
- // Should not throw
221
- await expect(sw.update(0, 0)).resolves.toBeUndefined();
222
- });
223
-
224
213
  test("no-op when prd not yet set", async () => {
225
214
  const dir = await mkdtemp(join(tmpdir(), "sw-test-"));
226
215
  const path = join(dir, "status.json");