@nathapp/nax 0.80.0 → 0.81.0-canary.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.80.0",
3
+ "version": "0.81.0-canary.1",
4
4
  "description": "AI Coding Agent Orchestrator — loops until done",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,10 +11,9 @@
11
11
  "dev": "bun run bin/nax.ts",
12
12
  "build": "bun build bin/nax.ts --outdir dist --target bun --define \"GIT_COMMIT=\\\"$(git rev-parse --short HEAD)\\\"\"",
13
13
  "typecheck": "bun x tsc --noEmit && bun x tsc --noEmit -p tsconfig.contracts.json",
14
- "lint": "bun x biome check src/ bin/ flows/ && bun run check:flows-no-bun && bun run check:no-real-global-nax && bun run check:feature-dir-ssot && bun run check:alias-internals && bun run check:deep-relatives && bun run check:nax-error && bun run check:logger-storyid && bun run check:log-format-layering && bun run check:file-sizes && bun run check:no-control-bytes",
15
- "lint:json": "bun x biome check src/ bin/ flows/ --reporter json && bun run check:nax-error 1>&2 && bun run check:logger-storyid 1>&2",
16
- "lint:fix": "bun x biome check --write src/ bin/ flows/",
17
- "check:flows-no-bun": "bun run scripts/check-flows-no-bun.ts",
14
+ "lint": "bun x biome check src/ bin/ && bun run check:no-real-global-nax && bun run check:feature-dir-ssot && bun run check:alias-internals && bun run check:deep-relatives && bun run check:nax-error && bun run check:logger-storyid && bun run check:log-format-layering && bun run check:file-sizes && bun run check:no-control-bytes && bun run check:review-prompts",
15
+ "lint:json": "bun x biome check src/ bin/ --reporter json && bun run check:nax-error 1>&2 && bun run check:logger-storyid 1>&2",
16
+ "lint:fix": "bun x biome check --write src/ bin/",
18
17
  "check:no-real-global-nax": "bun run scripts/check-no-real-global-nax.ts",
19
18
  "check:feature-dir-ssot": "bun run scripts/check-feature-dir-ssot.ts",
20
19
  "check:alias-internals": "bun run scripts/check-alias-internals.ts",
@@ -28,6 +27,8 @@
28
27
  "check:file-sizes": "bun run scripts/check-file-sizes.ts",
29
28
  "check:file-sizes:update": "bun run scripts/check-file-sizes.ts --update-baseline",
30
29
  "check:no-control-bytes": "bun run scripts/check-no-control-bytes.ts",
30
+ "check:review-prompts": "bun run scripts/check-review-prompts-generated.ts",
31
+ "gen:review-prompts": "bun run scripts/generate-review-prompts.ts",
31
32
  "release": "bun scripts/release.ts",
32
33
  "test": "AGENT=1 bun run scripts/run-tests.ts",
33
34
  "test:verbose": "bun run scripts/run-tests.ts",
@@ -88,7 +89,6 @@
88
89
  ],
89
90
  "files": [
90
91
  "dist/",
91
- "flows/",
92
92
  "README.md",
93
93
  "CHANGELOG.md"
94
94
  ],
