@nathapp/nax 0.77.2 → 0.77.3
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 +876 -419
- package/flows/nax-finish/commit-message.ts +90 -11
- package/flows/nax-finish/nax-finish.flow.ts +26 -21
- package/flows/nax-finish/pr-template-merge.ts +253 -0
- package/flows/nax-finish/steps/commit-round.ts +64 -0
- package/flows/nax-finish/steps/index.ts +2 -0
- package/flows/nax-finish/steps/pr-body.ts +70 -38
- package/flows/nax-finish/steps/review-round.ts +74 -0
- package/flows/nax-finish/types.ts +58 -0
- package/flows/nax-finish/verdict.ts +20 -3
- package/package.json +2 -2
|
@@ -23,6 +23,7 @@ import { dirname, isAbsolute, join } from "node:path";
|
|
|
23
23
|
import { runArgv } from "../exec";
|
|
24
24
|
import { readSpecSummary, resolveNarrative } from "../narrative";
|
|
25
25
|
import { findPrTemplate } from "../pr-template";
|
|
26
|
+
import { type BodySection, type TemplateMode, mergeTemplate } from "../pr-template-merge";
|
|
26
27
|
import { resolveTitle } from "../pr-title";
|
|
27
28
|
import type { Finding, FinishInput, FinishRound, RunFn } from "../types";
|
|
28
29
|
import type { Forge } from "./forge";
|
|
@@ -57,6 +58,10 @@ export interface FinishPrContext {
|
|
|
57
58
|
artifactSummary?: string;
|
|
58
59
|
/** Repository PR/MR template, verbatim. Absent when none resolves. */
|
|
59
60
|
template?: string;
|
|
61
|
+
/** How `template` is honoured. Absent → `merge`. See `pr-template-merge.ts`. */
|
|
62
|
+
templateMode?: TemplateMode;
|
|
63
|
+
/** Repo overrides for the template-heading → section-key table. */
|
|
64
|
+
templateSectionMap?: Record<string, string>;
|
|
60
65
|
/** Resolved "What changed" prose. Absent when neither source produced text. */
|
|
61
66
|
narrative?: string;
|
|
62
67
|
/**
|
|
@@ -244,6 +249,8 @@ export async function loadFinishPrContext(
|
|
|
244
249
|
diffstat: stat.diffstat,
|
|
245
250
|
artifactSummary: stat.artifactSummary,
|
|
246
251
|
template,
|
|
252
|
+
...(input.prBody?.template !== undefined ? { templateMode: input.prBody.template } : {}),
|
|
253
|
+
...(input.prBody?.sectionMap !== undefined ? { templateSectionMap: input.prBody.sectionMap } : {}),
|
|
247
254
|
narrative: resolveNarrative(args.narrative, specSummary),
|
|
248
255
|
title: resolveTitle(args.title, input.feature),
|
|
249
256
|
run: {
|
|
@@ -296,7 +303,6 @@ function formatDuration(durationMs: number): string {
|
|
|
296
303
|
|
|
297
304
|
function buildStoriesSection(stories: FinishPrStory[]): string {
|
|
298
305
|
const lines: string[] = [];
|
|
299
|
-
lines.push("## Stories");
|
|
300
306
|
lines.push("| Story | Title | ACs |");
|
|
301
307
|
lines.push("|-------|-------|-----|");
|
|
302
308
|
for (const story of stories) {
|
|
@@ -312,7 +318,7 @@ function buildStoriesSection(stories: FinishPrStory[]): string {
|
|
|
312
318
|
*/
|
|
313
319
|
function buildVerificationSection(ctx: FinishPrContext): string | null {
|
|
314
320
|
const { acceptance, regression, gatesRan, diffstat, artifactSummary } = ctx;
|
|
315
|
-
const lines: string[] = [
|
|
321
|
+
const lines: string[] = [];
|
|
316
322
|
if (acceptance !== undefined) lines.push(`- Acceptance: ${acceptance}`);
|
|
317
323
|
if (regression !== undefined) lines.push(`- Regression: ${regression}`);
|
|
318
324
|
if (gatesRan.length > 0) lines.push(`- Gates: ${gatesRan.join(", ")}`);
|
|
@@ -323,7 +329,7 @@ function buildVerificationSection(ctx: FinishPrContext): string | null {
|
|
|
323
329
|
if (artifactSummary !== undefined && artifactSummary.length > 0) {
|
|
324
330
|
lines.push(`- Excluded from diffstat — nax run artifacts: ${artifactSummary}`);
|
|
325
331
|
}
|
|
326
|
-
if (lines.length ===
|
|
332
|
+
if (lines.length === 0) return null;
|
|
327
333
|
return lines.join("\n");
|
|
328
334
|
}
|
|
329
335
|
|
|
@@ -334,10 +340,31 @@ function buildRoundHeading(round: FinishRound): string {
|
|
|
334
340
|
return `${base} (${short})`;
|
|
335
341
|
}
|
|
336
342
|
|
|
343
|
+
/**
|
|
344
|
+
* What an empty finding list means, in the reader's terms.
|
|
345
|
+
*
|
|
346
|
+
* Four different things used to render identically as "_no findings_", which a
|
|
347
|
+
* human reads as "a reviewer looked at this and approved it" (#1507). Only
|
|
348
|
+
* `passed` means that. `no-reviewer` is the one that actively misleads: the
|
|
349
|
+
* gate phase has no reviewer node at all, so its empty list is the absence of a
|
|
350
|
+
* review, not the result of one.
|
|
351
|
+
*
|
|
352
|
+
* `fixed` and the legacy `undefined` both fall through to "_no findings_":
|
|
353
|
+
* rounds written before `outcome` existed carry no field to read, and claiming
|
|
354
|
+
* anything more specific about them would be inventing detail.
|
|
355
|
+
*/
|
|
356
|
+
const EMPTY_ROUND_NOTE: Record<string, string> = {
|
|
357
|
+
passed: "- _no findings_",
|
|
358
|
+
"no-reviewer": "- _no reviewer for this phase_",
|
|
359
|
+
unparseable: "- _reviewer output could not be parsed_",
|
|
360
|
+
escalated: "- _escalated for human review_",
|
|
361
|
+
"review-skipped": "- _re-review skipped: this fix touched test files only_",
|
|
362
|
+
};
|
|
363
|
+
|
|
337
364
|
function buildRoundBlock(round: FinishRound): string {
|
|
338
365
|
const lines: string[] = [buildRoundHeading(round)];
|
|
339
366
|
if (round.findings.length === 0) {
|
|
340
|
-
lines.push("- _no findings_");
|
|
367
|
+
lines.push(EMPTY_ROUND_NOTE[round.outcome ?? ""] ?? "- _no findings_");
|
|
341
368
|
} else {
|
|
342
369
|
for (const finding of round.findings) lines.push(renderFinding(finding));
|
|
343
370
|
}
|
|
@@ -346,8 +373,7 @@ function buildRoundBlock(round: FinishRound): string {
|
|
|
346
373
|
|
|
347
374
|
function buildRoundsSection(rounds: FinishRound[]): string | null {
|
|
348
375
|
if (rounds.length === 0) return null;
|
|
349
|
-
|
|
350
|
-
return ["## Review rounds", ...blocks].join("\n\n");
|
|
376
|
+
return rounds.map(buildRoundBlock).join("\n\n");
|
|
351
377
|
}
|
|
352
378
|
|
|
353
379
|
function renderFinding(finding: Finding): string {
|
|
@@ -355,20 +381,17 @@ function renderFinding(finding: Finding): string {
|
|
|
355
381
|
}
|
|
356
382
|
|
|
357
383
|
/**
|
|
358
|
-
*
|
|
359
|
-
*
|
|
384
|
+
* Body only — the heading is attached by `buildFinishBody`, which drops any
|
|
385
|
+
* section whose body is null. "No text" therefore cannot render a bare
|
|
386
|
+
* `## What changed` heading, the empty-heading case #1477 forbids.
|
|
360
387
|
*/
|
|
361
388
|
function buildNarrativeSection(narrative: string | undefined): string | null {
|
|
362
|
-
|
|
363
|
-
if (!text) return null;
|
|
364
|
-
return ["## What changed", text].join("\n\n");
|
|
389
|
+
return narrative?.trim() || null;
|
|
365
390
|
}
|
|
366
391
|
|
|
367
392
|
function buildOutOfScopeSection(outOfScope: string[]): string | null {
|
|
368
393
|
if (outOfScope.length === 0) return null;
|
|
369
|
-
|
|
370
|
-
for (const item of outOfScope) lines.push(`- ${item}`);
|
|
371
|
-
return lines.join("\n");
|
|
394
|
+
return outOfScope.map((item) => `- ${item}`).join("\n");
|
|
372
395
|
}
|
|
373
396
|
|
|
374
397
|
function buildFooter(run: FinishPrContext["run"]): string | null {
|
|
@@ -382,29 +405,38 @@ function buildFooter(run: FinishPrContext["run"]): string | null {
|
|
|
382
405
|
return parts.join(" · ");
|
|
383
406
|
}
|
|
384
407
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
// Appended last and verbatim: `gh` / `glab` suppress the repo's own template
|
|
406
|
-
// whenever `--body` / `--description` is passed, so it has to be re-embedded.
|
|
407
|
-
if (ctx.template !== undefined && ctx.template.trim().length > 0) sections.push(ctx.template.trim());
|
|
408
|
+
/**
|
|
409
|
+
* The nax-authored sections, in canonical order — the order they appear in
|
|
410
|
+
* when the repo has no template, and the order the leftovers are appended in
|
|
411
|
+
* when it has one. `key` is what `mergeTemplate` matches against the repo's
|
|
412
|
+
* headings; the run footer carries an empty heading so it stays unmatchable
|
|
413
|
+
* and last.
|
|
414
|
+
*/
|
|
415
|
+
function buildBodySections(ctx: FinishPrContext): BodySection[] {
|
|
416
|
+
const candidates: { key: string; heading: string; body: string | null }[] = [
|
|
417
|
+
{ key: "narrative", heading: "What changed", body: buildNarrativeSection(ctx.narrative) },
|
|
418
|
+
{ key: "stories", heading: "Stories", body: ctx.stories.length > 0 ? buildStoriesSection(ctx.stories) : null },
|
|
419
|
+
{ key: "verification", heading: "Verification", body: buildVerificationSection(ctx) },
|
|
420
|
+
{ key: "rounds", heading: "Review rounds", body: buildRoundsSection(ctx.rounds) },
|
|
421
|
+
{ key: "outOfScope", heading: "Out of scope", body: buildOutOfScopeSection(ctx.outOfScope) },
|
|
422
|
+
{ key: "footer", heading: "", body: buildFooter(ctx.run) },
|
|
423
|
+
];
|
|
424
|
+
return candidates
|
|
425
|
+
.filter((c): c is { key: string; heading: string; body: string } => c.body !== null)
|
|
426
|
+
.map(({ key, heading, body }) => ({ key, heading, body }));
|
|
427
|
+
}
|
|
408
428
|
|
|
409
|
-
|
|
429
|
+
/**
|
|
430
|
+
* Assemble the body, merged into the repo's own PR template when it has one.
|
|
431
|
+
*
|
|
432
|
+
* The template supplies the *shape* (which headings, in what order) and these
|
|
433
|
+
* sections supply the *content* — see `pr-template-merge.ts` for why appending
|
|
434
|
+
* it verbatim, which is what this used to do, shipped a blank form under a
|
|
435
|
+
* filled one (nax#1504).
|
|
436
|
+
*/
|
|
437
|
+
export function buildFinishBody(ctx: FinishPrContext): string {
|
|
438
|
+
return mergeTemplate(ctx.template, buildBodySections(ctx), {
|
|
439
|
+
mode: ctx.templateMode,
|
|
440
|
+
sectionMap: ctx.templateSectionMap,
|
|
441
|
+
});
|
|
410
442
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recording the review rounds that produce no commit.
|
|
3
|
+
*
|
|
4
|
+
* `commit_<phase>` is the audit seam for rounds that *fix* something — it is the
|
|
5
|
+
* only point where a round's findings and its resulting commit are both known.
|
|
6
|
+
* But that made a commit the sole evidence a reviewer ever ran: a review that
|
|
7
|
+
* passed produced no `fix_*`, therefore no `commit_*`, therefore no round, and
|
|
8
|
+
* "this phase passed" became indistinguishable from "this phase never ran"
|
|
9
|
+
* (#1507). Worse, it made the owed re-review in #1506 unprovable after the fact.
|
|
10
|
+
*
|
|
11
|
+
* So the two seams split by what they know:
|
|
12
|
+
* - `commit_<phase>` records rounds that changed the tree (`outcome: "fixed"`).
|
|
13
|
+
* - here records rounds that did not (`passed` / `unparseable` / `escalated`).
|
|
14
|
+
*
|
|
15
|
+
* Wrapping `routeReview` rather than living inside it keeps that function pure
|
|
16
|
+
* and synchronous — it is the flow's routing SSOT and is exercised by a large
|
|
17
|
+
* table of unit tests that would all have to become async otherwise.
|
|
18
|
+
*/
|
|
19
|
+
import { inputOf } from "../flow-ctx";
|
|
20
|
+
import type { OutputsCtx, StepsCtx } from "../flow-ctx";
|
|
21
|
+
import type { Finding, FinishRoundOutcome } from "../types";
|
|
22
|
+
import { routeReview } from "../verdict";
|
|
23
|
+
import { appendRound } from "./result";
|
|
24
|
+
|
|
25
|
+
/** Route → what to call the round. `fix` is absent by construction — see below. */
|
|
26
|
+
const OUTCOME_BY_ROUTE: Record<string, FinishRoundOutcome> = {
|
|
27
|
+
clean: "passed",
|
|
28
|
+
reprompt: "unparseable",
|
|
29
|
+
escalate: "escalated",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The Nth time this phase's *review* node has run.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately not `fixAttemptCount`: that counts `fix_<phase>` steps, which is
|
|
36
|
+
* the right number for a round that fixed something and the wrong one here — a
|
|
37
|
+
* review that passes on the first look runs zero fix nodes, and every clean
|
|
38
|
+
* round would be numbered 0. Self-inclusive, for the same reason `repromptCount`
|
|
39
|
+
* is: acpx records the `review_<phase>` step before `route_<phase>` executes.
|
|
40
|
+
*/
|
|
41
|
+
function reviewAttemptCount(ctx: StepsCtx, phase: "spec" | "quality"): number {
|
|
42
|
+
return (ctx.state.steps ?? []).filter((s) => s.nodeId === `review_${phase}`).length;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Route this phase's review verdict, and record the round when it produced no
|
|
47
|
+
* commit.
|
|
48
|
+
*
|
|
49
|
+
* `fix` is the one route that records nothing here: it leads to `fix_<phase>` →
|
|
50
|
+
* `commit_<phase>`, which appends the round with the commit attached. Recording
|
|
51
|
+
* at both seams would double-count every fixed round in the PR body.
|
|
52
|
+
*
|
|
53
|
+
* Best-effort, exactly like `appendRound` itself: the route is returned whether
|
|
54
|
+
* or not the write lands. Losing the record is bad; failing the run that has
|
|
55
|
+
* already done the work is worse.
|
|
56
|
+
*/
|
|
57
|
+
export async function routeReviewAndRecord(
|
|
58
|
+
ctx: { input: unknown } & OutputsCtx & StepsCtx,
|
|
59
|
+
phase: "spec" | "quality",
|
|
60
|
+
): Promise<{ route: string; escalationReason?: string; findings: Finding[] }> {
|
|
61
|
+
const routed = routeReview(ctx, phase);
|
|
62
|
+
const outcome = OUTCOME_BY_ROUTE[routed.route];
|
|
63
|
+
if (outcome) {
|
|
64
|
+
await appendRound(inputOf(ctx), {
|
|
65
|
+
ts: new Date().toISOString(),
|
|
66
|
+
phase,
|
|
67
|
+
attempt: reviewAttemptCount(ctx, phase),
|
|
68
|
+
committed: false,
|
|
69
|
+
outcome,
|
|
70
|
+
findings: routed.findings,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return routed;
|
|
74
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { TemplateMode } from "./pr-template-merge";
|
|
2
|
+
|
|
1
3
|
/** One acceptance-test group as reported by `nax features resolve --json`. */
|
|
2
4
|
export interface AcceptanceGroup {
|
|
3
5
|
packageDir: string;
|
|
@@ -50,6 +52,47 @@ export type FinishPhase = "acceptance" | "spec" | "quality" | "gate";
|
|
|
50
52
|
* finish that died mid-loop is exactly when the record of what it already
|
|
51
53
|
* changed on the branch matters most.
|
|
52
54
|
*/
|
|
55
|
+
/**
|
|
56
|
+
* What produced a round — the difference between "a reviewer read this and
|
|
57
|
+
* approved it" and "nothing read this".
|
|
58
|
+
*
|
|
59
|
+
* Rounds used to be appended only where a fix produced a commit, so a review
|
|
60
|
+
* that passed left no record at all and was indistinguishable from a review
|
|
61
|
+
* that never ran (#1507). Every phase that executes now records a round, and
|
|
62
|
+
* this field says which of the five things happened.
|
|
63
|
+
*
|
|
64
|
+
* Optional because rounds recorded by earlier versions have no `outcome`, and
|
|
65
|
+
* the PR body still has to render those without claiming more than it knows.
|
|
66
|
+
*/
|
|
67
|
+
export type FinishRoundOutcome =
|
|
68
|
+
/** A reviewer reported findings and this phase's fix node ran. */
|
|
69
|
+
| "fixed"
|
|
70
|
+
/** A reviewer ran and reported nothing. The only value that means "approved". */
|
|
71
|
+
| "passed"
|
|
72
|
+
/** The reviewer replied, but no verdict could be read out of it. */
|
|
73
|
+
| "unparseable"
|
|
74
|
+
/** Handed off to a human — an explicit escalate, a cap, or a node that emitted nothing. */
|
|
75
|
+
| "escalated"
|
|
76
|
+
/**
|
|
77
|
+
* This phase has no reviewer at all (`gate`, `acceptance`). Distinct from
|
|
78
|
+
* `passed`: an empty finding list here means "nobody looked", and rendering it
|
|
79
|
+
* as "no findings" manufactures evidence of a review that does not exist.
|
|
80
|
+
*/
|
|
81
|
+
| "no-reviewer"
|
|
82
|
+
/**
|
|
83
|
+
* A re-review was owed and deliberately skipped — today only a `gate` fix
|
|
84
|
+
* that touched test files exclusively (`gateCommitRoute` → `tests-only`).
|
|
85
|
+
*
|
|
86
|
+
* Distinct from `no-reviewer`, which the same node writes when the fix *is*
|
|
87
|
+
* routed on to `review_quality`. Without the distinction both wrote
|
|
88
|
+
* `no-reviewer`, so the audit could not tell a gate fix that was re-reviewed
|
|
89
|
+
* from one whose re-review was skipped by policy — the #1507 failure mode
|
|
90
|
+
* surviving on the one path where the omission is intentional, and the path
|
|
91
|
+
* where a reader most needs to know. Recording it is also what makes "how
|
|
92
|
+
* often does this fire?" answerable before anyone decides to close the hole.
|
|
93
|
+
*/
|
|
94
|
+
| "review-skipped";
|
|
95
|
+
|
|
53
96
|
export interface FinishRound {
|
|
54
97
|
ts: string;
|
|
55
98
|
phase: FinishPhase;
|
|
@@ -57,6 +100,8 @@ export interface FinishRound {
|
|
|
57
100
|
attempt: number;
|
|
58
101
|
/** True when the fix produced a commit; false when it changed nothing. */
|
|
59
102
|
committed: boolean;
|
|
103
|
+
/** What produced this round; absent on rounds written before it existed. */
|
|
104
|
+
outcome?: FinishRoundOutcome;
|
|
60
105
|
/** Reviewer findings this round set out to fix (spec/quality phases). */
|
|
61
106
|
findings: Finding[];
|
|
62
107
|
/** Gate commands that were red this round (gate phase). */
|
|
@@ -92,6 +137,19 @@ export interface FinishInput {
|
|
|
92
137
|
*/
|
|
93
138
|
escalateTelegram: boolean;
|
|
94
139
|
timeouts?: FinishTimeouts;
|
|
140
|
+
/** PR/MR body composition, forwarded from `finish.autoFlow.prBody`. */
|
|
141
|
+
prBody?: FinishPrBodySettings;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* How the repo's own PR/MR template is honoured when composing the body.
|
|
146
|
+
* Absent (and absent fields) mean the defaults in `pr-template-merge.ts`.
|
|
147
|
+
*/
|
|
148
|
+
export interface FinishPrBodySettings {
|
|
149
|
+
/** `merge` (default) · `strict` (keep unfillable headings, empty) · `ignore`. */
|
|
150
|
+
template?: TemplateMode;
|
|
151
|
+
/** Normalised template heading → body-section key, layered over the defaults. */
|
|
152
|
+
sectionMap?: Record<string, string>;
|
|
95
153
|
}
|
|
96
154
|
export interface FinishResult {
|
|
97
155
|
feature: string;
|
|
@@ -118,14 +118,31 @@ export function repromptCount(ctx: StepsCtx, phase: "spec" | "quality"): number
|
|
|
118
118
|
* findings, so checking `findings.length === 0` ahead of it would route an
|
|
119
119
|
* unreadable review to `clean`, and the flow would open a PR having reviewed
|
|
120
120
|
* nothing. That silent false green is worse than the crash this replaces.
|
|
121
|
+
*
|
|
122
|
+
* An **absent** verdict escalates for the same reason, and it is a distinct
|
|
123
|
+
* case from an unparseable one: `parseReviewVerdict` never returns undefined,
|
|
124
|
+
* so a missing entry means the node produced no output at all — it never ran,
|
|
125
|
+
* or it died before emitting. Neither is an approval. This must not fall
|
|
126
|
+
* through to `findings ?? []`, because `ctx.outputs` holds only each node's
|
|
127
|
+
* latest output: on a loop re-entry the previous round's clean verdict can
|
|
128
|
+
* still be sitting there, and routing on it re-approves a diff nobody read.
|
|
129
|
+
* There is no reprompt path here — a node that emitted nothing has no raw tail
|
|
130
|
+
* to quote back, so a human is the only remaining reader.
|
|
121
131
|
*/
|
|
122
132
|
export function routeReview(
|
|
123
133
|
ctx: OutputsCtx & StepsCtx,
|
|
124
134
|
phase: "spec" | "quality",
|
|
125
135
|
): { route: string; escalationReason?: string; findings: Finding[] } {
|
|
126
136
|
const verdict = (ctx.outputs as Record<string, ReviewVerdict | undefined>)[`review_${phase}`];
|
|
127
|
-
|
|
128
|
-
|
|
137
|
+
if (!verdict) {
|
|
138
|
+
return {
|
|
139
|
+
route: "escalate",
|
|
140
|
+
escalationReason: `${phase} reviewer produced no verdict — the node emitted no output, so nothing reviewed this diff.`,
|
|
141
|
+
findings: [],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const findings = verdict.findings ?? [];
|
|
145
|
+
if (verdict.route === "reprompt") {
|
|
129
146
|
// `attempts` is self-inclusive (see repromptCount) — it already counts this
|
|
130
147
|
// round's failure, so `<=` (not `<`) is what makes MAX_REPROMPT_ATTEMPTS=1
|
|
131
148
|
// tolerate exactly one retry before escalating.
|
|
@@ -139,7 +156,7 @@ export function routeReview(
|
|
|
139
156
|
findings,
|
|
140
157
|
};
|
|
141
158
|
}
|
|
142
|
-
if (verdict
|
|
159
|
+
if (verdict.route === "escalate") {
|
|
143
160
|
return {
|
|
144
161
|
route: "escalate",
|
|
145
162
|
escalationReason: verdict.escalationReason ?? `${phase} review raised a finding needing human judgment`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nathapp/nax",
|
|
3
|
-
"version": "0.77.
|
|
3
|
+
"version": "0.77.3",
|
|
4
4
|
"description": "AI Coding Agent Orchestrator — loops until done",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"@biomejs/biome": "^1.9.4",
|
|
65
65
|
"@types/bun": "^1.3.8",
|
|
66
66
|
"react-devtools-core": "^7.0.1",
|
|
67
|
-
"typescript": "^
|
|
67
|
+
"typescript": "^7.0.2"
|
|
68
68
|
},
|
|
69
69
|
"license": "MIT",
|
|
70
70
|
"author": "William Khoo",
|