@nathapp/nax 0.22.4 → 0.23.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.
@@ -0,0 +1,97 @@
1
+ # TDD Strategies
2
+
3
+ nax supports three test strategies, selectable via `config.tdd.strategy` or per-story override.
4
+
5
+ ## Strategy Comparison
6
+
7
+ | Aspect | `three-session-tdd` | `three-session-tdd-lite` | `test-after` |
8
+ |---|---|---|---|
9
+ | **Sessions** | 3 separate sessions | 3 separate sessions | 1 session |
10
+ | **Session 1 (Test Writer)** | Strict isolation — tests only, NO src/ reads, NO stubs | Relaxed — can read src/, create stubs in src/ | ❌ No dedicated test writer |
11
+ | **Session 2 (Implementer)** | Implements against pre-written tests | Same | Implements + writes tests |
12
+ | **Session 3 (Verifier)** | Verifies isolation wasn't violated | Same | ❌ No verifier |
13
+ | **Isolation check** | ✅ Full isolation enforcement | ✅ Full isolation enforcement | ❌ None |
14
+ | **Isolation-violation fallback** | Triggers lite-mode retry | N/A (already lite) | N/A |
15
+ | **Rectification gate** | Checks implementer isolation | ⚡ Skips `verifyImplementerIsolation` | Standard |
16
+
17
+ ---
18
+
19
+ ## When Each Strategy Is Used
20
+
21
+ Controlled by `config.tdd.strategy`:
22
+
23
+ | Config value | Behaviour |
24
+ |---|---|
25
+ | `"strict"` | Always `three-session-tdd` |
26
+ | `"lite"` | Always `three-session-tdd-lite` |
27
+ | `"off"` | Always `test-after` |
28
+ | `"auto"` | LLM/keyword router decides (see routing rules below) |
29
+
30
+ ### Auto-Routing Rules (FEAT-013)
31
+
32
+ `test-after` is **deprecated** from auto mode. Default fallback is now `three-session-tdd-lite`.
33
+
34
+ | Condition | Strategy |
35
+ |---|---|
36
+ | Security / auth logic | `three-session-tdd` |
37
+ | Public API / complex / expert | `three-session-tdd` |
38
+ | UI / layout / CLI / integration / polyglot tags | `three-session-tdd-lite` |
39
+ | Simple / medium (default) | `three-session-tdd-lite` |
40
+
41
+ ---
42
+
43
+ ## Session Detail
44
+
45
+ ### `three-session-tdd` — Full Mode
46
+
47
+ 1. **Test Writer** — writes failing tests only. Cannot read src/ files or create any source stubs. Strict isolation enforced by post-session diff check.
48
+ 2. **Implementer** — makes all failing tests pass. Works against the test-writer's output.
49
+ 3. **Verifier** — confirms isolation: tests were written before implementation, no cheating.
50
+
51
+ If the test writer violates isolation (touches src/), the orchestrator flags it as `isolation-violation` and schedules a lite-mode retry on the next attempt.
52
+
53
+ ### `three-session-tdd-lite` — Lite Mode
54
+
55
+ Same 3-session flow, but the test writer prompt is relaxed:
56
+ - **Can read** existing src/ files (needed when importing existing types/interfaces).
57
+ - **Can create minimal stubs** in src/ (empty exports, no logic) to make imports resolve.
58
+ - Implementer isolation check (`verifyImplementerIsolation`) is **skipped** in the rectification gate.
59
+
60
+ Best for: existing codebases where greenfield isolation is impractical, or stories that modify existing modules.
61
+
62
+ ### `test-after` — Single Session
63
+
64
+ One Claude Code session writes tests and implements the feature together. No structured TDD flow.
65
+
66
+ - Higher failure rate observed in practice — Claude tends to write tests that are trivially passing or implementation-first.
67
+ - Use only when `tdd.strategy: "off"` or explicitly set per-story.
68
+
69
+ ---
70
+
71
+ ## Per-Story Override
72
+
73
+ Add `testStrategy` to a story in `prd.json` to override routing:
74
+
75
+ ```json
76
+ {
77
+ "userStories": [
78
+ {
79
+ "id": "US-001",
80
+ "testStrategy": "three-session-tdd-lite",
81
+ ...
82
+ }
83
+ ]
84
+ }
85
+ ```
86
+
87
+ Supported values: `"test-after"`, `"three-session-tdd"`, `"three-session-tdd-lite"`.
88
+
89
+ ---
90
+
91
+ ## Known Issues
92
+
93
+ - **BUG-045:** LLM batch routing bypasses `config.tdd.strategy`. `buildBatchPrompt()` only offers `test-after` and `three-session-tdd` to the LLM — no `three-session-tdd-lite`. The cache hit path returns the LLM decision directly without calling `determineTestStrategy()`, so `tdd.strategy: "lite"` is silently ignored for batch-routed stories. Fix: post-process batch decisions through `determineTestStrategy()`. See `src/routing/strategies/llm.ts:routeBatch()`.
94
+
95
+ ---
96
+
97
+ *Last updated: 2026-03-07*
@@ -97,7 +97,9 @@ async function createStatusFile(
97
97
  ...overrides,
98
98
  };
