forgedock 1.21.0 → 1.22.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.
@@ -2,46 +2,39 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
- import { readFileSync, readdirSync } from "node:fs";
6
5
  import { tmpDir } from "../tests/_tmp.mjs";
7
- import { join } from "node:path";
8
- import { runLean, MAX_ROUNDS } from "./lean.mjs";
9
- import { riskTier, isDocsOnly, isTestFile, isBlocking, parseFindings, choosePath, isLargeDiff } from "./risk.mjs";
10
- import { BUILDER_SYSTEM, REVIEWER_SYSTEM, SECURITY_REVIEWER_SYSTEM, approxTokens } from "./prompts.mjs";
6
+ import { runCore } from "./lean.mjs";
7
+ import { renderValidate, renderPlan, renderReview, renderRegister, renderPrBody, cell, NR } from "./render.mjs";
8
+ import { orchestrate, parseDeps, formatTable } from "./orchestrate.mjs";
9
+ import { REVIEWER_SYSTEM, approxTokens, builderPrompt, reviewerPrompt, parseVerdict } from "./prompts.mjs";
11
10
  import { mapRecord } from "../engine/telemetry-seam.mjs";
12
11
  import { readLog } from "../engine/runlog.mjs";
13
- import { runFromCli } from "../engine-cli.mjs";
14
- import { runCliBackend, buildLeanSessionFlags, buildReadOnlyToolFlags } from "../runner.mjs";
12
+ import { runFromCli, orchestrateFromCli, ENGINE_PATHS } from "../engine-cli.mjs";
15
13
 
16
14
  const WT = "/wt/lean-7";
17
- const finding = (o) => ({ id: "F1", severity: "LOW", confidence: "POSSIBLE", file: "a.js", line: 1, scope: "introduced", title: "t", failure_scenario: "s", ...o });
18
- const reply = (arr) => "```json\n" + JSON.stringify(arr) + "\n```";
15
+ const ok = (extra = {}) => "```json\n" + JSON.stringify({ verdict: "PASS", fixes_issue: true, regression: false, reason: "src/foo.js:3 fixed", ...extra }) + "\n```";
16
+ const bad = (reason = "src/foo.js:9 still wrong") => ok({ verdict: "FAIL", fixes_issue: false, reason });
19
17
 
20
- /** In-memory world: fake gh, git, fs, runner, ci. `script` hooks let each test steer the builder/reviewer. */
18
+ /** In-memory world with fake gh, git, fs and runner. */
21
19
  function world(o = {}) {
22
20
  const w = {
23
- prList: o.prList ?? [], remote: o.remote ?? "", apiIssues: o.apiIssues, leaseWrites: [], lease: o.lease ?? null, comments: [], labels: [], closed: [], edits: [], prs: [], merges: [], calls: [], telemetry: [], files: {}, ciQueue: [...(o.ci ?? [])],
24
- changed: o.changed ?? ["src/foo.js", "test/foo.test.js"], lines: o.lines ?? 40, head: "aaaa1111aaaa1111", waits: [...(o.waits ?? [])],
25
- reviewReplies: [...(o.reviews ?? [])], builderHooks: [...(o.builderHooks ?? [])], issueBody: o.body ?? "Bug in foo", clock: 0, validate: o.validate ?? { status: "valid", summary: "real", evidence: ["src/foo.js:3"] },
26
- };
27
- const writeBuilderFiles = () => {
28
- w.files[`${WT}/.forge-lean/validate.json`] = JSON.stringify(w.validate);
29
- w.files[`${WT}/.forge-lean/plan.md`] = "## Approach\nfix foo\n## Sibling sites\nbar";
30
- w.files[`${WT}/.forge-lean/implement.json`] = JSON.stringify({ summary: "fixed", commit: w.head, files: w.changed });
21
+ comments: [], labels: [], closed: [], edits: [], prs: [], merges: [], calls: [], telemetry: [], files: {}, head: "aaaa1111aaaa1111",
22
+ changed: o.changed ?? ["src/foo.js", "test/foo.test.js"], reviews: [...(o.reviews ?? [])], waits: [...(o.waits ?? [])], clock: 0,
23
+ result: o.result ?? { status: "fixed", summary: "fixed foo", evidence: [], tests: ["test/foo.test.js"] },
24
+ prList: o.prList ?? [], remote: o.remote ?? "", resumeResult: o.resumeResult, seq: [],
31
25
  };
32
26
  w.io = {
33
27
  now: () => w.clock, sleep: async (ms) => { w.clock += ms; },
34
28
  gh: async (a) => {
35
29
  const k = a.slice(0, 2).join(" ");
36
- if (a[0] === "api") { const m = /issues\/(\d+)$/.exec(a[1]); const r = m && w.apiIssues?.[m[1]]; return r ? JSON.stringify(r) : ""; }
30
+ if (a[0] === "api") return "";
37
31
  if (k === "pr list") return JSON.stringify(w.prList);
38
- if (k === "issue view" && a.includes("comments")) return JSON.stringify({ comments: w.comments.map((body) => ({ body })) });
39
- if (k === "issue view") return JSON.stringify({ number: 7, title: "foo breaks", body: w.issueBody, state: "OPEN" });
40
- if (k === "issue comment") { w.comments.push(a[a.indexOf("--body") + 1]); return `https://github.com/o/r/issues/7#issuecomment-${w.comments.length}\n`; }
41
- if (k === "issue close") { w.closed.push(a); return ""; }
32
+ if (k === "issue view") return JSON.stringify({ number: 7, title: "foo breaks", body: "Bug in foo", state: "OPEN" });
33
+ if (k === "issue comment") { w.comments.push(a[a.indexOf("--body") + 1]); w.seq.push("comment"); return `https://github.com/o/r/issues/7#issuecomment-${w.comments.length}\n`; }
34
+ if (k === "issue close") { w.closed.push(a); w.seq.push("close"); return ""; }
42
35
  if (k === "issue edit") { w.edits.push(a); return ""; }
43
- if (k === "pr create") { w.prs.push(a); return "https://github.com/o/r/pull/77\n"; }
44
- if (k === "pr merge") { w.merges.push(a); return ""; }
36
+ if (k === "pr create") { w.prs.push(a); w.seq.push("pr"); return "https://github.com/o/r/pull/77\n"; }
37
+ if (k === "pr merge") { w.merges.push(a); w.seq.push("merge"); return ""; }
45
38
  throw new Error(`unexpected gh ${a.join(" ")}`);
46
39
  },
47
40
  git: async (a) => {
@@ -49,477 +42,444 @@ function world(o = {}) {
49
42
  if (a[0] === "rev-parse" && a[1] === "--git-path") return "/repo/.git/info/exclude";
50
43
  if (a[0] === "rev-parse") return w.head;
51
44
  if (a[0] === "diff" && a[1] === "--name-only") return w.changed.join("\n");
52
- if (a[0] === "diff" && a[1] === "--numstat") return `${w.lines}\t0\tx`;
53
- if (a[0] === "ls-remote") return w.remote;
54
45
  if (a[0] === "diff") return "diff --git a/src/foo.js b/src/foo.js\n+fix";
46
+ if (a[0] === "ls-remote") return w.remote;
47
+ if (a[0] === "push") w.seq.push("push");
55
48
  return "";
56
49
  },
57
50
  runner: async (c) => {
58
- w.calls.push(["runner", c.role, c.resume ?? null, c.model ?? null, c.prompt, c.readOnly ?? false]);
59
- if (o.builderThrows && c.role === "builder") throw new Error("cli exploded");
51
+ w.calls.push(["runner", c.role, c.resume ?? null, c.model ?? null, c.prompt, c.readOnly ?? false, c.system]);
52
+ w.seq.push(c.role + (c.resume ? "-resume" : ""));
60
53
  if (c.role === "builder") {
61
- if (!c.resume) { w.builderStart = c; writeBuilderFiles(); }
62
- else { const h = w.builderHooks.shift(); if (h) h(w); w.head = `bbbb${w.calls.length}`.padEnd(16, "0"); }
63
- return { text: "done", sessionId: "sess-1", usage: { input_tokens: 10, output_tokens: 5 }, costUsd: 0.1, durationMs: 1000, turns: 3, model: "default" };
54
+ const r = c.resume ? (w.resumeResult ?? w.result) : w.result;
55
+ if (r) w.files[`${WT}/.forge-lean/result.json`] = JSON.stringify(r);
56
+ if (c.resume) w.head = `bbbb${w.calls.length}`.padEnd(16, "0");
57
+ return { text: "done", sessionId: "sess-1", usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 100 }, costUsd: 0.1, durationMs: 1000, turns: 3, model: "default" };
64
58
  }
65
- const text = w.reviewReplies.length ? w.reviewReplies.shift() : reply([]);
66
- return { text, sessionId: `rev-${w.calls.length}`, usage: { input_tokens: 4, output_tokens: 2 }, costUsd: 0.05, durationMs: 500, turns: 2, model: c.model };
59
+ return { text: w.reviews.length ? w.reviews.shift() : ok(), sessionId: "rev", usage: { input_tokens: 4, output_tokens: 2 }, costUsd: 0.05, durationMs: 500, turns: 2, model: c.model };
67
60
  },
68
- ciLocal: async () => w.ciQueue.length ? w.ciQueue.shift() : { result: "PASS", tail: "" },
69
- waitCiGreen: async () => w.waits.length ? w.waits.shift() : { rc: 0, text: `CI_GATE: PASS\nCI_GATE_HEAD: ${w.head}` },
70
- projector: { readState: async () => (w.lease ? { v: 1, lease: w.lease } : null), writeState: async (_i, st) => { w.leaseWrites.push(st); } },
71
- transitionLabel: async (n, s) => { w.labels.push(s); },
61
+ waitCiGreen: async () => (w.waits.length ? w.waits.shift() : { rc: 0, text: `CI_GATE_HEAD: ${w.head}` }),
62
+ transitionLabel: async (n, s) => { w.labels.push(s); w.seq.push(`label:${s}`); },
72
63
  fs: { read: async (p) => { if (!(p in w.files)) throw new Error("ENOENT"); return w.files[p]; }, append: async (p, t) => { w.files[p] = (w.files[p] ?? "") + t; } },
73
64
  };
74
65
  return w;
75
66
  }
