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
@@ -0,0 +1,44 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * forge#4629: `forgedock stats --experiment <id>`. Finished runs tagged with the experiment id, grouped by arm; each metric
5
+ * is null below the declared min n (release.mjs rule), a target is null when a value it needs is null, never a pass.
6
+ */
7
+ import { aggregate } from "./aggregate.mjs";
8
+ import { releaseMetrics } from "./release.mjs";
9
+ import { benchRecord } from "../experiment.mjs";
10
+
11
+ const isNum = (x) => typeof x === "number" && Number.isFinite(x);
12
+
13
+ /** One target against the per-arm metrics: absolute limit, or limit x the other arm's value. */
14
+ export function evalTarget(t, byArm) {
15
+ const value = byArm[t.arm]?.[t.metric], base = t.vs ? byArm[t.vs]?.[t.metric] : 1;
16
+ const limit = isNum(base) ? t.limit * base : null;
17
+ const pass = isNum(value) && isNum(limit) ? (t.op === "<=" ? value <= limit : value >= limit) : null;
18
+ return { pass, value: isNum(value) ? value : null, limit };
19
+ }
20
+
21
+ export function experimentReport(runs, exp) {
22
+ const mine = runs.filter((r) => r.finished && r.experiment === exp.id);
23
+ const arms = {}, byArm = {};
24
+ for (const arm of exp.arms) {
25
+ const rs = mine.filter((r) => r.arm === arm).map((r) => ({ ...r, version: "0.0.0" })); // one group per arm
26
+ const g = aggregate({ runs: rs }, { minN: exp.minN }).groups[0];
27
+ const m = g ? releaseMetrics([g], { minN: exp.minN }).versions[0] : null;
28
+ byArm[arm] = Object.fromEntries(exp.metrics.map((k) => [k, m?.[k] ?? null]));
29
+ arms[arm] = { n: rs.length, ...byArm[arm] };
30
+ }
31
+ const targets = Object.fromEntries(exp.targets.map((t) => [t.text, evalTarget(t, byArm)]));
32
+ const verdicts = Object.values(targets);
33
+ return { id: exp.id, minN: exp.minN, ...benchRecord({ source: "stats", arms, targets }),
34
+ targets_met: verdicts.length > 0 && verdicts.every((v) => v.pass === true) };
35
+ }
36
+
37
+ const fmt = (v) => (v == null ? "n/a" : Number.isInteger(v) ? String(v) : v.toFixed(2));
38
+ export function renderExperiment(r) {
39
+ const keys = Object.keys(Object.values(r.arms)[0] ?? {}).filter((k) => k !== "n");
40
+ const line = (c) => `| ${c.join(" | ")} |`;
41
+ const rows = Object.entries(r.arms).map(([a, v]) => line([a, String(v.n), ...keys.map((k) => fmt(v[k]))]));
42
+ const tg = Object.entries(r.targets).map(([t, v]) => `${t}: ${v.pass == null ? "n/a" : v.pass ? "met" : "missed"} (${fmt(v.value)} vs ${fmt(v.limit)})`);
43
+ return [`Experiment ${r.id} (min n ${r.minN}; n/a below it)`, line(["arm", "n", ...keys]), line(["---", "---", ...keys.map(() => "---")]), ...rows, ...tg, `targets met: ${r.targets_met}`].join("\n");
44
+ }
@@ -77,9 +77,10 @@ export function runsFromRecords(records) {
77
77
  const key = `${r.repo}#${r.issue}#${r.run ?? ""}`;
78
78
  const run = byRun.get(key) ?? { source: "github", repo: r.repo ?? null, version: r.forge_version ?? null, issue: r.issue, run: r.run ?? null,
79
79
  finished: false, hydrated: false, usageEvents: 0, reason: null, code: null, remediated: false, startTs: null, endTs: null, wallMs: null,
80
- phases: {}, builderAttempts: [], _first: null };
80
+ phases: {}, builderAttempts: [], experiment: null, arm: null, _first: null };
81
81
  byRun.set(key, run);
82
82
  if (!run._first || r.ts < run._first) run._first = r.ts;
