forgedock 1.18.0 → 1.20.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 (135) hide show
  1. package/README.md +2 -0
  2. package/bin/engine/outcome-codes.mjs +13 -0
  3. package/bin/engine/phases.mjs +54 -4
  4. package/bin/engine/runlog.mjs +10 -2
  5. package/bin/engine/telemetry-seam.mjs +3 -0
  6. package/bin/engine/telemetry.test.mjs +3 -2
  7. package/bin/engine-cli.mjs +32 -2
  8. package/bin/engine.mjs +187 -10
  9. package/bin/runner.mjs +89 -13
  10. package/bin/stats/aggregate.mjs +7 -3
  11. package/bin/stats/cache.mjs +7 -3
  12. package/bin/stats/github-source.mjs +136 -28
  13. package/bin/stats/index.mjs +46 -9
  14. package/bin/stats/render.mjs +1 -1
  15. package/bin/stats/stats.test.mjs +201 -40
  16. package/commands/orchestrate/phase-4-execution.md +1 -1
  17. package/commands/review-pr-agents/protocols.md +19 -0
  18. package/commands/review-pr-staging.md +4 -4
  19. package/commands/review-pr.md +31 -20
  20. package/commands/work-on/build/architect.md +54 -1
  21. package/commands/work-on/build/implement.md +28 -1
  22. package/commands/work-on/build/validate.md +321 -29
  23. package/commands/work-on/build.md +184 -17
  24. package/commands/work-on/close.md +9 -4
  25. package/commands/work-on/investigate.md +7 -0
  26. package/commands/work-on/remediate.md +43 -17
  27. package/commands/work-on/review.md +43 -22
  28. package/commands/work-on.md +4 -4
  29. package/docs/CONFIG.md +93 -2
  30. package/docs/OUTCOME-CODES.md +5 -1
  31. package/docs/RELEASE-NOTES.md +15 -0
  32. package/docs/STATS.md +11 -4
  33. package/docs/TELEMETRY.md +5 -2
  34. package/docs/WORK-ON-RUNTIME.md +29 -3
  35. package/package.json +2 -2
  36. package/packages/protocol/src/types.js +1 -1
  37. package/scripts/acceptance-red-green.sh +90 -0
  38. package/scripts/acceptance-red-green.test.sh +74 -0
  39. package/scripts/architect-resume-match.test.sh +12 -3
  40. package/scripts/build-knowledge-index.mjs +8 -5
  41. package/scripts/builder-resume-match.test.sh +12 -3
  42. package/scripts/calibration.mjs +5 -2
  43. package/scripts/cap-findings.test.sh +2 -0
  44. package/scripts/check-contract-scope.test.sh +2 -0
  45. package/scripts/check-edge-cases.sh +143 -0
  46. package/scripts/check-edge-cases.test.sh +175 -0
  47. package/scripts/check-spec-markers.sh +4 -0
  48. package/scripts/check-test-tmp-cleanup.sh +56 -0
  49. package/scripts/check-test-tmp-cleanup.test.sh +69 -0
  50. package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
  51. package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
  52. package/scripts/ci-local/adapters/buildkite.mjs +58 -0
  53. package/scripts/ci-local/adapters/circleci.mjs +101 -0
  54. package/scripts/ci-local/adapters/common.mjs +115 -0
  55. package/scripts/ci-local/adapters/explicit.mjs +21 -0
  56. package/scripts/ci-local/adapters/github-actions.mjs +349 -0
  57. package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
  58. package/scripts/ci-local/adapters/portability.mjs +104 -0
  59. package/scripts/ci-local/adapters/task-runners.mjs +67 -0
  60. package/scripts/ci-local/cli.mjs +50 -0
  61. package/scripts/ci-local/lib.mjs +379 -0
  62. package/scripts/ci-local/plan.mjs +186 -0
  63. package/scripts/ci-local/run.mjs +451 -0
  64. package/scripts/ci-local/scoping.mjs +531 -0
  65. package/scripts/ci-local.sh +98 -0
  66. package/scripts/ci-local.test.sh +1108 -0
  67. package/scripts/classify-finding.sh +33 -7
  68. package/scripts/classify-finding.test.sh +23 -0
  69. package/scripts/classify-test-run.sh +69 -0
  70. package/scripts/classify-test-run.test.sh +90 -0
  71. package/scripts/close-parent-c3.test.sh +2 -0
  72. package/scripts/consumer-sweep.sh +569 -0
  73. package/scripts/consumer-sweep.test.sh +357 -0
  74. package/scripts/detect-append-registries.sh +106 -0
  75. package/scripts/detect-append-registries.test.sh +64 -0
  76. package/scripts/file-deferred-followups.sh +91 -30
  77. package/scripts/file-deferred-followups.test.sh +101 -8
  78. package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
  79. package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
  80. package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
  81. package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
  82. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
  83. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
  84. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
  85. package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
  86. package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
  87. package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
  88. package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
  89. package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
  90. package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
  91. package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
  92. package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
  93. package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
  94. package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
  95. package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
  96. package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
  97. package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
  98. package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
  99. package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
  100. package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
  101. package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
  102. package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
  103. package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
  104. package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
  105. package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
  106. package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
  107. package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
  108. package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
  109. package/scripts/forge-root.test.sh +1 -1
  110. package/scripts/lib/telemetry.sh +3 -0
  111. package/scripts/remediate-approved-head.test.sh +2 -0
  112. package/scripts/remediate-basesync.test.sh +1 -1
  113. package/scripts/review-acceptance-gate.sh +2 -2
  114. package/scripts/review-acceptance-gate.test.sh +20 -0
  115. package/scripts/review-delta.sh +8 -1
  116. package/scripts/review-delta.test.sh +19 -0
  117. package/scripts/review-finalize.sh +294 -53
  118. package/scripts/review-finalize.test.sh +347 -9
  119. package/scripts/review-pr-delta-wiring.test.sh +13 -0
  120. package/scripts/review-prepare.sh +5 -3
  121. package/scripts/review-prepare.test.sh +18 -2
  122. package/scripts/review-roster.sh +18 -5
  123. package/scripts/review-roster.test.sh +57 -1
  124. package/scripts/run-shell-tests.sh +5 -0
  125. package/scripts/selfreview-plan.sh +86 -0
  126. package/scripts/selfreview-plan.test.sh +90 -0
  127. package/scripts/spawn-depth-check.sh +5 -4
  128. package/scripts/spawn-depth-check.test.sh +3 -0
  129. package/scripts/telemetry.test.sh +10 -0
  130. package/scripts/transition-label.sh +26 -0
  131. package/scripts/trusted-comments.test.sh +2 -1
  132. package/scripts/verify-phase-trail.test.sh +14 -0
  133. package/scripts/wait-ci-green.sh +61 -9
  134. package/scripts/wait-ci-green.test.sh +41 -3
  135. 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);
