@nathapp/nax 0.77.0 → 0.77.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.
package/dist/nax.js CHANGED
@@ -16865,7 +16865,10 @@ var init_schemas_execution = __esm(() => {
16865
16865
  maxFailureSummaryChars: exports_external.number().int().min(500).max(1e4).default(2000),
16866
16866
  abortOnIncreasingFailures: exports_external.boolean().default(true),
16867
16867
  consecutiveIncreasesToBail: exports_external.number().int().min(1).max(10).default(2),
16868
+ abortOnNoProgress: exports_external.boolean().default(true),
16869
+ consecutiveNoProgressToBail: exports_external.number().int().min(1).max(10).default(3),
16868
16870
  escalateOnExhaustion: exports_external.boolean().optional().default(true),
16871
+ storyScopedFixBudget: exports_external.boolean().default(true),
16869
16872
  rethinkAtAttempt: exports_external.number().int().min(1).default(2),
16870
16873
  urgencyAtAttempt: exports_external.number().int().min(1).default(3)
16871
16874
  });
@@ -17531,7 +17534,10 @@ var init_schemas3 = __esm(() => {
17531
17534
  maxFailureSummaryChars: 2000,
17532
17535
  abortOnIncreasingFailures: true,
17533
17536
  consecutiveIncreasesToBail: 2,
17537
+ abortOnNoProgress: true,
17538
+ consecutiveNoProgressToBail: 3,
17534
17539
  escalateOnExhaustion: true,
17540
+ storyScopedFixBudget: true,
17535
17541
  rethinkAtAttempt: 2,
17536
17542
  urgencyAtAttempt: 3
17537
17543
  },
@@ -43724,9 +43730,63 @@ var init_operations = __esm(() => {
43724
43730
  init_mutation_check();
43725
43731
  });
43726
43732
 
43733
+ // src/findings/cycle-iteration-log.ts
43734
+ function recordIteration(cycle, input, ctx, logger) {
43735
+ const iterationNum = cycle.iterations.length + 1;
43736
+ const findingsBeforeCount = input.findingsBefore.length;
43737
+ const findingsAfterCount = input.findingsAfter.length;
43738
+ const findingKeysBefore = input.findingsBefore.map(findingKey);
43739
+ const findingKeysAfter = input.findingsAfter.map(findingKey);
43740
+ const costUsd = input.fixesApplied.reduce((sum, fa) => sum + (fa.costUsd ?? 0), 0);
43741
+ const seenTargetFiles = new Set;
43742
+ const fixTargetFiles = [];
43743
+ for (const fa of input.fixesApplied) {
43744
+ for (const path6 of fa.targetFiles) {
43745
+ if (seenTargetFiles.has(path6))
43746
+ continue;
43747
+ seenTargetFiles.add(path6);
43748
+ fixTargetFiles.push(path6);
43749
+ }
43750
+ }
43751
+ const fixSummaries = input.fixesApplied.map((fa) => fa.summary);
43752
+ const hasFixes = input.fixesApplied.length > 0;
43753
+ const iteration = {
43754
+ iterationNum,
43755
+ findingsBefore: input.findingsBefore,
43756
+ fixesApplied: input.fixesApplied,
43757
+ findingsAfter: input.findingsAfter,
43758
+ outcome: input.outcome,
43759
+ startedAt: input.startedAt,
43760
+ finishedAt: input.finishedAt,
43761
+ findingKeysBefore,
43762
+ findingKeysAfter,
43763
+ ...hasFixes ? { fixTargetFiles, fixSummaries } : {},
43764
+ ...costUsd > 0 ? { costUsd } : {}
43765
+ };
43766
+ cycle.iterations.push(iteration);
43767
+ logger?.info("findings.cycle", "iteration completed", {
43768
+ storyId: ctx.storyId,
43769
+ packageDir: ctx.packageDir,
43770
+ cycleName: ctx.cycleName,
43771
+ iterationNum,
43772
+ strategiesRan: input.fixesApplied.map((fa) => fa.strategyName),
43773
+ outcome: input.outcome,
43774
+ findingsBefore: findingsBeforeCount,
43775
+ findingsAfter: findingsAfterCount,
43776
+ findingKeysBefore,
43777
+ findingKeysAfter,
43778
+ ...hasFixes ? { fixTargetFiles, fixSummaries } : {},
43779
+ ...costUsd > 0 ? { costUsd } : {}
43780
+ });
43781
+ return iteration;
43782
+ }
43783
+ var init_cycle_iteration_log = __esm(() => {
43784
+ init_types6();
43785
+ });
43786
+
43727
43787
  // src/findings/cycle-retirement.ts
43728
- function createDeclineLedger() {
43729
- const declinedByStrategy = new Map;
43788
+ function createDeclineLedger(backing) {
43789
+ const declinedByStrategy = backing ?? new Map;
43730
43790
  const hasDeclined = (strategyName, finding) => declinedByStrategy.get(strategyName)?.has(findingKey(finding)) === true;
43731
43791
  const isRetiredFor = (strategy, findings) => {
43732
43792
  const claimed = findings.filter((f) => strategy.appliesTo(f));
@@ -43818,13 +43878,14 @@ async function runFixCycle(cycle, ctx, cycleName, _deps = {}) {
43818
43878
  const storyId = ctx.storyId;
43819
43879
  const packageDir = ctx.packageDir;
43820
43880
  let totalCostUsd = 0;
43821
- const declines = createDeclineLedger();
43881
+ const declines = createDeclineLedger(_deps.declineBacking);
43822
43882
  let unresolvedDetail;
43823
43883
  const finish = (result) => unresolvedDetail !== undefined && result.unresolvedDetail === undefined ? { ...result, unresolvedDetail } : result;
43824
43884
  for (;; ) {
43825
43885
  if (cycle.findings.length === 0 && cycle.verdict === undefined) {
43826
43886
  return { iterations: cycle.iterations, finalFindings: [], exitReason: "resolved", costUsd: totalCostUsd };
43827
43887
  }
43888
+ const history = cycle.priorIterations ? [...cycle.priorIterations, ...cycle.iterations] : cycle.iterations;
43828
43889
  const selectable = cycle.strategies.filter((s) => !declines.isRetiredFor(s, cycle.findings));
43829
43890
  const active = selectActiveStrategies(selectable, cycle.findings, cycle.verdict);
43830
43891
  if (active.length === 0) {
@@ -43846,9 +43907,9 @@ async function runFixCycle(cycle, ctx, cycleName, _deps = {}) {
43846
43907
  costUsd: totalCostUsd
43847
43908
  });
43848
43909
  }
43849
- const uncappedActive = active.filter((s) => countStrategyAttempts(cycle.iterations, s.name) < s.maxAttempts);
43910
+ const uncappedActive = active.filter((s) => countStrategyAttempts(history, s.name) < s.maxAttempts);
43850
43911
  if (uncappedActive.length === 0) {
43851
- const exhaustedStrategy = active.find((s) => countStrategyAttempts(cycle.iterations, s.name) >= s.maxAttempts);
43912
+ const exhaustedStrategy = active.find((s) => countStrategyAttempts(history, s.name) >= s.maxAttempts);
43852
43913
  logger?.info("findings.cycle", "cycle exited \u2014 all active strategies exhausted", {
43853
43914
  storyId,
43854
43915
  packageDir,
@@ -43864,7 +43925,7 @@ async function runFixCycle(cycle, ctx, cycleName, _deps = {}) {
43864
43925
  costUsd: totalCostUsd
43865
43926
  });
43866
43927
  }
43867
- const totalAttempts = countTotalAttempts(cycle.iterations);
43928
+ const totalAttempts = countTotalAttempts(history);
43868
43929
  if (totalAttempts >= cycle.config.maxAttemptsTotal) {
43869
43930
  logger?.info("findings.cycle", "cycle exited \u2014 total attempt cap reached", {
43870
43931
  storyId,
@@ -43882,7 +43943,7 @@ async function runFixCycle(cycle, ctx, cycleName, _deps = {}) {
43882
43943
  });
43883
43944
  }
43884
43945
  for (const strategy of uncappedActive) {
43885
- const bailReason = strategy.bailWhen?.(cycle.iterations) ?? null;
43946
+ const bailReason = strategy.bailWhen?.(history) ?? null;
43886
43947
  if (bailReason !== null) {
43887
43948
  logger?.info("findings.cycle", "cycle exited \u2014 bail predicate fired", {
43888
43949
  storyId,
@@ -43935,15 +43996,14 @@ async function runFixCycle(cycle, ctx, cycleName, _deps = {}) {
43935
43996
  const allGaveUp = unresolvedFas.length === fixesApplied.length;
43936
43997
  if (allGaveUp) {
43937
43998
  const finishedAt2 = now();
43938
- cycle.iterations.push({
43939
- iterationNum: cycle.iterations.length + 1,
43999
+ recordIteration(cycle, {
43940
44000
  findingsBefore,
43941
44001
  fixesApplied,
43942
44002
  findingsAfter: cycle.findings,
43943
44003
  outcome: "unchanged",
43944
44004
  startedAt,
43945
44005
  finishedAt: finishedAt2
43946
- });
44006
+ }, { storyId, packageDir, cycleName }, logger);
43947
44007
  totalCostUsd += fixesApplied.reduce((sum, fa) => sum + (fa.costUsd ?? 0), 0);
43948
44008
  logger?.info("findings.cycle", "cycle exited \u2014 agent gave up", {
43949
44009
  storyId,
@@ -43971,7 +44031,7 @@ async function runFixCycle(cycle, ctx, cycleName, _deps = {}) {
43971
44031
  });
43972
44032
  }
43973
44033
  const allExhausted = group.every((s) => {
43974
- const prior = countStrategyAttempts(cycle.iterations, s.name);
44034
+ const prior = countStrategyAttempts(history, s.name);
43975
44035
  const current = fixesApplied.filter((fa) => fa.strategyName === s.name).length;
43976
44036
  return prior + current >= s.maxAttempts;
43977
44037
  });
@@ -43986,15 +44046,14 @@ async function runFixCycle(cycle, ctx, cycleName, _deps = {}) {
43986
44046
  liteShortCircuited = liteResult.shortCircuited ?? false;
43987
44047
  } catch (err) {
43988
44048
  const finishedAt3 = now();
43989
- cycle.iterations.push({
43990
- iterationNum: cycle.iterations.length + 1,
44049
+ recordIteration(cycle, {
43991
44050
  findingsBefore,
43992
44051
  fixesApplied,
43993
44052
  findingsAfter: cycle.findings,
43994
44053
  outcome: "unchanged",
43995
44054
  startedAt,
43996
44055
  finishedAt: finishedAt3
43997
- });
44056
+ }, { storyId, packageDir, cycleName }, logger);
43998
44057
  logger?.warn("findings.cycle", "lite validate failed on terminal exhausted branch", {
43999
44058
  storyId,
44000
44059
  packageDir,
@@ -44011,15 +44070,14 @@ async function runFixCycle(cycle, ctx, cycleName, _deps = {}) {
44011
44070
  }
44012
44071
  const outcome2 = classifyOutcome(findingsBefore, liteFindingsAfter);
44013
44072
  const finishedAt2 = now();
44014
- cycle.iterations.push({
44015
- iterationNum: cycle.iterations.length + 1,
44073
+ recordIteration(cycle, {
44016
44074
  findingsBefore,
44017
44075
  fixesApplied,
44018
44076
  findingsAfter: liteFindingsAfter,
44019
44077
  outcome: outcome2,
44020
44078
  startedAt,
44021
44079
  finishedAt: finishedAt2
44022
- });
44080
+ }, { storyId, packageDir, cycleName }, logger);
44023
44081
  cycle.findings = liteFindingsAfter;
44024
44082
  if (liteFindingsAfter.length === 0 && !liteShortCircuited) {
44025
44083
  logger?.info("findings.cycle", "cycle exited \u2014 resolved after terminal lite validate", {
@@ -44112,31 +44170,17 @@ async function runFixCycle(cycle, ctx, cycleName, _deps = {}) {
44112
44170
  }
44113
44171
  const outcome = classifyOutcome(findingsBefore, findingsAfter);
44114
44172
  const finishedAt = now();
44115
- const iterationNum = cycle.iterations.length + 1;
44116
- const iteration = {
44117
- iterationNum,
44173
+ recordIteration(cycle, {
44118
44174
  findingsBefore,
44119
44175
  fixesApplied,
44120
44176
  findingsAfter,
44121
44177
  outcome,
44122
44178
  startedAt,
44123
44179
  finishedAt
44124
- };
44125
- cycle.iterations.push(iteration);
44180
+ }, { storyId, packageDir, cycleName }, logger);
44126
44181
  cycle.findings = findingsAfter;
44127
44182
  const iterationCostUsd = fixesApplied.reduce((sum, fa) => sum + (fa.costUsd ?? 0), 0);
44128
44183
  totalCostUsd += iterationCostUsd;
44129
- logger?.info("findings.cycle", "iteration completed", {
44130
- storyId,
44131
- packageDir,
44132
- cycleName,
44133
- iterationNum,
44134
- strategiesRan: fixesApplied.map((fa) => fa.strategyName),
44135
- outcome,
44136
- findingsBefore: findingsBefore.length,
44137
- findingsAfter: findingsAfter.length,
44138
- ...iterationCostUsd > 0 ? { costUsd: iterationCostUsd } : {}
44139
- });
44140
44184
  if (outcome === "resolved") {
44141
44185
  return { iterations: cycle.iterations, finalFindings: [], exitReason: "resolved", costUsd: totalCostUsd };
44142
44186
  }
@@ -44146,6 +44190,7 @@ var _cycleDeps;
44146
44190
  var init_cycle = __esm(() => {
44147
44191
  init_logger2();
44148
44192
  init_operations();
44193
+ init_cycle_iteration_log();
44149
44194
  init_cycle_retirement();
44150
44195
  init_types6();
44151
44196
  _cycleDeps = {
@@ -44154,12 +44199,63 @@ var init_cycle = __esm(() => {
44154
44199
  };
44155
44200
  });
44156
44201
 
44202
+ // src/findings/bail-marker.ts
44203
+ function markNaxBailWrapper(predicate) {
44204
+ Object.assign(predicate, { [NAX_BAIL_WRAPPER]: true });
44205
+ return predicate;
44206
+ }
44207
+ function isNaxBailWrapper(predicate) {
44208
+ if (!predicate)
44209
+ return false;
44210
+ return predicate[NAX_BAIL_WRAPPER] === true;
44211
+ }
44212
+ var NAX_BAIL_WRAPPER = "__naxBailWrapper";
44213
+
44214
+ // src/findings/story-fix-history.ts
44215
+ function createStoryFixHistory() {
44216
+ return new Map;
44217
+ }
44218
+ function storyFixKey(storyId, tier) {
44219
+ return `${storyId}::${tier ?? "default"}`;
44220
+ }
44221
+ function getStoryFixState(store, key) {
44222
+ let existing = store.get(key);
44223
+ if (!existing) {
44224
+ existing = { iterations: [], declines: new Map };
44225
+ store.set(key, existing);
44226
+ }
44227
+ return existing;
44228
+ }
44229
+ function appendStoryFixIterations(store, key, iterations) {
44230
+ const existing = store.get(key);
44231
+ if (existing) {
44232
+ store.set(key, {
44233
+ iterations: [...existing.iterations, ...iterations],
44234
+ declines: existing.declines
44235
+ });
44236
+ } else {
44237
+ store.set(key, {
44238
+ iterations: [...iterations],
44239
+ declines: new Map
44240
+ });
44241
+ }
44242
+ }
44243
+ function mergeStoryFixDeclines(store, key, declines) {
44244
+ const existing = store.get(key);
44245
+ store.set(key, {
44246
+ iterations: existing?.iterations ?? [],
44247
+ declines: new Map([...declines].map(([name, keys]) => [name, new Set(keys)]))
44248
+ });
44249
+ }
44250
+
44157
44251
  // src/findings/index.ts
44158
44252
  var init_findings = __esm(() => {
44159
44253
  init_types6();
44160
44254
  init_adapters();
44161
44255
  init_path_utils();
44162
44256
  init_cycle();
44257
+ init_cycle_iteration_log();
44258
+ init_cycle_retirement();
44163
44259
  });
44164
44260
 
44165
44261
  // src/review/review-iteration-store.ts
@@ -44646,7 +44742,7 @@ var package_default;
44646
44742
  var init_package = __esm(() => {
44647
44743
  package_default = {
44648
44744
  name: "@nathapp/nax",
44649
- version: "0.77.0",
44745
+ version: "0.77.2",
44650
44746
  description: "AI Coding Agent Orchestrator \u2014 loops until done",
44651
44747
  type: "module",
44652
44748
  bin: {
@@ -44750,8 +44846,8 @@ var init_version = __esm(() => {
44750
44846
  NAX_VERSION = package_default.version;
44751
44847
  NAX_COMMIT = (() => {
44752
44848
  try {
44753
- if (/^[0-9a-f]{6,10}$/.test("4c943eec"))
44754
- return "4c943eec";
44849
+ if (/^[0-9a-f]{6,10}$/.test("888b55c1"))
44850
+ return "888b55c1";
44755
44851
  } catch {}
44756
44852
  try {
44757
44853
  const result = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"], {
@@ -51873,6 +51969,7 @@ function createRuntime(config2, workdir, opts) {
51873
51969
  const adversarialIterations = new Map;
51874
51970
  const semanticIterations = new Map;
51875
51971
  const rectificationOscillations = new Map;
51972
+ const storyFixHistory = createStoryFixHistory();
51876
51973
  const mutationSummaries = new Map;
51877
51974
  const dirtyWorktrees = new Set;
51878
51975
  let closed = false;
@@ -51899,6 +51996,7 @@ function createRuntime(config2, workdir, opts) {
51899
51996
  adversarialIterations,
51900
51997
  semanticIterations,
51901
51998
  rectificationOscillations,
51999
+ storyFixHistory,
51902
52000
  mutationSummaries,
51903
52001
  dirtyWorktrees,
51904
52002
  get signal() {
@@ -51946,6 +52044,7 @@ var init_runtime = __esm(() => {
51946
52044
  init_config();
51947
52045
  init_errors();
51948
52046
  init_pid_registry();
52047
+ init_findings();
51949
52048
  init_logger2();
51950
52049
  init_review_audit();
51951
52050
  init_session();
@@ -59904,7 +60003,7 @@ var init_completion = __esm(() => {
59904
60003
  const logger = getLogger();
59905
60004
  const isBatch = ctx.stories.length > 1;
59906
60005
  const sessionCost = ctx.runtime.costAggregator.byStory()[ctx.story.id]?.totalCostUsd ?? 0;
59907
- const persistPrd = ctx.skipPrdPersistence !== true;
60006
+ const persistPrd2 = ctx.skipPrdPersistence !== true;
59908
60007
  const prdPath = ctx.prdPath ?? (ctx.featureDir ? `${ctx.featureDir}/prd.json` : `${ctx.workdir}/nax/features/unknown/prd.json`);
59909
60008
  const storyStartTime = ctx.storyStartTime || new Date().toISOString();
59910
60009
  if (isBatch) {
@@ -59929,7 +60028,7 @@ var init_completion = __esm(() => {
59929
60028
  }
59930
60029
  }
59931
60030
  for (const completedStory of ctx.stories) {
59932
- if (persistPrd) {
60031
+ if (persistPrd2) {
59933
60032
  markStoryPassed(ctx.prd, completedStory.id);
59934
60033
  }
59935
60034
  const costPerStory = sessionCost / ctx.stories.length;
@@ -59963,7 +60062,7 @@ var init_completion = __esm(() => {
59963
60062
  }
59964
60063
  }
59965
60064
  }
59966
- if (persistPrd) {
60065
+ if (persistPrd2) {
59967
60066
  await _completionDeps.savePRD(ctx.prd, prdPath);
59968
60067
  }
59969
60068
  logHighMemoryCheckpoint(logger, ctx);
@@ -61235,6 +61334,45 @@ function countOscillationOutcomes(iterations) {
61235
61334
  return count;
61236
61335
  }
61237
61336
 
61337
+ // src/execution/story-orchestrator/no-progress-bail.ts
61338
+ function madeNoProgress(iteration) {
61339
+ if (iteration.findingsBefore.length === 0)
61340
+ return false;
61341
+ const after = new Set(iteration.findingsAfter.map(findingKey));
61342
+ return iteration.findingsBefore.every((finding) => after.has(findingKey(finding)));
61343
+ }
61344
+ function withNoProgressBail(strategies, enabled, consecutiveNoProgress) {
61345
+ if (!enabled)
61346
+ return strategies;
61347
+ const threshold = Math.max(1, consecutiveNoProgress);
61348
+ return strategies.map((strategy) => {
61349
+ const innerBail = strategy.bailWhen;
61350
+ const isUserBail = innerBail !== undefined && !isNaxBailWrapper(innerBail);
61351
+ return {
61352
+ ...strategy,
61353
+ bailWhen: markNaxBailWrapper((iterations) => {
61354
+ if (isUserBail) {
61355
+ const userReason = innerBail(iterations);
61356
+ if (userReason !== null)
61357
+ return userReason;
61358
+ }
61359
+ if (iterations.length >= threshold) {
61360
+ const trailing = iterations.slice(-threshold);
61361
+ if (trailing.every(madeNoProgress)) {
61362
+ return `no finding resolved for ${threshold} consecutive iteration(s); ${trailing.at(-1)?.findingsBefore.length ?? 0} finding(s) persisted`;
61363
+ }
61364
+ }
61365
+ if (!isUserBail && innerBail)
61366
+ return innerBail(iterations);
61367
+ return null;
61368
+ })
61369
+ };
61370
+ });
61371
+ }
61372
+ var init_no_progress_bail = __esm(() => {
61373
+ init_findings();
61374
+ });
61375
+
61238
61376
  // src/execution/checkpoint/resume-plan.ts
61239
61377
  function buildResumePlan(cp, current) {
61240
61378
  if (!cp) {
@@ -61767,7 +61905,7 @@ function withIncreasingFailuresBail(strategies, enabled, consecutiveIncreases) {
61767
61905
  const threshold = Math.max(1, consecutiveIncreases);
61768
61906
  return strategies.map((strategy) => ({
61769
61907
  ...strategy,
61770
- bailWhen: (iterations) => {
61908
+ bailWhen: markNaxBailWrapper((iterations) => {
61771
61909
  const userReason = strategy.bailWhen?.(iterations) ?? null;
61772
61910
  if (userReason !== null)
61773
61911
  return userReason;
@@ -61781,7 +61919,7 @@ function withIncreasingFailuresBail(strategies, enabled, consecutiveIncreases) {
61781
61919
  return `failure count increased for ${threshold} consecutive iteration(s): ${first.findingsBefore.length} -> ${last.findingsAfter.length}`;
61782
61920
  }
61783
61921
  return null;
61784
- }
61922
+ })
61785
61923
  }));
61786
61924
  }
61787
61925
  var _storyOrchestratorDeps, ALL_FINDING_SEVERITIES;
@@ -61947,6 +62085,12 @@ async function runRectification(ctx, state, phaseCosts, phaseOutputs, overrides)
61947
62085
  if (!ctx.storyId) {
61948
62086
  return {};
61949
62087
  }
62088
+ const storyFixBudgetEnabled = !nbfPath && ctx.runtime.configLoader.current().execution?.rectification?.storyScopedFixBudget === true;
62089
+ const store = ctx.runtime.storyFixHistory;
62090
+ const fixKey = storyFixBudgetEnabled ? storyFixKey(ctx.storyId, ctx.phaseTelemetry?.tier) : undefined;
62091
+ const fixState = fixKey !== undefined && store ? getStoryFixState(store, fixKey) : undefined;
62092
+ const priorIterationCount = fixState?.iterations.length ?? 0;
62093
+ const declineSnapshot = fixState ? new Map([...fixState.declines].map(([name, keys]) => [name, new Set(keys)])) : undefined;
61950
62094
  const fixOpPhaseOutputs = {};
61951
62095
  const wrappedCallOp = async (cycleCtx, op, input) => {
61952
62096
  const slot = { op, input };
@@ -61955,7 +62099,8 @@ async function runRectification(ctx, state, phaseCosts, phaseOutputs, overrides)
61955
62099
  const cycle = {
61956
62100
  findings: [...initialFindings],
61957
62101
  iterations: [],
61958
- strategies: withIncreasingFailuresBail(overrides?.strategies ?? rectification2.strategies, rectification2.abortOnIncreasingFailures, rectification2.consecutiveIncreasesToBail ?? 1),
62102
+ priorIterations: fixState?.iterations,
62103
+ strategies: withNoProgressBail(withIncreasingFailuresBail(overrides?.strategies ?? rectification2.strategies, rectification2.abortOnIncreasingFailures, rectification2.consecutiveIncreasesToBail ?? 1), rectification2.abortOnNoProgress ?? true, rectification2.consecutiveNoProgressToBail ?? 3),
61959
62104
  config: { maxAttemptsTotal: overrides?.maxAttempts ?? rectification2.maxAttempts, validatorRetries: 1 },
61960
62105
  validate: async (_validateCtx, opts) => {
61961
62106
  if (ctx.runtime.signal?.aborted)
@@ -62016,15 +62161,22 @@ async function runRectification(ctx, state, phaseCosts, phaseOutputs, overrides)
62016
62161
  return { findings: validated, shortCircuited };
62017
62162
  }
62018
62163
  };
62019
- const cycleResult = await _storyOrchestratorDeps.runFixCycle(cycle, ctx, "story-orchestrator-rectification", { callOp: wrappedCallOp });
62164
+ const cycleResult = await _storyOrchestratorDeps.runFixCycle(cycle, ctx, "story-orchestrator-rectification", { callOp: wrappedCallOp, declineBacking: declineSnapshot });
62165
+ if (fixKey !== undefined && store && cycleResult.iterations.length > 0) {
62166
+ appendStoryFixIterations(store, fixKey, cycleResult.iterations);
62167
+ }
62168
+ if (fixKey !== undefined && store && declineSnapshot) {
62169
+ mergeStoryFixDeclines(store, fixKey, declineSnapshot);
62170
+ }
62020
62171
  const oscillationCount = countOscillationOutcomes(cycleResult.iterations);
62021
62172
  if (oscillationCount > 0) {
62022
62173
  recordOscillations(ctx.runtime.rectificationOscillations, ctx.storyId, oscillationCount);
62023
62174
  }
62175
+ const reportedExitReason = cycleResult.exitReason === "validate-short-circuit" && priorIterationCount > 0 ? "max-attempts-per-strategy" : cycleResult.exitReason;
62024
62176
  phaseOutputs.rectification = {
62025
62177
  success: cycleResult.exitReason === "resolved",
62026
62178
  iterationCount: cycleResult.iterations.length,
62027
- exitReason: cycleResult.exitReason,
62179
+ exitReason: reportedExitReason,
62028
62180
  finalFindingsCount: cycleResult.finalFindings.length
62029
62181
  };
62030
62182
  const rectLogger = getSafeLogger();
@@ -62033,7 +62185,7 @@ async function runRectification(ctx, state, phaseCosts, phaseOutputs, overrides)
62033
62185
  initialFindingsCount: initialFindings.length,
62034
62186
  iterationCount: cycleResult.iterations.length,
62035
62187
  finalFindingsCount: cycleResult.finalFindings.length,
62036
- exitReason: cycleResult.exitReason,
62188
+ exitReason: reportedExitReason,
62037
62189
  costUsd: cycleResult.costUsd
62038
62190
  };
62039
62191
  if (cycleResult.exitReason === "resolved") {
@@ -62059,8 +62211,10 @@ async function runRectification(ctx, state, phaseCosts, phaseOutputs, overrides)
62059
62211
  return {};
62060
62212
  }
62061
62213
  var init_rectification = __esm(() => {
62214
+ init_findings();
62062
62215
  init_logger2();
62063
62216
  init_nbf_flake_triage();
62217
+ init_no_progress_bail();
62064
62218
  init_phase_eval();
62065
62219
  init_phase_eval();
62066
62220
  init_run_phase();
@@ -62414,6 +62568,7 @@ var init_story_orchestrator = __esm(() => {
62414
62568
  init_phase_eval();
62415
62569
  init_rectification();
62416
62570
  init_nbf_flake_triage();
62571
+ init_no_progress_bail();
62417
62572
  init_run_phase();
62418
62573
  init_review_decision();
62419
62574
  init_types9();
@@ -62834,7 +62989,9 @@ async function assemblePlanInputsFromCtx(ctx) {
62834
62989
  maxAttempts: ctx.config.execution.rectification.maxAttemptsTotal,
62835
62990
  strategies: [],
62836
62991
  abortOnIncreasingFailures: ctx.config.execution.rectification.abortOnIncreasingFailures,
62837
- consecutiveIncreasesToBail: ctx.config.execution.rectification.consecutiveIncreasesToBail
62992
+ consecutiveIncreasesToBail: ctx.config.execution.rectification.consecutiveIncreasesToBail,
62993
+ abortOnNoProgress: ctx.config.execution.rectification.abortOnNoProgress,
62994
+ consecutiveNoProgressToBail: ctx.config.execution.rectification.consecutiveNoProgressToBail
62838
62995
  } : undefined;
62839
62996
  const mutationCheckInput = ctx.config.execution?.mutationCheck?.enabled === true ? {
62840
62997
  story,
@@ -76378,6 +76535,7 @@ var init_lifecycle = __esm(() => {
76378
76535
  var exports_execution = {};
76379
76536
  __export(exports_execution, {
76380
76537
  writeExitSummary: () => writeExitSummary,
76538
+ withNoProgressBail: () => withNoProgressBail,
76381
76539
  withIncreasingFailuresBail: () => withIncreasingFailuresBail,
76382
76540
  toReviewDecisionPayload: () => toReviewDecisionPayload,
76383
76541
  synthesizeBackfillMetric: () => synthesizeBackfillMetric,
@@ -106619,8 +106777,12 @@ function buildPlanComposition(userStageConfig) {
106619
106777
 
106620
106778
  // src/plan/strategies/write-prd.ts
106621
106779
  init_errors();
106780
+ init_logger2();
106622
106781
  init_prd();
106623
106782
 
106783
+ // src/plan/strategies/persist-prd.ts
106784
+ init_operations();
106785
+
106624
106786
  // src/plan/strategies/finalize-routing.ts
106625
106787
  init_agents();
106626
106788
  function finalizePrdRouting(prd, agentRouting, profileName) {
@@ -106642,6 +106804,26 @@ function finalizePrdRouting(prd, agentRouting, profileName) {
106642
106804
  return { ...prd, userStories, routingProfile: profileName ?? "default" };
106643
106805
  }
106644
106806
 
106807
+ // src/plan/strategies/persist-prd.ts
106808
+ async function finalizeAndWritePrd(args) {
106809
+ const repaired = applyPlanFidelity(args.prd, args.specContent, args.featureName);
106810
+ const finalized = finalizePrdRouting({ ...repaired, project: args.projectName }, args.agentRouting, args.profileName);
106811
+ await args.writeFile(args.outputPath, JSON.stringify(finalized, null, 2));
106812
+ return args.outputPath;
106813
+ }
106814
+ async function persistPrd(ctx, prd) {
106815
+ return finalizeAndWritePrd({
106816
+ prd,
106817
+ specContent: ctx.specContent,
106818
+ featureName: ctx.options.feature,
106819
+ projectName: ctx.projectName,
106820
+ agentRouting: ctx.config.routing?.agents,
106821
+ profileName: ctx.profileName,
106822
+ outputPath: ctx.outputPath,
106823
+ writeFile: ctx.deps.writeFile
106824
+ });
106825
+ }
106826
+
106645
106827
  // src/plan/strategies/write-prd.ts
106646
106828
  async function writeOrRecoverPrd(ctx, prd, err) {
106647
106829
  const tryExtractPrd = (value) => {
@@ -106661,15 +106843,11 @@ async function writeOrRecoverPrd(ctx, prd, err) {
106661
106843
  };
106662
106844
  if (prd !== null) {
106663
106845
  if (Array.isArray(prd.userStories)) {
106664
- const finalized = finalizePrdRouting({ ...prd, project: ctx.projectName }, ctx.config.routing?.agents, ctx.profileName);
106665
- await ctx.deps.writeFile(ctx.outputPath, JSON.stringify(finalized, null, 2));
106666
- return ctx.outputPath;
106846
+ return { outputPath: await persistPrd(ctx, prd) };
106667
106847
  }
106668
106848
  const normalizedPrd = tryExtractPrd(prd);
106669
106849
  if (normalizedPrd !== null) {
106670
- const finalized = finalizePrdRouting({ ...normalizedPrd, project: ctx.projectName }, ctx.config.routing?.agents, ctx.profileName);
106671
- await ctx.deps.writeFile(ctx.outputPath, JSON.stringify(finalized, null, 2));
106672
- return ctx.outputPath;
106850
+ return { outputPath: await persistPrd(ctx, normalizedPrd) };
106673
106851
  }
106674
106852
  }
106675
106853
  if (err === undefined) {
@@ -106688,9 +106866,13 @@ async function writeOrRecoverPrd(ctx, prd, err) {
106688
106866
  }
106689
106867
  }
106690
106868
  recoveredPrd = recoveredPrd ?? validatePlanOutput(rawContent, ctx.options.feature, ctx.branchName);
106691
- const finalized = finalizePrdRouting({ ...recoveredPrd, project: ctx.projectName }, ctx.config.routing?.agents, ctx.profileName);
106692
- await ctx.deps.writeFile(ctx.outputPath, JSON.stringify(finalized, null, 2));
106693
- return ctx.outputPath;
106869
+ const reason = err instanceof Error ? err.message : String(err);
106870
+ getSafeLogger()?.warn("plan", "PRD recovered from disk after a plan failure \u2014 result is degraded", {
106871
+ featureName: ctx.options.feature,
106872
+ outputPath: ctx.outputPath,
106873
+ error: reason
106874
+ });
106875
+ return { outputPath: await persistPrd(ctx, recoveredPrd), degraded: { reason } };
106694
106876
  } catch {
106695
106877
  throw err;
106696
106878
  }
@@ -106747,9 +106929,7 @@ class DebatePlanStrategy {
106747
106929
  });
106748
106930
  if (debateResult.outcome !== "failed" && debateResult.output) {
106749
106931
  const prd2 = validatePlanOutput(debateResult.output, ctx.options.feature, ctx.branchName);
106750
- const scoped2 = applyPlanFidelity(prd2, ctx.specContent, ctx.options.feature);
106751
- const withProject2 = { ...scoped2, project: ctx.projectName };
106752
- return _debatePlanDeps.writeOrRecoverPrd(ctx, withProject2);
106932
+ return _debatePlanDeps.writeOrRecoverPrd(ctx, prd2);
106753
106933
  }
106754
106934
  const prd = await callOp({
106755
106935
  ...callCtx,
@@ -106766,8 +106946,7 @@ class DebatePlanStrategy {
106766
106946
  projectProfile: ctx.config.project
106767
106947
  });
106768
106948
  assertIsValidPrd(prd);
106769
- const withProject = { ...prd, project: ctx.projectName };
106770
- return _debatePlanDeps.writeOrRecoverPrd(ctx, withProject);
106949
+ return _debatePlanDeps.writeOrRecoverPrd(ctx, prd);
106771
106950
  } catch (err) {
106772
106951
  return _debatePlanDeps.writeOrRecoverPrd(ctx, null, err);
106773
106952
  } finally {
@@ -106846,10 +107025,7 @@ class PipelinePlanStrategy {
106846
107025
  if (verdict.outcome !== "passed") {
106847
107026
  throw new NaxError(verdict.specDeltasPath ? `Plan pipeline failed; see ${verdict.specDeltasPath}` : "Plan pipeline failed with no spec-deltas path", "PLAN_CRITIC_BLOCKED", { stage: "plan", specDeltasPath: verdict.specDeltasPath });
106848
107027
  }
106849
- const scoped2 = applyPlanFidelity(verdict.prd, ctx.specContent, ctx.options.feature);
106850
- const prdToWrite = finalizePrdRouting({ ...scoped2, project: ctx.projectName }, ctx.config.routing?.agents, ctx.profileName);
106851
- await ctx.deps.writeFile(ctx.outputPath, JSON.stringify(prdToWrite, null, 2));
106852
- return ctx.outputPath;
107028
+ return { outputPath: await persistPrd(ctx, verdict.prd) };
106853
107029
  } finally {
106854
107030
  await ctx.runtime.close().catch(() => {});
106855
107031
  }
@@ -106898,6 +107074,7 @@ class RefinePlanStrategy {
106898
107074
  }
106899
107075
 
106900
107076
  // src/plan/strategies/single.ts
107077
+ init_logger2();
106901
107078
  init_operations();
106902
107079
  init_prd();
106903
107080
  var _singlePlanDeps = {
@@ -106929,16 +107106,18 @@ class SinglePlanStrategy {
106929
107106
  projectProfile: ctx.config.project
106930
107107
  });
106931
107108
  assertIsValidPrd(prd);
106932
- const finalized = finalizePrdRouting({ ...prd, project: ctx.projectName }, ctx.config.routing?.agents, ctx.profileName);
106933
- await ctx.deps.writeFile(ctx.outputPath, JSON.stringify(finalized, null, 2));
106934
- return ctx.outputPath;
107109
+ return { outputPath: await persistPrd(ctx, prd) };
106935
107110
  } catch (err) {
106936
107111
  if (ctx.deps.existsSync(ctx.outputPath)) {
106937
107112
  const rawContent = await ctx.deps.readFile(ctx.outputPath);
106938
107113
  const recoveredPrd = validatePlanOutput(rawContent, ctx.options.feature, ctx.branchName);
106939
- const finalizedRecovered = finalizePrdRouting({ ...recoveredPrd, project: ctx.projectName }, ctx.config.routing?.agents, ctx.profileName);
106940
- await ctx.deps.writeFile(ctx.outputPath, JSON.stringify(finalizedRecovered, null, 2));
106941
- return ctx.outputPath;
107114
+ const reason = err instanceof Error ? err.message : String(err);
107115
+ getSafeLogger()?.warn("plan", "PRD recovered from disk after a plan failure \u2014 result is degraded", {
107116
+ featureName: ctx.options.feature,
107117
+ outputPath: ctx.outputPath,
107118
+ error: reason
107119
+ });
107120
+ return { outputPath: await persistPrd(ctx, recoveredPrd), degraded: { reason } };
106942
107121
  }
106943
107122
  throw err;
106944
107123
  } finally {
@@ -107547,6 +107726,8 @@ var FIELD_DESCRIPTIONS = {
107547
107726
  "execution.rectification.maxFailureSummaryChars": "Max characters in failure summary",
107548
107727
  "execution.rectification.abortOnIncreasingFailures": "Abort if failure count increases",
107549
107728
  "execution.rectification.consecutiveIncreasesToBail": "Consecutive regressing iterations required before abortOnIncreasingFailures bails (default: 2; 1 = legacy behaviour)",
107729
+ "execution.rectification.abortOnNoProgress": "Abort rectification when no progress is made for several consecutive iterations (default: true)",
107730
+ "execution.rectification.consecutiveNoProgressToBail": "Consecutive no-progress iterations required before abortOnNoProgress bails (default: 3; one higher than the count bail's 2 because the no-progress predicate fires on a wider shape)",
107550
107731
  "execution.rectification.escalateOnExhaustion": "Enable model tier escalation when attempts are exhausted with remaining failures",
107551
107732
  "execution.rectification.rethinkAtAttempt": "Attempt number at which 'rethink your approach' language is injected into the prompt (default: 2)",
107552
107733
  "execution.rectification.urgencyAtAttempt": "Attempt number at which 'final chance before escalation' urgency is added (default: 3)",
@@ -118060,6 +118241,14 @@ program2.name("nax").description("AI Coding Agent Orchestrator \u2014 loops unti
118060
118241
  function collectProfile(value, previous) {
118061
118242
  return previous.concat(value);
118062
118243
  }
118244
+ function warnIfPlanDegraded(result2) {
118245
+ if (!result2.degraded)
118246
+ return;
118247
+ console.log(source_default.yellow(`
118248
+ [WARN] PRD recovered after a plan failure \u2014 this is a degraded result`));
118249
+ console.log(source_default.dim(` Cause: ${result2.degraded.reason}`));
118250
+ console.log(source_default.dim(" Deterministic spec->PRD repairs were re-applied, but review the PRD before running."));
118251
+ }
118063
118252
  async function promptForConfirmation(question) {
118064
118253
  if (!process.stdin.isTTY) {
118065
118254
  return true;
@@ -118381,12 +118570,14 @@ program2.command("run").description("Run the orchestration loop for a feature").
118381
118570
  initLogger({ level: "info", filePath: planLogPath, useChalk: false, headless: true });
118382
118571
  console.log(source_default.dim(` [Plan log: ${planLogPath}]`));
118383
118572
  console.log(source_default.dim(" [Planning phase: generating PRD from spec]"));
118384
- const generatedPrdPath = await planCommand(workdir, config2, {
118573
+ const planResult = await planCommand(workdir, config2, {
118385
118574
  from: options.from,
118386
118575
  feature: options.feature,
118387
118576
  auto: options.oneShot ?? false,
118388
118577
  branch: undefined
118389
118578
  });
118579
+ const generatedPrdPath = planResult.outputPath;
118580
+ warnIfPlanDegraded(planResult);
118390
118581
  const generatedPrd = await loadPRD(generatedPrdPath);
118391
118582
  await runReplanLoop(workdir, config2, {
118392
118583
  feature: options.feature,
@@ -118805,15 +118996,16 @@ Use: nax plan -f <feature> --from <spec>`));
118805
118996
  console.error(source_default.red("Error: --from <spec-path> is required unless --decompose is used"));
118806
118997
  process.exit(1);
118807
118998
  }
118808
- const prdPath = await planCommand(workdir, config2, {
118999
+ const planResult = await planCommand(workdir, config2, {
118809
119000
  from: options.from,
118810
119001
  feature: options.feature,
118811
119002
  auto: options.auto || options.oneShot,
118812
119003
  branch: options.branch
118813
119004
  });
119005
+ warnIfPlanDegraded(planResult);
118814
119006
  console.log(source_default.green(`
118815
119007
  [OK] PRD generated`));
118816
- console.log(source_default.dim(` PRD: ${prdPath}`));
119008
+ console.log(source_default.dim(` PRD: ${planResult.outputPath}`));
118817
119009
  console.log(source_default.dim(` Log: ${planLogPath}`));
118818
119010
  console.log(source_default.dim(`
118819
119011
  Next: nax run -f ${options.feature}`));
@@ -54,10 +54,28 @@ export function loadCtxOf(ctx: OutputsCtx): LoadCtxOutput {
54
54
  * Absent when the node was skipped by config, died, or produced only
55
55
  * whitespace — `amend_body` treats all three identically, so there is one
56
56
  * branch downstream rather than three.
57
+ *
58
+ * Accepts the bare string the node used to return as well as the
59
+ * `{ narrative, title }` it returns now: a flow resumed from a run recorded
60
+ * before the title landed replays the old shape from its journal.
57
61
  */
58
62
  export function narrativeOf(ctx: OutputsCtx): string | undefined {
59
63
  const out = (ctx.outputs as Record<string, unknown>).narrative;
60
- return typeof out === "string" && out.trim().length > 0 ? out : undefined;
64
+ const prose = typeof out === "string" ? out : (out as { narrative?: unknown } | undefined)?.narrative;
65
+ return typeof prose === "string" && prose.trim().length > 0 ? prose : undefined;
66
+ }
67
+
68
+ /**
69
+ * The narrative node's parsed PR title, already sanitised by `parseTitle`.
70
+ *
71
+ * Absent whenever the node is — `resolveTitle` then falls back to
72
+ * `feat: <feature>`, which is what shipped before and what auto-PR opens with.
73
+ */
74
+ export function prTitleOf(ctx: OutputsCtx): string | undefined {
75
+ const out = (ctx.outputs as Record<string, unknown>).narrative;
76
+ if (typeof out !== "object" || out === null) return undefined;
77
+ const title = (out as { title?: unknown }).title;
78
+ return typeof title === "string" && title.trim().length > 0 ? title : undefined;
61
79
  }
62
80
 
63
81
  export function gateOutputs(ctx: OutputsCtx): { failing?: string[]; ran?: string[] } {
@@ -12,11 +12,41 @@
12
12
  * test can reach it.
13
13
  */
14
14
 
15
+ import { TITLE_CLOSE_TAG, TITLE_MAX_CHARS, TITLE_OPEN_TAG, parseTitle } from "./pr-title";
16
+
15
17
  /** Longest narrative rendered into a PR body, in characters, including the ellipsis. */
16
18
  export const NARRATIVE_MAX_CHARS = 4000;
17
19
 
18
20
  const TRUNCATION_SUFFIX = "…";
19
21
 
22
+ /**
23
+ * Sentinel wrapping the prose, so `parseNarrative` has an explicit anchor
24
+ * rather than an inferred one.
25
+ *
26
+ * acpx hands `parse` the concatenation of *every* agent message chunk in the
27
+ * turn — `chunks.join("")` in its `createQuietCaptureOutput`. This node reads
28
+ * the diff with tools, so the agent's between-tool-call narration ("Now I have
29
+ * a clear picture. Let me check…") is structurally part of that string. A
30
+ * prompt asking for "no preamble" cannot prevent it; only a delimiter can.
31
+ *
32
+ * A sentinel rather than the JSON contract the sibling nodes use: this payload
33
+ * is multi-paragraph prose about code, full of backticks, quotes and newlines.
34
+ * Literal newlines inside a JSON string are invalid JSON, so a JSON contract
35
+ * would fail on exactly the inputs this node exists to carry.
36
+ */
37
+ const OPEN_TAG = "<narrative>";
38
+ const CLOSE_TAG = "</narrative>";
39
+
40
+ /**
41
+ * Headings the agent emits despite being told not to. Anchors the fallback
42
+ * strip when the sentinel is absent: everything up to and including the
43
+ * heading is preamble.
44
+ */
45
+ const HEADING_RE = /^[\s\S]*?(?:\*\*What changed\*\*|##+\s*What changed)\s*/i;
46
+
47
+ /** A `<title>…</title>` block, closed or not — removed wholesale from the prose. */
48
+ const TITLE_BLOCK_RE = new RegExp(`${TITLE_OPEN_TAG}[\\s\\S]*?(?:${TITLE_CLOSE_TAG}|$)`, "gi");
49
+
20
50
  /** Headings a spec uses for its lead paragraph, in priority order. */
21
51
  const SUMMARY_HEADINGS = ["summary", "overview"] as const;
22
52
 
@@ -57,7 +87,18 @@ export function buildNarrativePrompt(args: { base: string }): string {
57
87
  "anything a reviewer would otherwise have to reconstruct from the diff by hand.",
58
88
  `Hard limit: ${NARRATIVE_MAX_CHARS} characters.`,
59
89
  "Do not write a heading — the heading is added for you.",
60
- "Return the prose only. No JSON, no code fences, no preamble.",
90
+ "",
91
+ "Then write the pull request title: a conventional-commit subject describing",
92
+ `the change (\`fix: …\`, \`feat: …\`, \`refactor: …\`), at most ${TITLE_MAX_CHARS} characters.`,
93
+ "Describe what the change does — not the feature's name, which the reader",
94
+ "can already see on the branch.",
95
+ "",
96
+ "Reply with exactly these two blocks, and write nothing after the last one:",
97
+ `${TITLE_OPEN_TAG}conventional-commit subject${TITLE_CLOSE_TAG}`,
98
+ `${OPEN_TAG}the prose${CLOSE_TAG}`,
99
+ "",
100
+ "Everything outside those tags is discarded, so anything you say while working",
101
+ "through the diff is safe to leave where it falls.",
61
102
  ].join("\n");
62
103
  }
63
104
 
@@ -66,10 +107,51 @@ export function buildNarrativePrompt(args: { base: string }): string {
66
107
  *
67
108
  * Never throws. A throw inside `parse` fails the node, and acpx has no error
68
109
  * edge — see `verdict.ts`. Here that would mean the flow dying *after* the PR
69
- * was already opened.
110
+ * was already opened, so every branch below degrades instead of rejecting.
111
+ *
112
+ * Three tiers, strongest anchor first:
113
+ * 1. Sentinel — the contract the prompt asks for.
114
+ * 2. Heading — the agent ignored the sentinel but still wrote
115
+ * `**What changed**`, which marks where its preamble stopped.
116
+ * 3. Bare trim — no anchor available; better a narrative with preamble than
117
+ * no narrative at all.
70
118
  */
71
119
  export function parseNarrative(text: string): string {
72
- return typeof text === "string" ? text.trim() : "";
120
+ if (typeof text !== "string") return "";
121
+
122
+ // Last opening tag, not the first: if the agent narrates the tag before
123
+ // emitting it for real ("I'll wrap this in <narrative>"), the real one wins.
124
+ const open = text.lastIndexOf(OPEN_TAG);
125
+ if (open !== -1) {
126
+ const from = open + OPEN_TAG.length;
127
+ const close = text.indexOf(CLOSE_TAG, from);
128
+ const inner = (close === -1 ? text.slice(from) : text.slice(from, close)).trim();
129
+ if (inner) return inner;
130
+ }
131
+
132
+ // Strip tag markers before the heading pass: an empty or malformed sentinel
133
+ // falls through to here, and leftover `<narrative>` markup in a PR body is
134
+ // worse than the preamble this function exists to remove. The title block
135
+ // goes entirely — tags and content — since it is not part of the prose.
136
+ const untagged = text.replace(TITLE_BLOCK_RE, "").split(OPEN_TAG).join("").split(CLOSE_TAG).join("");
137
+ return untagged.replace(HEADING_RE, "").trim();
138
+ }
139
+
140
+ /** What the `narrative` acp node returns: the prose, and the title to rename the PR to. */
141
+ export interface NarrativeNodeResult {
142
+ narrative: string;
143
+ title?: string;
144
+ }
145
+
146
+ /**
147
+ * `parse` for the narrative acp node.
148
+ *
149
+ * Both halves are optional to the flow: a missing title leaves the PR on
150
+ * `feat: <feature>`, and missing prose leaves the body's mechanical sections
151
+ * alone. Never throws, for the reason `parseNarrative` documents.
152
+ */
153
+ export function parseNarrativeNode(text: string): NarrativeNodeResult {
154
+ return { narrative: parseNarrative(text), title: parseTitle(text) };
73
155
  }
74
156
 
75
157
  function truncate(text: string): string {
@@ -44,7 +44,7 @@
44
44
  import { defineFlow } from "acpx/flows";
45
45
  import { buildFixCommitMessage } from "./commit-message";
46
46
  import { findingsOf, fixAttemptCount, gateOutputs, incrementalSince, inputOf, loadCtxOf } from "./flow-ctx";
47
- import { narrativePrompt, parseNarrative } from "./narrative";
47
+ import { narrativePrompt, parseNarrativeNode } from "./narrative";
48
48
  import { buildReviewPrompt, fixPrompt } from "./review-prompts";
49
49
  import {
50
50
  _contextDeps,
@@ -449,7 +449,7 @@ export default defineFlow({
449
449
  session: { isolated: true },
450
450
  profile: process.env.NAX_FINISH_NARRATIVE_PROFILE || undefined,
451
451
  prompt: narrativePrompt,
452
- parse: parseNarrative,
452
+ parse: parseNarrativeNode,
453
453
  },
454
454
  amend_body: {
455
455
  nodeType: "action",
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The PR title — sentinel, sanitiser, and the fallback chain.
3
+ *
4
+ * `buildFinishTitle` used to return `feat: <feature>` unconditionally, so every
5
+ * finish-opened PR was titled with its feature slug: `feat: schema-drift-gate`
6
+ * describes the run, not the change. The narrative node has already read the
7
+ * whole diff by the time the body is amended, so a real conventional-commit
8
+ * subject costs one extra sentinel in a prompt that was being sent anyway.
9
+ *
10
+ * No deterministic source can replace it. The spec's H1 is the slug in prose
11
+ * (`# SPEC: Schema drift gate`), the PRD carries no feature-level title, and
12
+ * concatenating story titles reads worse than the slug it replaces — which is
13
+ * why this is the one part of the PR metadata that is model-derived, and why
14
+ * everything below assumes the model may return junk.
15
+ *
16
+ * Lives beside `narrative.ts` rather than in `src/prompts/builders/` for the
17
+ * same reason that file gives: `flows/` runs in acpx's Node process and imports
18
+ * nothing from `src/`.
19
+ */
20
+
21
+ /** Sentinel wrapping the title. See `narrative.ts` for why a delimiter is required at all. */
22
+ export const TITLE_OPEN_TAG = "<title>";
23
+ export const TITLE_CLOSE_TAG = "</title>";
24
+
25
+ /**
26
+ * Longest title rendered onto a PR.
27
+ *
28
+ * 72 is the conventional-commit subject norm, and GitHub truncates around this
29
+ * width in list views.
30
+ */
31
+ export const TITLE_MAX_CHARS = 72;
32
+
33
+ /**
34
+ * Conventional-commit prefix, split into the type-with-scope and the subject.
35
+ *
36
+ * Types mirror the list in `.claude/rules/project-conventions.md`, plus
37
+ * `revert`. Captured rather than merely tested so the two halves can be
38
+ * rejoined with exactly one space — `feat:no space` and `feat:` both reach
39
+ * here, and testing alone let the latter become `feat: feat:`.
40
+ *
41
+ * A title arriving without any prefix is prefixed rather than rejected: the
42
+ * prose is usually right even when the model forgets the ceremony.
43
+ */
44
+ const CONVENTIONAL_PREFIX_RE =
45
+ /^((?:feat|fix|refactor|perf|docs|test|chore|ci|build|style|revert)(?:\([^)]*\))?!?):\s*([\s\S]*)$/i;
46
+
47
+ const DEFAULT_TYPE = "feat";
48
+
49
+ /** Wrapping quotes/backticks the model adds when it treats the title as a quoted string. */
50
+ const WRAPPING_CHARS = new Set(['"', "'", "`", "*", "_"]);
51
+
52
+ function stripWrapping(text: string): string {
53
+ let out = text;
54
+ // Loop: models nest these ("`fix: thing`" arrives quoted *and* fenced).
55
+ while (out.length >= 2) {
56
+ const first = out[0];
57
+ const last = out[out.length - 1];
58
+ if (first !== undefined && first === last && WRAPPING_CHARS.has(first)) {
59
+ out = out.slice(1, -1).trim();
60
+ continue;
61
+ }
62
+ break;
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * Cut to `TITLE_MAX_CHARS` on a word boundary where one is available.
69
+ *
70
+ * A mid-word cut reads as corruption rather than brevity; falling back to a
71
+ * hard slice only matters for a title with no spaces at all.
72
+ */
73
+ function clamp(text: string): string {
74
+ if (text.length <= TITLE_MAX_CHARS) return text;
75
+ const cut = text.slice(0, TITLE_MAX_CHARS);
76
+ const lastSpace = cut.lastIndexOf(" ");
77
+ // Guard against a long type prefix eating the whole budget: only honour a
78
+ // word boundary that leaves a meaningful subject behind.
79
+ const MIN_KEEP = 20;
80
+ return (lastSpace >= MIN_KEEP ? cut.slice(0, lastSpace) : cut).trimEnd();
81
+ }
82
+
83
+ /**
84
+ * Normalise a model-supplied title, or `undefined` if nothing usable survives.
85
+ *
86
+ * Never throws — this feeds `parse` on an acp node, and the flow's PR is
87
+ * already open by the time it runs.
88
+ */
89
+ export function sanitizeTitle(raw: string | undefined): string | undefined {
90
+ if (typeof raw !== "string") return undefined;
91
+
92
+ // First non-empty line: a title is single-line by definition, and a model
93
+ // that adds a rationale below it must not push that onto the PR.
94
+ const firstLine = raw.split(/\r?\n/).find((line) => line.trim().length > 0);
95
+ if (firstLine === undefined) return undefined;
96
+
97
+ // Collapse internal runs of whitespace before measuring, so the length cap
98
+ // reflects what a reader sees.
99
+ let title = stripWrapping(firstLine.trim()).replace(/\s+/g, " ");
100
+ // Markdown heading marks, for a model that answers the "write a title" ask
101
+ // with a heading.
102
+ title = title.replace(/^#+\s*/, "").trim();
103
+ title = stripWrapping(title);
104
+ // Trailing sentence punctuation — conventional-commit subjects carry none.
105
+ title = title.replace(/[.\s]+$/, "");
106
+ if (!title) return undefined;
107
+
108
+ const match = CONVENTIONAL_PREFIX_RE.exec(title);
109
+ const type = match?.[1] ?? DEFAULT_TYPE;
110
+ const subject = (match?.[2] ?? title).trim();
111
+ // A bare `feat:` carries no subject, and a type alone is not a title.
112
+ if (!subject) return undefined;
113
+
114
+ return clamp(`${type}: ${subject}`);
115
+ }
116
+
117
+ /**
118
+ * Extract the title from the narrative node's reply.
119
+ *
120
+ * Last opening tag wins, mirroring `parseNarrative` — a model that narrates the
121
+ * tag before emitting it must not beat the real one.
122
+ */
123
+ export function parseTitle(text: string): string | undefined {
124
+ if (typeof text !== "string") return undefined;
125
+ const open = text.lastIndexOf(TITLE_OPEN_TAG);
126
+ if (open === -1) return undefined;
127
+ const from = open + TITLE_OPEN_TAG.length;
128
+ const close = text.indexOf(TITLE_CLOSE_TAG, from);
129
+ return sanitizeTitle(close === -1 ? text.slice(from) : text.slice(from, close));
130
+ }
131
+
132
+ /**
133
+ * The title to render, best source first.
134
+ *
135
+ * `feat: <feature>` remains the floor: it is what shipped before, it is what
136
+ * the auto-PR plugin opens with, and it is always available.
137
+ */
138
+ export function resolveTitle(agentTitle: string | undefined, feature: string): string {
139
+ return sanitizeTitle(agentTitle) ?? `${DEFAULT_TYPE}: ${feature}`;
140
+ }
@@ -5,11 +5,14 @@
5
5
  * The finish flow opens a PR via `openOrPromotePr` and used to ship a
6
6
  * hardcoded `nax-finish: <feature>` title and a one-sentence body, throwing
7
7
  * away every artifact the run produced on the way. This module restores that
8
- * context as a deterministic markdown body the title matches
9
- * `src/plugins/builtin/auto-pr/pr-body.ts:buildTitle`, and the body is
10
- * assembled by string joins over the fields in `FinishPrContext`. No model
11
- * call: every section is reproducible from artifacts that exist before
12
- * `open_pr` runs, and so the body stays greppable in PR history.
8
+ * context as a deterministic markdown body, assembled by string joins over the
9
+ * fields in `FinishPrContext`. Every *section* is reproducible from artifacts
10
+ * that exist before `open_pr` runs, so the body stays greppable in PR history.
11
+ *
12
+ * Two fields are the exception, and both arrive later, from the narrative node
13
+ * that runs after the PR is already open: `narrative` and `title`. Each has a
14
+ * deterministic fallback (`resolveNarrative`, `resolveTitle`) so `open_pr` never
15
+ * waits on a model — see `steps/pr-narrative.ts`.
13
16
  *
14
17
  * Reimplemented here (rather than imported from `src/`) because `flows/`
15
18
  * ships to a different runtime — `acpx flow run` runs it in acpx's own Node
@@ -20,6 +23,7 @@ import { dirname, isAbsolute, join } from "node:path";
20
23
  import { runArgv } from "../exec";
21
24
  import { readSpecSummary, resolveNarrative } from "../narrative";
22
25
  import { findPrTemplate } from "../pr-template";
26
+ import { resolveTitle } from "../pr-title";
23
27
  import type { Finding, FinishInput, FinishRound, RunFn } from "../types";
24
28
  import type { Forge } from "./forge";
25
29
  import { readRounds } from "./result";
@@ -46,10 +50,20 @@ export interface FinishPrContext {
46
50
  regression?: string;
47
51
  gatesRan: string[];
48
52
  diffstat?: string;
53
+ /**
54
+ * `--shortstat` for the nax artifacts held out of `diffstat`. Absent when the
55
+ * branch touched none, so a repo that gitignores them renders nothing.
56
+ */
57
+ artifactSummary?: string;
49
58
  /** Repository PR/MR template, verbatim. Absent when none resolves. */
50
59
  template?: string;
51
60
  /** Resolved "What changed" prose. Absent when neither source produced text. */
52
61
  narrative?: string;
62
+ /**
63
+ * Resolved conventional-commit PR title. Always set — `resolveTitle` falls
64
+ * back to `feat: <feature>` when the narrative node produced nothing usable.
65
+ */
66
+ title: string;
53
67
  rounds: FinishRound[];
54
68
  run: {
55
69
  durationMs?: number;
@@ -121,7 +135,43 @@ function storiesFrom(prd: PrdArtifact | undefined): FinishPrStory[] {
121
135
  }
122
136
 
123
137
  /**
124
- * Run `git diff --stat <base>...HEAD` and return its stdout on success.
138
+ * Pathspec matching nax's own run artifacts, at any depth.
139
+ *
140
+ * `**` and the `glob` magic word are both load-bearing. nax writes artifacts
141
+ * to a repo-root `.nax/` *and* to a per-package `<pkg>/.nax/` — a root-anchored
142
+ * `:!.nax/**` silently keeps the per-package copy, which is routinely the
143
+ * largest file in the diff (587 of 2039 insertions on the run that motivated
144
+ * this). Without `glob`, git's default wildmatch lets `*` cross `/` and the
145
+ * two forms stop being distinguishable.
146
+ */
147
+ const NAX_ARTIFACT_PATHSPEC = "**/.nax/**";
148
+
149
+ /** The two halves of the branch's diff: what is under review, and what was held out. */
150
+ interface DiffstatResult {
151
+ diffstat?: string;
152
+ artifactSummary?: string;
153
+ }
154
+
155
+ /** Run `git diff <...args>` under `workdir`, or `undefined` on any non-happy path. */
156
+ async function runGitDiff(workdir: string, args: string[]): Promise<string | undefined> {
157
+ try {
158
+ const res = await _prBodyDeps.run(["git", "diff", ...args], { cwd: workdir });
159
+ if (res.exitCode !== 0) return undefined;
160
+ return res.stdout;
161
+ } catch {
162
+ return undefined;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Diffstat of the branch, excluding nax's own artifacts.
168
+ *
169
+ * The artifacts (`spec.md`, `prd.json`, the generated acceptance test) are
170
+ * committed and real, but they are the run's exhaust rather than the change
171
+ * under review, and they dominate the totals — quoting them in the headline
172
+ * advertises a 2039-line change where 791 lines are reviewable code.
173
+ * `artifactSummary` keeps them accounted for rather than silently dropped, so
174
+ * the body still reconciles against `gh pr diff`.
125
175
  *
126
176
  * Fail-open on every non-happy path — a non-zero exit (no commits, divergent
127
177
  * branch, base missing), a rejected run promise (forks too slow to start), or
@@ -129,18 +179,18 @@ function storiesFrom(prd: PrdArtifact | undefined): FinishPrStory[] {
129
179
  * optional, and a routine empty-branch finish must not lose `open_pr` to a
130
180
  * throw that the body can simply skip.
131
181
  */
132
- async function runDiffstat(workdir: string, base: string): Promise<string | undefined> {
182
+ async function runDiffstat(workdir: string, base: string): Promise<DiffstatResult> {
133
183
  // An empty `base` would interpolate to `...HEAD`, which git resolves as
134
184
  // `HEAD...HEAD` — exit 0, empty stdout — masking the missing-base case as
135
185
  // "no changes" instead of skipping explicitly.
136
- if (!base) return undefined;
137
- try {
138
- const res = await _prBodyDeps.run(["git", "diff", "--stat", `${base}...HEAD`], { cwd: workdir });
139
- if (res.exitCode !== 0) return undefined;
140
- return res.stdout;
141
- } catch {
142
- return undefined;
143
- }
186
+ if (!base) return {};
187
+ const range = `${base}...HEAD`;
188
+ const [diffstat, artifacts] = await Promise.all([
189
+ runGitDiff(workdir, ["--stat", range, "--", `:(glob,exclude)${NAX_ARTIFACT_PATHSPEC}`]),
190
+ runGitDiff(workdir, ["--shortstat", range, "--", `:(glob)${NAX_ARTIFACT_PATHSPEC}`]),
191
+ ]);
192
+ const summary = artifacts?.trim();
193
+ return { diffstat, artifactSummary: summary ? summary : undefined };
144
194
  }
145
195
 
146
196
  /**
@@ -161,14 +211,14 @@ async function loadTemplate(workdir: string, forge: Forge | undefined): Promise<
161
211
 
162
212
  export async function loadFinishPrContext(
163
213
  input: FinishInput,
164
- args: { base: string; gatesRan: string[]; forge?: Forge; specPath?: string; narrative?: string },
214
+ args: { base: string; gatesRan: string[]; forge?: Forge; specPath?: string; narrative?: string; title?: string },
165
215
  ): Promise<FinishPrContext> {
166
216
  const inputPrdPath = input.prdPath || "prd.json";
167
217
  const prdPath = isAbsolute(inputPrdPath) ? inputPrdPath : join(input.workdir, inputPrdPath);
168
218
  // [US-004] The audit trail (`rounds`), the diffstat, and the spec summary
169
219
  // are independent of the PRD/status reads — fetching them in parallel keeps
170
220
  // the loader's wall clock at max(readRounds, readJson×2, diffstat, spec).
171
- const [prd, status, rounds, diffstat, template, specSummary] = (await Promise.all([
221
+ const [prd, status, rounds, stat, template, specSummary] = (await Promise.all([
172
222
  readJson(prdPath),
173
223
  readJson(join(dirname(prdPath), "status.json")),
174
224
  readRounds(input),
@@ -179,7 +229,7 @@ export async function loadFinishPrContext(
179
229
  PrdArtifact | undefined,
180
230
  StatusArtifact | undefined,
181
231
  FinishRound[],
182
- string | undefined,
232
+ DiffstatResult,
183
233
  string | undefined,
184
234
  string | null,
185
235
  ];
@@ -191,9 +241,11 @@ export async function loadFinishPrContext(
191
241
  regression: status?.postRun?.regression?.status,
192
242
  gatesRan: args.gatesRan,
193
243
  rounds,
194
- diffstat,
244
+ diffstat: stat.diffstat,
245
+ artifactSummary: stat.artifactSummary,
195
246
  template,
196
247
  narrative: resolveNarrative(args.narrative, specSummary),
248
+ title: resolveTitle(args.title, input.feature),
197
249
  run: {
198
250
  durationMs: status?.durationMs,
199
251
  storiesPassed: status?.progress?.passed,
@@ -203,12 +255,18 @@ export async function loadFinishPrContext(
203
255
  }
204
256
 
205
257
  /**
206
- * Conventional-commit title matching `buildTitle` in
207
- * `src/plugins/builtin/auto-pr/pr-body.ts`, so finish-opened and
208
- * auto-PR-opened PRs read the same in a list view.
258
+ * The PR title: the narrative node's conventional-commit subject when it
259
+ * produced one, else `feat: <feature>`.
260
+ *
261
+ * That fallback is what this returned unconditionally, and is still what
262
+ * `buildTitle` in `src/plugins/builtin/auto-pr/pr-body.ts` opens with — so a
263
+ * finish run that reaches `open_pr` before the narrative node has spoken still
264
+ * reads identically to an auto-PR-opened one in a list view. The two diverge
265
+ * only once there is something better to say: `feat: schema-drift-gate` names
266
+ * the run, not the change.
209
267
  */
210
268
  export function buildFinishTitle(ctx: FinishPrContext): string {
211
- return `feat: ${ctx.feature}`;
269
+ return ctx.title;
212
270
  }
213
271
 
214
272
  /**
@@ -247,17 +305,24 @@ function buildStoriesSection(stories: FinishPrStory[]): string {
247
305
  return lines.join("\n");
248
306
  }
249
307
 
250
- function buildVerificationSection(
251
- acceptance: string | undefined,
252
- regression: string | undefined,
253
- gatesRan: string[],
254
- diffstat: string | undefined,
255
- ): string | null {
308
+ /**
309
+ * Takes the whole context rather than the five fields it reads: the section
310
+ * grew past the three-positional-parameter cap in the coding standards, and
311
+ * every field it wants is already on `FinishPrContext`.
312
+ */
313
+ function buildVerificationSection(ctx: FinishPrContext): string | null {
314
+ const { acceptance, regression, gatesRan, diffstat, artifactSummary } = ctx;
256
315
  const lines: string[] = ["## Verification"];
257
316
  if (acceptance !== undefined) lines.push(`- Acceptance: ${acceptance}`);
258
317
  if (regression !== undefined) lines.push(`- Regression: ${regression}`);
259
318
  if (gatesRan.length > 0) lines.push(`- Gates: ${gatesRan.join(", ")}`);
260
319
  if (diffstat !== undefined && diffstat.length > 0) lines.push(`- Diffstat:\n\n\`\`\`\n${diffstat}\n\`\`\``);
320
+ // Stated even though the files are excluded above: a reviewer who diffs the
321
+ // branch themselves sees more than the diffstat quotes, and an unexplained
322
+ // mismatch reads as a stale body.
323
+ if (artifactSummary !== undefined && artifactSummary.length > 0) {
324
+ lines.push(`- Excluded from diffstat — nax run artifacts: ${artifactSummary}`);
325
+ }
261
326
  if (lines.length === 1) return null;
262
327
  return lines.join("\n");
263
328
  }
@@ -325,7 +390,7 @@ export function buildFinishBody(ctx: FinishPrContext): string {
325
390
 
326
391
  if (ctx.stories.length > 0) sections.push(buildStoriesSection(ctx.stories));
327
392
 
328
- const verification = buildVerificationSection(ctx.acceptance, ctx.regression, ctx.gatesRan, ctx.diffstat);
393
+ const verification = buildVerificationSection(ctx);
329
394
  if (verification !== null) sections.push(verification);
330
395
 
331
396
  const roundsSection = buildRoundsSection(ctx.rounds);
@@ -9,7 +9,7 @@
9
9
  * Every failure is warned and swallowed for the same reason — a throw would
10
10
  * fail a flow whose real work already succeeded.
11
11
  */
12
- import { gateOutputs, inputOf, loadCtxOf, narrativeOf } from "../flow-ctx";
12
+ import { gateOutputs, inputOf, loadCtxOf, narrativeOf, prTitleOf } from "../flow-ctx";
13
13
  import { detectForge } from "./forge";
14
14
  import { updatePrBody } from "./pr";
15
15
  import { _prBodyDeps, buildFinishBody, buildFinishTitle, loadFinishPrContext } from "./pr-body";
@@ -19,9 +19,11 @@ export async function amendPrBodyNode(ctx: {
19
19
  outputs: unknown;
20
20
  }): Promise<{ route: "done"; amended: boolean }> {
21
21
  const narrative = narrativeOf(ctx);
22
+ const title = prTitleOf(ctx);
22
23
  // Nothing to add: the body already in place is correct, and rewriting it
23
- // identically would spend a forge call to change nothing.
24
- if (!narrative) return { route: "done", amended: false };
24
+ // identically would spend a forge call to change nothing. A title alone is
25
+ // still worth the call it is the part a reviewer reads first.
26
+ if (!narrative && !title) return { route: "done", amended: false };
25
27
 
26
28
  const i = inputOf(ctx);
27
29
  const loadCtx = loadCtxOf(ctx);
@@ -33,6 +35,7 @@ export async function amendPrBodyNode(ctx: {
33
35
  forge,
34
36
  specPath: loadCtx.specPath,
35
37
  narrative,
38
+ title,
36
39
  });
37
40
  await updatePrBody(forge, i.workdir, i.branch, buildFinishTitle(prCtx), buildFinishBody(prCtx));
38
41
  return { route: "done", amended: true };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.77.0",
3
+ "version": "0.77.2",
4
4
  "description": "AI Coding Agent Orchestrator — loops until done",
5
5
  "type": "module",
6
6
  "bin": {