@nathapp/nax 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CLAUDE.md +38 -8
  2. package/docs/ROADMAP.md +42 -17
  3. package/nax/features/prompt-builder/prd.json +152 -0
  4. package/nax/features/prompt-builder/progress.txt +3 -0
  5. package/nax/status.json +14 -14
  6. package/package.json +1 -1
  7. package/src/cli/config.ts +40 -1
  8. package/src/cli/prompts.ts +18 -6
  9. package/src/config/defaults.ts +1 -0
  10. package/src/config/schemas.ts +10 -0
  11. package/src/config/types.ts +7 -0
  12. package/src/pipeline/runner.ts +2 -1
  13. package/src/pipeline/stages/autofix.ts +5 -0
  14. package/src/pipeline/stages/execution.ts +5 -0
  15. package/src/pipeline/stages/prompt.ts +13 -4
  16. package/src/pipeline/stages/rectify.ts +5 -0
  17. package/src/pipeline/stages/regression.ts +6 -1
  18. package/src/pipeline/stages/verify.ts +2 -1
  19. package/src/pipeline/types.ts +9 -0
  20. package/src/precheck/checks-warnings.ts +37 -0
  21. package/src/precheck/checks.ts +1 -0
  22. package/src/precheck/index.ts +14 -7
  23. package/src/prompts/builder.ts +178 -0
  24. package/src/prompts/index.ts +2 -0
  25. package/src/prompts/loader.ts +43 -0
  26. package/src/prompts/sections/conventions.ts +15 -0
  27. package/src/prompts/sections/index.ts +11 -0
  28. package/src/prompts/sections/isolation.ts +24 -0
  29. package/src/prompts/sections/role-task.ts +32 -0
  30. package/src/prompts/sections/story.ts +13 -0
  31. package/src/prompts/sections/verdict.ts +70 -0
  32. package/src/prompts/templates/implementer.ts +6 -0
  33. package/src/prompts/templates/single-session.ts +6 -0
  34. package/src/prompts/templates/test-writer.ts +6 -0
  35. package/src/prompts/templates/verifier.ts +6 -0
  36. package/src/prompts/types.ts +21 -0
  37. package/src/tdd/orchestrator.ts +11 -1
  38. package/src/tdd/rectification-gate.ts +18 -13
  39. package/src/tdd/session-runner.ts +12 -12
  40. package/src/tdd/types.ts +2 -0
  41. package/test/integration/cli/cli-config-prompts-explain.test.ts +74 -0
  42. package/test/integration/prompts/pb-004-migration.test.ts +523 -0
  43. package/test/unit/precheck/checks-warnings.test.ts +114 -0
  44. package/test/unit/prompts/builder.test.ts +258 -0
  45. package/test/unit/prompts/loader.test.ts +355 -0
  46. package/test/unit/prompts/sections/conventions.test.ts +30 -0
  47. package/test/unit/prompts/sections/isolation.test.ts +35 -0
  48. package/test/unit/prompts/sections/role-task.test.ts +40 -0
  49. package/test/unit/prompts/sections/sections.test.ts +238 -0
  50. package/test/unit/prompts/sections/story.test.ts +45 -0
  51. package/test/unit/prompts/sections/verdict.test.ts +58 -0
package/CLAUDE.md CHANGED
@@ -92,16 +92,46 @@ Runner.run() [src/execution/runner.ts — thin orchestrator only]
92
92
  2. **Plan complex tasks**: for multi-file changes, write a short plan before implementing.
93
93
  3. **Implement in small chunks**: one logical concern per commit.
94
94
 
95
- ## Code Intelligence (Solograph MCP)
95
+ ## Code Intelligence (Solograph MCP) — MANDATORY
96
96
 
97
- Use **solograph** MCP tools on-demand do not use `web_search` or `kb_search`.
97
+ **Always use solograph MCP tools before writing code or analyzing architecture.** Do NOT use `web_search` or `kb_search` as substitutes.
98
98
 
99
- | Tool | When |
100
- |:-----|:-----|
101
- | `project_code_search` | Find existing patterns before writing new code |
102
- | `codegraph_explain` | Architecture overview before tackling unfamiliar areas |
103
- | `codegraph_query` | Dependency/impact analysis (Cypher) |
104
- | `project_code_reindex` | After creating or deleting source files |
99
+ ### Tool Selection Guide
100
+
101
+ | Tool | Capability | When to Use | Availability |
102
+ |:-----|:-----------|:-----------|:-------------|
103
+ | `codegraph_query` | Structural queries (Cypher) — find calls, dependencies, imports | **Preferred for dependency analysis, call tracing, symbol lookup** | ✅ Always works (in-memory graph) |
104
+ | `project_code_search` | Semantic search (Redis vector DB) pattern matching by meaning | Natural language queries like "find auth patterns" | ⚠️ Requires explicit `project_code_reindex` + Redis daemon |
105
+ | `codegraph_explain` | Architecture overview for unfamiliar subsystems | Understand module relationships before major changes | ✅ Always works |
106
+ | `project_code_reindex` | Index project for semantic search | After creating/deleting source files | ✅ Always works |
107
+
108
+ ### Recommended Workflow
109
+
110
+ For nax, **prefer `codegraph_query`** for routine tasks:
111
+ - Finding where functions are called (`calculateAggregateMetrics` called by `status-cost.ts`)
112
+ - Analyzing dependencies before refactoring
113
+ - Tracing import/export chains
114
+ - Querying symbol definitions and relationships
115
+
116
+ **Use `project_code_search` only if:**
117
+ - You need semantic similarity ("find authentication patterns")
118
+ - Redis is indexed and running (not guaranteed in all sessions)
119
+
120
+ ### Example Queries
121
+
122
+ ```cypher
123
+ -- Find files calling calculateAggregateMetrics
124
+ MATCH (f:File)-[:CALLS]->(s:Symbol {name: "calculateAggregateMetrics"})
125
+ RETURN f.path
126
+
127
+ -- Find all imports of aggregator.ts
128
+ MATCH (f:File)-[:IMPORTS]->(target:File {path: "src/metrics/aggregator.ts"})
129
+ RETURN f.path
130
+
131
+ -- Find symbols defined in a file
132
+ MATCH (f:File {path: "src/metrics/aggregator.ts"})-[:DEFINES]->(s:Symbol)
133
+ RETURN s.name, s.type
134
+ ```
105
135
 
