@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 +129 -58
- package/flows/nax-finish/flow-ctx.ts +19 -1
- package/flows/nax-finish/narrative.ts +85 -3
- package/flows/nax-finish/nax-finish.flow.ts +2 -2
- package/flows/nax-finish/pr-title.ts +140 -0
- package/flows/nax-finish/steps/pr-body.ts +95 -30
- package/flows/nax-finish/steps/pr-narrative.ts +6 -3
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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("
|
|
44754
|
-
return "
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
106692
|
-
|
|
106693
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
106940
|
-
|
|
106941
|
-
|
|
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
|
|
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
|
|
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: ${
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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
|
|
9
|
-
* `
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
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<
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
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
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
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
|
-
|
|
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 };
|