83
+ if (r.experiment != null && r.arm != null) { run.experiment = String(r.experiment); run.arm = String(r.arm); } // #4629
83
84
  if (r.event === "run_terminal") {
84
85
  run.finished = true; run.reason = r.reason ?? null; run.code = r.code ?? null; run.endTs = r.ts ?? null; run.wallMs = num(r.wall_ms);
85
86
  continue;
@@ -16,6 +16,10 @@ import { parseForgeYaml } from "../forge-utils.mjs";
16
16
  import { renderCompact, renderJson, renderMarkdown } from "./render.mjs";
17
17
  import { edgeQuery, parseEdge, verdictMix, contextPackAb, renderContextAb, builderTierReport, renderTiers, exploreAb, renderExploreAb, laneProfileReport, renderLaneProfiles } from "./query.mjs";
18
18
  import { batchEfficiency, windowEfficiency, compareEfficiency, formatEfficiencyLines, formatCompareLines, parseWindows } from "./efficiency.mjs";
19
+ import { releaseMetrics, renderReleaseMetrics, metricsComment, postMetrics } from "./release.mjs";
20
+ import { experimentReport, renderExperiment } from "./experiment.mjs";
21
+ import { readExperiment } from "../experiment.mjs";
22
+ import { compoundingReport, gatherCompounding, renderCompounding, DEFAULT_BUCKETS, DEFAULT_WINDOW_MS } from "./compounding.mjs";
19
23
  import { poolUtilisation, formatPoolLines } from "./pool.mjs";
20
24
  import { batchRoot } from "../lean/batch.mjs";
21
25
 
@@ -23,7 +27,10 @@ const here = dirname(fileURLToPath(import.meta.url));
23
27
 
24
28
  export const HELP = `Usage: forgedock stats [--since <version|date>] [--repo <owner/repo>] [--json] [--refresh]
25
29
  [--compact] [--no-local] [--no-cache] [--min-n N] [--release-times FILE]
30
+ forgedock stats --experiment <id> [--require-met] [--min-n N] [--repo <owner/repo>] [--json]
31
+ forgedock stats --by version [--post-metrics <pr>] [--min-n N] [--repo <owner/repo>] [--json]
26
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]
27
34
  forgedock stats --efficiency [--batch <id>] [--window 7d,30d] [--compare <date|version>] [--offline] [--repo <owner/repo>] [--json]
28
35
 
29
36
  Version-over-version report: Cost, Time, Flow, Review funnel, Gates and Issue flow per (version, repo), with a
@@ -42,6 +49,14 @@ delta against the previous version and a low-n flag for samples under 10. Missin
42
49
  --release-times FILE "<version> <epoch-seconds>" lines used to estimate versions of unversioned records
43
50
  (default: git tags v* of the ForgeDock checkout)
44
51
 
52
+ Per-release regression rule (forge#4628):
53
+ --require-met with --experiment: exit 1 unless every target is met (#4634; for a named post-deploy check)
54
+ --experiment <id> arms of the forge.yaml experiment: section (arms, ratio, metrics, targets, min_n) against its targets; n/a below min n
55
+ --emit-event with --experiment: emit one experiment.verdict event (met or missed; docs/EVENTS.md)
56
+ --by version median cost per run, wall p50, first-pass rate and needs-human rate of each release against the
57
+ previous one; a metric under --min-n runs is null and its verdict null, never a pass
58
+ --post-metrics <pr> post (or update) the <!-- FORGE:METRICS v1 --> snapshot comment on that release PR
59
+
45
60
  ALERTS (forge#4354; local run logs only, skipped with --no-local): fail-soft rates over the last 50 lanes of the repo
46
61
  (DEPS_PREPARED failed per ecosystem, deferred-to-ci, repeated TOOLCHAIN_WARNING, outcome codes whose share doubled);
47
62
  thresholds under forge.yaml stats.alerts.*. Nothing is printed when none fired or under 10 lanes.
@@ -71,6 +86,13 @@ Efficiency (forge#4402; local batch dirs and run logs, gh only for merged issues
71
86
  their delta and full-lane cohorts matched per size (forge#4491)
72
87
  --offline skip the GitHub crawl for merged issues (issue quality and acceptance print n/a)
73
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
+
74
96
  Builder model tiers (forge#4394; local run logs only, no GitHub crawl):
75
97
  --tiers merge, conformance and post-merge-rework rates, first-pass yield and cost per builder tier
76
98
  (mid, mid->top escalated, top) and routing class; --json for JSON
@@ -81,8 +103,11 @@ Lane profile baseline (forge#4436; local run logs only, every run of each issue,
81
103
  --issues <list> pin the report to these issues (comma list), for a before/after on the same issue set
82
104
  `;
83
105
 
106
+ /** #4634: 0, or 1 when --require-met and the experiment's targets are not all met (a named post-deploy check gates on it). */
107
+ export function experimentExit(opts, report) { return opts.requireMet && report.targets_met !== true ? 1 : 0; }
108
+
84
109
  export function parseArgs(argv) {
85
- 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, 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 };
86
111
  const val = (i, name) => {
87
112
  const v = argv[i + 1];
88
113
  if (v == null || v.startsWith("--")) throw new Error(`${name} needs a value`);
@@ -101,6 +126,8 @@ export function parseArgs(argv) {
101
126
  else if (name === "--context-ab") o.contextAb = true;
102
127
  else if (name === "--explore-ab") o.exploreAb = true;
103
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; }
104
131
  else if (name === "--lane-profile") o.laneProfile = true;
105
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); }
106
133
  else if (name === "--efficiency") o.efficiency = true;