@@ -1,215 +0,0 @@
1
- /**
2
- * Commit messages for the flow's `commit_<phase>` checkpoints.
3
- *
4
- * These commits are shipped history — they land on the feature branch and a
5
- * human reviews them in the PR. Every one of them used to read
6
- * `fix(<feature>): nax-finish <phase> fixes` with an empty body, so a reviewer
7
- * looking at six such commits could not tell which one re-enabled a disabled
8
- * market gate and which one renamed a variable. The reviewer already produced
9
- * exactly the material needed to say so — severity, title, problem, fix — and
10
- * it was being discarded at the one moment it could have been recorded.
11
- *
12
- * Subject lines follow the repo's conventional-commit rule and the 72-column
13
- * git summary convention; the findings go in the body, one bullet each.
14
- */
15
- import type { Finding, FinishPhase } from "./types";
16
-
17
- /** Git's conventional soft cap for a commit summary line. */
18
- const MAX_SUBJECT_LEN = 72;
19
-
20
- /** Worst-first, so the subject of a mixed batch reports the severity that matters. */
21
- const SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW"] as const;
22
-
23
- /** How much gate output to quote in the body before it stops being a commit message. */
24
- const MAX_GATE_OUTPUT_LINES = 20;
25
-
26
- /**
27
- * Markers a test runner uses to introduce a failing case, worst-supported-first.
28
- *
29
- * A heuristic, deliberately: nax orchestrates polyglot repos, so this cannot be
30
- * one runner's format. Each entry is the literal token that precedes the test's
31
- * name — bun/jest `(fail)`, go `--- FAIL:`, pytest `FAILED`, and the tick-style
32
- * reporters. Nothing downstream depends on a match; a miss just falls back to
33
- * the output tail, which is what shipped before.
34
- */
35
- const FAILURE_MARKERS = ["(fail)", "--- FAIL:", "FAILED ", "FAIL ", "✗ ", "× "];
36
-
37
- /** How many failing test names to name before the message stops being a commit message. */
38
- const MAX_NAMED_FAILURES = 10;
39
-
40
- /**
41
- * Strip machine-local filesystem layout out of text bound for shipped history.
42
- *
43
- * Two passes, because the two cases differ: a path under the repo is meaningful
44
- * once made relative, while a path outside it is noise no reader of the commit
45
- * can act on. The home-directory pattern catches what remains — runner output
46
- * routinely quotes absolute paths from outside the repo (caches, toolchains).
47
- */
48
- function redactPaths(text: string, workdir?: string): string {
49
- const withoutRepo = workdir ? text.split(`${workdir}/`).join("") : text;
50
- return withoutRepo.replace(/(?:\/Users\/|\/home\/)[^/\s)]+\//g, "~/");
51
- }
52
-
53
- /**
54
- * The names of the tests that actually failed, in output order.
55
- *
56
- * This is the whole point of the change: the body used to be the last 20 lines
57
- * of runner stdout, and a suite whose *passing* tests write to stderr pushes the
58
- * real failure out of that window — so the commit named a stack trace from a
59
- * test that passed (#1506).
60
- */
61
- function failingTestNames(output: string): string[] {
62
- const names: string[] = [];
63
- for (const line of output.split("\n")) {
64
- const trimmed = line.trim();
65
- const marker = FAILURE_MARKERS.find((m) => trimmed.startsWith(m));
66
- if (!marker) continue;
67
- // Drop bun's trailing `[0.12ms]` timing — it is noise in a commit message
68
- // and makes otherwise-identical messages differ between runs.
69
- const name = trimmed
70
- .slice(marker.length)
71
- .replace(/\s*\[[\d.]+m?s\]$/, "")
72
- .trim();
73
- if (name) names.push(name);
74
- }
75
- // Say so when the list is cut short. A bare list of ten reads as "ten tests
76
- // failed", and a reader who acts on that count is acting on a truncation.
77
- if (names.length > MAX_NAMED_FAILURES) {
78
- const dropped = names.length - MAX_NAMED_FAILURES;
79
- return [...names.slice(0, MAX_NAMED_FAILURES), `...and ${dropped} more failing test(s)`];
80
- }
81
- return names;
82
- }
83
-
84
- interface MessageCtx {
85
- outputs: Record<string, unknown>;
86
- }
87
-
88
- /** Options carrying what the message builder cannot read off `ctx.outputs`. */
89
- interface MessageOptions {
90
- /** Absolute repo root, used to rewrite quoted paths as repo-relative. */
91
- workdir?: string;
92
- }
93
-
94
- interface PhaseOutputs {
95
- findings?: Finding[];
96
- failing?: string[];
97
- output?: string;
98
- }
99
-
100
- function outputsFor(ctx: MessageCtx, nodeId: string): PhaseOutputs {
101
- return (ctx.outputs[nodeId] ?? {}) as PhaseOutputs;
102
- }
103
-
104
- function findingsFor(ctx: MessageCtx, phase: FinishPhase): Finding[] {
105
- const raw = outputsFor(ctx, `review_${phase}`).findings;
106
- return Array.isArray(raw) ? raw.filter((f): f is Finding => Boolean(f?.title)) : [];
107
- }
108
-
109
- function worstSeverity(findings: Finding[]): string {
110
- const present = new Set(findings.map((f) => f.severity));
111
- return SEVERITY_ORDER.find((s) => present.has(s)) ?? findings[0]?.severity ?? "LOW";
112
- }
113
-
114
- /**
115
- * Lowercase a finding title's leading word for the subject line.
116
- *
117
- * Reviewers write titles as sentences ("Market gate skip branch is
118
- * unreachable"); conventional-commit subjects read better in lower case. Only
119
- * the first character is touched — an all-caps leading token is an acronym
120
- * (`SSRF guard …`) and must survive intact.
121
- */
122
- function subjectCase(title: string): string {
123
- const [first = "", ...rest] = title.split(" ");
124
- const isAcronym = first.length > 1 && first === first.toUpperCase();
125
- return isAcronym
126
- ? title
127
- : `${first.charAt(0).toLowerCase()}${first.slice(1)}${rest.length ? ` ${rest.join(" ")}` : ""}`;
128
- }
129
-
130
- function truncate(s: string): string {
131
- return s.length <= MAX_SUBJECT_LEN ? s : `${s.slice(0, MAX_SUBJECT_LEN - 3)}...`;
132
- }
133
-
134
- /** "lint and test", "lint, test and typecheck" — a readable list for the subject. */
135
- function humanList(items: string[]): string {
136
- if (items.length <= 1) return items[0] ?? "";
137
- return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
138
- }
139
-
140
- function reviewSubject(phase: FinishPhase, findings: Finding[]): string {
141
- if (findings.length === 1) return subjectCase(findings[0].title);
142
- return `address ${findings.length} ${phase} review findings (worst: ${worstSeverity(findings)})`;
143
- }
144
-
145
- function subjectFor(phase: FinishPhase, ctx: MessageCtx): string {
146
- if (phase === "gate") {
147
- const failing = outputsFor(ctx, "quality_gates").failing ?? [];
148
- return failing.length > 0 ? `repair failing ${humanList(failing)} gates` : "repair failing quality gates";
149
- }
150
- if (phase === "acceptance") return "repair failing acceptance tests";
151
- const findings = findingsFor(ctx, phase);
152
- return findings.length > 0 ? reviewSubject(phase, findings) : `apply ${phase} review fixes`;
153
- }
154
-
155
- /**
156
- * What to quote from a runner's output: the failing test names if they can be
157
- * identified, otherwise the tail, as before.
158
- *
159
- * Never both. Naming the failures *and* pasting the tail reproduces the noise
160
- * this replaces, and the tail is the weaker signal whenever the names exist.
161
- */
162
- function runnerEvidence(output: string, opts: MessageOptions): string {
163
- const clean = redactPaths(output, opts.workdir).trim();
164
- const names = failingTestNames(clean);
165
- if (names.length > 0) return ["Failed tests:", ...names.map((n) => `- ${n}`)].join("\n");
166
- return clean.split("\n").slice(-MAX_GATE_OUTPUT_LINES).join("\n");
167
- }
168
-
169
- function bodyFor(phase: FinishPhase, ctx: MessageCtx, opts: MessageOptions): string[] {
170
- if (phase === "gate") {
171
- const gate = outputsFor(ctx, "quality_gates");
172
- const failing = gate.failing ?? [];
173
- const evidence = runnerEvidence(gate.output ?? "", opts);
174
- return [...(failing.length > 0 ? [`Failing: ${failing.join(", ")}`] : []), ...(evidence ? [evidence] : [])];
175
- }
176
- if (phase === "acceptance") {
177
- const evidence = runnerEvidence(outputsFor(ctx, "acceptance").output ?? "", opts);
178
- return evidence ? [evidence] : [];
179
- }
180
- const findings = findingsFor(ctx, phase);
181
- if (findings.length === 0) return [];
182
- return [
183
- findings
184
- .map((f) =>
185
- [`- [${f.severity}] ${f.title}`, f.problem ? ` ${f.problem}` : "", f.fix ? ` Fix: ${f.fix}` : ""]
186
- .filter(Boolean)
187
- .join("\n"),
188
- )
189
- .join("\n"),
190
- ];
191
- }
192
-
193
- /** Human-readable phase label for the attribution trailer. */
194
- function phaseLabel(phase: FinishPhase): string {
195
- return phase === "gate" ? "quality gate" : phase === "acceptance" ? "acceptance" : `${phase} review`;
196
- }
197
-
198
- /**
199
- * Build the commit message for a `commit_<phase>` checkpoint.
200
- *
201
- * Never throws and never returns an empty subject: a missing or malformed
202
- * reviewer output degrades to the phase label. A commit that cannot be
203
- * described is still a commit that must happen — failing here would strand the
204
- * fix uncommitted and reintroduce the stale-diff bug (#1397).
205
- */
206
- export function buildFixCommitMessage(
207
- phase: FinishPhase,
208
- feature: string,
209
- ctx: MessageCtx,
210
- opts: MessageOptions = {},
211
- ): string {
212
- const subject = truncate(`fix(${feature}): ${subjectFor(phase, ctx)}`);
213
- const body = bodyFor(phase, ctx, opts);
214
- return [subject, ...body, `nax-finish: ${phaseLabel(phase)} fixes`].join("\n\n");
215
- }
@@ -1,23 +0,0 @@
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
- }
@@ -1,135 +0,0 @@
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
- * ## Why `node:child_process` and not `Bun.spawn`
19
- *
20
- * The rest of nax is Bun-native (see `.claude/rules/project-conventions.md`),
21
- * but this module is **not** loaded by nax. `acpx flow run` loads it, in acpx's
22
- * own process, and the published `acpx` binary is a Node program
23
- * (`#!/usr/bin/env node`). Under Node the `Bun` global does not exist, so
24
- * `Bun.spawn` threw `ReferenceError: Bun is not defined` on the flow's very
25
- * first git call — aborting the flow before any node completed and before the
26
- * result file was written. Everything under `flows/` must therefore stay on
27
- * Node built-ins; `Bun.*` is banned here and only here, enforced by
28
- * `scripts/check-flows-no-bun.ts`.
29
- */
30
- import { spawn } from "node:child_process";
31
- import type { RunResult } from "./types";
32
-
33
- /** Fallbacks used when the plugin passes no explicit budget in the flow input. */
34
- export const DEFAULT_ACCEPTANCE_TIMEOUT_MS = 600_000;
35
- export const DEFAULT_GATE_TIMEOUT_MS = 900_000;
36
- /** Short budget for the flow's own git/forge plumbing — these are never long-running. */
37
- export const DEFAULT_ARGV_TIMEOUT_MS = 120_000;
38
-
39
- export interface ExecOptions {
40
- cwd: string;
41
- timeoutMs?: number;
42
- }
43
-
44
- /** Exit code reported when the wall-clock cap kills the process, matching `timeout(1)`. */
45
- const TIMEOUT_EXIT_CODE = 124;
46
- /** Exit code reported when the binary is missing, matching a shell's "command not found". */
47
- const NOT_FOUND_EXIT_CODE = 127;
48
-
49
- function spawnCapture(cmd: string[], opts: ExecOptions): Promise<RunResult> {
50
- return new Promise<RunResult>((resolve) => {
51
- const [file, ...args] = cmd;
52
- // `detached: true` puts the child in its own process group so we can
53
- // SIGKILL the whole tree when the timer fires — otherwise a SIGTERM on
54
- // `sh` only kills the shell, and any inherited pipes from a still-running
55
- // child (e.g. `sleep 30`) keep `close` from firing until the child exits.
56
- const proc = spawn(file as string, args, {
57
- cwd: opts.cwd,
58
- stdio: ["ignore", "pipe", "pipe"],
59
- detached: true,
60
- });
61
- let stdout = "";
62
- let stderr = "";
63
- let timedOut = false;
64
- let settled = false;
65
-
66
- proc.stdout.setEncoding("utf8");
67
- proc.stderr.setEncoding("utf8");
68
- proc.stdout.on("data", (chunk: string) => {
69
- stdout += chunk;
70
- });
71
- proc.stderr.on("data", (chunk: string) => {
72
- stderr += chunk;
73
- });
74
-
75
- // setTimeout (not a sleep) because the handle must be cancellable the moment
76
- // the process exits — the documented exception in forbidden-patterns.md.
77
- const timer =
78
- opts.timeoutMs && opts.timeoutMs > 0
79
- ? setTimeout(() => {
80
- timedOut = true;
81
- if (proc.pid !== undefined) {
82
- // Negative pid = process group. SIGKILL cannot be ignored,
83
- // guarantees the child tree (and inherited pipes) actually close.
84
- try {
85
- process.kill(-proc.pid, "SIGKILL");
86
- } catch {
87
- // Group already gone.
88
- }
89
- }
90
- }, opts.timeoutMs)
91
- : undefined;
92
-
93
- const settle = (result: RunResult): void => {
94
- if (settled) return;
95
- settled = true;
96
- if (timer) clearTimeout(timer);
97
- resolve(result);
98
- };
99
-
100
- // A missing binary (`gh`/`glab` not installed) surfaces as an `error` event
101
- // under Node, where `Bun.spawn` used to throw. Resolving with 127 instead of
102
- // rejecting keeps it a readable gate failure the flow can route on, rather
103
- // than an exception that kills `acpx flow run` with no result file.
104
- proc.on("error", (err: Error) => {
105
- settle({ exitCode: NOT_FOUND_EXIT_CODE, stdout, stderr: `${stderr}${err.message}`, timedOut });
106
- });
107
-
108
- // `close` (not `exit`) so both pipes are fully drained before we read them.
109
- // `code` is null when the process died from a signal — including our own
110
- // timeout kill — so it maps to a non-zero code rather than a false green.
111
- proc.on("close", (code: number | null) => {
112
- const exitCode = code ?? (timedOut ? TIMEOUT_EXIT_CODE : 1);
113
- settle(
114
- timedOut
115
- ? {
116
- exitCode: exitCode === 0 ? TIMEOUT_EXIT_CODE : exitCode,
117
- stdout,
118
- stderr: `${stderr}\n[nax-finish] killed after ${opts.timeoutMs}ms timeout`,
119
- timedOut: true,
120
- }
121
- : { exitCode, stdout, stderr },
122
- );
123
- });
124
- });
125
- }
126
-
127
- /** Spawn an argv array directly — no shell. For flow-constructed commands. */
128
- export function runArgv(cmd: string[], opts: ExecOptions): Promise<RunResult> {
129
- return spawnCapture(cmd, { ...opts, timeoutMs: opts.timeoutMs ?? DEFAULT_ARGV_TIMEOUT_MS });
130
- }
131
-
132
- /** Run a configured command string through `/bin/sh -c`, preserving shell semantics. */
133
- export function runShell(command: string, opts: ExecOptions): Promise<RunResult> {
134
- return spawnCapture(["/bin/sh", "-c", command], opts);
135
- }
@@ -1,145 +0,0 @@
1
- /**
2
- * Readers over an acpx `FlowNodeContext` — the flow graph's view of its own
3
- * state.
4
- *
5
- * Split out of `nax-finish.flow.ts` (600-line source cap). These are all pure
6
- * functions of `ctx.input` / `ctx.outputs` / `ctx.state.steps`; anything that
7
- * shells out lives under `./steps/`.
8
- *
9
- * Two views of the same run, and the difference matters in a graph whose whole
10
- * shape is loops:
11
- *
12
- * - `ctx.outputs` is a map keyed by node id, so it holds only each node's
13
- * **latest** output. A node re-entering a loop cannot see its own previous
14
- * round there.
15
- * - `ctx.state.steps` is the ordered history and carries every step's `output`,
16
- * so an earlier round IS recoverable from it. A step is appended on its
17
- * *outcome*, so the currently-executing node is never in this list — which is
18
- * what lets `incrementalSince` find the *previous* review rather than itself.
19
- */
20
- import type { AcceptanceStatus } from "./steps/context";
21
- import type { AcceptanceGroup, Finding, FinishInput, FinishPhase, ReviewVerdict } from "./types";
22
-
23
- /** Minimal shapes so each reader takes only the part of the context it reads. */
24
- export interface StepsCtx {
25
- state: { steps: { nodeId: string; output?: unknown }[] };
26
- }
27
- export interface OutputsCtx {
28
- outputs: unknown;
29
- }
30
-
31
- export const inputOf = (ctx: { input: unknown }) => ctx.input as FinishInput;
32
-
33
- /** What `load_ctx` resolves once, for every downstream node to read. */
34
- export interface LoadCtxOutput {
35
- base?: string;
36
- specPath?: string;
37
- groups?: AcceptanceGroup[];
38
- /** `nax features resolve`'s acceptance status, narrowed at `resolveFeature`. */
39
- acceptanceStatus?: AcceptanceStatus;
40
- /** Test-file regex sources from `nax features resolve`; empty = cannot classify. */
41
- testFileRegex?: string[];
42
- route?: string;
43
- /** Set only when `route` is `escalate` — see `preflight`. */
44
- reason?: string;
45
- }
46
-
47
- export function fixAttemptCount(ctx: StepsCtx, fixNodeId: string): number {
48
- return (ctx.state.steps ?? []).filter((s) => s.nodeId === fixNodeId).length;
49
- }
50
-
51
- export function loadCtxOf(ctx: OutputsCtx): LoadCtxOutput {
52
- return ((ctx.outputs as Record<string, LoadCtxOutput | undefined>).load_ctx ?? {}) as LoadCtxOutput;
53
- }
54
- /**
55
- * The narrative node's parsed prose.
56
- *
57
- * Absent when the node was skipped by config, died, or produced only
58
- * whitespace — `amend_body` treats all three identically, so there is one
59
- * branch downstream rather than three.
60
- *
61
- * Accepts the bare string the node used to return as well as the
62
- * `{ narrative, title }` it returns now: a flow resumed from a run recorded
63
- * before the title landed replays the old shape from its journal.
64
- */
65
- export function narrativeOf(ctx: OutputsCtx): string | undefined {
66
- const out = (ctx.outputs as Record<string, unknown>).narrative;
67
- const prose = typeof out === "string" ? out : (out as { narrative?: unknown } | undefined)?.narrative;
68
- return typeof prose === "string" && prose.trim().length > 0 ? prose : undefined;
69
- }
70
-
71
- /**
72
- * The narrative node's parsed PR title, already sanitised by `parseTitle`.
73
- *
74
- * Absent whenever the node is — `resolveTitle` then falls back to
75
- * `feat: <feature>`, which is what shipped before and what auto-PR opens with.
76
- */
77
- export function prTitleOf(ctx: OutputsCtx): string | undefined {
78
- const out = (ctx.outputs as Record<string, unknown>).narrative;
79
- if (typeof out !== "object" || out === null) return undefined;
80
- const title = (out as { title?: unknown }).title;
81
- return typeof title === "string" && title.trim().length > 0 ? title : undefined;
82
- }
83
-
84
- export function gateOutputs(ctx: OutputsCtx): { failing?: string[]; ran?: string[] } {
85
- return ((ctx.outputs as Record<string, { failing?: string[]; ran?: string[] } | undefined>).quality_gates ?? {}) as {
86
- failing?: string[];
87
- ran?: string[];
88
- };
89
- }
90
-
91
- /** The findings the `fix_<phase>` node was asked to resolve; empty for non-review phases. */
92
- export function findingsOf(ctx: OutputsCtx, phase: FinishPhase): Finding[] {
93
- if (phase !== "spec" && phase !== "quality") return [];
94
- return (ctx.outputs as Record<string, ReviewVerdict | undefined>)[`review_${phase}`]?.findings ?? [];
95
- }
96
-
97
- /**
98
- * The ref a re-review should diff from, or null to review the whole branch.
99
- *
100
- * A reviewer node re-reads the spec in full and the entire `git diff
101
- * base...HEAD` on every round. Reviews were 58% of the wall clock on
102
- * rs-stock/pipeline-run-outcome (7 calls, 1306s of 2232s), and round 3 re-read
103
- * everything rounds 1-2 had already cleared.
104
- *
105
- * The scoping ref is the `shaBefore` of the **first** `commit_*` step after this
106
- * phase's last review — that commit's parent is, by construction, the tree the
107
- * previous verdict passed on, since only `commit_*` nodes commit. Taking the
108
- * first (not the last) is what makes the window complete when more than one
109
- * commit landed in it, which happens when the acceptance loop commits between a
110
- * spec fix and its re-review: `firstCommit.shaBefore..HEAD` spans both.
111
- *
112
- * Read from `ctx.state.steps[].output`, not `ctx.outputs` — the latter keeps
113
- * only each node's newest output, which for two commit steps of the same node id
114
- * would have discarded the earlier `shaBefore` and silently under-scoped the
115
- * review.
116
- *
117
- * Only commit steps that actually **committed** anchor the window. A fix node
118
- * that edited nothing still records a `commit_*` step, and its `shaBefore` is
119
- * the current HEAD — so scoping to it asks the reviewer for `HEAD..HEAD`, an
120
- * empty diff, while the prompt tells it the prior findings "have since been
121
- * fixed and committed". It returns clean, `route_*` sends the flow onward, and
122
- * the findings ship unfixed. That leaves the loop through the green door, so
123
- * `MAX_FIX_ATTEMPTS` never catches it. A no-op round therefore either yields
124
- * the window to a later real commit, or falls back to a full review.
125
- *
126
- * Rounds journalled before `committed` existed carry no such field; `!== false`
127
- * keeps replaying them on the previous behaviour rather than widening every
128
- * resumed review to the whole branch.
129
- *
130
- * Returns null — a full review — when there is no prior review of this phase
131
- * (round 1), no commit landed since it (nothing new to look at), or the commit
132
- * step recorded no `shaBefore`.
133
- */
134
- export function incrementalSince(ctx: OutputsCtx & StepsCtx, phase: "spec" | "quality"): string | null {
135
- const steps = ctx.state.steps ?? [];
136
- const lastReview = steps.map((s) => s.nodeId).lastIndexOf(`review_${phase}`);
137
- if (lastReview < 0) return null;
138
- const firstCommit = steps
139
- .slice(lastReview + 1)
140
- .find(
141
- (s) => s.nodeId.startsWith("commit_") && (s.output as { committed?: boolean } | undefined)?.committed !== false,
142
- );
143
- if (!firstCommit) return null;
144
- return (firstCommit.output as { shaBefore?: string | null } | undefined)?.shaBefore ?? null;
145
- }