@nathapp/nax 0.57.3 → 0.57.4

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 (2) hide show
  1. package/dist/nax.js +161 -106
  2. package/package.json +1 -1
package/dist/nax.js CHANGED
@@ -3307,6 +3307,12 @@ function buildPlanModeDecomposePrompt(options) {
3307
3307
 
3308
3308
  ${siblings.map((s) => `- ${s.id}: ${s.title}`).join(`
3309
3309
  `)}
3310
+ ` : "";
3311
+ const maxAcCount = options.config?.precheck?.storySizeGate?.maxAcCount;
3312
+ const acConstraint = maxAcCount != null ? `
3313
+ ## Acceptance Criteria Constraint
3314
+
3315
+ Every sub-story must have at most ${maxAcCount} acceptance criteria. If a story would exceed this limit, split it into additional sub-stories instead of adding more ACs.
3310
3316
  ` : "";
3311
3317
  return `You are a senior software architect decomposing a complex user story into smaller, implementable sub-stories.
3312
3318
 
@@ -3316,7 +3322,7 @@ ${JSON.stringify(targetStory, null, 2)}${siblingsSummary}
3316
3322
  ## Codebase Context
3317
3323
 
3318
3324
  ${options.codebaseContext}
3319
-
3325
+ ${acConstraint}
3320
3326
  ${COMPLEXITY_GUIDE}
3321
3327
 
3322
3328
  ${TEST_STRATEGY_GUIDE}
@@ -21073,6 +21079,50 @@ function errorMessage(err) {
21073
21079
  return err instanceof Error ? err.message : String(err);
21074
21080
  }
21075
21081
 
21082
+ // src/utils/llm-json.ts
21083
+ function extractJsonFromMarkdown(text) {
21084
+ const match = text.match(/```(?:json)?\s*\n([\s\S]*?)\n?\s*```/);
21085
+ if (match) {
21086
+ return match[1] ?? text;
21087
+ }
21088
+ return text;
21089
+ }
21090
+ function stripTrailingCommas(text) {
21091
+ return text.replace(/,\s*([}\]])/g, "$1");
21092
+ }
21093
+ function extractJsonObject(text) {
21094
+ const objStart = text.indexOf("{");
21095
+ const arrStart = text.indexOf("[");
21096
+ let start;
21097
+ let closeChar;
21098
+ if (objStart === -1 && arrStart === -1)
21099
+ return null;
21100
+ if (objStart === -1) {
21101
+ start = arrStart;
21102
+ closeChar = "]";
21103
+ } else if (arrStart === -1) {
21104
+ start = objStart;
21105
+ closeChar = "}";
21106
+ } else if (objStart < arrStart) {
21107
+ start = objStart;
21108
+ closeChar = "}";
21109
+ } else {
21110
+ start = arrStart;
21111
+ closeChar = "]";
21112
+ }
21113
+ const end = text.lastIndexOf(closeChar);
21114
+ if (end <= start)
21115
+ return null;
21116
+ return text.slice(start, end + 1);
21117
+ }
21118
+ function wrapJsonPrompt(prompt) {
21119
+ return `IMPORTANT: Your entire response must be a single JSON object or array. Do not explain your reasoning. Do not use markdown formatting. Output ONLY the JSON.
21120
+
21121
+ ${prompt.trim()}
21122
+
21123
+ YOUR RESPONSE MUST START WITH { OR [ AND END WITH } OR ]. No other text.`;
21124
+ }
21125
+
21076
21126
  // src/acceptance/refinement.ts
21077
21127
  var exports_refinement = {};
