forgedock 1.21.0 → 1.23.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.
@@ -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 remediationHeadStale(state, io))
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 remediationHeadStale(state, io))
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 remediationHeadStale(state, io))
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:
@@ -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
- export const ENGINE_PATHS = ["full", "lean", "auto"];
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> Optional (#4081). Engine path; overrides forge.yaml engine.path. Default full.
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() ?? "full";
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 lean path (a rerun resumes from the open PR or remote branch automatically); use --path full for them");
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
- // #4081: lean path. `auto` escalates to the full pipeline only when the change is risky AND large.
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("lean path needs the repo: pass --repo or run inside a checkout with a GitHub remote");
457
- const { runLean } = deps.leanModule ?? (await import("./lean/lean.mjs"));
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 runLean({ issue, repo: leanRepo, lane, cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
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
- if (lres.terminalReason !== "route-full") {
463
- console.log(`issue #${issue} → ${lres.terminalReason}`);
464
- if (lres.terminalReason !== "merged") console.log(formatTerminalDiagnostics(dir, issue));
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|lean|auto] [--retry] [--wait-lease]", "Drive one issue through the durable engine (--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)"],
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 {
@@ -1,6 +1,6 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
- /** Lean path (#4081): the real effects behind runLean's injected `io`. Argv only, no shell. */
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 warm builder. Resolves usage + session_id. */
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: `lean-${role}` }, userMessage: prompt, systemPrompt: resume ? "" : system,
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
- async ciLocal({ cwd, base: baseRef }) {
40
- let out = "";
41
- try { out = (await pexec("bash", [script("ci-local.sh"), "run", "--repo", cwd, "--base", baseRef], { maxBuffer: 50 * 1024 * 1024, timeout: 3600000 })).stdout; }
42
- catch (e) { out = String(e.stdout ?? "") + String(e.stderr ?? ""); }
43
- return { result: parseCiLocalResult(out), tail: out.split("\n").slice(-60).join("\n") };
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: {