forgedock 1.25.0 → 1.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +12 -0
- package/bin/engine-cli.mjs +39 -12
- package/bin/lean/conformance.e2e.test.mjs +232 -0
- package/bin/lean/conformance.mjs +238 -0
- package/bin/lean/conformance.test.mjs +163 -0
- package/bin/lean/effects.mjs +23 -0
- package/bin/lean/lean.mjs +221 -51
- package/bin/lean/lean.test.mjs +452 -107
- package/bin/lean/prefilter.test.mjs +18 -0
- package/bin/lean/prompts.mjs +60 -57
- package/bin/lean/render.mjs +51 -6
- package/commands/orchestrate.md +4 -2
- package/docs/CONFIG.md +2 -1
- package/docs/OUTCOME-CODES.md +11 -0
- package/package.json +1 -1
- package/scripts/ci-local.test.sh +4 -1
package/bin/lean/lean.mjs
CHANGED
|
@@ -10,17 +10,25 @@ import { hostname as osHostname } from "node:os";
|
|
|
10
10
|
import { appendEvent } from "../engine/runlog.mjs";
|
|
11
11
|
import { codeForTerminalReason, OUTCOME_CODES } from "../engine/outcome-codes.mjs";
|
|
12
12
|
import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAULT_SESSION_LIMIT_WAIT_MS, parseLeaseHolder, sanitizeLeaseHost } from "../engine.mjs";
|
|
13
|
-
import { openDependency } from "../engine/phases.mjs";
|
|
13
|
+
import { openDependency, isTrustedComment } from "../engine/phases.mjs";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { TRACKED, validateContract, applyAmendments, precheck, parseReview, decide, closePlan, followupItems, pendingTracking } from "./conformance.mjs";
|
|
14
16
|
import { checkAdmission, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
|
|
15
17
|
import { resolveLane, isDefaultLane } from "./lane.mjs";
|
|
16
18
|
import { retrying, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
|
|
17
|
-
import { renderInvestigation,
|
|
18
|
-
import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE,
|
|
19
|
+
import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS } from "./render.mjs";
|
|
20
|
+
import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS } from "./prompts.mjs";
|
|
19
21
|
|
|
20
22
|
export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
|
|
23
|
+
/** forge.yaml engine.conformance: "shadow" (default) posts and logs the gate's verdict without enforcing it; "enforce" blocks on it. */
|
|
24
|
+
export const CONFORMANCE_MODES = Object.freeze(["shadow", "enforce"]);
|
|
25
|
+
/** In shadow mode only what the previous two-question reviewer blocked on still blocks: a requirement not done, a regression. */
|
|
26
|
+
const SHADOW_BLOCKS = [OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED, OUTCOME_CODES.CONFORMANCE_REGRESSION];
|
|
21
27
|
export const REGISTER_MARKER = "FORGE:WORK-ON"; // alias marker carried by the FORGE:TRAJECTORY comment
|
|
22
28
|
const num = (v) => (Number.isFinite(v) ? v : null);
|
|
23
29
|
export const MAX_SUBISSUES = 8;
|
|
30
|
+
/** 12-hex sha256 of the canonical contract JSON text (renderContract embeds the same text). */
|
|
31
|
+
export const contractHash = (text) => createHash("sha256").update(String(text)).digest("hex").slice(0, 12);
|
|
24
32
|
|
|
25
33
|
/**
|
|
26
34
|
* Typed builder block (#4124), from result.json `block`. Returns {kind:"dependency", issues} | {kind:"split", subissues}
|
|
@@ -60,10 +68,11 @@ export function classifyBlock(result, issue) {
|
|
|
60
68
|
* @param {string} [o.builderModel] default: CLI default
|
|
61
69
|
* @param {string} [o.reviewerModel] default claude-sonnet-5-5
|
|
62
70
|
* @param {number} [o.ciMaxMinutes] remote-CI wait budget (default 360); exhaustion is NOT needs-human
|
|
71
|
+
* @param {"shadow"|"enforce"} [o.conformance] conformance gate mode (default shadow)
|
|
63
72
|
*/
|
|
64
73
|
export async function runCore(o) {
|
|
65
74
|
const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
|
|
66
|
-
ciMaxMinutes = 360, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
|
|
75
|
+
ciMaxMinutes = 360, conformance = "shadow", mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
|
|
67
76
|
const now = io.now ?? (() => Date.now());
|
|
68
77
|
const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
69
78
|
let lane = null, base = null, defaults = []; // set by lane resolution, before the lease
|
|
@@ -160,7 +169,6 @@ export async function runCore(o) {
|
|
|
160
169
|
// ---- stage notes: investigation.md, plan.md, implementation.md become their own comments ----
|
|
161
170
|
const STAGES = [
|
|
162
171
|
{ key: "investigation", file: INVESTIGATION_FILE, marker: ["FORGE:INVESTIGATION", "FORGE:VALIDATE"], render: (md, ctx) => renderInvestigation(md, ctx.result) },
|
|
163
|
-
{ key: "plan", file: PLAN_FILE, marker: ["FORGE:PLAN"], render: (md, ctx) => renderPlan(md, ctx.result, ctx.files) },
|
|
164
172
|
{ key: "implementation", file: IMPLEMENTATION_FILE, marker: ["FORGE:IMPLEMENTATION"], render: (md, ctx) => renderImplementation(md, ctx.result, { branch, files: ctx.files }) },
|
|
165
173
|
];
|
|
166
174
|
const posted = {}; // key -> comment url (or "" when posted without a link)
|
|
@@ -176,10 +184,25 @@ export async function runCore(o) {
|
|
|
176
184
|
const url = await post(st.marker, st.render(md, { result, files }));
|
|
177
185
|
posted[st.key] = url ?? "";
|
|
178
186
|
}
|
|
187
|
+
/** Re-render a stage note that changed after a resume: edit the posted comment in place; post it when it was never posted or the edit fails. */
|
|
188
|
+
async function upsertStage(key) {
|
|
189
|
+
const st = STAGES.find((x) => x.key === key);
|
|
190
|
+
const id = /#issuecomment-(\d+)/.exec(posted[key] ?? "")?.[1];
|
|
191
|
+
const res = await readResult(), md = await readStage(st);
|
|
192
|
+
if (id) {
|
|
193
|
+
const files = key === "implementation" ? await stageFiles() : [];
|
|
194
|
+
const marks = st.marker.map((m) => `<!-- ${m} -->`).join("\n");
|
|
195
|
+
try { await ghw(["api", "-X", "PATCH", `repos/${repo}/issues/comments/${id}`, "-f", `body=${capComment(`${marks}\n${st.render(usableMd(md) ? md : null, { result: res, files })}`)}`]); return; } catch { /* fall back to a new comment */ }
|
|
196
|
+
}
|
|
197
|
+
delete posted[key];
|
|
198
|
+
await flushStages(res, [key]);
|
|
199
|
+
}
|
|
179
200
|
/** Post stage notes in order that exist, are stable (mtime unchanged for stableMs) and not yet posted. */
|
|
180
201
|
const seen = {};
|
|
202
|
+
let liveKeys = ["investigation"]; // which stage notes the running builder call may publish live (plan: investigation; implement: implementation)
|
|
181
203
|
async function livePoll() {
|
|
182
204
|
for (const st of STAGES) {
|
|
205
|
+
if (!liveKeys.includes(st.key)) continue;
|
|
183
206
|
if (st.key in posted) continue;
|
|
184
207
|
let m = null;
|
|
185
208
|
try { m = await io.fs.stat(`${wt}/${st.file}`); } catch { m = null; }
|
|
@@ -244,6 +267,8 @@ export async function runCore(o) {
|
|
|
244
267
|
} catch (e) { ev({ event: "CHECKPOINT_FAILED", reason, error: String(e?.message ?? e).slice(0, 300) }); }
|
|
245
268
|
}
|
|
246
269
|
const CONTINUE_NOTE = "\n\nThe branch already holds work from an earlier session that was interrupted (see the latest `wip(forge): checkpoint` commit). Continue from it: read `git log` and `git diff origin/BASE...HEAD`, do not start over.";
|
|
270
|
+
const PLAN_RERUN_NOTE = "\n\nThis is a rerun: the branch already holds commits from an earlier run. Read `git log` and `git diff origin/BASE...HEAD` to see what exists, but plan against the issue itself. This step is still planning only: do not edit code, do not commit, and do not write the result file unless the issue is invalid, already fixed or blocked.";
|
|
271
|
+
const RERUN_NOTE = "\n\nThis is a rerun: the branch already holds commits from an earlier run. Read `git log` and `git diff origin/BASE...HEAD`, verify that work against the contract, complete what is missing, re-run every validate command and write the result file. Do not start over.";
|
|
247
272
|
|
|
248
273
|
/** Continuous admission (#4115): while the builder runs, pause its process group when the host is over the ceiling, resume when under. */
|
|
249
274
|
function startGovernor(control) {
|
|
@@ -267,7 +292,9 @@ export async function runCore(o) {
|
|
|
267
292
|
return { async stop() { stopped = true; wake?.(); clearTimeout(t); await loop; if (paused) { control.resume?.(); paused = false; } } };
|
|
268
293
|
}
|
|
269
294
|
|
|
270
|
-
|
|
295
|
+
/** A timeout restart starts a fresh session; by default it continues the implementation against the frozen contract. */
|
|
296
|
+
const implementRestart = () => builderImplementPrompt() + CONTINUE_NOTE.replace("BASE", lane);
|
|
297
|
+
async function builder(n, prompt, resume, live = false, restart = implementRestart) {
|
|
271
298
|
let timeoutRetries = 0, p = prompt;
|
|
272
299
|
for (;;) {
|
|
273
300
|
try { return await builderOnce(n, p, resume, live); } catch (e) {
|
|
@@ -279,7 +306,7 @@ export async function runCore(o) {
|
|
|
279
306
|
if (timeoutRetries++ >= 1) { e.deferCode = idle ? OUTCOME_CODES.CLI_IDLE : OUTCOME_CODES.CLI_TIMEOUT; e.deferDetail = `builder ${reason}; work checkpointed to ${branch}; rerun resumes from it. ${String(e.message).slice(0, 300)}`; throw e; }
|
|
280
307
|
T.anomalies.push(`builder ${reason}: checkpointed, retried once`);
|
|
281
308
|
T.decisions.push(`Builder ${reason}: uncommitted work checkpointed to the lane branch; retrying once from the checkpoint`);
|
|
282
|
-
p =
|
|
309
|
+
p = restart(); resume = null;
|
|
283
310
|
}
|
|
284
311
|
}
|
|
285
312
|
}
|
|
@@ -312,6 +339,7 @@ export async function runCore(o) {
|
|
|
312
339
|
onProgress({ event: "phase_exit", phase: "build", status: "committed" });
|
|
313
340
|
return r;
|
|
314
341
|
}
|
|
342
|
+
const readContract = async () => { try { const j = JSON.parse(await io.fs.read(`${wt}/${CONTRACT_FILE}`)); return j && typeof j === "object" ? j : null; } catch { return null; } };
|
|
315
343
|
const readResult = async () => { try { const j = JSON.parse(await io.fs.read(`${wt}/${RESULT_FILE}`)); return j && typeof j === "object" ? j : null; } catch { return null; } };
|
|
316
344
|
|
|
317
345
|
/** Live-checks a builder block (#4124). Dependency issues are re-read, so a closed or unknown number is not a block. */
|
|
@@ -415,7 +443,7 @@ export async function runCore(o) {
|
|
|
415
443
|
ev({ event: "RUN_START", run: `core_${issue}_${t0}`, issue, lane, path: "core", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
|
|
416
444
|
let iss;
|
|
417
445
|
try {
|
|
418
|
-
iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state"]));
|
|
446
|
+
iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments"]));
|
|
419
447
|
} catch (e) { return finish("engine-error", `cannot read issue: ${e.message}`); }
|
|
420
448
|
if (String(iss.state).toUpperCase() === "CLOSED") return finish("invalid", "issue already closed");
|
|
421
449
|
|
|
@@ -490,14 +518,19 @@ export async function runCore(o) {
|
|
|
490
518
|
await io.fs.append(excl.startsWith("/") ? excl : `${wt}/${excl}`, `\n${LEAN_DIR}/\n`);
|
|
491
519
|
const changedFiles = async () => String(await git(["diff", "--name-only", `${base}...HEAD`])).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
492
520
|
|
|
493
|
-
// ---- 3. builder ----
|
|
521
|
+
// ---- 3. builder: plan call -> frozen contract -> implement call in the same session ----
|
|
494
522
|
let result = null, attemptN = 0, reviewN = 0;
|
|
495
523
|
const lastSubject = resumed ? String(await git(["log", "-1", "--format=%s"]).catch(() => "")).trim() : "";
|
|
496
524
|
const fromCheckpoint = lastSubject.startsWith("wip(forge): checkpoint");
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
525
|
+
const hasCommits = resumed ? (await changedFiles()).length > 0 : false;
|
|
526
|
+
// Requirements may only be quoted from the issue and trusted comments: an untrusted commenter cannot add a contract requirement.
|
|
527
|
+
const trustedComments = (iss.comments ?? []).filter((c) => {
|
|
528
|
+
const login = String(c?.author?.login ?? c?.user?.login ?? "");
|
|
529
|
+
return isTrustedComment({ author_association: c?.authorAssociation ?? c?.author_association, user: { login, type: c?.user?.type ?? (/\[bot\]$|^app\//.test(login) ? "Bot" : "User") } });
|
|
530
|
+
});
|
|
531
|
+
const issueText = [iss.title, iss.body ?? "", ...trustedComments.map((c) => c?.body ?? "")].join("\n\n");
|
|
532
|
+
/** A typed block (with one corrective resume for an untyped one) ends the run; returns the terminal or null. */
|
|
533
|
+
async function endIfBlocked() {
|
|
501
534
|
let block = result?.status === "blocked" ? await resolveBlock(result) : null;
|
|
502
535
|
if (block?.kind === "untyped" && sessionId) { // #4124: one corrective resume before any untyped block can reach needs-human
|
|
503
536
|
T.decisions.push(`Builder block rejected (${block.problem}); resumed once`);
|
|
@@ -505,70 +538,204 @@ export async function runCore(o) {
|
|
|
505
538
|
result = await readResult();
|
|
506
539
|
block = result?.status === "blocked" ? await resolveBlock(result) : null;
|
|
507
540
|
}
|
|
508
|
-
|
|
541
|
+
return block ? await endBlocked(block, result.summary) : null;
|
|
542
|
+
}
|
|
543
|
+
/** File (or append to) the parent's one batched follow-up issue for these requirements, then verify each tracking issue is open. */
|
|
544
|
+
async function track(reqs, why) {
|
|
545
|
+
const fail = (detail) => Object.assign(new Error(detail), { deferCode: OUTCOME_CODES.FOLLOWUP_UNAVAILABLE, deferDetail: `${detail}; nothing merged, rerun to retry` });
|
|
546
|
+
if (!io.fileFollowups) throw fail("no follow-up filing effect is configured");
|
|
547
|
+
const items = followupItems(reqs);
|
|
548
|
+
let filed;
|
|
549
|
+
try { filed = await io.fileFollowups({ parent: issue, repo, items }); } catch (e) { throw fail(`filing the follow-up issue failed: ${String(e?.message ?? e).slice(0, 200)}`); }
|
|
550
|
+
for (const [i, r] of reqs.entries()) {
|
|
551
|
+
const n = Number(filed?.[items[i].key]);
|
|
552
|
+
if (!Number.isSafeInteger(n) || n <= 0) throw fail(`no tracking issue was returned for ${r.id}`);
|
|
553
|
+
let st = null;
|
|
554
|
+
try { st = JSON.parse(String(await ghc(["issue", "view", String(n), "-R", repo, "--json", "number,state"]))); } catch { st = null; }
|
|
555
|
+
if (String(st?.state).toUpperCase() !== "OPEN") throw fail(`tracking issue #${n} for ${r.id} is not open or unreadable`);
|
|
556
|
+
r.tracked = `#${n}`;
|
|
557
|
+
}
|
|
558
|
+
T.decisions.push(`Tracked ${reqs.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(reqs.map((r) => r.tracked))].join(", ")} (${why})`);
|
|
559
|
+
}
|
|
560
|
+
/** The frozen contract posted by an earlier run of this issue, verified against its hash; null when absent. */
|
|
561
|
+
async function readFrozenContract() {
|
|
562
|
+
let out = "";
|
|
563
|
+
try { out = String(await ghc(["api", "--paginate", `repos/${repo}/issues/${issue}/comments`, "--jq", ".[] | {body, author_association, user: {type: .user.type, login: .user.login}}"])); } catch { return null; }
|
|
564
|
+
let found = null;
|
|
565
|
+
for (const l of out.split("\n")) {
|
|
566
|
+
let c; try { c = JSON.parse(l); } catch { continue; }
|
|
567
|
+
if (!String(c?.body ?? "").startsWith("<!-- FORGE:CONTRACT -->") || !isTrustedComment(c)) continue;
|
|
568
|
+
const fc = parseContractComment(c.body);
|
|
569
|
+
if (fc && contractHash(fc.text) === fc.hash) found = fc;
|
|
570
|
+
}
|
|
571
|
+
return found;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
let frozen = null, hash = null, planned = false;
|
|
575
|
+
if (resumed) {
|
|
576
|
+
const fc = await readFrozenContract();
|
|
577
|
+
if (fc) { frozen = fc.contract; hash = fc.hash; T.decisions.push(`Rerun: frozen contract ${hash} recovered from the issue`); }
|
|
578
|
+
}
|
|
579
|
+
await label("building");
|
|
580
|
+
if (!frozen) {
|
|
581
|
+
await builder(++attemptN, builderPlanPrompt(issue) + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : ""), null, true, () => builderPlanPrompt(issue));
|
|
582
|
+
planned = true;
|
|
583
|
+
result = await readResult();
|
|
509
584
|
const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
|
|
510
|
-
const
|
|
511
|
-
await flushStages(result, ["investigation"]); //
|
|
512
|
-
T.validate = [result ? (result.status === "fixed" ? "CONFIRMED" :
|
|
513
|
-
|
|
514
|
-
if (
|
|
515
|
-
if (closing) {
|
|
585
|
+
const closes = ["invalid", "already-fixed"].includes(result?.status);
|
|
586
|
+
if (closes || result?.status === "blocked") await flushStages(result, ["investigation"]); // the evidence comment for invalid/already-fixed
|
|
587
|
+
T.validate = [result?.status ? String(result.status === "fixed" ? "CONFIRMED" : result.status).toUpperCase() : "CONFIRMED", `${evidence.length} evidence item(s)`];
|
|
588
|
+
if (closes && !evidence.length) return await needsHuman(`Builder reported "${result.status}" without evidence, so the issue is not closed automatically.\n\n${result.summary ?? "(no summary)"}`, OUTCOME_CODES.BUILDER_NO_EVIDENCE);
|
|
589
|
+
if (closes) {
|
|
516
590
|
T.plan = "skipped"; T.implement = "skipped"; T.review = "skipped"; T.pr = "none"; T.merge = "n/a";
|
|
591
|
+
T.decisions.push(`Builder verdict: ${result.status}`);
|
|
517
592
|
await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "not planned"]);
|
|
518
593
|
await label("invalid");
|
|
519
594
|
return await finish("invalid", result.summary ?? result.status);
|
|
520
595
|
}
|
|
521
|
-
|
|
522
|
-
if (
|
|
523
|
-
await
|
|
524
|
-
|
|
596
|
+
const blocked = await endIfBlocked();
|
|
597
|
+
if (blocked) return blocked;
|
|
598
|
+
let contract = await readContract();
|
|
599
|
+
let vc = validateContract(contract, issueText);
|
|
600
|
+
if (!vc.ok && sessionId) {
|
|
601
|
+
T.decisions.push(`Contract rejected (${vc.problems[0]}); resumed once`);
|
|
602
|
+
await builder(++attemptN, contractCorrectionPrompt(vc.problems), sessionId, false, () => builderPlanPrompt(issue));
|
|
603
|
+
contract = await readContract();
|
|
604
|
+
vc = validateContract(contract, issueText);
|
|
605
|
+
}
|
|
606
|
+
await flushStages(result ?? { status: "fixed" }, ["investigation"]); // always posted: FORGE:INVESTIGATION/VALIDATE are read downstream
|
|
607
|
+
if (!vc.ok) return await needsHuman(`The builder's contract is still invalid after one correction:\n${vc.problems.slice(0, 12).map((x) => `- ${x}`).join("\n")}`, OUTCOME_CODES.CONTRACT_INVALID);
|
|
608
|
+
frozen = contract;
|
|
609
|
+
hash = contractHash(contractJson(frozen));
|
|
610
|
+
let body = renderContract(frozen, hash);
|
|
611
|
+
if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true }); // the JSON must survive the comment cap
|
|
612
|
+
await post(["FORGE:CONTRACT", "FORGE:PLAN"], body);
|
|
613
|
+
const deferredN = frozen.requirements.filter((r) => TRACKED.includes(r.disposition)).length;
|
|
614
|
+
ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: deferredN });
|
|
615
|
+
T.decisions.push(`Contract frozen (${hash}): ${frozen.requirements.length} requirement(s), ${deferredN} deferred pending review`);
|
|
525
616
|
} else {
|
|
526
|
-
T.validate = "resumed (existing branch)";
|
|
527
|
-
|
|
617
|
+
T.validate = "resumed (existing branch)";
|
|
618
|
+
}
|
|
619
|
+
T.plan = [`contract ${hash}`, `${frozen.requirements.length} requirement(s)`];
|
|
620
|
+
if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
|
|
621
|
+
const implementPrompt = builderImplementPrompt() + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane));
|
|
622
|
+
liveKeys = ["implementation"];
|
|
623
|
+
await builder(++attemptN, implementPrompt, planned ? sessionId : null, true);
|
|
624
|
+
result = await readResult();
|
|
625
|
+
{
|
|
626
|
+
const blocked = await endIfBlocked();
|
|
627
|
+
if (blocked) return blocked;
|
|
528
628
|
}
|
|
629
|
+
let files = await changedFiles();
|
|
630
|
+
if (!files.length) return await needsHuman(`Builder made no commit${result ? ` (status "${result.status}")` : " and wrote no result.json"}.\n\n${result?.summary ?? ""}`, OUTCOME_CODES.BUILDER_NO_COMMIT);
|
|
631
|
+
await flushStages(result, ["implementation"]);
|
|
632
|
+
T.implement = [`${files.length} file(s)`, branch];
|
|
633
|
+
T.decisions.push(`Builder verdict: ${result?.status ?? "none"}`);
|
|
529
634
|
|
|
530
|
-
// ---- 4.
|
|
635
|
+
// ---- 4. conformance: typed amendments, deterministic precheck, structured review; the engine computes the verdict ----
|
|
636
|
+
// enforce: every failure class blocks (one builder resume, then needs-human). shadow: the verdict is computed and posted,
|
|
637
|
+
// but only what the previous reviewer enforced blocks (a review that finds a requirement not implemented, or a regression).
|
|
531
638
|
await label("in-review");
|
|
532
|
-
|
|
533
|
-
|
|
639
|
+
const shadow = conformance !== "enforce";
|
|
640
|
+
let effective = frozen, review = null, lastAmend = "", resumes = 0, verdict = null;
|
|
641
|
+
async function amend() {
|
|
642
|
+
const a = applyAmendments(frozen, result?.amendments, issueText);
|
|
643
|
+
const sig = JSON.stringify(a.applied);
|
|
644
|
+
if (a.applied.length && sig !== lastAmend) {
|
|
645
|
+
lastAmend = sig;
|
|
646
|
+
await post("FORGE:AMENDMENT", renderAmendments(a.applied));
|
|
647
|
+
T.decisions.push(`Contract amended: ${a.applied.map((x) => `${x.kind} ${x.target}`).join(", ")}`);
|
|
648
|
+
}
|
|
649
|
+
effective = a.contract;
|
|
650
|
+
return a.problems.map((d) => ({ code: OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED, detail: d }));
|
|
651
|
+
}
|
|
652
|
+
const IGNORE = [`${LEAN_DIR}/`];
|
|
653
|
+
/** Up to two reviewer runs; a reply without usable rows (unparseable, incomplete, inconsistent) is re-asked, never charged to the builder. */
|
|
654
|
+
async function reviewOnce() {
|
|
655
|
+
const diff = String(await git(["diff", `${base}...HEAD`]));
|
|
656
|
+
const stat = Buffer.byteLength(diff, "utf-8") > REVIEW_DIFF_MAX_BYTES ? String(await git(["diff", "--stat=200", `${base}...HEAD`]).catch(() => "")) : "";
|
|
657
|
+
const listed = new Set(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean));
|
|
658
|
+
const ctx = { contract: effective, changedFiles: files, issueText, pathExists: (x) => listed.has(x), ignore: IGNORE };
|
|
659
|
+
let d = null;
|
|
534
660
|
for (let t = 0; t < 2; t++) {
|
|
535
|
-
const diff = String(await git(["diff", `${base}...HEAD`]));
|
|
536
|
-
// #4129: an over-budget diff is bounded in the prompt; the stat keeps every changed file visible.
|
|
537
|
-
const stat = Buffer.byteLength(diff, "utf-8") > REVIEW_DIFF_MAX_BYTES ? String(await git(["diff", "--stat=200", `${base}...HEAD`]).catch(() => "")) : "";
|
|
538
661
|
await renew();
|
|
539
662
|
const rr = await callRunner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
|
|
540
|
-
prompt: reviewerPrompt({ issue: iss, diff, stat, base }) });
|
|
663
|
+
prompt: reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base }) });
|
|
541
664
|
await renew();
|
|
542
665
|
await attempt("review", ++reviewN, rr);
|
|
543
|
-
|
|
544
|
-
|
|
666
|
+
review = parseReview(rr?.text);
|
|
667
|
+
d = decide(review, ctx);
|
|
668
|
+
ev({ event: "CONFORMANCE", round: reviewN, state: d.state, failures: d.failures.map((x) => x.code), mode: shadow ? "shadow" : "enforce" });
|
|
669
|
+
if (!d.state.startsWith("review.")) return d;
|
|
545
670
|
}
|
|
546
|
-
return
|
|
547
|
-
}
|
|
548
|
-
review
|
|
549
|
-
|
|
550
|
-
if (review.fail) {
|
|
551
|
-
if (!sessionId) return await needsHuman(`Review FAIL and the builder session id is unknown, cannot resume: ${review.reason}`, OUTCOME_CODES.REVIEW_FAILED);
|
|
552
|
-
T.decisions.push(`Review FAIL (round 1): builder resumed with the reason: ${String(review.reason).slice(0, 200)}`);
|
|
553
|
-
await builder(++attemptN, resumeBuilderPrompt(review.reason), sessionId);
|
|
671
|
+
return d;
|
|
672
|
+
}
|
|
673
|
+
/** Precheck then review, on the current result and changed files (also the base-sync re-review). */
|
|
674
|
+
async function gate() {
|
|
554
675
|
result = (await readResult()) ?? result;
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
676
|
+
files = await changedFiles();
|
|
677
|
+
const amendProblems = await amend();
|
|
678
|
+
const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE });
|
|
679
|
+
const pre = [...pc.failures, ...amendProblems];
|
|
680
|
+
if (pre.length) ev({ event: "PRECHECK", failures: pre.map((f) => f.code) });
|
|
681
|
+
if (pre.length && !shadow) return { state: "nonconforming", by: "precheck", failures: pre };
|
|
682
|
+
const d = await reviewOnce();
|
|
683
|
+
if (!shadow) return { ...d, by: "review" };
|
|
684
|
+
// shadow: record what enforce would have done; block only on what the previous reviewer blocked on
|
|
685
|
+
const would = d.state === "conforms" && !pre.length ? "conforms" : d.state.startsWith("review.") ? d.state : "nonconforming";
|
|
686
|
+
const wouldFail = [...pre, ...(d.state === "conforms" ? [] : d.failures)];
|
|
687
|
+
const blocking = d.state === "nonconforming" ? d.failures.filter((f) => SHADOW_BLOCKS.includes(f.code)) : [];
|
|
688
|
+
return { state: d.state === "review.unparseable" ? d.state : blocking.length ? "nonconforming" : "conforms", by: "review", failures: blocking, would, wouldFail, reviewState: d.state };
|
|
689
|
+
}
|
|
690
|
+
/** Shape the base-sync path expects: null when no usable review, else {fail, verdict, reason, failures}. */
|
|
691
|
+
const doReview = async () => {
|
|
692
|
+
const d = await gate();
|
|
693
|
+
if (d.state.startsWith("review.")) return null;
|
|
694
|
+
return { fail: d.state !== "conforms", verdict: d.state === "conforms" ? "CONFORMS" : "NONCONFORMING", reason: d.failures.map((f) => `${f.code}: ${f.detail}`).join("; "), failures: d.failures };
|
|
695
|
+
};
|
|
696
|
+
for (;;) {
|
|
697
|
+
const outcome = await gate();
|
|
698
|
+
verdict = outcome;
|
|
699
|
+
if (outcome.state === "conforms") break;
|
|
700
|
+
const list = outcome.failures.slice(0, 15).map((f) => `- ${f.code}: ${f.detail}`).join("\n");
|
|
701
|
+
if (outcome.state === "review.unparseable") return await needsHuman(`The conformance reviewer returned no parseable JSON twice; nothing merged.`, OUTCOME_CODES.REVIEW_NO_VERDICT);
|
|
702
|
+
if (outcome.state === "review.incomplete") return await needsHuman(`The conformance review was incomplete twice (a review without complete rows cannot pass); nothing merged:\n${list}`, OUTCOME_CODES.REVIEW_INCOMPLETE);
|
|
703
|
+
if (outcome.state === "review.inconsistent") return await needsHuman(`The conformance reviewer twice said NONCONFORMING without a failing row; nothing merged.`, OUTCOME_CODES.REVIEW_INCONSISTENT);
|
|
704
|
+
if (resumes >= 1 || !sessionId) return await needsHuman(`The change does not conform to the issue and its frozen contract after ${resumes ? "one builder resume" : "the build (no session to resume)"} (${outcome.by}); nothing merged:\n${list}`, outcome.failures[0].code);
|
|
705
|
+
resumes++;
|
|
706
|
+
T.decisions.push(`Not conforming (${outcome.by}${outcome.inconsistent ? ", reviewer verdict contradicted its rows" : ""}): ${[...new Set(outcome.failures.map((f) => f.code))].join(", ")}; builder resumed once`);
|
|
707
|
+
await builder(++attemptN, conformanceResumePrompt(outcome.failures), sessionId);
|
|
708
|
+
await upsertStage("implementation"); // the published note follows the code after every resume
|
|
709
|
+
}
|
|
710
|
+
// Deferrals the reviewer accepted are filed now, before any PR: the batched follow-up must exist and be linked.
|
|
711
|
+
const toFile = pendingTracking(effective);
|
|
712
|
+
if (!shadow && toFile.length) await track(toFile, "accepted by the conformance review");
|
|
713
|
+
if (shadow) {
|
|
714
|
+
const wf = verdict.wouldFail ?? [];
|
|
715
|
+
ev({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
|
|
716
|
+
await emit({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
|
|
717
|
+
T.review = [`shadow: would ${verdict.would === "conforms" ? "pass" : `block (${verdict.would})`}`, `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
|
|
718
|
+
await post("FORGE:REVIEW", renderShadowReview({ would: verdict.would, failures: wf, review: verdict.reviewState === "conforms" || verdict.reviewState === "nonconforming" ? review : null, contract: effective, unfiled: toFile }));
|
|
719
|
+
} else {
|
|
720
|
+
T.review = ["CONFORMS", `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
|
|
721
|
+
await post("FORGE:REVIEW", renderReview(review, effective));
|
|
558
722
|
}
|
|
559
|
-
T.review = [review.verdict, `${reviewN} reviewer run(s), ${attemptN > 1 ? "2 rounds" : "1 round"}`];
|
|
560
|
-
await post("FORGE:REVIEW", renderReview(review));
|
|
561
723
|
ev({ event: "PHASE_COMMIT", phase: "review" });
|
|
724
|
+
const closing = closePlan(shadow ? { requirements: [] } : effective);
|
|
562
725
|
|
|
563
726
|
// ---- 5. push + PR ----
|
|
564
|
-
|
|
727
|
+
files = await changedFiles();
|
|
565
728
|
await gitPush(["push", "-u", "origin", branch]);
|
|
566
729
|
laneGuard(lane, "open a PR");
|
|
567
730
|
const prOut = resumed?.pr ? `/pull/${resumed.pr}` : String(await ghw(["pr", "create", "-R", repo, "--base", lane, "--head", branch,
|
|
568
|
-
"--title", `${iss.title} (#${issue})`, "--body", renderPrBody({ issue, investigation: await readStage(STAGES[0]),
|
|
731
|
+
"--title", `${iss.title} (#${issue})`, "--body", renderPrBody({ issue, investigation: await readStage(STAGES[0]), contract: effective, implementation: await readStage(STAGES[1]), result, gitFiles: files, tracked: closing.tracked })])).trim();
|
|
569
732
|
const pr = Number(/\/pull\/(\d+)/.exec(prOut)?.[1] ?? /(\d+)\s*$/.exec(prOut)?.[1]);
|
|
570
733
|
if (!Number.isInteger(pr)) return await needsHuman(`Could not parse the PR number from: ${prOut.slice(0, 200)}`);
|
|
571
734
|
ev({ event: "PR_OPENED", pr });
|
|
735
|
+
if (resumed?.pr && closing.tracked.length) { // an existing PR still has to link the tracking issues visibly
|
|
736
|
+
try { await ghw(["pr", "edit", String(pr), "-R", repo, "--body", renderPrBody({ issue, investigation: await readStage(STAGES[0]), contract: effective, implementation: await readStage(STAGES[1]), result, gitFiles: files, tracked: closing.tracked })]); }
|
|
737
|
+
catch (e) { e.deferCode = OUTCOME_CODES.FOLLOWUP_UNAVAILABLE; e.deferDetail = `could not link the tracking issues on PR #${pr}; nothing merged, rerun to retry`; throw e; }
|
|
738
|
+
}
|
|
572
739
|
T.pr = [`#${pr}`, resumed?.pr ? "existing" : "opened"];
|
|
573
740
|
const prUrl = /https?:\/\/\S+/.exec(prOut)?.[0] ?? `https://github.com/${repo}/pull/${pr}`;
|
|
574
741
|
let localHead = String(await git(["rev-parse", "HEAD"])).trim();
|
|
@@ -634,7 +801,7 @@ export async function runCore(o) {
|
|
|
634
801
|
let rv = await doReview();
|
|
635
802
|
if (rv?.fail) {
|
|
636
803
|
T.decisions.push(`Base-sync re-review FAIL: builder resumed with the reason`);
|
|
637
|
-
await builder(++attemptN,
|
|
804
|
+
await builder(++attemptN, conformanceResumePrompt(rv.failures), sessionId ?? null);
|
|
638
805
|
if (await unresolved()) return await needsHuman("Base-sync re-review fix left conflict markers.", OUTCOME_CODES.BASE_CONFLICT);
|
|
639
806
|
if ((await gitOut(["rev-parse", "HEAD"])) !== after) await gitPush(["push", "origin", branch]);
|
|
640
807
|
rv = await doReview();
|
|
@@ -709,6 +876,9 @@ export async function runCore(o) {
|
|
|
709
876
|
}
|
|
710
877
|
}
|
|
711
878
|
await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "completed"]).catch(() => {});
|
|
879
|
+
if (closing.tracked.length) {
|
|
880
|
+
try { await post("FORGE:TRACKED", `## Remaining work tracked\nClosed as completed by PR #${pr}. The contracted deferred and post-deploy requirements are tracked in ${closing.tracked.join(", ")}.`); } catch { /* the PR body links them too */ }
|
|
881
|
+
}
|
|
712
882
|
await label("merged");
|
|
713
883
|
return await finish("merged", `PR #${pr}`, { pr });
|
|
714
884
|
} catch (e) {
|