forgedock 1.18.0 → 1.19.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 (116) hide show
  1. package/bin/engine/outcome-codes.mjs +13 -0
  2. package/bin/engine/phases.mjs +54 -4
  3. package/bin/engine/runlog.mjs +10 -2
  4. package/bin/engine/telemetry-seam.mjs +3 -0
  5. package/bin/engine/telemetry.test.mjs +3 -2
  6. package/bin/engine-cli.mjs +32 -2
  7. package/bin/engine.mjs +139 -7
  8. package/bin/runner.mjs +69 -9
  9. package/bin/stats/aggregate.mjs +7 -3
  10. package/bin/stats/cache.mjs +7 -3
  11. package/bin/stats/github-source.mjs +136 -28
  12. package/bin/stats/index.mjs +46 -9
  13. package/bin/stats/render.mjs +1 -1
  14. package/bin/stats/stats.test.mjs +201 -40
  15. package/commands/review-pr-staging.md +4 -4
  16. package/commands/review-pr.md +25 -15
  17. package/commands/work-on/build/architect.md +53 -0
  18. package/commands/work-on/build/implement.md +19 -0
  19. package/commands/work-on/build/validate.md +320 -28
  20. package/commands/work-on/build.md +66 -9
  21. package/commands/work-on/investigate.md +7 -0
  22. package/commands/work-on/remediate.md +32 -13
  23. package/commands/work-on/review.md +32 -19
  24. package/docs/CONFIG.md +89 -2
  25. package/docs/OUTCOME-CODES.md +5 -1
  26. package/docs/STATS.md +11 -4
  27. package/docs/TELEMETRY.md +5 -2
  28. package/docs/WORK-ON-RUNTIME.md +18 -0
  29. package/package.json +2 -2
  30. package/packages/protocol/src/types.js +1 -1
  31. package/scripts/acceptance-red-green.sh +90 -0
  32. package/scripts/acceptance-red-green.test.sh +74 -0
  33. package/scripts/cap-findings.test.sh +2 -0
  34. package/scripts/check-contract-scope.test.sh +2 -0
  35. package/scripts/check-edge-cases.sh +143 -0
  36. package/scripts/check-edge-cases.test.sh +175 -0
  37. package/scripts/check-spec-markers.sh +2 -0
  38. package/scripts/check-test-tmp-cleanup.sh +56 -0
  39. package/scripts/check-test-tmp-cleanup.test.sh +69 -0
  40. package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
  41. package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
  42. package/scripts/ci-local/adapters/buildkite.mjs +58 -0
  43. package/scripts/ci-local/adapters/circleci.mjs +101 -0
  44. package/scripts/ci-local/adapters/common.mjs +115 -0
  45. package/scripts/ci-local/adapters/explicit.mjs +21 -0
  46. package/scripts/ci-local/adapters/github-actions.mjs +349 -0
  47. package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
  48. package/scripts/ci-local/adapters/portability.mjs +104 -0
  49. package/scripts/ci-local/adapters/task-runners.mjs +67 -0
  50. package/scripts/ci-local/cli.mjs +50 -0
  51. package/scripts/ci-local/lib.mjs +371 -0
  52. package/scripts/ci-local/plan.mjs +186 -0
  53. package/scripts/ci-local/run.mjs +345 -0
  54. package/scripts/ci-local/scoping.mjs +508 -0
  55. package/scripts/ci-local.sh +97 -0
  56. package/scripts/ci-local.test.sh +919 -0
  57. package/scripts/classify-finding.sh +32 -6
  58. package/scripts/classify-finding.test.sh +23 -0
  59. package/scripts/classify-test-run.sh +69 -0
  60. package/scripts/classify-test-run.test.sh +90 -0
  61. package/scripts/close-parent-c3.test.sh +2 -0
  62. package/scripts/consumer-sweep.sh +569 -0
  63. package/scripts/consumer-sweep.test.sh +357 -0
  64. package/scripts/detect-append-registries.sh +106 -0
  65. package/scripts/detect-append-registries.test.sh +64 -0
  66. package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
  67. package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
  68. package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
  69. package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
  70. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
  71. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
  72. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
  73. package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
  74. package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
  75. package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
  76. package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
  77. package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
  78. package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
  79. package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
  80. package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
  81. package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
  82. package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
  83. package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
  84. package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
  85. package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
  86. package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
  87. package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
  88. package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
  89. package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
  90. package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
  91. package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
  92. package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
  93. package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
  94. package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
  95. package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
  96. package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
  97. package/scripts/forge-root.test.sh +1 -1
  98. package/scripts/lib/telemetry.sh +1 -0
  99. package/scripts/remediate-approved-head.test.sh +2 -0
  100. package/scripts/remediate-basesync.test.sh +1 -1
  101. package/scripts/review-acceptance-gate.sh +2 -2
  102. package/scripts/review-acceptance-gate.test.sh +20 -0
  103. package/scripts/review-delta.sh +8 -1
  104. package/scripts/review-delta.test.sh +19 -0
  105. package/scripts/review-finalize.sh +193 -41
  106. package/scripts/review-finalize.test.sh +211 -4
  107. package/scripts/review-pr-delta-wiring.test.sh +13 -0
  108. package/scripts/review-prepare.sh +5 -3
  109. package/scripts/review-prepare.test.sh +18 -2
  110. package/scripts/run-shell-tests.sh +5 -0
  111. package/scripts/telemetry.test.sh +2 -0
  112. package/scripts/trusted-comments.test.sh +2 -1
  113. package/scripts/verify-phase-trail.test.sh +8 -0
  114. package/scripts/wait-ci-green.sh +61 -9
  115. package/scripts/wait-ci-green.test.sh +41 -3
  116. package/scripts/with-run-tmpdir.sh +20 -0
@@ -1,8 +1,9 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { tmpDir } from "../tests/_tmp.mjs";
3
4
  import { describe, it, beforeEach, afterEach } from "node:test";
4
5
  import assert from "node:assert/strict";
