forgedock 1.30.0 → 1.32.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 (162) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -2
  2. package/bin/engine/runlog.mjs +82 -12
  3. package/bin/engine/telemetry-seam.mjs +11 -4
  4. package/bin/engine/telemetry-seam.test.mjs +18 -0
  5. package/bin/engine-cli.mjs +409 -38
  6. package/bin/engine.mjs +47 -37
  7. package/bin/forgedock.mjs +35 -3
  8. package/bin/gc.mjs +50 -17
  9. package/bin/hooks/reread-nudge.mjs +65 -0
  10. package/bin/labels.json +5 -0
  11. package/bin/lean/apishape.mjs +170 -0
  12. package/bin/lean/apishape.test.mjs +187 -0
  13. package/bin/lean/batch.mjs +32 -0
  14. package/bin/lean/batch.test.mjs +338 -2
  15. package/bin/lean/budget.mjs +89 -0
  16. package/bin/lean/budget.test.mjs +106 -0
  17. package/bin/lean/checks.mjs +32 -9
  18. package/bin/lean/checks.test.mjs +78 -1
  19. package/bin/lean/conformance.e2e.test.mjs +27 -5
  20. package/bin/lean/conformance.mjs +150 -15
  21. package/bin/lean/conformance.test.mjs +175 -3
  22. package/bin/lean/context.mjs +128 -44
  23. package/bin/lean/context.test.mjs +157 -5
  24. package/bin/lean/decompose.mjs +90 -0
  25. package/bin/lean/decompose.test.mjs +105 -0
  26. package/bin/lean/depcache.mjs +11 -7
  27. package/bin/lean/depcache.test.mjs +81 -0
  28. package/bin/lean/e2e.mjs +102 -0
  29. package/bin/lean/e2e.test.mjs +86 -0
  30. package/bin/lean/effects.mjs +48 -15
  31. package/bin/lean/explore.mjs +47 -0
  32. package/bin/lean/explore.test.mjs +61 -0
  33. package/bin/lean/facts.mjs +84 -0
  34. package/bin/lean/facts.test.mjs +66 -0
  35. package/bin/lean/fixround.mjs +90 -0
  36. package/bin/lean/fixround.test.mjs +117 -0
  37. package/bin/lean/heavy.mjs +35 -2
  38. package/bin/lean/heavy.test.mjs +32 -2
  39. package/bin/lean/lean.mjs +712 -134
  40. package/bin/lean/lean.test.mjs +1626 -37
  41. package/bin/lean/ledger.mjs +5 -3
  42. package/bin/lean/ledger.test.mjs +18 -0
  43. package/bin/lean/light.mjs +135 -0
  44. package/bin/lean/light.test.mjs +173 -0
  45. package/bin/lean/lightlane.mjs +219 -0
  46. package/bin/lean/lightlane.test.mjs +237 -0
  47. package/bin/lean/limits.test.mjs +36 -1
  48. package/bin/lean/orchestrate.mjs +303 -41
  49. package/bin/lean/pitfalls.mjs +136 -0
  50. package/bin/lean/pitfalls.test.mjs +117 -0
  51. package/bin/lean/postmerge.mjs +85 -0
  52. package/bin/lean/postmerge.test.mjs +79 -0
  53. package/bin/lean/precheck.mjs +138 -13
  54. package/bin/lean/precheck.test.mjs +118 -1
  55. package/bin/lean/prefilter.mjs +220 -5
  56. package/bin/lean/prefilter.test.mjs +260 -4
  57. package/bin/lean/preflight.mjs +108 -3
  58. package/bin/lean/preflight.test.mjs +48 -1
  59. package/bin/lean/probe.mjs +297 -0
  60. package/bin/lean/probe.test.mjs +210 -0
  61. package/bin/lean/prompts.mjs +104 -13
  62. package/bin/lean/render.mjs +7 -3
  63. package/bin/lean/resume.mjs +67 -0
  64. package/bin/lean/resume.test.mjs +84 -0
  65. package/bin/lean/review-trigger.mjs +12 -0
  66. package/bin/lean/review-trigger.test.mjs +17 -1
  67. package/bin/lean/routing.mjs +74 -0
  68. package/bin/lean/routing.test.mjs +67 -0
  69. package/bin/lean/sched.mjs +16 -1
  70. package/bin/lean/sched.test.mjs +175 -1
  71. package/bin/lean/schedd.mjs +55 -9
  72. package/bin/lean/schedd.test.mjs +170 -3
  73. package/bin/lean/selfreview.mjs +37 -0
  74. package/bin/lean/selfreview.test.mjs +51 -0
  75. package/bin/lean/seqlease.mjs +40 -0
  76. package/bin/lean/seqlease.test.mjs +101 -0
  77. package/bin/lean/sequence.mjs +296 -13
  78. package/bin/lean/sequence.test.mjs +263 -2
  79. package/bin/lean/toolchain.mjs +60 -15
  80. package/bin/lean/toolchain.test.mjs +120 -3
  81. package/bin/lean/toolstats.mjs +72 -0
  82. package/bin/lean/toolstats.test.mjs +47 -0
  83. package/bin/lean/verifier.mjs +232 -0
  84. package/bin/lean/verifier.test.mjs +163 -0
  85. package/bin/lean/verify.mjs +72 -10
  86. package/bin/lean/verify.test.mjs +68 -3
  87. package/bin/review-staging.mjs +533 -0
  88. package/bin/runner.mjs +82 -13
  89. package/bin/stats/aggregate.mjs +87 -2
  90. package/bin/stats/efficiency.mjs +335 -0
  91. package/bin/stats/efficiency.test.mjs +266 -0
  92. package/bin/stats/github-source.mjs +8 -3
  93. package/bin/stats/index.mjs +91 -6
  94. package/bin/stats/local-source.mjs +145 -8
  95. package/bin/stats/query.mjs +181 -3
  96. package/bin/stats/render.mjs +22 -0
  97. package/bin/stats/stats.test.mjs +403 -8
  98. package/commands/orchestrate.md +3 -1
  99. package/commands/review-pr-agents/protocols.md +1 -1
  100. package/commands/review-pr-staging.md +84 -831
  101. package/commands/review-pr.md +1 -1
  102. package/commands/work-on/build/implement.md +1 -0
  103. package/commands/work-on/build/validate.md +11 -1
  104. package/docs/CONFIG.md +80 -5
  105. package/docs/OUTCOME-CODES.md +2 -1
  106. package/docs/STATS.md +66 -1
  107. package/docs/TELEMETRY.md +1 -1
  108. package/docs/eval/corpus/lightlane-v1.json +55 -0
  109. package/docs/eval/lightlane-bench.md +91 -0
  110. package/docs/spec/review-protocol.md +1 -1
  111. package/package.json +1 -1
  112. package/scripts/bench-lightlane.mjs +286 -0
  113. package/scripts/ci-local/lib.mjs +20 -0
  114. package/scripts/ci-local/plan.mjs +2 -12
  115. package/scripts/ci-local/scoping.mjs +10 -1
  116. package/scripts/ci-local.test.sh +2 -0
  117. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  118. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  119. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  120. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  121. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  122. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  123. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  124. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  125. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  126. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  127. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  128. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  129. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  130. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  131. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  132. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  133. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  134. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  135. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  136. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  137. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  138. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  139. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  140. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  141. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  142. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  143. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  144. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  145. package/scripts/forge-root.test.sh +2 -2
  146. package/scripts/publish-staging-sync.test.sh +62 -4
  147. package/scripts/review-finalize.sh +17 -6
  148. package/scripts/review-finalize.test.sh +47 -0
  149. package/scripts/review-pr-arguments.test.sh +13 -1
  150. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  151. package/scripts/review-pr-gate2.test.sh +6 -14
  152. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  153. package/scripts/review-pr-staging-scope.test.sh +39 -33
  154. package/scripts/review-prepare.sh +73 -6
  155. package/scripts/review-prepare.test.sh +85 -0
  156. package/scripts/review-staging-post.sh +293 -0
  157. package/scripts/review-staging-post.test.sh +143 -0
  158. package/scripts/review-staging-pre.sh +401 -0
  159. package/scripts/review-staging-pre.test.sh +150 -0
  160. package/scripts/symbol-index.mjs +26 -7
  161. package/scripts/symbol-index.py +55 -9
  162. package/scripts/trusted-comments.test.sh +1 -1