76
-
77
- const run = (w, extra = {}) => runLean({ issue: 7, repo: "o/r", lane: "staging", cwd: "/repo", worktreeRoot: "/wt", dir: tmpDir("lean-"),
67
+ const run = (w, extra = {}) => runCore({ issue: 7, repo: "o/r", lane: "staging", cwd: "/repo", worktreeRoot: "/wt", dir: tmpDir("core-"),
78
68
  io: w.io, emitTelemetry: async (r) => w.telemetry.push(r), random: () => 0.5, ...extra });
79
- const markers = (w) => w.comments.map((c) => /FORGE:[A-Z_]+/.exec(c)[0]);
80
- const resumes = (w) => w.calls.filter((c) => c[0] === "runner" && c[1] === "builder");
69
+ const builders = (w) => w.calls.filter((c) => c[0] === "runner" && c[1] === "builder");
70
+ const reviewers = (w) => w.calls.filter((c) => c[0] === "runner" && c[1] === "reviewer");
71
+ const registers = (w) => w.comments.filter((c) => c.startsWith("<!-- FORGE:WORK-ON -->"));
81
72
 
82
- describe("lean path: happy path and knowledge graph", () => {
83
- it("drives an issue end to end and posts every stage comment with its marker and content", async () => {
73
+ describe("core work-on", () => {
74
+ it("happy path: started comment, one register comment with everything, merge at the reviewed head", async () => {
84
75
  const w = world();
85
- const r = await run(w);
76
+ const dir = tmpDir("core-");
77
+ const r = await run(w, { dir });
86
78
  assert.equal(r.terminalReason, "merged");
87
- assert.deepEqual(markers(w), ["FORGE:VALIDATE", "FORGE:PLAN", "FORGE:IMPLEMENT", "FORGE:REVIEW", "FORGE:CLOSE"]);
88
- assert.match(w.comments[0], /"status": "valid"/);
89
- assert.match(w.comments[1], /Sibling sites/);
90
- assert.match(w.comments[2], /"summary": "fixed"/);
91
- const body = w.prs[0][w.prs[0].indexOf("--body") + 1];
92
- assert.match(body, /Closes #7/);
93
- for (const m of ["FORGE:VALIDATE", "FORGE:PLAN", "FORGE:IMPLEMENT", "FORGE:REVIEW"]) assert.match(body, new RegExp(`${m}: https://github.com/o/r/issues/7#issuecomment-\\d+`));
79
+ assert.equal(r.pr, 77);
80
+ assert.match(w.comments[0], /FORGE:STARTED/);
81
+ const reg = registers(w);
82
+ assert.equal(reg.length, 1);
83
+ for (const needle of ["fixed foo", "src/foo.js", "test/foo.test.js", "PASS", "src/foo.js:3 fixed", "https://github.com/o/r/pull/77", "cost $0.15", "tokens 14 in / 7 out / 100 cache", "wall"]) {
84
+ assert.ok(reg[0].includes(needle), `register comment lacks ${needle}:\n${reg[0]}`);
85
+ }
86
+ assert.match(w.prs[0][w.prs[0].indexOf("--body") + 1], /Closes #7/);
94
87
  assert.deepEqual(w.merges[0].slice(-2), ["--match-head-commit", w.head]);
88
+ assert.equal(w.closed.length, 1);
89
+ assert.equal(builders(w).length, 1);
90
+ assert.equal(reviewers(w).length, 1);
91
+ const ev = readLog(dir, 7).map((e) => e.event);
92
+ assert.equal(ev[0], "RUN_START"); assert.equal(ev.at(-1), "RUN_TERMINAL");
93
+ });
94
+
95
+ it("validation comment is posted before the reviewer and the in-review label; content for a fixed issue; register links it", async () => {
96
+ const w = world({ result: { status: "fixed", summary: "fixed foo\nmore", evidence: ["src/foo.js:3 null deref"], tests: ["t"] } });
97
+ await run(w);
98
+ const vi = w.comments.findIndex((c) => c.startsWith("<!-- FORGE:VALIDATE -->"));
99
+ assert.ok(vi >= 0);
100
+ const v = w.comments[vi];
101
+ assert.match(v, /Verdict:\*\* valid/);
102
+ assert.match(v, /src\/foo.js:3 null deref/);
103
+ assert.match(v, /Summary:\*\* fixed foo$/);
104
+ assert.ok(!v.includes("more"));
105
+ const pos = (x) => w.seq.indexOf(x);
106
+ const commentSeq = w.seq.map((s, i) => (s === "comment" ? i : -1)).filter((i) => i >= 0);
107
+ assert.ok(commentSeq[vi] > pos("builder") && commentSeq[vi] < pos("reviewer"));
108
+ assert.ok(commentSeq[vi] < pos("label:in-review"));
109
+ assert.match(registers(w)[0], /Validation: https:\/\/github.com\/o\/r\/issues\/7#issuecomment-\d+/);
110
+ assert.equal(w.comments.filter((c) => c.includes("FORGE:VALIDATE")).length, 1);
111
+ });
112
+
113
+ it("labels transition in order, and register is posted after the PR and before the merge", async () => {
114
+ const w = world();
115
+ await run(w);
95
116
  assert.deepEqual(w.labels, ["investigating", "building", "in-review", "merged"]);
96
- assert.ok(w.closed.length === 1);
97
- assert.ok(w.files["/repo/.git/info/exclude"].includes(".forge-lean/"), "scratch dir is git-excluded");
117
+ const order = w.seq.filter((s) => ["label:investigating", "label:building", "builder", "label:in-review", "reviewer", "push", "pr", "merge", "label:merged"].includes(s));
118
+ assert.deepEqual(order, ["label:investigating", "label:building", "builder", "label:in-review", "reviewer", "push", "pr", "merge", "label:merged"]);
119
+ assert.ok(w.seq.indexOf("pr") < w.seq.lastIndexOf("comment") && w.seq.lastIndexOf("comment") < w.seq.indexOf("merge"));
98
120
  });
99
121
 
100
- it("emits one phase_attempt per stage plus run_terminal, stats-compatible", async () => {
122
+ it("emits phase_attempt for builder and reviewer plus run_terminal, stats-compatible", async () => {
101
123
  const w = world();
102
- const r = await run(w);
124
+ await run(w);
103
125
  const mapped = w.telemetry.map((t) => mapRecord(t, 5)).filter(Boolean);
104
126
  assert.deepEqual(mapped.filter((m) => m.event === "phase_attempt").map((m) => m.phase), ["implement", "review"]);
105
- assert.equal(mapped[0].payload.stages, "validate,architect,implement");
106
- assert.equal(mapped.filter((m) => m.event === "phase_attempt" && m.payload.input_tokens === undefined).length, 0, "no null-usage records");
127
+ assert.equal(mapped[0].payload.input_tokens, 10);
107
128
  assert.equal(mapped.at(-1).event, "run_terminal");
108
129
  assert.equal(mapped.at(-1).payload.reason, "merged");
109
- const v = mapped[0].payload;
110
- assert.equal(mapped[0].payload.attempt, 1);
111
- assert.equal(v.input_tokens, 10); assert.equal(v.cost_usd, 0.1);
112
- assert.ok(r.cost_usd > 0);
113
130
  });
114
131
 
115
- it("writes the run log (RUN_START, PHASE_COMMIT per stage, RUN_TERMINAL)", async () => {
116
- const w = world(); const dir = tmpDir("lean-");
117
- await run(w, { dir });
118
- const ev = readLog(dir, 7).map((e) => e.event);
119
- assert.equal(ev[0], "RUN_START"); assert.equal(ev.at(-1), "RUN_TERMINAL");
120
- assert.ok(readLog(dir, 7).filter((e) => e.event === "PHASE_COMMIT").map((e) => e.phase).includes("review"));
132
+ it("invalid with evidence closes the issue as invalid and opens no PR", async () => {
133
+ const w = world({ changed: [], result: { status: "invalid", summary: "not a bug", evidence: ["src/foo.js:3 already guards null"], tests: [] } });
134
+ const r = await run(w);
135
+ assert.equal(r.terminalReason, "invalid");
136
+ assert.deepEqual(w.labels, ["investigating", "building", "invalid"]);
137
+ const vs = w.comments.filter((c) => c.startsWith("<!-- FORGE:VALIDATE -->"));
138
+ assert.equal(vs.length, 1, "the validation comment is the evidence comment");
139
+ assert.match(vs[0], /Verdict:\*\* invalid/);
140
+ assert.match(vs[0], /src\/foo.js:3 already guards null/);
141
+ assert.match(vs[0], /Summary:\*\* not a bug/);
142
+ assert.equal(registers(w).length, 0, "no duplicate evidence comment");
143
+ assert.equal(w.closed.length, 1);
144
+ assert.equal(w.prs.length, 0);
145
+ assert.equal(reviewers(w).length, 0);
121
146
  });
122
147
 
123
- it("builder default model, reviewer claude-sonnet-5-5 unless configured", async () => {
124
- const w = world(); await run(w);
125
- assert.equal(resumes(w)[0][3], null);
126
- assert.equal(w.calls.find((c) => c[1] === "reviewer")[3], "claude-sonnet-5-5");
127
- const w2 = world(); await run(w2, { reviewerModel: "my-model" });
128
- assert.equal(w2.calls.find((c) => c[1] === "reviewer")[3], "my-model");
148
+ it("already-fixed with evidence also closes", async () => {
149
+ const w = world({ changed: [], result: { status: "already-fixed", summary: "done upstream", evidence: ["a.js:1"], tests: [] } });
150
+ assert.equal((await run(w)).terminalReason, "invalid");
129
151
  });
130
- });
131
152
 
132
- describe("lean path: review fixes (#4083)", () => {
133
- it("merge requires the CI-gated head to equal the reviewed head and always passes the local head", async () => {
134
- const w = world({ waits: [{ rc: 0, text: "CI_GATE_HEAD: deadbeefdead" }] });
153
+ it("invalid without evidence goes to needs-human and does not close", async () => {
154
+ const w = world({ changed: [], result: { status: "invalid", summary: "trust me", evidence: [], tests: [] } });
135
155
  const r = await run(w);
136
156
  assert.equal(r.terminalReason, "needs-human");
137
- assert.equal(w.merges.length, 0);
138
- const w2 = world({ waits: [{ rc: 0, text: "CI_GATE: PASS" }] });
139
- assert.equal((await run(w2)).terminalReason, "merged");
140
- assert.equal(w2.merges[0].at(-1), w2.head);
141
- });
142
-
143
- it("a gate needs-human records exactly one RUN_TERMINAL and one telemetry terminal", async () => {
144
- const f = { result: "FAIL", tail: "x" };
145
- const w = world({ ci: [f, f, f] }); const dir = tmpDir("lean-");
146
- const r = await run(w, { dir });
147
- assert.equal(r.terminalReason, "needs-human");
148
- assert.equal(readLog(dir, 7).filter((e) => e.event === "RUN_TERMINAL").length, 1);
149
- assert.equal(w.telemetry.filter((t) => t.event === "RUN_TERMINAL").length, 1);
150
- });
151
-
152
- it("a held lease defers the run before any work; an owned lease is written and released", async () => {
153
- const held = world({ lease: { by: "other_1", until: 10 ** 13 } });
154
- const r = await run(held, { agentId: "me" });
155
- assert.equal(r.terminalReason, "deferred");
156
- assert.equal(held.calls.length, 0);
157
- const w = world();
158
- await run(w, { agentId: "me", leaseRenewMs: 10 ** 9 });
159
- assert.equal(w.leaseWrites[0].lease.by, "me");
160
- assert.equal(w.leaseWrites.at(-1).lease, null);
161
- assert.equal(w.leaseWrites.at(-1).terminal, true);
162
- });
163
-
164
- it("reruns reset the lean worktree and branch instead of failing, and errors clear the in-progress label", async () => {
165
- const w = world();
166
- await run(w);
167
- const add = w.calls.find((c) => c[1] === "worktree" && c[2] === "add");
168
- assert.ok(add.includes("-B"));
169
- assert.ok(w.calls.some((c) => c[1] === "worktree" && c[2] === "remove"));
170
- const e = world({ builderThrows: true });
171
- const r = await run(e);
172
- assert.equal(r.terminalReason, "engine-error");
173
- assert.ok(e.edits.some((a) => a.includes("--remove-label") && a.join(" ").includes("workflow:investigating")));
174
- });
175
-
176
- it("closes only on invalid/already-fixed WITH evidence; otherwise needs-human with the reasoning", async () => {
177
- for (const validate of [{ status: "invalid", summary: "no repro", evidence: [] }, { status: "invalid", summary: "no repro", evidence: [" "] },
178
- { status: "maybe", summary: "unsure why", evidence: ["a.js:1"] }]) {
179
- const w = world({ validate });
180
- const r = await run(w);
181
- assert.equal(r.terminalReason, "needs-human");
182
- assert.equal(w.closed.length, 0);
183
- assert.ok(w.comments.some((c) => c.includes("FORGE:NEEDS_HUMAN") && c.includes(validate.summary)));
184
- }
185
- const ok = world({ validate: { status: "already-fixed", summary: "fixed in abc", evidence: ["commit abc1234"] } });
186
- assert.equal((await run(ok)).terminalReason, "invalid");
187
- assert.equal(ok.closed.length, 1);
188
- });
189
-
190
- it("reviewers are read-only (Read/Grep/Glob) and the builder is not", async () => {
191
- const w = world({ changed: ["src/auth/x.js", "test/x.test.js"] });
192
- await run(w);
193
- const rev = w.calls.filter((c) => c[0] === "runner" && c[1] !== "builder");
194
- assert.ok(rev.length >= 2 && rev.every((c) => c[5] === true));
195
- assert.ok(w.calls.filter((c) => c[1] === "builder").every((c) => c[5] === false));
196
- assert.deepEqual(buildReadOnlyToolFlags({ strictMcp: true }), ["--tools", "Read,Grep,Glob"]);
197
- assert.ok(buildReadOnlyToolFlags({ strictMcp: false }).includes("Bash"));
157
+ assert.equal(w.closed.length, 0);
158
+ assert.ok(w.edits.some((e) => e.includes("needs-human")));
159
+ assert.ok(w.comments.some((c) => c.includes("FORGE:NEEDS_HUMAN") && c.includes("without evidence")));
198
160
  });
199
161
 
200
- it("review rounds are numbered from 1", async () => {
201
- const w = world({ reviews: [reply([finding({ severity: "HIGH" })]), reply([])] });
202
- await run(w);
203
- const revs = w.comments.filter((c) => c.includes("FORGE:REVIEW"));
204
- assert.match(revs[0], /round 1 \|/); assert.match(revs[1], /round 2 \|/);
162
+ it("review FAIL -> builder resumed via --resume with the reason -> re-review PASS -> merge", async () => {
163
+ const w = world({ reviews: [bad("src/foo.js:9 still wrong"), ok()] });
164
+ const r = await run(w);
165
+ assert.equal(r.terminalReason, "merged");
166
+ const b = builders(w);
167
+ assert.equal(b.length, 2);
168
+ assert.equal(b[1][2], "sess-1");
169
+ assert.match(b[1][4], /src\/foo.js:9 still wrong/);
170
+ assert.equal(reviewers(w).length, 2);
171
+ assert.equal(w.prs.length, 1);
172
+ assert.equal(registers(w).length, 1);
173
+ assert.deepEqual(w.merges[0].slice(-2), ["--match-head-commit", w.head]);
205
174
  });
206
- });
207
-
208
- describe("lean path: release review fixes (#4084)", () => {
209
- const approvedAt = (w, head) => w.comments.push(`<!-- FORGE:REVIEW lean v1 -->\n## FORGE:REVIEW\n**Verdict:** APPROVED | round 1 | reviewed ${head.slice(0, 12)} | risk low\n\nx`);
210
175
 
211
- it("rerun with an open PR at an already-reviewed head resumes straight to the CI wait (no builder, no reviewer, no create)", async () => {
212
- const w = world({ prList: [{ number: 77, headRefOid: "aaaa1111aaaa1111" }], remote: "sha\trefs/heads/feat/issue-7-lean" });
213
- w.comments.push("<!-- FORGE:PLAN lean v1 -->\n## FORGE:PLAN\nthe plan");
214
- approvedAt(w, w.head);
176
+ it("review FAIL twice -> needs-human, no PR", async () => {
177
+ const w = world({ reviews: [bad(), bad("still bad")] });
215
178
  const r = await run(w);
216
- assert.equal(r.terminalReason, "merged");
217
- assert.equal(w.calls.filter((c) => c[0] === "runner").length, 0);
179
+ assert.equal(r.terminalReason, "needs-human");
180
+ assert.equal(builders(w).length, 2);
218
181
  assert.equal(w.prs.length, 0);
219
- assert.equal(w.merges[0][2], "77");
220
- const add = w.calls.find((c) => c[1] === "worktree" && c[2] === "add");
221
- assert.ok(add.includes("origin/feat/issue-7-lean"), "checks out the remote branch, not the base");
182
+ assert.equal(w.merges.length, 0);
183
+ assert.ok(w.edits.some((e) => e.includes("needs-human")));
222
184
  });
223
185
 
224
- it("rerun whose head was not reviewed continues from review, reusing the plan from the issue", async () => {
225
- const w = world({ prList: [{ number: 77 }] });
226
- w.comments.push("<!-- FORGE:PLAN lean v1 -->\n## FORGE:PLAN\nthe plan text");
227
- approvedAt(w, "ffff0000ffff");
228
- const r = await run(w);
229
- assert.equal(r.terminalReason, "merged");
230
- const rev = w.calls.find((c) => c[1] === "reviewer");
231
- assert.ok(rev[4].includes("the plan text"));
232
- assert.ok(!w.calls.some((c) => c[1] === "builder"));
186
+ it("a FAIL verdict with fixes_issue true and no regression is not a failure", async () => {
187
+ const w = world({ reviews: [ok({ verdict: "FAIL" })] });
188
+ assert.equal((await run(w)).terminalReason, "merged");
189
+ assert.equal(builders(w).length, 1);
233
190
  });
234
191
 
235
- it("ci-pending leaves a resumable state: the worktree is removed, the branch is kept", async () => {
236
- const w = world({ waits: Array.from({ length: 500 }, () => ({ rc: 3, text: "" })) });
237
- const r = await run(w, { ciMaxMinutes: 5 });
238
- assert.equal(r.terminalReason, "ci-pending");
239
- assert.ok(w.calls.filter((c) => c[1] === "worktree" && c[2] === "remove").length >= 2);
240
- assert.ok(!w.calls.some((c) => c[1] === "branch"));
192
+ it("builder blocked, or no commit, is needs-human", async () => {
193
+ assert.equal((await run(world({ result: { status: "blocked", summary: "need creds" } }))).terminalReason, "needs-human");
194
+ assert.equal((await run(world({ changed: [] }))).terminalReason, "needs-human");
241
195
  });
242
196
 
243
- it("an open Depends-on dependency refuses the run before any worktree", async () => {
244
- const own = { author_association: "OWNER", body: "x\n**Depends on**: #5", state: "open" };
245
- const w = world({ apiIssues: { 7: own, 5: { state: "open" } } });
246
- const r = await run(w);
247
- assert.equal(r.terminalReason, "dependency-open");
248
- assert.deepEqual(r.dependencies, [5]);
249
- assert.equal(w.calls.length, 0);
250
- const u = world({ apiIssues: { 7: own } });
251
- assert.equal((await run(u)).terminalReason, "dependency-unreadable");
252
- const closed = world({ apiIssues: { 7: own, 5: { state: "closed" } } });
253
- assert.equal((await run(closed)).terminalReason, "merged");
197
+ it("red remote CI is needs-human; slow CI polls with backoff and ends ci-pending, never needs-human", async () => {
198
+ const w = world({ waits: [{ rc: 1, text: "lint failed" }] });
199
+ assert.equal((await run(w, { mergeWait: "all" })).terminalReason, "needs-human");
200
+ const w2 = world({ waits: Array(500).fill({ rc: 2, text: "" }) });
201
+ const r2 = await run(w2, { ciMaxMinutes: 10, mergeWait: "all" });
202
+ assert.equal(r2.terminalReason, "ci-pending");
203
+ assert.equal(w2.edits.length, 0);
204
+ const w3 = world({ waits: [{ rc: 2, text: "" }, { rc: 2, text: "" }] });
205
+ assert.equal((await run(w3, { mergeWait: "all" })).terminalReason, "merged");
254
206
  });
255
207
 
256
- it("a broken local CI gate (no result) is needs-human with no builder resume; DEGRADED still passes", async () => {
257
- const w = world({ ci: [{ result: "BROKEN", tail: "no result" }] });
258
- const r = await run(w);
259
- assert.equal(r.terminalReason, "needs-human");
260
- assert.equal(resumes(w).length, 1);
261
- const d = world({ ci: [{ result: "DEGRADED", tail: "" }] });
262
- assert.equal((await run(d)).terminalReason, "merged");
208
+ it("refuses merge when the CI-gated head differs from the reviewed head", async () => {
209
+ const w = world({ waits: [{ rc: 0, text: "CI_GATE_HEAD: deadbeefdead" }] });
210
+ assert.equal((await run(w, { mergeWait: "all" })).terminalReason, "needs-human");
211
+ assert.equal(w.merges.length, 0);
263
212
  });
264
213
 
265
- it("parseCiLocalResult: missing or garbage output is BROKEN", async () => {
266
- const { parseCiLocalResult } = await import("./effects.mjs");
267
- assert.equal(parseCiLocalResult(""), "BROKEN");
268
- assert.equal(parseCiLocalResult("CI_LOCAL_RESULT: ???"), "BROKEN");
269
- assert.equal(parseCiLocalResult("x\nCI_LOCAL_RESULT: DEGRADED reason=t"), "DEGRADED");
270
- assert.equal(parseCiLocalResult("CI_LOCAL_RESULT: PASS"), "PASS");
271
- assert.equal(parseCiLocalResult("CI_LOCAL_RESULT: FAIL"), "FAIL");
214
+ it("an open Depends-on dependency refuses the run before any label or worktree", async () => {
215
+ const w = world();
216
+ const gh = w.io.gh;
217
+ w.io.gh = async (a) => {
218
+ if (a[0] === "api" && /issues\/7$/.test(a[1])) return JSON.stringify({ body: "**Depends on**: #3", user: { login: "x" }, author_association: "OWNER" });
219
+ if (a[0] === "api" && /issues\/3$/.test(a[1])) return JSON.stringify({ state: "open" });
220
+ return gh(a);
221
+ };
222
+ const r = await run(w);
223
+ assert.ok(["dependency-open", "dependency-unreadable"].includes(r.terminalReason));
224
+ assert.equal(w.labels.length, 0);
225
+ assert.equal(w.calls.filter((c) => c[1] === "worktree").length, 0);
272
226
  });
273
227
 
274
- it("RUN_START carries repo and version so local stats attribute lean runs", async () => {
275
- const w = world(); const dir = tmpDir("lean-");
276
- await run(w, { dir });
277
- const rs = readLog(dir, 7).find((e) => e.event === "RUN_START");
278
- assert.equal(rs.repo, "o/r"); assert.ok(rs.version && rs.forge_version);
228
+ it("an open PR / remote branch resumes: worktree from the branch, builder skipped, review then merge", async () => {
229
+ const w = world({ prList: [{ number: 77, headRefOid: "x" }], remote: "abc\trefs/heads/feat/issue-7-lean" });
230
+ const r = await run(w);
231
+ assert.equal(r.terminalReason, "merged");
232
+ assert.equal(builders(w).length, 0);
233
+ assert.equal(w.prs.length, 0);
234
+ assert.ok(w.calls.some((c) => c[0] === "git" && c[1] === "worktree" && c[2] === "add" && c.includes("origin/feat/issue-7-lean")));
279
235
  });
280
236
 
281
- it("the lease is renewed before and after every builder and reviewer call", async () => {
282
- const w = world();
283
- await run(w, { agentId: "me", leaseRenewMs: 10 ** 9 });
284
- const live = w.leaseWrites.filter((x) => x.lease).length;
285
- const calls = w.calls.filter((c) => c[0] === "runner").length;
286
- assert.ok(live >= 1 + 2 * calls, `${live} renewals for ${calls} calls`);
237
+ it("builder uses the CLI default model; reviewer is read-only Sonnet unless configured", async () => {
238
+ const w = world(); await run(w);
239
+ assert.equal(builders(w)[0][3], null);
240
+ assert.equal(builders(w)[0][5], false);
241
+ assert.equal(reviewers(w)[0][3], "claude-sonnet-5-5");
242
+ assert.equal(reviewers(w)[0][5], true);
243
+ const w2 = world(); await run(w2, { reviewerModel: "my-model" });
244
+ assert.equal(reviewers(w2)[0][3], "my-model");
287
245
  });
246
+ });
288
247
 
289
- it("lease TTL stays inside the projector's 30 minute honour cap", async () => {
290
- const w = world(); let until = 0;
291
- w.io.projector.writeState = async (_i, st) => { if (st.lease) until = st.lease.until - w.clock; };
292
- await run(w, { agentId: "me", leaseRenewMs: 10 ** 9 });
293
- assert.ok(until > 0 && until <= 30 * 60000);
248
+ describe("comment templates", () => {
249
+ const full = { status: "fixed", summary: "fixed foo", evidence: ["src/foo.js:3 null deref"], root_cause: "foo derefs null at src/foo.js:3",
250
+ approach: "guard null", sibling_sites_checked: ["src/bar.js:8: same guard added"], changes: [{ file: "src/foo.js", change: "add guard", why: "avoid crash" }],
251
+ tests: [{ command: "node --test test/foo.test.js", result: "pass", fails_without_fix: true }], risks: ["none"], follow_ups: ["audit baz"] };
252
+ const rv = { verdict: "PASS", fixes_issue: true, regression: false, reason: "src/foo.js:3 ok", checked: "callers of foo" };
253
+
254
+ it("renders full fields", () => {
255
+ const v = renderValidate(full, "valid"); const p = renderPlan(full, ["src/foo.js"]);
256
+ assert.match(v, /Root cause:\*\* foo derefs null at src\/foo.js:3/);
257
+ assert.match(p, /Approach:\*\* guard null/); assert.match(p, /src\/bar.js:8: same guard added/); assert.match(p, /node --test test\/foo.test.js/);
258
+ assert.match(renderReview(rv), /Checked:\*\* callers of foo/);
259
+ const reg = renderRegister({ result: full, gitFiles: [], review: rv, links: { validate: "V", plan: "P", review: "R" }, prUrl: "PRURL", usage: "cost $1", policy: "required" });
260
+ for (const n of ["| src/foo.js | add guard | avoid crash |", "fails without fix: yes", "Risks:** none", "Follow-ups:** audit baz", "Validation: V", "Plan: P", "Review: R", "PR: PRURL", "Merge policy:** required", "cost $1"]) assert.ok(reg.includes(n), n);
261
+ const pr = renderPrBody({ issue: 7, result: full });
262
+ assert.match(pr, /^Closes #7/); assert.match(pr, /\| file \| change \| why \|/); assert.match(pr, /node --test/);
263
+ });
264
+
265
+ it("missing fields render as 'not reported', never broken markdown", () => {
266
+ const reg = renderRegister({ result: {}, gitFiles: ["a.js"], review: { ...rv, checked: undefined }, links: {}, prUrl: "U", usage: "u", policy: "none" });
267
+ assert.ok(reg.includes(NR));
268
+ assert.match(reg, /\| a.js \| not reported \| not reported \|/);
269
+ assert.match(reg, /Verification\*\*\nnot reported/);
270
+ assert.doesNotMatch(reg, /undefined|null|\[object/);
271
+ assert.doesNotMatch(renderValidate(null, "blocked"), /undefined|null/);
272
+ assert.doesNotMatch(renderPlan(undefined), /undefined/);
273
+ assert.doesNotMatch(renderReview({ verdict: "PASS", fixes_issue: true, regression: false }), /undefined/);
274
+ });
275
+
276
+ it("escapes pipes and newlines in table cells", () => {
277
+ assert.equal(cell("a|b\nc"), "a\\|b c");
278
+ const t = renderPrBody({ issue: 1, result: { changes: [{ file: "a|b.js", change: "x\ny", why: "p|q" }] } });
279
+ const row = t.split("\n").find((l) => l.startsWith("| a"));
280
+ assert.equal(row, "| a\\|b.js | x y | p\\|q |");
281
+ });
282
+
283
+ it("posts VALIDATE, PLAN, REVIEW, WORK-ON in order; VALIDATE and PLAN before the reviewer", async () => {
284
+ const w = world({ result: full });
285
+ await run(w);
286
+ const m = w.comments.map((c) => /FORGE:[A-Z_-]+/.exec(c)[0]);
287
+ assert.deepEqual(m, ["FORGE:STARTED", "FORGE:VALIDATE", "FORGE:PLAN", "FORGE:REVIEW", "FORGE:WORK-ON"]);
288
+ const idx = []; w.seq.forEach((s, i) => { if (s === "comment") idx.push(i); });
289
+ assert.ok(idx[1] < w.seq.indexOf("reviewer") && idx[2] < w.seq.indexOf("reviewer") && idx[3] > w.seq.indexOf("reviewer"));
290
+ const pr = w.prs[0][w.prs[0].indexOf("--body") + 1];
291
+ assert.match(pr, /Root cause/); assert.match(pr, /\| src\/foo.js \| add guard \| avoid crash \|/);
294
292
  });
295
293
 
296
- it("--path without a value errors; --retry and --wait-lease are rejected on lean", async () => {
297
- const mk = () => ({ leanModule: { runLean: async () => ({ terminalReason: "merged" }) }, leanIo: {}, dir: tmpDir("lean-cli-"),
298
- emitTelemetry: async () => {}, io: { gh: async () => "o/r\n" }, runIssue: async () => ({ terminalReason: "merged" }) });
299
- await assert.rejects(runFromCli(["7", "--lane", "staging", "--path"], mk()), /--path requires a value/);
300
- await assert.rejects(runFromCli(["7", "--lane", "staging", "--path", "--retry"], mk()), /--path requires a value/);
301
- await assert.rejects(runFromCli(["7", "--lane", "staging", "--path", "lean", "--retry", "--repo", "o/r"], mk()), /not supported with the lean path/);
302
- await assert.rejects(runFromCli(["7", "--lane", "staging", "--path", "auto", "--wait-lease", "--repo", "o/r"], mk()), /not supported with the lean path/);
294
+ it("builder prompt with the structured fields stays within 1.2k tokens", () => {
295
+ assert.ok(approxTokens(builderPrompt(7)) <= 1200);
296
+ assert.match(builderPrompt(7), /root_cause/); assert.match(builderPrompt(7), /sibling_sites_checked/);
303
297
  });
304
298
  });
305
299
 
306
- describe("lean path: invalid issue", () => {
307
- it("closes an invalid issue with evidence and never builds a PR", async () => {
308
- const w = world({ validate: { status: "invalid", summary: "not reproducible", evidence: ["src/foo.js:3 already guards null"] } });
300
+ describe("merge_wait policy", () => {
301
+ const states = (o) => Object.entries(o).map(([name, state]) => ({ name, state }));
302
+ it("required with no protection (empty) merges immediately without polling", async () => {
303
+ const w = world(); let polled = 0;
304
+ w.io.requiredChecks = async () => []; w.io.checkStates = async () => { polled++; return []; };
305
+ w.io.waitCiGreen = async () => { polled++; return { rc: 2, text: "" }; };
309
306
  const r = await run(w);
310
- assert.equal(r.terminalReason, "invalid");
311
- assert.match(w.comments[0], /already guards null/);
312
- assert.equal(w.closed.length, 1); assert.ok(w.closed[0].includes("not planned"));
313
- assert.equal(w.prs.length, 0);
314
- assert.ok(w.labels.includes("invalid"));
307
+ assert.equal(r.terminalReason, "merged"); assert.equal(polled, 0);
308
+ assert.match(registers(w)[0], /Merge policy:\*\* required \(no required checks/);
315
309
  });
316
- });
317
-
318
- describe("lean path: gates resume the same session", () => {
319
- it("ci-local FAIL -> resume with the failure tail -> pass", async () => {
320
- const w = world({ ci: [{ result: "FAIL", tail: "FAILED test_foo: expected 1" }] });
310
+ it("required with a pending required check waits (backoff) then merges", async () => {
311
+ const w = world(); const seqs = [states({ build: "pending" }), states({ build: "pending" }), states({ build: "success", other: "failure" })]; let sleeps = 0;
312
+ w.io.requiredChecks = async () => ["build"]; w.io.checkStates = async () => seqs.shift();
313
+ const s = w.io.sleep; w.io.sleep = async (ms) => { sleeps++; return s(ms); };
321
314
  const r = await run(w);
322
- assert.equal(r.terminalReason, "merged");
323
- const b = resumes(w);
324
- assert.equal(b.length, 2);
325
- assert.equal(b[0][2], null); assert.equal(b[1][2], "sess-1");
326
- assert.match(b[1][4], /FAILED test_foo/);
327
- assert.equal(b.filter((c) => c[2] === null).length, 1, "never a cold reload");
315
+ assert.equal(r.terminalReason, "merged"); assert.equal(sleeps, 2);
316
+ assert.match(registers(w)[0], /required \(build\)/);
317
+ assert.deepEqual(w.merges[0].slice(-2), ["--match-head-commit", w.head]);
318
+ });
319
+ it("required with a failing required check is needs-human; slow required check is ci-pending", async () => {
320
+ const w = world(); w.io.requiredChecks = async () => ["build"]; w.io.checkStates = async () => states({ build: "failure" });
321
+ assert.equal((await run(w)).terminalReason, "needs-human"); assert.equal(w.merges.length, 0);
322
+ const w2 = world(); w2.io.requiredChecks = async () => ["build"]; w2.io.checkStates = async () => states({ build: "pending" });
323
+ assert.equal((await run(w2, { ciMaxMinutes: 5 })).terminalReason, "ci-pending");
324
+ });
325
+ it("all waits for every check (waitCiGreen); none merges immediately", async () => {
326
+ const w = world({ waits: [{ rc: 2, text: "" }] }); let req = 0; w.io.requiredChecks = async () => { req++; return []; };
327
+ assert.equal((await run(w, { mergeWait: "all" })).terminalReason, "merged");
328
+ assert.equal(w.waits.length, 0); assert.equal(req, 0);
329
+ assert.match(registers(w)[0], /Merge policy:\*\* all/);
330
+ const w2 = world({ waits: [{ rc: 1, text: "red" }] });
331
+ assert.equal((await run(w2, { mergeWait: "none" })).terminalReason, "merged");
332
+ assert.equal(w2.waits.length, 1); assert.deepEqual(w2.merges[0].slice(-2), ["--match-head-commit", w2.head]);
328
333
  });
334
+ });
329
335
 
330
- it("missing test file -> resume -> pass once the hook adds one", async () => {
331
- const w = world({ changed: ["src/foo.js"], builderHooks: [(x) => x.changed.push("test/foo.test.js")] });
332
- const r = await run(w);
333
- assert.equal(r.terminalReason, "merged");
334
- assert.match(resumes(w)[1][4], /No test file changed/);
336
+ describe("prompts", () => {
337
+ it("builder prompt is at most 1k tokens and short and plain", () => {
338
+ const p = builderPrompt(7);
339
+ assert.ok(approxTokens(p) <= 1000);
340
+ assert.match(p, /Resolve issue #7 \(read it with gh issue view 7 --comments\)/);
341
+ assert.match(p, /Do not push or touch GitHub/);
342
+ assert.match(p, /result\.json/);
343
+ });
344
+
345
+ it("reviewer system prompt is at most 1.5k tokens and holds only the two questions", () => {
346
+ assert.ok(approxTokens(REVIEWER_SYSTEM) <= 1500);
347
+ assert.match(REVIEWER_SYSTEM, /Does this change fix the issue as described\?/);
348
+ assert.match(REVIEWER_SYSTEM, /introduce a regression/);
349
+ for (const banned of [/edge/i, /style/i, /harden/i, /nice.to.have/i, /severity/i, /nit/i, /optional/i, /refactor/i, /naming/i, /performance/i, /security/i]) {
350
+ assert.doesNotMatch(REVIEWER_SYSTEM, banned);
351
+ }
352
+ assert.equal((REVIEWER_SYSTEM.match(/^\d\. /gm) ?? []).length, 2);
335
353
  });
336
354
 
337
- it("docs-only change needs no test", async () => {
338
- const w = world({ changed: ["docs/guide.md", "README.md"] });
339
- assert.equal((await run(w)).terminalReason, "merged");
340
- assert.equal(resumes(w).length, 1);
355
+ it("the full reviewer prompt for a typical diff stays under 1.5k tokens of instructions plus content", () => {
356
+ const p = reviewerPrompt({ issue: { number: 7, title: "t", body: "b" }, diff: "x".repeat(2000) });
357
+ assert.match(p, /<diff>/);
358
+ assert.ok(approxTokens(REVIEWER_SYSTEM) + approxTokens(reviewerPrompt({ issue: { number: 7, title: "t", body: "b" }, diff: "" })) <= 1500);
341
359
  });
342
360
 
343
- it(`${MAX_ROUNDS} failed gate rounds -> needs-human`, async () => {
344
- const w = world({ ci: [{ result: "FAIL", tail: "x" }, { result: "FAIL", tail: "x" }, { result: "FAIL", tail: "x" }] });
345
- const r = await run(w);
346
- assert.equal(r.terminalReason, "needs-human");
347
- assert.equal(resumes(w).length, 1 + MAX_ROUNDS);
348
- assert.ok(w.edits.some((e) => e.includes("needs-human")));
349
- assert.equal(w.prs.length, 0);
361
+ it("parseVerdict: flags decide, garbage is null", () => {
362
+ assert.equal(parseVerdict(ok()).fail, false);
363
+ assert.equal(parseVerdict(ok({ regression: true })).fail, true);
364
+ assert.equal(parseVerdict(bad()).fail, true);
365
+ assert.equal(parseVerdict("no json"), null);
366
+ assert.equal(parseVerdict('{"verdict":"FAIL","reason":"x"}').fail, true);
350
367
  });
351
368
  });
352
369
 
353
- describe("lean path: review", () => {
354
- it("blocking finding -> builder resumed via --resume -> delta re-review -> merge", async () => {
355
- const w = world({ reviews: [reply([finding({ severity: "HIGH", title: "null deref" })]), reply([])] });
356
- const r = await run(w);
357
- assert.equal(r.terminalReason, "merged");
358
- const b = resumes(w);
359
- assert.equal(b[1][2], "sess-1"); assert.match(b[1][4], /null deref/);
360
- const reviews = w.calls.filter((c) => c[1] === "reviewer");
361
- assert.equal(reviews.length, 2);
362
- assert.match(reviews[1][4], /DELTA re-review/);
363
- assert.equal(reviews[1][2], null, "reviewer is always a fresh session");
364
- assert.deepEqual(markers(w).filter((m) => m === "FORGE:REVIEW").length, 2);
365
- });
366
-
367
- it("2 failed fix rounds -> needs-human", async () => {
368
- const bad = reply([finding({ severity: "CRITICAL" })]);
369
- const w = world({ reviews: [bad, bad, bad] });
370
- const r = await run(w);
371
- assert.equal(r.terminalReason, "needs-human");
372
- assert.equal(resumes(w).length, 1 + MAX_ROUNDS);
373
- assert.equal(w.prs.length, 0);
370
+ describe("run-issue routing", () => {
371
+ const mk = () => { const seen = {}; return { seen, dir: tmpDir("core-cli-"), emitTelemetry: async () => {}, leanIo: {}, io: { gh: async () => "o/r\n" },
372
+ leanModule: { runCore: async (o) => { seen.core = o.path; return { terminalReason: "merged" }; } }, runIssue: async () => { seen.full = true; return { terminalReason: "merged" }; } }; };
373
+ const quiet = async (fn) => { const l = console.log; console.log = () => {}; try { return await fn(); } finally { console.log = l; } };
374
+
375
+ it("default (no --path) runs the core work-on", async () => {
376
+ const d = mk();
377
+ await quiet(() => runFromCli(["7", "--lane", "staging", "--repo", "o/r"], d));
378
+ assert.equal(d.seen.core, "core"); assert.ok(!d.seen.full);
374
379
  });
375
380
 
376
- it("non-blocking findings are listed in the review comment and never filed as issues", async () => {
377
- const w = world({ reviews: [reply([finding({ severity: "LOW", title: "naming nit" }), finding({ severity: "MEDIUM", confidence: "LIKELY", title: "maybe" }),
378
- finding({ severity: "MEDIUM", confidence: "CONFIRMED", scope: "pre-existing", title: "old bug" })])] });
379
- const r = await run(w);
380
- assert.equal(r.terminalReason, "merged");
381
- const rev = w.comments.find((c) => c.includes("FORGE:REVIEW"));
382
- for (const t of ["naming nit", "maybe", "old bug"]) assert.ok(rev.includes(t));
383
- assert.equal(resumes(w).length, 1);
381
+ it("--path lean and --path auto are aliases of the core", async () => {
382
+ for (const p of ["lean", "auto", "core"]) {
383
+ const d = mk();
384
+ await quiet(() => runFromCli(["7", "--lane", "staging", "--repo", "o/r", "--path", p], d));
385
+ assert.ok(d.seen.core && !d.seen.full, p);
386
+ }
384
387
  });
385
388
 
386
- it("unparseable reviewer output is retried once, then needs-human", async () => {
387
- const w = world({ reviews: ["no json here", "still none"] });
388
- assert.equal((await run(w)).terminalReason, "needs-human");
389
+ it("--path full still routes to the old engine", async () => {
390
+ const d = mk();
391
+ await quiet(() => runFromCli(["7", "--lane", "staging", "--repo", "o/r", "--path", "full"], d));
392
+ assert.ok(d.seen.full); assert.equal(d.seen.core, undefined);
389
393
  });
390
394
 
391
- it("blocking classification", () => {
392
- assert.ok(isBlocking({ severity: "HIGH", confidence: "POSSIBLE" }));
393
- assert.ok(isBlocking({ severity: "CRITICAL" }));
394
- assert.ok(isBlocking({ severity: "MEDIUM", confidence: "CONFIRMED", scope: "introduced" }));
395
- assert.ok(!isBlocking({ severity: "MEDIUM", confidence: "CONFIRMED", scope: "pre-existing" }));
396
- assert.ok(!isBlocking({ severity: "MEDIUM", confidence: "LIKELY", scope: "introduced" }));
397
- assert.ok(!isBlocking({ severity: "LOW", confidence: "CONFIRMED" }));
398
- assert.deepEqual(parseFindings("x ```json\n[{\"a\":1}]\n``` y"), [{ a: 1 }]);
399
- assert.equal(parseFindings("nothing"), null);
395
+ it("bad --path errors; --retry is rejected on the core path", async () => {
396
+ await assert.rejects(runFromCli(["7", "--lane", "staging", "--path", "bogus"], mk()), /--path must be/);
397
+ await assert.rejects(runFromCli(["7", "--lane", "staging", "--repo", "o/r", "--retry"], mk()), /not supported with the core path/);
398
+ assert.deepEqual(ENGINE_PATHS, ["core", "full", "lean", "auto"]);
400
399
  });
401
400
  });
402
401
 
403
- describe("lean path: risk tier and routing", () => {
404
- it("risky path adds a security reviewer (and a calm change does not)", async () => {
405
- const w = world({ changed: ["src/auth/login.js", "test/auth.test.js"] });
406
- await run(w);
407
- const roles = w.calls.filter((c) => c[0] === "runner").map((c) => c[1]);
408
- assert.ok(roles.includes("security-reviewer"));
409
- const w2 = world(); await run(w2);
410
- assert.ok(!w2.calls.some((c) => c[1] === "security-reviewer"));
402
+ describe("orchestrate", () => {
403
+ const tick = () => new Promise((r) => setImmediate(r));
404
+ const harness = (bodies, outcomes = {}) => {
405
+ const log = { starts: [], ends: [], active: 0, maxActive: 0 };
406
+ return { log,
407
+ readBody: async (n) => bodies[n] ?? "",
408
+ runOne: async (n) => {
409
+ log.starts.push(n); log.active++; log.maxActive = Math.max(log.maxActive, log.active);
410
+ await tick(); await tick();
411
+ log.active--; log.ends.push(n);
412
+ const reason = outcomes[n] ?? "merged";
413
+ return { terminalReason: reason, pr: reason === "merged" ? 100 + n : null, cost_usd: 0.5, detail: reason };
414
+ } };
415
+ };
416
+
417
+ it("parseDeps reads Depends-on lines and ignores self references", () => {
418
+ assert.deepEqual(parseDeps("x\n**Depends on**: #4\n**Depends on**: #7\n", 7), [4]);
411
419
  });
412
420
 
413
- it("riskTier covers auth, crypto, secret, permission, migration, payment, workflows", () => {
414
- for (const f of ["lib/auth/x.py", "pkg/crypto/aes.go", "config/secrets.yml", "app/permissions.rb", "db/migrations/001.sql", "src/payment/charge.ts", ".github/workflows/ci.yml"]) {
415
- assert.equal(riskTier([f]).tier, "high", f);
416
- }
417
- assert.equal(riskTier(["src/util/format.js"]).tier, "low");
418
- assert.equal(riskTier(["src/a.js"], "rotate the secret key").tier, "high");
419
- });
420
-
421
- it("auto: full only when risk high AND diff large", async () => {
422
- assert.equal(choosePath("auto", { tier: "high" }, { files: 20, lines: 10 }), "full");
423
- assert.equal(choosePath("auto", { tier: "high" }, { files: 2, lines: 50 }), "lean");
424
- assert.equal(choosePath("auto", { tier: "low" }, { files: 40, lines: 5000 }), "lean");
425
- assert.equal(choosePath("lean", { tier: "high" }, { files: 40, lines: 5000 }), "lean");
426
- assert.ok(isLargeDiff({ lines: 400 }));
427
- const big = world({ changed: ["src/auth/a.js", "test/a.test.js"], lines: 900 });
428
- const r = await run(big, { path: "auto" });
429
- assert.equal(r.terminalReason, "route-full");
430
- assert.equal(big.prs.length, 0);
431
- const small = world({ changed: ["src/auth/a.js", "test/a.test.js"], lines: 30 });
432
- assert.equal((await run(small, { path: "auto" })).terminalReason, "merged");
433
- const calm = world({ lines: 900 });
434
- assert.equal((await run(calm, { path: "auto" })).terminalReason, "merged");
421
+ it("runs at most K at once", async () => {
422
+ const h = harness({});
423
+ const rows = await orchestrate({ issues: [1, 2, 3, 4, 5, 6], parallel: 2, ...h });
424
+ assert.equal(h.log.maxActive, 2);
425
+ assert.equal(rows.length, 6);
426
+ assert.ok(rows.every((r) => r.outcome === "merged"));
435
427
  });
436
- });
437
428
 
438
- describe("lean path: remote CI", () => {
439
- it("slow CI polls with backoff and is never a needs-human", async () => {
440
- const w = world({ waits: Array.from({ length: 500 }, () => ({ rc: 3, text: "pending" })) });
441
- const r = await run(w, { ciMaxMinutes: 30 });
442
- assert.equal(r.terminalReason, "ci-pending");
443
- assert.equal(w.edits.length, 0);
444
- assert.equal(w.merges.length, 0);
445
- assert.ok(w.clock >= 30 * 60000);
429
+ it("default style K=3 runs three in parallel", async () => {
430
+ const h = harness({});
431
+ await orchestrate({ issues: [1, 2, 3, 4], parallel: 3, ...h });
432
+ assert.equal(h.log.maxActive, 3);
446
433
  });
447
434
 
448
- it("pending then green merges", async () => {
449
- const w = world({ waits: [{ rc: 3, text: "" }, { rc: 4, text: "" }] });
450
- assert.equal((await run(w)).terminalReason, "merged");
435
+ it("a dependent waits until its dependency has merged", async () => {
436
+ const h = harness({ 2: "**Depends on**: #1" });
437
+ await orchestrate({ issues: [2, 1, 3], parallel: 3, ...h });
438
+ assert.ok(h.log.ends.indexOf(1) < h.log.starts.indexOf(2), JSON.stringify(h.log));
451
439
  });
452
440
 
453
- it("red remote CI -> needs-human", async () => {
454
- const w = world({ waits: [{ rc: 1, text: "CI_GATE: FAIL" }] });
455
- assert.equal((await run(w)).terminalReason, "needs-human");
456
- assert.equal(w.merges.length, 0);
441
+ it("a dependent is skipped, without running, when its dependency ends needs-human (transitively)", async () => {
442
+ const h = harness({ 2: "**Depends on**: #1", 3: "**Depends on**: #2" }, { 1: "needs-human" });
443
+ const rows = await orchestrate({ issues: [1, 2, 3, 4], parallel: 3, ...h });
444
+ assert.deepEqual(rows.map((r) => r.outcome), ["needs-human", "skipped", "skipped", "merged"]);
445
+ assert.ok(!h.log.starts.includes(2) && !h.log.starts.includes(3));
446
+ assert.match(rows[1].detail, /#1/);
457
447
  });
458
- });
459
448
 
460
- describe("lean path: codebase-agnostic", () => {
461
- it("works on non-FD layouts (Go, Java, Ruby)", async () => {
462
- for (const changed of [["pkg/cache/cache.go", "pkg/cache/cache_test.go"], ["src/main/java/a/Foo.java", "src/test/java/a/FooTest.java"], ["app/models/foo.rb", "spec/models/foo_spec.rb"]]) {
463
- const w = world({ changed });
464
- assert.equal((await run(w)).terminalReason, "merged", changed.join());
465
- }
466
- assert.ok(isTestFile("pkg/x_test.go") && isTestFile("tests/test_x.py") && isTestFile("src/__tests__/a.ts") && !isTestFile("src/main.go"));
467
- assert.ok(isDocsOnly(["docs/a.rst", "README.md"]) && !isDocsOnly(["README.md", "src/a.go"]));
449
+ it("a dependency that closed as invalid does not block", async () => {
450
+ const h = harness({ 2: "**Depends on**: #1" }, { 1: "invalid" });
451
+ const rows = await orchestrate({ issues: [1, 2], parallel: 2, ...h });
452
+ assert.equal(rows[1].outcome, "merged");
468
453
  });
469
454
 
470
- it("prompts carry no ForgeDock-specific paths", () => {
471
- for (const p of [BUILDER_SYSTEM, REVIEWER_SYSTEM, SECURITY_REVIEWER_SYSTEM]) assert.doesNotMatch(p, /commands\/|scripts\/|bin\/|forge\.yaml|forgedock/i);
472
- const src = readFileSync(new URL("./lean.mjs", import.meta.url), "utf8");
473
- assert.doesNotMatch(src, /bin\/engine\.mjs|commands\//);
455
+ it("a dependency cycle is skipped rather than hanging; a thrown run is an engine-error row", async () => {
456
+ const h = harness({ 1: "**Depends on**: #2", 2: "**Depends on**: #1" });
457
+ const rows = await orchestrate({ issues: [1, 2], parallel: 2, ...h });
458
+ assert.ok(rows.every((r) => r.outcome === "skipped"));
459
+ const rows2 = await orchestrate({ issues: [9], runOne: async () => { throw new Error("boom"); }, readBody: async () => "" });
460
+ assert.equal(rows2[0].outcome, "engine-error");
474
461
  });
475
- });
476
462
 
477
- describe("lean path: prompts", () => {
478
- it("each phase prompt is 4k tokens or less and states the contract", () => {
479
- for (const p of [BUILDER_SYSTEM, REVIEWER_SYSTEM, SECURITY_REVIEWER_SYSTEM]) assert.ok(approxTokens(p) <= 4000, `${approxTokens(p)} tokens`);
480
- for (const f of ["validate.json", "plan.md", "implement.json"]) assert.ok(BUILDER_SYSTEM.includes(`.forge-lean/${f}`));
481
- assert.match(BUILDER_SYSTEM, /FAILS without the fix/);
482
- assert.match(BUILDER_SYSTEM, /[Ss]ibling/);
483
- assert.match(REVIEWER_SYSTEM, /JSON array/);
463
+ it("formatTable prints issue, outcome, PR, cost, minutes", () => {
464
+ const t = formatTable([{ issue: 1, outcome: "merged", pr: 101, cost_usd: 0.5, minutes: 3.2 }, { issue: 2, outcome: "skipped", pr: null, cost_usd: 0, minutes: 0, detail: "dependency #1 ended needs-human" }]);
465
+ assert.match(t, /issue\s+outcome\s+PR\s+cost\s+min/);
466
+ assert.match(t, /#1\s+merged\s+#101\s+\$0\.50\s+3\.2/);
467
+ assert.match(t, /skipped \(dependency #1/);
484
468
  });
485
- });
486
469
 
487
- describe("runner extension and CLI wiring", () => {
488
- it("runCliBackend appends extraArgs and returns the envelope session_id", () => {
489
- let argv;
490
- const r = runCliBackend({ spec: { name: "x" }, userMessage: "go", cwd: process.cwd(), bin: "claude", logger: { log() {} },
491
- extraArgs: ["--resume", "sess-9"], sessionFlags: ["--strict-mcp-config"],
492
- spawnFn: (_b, a) => { argv = a; return { status: 0, stderr: "", stdout: JSON.stringify({ result: "ok", session_id: "sess-9", usage: { input_tokens: 1 } }) }; } });
493
- assert.equal(argv[argv.indexOf("--resume") + 1], "sess-9");
494
- assert.equal(r.sessionId, "sess-9");
495
- const r2 = runCliBackend({ spec: { name: "x" }, userMessage: "go", cwd: process.cwd(), logger: { log() {} }, spawnFn: () => ({ status: 0, stdout: "{}", stderr: "" }) });
496
- assert.equal(r2.sessionId, null);
497
- });
498
-
499
- it("buildLeanSessionFlags reuses the capability probe and falls back cleanly", () => {
500
- const f = buildLeanSessionFlags({ strictMcp: true, disableSlash: true }, {});
501
- for (const x of ["--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--disable-slash-commands"]) assert.ok(f.includes(x), x);
502
- assert.ok(!f.includes("--setting-sources"), "user-scope safety hooks keep running");
503
- assert.deepEqual(buildLeanSessionFlags({ strictMcp: false, disableSlash: false }, {}), []);
504
- assert.deepEqual(buildLeanSessionFlags({ strictMcp: true, disableSlash: true }, { FORGEDOCK_FULL_SESSION: "1" }), []);
505
- });
506
-
507
- it("run-issue --path lean dispatches to the lean engine; default stays full; auto escalates", async () => {
508
- const dir = tmpDir("lean-cli-");
509
- const mk = (result) => { const seen = {}; return { seen, leanModule: { runLean: async (o) => { seen.path = o.path; return result; } }, leanIo: {}, dir, emitTelemetry: async () => {},
510
- io: { gh: async () => "o/r\n" }, runIssue: async () => { seen.full = true; return { terminalReason: "merged" }; } }; };
511
- const silent = console.log; console.log = () => {};
470
+ it("orchestrateFromCli wires parallel, lane and prints the table", async () => {
471
+ const seen = [];
472
+ const logs = [];
473
+ const l = console.log; console.log = (s) => logs.push(String(s));
512
474
  try {
513
- const a = mk({ terminalReason: "merged" });
514
- await runFromCli(["7", "--lane", "staging", "--path", "lean", "--repo", "o/r"], a);
515
- assert.equal(a.seen.path, "lean"); assert.ok(!a.seen.full);
516
- const b = mk({ terminalReason: "route-full", detail: "x" });
517
- await runFromCli(["7", "--lane", "staging", "--path", "auto", "--repo", "o/r"], b);
518
- assert.equal(b.seen.path, "auto"); assert.ok(b.seen.full);
519
- const c = mk({}); await runFromCli(["7", "--lane", "staging", "--repo", "o/r"], c);
520
- assert.equal(c.seen.path, undefined); assert.ok(c.seen.full);
521
- await assert.rejects(runFromCli(["7", "--lane", "staging", "--path", "bogus"], mk({})), /--path must be/);
522
- } finally { console.log = silent; }
523
- assert.ok(readdirSync(dir).length >= 0);
475
+ const res = await orchestrateFromCli(["1", "2", "--parallel", "2", "--lane", "main", "--repo", "o/r"], {
476
+ dir: tmpDir("core-orch-"), emitTelemetry: async () => {}, io: { gh: async () => "o/r\n" },
477
+ leanIo: { gh: async () => JSON.stringify({ body: "" }) },
478
+ leanModule: { runCore: async (o) => { seen.push([o.issue, o.lane, o.repo]); return { terminalReason: "merged", pr: 50 + o.issue, cost_usd: 1, wall_ms: 1 }; } } });
479
+ assert.equal(res.ok, true);
480
+ } finally { console.log = l; }
481
+ assert.deepEqual(seen.sort(), [[1, "main", "o/r"], [2, "main", "o/r"]]);
482
+ assert.ok(logs.some((s) => /issue\s+outcome/.test(s)));
483
+ await assert.rejects(orchestrateFromCli([], {}), /usage/);
524
484
  });
525
485
  });