5
- import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
6
+ import { readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
6
7
  import { tmpdir } from "node:os";
7
8
  import { dirname, join } from "node:path";
8
9
  import { fileURLToPath } from "node:url";
@@ -10,7 +11,7 @@ import { spawnSync } from "node:child_process";
10
11
  import { aggregate, compareVersions, percentile } from "./aggregate.mjs";
11
12
  import { cacheDir } from "./cache.mjs";
12
13
  import { fetchGithubRecords, parseTelemetryComments, runsFromRecords, toModel } from "./github-source.mjs";
13
- import { buildReport, parseArgs, parseSince, runStats } from "./index.mjs";
14
+ import { buildReport, makeProgressPrinter, parseArgs, parseSince, runStats, sinceToTimestamp } from "./index.mjs";
14
15
  import { readLocalRuns } from "./local-source.mjs";
15
16
  import { renderCompact, renderJson, renderMarkdown } from "./render.mjs";
16
17
 
@@ -29,6 +30,10 @@ function fakeGh() {
29
30
  const path = args.find((a) => a.startsWith("repos/"));
30
31
  const m = /issues\/(\d+)\/comments$/.exec(path);
31
32
  if (m) return JSON.stringify(comments[m[1]] ?? []);
33
+ if (/issues\/comments\?/.test(path)) { // repo-wide endpoint: every comment, tagged with its issue (fixtures carry no updated_at)
34
+ const all = Object.entries(comments).flatMap(([n, list]) => list.map((c) => ({ ...c, issue_url: `https://api.github.com/repos/o/r/issues/${n}` })));
35
+ return JSON.stringify(/page=1(&|$)/.test(path) ? all : []);
36
+ }
32
37
  const since = /since=([^&]+)/.exec(path)?.[1];
33
38
  return JSON.stringify(issues.filter((i) => !since || i.updated_at >= since));
34
39
  };
@@ -36,7 +41,7 @@ function fakeGh() {
36
41
  }
37
42
 
38
43
  let td;
39
- beforeEach(() => { td = mkdtempSync(join(tmpdir(), "stats-")); });
44
+ beforeEach(() => { td = tmpDir("stats-"); });
40
45
  afterEach(() => rmSync(td, { recursive: true, force: true }));
41
46
 
42
47
  describe("telemetry comment parsing", () => {
@@ -58,9 +63,9 @@ describe("telemetry comment parsing", () => {
58
63
  });
59
64
 
60
65
  describe("GitHub-only report (fresh machine)", () => {
61
- it("rebuilds cost, time, flow, funnel, gates and issue flow from GitHub alone", () => {
66
+ it("rebuilds cost, time, flow, funnel, gates and issue flow from GitHub alone", async () => {
62
67
  const { gh } = fakeGh();
63
- const report = buildReport({ repo: "o/r", local: false, cache: false, minN: 10 }, { gh, env: ENV, releaseTimes: [] });
68
+ const report = await buildReport({ repo: "o/r", local: false, cache: false, minN: 10 }, { gh, env: ENV, releaseTimes: [] });
64
69
  const g = report.groups.find((x) => x.version === "1.17.0");
65
70
  assert.equal(g.n, 1);
66
71
  assert.equal(g.cost.cacheReadMean, 3000000);
@@ -85,6 +90,8 @@ describe("GitHub-only report (fresh machine)", () => {
85
90
  assert.equal(u.funnel.falsePositive, 1);
86
91
  assert.equal(u.issueFlow.net, 2);
87
92
  assert.equal(u.issueFlow.findingsPerMerge, null);
93
+ assert.equal(g.funnel.introduced, 2, "only events carrying scope=introduced are counted");
94
+ assert.equal(g.issueFlow.introducedPerMerge, (g.issueFlow.closed.merged ?? 0) > 0 ? 2 / g.issueFlow.closed.merged : null);
88
95
  assert.ok(!JSON.stringify(report).includes("999999999"), "untrusted record leaked into the report");
89
96
  });
90
97
  it("matches the run-record field names written by the engine seam", () => {
@@ -138,6 +145,15 @@ describe("local runs, joins, version delta and low-n", () => {
138
145
  assert.equal(legacy.cost.cacheReadMean, 4000000, "legacy PHASE_COMMIT usage counted when no attempt events exist");
139
146
  assert.equal(legacy.flow.codesN, 0, "old logs have no outcome codes");
140
147
  });
148
+ it("reports introduced findings per merged issue", () => {
149
+ const ev = (eid, scope) => ({ event: "finding_disposition", eid, repo: "o/r", pr: 1, issue: eid === "d" ? 2 : 1, version: "1.18.0", severity: "MEDIUM", disposition: "INPR_FIX", ...(scope ? { scope } : {}) });
150
+ const issue = (number) => ({ number, repo: "o/r", isPr: false, labels: ["workflow:merged"], createdAt: "2026-10-06T00:00:00Z", closedAt: "2026-10-07T00:00:00Z" });
151
+ const r = aggregate({ events: [ev("a", "introduced"), ev("b", "introduced"), ev("c", "pre-existing"), ev("d")], issues: [issue(1), issue(2)] }, { releaseTimes: [] });
152
+ const g = r.groups.find((x) => x.version === "1.18.0");
153
+ assert.equal(g.funnel.introduced, 2);
154
+ assert.equal(g.issueFlow.introducedPerMerge, 1);
155
+ assert.match(renderMarkdown(r), /introduced findings per merged issue/);
156
+ });
141
157
  it("computes the delta against the previous version of the same repo", () => {
142
158
  const g = agg().groups.find((x) => x.version === "1.17.0");
143
159
  assert.equal(g.delta.against, "1.16.0");
@@ -178,9 +194,9 @@ describe("n/a degradation and rendering", () => {
178
194
  assert.match(renderMarkdown(aggregate({})), /No data/);
179
195
  assert.equal(percentile([], 90), null);
180
196
  });
181
- it("json output is parseable and carries every section", () => {
197
+ it("json output is parseable and carries every section", async () => {
182
198
  const { gh } = fakeGh();
183
- const report = buildReport({ repo: "o/r", local: false, cache: false, minN: 10 }, { gh, env: ENV, releaseTimes: [] });
199
+ const report = await buildReport({ repo: "o/r", local: false, cache: false, minN: 10 }, { gh, env: ENV, releaseTimes: [] });
184
200
  const parsed = JSON.parse(renderJson(report));
185
201
  for (const k of ["cost", "time", "flow", "funnel", "gates", "issueFlow"]) assert.ok(k in parsed.groups[0]);
186
202
  const md = renderMarkdown(report);
@@ -193,56 +209,173 @@ describe("n/a degradation and rendering", () => {
193
209
  });
194
210
 
195
211
  describe("cache", () => {
196
- it("refreshes incrementally from the updated_at watermark and survives deletion or corruption", () => {
212
+ it("refreshes incrementally from the updated_at watermark and survives deletion or corruption", async () => {
197
213
  const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache") };
198
214
  const a = fakeGh();
199
- const first = fetchGithubRecords({ repo: "o/r", gh: a.gh, env });
215
+ const first = await fetchGithubRecords({ repo: "o/r", gh: a.gh, env });
200
216
  assert.equal(first.fromCache, false);
201
217
  assert.ok(!a.calls[0].includes("since="));
202
218
  const b = fakeGh();
203
- const second = fetchGithubRecords({ repo: "o/r", gh: b.gh, env });
219
+ const second = await fetchGithubRecords({ repo: "o/r", gh: b.gh, env });
204
220
  assert.equal(second.fromCache, true);
205
221
  assert.match(b.calls[0], /since=2026-10-06T12:55:00Z/); // watermark minus the 5 minute overlap window
206
222
  assert.deepEqual(second.records.map((r) => r.eid).sort(), first.records.map((r) => r.eid).sort());
207
223
  // corrupt cache -> treated as empty -> same result from a full fetch
208
224
  writeFileSync(join(cacheDir("o/r", env), "cache.json"), "{not json");
209
225
  const c = fakeGh();
210
- const third = fetchGithubRecords({ repo: "o/r", gh: c.gh, env });
226
+ const third = await fetchGithubRecords({ repo: "o/r", gh: c.gh, env });
211
227
  assert.equal(third.fromCache, false);
212
228
  assert.equal(third.records.length, first.records.length);
213
229
  // --refresh ignores a good cache
214
230
  const d = fakeGh();
215
- fetchGithubRecords({ repo: "o/r", gh: d.gh, env, refresh: true });
231
+ await fetchGithubRecords({ repo: "o/r", gh: d.gh, env, refresh: true });
216
232
  assert.ok(!d.calls[0].includes("since="));
217
233
  assert.equal(toModel("o/r", third).issues.length, 5);
218
234
  });
219
- it("saves a partial checkpoint on a mid-crawl failure and the next run resumes from it", () => {
220
- const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-partial") };
221
- const ok = fakeGh();
222
- const full = fetchGithubRecords({ repo: "o/r", gh: ok.gh, env: { ...ENV }, useCache: false });
223
- const withComments = full.items.filter((i) => ok.calls.some((c) => c.includes(`/issues/${i.number}/comments`)));
224
- assert.ok(withComments.length >= 2, "fixture needs at least two items with comments");
225
- let commentCalls = 0;
226
- // The real API honours sort=updated&direction=asc, which the watermark resume relies on; the fixture is unsorted.
227
- const asc = (gh) => (args) => {
228
- const out = gh(args);
229
- return args.some((a) => /\/issues\?/.test(a)) ? JSON.stringify(JSON.parse(out).sort((x, y) => x.updated_at.localeCompare(y.updated_at))) : out;
235
+ // Page-aware fake of the issues API over a synthetic store; the repo-wide comments endpoint is empty unless `comments` is given.
236
+ const storeGh = (store, { comments = [], failOnListCall = 0 } = {}) => {
237
+ const calls = [];
238
+ let listCalls = 0;
239
+ const gh = (args) => {
240
+ const path = args.find((a) => a.startsWith("repos/"));
241
+ calls.push(path);
242
+ const q = new URLSearchParams(path.split("?")[1] ?? "");
243
+ if (/issues\/comments\?/.test(path)) {
244
+ const since = q.get("since"), page = Number(q.get("page") ?? 1);
245
+ return JSON.stringify(comments.filter((c) => !since || c.updated_at >= since).slice((page - 1) * 100, page * 100));
246
+ }
247
+ const m = /issues\/(\d+)\/comments$/.exec(path);
248
+ if (m) return JSON.stringify(comments.filter((c) => c.issue_url.endsWith(`/issues/${m[1]}`)));
249
+ const one = /issues\/(\d+)$/.exec(path);
250
+ if (one) return JSON.stringify(store.find((i) => i.number === Number(one[1])));
251
+ if (++listCalls === failOnListCall) throw new Error("rate limited");
252
+ const since = q.get("since");
253
+ const sorted = store.filter((i) => !since || i.updated_at >= since).sort((x, y) => x.updated_at.localeCompare(y.updated_at) || x.number - y.number);
254
+ const per = Number(q.get("per_page")), page = Number(q.get("page") ?? 1);
255
+ return JSON.stringify(sorted.slice((page - 1) * per, page * per));
230
256
  };
231
- const flaky = asc((args) => {
232
- if (args[args.length - 1].endsWith("/comments") && ++commentCalls === 2) throw new Error("rate limited");
233
- return ok.gh(args);
234
- });
235
- assert.throws(() => fetchGithubRecords({ repo: "o/r", gh: flaky, env }), /rate limited/);
257
+ return { gh, calls };
258
+ };
259
+ const ts = (n) => new Date(Date.UTC(2026, 9, 1) + n * 3600000).toISOString().replace(/\.\d{3}Z$/, "Z");
260
+ const mkItems = (n) => Array.from({ length: n }, (_, i) => ({ number: i + 1, updated_at: ts(i + 1), created_at: ts(0), state: "open", comments: 0, labels: [], user: { type: "Bot" } }));
261
+ const telemetryComment = (issue, eid, event, updated, extra = {}) => ({ id: issue * 1000 + Number(eid.replace(/\D/g, "")), updated_at: updated, user: { type: "Bot" },
262
+ issue_url: `https://api.github.com/repos/o/r/issues/${issue}`,
263
+ body: `<!-- FORGE:TELEMETRY v1 -->\n\`\`\`jsonl\n${JSON.stringify({ v: 1, ts: updated, forge_version: "1.18.0", repo: "o/r", issue, run: `r_${issue}`, eid, event, phase: "build", cache_read_tokens: 100, ...extra })}\n\`\`\`` });
264
+
265
+ it("saves a partial checkpoint on a mid-crawl failure and the next run resumes from it", async () => {
266
+ const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-partial") };
267
+ const store = mkItems(250);
268
+ await assert.rejects(() => fetchGithubRecords({ repo: "o/r", gh: storeGh(store, { failOnListCall: 3 }).gh, env }), /rate limited/);
236
269
  const saved = JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8"));
237
270
  assert.ok(saved.watermark, "partial watermark persisted");
238
- assert.ok(Object.keys(saved.items).length > 0 && Object.keys(saved.items).length < full.items.length);
239
- const b = fakeGh();
240
- const resumed = fetchGithubRecords({ repo: "o/r", gh: asc(b.gh), env });
271
+ assert.ok(Object.keys(saved.items).length >= 100 && Object.keys(saved.items).length < 250);
272
+ const b = storeGh(store);
273
+ const resumed = await fetchGithubRecords({ repo: "o/r", gh: b.gh, env });
241
274
  assert.equal(resumed.fromCache, true);
242
- assert.match(b.calls[0], /since=/);
243
- assert.deepEqual(resumed.records.map((r) => r.eid).sort(), full.records.map((r) => r.eid).sort());
275
+ assert.ok(b.calls[0].includes("since="), "resumes from the watermark");
276
+ assert.equal(resumed.items.length, 250);
244
277
  });
245
- it("never skips an item updated mid-crawl (keyset crawl, not page-number pagination)", () => {
278
+ it("a mid-crawl failure never advances the watermark past unpersisted telemetry (resume still finds it)", async () => {
279
+ const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-pending-telemetry") };
280
+ const store = mkItems(250);
281
+ // A telemetry comment on the NEWEST issue (listed last), posted before the failing run starts.
282
+ const comments = [telemetryComment(250, "z1", "phase_attempt", ts(250))];
283
+ await assert.rejects(() => fetchGithubRecords({ repo: "o/r", gh: storeGh(store, { comments, failOnListCall: 3 }).gh, env }), /rate limited/);
284
+ const saved = JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8"));
285
+ assert.ok(!saved.watermark, "watermark must not be persisted while telemetry records are unpersisted");
286
+ // Resume: the comment is older than any watermark a buggy checkpoint would have saved.
287
+ const resumed = await fetchGithubRecords({ repo: "o/r", gh: storeGh(store, { comments }).gh, env });
288
+ assert.equal(resumed.items.length, 250);
289
+ assert.deepEqual(resumed.records.map((r) => r.eid), ["z1"]);
290
+ assert.equal(resumed.items.find((i) => i.number === 250).records.length, 1);
291
+ });
292
+ it("bounds the crawl by the window: API calls track the window, not the repo size", async () => {
293
+ const store = mkItems(3000); // 3000 items, one per hour
294
+ const from = ts(2990);
295
+ const windowItems = store.filter((i) => i.updated_at >= from).length;
296
+ const comments = store.slice(-5).map((i) => telemetryComment(i.number, `x${i.number}`, "phase_attempt", i.updated_at));
297
+ const { gh, calls } = storeGh(store, { comments });
298
+ const r = await fetchGithubRecords({ repo: "o/r", gh, useCache: false, since: from, env: ENV });
299
+ assert.equal(r.items.length, windowItems);
300
+ // 1 comments page + one refetch per telemetry issue + ceil(window / 100) list pages; never one call per repo item.
301
+ const bound = 1 + comments.length + Math.ceil(windowItems / 100) + 1;
302
+ assert.ok(r.apiCalls <= bound, `api calls ${r.apiCalls} exceed window bound ${bound}`);
303
+ assert.equal(calls.length, r.apiCalls);
304
+ assert.ok(calls.length < 30 && store.length > 100 * calls.length, `calls.length ${calls.length} must be far below the repo size`);
305
+ assert.ok(calls.some((c) => /issues\/comments\?.*since=/.test(c)), "comments come from the repo-wide since= endpoint");
306
+ assert.ok(!calls.some((c) => /issues\/\d+\/comments$/.test(c) && !comments.some((x) => x.issue_url.endsWith(c.match(/issues\/(\d+)\//)[1]))), "no comment call for items without telemetry");
307
+ assert.equal(r.records.length, comments.length);
308
+ });
309
+ it("re-reads every part of an issue whose telemetry was touched in the window, and only those", async () => {
310
+ const store = mkItems(20);
311
+ const comments = [telemetryComment(7, "e1", "phase_attempt", ts(1)), telemetryComment(7, "e2", "run_terminal", ts(15), { reason: "merged" }),
312
+ telemetryComment(9, "e1", "phase_attempt", ts(2))];
313
+ const { gh, calls } = storeGh(store, { comments });
314
+ const r = await fetchGithubRecords({ repo: "o/r", gh, useCache: false, since: ts(10), env: ENV });
315
+ assert.deepEqual(r.records.filter((x) => x.issue === 7).map((x) => x.event).sort(), ["phase_attempt", "run_terminal"], "the pre-window part is fetched too");
316
+ assert.equal(calls.filter((c) => /issues\/7\/comments$/.test(c)).length, 1);
317
+ assert.equal(calls.filter((c) => /issues\/9\/comments$/.test(c)).length, 0, "issue 9 has no telemetry in the window");
318
+ });
319
+ it("accepts an async gh and awaits it", async () => {
320
+ const store = mkItems(5);
321
+ const { gh } = storeGh(store);
322
+ const r = await fetchGithubRecords({ repo: "o/r", gh: async (a) => gh(a), useCache: false, env: ENV });
323
+ assert.equal(r.items.length, 5);
324
+ });
325
+ it("records the coverage floor, crawls only the gap for a wider window and resets on --refresh", async () => {
326
+ const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-floor") };
327
+ const store = mkItems(100);
328
+ const w1 = storeGh(store);
329
+ const narrow = await fetchGithubRecords({ repo: "o/r", gh: w1.gh, env, since: ts(90) });
330
+ assert.equal(narrow.items.length, 11);
331
+ assert.equal(JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8")).coveredFrom, ts(90));
332
+ // same or narrower window: incremental from the watermark, floor unchanged
333
+ const same = storeGh(store);
334
+ await fetchGithubRecords({ repo: "o/r", gh: same.gh, env, since: ts(95) });
335
+ assert.ok(/since=/.test(same.calls[0]) && !same.calls[0].includes(`since=${ts(95)}`), "resumes from the watermark, not the window start");
336
+ assert.equal(JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8")).coveredFrom, ts(90));
337
+ // wider window: the uncovered gap is crawled (from the new start), then the floor is lowered
338
+ const wide = storeGh(store);
339
+ const r = await fetchGithubRecords({ repo: "o/r", gh: wide.gh, env, since: ts(50) });
340
+ assert.ok(wide.calls[0].includes(`since=${ts(50)}`));
341
+ assert.equal(r.items.length, 51, "the pre-window gap is not silently treated as covered");
342
+ assert.equal(JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8")).coveredFrom, ts(50));
343
+ // no --since after a windowed run: full history is crawled and the floor becomes null
344
+ const all = storeGh(store);
345
+ assert.equal((await fetchGithubRecords({ repo: "o/r", gh: all.gh, env })).items.length, 100);
346
+ assert.equal(JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8")).coveredFrom, null);
347
+ // --refresh ignores the cache and its floor
348
+ const fresh = storeGh(store);
349
+ await fetchGithubRecords({ repo: "o/r", gh: fresh.gh, env, refresh: true, since: ts(80) });
350
+ assert.equal(JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8")).coveredFrom, ts(80));
351
+ });
352
+ it("does not trust a legacy cache (no coveredFrom) for a window and never lowers the floor on failure", async () => {
353
+ const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-legacy") };
354
+ const store = mkItems(250);
355
+ mkdirSync(cacheDir("o/r", env), { recursive: true });
356
+ writeFileSync(join(cacheDir("o/r", env), "cache.json"), JSON.stringify({ v: 1, watermark: ts(250), items: {} }));
357
+ const g = storeGh(store, { failOnListCall: 2 });
358
+ await assert.rejects(() => fetchGithubRecords({ repo: "o/r", gh: g.gh, env, since: ts(10) }), /rate limited/);
359
+ const saved = JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8"));
360
+ assert.equal(saved.watermark, ts(250), "failed gap crawl keeps the old watermark");
361
+ assert.ok(!("coveredFrom" in saved) || saved.coveredFrom == null || saved.coveredFrom === undefined);
362
+ const h = storeGh(store);
363
+ const r = await fetchGithubRecords({ repo: "o/r", gh: h.gh, env, since: ts(10) });
364
+ assert.ok(h.calls[0].includes(`since=${ts(10)}`));
365
+ assert.equal(r.items.length, 241);
366
+ });
367
+ it("reports progress through onProgress and backs off when the rate limit is low", async () => {
368
+ const store = mkItems(6000);
369
+ const events = [];
370
+ await fetchGithubRecords({ repo: "o/r", gh: storeGh(store).gh, useCache: false, env: ENV, onProgress: (e) => events.push(e) });
371
+ assert.ok(events.some((e) => e.phase === "comments") && events.some((e) => e.phase === "issues" && e.done === 6000));
372
+ const slept = [];
373
+ const base = storeGh(store).gh;
374
+ const gh = (args) => (args.join(" ") === "api rate_limit" ? JSON.stringify({ resources: { core: { remaining: 3, reset: 1_000_100 } } }) : base(args));
375
+ await fetchGithubRecords({ repo: "o/r", gh, useCache: false, env: ENV, rateCheck: true, now: () => 1_000_000_000, sleep: async (ms) => { slept.push(ms); } });
376
+ assert.ok(slept.length > 0 && slept[0] === 101000, `slept ${slept}`);
377
+ });
378
+ it("never skips an item updated mid-crawl (keyset crawl, not page-number pagination)", async () => {
246
379
  const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-midcrawl") };
247
380
  const ts = (n) => new Date(Date.UTC(2026, 9, 1) + n * 60000).toISOString().replace(/\.\d{3}Z$/, "Z");
248
381
  const store = Array.from({ length: 250 }, (_, i) => ({ number: i + 1, updated_at: ts(i + 1), created_at: ts(0), state: "open", comments: 0, labels: [], user: { type: "Bot" } }));
@@ -252,6 +385,7 @@ describe("cache", () => {
252
385
  // listing call an already-served item is bumped, which moves it to the end of the ordering.
253
386
  const gh = (args) => {
254
387
  const path = args.find((a) => a.startsWith("repos/"));
388
+ if (/issues\/comments\?/.test(path)) return "[]";
255
389
  const q = new URLSearchParams(path.split("?")[1]);
256
390
  listCalls++;
257
391
  const since = q.get("since");
@@ -261,31 +395,32 @@ describe("cache", () => {
261
395
  if (listCalls === 1) store[4].updated_at = ts(++maxUpdated);
262
396
  return out;
263
397
  };
264
- const first = fetchGithubRecords({ repo: "o/r", gh, env });
398
+ const first = await fetchGithubRecords({ repo: "o/r", gh, env });
265
399
  assert.equal(first.items.length, 250, "every item is cached after the crawl, none skipped by the shift");
266
400
  assert.equal(new Set(first.items.map((i) => i.number)).size, 250);
267
401
  // The bumped item moved past the crawl cursor, so the next incremental run (and its overlap window) still sees it.
268
402
  store[9].updated_at = ts(++maxUpdated); // another item updated after the first run
269
- const second = fetchGithubRecords({ repo: "o/r", gh, env });
403
+ const second = await fetchGithubRecords({ repo: "o/r", gh, env });
270
404
  assert.equal(second.fromCache, true);
271
405
  assert.equal(second.items.length, 250);
272
406
  assert.equal(second.items.find((i) => i.number === 10).updatedAt, store[9].updated_at);
273
407
  assert.equal(second.items.find((i) => i.number === 5).updatedAt, store[4].updated_at);
274
408
  });
275
- it("terminates and drains a full page whose items all share one updated_at", () => {
409
+ it("terminates and drains a full page whose items all share one updated_at", async () => {
276
410
  const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-sameTs") };
277
411
  const store = Array.from({ length: 230 }, (_, i) => ({ number: i + 1, updated_at: "2026-10-02T00:00:00Z", created_at: "2026-10-01T00:00:00Z", comments: 0, labels: [], user: { type: "Bot" } }));
278
412
  const gh = (args) => {
413
+ if (/issues\/comments\?/.test(args.find((a) => a.startsWith("repos/")))) return "[]";
279
414
  const q = new URLSearchParams(args.find((a) => a.startsWith("repos/")).split("?")[1]);
280
415
  const per = Number(q.get("per_page")), page = Number(q.get("page") ?? 1);
281
416
  const since = q.get("since");
282
417
  return JSON.stringify(store.filter((i) => !since || i.updated_at >= since).slice((page - 1) * per, page * per));
283
418
  };
284
- assert.equal(fetchGithubRecords({ repo: "o/r", gh, env }).items.length, 230);
419
+ assert.equal((await fetchGithubRecords({ repo: "o/r", gh, env })).items.length, 230);
285
420
  });
286
- it("rejects an unsafe repo before any gh call", () => {
421
+ it("rejects an unsafe repo before any gh call", async () => {
287
422
  const { gh, calls } = fakeGh();
288
- assert.throws(() => fetchGithubRecords({ repo: "o/r; rm -rf /", gh }), /invalid repo/);
423
+ await assert.rejects(() => fetchGithubRecords({ repo: "o/r; rm -rf /", gh }), /invalid repo/);
289
424
  assert.equal(calls.length, 0);
290
425
  });
291
426
  });
@@ -298,6 +433,32 @@ describe("cli", () => {
298
433
  assert.throws(() => parseArgs(["--bogus"]), /unknown argument/);
299
434
  assert.throws(() => parseSince("yesterday"), /--since must be/);
300
435
  });
436
+ it("resolves --since to a crawl lower bound (date as is, version via release time minus a margin)", () => {
437
+ assert.equal(sinceToTimestamp("2026-10-01").since, "2026-10-01T00:00:00Z");
438
+ assert.equal(sinceToTimestamp(undefined).since, null);
439
+ const v = sinceToTimestamp("v1.16.0", [{ v: "1.16.0", t: Date.parse("2026-10-05T00:00:00Z") / 1000 }]);
440
+ assert.equal(v.since, "2026-10-04T00:00:00Z");
441
+ const unknown = sinceToTimestamp("1.99.0", []);
442
+ assert.equal(unknown.since, null);
443
+ assert.match(unknown.note, /full history/);
444
+ });
445
+ it("prints progress to stderr only, throttled, and keeps --json stdout parseable", async () => {
446
+ const out = [];
447
+ let t = 0;
448
+ const print = makeProgressPrinter((x) => out.push(x), () => t);
449
+ print({ phase: "issues", done: 100, total: null });
450
+ t += 500; print({ phase: "issues", done: 200, total: null });
451
+ t += 2000; print({ phase: "issues", done: 300, total: null });
452
+ assert.equal(out.length, 2);
453
+ const { gh } = fakeGh();
454
+ const realOut = process.stdout.write, realErr = process.stderr.write;
455
+ let stdout = "", stderr = "";
456
+ process.stdout.write = (c) => { stdout += c; return true; };
457
+ process.stderr.write = (c) => { stderr += c; return true; };
458
+ try { assert.equal(await runStats(["--repo", "o/r", "--json", "--no-cache", "--no-local"], { gh, env: ENV, releaseTimes: [] }), 0); } finally { process.stdout.write = realOut; process.stderr.write = realErr; }
459
+ assert.ok(JSON.parse(stdout).groups, "stdout is only the JSON report");
460
+ assert.match(stderr, /forgedock stats: (comments|issues) /);
461
+ });
301
462
  it("--help exits 0 and importing the module did not run it", () => {
302
463
  const r = spawnSync(process.execPath, [join(here, "index.mjs"), "--help"], { encoding: "utf8" });
303
464
  assert.equal(r.status, 0);
@@ -471,7 +471,7 @@ if [ -n "$TRUSTED_SCRIPT" ] && [ -n "${ALL_PR_NUMBERS:-}" ] && [ -n "${PR_NUMBER
471
471
  # Reviewed = trusted FORGE:REVIEW_ROUTE (a real review mode) whose sha is the PR's FINAL head, plus at least one review-agent comment
472
472
  # carrying the full-length `Reviewed-SHA: <head>` line and a findings block (same predicate as review-pr.md Phase 5; a 7-char substring or a comment with no findings block never counts), and no degraded/blocked review marker. A PR pushed to after its review has a stale sha: the exact-head gate gives it no credit, and only the delta branch below (shared resolver `scripts/review-delta.sh`) may credit the commits an earlier trusted complete panel covered or a trusted `FORGE:REVIEW-CARRIED` base sync carried; everything else stays UNREVIEWED.
473
473
  _ROUTE=$(printf '%s' "$_CM" | bash "$TRUSTED_SCRIPT" count "^<!-- FORGE:REVIEW_ROUTE mode=(?!spec-evolution-blocked)[^ ]+ spec=review-pr.md sha=${_HEAD7} -->") || { SCOPE_MODE=full; break; }
474
- _AGENT=$(printf '%s' "$_CM" | bash "$TRUSTED_SCRIPT" count "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->)<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${_HEAD}(\\r?\\n|$)") || { SCOPE_MODE=full; break; }
474
+ _AGENT=$(printf '%s' "$_CM" | bash "$TRUSTED_SCRIPT" count "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${_HEAD}(\\r?\\n|$)") || { SCOPE_MODE=full; break; }
475
475
  _DEGRADED=$(printf '%s' "$_CM" | bash "$TRUSTED_SCRIPT" count "^<!-- FORGE:(REVIEW_DEGRADED|REVIEW_BLOCKED)") || { SCOPE_MODE=full; break; }
476
476
  case "$_ROUTE$_AGENT$_DEGRADED" in ''|*[!0-9]*) SCOPE_MODE=full; break ;; esac
477
477
  if [ "$_ROUTE" -ge 1 ] && [ "$_AGENT" -ge 1 ] && [ "$_DEGRADED" -eq 0 ] && [ "$(printf '%s' "$_J" | jq '.commits|length')" -lt 100 ]; then
@@ -1044,7 +1044,7 @@ for AGENT_DOMAIN in $LAUNCHED_REVIEW_AGENTS; do
1044
1044
  AGENT_COMMENT_COUNT=0
1045
1045
  if [ -n "$REVIEW_SHA_FULL" ] && [ -n "$TRUSTED_SCRIPT" ]; then
1046
1046
  AGENT_COMMENT_COUNT=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null \
1047
- | bash "$TRUSTED_SCRIPT" count "^<!-- FORGE:REVIEW-AGENT:${AGENT_DOMAIN} -->[\\s\\S]*(^|\\n)Reviewed-SHA:[ ]${REVIEW_SHA_FULL}(\\r?\\n|$)" 2>/dev/null || echo 0)
1047
+ | bash "$TRUSTED_SCRIPT" count "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:REVIEW-AGENT:${AGENT_DOMAIN} -->[\\s\\S]*(^|\\n)Reviewed-SHA:[ ]${REVIEW_SHA_FULL}(\\r?\\n|$)" 2>/dev/null || echo 0)
1048
1048
  fi
1049
1049
  case "$AGENT_COMMENT_COUNT" in ''|*[!0-9]*) AGENT_COMMENT_COUNT=0 ;; esac # fail closed: counts as missing
1050
1050
  if [ "${AGENT_COMMENT_COUNT:-0}" -lt 1 ]; then
@@ -1054,7 +1054,7 @@ done
1054
1054
 
1055
1055
  if [ -n "$MISSING_AGENT_COMMENTS" ]; then
1056
1056
  echo "REVIEW DELIVERY FAILURE: missing findings comment(s) for:${MISSING_AGENT_COMMENTS}"
1057
- echo "Recover each agent's returned verdict, findings, and durable body path before rerunning review."
1057
+ echo "A comment without a REVIEW-FINDINGS-START block counts as missing. Re-dispatch the missing agent once with the note: structured findings block required. A second miss is a degraded panel. Recover each agent's returned verdict, findings, and durable body path before rerunning review."
1058
1058
  exit 1
1059
1059
  fi
1060
1060
  ```
@@ -1086,7 +1086,7 @@ bash "$RF" classify --pr "$PR_NUMBER" --repo "$GH_REPO" --context "$CTX" --out-d
1086
1086
  cat "$OUT_DIR/note-list.txt" 2>/dev/null
1087
1087
  ```
1088
1088
 
1089
- Read `FINDINGS_KEPT`, `FINDINGS_DROPPED_STALE`, `FINDINGS_PREEXISTING` and `NOTES_DEMOTED_NO_SCENARIO` for the summary. With no kept finding and no unstructured finding in the trusted reviewer bodies, skip to Phase 8.
1089
+ Read `FINDINGS_KEPT`, `FINDINGS_DROPPED_STALE`, `FINDINGS_PREEXISTING` and `NOTES_DEMOTED_NO_SCENARIO` for the summary. Findings come only from `FINDING:` markers, never from free text. If `AGENTS_WITHOUT_BLOCK` is non-empty those agents are undelivered (no findings block): re-dispatch them once with the note "structured findings block required", else take the degraded-panel path; `CLEAN_STRUCTURED` is withheld meanwhile. With no kept finding and `CLEAN_STRUCTURED=true`, skip to Phase 8. A marker-less finding is classified with `classify-finding.sh --unstructured` (empty or unparseable severity is `NOTE unstructured`, never ISSUE).
1090
1090
 
1091
1091
  ### 7B: Note disposition (same damper and classifier as `/review-pr` §6B.5)
1092
1092
 
@@ -367,6 +367,8 @@ Reviewer roster tier \`${ROSTER_TIER}\`: ${ROSTER_REASON}. Roster: ${SELECTED_AG
367
367
 
368
368
  **Extended review (`REVIEW_DEPTH=EXTENDED`)** <!-- Added: forge#3862 -->: the roster script has already lifted a small, medium or fix-pr roster to standard and widened the standard baseline to the top-3 domains. Additionally dispatch each selected domain agent once per slice of at most `review.depth.slice_files` changed files (default 12; one slice when the PR has no more files), each slice carrying only its files' diff and the shared PR context, and merge the per-slice findings in Phase 5 (the dedup there treats the same `file:line` from two slices as one). The Security agent always reviews the whole diff, never a slice. Extended depth never skips a Phase 2 check, never weakens the Security floor, and never blocks the merge by itself; it is not a finding.
369
369
 
370
+ **Degraded gate** <!-- Added: forge#3628 -->: when `MERGE_ISSUE` is set, read the `**Degraded**:` line of its newest trusted `FORGE:QUALITY_GATE` and the `FORGE:ACCEPTANCE_GATE:DEGRADED` list (via `scripts/trusted-comments.sh`). A non-`none` list means a check ran nothing (tests skipped or not run, known-slow skip, blast-radius incomplete). Give each domain agent that list as "Degraded checks to review by hand": it must read the code those checks would have exercised. Post the section in a PR comment whose first line is `<!-- FORGE:REVIEW-AGENT:{domain} -->` (or the synthesis marker) and which carries a `Reviewed-SHA: ${REVIEW_SHA}` line for the current head. Under `### Degraded Paths Reviewed`, write one list item per degraded check as `- <exact name>: <how its path was reviewed>`; a generic section, a stale-head comment, or an item saying the check was NOT reviewed does not satisfy Phase 8 (forge#3995).
371
+
370
372
  ### 3B.5: Provenance-Based Trust Escalation (Conditional)
371
373
 
372
374
  Proven `(task-type × module-set)` combinations may drop optional judgment agents; novel ones widen to the full affected-domain panel (`NOVEL_NEEDS_HUMAN` also sets `needs-human` on merge). **Hard floor**: Security and every Phase 2 check run at every tier. **Application is skipped** (the lookup still runs, recording the calibration cell key) for `THOROUGH`, milestone-to-staging, `BASE_SYNC_ONLY`, and a `DELTA` head past two full rounds. **Default is SHADOW**: any error path (absent table, parse failure, git error) logs and leaves the roster unchanged, never looser or tighter; `review.trust_shadow_mode: "false"` enforces. `PROVEN` narrows only to Security plus hard-signal agents (AUTH, BILLING, DATABASE or INFRA score >= 3) and never under churn escalation or a danger zone. <!-- forge#1745, forge#1804 -->
@@ -437,7 +439,7 @@ Re-reviewing a head that only moved does not need the whole panel over the whole
437
439
 
438
440
  Phase 2, the §6B.4 gates (full PR diff at the current head) and the 7B/8 gates run unchanged in every mode.
439
441
 
440
- **Reviewer-comment trust and shape (applies to every check below and in Phase 4)**: a reviewer comment counts only when it is from a trusted author (`scripts/trusted-comments.sh`, the same predicate as the in-PR fix gate and `verify-phase-trail.sh`: trusted association, Bot account, or `FORGE_TRAIL_TRUSTED_LOGINS`), its body **starts with** `<!-- FORGE:REVIEW-AGENT:{domain} -->`, and it carries a line `Reviewed-SHA: ${REVIEW_SHA}` for the current head. The head SHA is public, so the SHA line alone proves nothing: an untrusted commenter must never suppress dispatch or satisfy the panel guard. An unresolved trust script or a non-integer count means **not posted** (fail closed).
442
+ **Reviewer-comment trust and shape (applies to every check below and in Phase 4)**: a reviewer comment counts only when it is from a trusted author (`scripts/trusted-comments.sh`, the same predicate as the in-PR fix gate and `verify-phase-trail.sh`: trusted association, Bot account, or `FORGE_TRAIL_TRUSTED_LOGINS`), its body **starts with** `<!-- FORGE:REVIEW-AGENT:{domain} -->`, it carries a line `Reviewed-SHA: ${REVIEW_SHA}` for the current head, and it carries a `<!-- REVIEW-FINDINGS-START -->` ... `<!-- REVIEW-FINDINGS-END -->` block (empty allowed). A verdict without the block ("APPROVE, no findings") is **undelivered**, never read as clean and never scanned for free-text findings. The head SHA is public, so the SHA line alone proves nothing: an untrusted commenter must never suppress dispatch or satisfy the panel guard. An unresolved trust script or a non-integer count means **not posted** (fail closed).
441
443
 
442
444
  **Pre-dispatch fence.** For `BASE_SYNC_ONLY` it dispatches zero reviewers and posts the carry record (then go straight to Phase 4). Otherwise it implements **idempotent re-entry**: a re-invoked review reuses reviewers that already posted for the current head, so it prints `PENDING_AGENTS` (the domains of the effective roster with no trusted current-SHA comment; comments for another SHA never count) and the linked issue's `BLAST_RADIUS_MANIFEST` (advisory; any failure prints it empty).
443
445
 
@@ -479,7 +481,7 @@ agent_domain() { local l; l=$(printf '%s' "${1}" | tr '[:upper:]' '[:lower:]');
479
481
  for AGENT in $SELECTED_AGENTS; do
480
482
  AGENT_DOMAIN=$(agent_domain "$AGENT"); HAVE=""
481
483
  if [ -n "$TRUSTED_SCRIPT" ]; then
482
- HAVE=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" count "^<!-- FORGE:REVIEW-AGENT:${AGENT_DOMAIN} -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${REVIEW_SHA}(\\r?\\n|$)" 2>/dev/null || echo "")
484
+ HAVE=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" count "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:REVIEW-AGENT:${AGENT_DOMAIN} -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${REVIEW_SHA}(\\r?\\n|$)" 2>/dev/null || echo "")
483
485
  fi
484
486
  case "$HAVE" in ''|*[!0-9]*) HAVE=0 ;; esac # fail closed: unreadable or untrusted counts as not posted
485
487
  [ "$HAVE" -lt 1 ] && PENDING_AGENTS="${PENDING_AGENTS} ${AGENT}"
@@ -548,7 +550,7 @@ Each agent's `[FILE_LIST]` is `slices.<domain>.paths` from the context: the Secu
548
550
 
549
551
  ## Phase 4: Wait for Agents
550
552
 
551
- Completion is read from GitHub, never from notifications. A reviewer is complete only when a **trusted** comment (3C trust rule) starting with `<!-- FORGE:REVIEW-AGENT:{domain} -->` carries `Reviewed-SHA: <current head>`.
553
+ Completion is read from GitHub, never from notifications. A reviewer is complete only when a **trusted** comment (3C trust rule) starting with `<!-- FORGE:REVIEW-AGENT:{domain} -->` carries `Reviewed-SHA: <current head>` and a findings block (`REVIEW-FINDINGS-START` ... `REVIEW-FINDINGS-END`, empty allowed). A domain whose comment lacks the block is missing: re-dispatch it **once** (3C, with the note "structured findings block required: end your comment with REVIEW-FINDINGS-START/END, empty if no findings"), then re-run this phase; a second miss is a delivery failure and takes the existing degraded-panel hard stop below. Never loop beyond that one re-dispatch.
552
554
 
553
555
  - **Joined tools** (`Task`, `task`): the reviewers already returned, so one pass decides; anything missing is a delivery failure.
554
556
  - **Async `Agent`**: bounded wait, **one poll pass per Bash call** (a call is capped at 120s, 600s max, so one long loop would be killed before the hard stop ran). The first pass records an epoch deadline in a private absolute file keyed by PR and head SHA; every exit removes it and 3C removes it before (re)dispatch, so a re-review never inherits an expired window. Re-run while it prints `PANEL_STATUS: WAITING`.
@@ -590,7 +592,7 @@ agent_domain() { local l; l=$(printf '%s' "${1}" | tr '[:upper:]' '[:lower:]');
590
592
  for AGENT in $SELECTED_AGENTS; do
591
593
  AGENT_DOMAIN=$(agent_domain "$AGENT"); N=""
592
594
  if [ -n "$TRUSTED_SCRIPT" ] && [ -n "$COMMENTS_JSON" ]; then
593
- N=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" count "^<!-- FORGE:REVIEW-AGENT:${AGENT_DOMAIN} -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${REVIEW_SHA}(\\r?\\n|$)" 2>/dev/null || echo "")
595
+ N=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" count "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:REVIEW-AGENT:${AGENT_DOMAIN} -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${REVIEW_SHA}(\\r?\\n|$)" 2>/dev/null || echo "")
594
596
  fi
595
597
  case "$N" in ''|*[!0-9]*) N=0 ;; esac # fail closed
596
598
  if [ "$N" -lt 1 ]; then MISSING_AGENT_COMMENTS="${MISSING_AGENT_COMMENTS} ${AGENT_DOMAIN}"; else ACTUAL_AGENT_COUNT=$(( ACTUAL_AGENT_COUNT + 1 )); fi
@@ -655,7 +657,7 @@ cat "$OUT_DIR/note-list.txt" 2>/dev/null
655
657
 
656
658
  `extract` reads the trusted synthesized block for the head, else its trusted agent bodies (`BASE_SYNC_ONLY`: the carried head; `DELTA`: this head plus the earlier reviewed one), parses every `<!-- FINDING:... -->` marker (an unparseable marker blocks the review), dedupes by file + line range ±5 and by title (3+ shared keywords, higher confidence wins), and applies the **§6B.4 gates** in order, each keeping the finding when its input is unreadable: (1) **head-SHA**: a cited line past the end of the file at `REVIEW_SHA` is stale and DROPPED (`findings_dropped_stale`); (2) **introduced-line**: a MEDIUM-or-lower finding whose range (±5) misses the PR's added lines is `pre-existing` (**CRITICAL/HIGH are never routed as pre-existing**, whatever their confidence, `protocols.md` §5; input comes from the diff, never a reviewer's `**Scope**` tag; deleted, renamed and patch-less files always intersect); (3) **concrete-failure**: a MEDIUM-or-lower finding without a `**Failure scenario**` becomes a NOTE (`notes_demoted_no_scenario`).
657
659
 
658
- Read `STRUCTURED_FINDINGS`, `FINDINGS_KEPT`, `FINDINGS_DROPPED_STALE`, `FINDINGS_PREEXISTING` and `NOTES_DEMOTED_NO_SCENARIO` for the record and summary. The caller's part of 6A: add the Phase 2.5 `INTEG-N` findings no reviewer covered (CONFIRMED HIGH, so ISSUE, filed in 6C); on `CLEAN_STRUCTURED=true` scan the trusted agent comments for unstructured findings (lines starting "Finding", "Issue", "Bug", "Warning"), and with none and no `INTEG-N` the review is clean: skip to Phase 7. **Pre-existing route**: a pre-existing finding (MEDIUM or lower) is never filed as `review-finding`/`needs-validation` nor linked to this PR (it stays out of findings-per-PR and the amplification ratio); it still passes the §6B.5 classifier, is listed once in the summary, and at most 3 per review (highest severity first) are filed with the `pre-existing` label plus `priority:*`, citing this PR (`preexisting_filed`, not `findings_filed`).
660
+ Read `STRUCTURED_FINDINGS`, `FINDINGS_KEPT`, `FINDINGS_DROPPED_STALE`, `FINDINGS_PREEXISTING` and `NOTES_DEMOTED_NO_SCENARIO` for the record and summary. The caller's part of 6A: add the Phase 2.5 `INTEG-N` findings no reviewer covered (CONFIRMED HIGH, so ISSUE, filed in 6C); findings come only from `FINDING:` markers, never from free text. If `AGENTS_WITHOUT_BLOCK` is non-empty those agents are undelivered: re-dispatch them once (Phase 4 rule), else take the degraded-panel path; `CLEAN_STRUCTURED` is withheld while it is non-empty. On `CLEAN_STRUCTURED=true` and no `INTEG-N` the review is clean: skip to Phase 7. A finding that somehow has no marker is classified with `classify-finding.sh --unstructured` (empty or unparseable severity gives `NOTE unstructured`, never ISSUE). **Pre-existing route**: a pre-existing finding (MEDIUM or lower) is never filed as `review-finding`/`needs-validation` nor linked to this PR (it stays out of findings-per-PR and the amplification ratio); it still passes the §6B.5 classifier, is listed once in the summary, and at most 3 per review (highest severity first) are filed with the `pre-existing` label plus `priority:*`, citing this PR (`preexisting_filed`, not `findings_filed`).
659
661
 
660
662
  ### 6B.5: Non-blocking note disposition (MANDATORY before 6C)
661
663
 
@@ -674,7 +676,7 @@ Each NOTE gets one disposition: (1) **fix in this PR**, only comment, documentat
674
676
 
675
677
  ### 6B.6: In-PR fix gate
676
678
 
677
- A CONFIRMED MEDIUM-or-higher defect in a file this PR changed is cheaper to fix before merge than through a separate `/work-on` cycle: `classify` marks it `INPR_FIX` and posts the `FORGE:INPR_FIX` request after the disposition marker (`INPR_FIX_POSTED=true`), so do NOT file those findings in 6C. Narrow by design: auto-merge reviews with `--issue`, files in the PR diff, one round per PR (any trusted `FORGE:INPR_FIX*` marker ends it). Phase 8 then refuses to merge this head (blocker `in-pr fix required`). If the round fails, `/work-on` waives it for MEDIUM and files the findings (`work-on/review.md` R4); HIGH/CRITICAL are never waived and end at `needs-human`.
679
+ A CONFIRMED MEDIUM-or-higher defect, or a LIKELY MEDIUM defect whose line the PR introduced (`scope=introduced`), in a file this PR changed is cheaper to fix before merge than through a separate `/work-on` cycle: `classify` marks it `INPR_FIX` and posts the `FORGE:INPR_FIX` request after the disposition marker (`INPR_FIX_POSTED=true`), so do NOT file those findings in 6C. `review-finalize.sh extract` computes `scope` (`introduced` | `pre-existing` | `unknown`) per finding from the PR diff (the added-line window; unknown provenance never widens the gate). Narrow by design: auto-merge reviews with `--issue`, files in the PR diff, one round per PR (any trusted `FORGE:INPR_FIX*` marker ends it). Phase 8 then refuses to merge this head (blocker `in-pr fix required`). If the round fails, `/work-on` waives it for MEDIUM and files the findings (`work-on/review.md` R4); HIGH/CRITICAL are never waived and end at `needs-human`.
678
680
 
679
681
  ### 6C: Create Issues
680
682
 
@@ -718,6 +720,7 @@ cat <<'ISSUE_EOF' > "$FINDING_ISSUE_BODY_FILE"
718
720
  **Agent**: [name] ([domain])
719
721
  **Confidence**: [CONFIRMED/LIKELY/POSSIBLE]
720
722
  **Severity**: [CRITICAL/HIGH/MEDIUM/LOW]
723
+ **Scope**: introduced|pre-existing (the worklist item's `scope`; omit the line when it is `unknown`)
721
724
  **Review comment**: [permalink to agent comment]
722
725
 
723
726
  ## Pattern Metadata
@@ -799,25 +802,29 @@ Append a `## Review Findings` table (summaries and issue links) to the PR body:
799
802
 
800
803
  ```bash
801
804
  PR_NUMBER="{PR_NUMBER}"; REPO="{GH_REPO}"; MERGE_ISSUE="{MERGE_ISSUE}"
802
- MERGE_HEALTH="{MERGE_HEALTH}"; MERGE_HEALTH_STATE="{MERGE_HEALTH_STATE}"; BASE="{BASE}"; HEAD="{HEAD}"; REVIEW_SHA="{REVIEW_SHA}"
805
+ MERGE_HEALTH="{MERGE_HEALTH}"; MERGE_HEALTH_STATE="{MERGE_HEALTH_STATE}"; BASE="{BASE}"; HEAD="{HEAD}"; REVIEW_SHA="{REVIEW_SHA}"; CTX="{CONTEXT_FILE}"
803
806
  INTENSITY_TIER="{INTENSITY_TIER}"; TABLE_CELL_KEY="{TABLE_CELL_KEY}"; TRUST_SHADOW_MODE="{TRUST_SHADOW_MODE}"; TRUST_NEEDS_HUMAN="{TRUST_NEEDS_HUMAN}"
804
807
  type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
805
808
  RF=$(forge_resolve review-finalize.sh) # install roots only, never the PR checkout; unresolvable is a stop (fail closed), never a skipped gate
806
809
  [ -n "$RF" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: scripts/review-finalize.sh not resolvable (set FORGEDOCK_HOME to the ForgeDock install); refusing to continue (fail closed)"; exit 1; } # STOP
807
- # --result: stale (PR HEAD moved) | clean (no blocking criteria) | blocking (any criterion above). Free text is data, never code.
810
+ OUT_DIR="$(dirname "$CTX")/review-finalize-${PR_NUMBER}"; mkdir -p "$OUT_DIR"
811
+ # Free text (purpose-regression findings, agents list) derives from PR content: BEFORE this fence, write it with the file-write tool (never a
812
+ # shell heredoc, echo or printf of the text) to "$OUT_DIR/purpose-findings.txt" (ids and reasons; leave absent when there is no purpose regression)
813
+ # and "$OUT_DIR/agents.txt" ("{N} agents: {names}"). It is passed by path, so it is never re-parsed as shell. Never put free text in a shell word.
814
+ # --result: stale (PR HEAD moved) | clean (no blocking criteria) | blocking (any criterion above).
808
815
  VERDICT_OUT=$(bash "$RF" verdict --pr "$PR_NUMBER" --repo "$REPO" --review-sha "$REVIEW_SHA" --base "$BASE" --head-branch "$HEAD" \
809
816
  --merge-health "$MERGE_HEALTH" --merge-health-state "$MERGE_HEALTH_STATE" --is-milestone "{IS_MILESTONE_TO_STAGING}" \
810
- --has-purpose-regression "{HAS_PURPOSE_REGRESSION}" --purpose-findings '{PURPOSE_REGRESSION_FINDINGS}' \
811
- --result "{RESULT}" --agents '{N} agents: {names}' --findings-count "{M}" --blocking-count "{N_BLOCKING}" \
817
+ --has-purpose-regression "{HAS_PURPOSE_REGRESSION}" --purpose-findings-file "$OUT_DIR/purpose-findings.txt" \
818
+ --result "{RESULT}" --agents-file "$OUT_DIR/agents.txt" --findings-count "{M}" --blocking-count "{N_BLOCKING}" \
812
819
  --intensity-tier "$INTENSITY_TIER" --table-cell-key "$TABLE_CELL_KEY" --trust-shadow-mode "$TRUST_SHADOW_MODE"); VERDICT_RC=$?
813
820
  printf '%s\n' "$VERDICT_OUT"
814
821
  [ "$VERDICT_RC" -eq 0 ] || exit 1 # STOP: REVIEW_RESULT: status: BLOCKED was printed
815
822
  # 7B.5: reads task type and confidence from the linked issue's FORGE:INVESTIGATOR, consults origin/forge-knowledge, records FORGE:CALIBRATION_CHECK.
816
- bash "$RF" calibrate --pr "$PR_NUMBER" --repo "$REPO" --issue "$MERGE_ISSUE" \
823
+ bash "$RF" calibrate --pr "$PR_NUMBER" --repo "$REPO" --issue "$MERGE_ISSUE" --out-dir "$OUT_DIR" \
817
824
  --intensity-tier "$INTENSITY_TIER" --table-cell-key "$TABLE_CELL_KEY" --trust-shadow-mode "$TRUST_SHADOW_MODE" --trust-needs-human "$TRUST_NEEDS_HUMAN"
818
825
  ```
819
826
 
820
- `verdict` computes `BASE_CONFLICT` (the single source of truth for the Phase 8 guards), posts the verdict with the trust annotation, and never posts a clean verdict over a base conflict or purpose regression (it downgrades to CHANGES REQUESTED, `VERDICT_COERCED=true`). Read `VERDICT=`, `BASE_CONFLICT=`, `HAS_PURPOSE_REGRESSION=` and, from `calibrate`, `CALIBRATION_NEEDS_HUMAN=` / `CALIBRATION_NOTE=`: they are the Phase 8 flags.
827
+ `verdict` computes `BASE_CONFLICT` (the single source of truth for the Phase 8 guards), posts the verdict with the trust annotation, and never posts a clean verdict over a base conflict or purpose regression (it downgrades to CHANGES REQUESTED, `VERDICT_COERCED=true`). Read `VERDICT=`, `BASE_CONFLICT=`, `HAS_PURPOSE_REGRESSION=` and, from `calibrate`, `CALIBRATION_NEEDS_HUMAN=` / `CALIBRATION_NOTE=`: they are the Phase 8 flags. The note itself is also written to `$OUT_DIR/calibration-note.txt`, which Phase 8 passes by path; free text is never placed in a shell word.
821
828
 
822
829
  ---
823
830
 
@@ -825,18 +832,20 @@ bash "$RF" calibrate --pr "$PR_NUMBER" --repo "$REPO" --issue "$MERGE_ISSUE" \
825
832
 
826
833
  **Skip if** `AUTO_MERGE=false`.
827
834
 
828
- **One script owns every Phase 8 gate (MANDATORY; a gate is never skipped, reordered or hand-run).** `review-finalize.sh merge-gate` runs, in order, and stops at the first refusal: the note-disposition preflight (a trusted `FORGE:NOTE_DISPOSITION` must exist), the in-PR fix gate (an unanswered trusted `FORGE:INPR_FIX` at the current head blocks), the phase-trail preflight (`scripts/verify-phase-trail.sh` on the linked issue; `--issue` is mandatory and never inferred; an infrastructure failure is `BLOCKED` plus `needs-human`) <!-- Added: forge#3061 -->, the guard-state flags below (`VERDICT` is `APPROVED` or `CHANGES REQUESTED`, the others `true`/`false`; an empty or unsubstituted value aborts with `needs-human`; `--other-blocking true` when 7B criterion 1, 2, 3 or 5 fired), routing (a base conflict as the ONLY blocker routes to the base-sync path, `blocker: base-conflict`; any other hold adds `needs-human`), the pre-merge mergeability guard on fresh state, the previously-escalated guard (parks at `workflow:awaiting-merge`, never merges), the CI gate (`scripts/wait-ci-green.sh`) with the reviewed-head guard (only the commit the `FORGE:REVIEW_ROUTE` sha approved; a docs-only delta is allowed), and finally the merge with `--match-head-commit`. Every gate fails closed.
835
+ **One script owns every Phase 8 gate (MANDATORY; a gate is never skipped, reordered or hand-run).** `review-finalize.sh merge-gate` runs, in order, and stops at the first refusal: the note-disposition preflight (a trusted `FORGE:NOTE_DISPOSITION` must exist), the in-PR fix gate (an unanswered trusted `FORGE:INPR_FIX` at the current head blocks), the phase-trail preflight (`scripts/verify-phase-trail.sh` on the linked issue; `--issue` is mandatory and never inferred; an infrastructure failure is `BLOCKED` plus `needs-human`) <!-- Added: forge#3061 -->, the degraded-gate review (`degraded-gate-unreviewed` unless a `### Degraded Paths Reviewed` section covers each degraded check), the guard-state flags below (`VERDICT` is `APPROVED` or `CHANGES REQUESTED`, the others `true`/`false`; an empty or unsubstituted value aborts with `needs-human`; `--other-blocking true` when 7B criterion 1, 2, 3 or 5 fired), routing (a base conflict as the ONLY blocker routes to the base-sync path, `blocker: base-conflict`; any other hold adds `needs-human`), the pre-merge mergeability guard on fresh state, the previously-escalated guard (parks at `workflow:awaiting-merge`, never merges), the CI gate (`scripts/wait-ci-green.sh`) with the reviewed-head guard (only the commit the `FORGE:REVIEW_ROUTE` sha approved; a docs-only delta is allowed), and finally the merge with `--match-head-commit`. Every gate fails closed.
829
836
 
830
837
  ```bash
831
838
  PR_NUMBER="{PR_NUMBER}"; REPO="{GH_REPO}"
832
839
  MERGE_ISSUE="{MERGE_ISSUE}"; MERGE_BASE="{MERGE_BASE}"; IS_MILESTONE_TO_STAGING="{IS_MILESTONE_TO_STAGING}"
833
- VERDICT="{VERDICT}"; HAS_PURPOSE_REGRESSION="{HAS_PURPOSE_REGRESSION}"; CALIBRATION_NEEDS_HUMAN="{CALIBRATION_NEEDS_HUMAN}"; CALIBRATION_NOTE='{CALIBRATION_NOTE}'
840
+ VERDICT="{VERDICT}"; HAS_PURPOSE_REGRESSION="{HAS_PURPOSE_REGRESSION}"; CALIBRATION_NEEDS_HUMAN="{CALIBRATION_NEEDS_HUMAN}"; CTX="{CONTEXT_FILE}"
834
841
  TRUST_NEEDS_HUMAN="{TRUST_NEEDS_HUMAN}"; BASE_CONFLICT="{BASE_CONFLICT}"; OTHER_BLOCKING="{OTHER_BLOCKING}"; TABLE_CELL_KEY="{TABLE_CELL_KEY}"
835
842
  type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
836
843
  RF=$(forge_resolve review-finalize.sh) # install roots only, never the PR checkout; unresolvable is a stop (fail closed), never a skipped gate
837
844
  [ -n "$RF" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: scripts/review-finalize.sh not resolvable (set FORGEDOCK_HOME to the ForgeDock install); refusing to continue (fail closed)"; exit 1; } # STOP
845
+ # The calibration note derives from issue text: `calibrate` (Phase 7B.5) wrote it to this file; it is passed by path, never substituted into a shell word.
846
+ OUT_DIR="$(dirname "$CTX")/review-finalize-${PR_NUMBER}"
838
847
  GATE_OUT=$(bash "$RF" merge-gate --pr "$PR_NUMBER" --repo "$REPO" --issue "$MERGE_ISSUE" --base "$MERGE_BASE" \
839
- --verdict "$VERDICT" --has-purpose-regression "$HAS_PURPOSE_REGRESSION" --calibration-needs-human "$CALIBRATION_NEEDS_HUMAN" --calibration-note "$CALIBRATION_NOTE" \
848
+ --verdict "$VERDICT" --has-purpose-regression "$HAS_PURPOSE_REGRESSION" --calibration-needs-human "$CALIBRATION_NEEDS_HUMAN" --calibration-note-file "$OUT_DIR/calibration-note.txt" \
840
849
  --trust-needs-human "$TRUST_NEEDS_HUMAN" --base-conflict "$BASE_CONFLICT" --other-blocking "$OTHER_BLOCKING" --table-cell-key "$TABLE_CELL_KEY"); GATE_RC=$?
841
850
  printf '%s\n' "$GATE_OUT"
842
851
  # 8B: only for a milestone->staging PR whose merge just succeeded: clears the milestone on its open review-finding issues so they flow through the fast lane.
@@ -849,6 +858,7 @@ exit "$GATE_RC"
849
858
  **Outcome mapping.** Report the script's output; never re-run its gates by hand and never `gh pr merge` yourself.
850
859
  - Exit 0, `MERGE_OUTCOME=merged` (`MERGE_STATE=MERGED`): merged. `merge-failed`: the merge did not land (the script commented on the issue). `parked-awaiting-merge`: parked per the previously-escalated guard; the script performs the transition itself (`transition-label.sh ... awaiting-merge`, prose fallback `gh issue edit --add-label "workflow:awaiting-merge"`).
851
860
  - Exit 3: print the `REVIEW_RESULT: status: BLOCKED, blocker: ...` line and stop; `MERGE_OUTCOME=` names the case (`base-conflict`, `needs-human`, `protection-blocked`, `stale-review`, `ci-not-green`). A CI gate TIMEOUT prints `CI_GATE_TIMEOUT`: re-run the same fence up to 3 more times. A phase-trail blocker with no MISSING lines (`phase trail unreadable (rc=N)`, `auto-merge requires --issue`) is an infrastructure or usage failure: callers neither re-run phases nor merge manually.
861
+ - Exit 3 with `blocker: degraded-gate-unreviewed` (`needs-human` added): the linked issue's gate is DEGRADED and no trusted review comment for the current head has a `### Degraded Paths Reviewed` section with a `- name: how reviewed` item for every degraded check. Not a code defect: `work-on:review` (R4) re-runs the review once so the section is added. Never merge by hand. <!-- Added: forge#3628 -->
852
862
  - Exit 4: `REVIEW_RESULT: status: PHASE_TRAIL_FAILED` plus the `MISSING:` lines. Return that status and stop: this phase never re-runs the missing phases and never escalates to `needs-human`; `work-on:review` (R4) alone owns re-dispatching them (forge#3102).
853
863
 
854
864
  Phase 8 ONLY merges the PR. It does NOT close the issue, update labels or clean up worktrees: when invoked via `/work-on` those belong to `work-on/close.md`, and doing them here would close the issue with `workflow:merged` before the close phase ran. <!-- Added: forge#815 -->