21078
21128
  __export(exports_refinement, {
@@ -21086,7 +21136,7 @@ function buildRefinementPrompt(criteria, codebaseContext, options) {
21086
21136
  `);
21087
21137
  const strategySection = buildStrategySection(options);
21088
21138
  const refinedExample = buildRefinedExample(options?.testStrategy);
21089
- return `You are an acceptance criteria refinement assistant. Your task is to convert raw acceptance criteria into concrete, machine-verifiable assertions.
21139
+ const core2 = `You are an acceptance criteria refinement assistant. Your task is to convert raw acceptance criteria into concrete, machine-verifiable assertions.
21090
21140
 
21091
21141
  CODEBASE CONTEXT:
21092
21142
  ${codebaseContext}
@@ -21095,7 +21145,7 @@ ACCEPTANCE CRITERIA TO REFINE:
21095
21145
  ${criteriaList}
21096
21146
 
21097
21147
  For each criterion, produce a refined version that is concrete and automatically testable where possible.
21098
- Respond with ONLY a JSON array (no markdown code fences):
21148
+ Respond with a JSON array:
21099
21149
  [{
21100
21150
  "original": "<exact original criterion text>",
21101
21151
  "refined": "<concrete, machine-verifiable description>",
@@ -21107,8 +21157,8 @@ Rules:
21107
21157
  - "original" must match the input criterion text exactly
21108
21158
  - "refined" must be a concrete assertion (e.g., ${refinedExample})
21109
21159
  - "testable" is false only if the criterion cannot be automatically verified (e.g., "UX feels responsive", "design looks good")
21110
- - "storyId" leave as empty string \u2014 it will be assigned by the caller
21111
- - Respond with ONLY the JSON array`;
21160
+ - "storyId" leave as empty string \u2014 it will be assigned by the caller`;
21161
+ return wrapJsonPrompt(core2);
21112
21162
  }
21113
21163
  function buildStrategySection(options) {
21114
21164
  if (!options?.testStrategy) {
@@ -21157,7 +21207,9 @@ function parseRefinementResponse(response, criteria) {
21157
21207
  return fallbackCriteria(criteria);
21158
21208
  }
21159
21209
  try {
21160
- const parsed = JSON.parse(response);
21210
+ const fromFence = extractJsonFromMarkdown(response);
21211
+ const cleaned = stripTrailingCommas(fromFence !== response ? fromFence : response);
21212
+ const parsed = JSON.parse(cleaned);
21161
21213
  if (!Array.isArray(parsed)) {
21162
21214
  return fallbackCriteria(criteria);
21163
21215
  }
@@ -21917,7 +21969,7 @@ function buildRoutingPrompt(story, config2) {
21917
21969
  const { title, description, acceptanceCriteria, tags } = story;
21918
21970
  const criteria = acceptanceCriteria.map((c, i) => `${i + 1}. ${c}`).join(`
21919
21971
  `);
21920
- return `You are a code task router. Classify a user story's complexity and select the cheapest model tier that will succeed.
21972
+ const core2 = `You are a code task router. Classify a user story's complexity and select the cheapest model tier that will succeed.
21921
21973
 
21922
21974
  ## Story
21923
21975
  Title: ${title}
@@ -21943,8 +21995,9 @@ Tags: ${tags.join(", ")}
21943
21995
  - Many files \u2260 complex \u2014 copy-paste refactors across files are simple.
21944
21996
  - Pure refactoring/deletion with no new behavior \u2192 simple.
21945
21997
 
21946
- Respond with ONLY this JSON (no markdown, no explanation):
21998
+ Respond with:
21947
21999
  {"complexity":"simple|medium|complex|expert","modelTier":"fast|balanced|powerful","reasoning":"<one line>"}`;
22000
+ return wrapJsonPrompt(core2);
21948
22001
  }
21949
22002
  function buildBatchRoutingPrompt(stories, config2) {
21950
22003
  const storyBlocks = stories.map((story, idx) => {
@@ -21958,7 +22011,7 @@ ${criteria}
21958
22011
  }).join(`
21959
22012
 
21960
22013
  `);
21961
- return `You are a code task router. Classify each story's complexity and select the cheapest model tier that will succeed.
22014
+ const batchCore = `You are a code task router. Classify each story's complexity and select the cheapest model tier that will succeed.
21962
22015
 
21963
22016
  ## Stories
21964
22017
  ${storyBlocks}
@@ -21980,8 +22033,9 @@ ${storyBlocks}
21980
22033
  - Many files \u2260 complex \u2014 copy-paste refactors across files are simple.
21981
22034
  - Pure refactoring/deletion with no new behavior \u2192 simple.
21982
22035
 
21983
- Respond with ONLY a JSON array (no markdown, no explanation):
22036
+ Respond with a JSON array:
21984
22037
  [{"id":"US-001","complexity":"simple|medium|complex|expert","modelTier":"fast|balanced|powerful","reasoning":"<one line>"}]`;
22038
+ return wrapJsonPrompt(batchCore);
21985
22039
  }
21986
22040
  function validateRoutingDecision(parsed, config2, story) {
21987
22041
  if (!parsed.complexity || !parsed.modelTier || !parsed.reasoning) {
@@ -22006,35 +22060,22 @@ function validateRoutingDecision(parsed, config2, story) {
22006
22060
  };
22007
22061
  }
22008
22062
  function stripCodeFences(text) {
22009
- let result = text.trim();
22010
- if (result.startsWith("```")) {
22011
- const lines = result.split(`
22012
- `);
22013
- result = lines.slice(1, -1).join(`
22014
- `).trim();
22063
+ const trimmed = text.trim();
22064
+ const fromFence = extractJsonFromMarkdown(trimmed);
22065
+ if (fromFence !== trimmed)
22066
+ return fromFence;
22067
+ if (trimmed.startsWith("json")) {
22068
+ return trimmed.slice(4).trim();
22015
22069
  }
22016
- if (result.startsWith("json")) {
22017
- result = result.slice(4).trim();
22018
- }
22019
- return result;
22070
+ return trimmed;
22020
22071
  }
22021
22072
  function parseRoutingResponse(output, story, config2) {
22022
- const jsonText = stripCodeFences(output);
22073
+ const jsonText = extractJsonFromMarkdown(output.trim());
22023
22074
  const parsed = JSON.parse(jsonText);
22024
22075
  return validateRoutingDecision(parsed, config2, story);
22025
22076
  }
22026
22077
  function parseBatchResponse(output, stories, config2) {
22027
- let jsonText = output.trim();
22028
- if (jsonText.startsWith("```")) {
22029
- const lines = jsonText.split(`
22030
- `);
22031
- jsonText = lines.slice(1, -1).join(`
22032
- `).trim();
22033
- }
22034
- if (jsonText.startsWith("json")) {
22035
- jsonText = jsonText.slice(4).trim();
22036
- }
22037
- const parsed = JSON.parse(jsonText);
22078
+ const parsed = JSON.parse(extractJsonFromMarkdown(output.trim()));
22038
22079
  if (!Array.isArray(parsed)) {
22039
22080
  throw new Error("Batch LLM response must be a JSON array");
22040
22081
  }
@@ -22468,7 +22509,7 @@ var package_default;
22468
22509
  var init_package = __esm(() => {
22469
22510
  package_default = {
22470
22511
  name: "@nathapp/nax",
22471
- version: "0.57.3",
22512
+ version: "0.57.4",
22472
22513
  description: "AI Coding Agent Orchestrator \u2014 loops until done",
22473
22514
  type: "module",
22474
22515
  bin: {
@@ -22547,8 +22588,8 @@ var init_version = __esm(() => {
22547
22588
  NAX_VERSION = package_default.version;
22548
22589
  NAX_COMMIT = (() => {
22549
22590
  try {
22550
- if (/^[0-9a-f]{6,10}$/.test("166deae0"))
22551
- return "166deae0";
22591
+ if (/^[0-9a-f]{6,10}$/.test("b3088982"))
22592
+ return "b3088982";
22552
22593
  } catch {}
22553
22594
  try {
22554
22595
  const result = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"], {
@@ -27090,7 +27131,7 @@ function buildPrompt(story, semanticConfig, diff, stat) {
27090
27131
  ${semanticConfig.rules.map((r, i) => `${i + 1}. ${r}`).join(`
27091
27132
  `)}
27092
27133
  ` : "";
27093
- return `You are a semantic code reviewer with access to the repository files. Your job is to verify that the implementation satisfies the story's acceptance criteria (ACs). You are NOT a linter or style checker \u2014 lint, typecheck, and convention checks are handled separately.
27134
+ const core2 = `You are a semantic code reviewer with access to the repository files. Your job is to verify that the implementation satisfies the story's acceptance criteria (ACs). You are NOT a linter or style checker \u2014 lint, typecheck, and convention checks are handled separately.
27094
27135
 
27095
27136
  ## Story: ${story.title}
27096
27137
 
@@ -27138,25 +27179,36 @@ Respond with JSON only \u2014 no explanation text before or after:
27138
27179
  }
27139
27180
 
27140
27181
  If all ACs are correctly implemented, respond with { "passed": true, "findings": [] }.`;
27182
+ return wrapJsonPrompt(core2);
27183
+ }
27184
+ function validateLLMShape(parsed) {
27185
+ if (typeof parsed !== "object" || parsed === null)
27186
+ return null;
27187
+ const obj = parsed;
27188
+ if (typeof obj.passed !== "boolean")
27189
+ return null;
27190
+ if (!Array.isArray(obj.findings))
27191
+ return null;
27192
+ return { passed: obj.passed, findings: obj.findings };
27141
27193
  }
27142
27194
  function parseLLMResponse(raw) {
27195
+ const text = raw.trim();
27143
27196
  try {
27144
- let cleaned = raw.trim();
27145
- const fenceMatch = cleaned.match(/^```(?:json)?\s*\n([\s\S]*?)\n```/);
27146
- if (fenceMatch)
27147
- cleaned = fenceMatch[1].trim();
27148
- const parsed = JSON.parse(cleaned);
27149
- if (typeof parsed !== "object" || parsed === null)
27150
- return null;
27151
- const obj = parsed;
27152
- if (typeof obj.passed !== "boolean")
27153
- return null;
27154
- if (!Array.isArray(obj.findings))
27155
- return null;
27156
- return { passed: obj.passed, findings: obj.findings };
27157
- } catch {
27158
- return null;
27197
+ return validateLLMShape(JSON.parse(text));
27198
+ } catch {}
27199
+ const fromFence = extractJsonFromMarkdown(text);
27200
+ if (fromFence !== text) {
27201
+ try {
27202
+ return validateLLMShape(JSON.parse(stripTrailingCommas(fromFence)));
27203
+ } catch {}
27204
+ }
27205
+ const bareJson = extractJsonObject(text);
27206
+ if (bareJson) {
27207
+ try {
27208
+ return validateLLMShape(JSON.parse(stripTrailingCommas(bareJson)));
27209
+ } catch {}
27159
27210
  }
27211
+ return null;
27160
27212
  }
27161
27213
  function formatFindings(findings) {
27162
27214
  return findings.map((f) => `[${f.severity}] ${f.file}:${f.line} \u2014 ${f.issue}
@@ -70812,16 +70864,6 @@ function mapDecomposedStoriesToUserStories(stories, parentStoryId) {
70812
70864
  init_test_strategy();
70813
70865
  var VALID_COMPLEXITY = ["simple", "medium", "complex", "expert"];
70814
70866
  var STORY_ID_NO_SEPARATOR = /^([A-Za-z]+)(\d+)$/;
70815
- function extractJsonFromMarkdown(text) {
70816
- const match = text.match(/```(?:json)?\s*\n([\s\S]*?)\n?\s*```/);
70817
- if (match) {
70818
- return match[1] ?? text;
70819
- }
70820
- return text;
70821
- }
70822
- function stripTrailingCommas(text) {
70823
- return text.replace(/,\s*([}\]])/g, "$1");
70824
- }
70825
70867
  function normalizeStoryId(id) {
70826
70868
  const match = id.match(STORY_ID_NO_SEPARATOR);
70827
70869
  if (match) {
@@ -71485,61 +71527,74 @@ async function planDecomposeCommand(workdir, config2, options) {
71485
71527
  throw new Error(`[decompose] No agent adapter found for '${agentName}'`);
71486
71528
  const timeoutSeconds = config2?.plan?.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS2;
71487
71529
  const maxAcCount = config2?.precheck?.storySizeGate?.maxAcCount ?? Number.POSITIVE_INFINITY;
71530
+ const maxReplanAttempts = config2?.precheck?.storySizeGate?.maxReplanAttempts ?? 3;
71488
71531
  if (typeof adapter.decompose !== "function") {
71489
71532
  throw new NaxError(`Agent "${agentName}" does not support decompose() required by plan --decompose`, "DECOMPOSE_NOT_SUPPORTED", { stage: "decompose", agent: agentName, storyId: options.storyId });
71490
71533
  }
71491
71534
  const debateStages = config2?.debate?.stages;
71492
71535
  const debateDecompEnabled = config2?.debate?.enabled && debateStages?.decompose?.enabled;
71493
71536
  let decompStories;
71494
- if (debateDecompEnabled) {
71495
- const decomposeStageConfig = debateStages.decompose;
71496
- const prompt = buildDecomposePrompt({
71497
- specContent: "",
71498
- codebaseContext,
71499
- workdir,
71500
- targetStory,
71501
- siblings,
71502
- featureName: options.feature,
71503
- storyId: options.storyId,
71504
- config: config2
71505
- });
71506
- const debateSession = _planDeps.createDebateSession({
71507
- storyId: options.storyId,
71508
- stage: "decompose",
71509
- stageConfig: decomposeStageConfig,
71510
- config: config2,
71511
- workdir,
71512
- featureName: options.feature,
71513
- timeoutSeconds
71514
- });
71515
- const debateResult = await debateSession.run(prompt);
71516
- if (debateResult.outcome !== "failed" && debateResult.output) {
71517
- decompStories = parseDecomposeOutput(debateResult.output);
71518
- }
71519
- }
71520
- if (!decompStories) {
71521
- const result = await adapter.decompose({
71522
- specContent: "",
71523
- codebaseContext,
71524
- workdir,
71525
- targetStory,
71526
- siblings,
71527
- featureName: options.feature,
71528
- storyId: options.storyId,
71529
- config: config2
71530
- });
71531
- decompStories = result.stories;
71532
- }
71533
- for (const sub of decompStories) {
71534
- if (!sub.complexity || !sub.testStrategy) {
71535
- throw new NaxError(`Sub-story "${sub.id}" is missing required routing fields`, "DECOMPOSE_VALIDATION_FAILED", {
71537
+ let repairHint = "";
71538
+ for (let attempt = 0;attempt < maxReplanAttempts; attempt++) {
71539
+ if (attempt === 0 && debateDecompEnabled) {
71540
+ const decomposeStageConfig = debateStages.decompose;
71541
+ const prompt = buildDecomposePrompt({
71542
+ specContent: "",
71543
+ codebaseContext,
71544
+ workdir,
71545
+ targetStory,
71546
+ siblings,
71547
+ featureName: options.feature,
71548
+ storyId: options.storyId,
71549
+ config: config2
71550
+ });
71551
+ const debateSession = _planDeps.createDebateSession({
71552
+ storyId: options.storyId,
71536
71553
  stage: "decompose",
71537
- storyId: sub.id
71554
+ stageConfig: decomposeStageConfig,
71555
+ config: config2,
71556
+ workdir,
71557
+ featureName: options.feature,
71558
+ timeoutSeconds
71538
71559
  });
71560
+ const debateResult = await debateSession.run(prompt);
71561
+ if (debateResult.outcome !== "failed" && debateResult.output) {
71562
+ decompStories = parseDecomposeOutput(debateResult.output);
71563
+ }
71539
71564
  }
71540
- if (sub.acceptanceCriteria && sub.acceptanceCriteria.length > maxAcCount) {
71541
- throw new NaxError(`Sub-story "${sub.id}" has ${sub.acceptanceCriteria.length} ACs, exceeds maxAcCount of ${maxAcCount}`, "DECOMPOSE_VALIDATION_FAILED", { stage: "decompose", storyId: sub.id });
71565
+ if (!decompStories) {
71566
+ const effectiveContext = repairHint ? `${codebaseContext}
71567
+
71568
+ ${repairHint}` : codebaseContext;
71569
+ const result = await adapter.decompose({
71570
+ specContent: "",
71571
+ codebaseContext: effectiveContext,
71572
+ workdir,
71573
+ targetStory,
71574
+ siblings,
71575
+ featureName: options.feature,
71576
+ storyId: options.storyId,
71577
+ config: config2
71578
+ });
71579
+ decompStories = result.stories;
71580
+ }
71581
+ for (const sub of decompStories) {
71582
+ if (!sub.complexity || !sub.testStrategy) {
71583
+ throw new NaxError(`Sub-story "${sub.id}" is missing required routing fields`, "DECOMPOSE_VALIDATION_FAILED", {
71584
+ stage: "decompose",
71585
+ storyId: sub.id
71586
+ });
71587
+ }
71588
+ }
71589
+ const violations = decompStories.filter((sub) => sub.acceptanceCriteria && sub.acceptanceCriteria.length > maxAcCount);
71590
+ if (violations.length === 0)
71591
+ break;
71592
+ const violationSummary = violations.map((v) => `"${v.id}" (${v.acceptanceCriteria.length} ACs, max ${maxAcCount})`).join(", ");
71593
+ if (attempt + 1 >= maxReplanAttempts) {
71594
+ throw new NaxError(`Decompose AC repair failed after ${maxReplanAttempts} attempts. Oversized sub-stories: ${violationSummary}`, "DECOMPOSE_VALIDATION_FAILED", { stage: "decompose", storyId: options.storyId });
71542
71595
  }
71596
+ repairHint = `REPAIR REQUIRED (attempt ${attempt + 1}/${maxReplanAttempts}): The following sub-stories exceeded maxAcCount of ${maxAcCount}: ${violationSummary}. Split each offending story further so every sub-story has at most ${maxAcCount} acceptance criteria.`;
71597
+ decompStories = undefined;
71543
71598
  }
71544
71599
  const subStoriesWithParent = mapDecomposedStoriesToUserStories(decompStories, options.storyId);
71545
71600
  const updatedStories = prd.userStories.map((s) => s.id === options.storyId ? { ...s, status: "decomposed" } : s);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.57.3",
3
+ "version": "0.57.4",
4
4
  "description": "AI Coding Agent Orchestrator — loops until done",
5
5
  "type": "module",
6
6
  "bin": {