@@ -112,8 +139,13 @@ export function parseArgs(argv) {
112
139
  try { if (!v || !Object.keys(parseSince(v)).length) throw bad(); } catch { throw bad(); }
113
140
  o.compare = v;
114
141
  }
142
+ else if (name === "--by") { const v = take(); if (v !== "version") throw new Error("--by must be version"); o.by = v; }
143
+ else if (name === "--experiment") { const v = take(); if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v)) throw new Error("--experiment must be an experiment id"); o.experiment = v; }
144
+ else if (name === "--emit-event") o.emitEvent = true;
145
+ else if (name === "--post-metrics") { const v = take(); if (!/^[1-9]\d*$/.test(v)) throw new Error("--post-metrics must be a PR number"); o.postMetrics = Number(v); }
146
+ else if (name === "--require-met") o.requireMet = true;
115
147
  else if (name === "--offline") o.offline = true;
116
- else if (name === "--min-n") { const v = take(); if (!/^\d+$/.test(v)) throw new Error("--min-n must be a non-negative integer"); o.minN = Number(v); }
148
+ else if (name === "--min-n") { const v = take(); if (!/^\d+$/.test(v)) throw new Error("--min-n must be a non-negative integer"); o.minN = Number(v); o.minNSet = true; }
117
149
  else if (name === "--json") o.json = true;
118
150
  else if (name === "--refresh") o.refresh = true;
119
151
  else if (name === "--compact") o.compact = true;
@@ -123,8 +155,10 @@ export function parseArgs(argv) {
123
155
  else throw new Error(`unknown argument: ${a}`);
124
156
  }
125
157
  if (o.batch && !o.efficiency) throw new Error("--batch and --window need --efficiency");
126
- 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");
127
160
  if ((o.compare || o.offline) && !o.efficiency) throw new Error("--compare and --offline need --efficiency");
161
+ if (o.postMetrics && !o.by) throw new Error("--post-metrics needs --by version");
128
162
  return o;
129
163
  }
130
164
 
@@ -280,6 +314,12 @@ export async function buildReport(opts, deps = {}) {
280
314
  return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache } }, query };
281
315
  }
282
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
+ }
283
323
  const ghKeys = new Set(model.runs.map((r) => `${r.repo}#${r.issue}`));
284
324
  let local = [];
285
325
  if (opts.local) {
@@ -287,6 +327,12 @@ export async function buildReport(opts, deps = {}) {
287
327
  }
288
328
  const report = aggregate({ runs: [...model.runs, ...local], events: model.events, issues: model.issues },
289
329
  { minN: opts.minN, releaseTimes, ...parseSince(opts.since) });
330
+ if (opts.experiment) { // forge#4629
331
+ const exp = deps.experiment ?? readExperiment();
332
+ if (!exp || exp.id !== opts.experiment) throw new Error(`experiment ${opts.experiment} is not declared in forge.yaml (experiment: id, arms, ...)`);
333
+ return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache }, local: { runs: local.length } }, query: { experiment: experimentReport([...model.runs, ...local], { ...exp, minN: opts.minNSet ? opts.minN : exp.minN }) } };
334
+ }
335
+ if (opts.by) return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache }, local: { runs: local.length } }, query: { release_metrics: releaseMetrics(report.groups, { minN: opts.minN, repo }) } };
290
336
  // forge#4354: fail-soft alerts from the local run logs only (no gh call)