@@ -0,0 +1,533 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * `forgedock review-staging [<PR>]` (#4358): the deterministic pre-dispatch of the staging->main review
5
+ * (commands/review-pr-staging.md Phase -1, 0A, 0B.1, 0C and the roster), with one reviewer brief per selected domain
6
+ * written to a file. The calling agent dispatches the briefs the manifest lists; nothing is extracted from the spec by hand.
7
+ *
8
+ * Steps: route marker -> bundle PR set -> open review-finding gate (stops on block) -> 0B.1 scope -> review-prepare.sh
9
+ * (stops on BLOCKED) -> roster (ctx.roster, computed by review-roster.sh inside review-prepare.sh) -> briefs -> manifest.
10
+ *
11
+ * `--delta <from-sha>` (#4360), after a fix landed mid-review: review-prepare.sh --delta-from narrows the diff, slices and roster to
12
+ * <from>..head (the domains the delta touches); the spec-owned panel is credited from the earlier pass, and --finalize reads the
13
+ * earlier pass's findings too (review-finalize.sh extract, DELTA mode), so that pass is credited, not redone.
14
+ *
15
+ * Post-dispatch (#4359), after the reviewers posted and the agent saved the /test-gate output:
16
+ * `--finalize --test-gate-output FILE [--notes-dropped N]`: delivery + panel guard and the 6.5 verdict handling
17
+ * (scripts/review-staging-post.sh), then review-finalize.sh extract, classify (dry run, then for real) and file-prepare;
18
+ * prints one FILE line per worklist item to file through /issue. `--summary [--filed N]...`: the Phase 8 verdict and summary
19
+ * comment, which always carries one gate marker (FORGE:GATE_PASS on APPROVE FOR DEPLOY, FORGE:GATE_FAILURE otherwise).
20
+ *
21
+ * State files keep the spec's names in $FORGE_SCRATCHPAD (default <cwd>/.forge-scratch). Scripts come from this install's
22
+ * own root, never the repo under review. Every effect goes through `io`, so the flow is testable without gh or git.
23
+ */
24
+ import { execFile } from "node:child_process";
25
+ import * as nodeFs from "node:fs";
26
+ import { join } from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+ import { parseForgeYaml } from "./forge-utils.mjs";
29
+
30
+ const INSTALL_ROOT = fileURLToPath(new URL("..", import.meta.url));
31
+ const USAGE = "usage: forgedock review-staging [<PR number|PR URL>] [--thorough | --delta <from-sha>] [--json]\n" +
32
+ " forgedock review-staging [<PR>] --finalize --test-gate-output <file> [--notes-dropped <n>] [--json]\n" +
33
+ " forgedock review-staging [<PR>] --summary [--filed <issue>]... [--json]";
34
+ const SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
35
+ /** Persona file / catalog keys per canonical marker domain (review-pr-agents.md routing table). */
36
+ const INDEX_KEY = { auth: "auth", billing: "billing", database: "database", api: "api", frontend: "frontend", infra: "infrastructure" };
37
+ const CONFIG_DOMAIN = { scraper: "scraping" };
38
+ const READ_ONLY_GIT_RULE = "Read-only git rule: never run `git checkout`, `git switch`, `git reset`, `git stash`, or `git merge` in the main checkout (the shared primary working tree). Inspect PR code with `git show <sha>:<path>`, `gh pr diff`, `gh api`, or a per-PR worktree (`git worktree add --detach`) you remove afterward.";
39
+ /** Reviewers whose prompts are prose inside the spec (no persona template): dispatched per the spec, not briefed here. */
40
+ const SPEC_PANEL = "material-change (Phase 2), bug hunters (Phase 3), code-quality (Phase 4), regression-risk (Phase 6)";
41
+
42
+ class Stop extends Error {
43
+ constructor(code, lines) { super(lines.join("\n")); this.code = code; this.lines = lines; }
44
+ }
45
+
46
+ /** argv -> {pr, thorough, json, mode, testGateOutput, notesDropped, filed, delta}. Throws Stop(2) on anything else. */
47
+ export function parseArgs(argv) {
48
+ const o = { pr: null, thorough: false, json: false, mode: "pre", testGateOutput: null, notesDropped: 0, filed: [], delta: null };
49
+ const setMode = (m, a) => { if (o.mode !== "pre") throw new Stop(2, [`review-staging: ${a} cannot be combined with --${o.mode}`, USAGE]); o.mode = m; };
50
+ const value = (i, a) => { const v = argv[i + 1]; if (v == null || v.startsWith("--")) throw new Stop(2, [`review-staging: ${a} requires a value`, USAGE]); return v; };
51
+ for (let i = 0; i < argv.length; i++) {
52
+ const a = argv[i];
53
+ if (a === "--thorough") o.thorough = true;
54
+ else if (a === "--json") o.json = true;
55
+ else if (a === "--finalize") setMode("finalize", a);
56
+ else if (a === "--summary") setMode("summary", a);
57
+ else if (a === "--test-gate-output") o.testGateOutput = value(i++, a);
58
+ else if (a === "--delta") {
59
+ const v = value(i++, a);
60
+ if (!SHA_RE.test(v)) throw new Stop(2, [`review-staging: --delta takes the full 40/64-hex head SHA of the earlier pass: ${v}`, USAGE]);
61
+ o.delta = v;
62
+ }
63
+ else if (a === "--notes-dropped") {
64
+ const v = value(i++, a);
65
+ if (!/^\d+$/.test(v)) throw new Stop(2, [`review-staging: --notes-dropped must be a count: ${v}`, USAGE]);
66
+ o.notesDropped = Number(v);
67
+ } else if (a === "--filed") {
68
+ for (const n of value(i++, a).split(/[\s,]+/).filter(Boolean)) {
69
+ const m = /^#?(\d+)$/.exec(n);
70
+ if (!m) throw new Stop(2, [`review-staging: --filed takes issue numbers: ${n}`, USAGE]);
71
+ o.filed.push(Number(m[1]));
72
+ }
73
+ } else if (!a.startsWith("-") && o.pr == null) {
74
+ const m = /^(?:https?:\/\/\S*\/pull\/)?(\d+)$/.exec(a);
75
+ if (!m) throw new Stop(2, [`review-staging: not a PR number or URL: ${a}`, USAGE]);
76
+ o.pr = Number(m[1]);
77
+ } else throw new Stop(2, [`review-staging: unknown argument: ${a}`, USAGE]);
78
+ }
79
+ if (o.mode !== "finalize" && (o.testGateOutput != null || o.notesDropped)) throw new Stop(2, ["review-staging: --test-gate-output/--notes-dropped belong to --finalize", USAGE]);
80
+ if (o.mode !== "summary" && o.filed.length) throw new Stop(2, ["review-staging: --filed belongs to --summary", USAGE]);
81
+ if (o.mode !== "pre" && o.thorough) throw new Stop(2, ["review-staging: --thorough belongs to the pre-dispatch", USAGE]);
82
+ if (o.mode !== "pre" && o.delta) throw new Stop(2, ["review-staging: --delta belongs to the pre-dispatch (--finalize reads it from the review context)", USAGE]);
83
+ if (o.thorough && o.delta) throw new Stop(2, ["review-staging: --delta cannot be combined with --thorough", USAGE]);
84
+ if (o.mode === "finalize" && !o.testGateOutput) throw new Stop(2, ["review-staging: --finalize requires --test-gate-output <file> (the saved /test-gate output)", USAGE]);
85
+ return o;
86
+ }
87
+
88
+ /** Replace every `[KEY]` in one pass: a substituted value is never rescanned, so diff text cannot inject placeholders. */
89
+ export function renderBrief(template, values) {
90
+ return template.replace(/\[([A-Z][A-Z0-9_]*)\]/g, (m, k) => (Object.hasOwn(values, k) && values[k] != null ? String(values[k]) : m));
91
+ }
92
+
93
+ /** Text manifest the calling agent acts on. */
94
+ export function buildManifest(m) {
95
+ const lines = [
96
+ `REVIEW_STAGING: PR #${m.pr} head ${m.headSha}`,
97
+ `GATE: ${m.gate}`,
98
+ `BUNDLE_PRS: ${(m.bundle ?? []).join(" ")}`,
99
+ `SCOPE_MODE=${m.scopeMode}`,
100
+ `ROSTER: tier=${m.tier} agents=${m.briefs.map((b) => b.domain).join(" ")}${m.reason ? ` reason=${m.reason}` : ""}`,
101
+ ...(m.delta ? [`DELTA: ${m.delta.from}..${m.headSha} files=${m.delta.files.length}`] : []),
102
+ `CONTEXT_FILE=${m.contextFile}`,
103
+ ...m.briefs.map((b) => `DISPATCH ${b.domain} ${b.path}`),
104
+ `NEXT: dispatch one isolated reviewer per DISPATCH line (prompt = the brief file's content) in a single message; before each launch append its domain to ${m.launchedFile}.`,
105
+ m.delta
106
+ ? `NEXT: delta pass: the spec-owned panel (${SPEC_PANEL}) is credited from the earlier pass at ${m.delta.from.slice(0, 7)}; dispatch only the DISPATCH lines.`
107
+ : `NEXT: dispatch the spec-owned panel per commands/review-pr-staging.md (${SPEC_PANEL}), appending each domain to the same file.`,
108
+ `NEXT: after every reviewer posted, run /test-gate, save its output to a file, then run: forgedock review-staging ${m.pr} --finalize --test-gate-output <file>`,
109
+ ];
110
+ return lines.join("\n");
111
+ }
112
+
113
+ function defaultIo() {
114
+ const run = (cmd, args, opts = {}) => new Promise((res) => {
115
+ execFile(cmd, args, { maxBuffer: 256 * 1024 * 1024, env: opts.env ?? process.env, cwd: opts.cwd, timeout: opts.timeout ?? 0 }, (err, stdout, stderr) => {
116
+ res({ code: err ? (typeof err.code === "number" ? err.code : 1) : 0, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
117
+ });
118
+ });
119
+ return {
120
+ root: INSTALL_ROOT, env: process.env, cwd: process.cwd(), run,
121
+ gh: async (args) => { const r = await run("gh", args); if (r.code !== 0) throw new Error(`gh ${args.slice(0, 2).join(" ")} failed: ${r.stderr.trim()}`); return r.stdout; },
122
+ readFile: (p) => nodeFs.readFileSync(p, "utf8"),
123
+ writeFile: (p, s) => nodeFs.writeFileSync(p, s),
124
+ rm: (p) => nodeFs.rmSync(p, { force: true }),
125
+ mkdir: (p) => nodeFs.mkdirSync(p, { recursive: true }),
126
+ out: (s) => process.stdout.write(s + "\n"),
127
+ err: (s) => process.stderr.write(s + "\n"),
128
+ };
129
+ }
130
+
131
+ function readConfig(io) {
132
+ const path = io.env.FORGE_CONFIG || join(io.cwd, "forge.yaml");
133
+ let cfg = {};
134
+ try { cfg = parseForgeYaml(io.readFile(path)) ?? {}; } catch { /* absent config: owner/repo check below fails */ }
135
+ const owner = cfg.project?.owner, repo = cfg.project?.repo;
136
+ if (!owner || !repo) throw new Stop(1, [`review-staging: project.owner/project.repo not set in ${path}`]);
137
+ return { ghRepo: `${owner}/${repo}`, defaultBranch: cfg.branches?.default || "main", stagingBranch: cfg.branches?.staging || "staging", configPath: path };
138
+ }
139
+
140
+ /** Same derivation as commands/review-pr-staging.md Phase -1 (and scripts/lib/fence-bootstrap.sh). */
141
+ function runKey(env) {
142
+ const k = env.FORGE_RUN_KEY || env.FORGE_RUN_ID || env.CLAUDE_CODE_SESSION_ID || env.CLAUDE_SESSION_ID || env.CODEX_THREAD_ID || env.OPENCODE_SESSION_ID || String(process.ppid);
143
+ return k.replace(/[^A-Za-z0-9_-]/g, "_");
144
+ }
145
+
146
+ const findingFilter = (n) => (i) => (i.title ?? "").endsWith(`PR #${n})`) || new RegExp(`(^|\\n)\\*\\*Source\\*\\*: PR #${n}([^0-9]|$)`).test(i.body ?? "");
147
+
148
+ async function priorFindings(io, c, pr, state) {
149
+ try {
150
+ const list = JSON.parse(await io.gh(["issue", "list", "-R", c.ghRepo, "--label", "review-finding", "--state", state, "--search", `PR #${pr}`, "--limit", "50", "--json", "number,title,body"]));
151
+ return list.filter(findingFilter(pr)).map((i) => `#${i.number}: ${i.title}`);
152
+ } catch { return []; }
153
+ }
154
+
155
+ /** Shared protocols plus the persona text for `domain`; null when the template source cannot supply it. */
156
+ function loadTemplate(io, templates, domain) {
157
+ const tryRead = (p) => { try { return io.readFile(p); } catch { return null; } };
158
+ if (templates?.source === "monolithic_catalog") {
159
+ const cat = templates.monolithic_catalog && tryRead(templates.monolithic_catalog);
160
+ if (!cat) return null;
161
+ const parts = cat.split(/^(?=### Agent:)/m);
162
+ const persona = parts.slice(1).find((p) => p.split("\n")[0].toLowerCase().includes(domain === "infra" ? "infra" : domain));
163
+ return persona ? `${parts[0]}\n${persona}` : null;
164
+ }
165
+ if (!templates?.base) return null;
166
+ const protocols = tryRead(join(templates.base, "protocols.md"));
167
+ const persona = tryRead(join(templates.base, `${domain}.md`));
168
+ return protocols && persona ? `${protocols}\n\n---\n\n${persona}` : null;
169
+ }
170
+
171
+ function scopeSection(scope, delta, head) {
172
+ const list = (a) => (a?.length ? a.join(" ") : "(none)");
173
+ if (delta) {
174
+ return [
175
+ "## Review scope",
176
+ `DELTA review: review only the changes in ${delta.from}..${head} (the diff slice above is that range). The earlier pass at ${delta.from} is credited: its findings are carried into triage, so do not re-review unchanged code or re-report findings in it. Report the scope used in your comment.`,
177
+ `DELTA_FILES: ${list(delta.files)}`,
178
+ ].join("\n");
179
+ }
180
+ if (scope.scope_mode !== "scoped") return `## Review scope\nSCOPE_MODE=full: review the full bundle diff. Report the scope used in your comment.`;
181
+ return [
182
+ "## Review scope",
183
+ "SCOPE_MODE=scoped. Line-level review is limited to CROSS_PR_FILES, the diffs of UNREVIEWED_COMMITS, and a runtime/deploy readiness checklist (required secrets and env vars present and documented, migration ordering and collisions, deploy wiring and workflow sibling drift). Report every finding you see with its severity and file, whatever file or commit it lives in; a CRITICAL or HIGH finding is always kept. Safety-domain agents (Security, Auth, Billing, Concurrency, Database) still read the whole diff of any in-scope file. Report the scope used in your comment.",
184
+ `REVIEWED_COMMITS: ${list(scope.reviewed_commits)}`,
185
+ `UNREVIEWED_COMMITS: ${list(scope.unreviewed_commits)}`,
186
+ `CROSS_PR_FILES: ${list(scope.cross_pr_files)}`,
187
+ `DEPLOY_WIRING_FILES: ${list(scope.deploy_wiring_files)}`,
188
+ ].join("\n");
189
+ }
190
+
191
+ async function bash(io, script, args, env) {
192
+ return io.run("bash", [join(io.root, "scripts", script), ...args], { env, cwd: io.cwd });
193
+ }
194
+
195
+ async function openStagingPr(io, c) {
196
+ const n = (await io.gh(["pr", "list", "-R", c.ghRepo, "--head", c.stagingBranch, "--base", c.defaultBranch, "--state", "open", "--json", "number", "--jq", ".[0].number"])).trim();
197
+ if (!/^\d+$/.test(n)) throw new Stop(1, [`review-staging: no open ${c.stagingBranch}->${c.defaultBranch} PR found`]);
198
+ return Number(n);
199
+ }
200
+
201
+ async function headSha(io, c, pr) {
202
+ let head = "";
203
+ try { head = (await io.gh(["pr", "view", String(pr), "-R", c.ghRepo, "--json", "headRefOid", "--jq", ".headRefOid"])).trim(); } catch { /* fail closed below */ }
204
+ if (!SHA_RE.test(head)) throw new Stop(1, [`REVIEW_RESULT: status: BLOCKED, blocker: could not resolve a full head SHA for PR #${pr}; nothing reviewed (fail closed)`]);
205
+ return head;
206
+ }
207
+
208
+ async function main(argv, io) {
209
+ const a = parseArgs(argv);
210
+ if (a.mode === "finalize") return finalize(a, io);
211
+ if (a.mode === "summary") return summary(a, io);
212
+ const c = readConfig(io);
213
+ const sd = io.env.FORGE_SCRATCHPAD || join(io.cwd, ".forge-scratch");
214
+ io.mkdir(sd);
215
+ // Child scripts resolve ForgeDock helpers from this install (FORGEDOCK_HOME is authoritative in forge_resolve).
216
+ const env = { ...io.env, FORGE_SCRATCHPAD: sd, FORGEDOCK_HOME: io.root };
217
+
218
+ // Phase -1: PR, head SHA, prior passes, route marker, run-keyed pointer
219
+ const pr = a.pr ?? await openStagingPr(io, c);
220
+ const head = await headSha(io, c, pr);
221
+ if (a.delta && a.delta === head) throw new Stop(1, [`REVIEW_RESULT: status: BLOCKED, blocker: --delta ${head} is the current head of PR #${pr}: nothing changed since that pass, nothing to review`]);
222
+ const sha7 = head.slice(0, 7);
223
+ let priorRoutes = 0;
224
+ try {
225
+ const out = await io.gh(["api", "--paginate", `repos/${c.ghRepo}/issues/${pr}/comments`, "--jq", '[.[] | select(.body | test("FORGE:REVIEW_ROUTE"))] | length']);
226
+ priorRoutes = out.split(/\s+/).filter(Boolean).reduce((s, x) => s + (Number(x) || 0), 0);
227
+ } catch { /* unreadable: treated as a first pass */ }
228
+ const priorOpen = priorRoutes > 0 ? await priorFindings(io, c, pr, "open") : [];
229
+ const priorResolved = priorRoutes > 0 ? await priorFindings(io, c, pr, "closed") : [];
230
+ await io.gh(["pr", "comment", String(pr), "-R", c.ghRepo, "--body", `<!-- FORGE:REVIEW_ROUTE mode=staging-deploy spec=review-pr-staging.md sha=${sha7} -->`]);
231
+ io.writeFile(join(sd, `staging_pr_number.${runKey(io.env)}`), `${pr}\n`);
232
+
233
+ // 0A: bundle PR set, then the open review-finding gate
234
+ const common = ["--repo", c.ghRepo, "--pr", String(pr)];
235
+ const branches = ["--default", c.defaultBranch, "--staging", c.stagingBranch];
236
+ const prsFile = join(sd, `${pr}_bundle-prs.txt`);
237
+ const b = await bash(io, "review-staging-pre.sh", ["bundle", ...common, ...branches, "--sd", sd], env);
238
+ if (b.stdout.trim()) io.err(b.stdout.trim());
239
+ if (b.code !== 0) throw new Stop(1, [b.stderr.trim(), `REVIEW_RESULT: status: BLOCKED, blocker: bundle PR set failed (exit ${b.code})`].filter(Boolean));
240
+ let bundlePrs = "";
241
+ try { bundlePrs = io.readFile(prsFile); } catch { /* unreadable: fail closed below */ }
242
+ // An empty bundle would let the gate pass vacuously (forge#4470): nothing gated is never a pass.
243
+ if (!bundlePrs.split("\n").some((l) => /^\d+$/.test(l.trim()))) throw new Stop(1, [`REVIEW_RESULT: status: BLOCKED, blocker: bundle PR set is empty or unreadable (${prsFile}); nothing gated (fail closed)`]);
244
+ const g = await bash(io, "review-staging-pre.sh", ["gate", ...common, "--prs-file", prsFile], env);
245
+ if (g.code !== 0) {
246
+ const out = g.stdout.trim();
247
+ throw new Stop(1, [out, g.stderr.trim(), /RESULT: BLOCK DEPLOY/.test(out) ? "" : "RESULT: BLOCK DEPLOY"].filter(Boolean));
248
+ }
249
+ const gate = /human override/.test(g.stdout) ? "PASS (override)" : "PASS";
250
+
251
+ // 0B.1: scope
252
+ const s = await bash(io, "review-staging-pre.sh", ["scope", ...common, ...branches, "--prs-file", prsFile, "--sd", sd], env);
253
+ if (s.stdout.trim()) io.err(s.stdout.trim());
254
+ let scope;
255
+ try { scope = JSON.parse(io.readFile(join(sd, `${pr}_scope.json`))); } catch { scope = null; }
256
+ if (s.code !== 0 || !scope) scope = { scope_mode: "full" }; // a scope failure widens review, never narrows it
257
+
258
+ // 0C: prepare (also runs review-roster.sh)
259
+ const ctxFile = join(sd, `review-ctx-${pr}.json`);
260
+ const p = await bash(io, "review-prepare.sh", ["--pr", String(pr), "--repo", c.ghRepo, "--no-delta", "--build-gate", "advisory", "--out", ctxFile,
261
+ ...(a.thorough ? ["--thorough", "true"] : []), ...(a.delta ? ["--delta-from", a.delta] : [])], env);
262
+ let ctx = null;
263
+ try { ctx = JSON.parse(io.readFile(ctxFile)); } catch { /* reported below */ }
264
+ if (p.code !== 0 || !ctx) {
265
+ const blockers = (ctx?.blockers ?? []).map((x) => `BLOCKER: ${x}`);
266
+ throw new Stop(1, [...blockers, `REVIEW_RESULT: status: BLOCKED, blocker: review-prepare failed (exit ${p.code}); nothing reviewed (fail closed)`]);
267
+ }
268
+
269
+ // Delta (#4360): only when prepare actually narrowed to it (an empty file delta widened to FULL)
270
+ const delta = ctx.delta?.mode === "DELTA" && SHA_RE.test(ctx.delta.from ?? "") ? { from: ctx.delta.from, files: ctx.delta.files ?? [] } : null;
271
+
272
+ // Roster + briefs
273
+ const agents = ctx.roster?.agents?.length ? ctx.roster.agents : ["security"];
274
+ const launchedFile = join(sd, `${pr}_launched_review_agents`);
275
+ io.writeFile(launchedFile, "");
276
+ const briefDir = join(sd, `review-staging-${pr}`);
277
+ io.mkdir(briefDir);
278
+ const briefs = [];
279
+ for (const domain of agents) {
280
+ const tpl = loadTemplate(io, ctx.templates, domain);
281
+ if (!tpl) throw new Stop(1, [`REVIEW_RESULT: status: BLOCKED, blocker: persona template for ${domain} not found (source ${ctx.templates?.source ?? "none"}); never review inline`]);
282
+ const slice = ctx.slices?.[domain];
283
+ let diff = "";
284
+ try { diff = slice?.diff_file ? io.readFile(slice.diff_file) : ""; } catch { diff = ""; }
285
+ const values = {
286
+ PR_NUMBER: pr, REVIEW_SHA: head, REVIEW_SHA_SHORT: sha7, AGENT_DOMAIN: domain, GH_REPO: c.ghRepo,
287
+ TITLE: ctx.pr?.title ?? "", FILE_LIST: (slice?.paths ?? ctx.full_files ?? []).join(" "), SERVICES: (ctx.classification?.services ?? []).join(" "),
288
+ PROJECT_NAME: ctx.config?.project_name ?? "", PROJECT_CONTEXT: ctx.config?.project_context ?? "",
289
+ DOMAIN_CONTEXT: ctx.config?.domains?.[CONFIG_DOMAIN[domain] ?? domain] ?? "", CHURN_CONTEXT: ctx.churn_context ?? "",
290
+ INDEX_SLICE: INDEX_KEY[domain] ? ctx.index_slices?.[INDEX_KEY[domain]] ?? "" : "", DOMAIN_DIFF_SLICE: diff,
291
+ };
292
+ const extra = [
293
+ `## Staging review (staging->main PR #${pr}, head ${head})`,
294
+ `Your marker domain is \`${domain}\`: start your comment with \`<!-- FORGE:REVIEW-AGENT:${domain} -->\`, then the line \`Reviewed-SHA: ${head}\`, and end it with the structured findings block.`,
295
+ scopeSection(scope, delta, head),
296
+ ...(ctx.broken_assumptions?.length ? ["## Pre-found integration issues to verify", ...ctx.broken_assumptions.map((x) => `- ${x}`)] : []),
297
+ ...(priorOpen.length ? ["## Prior open findings (carried forward: do not re-file)", ...priorOpen.map((x) => `- ${x}`)] : []),
298
+ ...(priorResolved.length ? ["## Prior resolved findings (excluded from the verdict)", ...priorResolved.map((x) => `- ${x}`)] : []),
299
+ READ_ONLY_GIT_RULE,
300
+ ].join("\n\n");
301
+ const path = join(briefDir, `brief-${domain}.md`);
302
+ io.writeFile(path, `${renderBrief(tpl, values)}\n\n---\n\n${extra}\n`);
303
+ briefs.push({ domain, path });
304
+ }
305
+
306
+ let bundle = [];
307
+ try { bundle = io.readFile(prsFile).split(/\s+/).filter((n) => /^\d+$/.test(n)); } catch { /* reported empty */ }
308
+ const m = { pr, headSha: head, gate, bundle, scopeMode: scope.scope_mode, tier: ctx.roster?.tier ?? "full", reason: ctx.roster?.reason ?? "", contextFile: ctxFile, launchedFile, briefs, reentry: priorRoutes > 0, delta };
309
+ io.out(a.json ? JSON.stringify(m) : buildManifest(m));
310
+ return 0;
311
+ }
312
+
313
+ const BLOCK_DEPLOY = "RESULT: BLOCK DEPLOY";
314
+ const VERDICT_RE = /^(BLOCK|PASS|SKIP)$/;
315
+
316
+ /** `KEY=value` lines (review-finalize.sh output) -> object. */
317
+ const parseKv = (text) => Object.fromEntries(String(text ?? "").split("\n").map((l) => /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(l.trim())).filter(Boolean).map((m) => [m[1], m[2]]));
318
+ const readJson = (io, p) => { try { return JSON.parse(io.readFile(p)); } catch { return null; } };
319
+ const readText = (io, p) => { try { return io.readFile(p); } catch { return null; } };
320
+ const relay = (io, r) => { for (const t of [r.stdout, r.stderr]) if (t?.trim()) io.err(t.trim()); };
321
+ const upper = (x) => String(x ?? "").trim().toUpperCase();
322
+
323
+ /** Shared post-dispatch context: config, scratchpad, PR (argument, then the run-keyed pointer, then the open PR) and head. */
324
+ async function postContext(a, io) {
325
+ const c = readConfig(io);
326
+ const sd = io.env.FORGE_SCRATCHPAD || join(io.cwd, ".forge-scratch");
327
+ io.mkdir(sd);
328
+ const env = { ...io.env, FORGE_SCRATCHPAD: sd, FORGEDOCK_HOME: io.root };
329
+ let pr = a.pr;
330
+ if (pr == null) {
331
+ const n = (readText(io, join(sd, `staging_pr_number.${runKey(io.env)}`)) ?? "").trim();
332
+ pr = /^\d+$/.test(n) ? Number(n) : await openStagingPr(io, c);
333
+ }
334
+ return { c, sd, env, pr, head: await headSha(io, c, pr) };
335
+ }
336
+
337
+ /** Persisted Phase 6.5 result, validated on reload: anything missing or unknown is an invalid (BLOCK) verdict. */
338
+ function testGateResult(io, sd, pr) {
339
+ const v = (readText(io, join(sd, `${pr}_test_gate_verdict`)) ?? "").split("\n")[0].trim();
340
+ // eslint-disable-next-line no-control-regex
341
+ const reason = (readText(io, join(sd, `${pr}_test_gate_reason`)) ?? "").split("\n")[0].replace(/[\x00-\x1f]/g, "").slice(0, 400);
342
+ return VERDICT_RE.test(v) ? { verdict: v, reason, invalid: false } : { verdict: "BLOCK", reason: "persisted test-gate verdict missing/invalid", invalid: true };
343
+ }
344
+
345
+ /** --finalize: delivery, test gate, extract, classify (dry run + real), file-prepare; prints the items to file. */
346
+ async function finalize(a, io) {
347
+ const { c, sd, env, pr, head } = await postContext(a, io);
348
+ const common = ["--repo", c.ghRepo, "--pr", String(pr), "--sd", sd, "--head", head];
349
+
350
+ // Incomplete-panel guard + 6.75 delivery
351
+ const d = await bash(io, "review-staging-post.sh", ["delivery", ...common], env);
352
+ relay(io, d);
353
+ if (d.code !== 0) throw new Stop(1, [`REVIEW_RESULT: status: BLOCKED, blocker: reviewer delivery incomplete (exit ${d.code}); re-dispatch each missing reviewer once with the note "structured findings block required", else the panel is degraded`, BLOCK_DEPLOY]);
354
+
355
+ // 6.5 verdict handling (override, posture, test-gate markers)
356
+ const prsFile = join(sd, `${pr}_bundle-prs.txt`);
357
+ const t = await bash(io, "review-staging-post.sh", ["test-gate", ...common, "--prs-file", prsFile, "--output", a.testGateOutput, "--default", c.defaultBranch,
358
+ "--config", c.configPath], env); // posture and override phrase are read from the config by the script (yq), as the spec did
359
+ relay(io, t);
360
+ if (t.code !== 0) throw new Stop(1, [t.code === 1 ? "" : `REVIEW_RESULT: status: BLOCKED, blocker: the test gate could not run (exit ${t.code})`, BLOCK_DEPLOY].filter(Boolean));
361
+ const tg = testGateResult(io, sd, pr);
362
+
363
+ // 7A-7C through the shared review-finalize.sh (exit 3 = REVIEW_RESULT: status: BLOCKED was printed)
364
+ const ctxFile = join(sd, `review-ctx-${pr}.json`);
365
+ if (!readText(io, ctxFile)) throw new Stop(1, [`REVIEW_RESULT: status: BLOCKED, blocker: review context ${ctxFile} missing (the pre-dispatch did not run); nothing triaged (fail closed)`, BLOCK_DEPLOY]);
366
+ // A DELTA context (--delta, #4360) makes extract read the earlier pass's findings too: that pass is credited, not redone.
367
+ const cd = readJson(io, ctxFile)?.delta;
368
+ const delta = cd?.mode === "DELTA" && SHA_RE.test(cd.from ?? "") ? { from: cd.from, files: (cd.files ?? []).filter((f) => typeof f === "string" && f) } : null;
369
+ const outDir = join(sd, `review-finalize-${pr}`);
370
+ const rf = async (sub, extra) => {
371
+ const r = await io.run("bash", [join(io.root, "scripts", "review-finalize.sh"), sub, "--pr", String(pr), "--repo", c.ghRepo, "--context", ctxFile, "--out-dir", outDir, ...extra], { env, cwd: io.cwd });
372
+ if (r.code !== 0) throw new Stop(1, [r.stdout.trim(), r.stderr.trim(), r.code === 3 ? "" : `REVIEW_RESULT: status: BLOCKED, blocker: review-finalize ${sub} failed (exit ${r.code})`, BLOCK_DEPLOY].filter(Boolean));
373
+ return r;
374
+ };
375
+ const x = parseKv((await rf("extract", [])).stdout);
376
+ if (x.AGENTS_WITHOUT_BLOCK?.trim()) throw new Stop(1, [`REVIEW DELIVERY FAILURE: no structured findings block from: ${x.AGENTS_WITHOUT_BLOCK.trim()}. Re-dispatch them once with the note "structured findings block required", else the panel is degraded.`, BLOCK_DEPLOY]);
377
+
378
+ // 0B.1 scope set for 7B (a missing scope file means SCOPE_MODE=full: no out-of-scope demotion, announced)
379
+ const scope = readJson(io, join(sd, `${pr}_scope.json`));
380
+ if (!scope) io.err(`WARNING: ${join(sd, `${pr}_scope.json`)} unreadable: SCOPE_MODE=full (no out-of-scope demotion)`);
381
+ const scopeMode = scope?.scope_mode === "scoped" ? "scoped" : "full";
382
+ const scopeFile = join(sd, `${pr}_scope-files.txt`);
383
+ io.rm(scopeFile);
384
+ if (scopeMode === "scoped") {
385
+ // the delta's own files are always in scope: a finding on the fix is never demoted to NOTE out_of_scope
386
+ const files = [...new Set([...(scope.cross_pr_files ?? []), ...(scope.unreviewed_files ?? []), ...(scope.deploy_wiring_files ?? []), ...(delta?.files ?? [])].filter(Boolean))].sort();
387
+ io.writeFile(scopeFile, files.length ? `${files.join("\n")}\n` : "");
388
+ }
389
+ const scopeArgs = scopeMode === "scoped" ? ["--scope-files", scopeFile] : [];
390
+ const bundle = (readText(io, prsFile) ?? "").split(/\s+/).filter((n) => /^\d+$/.test(n));
391
+ const priorPrs = bundle.join(" ");
392
+
393
+ await rf("classify", ["--auto-merge", "false", ...scopeArgs, "--prior-prs", priorPrs, "--dry-run"]);
394
+ const notes = (readText(io, join(outDir, "note-list.txt")) ?? "").split("\n").filter((l) => l.trim());
395
+ const k = parseKv((await rf("classify", ["--auto-merge", "false", ...scopeArgs, "--prior-prs", priorPrs, "--notes-fixed", "0", "--notes-dropped", String(a.notesDropped)])).stdout);
396
+ const patterns = join(outDir, "patterns.json");
397
+ await rf("file-prepare", readText(io, patterns)?.trim() ? ["--patterns", patterns] : []);
398
+ const worklist = readJson(io, join(outDir, "worklist.json"));
399
+ if (!Array.isArray(worklist?.items)) throw new Stop(1, [`REVIEW_RESULT: status: BLOCKED, blocker: ${join(outDir, "worklist.json")} unreadable after file-prepare (fail closed)`, BLOCK_DEPLOY]);
400
+ try {
401
+ await io.gh(["label", "create", "staging-review", "--color", "1D76DB", "--description", "Finding from a staging branch review before deploy to main. Managed by ForgeDock.", "--force", "-R", c.ghRepo]);
402
+ } catch { /* an existing label or a transient failure never blocks filing */ }
403
+ let codeBranch = "";
404
+ try { codeBranch = (await io.gh(["pr", "view", String(pr), "-R", c.ghRepo, "--json", "headRefName", "--jq", ".headRefName"])).trim(); } catch { /* reported empty */ }
405
+ if (!/^[A-Za-z0-9._/-]+$/.test(codeBranch)) codeBranch = c.stagingBranch;
406
+
407
+ const findings = readJson(io, join(outDir, "findings.json"))?.findings ?? [];
408
+ const confirmed = (sev) => findings.filter((f) => upper(f.confidence) === "CONFIRMED" && upper(f.severity) === sev).length;
409
+ const outOfScope = Number(/out_of_scope=(\d+)/.exec(readText(io, join(outDir, "note-disposition.md")) ?? "")?.[1] ?? 0);
410
+ const counts = {
411
+ findings_kept: Number(x.FINDINGS_KEPT ?? 0), findings_dropped_stale: Number(x.FINDINGS_DROPPED_STALE ?? 0), findings_preexisting: Number(x.FINDINGS_PREEXISTING ?? 0),
412
+ notes_demoted_no_scenario: Number(x.NOTES_DEMOTED_NO_SCENARIO ?? 0), findings_filed: Number(k.FINDINGS_FILED ?? 0), notes_listed: Number(k.NOTES_LISTED ?? 0),
413
+ notes_capped: Number(k.NOTES_CAPPED ?? 0), notes_reflag: Number(k.NOTES_REFLAG ?? 0), notes_dropped: a.notesDropped, out_of_scope: outOfScope,
414
+ };
415
+ const items = worklist.items;
416
+ const state = {
417
+ pr, head, bundle, codeBranch, scopeMode, delta, testGate: { verdict: tg.verdict, reason: tg.reason }, counts, notes,
418
+ confirmedCritical: confirmed("CRITICAL"), confirmedHigh: confirmed("HIGH"),
419
+ toFile: items.filter((i) => i.action === "file"), skipped: items.filter((i) => String(i.action).startsWith("skip-")),
420
+ };
421
+ io.writeFile(join(sd, `${pr}_finalize.json`), JSON.stringify(state));
422
+ if (a.json) { io.out(JSON.stringify(state)); return 0; }
423
+ io.out([
424
+ `REVIEW_STAGING_FINALIZE: PR #${pr} head ${head}`,
425
+ `TEST_GATE: ${tg.verdict} (${tg.reason})`,
426
+ `SCOPE_MODE=${scopeMode}`,
427
+ ...(delta ? [`DELTA_FROM=${delta.from}`] : []),
428
+ Object.entries(counts).map(([kk, v]) => `${kk.toUpperCase()}=${v}`).join(" "),
429
+ `CODE_BRANCH=${codeBranch}`,
430
+ ...notes.map((n) => `NOTE ${n}`),
431
+ ...state.skipped.map((i) => `SKIP ${i.action}${i.dedup_of ? ` #${i.dedup_of}` : ""} ${i.title ?? ""}`.trimEnd()),
432
+ ...state.toFile.map((i) => `FILE ${JSON.stringify(i)}`),
433
+ `NEXT: list or drop each NOTE in the PR body under "## Non-blocking notes" (edit, never replace); a dropped NOTE count goes to --notes-dropped on a re-run.`,
434
+ `NEXT: for each FILE line write the finding body and file it through /issue (commands/review-pr-staging.md, filing step), then pipe its ISSUE_CREATE_RESULT line to review-finalize.sh file-verify.`,
435
+ `NEXT: then run: forgedock review-staging ${pr} --summary${state.toFile.length ? " --filed <N>..." : ""}`,
436
+ ].join("\n"));
437
+ return 0;
438
+ }
439
+
440
+ const TEST_GATE_NOTE = {
441
+ SKIP: (r) => `> **Test Gate: SKIP**: No runtime tests ran for this bundle. This means acceptance criteria were NOT verified against running code before deploy. Cause: ${r}. To enable runtime testing, configure \`verification.integration_tests\` in \`forge.yaml\`.`,
442
+ PASS: () => "> **Test Gate: PASS**: Acceptance criteria verified against running code. No batch-introduced runtime failures detected.",
443
+ BLOCK: (r) => `> **Test Gate: BLOCK (override/advisory)**: Runtime failures were detected but deploy is proceeding. Reason: ${r}. Filed \`test-failure\` issues track the failures.`,
444
+ };
445
+
446
+ /** Phase 8 verdict from the deterministic inputs (CI, test gate, CONFIRMED CRITICAL/HIGH). */
447
+ export function stagingVerdict({ ciFailing, testGateInvalid, confirmedCritical, confirmedHigh }) {
448
+ if (ciFailing > 0) return { verdict: "BLOCK DEPLOY", why: `CI failing (${ciFailing} required check(s) red; forgedock/verify excluded)` };
449
+ if (testGateInvalid) return { verdict: "BLOCK DEPLOY", why: "test-gate result missing/invalid (fail closed)" };
450
+ if (confirmedCritical > 0) return { verdict: "BLOCK DEPLOY", why: `${confirmedCritical} CONFIRMED CRITICAL finding(s)` };
451
+ if (confirmedHigh > 0) return { verdict: "NEEDS FIXES FIRST", why: `${confirmedHigh} CONFIRMED HIGH finding(s)` };
452
+ return { verdict: "APPROVE FOR DEPLOY", why: "no CI failure, no blocking test gate, no CONFIRMED CRITICAL/HIGH finding" };
453
+ }
454
+
455
+ /** --summary: Phase 8 verdict + summary comment ending in exactly one gate marker kind. Never merges. */
456
+ async function summary(a, io) {
457
+ const { c, sd, env, pr, head } = await postContext(a, io);
458
+ const st = readJson(io, join(sd, `${pr}_finalize.json`));
459
+ if (!st || st.pr !== pr) throw new Stop(1, [`REVIEW_RESULT: status: BLOCKED, blocker: no --finalize state for PR #${pr}; run forgedock review-staging ${pr} --finalize first`, BLOCK_DEPLOY]);
460
+ if (st.head !== head) throw new Stop(1, [`REVIEW_RESULT: status: BLOCKED, blocker: head moved since --finalize (${String(st.head).slice(0, 7)} -> ${head.slice(0, 7)}); re-run the staging review`, BLOCK_DEPLOY]);
461
+ const tg = testGateResult(io, sd, pr);
462
+
463
+ // Phase 1E read: forgedock/verify is excluded; an unreadable result counts as failing (fail closed)
464
+ let ciFailing = 1;
465
+ const ci = await io.run("gh", ["pr", "checks", String(pr), "-R", c.ghRepo, "--json", "name,bucket", "--jq", '[.[] | select(.name != "forgedock/verify") | select(.bucket == "fail" or .bucket == "cancel")] | length'], { env, cwd: io.cwd });
466
+ if (/^\d+$/.test(ci.stdout.trim())) ciFailing = Number(ci.stdout.trim());
467
+
468
+ const v = stagingVerdict({ ciFailing, testGateInvalid: tg.invalid, confirmedCritical: st.confirmedCritical, confirmedHigh: st.confirmedHigh });
469
+ const pass = v.verdict === "APPROVE FOR DEPLOY";
470
+ const marker = pass ? "FORGE:GATE_PASS" : "FORGE:GATE_FAILURE";
471
+ const bundle = (st.bundle ?? []).join(" ");
472
+ const filed = a.filed.map((n) => `#${n}`);
473
+ if (a.filed.length < (st.toFile?.length ?? 0)) io.err(`WARNING: ${st.toFile.length} worklist item(s) to file, ${a.filed.length} reported filed (pattern consolidation may merge items; anything else is a dropped finding)`);
474
+ const sc = readJson(io, join(sd, `${pr}_scope.json`)) ?? {};
475
+ const n = st.counts ?? {};
476
+ const deduped = (st.skipped ?? []).map((i) => (i.action === "skip-dedup" && i.dedup_of ? `deduped: #${i.dedup_of}` : `${i.action}: ${i.title ?? ""}`));
477
+ const optional = async (title, cmd, args, timeout) => {
478
+ const r = await io.run(cmd, args, { env, cwd: io.cwd, timeout });
479
+ return r.code === 0 && r.stdout.trim() ? [`### ${title}`, r.stdout.trim()] : [`${title}: unavailable`];
480
+ };
481
+ const body = [
482
+ `<!-- ${marker} -->`,
483
+ `## Staging Review: ${v.verdict}`,
484
+ "",
485
+ `**Verdict**: ${v.verdict} (${v.why})`,
486
+ `**Head**: \`${head}\``,
487
+ `**Bundle PRs**: ${bundle || "(none)"}`,
488
+ "",
489
+ "### Risk Matrix",
490
+ "| Domain | Result | Notes |",
491
+ "|--------|--------|-------|",
492
+ `| CI | ${ciFailing > 0 ? "FAIL" : "PASS"} | ${ciFailing} failing required check(s), forgedock/verify excluded |`,
493
+ `| Findings | ${st.confirmedCritical} CRITICAL / ${st.confirmedHigh} HIGH (CONFIRMED) | per-domain verdicts are in the FORGE:REVIEW-AGENT comments |`,
494
+ `| **Test Gate** | \`${tg.verdict}\` | \`${tg.reason || "Phase 6.5 not run"}\` |`,
495
+ "",
496
+ (TEST_GATE_NOTE[tg.verdict] ?? TEST_GATE_NOTE.BLOCK)(tg.reason),
497
+ "",
498
+ "### Finding Triage Results",
499
+ `- SCOPE_MODE: ${st.scopeMode}; reviewed_prs: ${sc.reviewed_pr_count ?? 0}; unreviewed_commits: ${(sc.unreviewed_commits ?? []).length}; cross_pr_files: ${(sc.cross_pr_files ?? []).length}`,
500
+ `- findings_kept: ${n.findings_kept ?? 0}; dropped_stale: ${n.findings_dropped_stale ?? 0}; pre-existing: ${n.findings_preexisting ?? 0}; notes_demoted_no_scenario: ${n.notes_demoted_no_scenario ?? 0}`,
501
+ `- findings_filed: ${n.findings_filed ?? 0}${filed.length ? ` (${filed.join(", ")})` : ""}; out_of_scope: ${n.out_of_scope ?? 0}`,
502
+ `- notes_fixed: 0; notes_listed: ${n.notes_listed ?? 0}; notes_dropped: ${n.notes_dropped ?? 0}; notes_capped: ${n.notes_capped ?? 0}; notes_reflag: ${n.notes_reflag ?? 0}`,
503
+ `- deduped: ${deduped.length ? deduped.join("; ") : "(none)"}`,
504
+ ...(st.delta?.from ? [`- delta pass: ${String(st.delta.from).slice(0, 7)}..${head.slice(0, 7)} (${(st.delta.files ?? []).length} file(s)); earlier pass at ${st.delta.from} credited (its findings re-read by extract)`] : []),
505
+ "",
506
+ ...(await optional("Pipeline cost", "bash", [join(io.root, "scripts", "cost-report.sh"), "--last", "3"], 60_000)),
507
+ "",
508
+ ...(await optional("Pipeline stats", process.execPath, [join(io.root, "bin", "forgedock.mjs"), "stats", "--compact", "--repo", c.ghRepo], 120_000)),
509
+ "",
510
+ "This review never merges staging -> main: the deploy decision is made on GitHub.",
511
+ "",
512
+ `<!-- ${marker}:TYPE=staging-review|BUNDLE=${bundle} -->`,
513
+ ].join("\n");
514
+ try {
515
+ await io.gh(["pr", "comment", String(pr), "-R", c.ghRepo, "--body", body]);
516
+ } catch (e) {
517
+ throw new Stop(1, [`REVIEW_RESULT: status: BLOCKED, blocker: could not post the Phase 8 summary and gate marker (${e.message})`, BLOCK_DEPLOY]);
518
+ }
519
+ if (a.json) io.out(JSON.stringify({ pr, headSha: head, verdict: v.verdict, why: v.why, marker, ciFailing, testGate: tg.verdict, filed: a.filed }));
520
+ else io.out([`VERDICT: ${v.verdict} (${v.why})`, `GATE: ${marker} posted on PR #${pr}`, ...(pass ? [] : [BLOCK_DEPLOY])].join("\n"));
521
+ return pass ? 0 : 1;
522
+ }
523
+
524
+ /** CLI entry: returns the exit code (0 ready to dispatch / continue / approved, 1 blocked/failed/not approved, 2 usage). */
525
+ export async function reviewStagingFromCli(argv, io = defaultIo()) {
526
+ try {
527
+ return await main(argv, io);
528
+ } catch (e) {
529
+ if (e instanceof Stop) { io.err(e.lines.join("\n")); return e.code; }
530
+ io.err(`review-staging: ${e.message}`);
531
+ return 1;
532
+ }
533
+ }