forgedock 1.36.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 (112) hide show
  1. package/bin/bot-token-status.mjs +26 -0
  2. package/bin/engine/events.mjs +10 -1
  3. package/bin/engine/events.test.mjs +15 -1
  4. package/bin/engine/outcome-codes.mjs +39 -1
  5. package/bin/engine/phases.mjs +65 -5
  6. package/bin/engine/telemetry-seam.mjs +1 -1
  7. package/bin/engine/telemetry-seam.test.mjs +4 -0
  8. package/bin/engine-cli.mjs +268 -37
  9. package/bin/engine.mjs +3 -1
  10. package/bin/forgedock.mjs +108 -5
  11. package/bin/labels.json +50 -0
  12. package/bin/lean/batch.mjs +10 -4
  13. package/bin/lean/batch.test.mjs +75 -1
  14. package/bin/lean/conformance.e2e.test.mjs +1 -1
  15. package/bin/lean/conformance.mjs +125 -11
  16. package/bin/lean/conformance.test.mjs +122 -1
  17. package/bin/lean/decisions.mjs +181 -0
  18. package/bin/lean/decisions.test.mjs +71 -0
  19. package/bin/lean/effects.mjs +29 -14
  20. package/bin/lean/execution.mjs +60 -16
  21. package/bin/lean/execution.test.mjs +59 -2
  22. package/bin/lean/gatewatch.mjs +31 -0
  23. package/bin/lean/gatewatch.test.mjs +59 -0
  24. package/bin/lean/guardgate.mjs +170 -0
  25. package/bin/lean/guardgate.test.mjs +153 -0
  26. package/bin/lean/guards.mjs +243 -0
  27. package/bin/lean/guards.test.mjs +177 -0
  28. package/bin/lean/heavy.mjs +54 -8
  29. package/bin/lean/heavy.test.mjs +74 -1
  30. package/bin/lean/hostkill.mjs +1 -1
  31. package/bin/lean/integration.mjs +276 -0
  32. package/bin/lean/integration.test.mjs +189 -0
  33. package/bin/lean/lean.mjs +134 -28
  34. package/bin/lean/lean.test.mjs +239 -22
  35. package/bin/lean/lifecycle.mjs +129 -0
  36. package/bin/lean/lifecycle.test.mjs +118 -0
  37. package/bin/lean/light.mjs +1 -1
  38. package/bin/lean/milestonesync.mjs +130 -0
  39. package/bin/lean/milestonesync.test.mjs +133 -0
  40. package/bin/lean/orchestrate.mjs +83 -15
  41. package/bin/lean/parked-sweep.mjs +45 -0
  42. package/bin/lean/parked-sweep.test.mjs +71 -0
  43. package/bin/lean/postdeploy-check.mjs +38 -6
  44. package/bin/lean/postdeploy-check.test.mjs +62 -2
  45. package/bin/lean/postdeploy-monitor.mjs +12 -2
  46. package/bin/lean/postdeploy-monitor.test.mjs +27 -1
  47. package/bin/lean/prefilter.mjs +83 -23
  48. package/bin/lean/prefilter.test.mjs +73 -1
  49. package/bin/lean/probe.mjs +2 -2
  50. package/bin/lean/probe.test.mjs +1 -1
  51. package/bin/lean/prompts.mjs +15 -9
  52. package/bin/lean/resume.test.mjs +1 -1
  53. package/bin/lean/retry.mjs +28 -0
  54. package/bin/lean/sandbox-provider.contract.test.mjs +8 -3
  55. package/bin/lean/sandbox-remote.mjs +4 -4
  56. package/bin/lean/schedd.mjs +53 -9
  57. package/bin/lean/schedd.test.mjs +77 -5
  58. package/bin/lean/sequence.mjs +24 -0
  59. package/bin/lean/sequence.test.mjs +31 -1
  60. package/bin/lean/signoff.mjs +66 -0
  61. package/bin/lean/signoff.test.mjs +102 -0
  62. package/bin/lean/train.mjs +177 -0
  63. package/bin/lean/train.test.mjs +113 -0
  64. package/bin/pool/cli.mjs +2 -0
  65. package/bin/pool/health.mjs +30 -0
  66. package/bin/review-context.mjs +298 -0
  67. package/bin/runner.mjs +26 -3
  68. package/bin/stats/compounding.mjs +157 -0
  69. package/bin/stats/compounding.test.mjs +74 -0
  70. package/bin/stats/index.mjs +21 -2
  71. package/commands/bootstrap.md +10 -8
  72. package/commands/deploy-info.md +3 -0
  73. package/commands/issue.md +2 -0
  74. package/commands/milestone.md +43 -54
  75. package/commands/orchestrate/phase-4-execution.md +2 -0
  76. package/commands/orchestrate.md +8 -1
  77. package/commands/pipeline-status.md +20 -0
  78. package/commands/review-pr.md +1 -0
  79. package/commands/work-on/build/implement.md +1 -1
  80. package/commands/work-on/build.md +4 -2
  81. package/commands/work-on/investigate.md +3 -1
  82. package/commands/work-on/remediate.md +1 -1
  83. package/docs/CONFIG.md +71 -2
  84. package/docs/EVENTS.md +1 -4
  85. package/docs/OUTCOME-CODES.md +9 -1
  86. package/docs/STATS.md +20 -0
  87. package/docs/site/start-a-new-project.md +1 -1
  88. package/docs/spec/label-state-machine.md +6 -0
  89. package/package.json +1 -1
  90. package/pool/install/install-agent.sh +4 -2
  91. package/pool/install/install-router.sh +4 -2
  92. package/pool/install/install-schedd.sh +28 -0
  93. package/pool/install/unit-hardening.sh +34 -0
  94. package/scripts/acceptance-red-green.sh +19 -2
  95. package/scripts/acceptance-red-green.test.sh +2 -0
  96. package/scripts/bootstrap-create.mjs +3 -2
  97. package/scripts/bootstrap-validate.mjs +31 -4
  98. package/scripts/file-deferred-followups.sh +285 -77
  99. package/scripts/file-deferred-followups.test.sh +111 -4
  100. package/scripts/hotspots.sh +23 -2
  101. package/scripts/hotspots.test.sh +9 -0
  102. package/scripts/plan-validate-live.mjs +111 -0
  103. package/scripts/readiness.mjs +141 -0
  104. package/scripts/review-prepare.sh +17 -1
  105. package/scripts/review-prepare.test.sh +17 -0
  106. package/scripts/settle-scope.mjs +74 -0
  107. package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
  108. package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
  109. package/scripts/wait-ci-green.sh +14 -3
  110. package/scripts/wait-ci-green.test.sh +3 -1
  111. package/templates/bootstrap/needs-human-issue.md +4 -1
  112. package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
