forgedock 1.35.0 → 1.37.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.
Files changed (129) hide show
  1. package/README.md +78 -324
  2. package/bin/bot-token-status.mjs +26 -0
  3. package/bin/engine/events.mjs +161 -0
  4. package/bin/engine/events.test.mjs +142 -0
  5. package/bin/engine/outcome-codes.mjs +39 -1
  6. package/bin/engine/phases.mjs +65 -5
  7. package/bin/engine/telemetry-seam.mjs +5 -4
  8. package/bin/engine/telemetry-seam.test.mjs +4 -0
  9. package/bin/engine-cli.mjs +372 -46
  10. package/bin/engine.mjs +3 -1
  11. package/bin/experiment.mjs +55 -0
  12. package/bin/forgedock.mjs +108 -5
  13. package/bin/hooks/reread-nudge.mjs +15 -4
  14. package/bin/labels.json +50 -0
  15. package/bin/lean/batch.mjs +52 -6
  16. package/bin/lean/batch.test.mjs +176 -2
  17. package/bin/lean/conformance.e2e.test.mjs +1 -1
  18. package/bin/lean/conformance.mjs +136 -11
  19. package/bin/lean/conformance.test.mjs +148 -1
  20. package/bin/lean/decisions.mjs +181 -0
  21. package/bin/lean/decisions.test.mjs +71 -0
  22. package/bin/lean/effects.mjs +32 -14
  23. package/bin/lean/execution.mjs +62 -16
  24. package/bin/lean/execution.test.mjs +59 -2
  25. package/bin/lean/gatewatch.mjs +31 -0
  26. package/bin/lean/gatewatch.test.mjs +59 -0
  27. package/bin/lean/guardgate.mjs +170 -0
  28. package/bin/lean/guardgate.test.mjs +153 -0
  29. package/bin/lean/guards.mjs +243 -0
  30. package/bin/lean/guards.test.mjs +177 -0
  31. package/bin/lean/heavy.mjs +54 -8
  32. package/bin/lean/heavy.test.mjs +74 -1
  33. package/bin/lean/hostkill.mjs +1 -1
  34. package/bin/lean/i18n.mjs +65 -0
  35. package/bin/lean/i18n.test.mjs +47 -0
  36. package/bin/lean/integration.mjs +276 -0
  37. package/bin/lean/integration.test.mjs +189 -0
  38. package/bin/lean/lane.mjs +5 -2
  39. package/bin/lean/lean.mjs +169 -30
  40. package/bin/lean/lean.test.mjs +360 -24
  41. package/bin/lean/lifecycle.mjs +129 -0
  42. package/bin/lean/lifecycle.test.mjs +118 -0
  43. package/bin/lean/light.mjs +1 -1
  44. package/bin/lean/milestonesync.mjs +130 -0
  45. package/bin/lean/milestonesync.test.mjs +133 -0
  46. package/bin/lean/orchestrate.mjs +132 -20
  47. package/bin/lean/parked-sweep.mjs +45 -0
  48. package/bin/lean/parked-sweep.test.mjs +71 -0
  49. package/bin/lean/postdeploy-check.mjs +181 -0
  50. package/bin/lean/postdeploy-check.test.mjs +206 -0
  51. package/bin/lean/postdeploy-monitor.mjs +97 -0
  52. package/bin/lean/postdeploy-monitor.test.mjs +147 -0
  53. package/bin/lean/precheck.mjs +6 -4
  54. package/bin/lean/precheck.test.mjs +17 -2
  55. package/bin/lean/prefilter.mjs +106 -21
  56. package/bin/lean/prefilter.test.mjs +86 -6
  57. package/bin/lean/probe.mjs +4 -3
  58. package/bin/lean/probe.test.mjs +1 -1
  59. package/bin/lean/prompts.mjs +23 -9
  60. package/bin/lean/resume.test.mjs +1 -1
  61. package/bin/lean/retry.mjs +28 -0
  62. package/bin/lean/sandbox-provider.contract.test.mjs +36 -3
  63. package/bin/lean/sandbox-remote.mjs +11 -4
  64. package/bin/lean/schedd.mjs +77 -13
  65. package/bin/lean/schedd.test.mjs +131 -5
  66. package/bin/lean/sequence.mjs +24 -0
  67. package/bin/lean/sequence.test.mjs +31 -1
  68. package/bin/lean/signoff.mjs +66 -0
  69. package/bin/lean/signoff.test.mjs +102 -0
  70. package/bin/lean/train.mjs +177 -0
  71. package/bin/lean/train.test.mjs +113 -0
  72. package/bin/pool/cli.mjs +2 -0
  73. package/bin/pool/health.mjs +30 -0
  74. package/bin/review-context.mjs +298 -0
  75. package/bin/runner.mjs +26 -3
  76. package/bin/stats/aggregate.mjs +1 -1
  77. package/bin/stats/compounding.mjs +157 -0
  78. package/bin/stats/compounding.test.mjs +74 -0
  79. package/bin/stats/experiment.mjs +44 -0
  80. package/bin/stats/github-source.mjs +2 -1
  81. package/bin/stats/index.mjs +62 -3
  82. package/bin/stats/release.mjs +75 -0
  83. package/bin/stats/release.test.mjs +68 -0
  84. package/commands/bootstrap.md +10 -8
  85. package/commands/deploy-info.md +3 -0
  86. package/commands/issue.md +2 -0
  87. package/commands/milestone.md +43 -54
  88. package/commands/orchestrate/phase-4-execution.md +2 -0
  89. package/commands/orchestrate.md +15 -3
  90. package/commands/pipeline-status.md +20 -0
  91. package/commands/review-pr.md +1 -0
  92. package/commands/work-on/build/implement.md +1 -1
  93. package/commands/work-on/build.md +4 -2
  94. package/commands/work-on/investigate.md +3 -1
  95. package/commands/work-on/remediate.md +1 -1
  96. package/docs/CONFIG.md +120 -2
  97. package/docs/EVENTS.md +65 -0
  98. package/docs/OUTCOME-CODES.md +9 -1
  99. package/docs/STATS.md +51 -0
  100. package/docs/TELEMETRY.md +3 -1
  101. package/docs/site/install.md +89 -0
  102. package/docs/site/start-a-new-project.md +1 -1
  103. package/docs/spec/label-state-machine.md +6 -0
  104. package/package.json +1 -1
  105. package/pool/install/install-agent.sh +4 -2
  106. package/pool/install/install-router.sh +4 -2
  107. package/pool/install/install-schedd.sh +28 -0
  108. package/pool/install/unit-hardening.sh +34 -0
  109. package/scripts/acceptance-red-green.sh +19 -2
  110. package/scripts/acceptance-red-green.test.sh +2 -0
  111. package/scripts/bench-lightlane.mjs +2 -1
  112. package/scripts/bootstrap-create.mjs +3 -2
  113. package/scripts/bootstrap-validate.mjs +31 -4
  114. package/scripts/eval-gate-scorecard.mjs +7 -0
  115. package/scripts/file-deferred-followups.sh +286 -77
  116. package/scripts/file-deferred-followups.test.sh +117 -2
  117. package/scripts/hotspots.sh +23 -2
  118. package/scripts/hotspots.test.sh +9 -0
  119. package/scripts/plan-validate-live.mjs +111 -0
  120. package/scripts/readiness.mjs +141 -0
  121. package/scripts/review-prepare.sh +17 -1
  122. package/scripts/review-prepare.test.sh +17 -0
  123. package/scripts/settle-scope.mjs +74 -0
  124. package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
  125. package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
  126. package/scripts/wait-ci-green.sh +14 -3
  127. package/scripts/wait-ci-green.test.sh +3 -1
  128. package/templates/bootstrap/needs-human-issue.md +4 -1
  129. package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
