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.
- package/bin/engine/phases.mjs +15 -3
- package/bin/engine-cli.mjs +57 -16
- package/bin/forgedock.mjs +15 -3
- package/bin/lean/effects.mjs +18 -13
- package/bin/lean/lean.mjs +145 -213
- package/bin/lean/lean.test.mjs +360 -400
- package/bin/lean/orchestrate.mjs +69 -0
- package/bin/lean/prompts.mjs +37 -44
- package/bin/lean/render.mjs +66 -0
- package/docs/CONFIG.md +2 -1
- package/docs/RELEASE-NOTES.md +12 -3
- package/package.json +1 -1
- package/scripts/ci-local.test.sh +2 -2
- package/scripts/review-roster.sh +14 -1
- package/scripts/review-roster.test.sh +13 -0
- package/bin/lean/risk.mjs +0 -49
package/bin/engine/phases.mjs
CHANGED
|
@@ -977,6 +977,18 @@ export const PHASES = [
|
|
|
977
977
|
};
|
|
978
978
|
// forge#3685: a resume that ends HELD posts a non-terminal FORGE:REMEDIATION_HELD record (no COMPLETE).
|
|
979
979
|
// Newest-wins between the held record and COMPLETE, by position in the (trusted, scoped) comment list.
|
|
980
|
+
// forge#3947: a trail posted after THIS dispatch's marker is this run's outcome; a push that moved the PR
|
|
981
|
+
// head after it was posted must not reclassify it. Only an older trail is judged against the live head.
|
|
982
|
+
const headStale = async () => {
|
|
983
|
+
let dispatchAt = -1, trailAt = -1;
|
|
984
|
+
const dispatchSentinel = `${REMEDIATION_DISPATCH_MARKER}: pr=${state.pr}`;
|
|
985
|
+
scoped.forEach((b, i) => {
|
|
986
|
+
if (state.pr != null && hasSentinelLine(b, dispatchSentinel)) dispatchAt = i;
|
|
987
|
+
if (startsWithHeader(b, PHASE_MARKERS.remediate.header) && hasSentinelLine(b, completion)) trailAt = i;
|
|
988
|
+
});
|
|
989
|
+
if (dispatchAt >= 0 && trailAt > dispatchAt) return false;
|
|
990
|
+
return remediationHeadStale(state, io);
|
|
991
|
+
};
|
|
980
992
|
const isHeld = (b) => startsWithHeader(b, REMEDIATION_HELD_MARKER) && hasSentinelLine(b, REMEDIATION_HELD_MARKER);
|
|
981
993
|
let heldAt = -1, completeAt = -1;
|
|
982
994
|
scoped.forEach((b, i) => {
|
|
@@ -1039,7 +1051,7 @@ export const PHASES = [
|
|
|
1039
1051
|
case "RE-ESCALATED":
|
|
1040
1052
|
case "UNFIXABLE":
|
|
1041
1053
|
// forge#3931: a trail recorded against an older PR head must not be replayed as this run's outcome.
|
|
1042
|
-
if (await
|
|
1054
|
+
if (await headStale())
|
|
1043
1055
|
return { status: "failed", code: OC.REMEDIATE_NO_MARKER, detail: `stale ${completion} trail: PR head moved since it was recorded` };
|
|
1044
1056
|
// Both leave the issue at needs-human (a fresh escalation, or a
|
|
1045
1057
|
// policy judgment call respectively) — reuse the existing
|
|
@@ -1053,7 +1065,7 @@ export const PHASES = [
|
|
|
1053
1065
|
case "INPR-FIX-REQUESTED": {
|
|
1054
1066
|
// forge#4021: the re-review approved but posted a FORGE:INPR_FIX work order. One further in-PR fix
|
|
1055
1067
|
// remediation is allowed (never needs-human on the first request); the bound marker count caps it.
|
|
1056
|
-
if (await
|
|
1068
|
+
if (await headStale())
|
|
1057
1069
|
return { status: "failed", code: OC.REMEDIATE_NO_MARKER, detail: `stale ${completion} trail: PR head moved since it was recorded` };
|
|
1058
1070
|
if (await prMerged()) return mergedResult(reGateOutcome);
|
|
1059
1071
|
const round = /\*\*INPR round\*\*:\s*(\d+)/.exec(latest)?.[1] ?? null;
|
|
@@ -1065,7 +1077,7 @@ export const PHASES = [
|
|
|
1065
1077
|
return { status: "committed", code: OC.REMEDIATE_INPR_REQUESTED, outputs: { ...outputs, remediation: "inpr-fix" } };
|
|
1066
1078
|
}
|
|
1067
1079
|
case "REREVIEW-REQUIRED":
|
|
1068
|
-
if (await
|
|
1080
|
+
if (await headStale())
|
|
1069
1081
|
return { status: "failed", code: OC.REMEDIATE_NO_MARKER, detail: `stale ${completion} trail: PR head moved since it was recorded` };
|
|
1070
1082
|
return { status: "committed", terminalReason: "needs-human", code: OC.REMEDIATE_RE_ESCALATED, outputs: { reGateOutcome } };
|
|
1071
1083
|
default:
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -85,7 +85,7 @@ export function readCiPendingMaxMinutes(cwd = process.cwd()) {
|
|
|
85
85
|
} catch { return undefined; }
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
/** forge.yaml `engine.path` (full|lean|auto) from the cwd checkout; undefined when absent or invalid. */
|
|
88
|
+
/** forge.yaml `engine.path` (core|full|lean|auto) from the cwd checkout; undefined when absent or invalid. */
|
|
89
89
|
export function readEnginePath(cwd = process.cwd()) {
|
|
90
90
|
try {
|
|
91
91
|
const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.path ?? "").trim();
|
|
@@ -98,7 +98,15 @@ function readLeanSetting(key, cwd = process.cwd()) {
|
|
|
98
98
|
return /^[A-Za-z0-9._:[\]-]+$/.test(v) ? v : undefined;
|
|
99
99
|
} catch { return undefined; }
|
|
100
100
|
}
|
|
101
|
-
|
|
101
|
+
// core is the default; lean and auto are aliases of core. full is the legacy multi-phase pipeline.
|
|
102
|
+
/** forge.yaml `engine.merge_wait` (required|all|none); undefined when absent or invalid. */
|
|
103
|
+
export function readMergeWait(cwd = process.cwd()) {
|
|
104
|
+
try {
|
|
105
|
+
const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.merge_wait ?? "").trim();
|
|
106
|
+
return ["required", "all", "none"].includes(v) ? v : undefined;
|
|
107
|
+
} catch { return undefined; }
|
|
108
|
+
}
|
|
109
|
+
export const ENGINE_PATHS = ["core", "full", "lean", "auto"];
|
|
102
110
|
|
|
103
111
|
export function runDir() { return join(homedir(), ".forge", "runs"); }
|
|
104
112
|
|
|
@@ -395,7 +403,7 @@ export function lastLocalRun(dir) {
|
|
|
395
403
|
* run's lease, wait (bounded by the lease TTL) until it
|
|
396
404
|
* expires or its same-host pid is dead, then continue.
|
|
397
405
|
* Without it, a held lease exits `deferred` (code 75).
|
|
398
|
-
* --path <full|lean|auto>
|
|
406
|
+
* --path <core|full|lean|auto> Engine path; overrides forge.yaml engine.path. Default core (lean work-on); lean/auto are aliases; full is the legacy multi-phase pipeline.
|
|
399
407
|
* --retry Optional (forge#3511). Reopen a run that ended `engine-error`
|
|
400
408
|
* (e.g. a session-limit hit) and resume from its last committed
|
|
401
409
|
* phase. A run that ended `phase-complete` (multi-phase issue, phase merged,
|
|
@@ -419,9 +427,9 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
419
427
|
const pathFlag = flag(argv, "--path");
|
|
420
428
|
if (argv.includes("--path") && (!pathFlag || pathFlag.startsWith("--"))) throw new Error(`--path requires a value: ${ENGINE_PATHS.join("|")}`);
|
|
421
429
|
if (pathFlag && !ENGINE_PATHS.includes(pathFlag)) throw new Error(`--path must be one of ${ENGINE_PATHS.join("|")}`);
|
|
422
|
-
const enginePath = pathFlag ?? readEnginePath() ?? "
|
|
430
|
+
const enginePath = pathFlag ?? readEnginePath() ?? "core";
|
|
423
431
|
if (enginePath !== "full" && (retry || waitLease)) {
|
|
424
|
-
throw new Error("--retry and --wait-lease are not supported with the
|
|
432
|
+
throw new Error("--retry and --wait-lease are not supported with the core path (a rerun resumes from the open PR or remote branch automatically); use --path full for them");
|
|
425
433
|
}
|
|
426
434
|
const io = deps.io ?? makeIo();
|
|
427
435
|
await assertRepoMatchesCwd(io, repo);
|
|
@@ -451,20 +459,17 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
451
459
|
else if (e.event === "phase_paused") console.log(`⏸ phase ${e.phase} paused: ${e.detail ?? "session limit"}`);
|
|
452
460
|
};
|
|
453
461
|
if (enginePath !== "full") {
|
|
454
|
-
//
|
|
462
|
+
// Core work-on (lean): one builder session, one reviewer, GitHub as the register.
|
|
455
463
|
const leanRepo = repo ?? (await resolveDefaultRepo(io));
|
|
456
|
-
if (!leanRepo) throw new Error("
|
|
457
|
-
const {
|
|
464
|
+
if (!leanRepo) throw new Error("core path needs the repo: pass --repo or run inside a checkout with a GitHub remote");
|
|
465
|
+
const { runCore } = deps.leanModule ?? (await import("./lean/lean.mjs"));
|
|
458
466
|
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo: leanRepo });
|
|
459
|
-
const lres = await
|
|
467
|
+
const lres = await runCore({ issue, repo: leanRepo, lane, cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
|
|
460
468
|
emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
|
|
461
|
-
...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
return lres;
|
|
466
|
-
}
|
|
467
|
-
console.log(`issue #${issue} → routing to the full pipeline (${lres.detail})`);
|
|
469
|
+
...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
|
|
470
|
+
console.log(`issue #${issue} → ${lres.terminalReason}`);
|
|
471
|
+
if (!["merged", "invalid"].includes(lres.terminalReason)) console.log(lres.detail ?? "");
|
|
472
|
+
return lres;
|
|
468
473
|
}
|
|
469
474
|
const res = await runIssueFn({ issue, dir, agentId, lane, io,
|
|
470
475
|
...(repo ? { repo } : {}),
|
|
@@ -623,3 +628,39 @@ export async function resumeStalledFromCli(argv, deps = {}) {
|
|
|
623
628
|
}
|
|
624
629
|
|
|
625
630
|
function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : null; }
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* `forgedock orchestrate <issue...> [--parallel K] [--lane L] [--repo owner/repo]`
|
|
634
|
+
* Runs core work-on per issue with K concurrency (default 3), honouring `**Depends on**: #X` among the given issues.
|
|
635
|
+
* Prints a compact summary table. Lane defaults to staging.
|
|
636
|
+
*/
|
|
637
|
+
export async function orchestrateFromCli(argv, deps = {}) {
|
|
638
|
+
const valued = ["--parallel", "--lane", "--repo", "--model"];
|
|
639
|
+
const issues = [];
|
|
640
|
+
for (let i = 0; i < argv.length; i++) {
|
|
641
|
+
if (valued.includes(argv[i])) { i++; continue; }
|
|
642
|
+
if (/^#?\d+$/.test(argv[i])) issues.push(parseInt(argv[i].replace("#", ""), 10));
|
|
643
|
+
}
|
|
644
|
+
if (!issues.length) throw new Error("usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--repo owner/repo]");
|
|
645
|
+
const parallel = flag(argv, "--parallel") == null ? 3 : parseInt(flag(argv, "--parallel"), 10);
|
|
646
|
+
if (!Number.isInteger(parallel) || parallel < 1) throw new Error("--parallel must be a positive integer");
|
|
647
|
+
const lane = flag(argv, "--lane") ?? "staging";
|
|
648
|
+
const model = flag(argv, "--model");
|
|
649
|
+
const io = deps.io ?? makeIo();
|
|
650
|
+
const repo = flag(argv, "--repo") ?? (await resolveDefaultRepo(io));
|
|
651
|
+
if (!repo) throw new Error("orchestrate needs the repo: pass --repo or run inside a checkout with a GitHub remote");
|
|
652
|
+
const dir = deps.dir ?? runDir();
|
|
653
|
+
const { runCore } = deps.leanModule ?? (await import("./lean/lean.mjs"));
|
|
654
|
+
const { orchestrate, formatTable } = await import("./lean/orchestrate.mjs");
|
|
655
|
+
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
|
|
656
|
+
const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
657
|
+
const rows = await orchestrate({
|
|
658
|
+
issues, parallel,
|
|
659
|
+
readBody: async (n) => JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body"])).body,
|
|
660
|
+
runOne: (n) => runCore({ issue: n, repo, lane, cwd: process.cwd(), dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry,
|
|
661
|
+
...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) }),
|
|
662
|
+
onUpdate: (r) => console.log(`#${r.issue} → ${r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`),
|
|
663
|
+
});
|
|
664
|
+
console.log("\n" + formatTable(rows));
|
|
665
|
+
return { rows, ok: rows.every((r) => ["merged", "invalid"].includes(r.outcome)) };
|
|
666
|
+
}
|
package/bin/forgedock.mjs
CHANGED
|
@@ -3207,7 +3207,8 @@ function help() {
|
|
|
3207
3207
|
["npx forgedock disable [dir]", "Opt a directory out of ForgeDock"],
|
|
3208
3208
|
["npx forgedock status [dir]", "Show ForgeDock state for a directory"],
|
|
3209
3209
|
["npx forgedock run <cmd> [args]", "Run a command headlessly (local claude CLI or Anthropic API)"],
|
|
3210
|
-
["npx forgedock run-issue <issue> --lane <lane> [--path full
|
|
3210
|
+
["npx forgedock run-issue <issue> --lane <lane> [--path core|full] [--retry] [--wait-lease]", "Run the lean core work-on on one issue (default; --path full = legacy multi-phase pipeline) (--retry reopens an engine-error run, or a needs-human run once the label is cleared; --wait-lease waits for a held lease; exit 75 = deferred, lease held)"],
|
|
3211
|
+
["npx forgedock orchestrate <issue...> [--parallel K] [--lane L]", "Run core work-on on several issues with bounded concurrency and Depends-on ordering"],
|
|
3211
3212
|
["npx forgedock resume-stalled [--dry-run]", "Fleet stall recovery — re-dispatch expired-lease issues"],
|
|
3212
3213
|
["npx forgedock demo", "Set up a risk-free demo repo and print next steps"],
|
|
3213
3214
|
["npx forgedock labels [setup] [--repo owner/repo]", "Bootstrap ForgeDock-managed labels on a GitHub repo (idempotent)"],
|
|
@@ -3781,10 +3782,10 @@ async function watch() {
|
|
|
3781
3782
|
// The journey-routed commands render their own branded marks (hero/compact);
|
|
3782
3783
|
// splash() would double-brand them. Engine-surface commands, help, and
|
|
3783
3784
|
// unknown-command output keep the logo.
|
|
3784
|
-
const SPLASH_COMMANDS = new Set(["run", "run-issue", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
|
|
3785
|
+
const SPLASH_COMMANDS = new Set(["run", "run-issue", "orchestrate", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
|
|
3785
3786
|
const KNOWN_COMMANDS = new Set([
|
|
3786
3787
|
"install", "init", "enable", "disable", "status", "uninstall", "update",
|
|
3787
|
-
"run", "backend-check", "run-issue", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "help", "--help", "-h",
|
|
3788
|
+
"run", "backend-check", "run-issue", "orchestrate", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "help", "--help", "-h",
|
|
3788
3789
|
"version", "--version", "-v",
|
|
3789
3790
|
]);
|
|
3790
3791
|
if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
|
|
@@ -3982,6 +3983,17 @@ switch (command) {
|
|
|
3982
3983
|
}
|
|
3983
3984
|
break;
|
|
3984
3985
|
}
|
|
3986
|
+
case "orchestrate": {
|
|
3987
|
+
const { orchestrateFromCli } = await import("./engine-cli.mjs");
|
|
3988
|
+
try {
|
|
3989
|
+
const result = await orchestrateFromCli(restArgs);
|
|
3990
|
+
if (result && !result.ok) exitCode = 1;
|
|
3991
|
+
} catch (err) {
|
|
3992
|
+
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
3993
|
+
exitCode = 1;
|
|
3994
|
+
}
|
|
3995
|
+
break;
|
|
3996
|
+
}
|
|
3985
3997
|
case "resume-stalled": {
|
|
3986
3998
|
const { resumeStalledFromCli } = await import("./engine-cli.mjs");
|
|
3987
3999
|
try {
|
package/bin/lean/effects.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
-
/**
|
|
3
|
+
/** Core work-on: the real effects behind runCore's injected `io`. Argv only, no shell. */
|
|
4
4
|
import { execFile } from "node:child_process";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { readFileSync, appendFileSync, mkdirSync } from "node:fs";
|
|
@@ -13,34 +13,39 @@ import { makeProjector } from "../engine/projector.mjs";
|
|
|
13
13
|
const pexec = promisify(execFile);
|
|
14
14
|
const script = (n) => fileURLToPath(new URL(`../../scripts/${n}`, import.meta.url));
|
|
15
15
|
|
|
16
|
-
/** Claude CLI session runner: fresh session, or `--resume <id>` for the
|
|
16
|
+
/** Claude CLI session runner: fresh session, or `--resume <id>` for the builder. Resolves usage + session_id. */
|
|
17
17
|
export function makeLeanRunner({ runCli = runCliBackend } = {}) {
|
|
18
18
|
return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false }) => {
|
|
19
19
|
const bin = resolveClaudeCliBinary(cwd) || "claude";
|
|
20
20
|
const caps = getClaudeCliCapabilities(cwd);
|
|
21
21
|
const flags = [...buildLeanSessionFlags(caps), ...(readOnly ? buildReadOnlyToolFlags(caps) : [])];
|
|
22
|
-
const r = runCli({ spec: { name: `
|
|
22
|
+
const r = runCli({ spec: { name: `core-${role}` }, userMessage: prompt, systemPrompt: resume ? "" : system,
|
|
23
23
|
args: [String(issue)], cwd, bin, model, sessionFlags: flags, extraArgs: resume ? ["--resume", resume] : [],
|
|
24
24
|
logger: { log() {} } });
|
|
25
25
|
return { text: r.text, sessionId: r.sessionId, usage: r.usage, costUsd: r.costUsd, durationMs: r.durationMs, turns: r.turns, model: r.cliModel ?? "default" };
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/** A missing or unparseable CI_LOCAL_RESULT means the gate itself is broken (BROKEN); only an explicit DEGRADED passes. */
|
|
30
|
-
export function parseCiLocalResult(out) {
|
|
31
|
-
return /CI_LOCAL_RESULT:\s*(PASS|FAIL|DEGRADED)\b/.exec(String(out))?.[1] ?? "BROKEN";
|
|
32
|
-
}
|
|
33
|
-
|
|
34
29
|
export function makeLeanIo({ repo }) {
|
|
35
30
|
const base = makeIo();
|
|
36
31
|
const run = (bin) => async (args, opts = {}) => (await pexec(bin, args, { cwd: opts.cwd, maxBuffer: 100 * 1024 * 1024, timeout: 120000 })).stdout;
|
|
37
32
|
return {
|
|
38
33
|
gh: run("gh"), git: run("git"), projector: makeProjector({ gh: base.gh }), runner: makeLeanRunner(), waitCiGreen: base.waitCiGreen,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
try {
|
|
42
|
-
|
|
43
|
-
|
|
34
|
+
/** Names of the checks the base branch requires (contexts + checks). [] on 404 / no protection. */
|
|
35
|
+
async requiredChecks(lane) {
|
|
36
|
+
try {
|
|
37
|
+
const j = JSON.parse(await run("gh")(["api", `repos/${repo}/branches/${lane}/protection/required_status_checks`]));
|
|
38
|
+
return [...new Set([...(j.contexts ?? []), ...(j.checks ?? []).map((c) => c.context)].filter(Boolean))];
|
|
39
|
+
} catch { return []; }
|
|
40
|
+
},
|
|
41
|
+
/** [{name, state: success|failure|pending}] for the PR's checks. */
|
|
42
|
+
async checkStates(pr) {
|
|
43
|
+
const j = JSON.parse(await run("gh")(["pr", "view", String(pr), "-R", repo, "--json", "statusCheckRollup"]));
|
|
44
|
+
return (j.statusCheckRollup ?? []).map((c) => {
|
|
45
|
+
const v = String(c.conclusion || c.state || "").toUpperCase();
|
|
46
|
+
const state = ["SUCCESS", "NEUTRAL", "SKIPPED"].includes(v) ? "success" : ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(v) ? "failure" : "pending";
|
|
47
|
+
return { name: c.name ?? c.context, state };
|
|
48
|
+
});
|
|
44
49
|
},
|
|
45
50
|
async transitionLabel(issue, state) { await pexec("bash", [script("transition-label.sh"), String(issue), "-R", repo, state], { timeout: 60000 }); },
|
|
46
51
|
fs: {
|