@@ -0,0 +1,157 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * #4696: compounding metrics for `forgedock stats --compounding`: does a repo get safer the longer ForgeDock works on it.
5
+ * Deterministic (0 LLM calls): guards (#4692 FORGE:GUARD comments), `reopened` issue events, git blame, and gate events
6
+ * from the FORGE:TELEMETRY records. The report core is pure; `gatherCompounding` supplies the GitHub and git inputs.
7
+ * reintroduction: a guarded fix whose issue was reopened after the lane closed it (a), or whose merge commit is the blame
8
+ * of the old-side lines of a later guarded fix within 90 days (b; squash and rebase merges, where merge_sha is the commit)
9
+ * reopen_after_close: lane-closed (merged) issues that were reopened afterwards
10
+ * guard / decision counts: gate events guard_run, guard_broken, supersedes_accepted, decision_superseded,
11
+ * decision_violation (#4693, #4695); null (n/a) until a repo has recorded any of that kind
12
+ * Grouped as a rolling window, per release and per lane-age bucket (lane rank = merge order of the repo's lanes).
13
+ */
14
+ import { execFileSync } from "node:child_process";
15
+ import { collectGuards } from "../lean/guards.mjs";
16
+ import { parsePages } from "./github-source.mjs";
17
+
18
+ export const REINTRO_DAYS = 90;
19
+ export const DEFAULT_BUCKETS = [100, 500];
20
+ export const DEFAULT_WINDOW_MS = 30 * 86400000;
21
+ export const COUNTED_EVENTS = ["guard_run", "guard_broken", "supersedes_accepted", "decision_superseded", "decision_violation"];
22
+ const DAY = 86400000;
23
+ const ratio = (a, b) => (b ? Math.round((a / b) * 1000) / 1000 : null);
24
+ const ms = (ts) => { const t = Date.parse(ts ?? ""); return Number.isFinite(t) ? t : null; };
25
+
26
+ export const bucketLabel = (rank, edges) => {
27
+ if (rank == null) return "unranked";
28
+ let lo = 1;
29
+ for (const e of edges) { if (rank <= e) return `${lo}-${e}`; lo = e + 1; }
30
+ return `${lo}+`;
31
+ };
32
+
33
+ /** Guards whose fixed lines a later guarded fix changed within 90 days: [{fix, by}] (fix = earlier guard's issue). Git probes may return null (unresolved). */
34
+ export function blameLineage(guards, git) {
35
+ const t = new Map(), hits = [];
36
+ const time = (g) => { if (!t.has(g.merge_sha)) t.set(g.merge_sha, git.time(g.merge_sha)); return t.get(g.merge_sha); };
37
+ const byCommit = new Map(guards.filter((g) => g.merge_sha).map((g) => [g.merge_sha, g]));
38
+ let unresolved = 0;
39
+ for (const g2 of guards) {
40
+ if (!g2.merge_sha) { unresolved++; continue; }
41
+ const ranges = git.oldRanges(g2.merge_sha);
42
+ if (!ranges) { unresolved++; continue; }
43
+ const seen = new Set();
44
+ for (const r of ranges) for (const sha of git.blame(`${g2.merge_sha}^`, r.file, r.start, r.end) ?? []) {
45
+ const g1 = byCommit.get(sha);
46
+ if (!g1 || g1.issue === g2.issue || seen.has(g1.issue)) continue;
47
+ const a = time(g1), b = time(g2);
48
+ if (a == null || b == null || b < a || b - a > REINTRO_DAYS * 86400) continue;
49
+ seen.add(g1.issue); hits.push({ fix: g1.issue, by: g2.issue });
50
+ }
51
+ }
52
+ return { hits, unresolved };
53
+ }
54
+
55
+ /** Metrics of one group of lane entries and the gate events falling in it. */
56
+ function summarize(entries, events, have, minN) {
57
+ const guarded = entries.filter((e) => e.guarded), lanes = entries.filter((e) => e.merged);
58
+ const count = (k) => (have[k] ? events.filter((e) => e.event === k).length : null);
59
+ return {
60
+ lanes: lanes.length,
61
+ reopen_after_close: { reopened: lanes.filter((e) => e.reopened).length, rate: ratio(lanes.filter((e) => e.reopened).length, lanes.length) },
62
+ reintroduction: { guarded: guarded.length, reintroduced: guarded.filter((e) => e.reintroduced).length, rate: ratio(guarded.filter((e) => e.reintroduced).length, guarded.length) },
63
+ guard: { tests_run: count("guard_run"), broken_caught: count("guard_broken"), supersedes_accepted: count("supersedes_accepted") },
64
+ decisions: { superseded: count("decision_superseded"), violations: count("decision_violation") },
65
+ low_n: Math.max(lanes.length, guarded.length) < minN,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Pure report. guards: parsed FORGE:GUARD objects; reopens: [{issue, ts}]; runs: toModel runs; events: toModel gate events;
71
+ * git: {time(sha)->epoch s|null, oldRanges(sha)->[{file,start,end}]|null, blame(rev,file,start,end)->[sha]|null}.
72
+ */
73
+ export function compoundingReport({ guards = [], reopens = [], runs = [], events = [], git, windowMs = DEFAULT_WINDOW_MS, buckets = DEFAULT_BUCKETS, now = Date.now(), minN = 10 }) {
74
+ const merged = runs.filter((r) => r.reason === "merged").sort((a, b) => (ms(a.endTs) ?? 0) - (ms(b.endTs) ?? 0) || a.issue - b.issue);
75
+ const byIssue = new Map(merged.map((r, i) => [r.issue, { issue: r.issue, merged: true, rank: i + 1, version: r.version ?? null, at: ms(r.endTs), guarded: false, reopened: false, reintroduced: false }]));
76
+ const gset = new Map();
77
+ for (const g of guards) if (!gset.has(g.issue)) gset.set(g.issue, g);
78
+ const uniq = [...gset.values()];
79
+ for (const g of uniq) {
80
+ const e = byIssue.get(g.issue) ?? { issue: g.issue, merged: false, rank: null, version: null, at: null, guarded: false, reopened: false, reintroduced: false };
81
+ e.guarded = true;
82
+ const secs = git?.time && g.merge_sha ? git.time(g.merge_sha) : null;
83
+ if (e.at == null && secs != null) e.at = secs * 1000;
84
+ byIssue.set(g.issue, e);
85
+ }
86
+ for (const e of byIssue.values()) { // (a) and reopen-after-close: a reopen after the lane closed the issue
87
+ e.reopened = reopens.some((r) => r.issue === e.issue && (e.at == null || (ms(r.ts) ?? 0) > e.at));
88
+ if (e.guarded && e.reopened) e.reintroduced = true;
89
+ }
90
+ const lineage = git && uniq.length ? blameLineage(uniq, git) : { hits: [], unresolved: uniq.length };
91
+ for (const h of lineage.hits) if (byIssue.has(h.fix)) byIssue.get(h.fix).reintroduced = true;
92
+ const entries = [...byIssue.values()];
93
+ const have = Object.fromEntries(COUNTED_EVENTS.map((k) => [k, events.some((e) => e.event === k)]));
94
+ const evMs = (e) => ms(e.ts);
95
+ const out = { rolling: null, by_release: {}, by_lane_age: {}, sources: { guards: uniq.length, reopens: reopens.length, blame_hits: lineage.hits.length, blame_unresolved: lineage.unresolved }, buckets };
96
+ out.rolling = { window_days: Math.round(windowMs / DAY), ...summarize(entries.filter((e) => e.at != null && now - e.at <= windowMs && e.at <= now),
97
+ events.filter((e) => evMs(e) != null && now - evMs(e) <= windowMs && evMs(e) <= now), have, minN) };
98
+ const versions = [...new Set([...entries.map((e) => e.version), ...events.map((e) => e.version)].filter(Boolean))].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
99
+ for (const v of versions) out.by_release[v] = summarize(entries.filter((e) => e.version === v), events.filter((e) => e.version === v), have, minN);
100
+ const labels = [...new Set(entries.map((e) => bucketLabel(e.rank, buckets)))];
101
+ const order = (l) => (l === "unranked" ? Infinity : Number.parseInt(l, 10));
102
+ for (const l of labels.sort((a, b) => order(a) - order(b))) {
103
+ const inB = entries.filter((e) => bucketLabel(e.rank, buckets) === l);
104
+ const evs = events.filter((e) => { const r = byIssue.get(e.issue); return r && bucketLabel(r.rank, buckets) === l; });
105
+ out.by_lane_age[l] = summarize(inB, evs, have, minN);
106
+ }
107
+ return out;
108
+ }
109
+
110
+ const pct = (x) => (x == null ? "n/a" : `${Math.round(x * 1000) / 10}%`);
111
+ const n = (x) => (x == null ? "n/a" : String(x));
112
+ function line(label, s) {
113
+ return ` ${label}: ${s.lanes} lanes, reintroduction ${pct(s.reintroduction.rate)} (${s.reintroduction.reintroduced}/${s.reintroduction.guarded}), reopen-after-close ${pct(s.reopen_after_close.rate)}, `
114
+ + `guard runs ${n(s.guard.tests_run)} broken ${n(s.guard.broken_caught)} supersedes ${n(s.guard.supersedes_accepted)}, decisions superseded ${n(s.decisions.superseded)} violations ${n(s.decisions.violations)}${s.low_n ? " (low n)" : ""}`;
115
+ }
116
+
117
+ export function renderCompounding(r) {
118
+ const rows = [`compounding: ${r.sources.guards} guards, ${r.sources.reopens} reopen events, ${r.sources.blame_hits} blame hits (${r.sources.blame_unresolved} guards unresolved)`,
119
+ line(`rolling ${r.rolling.window_days}d`, r.rolling), "by lane age:", ...Object.entries(r.by_lane_age).map(([k, s]) => line(k, s))];
120
+ const rel = Object.entries(r.by_release);
121
+ return [...rows, "by release:", ...(rel.length ? rel.map(([k, s]) => line(k, s)) : [" n/a"])].join("\n");
122
+ }
123
+
124
+ const tryGit = (cwd, args) => { try { return execFileSync("git", ["-C", cwd, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 }); } catch { return null; } };
125
+
126
+ /** git probes over a local clone; every miss (unknown sha, shallow clone) is null, never a thrown error. */
127
+ export function gitProbes(cwd) {
128
+ return {
129
+ time: (sha) => { const t = Number(tryGit(cwd, ["show", "-s", "--format=%ct", sha])?.trim()); return Number.isFinite(t) && t > 0 ? t : null; },
130
+ oldRanges: (sha) => {
131
+ const d = tryGit(cwd, ["diff", "-U0", "--no-renames", `${sha}^`, sha]);
132
+ if (d == null) return null;
133
+ const out = []; let file = null;
134
+ for (const l of d.split("\n")) {
135
+ if (l.startsWith("--- ")) { file = l === "--- /dev/null" ? null : l.replace(/^--- a\//, ""); continue; }
136
+ const m = /^@@ -(\d+)(?:,(\d+))? \+/.exec(l);
137
+ if (m && file && m[2] !== "0") out.push({ file, start: Number(m[1]), end: Number(m[1]) + (m[2] === undefined ? 1 : Number(m[2])) - 1 });
138
+ }
139
+ return out;
140
+ },
141
+ blame: (rev, file, start, end) => {
142
+ const o = tryGit(cwd, ["blame", "--porcelain", "-L", `${start},${end}`, rev, "--", file]);
143
+ return o == null ? null : [...new Set(o.split("\n").filter((l) => /^[0-9a-f]{40} \d+ \d+/.test(l)).map((l) => l.slice(0, 40)))];
144
+ },
145
+ };
146
+ }
147
+
148
+ /** GitHub + git inputs for compoundingReport: guards from FORGE:GUARD comments, reopen events from one paginated events call. */
149
+ export async function gatherCompounding({ repo, gh, cwd = process.cwd() }) {
150
+ const guards = await collectGuards({ gh, repo });
151
+ let reopens = [];
152
+ try {
153
+ reopens = parsePages(await gh(["api", "--paginate", `repos/${repo}/issues/events?per_page=100`]))
154
+ .filter((e) => e?.event === "reopened" && Number.isSafeInteger(e.issue?.number)).map((e) => ({ issue: e.issue.number, ts: e.created_at }));
155
+ } catch { /* events unreadable: reopen metrics read 0 reopens, shown with the count in sources */ }
156
+ return { guards, reopens, git: gitProbes(cwd) };
157
+ }
@@ -0,0 +1,74 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { compoundingReport, renderCompounding, bucketLabel, gitProbes } from "./compounding.mjs";
6
+ import { parseArgs } from "./index.mjs";
7
+
8
+ const NOW = Date.parse("2026-10-24T00:00:00Z"), DAY = 86400000, S = (d) => Math.floor((NOW - d * DAY) / 1000);
9
+ const run = (issue, daysAgo, version = "1.0.0") => ({ issue, reason: "merged", version, endTs: new Date(NOW - daysAgo * DAY).toISOString() });
10
+ const guard = (issue, sha) => ({ v: 1, issue, merge_sha: sha, tests: [{ path: "t.mjs", ids: [] }], hunks: [] });
11
+ // shas: a (issue 1, 40d ago), b (issue 2, 10d ago), c (issue 3, 1d ago)
12
+ const TIMES = { a: S(40), b: S(10), c: S(1) };
13
+ const fakeGit = (blames = {}) => ({ time: (s) => TIMES[s] ?? null, oldRanges: (s) => [{ file: "f.mjs", start: 1, end: 3 }], blame: (rev) => blames[rev] ?? [] });
14
+ const base = { runs: [run(1, 40), run(2, 10), run(3, 1)], guards: [guard(1, "a"), guard(2, "b"), guard(3, "c")], now: NOW, minN: 1 };
15
+
16
+ test("reintroduction (b): a later fix whose old lines blame to an earlier fix within 90 days", () => {
17
+ const r = compoundingReport({ ...base, git: fakeGit({ "b^": ["a"] }) });
18
+ assert.deepEqual(r.rolling.reintroduction, { guarded: 2, reintroduced: 0, rate: 0 }); // 30d window holds fixes 2, 3; the broken fix is #1 (40d ago)
19
+ assert.equal(r.sources.blame_hits, 1);
20
+ const all = Object.values(r.by_lane_age)[0];
21
+ assert.deepEqual(all.reintroduction, { guarded: 3, reintroduced: 1, rate: 0.333 });
22
+ });
23
+
24
+ test("reintroduction (b): older than 90 days or unknown time does not count; unresolved is reported, not zeroed", () => {
25
+ const far = { ...base, guards: [guard(1, "a"), guard(2, "b")], runs: [run(1, 200), run(2, 10)] };
26
+ const r = compoundingReport({ ...far, git: { ...fakeGit({ "b^": ["a"] }), time: (s) => ({ a: S(200), b: S(10) })[s] ?? null } });
27
+ assert.equal(r.sources.blame_hits, 0);
28
+ const u = compoundingReport({ ...base, git: { time: () => null, oldRanges: () => null, blame: () => null } });
29
+ assert.equal(u.sources.blame_unresolved, 3);
30
+ assert.equal(u.sources.blame_hits, 0);
31
+ });
32
+
33
+ test("reintroduction (a) and reopen-after-close: reopen after the lane closed the issue", () => {
34
+ const reopens = [{ issue: 2, ts: new Date(NOW - 5 * DAY).toISOString() }, { issue: 3, ts: new Date(NOW - 3 * DAY).toISOString() /* before close? no: close was 1d ago */ }];
35
+ const r = compoundingReport({ ...base, reopens, git: fakeGit() });
36
+ assert.equal(r.rolling.reopen_after_close.reopened, 1); // issue 2 reopened after its close; issue 3's reopen predates its close
37
+ assert.equal(r.rolling.reintroduction.reintroduced, 1);
38
+ assert.equal(r.rolling.lanes, 2);
39
+ });
40
+
41
+ test("guard and decision counts are n/a until a kind is recorded, then counted per window", () => {
42
+ const none = compoundingReport({ ...base, git: fakeGit() });
43
+ assert.equal(none.rolling.guard.broken_caught, null);
44
+ assert.equal(none.rolling.decisions.violations, null);
45
+ const events = [{ event: "guard_broken", issue: 2, ts: new Date(NOW - DAY).toISOString(), version: "1.0.0" }, { event: "guard_broken", issue: 1, ts: new Date(NOW - 60 * DAY).toISOString(), version: "1.0.0" }];
46
+ const r = compoundingReport({ ...base, events, git: fakeGit() });
47
+ assert.equal(r.rolling.guard.broken_caught, 1);
48
+ assert.equal(r.rolling.guard.tests_run, null);
49
+ assert.equal(r.by_release["1.0.0"].guard.broken_caught, 2);
50
+ });
51
+
52
+ test("lane-age buckets by merge order and rendering", () => {
53
+ assert.deepEqual([1, 100, 101, 500, 501, null].map((n) => bucketLabel(n, [100, 500])), ["1-100", "1-100", "101-500", "101-500", "501+", "unranked"]);
54
+ const runs = Array.from({ length: 4 }, (_, i) => run(i + 1, 40 - i));
55
+ const r = compoundingReport({ runs, guards: [], buckets: [2], now: NOW, minN: 1, git: fakeGit() });
56
+ assert.deepEqual(Object.keys(r.by_lane_age), ["1-2", "3+"]);
57
+ assert.equal(r.by_lane_age["3+"].lanes, 2);
58
+ assert.match(renderCompounding(r), /by lane age:\n 1-2: 2 lanes, reintroduction n\/a \(0\/0\)/);
59
+ });
60
+
61
+ test("gitProbes degrades to null outside a repo or for an unknown sha", () => {
62
+ const g = gitProbes("/nonexistent-dir-for-compounding");
63
+ assert.equal(g.time("deadbeef"), null);
64
+ assert.equal(g.oldRanges("deadbeef"), null);
65
+ assert.equal(g.blame("deadbeef^", "x", 1, 2), null);
66
+ });
67
+
68
+ test("parseArgs: --compounding takes --window and --bucket; --bucket alone is rejected", () => {
69
+ const o = parseArgs(["--compounding", "--window", "30d", "--bucket", "50,200"]);
70
+ assert.equal(o.compounding, true);
71
+ assert.deepEqual(o.bucket, [50, 200]);
72
+ assert.throws(() => parseArgs(["--bucket", "10"]), /needs --compounding/);
73
+ assert.throws(() => parseArgs(["--compounding", "--bucket", "9,5"]), /increasing/);
74
+ });
@@ -19,6 +19,7 @@ import { batchEfficiency, windowEfficiency, compareEfficiency, formatEfficiencyL
19
19
  import { releaseMetrics, renderReleaseMetrics, metricsComment, postMetrics } from "./release.mjs";
20
20
  import { experimentReport, renderExperiment } from "./experiment.mjs";
21
21
  import { readExperiment } from "../experiment.mjs";
22
+ import { compoundingReport, gatherCompounding, renderCompounding, DEFAULT_BUCKETS, DEFAULT_WINDOW_MS } from "./compounding.mjs";
22
23
  import { poolUtilisation, formatPoolLines } from "./pool.mjs";
23
24
  import { batchRoot } from "../lean/batch.mjs";
24
25
 
@@ -29,6 +30,7 @@ export const HELP = `Usage: forgedock stats [--since <version|date>] [--repo <ow
29
30
  forgedock stats --experiment <id> [--require-met] [--min-n N] [--repo <owner/repo>] [--json]
30
31
  forgedock stats --by version [--post-metrics <pr>] [--min-n N] [--repo <owner/repo>] [--json]
31
32
  forgedock stats --pool [--window 7d,30d] [--repo <owner/repo>] [--json]
33
+ forgedock stats --compounding [--window 30d] [--bucket 100,500] [--since <date>] [--min-n N] [--repo <owner/repo>] [--json]
32
34
  forgedock stats --efficiency [--batch <id>] [--window 7d,30d] [--compare <date|version>] [--offline] [--repo <owner/repo>] [--json]
33
35
 
34
36
  Version-over-version report: Cost, Time, Flow, Review funnel, Gates and Issue flow per (version, repo), with a
@@ -84,6 +86,13 @@ Efficiency (forge#4402; local batch dirs and run logs, gh only for merged issues
84
86
  their delta and full-lane cohorts matched per size (forge#4491)
85
87
  --offline skip the GitHub crawl for merged issues (issue quality and acceptance print n/a)
86
88
 
89
+ Compounding (forge#4696; GitHub + git of the current clone, no model):
90
+ --compounding reintroduction rate (guarded fix reopened, or its lines blamed by a later guarded fix within 90 days),
91
+ reopen-after-close rate, guard activity (guard_run, guard_broken, supersedes_accepted) and decision
92
+ churn (decision_superseded, decision_violation), as a rolling window, per release and per lane-age
93
+ bucket; n/a where no data is recorded. Pair with --efficiency --compare for $/merge and turns/merge
94
+ --bucket <list> lane-age bucket upper bounds (default 100,500: lanes 1-100, 101-500, 501+)
95
+
87
96
  Builder model tiers (forge#4394; local run logs only, no GitHub crawl):
88
97
  --tiers merge, conformance and post-merge-rework rates, first-pass yield and cost per builder tier
89
98
  (mid, mid->top escalated, top) and routing class; --json for JSON
@@ -98,7 +107,7 @@ Lane profile baseline (forge#4436; local run logs only, every run of each issue,
98
107
  export function experimentExit(opts, report) { return opts.requireMet && report.targets_met !== true ? 1 : 0; }
99
108
 
100
109
  export function parseArgs(argv) {
101
- const o = { since: null, repo: null, edge: null, milestone: null, contextAb: false, exploreAb: false, tiers: false, laneProfile: false, issues: null, efficiency: false, pool: false, batch: null, windows: null, compare: null, by: null, experiment: null, emitEvent: false, requireMet: false, postMetrics: null, offline: false, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
110
+ const o = { since: null, repo: null, edge: null, milestone: null, contextAb: false, exploreAb: false, tiers: false, compounding: false, bucket: null, laneProfile: false, issues: null, efficiency: false, pool: false, batch: null, windows: null, compare: null, by: null, experiment: null, emitEvent: false, requireMet: false, postMetrics: null, offline: false, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
102
111
  const val = (i, name) => {
103
112
  const v = argv[i + 1];
104
113
  if (v == null || v.startsWith("--")) throw new Error(`${name} needs a value`);
@@ -117,6 +126,8 @@ export function parseArgs(argv) {
117
126
  else if (name === "--context-ab") o.contextAb = true;
118
127
  else if (name === "--explore-ab") o.exploreAb = true;
119
128
  else if (name === "--tiers") o.tiers = true;
129
+ else if (name === "--compounding") o.compounding = true;
130
+ else if (name === "--bucket") { const v = take(); if (!/^[1-9]\d*(,[1-9]\d*)*$/.test(v)) throw new Error("--bucket must be a comma list of lane counts"); const b = v.split(",").map(Number); if (b.some((x, i) => i && x <= b[i - 1])) throw new Error("--bucket must be increasing"); o.bucket = b; }
120
131
  else if (name === "--lane-profile") o.laneProfile = true;
121
132
  else if (name === "--issues") { const v = take(); if (!/^[1-9]\d*(,[1-9]\d*)*$/.test(v)) throw new Error("--issues must be a comma list of issue numbers"); o.issues = v.split(",").map(Number); }
122
133
  else if (name === "--efficiency") o.efficiency = true;
@@ -144,7 +155,8 @@ export function parseArgs(argv) {
144
155
  else throw new Error(`unknown argument: ${a}`);
145
156
  }
146
157
  if (o.batch && !o.efficiency) throw new Error("--batch and --window need --efficiency");
147
- if (o.windows && !o.efficiency && !o.pool) throw new Error("--batch and --window need --efficiency");
158
+ if (o.windows && !o.efficiency && !o.pool && !o.compounding) throw new Error("--batch and --window need --efficiency");
159
+ if (o.bucket && !o.compounding) throw new Error("--bucket needs --compounding");
148
160
  if ((o.compare || o.offline) && !o.efficiency) throw new Error("--compare and --offline need --efficiency");
149
161
  if (o.postMetrics && !o.by) throw new Error("--post-metrics needs --by version");
150
162
  return o;
@@ -302,6 +314,12 @@ export async function buildReport(opts, deps = {}) {
302
314
  return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache } }, query };
303
315
  }
304
316
  const model = toModel(repo, gh);
317
+ if (opts.compounding) { // forge#4696: GitHub records + guards + git, no model
318
+ const inputs = await (deps.gatherCompounding ?? gatherCompounding)({ repo, gh: deps.gh ?? defaultGh });
319
+ const query = { compounding: compoundingReport({ ...inputs, runs: model.runs, events: model.events, windowMs: opts.windows?.[0] ?? DEFAULT_WINDOW_MS,
320
+ buckets: opts.bucket ?? DEFAULT_BUCKETS, now: deps.now?.() ?? Date.now(), minN: opts.minN }) };
321
+ return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache } }, query };
322
+ }
305
323
  const ghKeys = new Set(model.runs.map((r) => `${r.repo}#${r.issue}`));
306
324
  let local = [];
307
325
  if (opts.local) {
@@ -348,6 +366,7 @@ export async function runStats(argv, deps = {}) {
348
366
  if (report.query?.experiment) { process.stdout.write(`${opts.json ? renderJson(report) : renderExperiment(report.query.experiment)}\n`); return experimentExit(opts, report.query.experiment); }
349
367
  if (report.query?.lane_profile && !opts.json) { process.stdout.write(`${renderLaneProfiles(report.query.lane_profile)}\n`); return 0; }
350
368
  if (report.query?.tiers && !opts.json) { process.stdout.write(`${renderTiers(report.query.tiers)}\n`); return 0; }
369
+ if (report.query?.compounding && !opts.json) { process.stdout.write(`${renderCompounding(report.query.compounding)}\n`); return 0; }
351
370
  if (report.query?.pool && !opts.json) { process.stdout.write(`${formatPoolLines(report.query.pool).join("\n")}\n`); return 0; }
352
371
  if (report.query?.efficiency && !opts.json) {
353
372
  const e = report.query.efficiency;
@@ -18,18 +18,20 @@ BOOT_TPL="$(forge_root)/templates/bootstrap"; VALIDATE=$(forge_resolve bootstrap
18
18
  ```
19
19
 
20
20
  1. **Read the input.** Use the free text, or read each doc at the given path(s).
21
- 2. **Interview** from `interview.md`. Cover every aspect it lists, briefly and conversationally. Skip what the input already answers.
22
- 3. **Fill the blanks.** Give every unanswered aspect a sensible default with a one-line rationale tied to what the user said, using the closest `stack-profiles.md` preset. Show them all in ONE editable "Here's what I assumed" list and apply the user's edits.
23
- 4. **Ask only genuine doubts** (they change architecture or cost: native vs PWA, data residency, self-host vs managed DB, product name, repo owner). Ask with AskUserQuestion, at most 4 multiple-choice questions per batch, each with a recommended option. Everything else stays a default. Headless (no AskUserQuestion, e.g. `npx forgedock bootstrap`): take each recommended option and list it under "Here's what I assumed".
21
+ 2. **Interview** from `interview.md`. Cover each aspect briefly; skip what the input answers.
22
+ 3. **Fill the blanks.** Default every unanswered aspect with a one-line rationale, using the closest `stack-profiles.md` preset. Show them in ONE editable "Here's what I assumed" list and apply the user's edits.
23
+ 4. **Ask only genuine doubts** (architecture or cost: native vs PWA, data residency, self-host vs managed DB, product name, repo owner). Ask with AskUserQuestion, at most 4 multiple-choice questions per batch, each with a recommended option. Everything else stays a default. Headless (no AskUserQuestion, e.g. `npx forgedock bootstrap`): take each recommended option and list it under "Here's what I assumed".
24
24
  5. **Spike doc.** Write `technical-spike.md` to `--spike` (default `docs/engineering/technical-spike.md`); set the plan's `spikeDoc` to that path. Fill every section and give each decision an ADR-style `**Rationale**:` line.
25
25
  6. **Plan JSON** in `$TMPDIR` (shape: header of `bootstrap-validate.mjs` and `bootstrap-create.mjs`):
26
- - Milestones in build order, foundation first.
26
+ - Milestones as dependency layers, foundation first: each depends only on earlier ones (#4659).
27
+ - A thin deployable slice (CI deploy workflow, staging env, seed) in milestone 1 or 2, never last.
27
28
  - A `SPIKE` parent issue.
28
29
  - Sub-issues from `issue-body.md` with planned paths, `[type:*]` tags, `priority` and `component`.
29
30
  - `"ui": true` issues carry the full design spec. `"hero": true` issues route to `/design`.
30
- - One `needs-human-issue.md` issue per credential, account or external input. Code issues reference it as `#{{KEY}}` but never depend on it, and name the mock that keeps dev and CI green.
31
+ - One `needs-human-issue.md` issue per credential, account or external input, with `gates:` (#4660). Code issues reference it as `#{{KEY}}`, never depend on it, and name the mock that keeps dev and CI green.
31
32
  - Sub-issues point at the spike via `**Parent**` only, never `Depends on`.
32
33
  - `guardrails` and `domainRules` from the interview.
33
- 7. **Validate.** Run `node "$VALIDATE" <plan>`. Fix the plan until it exits 0. Never create from an invalid plan.
34
- 8. **Create.** Run `node "$CREATE" <plan>`, adding `--dry-run` when the input has it (preview only, GitHub untouched). The script reuses existing issues by title and parent, so on an error re-run the same command to resume.
35
- 9. **Hand off.** Print the script's hand-off verbatim: what was created (links), what needs a human (links), and the `/orchestrate` command per milestone, in order.
34
+ 7. **Validate.** Run `node "$VALIDATE" <plan>`. Fix the plan until it exits 0. Heed `deploy-last` warnings. Never create from an invalid plan.
35
+ 8. **Create.** Run `node "$CREATE" <plan>`, adding `--dry-run` when the input has it (preview only, GitHub untouched). It reuses existing issues, so on an error re-run it to resume.
36
+ 9. **Hand off.** Print the script's hand-off verbatim: created (links), needs a human (links), and the `/orchestrate` command per milestone, in order.
37
+ 10. Later: `npx forgedock plan validate --live` (#4659).
@@ -325,6 +325,7 @@ else
325
325
  # Step 3: Count commits queued (commits in staging not yet in main)
326
326
  QUEUED_COMMITS=$(git rev-list --count origin/{DEFAULT_BRANCH}..origin/{STAGING_BRANCH} 2>/dev/null || echo "?")
327
327
  fi
328
+ INTEGRATION_RED=$(gh issue list {GH_FLAG} --label integration-red --json number,title --jq '.[]|"#\(.number) \(.title)"') # forge#4667
328
329
  ```
329
330
 
330
331
  Train state is included in the Phase 5 output summary.
@@ -339,10 +340,12 @@ Train state is included in the Phase 5 output summary.
339
340
  **Commits**: {N} | **PRs**: {N} | **Issues closed**: {N} | **Files changed**: {N}
340
341
 
341
342
  ### Deploy Train State
343
+ **Gate**: `node ${FORGEDOCK_HOME:-${CLAUDE_PLUGIN_ROOT}}/scripts/readiness.mjs --target {preview|staging|prod}`; NOT DEPLOYABLE (open prerequisites) means never call the target deployable (#4660).
342
344
  **Train PR**: #{TRAIN_PR_NUMBER} (SHA: {TRAIN_PR_SHA}, opened: {TRAIN_PR_CREATED})
343
345
  **Status**: {TRAIN_STATUS}
344
346
  {If HELD: "**Held by**: {count} open finding(s) — fix and push to unblock:\n{HELD_BY}"}
345
347
  {If CLEAR: "**Ready to merge**: no open findings on the train PR"}
348
+ {If INTEGRATION_RED: "**Ship-gate risk**: red integration run:\n{INTEGRATION_RED}"}
346
349
  {If NO_TRAIN: "No open staging→main PR — run /review-pr-staging to open one"}
347
350
  **Queued commits**: {QUEUED_COMMITS} (commits in staging not yet in main)
348
351
 
package/commands/issue.md CHANGED
@@ -85,6 +85,8 @@ Duplicates: the script's token-overlap dedup is authoritative. Before drafting,
85
85
 
86
86
  ### 3. Draft
87
87
 
88
+ **External prerequisite** (credential, account, sign-off): label `needs-human:external` and put `gates: preview|staging-deploy|prod-deploy|feature-flag:<name>` and `owner:` in the body (#4660).
89
+
88
90
  **Title**: `{prefix}({scope}): {specific description}`, max 80 characters, no vague verbs ("improve", "update", "handle") without specifics.
89
91
 
90
92
  **Body** (pipeline template; `## Problem`, `## Affected Files` and `## Acceptance Criteria` are mandatory, the rest optional; in greenfield mode Affected Files may list *planned* paths inside the spike's declared repo layout):
@@ -96,6 +96,8 @@ Parse `$ARGUMENTS` to determine which action to take:
96
96
  | `drift <slug>` | → **Milestone Drift** |
97
97
  | `close <slug>` | → **Close Milestone** |
98
98
 
99
+ Unattended multi-milestone runs use the CLI, not this spec: `forgedock milestone train <slug...> [--parallel-milestones N]` (resume with `--resume <id>`, add a milestone with `--insert <id> <slug>`). It keeps one state file, reuses the lifecycle above and never edits `forge.yaml`. See `devdocs/decisions/4658-milestone-train.md`.
100
+
99
101
  ---
100
102
 
101
103
  ## Action: Create Milestone
@@ -356,6 +358,10 @@ else
356
358
  fi
357
359
  ```
358
360
 
361
+ ### Step 6C: Re-validate dependency order
362
+
363
+ After a milestone is added, the train order changes or an issue is decomposed, run `npx forgedock plan validate --live` (#4659). Fix every `invalid` edge (a dependency in a later milestone) before continuing; `held` edges are reported only.
364
+
359
365
  ### Step 7: Report
360
366
 
361
367
  ```
@@ -444,37 +450,28 @@ git fetch origin milestone/{slug}
444
450
  OPEN_COUNT=$(gh issue list --milestone "{TITLE}" --state open --json number --jq '. | length')
445
451
  ```
446
452
 
447
- If there are open issues, warn the user:
453
+ If there are open issues, this is a hard gate (interactive and unattended; unattended runs fail instead of asking). Every open issue must be **carried over** with a target milestone *and* a class (`engineering|external|post-deploy|wont-do`), or closed first. The class comes from labels (`carry:<class>`, `needs-human:external`, `awaiting-promotion|awaiting-precondition|awaiting-measurement`, `wont-do`; else `engineering`); the target is a `carry-to:<milestone title>` label or the user's answer. Run the check with `preflightCarryOver` (`bin/lean/lifecycle.mjs`):
454
+
448
455
  ```
449
- ⚠ Milestone "{TITLE}" still has {OPEN_COUNT} open issues:
450
- {list them}
456
+ ⚠ Milestone "{TITLE}" still has {OPEN_COUNT} open issues without a carry-over target:
457
+ {list them with their class}
451
458
 
452
- Ship anyway? Open issues will remain assigned to the milestone.
459
+ Name a target milestone for each (or close it). Ship is blocked until every open issue has one.
453
460
  ```
454
461
 
455
- Wait for confirmation.
462
+ An issue that is `needs-human:external`, flag/legal-gated (`flag-gated`, `legal-gated`) or depends on an issue that is not on the target base is **never** demoted to fast lane: it is carried to its target milestone.
456
463
 
457
464
  ### Step 2: Sync milestone branch with staging
458
465
 
459
- Before shipping, ensure the milestone branch is up to date with `staging` (picks up any fast-lane fixes that accumulated since the milestone branch was created):
466
+ Before shipping, bring the milestone branch up to date with `staging` (picks up fast-lane fixes that accumulated since it was cut). `forgedock milestone-sync` does it deterministically (#4663): a milestone already `shipped:*`/`deployed:default`/`closed` is refused, a branch already containing staging is a no-op, append-only registry conflicts are resolved by the lane base-merge resolver (`lean.base_merge_*`), and a protected branch (ruleset or branch protection, or a push the rules reject) is synced through the single `sync/milestone-{slug}` PR with auto-merge armed (merged directly when CLEAN). A `ship` sync is never debounced.
460
467
 
461
468
  ```bash
462
469
  cd {REPO_PATH}
463
- git fetch origin {STAGING_BRANCH} milestone/{slug}
464
- # Never mutate the main checkout: sync in a detached worktree at the milestone tip
465
- SYNC_WT=$(mktemp -d)
466
- git worktree add --detach "$SYNC_WT" origin/milestone/{slug}
467
- if git -C "$SYNC_WT" merge origin/{STAGING_BRANCH} --no-edit; then
468
- git -C "$SYNC_WT" push origin HEAD:refs/heads/milestone/{slug}
469
- git worktree remove --force "$SYNC_WT"
470
- else
471
- # Conflicts: report to user and STOP — do not auto-resolve
472
- git -C "$SYNC_WT" merge --abort 2>/dev/null || true
473
- git worktree remove --force "$SYNC_WT"
474
- echo "STOP: merge conflict syncing milestone/{slug} with {STAGING_BRANCH}"; exit 1
475
- fi
470
+ forgedock milestone-sync {slug} --reason ship --base {STAGING_BRANCH} --repo {owner}/{repo} || { echo "STOP: milestone sync failed or conflicts"; exit 1; }
476
471
  ```
477
472
 
473
+ If the sync went through a PR (`pr-created`/`pr-updated`), wait for it to merge before Step 2.5.
474
+
478
475
  ### Step 2.5: Pre-Merge Hunk-Loss Audit (MANDATORY before creating PR)
479
476
 
480
477
  **WHY THIS EXISTS**: When a long-lived milestone branch is merged into staging, git hunks that exist in staging but were NOT touched by the milestone can be silently dropped. This happened in a 20h window that produced 13 regression-fix PRs (27% of total pipeline throughput) restoring lost code: postgres tuning params, completion_refund sweep, credit_ledger columns, browser flags, pool guards, Redis config, crawl TTL refresh, challenge_strategy guard. Each regression required investigation → new issue → new PR → review → merge — consuming nearly a third of pipeline capacity on damage repair instead of forward progress.
@@ -730,24 +727,28 @@ Run /milestone ship {slug} again when the milestone is ready for another shippin
730
727
  ```bash
731
728
  gh pr merge {PR_NUMBER} --merge
732
729
 
733
- # Step 6A: Promote remaining open issues to fast lane
734
- # Query open issues still on this milestone (per_page=100 to avoid silent truncation beyond default 30)
735
- OPEN_ISSUES=$(gh api "repos/{owner}/{repo}/issues?milestone={MS_NUMBER}&state=open&per_page=100" \
736
- --jq '.[].number')
730
+ # Step 6A: Carry over remaining open issues (forge#4657). Never clear the milestone: every open issue moves to its
731
+ # target milestone (Step 1 gate) and keeps its class label. Only an issue whose preflightCarryOver entry has demote=true
732
+ # and whose target is "fast lane" may lose its milestone; external, gated and off-base-dependent issues are never demoted.
733
+ OPEN_ISSUES=$(gh api "repos/{owner}/{repo}/issues?milestone={MS_NUMBER}&state=open&per_page=100" --jq '.[].number')
737
734
  OPEN_COUNT=$(echo "$OPEN_ISSUES" | grep -c '[0-9]' 2>/dev/null || echo 0)
738
-
739
- if [ "$OPEN_COUNT" -gt 0 ]; then
740
- echo "Promoting $OPEN_COUNT open issue(s) to fast lane (removing milestone assignment)..."
741
- for ISSUE_NUM in $OPEN_ISSUES; do
742
- gh api "repos/{owner}/{repo}/issues/$ISSUE_NUM" -X PATCH --field milestone=null
743
- echo " Demoted #$ISSUE_NUM → fast lane"
744
- done
745
- else
746
- echo "No open issues remain on the milestone — nothing to demote."
747
- fi
748
-
749
- # Step 6B: Close the milestone
750
- gh api repos/{owner}/{repo}/milestones/{MILESTONE_NUMBER} --method PATCH --field state="closed"
735
+ for ISSUE_NUM in $OPEN_ISSUES; do
736
+ gh issue edit "$ISSUE_NUM" --milestone "{TARGET_TITLE_OF_ISSUE}" --add-label "carry:{CLASS_OF_ISSUE}" # <!-- allowlist:check-command-side-effects -->
737
+ echo " Carried #$ISSUE_NUM -> {TARGET_TITLE_OF_ISSUE} ({CLASS_OF_ISSUE})"
738
+ done
739
+
740
+ # Step 6B: Record the lifecycle. The merge is on staging, not yet on the default branch: write
741
+ # a FORGE:LIFECYCLE record ({"v":1,"state":"shipped:staging","sha":"<merge commit>"}) in the milestone description and
742
+ # close the milestone only when forge.yaml `milestones.close_on` (default `default`) is reached. With close_on=default the
743
+ # milestone stays open until the staging -> default merge is observed (deployed:default), then it closes.
744
+ MERGE_SHA=$(gh pr view {PR_NUMBER} --json mergeCommit --jq '.mergeCommit.oid')
745
+ DESC=$(gh api repos/{owner}/{repo}/milestones/{MILESTONE_NUMBER} --jq '.description // ""')
746
+ TAG="FORGE:LIFECYCLE"
747
+ BLOCK="<!-- $TAG {\"v\":1,\"state\":\"shipped:staging\",\"sha\":\"$MERGE_SHA\"} -->"
748
+ gh api repos/{owner}/{repo}/milestones/{MILESTONE_NUMBER} --method PATCH --field description="$(printf '%s\n\n%s' "$(printf '%s' "$DESC" | sed "/$TAG/d")" "$BLOCK")"
749
+ # close_on=staging only (and only when no issue is left open on the milestone):
750
+ # gh api repos/{owner}/{repo}/milestones/{MILESTONE_NUMBER} --method PATCH --field state="closed"
751
+ # Orchestrate-driven runners get the same transition from bin/lean/lifecycle.mjs advanceMilestone at batch end.
751
752
 
752
753
  # Step 6C: Delete the milestone branch
753
754
  git push origin --delete milestone/{slug}
@@ -758,8 +759,8 @@ Report:
758
759
  ```
759
760
  ## Shipped: {MILESTONE_TITLE}
760
761
 
761
- Merged to staging. Milestone branch deleted.
762
- {If OPEN_COUNT > 0: "{OPEN_COUNT} open issue(s) moved to fast lane: #{list}. They will target staging on next /work-on run."}
762
+ Merged to staging (lifecycle: shipped:staging). Milestone branch deleted. Milestone closes at `milestones.close_on` ({close_on}).
763
+ {If OPEN_COUNT > 0: "{OPEN_COUNT} open issue(s) carried over: #{list} -> {target} ({class}). None were demoted to fast lane."}
763
764
  {If OPEN_COUNT == 0: "No open issues remaining — clean close."}
764
765
 
765
766
  To deploy: merge `staging → main` via GitHub web UI when ready.
@@ -769,27 +770,15 @@ To deploy: merge `staging → main` via GitHub web UI when ready.
769
770
 
770
771
  ## Action: Sync Milestone Branch
771
772
 
772
- Syncs the milestone branch with the latest default branch to pick up fast-lane fixes. Run periodically on long-lived milestones.
773
+ Syncs the milestone branch with the latest default branch to pick up fast-lane fixes. Sync **on demand only**: before ship, before a batch, or when a conflict is expected. Never on every base move, and never a milestone whose lifecycle is `shipped:*` (the command refuses it; a shipped milestone is also never the "active" one to sync, `isSyncable` in `bin/lean/milestonesync.mjs`).
773
774
 
774
775
  ```bash
775
776
  cd {REPO_PATH}
776
777
  DEFAULT_BRANCH=$(yq '.branches.default' "$CONFIG_FILE")
777
- git fetch origin $DEFAULT_BRANCH milestone/{slug}
778
- # Never mutate the main checkout: sync in a detached worktree at the milestone tip
779
- SYNC_WT=$(mktemp -d)
780
- git worktree add --detach "$SYNC_WT" origin/milestone/{slug}
781
- if git -C "$SYNC_WT" merge origin/$DEFAULT_BRANCH --no-edit; then
782
- git -C "$SYNC_WT" push origin HEAD:refs/heads/milestone/{slug}
783
- git worktree remove --force "$SYNC_WT"
784
- else
785
- # If conflicts, report to user and STOP
786
- git -C "$SYNC_WT" merge --abort 2>/dev/null || true
787
- git worktree remove --force "$SYNC_WT"
788
- echo "STOP: merge conflict syncing milestone/{slug} with $DEFAULT_BRANCH"; exit 1
789
- fi
778
+ forgedock milestone-sync {slug} --reason manual --base "$DEFAULT_BRANCH" --repo {owner}/{repo}
790
779
  ```
791
780
 
792
- Report: "Synced milestone/{slug} with latest `{DEFAULT_BRANCH}`. {N} new commits incorporated."
781
+ Protected branches get ONE sync PR (`sync/milestone-{slug}`) whose head is force-pushed in place; a base that moved again inside `milestones.sync_debounce` (default 10m) reports `debounced` and leaves the PR (and its CI) alone. Report the printed status.
793
782
 
794
783
  ---
795
784
 
@@ -828,5 +817,5 @@ This automatically makes the issue a feature-lane issue — next time `/work-on`
828
817
 
829
818
  - **Milestone already exists**: Check first with `gh api repos/{owner}/{repo}/milestones --jq '.[] | select(.title == "{TITLE}")'`
830
819
  - **Branch already exists**: Reuse it, don't recreate
831
- - **Merge conflicts on sync/ship**: Report to user, do NOT auto-resolve. List conflicting files.
820
+ - **Merge conflicts on sync/ship**: append-only registry conflicts matching `lean.base_merge_*` are resolved deterministically by `milestone-sync`; any other conflict is reported with its files and the branch is left untouched. Do NOT hand-resolve.
832
821
  - **No issues in milestone**: Warn user — empty milestones are valid but unusual
@@ -1581,6 +1581,8 @@ The merged-trail hold on #${N} was released by a human (needs-human cleared or t
1581
1581
  return 0
1582
1582
  }
1583
1583
 
1584
+ # gh failures are unknown, not state (forge#4662): in every hold/settle/ship read, a failed gh call (HTTP 401, 5xx, timeout) is never
1585
+ # "not closed" or "no PR". Keep the previous classification, back off, and after 5 consecutive failures post one alert and stop that loop.
1584
1586
  # classify_predecessor_state <issue> (forge#3168): the helpers hold_merged_trail, resolve_orch_login,
1585
1587
  # trail_escalation_state and release_merged_trail above MUST be declared in the SAME shell before this function runs
1586
1588
  # (reverify_merged_trail is nested below). It has exactly ONE guarded write (the DRY_RUN-guarded hold_merged_trail
@@ -105,7 +105,7 @@ remaining maintenance once no feature is pending; otherwise the rest ends `held
105
105
  for them. After a decision (a `FORGE:DECISION` posted and `needs-human` removed), a blocked issue that already ended in the batch
106
106
  (`needs-human`, `invalid`, `skipped`, `dependency-*`, `ci-pending`) returns to it with
107
107
  `forgedock orchestrate --requeue <id> N... --reason "..."` (refused while it is labelled `needs-human` or closed; skipped dependents
108
- return to pending with it; a finished batch is reopened); `--add` skips such an issue as `already in batch`. Its pre-filter still closes already-fixed ones. The runner requeues `deferred` lanes by code (host busy and a full sandbox pool after their
108
+ return to pending with it; a finished batch is reopened); `--add` on a `skipped` issue re-queues it (other ended issues still report `already in batch`). A dependency-skipped issue shows as `waiting on #X` while the batch runs (the blocker is re-checked each pass; merged or closed re-admits it, closed as not planned keeps it skipped) and ends `skipped` only if the blocker never resolved. Its pre-filter still closes already-fixed ones. The runner requeues `deferred` lanes by code (host busy and a full sandbox pool after their
109
109
  retry-in, uncapped, a usage limit holds admissions for that account (backend identity) until the reset, across every batch on
110
110
  the host; `forgedock limits show|clear <id>|--all` lists or drops holds; transient merge/push/idle failures back off; other
111
111
  deferrals stop after `orchestration.max_deferrals`), relaunches an `engine-error` once, and stops admitting after
@@ -193,6 +193,13 @@ Escalate only these:
193
193
  Collect escalations into one question list and ask it once, at the end of the batch. Never ask per issue, and keep every
194
194
  other lane running meanwhile.
195
195
 
196
+ ## 3b. Settle scope (#4665)
197
+
198
+ When settle begins, freeze the milestone's issue set (`scripts/settle-scope.mjs` `freezeScope`). Issues filed later are
199
+ classified by `classifyNew`: decomposition children of a frozen issue stay in the milestone and join the measured set;
200
+ deferred follow-ups go to the next milestone or the backlog; review findings and others count as growth only.
201
+ `convergence` measures the frozen set plus those children; a STOP prints `formatStopReport` (growth and sources).
202
+
196
203
  ## 4. Report
197
204
 
198
205
  When all lanes have finished, print one table: issue, outcome, PR, minutes (issues the pre-filter closed