@nathapp/nax 0.80.1 → 0.81.0-canary.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 +44737 -42009
- package/package.json +6 -6
- package/flows/nax-finish/commit-message.ts +0 -239
- package/flows/nax-finish/errors.ts +0 -23
- package/flows/nax-finish/exec.ts +0 -135
- package/flows/nax-finish/findings-parse.ts +0 -150
- package/flows/nax-finish/flow-ctx.ts +0 -150
- package/flows/nax-finish/narrative.ts +0 -215
- package/flows/nax-finish/nax-finish.flow.ts +0 -566
- package/flows/nax-finish/pr-template-merge.ts +0 -253
- package/flows/nax-finish/pr-template.ts +0 -56
- package/flows/nax-finish/pr-title.ts +0 -140
- package/flows/nax-finish/review-prompts.ts +0 -468
- package/flows/nax-finish/steps/acceptance.ts +0 -76
- package/flows/nax-finish/steps/commit-round.ts +0 -67
- package/flows/nax-finish/steps/context.ts +0 -158
- package/flows/nax-finish/steps/escalate.ts +0 -93
- package/flows/nax-finish/steps/forge.ts +0 -93
- package/flows/nax-finish/steps/gates.ts +0 -183
- package/flows/nax-finish/steps/git.ts +0 -130
- package/flows/nax-finish/steps/index.ts +0 -13
- package/flows/nax-finish/steps/pr-body.ts +0 -462
- package/flows/nax-finish/steps/pr-narrative.ts +0 -46
- package/flows/nax-finish/steps/pr.ts +0 -116
- package/flows/nax-finish/steps/quality.ts +0 -102
- package/flows/nax-finish/steps/result.ts +0 -112
- package/flows/nax-finish/steps/review-audit.ts +0 -91
- package/flows/nax-finish/steps/review-round.ts +0 -109
- package/flows/nax-finish/types.ts +0 -260
- package/flows/nax-finish/verdict.ts +0 -210
|
@@ -1,566 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* nax-finish: autonomous acpx flow driving a completed feature branch to a
|
|
3
|
-
* ready PR (or an escalation) — acceptance gate, spec review, quality
|
|
4
|
-
* review, quality gates, each iterated with an LLM fix-and-reverify loop.
|
|
5
|
-
*
|
|
6
|
-
* Reviewer agent profiles (review_spec / review_quality) are read from
|
|
7
|
-
* NAX_FINISH_SPEC_PROFILE / NAX_FINISH_QUALITY_PROFILE at module load time.
|
|
8
|
-
* The post-run plugin that invokes `acpx flow run` sets these env vars from
|
|
9
|
-
* `finish.autoFlow.reviewers.{spec,quality}` config before spawning, since
|
|
10
|
-
* this flow module reloads fresh on every `acpx flow run` invocation.
|
|
11
|
-
* Unset → both fall back to acpx's `--default-agent`.
|
|
12
|
-
*
|
|
13
|
-
* This module is loaded by acpx, from wherever `flows/` is installed, with the
|
|
14
|
-
* user's repo as cwd — so it never imports from nax's `src/` (see ./errors.ts).
|
|
15
|
-
*
|
|
16
|
-
* Graph shape (deviations from the design doc are deliberate and noted):
|
|
17
|
-
* - `load_ctx` is an `action`, not a `compute`: it shells git + `nax features
|
|
18
|
-
* resolve` once, and its output feeds both the review prompts (specPath) and
|
|
19
|
-
* the acceptance gate (groups), so nothing resolves the feature twice.
|
|
20
|
-
* - Review fixes loop: `review_* → route_* → fix_* → commit_* → (re-run
|
|
21
|
-
* acceptance | re-review) → review_*` until the reviewer comes back clean or
|
|
22
|
-
* the fix cap trips. A single-shot fix left the fixed diff unverified.
|
|
23
|
-
* - Every `fix_*` node is followed by a `commit_*` node. The reviewers read
|
|
24
|
-
* `git diff <base>...HEAD`, so an uncommitted fix is invisible to the
|
|
25
|
-
* re-review: the loop re-reported findings that were already fixed and always
|
|
26
|
-
* escalated at the cap (issue #1397).
|
|
27
|
-
* - `route_*` compute nodes hold the escalate/clean/fix decision so the cap is
|
|
28
|
-
* enforced deterministically rather than trusting the model's own route.
|
|
29
|
-
* - `quality_gates` re-runs the feature's acceptance tests before the repo's
|
|
30
|
-
* own commands. The quality-review and gate fix loops both edit code after
|
|
31
|
-
* the `acceptance` node last passed, and the repo-root `test` command does
|
|
32
|
-
* not cover per-feature acceptance tests — so without this a fix could break
|
|
33
|
-
* the contract the first gate proved and still ship.
|
|
34
|
-
* - `commit_gate` re-enters `review_quality` whenever its fix committed — the
|
|
35
|
-
* gate loop was otherwise the one editing loop whose output faced only
|
|
36
|
-
* mechanical checks, which a fix that degrades the tests it repairs will
|
|
37
|
-
* satisfy. A test-only fix used to skip the re-review as a cost tradeoff;
|
|
38
|
-
* #1510 closed that hole. See `gateCommitRoute`.
|
|
39
|
-
* - `load_ctx` and `open_pr` both route to `escalate` rather than throwing on
|
|
40
|
-
* their own failures. acpx has no error edge, so a throw ends the run with no
|
|
41
|
-
* result file for the plugin to read or notify from — the one outcome that
|
|
42
|
-
* must always be reported is "a human is needed" (#1399). `open_pr` matters
|
|
43
|
-
* most: it is reached only after every gate is green, so a failed push or
|
|
44
|
-
* forge call there discards a completed, verified run.
|
|
45
|
-
* - Every `commit_*` node appends its round to the finish-audit trail as it
|
|
46
|
-
* happens, rather than a terminal node reconstructing them from
|
|
47
|
-
* `ctx.state.steps`. Appending live is what makes the trail survive a flow
|
|
48
|
-
* that is killed or times out — no terminal node runs on that path, and a
|
|
49
|
-
* crashed finish is exactly when the record of what it changed matters most.
|
|
50
|
-
*/
|
|
51
|
-
import { defineFlow } from "acpx/flows";
|
|
52
|
-
import { buildFixCommitMessage } from "./commit-message";
|
|
53
|
-
import {
|
|
54
|
-
findingsOf,
|
|
55
|
-
fixAttemptCount,
|
|
56
|
-
gateOutputs,
|
|
57
|
-
incrementalSince,
|
|
58
|
-
inputOf,
|
|
59
|
-
loadCtxOf,
|
|
60
|
-
reviewGapsOf,
|
|
61
|
-
} from "./flow-ctx";
|
|
62
|
-
import { narrativePrompt, parseNarrativeNode } from "./narrative";
|
|
63
|
-
import { buildReviewPrompt, fixPrompt } from "./review-prompts";
|
|
64
|
-
import {
|
|
65
|
-
_contextDeps,
|
|
66
|
-
acceptanceGateNode,
|
|
67
|
-
amendPrBodyNode,
|
|
68
|
-
appendRound,
|
|
69
|
-
buildCommitRound,
|
|
70
|
-
buildEscalationComment,
|
|
71
|
-
commitAndPush,
|
|
72
|
-
commitFixes,
|
|
73
|
-
detectBaseBranch,
|
|
74
|
-
detectForge,
|
|
75
|
-
filesInCommit,
|
|
76
|
-
loadFinishPrContext,
|
|
77
|
-
openOrPromotePr,
|
|
78
|
-
partitionTestFiles,
|
|
79
|
-
postEscalation,
|
|
80
|
-
preflight,
|
|
81
|
-
qualityGatesNode,
|
|
82
|
-
resolveFeature,
|
|
83
|
-
routeReviewAndRecord,
|
|
84
|
-
validateDispositions,
|
|
85
|
-
writeResult,
|
|
86
|
-
} from "./steps";
|
|
87
|
-
import type { Forge } from "./steps/forge";
|
|
88
|
-
import { _prBodyDeps, buildFinishBody, buildFinishTitle } from "./steps/pr-body";
|
|
89
|
-
import type { FindingDisposition, FinishInput, FinishPhase, FinishResult, ReviewVerdict } from "./types";
|
|
90
|
-
import { parseFixVerdict, parseReviewVerdict } from "./verdict";
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Disabled only on an explicit "0". An unset variable means enabled, so a flow
|
|
94
|
-
* invoked directly by `acpx flow run` — outside the plugin that sets the env —
|
|
95
|
-
* still writes the narrative.
|
|
96
|
-
*/
|
|
97
|
-
const NARRATIVE_ENABLED = process.env.NAX_FINISH_NARRATIVE !== "0";
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Injectable seam for the `open_pr` node's title/body assembly — tests stub
|
|
101
|
-
* these to control the fallback-vs-built-metadata paths without a real PRD or
|
|
102
|
-
* git checkout.
|
|
103
|
-
*/
|
|
104
|
-
export const _openPrDeps = {
|
|
105
|
-
loadFinishPrContext,
|
|
106
|
-
buildFinishTitle,
|
|
107
|
-
buildFinishBody,
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Route for `commit_gate`, whose successor depends on what the fix touched.
|
|
112
|
-
*
|
|
113
|
-
* - `unchanged` — nothing committed; no new diff, so nothing to review.
|
|
114
|
-
* - `tests-only` — every touched path matched the repo's test-file patterns.
|
|
115
|
-
* - `changed` — production code was touched, or the paths could not be
|
|
116
|
-
* classified at all. "Cannot classify" reviews rather than skips.
|
|
117
|
-
*
|
|
118
|
-
* `tests-only` and `changed` both re-enter `review_quality` (#1510). They used
|
|
119
|
-
* to diverge: `tests-only` skipped the re-review as a cost tradeoff, on the
|
|
120
|
-
* reasoning that a gate fix is usually a mechanical test repair. That was a
|
|
121
|
-
* real hole — the defect that motivated the re-entry was itself test-only, 8
|
|
122
|
-
* copy-pasted stubs across 3 test files, so the skip would not have caught the
|
|
123
|
-
* very thing it was built for. A gate fix can turn a red suite green by
|
|
124
|
-
* degrading the tests it repairs, and `quality_gates` is satisfied by exactly
|
|
125
|
-
* that; nothing else read that diff. The audit settled the cost side: across
|
|
126
|
-
* every finish recorded, exactly one `gate` round has ever fired, so the skip
|
|
127
|
-
* was saving a review that almost never runs.
|
|
128
|
-
*
|
|
129
|
-
* The classification is kept even though both routes now review. It is what a
|
|
130
|
-
* cheaper test-quality-scoped reviewer would key off if gate rounds ever become
|
|
131
|
-
* frequent enough for the full re-review to hurt.
|
|
132
|
-
*/
|
|
133
|
-
async function gateCommitRoute(
|
|
134
|
-
i: FinishInput,
|
|
135
|
-
committed: boolean,
|
|
136
|
-
shaAfter: string | null,
|
|
137
|
-
testFileRegex: string[],
|
|
138
|
-
): Promise<string> {
|
|
139
|
-
if (!committed) return "unchanged";
|
|
140
|
-
// Committed, but HEAD did not resolve: the fix is real and unclassifiable, so
|
|
141
|
-
// it must be reviewed. Folding this into the `!committed` branch would skip
|
|
142
|
-
// the review for a change that actually landed — the one direction this
|
|
143
|
-
// function must never fail in.
|
|
144
|
-
if (!shaAfter) return "changed";
|
|
145
|
-
const files = await filesInCommit(i.workdir, shaAfter);
|
|
146
|
-
if (files.length === 0) return "changed";
|
|
147
|
-
return partitionTestFiles(files, testFileRegex).nonTest.length > 0 ? "changed" : "tests-only";
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Build the `commit_<phase>` node that follows `fix_<phase>`.
|
|
152
|
-
*
|
|
153
|
-
* One node per phase rather than a single shared one because each returns to a
|
|
154
|
-
* different successor, and acpx routes on the node id — a shared node would
|
|
155
|
-
* need a switch reconstructing which fix ran from the step history.
|
|
156
|
-
*
|
|
157
|
-
* Also the audit seam: this is the only point in the graph where a round's
|
|
158
|
-
* findings and its commit are both known. `ctx.outputs` keeps only the latest
|
|
159
|
-
* output per node, so a round not recorded here is a round no terminal node
|
|
160
|
-
* can reconstruct. `shaBefore` is recorded for the same reason — it is what the
|
|
161
|
-
* next review of this phase diffs from (see `incrementalSince`).
|
|
162
|
-
*/
|
|
163
|
-
function commitFixNode(phase: FinishPhase) {
|
|
164
|
-
return {
|
|
165
|
-
nodeType: "action" as const,
|
|
166
|
-
async run(ctx: {
|
|
167
|
-
input: unknown;
|
|
168
|
-
outputs: unknown;
|
|
169
|
-
state: { steps: { nodeId: string }[] };
|
|
170
|
-
}): Promise<{ committed: boolean; route: string; shaBefore: string | null; shaAfter: string | null }> {
|
|
171
|
-
const i = inputOf(ctx);
|
|
172
|
-
const messageCtx = { outputs: ctx.outputs as Record<string, unknown> };
|
|
173
|
-
// A rejection is only as good as its citation, so the path is checked the
|
|
174
|
-
// same way a reviewer's touchpoints are. A missing file does not veto the
|
|
175
|
-
// rejection — the fixer may have cited a line rather than a path, or moved
|
|
176
|
-
// the file — it marks it, so the PR body (and the commit message below)
|
|
177
|
-
// can say the waiver is unverified rather than silently presenting it as
|
|
178
|
-
// evidenced. Resolved before the commit so the shipped commit message can
|
|
179
|
-
// render a rejection the same way the PR body does, instead of `Fix: …`.
|
|
180
|
-
const dispositions = await validateDispositions(
|
|
181
|
-
i.workdir,
|
|
182
|
-
(ctx.outputs as Record<string, { dispositions?: FindingDisposition[] } | undefined>)[`fix_${phase}`]
|
|
183
|
-
?.dispositions ?? [],
|
|
184
|
-
);
|
|
185
|
-
// skipHooks: an intermediate checkpoint must not be rejected by a repo's
|
|
186
|
-
// pre-commit hook — quality_gates runs the repo's real gates before any
|
|
187
|
-
// PR opens, and a hook failure here would kill the flow mid-loop.
|
|
188
|
-
const { committed, shaBefore, shaAfter } = await commitFixes(
|
|
189
|
-
i.workdir,
|
|
190
|
-
buildFixCommitMessage(phase, i.feature, messageCtx, { workdir: i.workdir, dispositions }),
|
|
191
|
-
{ skipHooks: true },
|
|
192
|
-
);
|
|
193
|
-
// Routed BEFORE the round is recorded: `buildCommitRound` needs the
|
|
194
|
-
// successor to tell an owed-but-skipped re-review from a phase that never
|
|
195
|
-
// had a reviewer. Only `commit_gate` routes on this; the other phases have
|
|
196
|
-
// unconditional edges and ignore it.
|
|
197
|
-
const route =
|
|
198
|
-
phase === "gate"
|
|
199
|
-
? await gateCommitRoute(i, committed, shaAfter, loadCtxOf(ctx).testFileRegex ?? [])
|
|
200
|
-
: committed
|
|
201
|
-
? "changed"
|
|
202
|
-
: "unchanged";
|
|
203
|
-
await appendRound(
|
|
204
|
-
i,
|
|
205
|
-
buildCommitRound({
|
|
206
|
-
phase,
|
|
207
|
-
attempt: fixAttemptCount(ctx, `fix_${phase}`),
|
|
208
|
-
committed,
|
|
209
|
-
route,
|
|
210
|
-
findings: findingsOf(ctx, phase),
|
|
211
|
-
failing: phase === "gate" ? (gateOutputs(ctx).failing ?? []) : undefined,
|
|
212
|
-
shaAfter,
|
|
213
|
-
now: new Date().toISOString(),
|
|
214
|
-
dispositions,
|
|
215
|
-
}),
|
|
216
|
-
);
|
|
217
|
-
return { committed, route, shaBefore, shaAfter };
|
|
218
|
-
},
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
export default defineFlow({
|
|
223
|
-
name: "nax-finish",
|
|
224
|
-
permissions: {
|
|
225
|
-
requiredMode: "approve-all",
|
|
226
|
-
requireExplicitGrant: true,
|
|
227
|
-
reason: "This flow edits files, pushes commits, runs quality gates, comments on and opens/promotes PRs.",
|
|
228
|
-
},
|
|
229
|
-
startAt: "load_ctx",
|
|
230
|
-
nodes: {
|
|
231
|
-
load_ctx: {
|
|
232
|
-
nodeType: "action",
|
|
233
|
-
async run(ctx) {
|
|
234
|
-
const i = inputOf(ctx);
|
|
235
|
-
const base = await detectBaseBranch(i.workdir);
|
|
236
|
-
const resolution = await resolveFeature(i.feature, i.workdir);
|
|
237
|
-
const pf = await preflight(i.workdir, base);
|
|
238
|
-
return {
|
|
239
|
-
base,
|
|
240
|
-
specPath: resolution.specPath,
|
|
241
|
-
acceptanceStatus: resolution.acceptanceStatus,
|
|
242
|
-
groups: resolution.groups,
|
|
243
|
-
testFileRegex: resolution.testFileRegex,
|
|
244
|
-
commitsAhead: pf.commitsAhead,
|
|
245
|
-
route: pf.route,
|
|
246
|
-
...(pf.reason ? { reason: pf.reason } : {}),
|
|
247
|
-
};
|
|
248
|
-
},
|
|
249
|
-
},
|
|
250
|
-
acceptance: {
|
|
251
|
-
nodeType: "action",
|
|
252
|
-
run: acceptanceGateNode,
|
|
253
|
-
},
|
|
254
|
-
fix_acceptance: {
|
|
255
|
-
nodeType: "acp",
|
|
256
|
-
prompt: (ctx) => fixPrompt("acceptance", ctx),
|
|
257
|
-
parse: parseFixVerdict,
|
|
258
|
-
},
|
|
259
|
-
commit_acceptance: commitFixNode("acceptance"),
|
|
260
|
-
review_spec: {
|
|
261
|
-
nodeType: "acp",
|
|
262
|
-
session: { isolated: true },
|
|
263
|
-
profile: process.env.NAX_FINISH_SPEC_PROFILE || undefined,
|
|
264
|
-
prompt(ctx) {
|
|
265
|
-
const outs = loadCtxOf(ctx);
|
|
266
|
-
return buildReviewPrompt("spec", {
|
|
267
|
-
base: outs.base ?? "origin/main",
|
|
268
|
-
specPath: outs.specPath ?? "",
|
|
269
|
-
since: incrementalSince(ctx, "spec"),
|
|
270
|
-
priorFindings: findingsOf(ctx, "spec"),
|
|
271
|
-
gaps: reviewGapsOf(ctx, "spec"),
|
|
272
|
-
});
|
|
273
|
-
},
|
|
274
|
-
parse: parseReviewVerdict,
|
|
275
|
-
},
|
|
276
|
-
route_spec: {
|
|
277
|
-
nodeType: "compute",
|
|
278
|
-
run: (ctx) => routeReviewAndRecord(ctx, "spec"),
|
|
279
|
-
},
|
|
280
|
-
fix_spec: {
|
|
281
|
-
nodeType: "acp",
|
|
282
|
-
prompt: (ctx) => fixPrompt("spec", ctx),
|
|
283
|
-
parse: parseFixVerdict,
|
|
284
|
-
},
|
|
285
|
-
commit_spec: commitFixNode("spec"),
|
|
286
|
-
review_quality: {
|
|
287
|
-
nodeType: "acp",
|
|
288
|
-
session: { isolated: true },
|
|
289
|
-
profile: process.env.NAX_FINISH_QUALITY_PROFILE || undefined,
|
|
290
|
-
prompt(ctx) {
|
|
291
|
-
const outs = loadCtxOf(ctx);
|
|
292
|
-
return buildReviewPrompt("quality", {
|
|
293
|
-
base: outs.base ?? "origin/main",
|
|
294
|
-
specPath: outs.specPath ?? "",
|
|
295
|
-
since: incrementalSince(ctx, "quality"),
|
|
296
|
-
priorFindings: findingsOf(ctx, "quality"),
|
|
297
|
-
gaps: reviewGapsOf(ctx, "quality"),
|
|
298
|
-
});
|
|
299
|
-
},
|
|
300
|
-
parse: parseReviewVerdict,
|
|
301
|
-
},
|
|
302
|
-
route_quality: {
|
|
303
|
-
nodeType: "compute",
|
|
304
|
-
run: (ctx) => routeReviewAndRecord(ctx, "quality"),
|
|
305
|
-
},
|
|
306
|
-
fix_quality: {
|
|
307
|
-
nodeType: "acp",
|
|
308
|
-
prompt: (ctx) => fixPrompt("quality", ctx),
|
|
309
|
-
parse: parseFixVerdict,
|
|
310
|
-
},
|
|
311
|
-
commit_quality: commitFixNode("quality"),
|
|
312
|
-
fix_gate: {
|
|
313
|
-
nodeType: "acp",
|
|
314
|
-
prompt: (ctx) => fixPrompt("gate", ctx),
|
|
315
|
-
parse: parseFixVerdict,
|
|
316
|
-
},
|
|
317
|
-
commit_gate: commitFixNode("gate"),
|
|
318
|
-
quality_gates: {
|
|
319
|
-
nodeType: "action",
|
|
320
|
-
run: qualityGatesNode,
|
|
321
|
-
},
|
|
322
|
-
open_pr: {
|
|
323
|
-
nodeType: "action",
|
|
324
|
-
async run(ctx) {
|
|
325
|
-
const i = inputOf(ctx);
|
|
326
|
-
const loadCtx = loadCtxOf(ctx);
|
|
327
|
-
if (loadCtx.route === "nothing-to-finish") {
|
|
328
|
-
await writeResult(i, { feature: i.feature, status: "nothing-to-finish" });
|
|
329
|
-
return { route: "done", status: "nothing-to-finish" };
|
|
330
|
-
}
|
|
331
|
-
// Every fix node edited the working tree; without this the PR would be
|
|
332
|
-
// opened from a remote branch missing all of them.
|
|
333
|
-
//
|
|
334
|
-
// Routed, not thrown. acpx has no error edge, so a throw here kills the
|
|
335
|
-
// flow — and this is the last node on the happy path, reached only once
|
|
336
|
-
// every gate is green and every fix has landed. It died before
|
|
337
|
-
// `writeResult`, so the plugin found no result file and notified
|
|
338
|
-
// nobody: the #1399 failure mode the `escalate` node was hardened
|
|
339
|
-
// against and this one was not. A protected branch, an expired token or
|
|
340
|
-
// a non-fast-forward push is exactly the kind of dead end `escalate`
|
|
341
|
-
// exists to report.
|
|
342
|
-
let sync: { committed: boolean };
|
|
343
|
-
try {
|
|
344
|
-
sync = await commitAndPush(i.workdir, i.branch, `fix(${i.feature}): nax-finish automated fixes`);
|
|
345
|
-
} catch (error) {
|
|
346
|
-
return {
|
|
347
|
-
route: "escalate",
|
|
348
|
-
reason: `nax-finish could not push "${i.branch}", so no PR was opened: ${String(error)}`,
|
|
349
|
-
};
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
const fallbackTitle = `nax-finish: ${i.feature}`;
|
|
353
|
-
const fallbackBody = `Automated finish of \`${i.feature}\`.`;
|
|
354
|
-
let title = fallbackTitle;
|
|
355
|
-
let body = fallbackBody;
|
|
356
|
-
// Detected once, here, and handed to both the body builder (which needs
|
|
357
|
-
// it for the repo template) and the opener. Detecting in both would let
|
|
358
|
-
// them disagree. On a throw it stays undefined and `openOrPromotePr`
|
|
359
|
-
// detects for itself, exactly as it did before.
|
|
360
|
-
let forge: Forge | undefined;
|
|
361
|
-
try {
|
|
362
|
-
forge = await detectForge(_prBodyDeps.run, i.workdir, "finish-pr");
|
|
363
|
-
const prCtx = await _openPrDeps.loadFinishPrContext(i, {
|
|
364
|
-
base: loadCtx.base ?? "",
|
|
365
|
-
gatesRan: gateOutputs(ctx).ran ?? [],
|
|
366
|
-
forge,
|
|
367
|
-
specPath: loadCtx.specPath,
|
|
368
|
-
});
|
|
369
|
-
title = _openPrDeps.buildFinishTitle(prCtx);
|
|
370
|
-
body = _openPrDeps.buildFinishBody(prCtx);
|
|
371
|
-
} catch (error) {
|
|
372
|
-
_prBodyDeps.warn("[finish-pr] Falling back to default PR title/body", { path: i.prdPath, error });
|
|
373
|
-
title = fallbackTitle;
|
|
374
|
-
body = fallbackBody;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// Same reasoning as the push above: a forge that refuses to create or
|
|
378
|
-
// promote (rate limit, revoked token, unrecognised remote) must reach a
|
|
379
|
-
// human through `escalate`, not take the flow down silently.
|
|
380
|
-
let r: { status: "opened" | "promoted" | "already-ready"; url?: string };
|
|
381
|
-
try {
|
|
382
|
-
r = await openOrPromotePr(i.workdir, i.branch, title, body, forge);
|
|
383
|
-
} catch (error) {
|
|
384
|
-
return {
|
|
385
|
-
route: "escalate",
|
|
386
|
-
reason: `nax-finish could not open or promote the PR for "${i.branch}": ${String(error)}`,
|
|
387
|
-
};
|
|
388
|
-
}
|
|
389
|
-
await writeResult(i, { feature: i.feature, status: r.status, url: r.url });
|
|
390
|
-
// The PR now exists with the mechanical narrative already in place.
|
|
391
|
-
// Anything the narrative node does from here is an improvement on a
|
|
392
|
-
// body that is already correct.
|
|
393
|
-
return { route: NARRATIVE_ENABLED ? "narrate" : "done", committed: sync.committed, ...r };
|
|
394
|
-
},
|
|
395
|
-
},
|
|
396
|
-
narrative: {
|
|
397
|
-
nodeType: "acp",
|
|
398
|
-
session: { isolated: true },
|
|
399
|
-
profile: process.env.NAX_FINISH_NARRATIVE_PROFILE || undefined,
|
|
400
|
-
prompt: narrativePrompt,
|
|
401
|
-
parse: parseNarrativeNode,
|
|
402
|
-
},
|
|
403
|
-
amend_body: {
|
|
404
|
-
nodeType: "action",
|
|
405
|
-
run: amendPrBodyNode,
|
|
406
|
-
},
|
|
407
|
-
// Inert terminal. acpx switch cases must name a real node, so the `done`
|
|
408
|
-
// route out of open_pr needs somewhere to land.
|
|
409
|
-
finish_done: {
|
|
410
|
-
nodeType: "compute",
|
|
411
|
-
run: () => ({ route: "done" }),
|
|
412
|
-
},
|
|
413
|
-
escalate: {
|
|
414
|
-
nodeType: "action",
|
|
415
|
-
async run(ctx) {
|
|
416
|
-
const i = inputOf(ctx);
|
|
417
|
-
const outs = ctx.outputs as Record<string, { route?: string; reason?: string } | undefined>;
|
|
418
|
-
const routed = ctx.outputs as Record<string, ReviewVerdict | undefined>;
|
|
419
|
-
const verdict =
|
|
420
|
-
routed.route_spec?.route === "escalate"
|
|
421
|
-
? routed.route_spec
|
|
422
|
-
: routed.route_quality?.route === "escalate"
|
|
423
|
-
? routed.route_quality
|
|
424
|
-
: undefined;
|
|
425
|
-
// Ordered by how far down the graph the node sits, so the *last* thing
|
|
426
|
-
// that gave up names the reason. `load_ctx` and `open_pr` are here
|
|
427
|
-
// because both can now route here rather than throw — a base ref that
|
|
428
|
-
// does not resolve, and a push or forge call that failed after every
|
|
429
|
-
// gate was green.
|
|
430
|
-
const loopExhausted = [outs.open_pr, outs.quality_gates, outs.acceptance, outs.load_ctx].find(
|
|
431
|
-
(o) => o?.route === "escalate",
|
|
432
|
-
);
|
|
433
|
-
const reason =
|
|
434
|
-
verdict?.escalationReason ?? loopExhausted?.reason ?? "nax-finish could not reach a green, shippable state";
|
|
435
|
-
|
|
436
|
-
// Push what was fixed so the escalation describes state a human can see.
|
|
437
|
-
// A push failure must not swallow the escalation itself, so it is
|
|
438
|
-
// reported in the message rather than thrown.
|
|
439
|
-
let syncNote = "";
|
|
440
|
-
try {
|
|
441
|
-
await commitAndPush(i.workdir, i.branch, `wip(${i.feature}): nax-finish partial fixes before escalation`);
|
|
442
|
-
} catch (err) {
|
|
443
|
-
syncNote = `\n\n> Note: nax-finish could not push its partial fixes — ${String(err)}`;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// Write the result BEFORE attempting delivery. Delivery touches the
|
|
447
|
-
// network and the forge — a rate limit, an expired token, a locked PR
|
|
448
|
-
// or an unrecognised remote used to throw here, killing the node before
|
|
449
|
-
// any result existed. The plugin then had nothing to report and, on the
|
|
450
|
-
// Telegram channel, nothing to notify from: the one path whose job is
|
|
451
|
-
// to say "a human is needed" was the one path with no fallback (#1399).
|
|
452
|
-
const result: FinishResult = {
|
|
453
|
-
feature: i.feature,
|
|
454
|
-
status: "escalated",
|
|
455
|
-
escalationReason: reason,
|
|
456
|
-
findings: verdict?.findings ?? [],
|
|
457
|
-
};
|
|
458
|
-
await writeResult(i, result);
|
|
459
|
-
|
|
460
|
-
const comment = buildEscalationComment(i.feature, reason, verdict?.findings ?? []) + syncNote;
|
|
461
|
-
let url: string | undefined;
|
|
462
|
-
let channel: string | undefined;
|
|
463
|
-
let deliveryError: string | undefined;
|
|
464
|
-
try {
|
|
465
|
-
const posted = await postEscalation(i.workdir, i.branch, comment, {
|
|
466
|
-
preferTelegram: i.escalateTelegram,
|
|
467
|
-
});
|
|
468
|
-
url = posted.url;
|
|
469
|
-
channel = posted.channel;
|
|
470
|
-
} catch (err) {
|
|
471
|
-
deliveryError = String(err);
|
|
472
|
-
}
|
|
473
|
-
await writeResult(i, { ...result, url, deliveryError });
|
|
474
|
-
|
|
475
|
-
return { route: "done", url, channel, deliveryError, escalationReason: reason };
|
|
476
|
-
},
|
|
477
|
-
},
|
|
478
|
-
},
|
|
479
|
-
edges: [
|
|
480
|
-
{
|
|
481
|
-
from: "load_ctx",
|
|
482
|
-
switch: {
|
|
483
|
-
on: "$.route",
|
|
484
|
-
// `escalate`: the branch could not be measured against its base at all,
|
|
485
|
-
// so neither "proceed" nor "nothing-to-finish" would be a true claim.
|
|
486
|
-
cases: { proceed: "acceptance", "nothing-to-finish": "open_pr", escalate: "escalate" },
|
|
487
|
-
},
|
|
488
|
-
},
|
|
489
|
-
{
|
|
490
|
-
from: "acceptance",
|
|
491
|
-
switch: { on: "$.route", cases: { proceed: "review_spec", fix: "fix_acceptance", escalate: "escalate" } },
|
|
492
|
-
},
|
|
493
|
-
// Each fix commits before anything re-reads the diff: the reviewers see
|
|
494
|
-
// `git diff <base>...HEAD` only, so an uncommitted fix would be re-reported
|
|
495
|
-
// verbatim until the cap escalated it (#1397).
|
|
496
|
-
{ from: "fix_acceptance", to: "commit_acceptance" },
|
|
497
|
-
{ from: "commit_acceptance", to: "acceptance" },
|
|
498
|
-
{ from: "review_spec", to: "route_spec" },
|
|
499
|
-
{
|
|
500
|
-
from: "route_spec",
|
|
501
|
-
switch: {
|
|
502
|
-
on: "$.route",
|
|
503
|
-
cases: {
|
|
504
|
-
clean: "review_quality",
|
|
505
|
-
fix: "fix_spec",
|
|
506
|
-
escalate: "escalate",
|
|
507
|
-
reprompt: "review_spec",
|
|
508
|
-
incomplete: "review_spec",
|
|
509
|
-
},
|
|
510
|
-
},
|
|
511
|
-
},
|
|
512
|
-
// Spec fixes re-run the acceptance gate first (they can break it), and the
|
|
513
|
-
// acceptance node's `proceed` edge leads back into review_spec for re-review.
|
|
514
|
-
{ from: "fix_spec", to: "commit_spec" },
|
|
515
|
-
{ from: "commit_spec", to: "acceptance" },
|
|
516
|
-
{ from: "review_quality", to: "route_quality" },
|
|
517
|
-
{
|
|
518
|
-
from: "route_quality",
|
|
519
|
-
switch: {
|
|
520
|
-
on: "$.route",
|
|
521
|
-
cases: {
|
|
522
|
-
clean: "quality_gates",
|
|
523
|
-
fix: "fix_quality",
|
|
524
|
-
escalate: "escalate",
|
|
525
|
-
reprompt: "review_quality",
|
|
526
|
-
incomplete: "review_quality",
|
|
527
|
-
},
|
|
528
|
-
},
|
|
529
|
-
},
|
|
530
|
-
// Quality fixes are re-reviewed by the same lens; the repo-root gates that
|
|
531
|
-
// follow catch anything the fix broke mechanically.
|
|
532
|
-
{ from: "fix_quality", to: "commit_quality" },
|
|
533
|
-
{ from: "commit_quality", to: "review_quality" },
|
|
534
|
-
{
|
|
535
|
-
from: "quality_gates",
|
|
536
|
-
switch: { on: "$.route", cases: { green: "open_pr", fix: "fix_gate", escalate: "escalate" } },
|
|
537
|
-
},
|
|
538
|
-
{ from: "fix_gate", to: "commit_gate" },
|
|
539
|
-
// A gate fix that changed code goes back through the quality reviewer, not
|
|
540
|
-
// straight to the gates. The gate loop is the last one to edit the tree and
|
|
541
|
-
// was the only one whose edits nothing reviewed: `quality_gates` proves the
|
|
542
|
-
// repo's commands are green, which a bad fix can satisfy. Observed on
|
|
543
|
-
// rs-stock/pipeline-run-outcome — the gate round repaired 8 tests by
|
|
544
|
-
// copy-pasting an identical 4-line stub into each, and it shipped, because
|
|
545
|
-
// no reviewer ran after it. Re-entry costs one review per gate round; both
|
|
546
|
-
// loops stay bounded by their own MAX_FIX_ATTEMPTS caps.
|
|
547
|
-
//
|
|
548
|
-
// `unchanged` skips it: with nothing committed there is no new diff to
|
|
549
|
-
// review, and re-running the reviewer on an identical tree would burn a
|
|
550
|
-
// turn to re-report what route_quality already called clean.
|
|
551
|
-
{
|
|
552
|
-
from: "commit_gate",
|
|
553
|
-
switch: {
|
|
554
|
-
on: "$.route",
|
|
555
|
-
cases: { changed: "review_quality", "tests-only": "review_quality", unchanged: "quality_gates" },
|
|
556
|
-
},
|
|
557
|
-
},
|
|
558
|
-
// The narrative runs only once the PR exists. acpx has no error edge, so an
|
|
559
|
-
// acp node before `open_pr` would be able to fail the flow and cost the PR.
|
|
560
|
-
{
|
|
561
|
-
from: "open_pr",
|
|
562
|
-
switch: { on: "$.route", cases: { narrate: "narrative", done: "finish_done", escalate: "escalate" } },
|
|
563
|
-
},
|
|
564
|
-
{ from: "narrative", to: "amend_body" },
|
|
565
|
-
],
|
|
566
|
-
});
|