291
337
  const alerts = opts.local ? computeAlerts(deps.localLanes ?? readLanes(runsDir(env)), { repo, ...(deps.alertConfig ?? readAlertConfig()) }) : [];
292
338
  return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache }, local: { runs: local.length } }, ...report, alerts };
@@ -299,6 +345,10 @@ export async function runStats(argv, deps = {}) {
299
345
  return 2;
300
346
  }
301
347
  if (opts.help) { process.stdout.write(HELP); return 0; }
348
+ if (opts.requireMet && !opts.experiment) {
349
+ process.stderr.write(`forgedock stats: --require-met needs --experiment <id>\n`);
350
+ return 2;
351
+ }
302
352
  opts.repo = resolveRepo(opts.repo);
303
353
  if (!opts.repo || !isValidRepo(opts.repo)) {
304
354
  process.stderr.write(`forgedock stats: could not resolve a valid owner/repo (pass --repo owner/repo)\n`);
@@ -306,8 +356,17 @@ export async function runStats(argv, deps = {}) {
306
356
  }
307
357
  try {
308
358
  const report = await buildReport(opts, { onProgress: makeProgressPrinter(), ...deps });
359
+ if (report.query?.release_metrics) {
360
+ const rm = report.query.release_metrics;
361
+ if (opts.postMetrics) await postMetrics({ gh: deps.gh ?? defaultGh, repo: opts.repo, pr: opts.postMetrics, body: metricsComment(rm) });
362
+ process.stdout.write(`${opts.json ? renderJson(report) : renderReleaseMetrics(rm)}\n`);
363
+ return 0;
364
+ }
365
+ if (report.query?.experiment && opts.emitEvent) await (deps.emitExperiment ?? (async (r, x) => (await import("../engine-cli.mjs")).experimentEventSink(r, x)))(opts.repo, report.query.experiment);
366
+ if (report.query?.experiment) { process.stdout.write(`${opts.json ? renderJson(report) : renderExperiment(report.query.experiment)}\n`); return experimentExit(opts, report.query.experiment); }
309
367
  if (report.query?.lane_profile && !opts.json) { process.stdout.write(`${renderLaneProfiles(report.query.lane_profile)}\n`); return 0; }
310
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; }
311
370
  if (report.query?.pool && !opts.json) { process.stdout.write(`${formatPoolLines(report.query.pool).join("\n")}\n`); return 0; }
312
371
  if (report.query?.efficiency && !opts.json) {
313
372
  const e = report.query.efficiency;
@@ -0,0 +1,75 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * forge#4628: per-release regression rule over the `aggregate()` groups. Each metric is null when its own sample is
5
+ * below minN; a verdict is null when either side is null. There is no "pass" value, only regressed|improved|flat|null.
6
+ */
7
+ import { DEFAULT_MIN_N, UNKNOWN, compareVersions } from "./aggregate.mjs";
8
+
9
+ export const METRICS_MARKER = "<!-- FORGE:METRICS v1 -->";
10
+ const isNum = (x) => typeof x === "number" && Number.isFinite(x);
11
+ // lower is better for all but the first-pass rate
12
+ const METRICS = [
13
+ { key: "usd_median", label: "median cost/run", lowerBetter: true, flat: 0.05 },
14
+ { key: "wall_p50_ms", label: "wall p50", lowerBetter: true, flat: 0.05 },
15
+ { key: "first_pass_rate", label: "first-pass rate", lowerBetter: false, flat: 0.02 },
16
+ { key: "needs_human_rate", label: "needs-human rate", lowerBetter: true, flat: 0.02 },
17
+ ];
18
+
19
+ /** The metric values of one group, each null below minN (its own n). */
20
+ function valuesOf(g, minN) {
21
+ const pick = (n, v) => (isNum(n) && n >= minN && isNum(v) ? v : null);
22
+ return {
23
+ n: g.n,
24
+ usd_median: pick(g.cost?.usdN, g.cost?.usdMedian),
25
+ wall_p50_ms: pick(g.time?.wall?.n, g.time?.wall?.p50),
26
+ first_pass_rate: pick(g.firstPass?.n, g.firstPass?.rate),
27
+ needs_human_rate: pick(g.flow?.n, g.flow?.needsHumanPct),
28
+ };
29
+ }
30
+
31
+ /** Relative change for cost and time, absolute for rates; flat inside the tolerance. */
32
+ function verdict(m, cur, prev) {
33
+ if (!isNum(cur) || !isNum(prev)) return null;
34
+ const rate = m.key.endsWith("_rate");
35
+ const d = rate ? cur - prev : prev === 0 ? (cur === 0 ? 0 : Infinity) : (cur - prev) / prev;
36
+ if (Math.abs(d) <= m.flat) return "flat";
37
+ return (d < 0) === m.lowerBetter ? "improved" : "regressed";
38
+ }
39
+
40
+ /** Known versions of one repo, oldest first, each with its metrics and the verdicts against the previous one. */
41
+ export function releaseMetrics(groups, { minN = DEFAULT_MIN_N, repo = null } = {}) {
42
+ const known = groups.filter((g) => g.version !== UNKNOWN && (repo == null || g.repo === repo))
43
+ .sort((a, b) => compareVersions(a.version, b.version));
44
+ const versions = known.map((g, i) => {
45
+ const metrics = valuesOf(g, minN), prev = i ? valuesOf(known[i - 1], minN) : null;
46
+ const verdicts = Object.fromEntries(METRICS.map((m) => [m.key, prev ? verdict(m, metrics[m.key], prev[m.key]) : null]));
47
+ return { version: g.version, against: i ? known[i - 1].version : null, ...metrics, verdicts };
48
+ });
49
+ return { repo, minN, versions };
50
+ }
51
+
52
+ const fmt = (key, v) => (v == null ? "n/a" : key === "usd_median" ? `$${v.toFixed(2)}` : key === "wall_p50_ms" ? `${(v / 60000).toFixed(1)}m` : `${(v * 100).toFixed(0)}%`);
53
+
54
+ export function renderReleaseMetrics(rm) {
55
+ const rows = rm.versions.map((v) => [v.version, String(v.n), ...METRICS.map((m) => `${fmt(m.key, v[m.key])} (${v.verdicts[m.key] ?? "n/a"})`)]);
56
+ const head = ["version", "n", ...METRICS.map((m) => m.label)];
57
+ const line = (r) => `| ${r.join(" | ")} |`;
58
+ return [`Release metrics (min n ${rm.minN}; n/a below it)`, line(head), line(head.map(() => "---")), ...rows.map(line)].join("\n");
59
+ }
60
+
61
+ /** The FORGE:METRICS comment: marker, table and the snapshot as fenced JSON. */
62
+ export function metricsComment(rm) {
63
+ return `${METRICS_MARKER}\n${renderReleaseMetrics(rm)}\n\n\`\`\`json\n${JSON.stringify(rm)}\n\`\`\`\n`;
64
+ }
65
+
66
+ /** Create the FORGE:METRICS comment on the PR, or update the existing one (argv-based gh). */
67
+ export async function postMetrics({ gh, repo, pr, body }) {
68
+ const raw = await gh(["api", "--paginate", `repos/${repo}/issues/${pr}/comments`]);
69
+ const pages = String(raw ?? "").trim().replace(/\]\s*\[/g, ",");
70
+ const list = pages ? JSON.parse(pages.startsWith("[") ? pages : `[${pages}]`) : [];
71
+ const mine = list.filter((c) => typeof c?.body === "string" && c.body.startsWith(METRICS_MARKER)).pop();
72
+ if (mine) await gh(["api", "-X", "PATCH", `repos/${repo}/issues/comments/${mine.id}`, "-f", `body=${body}`]);
73
+ else await gh(["api", "-X", "POST", `repos/${repo}/issues/${pr}/comments`, "-f", `body=${body}`]);
74
+ return mine ? "updated" : "created";
75
+ }
@@ -0,0 +1,68 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { aggregate } from "./aggregate.mjs";
6
+ import { parseArgs } from "./index.mjs";
7
+ import { METRICS_MARKER, metricsComment, postMetrics, releaseMetrics, renderReleaseMetrics } from "./release.mjs";
8
+
9
+ const trig = (fp) => [{ trigger: "build" }, ...(fp ? [] : [{ trigger: "review" }])];
10
+ function runs(version, n, { usd, wall, fp, human }) {
11
+ return Array.from({ length: n }, (_, i) => ({
12
+ source: "github", repo: "o/r", version, issue: i + (version === "1.2.0" ? 100 : 0),
13
+ finished: true, usageEvents: 1, reason: i < human ? "needs-human" : "merged", wallMs: wall, endTs: "2026-10-01T00:00:00Z",
14
+ phases: { build: { usd, cr: 1, ms: 1 } }, builderAttempts: trig(i < fp),
15
+ }));
16
+ }
17
+ const groupsFor = (all, minN = 10) => aggregate({ runs: all }, { minN }).groups;
18
+
19
+ describe("releaseMetrics (#4628)", () => {
20
+ const prev = runs("1.1.0", 12, { usd: 2, wall: 600000, fp: 6, human: 1 });
21
+ it("compares each metric with the previous release and never reports a pass", () => {
22
+ const cur = runs("1.2.0", 12, { usd: 3, wall: 300000, fp: 11, human: 1 });
23
+ const rm = releaseMetrics(groupsFor([...prev, ...cur]), { minN: 10 });
24
+ const v = rm.versions[1];
25
+ assert.equal(v.against, "1.1.0");
26
+ assert.deepEqual(v.verdicts, { usd_median: "regressed", wall_p50_ms: "improved", first_pass_rate: "improved", needs_human_rate: "flat" });
27
+ assert.equal(rm.versions[0].verdicts.usd_median, null);
28
+ assert.ok(!JSON.stringify(rm).includes("pass\""));
29
+ });
30
+ it("is null below min n, and the verdict is null when either side is", () => {
31
+ const cur = runs("1.2.0", 3, { usd: 9, wall: 1, fp: 0, human: 3 });
32
+ const rm = releaseMetrics(groupsFor([...prev, ...cur]), { minN: 10 });
33
+ const v = rm.versions[1];
34
+ assert.deepEqual([v.usd_median, v.wall_p50_ms, v.first_pass_rate, v.needs_human_rate], [null, null, null, null]);
35
+ assert.deepEqual(Object.values(v.verdicts), [null, null, null, null]);
36
+ assert.match(renderReleaseMetrics(rm), /n\/a/);
37
+ });
38
+ it("skips unknown versions", () => {
39
+ const rm = releaseMetrics(groupsFor(runs(null, 12, { usd: 1, wall: 1, fp: 1, human: 0 })), { minN: 1 });
40
+ assert.deepEqual(rm.versions, []);
41
+ });
42
+ });
43
+
44
+ describe("FORGE:METRICS comment (#4628)", () => {
45
+ const rm = releaseMetrics([], { minN: 10, repo: "o/r" });
46
+ it("carries the marker and the JSON snapshot", () => {
47
+ const b = metricsComment(rm);
48
+ assert.ok(b.startsWith(METRICS_MARKER));
49
+ assert.deepEqual(JSON.parse(/```json\n(.*)\n```/s.exec(b)[1]), rm);
50
+ });
51
+ it("creates the comment, then updates it", async () => {
52
+ const calls = [];
53
+ const mk = (list) => (args) => { calls.push(args.join(" ")); return args[0] === "api" && args.length === 3 ? JSON.stringify(list) : ""; };
54
+ assert.equal(await postMetrics({ gh: mk([{ id: 1, body: "x" }]), repo: "o/r", pr: 7, body: "b" }), "created");
55
+ assert.match(calls.at(-1), /-X POST repos\/o\/r\/issues\/7\/comments/);
56
+ assert.equal(await postMetrics({ gh: mk([{ id: 5, body: `${METRICS_MARKER}\nold` }]), repo: "o/r", pr: 7, body: "b" }), "updated");
57
+ assert.match(calls.at(-1), /-X PATCH repos\/o\/r\/issues\/comments\/5/);
58
+ });
59
+ });
60
+
61
+ describe("--by / --post-metrics parsing (#4628)", () => {
62
+ it("accepts version and a PR number; rejects the rest", () => {
63
+ assert.equal(parseArgs(["--by", "version", "--post-metrics", "12"]).postMetrics, 12);
64
+ assert.throws(() => parseArgs(["--by", "repo"]), /--by must be version/);
65
+ assert.throws(() => parseArgs(["--post-metrics", "12"]), /needs --by version/);
66
+ assert.throws(() => parseArgs(["--by", "version", "--post-metrics", "x"]), /PR number/);
67
+ });
68
+ });
@@ -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