@nathapp/nax 0.77.0 → 0.77.1

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
@@ -43724,6 +43724,60 @@ var init_operations = __esm(() => {
43724
43724
  init_mutation_check();
43725
43725
  });
43726
43726
 
43727
+ // src/findings/cycle-iteration-log.ts
43728
+ function recordIteration(cycle, input, ctx, logger) {
43729
+ const iterationNum = cycle.iterations.length + 1;
43730
+ const findingsBeforeCount = input.findingsBefore.length;
43731
+ const findingsAfterCount = input.findingsAfter.length;
43732
+ const findingKeysBefore = input.findingsBefore.map(findingKey);
43733
+ const findingKeysAfter = input.findingsAfter.map(findingKey);
43734
+ const costUsd = input.fixesApplied.reduce((sum, fa) => sum + (fa.costUsd ?? 0), 0);
43735
+ const seenTargetFiles = new Set;
43736
+ const fixTargetFiles = [];
43737
+ for (const fa of input.fixesApplied) {
43738
+ for (const path6 of fa.targetFiles) {
43739
+ if (seenTargetFiles.has(path6))
43740
+ continue;
43741
+ seenTargetFiles.add(path6);
43742
+ fixTargetFiles.push(path6);
43743
+ }
43744
+ }
43745
+ const fixSummaries = input.fixesApplied.map((fa) => fa.summary);
43746
+ const hasFixes = input.fixesApplied.length > 0;
43747
+ const iteration = {
43748
+ iterationNum,
43749
+ findingsBefore: input.findingsBefore,
43750
+ fixesApplied: input.fixesApplied,
43751
+ findingsAfter: input.findingsAfter,
43752
+ outcome: input.outcome,
43753
+ startedAt: input.startedAt,
43754
+ finishedAt: input.finishedAt,
43755
+ findingKeysBefore,
43756
+ findingKeysAfter,
43757
+ ...hasFixes ? { fixTargetFiles, fixSummaries } : {},
43758
+ ...costUsd > 0 ? { costUsd } : {}
43759
+ };
43760
+ cycle.iterations.push(iteration);
43761
+ logger?.info("findings.cycle", "iteration completed", {
43762
+ storyId: ctx.storyId,
43763
+ packageDir: ctx.packageDir,
43764
+ cycleName: ctx.cycleName,
43765
+ iterationNum,
43766
+ strategiesRan: input.fixesApplied.map((fa) => fa.strategyName),
43767
+ outcome: input.outcome,
43768
+ findingsBefore: findingsBeforeCount,
43769
+ findingsAfter: findingsAfterCount,
43770
+ findingKeysBefore,
43771
+ findingKeysAfter,
43772
+ ...hasFixes ? { fixTargetFiles, fixSummaries } : {},
43773
+ ...costUsd > 0 ? { costUsd } : {}
43774
+ });
43775
+ return iteration;
43776
+ }
43777
+ var init_cycle_iteration_log = __esm(() => {
43778
+ init_types6();
43779
+ });
43780
+
43727
43781
  // src/findings/cycle-retirement.ts