package/bin/engine.mjs CHANGED
@@ -506,12 +506,14 @@ export async function runIssue(opts) {
506
506
  // declaration or an untrusted body proceeds. An unreadable body/dependency fails closed.
507
507
  {
508
508
  let dep = null;
509
- try { dep = await openDependency(issue, io); } catch (e) {
509
+ try { dep = await openDependency(issue, io, { lane }); } catch (e) {
510
510
  dep = { unreadable: true, detail: `issue ${issue} **Depends on** check failed: ${e?.message || e}` };
511
511
  }
512
512
  if (dep?.unreadable) {
513
513
  return { terminalReason: "dependency-unreadable", code: OUTCOME_CODES.DEPENDENCY_UNREADABLE, detail: dep.detail };
514
514
  }
515
+ // #4686: an open dependency whose PR is already on this lane is satisfied; name the merge commit.
516
+ for (const x of dep?.satisfied ?? []) emitProgress({ event: "dependency_satisfied", detail: `#${x.issue} merged into ${lane} by PR #${x.pr} (merge commit ${x.sha.slice(0, 12)})` });
515
517
  if (dep?.open?.length) {
516
518
  return {
517
519
  terminalReason: "dependency-open",
@@ -0,0 +1,55 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * forge#4629: live A/B experiments. forge.yaml `experiment:` section (flat keys, the parser has one nesting level):
5
+ * id: light-vs-full arms: light,full ratio: 1,1 (weights, default equal)
6
+ * metrics: usd_median,wall_p50_ms,first_pass_rate,needs_human_rate (default all)
7
+ * targets: light.usd_median<=1.2xfull,light.first_pass_rate>=0.8 min_n: 10
8
+ * The arm is a hash of (id, issue), so a re-run or resume keeps its arm. Pure; the stats report is bin/stats/experiment.mjs.
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ import { readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { parseForgeYaml } from "./forge-utils.mjs";
14
+
15
+ export const EXPERIMENT_METRICS = ["usd_median", "wall_p50_ms", "first_pass_rate", "needs_human_rate"];
16
+ export const BENCH_SCHEMA = "forge.bench.v1";
17
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
18
+ const TARGET = /^([A-Za-z0-9][A-Za-z0-9._-]*)\.([a-z_0-9]+)(<=|>=)(\d+(?:\.\d+)?)(?:x([A-Za-z0-9][A-Za-z0-9._-]*))?$/;
19
+ const list = (v) => String(v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
20
+
21
+ /** Parsed declaration {id, arms, ratio, metrics, targets, minN}, or null when absent or invalid. */
22
+ export function parseExperiment(sec, { defaultMinN = 10 } = {}) {
23
+ if (!sec || typeof sec !== "object") return null;
24
+ const id = String(sec.id ?? "").trim(), arms = list(sec.arms);
25
+ if (!NAME.test(id) || arms.length < 2 || new Set(arms).size !== arms.length || !arms.every((a) => NAME.test(a))) return null;
26
+ const ratio = sec.ratio == null || sec.ratio === "" ? arms.map(() => 1) : list(sec.ratio).map((x) => (/^[1-9]\d*$/.test(x) ? Number(x) : NaN));
27
+ if (ratio.length !== arms.length || ratio.some(Number.isNaN)) return null;
28
+ const metrics = sec.metrics ? list(sec.metrics) : [...EXPERIMENT_METRICS];
29
+ if (!metrics.length || metrics.some((m) => !EXPERIMENT_METRICS.includes(m))) return null;
30
+ const targets = [];
31
+ for (const t of list(sec.targets)) {
32
+ const m = TARGET.exec(t);
33
+ if (!m || !arms.includes(m[1]) || !metrics.includes(m[2]) || (m[5] && !arms.includes(m[5]))) return null;
34
+ targets.push({ arm: m[1], metric: m[2], op: m[3], limit: Number(m[4]), vs: m[5] ?? null, text: t });
35
+ }
36
+ const mn = String(sec.min_n ?? "").trim();
37
+ if (mn && !/^\d+$/.test(mn)) return null;
38
+ return { id, arms, ratio, metrics, targets, minN: mn ? Number(mn) : defaultMinN };
39
+ }
40
+
41
+ /** forge.yaml `experiment:` of `cwd`, or null. */
42
+ export function readExperiment(cwd = process.cwd()) {
43
+ try { return parseExperiment(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.experiment); } catch { return null; }
44
+ }
45
+
46
+ /** Deterministic arm of an issue: sha1(id:issue) mod the ratio total, walked over the arms in declared order. */
47
+ export function assignArm(exp, issue) {
48
+ const total = exp.ratio.reduce((a, b) => a + b, 0);
49
+ let pick = createHash("sha1").update(`${exp.id}:${issue}`).digest().readUInt32BE(0) % total;
50
+ for (let i = 0; i < exp.arms.length; i++) { if (pick < exp.ratio[i]) return exp.arms[i]; pick -= exp.ratio[i]; }
51
+ return exp.arms[0];
52
+ }
53
+
54
+ /** The shared result record of the bench harness, the eval gate and `stats --experiment`: targets are {pass: true|false|null, value, limit}. */
55
+ export const benchRecord = ({ source, arms = {}, targets = {} }) => ({ schema: BENCH_SCHEMA, source, arms, targets });
package/bin/forgedock.mjs CHANGED
@@ -86,6 +86,8 @@ import {
86
86
  CYAN,
87
87
  } from "./tui.mjs";
88
88
  import { buildMinimalForgeYaml } from "./init-detect.mjs";
89
+ import { checkContext, refreshContextFlow } from "./review-context.mjs";
90
+ import { botTokenStatus } from "./bot-token-status.mjs";
89
91
  import {
90
92
  parseNameStatusDiff,
91
93
  classifyCommandChanges,
@@ -234,7 +236,7 @@ const SCRIPTS_DIR = join(FORGE_HOME, "scripts");
234
236
  // --minimal is init-only. Subcommands with their own flag parsing (run,
235
237
  // run-issue, demo) receive restArgs — everything after the command token.
236
238
  const rawArgs = process.argv.slice(2);
237
- const FLAGS = new Set(["--fast", "--manual", "--verbose", "--minimal", "--extras"]);
239
+ const FLAGS = new Set(["--fast", "--manual", "--verbose", "--minimal", "--extras", "--refresh-context"]);
238
240
  const flags = rawArgs.filter((a) => FLAGS.has(a));
239
241
  // --global is stripped from positionals/restArgs for backward compatibility
240
242
  // with existing invocations, but is otherwise a no-op — see the install-mode
@@ -1042,6 +1044,13 @@ function ensureToolStateIgnored(c) {
1042
1044
 
1043
1045
  async function initFlow(c) {
1044
1046
  const outputPath = join(c.cwd, "forge.yaml");
1047
+ if (flags.includes("--refresh-context")) {
1048
+ return refreshContextFlow({
1049
+ cwd: c.cwd,
1050
+ out: c.stdout,
1051
+ deps: { interactive: process.stdin.isTTY === true, confirm, backup: backupExisting, write: atomicWriteFile },
1052
+ });
1053
+ }
1045
1054
  const hasExisting = existsSync(outputPath);
1046
1055
  if (hasExisting && process.stdin.isTTY !== true) {
1047
1056
  const dim = (s) => (c.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
@@ -2704,6 +2713,15 @@ async function doctor(fix = false) {
2704
2713
  );
2705
2714
  }
2706
2715
 
2716
+ try {
2717
+ const ctxCheck = checkContext({ configPath: forgeYamlPath, root: process.cwd(), repo: forgeOwner && forgeRepo ? `${forgeOwner}/${forgeRepo}` : "" });
2718
+ if (ctxCheck.stale) {
2719
+ warn("review context", `REVIEW_CONTEXT_STALE: ${ctxCheck.reasons.map((r) => r.detail).join("; ")}. Run: npx forgedock init --refresh-context`);
2720
+ } else if (ctxCheck.advisory) {
2721
+ warn("review context", `REVIEW_CONTEXT_STALE: generated ${ctxCheck.generated_at}, ${ctxCheck.merged_since} PRs merged since (limit ${ctxCheck.max_merged_prs}). Run: npx forgedock init --refresh-context`);
2722
+ }
2723
+ } catch { /* advisory check: never fail doctor */ }
2724
+
2707
2725
  const defaultMatch = content.match(/^\s+default:\s+"([^"]*)"/m);
2708
2726
  const stagingMatch = content.match(/^\s+staging:\s+"([^"]*)"/m);
2709
2727
  if (
@@ -3243,14 +3261,20 @@ async function doctor(fix = false) {
3243
3261
  }
3244
3262
 
3245
3263
  // ── Check 12: GitHub App / bot token status ─────────────────────────────────
3246
- // Informational only — never a hard failure. Installing the ForgeDock GitHub
3264
+ // Informational without a refresh stamp (#4662: with one, a stale or expiring token fails). Installing the ForgeDock GitHub
3247
3265
  // App registers it against the account/org but does not, by itself, mint a
3248
3266
  // bot token: that requires the app's private key, which only the app owner
3249
3267
  // holds. Pipeline gh calls always use whatever `gh auth` context is active
3250
3268
  // locally (personal token unless the operator manually configured a bot
3251
3269
  // token) — this check exists so that state is surfaced instead of silent.
3252
3270
  // See docs/CONFIG.md "GitHub App Install" and forge#1890.
3253
- {
3271
+ const botTok = botTokenStatus(); // #4662: lifetime from the refresh stamp; no stamp means no refresher is installed
3272
+ if (botTok.configured) {
3273
+ const min = (ms) => Math.round(ms / 60000);
3274
+ const detail = botTok.remainingMs != null ? `${min(botTok.remainingMs)} min left, refreshed ${min(botTok.ageMs)} min ago (${botTok.refreshedAt})` : botTok.reason;
3275
+ if (botTok.ok) pass("GitHub App / bot token", detail);
3276
+ else fail(`GitHub App / bot token: ${botTok.reason} (${detail})`, "run scripts/refresh-bot-token.sh and install the timer: docs/CONFIG.md \"Bot token refresh\"");
3277
+ } else {
3254
3278
  warn(
3255
3279
  "GitHub App / bot token",
3256
3280
  "pipeline commands use your active `gh auth` (personal token, unless you've manually configured a bot token). Installing the GitHub App alone does not create one — see docs/CONFIG.md \"GitHub App Install\".",
@@ -3277,6 +3301,19 @@ async function doctor(fix = false) {
3277
3301
  }
3278
3302
  }
3279
3303
 
3304
+ // ── Check 14: Router/schedd restarts (#4673) ───────────────────────────────
3305
+ // A restart in the last hour usually means an OOM killer or SIGTERM took the coordinator and dropped in-flight lanes. Warn only.
3306
+ {
3307
+ try {
3308
+ const { restartCheck } = await import("./pool/health.mjs");
3309
+ for (const unit of ["computeshare-router", "forgedock-schedd"]) {
3310
+ const r = restartCheck({ unit });
3311
+ if (r.status === "warn") warn(`Restarts: ${unit}`, r.detail);
3312
+ else if (r.status === "pass") pass(`Restarts: ${unit}`, r.detail);
3313
+ }
3314
+ } catch { /* no systemd here: nothing to report */ }
3315
+ }
3316
+
3280
3317
  // ── Summary ────────────────────────────────────────────────────────────────
3281
3318
  console.log("");
3282
3319
  if (fix && fixesApplied > 0) {
@@ -3314,12 +3351,16 @@ function help() {
3314
3351
  ["npx forgedock run <cmd> [args]", "Run a command headlessly (local claude CLI or Anthropic API)"],
3315
3352
  ["npx forgedock bootstrap <idea|docs> [--dry-run] [--spike <path>] | --plan <plan.json> [--dry-run]", "Headless /bootstrap: idea to orchestrate-ready backlog (--dry-run previews milestones, titles, DAG and estimated agent cost without GitHub writes); --plan validates and creates (or previews) a plan, and re-running resumes from GitHub"],
3316
3353
  ["npx forgedock run-issue <issue> [--lane <lane>] [--lane-override] [--path core|full] [--retry] [--wait-lease]", "Run the lean core work-on on one issue (default; --path full = legacy multi-phase pipeline; the lane comes from the issue: milestone → milestone/<slug>, else staging; --lane is an assertion, never the default branch) (--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)"],
3354
+ ["npx forgedock plan validate --live [--repo owner/repo] [--json]", "Re-run the bootstrap dependency-order rules on live GitHub state: each cross-milestone Depends-on edge is ok, held or invalid (exit 1 on invalid)"],
3317
3355
  ["npx forgedock orchestrate <issue...> [--parallel K] [--lane L]", "Run core work-on on several issues with bounded concurrency and Depends-on ordering; each issue on its own lane, cross-lane dependencies reported"],
3318
- ["npx forgedock orchestrate --resume <id> [N...] | --set-parallel <id> K | --add <id> N... | --cancel <id> | --hold <id> N... | --release <id> N...", "Reattach to a batch hosted by the schedd daemon (a finished one replays its report, an orphaned one is resumed with live lanes re-adopted), change its concurrency live, add issues to a running (or held) batch under its K and DAG, or stop one batch without stopping schedd (running lanes torn down with their work saved, the rest held for --resume), or hold/release single issues of a batch (a held issue is not admitted until released or named by --resume)"],
3356
+ ["npx forgedock orchestrate --resume <id> [N...] | --set-parallel <id> K | --add <id> N... | --requeue <id> N... [--reason \"...\"] | --cancel <id> | --hold <id> N... | --release <id> N... | --show-env <id> | --set-env <id> KEY=VAL | --unset-env <id> KEY", "Reattach to a batch hosted by the schedd daemon (a finished one replays its report, an orphaned one is resumed with live lanes re-adopted), change its concurrency live, add issues to a running (or held) batch under its K and DAG, requeue a terminal issue (and the dependents skipped behind it) after an operator decision (refused while it is labelled needs-human or closed; a finished batch is reopened), or stop one batch without stopping schedd (running lanes torn down with their work saved, the rest held for --resume), or hold/release single issues of a batch (a held issue is not admitted until released or named by --resume), or print (secrets redacted), set or unset a batch's pinned lane env (applies to newly launched lanes)"],
3319
3357
  ["npx forgedock triage <issue...> [--lane L] [--dry-run]", "Already-fixed pre-filter: close issues a merged PR or green CI already resolved; print the rest"],
3320
3358
  ["npx forgedock probe [issue...] [--dry-run]", "Run declared acceptance probes for closed issues whose fix is on the default branch; a fail reopens the issue"],
3321
3359
  ["npx forgedock check [--base REF] [--json]", "Run the lane's frozen validate commands and change-scoped pre-PR checks locally in this worktree, as the engine will (no GitHub, no Actions)"],
3360
+ ["npx forgedock milestone train <slug...> [--parallel-milestones N] | --resume <id> | --insert <id> <slug> [--before s|--after s] | --show <id>", "Unattended multi-milestone train: one durable state file (order, active set, lifecycle), typed STOP reasons, resumable; never edits forge.yaml"],
3361
+ ["npx forgedock guards list [--path p] [--json] | backfill --since <date> [--dry-run]", "Fix guards: list the FORGE:GUARD records (test files + protected hunks of merged fixes) covering a path, or build them for already-merged lean lanes"],
3322
3362
  ["npx forgedock limits show | clear <id>|--all", "Show or clear per-account usage-limit holds shared by orchestrate batches"],
3363
+ ["npx forgedock decisions list [--stale] [--refresh]", "List anchored decisions (FORGE:DECISION); --stale shows those with an anchor missing on the base"],
3323
3364
  ["npx forgedock sched show | cap <N>|--clear | weight <owner/repo> <N>|--clear", "Show the host lane registry, set the host-wide lane cap shared by orchestrate batches, or set a repo's share of slots within a priority (default equal)"],
3324
3365
  ["npx forgedock schedd [status]", "Host scheduler daemon hosting every orchestrate batch (auto-spawned by orchestrate; run in the foreground for a systemd-user unit); status shows its pid and batches"],
3325
3366
  ["npx forgedock pool router [install] | enroll [--cpus N --mem GB] | status | upgrade [agent] | remove <agent>", "Built-in remote compute pool: run the router (foreground, or install it as a user service), print the one-time install command for a host, list agents, upgrade or remove them"],
@@ -3350,6 +3391,7 @@ function help() {
3350
3391
  ["--manual", "Plain text prompts instead of the review screen (init)"],
3351
3392
  ["--verbose", "Show detection sources for every field (init)"],
3352
3393
  ["--minimal", "Generate a minimal forge.yaml with required sections only (init)"],
3394
+ ["--refresh-context", "Regenerate review.context/tech_stack from manifests and show a diff for approval (init)"],
3353
3395
  ["--version, -v", "Print the installed version and check for updates"],
3354
3396
  ];
3355
3397
 
@@ -3903,7 +3945,7 @@ async function watch() {
3903
3945
  const SPLASH_COMMANDS = new Set(["run", "bootstrap", "run-issue", "orchestrate", "triage", "probe", "gc", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
3904
3946
  const KNOWN_COMMANDS = new Set([
3905
3947
  "install", "init", "enable", "disable", "status", "uninstall", "update",
3906
- "run", "bootstrap", "backend-check", "run-issue", "orchestrate", "triage", "probe", "review-staging", "gc", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "limits", "sched", "schedd", "pool", "check", "help", "--help", "-h",
3948
+ "run", "bootstrap", "backend-check", "run-issue", "orchestrate", "milestone", "milestone-sync", "guards", "triage", "probe", "review-staging", "gc", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "limits", "decisions", "sched", "schedd", "pool", "check", "help", "--help", "-h",
3907
3949
  "version", "--version", "-v",
3908
3950
  ]);
3909
3951
  if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
@@ -4060,6 +4102,12 @@ switch (command) {
4060
4102
  case "run":
4061
4103
  await run();
4062
4104
  break;
4105
+ case "plan": {
4106
+ // #4659: bootstrap-validate's dependency rules on live GitHub state
4107
+ const { runLive } = await import("../scripts/plan-validate-live.mjs");
4108
+ exitCode = await runLive(restArgs);
4109
+ break;
4110
+ }
4063
4111
  case "bootstrap": {
4064
4112
  // #4177: headless /bootstrap; --plan runs scripts/bootstrap-create.mjs directly (no LLM), a re-run resumes.
4065
4113
  const { runBootstrap } = await import("./bootstrap.mjs");
@@ -4136,6 +4184,17 @@ switch (command) {
4136
4184
  }
4137
4185
  break;
4138
4186
  }
4187
+ case "decisions": {
4188
+ // #4695: anchored decisions of the local index
4189
+ const { decisionsFromCli } = await import("./engine-cli.mjs");
4190
+ try {
4191
+ await decisionsFromCli(restArgs);
4192
+ } catch (err) {
4193
+ process.stderr.write(`${RED}${err.message}${RESET}\n`);
4194
+ exitCode = 1;
4195
+ }
4196
+ break;
4197
+ }
4139
4198
  case "check": {
4140
4199
  // #4364: the builder's in-session run of the engine's validate and pre-PR checks; local only
4141
4200
  const { checkFromCli } = await import("./engine-cli.mjs");
@@ -4199,6 +4258,50 @@ switch (command) {
4199
4258
  }
4200
4259
  break;
4201
4260
  }
4261
+ case "milestone-sync": {
4262
+ // On-demand milestone branch sync (#4663): PR + auto-merge under protected branches, one debounced sync PR.
4263
+ const { milestoneSyncFromCli } = await import("./engine-cli.mjs");
4264
+ try {
4265
+ exitCode = await milestoneSyncFromCli(restArgs);
4266
+ } catch (err) {
4267
+ process.stderr.write(`${RED}${err.message}${RESET}\n`);
4268
+ exitCode = 1;
4269
+ }
4270
+ break;
4271
+ }
4272
+ case "milestone": {
4273
+ // Native unattended milestone train (#4658): `forgedock milestone train <slug...>`.
4274
+ const { trainFromCli } = await import("./engine-cli.mjs");
4275
+ try {
4276
+ if (restArgs[0] !== "train") throw new Error("usage: forgedock milestone train <slug...> [--parallel-milestones N] | --resume <id> | --insert <id> <slug> | --show <id>");
4277
+ const result = await trainFromCli(restArgs.slice(1));
4278
+ if (result && !result.ok) exitCode = 1;
4279
+ } catch (err) {
4280
+ process.stderr.write(`${RED}${err.message}${RESET}\n`);
4281
+ exitCode = 1;
4282
+ }
4283
+ break;
4284
+ }
4285
+ case "guards": {
4286
+ // Fix guards (#4692): `forgedock guards list [--path p]` | `guards backfill --since <date>`.
4287
+ const { guardsFromCli } = await import("./engine-cli.mjs");
4288
+ try { exitCode = await guardsFromCli(restArgs); } catch (err) {
4289
+ process.stderr.write(`${RED}${err.message}${RESET}\n`);
4290
+ exitCode = 1;
4291
+ }
4292
+ break;
4293
+ }
4294
+ case "integration": {
4295
+ // Milestone integration runs (#4667): `forgedock integration run` for cron or a scheduled workflow.
4296
+ const { integrationFromCli } = await import("./engine-cli.mjs");
4297
+ try {
4298
+ exitCode = await integrationFromCli(restArgs);
4299
+ } catch (err) {
4300
+ process.stderr.write(`${RED}${err.message}${RESET}\n`);
4301
+ exitCode = 1;
4302
+ }
4303
+ break;
4304
+ }
4202
4305
  case "probe": {
4203
4306
  const { probeFromCli } = await import("./engine-cli.mjs");
4204
4307
  try {
@@ -9,6 +9,8 @@
9
9
  * #4580: the same script is also a PreToolUse guard on Read: a full-file Read (no offset/limit) of a path already read
10
10
  * REREAD_MIN-1 times whose mtime is unchanged since its last read is denied with a reason that points at notes or a line
11
11
  * range. Ranged reads and Bash never deny, and any error allows. A changed mtime resets the path's count.
12
+ * #4622: the guard counts only full-file Reads ({path: count} in <session_id>.full.json), so ranged Reads and Bash samples never
13
+ * make a first full Read look like a re-read; the nudge still counts every read.
12
14
  * State: {path: count} in <tmpdir>/forgedock-reread/<session_id>.json plus {path: mtimeMs} in <session_id>.mtime.json (per-run caches).
13
15
  */
14
16
  import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
@@ -44,7 +46,13 @@ export const stateFile = (sessionId, dir = join(tmpdir(), "forgedock-reread")) =
44
46
  const mtimeOf = (p) => { try { return statSync(p).mtimeMs; } catch { return null; } };
45
47
  const loadJson = (file) => { try { const v = JSON.parse(readFileSync(file, "utf8")); return v && typeof v === "object" ? v : {}; } catch { return {}; } };
46
48
 
47
- /** Pure core of the guard: the deny reason for a payload, or null. `mtimeNow(path)` is the file's current mtime. */
49
+ /** Paths a payload reads whole: a Read with no offset/limit (what the guard counts, #4622). */
50
+ export function fullReads(payload) {
51
+ const inp = payload?.tool_input ?? {};
52
+ return payload?.tool_name === "Read" && inp.offset == null && inp.limit == null ? payloadReads(payload) : [];
53
+ }
54
+
55
+ /** Pure core of the guard: the deny reason for a payload, or null. `counts` are full-read counts; `mtimeNow(path)` is the file's current mtime. */
48
56
  export function guardFor(payload, counts = {}, mtimes = {}, mtimeNow = mtimeOf) {
49
57
  const inp = payload?.tool_input ?? {};
50
58
  if (payload?.tool_name !== "Read" || inp.offset != null || inp.limit != null) return null;
@@ -64,20 +72,23 @@ export function main(input, { dir } = {}) {
64
72
  if (!payload?.session_id) return "";
65
73
  if (!payloadReads(payload).length) return "";
66
74
  const file = stateFile(payload.session_id, dir), mfile = file.replace(/\.json$/, ".mtime.json");
67
- const counts = loadJson(file), mtimes = loadJson(mfile);
75
+ const ffile = file.replace(/\.json$/, ".full.json");
76
+ const counts = loadJson(file), mtimes = loadJson(mfile), full = loadJson(ffile);
68
77
  if (payload.hook_event_name === "PreToolUse") {
69
- const reason = guardFor(payload, counts, mtimes);
78
+ const reason = guardFor(payload, full, mtimes);
70
79
  return reason ? `${JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } })}\n` : "";
71
80
  }
72
81
  for (const p of payloadReads(payload)) {
73
82
  const now = mtimeOf(p);
74
- if (now != null && mtimes[p] !== now) delete counts[p]; // the file changed (or is new): earlier reads no longer count
83
+ if (now != null && mtimes[p] !== now) { delete counts[p]; delete full[p]; } // the file changed (or is new): earlier reads no longer count
75
84
  if (now != null) mtimes[p] = now;
76
85
  }
86
+ for (const p of fullReads(payload)) full[p] = (full[p] ?? 0) + 1;
77
87
  const r = nudgeFor(payload, counts);
78
88
  mkdirSync(dirname(file), { recursive: true });
79
89
  writeFileSync(file, JSON.stringify(r.counts));
80
90
  writeFileSync(mfile, JSON.stringify(mtimes));
91
+ writeFileSync(ffile, JSON.stringify(full));
81
92
  return r.message ? `${JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: r.message } })}\n` : "";
82
93
  } catch {
83
94
  return "";
package/bin/labels.json CHANGED
@@ -69,11 +69,31 @@
69
69
  "color": "E4E669",
70
70
  "description": "Pipeline stalled — requires human intervention. Managed by ForgeDock."
71
71
  },
72
+ {
73
+ "name": "needs-human:external",
74
+ "color": "E4E669",
75
+ "description": "Waiting on an external prerequisite (credential, account, sign-off). Comment names the owner and unblock condition. Managed by ForgeDock."
76
+ },
77
+ {
78
+ "name": "parked",
79
+ "color": "FBCA04",
80
+ "description": "Mechanical park on a terminal code; per-code labels are parked:<code>. Managed by ForgeDock."
81
+ },
82
+ {
83
+ "name": "remediation-queue",
84
+ "color": "FBCA04",
85
+ "description": "Parked issue awaiting /work-on:remediate. Managed by ForgeDock."
86
+ },
72
87
  {
73
88
  "name": "workflow:engine-error",
74
89
  "color": "B60205",
75
90
  "description": "Pipeline stalled — engine/tool failure (not a genuine human-judgment block). Managed by ForgeDock."
76
91
  },
92
+ {
93
+ "name": "awaiting-due",
94
+ "color": "C5DEF5",
95
+ "description": "Post-deploy follow-up parked until its due date; re-queued by the engine. Managed by ForgeDock."
96
+ },
77
97
  {
78
98
  "name": "awaiting-promotion",
79
99
  "color": "C5DEF5",
@@ -183,5 +203,35 @@
183
203
  "name": "performance",
184
204
  "color": "F9D0C4",
185
205
  "description": "Performance optimization or regression. Managed by ForgeDock."
206
+ },
207
+ {
208
+ "name": "integration-red",
209
+ "color": "D93F0B",
210
+ "description": "Scheduled full-suite integration run on a milestone branch is red. Managed by ForgeDock."
211
+ },
212
+ {
213
+ "name": "followup:engineering",
214
+ "color": "BFD4F2",
215
+ "description": "Deferred buildable engineering work. Managed by ForgeDock."
216
+ },
217
+ {
218
+ "name": "followup:post-deploy",
219
+ "color": "BFD4F2",
220
+ "description": "Deferred post-deploy checks. Managed by ForgeDock."
221
+ },
222
+ {
223
+ "name": "followup:external",
224
+ "color": "BFD4F2",
225
+ "description": "Deferred items blocked on something outside the repo (credentials, third parties). Managed by ForgeDock."
226
+ },
227
+ {
228
+ "name": "followup:ops",
229
+ "color": "BFD4F2",
230
+ "description": "Deferred ops work (drills, runbooks). Managed by ForgeDock."
231
+ },
232
+ {
233
+ "name": "external-prerequisite",
234
+ "color": "E99695",
235
+ "description": "Follow-up that waits on an external prerequisite. Managed by ForgeDock."
186
236
  }
187
237
  ]
@@ -6,6 +6,7 @@
6
6
  * A batch lives in `~/.forge/batches/<id>/` (or `FORGEDOCK_BATCH_DIR`): `plan.json` (the command), `state.json` (the
7
7
  * scheduler state, rewritten after every transition), `log.jsonl` (one line per lane update), `parallel` (live K),
8
8
  * `add` (issues added to the batch by `--add`, #4408: a cumulative JSON set the runner re-reads every pass),
9
+ * `requeue` (`--requeue`, #4550: a cumulative JSON list of `{issue, reason, by, at}` the runner applies once each, moving a terminal issue back to pending),
9
10
  * `hold` (issues held by `--hold`, #4592: a cumulative JSON set; the runner admits none of them until `--release` or a `--resume` names them),
10
11
  * `cancel` (`{at, reason}` written by `--cancel`, #4376: the runner stops admitting and stops its local lanes; a resume clears it),
11
12
  * `lanes/<issue>.{json,log}` (each lane's result and output) and `report.json` (the final rows and printed report, written
@@ -69,6 +70,16 @@ const readAdded = (file) => {
69
70
  return Array.isArray(v) ? [...new Set(v.filter((n) => Number.isSafeInteger(n) && n > 0))].sort((a, b) => a - b) : [];
70
71
  };
71
72
 
73
+ /** `outcome`s `--requeue` may move back to pending (#4550). */
74
+ export const REQUEUABLE = (outcome) => ["needs-human", "invalid", "skipped", "ci-pending", "budget-exceeded"].includes(outcome) || /^dependency-/.test(String(outcome));
75
+ /** Key of one requeue entry; the runner records the applied keys in state.json `requeueApplied`. */
76
+ export const requeueKey = (e) => `${e.issue}@${e.at}`;
77
+
78
+ const readRequeueFile = (file) => {
79
+ const v = readJson(file);
80
+ return Array.isArray(v) ? v.filter((e) => e && Number.isSafeInteger(e.issue) && e.issue > 0 && typeof e.at === "string") : [];
81
+ };
82
+
72
83
  /** The positive integers among `list` (numbers, `7`, `#7`). */
73
84
  const ints = (list) => (Array.isArray(list) ? list : []).map((x) => (typeof x === "number" ? x : typeof x === "string" && /^#?\d+$/.test(x.trim()) ? parseInt(x.trim().replace("#", ""), 10) : NaN)).filter((n) => Number.isSafeInteger(n) && n > 0);
74
85
 
@@ -88,7 +99,7 @@ export function openBatch(root, id) {
88
99
  if (!ID_RE.test(String(id ?? ""))) throw new Error(`invalid batch id ${JSON.stringify(id)}`);
89
100
  const dir = join(root, id);
90
101
  const f = (name) => join(dir, name);
91
- return {
102
+ const self = {
92
103
  id, dir,
93
104
  exists: () => existsSync(f("plan.json")),
94
105
  readPlan: () => readJson(f("plan.json")),
@@ -111,19 +122,53 @@ export function openBatch(root, id) {
111
122
  * `order`, in the file or earlier in `list`: skipped `already in batch`. @returns {{added:number[], skipped:Array<{issue, reason}>}}
112
123
  */
113
124
  addIssues: (list) => {
114
- const have = new Set([...(readJson(f("state.json"))?.order ?? []), ...readAdded(f("add"))]);
115
- const added = [], skipped = [];
125
+ const st = readJson(f("state.json")) ?? {};
126
+ const have = new Set([...(st.order ?? []), ...readAdded(f("add"))]);
127
+ const added = [], skipped = [], requeued = [];
116
128
  for (const x of Array.isArray(list) ? list : []) {
117
129
  const n = typeof x === "number" ? x : typeof x === "string" && /^#?\d+$/.test(x.trim()) ? parseInt(x.trim().replace("#", ""), 10) : NaN;
118
130
  if (!Number.isSafeInteger(n) || n < 1) { skipped.push({ issue: x, reason: "not an integer" }); continue; }
131
+ // #4687: an issue skipped in the batch (a dependency ended) is re-queued, not refused; any other row is "already in batch"
132
+ if (have.has(n) && st.results?.[n]?.outcome === "skipped" && !requeued.includes(n) && self.requeue([n], { reason: "--add", by: "operator" }).requeued.length) { requeued.push(n); continue; }
119
133
  if (have.has(n)) { skipped.push({ issue: n, reason: "already in batch" }); continue; }
120
134
  have.add(n); added.push(n);
121
135
  }
122
136
  if (added.length) writeAtomic(f("add"), JSON.stringify([...new Set([...readAdded(f("add")), ...added])].sort((a, b) => a - b)) + "\n");
123
- return { added, skipped };
137
+ return { added, skipped, requeued };
138
+ },
139
+ /**
140
+ * `--requeue` (#4550): record that each issue returns to pending. Refused: not a positive integer, not in the batch's
141
+ * `order`, no terminal result yet (`not terminal`), or already requeued and not yet applied. The needs-human label and
142
+ * closed checks need GitHub and live in the CLI. @returns {{requeued:number[], refused:Array<{issue, reason}>}}
143
+ */
144
+ requeue: (list, { reason = "operator decision", by = "operator" } = {}) => {
145
+ const st = readJson(f("state.json")) ?? {};
146
+ const order = new Set(st.order ?? []), applied = new Set(st.requeueApplied ?? []);
147
+ const entries = readRequeueFile(f("requeue"));
148
+ const pend = new Set(entries.filter((e) => !applied.has(requeueKey(e))).map((e) => e.issue));
149
+ const requeued = [], refused = [];
150
+ for (const x of Array.isArray(list) ? list : []) {
151
+ const n = typeof x === "number" ? x : typeof x === "string" && /^#?\d+$/.test(x.trim()) ? parseInt(x.trim().replace("#", ""), 10) : NaN;
152
+ if (!Number.isSafeInteger(n) || n < 1) refused.push({ issue: x, reason: "not an integer" });
153
+ else if (!order.has(n)) refused.push({ issue: n, reason: "not in batch" });
154
+ else if (pend.has(n)) refused.push({ issue: n, reason: "already requeued" });
155
+ else if (!REQUEUABLE(st.results?.[n]?.outcome)) refused.push({ issue: n, reason: "not terminal" });
156
+ else { pend.add(n); requeued.push(n); }
157
+ }
158
+ if (requeued.length) {
159
+ const at = new Date().toISOString();
160
+ writeAtomic(f("requeue"), JSON.stringify([...entries, ...requeued.map((issue) => ({ issue, reason: String(reason), by: String(by), at }))]) + "\n");
161
+ }
162
+ return { requeued, refused };
124
163
  },
164
+ /** Every `--requeue` entry, in order; [] when missing or invalid. */
165
+ readRequeued: () => readRequeueFile(f("requeue")),
166
+ /** Entries the runner has not applied yet (a finished or unhosted batch with a pending requeue). */
167
+ unrunRequeues: () => { const done = new Set(readJson(f("state.json"))?.requeueApplied ?? []); return readRequeueFile(f("requeue")).filter((e) => !done.has(requeueKey(e))); },
125
168
  /** Issues held by `--hold` (#4592): the `hold` file, sorted unique positive integers; [] when missing or invalid. */
126
169
  readHeld: () => readAdded(f("hold")),
170
+ /** #4656: gates waiting on issues (`gates` file: [{ name, waitsOn: [n...], since? }], written by the ship/settle pre-flight); [] when missing or invalid. */
171
+ readGates: () => { const v = readJson(f("gates")); return Array.isArray(v) ? v.filter((g) => g && typeof g.name === "string" && Array.isArray(g.waitsOn)) : []; },
127
172
  /** Merge the positive integers of `list` into the `hold` file. @returns {number[]} the issues newly held */
128
173
  holdIssues: (list) => {
129
174
  const have = new Set(readAdded(f("hold")));
@@ -138,8 +183,8 @@ export function openBatch(root, id) {
138
183
  if (freed.length) writeAtomic(f("hold"), JSON.stringify([...have].filter((n) => !freed.includes(n)).sort((a, b) => a - b)) + "\n");
139
184
  return freed;
140
185
  },
141
- /** `--cancel` (#4376): `{at, reason}` in the `cancel` file; the runner re-reads it every pass. */
142
- requestCancel: ({ reason = "operator" } = {}) => writeAtomic(f("cancel"), JSON.stringify({ at: new Date().toISOString(), reason: String(reason) }) + "\n"),
186
+ /** `--cancel` (#4376): `{at, reason, stopRemote?}` in the `cancel` file; the runner re-reads it every pass. `stopRemote` (#4612) also asks remote lanes to stop at their checkpoint. */
187
+ requestCancel: ({ reason = "operator", stopRemote = false } = {}) => writeAtomic(f("cancel"), JSON.stringify({ at: new Date().toISOString(), reason: String(reason), ...(stopRemote === true ? { stopRemote: true } : {}) }) + "\n"),
143
188
  /** The cancel record, or null when missing or invalid. */
144
189
  readCancel: () => { const v = readJson(f("cancel")); return v && typeof v === "object" && !Array.isArray(v) && typeof v.at === "string" ? v : null; },
145
190
  clearCancel: () => rmSync(f("cancel"), { force: true }),
@@ -157,6 +202,7 @@ export function openBatch(root, id) {
157
202
  resultFile: (n) => f(join("lanes", `${n}.json`)),
158
203
  logFile: (n) => f(join("lanes", `${n}.log`)),
159
204
  };
205
+ return self;
160
206
  }
161
207
 
162
208
  /** True when `pid` is alive and (where /proc exists) its command line names `marker`, so a reused pid is not adopted. */