106
136
  ## Coding Standards & Forbidden Patterns
107
137
 
package/docs/ROADMAP.md CHANGED
@@ -135,6 +135,31 @@
135
135
 
136
136
  ---
137
137
 
138
+
139
+ ## v0.28.0 — Prompt Builder
140
+
141
+ **Theme:** Unified, user-overridable prompt architecture replacing 11 scattered functions
142
+ **Status:** 🔲 Planned
143
+ **Spec:** `nax/features/prompt-builder/prd.json`
144
+
145
+ ### Stories
146
+ - [ ] **PB-001:** PromptBuilder class with layered section architecture + fluent API
147
+ - [ ] **PB-002:** Typed sections: isolation, role-task, story, verdict, conventions
148
+ - [ ] **PB-003:** Default templates + user override loader + config schema (`prompts.overrides`)
149
+ - [ ] **PB-004:** Migrate all 6 user-facing prompt call sites to PromptBuilder
150
+ - [ ] **PB-005:** Document `prompts` config in `nax config --explain` + precheck validation
151
+
152
+ ---
153
+
154
+ ## v0.27.1 — Pipeline Observability ✅ Shipped (2026-03-08)
155
+
156
+ **Theme:** Fix redundant verify stage + improve pipeline skip log messages
157
+ **Status:** ✅ Shipped (2026-03-08)
158
+
159
+ ### Bugfixes
160
+ - [x] **BUG-054:** Skip pipeline verify stage when TDD full-suite gate already passed — `runFullSuiteGate()` now returns `boolean`, propagated via `ThreeSessionTddResult` → `executionStage` → `ctx.fullSuiteGatePassed` → `verifyStage.enabled()` returns false with reason "not needed (full-suite gate already passed)"
161
+ - [x] **BUG-055:** Pipeline skip messages now differentiate "not needed" from "disabled". Added optional `skipReason(ctx)` to `PipelineStage` interface; `rectify`, `autofix`, `regression`, `verify` stages all provide context-aware reasons
162
+
138
163
  ## v0.27.0 — Review Quality ✅ Shipped (2026-03-08)
139
164
 
140
165
  **Theme:** Fix review stage reliability — dirty working tree false-positive, stale precheck, dead config fields
@@ -262,6 +287,7 @@
262
287
 
263
288
  | Version | Theme | Date | Details |
264
289
  |:---|:---|:---|:---|
290
+ | v0.27.1 | Pipeline Observability | 2026-03-08 | BUG-054: skip redundant verify after full-suite gate; BUG-055: differentiate skip reasons |
265
291
  | v0.26.0 | Routing Persistence | 2026-03-08 | RRP-001–004: persist initial routing, initialComplexity, contentHash staleness detection, unit tests; BUG-052: structured logger in review/optimizer |
266
292
  | v0.25.0 | Trigger Completion | 2026-03-07 | TC-001–004: run.complete event, crash recovery, headless formatter, trigger completion |
267
293
  | v0.24.0 | Central Run Registry | 2026-03-07 | CRR-000–003: events writer, registry, nax runs CLI, nax logs --run global resolution |
@@ -321,22 +347,21 @@
321
347
  - [x] ~~**BUG-022:** Story interleaving — `getNextStory()` round-robins instead of exhausting retries on current story → fixed in v0.18.0~~
322
348
  - [x] ~~**BUG-023:** Agent failure silent — no exitCode/stderr in JSONL → fixed in v0.18.0~~
323
349
  - [x] ~~**BUG-025:** `needsHumanReview` not triggering interactive plugin → fixed in v0.18.0~~