43728
43782
  function createDeclineLedger() {
43729
43783
  const declinedByStrategy = new Map;
@@ -43935,15 +43989,14 @@ async function runFixCycle(cycle, ctx, cycleName, _deps = {}) {
43935
43989
  const allGaveUp = unresolvedFas.length === fixesApplied.length;
43936
43990
  if (allGaveUp) {
43937
43991
  const finishedAt2 = now();
43938
- cycle.iterations.push({
43939
- iterationNum: cycle.iterations.length + 1,
43992
+ recordIteration(cycle, {
43940
43993
  findingsBefore,
43941
43994
  fixesApplied,
43942
43995
  findingsAfter: cycle.findings,
43943
43996
  outcome: "unchanged",
43944
43997
  startedAt,
43945
43998
  finishedAt: finishedAt2
43946
- });
43999
+ }, { storyId, packageDir, cycleName }, logger);
43947
44000
  totalCostUsd += fixesApplied.reduce((sum, fa) => sum + (fa.costUsd ?? 0), 0);
43948
44001
  logger?.info("findings.cycle", "cycle exited \u2014 agent gave up", {
43949
44002
  storyId,
@@ -43986,15 +44039,14 @@ async function runFixCycle(cycle, ctx, cycleName, _deps = {}) {
43986
44039
  liteShortCircuited = liteResult.shortCircuited ?? false;
43987
44040
  } catch (err) {
43988
44041
  const finishedAt3 = now();
43989
- cycle.iterations.push({
43990
- iterationNum: cycle.iterations.length + 1,
44042
+ recordIteration(cycle, {
43991
44043
  findingsBefore,
43992
44044
  fixesApplied,
43993
44045
  findingsAfter: cycle.findings,
43994
44046
  outcome: "unchanged",
43995
44047
  startedAt,
43996
44048
  finishedAt: finishedAt3
43997
- });
44049
+ }, { storyId, packageDir, cycleName }, logger);
43998
44050
  logger?.warn("findings.cycle", "lite validate failed on terminal exhausted branch", {
43999
44051
  storyId,
44000
44052
  packageDir,
@@ -44011,15 +44063,14 @@ async function runFixCycle(cycle, ctx, cycleName, _deps = {}) {
44011
44063
  }
44012
44064
  const outcome2 = classifyOutcome(findingsBefore, liteFindingsAfter);
44013
44065
  const finishedAt2 = now();
44014
- cycle.iterations.push({
44015
- iterationNum: cycle.iterations.length + 1,
44066
+ recordIteration(cycle, {
44016
44067
  findingsBefore,
44017
44068
  fixesApplied,
44018
44069
  findingsAfter: liteFindingsAfter,
44019
44070
  outcome: outcome2,
44020
44071
  startedAt,
44021
44072
  finishedAt: finishedAt2
44022
- });
44073
+ }, { storyId, packageDir, cycleName }, logger);
44023
44074
  cycle.findings = liteFindingsAfter;
44024
44075
  if (liteFindingsAfter.length === 0 && !liteShortCircuited) {
44025
44076
  logger?.info("findings.cycle", "cycle exited \u2014 resolved after terminal lite validate", {
@@ -44112,31 +44163,17 @@ async function runFixCycle(cycle, ctx, cycleName, _deps = {}) {
44112
44163
  }
44113
44164
  const outcome = classifyOutcome(findingsBefore, findingsAfter);
44114
44165
  const finishedAt = now();
44115
- const iterationNum = cycle.iterations.length + 1;
44116
- const iteration = {
44117
- iterationNum,
44166
+ recordIteration(cycle, {
44118
44167
  findingsBefore,
44119
44168
  fixesApplied,
44120
44169
  findingsAfter,
44121
44170
  outcome,
44122
44171
  startedAt,
44123
44172
  finishedAt
44124
- };
44125
- cycle.iterations.push(iteration);
44173
+ }, { storyId, packageDir, cycleName }, logger);
44126
44174
  cycle.findings = findingsAfter;
44127
44175
  const iterationCostUsd = fixesApplied.reduce((sum, fa) => sum + (fa.costUsd ?? 0), 0);
44128
44176
  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
44177
  if (outcome === "resolved") {
44141
44178
  return { iterations: cycle.iterations, finalFindings: [], exitReason: "resolved", costUsd: totalCostUsd };
44142
44179
  }
@@ -44146,6 +44183,7 @@ var _cycleDeps;
44146
44183
  var init_cycle = __esm(() => {
44147
44184
  init_logger2();
44148
44185
  init_operations();
44186
+ init_cycle_iteration_log();
44149
44187
  init_cycle_retirement();
44150
44188
  init_types6();
44151
44189
  _cycleDeps = {
@@ -44160,6 +44198,7 @@ var init_findings = __esm(() => {
44160
44198
  init_adapters();
44161
44199
  init_path_utils();
44162
44200
  init_cycle();
44201
+ init_cycle_iteration_log();
44163
44202
  });
44164
44203
 
44165
44204
  // src/review/review-iteration-store.ts
@@ -44646,7 +44685,7 @@ var package_default;
44646
44685
  var init_package = __esm(() => {
44647
44686
  package_default = {
44648
44687
  name: "@nathapp/nax",
44649
- version: "0.77.0",
44688
+ version: "0.77.1",
44650
44689
  description: "AI Coding Agent Orchestrator \u2014 loops until done",
44651
44690
  type: "module",
44652
44691
  bin: {
@@ -44750,8 +44789,8 @@ var init_version = __esm(() => {
44750
44789
  NAX_VERSION = package_default.version;
44751
44790
  NAX_COMMIT = (() => {
44752
44791
  try {
44753
- if (/^[0-9a-f]{6,10}$/.test("4c943eec"))
44754
- return "4c943eec";
44792
+ if (/^[0-9a-f]{6,10}$/.test("a50be624"))
44793
+ return "a50be624";
44755
44794
  } catch {}
44756
44795
  try {
44757
44796
  const result = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"], {
@@ -59904,7 +59943,7 @@ var init_completion = __esm(() => {
59904
59943
  const logger = getLogger();
59905
59944
  const isBatch = ctx.stories.length > 1;
59906
59945
  const sessionCost = ctx.runtime.costAggregator.byStory()[ctx.story.id]?.totalCostUsd ?? 0;
59907
- const persistPrd = ctx.skipPrdPersistence !== true;
59946
+ const persistPrd2 = ctx.skipPrdPersistence !== true;
59908
59947
  const prdPath = ctx.prdPath ?? (ctx.featureDir ? `${ctx.featureDir}/prd.json` : `${ctx.workdir}/nax/features/unknown/prd.json`);
59909
59948
  const storyStartTime = ctx.storyStartTime || new Date().toISOString();
59910
59949
  if (isBatch) {
@@ -59929,7 +59968,7 @@ var init_completion = __esm(() => {
59929
59968
  }
59930
59969
  }
59931
59970
  for (const completedStory of ctx.stories) {
59932
- if (persistPrd) {
59971
+ if (persistPrd2) {
59933
59972
  markStoryPassed(ctx.prd, completedStory.id);
59934
59973
  }
59935
59974
  const costPerStory = sessionCost / ctx.stories.length;
@@ -59963,7 +60002,7 @@ var init_completion = __esm(() => {
59963
60002
  }
59964
60003
  }
59965
60004
  }
59966
- if (persistPrd) {
60005
+ if (persistPrd2) {
59967
60006
  await _completionDeps.savePRD(ctx.prd, prdPath);
59968
60007
  }
59969
60008
  logHighMemoryCheckpoint(logger, ctx);
@@ -106619,8 +106658,12 @@ function buildPlanComposition(userStageConfig) {
106619
106658
 
106620
106659
  // src/plan/strategies/write-prd.ts
106621
106660
  init_errors();
106661
+ init_logger2();
106622
106662
  init_prd();
106623
106663
 
106664
+ // src/plan/strategies/persist-prd.ts
106665
+ init_operations();
106666
+
106624
106667
  // src/plan/strategies/finalize-routing.ts
106625
106668
  init_agents();
106626
106669
  function finalizePrdRouting(prd, agentRouting, profileName) {
@@ -106642,6 +106685,26 @@ function finalizePrdRouting(prd, agentRouting, profileName) {
106642
106685
  return { ...prd, userStories, routingProfile: profileName ?? "default" };
106643
106686
  }
106644
106687
 
106688
+ // src/plan/strategies/persist-prd.ts
106689
+ async function finalizeAndWritePrd(args) {
106690
+ const repaired = applyPlanFidelity(args.prd, args.specContent, args.featureName);
106691
+ const finalized = finalizePrdRouting({ ...repaired, project: args.projectName }, args.agentRouting, args.profileName);
106692
+ await args.writeFile(args.outputPath, JSON.stringify(finalized, null, 2));
106693
+ return args.outputPath;
106694
+ }
106695
+ async function persistPrd(ctx, prd) {
106696
+ return finalizeAndWritePrd({
106697
+ prd,
106698
+ specContent: ctx.specContent,
106699
+ featureName: ctx.options.feature,
106700
+ projectName: ctx.projectName,
106701
+ agentRouting: ctx.config.routing?.agents,
106702
+ profileName: ctx.profileName,
106703
+ outputPath: ctx.outputPath,
106704
+ writeFile: ctx.deps.writeFile
106705
+ });
106706
+ }
106707
+
106645
106708
  // src/plan/strategies/write-prd.ts
106646
106709
  async function writeOrRecoverPrd(ctx, prd, err) {
106647
106710
  const tryExtractPrd = (value) => {
@@ -106661,15 +106724,11 @@ async function writeOrRecoverPrd(ctx, prd, err) {
106661
106724
  };
106662
106725
  if (prd !== null) {
106663
106726
  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;
106727
+ return { outputPath: await persistPrd(ctx, prd) };
106667
106728
  }
106668
106729
  const normalizedPrd = tryExtractPrd(prd);
106669
106730
  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;
106731
+ return { outputPath: await persistPrd(ctx, normalizedPrd) };
106673
106732
  }
106674
106733
  }
106675
106734
  if (err === undefined) {
@@ -106688,9 +106747,13 @@ async function writeOrRecoverPrd(ctx, prd, err) {
106688
106747
  }
106689
106748
  }
106690
106749
  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;
106750
+ const reason = err instanceof Error ? err.message : String(err);
106751
+ getSafeLogger()?.warn("plan", "PRD recovered from disk after a plan failure \u2014 result is degraded", {
106752
+ featureName: ctx.options.feature,
106753
+ outputPath: ctx.outputPath,
106754
+ error: reason
106755
+ });
106756
+ return { outputPath: await persistPrd(ctx, recoveredPrd), degraded: { reason } };
106694
106757
  } catch {
106695
106758
  throw err;
106696
106759
  }
@@ -106747,9 +106810,7 @@ class DebatePlanStrategy {
106747
106810
  });
106748
106811
  if (debateResult.outcome !== "failed" && debateResult.output) {
106749
106812
  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);
106813
+ return _debatePlanDeps.writeOrRecoverPrd(ctx, prd2);
106753
106814
  }
106754
106815
  const prd = await callOp({
106755
106816
  ...callCtx,
@@ -106766,8 +106827,7 @@ class DebatePlanStrategy {
106766
106827
  projectProfile: ctx.config.project
106767
106828
  });
106768
106829
  assertIsValidPrd(prd);
106769
- const withProject = { ...prd, project: ctx.projectName };
106770
- return _debatePlanDeps.writeOrRecoverPrd(ctx, withProject);
106830
+ return _debatePlanDeps.writeOrRecoverPrd(ctx, prd);
106771
106831
  } catch (err) {
106772
106832
  return _debatePlanDeps.writeOrRecoverPrd(ctx, null, err);
106773
106833
  } finally {
@@ -106846,10 +106906,7 @@ class PipelinePlanStrategy {
106846
106906
  if (verdict.outcome !== "passed") {
106847
106907
  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
106908
  }
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;
106909
+ return { outputPath: await persistPrd(ctx, verdict.prd) };
106853
106910
  } finally {
106854
106911
  await ctx.runtime.close().catch(() => {});
106855
106912
  }
@@ -106898,6 +106955,7 @@ class RefinePlanStrategy {
106898
106955
  }
106899
106956
 
106900
106957
  // src/plan/strategies/single.ts
106958
+ init_logger2();
106901
106959
  init_operations();
106902
106960
  init_prd();
106903
106961
  var _singlePlanDeps = {
@@ -106929,16 +106987,18 @@ class SinglePlanStrategy {
106929
106987
  projectProfile: ctx.config.project
106930
106988
  });
106931
106989
  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;
106990
+ return { outputPath: await persistPrd(ctx, prd) };
106935
106991
  } catch (err) {
106936
106992
  if (ctx.deps.existsSync(ctx.outputPath)) {
106937
106993
  const rawContent = await ctx.deps.readFile(ctx.outputPath);
106938
106994
  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;
106995
+ const reason = err instanceof Error ? err.message : String(err);
106996
+ getSafeLogger()?.warn("plan", "PRD recovered from disk after a plan failure \u2014 result is degraded", {
106997
+ featureName: ctx.options.feature,
106998
+ outputPath: ctx.outputPath,
106999
+ error: reason
107000
+ });
107001
+ return { outputPath: await persistPrd(ctx, recoveredPrd), degraded: { reason } };
106942
107002
  }
106943
107003
  throw err;
106944
107004
  } finally {
@@ -118060,6 +118120,14 @@ program2.name("nax").description("AI Coding Agent Orchestrator \u2014 loops unti
118060
118120
  function collectProfile(value, previous) {
118061
118121
  return previous.concat(value);
118062
118122
  }
118123
+ function warnIfPlanDegraded(result2) {
118124
+ if (!result2.degraded)
118125
+ return;
118126
+ console.log(source_default.yellow(`
118127
+ [WARN] PRD recovered after a plan failure \u2014 this is a degraded result`));
118128
+ console.log(source_default.dim(` Cause: ${result2.degraded.reason}`));
118129
+ console.log(source_default.dim(" Deterministic spec->PRD repairs were re-applied, but review the PRD before running."));
118130
+ }
118063
118131
  async function promptForConfirmation(question) {
118064
118132
  if (!process.stdin.isTTY) {
118065
118133
  return true;
@@ -118381,12 +118449,14 @@ program2.command("run").description("Run the orchestration loop for a feature").
118381
118449
  initLogger({ level: "info", filePath: planLogPath, useChalk: false, headless: true });
118382
118450
  console.log(source_default.dim(` [Plan log: ${planLogPath}]`));
118383
118451
  console.log(source_default.dim(" [Planning phase: generating PRD from spec]"));
118384
- const generatedPrdPath = await planCommand(workdir, config2, {
118452
+ const planResult = await planCommand(workdir, config2, {
118385
118453
  from: options.from,
118386
118454
  feature: options.feature,
118387
118455
  auto: options.oneShot ?? false,
118388
118456
  branch: undefined
118389
118457
  });
118458
+ const generatedPrdPath = planResult.outputPath;
118459
+ warnIfPlanDegraded(planResult);
118390
118460
  const generatedPrd = await loadPRD(generatedPrdPath);
118391
118461
  await runReplanLoop(workdir, config2, {
118392
118462
  feature: options.feature,
@@ -118805,15 +118875,16 @@ Use: nax plan -f <feature> --from <spec>`));
118805
118875
  console.error(source_default.red("Error: --from <spec-path> is required unless --decompose is used"));
118806
118876
  process.exit(1);
118807
118877
  }
118808
- const prdPath = await planCommand(workdir, config2, {
118878
+ const planResult = await planCommand(workdir, config2, {
118809
118879
  from: options.from,
118810
118880
  feature: options.feature,
118811
118881
  auto: options.auto || options.oneShot,
118812
118882
  branch: options.branch
118813
118883
  });
118884
+ warnIfPlanDegraded(planResult);
118814
118885
  console.log(source_default.green(`
118815
118886
  [OK] PRD generated`));
118816
- console.log(source_default.dim(` PRD: ${prdPath}`));
118887
+ console.log(source_default.dim(` PRD: ${planResult.outputPath}`));
118817
118888
  console.log(source_default.dim(` Log: ${planLogPath}`));
118818
118889
  console.log(source_default.dim(`
118819
118890
  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.1",
4
4
  "description": "AI Coding Agent Orchestrator — loops until done",
5
5
  "type": "module",
6
6
  "bin": {