@nathapp/nax 0.73.5 → 0.74.0
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 +1240 -526
- package/flows/nax-finish/errors.ts +23 -0
- package/flows/nax-finish/exec.ts +70 -0
- package/flows/nax-finish/nax-finish.flow.ts +330 -0
- package/flows/nax-finish/review-prompts.ts +344 -0
- package/flows/nax-finish/steps/acceptance.ts +53 -0
- package/flows/nax-finish/steps/context.ts +64 -0
- package/flows/nax-finish/steps/escalate.ts +93 -0
- package/flows/nax-finish/steps/forge.ts +44 -0
- package/flows/nax-finish/steps/git.ts +73 -0
- package/flows/nax-finish/steps/index.ts +8 -0
- package/flows/nax-finish/steps/pr.ts +71 -0
- package/flows/nax-finish/steps/quality.ts +93 -0
- package/flows/nax-finish/steps/result.ts +15 -0
- package/flows/nax-finish/types.ts +59 -0
- package/package.json +6 -4
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-contained error type for the nax-finish flow.
|
|
3
|
+
*
|
|
4
|
+
* The flow module is loaded by `acpx flow run` from wherever `flows/` happens
|
|
5
|
+
* to be installed, in acpx's own process, with the *user's* repo as cwd. It
|
|
6
|
+
* therefore cannot import from nax's `src/` — neither via the `@/*` path alias
|
|
7
|
+
* (which only resolves through nax's own tsconfig) nor via a relative path
|
|
8
|
+
* (only `flows/` is published, not `src/`). `FinishError` mirrors `NaxError`'s
|
|
9
|
+
* shape (message + machine-readable code + structured context) so escalation
|
|
10
|
+
* output and logs stay consistent with the rest of nax.
|
|
11
|
+
*/
|
|
12
|
+
export class FinishError extends Error {
|
|
13
|
+
readonly code: string;
|
|
14
|
+
readonly context: Record<string, unknown>;
|
|
15
|
+
|
|
16
|
+
constructor(message: string, code: string, context: Record<string, unknown> = {}) {
|
|
17
|
+
const { cause, ...rest } = context;
|
|
18
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
19
|
+
this.name = "FinishError";
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.context = rest;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subprocess execution for the nax-finish flow.
|
|
3
|
+
*
|
|
4
|
+
* Two distinct entry points, deliberately:
|
|
5
|
+
*
|
|
6
|
+
* - `runArgv` — for commands *this flow* constructs (`git`, `gh`, `glab`). An
|
|
7
|
+
* argv array is spawned directly: no shell, so no quoting or injection
|
|
8
|
+
* surface for branch names and review text.
|
|
9
|
+
* - `runShell` — for command *strings the user configured* (`quality.commands`,
|
|
10
|
+
* `acceptance.command`). These are run through `/bin/sh -c`, matching
|
|
11
|
+
* `src/quality/runner.ts`, which is the only way to preserve `&&`, quoting,
|
|
12
|
+
* globs and env prefixes. Splitting such a string on whitespace and spawning
|
|
13
|
+
* argv (the previous behaviour) silently mis-ran every non-trivial command.
|
|
14
|
+
*
|
|
15
|
+
* Both cap wall-clock time: an unbounded gate would hang `acpx flow run`, and
|
|
16
|
+
* the post-run plugin awaits that subprocess.
|
|
17
|
+
*/
|
|
18
|
+
import type { RunResult } from "./types";
|
|
19
|
+
|
|
20
|
+
/** Fallbacks used when the plugin passes no explicit budget in the flow input. */
|
|
21
|
+
export const DEFAULT_ACCEPTANCE_TIMEOUT_MS = 600_000;
|
|
22
|
+
export const DEFAULT_GATE_TIMEOUT_MS = 900_000;
|
|
23
|
+
/** Short budget for the flow's own git/forge plumbing — these are never long-running. */
|
|
24
|
+
export const DEFAULT_ARGV_TIMEOUT_MS = 120_000;
|
|
25
|
+
|
|
26
|
+
export interface ExecOptions {
|
|
27
|
+
cwd: string;
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function spawnCapture(cmd: string[], opts: ExecOptions): Promise<RunResult> {
|
|
32
|
+
const proc = Bun.spawn(cmd, { cwd: opts.cwd, stdout: "pipe", stderr: "pipe" });
|
|
33
|
+
let timedOut = false;
|
|
34
|
+
// setTimeout (not Bun.sleep) because the handle must be cancellable the moment
|
|
35
|
+
// the process exits — the documented exception in forbidden-patterns.md.
|
|
36
|
+
const timer =
|
|
37
|
+
opts.timeoutMs && opts.timeoutMs > 0
|
|
38
|
+
? setTimeout(() => {
|
|
39
|
+
timedOut = true;
|
|
40
|
+
proc.kill();
|
|
41
|
+
}, opts.timeoutMs)
|
|
42
|
+
: undefined;
|
|
43
|
+
try {
|
|
44
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
45
|
+
proc.exited,
|
|
46
|
+
new Response(proc.stdout).text(),
|
|
47
|
+
new Response(proc.stderr).text(),
|
|
48
|
+
]);
|
|
49
|
+
return timedOut
|
|
50
|
+
? {
|
|
51
|
+
exitCode: exitCode === 0 ? 124 : exitCode,
|
|
52
|
+
stdout,
|
|
53
|
+
stderr: `${stderr}\n[nax-finish] killed after ${opts.timeoutMs}ms timeout`,
|
|
54
|
+
timedOut: true,
|
|
55
|
+
}
|
|
56
|
+
: { exitCode, stdout, stderr };
|
|
57
|
+
} finally {
|
|
58
|
+
if (timer) clearTimeout(timer);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Spawn an argv array directly — no shell. For flow-constructed commands. */
|
|
63
|
+
export function runArgv(cmd: string[], opts: ExecOptions): Promise<RunResult> {
|
|
64
|
+
return spawnCapture(cmd, { ...opts, timeoutMs: opts.timeoutMs ?? DEFAULT_ARGV_TIMEOUT_MS });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Run a configured command string through `/bin/sh -c`, preserving shell semantics. */
|
|
68
|
+
export function runShell(command: string, opts: ExecOptions): Promise<RunResult> {
|
|
69
|
+
return spawnCapture(["/bin/sh", "-c", command], opts);
|
|
70
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
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_* → (re-run acceptance |
|
|
21
|
+
* re-review) → review_*` until the reviewer comes back clean or the fix cap
|
|
22
|
+
* trips. A single-shot fix left the fixed diff unverified.
|
|
23
|
+
* - `route_*` compute nodes hold the escalate/clean/fix decision so the cap is
|
|
24
|
+
* enforced deterministically rather than trusting the model's own route.
|
|
25
|
+
*/
|
|
26
|
+
import { defineFlow, extractJsonObject } from "acpx/flows";
|
|
27
|
+
import { buildReviewPrompt, fixPrompt } from "./review-prompts";
|
|
28
|
+
import {
|
|
29
|
+
_contextDeps,
|
|
30
|
+
buildEscalationComment,
|
|
31
|
+
commitAndPush,
|
|
32
|
+
detectBaseBranch,
|
|
33
|
+
loadQualityCommands,
|
|
34
|
+
openOrPromotePr,
|
|
35
|
+
postEscalation,
|
|
36
|
+
preflight,
|
|
37
|
+
resolveFeature,
|
|
38
|
+
runAcceptanceGate,
|
|
39
|
+
runQualityGates,
|
|
40
|
+
writeResult,
|
|
41
|
+
} from "./steps";
|
|
42
|
+
import type { AcceptanceGroup, FinishInput, ReviewVerdict } from "./types";
|
|
43
|
+
|
|
44
|
+
const inputOf = (ctx: { input: unknown }) => ctx.input as FinishInput;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Cap on fix-and-reverify iterations, per phase, before escalating instead of
|
|
48
|
+
* looping forever. acpx's flow engine has no built-in cycle guard, so without
|
|
49
|
+
* this cap a stubborn failure (LLM can't fix it, or fixes something else each
|
|
50
|
+
* time) hangs `acpx flow run` — and the post-run plugin awaits that subprocess.
|
|
51
|
+
*/
|
|
52
|
+
const MAX_FIX_ATTEMPTS = 3;
|
|
53
|
+
|
|
54
|
+
interface LoadCtxOutput {
|
|
55
|
+
base?: string;
|
|
56
|
+
specPath?: string;
|
|
57
|
+
groups?: AcceptanceGroup[];
|
|
58
|
+
route?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function fixAttemptCount(ctx: { state: { steps: { nodeId: string }[] } }, fixNodeId: string): number {
|
|
62
|
+
return (ctx.state.steps ?? []).filter((s) => s.nodeId === fixNodeId).length;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function loadCtxOf(ctx: { outputs: unknown }): LoadCtxOutput {
|
|
66
|
+
return ((ctx.outputs as Record<string, LoadCtxOutput | undefined>).load_ctx ?? {}) as LoadCtxOutput;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Re-run the acceptance gate, routing on the shared fix-cap rules. */
|
|
70
|
+
async function acceptanceGateNode(ctx: {
|
|
71
|
+
input: unknown;
|
|
72
|
+
outputs: unknown;
|
|
73
|
+
state: { steps: { nodeId: string }[] };
|
|
74
|
+
}): Promise<{ route: string; reason?: string; output: string }> {
|
|
75
|
+
const i = inputOf(ctx);
|
|
76
|
+
const groups = loadCtxOf(ctx).groups ?? [];
|
|
77
|
+
const r = await runAcceptanceGate(i.workdir, groups, { timeoutMs: i.timeouts?.acceptanceMs });
|
|
78
|
+
if (r.passed) return { route: "proceed", output: r.output };
|
|
79
|
+
const attempts = fixAttemptCount(ctx, "fix_acceptance");
|
|
80
|
+
if (attempts >= MAX_FIX_ATTEMPTS) {
|
|
81
|
+
return {
|
|
82
|
+
route: "escalate",
|
|
83
|
+
reason: `Acceptance tests still failing after ${attempts} fix attempts.`,
|
|
84
|
+
output: r.output,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return { route: "fix", output: r.output };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Turn a reviewer verdict into a deterministic route.
|
|
92
|
+
*
|
|
93
|
+
* `clean` (no findings) skips the fix node entirely — prompting an agent to
|
|
94
|
+
* "apply the recommended fixes" for an empty finding list burns a turn and
|
|
95
|
+
* invites unrequested edits.
|
|
96
|
+
*/
|
|
97
|
+
function routeReview(
|
|
98
|
+
ctx: { outputs: unknown; state: { steps: { nodeId: string }[] } },
|
|
99
|
+
phase: "spec" | "quality",
|
|
100
|
+
): { route: string; escalationReason?: string; findings: ReviewVerdict["findings"] } {
|
|
101
|
+
const verdict = (ctx.outputs as Record<string, ReviewVerdict | undefined>)[`review_${phase}`];
|
|
102
|
+
const findings = verdict?.findings ?? [];
|
|
103
|
+
if (verdict?.route === "escalate") {
|
|
104
|
+
return {
|
|
105
|
+
route: "escalate",
|
|
106
|
+
escalationReason: verdict.escalationReason ?? `${phase} review raised a finding needing human judgment`,
|
|
107
|
+
findings,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (findings.length === 0) return { route: "clean", findings };
|
|
111
|
+
const attempts = fixAttemptCount(ctx, `fix_${phase}`);
|
|
112
|
+
if (attempts >= MAX_FIX_ATTEMPTS) {
|
|
113
|
+
return {
|
|
114
|
+
route: "escalate",
|
|
115
|
+
escalationReason: `${phase} review still reporting ${findings.length} finding(s) after ${attempts} fix attempts.`,
|
|
116
|
+
findings,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return { route: "fix", findings };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Normalise a reviewer's JSON, rewriting a findings-free `proceed` to `clean`. */
|
|
123
|
+
function parseVerdict(text: string): ReviewVerdict {
|
|
124
|
+
const raw = extractJsonObject(text) as Partial<ReviewVerdict>;
|
|
125
|
+
const findings = Array.isArray(raw.findings) ? raw.findings : [];
|
|
126
|
+
const route = raw.route === "escalate" ? "escalate" : findings.length === 0 ? "clean" : "proceed";
|
|
127
|
+
return { route, findings, escalationReason: raw.escalationReason };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export default defineFlow({
|
|
131
|
+
name: "nax-finish",
|
|
132
|
+
permissions: {
|
|
133
|
+
requiredMode: "approve-all",
|
|
134
|
+
requireExplicitGrant: true,
|
|
135
|
+
reason: "This flow edits files, pushes commits, runs quality gates, comments on and opens/promotes PRs.",
|
|
136
|
+
},
|
|
137
|
+
startAt: "load_ctx",
|
|
138
|
+
nodes: {
|
|
139
|
+
load_ctx: {
|
|
140
|
+
nodeType: "action",
|
|
141
|
+
async run(ctx) {
|
|
142
|
+
const i = inputOf(ctx);
|
|
143
|
+
const base = await detectBaseBranch(i.workdir);
|
|
144
|
+
const resolution = await resolveFeature(i.feature, i.workdir);
|
|
145
|
+
const pf = await preflight(i.workdir, base);
|
|
146
|
+
return {
|
|
147
|
+
base,
|
|
148
|
+
specPath: resolution.specPath,
|
|
149
|
+
acceptanceStatus: resolution.acceptanceStatus,
|
|
150
|
+
groups: resolution.groups,
|
|
151
|
+
commitsAhead: pf.commitsAhead,
|
|
152
|
+
route: pf.route,
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
acceptance: {
|
|
157
|
+
nodeType: "action",
|
|
158
|
+
run: acceptanceGateNode,
|
|
159
|
+
},
|
|
160
|
+
fix_acceptance: {
|
|
161
|
+
nodeType: "acp",
|
|
162
|
+
prompt: (ctx) => fixPrompt("acceptance", ctx),
|
|
163
|
+
parse: parseVerdict,
|
|
164
|
+
},
|
|
165
|
+
review_spec: {
|
|
166
|
+
nodeType: "acp",
|
|
167
|
+
session: { isolated: true },
|
|
168
|
+
profile: process.env.NAX_FINISH_SPEC_PROFILE || undefined,
|
|
169
|
+
prompt(ctx) {
|
|
170
|
+
const outs = loadCtxOf(ctx);
|
|
171
|
+
return buildReviewPrompt("spec", { base: outs.base ?? "origin/main", specPath: outs.specPath ?? "" });
|
|
172
|
+
},
|
|
173
|
+
parse: parseVerdict,
|
|
174
|
+
},
|
|
175
|
+
route_spec: {
|
|
176
|
+
nodeType: "compute",
|
|
177
|
+
run: (ctx) => routeReview(ctx, "spec"),
|
|
178
|
+
},
|
|
179
|
+
fix_spec: {
|
|
180
|
+
nodeType: "acp",
|
|
181
|
+
prompt: (ctx) => fixPrompt("spec", ctx),
|
|
182
|
+
parse: parseVerdict,
|
|
183
|
+
},
|
|
184
|
+
review_quality: {
|
|
185
|
+
nodeType: "acp",
|
|
186
|
+
session: { isolated: true },
|
|
187
|
+
profile: process.env.NAX_FINISH_QUALITY_PROFILE || undefined,
|
|
188
|
+
prompt(ctx) {
|
|
189
|
+
const outs = loadCtxOf(ctx);
|
|
190
|
+
return buildReviewPrompt("quality", { base: outs.base ?? "origin/main", specPath: outs.specPath ?? "" });
|
|
191
|
+
},
|
|
192
|
+
parse: parseVerdict,
|
|
193
|
+
},
|
|
194
|
+
route_quality: {
|
|
195
|
+
nodeType: "compute",
|
|
196
|
+
run: (ctx) => routeReview(ctx, "quality"),
|
|
197
|
+
},
|
|
198
|
+
fix_quality: {
|
|
199
|
+
nodeType: "acp",
|
|
200
|
+
prompt: (ctx) => fixPrompt("quality", ctx),
|
|
201
|
+
parse: parseVerdict,
|
|
202
|
+
},
|
|
203
|
+
fix_gate: {
|
|
204
|
+
nodeType: "acp",
|
|
205
|
+
prompt: (ctx) => fixPrompt("gate", ctx),
|
|
206
|
+
parse: parseVerdict,
|
|
207
|
+
},
|
|
208
|
+
quality_gates: {
|
|
209
|
+
nodeType: "action",
|
|
210
|
+
async run(ctx) {
|
|
211
|
+
const i = inputOf(ctx);
|
|
212
|
+
const cmds = await loadQualityCommands(i.workdir);
|
|
213
|
+
const r = await runQualityGates(i.workdir, cmds, { timeoutMs: i.timeouts?.gateMs });
|
|
214
|
+
if (r.passed) return { route: "green", ran: r.ran, failing: r.failing, output: r.output };
|
|
215
|
+
// Nothing configured is not a pass — escalate immediately rather than
|
|
216
|
+
// open a "ready" PR having verified nothing. An LLM fix node cannot
|
|
217
|
+
// invent the repo's build/test commands.
|
|
218
|
+
if (r.ran.length === 0) {
|
|
219
|
+
return {
|
|
220
|
+
route: "escalate",
|
|
221
|
+
reason: "No quality.commands configured in .nax/config.json — nax-finish verified nothing.",
|
|
222
|
+
ran: r.ran,
|
|
223
|
+
failing: r.failing,
|
|
224
|
+
output: r.output,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const attempts = fixAttemptCount(ctx, "fix_gate");
|
|
228
|
+
if (attempts >= MAX_FIX_ATTEMPTS) {
|
|
229
|
+
return {
|
|
230
|
+
route: "escalate",
|
|
231
|
+
reason: `Quality gates still failing after ${attempts} fix attempts (${r.failing.join(", ")}).`,
|
|
232
|
+
ran: r.ran,
|
|
233
|
+
failing: r.failing,
|
|
234
|
+
output: r.output,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return { route: "fix", ran: r.ran, failing: r.failing, output: r.output };
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
open_pr: {
|
|
241
|
+
nodeType: "action",
|
|
242
|
+
async run(ctx) {
|
|
243
|
+
const i = inputOf(ctx);
|
|
244
|
+
if (loadCtxOf(ctx).route === "nothing-to-finish") {
|
|
245
|
+
await writeResult(i.workdir, { feature: i.feature, status: "nothing-to-finish" });
|
|
246
|
+
return { route: "done", status: "nothing-to-finish" };
|
|
247
|
+
}
|
|
248
|
+
// Every fix node edited the working tree; without this the PR would be
|
|
249
|
+
// opened from a remote branch missing all of them.
|
|
250
|
+
const sync = await commitAndPush(i.workdir, i.branch, `fix(${i.feature}): nax-finish automated fixes`);
|
|
251
|
+
const r = await openOrPromotePr(
|
|
252
|
+
i.workdir,
|
|
253
|
+
i.branch,
|
|
254
|
+
`nax-finish: ${i.feature}`,
|
|
255
|
+
`Automated finish of \`${i.feature}\`.`,
|
|
256
|
+
);
|
|
257
|
+
await writeResult(i.workdir, { feature: i.feature, status: r.status, url: r.url });
|
|
258
|
+
return { route: "done", committed: sync.committed, ...r };
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
escalate: {
|
|
262
|
+
nodeType: "action",
|
|
263
|
+
async run(ctx) {
|
|
264
|
+
const i = inputOf(ctx);
|
|
265
|
+
const outs = ctx.outputs as Record<string, { route?: string; reason?: string } | undefined>;
|
|
266
|
+
const routed = ctx.outputs as Record<string, ReviewVerdict | undefined>;
|
|
267
|
+
const verdict =
|
|
268
|
+
routed.route_spec?.route === "escalate"
|
|
269
|
+
? routed.route_spec
|
|
270
|
+
: routed.route_quality?.route === "escalate"
|
|
271
|
+
? routed.route_quality
|
|
272
|
+
: undefined;
|
|
273
|
+
const loopExhausted =
|
|
274
|
+
outs.acceptance?.route === "escalate"
|
|
275
|
+
? outs.acceptance
|
|
276
|
+
: outs.quality_gates?.route === "escalate"
|
|
277
|
+
? outs.quality_gates
|
|
278
|
+
: undefined;
|
|
279
|
+
const reason =
|
|
280
|
+
verdict?.escalationReason ?? loopExhausted?.reason ?? "nax-finish could not reach a green, shippable state";
|
|
281
|
+
|
|
282
|
+
// Push what was fixed so the escalation describes state a human can see.
|
|
283
|
+
// A push failure must not swallow the escalation itself, so it is
|
|
284
|
+
// reported in the message rather than thrown.
|
|
285
|
+
let syncNote = "";
|
|
286
|
+
try {
|
|
287
|
+
await commitAndPush(i.workdir, i.branch, `wip(${i.feature}): nax-finish partial fixes before escalation`);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
syncNote = `\n\n> Note: nax-finish could not push its partial fixes — ${String(err)}`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const comment = buildEscalationComment(i.feature, reason, verdict?.findings ?? []) + syncNote;
|
|
293
|
+
const { url, channel } = await postEscalation(i.workdir, i.branch, comment, {
|
|
294
|
+
preferTelegram: i.escalateTelegram,
|
|
295
|
+
});
|
|
296
|
+
await writeResult(i.workdir, { feature: i.feature, status: "escalated", url, escalationReason: reason });
|
|
297
|
+
return { route: "done", url, channel, escalationReason: reason };
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
edges: [
|
|
302
|
+
{ from: "load_ctx", switch: { on: "$.route", cases: { proceed: "acceptance", "nothing-to-finish": "open_pr" } } },
|
|
303
|
+
{
|
|
304
|
+
from: "acceptance",
|
|
305
|
+
switch: { on: "$.route", cases: { proceed: "review_spec", fix: "fix_acceptance", escalate: "escalate" } },
|
|
306
|
+
},
|
|
307
|
+
{ from: "fix_acceptance", to: "acceptance" },
|
|
308
|
+
{ from: "review_spec", to: "route_spec" },
|
|
309
|
+
{
|
|
310
|
+
from: "route_spec",
|
|
311
|
+
switch: { on: "$.route", cases: { clean: "review_quality", fix: "fix_spec", escalate: "escalate" } },
|
|
312
|
+
},
|
|
313
|
+
// Spec fixes re-run the acceptance gate first (they can break it), and the
|
|
314
|
+
// acceptance node's `proceed` edge leads back into review_spec for re-review.
|
|
315
|
+
{ from: "fix_spec", to: "acceptance" },
|
|
316
|
+
{ from: "review_quality", to: "route_quality" },
|
|
317
|
+
{
|
|
318
|
+
from: "route_quality",
|
|
319
|
+
switch: { on: "$.route", cases: { clean: "quality_gates", fix: "fix_quality", escalate: "escalate" } },
|
|
320
|
+
},
|
|
321
|
+
// Quality fixes are re-reviewed by the same lens; the repo-root gates that
|
|
322
|
+
// follow catch anything the fix broke mechanically.
|
|
323
|
+
{ from: "fix_quality", to: "review_quality" },
|
|
324
|
+
{
|
|
325
|
+
from: "quality_gates",
|
|
326
|
+
switch: { on: "$.route", cases: { green: "open_pr", fix: "fix_gate", escalate: "escalate" } },
|
|
327
|
+
},
|
|
328
|
+
{ from: "fix_gate", to: "quality_gates" },
|
|
329
|
+
],
|
|
330
|
+
});
|