@nathapp/nax 0.77.0 → 0.77.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/nax.js +265 -73
- 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
|
@@ -16865,7 +16865,10 @@ var init_schemas_execution = __esm(() => {
|
|
|
16865
16865
|
maxFailureSummaryChars: exports_external.number().int().min(500).max(1e4).default(2000),
|
|
16866
16866
|
abortOnIncreasingFailures: exports_external.boolean().default(true),
|
|
16867
16867
|
consecutiveIncreasesToBail: exports_external.number().int().min(1).max(10).default(2),
|
|
16868
|
+
abortOnNoProgress: exports_external.boolean().default(true),
|
|
16869
|
+
consecutiveNoProgressToBail: exports_external.number().int().min(1).max(10).default(3),
|
|
16868
16870
|
escalateOnExhaustion: exports_external.boolean().optional().default(true),
|
|
16871
|
+
storyScopedFixBudget: exports_external.boolean().default(true),
|
|
16869
16872
|
rethinkAtAttempt: exports_external.number().int().min(1).default(2),
|
|
16870
16873
|
urgencyAtAttempt: exports_external.number().int().min(1).default(3)
|
|
16871
16874
|
});
|
|
@@ -17531,7 +17534,10 @@ var init_schemas3 = __esm(() => {
|
|
|
17531
17534
|
maxFailureSummaryChars: 2000,
|
|
17532
17535
|
abortOnIncreasingFailures: true,
|
|
17533
17536
|
consecutiveIncreasesToBail: 2,
|
|
17537
|
+
abortOnNoProgress: true,
|
|
17538
|
+
consecutiveNoProgressToBail: 3,
|
|
17534
17539
|
escalateOnExhaustion: true,
|
|
17540
|
+
storyScopedFixBudget: true,
|
|
17535
17541
|
rethinkAtAttempt: 2,
|
|
17536
17542
|
urgencyAtAttempt: 3
|
|
17537
17543
|
},
|
|
@@ -43724,9 +43730,63 @@ var init_operations = __esm(() => {
|
|
|
43724
43730
|
init_mutation_check();
|
|
43725
43731
|
});
|
|
43726
43732
|
|
|
43733
|
+
// src/findings/cycle-iteration-log.ts
|
|
43734
|
+
function recordIteration(cycle, input, ctx, logger) {
|
|
43735
|
+
const iterationNum = cycle.iterations.length + 1;
|
|
43736
|
+
const findingsBeforeCount = input.findingsBefore.length;
|
|
43737
|
+
const findingsAfterCount = input.findingsAfter.length;
|
|
43738
|
+
const findingKeysBefore = input.findingsBefore.map(findingKey);
|
|
43739
|
+
const findingKeysAfter = input.findingsAfter.map(findingKey);
|
|
43740
|
+
const costUsd = input.fixesApplied.reduce((sum, fa) => sum + (fa.costUsd ?? 0), 0);
|
|
43741
|
+
const seenTargetFiles = new Set;
|
|
43742
|
+
const fixTargetFiles = [];
|
|
43743
|
+
for (const fa of input.fixesApplied) {
|
|
43744
|
+
for (const path6 of fa.targetFiles) {
|
|
43745
|
+
if (seenTargetFiles.has(path6))
|
|
43746
|
+
continue;
|
|
43747
|
+
seenTargetFiles.add(path6);
|
|
43748
|
+
fixTargetFiles.push(path6);
|
|
43749
|
+
}
|
|
43750
|
+
}
|
|
43751
|
+
const fixSummaries = input.fixesApplied.map((fa) => fa.summary);
|
|
43752
|
+
const hasFixes = input.fixesApplied.length > 0;
|
|
43753
|
+
const iteration = {
|
|
43754
|
+
iterationNum,
|
|
43755
|
+
findingsBefore: input.findingsBefore,
|
|
43756
|
+
fixesApplied: input.fixesApplied,
|
|
43757
|
+
findingsAfter: input.findingsAfter,
|
|
43758
|
+
outcome: input.outcome,
|
|
43759
|
+
startedAt: input.startedAt,
|
|
43760
|
+
finishedAt: input.finishedAt,
|
|
43761
|
+
findingKeysBefore,
|
|
43762
|
+
findingKeysAfter,
|
|
43763
|
+
...hasFixes ? { fixTargetFiles, fixSummaries } : {},
|
|
43764
|
+
...costUsd > 0 ? { costUsd } : {}
|
|
43765
|
+
};
|
|
43766
|
+
cycle.iterations.push(iteration);
|
|
43767
|
+
logger?.info("findings.cycle", "iteration completed", {
|
|
43768
|
+
storyId: ctx.storyId,
|
|
43769
|
+
packageDir: ctx.packageDir,
|
|
43770
|
+
cycleName: ctx.cycleName,
|
|
43771
|
+
iterationNum,
|
|
43772
|
+
strategiesRan: input.fixesApplied.map((fa) => fa.strategyName),
|
|
43773
|
+
outcome: input.outcome,
|
|
43774
|
+
findingsBefore: findingsBeforeCount,
|
|
43775
|
+
findingsAfter: findingsAfterCount,
|
|
43776
|
+
findingKeysBefore,
|
|
43777
|
+
findingKeysAfter,
|
|
43778
|
+
...hasFixes ? { fixTargetFiles, fixSummaries } : {},
|
|
43779
|
+
...costUsd > 0 ? { costUsd } : {}
|
|
43780
|
+
});
|
|
43781
|
+
return iteration;
|
|
43782
|
+
}
|
|
43783
|
+
var init_cycle_iteration_log = __esm(() => {
|
|
43784
|
+
init_types6();
|
|
43785
|
+
});
|
|
43786
|
+
|
|
43727
43787
|
// src/findings/cycle-retirement.ts
|
|
43728
|
-
function createDeclineLedger() {
|
|
43729
|
-
const declinedByStrategy = new Map;
|
|
43788
|
+
function createDeclineLedger(backing) {
|
|
43789
|
+
const declinedByStrategy = backing ?? new Map;
|
|
43730
43790
|
const hasDeclined = (strategyName, finding) => declinedByStrategy.get(strategyName)?.has(findingKey(finding)) === true;
|
|
43731
43791
|
const isRetiredFor = (strategy, findings) => {
|
|
43732
43792
|
const claimed = findings.filter((f) => strategy.appliesTo(f));
|
|
@@ -43818,13 +43878,14 @@ async function runFixCycle(cycle, ctx, cycleName, _deps = {}) {
|
|
|
43818
43878
|
const storyId = ctx.storyId;
|
|
43819
43879
|
const packageDir = ctx.packageDir;
|
|
43820
43880
|
let totalCostUsd = 0;
|
|
43821
|
-
const declines = createDeclineLedger();
|
|
43881
|
+
const declines = createDeclineLedger(_deps.declineBacking);
|
|
43822
43882
|
let unresolvedDetail;
|
|
43823
43883
|
const finish = (result) => unresolvedDetail !== undefined && result.unresolvedDetail === undefined ? { ...result, unresolvedDetail } : result;
|
|
43824
43884
|
for (;; ) {
|
|
43825
43885
|
if (cycle.findings.length === 0 && cycle.verdict === undefined) {
|
|
43826
43886
|
return { iterations: cycle.iterations, finalFindings: [], exitReason: "resolved", costUsd: totalCostUsd };
|
|
43827
43887
|
}
|
|
43888
|
+
const history = cycle.priorIterations ? [...cycle.priorIterations, ...cycle.iterations] : cycle.iterations;
|
|
43828
43889
|
const selectable = cycle.strategies.filter((s) => !declines.isRetiredFor(s, cycle.findings));
|
|
43829
43890
|
const active = selectActiveStrategies(selectable, cycle.findings, cycle.verdict);
|
|
43830
43891
|
if (active.length === 0) {
|
|
@@ -43846,9 +43907,9 @@ async function runFixCycle(cycle, ctx, cycleName, _deps = {}) {
|
|
|
43846
43907
|
costUsd: totalCostUsd
|
|
43847
43908
|
});
|
|
43848
43909
|
}
|
|
43849
|
-
const uncappedActive = active.filter((s) => countStrategyAttempts(
|
|
43910
|
+
const uncappedActive = active.filter((s) => countStrategyAttempts(history, s.name) < s.maxAttempts);
|
|
43850
43911
|
if (uncappedActive.length === 0) {
|
|
43851
|
-
const exhaustedStrategy = active.find((s) => countStrategyAttempts(
|
|
43912
|
+
const exhaustedStrategy = active.find((s) => countStrategyAttempts(history, s.name) >= s.maxAttempts);
|
|
43852
43913
|
logger?.info("findings.cycle", "cycle exited \u2014 all active strategies exhausted", {
|
|
43853
43914
|
storyId,
|
|
43854
43915
|
packageDir,
|
|
@@ -43864,7 +43925,7 @@ async function runFixCycle(cycle, ctx, cycleName, _deps = {}) {
|
|
|
43864
43925
|
costUsd: totalCostUsd
|
|
43865
43926
|
});
|
|
43866
43927
|
}
|
|
43867
|
-
const totalAttempts = countTotalAttempts(
|
|
43928
|
+
const totalAttempts = countTotalAttempts(history);
|
|
43868
43929
|
if (totalAttempts >= cycle.config.maxAttemptsTotal) {
|
|
43869
43930
|
logger?.info("findings.cycle", "cycle exited \u2014 total attempt cap reached", {
|
|
43870
43931
|
storyId,
|
|
@@ -43882,7 +43943,7 @@ async function runFixCycle(cycle, ctx, cycleName, _deps = {}) {
|
|
|
43882
43943
|
});
|
|
43883
43944
|
}
|
|
43884
43945
|
for (const strategy of uncappedActive) {
|
|
43885
|
-
const bailReason = strategy.bailWhen?.(
|
|
43946
|
+
const bailReason = strategy.bailWhen?.(history) ?? null;
|
|
43886
43947
|
if (bailReason !== null) {
|
|
43887
43948
|
logger?.info("findings.cycle", "cycle exited \u2014 bail predicate fired", {
|
|
43888
43949
|
storyId,
|
|
@@ -43935,15 +43996,14 @@ async function runFixCycle(cycle, ctx, cycleName, _deps = {}) {
|
|
|
43935
43996
|
const allGaveUp = unresolvedFas.length === fixesApplied.length;
|
|
43936
43997
|
if (allGaveUp) {
|
|
43937
43998
|
const finishedAt2 = now();
|
|
43938
|
-
cycle
|
|
43939
|
-
iterationNum: cycle.iterations.length + 1,
|
|
43999
|
+
recordIteration(cycle, {
|
|
43940
44000
|
findingsBefore,
|
|
43941
44001
|
fixesApplied,
|
|
43942
44002
|
findingsAfter: cycle.findings,
|
|
43943
44003
|
outcome: "unchanged",
|
|
43944
44004
|
startedAt,
|
|
43945
44005
|
finishedAt: finishedAt2
|
|
43946
|
-
});
|
|
44006
|
+
}, { storyId, packageDir, cycleName }, logger);
|
|
43947
44007
|
totalCostUsd += fixesApplied.reduce((sum, fa) => sum + (fa.costUsd ?? 0), 0);
|
|
43948
44008
|
logger?.info("findings.cycle", "cycle exited \u2014 agent gave up", {
|
|
43949
44009
|
storyId,
|
|
@@ -43971,7 +44031,7 @@ async function runFixCycle(cycle, ctx, cycleName, _deps = {}) {
|
|
|
43971
44031
|
});
|
|
43972
44032
|
}
|
|
43973
44033
|
const allExhausted = group.every((s) => {
|
|
43974
|
-
const prior = countStrategyAttempts(
|
|
44034
|
+
const prior = countStrategyAttempts(history, s.name);
|
|
43975
44035
|
const current = fixesApplied.filter((fa) => fa.strategyName === s.name).length;
|
|
43976
44036
|
return prior + current >= s.maxAttempts;
|
|
43977
44037
|
});
|
|
@@ -43986,15 +44046,14 @@ async function runFixCycle(cycle, ctx, cycleName, _deps = {}) {
|
|
|
43986
44046
|
liteShortCircuited = liteResult.shortCircuited ?? false;
|
|
43987
44047
|
} catch (err) {
|
|
43988
44048
|
const finishedAt3 = now();
|
|
43989
|
-
cycle
|
|
43990
|
-
iterationNum: cycle.iterations.length + 1,
|
|
44049
|
+
recordIteration(cycle, {
|
|
43991
44050
|
findingsBefore,
|
|
43992
44051
|
fixesApplied,
|
|
43993
44052
|
findingsAfter: cycle.findings,
|
|
43994
44053
|
outcome: "unchanged",
|
|
43995
44054
|
startedAt,
|
|
43996
44055
|
finishedAt: finishedAt3
|
|
43997
|
-
});
|
|
44056
|
+
}, { storyId, packageDir, cycleName }, logger);
|
|
43998
44057
|
logger?.warn("findings.cycle", "lite validate failed on terminal exhausted branch", {
|
|
43999
44058
|
storyId,
|
|
44000
44059
|
packageDir,
|
|
@@ -44011,15 +44070,14 @@ async function runFixCycle(cycle, ctx, cycleName, _deps = {}) {
|
|
|
44011
44070
|
}
|
|
44012
44071
|
const outcome2 = classifyOutcome(findingsBefore, liteFindingsAfter);
|
|
44013
44072
|
const finishedAt2 = now();
|
|
44014
|
-
cycle
|
|
44015
|
-
iterationNum: cycle.iterations.length + 1,
|
|
44073
|
+
recordIteration(cycle, {
|
|
44016
44074
|
findingsBefore,
|
|
44017
44075
|
fixesApplied,
|
|
44018
44076
|
findingsAfter: liteFindingsAfter,
|
|
44019
44077
|
outcome: outcome2,
|
|
44020
44078
|
startedAt,
|
|
44021
44079
|
finishedAt: finishedAt2
|
|
44022
|
-
});
|
|
44080
|
+
}, { storyId, packageDir, cycleName }, logger);
|
|
44023
44081
|
cycle.findings = liteFindingsAfter;
|
|
44024
44082
|
if (liteFindingsAfter.length === 0 && !liteShortCircuited) {
|
|
44025
44083
|
logger?.info("findings.cycle", "cycle exited \u2014 resolved after terminal lite validate", {
|
|
@@ -44112,31 +44170,17 @@ async function runFixCycle(cycle, ctx, cycleName, _deps = {}) {
|
|
|
44112
44170
|
}
|
|
44113
44171
|
const outcome = classifyOutcome(findingsBefore, findingsAfter);
|
|
44114
44172
|
const finishedAt = now();
|
|
44115
|
-
|
|
44116
|
-
const iteration = {
|
|
44117
|
-
iterationNum,
|
|
44173
|
+
recordIteration(cycle, {
|
|
44118
44174
|
findingsBefore,
|
|
44119
44175
|
fixesApplied,
|
|
44120
44176
|
findingsAfter,
|
|
44121
44177
|
outcome,
|
|
44122
44178
|
startedAt,
|
|
44123
44179
|
finishedAt
|
|
44124
|
-
};
|
|
44125
|
-
cycle.iterations.push(iteration);
|
|
44180
|
+
}, { storyId, packageDir, cycleName }, logger);
|
|
44126
44181
|
cycle.findings = findingsAfter;
|
|
44127
44182
|
const iterationCostUsd = fixesApplied.reduce((sum, fa) => sum + (fa.costUsd ?? 0), 0);
|
|
44128
44183
|
totalCostUsd += iterationCostUsd;
|
|
44129
|
-
logger?.info("findings.cycle", "iteration completed", {
|
|
44130
|
-
storyId,
|
|
44131
|
-
packageDir,
|
|
44132
|
-
cycleName,
|
|
44133
|
-
iterationNum,
|
|
44134
|
-
strategiesRan: fixesApplied.map((fa) => fa.strategyName),
|
|
44135
|
-
outcome,
|
|
44136
|
-
findingsBefore: findingsBefore.length,
|
|
44137
|
-
findingsAfter: findingsAfter.length,
|
|
44138
|
-
...iterationCostUsd > 0 ? { costUsd: iterationCostUsd } : {}
|
|
44139
|
-
});
|
|
44140
44184
|
if (outcome === "resolved") {
|
|
44141
44185
|
return { iterations: cycle.iterations, finalFindings: [], exitReason: "resolved", costUsd: totalCostUsd };
|
|
44142
44186
|
}
|
|
@@ -44146,6 +44190,7 @@ var _cycleDeps;
|
|
|
44146
44190
|
var init_cycle = __esm(() => {
|
|
44147
44191
|
init_logger2();
|
|
44148
44192
|
init_operations();
|
|
44193
|
+
init_cycle_iteration_log();
|
|
44149
44194
|
init_cycle_retirement();
|
|
44150
44195
|
init_types6();
|
|
44151
44196
|
_cycleDeps = {
|
|
@@ -44154,12 +44199,63 @@ var init_cycle = __esm(() => {
|
|
|
44154
44199
|
};
|
|
44155
44200
|
});
|
|
44156
44201
|
|
|
44202
|
+
// src/findings/bail-marker.ts
|
|
44203
|
+
function markNaxBailWrapper(predicate) {
|
|
44204
|
+
Object.assign(predicate, { [NAX_BAIL_WRAPPER]: true });
|
|
44205
|
+
return predicate;
|
|
44206
|
+
}
|
|
44207
|
+
function isNaxBailWrapper(predicate) {
|
|
44208
|
+
if (!predicate)
|
|
44209
|
+
return false;
|
|
44210
|
+
return predicate[NAX_BAIL_WRAPPER] === true;
|
|
44211
|
+
}
|
|
44212
|
+
var NAX_BAIL_WRAPPER = "__naxBailWrapper";
|
|
44213
|
+
|
|
44214
|
+
// src/findings/story-fix-history.ts
|
|
44215
|
+
function createStoryFixHistory() {
|
|
44216
|
+
return new Map;
|
|
44217
|
+
}
|
|
44218
|
+
function storyFixKey(storyId, tier) {
|
|
44219
|
+
return `${storyId}::${tier ?? "default"}`;
|
|
44220
|
+
}
|
|
44221
|
+
function getStoryFixState(store, key) {
|
|
44222
|
+
let existing = store.get(key);
|
|
44223
|
+
if (!existing) {
|
|
44224
|
+
existing = { iterations: [], declines: new Map };
|
|
44225
|
+
store.set(key, existing);
|
|
44226
|
+
}
|
|
44227
|
+
return existing;
|
|
44228
|
+
}
|
|
44229
|
+
function appendStoryFixIterations(store, key, iterations) {
|
|
44230
|
+
const existing = store.get(key);
|
|
44231
|
+
if (existing) {
|
|
44232
|
+
store.set(key, {
|
|
44233
|
+
iterations: [...existing.iterations, ...iterations],
|
|
44234
|
+
declines: existing.declines
|
|
44235
|
+
});
|
|
44236
|
+
} else {
|
|
44237
|
+
store.set(key, {
|
|
44238
|
+
iterations: [...iterations],
|
|
44239
|
+
declines: new Map
|
|
44240
|
+
});
|
|
44241
|
+
}
|
|
44242
|
+
}
|
|
44243
|
+
function mergeStoryFixDeclines(store, key, declines) {
|
|
44244
|
+
const existing = store.get(key);
|
|
44245
|
+
store.set(key, {
|
|
44246
|
+
iterations: existing?.iterations ?? [],
|
|
44247
|
+
declines: new Map([...declines].map(([name, keys]) => [name, new Set(keys)]))
|
|
44248
|
+
});
|
|
44249
|
+
}
|
|
44250
|
+
|
|
44157
44251
|
// src/findings/index.ts
|
|
44158
44252
|
var init_findings = __esm(() => {
|
|
44159
44253
|
init_types6();
|
|
44160
44254
|
init_adapters();
|
|
44161
44255
|
init_path_utils();
|
|
44162
44256
|
init_cycle();
|
|
44257
|
+
init_cycle_iteration_log();
|
|
44258
|
+
init_cycle_retirement();
|
|
44163
44259
|
});
|
|
44164
44260
|
|
|
44165
44261
|
// src/review/review-iteration-store.ts
|
|
@@ -44646,7 +44742,7 @@ var package_default;
|
|
|
44646
44742
|
var init_package = __esm(() => {
|
|
44647
44743
|
package_default = {
|
|
44648
44744
|
name: "@nathapp/nax",
|
|
44649
|
-
version: "0.77.
|
|
44745
|
+
version: "0.77.2",
|
|
44650
44746
|
description: "AI Coding Agent Orchestrator \u2014 loops until done",
|
|
44651
44747
|
type: "module",
|
|
44652
44748
|
bin: {
|
|
@@ -44750,8 +44846,8 @@ var init_version = __esm(() => {
|
|
|
44750
44846
|
NAX_VERSION = package_default.version;
|
|
44751
44847
|
NAX_COMMIT = (() => {
|
|
44752
44848
|
try {
|
|
44753
|
-
if (/^[0-9a-f]{6,10}$/.test("
|
|
44754
|
-
return "
|
|
44849
|
+
if (/^[0-9a-f]{6,10}$/.test("888b55c1"))
|
|
44850
|
+
return "888b55c1";
|
|
44755
44851
|
} catch {}
|
|
44756
44852
|
try {
|
|
44757
44853
|
const result = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"], {
|
|
@@ -51873,6 +51969,7 @@ function createRuntime(config2, workdir, opts) {
|
|
|
51873
51969
|
const adversarialIterations = new Map;
|
|
51874
51970
|
const semanticIterations = new Map;
|
|
51875
51971
|
const rectificationOscillations = new Map;
|
|
51972
|
+
const storyFixHistory = createStoryFixHistory();
|
|
51876
51973
|
const mutationSummaries = new Map;
|
|
51877
51974
|
const dirtyWorktrees = new Set;
|
|
51878
51975
|
let closed = false;
|
|
@@ -51899,6 +51996,7 @@ function createRuntime(config2, workdir, opts) {
|
|
|
51899
51996
|
adversarialIterations,
|
|
51900
51997
|
semanticIterations,
|
|
51901
51998
|
rectificationOscillations,
|
|
51999
|
+
storyFixHistory,
|
|
51902
52000
|
mutationSummaries,
|
|
51903
52001
|
dirtyWorktrees,
|
|
51904
52002
|
get signal() {
|
|
@@ -51946,6 +52044,7 @@ var init_runtime = __esm(() => {
|
|
|
51946
52044
|
init_config();
|
|
51947
52045
|
init_errors();
|
|
51948
52046
|
init_pid_registry();
|
|
52047
|
+
init_findings();
|
|
51949
52048
|
init_logger2();
|
|
51950
52049
|
init_review_audit();
|
|
51951
52050
|
init_session();
|
|
@@ -59904,7 +60003,7 @@ var init_completion = __esm(() => {
|
|
|
59904
60003
|
const logger = getLogger();
|
|
59905
60004
|
const isBatch = ctx.stories.length > 1;
|
|
59906
60005
|
const sessionCost = ctx.runtime.costAggregator.byStory()[ctx.story.id]?.totalCostUsd ?? 0;
|
|
59907
|
-
const
|
|
60006
|
+
const persistPrd2 = ctx.skipPrdPersistence !== true;
|
|
59908
60007
|
const prdPath = ctx.prdPath ?? (ctx.featureDir ? `${ctx.featureDir}/prd.json` : `${ctx.workdir}/nax/features/unknown/prd.json`);
|
|
59909
60008
|
const storyStartTime = ctx.storyStartTime || new Date().toISOString();
|
|
59910
60009
|
if (isBatch) {
|
|
@@ -59929,7 +60028,7 @@ var init_completion = __esm(() => {
|
|
|
59929
60028
|
}
|
|
59930
60029
|
}
|
|
59931
60030
|
for (const completedStory of ctx.stories) {
|
|
59932
|
-
if (
|
|
60031
|
+
if (persistPrd2) {
|
|
59933
60032
|
markStoryPassed(ctx.prd, completedStory.id);
|
|
59934
60033
|
}
|
|
59935
60034
|
const costPerStory = sessionCost / ctx.stories.length;
|
|
@@ -59963,7 +60062,7 @@ var init_completion = __esm(() => {
|
|
|
59963
60062
|
}
|
|
59964
60063
|
}
|
|
59965
60064
|
}
|
|
59966
|
-
if (
|
|
60065
|
+
if (persistPrd2) {
|
|
59967
60066
|
await _completionDeps.savePRD(ctx.prd, prdPath);
|
|
59968
60067
|
}
|
|
59969
60068
|
logHighMemoryCheckpoint(logger, ctx);
|
|
@@ -61235,6 +61334,45 @@ function countOscillationOutcomes(iterations) {
|
|
|
61235
61334
|
return count;
|
|
61236
61335
|
}
|
|
61237
61336
|
|
|
61337
|
+
// src/execution/story-orchestrator/no-progress-bail.ts
|
|
61338
|
+
function madeNoProgress(iteration) {
|
|
61339
|
+
if (iteration.findingsBefore.length === 0)
|
|
61340
|
+
return false;
|
|
61341
|
+
const after = new Set(iteration.findingsAfter.map(findingKey));
|
|
61342
|
+
return iteration.findingsBefore.every((finding) => after.has(findingKey(finding)));
|
|
61343
|
+
}
|
|
61344
|
+
function withNoProgressBail(strategies, enabled, consecutiveNoProgress) {
|
|
61345
|
+
if (!enabled)
|
|
61346
|
+
return strategies;
|
|
61347
|
+
const threshold = Math.max(1, consecutiveNoProgress);
|
|
61348
|
+
return strategies.map((strategy) => {
|
|
61349
|
+
const innerBail = strategy.bailWhen;
|
|
61350
|
+
const isUserBail = innerBail !== undefined && !isNaxBailWrapper(innerBail);
|
|
61351
|
+
return {
|
|
61352
|
+
...strategy,
|
|
61353
|
+
bailWhen: markNaxBailWrapper((iterations) => {
|
|
61354
|
+
if (isUserBail) {
|
|
61355
|
+
const userReason = innerBail(iterations);
|
|
61356
|
+
if (userReason !== null)
|
|
61357
|
+
return userReason;
|
|
61358
|
+
}
|
|
61359
|
+
if (iterations.length >= threshold) {
|
|
61360
|
+
const trailing = iterations.slice(-threshold);
|
|
61361
|
+
if (trailing.every(madeNoProgress)) {
|
|
61362
|
+
return `no finding resolved for ${threshold} consecutive iteration(s); ${trailing.at(-1)?.findingsBefore.length ?? 0} finding(s) persisted`;
|
|
61363
|
+
}
|
|
61364
|
+
}
|
|
61365
|
+
if (!isUserBail && innerBail)
|
|
61366
|
+
return innerBail(iterations);
|
|
61367
|
+
return null;
|
|
61368
|
+
})
|
|
61369
|
+
};
|
|
61370
|
+
});
|
|
61371
|
+
}
|
|
61372
|
+
var init_no_progress_bail = __esm(() => {
|
|
61373
|
+
init_findings();
|
|
61374
|
+
});
|
|
61375
|
+
|
|
61238
61376
|
// src/execution/checkpoint/resume-plan.ts
|
|
61239
61377
|
function buildResumePlan(cp, current) {
|
|
61240
61378
|
if (!cp) {
|
|
@@ -61767,7 +61905,7 @@ function withIncreasingFailuresBail(strategies, enabled, consecutiveIncreases) {
|
|
|
61767
61905
|
const threshold = Math.max(1, consecutiveIncreases);
|
|
61768
61906
|
return strategies.map((strategy) => ({
|
|
61769
61907
|
...strategy,
|
|
61770
|
-
bailWhen: (iterations) => {
|
|
61908
|
+
bailWhen: markNaxBailWrapper((iterations) => {
|
|
61771
61909
|
const userReason = strategy.bailWhen?.(iterations) ?? null;
|
|
61772
61910
|
if (userReason !== null)
|
|
61773
61911
|
return userReason;
|
|
@@ -61781,7 +61919,7 @@ function withIncreasingFailuresBail(strategies, enabled, consecutiveIncreases) {
|
|
|
61781
61919
|
return `failure count increased for ${threshold} consecutive iteration(s): ${first.findingsBefore.length} -> ${last.findingsAfter.length}`;
|
|
61782
61920
|
}
|
|
61783
61921
|
return null;
|
|
61784
|
-
}
|
|
61922
|
+
})
|
|
61785
61923
|
}));
|
|
61786
61924
|
}
|
|
61787
61925
|
var _storyOrchestratorDeps, ALL_FINDING_SEVERITIES;
|
|
@@ -61947,6 +62085,12 @@ async function runRectification(ctx, state, phaseCosts, phaseOutputs, overrides)
|
|
|
61947
62085
|
if (!ctx.storyId) {
|
|
61948
62086
|
return {};
|
|
61949
62087
|
}
|
|
62088
|
+
const storyFixBudgetEnabled = !nbfPath && ctx.runtime.configLoader.current().execution?.rectification?.storyScopedFixBudget === true;
|
|
62089
|
+
const store = ctx.runtime.storyFixHistory;
|
|
62090
|
+
const fixKey = storyFixBudgetEnabled ? storyFixKey(ctx.storyId, ctx.phaseTelemetry?.tier) : undefined;
|
|
62091
|
+
const fixState = fixKey !== undefined && store ? getStoryFixState(store, fixKey) : undefined;
|
|
62092
|
+
const priorIterationCount = fixState?.iterations.length ?? 0;
|
|
62093
|
+
const declineSnapshot = fixState ? new Map([...fixState.declines].map(([name, keys]) => [name, new Set(keys)])) : undefined;
|
|
61950
62094
|
const fixOpPhaseOutputs = {};
|
|
61951
62095
|
const wrappedCallOp = async (cycleCtx, op, input) => {
|
|
61952
62096
|
const slot = { op, input };
|
|
@@ -61955,7 +62099,8 @@ async function runRectification(ctx, state, phaseCosts, phaseOutputs, overrides)
|
|
|
61955
62099
|
const cycle = {
|
|
61956
62100
|
findings: [...initialFindings],
|
|
61957
62101
|
iterations: [],
|
|
61958
|
-
|
|
62102
|
+
priorIterations: fixState?.iterations,
|
|
62103
|
+
strategies: withNoProgressBail(withIncreasingFailuresBail(overrides?.strategies ?? rectification2.strategies, rectification2.abortOnIncreasingFailures, rectification2.consecutiveIncreasesToBail ?? 1), rectification2.abortOnNoProgress ?? true, rectification2.consecutiveNoProgressToBail ?? 3),
|
|
61959
62104
|
config: { maxAttemptsTotal: overrides?.maxAttempts ?? rectification2.maxAttempts, validatorRetries: 1 },
|
|
61960
62105
|
validate: async (_validateCtx, opts) => {
|
|
61961
62106
|
if (ctx.runtime.signal?.aborted)
|
|
@@ -62016,15 +62161,22 @@ async function runRectification(ctx, state, phaseCosts, phaseOutputs, overrides)
|
|
|
62016
62161
|
return { findings: validated, shortCircuited };
|
|
62017
62162
|
}
|
|
62018
62163
|
};
|
|
62019
|
-
const cycleResult = await _storyOrchestratorDeps.runFixCycle(cycle, ctx, "story-orchestrator-rectification", { callOp: wrappedCallOp });
|
|
62164
|
+
const cycleResult = await _storyOrchestratorDeps.runFixCycle(cycle, ctx, "story-orchestrator-rectification", { callOp: wrappedCallOp, declineBacking: declineSnapshot });
|
|
62165
|
+
if (fixKey !== undefined && store && cycleResult.iterations.length > 0) {
|
|
62166
|
+
appendStoryFixIterations(store, fixKey, cycleResult.iterations);
|
|
62167
|
+
}
|
|
62168
|
+
if (fixKey !== undefined && store && declineSnapshot) {
|
|
62169
|
+
mergeStoryFixDeclines(store, fixKey, declineSnapshot);
|
|
62170
|
+
}
|
|
62020
62171
|
const oscillationCount = countOscillationOutcomes(cycleResult.iterations);
|
|
62021
62172
|
if (oscillationCount > 0) {
|
|
62022
62173
|
recordOscillations(ctx.runtime.rectificationOscillations, ctx.storyId, oscillationCount);
|
|
62023
62174
|
}
|
|
62175
|
+
const reportedExitReason = cycleResult.exitReason === "validate-short-circuit" && priorIterationCount > 0 ? "max-attempts-per-strategy" : cycleResult.exitReason;
|
|
62024
62176
|
phaseOutputs.rectification = {
|
|
62025
62177
|
success: cycleResult.exitReason === "resolved",
|
|
62026
62178
|
iterationCount: cycleResult.iterations.length,
|
|
62027
|
-
exitReason:
|
|
62179
|
+
exitReason: reportedExitReason,
|
|
62028
62180
|
finalFindingsCount: cycleResult.finalFindings.length
|
|
62029
62181
|
};
|
|
62030
62182
|
const rectLogger = getSafeLogger();
|
|
@@ -62033,7 +62185,7 @@ async function runRectification(ctx, state, phaseCosts, phaseOutputs, overrides)
|
|
|
62033
62185
|
initialFindingsCount: initialFindings.length,
|
|
62034
62186
|
iterationCount: cycleResult.iterations.length,
|
|
62035
62187
|
finalFindingsCount: cycleResult.finalFindings.length,
|
|
62036
|
-
exitReason:
|
|
62188
|
+
exitReason: reportedExitReason,
|
|
62037
62189
|
costUsd: cycleResult.costUsd
|
|
62038
62190
|
};
|
|
62039
62191
|
if (cycleResult.exitReason === "resolved") {
|
|
@@ -62059,8 +62211,10 @@ async function runRectification(ctx, state, phaseCosts, phaseOutputs, overrides)
|
|
|
62059
62211
|
return {};
|
|
62060
62212
|
}
|
|
62061
62213
|
var init_rectification = __esm(() => {
|
|
62214
|
+
init_findings();
|
|
62062
62215
|
init_logger2();
|
|
62063
62216
|
init_nbf_flake_triage();
|
|
62217
|
+
init_no_progress_bail();
|
|
62064
62218
|
init_phase_eval();
|
|
62065
62219
|
init_phase_eval();
|
|
62066
62220
|
init_run_phase();
|
|
@@ -62414,6 +62568,7 @@ var init_story_orchestrator = __esm(() => {
|
|
|
62414
62568
|
init_phase_eval();
|
|
62415
62569
|
init_rectification();
|
|
62416
62570
|
init_nbf_flake_triage();
|
|
62571
|
+
init_no_progress_bail();
|
|
62417
62572
|
init_run_phase();
|
|
62418
62573
|
init_review_decision();
|
|
62419
62574
|
init_types9();
|
|
@@ -62834,7 +62989,9 @@ async function assemblePlanInputsFromCtx(ctx) {
|
|
|
62834
62989
|
maxAttempts: ctx.config.execution.rectification.maxAttemptsTotal,
|
|
62835
62990
|
strategies: [],
|
|
62836
62991
|
abortOnIncreasingFailures: ctx.config.execution.rectification.abortOnIncreasingFailures,
|
|
62837
|
-
consecutiveIncreasesToBail: ctx.config.execution.rectification.consecutiveIncreasesToBail
|
|
62992
|
+
consecutiveIncreasesToBail: ctx.config.execution.rectification.consecutiveIncreasesToBail,
|
|
62993
|
+
abortOnNoProgress: ctx.config.execution.rectification.abortOnNoProgress,
|
|
62994
|
+
consecutiveNoProgressToBail: ctx.config.execution.rectification.consecutiveNoProgressToBail
|
|
62838
62995
|
} : undefined;
|
|
62839
62996
|
const mutationCheckInput = ctx.config.execution?.mutationCheck?.enabled === true ? {
|
|
62840
62997
|
story,
|
|
@@ -76378,6 +76535,7 @@ var init_lifecycle = __esm(() => {
|
|
|
76378
76535
|
var exports_execution = {};
|
|
76379
76536
|
__export(exports_execution, {
|
|
76380
76537
|
writeExitSummary: () => writeExitSummary,
|
|
76538
|
+
withNoProgressBail: () => withNoProgressBail,
|
|
76381
76539
|
withIncreasingFailuresBail: () => withIncreasingFailuresBail,
|
|
76382
76540
|
toReviewDecisionPayload: () => toReviewDecisionPayload,
|
|
76383
76541
|
synthesizeBackfillMetric: () => synthesizeBackfillMetric,
|
|
@@ -106619,8 +106777,12 @@ function buildPlanComposition(userStageConfig) {
|
|
|
106619
106777
|
|
|
106620
106778
|
// src/plan/strategies/write-prd.ts
|
|
106621
106779
|
init_errors();
|
|
106780
|
+
init_logger2();
|
|
106622
106781
|
init_prd();
|
|
106623
106782
|
|
|
106783
|
+
// src/plan/strategies/persist-prd.ts
|
|
106784
|
+
init_operations();
|
|
106785
|
+
|
|
106624
106786
|
// src/plan/strategies/finalize-routing.ts
|
|
106625
106787
|
init_agents();
|
|
106626
106788
|
function finalizePrdRouting(prd, agentRouting, profileName) {
|
|
@@ -106642,6 +106804,26 @@ function finalizePrdRouting(prd, agentRouting, profileName) {
|
|
|
106642
106804
|
return { ...prd, userStories, routingProfile: profileName ?? "default" };
|
|
106643
106805
|
}
|
|
106644
106806
|
|
|
106807
|
+
// src/plan/strategies/persist-prd.ts
|
|
106808
|
+
async function finalizeAndWritePrd(args) {
|
|
106809
|
+
const repaired = applyPlanFidelity(args.prd, args.specContent, args.featureName);
|
|
106810
|
+
const finalized = finalizePrdRouting({ ...repaired, project: args.projectName }, args.agentRouting, args.profileName);
|
|
106811
|
+
await args.writeFile(args.outputPath, JSON.stringify(finalized, null, 2));
|
|
106812
|
+
return args.outputPath;
|
|
106813
|
+
}
|
|
106814
|
+
async function persistPrd(ctx, prd) {
|
|
106815
|
+
return finalizeAndWritePrd({
|
|
106816
|
+
prd,
|
|
106817
|
+
specContent: ctx.specContent,
|
|
106818
|
+
featureName: ctx.options.feature,
|
|
106819
|
+
projectName: ctx.projectName,
|
|
106820
|
+
agentRouting: ctx.config.routing?.agents,
|
|
106821
|
+
profileName: ctx.profileName,
|
|
106822
|
+
outputPath: ctx.outputPath,
|
|
106823
|
+
writeFile: ctx.deps.writeFile
|
|
106824
|
+
});
|
|
106825
|
+
}
|
|
106826
|
+
|
|
106645
106827
|
// src/plan/strategies/write-prd.ts
|
|
106646
106828
|
async function writeOrRecoverPrd(ctx, prd, err) {
|
|
106647
106829
|
const tryExtractPrd = (value) => {
|
|
@@ -106661,15 +106843,11 @@ async function writeOrRecoverPrd(ctx, prd, err) {
|
|
|
106661
106843
|
};
|
|
106662
106844
|
if (prd !== null) {
|
|
106663
106845
|
if (Array.isArray(prd.userStories)) {
|
|
106664
|
-
|
|
106665
|
-
await ctx.deps.writeFile(ctx.outputPath, JSON.stringify(finalized, null, 2));
|
|
106666
|
-
return ctx.outputPath;
|
|
106846
|
+
return { outputPath: await persistPrd(ctx, prd) };
|
|
106667
106847
|
}
|
|
106668
106848
|
const normalizedPrd = tryExtractPrd(prd);
|
|
106669
106849
|
if (normalizedPrd !== null) {
|
|
106670
|
-
|
|
106671
|
-
await ctx.deps.writeFile(ctx.outputPath, JSON.stringify(finalized, null, 2));
|
|
106672
|
-
return ctx.outputPath;
|
|
106850
|
+
return { outputPath: await persistPrd(ctx, normalizedPrd) };
|
|
106673
106851
|
}
|
|
106674
106852
|
}
|
|
106675
106853
|
if (err === undefined) {
|
|
@@ -106688,9 +106866,13 @@ async function writeOrRecoverPrd(ctx, prd, err) {
|
|
|
106688
106866
|
}
|
|
106689
106867
|
}
|
|
106690
106868
|
recoveredPrd = recoveredPrd ?? validatePlanOutput(rawContent, ctx.options.feature, ctx.branchName);
|
|
106691
|
-
const
|
|
106692
|
-
|
|
106693
|
-
|
|
106869
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
106870
|
+
getSafeLogger()?.warn("plan", "PRD recovered from disk after a plan failure \u2014 result is degraded", {
|
|
106871
|
+
featureName: ctx.options.feature,
|
|
106872
|
+
outputPath: ctx.outputPath,
|
|
106873
|
+
error: reason
|
|
106874
|
+
});
|
|
106875
|
+
return { outputPath: await persistPrd(ctx, recoveredPrd), degraded: { reason } };
|
|
106694
106876
|
} catch {
|
|
106695
106877
|
throw err;
|
|
106696
106878
|
}
|
|
@@ -106747,9 +106929,7 @@ class DebatePlanStrategy {
|
|
|
106747
106929
|
});
|
|
106748
106930
|
if (debateResult.outcome !== "failed" && debateResult.output) {
|
|
106749
106931
|
const prd2 = validatePlanOutput(debateResult.output, ctx.options.feature, ctx.branchName);
|
|
106750
|
-
|
|
106751
|
-
const withProject2 = { ...scoped2, project: ctx.projectName };
|
|
106752
|
-
return _debatePlanDeps.writeOrRecoverPrd(ctx, withProject2);
|
|
106932
|
+
return _debatePlanDeps.writeOrRecoverPrd(ctx, prd2);
|
|
106753
106933
|
}
|
|
106754
106934
|
const prd = await callOp({
|
|
106755
106935
|
...callCtx,
|
|
@@ -106766,8 +106946,7 @@ class DebatePlanStrategy {
|
|
|
106766
106946
|
projectProfile: ctx.config.project
|
|
106767
106947
|
});
|
|
106768
106948
|
assertIsValidPrd(prd);
|
|
106769
|
-
|
|
106770
|
-
return _debatePlanDeps.writeOrRecoverPrd(ctx, withProject);
|
|
106949
|
+
return _debatePlanDeps.writeOrRecoverPrd(ctx, prd);
|
|
106771
106950
|
} catch (err) {
|
|
106772
106951
|
return _debatePlanDeps.writeOrRecoverPrd(ctx, null, err);
|
|
106773
106952
|
} finally {
|
|
@@ -106846,10 +107025,7 @@ class PipelinePlanStrategy {
|
|
|
106846
107025
|
if (verdict.outcome !== "passed") {
|
|
106847
107026
|
throw new NaxError(verdict.specDeltasPath ? `Plan pipeline failed; see ${verdict.specDeltasPath}` : "Plan pipeline failed with no spec-deltas path", "PLAN_CRITIC_BLOCKED", { stage: "plan", specDeltasPath: verdict.specDeltasPath });
|
|
106848
107027
|
}
|
|
106849
|
-
|
|
106850
|
-
const prdToWrite = finalizePrdRouting({ ...scoped2, project: ctx.projectName }, ctx.config.routing?.agents, ctx.profileName);
|
|
106851
|
-
await ctx.deps.writeFile(ctx.outputPath, JSON.stringify(prdToWrite, null, 2));
|
|
106852
|
-
return ctx.outputPath;
|
|
107028
|
+
return { outputPath: await persistPrd(ctx, verdict.prd) };
|
|
106853
107029
|
} finally {
|
|
106854
107030
|
await ctx.runtime.close().catch(() => {});
|
|
106855
107031
|
}
|
|
@@ -106898,6 +107074,7 @@ class RefinePlanStrategy {
|
|
|
106898
107074
|
}
|
|
106899
107075
|
|
|
106900
107076
|
// src/plan/strategies/single.ts
|
|
107077
|
+
init_logger2();
|
|
106901
107078
|
init_operations();
|
|
106902
107079
|
init_prd();
|
|
106903
107080
|
var _singlePlanDeps = {
|
|
@@ -106929,16 +107106,18 @@ class SinglePlanStrategy {
|
|
|
106929
107106
|
projectProfile: ctx.config.project
|
|
106930
107107
|
});
|
|
106931
107108
|
assertIsValidPrd(prd);
|
|
106932
|
-
|
|
106933
|
-
await ctx.deps.writeFile(ctx.outputPath, JSON.stringify(finalized, null, 2));
|
|
106934
|
-
return ctx.outputPath;
|
|
107109
|
+
return { outputPath: await persistPrd(ctx, prd) };
|
|
106935
107110
|
} catch (err) {
|
|
106936
107111
|
if (ctx.deps.existsSync(ctx.outputPath)) {
|
|
106937
107112
|
const rawContent = await ctx.deps.readFile(ctx.outputPath);
|
|
106938
107113
|
const recoveredPrd = validatePlanOutput(rawContent, ctx.options.feature, ctx.branchName);
|
|
106939
|
-
const
|
|
106940
|
-
|
|
106941
|
-
|
|
107114
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
107115
|
+
getSafeLogger()?.warn("plan", "PRD recovered from disk after a plan failure \u2014 result is degraded", {
|
|
107116
|
+
featureName: ctx.options.feature,
|
|
107117
|
+
outputPath: ctx.outputPath,
|
|
107118
|
+
error: reason
|
|
107119
|
+
});
|
|
107120
|
+
return { outputPath: await persistPrd(ctx, recoveredPrd), degraded: { reason } };
|
|
106942
107121
|
}
|
|
106943
107122
|
throw err;
|
|
106944
107123
|
} finally {
|
|
@@ -107547,6 +107726,8 @@ var FIELD_DESCRIPTIONS = {
|
|
|
107547
107726
|
"execution.rectification.maxFailureSummaryChars": "Max characters in failure summary",
|
|
107548
107727
|
"execution.rectification.abortOnIncreasingFailures": "Abort if failure count increases",
|
|
107549
107728
|
"execution.rectification.consecutiveIncreasesToBail": "Consecutive regressing iterations required before abortOnIncreasingFailures bails (default: 2; 1 = legacy behaviour)",
|
|
107729
|
+
"execution.rectification.abortOnNoProgress": "Abort rectification when no progress is made for several consecutive iterations (default: true)",
|
|
107730
|
+
"execution.rectification.consecutiveNoProgressToBail": "Consecutive no-progress iterations required before abortOnNoProgress bails (default: 3; one higher than the count bail's 2 because the no-progress predicate fires on a wider shape)",
|
|
107550
107731
|
"execution.rectification.escalateOnExhaustion": "Enable model tier escalation when attempts are exhausted with remaining failures",
|
|
107551
107732
|
"execution.rectification.rethinkAtAttempt": "Attempt number at which 'rethink your approach' language is injected into the prompt (default: 2)",
|
|
107552
107733
|
"execution.rectification.urgencyAtAttempt": "Attempt number at which 'final chance before escalation' urgency is added (default: 3)",
|
|
@@ -118060,6 +118241,14 @@ program2.name("nax").description("AI Coding Agent Orchestrator \u2014 loops unti
|
|
|
118060
118241
|
function collectProfile(value, previous) {
|
|
118061
118242
|
return previous.concat(value);
|
|
118062
118243
|
}
|
|
118244
|
+
function warnIfPlanDegraded(result2) {
|
|
118245
|
+
if (!result2.degraded)
|
|
118246
|
+
return;
|
|
118247
|
+
console.log(source_default.yellow(`
|
|
118248
|
+
[WARN] PRD recovered after a plan failure \u2014 this is a degraded result`));
|
|
118249
|
+
console.log(source_default.dim(` Cause: ${result2.degraded.reason}`));
|
|
118250
|
+
console.log(source_default.dim(" Deterministic spec->PRD repairs were re-applied, but review the PRD before running."));
|
|
118251
|
+
}
|
|
118063
118252
|
async function promptForConfirmation(question) {
|
|
118064
118253
|
if (!process.stdin.isTTY) {
|
|
118065
118254
|
return true;
|
|
@@ -118381,12 +118570,14 @@ program2.command("run").description("Run the orchestration loop for a feature").
|
|
|
118381
118570
|
initLogger({ level: "info", filePath: planLogPath, useChalk: false, headless: true });
|
|
118382
118571
|
console.log(source_default.dim(` [Plan log: ${planLogPath}]`));
|
|
118383
118572
|
console.log(source_default.dim(" [Planning phase: generating PRD from spec]"));
|
|
118384
|
-
const
|
|
118573
|
+
const planResult = await planCommand(workdir, config2, {
|
|
118385
118574
|
from: options.from,
|
|
118386
118575
|
feature: options.feature,
|
|
118387
118576
|
auto: options.oneShot ?? false,
|
|
118388
118577
|
branch: undefined
|
|
118389
118578
|
});
|
|
118579
|
+
const generatedPrdPath = planResult.outputPath;
|
|
118580
|
+
warnIfPlanDegraded(planResult);
|
|
118390
118581
|
const generatedPrd = await loadPRD(generatedPrdPath);
|
|
118391
118582
|
await runReplanLoop(workdir, config2, {
|
|
118392
118583
|
feature: options.feature,
|
|
@@ -118805,15 +118996,16 @@ Use: nax plan -f <feature> --from <spec>`));
|
|
|
118805
118996
|
console.error(source_default.red("Error: --from <spec-path> is required unless --decompose is used"));
|
|
118806
118997
|
process.exit(1);
|
|
118807
118998
|
}
|
|
118808
|
-
const
|
|
118999
|
+
const planResult = await planCommand(workdir, config2, {
|
|
118809
119000
|
from: options.from,
|
|
118810
119001
|
feature: options.feature,
|
|
118811
119002
|
auto: options.auto || options.oneShot,
|
|
118812
119003
|
branch: options.branch
|
|
118813
119004
|
});
|
|
119005
|
+
warnIfPlanDegraded(planResult);
|
|
118814
119006
|
console.log(source_default.green(`
|
|
118815
119007
|
[OK] PRD generated`));
|
|
118816
|
-
console.log(source_default.dim(` PRD: ${
|
|
119008
|
+
console.log(source_default.dim(` PRD: ${planResult.outputPath}`));
|
|
118817
119009
|
console.log(source_default.dim(` Log: ${planLogPath}`));
|
|
118818
119010
|
console.log(source_default.dim(`
|
|
118819
119011
|
Next: nax run -f ${options.feature}`));
|
|
@@ -54,10 +54,28 @@ export function loadCtxOf(ctx: OutputsCtx): LoadCtxOutput {
|
|
|
54
54
|
* Absent when the node was skipped by config, died, or produced only
|
|
55
55
|
* whitespace — `amend_body` treats all three identically, so there is one
|
|
56
56
|
* branch downstream rather than three.
|
|
57
|
+
*
|
|
58
|
+
* Accepts the bare string the node used to return as well as the
|
|
59
|
+
* `{ narrative, title }` it returns now: a flow resumed from a run recorded
|
|
60
|
+
* before the title landed replays the old shape from its journal.
|
|
57
61
|
*/
|
|
58
62
|
export function narrativeOf(ctx: OutputsCtx): string | undefined {
|
|
59
63
|
const out = (ctx.outputs as Record<string, unknown>).narrative;
|
|
60
|
-
|
|
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 };
|