@nathapp/nax 0.22.3 → 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.
- package/README.md +21 -2
- package/docs/ROADMAP.md +6 -0
- package/docs/specs/central-run-registry.md +13 -1
- package/docs/tdd/strategies.md +97 -0
- package/nax/config.json +4 -3
- package/nax/features/diagnose/acceptance.test.ts +3 -1
- package/nax/features/status-file-consolidation/prd.json +52 -7
- package/nax/status.json +17 -8
- package/package.json +4 -4
- package/src/cli/diagnose.ts +1 -1
- package/src/cli/status-features.ts +55 -7
- package/src/config/schemas.ts +3 -0
- package/src/config/types.ts +2 -0
- package/src/execution/crash-recovery.ts +30 -7
- package/src/execution/lifecycle/run-setup.ts +6 -1
- package/src/execution/runner.ts +8 -0
- package/src/execution/status-writer.ts +42 -0
- package/src/pipeline/stages/verify.ts +21 -2
- package/src/verification/orchestrator-types.ts +2 -0
- package/src/verification/smart-runner.ts +5 -2
- package/src/verification/strategies/scoped.ts +9 -2
- package/src/verification/types.ts +2 -0
- package/src/version.ts +23 -0
- package/test/e2e/plan-analyze-run.test.ts +5 -0
- package/test/integration/cli/cli-diagnose.test.ts +3 -1
- package/test/integration/execution/feature-status-write.test.ts +302 -0
- package/test/integration/execution/status-file-integration.test.ts +1 -1
- package/test/integration/execution/status-writer.test.ts +112 -0
- package/test/unit/cli-status-project-level.test.ts +283 -0
- package/test/unit/config/quality-commands-schema.test.ts +72 -0
- package/test/unit/execution/sfc-004-dead-code-cleanup.test.ts +89 -0
- package/test/unit/verification/smart-runner.test.ts +16 -0
package/README.md
CHANGED
|
@@ -223,14 +223,33 @@ Config is layered — project overrides global:
|
|
|
223
223
|
},
|
|
224
224
|
"quality": {
|
|
225
225
|
"commands": {
|
|
226
|
-
"test": "bun test",
|
|
226
|
+
"test": "bun test test/ --timeout=60000",
|
|
227
|
+
"testScoped": "bun test --timeout=60000 {{files}}",
|
|
227
228
|
"lint": "bun run lint",
|
|
228
|
-
"typecheck": "bun x tsc --noEmit"
|
|
229
|
+
"typecheck": "bun x tsc --noEmit",
|
|
230
|
+
"lintFix": "bun x biome check --fix src/",
|
|
231
|
+
"formatFix": "bun x biome format --write src/"
|
|
229
232
|
}
|
|
230
233
|
}
|
|
231
234
|
}
|
|
232
235
|
```
|
|
233
236
|
|
|
237
|
+
### Scoped Test Command
|
|
238
|
+
|
|
239
|
+
By default, nax runs scoped tests (per-story verification) by appending discovered test files to the `test` command. This can produce incorrect commands when the base command includes a directory path (e.g. `bun test test/`), since the path is not replaced — it is appended alongside it.
|
|
240
|
+
|
|
241
|
+
Use `testScoped` to define the exact scoped test command with a `{{files}}` placeholder:
|
|
242
|
+
|
|
243
|
+
| Runner | `test` | `testScoped` |
|
|
244
|
+
|:-------|:-------|:-------------|
|
|
245
|
+
| Bun | `bun test test/ --timeout=60000` | `bun test --timeout=60000 {{files}}` |
|
|
246
|
+
| Jest | `npx jest` | `npx jest -- {{files}}` |
|
|
247
|
+
| pytest | `pytest tests/` | `pytest {{files}}` |
|
|
248
|
+
| cargo | `cargo test` | `cargo test {{files}}` |
|
|
249
|
+
| go | `go test ./...` | `go test {{files}}` |
|
|
250
|
+
|
|
251
|
+
If `testScoped` is not configured, nax falls back to a heuristic that replaces the last path-like token in the `test` command. **Recommended:** always configure `testScoped` explicitly to avoid surprises.
|
|
252
|
+
|
|
234
253
|
**TDD strategy options:**
|
|
235
254
|
|
|
236
255
|
| Value | Behaviour |
|
package/docs/ROADMAP.md
CHANGED
|
@@ -127,6 +127,8 @@
|
|
|
127
127
|
|
|
128
128
|
### Stories
|
|
129
129
|
- [x] ~~**SFC-001:** Auto-write project-level status — remove `--status-file` flag, always write to `<workdir>/nax/status.json`~~
|
|
130
|
+
- [ ] **BUG-043:** Fix scoped test command construction + add `testScoped` config with `{{files}}` template
|
|
131
|
+
- [ ] **BUG-044:** Log scoped and full-suite test commands at info level in verify stage
|
|
130
132
|
- [ ] **SFC-002:** Write feature-level status on run end — copy final snapshot to `<workdir>/nax/features/<feature>/status.json`
|
|
131
133
|
- [ ] **SFC-003:** Align status readers — `nax status` + `nax diagnose` read from correct paths
|
|
132
134
|
- [ ] **SFC-004:** Clean up dead code — remove `--status-file` option, `.nax-status.json` references
|
|
@@ -140,6 +142,7 @@
|
|
|
140
142
|
**Spec:** [docs/specs/central-run-registry.md](specs/central-run-registry.md)
|
|
141
143
|
|
|
142
144
|
### Stories
|
|
145
|
+
- [ ] **CRR-000:** `src/pipeline/subscribers/events-writer.ts` — `wireEventsWriter()`, writes lifecycle events to `~/.nax/events/<project>/events.jsonl` (machine-readable completion signal for watchdog/CI)
|
|
143
146
|
- [ ] **CRR-001:** `src/pipeline/subscribers/registry.ts` — `wireRegistry()` subscriber, listens to `run:started`, writes `~/.nax/runs/<project>-<feature>-<runId>/meta.json` (path pointers only — no data duplication, no symlinks)
|
|
144
147
|
- [ ] **CRR-002:** `src/commands/runs.ts` — `nax runs` CLI, reads `meta.json` → resolves live `status.json` from `statusPath`, displays table (project, feature, status, stories, duration, date). Filters: `--project`, `--last`, `--status`
|
|
145
148
|
- [ ] **CRR-003:** `nax logs --run <runId>` — resolve run from global registry via `eventsDir`, stream logs from any directory
|
|
@@ -284,6 +287,9 @@
|
|
|
284
287
|
|
|
285
288
|
- [x] ~~**BUG-037:** Test output summary (verify stage) captures precheck boilerplate instead of actual `bun test` failure. Fixed: `.slice(-20)` tail — shipped in v0.22.1 (re-arch phase 2).~~
|
|
286
289
|
- [x] ~~**BUG-038:** `smart-runner` over-matching when global defaults change. Fixed by FEAT-010 (v0.21.0) — per-attempt `storyGitRef` baseRef tracking; `git diff <baseRef>..HEAD` prevents cross-story file pollution.~~
|
|
290
|
+
- [ ] **BUG-043:** Scoped test command appends files instead of replacing path — `runners.ts:scoped()` concatenates `scopedTestPaths` to full-suite command, resulting in `bun test test/ --timeout=60000 /path/to/file.ts` (runs everything). Fix: use `testScoped` config with `{{files}}` template, fall back to `buildSmartTestCommand()` heuristic. **Location:** `src/verification/runners.ts:scoped()`
|
|
291
|
+
- [ ] **BUG-044:** Scoped/full-suite test commands not logged — no visibility into what command was actually executed during verify stage. Fix: log at info level before execution.
|
|
292
|
+
|
|
287
293
|
### Features
|
|
288
294
|
- [x] ~~`nax unlock` command~~
|
|
289
295
|
- [x] ~~Constitution file support~~
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Central Run Registry — Spec
|
|
2
2
|
|
|
3
|
-
**Version:** v0.
|
|
3
|
+
**Version:** v0.24.0
|
|
4
4
|
**Status:** Planned
|
|
5
5
|
|
|
6
6
|
---
|
|
@@ -60,6 +60,18 @@ A global `~/.nax/runs/` registry that indexes every nax run via path references
|
|
|
60
60
|
|
|
61
61
|
## Implementation
|
|
62
62
|
|
|
63
|
+
### CRR-000: Events File Writer (new subscriber)
|
|
64
|
+
|
|
65
|
+
- New module: `src/pipeline/subscribers/events-writer.ts` — `wireEventsWriter()`
|
|
66
|
+
- Writes to `~/.nax/events/<project>/events.jsonl` — one JSON line per lifecycle event
|
|
67
|
+
- Listens to event bus: `run:started`, `story:started`, `story:completed`, `story:failed`, `run:completed`
|
|
68
|
+
- Each line: `{"ts", "event", "runId", "feature", "project", "storyId?"}`
|
|
69
|
+
- `run:completed` emits an `on-complete` event — used by external tooling (watchdog) to distinguish clean exit from crash
|
|
70
|
+
- Best-effort: never throw/block the main run on write failure
|
|
71
|
+
- Directory created on first write
|
|
72
|
+
|
|
73
|
+
**Motivation:** External tools (nax-watchdog, CI integrations) need a reliable signal that nax exited gracefully. Currently nax writes no machine-readable completion event, causing false crash reports. This also provides the foundation for CRR — `meta.json` can reference the events file path.
|
|
74
|
+
|
|
63
75
|
### CRR-001: Registry Writer (new subscriber)
|
|
64
76
|
|
|
65
77
|
- New module: `src/execution/run-registry.ts` — `registerRun(meta)`, `getRunsDir()`
|
|
@@ -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*
|
package/nax/config.json
CHANGED
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"fallbackToKeywords": true,
|
|
53
53
|
"cacheDecisions": true,
|
|
54
54
|
"mode": "hybrid",
|
|
55
|
-
"timeoutMs":
|
|
55
|
+
"timeoutMs": 60000
|
|
56
56
|
}
|
|
57
57
|
},
|
|
58
58
|
"execution": {
|
|
@@ -84,7 +84,8 @@
|
|
|
84
84
|
"commands": {
|
|
85
85
|
"test": "bun run test",
|
|
86
86
|
"typecheck": "bun run typecheck",
|
|
87
|
-
"lint": "bun run lint"
|
|
87
|
+
"lint": "bun run lint",
|
|
88
|
+
"testScoped": "bun test --timeout=60000 {{files}}"
|
|
88
89
|
},
|
|
89
90
|
"forceExit": false,
|
|
90
91
|
"detectOpenHandles": true,
|
|
@@ -150,4 +151,4 @@
|
|
|
150
151
|
"scopeToStory": true
|
|
151
152
|
}
|
|
152
153
|
}
|
|
153
|
-
}
|
|
154
|
+
}
|
|
@@ -97,7 +97,9 @@ async function createStatusFile(
|
|
|
97
97
|
...overrides,
|
|
98
98
|
};
|
|
99
99
|
|
|
100
|
-
|
|
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
|
/**
|
|
@@ -10,13 +10,36 @@
|
|
|
10
10
|
"title": "Auto-write project-level status",
|
|
11
11
|
"description": "Remove --status-file CLI option. StatusWriter always writes to <workdir>/nax/status.json automatically. In bin/nax.ts, remove --status-file option and compute statusFile = join(workdir, 'nax', 'status.json'). In runner.ts, statusFile is no longer optional. In status-writer.ts, remove the if (!this.statusFile) guard in update().",
|
|
12
12
|
"complexity": "medium",
|
|
13
|
-
"status": "
|
|
13
|
+
"status": "pending",
|
|
14
14
|
"acceptanceCriteria": [
|
|
15
15
|
"Running nax without --status-file flag writes nax/status.json automatically",
|
|
16
16
|
"nax/status.json contains valid NaxStatusFile schema with run.id, run.status, progress counts",
|
|
17
17
|
"--status-file CLI option no longer exists",
|
|
18
18
|
"StatusWriter.update() always writes (no no-op guard on missing statusFile)"
|
|
19
|
-
]
|
|
19
|
+
],
|
|
20
|
+
"attempts": 0,
|
|
21
|
+
"priorErrors": [
|
|
22
|
+
"Attempt 1 failed with model tier: fast: Review failed: test failed (exit code -1)"
|
|
23
|
+
],
|
|
24
|
+
"priorFailures": [
|
|
25
|
+
{
|
|
26
|
+
"attempt": 1,
|
|
27
|
+
"modelTier": "fast",
|
|
28
|
+
"stage": "escalation",
|
|
29
|
+
"summary": "Failed with tier fast, escalating to next tier",
|
|
30
|
+
"timestamp": "2026-03-07T06:22:18.122Z"
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"escalations": [],
|
|
34
|
+
"dependencies": [],
|
|
35
|
+
"tags": [],
|
|
36
|
+
"storyPoints": 1,
|
|
37
|
+
"routing": {
|
|
38
|
+
"complexity": "medium",
|
|
39
|
+
"modelTier": "balanced",
|
|
40
|
+
"testStrategy": "test-after",
|
|
41
|
+
"reasoning": "Straightforward refactor: remove CLI option, hardcode path computation, remove null guard across 3 files"
|
|
42
|
+
}
|
|
20
43
|
},
|
|
21
44
|
{
|
|
22
45
|
"id": "SFC-002",
|
|
@@ -29,12 +52,19 @@
|
|
|
29
52
|
"After a failed run, nax/features/<feature>/status.json exists with status 'failed'",
|
|
30
53
|
"After a crash, nax/features/<feature>/status.json exists with status 'crashed'",
|
|
31
54
|
"Feature status.json uses the same NaxStatusFile schema as project-level"
|
|
32
|
-
]
|
|
55
|
+
],
|
|
56
|
+
"attempts": 0,
|
|
57
|
+
"priorErrors": [],
|
|
58
|
+
"priorFailures": [],
|
|
59
|
+
"escalations": [],
|
|
60
|
+
"dependencies": [],
|
|
61
|
+
"tags": [],
|
|
62
|
+
"storyPoints": 1
|
|
33
63
|
},
|
|
34
64
|
{
|
|
35
65
|
"id": "SFC-003",
|
|
36
66
|
"title": "Align status readers",
|
|
37
|
-
"description": "Make nax status read project-level status from nax/status.json for currently running info. Make nax diagnose read from nax/status.json instead of .nax-status.json. status-features.ts loadStatusFile() already reads <featureDir>/status.json which SFC-002 now writes
|
|
67
|
+
"description": "Make nax status read project-level status from nax/status.json for currently running info. Make nax diagnose read from nax/status.json instead of .nax-status.json. status-features.ts loadStatusFile() already reads <featureDir>/status.json which SFC-002 now writes — no change needed for feature-level reads.",
|
|
38
68
|
"complexity": "simple",
|
|
39
69
|
"status": "pending",
|
|
40
70
|
"acceptanceCriteria": [
|
|
@@ -42,7 +72,14 @@
|
|
|
42
72
|
"nax status shows per-feature historical status from nax/features/<feature>/status.json",
|
|
43
73
|
"nax diagnose reads from nax/status.json (not .nax-status.json)",
|
|
44
74
|
"No references to .nax-status.json remain in codebase"
|
|
45
|
-
]
|
|
75
|
+
],
|
|
76
|
+
"attempts": 0,
|
|
77
|
+
"priorErrors": [],
|
|
78
|
+
"priorFailures": [],
|
|
79
|
+
"escalations": [],
|
|
80
|
+
"dependencies": [],
|
|
81
|
+
"tags": [],
|
|
82
|
+
"storyPoints": 1
|
|
46
83
|
},
|
|
47
84
|
{
|
|
48
85
|
"id": "SFC-004",
|
|
@@ -55,7 +92,15 @@
|
|
|
55
92
|
"No references to .nax-status.json in codebase",
|
|
56
93
|
"RunOptions.statusFile is required (not optional)",
|
|
57
94
|
"All existing tests pass"
|
|
58
|
-
]
|
|
95
|
+
],
|
|
96
|
+
"attempts": 0,
|
|
97
|
+
"priorErrors": [],
|
|
98
|
+
"priorFailures": [],
|
|
99
|
+
"escalations": [],
|
|
100
|
+
"dependencies": [],
|
|
101
|
+
"tags": [],
|
|
102
|
+
"storyPoints": 1
|
|
59
103
|
}
|
|
60
|
-
]
|
|
104
|
+
],
|
|
105
|
+
"updatedAt": "2026-03-07T06:22:18.122Z"
|
|
61
106
|
}
|
package/nax/status.json
CHANGED
|
@@ -4,25 +4,34 @@
|
|
|
4
4
|
"id": "run-2026-03-07T06-14-21-018Z",
|
|
5
5
|
"feature": "status-file-consolidation",
|
|
6
6
|
"startedAt": "2026-03-07T06:14:21.018Z",
|
|
7
|
-
"status": "
|
|
7
|
+
"status": "crashed",
|
|
8
8
|
"dryRun": false,
|
|
9
|
-
"pid": 217461
|
|
9
|
+
"pid": 217461,
|
|
10
|
+
"crashedAt": "2026-03-07T06:22:36.300Z",
|
|
11
|
+
"crashSignal": "SIGTERM"
|
|
10
12
|
},
|
|
11
13
|
"progress": {
|
|
12
14
|
"total": 4,
|
|
13
|
-
"passed":
|
|
15
|
+
"passed": 0,
|
|
14
16
|
"failed": 0,
|
|
15
17
|
"paused": 0,
|
|
16
18
|
"blocked": 0,
|
|
17
|
-
"pending":
|
|
19
|
+
"pending": 4
|
|
18
20
|
},
|
|
19
21
|
"cost": {
|
|
20
22
|
"spent": 0,
|
|
21
23
|
"limit": 3
|
|
22
24
|
},
|
|
23
|
-
"current":
|
|
25
|
+
"current": {
|
|
26
|
+
"storyId": "SFC-002",
|
|
27
|
+
"title": "Write feature-level status on run end",
|
|
28
|
+
"complexity": "medium",
|
|
29
|
+
"tddStrategy": "test-after",
|
|
30
|
+
"model": "balanced",
|
|
31
|
+
"attempt": 1,
|
|
32
|
+
"phase": "routing"
|
|
33
|
+
},
|
|
24
34
|
"iterations": 0,
|
|
25
|
-
"updatedAt": "2026-03-07T06:
|
|
26
|
-
"durationMs":
|
|
27
|
-
"lastHeartbeat": "2026-03-07T06:19:34.987Z"
|
|
35
|
+
"updatedAt": "2026-03-07T06:22:36.300Z",
|
|
36
|
+
"durationMs": 495282
|
|
28
37
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nathapp/nax",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "AI Coding Agent Orchestrator
|
|
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
|
+
}
|
package/src/cli/diagnose.ts
CHANGED
|
@@ -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, "
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
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("
|
|
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`;
|
package/src/config/schemas.ts
CHANGED
|
@@ -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),
|
package/src/config/types.ts
CHANGED
|
@@ -140,6 +140,8 @@ export interface QualityConfig {
|
|
|
140
140
|
typecheck?: string;
|
|
141
141
|
lint?: string;
|
|
142
142
|
test?: string;
|
|
143
|
+
/** Scoped test command template with {{files}} placeholder (e.g., "bun test --timeout=60000 {{files}}") */
|
|
144
|
+
testScoped?: string;
|
|
143
145
|
/** Auto-fix lint errors (e.g., "biome check --fix") */
|
|
144
146
|
lintFix?: string;
|
|
145
147
|
/** Auto-fix formatting (e.g., "biome format --write") */
|
|
@@ -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(
|
|
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(
|
|
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
|
package/src/execution/runner.ts
CHANGED
|
@@ -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");
|