99
99
 
100
- await Bun.write(join(dir, ".nax-status.json"), JSON.stringify(status, null, 2));
100
+ // Ensure nax directory exists
101
+ mkdirSync(join(dir, "nax"), { recursive: true });
102
+ await Bun.write(join(dir, "nax", "status.json"), JSON.stringify(status, null, 2));
101
103
  }
102
104
 
103
105
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.22.4",
4
- "description": "AI Coding Agent Orchestrator \u2014 loops until done",
3
+ "version": "0.23.0",
4
+ "description": "AI Coding Agent Orchestrator loops until done",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "nax": "./bin/nax.ts"
@@ -9,7 +9,7 @@
9
9
  "scripts": {
10
10
  "prepare": "git config core.hooksPath .githooks",
11
11
  "dev": "bun run bin/nax.ts",
12
- "build": "bun build bin/nax.ts --outdir dist --target bun",
12
+ "build": "bun build bin/nax.ts --outdir dist --target bun --define \"GIT_COMMIT=\\\"$(git rev-parse --short HEAD)\\\"\"",
13
13
  "typecheck": "bun x tsc --noEmit",
14
14
  "lint": "bun x biome check src/ bin/",
15
15
  "test": "NAX_SKIP_PRECHECK=1 bun test test/ --timeout=60000",
@@ -44,4 +44,4 @@
44
44
  "tdd",
45
45
  "coding"
46
46
  ]
47
- }
47
+ }
@@ -86,7 +86,7 @@ function isProcessAlive(pid: number): boolean {
86
86
  }
87
87
 
88
88
  async function loadStatusFile(workdir: string): Promise<NaxStatusFile | null> {
89
- const statusPath = join(workdir, ".nax-status.json");
89
+ const statusPath = join(workdir, "nax", "status.json");
90
90
  if (!existsSync(statusPath)) return null;
91
91
  try {
92
92
  return (await Bun.file(statusPath).json()) as NaxStatusFile;
@@ -41,14 +41,11 @@ interface FeatureSummary {
41
41
  };
42
42
  }
43
43
 
44
- /** Check if a process is alive via PID check */
44
+ /** Check if a process is alive via POSIX signal 0 (portable, no subprocess) */
45
45
  function isPidAlive(pid: number): boolean {
46
46
  try {
47
- const result = Bun.spawnSync(["ps", "-p", String(pid)], {
48
- stdout: "ignore",
49
- stderr: "ignore",
50
- });
51
- return result.exitCode === 0;
47
+ process.kill(pid, 0);
48
+ return true;
52
49
  } catch {
53
50
  return false;
54
51
  }
@@ -69,6 +66,21 @@ async function loadStatusFile(featureDir: string): Promise<NaxStatusFile | null>
69
66
  }
70
67
  }
