@nathapp/nax 0.36.0 → 0.36.2

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 (49) hide show
  1. package/dist/nax.js +543 -154
  2. package/package.json +1 -1
  3. package/src/agents/claude-decompose.ts +3 -3
  4. package/src/cli/constitution.ts +0 -92
  5. package/src/constitution/generator.ts +0 -33
  6. package/src/constitution/index.ts +2 -1
  7. package/src/constitution/loader.ts +1 -13
  8. package/src/context/builder.ts +1 -2
  9. package/src/context/elements.ts +1 -12
  10. package/src/context/index.ts +2 -1
  11. package/src/context/test-scanner.ts +1 -1
  12. package/src/execution/dry-run.ts +1 -1
  13. package/src/execution/escalation/escalation.ts +5 -3
  14. package/src/execution/escalation/tier-escalation.ts +41 -4
  15. package/src/execution/iteration-runner.ts +5 -0
  16. package/src/execution/parallel-executor.ts +293 -9
  17. package/src/execution/parallel.ts +40 -21
  18. package/src/execution/pipeline-result-handler.ts +3 -2
  19. package/src/execution/runner.ts +13 -3
  20. package/src/interaction/chain.ts +17 -1
  21. package/src/metrics/tracker.ts +8 -4
  22. package/src/metrics/types.ts +2 -0
  23. package/src/pipeline/event-bus.ts +1 -1
  24. package/src/pipeline/stages/completion.ts +1 -1
  25. package/src/pipeline/stages/execution.ts +23 -1
  26. package/src/pipeline/stages/verify.ts +8 -1
  27. package/src/pipeline/subscribers/reporters.ts +3 -3
  28. package/src/pipeline/types.ts +4 -0
  29. package/src/plugins/types.ts +1 -1
  30. package/src/prd/types.ts +2 -0
  31. package/src/prompts/builder.ts +13 -6
  32. package/src/prompts/sections/conventions.ts +5 -7
  33. package/src/prompts/sections/isolation.ts +7 -7
  34. package/src/prompts/sections/role-task.ts +64 -64
  35. package/src/review/orchestrator.ts +11 -1
  36. package/src/routing/strategies/llm-prompts.ts +1 -1
  37. package/src/routing/strategies/llm.ts +3 -3
  38. package/src/tdd/index.ts +2 -3
  39. package/src/tdd/isolation.ts +0 -13
  40. package/src/tdd/orchestrator.ts +5 -0
  41. package/src/tdd/prompts.ts +1 -231
  42. package/src/tdd/session-runner.ts +2 -0
  43. package/src/tdd/types.ts +2 -1
  44. package/src/tdd/verdict.ts +20 -2
  45. package/src/verification/crash-detector.ts +34 -0
  46. package/src/verification/orchestrator-types.ts +8 -1
  47. package/src/verification/parser.ts +0 -10
  48. package/src/verification/rectification-loop.ts +2 -51
  49. package/src/worktree/dispatcher.ts +0 -59
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.36.0",
3
+ "version": "0.36.2",
4
4
  "description": "AI Coding Agent Orchestrator \u2014 loops until done",
5
5
  "type": "module",
