@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.
- package/bin/nax.ts +20 -2
- package/docs/tdd/strategies.md +97 -0
- package/nax/features/central-run-registry/prd.json +105 -0
- package/nax/features/diagnose/acceptance.test.ts +3 -1
- package/package.json +3 -3
- package/src/cli/diagnose.ts +1 -1
- package/src/cli/status-features.ts +55 -7
- package/src/commands/index.ts +1 -0
- package/src/commands/logs.ts +87 -17
- package/src/commands/runs.ts +220 -0
- package/src/config/schemas.ts +3 -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/sequential-executor.ts +4 -0
- package/src/execution/status-writer.ts +42 -0
- package/src/pipeline/subscribers/events-writer.ts +121 -0
- package/src/pipeline/subscribers/registry.ts +73 -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/cli/cli-logs.test.ts +40 -17
- 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/commands/logs.test.ts +63 -22
- package/test/unit/commands/runs.test.ts +303 -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/pipeline/subscribers/events-writer.test.ts +227 -0
- package/test/unit/pipeline/subscribers/registry.test.ts +149 -0
package/bin/nax.ts
CHANGED
|
@@ -62,6 +62,7 @@ import { generateCommand } from "../src/cli/generate";
|
|
|
62
62
|
import { diagnose } from "../src/commands/diagnose";
|
|
63
63
|
import { logsCommand } from "../src/commands/logs";
|
|
64
64
|
import { precheckCommand } from "../src/commands/precheck";
|
|
65
|
+
import { runsCommand } from "../src/commands/runs";
|
|
65
66
|
import { unlockCommand } from "../src/commands/unlock";
|
|
66
67
|
import { DEFAULT_CONFIG, findProjectDir, loadConfig, validateDirectory } from "../src/config";
|
|
67
68
|
import { run } from "../src/execution";
|
|
@@ -685,7 +686,7 @@ program
|
|
|
685
686
|
.option("-s, --story <id>", "Filter to specific story")
|
|
686
687
|
.option("--level <level>", "Filter by log level (debug|info|warn|error)")
|
|
687
688
|
.option("-l, --list", "List all runs in table format", false)
|
|
688
|
-
.option("-r, --run <
|
|
689
|
+
.option("-r, --run <runId>", "Select run by run ID from central registry (global)")
|
|
689
690
|
.option("-j, --json", "Output raw JSONL", false)
|
|
690
691
|
.action(async (options) => {
|
|
691
692
|
let workdir: string;
|
|
@@ -773,7 +774,24 @@ program
|
|
|
773
774
|
});
|
|
774
775
|
|
|
775
776
|
// ── runs ─────────────────────────────────────────────
|
|
776
|
-
const runs = program
|
|
777
|
+
const runs = program
|
|
778
|
+
.command("runs")
|
|
779
|
+
.description("Show all registered runs from the central registry (~/.nax/runs/)")
|
|
780
|
+
.option("--project <name>", "Filter by project name")
|
|
781
|
+
.option("--last <N>", "Limit to N most recent runs (default: 20)")
|
|
782
|
+
.option("--status <status>", "Filter by status (running|completed|failed|crashed)")
|
|
783
|
+
.action(async (options) => {
|
|
784
|
+
try {
|
|
785
|
+
await runsCommand({
|
|
786
|
+
project: options.project,
|
|
787
|
+
last: options.last !== undefined ? Number.parseInt(options.last, 10) : undefined,
|
|
788
|
+
status: options.status,
|
|
789
|
+
});
|
|
790
|
+
} catch (err) {
|
|
791
|
+
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
|
792
|
+
process.exit(1);
|
|
793
|
+
}
|
|
794
|
+
});
|
|
777
795
|
|
|
778
796
|
runs
|
|
779
797
|
.command("list")
|
|
@@ -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*
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
{
|
|
2
|
+
"project": "nax",
|
|
3
|
+
"branchName": "feat/central-run-registry",
|
|
4
|
+
"feature": "central-run-registry",
|
|
5
|
+
"version": "0.24.0",
|
|
6
|
+
"description": "Global run index across all projects. Events file writer for machine-readable lifecycle events, registry subscriber that indexes every run to ~/.nax/runs/, nax runs CLI for cross-project run history, and nax logs --run <runId> for global log resolution.",
|
|
7
|
+
"userStories": [
|
|
8
|
+
{
|
|
9
|
+
"id": "CRR-000",
|
|
10
|
+
"title": "Events file writer subscriber",
|
|
11
|
+
"description": "New subscriber: src/pipeline/subscribers/events-writer.ts \u2014 wireEventsWriter(bus, project, feature, runId, workdir). Listens to run:started, story:started, story:completed, story:failed, run:completed, run:paused. Writes one JSON line per event to ~/.nax/events/<project>/events.jsonl (append mode). Each line: {\"ts\": ISO8601, \"event\": string, \"runId\": string, \"feature\": string, \"project\": string, \"storyId\"?: string, \"data\"?: object}. The run:completed event writes an 'on-complete' entry \u2014 the machine-readable signal that nax exited gracefully (fixes watchdog false crash reports). Best-effort: wrap all writes in try/catch, log warnings on failure, never throw or block the pipeline. Create ~/.nax/events/<project>/ on first write via mkdir recursive. Derive project name from path.basename(workdir). Wire in sequential-executor.ts alongside existing wireHooks/wireReporters/wireInteraction calls. Return UnsubscribeFn matching existing subscriber pattern.",
|
|
12
|
+
"complexity": "medium",
|
|
13
|
+
"status": "pending",
|
|
14
|
+
"acceptanceCriteria": [
|
|
15
|
+
"After a run, ~/.nax/events/<project>/events.jsonl exists with JSONL entries",
|
|
16
|
+
"Each line is valid JSON with ts, event, runId, feature, project fields",
|
|
17
|
+
"run:completed produces an entry with event=on-complete",
|
|
18
|
+
"story:started/completed/failed events include storyId",
|
|
19
|
+
"Write failure does not crash or block the nax run",
|
|
20
|
+
"Directory is created automatically on first write",
|
|
21
|
+
"wireEventsWriter is called in sequential-executor.ts",
|
|
22
|
+
"Returns UnsubscribeFn consistent with wireHooks/wireReporters pattern"
|
|
23
|
+
],
|
|
24
|
+
"attempts": 0,
|
|
25
|
+
"priorErrors": [],
|
|
26
|
+
"priorFailures": [],
|
|
27
|
+
"escalations": [],
|
|
28
|
+
"dependencies": [],
|
|
29
|
+
"tags": [],
|
|
30
|
+
"storyPoints": 2
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "CRR-001",
|
|
34
|
+
"title": "Registry writer subscriber",
|
|
35
|
+
"description": "New subscriber: src/pipeline/subscribers/registry.ts \u2014 wireRegistry(bus, project, feature, runId, workdir). Listens to run:started. On event, creates ~/.nax/runs/<project>-<feature>-<runId>/meta.json with fields: {runId, project, feature, workdir, statusPath: join(workdir, 'nax/features', feature, 'status.json'), eventsDir: join(workdir, 'nax/features', feature, 'runs'), registeredAt: ISO8601}. meta.json schema exported as MetaJson interface. Written once, never updated. Best-effort: try/catch + warn log, never throw/block. Create ~/.nax/runs/ directory on first call via mkdir recursive. Derive project from path.basename(workdir). Wire in sequential-executor.ts alongside wireEventsWriter. Return UnsubscribeFn.",
|
|
36
|
+
"complexity": "medium",
|
|
37
|
+
"status": "pending",
|
|
38
|
+
"acceptanceCriteria": [
|
|
39
|
+
"After run start, ~/.nax/runs/<project>-<feature>-<runId>/meta.json exists",
|
|
40
|
+
"meta.json contains runId, project, feature, workdir, statusPath, eventsDir, registeredAt",
|
|
41
|
+
"statusPath points to <workdir>/nax/features/<feature>/status.json",
|
|
42
|
+
"eventsDir points to <workdir>/nax/features/<feature>/runs",
|
|
43
|
+
"MetaJson interface is exported from the module",
|
|
44
|
+
"Write failure does not crash or block the nax run",
|
|
45
|
+
"wireRegistry is called in sequential-executor.ts"
|
|
46
|
+
],
|
|
47
|
+
"attempts": 0,
|
|
48
|
+
"priorErrors": [],
|
|
49
|
+
"priorFailures": [],
|
|
50
|
+
"escalations": [],
|
|
51
|
+
"dependencies": [],
|
|
52
|
+
"tags": [],
|
|
53
|
+
"storyPoints": 2
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"id": "CRR-002",
|
|
57
|
+
"title": "nax runs CLI command",
|
|
58
|
+
"description": "New command: src/commands/runs.ts \u2014 runsCommand(options). Reads all ~/.nax/runs/*/meta.json, resolves each statusPath to read the live NaxStatusFile for current state. Displays a table sorted by registeredAt desc. Columns: RUN ID, PROJECT, FEATURE, STATUS, STORIES (passed/total), DURATION, DATE. Default limit: 20. Options: --project <name> (filter), --last <N> (limit), --status <running|completed|failed|crashed> (filter). If statusPath missing, show status as '[unavailable]'. Register in src/commands/index.ts and wire in bin/nax.ts CLI arg parser (match pattern of existing diagnose/logs/status commands). Use chalk for colored output. Green for completed, red for failed, yellow for running, dim for unavailable.",
|
|
59
|
+
"complexity": "medium",
|
|
60
|
+
"status": "pending",
|
|
61
|
+
"acceptanceCriteria": [
|
|
62
|
+
"nax runs displays a table of all registered runs sorted newest-first",
|
|
63
|
+
"nax runs --project <name> filters by project name",
|
|
64
|
+
"nax runs --last <N> limits output to N runs",
|
|
65
|
+
"nax runs --status <status> filters by run status",
|
|
66
|
+
"Runs with missing statusPath show '[unavailable]' status gracefully",
|
|
67
|
+
"Empty registry shows 'No runs found' message",
|
|
68
|
+
"Command is registered in CLI help output and bin/nax.ts"
|
|
69
|
+
],
|
|
70
|
+
"attempts": 0,
|
|
71
|
+
"priorErrors": [],
|
|
72
|
+
"priorFailures": [],
|
|
73
|
+
"escalations": [],
|
|
74
|
+
"dependencies": [
|
|
75
|
+
"CRR-001"
|
|
76
|
+
],
|
|
77
|
+
"tags": [],
|
|
78
|
+
"storyPoints": 3
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"id": "CRR-003",
|
|
82
|
+
"title": "nax logs --run <runId> global resolution",
|
|
83
|
+
"description": "Enhance src/commands/logs.ts logsCommand: when --run <runId> is provided, scan ~/.nax/runs/*/meta.json for matching runId (exact or prefix match). Read eventsDir from meta.json, locate the JSONL log file in that directory (newest .jsonl file), then display using existing displayLogs/followLogs functions. Falls back to current behavior (local feature context) when --run is not specified. If runId not found in registry, throw error Run not found in registry: <runId>. If eventsDir or log file missing, show unavailable message. Update LogsOptions interface to accept run?: string. Update bin/nax.ts to pass --run option through.",
|
|
84
|
+
"complexity": "medium",
|
|
85
|
+
"status": "pending",
|
|
86
|
+
"acceptanceCriteria": [
|
|
87
|
+
"nax logs --run <runId> displays logs from the matching run regardless of cwd",
|
|
88
|
+
"Resolution uses ~/.nax/runs/*/meta.json to find eventsDir path",
|
|
89
|
+
"Unknown runId shows clear error message",
|
|
90
|
+
"Missing log files show unavailable message",
|
|
91
|
+
"Without --run flag, existing local behavior is unchanged",
|
|
92
|
+
"Partial runId matching works (prefix match on runId field)"
|
|
93
|
+
],
|
|
94
|
+
"attempts": 0,
|
|
95
|
+
"priorErrors": [],
|
|
96
|
+
"priorFailures": [],
|
|
97
|
+
"escalations": [],
|
|
98
|
+
"dependencies": [
|
|
99
|
+
"CRR-001"
|
|
100
|
+
],
|
|
101
|
+
"tags": [],
|
|
102
|
+
"storyPoints": 2
|
|
103
|
+
}
|
|
104
|
+
]
|
|
105
|
+
}
|
|
@@ -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
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nathapp/nax",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "AI Coding Agent Orchestrator \u2014 loops until done",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -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/commands/index.ts
CHANGED
|
@@ -5,4 +5,5 @@
|
|
|
5
5
|
export { resolveProject, type ResolveProjectOptions, type ResolvedProject } from "./common";
|
|
6
6
|
export { logsCommand, type LogsOptions } from "./logs";
|
|
7
7
|
export { precheckCommand, type PrecheckOptions } from "./precheck";
|
|
8
|
+
export { runsCommand, type RunsOptions } from "./runs";
|
|
8
9
|
export { unlockCommand, type UnlockOptions } from "./unlock";
|
package/src/commands/logs.ts
CHANGED
|
@@ -6,13 +6,23 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { existsSync, readdirSync } from "node:fs";
|
|
9
|
+
import { readdir } from "node:fs/promises";
|
|
10
|
+
import { homedir } from "node:os";
|
|
9
11
|
import { join } from "node:path";
|
|
10
12
|
import chalk from "chalk";
|
|
11
13
|
import type { LogEntry, LogLevel } from "../logger/types";
|
|
12
14
|
import { type FormattedEntry, formatLogEntry, formatRunSummary } from "../logging/formatter";
|
|
13
15
|
import type { RunSummary, VerbosityMode } from "../logging/types";
|
|
16
|
+
import type { MetaJson } from "../pipeline/subscribers/registry";
|
|
14
17
|
import { type ResolveProjectOptions, resolveProject } from "./common";
|
|
15
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Swappable dependencies for testing (project convention: _deps over mock.module).
|
|
21
|
+
*/
|
|
22
|
+
export const _deps = {
|
|
23
|
+
getRunsDir: () => process.env.NAX_RUNS_DIR ?? join(homedir(), ".nax", "runs"),
|
|
24
|
+
};
|
|
25
|
+
|
|
16
26
|
/**
|
|
17
27
|
* Options for logs command
|
|
18
28
|
*/
|
|
@@ -43,12 +53,84 @@ const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
|
|
|
43
53
|
error: 3,
|
|
44
54
|
};
|
|
45
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Resolve log file path for a runId from the central registry (~/.nax/runs/).
|
|
58
|
+
*
|
|
59
|
+
* Scans all ~/.nax/runs/*\/meta.json entries for an exact or prefix match on runId.
|
|
60
|
+
* Returns the path to the matching run's JSONL file, or null if eventsDir/file is unavailable.
|
|
61
|
+
* Throws if the runId is not found in the registry at all.
|
|
62
|
+
*
|
|
63
|
+
* @param runId - Full or prefix run ID to look up
|
|
64
|
+
* @returns Absolute path to the JSONL log file, or null if unavailable
|
|
65
|
+
*/
|
|
66
|
+
async function resolveRunFileFromRegistry(runId: string): Promise<string | null> {
|
|
67
|
+
const runsDir = _deps.getRunsDir();
|
|
68
|
+
|
|
69
|
+
let entries: string[];
|
|
70
|
+
try {
|
|
71
|
+
entries = await readdir(runsDir);
|
|
72
|
+
} catch {
|
|
73
|
+
throw new Error(`Run not found in registry: ${runId}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let matched: MetaJson | null = null;
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
const metaPath = join(runsDir, entry, "meta.json");
|
|
79
|
+
try {
|
|
80
|
+
const meta: MetaJson = await Bun.file(metaPath).json();
|
|
81
|
+
if (meta.runId === runId || meta.runId.startsWith(runId)) {
|
|
82
|
+
matched = meta;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// skip unreadable meta.json entries
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!matched) {
|
|
91
|
+
throw new Error(`Run not found in registry: ${runId}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!existsSync(matched.eventsDir)) {
|
|
95
|
+
console.log(`Log directory unavailable for run: ${runId}`);
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const files = readdirSync(matched.eventsDir)
|
|
100
|
+
.filter((f) => f.endsWith(".jsonl") && f !== "latest.jsonl")
|
|
101
|
+
.sort()
|
|
102
|
+
.reverse();
|
|
103
|
+
|
|
104
|
+
if (files.length === 0) {
|
|
105
|
+
console.log(`No log files found for run: ${runId}`);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Look for the specific run file by runId, fall back to newest
|
|
110
|
+
const specificFile = files.find((f) => f === `${matched.runId}.jsonl`);
|
|
111
|
+
return join(matched.eventsDir, specificFile ?? files[0]);
|
|
112
|
+
}
|
|
113
|
+
|
|
46
114
|
/**
|
|
47
115
|
* Display logs with filtering and formatting
|
|
48
116
|
*
|
|
49
117
|
* @param options - Command options
|
|
50
118
|
*/
|
|
51
119
|
export async function logsCommand(options: LogsOptions): Promise<void> {
|
|
120
|
+
// When --run <runId> is provided, resolve via central registry
|
|
121
|
+
if (options.run) {
|
|
122
|
+
const runFile = await resolveRunFileFromRegistry(options.run);
|
|
123
|
+
if (!runFile) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (options.follow) {
|
|
127
|
+
await followLogs(runFile, options);
|
|
128
|
+
} else {
|
|
129
|
+
await displayLogs(runFile, options);
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
52
134
|
// Resolve project directory
|
|
53
135
|
const resolved = resolveProject({ dir: options.dir });
|
|
54
136
|
const naxDir = join(resolved.projectDir, "nax");
|
|
@@ -77,8 +159,8 @@ export async function logsCommand(options: LogsOptions): Promise<void> {
|
|
|
77
159
|
return;
|
|
78
160
|
}
|
|
79
161
|
|
|
80
|
-
// Determine which run to display
|
|
81
|
-
const runFile = await selectRunFile(runsDir
|
|
162
|
+
// Determine which run to display (latest by default — --run handled above via registry)
|
|
163
|
+
const runFile = await selectRunFile(runsDir);
|
|
82
164
|
|
|
83
165
|
if (!runFile) {
|
|
84
166
|
throw new Error("No runs found for this feature");
|
|
@@ -95,9 +177,9 @@ export async function logsCommand(options: LogsOptions): Promise<void> {
|
|
|
95
177
|
}
|
|
96
178
|
|
|
97
179
|
/**
|
|
98
|
-
* Select which run file to display
|
|
180
|
+
* Select which run file to display (always returns the latest run)
|
|
99
181
|
*/
|
|
100
|
-
async function selectRunFile(runsDir: string
|
|
182
|
+
async function selectRunFile(runsDir: string): Promise<string | null> {
|
|
101
183
|
const files = readdirSync(runsDir)
|
|
102
184
|
.filter((f) => f.endsWith(".jsonl") && f !== "latest.jsonl")
|
|
103
185
|
.sort()
|
|
@@ -107,19 +189,7 @@ async function selectRunFile(runsDir: string, runTimestamp?: string): Promise<st
|
|
|
107
189
|
return null;
|
|
108
190
|
}
|
|
109
191
|
|
|
110
|
-
|
|
111
|
-
if (!runTimestamp) {
|
|
112
|
-
return join(runsDir, files[0]);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Find matching run by partial timestamp
|
|
116
|
-
const matchingFile = files.find((f) => f.startsWith(runTimestamp));
|
|
117
|
-
|
|
118
|
-
if (!matchingFile) {
|
|
119
|
-
throw new Error(`Run not found: ${runTimestamp}`);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return join(runsDir, matchingFile);
|
|
192
|
+
return join(runsDir, files[0]);
|
|
123
193
|
}
|
|
124
194
|
|
|
125
195
|
/**
|