71
68
 
69
+ /** Load project-level status.json (if it exists) */
70
+ async function loadProjectStatusFile(projectDir: string): Promise<NaxStatusFile | null> {
71
+ const statusPath = join(projectDir, "nax", "status.json");
72
+ if (!existsSync(statusPath)) {
73
+ return null;
74
+ }
75
+
76
+ try {
77
+ const content = Bun.file(statusPath);
78
+ return (await content.json()) as NaxStatusFile;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
72
84
  /** Get feature summary from prd.json and optional status.json */
73
85
  async function getFeatureSummary(featureName: string, featureDir: string): Promise<FeatureSummary> {
74
86
  const prdPath = join(featureDir, "prd.json");
@@ -154,10 +166,46 @@ async function displayAllFeatures(projectDir: string): Promise<void> {
154
166
  return;
155
167
  }
156
168
 
169
+ // Load project-level status if available (current run info)
170
+ const projectStatus = await loadProjectStatusFile(projectDir);
171
+
172
+ // Display current run info if available
173
+ if (projectStatus) {
174
+ const pidAlive = isPidAlive(projectStatus.run.pid);
175
+
176
+ if (projectStatus.run.status === "running" && pidAlive) {
177
+ console.log(chalk.yellow("⚡ Currently Running:\n"));
178
+ console.log(chalk.dim(` Feature: ${projectStatus.run.feature}`));
179
+ console.log(chalk.dim(` Run ID: ${projectStatus.run.id}`));
180
+ console.log(chalk.dim(` Started: ${projectStatus.run.startedAt}`));
181
+ console.log(chalk.dim(` Progress: ${projectStatus.progress.passed}/${projectStatus.progress.total} stories`));
182
+ console.log(chalk.dim(` Cost: $${projectStatus.cost.spent.toFixed(4)}`));
183
+
184
+ if (projectStatus.current) {
185
+ console.log(chalk.dim(` Current: ${projectStatus.current.storyId} - ${projectStatus.current.title}`));
186
+ }
187
+
188
+ console.log();
189
+ } else if ((projectStatus.run.status === "running" && !pidAlive) || projectStatus.run.status === "crashed") {
190
+ console.log(chalk.red("💥 Crashed Run Detected:\n"));
191
+ console.log(chalk.dim(` Feature: ${projectStatus.run.feature}`));
192
+ console.log(chalk.dim(` Run ID: ${projectStatus.run.id}`));
193
+ console.log(chalk.dim(` PID: ${projectStatus.run.pid} (dead)`));
194
+ console.log(chalk.dim(` Started: ${projectStatus.run.startedAt}`));
195
+ if (projectStatus.run.crashedAt) {
196
+ console.log(chalk.dim(` Crashed: ${projectStatus.run.crashedAt}`));
197
+ }
198
+ if (projectStatus.run.crashSignal) {
199
+ console.log(chalk.dim(` Signal: ${projectStatus.run.crashSignal}`));
200
+ }
201
+ console.log();
202
+ }
203
+ }
204
+
157
205
  // Load summaries for all features
158
206
  const summaries = await Promise.all(features.map((name) => getFeatureSummary(name, join(featuresDir, name))));
159
207
 
160
- console.log(chalk.bold("\n📊 Features\n"));
208
+ console.log(chalk.bold("📊 Features\n"));
161
209
 
162
210
  // Print table header
163
211
  const header = ` ${"Feature".padEnd(25)} ${"Done".padEnd(6)} ${"Failed".padEnd(8)} ${"Pending".padEnd(9)} ${"Last Run".padEnd(22)} ${"Cost".padEnd(10)} Status`;
@@ -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");
@@ -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
  }
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})`;
@@ -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
  /**