6
6
  "bin": {
@@ -130,7 +130,7 @@ export function parseDecomposeOutput(output: string): DecomposedStory[] {
130
130
  : ["Implementation complete"],
131
131
  tags: Array.isArray(record.tags) ? record.tags : [],
132
132
  dependencies: Array.isArray(record.dependencies) ? record.dependencies : [],
133
- complexity: validateComplexity(record.complexity),
133
+ complexity: coerceComplexity(record.complexity),
134
134
  // contextFiles: prefer the new field; fall back to legacy relevantFiles from older LLM responses
135
135
  contextFiles: Array.isArray(record.contextFiles)
136
136
  ? record.contextFiles
@@ -158,9 +158,9 @@ export function parseDecomposeOutput(output: string): DecomposedStory[] {
158
158
  }
159
159
 
160
160
  /**
161
- * Validate complexity value from decompose output.
161
+ * Coerce complexity value from decompose output.
162
162
  */
163
- export function validateComplexity(value: unknown): "simple" | "medium" | "complex" | "expert" {
163
+ export function coerceComplexity(value: unknown): "simple" | "medium" | "complex" | "expert" {
164
164
  if (value === "simple" || value === "medium" || value === "complex" || value === "expert") {
165
165
  return value;
166
166
  }
@@ -4,12 +4,6 @@
4
4
  * Generates agent-specific config files from nax/constitution.md.
5
5
  */
6
6
 
7
- import { existsSync } from "node:fs";
8
- import { join } from "node:path";
9
- import chalk from "chalk";
10
- import { generateAll, generateFor } from "../constitution/generator";
11
- import type { AgentType } from "../constitution/generators/types";
12
-
13
7
  /** Constitution generate options */
14
8
  export interface ConstitutionGenerateOptions {
15
9
  /** Path to constitution file (default: nax/constitution.md) */
@@ -21,89 +15,3 @@ export interface ConstitutionGenerateOptions {
21
15
  /** Dry run mode (don't write files) */
22
16
  dryRun?: boolean;
23
17
  }
24
-
25
- /**
26
- * Constitution generate command
27
- */
28
- export async function constitutionGenerateCommand(options: ConstitutionGenerateOptions): Promise<void> {
29
- const workdir = process.cwd();
30
- const constitutionPath = options.constitution
31
- ? join(workdir, options.constitution)
32
- : join(workdir, "nax/constitution.md");
33
- const outputDir = options.output ? join(workdir, options.output) : workdir;
34
-
35
- // Validate constitution file exists
36
- if (!existsSync(constitutionPath)) {
37
- console.error(chalk.red(`[FAIL] Constitution file not found: ${constitutionPath}`));
38
- console.error(chalk.yellow(`Create ${constitutionPath} first or use --constitution to specify a different path.`));
39
- process.exit(1);
40
- }
41
-
42
- console.log(chalk.blue(`[OK] Loading constitution from ${constitutionPath}`));
43
-
44
- // Validate agent type if specified
45
- const validAgents: AgentType[] = ["claude", "opencode", "cursor", "windsurf", "aider"];
46
- if (options.agent && !validAgents.includes(options.agent as AgentType)) {
47
- console.error(chalk.red(`[FAIL] Unknown agent type: ${options.agent}`));
48
- console.error(chalk.yellow(`Valid agents: ${validAgents.join(", ")}`));
49
- process.exit(1);
50
- }
51
-
52
- const dryRun = options.dryRun ?? false;
53
-
54
- if (dryRun) {
55
- console.log(chalk.yellow("[DRY RUN] No files will be written"));
56
- }
57
-
58
- try {
59
- // Generate for specific agent or all agents
60
- if (options.agent) {
61
- const agent = options.agent as AgentType;
62
- console.log(chalk.blue(`-> Generating config for ${agent}...`));
63
-
64
- const result = await generateFor(agent, constitutionPath, outputDir, dryRun);
65
-
66
- if (result.error) {
67
- console.error(chalk.red(`[FAIL] ${agent}: ${result.error}`));
68
- process.exit(1);
69
- }
70
-
71
- if (dryRun) {
72
- console.log(chalk.green(`[OK] ${agent} -> ${result.outputFile} (${result.content.length} bytes, dry run)`));
73
- } else {
74
- console.log(chalk.green(`[OK] ${agent} -> ${result.outputFile} (${result.content.length} bytes)`));
75
- }
76
- } else {
77
- console.log(chalk.blue("-> Generating configs for all agents..."));
78
-
79
- const results = await generateAll(constitutionPath, outputDir, dryRun);
80
-
81
- let errorCount = 0;
82
- for (const result of results) {
83
- if (result.error) {
84
- console.error(chalk.red(`[FAIL] ${result.agent}: ${result.error}`));
85
- errorCount++;
86
- } else if (dryRun) {
87
- console.log(
88
- chalk.green(`[OK] ${result.agent} -> ${result.outputFile} (${result.content.length} bytes, dry run)`),
89
- );
90
- } else {
91
- console.log(chalk.green(`[OK] ${result.agent} -> ${result.outputFile} (${result.content.length} bytes)`));
92
- }
93
- }
94
-
95
- if (errorCount > 0) {
96
- console.error(chalk.red(`[FAIL] ${errorCount} generation(s) failed`));
97
- process.exit(1);
98
- }
99
- }
100
-
101
- if (!dryRun) {
102
- console.log(chalk.green(`\n[OK] Constitution config(s) generated in ${outputDir}`));
103
- }
104
- } catch (err) {
105
- const error = err instanceof Error ? err.message : String(err);
106
- console.error(chalk.red(`[FAIL] Generation failed: ${error}`));
107
- process.exit(1);
108
- }
109
- }
@@ -156,36 +156,3 @@ export async function generateAll(
156
156
 
157
157
  return results;
158
158
  }
159
-
160
- /**
161
- * Check if constitution file is newer than generated configs
162
- */
163
- export function isConstitutionNewer(constitutionPath: string, outputDir: string): boolean {
164
- if (!existsSync(constitutionPath)) {
165
- return false;
166
- }
167
-
168
- const constitutionStat = Bun.file(constitutionPath);
169
- const constitutionMtime = constitutionStat.lastModified;
170
-
171
- // Check all generated files
172
- const agents: AgentType[] = ["claude", "opencode", "cursor", "windsurf", "aider"];
173
- for (const agent of agents) {
174
- const outputFile = GENERATORS[agent].outputFile;
175
- const outputPath = join(outputDir, outputFile);
176
-
177
- if (!existsSync(outputPath)) {
178
- // If any config file is missing, consider constitution newer
179
- return true;
180
- }
181
-
182
- const outputStat = Bun.file(outputPath);
183
- const outputMtime = outputStat.lastModified;
184
-
185
- if (constitutionMtime > outputMtime) {
186
- return true;
187
- }
188
- }
189
-
190
- return false;
191
- }
@@ -7,4 +7,5 @@
7
7
  */
8
8
 
9
9
  export type { ConstitutionConfig, ConstitutionResult } from "./types";
10
- export { loadConstitution, estimateTokens, truncateToTokens } from "./loader";
10
+ export { loadConstitution, truncateToTokens } from "./loader";
11
+ export { estimateTokens } from "../optimizer/types";
@@ -8,21 +8,9 @@ import { existsSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import { validateFilePath } from "../config/path-security";
10
10
  import { globalConfigDir } from "../config/paths";
11
+ import { estimateTokens } from "../optimizer/types";
11
12
  import type { ConstitutionConfig, ConstitutionResult } from "./types";
12
13
 
13
- /**
14
- * Estimate token count for text
15
- *
16
- * Uses simple heuristic: 1 token ≈ 3 characters (conservative estimate)
17
- * This is a rough approximation sufficient for quota management.
18
- *
19
- * @param text - Text to estimate tokens for
20
- * @returns Estimated token count
21
- */
22
- export function estimateTokens(text: string): number {
23
- return Math.ceil(text.length / 3);
24
- }
25
-
26
14
  /**
27
15
  * Truncate text to fit within token limit
28
16
  *
@@ -7,6 +7,7 @@
7
7
  import path from "node:path";
8
8
  import type { NaxConfig } from "../config";
9
9
  import { getLogger } from "../logger";
10
+ import { estimateTokens } from "../optimizer/types";
10
11
  import type { UserStory } from "../prd";
11
12
  import { countStories, getContextFiles } from "../prd";
12
13
  import { autoDetectContextFiles } from "./auto-detect";
@@ -18,7 +19,6 @@ import {
18
19
  createProgressContext,
19
20
  createStoryContext,
20
21
  createTestCoverageContext,
21
- estimateTokens,
22
22
  } from "./elements";
23
23
  import { generateTestCoverageSummary } from "./test-scanner";
24
24
  import type { BuiltContext, ContextBudget, ContextElement, StoryContext } from "./types";
@@ -30,7 +30,6 @@ export const _deps = {
30
30
 
31
31
  // Re-export for backward compatibility
32
32
  export {
33
- estimateTokens,
34
33
  createStoryContext,
35
34
  createDependencyContext,
36
35
  createErrorContext,
@@ -5,21 +5,10 @@
5
5
  */
6
6
 
7
7
  import { getLogger } from "../logger";
8
+ import { estimateTokens } from "../optimizer/types";
8
9
  import type { StructuredFailure, UserStory } from "../prd";
9
10
  import type { ContextElement } from "./types";
10
11
 
11
- /**
12
- * Approximate character-to-token ratio for token estimation.
13
- * Value of 3 is a middle ground optimized for mixed content (prose + code + markdown).
14
- * Slightly overestimates tokens, preventing budget overflow.
15
- */
16
- const CHARS_PER_TOKEN = 3;
17
-
18
- /** Estimate token count for text using character-to-token ratio. */
19
- export function estimateTokens(text: string): number {
20
- return Math.ceil(text.length / CHARS_PER_TOKEN);
21
- }
22
-
23
12
  /** Create context element from current story */
24
13
  export function createStoryContext(story: UserStory, priority: number): ContextElement {
25
14
  const content = formatStoryAsText(story);
@@ -5,7 +5,6 @@
5
5
  export type { ContextElement, ContextBudget, StoryContext, BuiltContext } from "./types";
6
6
 
7
7
  export {
8
- estimateTokens,
9
8
  createStoryContext,
10
9
  createDependencyContext,
11
10
  createErrorContext,
@@ -17,6 +16,8 @@ export {
17
16
  formatContextAsMarkdown,
18
17
  } from "./builder";
19
18
 
19
+ export { estimateTokens } from "../optimizer/types";
20
+
20
21
  export {
21
22
  generateTestCoverageSummary,
22
23
  scanTestFiles,
@@ -9,7 +9,7 @@
9
9
  import path from "node:path";
10
10
  import { Glob } from "bun";
11
11
  import { getLogger } from "../logger";
12
- import { estimateTokens } from "./builder";
12
+ import { estimateTokens } from "../optimizer/types";
13
13
 
14
14
  // ============================================================================
15
15
  // Types
@@ -66,7 +66,7 @@ export async function handleDryRun(ctx: DryRunContext): Promise<DryRunResult> {
66
66
  storyId: s.id,
67
67
  story: s,
68
68
  passed: true,
69
- durationMs: 0,
69
+ runElapsedMs: 0,
70
70
  cost: 0,
71
71
  modelTier: ctx.routing.modelTier,
72
72
  testStrategy: ctx.routing.testStrategy,
@@ -22,18 +22,20 @@ import type { TierConfig } from "../../config";
22
22
  * ```
23
23
  */
24
24
  export function escalateTier(currentTier: string, tierOrder: TierConfig[]): string | null {
25
- const currentIndex = tierOrder.findIndex((t) => t.tier === currentTier);
25
+ const getName = (t: TierConfig) => t.tier ?? (t as unknown as { name?: string }).name ?? null;
26
+ const currentIndex = tierOrder.findIndex((t) => getName(t) === currentTier);
26
27
  if (currentIndex === -1 || currentIndex === tierOrder.length - 1) {
27
28
  return null;
28
29
  }
29
- return tierOrder[currentIndex + 1].tier;
30
+ return getName(tierOrder[currentIndex + 1]);
30
31
  }
31
32
 
32
33
  /**
33
34
  * Get the tier config for a given tier name.
34
35
  */
35
36
  export function getTierConfig(tierName: string, tierOrder: TierConfig[]): TierConfig | undefined {
36
- return tierOrder.find((t) => t.tier === tierName);
37
+ const getName = (t: TierConfig) => t.tier ?? (t as unknown as { name?: string }).name ?? null;
38
+ return tierOrder.find((t) => getName(t) === tierName);
37
39
  }
38
40
 
39
41
  /**
@@ -24,6 +24,7 @@ function buildEscalationFailure(
24
24
  story: UserStory,
25
25
  currentTier: string,
26
26
  reviewFindings?: import("../../plugins/types").ReviewFinding[],
27
+ cost?: number,
27
28
  ): StructuredFailure {
28
29
  return {
29
30
  attempt: (story.attempts ?? 0) + 1,
@@ -31,6 +32,7 @@ function buildEscalationFailure(
31
32
  stage: "escalation" as const,
32
33
  summary: `Failed with tier ${currentTier}, escalating to next tier`,
33
34
  reviewFindings: reviewFindings && reviewFindings.length > 0 ? reviewFindings : undefined,
35
+ cost: cost ?? 0,
34
36
  timestamp: new Date().toISOString(),
35
37
  };
36
38
  }
@@ -54,6 +56,8 @@ export function resolveMaxAttemptsOutcome(failureCategory?: FailureCategory): "p
54
56
  case "verifier-rejected":
55
57
  case "greenfield-no-tests":
56
58
  return "pause";
59
+ case "runtime-crash":
60
+ return "pause";
57
61
  case "session-failure":
58
62
  case "tests-failing":
59
63
  return "fail";
@@ -208,14 +212,38 @@ export interface EscalationHandlerContext {
208
212
  feature: string;
209
213
  totalCost: number;
210
214
  workdir: string;
215
+ /** Verify result from the pipeline verify stage — used to detect RUNTIME_CRASH (BUG-070) */
216
+ verifyResult?: { status: string; success: boolean };
217
+ /** Cost of the failed attempt being escalated (BUG-067: accumulated across escalations) */
218
+ attemptCost?: number;
211
219
  }
212
220
 
213
221
  export interface EscalationHandlerResult {
214
- outcome: "escalated" | "paused" | "failed";
222
+ outcome: "escalated" | "paused" | "failed" | "retry-same";
215
223
  prdDirty: boolean;
216
224
  prd: PRD;
217
225
  }
218
226
 
227
+ /**
228
+ * Determine if the pipeline should retry the same tier due to a transient runtime crash.
229
+ *
230
+ * Returns true when the verify result status is RUNTIME_CRASH — these are Bun
231
+ * runtime-level failures, not code quality issues, so escalating the model tier
232
+ * would not help. Instead the same tier should be retried.
233
+ *
234
+ * @param verifyResult - Verify result from the pipeline verify stage
235
+ */
236
+ export function shouldRetrySameTier(verifyResult: { status: string; success: boolean } | undefined): boolean {
237
+ return verifyResult?.status === "RUNTIME_CRASH";
238
+ }
239
+
240
+ /**
241
+ * Swappable dependencies for testing (avoids mock.module() which leaks in Bun 1.x).
242
+ */
243
+ export const _tierEscalationDeps = {
244
+ savePRD,
245
+ };
246
+
219
247
  /**
220
248
  * Handle tier escalation after pipeline escalation action
221
249
  *
@@ -223,6 +251,15 @@ export interface EscalationHandlerResult {
223
251
  */
224
252
  export async function handleTierEscalation(ctx: EscalationHandlerContext): Promise<EscalationHandlerResult> {
225
253
  const logger = getSafeLogger();
254
+
255
+ // BUG-070: Runtime crashes are transient — retry same tier, do NOT escalate
256
+ if (shouldRetrySameTier(ctx.verifyResult)) {
257
+ logger?.warn("escalation", "Runtime crash detected — retrying same tier (transient, not a code issue)", {
258
+ storyId: ctx.story.id,
259
+ });
260
+ return { outcome: "retry-same", prdDirty: false, prd: ctx.prd };
261
+ }
262
+
226
263
  const nextTier = escalateTier(ctx.routing.modelTier, ctx.config.autoMode.escalation.tierOrder);
227
264
  const escalateWholeBatch = ctx.config.autoMode.escalation.escalateEntireBatch ?? true;
228
265
  const storiesToEscalate = ctx.isBatchExecution && escalateWholeBatch ? ctx.storiesToExecute : [ctx.story];
@@ -294,8 +331,8 @@ export async function handleTierEscalation(ctx: EscalationHandlerContext): Promi
294
331
  const isChangingTier = currentStoryTier !== nextTier;
295
332
  const shouldResetAttempts = isChangingTier || shouldSwitchToTestAfter;
296
333
 
297
- // Build escalation failure
298
- const escalationFailure = buildEscalationFailure(s, currentStoryTier, escalateReviewFindings);
334
+ // Build escalation failure (BUG-067: include cost for accumulatedAttemptCost in metrics)
335
+ const escalationFailure = buildEscalationFailure(s, currentStoryTier, escalateReviewFindings, ctx.attemptCost);
299
336
 
300
337
  return {
301
338
  ...s,
@@ -307,7 +344,7 @@ export async function handleTierEscalation(ctx: EscalationHandlerContext): Promi
307
344
  }) as PRD["userStories"],
308
345
  } as PRD;
309
346
 
310
- await savePRD(updatedPrd, ctx.prdPath);
347
+ await _tierEscalationDeps.savePRD(updatedPrd, ctx.prdPath);
311
348
 
312
349
  // Clear routing cache for all escalated stories to avoid returning old cached decisions
313
350
  for (const story of storiesToEscalate) {
@@ -60,6 +60,10 @@ export async function runIteration(
60
60
 
61
61
  const storyStartTime = Date.now();
62
62
  const storyGitRef = await captureGitRef(ctx.workdir);
63
+
64
+ // BUG-067: Accumulate cost from all prior failed attempts (stored in priorFailures by handleTierEscalation)
65
+ const accumulatedAttemptCost = (story.priorFailures || []).reduce((sum, f) => sum + (f.cost || 0), 0);
66
+
63
67
  const pipelineContext: PipelineContext = {
64
68
  config: ctx.config,
65
69
  prd,
@@ -74,6 +78,7 @@ export async function runIteration(
74
78
  storyStartTime: new Date().toISOString(),
75
79
  storyGitRef: storyGitRef ?? undefined,
76
80
  interaction: ctx.interactionChain ?? undefined,
81
+ accumulatedAttemptCost: accumulatedAttemptCost > 0 ? accumulatedAttemptCost : undefined,
77
82
  };
78
83
 
79
84
  ctx.statusWriter.setPrd(prd);