324
-
325
- - [x] **BUG-029:** Escalation resets story to `pending` → bypasses BUG-022 retry priority. `handleTierEscalation()` sets `status: "pending"` after escalation, but `getNextStory()` Priority 1 only checks `status === "failed"`. Result: after BUG-026 escalated (iter 1), nax moved to BUG-028 (iter 2) instead of retrying BUG-026 immediately. **Location:** `src/prd/index.ts:getNextStory()` + `src/execution/escalation/tier-escalation.ts`. **Fix:** `getNextStory()` should also prioritize stories with `story.routing.modelTier` that changed since last attempt (escalation marker), or `handleTierEscalation` should use a distinct status like `"retry-pending"` that Priority 1 recognizes.
326
- - [x] **BUG-030:** Review lint failure → hard `"fail"`, no rectification or retry. `src/pipeline/stages/review.ts:92` returns `{ action: "fail" }` for all review failures including lint. In `pipeline-result-handler.ts`, `"fail"` calls `markStoryFailed()` — permanently dead. But lint errors are auto-fixable (agent can run `biome check --fix`). Contrast with verify stage which returns `"escalate"` on test failure, allowing retry. SFC-001 and SFC-002 both hit this — tests passed but 5 Biome lint errors killed the stories permanently. **Fix:** Review stage should return `"escalate"` (not `"fail"`) for lint/typecheck failures, or add a review-rectification loop (like verify has) that gives the agent one retry with the lint output as context. Reserve `"fail"` for unfixable review issues (e.g. plugin reviewer rejection).
327
- - [x] **BUG-031:** Keyword fallback classifier gives inconsistent strategy across retries for same story. BUG-026 was classified as `test-after` on iter 1 (keyword fallback), but `three-session-tdd-lite` on iter 5 (same keyword fallback). The keyword classifier in `src/routing/strategies/keyword.ts:classifyComplexity()` may be influenced by `priorErrors` text added between attempts, shifting the keyword match result. **Location:** `src/routing/strategies/keyword.ts`. **Fix:** Keyword classifier should only consider the story's original title + description + acceptance criteria, not accumulated `priorErrors` or `priorFailures`. Alternatively, once a strategy is set in `story.routing.testStrategy`, the routing stage should preserve it across retries (already partially done in `routing.ts:40-41` but may not apply when LLM falls back to keyword).
328
- - [x] **BUG-032:** Routing stage overrides escalated `modelTier` with complexity-derived tier. `src/pipeline/stages/routing.ts:43` always runs `complexityToModelTier(routing.complexity, config)` even when `story.routing.modelTier` was explicitly set by `handleTierEscalation()`. BUG-026 was escalated to `balanced` (logged in iteration header), but `Task classified` shows `modelTier=fast` because `complexityToModelTier("simple", config)` → `"fast"`. Related to BUG-013 (escalation routing not applied) which was marked fixed, but the fix in `applyCachedRouting()` in `pipeline-result-handler.ts:295-310` runs **after** the routing stage — too late. **Location:** `src/pipeline/stages/routing.ts:43`. **Fix:** When `story.routing.modelTier` is explicitly set (by escalation), skip `complexityToModelTier()` and use the cached tier directly. Only derive from complexity when `story.routing.modelTier` is absent.
329
- - [x] **BUG-033:** LLM routing has no retry on timeout — single attempt with hardcoded 15s default. All 5 LLM routing attempts in the v0.18.3 run timed out at 15s, forcing keyword fallback every time. `src/routing/strategies/llm.ts:63` reads `llmConfig?.timeoutMs ?? 15000` but there's no retry logic — one timeout = immediate fallback. **Location:** `src/routing/strategies/llm.ts:callLlm()`. **Fix:** Add `routing.llm.retries` config (default: 1) with backoff. Also surface `routing.llm.timeoutMs` in `nax config --explain` and consider raising default to 30s for batch routing which processes multiple stories.
330
-
331
- - [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).~~
332
- - [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.~~
333
- - [x] ~~**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()`
334
- - [x] ~~**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.
335
- - [ ] **BUG-049:** Review typecheck runs on dirty working tree — false-positive pass when agent commits partial changes. If the agent stages only some files (e.g. forgets `git add types.ts`), the working tree retains the uncommitted fix and `bun run typecheck` passes — but the committed state has a type error. Discovered in routing-persistence run: RRP-003 committed `contentHash` refs in `routing.ts` without the matching `StoryRouting.contentHash` field in `types.ts`; typecheck passed because `types.ts` was locally modified but uncommitted. **Location:** `src/review/runner.ts:runCheck()`. **Fix:** Before running built-in checks, assert working tree is clean (`git diff --name-only` returns empty). If dirty, fail with "uncommitted changes detected" or log a warning and stash/restore.
336
- - [ ] **BUG-050:** `checkOptionalCommands` precheck uses legacy config fields — misleading "not configured" warning. Checks `config.execution.lintCommand` and `config.execution.typecheckCommand` (stale/legacy fields). Actual config uses `config.review.commands.typecheck` and `config.review.commands.lint`. Result: precheck always warns "Optional commands not configured: lint, typecheck" even when correctly configured, desensitizing operators to real warnings. **Location:** `src/precheck/checks-warnings.ts:checkOptionalCommands()`. **Fix:** Update check to resolve via the same priority chain as `review/runner.ts`: `execution.*Command` → `review.commands.*` → `package.json` scripts.
337
- - [ ] **BUG-052:** `console.warn` in runtime pipeline code bypasses structured JSONL logger — invisible to log consumers. `src/review/runner.ts` and `src/optimizer/index.ts` used `console.warn()` for skip/fallback events, which print to stderr but are never written to the JSONL log file. This made review stage skip decisions invisible during post-run analysis. **Location:** `src/review/runner.ts:runReview()`, `src/optimizer/index.ts:resolveOptimizer()`. **Fix:** Replace with `getSafeLogger()?.warn()`. Fixed in feat/routing-persistence.
338
- - [ ] **BUG-052:** `console.warn` in runtime pipeline code bypasses structured JSONL logger invisible to log consumers. `src/review/runner.ts` and `src/optimizer/index.ts` used `console.warn()` for skip/fallback events, which print to stderr but are never written to the JSONL log file. This made review stage skip decisions invisible during post-run analysis. **Location:** `src/review/runner.ts:runReview()`, `src/optimizer/index.ts:resolveOptimizer()`. **Fix:** Replace with `getSafeLogger()?.warn()`. ✅ Fixed in feat/routing-persistence.
339
- - [ ] **BUG-051:** `quality.commands.typecheck` and `quality.commands.lint` are dead config — silently ignored. `QualityConfig.commands.{typecheck,lint}` exist in the type definition and are documented in `nax config --explain`, but are never read by any runtime code. The review runner reads only `review.commands.typecheck/lint`. Users who set `quality.commands.typecheck` get no effect. **Location:** `src/config/types.ts` (QualityConfig), `src/review/runner.ts:resolveCommand()`. **Fix:** Either (A) remove the dead fields from `QualityConfig` and update docs, or (B) consolidate — make review runner read from `quality.commands` and deprecate `review.commands`.
350
+ - [x] ~~**BUG-029:** Escalation resets story to `pending`. Fixed.~~
351
+ - [x] ~~**BUG-030:** Review lint failure resets. Fixed.~~
352
+ - [x] ~~**BUG-031:** Keyword fallback classifier inconsistency. Fixed.~~
353
+ - [x] ~~**BUG-032:** Routing stage overrides escalated modelTier. Fixed.~~
354
+ - [x] ~~**BUG-033:** LLM routing timeout/retry. Fixed.~~
355
+ - [x] ~~**BUG-037:** Test output summary (verify stage) tail. Fixed.~~
356
+ - [x] ~~**BUG-038:** smart-runner over-matching. Fixed.~~
357
+ - [x] ~~**BUG-043:** Scoped test command construction. Fixed.~~
358
+ - [x] ~~**BUG-044:** Scoped/full-suite test command logging. Fixed.~~
359
+ - [x] ~~**BUG-049:** Review typecheck runs on dirty working tree. Fixed in v0.27.0.~~
360
+ - [x] ~~**BUG-050:** `checkOptionalCommands` precheck uses legacy config fields. Fixed in v0.27.0.~~
361
+ - [x] ~~**BUG-051:** `quality.commands.typecheck/lint` are dead config. Fixed in v0.27.0.~~
362
+ - [x] ~~**BUG-052:** `console.warn` in runtime pipeline code bypasses JSONL logger. Fixed in v0.26.0.~~
363
+ - [x] ~~**BUG-054:** Redundant scoped verify after TDD full-suite gate passes. Fixed in v0.27.1.~~ When rectification gate runs full test suite and passes, the pipeline verify stage re-runs scoped tests (subset). **Fix:** Skip verify if full-suite gate already passed.
364
+ - [x] ~~**BUG-055:** Pipeline skip messages conflate "not needed" with "disabled". Fixed in v0.27.1.~~ `runner.ts:54` logs "skipped (disabled)" for all stages where `enabled()` returns false, even if just because tests passed. **Fix:** Differentiate log message.
340
365
 
341
366
  ### Features
342
367
  - [x] ~~`nax unlock` command~~
@@ -362,4 +387,4 @@ Sequential canary → stable: `v0.12.0-canary.0` → `canary.N` → `v0.12.0`
362
387
  Canary: `npm publish --tag canary`
363
388
  Stable: `npm publish` (latest)
364
389
 
365
- *Last updated: 2026-03-07 (v0.22.1 shipped — Pipeline Re-Architecture: VerificationOrchestrator, EventBus, new stages, post-run SSOT)*
390
+ *Last updated: 2026-03-08 (v0.27.1 shipped — Pipeline Observability; v0.28.0 PRD ready Prompt Builder)*
@@ -0,0 +1,152 @@
1
+ {
2
+ "project": "nax-prompt-builder",
3
+ "branchName": "feat/prompt-builder",
4
+ "feature": "prompt-builder",
5
+ "updatedAt": "2026-03-08T07:39:46.206Z",
6
+ "userStories": [
7
+ {
8
+ "id": "PB-001",
9
+ "title": "Create PromptBuilder class with layered section architecture",
10
+ "description": "Currently nax has 11 scattered prompt-building functions across src/tdd/prompts.ts and src/execution/prompts.ts. There is no unified entry point, no user override support, and adding a new prompt variable requires touching multiple files. Introduce a PromptBuilder class in src/prompts/builder.ts with a fluent API. The builder composes a prompt from ordered sections: (1) Constitution, (2) Role task, (3) User override OR default template body, (4) Story context, (5) Isolation rules, (6) Context markdown, (7) Conventions footer. Each section is a typed unit independently testable. The builder is the single entry point — all existing prompt-building calls are migrated to use it.",
11
+ "acceptanceCriteria": [
12
+ "src/prompts/builder.ts exports PromptBuilder class with fluent API: PromptBuilder.for(role, options).story(story).context(md).constitution(c).override(path).build()",
13
+ "PromptBuilder.build() returns Promise<string> — async to support file loading for overrides",
14
+ "Section precedence enforced: constitution first, role task prepended, isolation rules appended, story context always included, conventions footer always last",
15
+ "Non-overridable sections (isolation rules, story context, conventions footer) cannot be removed by user override",
16
+ "src/prompts/types.ts exports: PromptRole ('test-writer' | 'implementer' | 'verifier' | 'single-session'), PromptSection interface, PromptOptions type",
17
+ "Unit tests: verify section order for each role; verify non-overridable sections always present; verify missing override falls through to default template"
18
+ ],
19
+ "complexity": "medium",
20
+ "status": "passed",
21
+ "tags": [
22
+ "feature",
23
+ "prompts",
24
+ "architecture"
25
+ ],
26
+ "attempts": 0,
27
+ "priorErrors": [],
28
+ "priorFailures": [],
29
+ "escalations": [],
30
+ "dependencies": [],
31
+ "storyPoints": 1,
32
+ "routing": {
33
+ "complexity": "medium",
34
+ "initialComplexity": "medium",
35
+ "testStrategy": "three-session-tdd-lite",
36
+ "reasoning": "Class design with fluent API, async file loading, section composition/ordering logic, and section non-overridability enforcement.",
37
+ "contentHash": "0a1c55a2430989f408b99f7b75a82491fc6606eb736f734386e36c61ccc143c9"
38
+ },
39
+ "passes": true
40
+ },
41
+ {
42
+ "id": "PB-002",
43
+ "title": "Implement typed sections: isolation, role-task, story, verdict, conventions",
44
+ "description": "The PromptBuilder needs typed section builders that produce the non-overridable parts of each prompt. Five sections: (1) isolation.ts — generates isolation rules based on strict (no src/ access) or lite (may read src/, create stubs) mode; (2) role-task.ts — generates role definition for standard implementer (make failing tests pass) or lite implementer (write tests and implement); (3) story.ts — formats title, description, acceptance criteria; (4) verdict.ts — verifier verdict JSON schema instructions (non-overridable); (5) conventions.ts — bun test scoping warning and commit conventions.",
45
+ "acceptanceCriteria": [
46
+ "src/prompts/sections/isolation.ts exports buildIsolationSection(mode: 'strict' | 'lite'): string — strict forbids src/ modification, lite allows reading src/ and creating stubs",
47
+ "src/prompts/sections/role-task.ts exports buildRoleTaskSection(variant: 'standard' | 'lite'): string — standard says 'make failing tests pass, do not modify test files', lite says 'write tests first then implement'",
48
+ "src/prompts/sections/story.ts exports buildStorySection(story: UserStory): string — formats title, description, numbered acceptance criteria",
49
+ "src/prompts/sections/verdict.ts exports buildVerdictSection(story: UserStory): string — identical content to current buildVerifierPrompt verdict instructions",
50
+ "src/prompts/sections/conventions.ts exports buildConventionsSection(): string — includes bun test scoping warning and commit message instruction",
51
+ "Each section function is pure (no side effects, no file I/O) and independently unit-testable",
52
+ "Unit tests: isolation strict does not contain 'MAY read'; isolation lite contains 'MAY read src/'; role-task standard contains 'Do NOT modify test files'; role-task lite contains 'Write tests first'"
53
+ ],
54
+ "complexity": "simple",
55
+ "status": "passed",
56
+ "tags": [
57
+ "feature",
58
+ "prompts",
59
+ "sections"
60
+ ],
61
+ "attempts": 0,
62
+ "priorErrors": [],
63
+ "priorFailures": [],
64
+ "escalations": [],
65
+ "dependencies": [],
66
+ "storyPoints": 1,
67
+ "passes": true
68
+ },
69
+ {
70
+ "id": "PB-003",
71
+ "title": "Implement default templates and user override loader",
72
+ "description": "Each PromptRole needs a default template forming the overridable body of the prompt. Implement src/prompts/loader.ts which resolves and reads the user override file relative to workdir. If the path is missing or the file does not exist, the loader returns null and the default template is used. The NaxConfig schema gains a new optional prompts block: { overrides: { 'test-writer'?: string, 'implementer'?: string, 'verifier'?: string, 'single-session'?: string } }.",
73
+ "acceptanceCriteria": [
74
+ "src/prompts/templates/ contains test-writer.ts, implementer.ts, verifier.ts, single-session.ts — each exports a default template string (body section only, no story/isolation/conventions)",
75
+ "src/prompts/loader.ts exports loadOverride(role: PromptRole, workdir: string, config: NaxConfig): Promise<string | null>",
76
+ "Loader resolves path relative to workdir — e.g. '.nax/prompts/test-writer.md' -> '<workdir>/.nax/prompts/test-writer.md'",
77
+ "Loader returns null (not error) when config.prompts is absent, role key is absent, or file does not exist",
78
+ "Loader throws with clear message when file path is set but file is unreadable (permissions error)",
79
+ "src/config/types.ts NaxConfig gains optional prompts?: { overrides?: Partial<Record<PromptRole, string>> }",
80
+ "Unit tests: loader returns null when config.prompts undefined; returns null when file missing; returns content when file exists; throws on permission error"
81
+ ],
82
+ "complexity": "simple",
83
+ "status": "passed",
84
+ "tags": [
85
+ "feature",
86
+ "prompts",
87
+ "config",
88
+ "loader"
89
+ ],
90
+ "attempts": 0,
91
+ "priorErrors": [],
92
+ "priorFailures": [],
93
+ "escalations": [],
94
+ "dependencies": [],
95
+ "storyPoints": 1,
96
+ "passes": true
97
+ },
98
+ {
99
+ "id": "PB-004",
100
+ "title": "Migrate all existing prompt-building call sites to PromptBuilder",
101
+ "description": "Replace the 6 user-facing scattered prompt functions with PromptBuilder calls. Mapping: buildTestWriterPrompt -> PromptBuilder.for('test-writer', { isolation: 'strict' }); buildTestWriterLitePrompt -> PromptBuilder.for('test-writer', { isolation: 'lite' }); buildImplementerPrompt -> PromptBuilder.for('implementer', { variant: 'standard' }); buildImplementerLitePrompt -> PromptBuilder.for('implementer', { variant: 'lite' }); buildVerifierPrompt -> PromptBuilder.for('verifier'); buildSingleSessionPrompt -> PromptBuilder.for('single-session'). Internal prompts (rectification, routing, batch) remain unchanged.",
102
+ "acceptanceCriteria": [
103
+ "All 6 user-facing prompt functions replaced with PromptBuilder calls in their respective call sites",
104
+ "buildImplementerRectificationPrompt, buildRectificationPrompt, buildBatchPrompt (execution), buildRoutingPrompt, buildBatchPrompt (routing) remain unchanged",
105
+ "Generated prompt text for each role is semantically equivalent to previous output when no user override is set (no regression)",
106
+ "All call sites pass workdir and config so the loader can check for overrides",
107
+ "src/tdd/prompts.ts and src/execution/prompts.ts deleted or reduced to re-exports only if referenced by tests",
108
+ "Integration test: build each of the 6 roles with no override, verify output contains story title, acceptance criteria, and role-specific instructions"
109
+ ],
110
+ "complexity": "medium",
111
+ "status": "pending",
112
+ "tags": [
113
+ "feature",
114
+ "prompts",
115
+ "migration",
116
+ "refactor"
117
+ ],
118
+ "attempts": 0,
119
+ "priorErrors": [],
120
+ "priorFailures": [],
121
+ "escalations": [],
122
+ "dependencies": [],
123
+ "storyPoints": 1
124
+ },
125
+ {
126
+ "id": "PB-005",
127
+ "title": "Document prompts config in nax config --explain and add precheck validation",
128
+ "description": "Users need to discover the prompts.overrides config block. Update nax config --explain to document it with example paths. Add a precheck warning (non-blocking) when a configured override file path does not exist at run start — surfaces the issue early rather than silently falling back to the default at runtime.",
129
+ "acceptanceCriteria": [
130
+ "nax config --explain output includes a 'prompts' section describing overrides for each role with example: '.nax/prompts/test-writer.md'",
131
+ "src/precheck/checks-warnings.ts adds check: for each key in config.prompts?.overrides, if resolved file does not exist, emit warning 'Prompt override file not found for role <role>: <resolved-path>'",
132
+ "Precheck warning is non-blocking — run continues with default template",
133
+ "Precheck skipped when config.prompts absent or overrides empty",
134
+ "Unit tests: override path exists -> no warning; override path set but file missing -> warning emitted; config.prompts absent -> no warning"
135
+ ],
136
+ "complexity": "simple",
137
+ "status": "pending",
138
+ "tags": [
139
+ "feature",
140
+ "prompts",
141
+ "docs",
142
+ "precheck"
143
+ ],
144
+ "attempts": 0,
145
+ "priorErrors": [],
146
+ "priorFailures": [],
147
+ "escalations": [],
148
+ "dependencies": [],
149
+ "storyPoints": 1
150
+ }
151
+ ]
152
+ }
@@ -0,0 +1,3 @@
1
+ [2026-03-08T07:17:53.244Z] PB-001 — PASSED — Create PromptBuilder class with layered section architecture — Cost: $0.3280
2
+ [2026-03-08T07:29:04.611Z] PB-002 — PASSED — Implement typed sections: isolation, role-task, story, verdict, conventions — Cost: $0.1821
3
+ [2026-03-08T07:39:44.184Z] PB-003 — PASSED — Implement default templates and user override loader — Cost: $0.2823
package/nax/status.json CHANGED
@@ -1,36 +1,36 @@
1
1
  {
2
2
  "version": 1,
3
3
  "run": {
4
- "id": "run-2026-03-07T16-14-49-336Z",
5
- "feature": "routing-persistence",
6
- "startedAt": "2026-03-07T16:14:49.336Z",
4
+ "id": "run-2026-03-08T07-06-40-645Z",
5
+ "feature": "prompt-builder",
6
+ "startedAt": "2026-03-08T07:06:40.645Z",
7
7
  "status": "running",
8
8
  "dryRun": false,
9
- "pid": 3412
9
+ "pid": 42182
10
10
  },
11
11
  "progress": {
12
- "total": 4,
13
- "passed": 1,
12
+ "total": 5,
13
+ "passed": 3,
14
14
  "failed": 0,
15
15
  "paused": 0,
16
16
  "blocked": 0,
17
- "pending": 3
17
+ "pending": 2
18
18
  },
19
19
  "cost": {
20
- "spent": 0.52230675,
20
+ "spent": 0.7924205000000001,
21
21
  "limit": 8
22
22
  },
23
23
  "current": {
24
- "storyId": "RRP-002",
25
- "title": "Add initialComplexity to StoryRouting and StoryMetrics for accurate reporting",
24
+ "storyId": "PB-004",
25
+ "title": "Migrate all existing prompt-building call sites to PromptBuilder",
26
26
  "complexity": "medium",
27
27
  "tddStrategy": "test-after",
28
28
  "model": "balanced",
29
29
  "attempt": 1,
30
30
  "phase": "routing"
31
31
  },
32
- "iterations": 2,
33
- "updatedAt": "2026-03-07T16:45:19.261Z",
34
- "durationMs": 1829925,
35
- "lastHeartbeat": "2026-03-07T16:45:19.261Z"
32
+ "iterations": 4,
33
+ "updatedAt": "2026-03-08T07:59:01.999Z",
34
+ "durationMs": 3141354,
35
+ "lastHeartbeat": "2026-03-08T07:59:01.999Z"
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "AI Coding Agent Orchestrator \u2014 loops until done",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/config.ts CHANGED
@@ -183,6 +183,14 @@ const FIELD_DESCRIPTIONS: Record<string, string> = {
183
183
  "precheck.storySizeGate.maxAcCount": "Max acceptance criteria count before flagging",
184
184
  "precheck.storySizeGate.maxDescriptionLength": "Max description character length before flagging",
185
185
  "precheck.storySizeGate.maxBulletPoints": "Max bullet point count before flagging",
186
+
187
+ // Prompts
188
+ prompts: "Prompt template overrides (PB-003: PromptBuilder)",
189
+ "prompts.overrides": "Custom prompt template files for specific roles",
190
+ "prompts.overrides.test-writer": 'Path to custom test-writer prompt (e.g., ".nax/prompts/test-writer.md")',
191
+ "prompts.overrides.implementer": 'Path to custom implementer prompt (e.g., ".nax/prompts/implementer.md")',
192
+ "prompts.overrides.verifier": 'Path to custom verifier prompt (e.g., ".nax/prompts/verifier.md")',
193
+ "prompts.overrides.single-session": 'Path to custom single-session prompt (e.g., ".nax/prompts/single-session.md")',
186
194
  };
187
195
 
188
196
  /** Options for config command */
@@ -473,6 +481,34 @@ function displayConfigWithDescriptions(
473
481
  // Handle objects
474
482
  const entries = Object.entries(obj as Record<string, unknown>);
475
483
 
484
+ // Special handling for prompts section: always show overrides documentation
485
+ const objAsRecord = obj as Record<string, unknown>;
486
+ const isPromptsSection = path.join(".") === "prompts";
487
+ if (isPromptsSection && !objAsRecord.overrides) {
488
+ // Add prompts.overrides documentation even if not in config
489
+ const description = FIELD_DESCRIPTIONS["prompts.overrides"];
490
+ if (description) {
491
+ console.log(`${indentStr}# prompts.overrides: ${description}`);
492
+ }
493
+
494
+ // Show role examples
495
+ const roles = ["test-writer", "implementer", "verifier", "single-session"];
496
+ console.log(`${indentStr}overrides:`);
497
+ for (const role of roles) {
498
+ const roleDesc = FIELD_DESCRIPTIONS[`prompts.overrides.${role}`];
499
+ if (roleDesc) {
500
+ console.log(`${indentStr} # ${roleDesc}`);
501
+ // Extract the example path from description
502
+ const match = roleDesc.match(/e\.g\., "([^"]+)"/);
503
+ if (match) {
504
+ console.log(`${indentStr} # ${role}: "${match[1]}"`);
505
+ }
506
+ }
507
+ }
508
+ console.log();
509
+ return;
510
+ }
511
+
476
512
  for (let i = 0; i < entries.length; i++) {
477
513
  const [key, value] = entries[i];
478
514
  const currentPath = [...path, key];
@@ -481,7 +517,10 @@ function displayConfigWithDescriptions(
481
517
 
482
518
  // Display description comment if available
483
519
  if (description) {
484
- console.log(`${indentStr}# ${description}`);
520
+ // Include path only for prompts section (where tests expect "prompts.overrides" to appear)
521
+ const isPromptsSubSection = currentPathStr.startsWith("prompts.");
522
+ const comment = isPromptsSubSection ? `${currentPathStr}: ${description}` : description;
523
+ console.log(`${indentStr}# ${comment}`);
485
524
  }
486
525
 
487
526
  // Handle nested objects
@@ -17,6 +17,7 @@ import type { PipelineContext } from "../pipeline";
17
17
  import { constitutionStage, contextStage, promptStage, routingStage } from "../pipeline/stages";
18
18
  import type { UserStory } from "../prd";
19
19
  import { loadPRD } from "../prd";
20
+ import { PromptBuilder } from "../prompts";
20
21
 
21
22
  export interface PromptsCommandOptions {
22
23
  /** Feature name */
@@ -253,14 +254,25 @@ async function handleThreeSessionTddPrompts(
253
254
  outputDir: string | undefined,
254
255
  logger: ReturnType<typeof getLogger>,
255
256
  ): Promise<void> {
256
- // Import TDD prompt builders
257
- const { buildTestWriterPrompt, buildImplementerPrompt, buildVerifierPrompt } = await import("../tdd/prompts");
257
+ // Build prompts for each session using PromptBuilder
258
+ const [testWriterPrompt, implementerPrompt, verifierPrompt] = await Promise.all([
259
+ PromptBuilder.for("test-writer", { isolation: "strict" })
260
+ .withLoader(ctx.workdir, ctx.config)
261
+ .story(story)
262
+ .context(ctx.contextMarkdown)
263
+ .build(),
264
+ PromptBuilder.for("implementer", { variant: "standard" })
265
+ .withLoader(ctx.workdir, ctx.config)
266
+ .story(story)
267
+ .context(ctx.contextMarkdown)
268
+ .build(),
269
+ PromptBuilder.for("verifier").withLoader(ctx.workdir, ctx.config).story(story).build(),
270
+ ]);
258
271
 
259
- // Build prompts for each session
260
272
  const sessions = [
261
- { role: "test-writer", prompt: buildTestWriterPrompt(story, ctx.contextMarkdown) },
262
- { role: "implementer", prompt: buildImplementerPrompt(story, ctx.contextMarkdown) },
263
- { role: "verifier", prompt: buildVerifierPrompt(story) },
273
+ { role: "test-writer", prompt: testWriterPrompt },
274
+ { role: "implementer", prompt: implementerPrompt },
275
+ { role: "verifier", prompt: verifierPrompt },
264
276
  ];
265
277
 
266
278
  for (const session of sessions) {
@@ -161,4 +161,5 @@ export const DEFAULT_CONFIG: NaxConfig = {
161
161
  maxBulletPoints: 8,
162
162
  },
163
163
  },
164
+ prompts: {},
164
165
  };
@@ -290,6 +290,15 @@ const PrecheckConfigSchema = z.object({
290
290
  storySizeGate: StorySizeGateConfigSchema,
291
291
  });
292
292
 
293
+ const PromptsConfigSchema = z.object({
294
+ overrides: z
295
+ .record(
296
+ z.enum(["test-writer", "implementer", "verifier", "single-session"]),
297
+ z.string().min(1, "Override path must be non-empty"),
298
+ )
299
+ .optional(),
300
+ });
301
+
293
302
  export const NaxConfigSchema = z
294
303
  .object({
295
304
  version: z.number(),
@@ -310,6 +319,7 @@ export const NaxConfigSchema = z
310
319
  hooks: HooksConfigSchema.optional(),
311
320
  interaction: InteractionConfigSchema.optional(),
312
321
  precheck: PrecheckConfigSchema.optional(),
322
+ prompts: PromptsConfigSchema.optional(),
313
323
  })
314
324
  .refine((data) => data.version === 1, {
315
325
  message: "Invalid version: expected 1",
@@ -406,6 +406,11 @@ export interface RoutingConfig {
406
406
  llm?: LlmRoutingConfig;
407
407
  }
408
408
 
409
+ /** Prompt overrides config (PB-003) */
410
+ export interface PromptsConfig {
411
+ overrides?: Partial<Record<"test-writer" | "implementer" | "verifier" | "single-session", string>>;
412
+ }
413
+
409
414
  /** Full nax configuration */
410
415
  export interface NaxConfig {
411
416
  /** Schema version */
@@ -444,6 +449,8 @@ export interface NaxConfig {
444
449
  interaction?: InteractionConfig;
445
450
  /** Precheck settings (v0.16.0) */
446
451
  precheck?: PrecheckConfig;
452
+ /** Prompt override settings (PB-003) */
453
+ prompts?: PromptsConfig;
447
454
  }
448
455
 
449
456
  /** Resolve a ModelEntry (string shorthand or full object) into a ModelDef */
@@ -51,7 +51,8 @@ export async function runPipeline(
51
51
 
52
52
  // Skip disabled stages
53
53
  if (!stage.enabled(context)) {
54
- logger.debug("pipeline", `Stage "${stage.name}" skipped (disabled)`);
54
+ const reason = stage.skipReason?.(context) ?? "disabled";
55
+ logger.debug("pipeline", `Stage "${stage.name}" skipped (${reason})`);
55
56
  i++;
56
57
  continue;
57
58
  }
@@ -29,6 +29,11 @@ export const autofixStage: PipelineStage = {
29
29
  return autofixEnabled;
30
30
  },
31
31
 
32
+ skipReason(ctx: PipelineContext): string {
33
+ if (!ctx.reviewResult || ctx.reviewResult.success) return "not needed (review passed)";
34
+ return "disabled (autofix not enabled in config)";
35
+ },
36
+
32
37
  async execute(ctx: PipelineContext): Promise<StageResult> {
33
38
  const logger = getLogger();
34
39
  const { reviewResult } = ctx;
@@ -147,6 +147,11 @@ export const executionStage: PipelineStage = {
147
147
  durationMs: 0, // TDD result doesn't track total duration
148
148
  };
149
149
 
150
+ // Propagate full-suite gate result so verify stage can skip redundant run (BUG-054)
151
+ if (tddResult.fullSuiteGatePassed) {
152
+ ctx.fullSuiteGatePassed = true;
153
+ }
154
+
150
155
  if (!tddResult.success) {
151
156
  // Store failure category in context for runner to use at max-attempts decision
152
157
  ctx.tddFailureCategory = tddResult.failureCategory;
@@ -21,8 +21,9 @@
21
21
  * ```
22
22
  */
23
23
 
24
- import { buildBatchPrompt, buildSingleSessionPrompt } from "../../execution/prompts";
24
+ import { buildBatchPrompt } from "../../execution/prompts";
25
25
  import { getLogger } from "../../logger";
26
+ import { PromptBuilder } from "../../prompts";
26
27
  import type { PipelineContext, PipelineStage, StageResult } from "../types";
27
28
 
28
29
  export const promptStage: PipelineStage = {
@@ -34,9 +35,17 @@ export const promptStage: PipelineStage = {
34
35
  const logger = getLogger();
35
36
  const isBatch = ctx.stories.length > 1;
36
37
 
37
- const prompt = isBatch
38
- ? buildBatchPrompt(ctx.stories, ctx.contextMarkdown, ctx.constitution)
39
- : buildSingleSessionPrompt(ctx.story, ctx.contextMarkdown, ctx.constitution);
38
+ let prompt: string;
39
+ if (isBatch) {
40
+ prompt = buildBatchPrompt(ctx.stories, ctx.contextMarkdown, ctx.constitution);
41
+ } else {
42
+ const builder = PromptBuilder.for("single-session")
43
+ .withLoader(ctx.workdir, ctx.config)
44
+ .story(ctx.story)
45
+ .context(ctx.contextMarkdown)
46
+ .constitution(ctx.constitution?.content);
47
+ prompt = await builder.build();
48
+ }
40
49
 
41
50
  ctx.prompt = prompt;
42
51
 
@@ -27,6 +27,11 @@ export const rectifyStage: PipelineStage = {
27
27
  return ctx.config.execution.rectification?.enabled ?? false;
28
28
  },
29
29
 
30
+ skipReason(ctx: PipelineContext): string {
31
+ if (!ctx.verifyResult || ctx.verifyResult.success) return "not needed (verify passed)";
32
+ return "disabled (rectification not enabled in config)";
33
+ },
34
+
30
35
  async execute(ctx: PipelineContext): Promise<StageResult> {
31
36
  const logger = getLogger();
32
37
  const { verifyResult } = ctx;