@@ -2586,7 +2586,7 @@ Do not ask the user questions — you are running autonomously in the background
2586
2586
 
2587
2587
  Run the release block only at the end of Step 3 below, once the outcome is settled (merged or labelled terminal state, or the fallback's label write verified), not right after `review-pr` returns.
2588
2588
 
2589
- **Step 3 — outcome.** On `REVIEW_RESULT: status: COMPLETE` (merged), drive `work-on:close` and let item 6.6 wake dependents as normal. If `review-pr` returns another result with its own labelled terminal state (CHANGES REQUESTED, or `needs-human` for genuine blockers), that verdict applies and the issue is `GATED` or failed per the existing rules. `REREVIEW_MARKER=LOST` (another actor claimed this head first) and `REREVIEW_STATE=CLAIMED` take no action: leave the issue as it is and re-check next cycle. Before applying the terminal fallback for `STALE`, re-read the PR state and the issue labels once more: a merged or closed PR, or an issue that already advanced, means skip the fallback and report the state (forge#3428). If dispatch failed, the skill did not resolve, the issue is still at `workflow:in-review` with no `needs-human` after the retry, Step 1 printed `STALE`, or Step 2 printed `REREVIEW_MARKER=UNVERIFIED` and the re-check below found no live claim, apply the **terminal fallback** defined in `commands/work-on.md` Phase 0A.1 (labels re-read first, comment with the reason, add `needs-human` and remove `workflow:in-review` in one edit, verify the write), classify the issue `GATED`, and surface it to the operator in the run summary as `GATED: re-review could not run (#{PRED}, PR #{REREVIEW_PR}): <reason>`. For `REREVIEW_MARKER=UNVERIFIED`, first re-check for another actor's fresh claim (forge#3429): re-run the Step 1 CLAIMED/STALE/DONE classification for this head (with the SHARED HELPERS pasted at the top of that block). Map the result, reading the printed `FIRST_CLAIM_ID` (the lowest-id, winning claim, the same `.[0]` row that decides ownership everywhere else): if `FIRST_CLAIM_ID` equals this actor's own `CLAIM_ID` (the value Step 2 printed; an empty value never matches), the winning claim is our own unverified one, so go to the fallback (do not stand down on it). If `FIRST_CLAIM_ID` is another actor's id, the winning claim is not ours: classify it with the mapping below, exactly as the router's Claim pre-check step 4 does for `LOST` (forge#3431), so a `STALE` foreign winner (a crashed earlier claimant) goes to the fallback and never strands the issue, even though our own row still exists. Otherwise (and for a foreign winner) `CLAIMED` (another actor's live claim) and `NONE` (merged, closed, or advanced past in-review) stand down like `LOST` (no dispatch, no fallback, re-check next cycle); `UNREADABLE` writes nothing (fail closed, classify `GATED` this cycle without the fallback, as in Step 1); `STALE` and `PENDING` go to the fallback. The reason is `re-review claim could not be recorded` and the report line is `GATED: re-review could not run (#{PRED}, PR #{REREVIEW_PR}): marker write failed`; because the claim is written first, `UNVERIFIED` now means `review-pr` was NOT dispatched. Do not leave the issue `PENDING`. A failed label write is reported as such, not as done. The verified per-head claim bounds this to one re-review per PR head across the orchestrator and the router (no loop). Never review inline.
2589
+ **Step 3 — outcome.** On `REVIEW_RESULT: status: COMPLETE` (merged), drive `work-on:close` and let item 6.6 wake dependents as normal. If `review-pr` returns another result with its own labelled terminal state (CHANGES REQUESTED, or `needs-human` for genuine blockers), that verdict applies and the issue is `GATED` or failed per the existing rules. **`base-conflict` mapping (forge#3516)**: `REVIEW_RESULT: status: BLOCKED` whose blocker contains `base-conflict` carries no label, so it is neither COMPLETE nor a labelled terminal state and must not fall through. Only the verified claim owner (`REREVIEW_MARKER=VERIFIED`) maps it, mirroring `commands/work-on.md` Phase 0A.1 "Base-conflict mapping": re-read the issue labels (closed, `needs-human`, or past `workflow:in-review` means skip and report; idempotent), and unless `DRY_RUN=true` (then record "would set workflow:remediating") run `transition-label.sh {PRED} -R {GH_REPO} remediating` and re-read the labels to verify. `needs-human` keeps precedence over `workflow:remediating` in `classify_predecessor_state`, and item 6.4 dispatches the base-sync remediation on the next cycle, bounded by `FORGE:BASESYNC_REMEDIATION`. If the transition fails or is unverified, apply the terminal fallback (`needs-human`) and do not post the release as settled. Never treat it as GATED-by-human or merge manually. `REREVIEW_MARKER=LOST` (another actor claimed this head first) and `REREVIEW_STATE=CLAIMED` take no action: leave the issue as it is and re-check next cycle. Before applying the terminal fallback for `STALE`, re-read the PR state and the issue labels once more: a merged or closed PR, or an issue that already advanced, means skip the fallback and report the state (forge#3428). If dispatch failed, the skill did not resolve, the issue is still at `workflow:in-review` with no `needs-human` after the retry, Step 1 printed `STALE`, or Step 2 printed `REREVIEW_MARKER=UNVERIFIED` and the re-check below found no live claim, apply the **terminal fallback** defined in `commands/work-on.md` Phase 0A.1 (labels re-read first, comment with the reason, add `needs-human` and remove `workflow:in-review` in one edit, verify the write), classify the issue `GATED`, and surface it to the operator in the run summary as `GATED: re-review could not run (#{PRED}, PR #{REREVIEW_PR}): <reason>`. For `REREVIEW_MARKER=UNVERIFIED`, first re-check for another actor's fresh claim (forge#3429): re-run the Step 1 CLAIMED/STALE/DONE classification for this head (with the SHARED HELPERS pasted at the top of that block). Map the result, reading the printed `FIRST_CLAIM_ID` (the lowest-id, winning claim, the same `.[0]` row that decides ownership everywhere else): if `FIRST_CLAIM_ID` equals this actor's own `CLAIM_ID` (the value Step 2 printed; an empty value never matches), the winning claim is our own unverified one, so go to the fallback (do not stand down on it). If `FIRST_CLAIM_ID` is another actor's id, the winning claim is not ours: classify it with the mapping below, exactly as the router's Claim pre-check step 4 does for `LOST` (forge#3431), so a `STALE` foreign winner (a crashed earlier claimant) goes to the fallback and never strands the issue, even though our own row still exists. Otherwise (and for a foreign winner) `CLAIMED` (another actor's live claim) and `NONE` (merged, closed, or advanced past in-review) stand down like `LOST` (no dispatch, no fallback, re-check next cycle); `UNREADABLE` writes nothing (fail closed, classify `GATED` this cycle without the fallback, as in Step 1); `STALE` and `PENDING` go to the fallback. The reason is `re-review claim could not be recorded` and the report line is `GATED: re-review could not run (#{PRED}, PR #{REREVIEW_PR}): marker write failed`; because the claim is written first, `UNVERIFIED` now means `review-pr` was NOT dispatched. Do not leave the issue `PENDING`. A failed label write is reported as such, not as done. The verified per-head claim bounds this to one re-review per PR head across the orchestrator and the router (no loop). Never review inline.
2590
2590
 
2591
2591
  This satisfies #1809 Q2 (the orchestrator auto-dispatches remediation against the gated issue itself — the exact gap forge#1812's item 6.5/6.6 left open, since those items only ever track and wake *dependents*, never the gated PR's own remediation) — and closes forge#2243 (the gap that #1812's fix left open for *leaf* issues with no dependents: because this item's `$PRED` binding was never made explicit and self-contained, it was only ever reached while walking a dependent's predecessor list, so a `needs-human` issue that has no dependents never got remediation dispatched and its CHANGES-REQUESTED PR re-reviewed the same unchanged commit forever). The remediation agent's outcome is picked up on the **next** completion-monitoring cycle of this same Step 4B loop: if it lands (`workflow:merged`), item 6.6 below fires normally and wakes any `blocked-on-human-merge` dependents (if any exist — a leaf issue simply has none to wake); if it holds/re-escalates, the issue simply remains `GATED` and item 6.5 continues tracking its dependents (if any) unchanged.
2592
2592
 
@@ -42,6 +42,25 @@ Pipeline agents MUST NOT use `find` (unbounded or filesystem-wide) to locate pro
42
42
 
43
43
  ---
44
44
 
45
+ ## SELF-REVIEW MODE (build-time, local diff) <!-- Added: forge#3971 -->
46
+
47
+ `work-on:build` Phase B5.6 runs the same domain personas on the builder's staged diff before any PR exists, so the builder can fix adversarial-completeness findings while the plan and code are warm. The personas, the Evidence-Based Review Protocol (sections 1-7) and the Structured Findings Protocol apply unchanged; only the transport differs. This mode is entered only when the dispatch prompt says `MODE: SELF-REVIEW`. It is never the official review and never satisfies a review gate.
48
+
49
+ **Inputs the dispatcher supplies**: `[AGENT_DOMAIN]` (the marker domain), `[ISSUE_NUMBER]`, `[GH_REPO]`, `[WORKTREE_PATH]`, `[PR_BASE]`, `[TREE_HASH]` (the staged tree, `git write-tree`), and `[DOMAIN_DIFF_SLICE]` (the slice of `git diff --cached origin/[PR_BASE]`, scoped exactly as in Per-Agent Input Scoping; the Security agent gets the full diff).
50
+
51
+ **Substitutions** (persona text that names a PR still applies, translated):
52
+ - No `gh pr *` call is ever made: there is no PR. Read the slice from `[DOMAIN_DIFF_SLICE]` (materialize `SLICE_FILE` with the same heredoc, using `[ISSUE_NUMBER]` and `[AGENT_DOMAIN]` in the path in place of `[PR_NUMBER]` and the SHA), and list changed files with `git -C "[WORKTREE_PATH]" diff --cached --name-only "origin/[PR_BASE]"` wherever a persona runs `gh pr diff [PR_NUMBER] --name-only`. Reads of other repo files happen inside `[WORKTREE_PATH]` with the usual bounded-output rules.
53
+ - No `Reviewed-SHA` staleness check. The pin is the tree: include exactly one line `Tree: [TREE_HASH]` (outside the marker) in the persisted body.
54
+ - Persist Before Post still applies, but the post is `gh issue comment [ISSUE_NUMBER] -R [GH_REPO] --body-file "$REVIEW_BODY_PATH"`, never `gh pr comment`.
55
+
56
+ **Marker**: the body carries exactly one `<!-- FORGE:SELFREVIEW-AGENT:{domain} -->` marker and NEVER a `FORGE:REVIEW-AGENT` marker or any other FORGE phase marker. `/review-pr`, `scripts/review-finalize.sh` and `scripts/verify-phase-trail.sh` match only `FORGE:REVIEW-AGENT:*`, so a self-review comment never counts as a panel member or as review evidence.
57
+
58
+ **Findings**: the same `<!-- REVIEW-FINDINGS-START -->` block, the same prefixes, confidence, severity, `**Failure scenario**` and `**Scope**` rules. Report the full list, including POSSIBLE and LOW (the builder acts only on CONFIRMED and LIKELY at MEDIUM or above and records the rest). Return the verdict and one line per finding to the dispatcher as usual; completion is read from the posted comment's `Tree:` line, not from a notification.
59
+
60
+ **Do not**: edit any file, run `git add`/`git commit`/`git push`, post a verdict, label, or review marker, or create issues. A self-review agent is read-only.
61
+
62
+ ---
63
+
45
64
  ## Evidence-Based Review Protocol (ALL Agents Follow) <!-- allowlist:check-protocol-restatements -->
46
65
 
47
66
  Every agent MUST follow this protocol:
@@ -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