forgedock 1.19.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 (44) hide show
  1. package/README.md +2 -0
  2. package/bin/engine.mjs +48 -3
  3. package/bin/runner.mjs +20 -4
  4. package/commands/orchestrate/phase-4-execution.md +1 -1
  5. package/commands/review-pr-agents/protocols.md +19 -0
  6. package/commands/review-pr.md +7 -6
  7. package/commands/work-on/build/architect.md +1 -1
  8. package/commands/work-on/build/implement.md +9 -1
  9. package/commands/work-on/build/validate.md +1 -1
  10. package/commands/work-on/build.md +118 -8
  11. package/commands/work-on/close.md +9 -4
  12. package/commands/work-on/remediate.md +11 -4
  13. package/commands/work-on/review.md +11 -3
  14. package/commands/work-on.md +4 -4
  15. package/docs/CONFIG.md +5 -1
  16. package/docs/RELEASE-NOTES.md +15 -0
  17. package/docs/TELEMETRY.md +2 -2
  18. package/docs/WORK-ON-RUNTIME.md +11 -3
  19. package/package.json +1 -1
  20. package/scripts/architect-resume-match.test.sh +12 -3
  21. package/scripts/build-knowledge-index.mjs +8 -5
  22. package/scripts/builder-resume-match.test.sh +12 -3
  23. package/scripts/calibration.mjs +5 -2
  24. package/scripts/check-spec-markers.sh +2 -0
  25. package/scripts/ci-local/lib.mjs +11 -3
  26. package/scripts/ci-local/run.mjs +155 -49
  27. package/scripts/ci-local/scoping.mjs +27 -4
  28. package/scripts/ci-local.sh +3 -2
  29. package/scripts/ci-local.test.sh +189 -0
  30. package/scripts/classify-finding.sh +1 -1
  31. package/scripts/file-deferred-followups.sh +91 -30
  32. package/scripts/file-deferred-followups.test.sh +101 -8
  33. package/scripts/lib/telemetry.sh +2 -0
  34. package/scripts/review-finalize.sh +101 -12
  35. package/scripts/review-finalize.test.sh +136 -5
  36. package/scripts/review-roster.sh +18 -5
  37. package/scripts/review-roster.test.sh +57 -1
  38. package/scripts/selfreview-plan.sh +86 -0
  39. package/scripts/selfreview-plan.test.sh +90 -0
  40. package/scripts/spawn-depth-check.sh +5 -4
  41. package/scripts/spawn-depth-check.test.sh +3 -0
  42. package/scripts/telemetry.test.sh +8 -0
  43. package/scripts/transition-label.sh +26 -0
  44. package/scripts/verify-phase-trail.test.sh +6 -0
package/README.md CHANGED
@@ -303,6 +303,8 @@ If you're carrying an older `--global` habit in scripts or CI, it's harmless to
303
303
 
304
304
  </details>
305
305
 
306
+ **After upgrading**: run `npx forgedock labels setup` once per repo. Repos created before the `workflow:remediating` state (forge#3541) lack that label; the pipeline now creates it on demand (best-effort), but the setup command is the supported way to sync all labels.
307
+
306
308
  ---
307
309
 
308
310
  ## For companies
package/bin/engine.mjs CHANGED
@@ -4,7 +4,8 @@
4
4
  * GitHub state (phase.detectOutcome). All effects are injected → fully testable.
5
5
  */
6
6
  import { fileURLToPath } from "node:url";
7
- import { readFileSync, rmSync } from "node:fs";
7
+ import { readFileSync, rmSync, readdirSync } from "node:fs";
8
+ import { execFileSync } from "node:child_process";
8
9
  import { createHash, scryptSync } from "node:crypto";
9
10
  import { hostname as osHostname, homedir } from "node:os";
10
11
  import { appendEvent, readLog, deriveState, rewriteLog } from "./engine/runlog.mjs";
@@ -268,6 +269,46 @@ export function isLeaseHolderDead({ by, host = osHostname(), killFn = process.ki
268
269
  }
269
270
  }
270
271
 
272
+ /**
273
+ * forge#3724: a dead lease-holder pid proves the engine is dead, not that its phase child is. The runner
274
+ * stamps every phase child with `FORGE_LEASE_HOLDER=<by>` (inherited by grandchildren), so a surviving
275
+ * orphan is found by scanning process environments. Returns FALSE only when the probe affirmatively finds
276
+ * no live process carrying the marker; unsupported platform, unreadable /proc, a failed `ps`, or any thrown
277
+ * error returns TRUE (fail-safe: the caller keeps waiting for `lease.until`). Per-process EACCES/ENOENT is
278
+ * skipped (exited, or another uid). Residual: a child that scrubs its env (`env -i`) is not detected.
279
+ */
280
+ export function leaseChildAlive({ by, platform = process.platform, procRoot = "/proc", ownPid = process.pid,
281
+ readdirFn = readdirSync, readFileFn = readFileSync, psFn } = {}) {
282
+ try {
283
+ const marker = `FORGE_LEASE_HOLDER=${by}`;
284
+ if (!parseLeaseHolder(by)) return true;
285
+ if (platform === "linux") {
286
+ for (const name of readdirFn(procRoot)) {
287
+ if (!/^[0-9]+$/.test(name) || Number(name) === ownPid) continue;
288
+ let env;
289
+ try { env = readFileFn(`${procRoot}/${name}/environ`, "latin1"); }
290
+ catch (e) { if (e?.code === "EACCES" || e?.code === "ENOENT" || e?.code === "ESRCH" || e?.code === "EPERM") continue; return true; }
291
+ if (String(env).split("\0").includes(marker)) return true;
292
+ }
293
+ return false;
294
+ }
295
+ if (platform === "darwin") {
296
+ const run = psFn || (() => execFileSync("ps", ["eww", "-Ao", "pid,command"], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, timeout: 10000 }));
297
+ const out = run();
298
+ if (typeof out !== "string" || !out.trim()) return true;
299
+ for (const line of out.split("\n")) {
300
+ const m = /^\s*(\d+)\s/.exec(line);
301
+ if (!m || Number(m[1]) === ownPid) continue;
302
+ if (line.split(/\s+/).includes(marker)) return true;
303
+ }
304
+ return false;
305
+ }
306
+ return true;
307
+ } catch {
308
+ return true;
309
+ }
310
+ }
311
+
271
312
  /** Max startup wait for a persisted rate-limit reset (guards a corrupt far-future resetAt). */
272
313
  export const MAX_RESUME_RATE_LIMIT_WAIT_MS = 6 * 60 * 60 * 1000;
273
314
 
@@ -330,7 +371,8 @@ export const MAX_RESUME_RATE_LIMIT_WAIT_MS = 6 * 60 * 60 * 1000;
330
371
  * the lease, poll (via `sleep`, bounded by `leaseTtlMs`) until it expires or
331
372
  * its holder is reclaimable, then continue; `deferred` on timeout.
332
373
  * @param {string} [opts.hostname] - injectable host name (tests).
333
- * @param {Function} [opts.killFn] - injectable `process.kill` (tests).
374
+ * @param {Function} [opts.killFn] - injectable `process.kill` (tests).
375
+ * @param {Function} [opts.childAliveFn] - forge#3724: injectable `leaseChildAlive` (orphaned phase-child probe; tests).
334
376
  */
335
377
  // forge#3824: stamped on RUN_START for per-release cost attribution; null when unreadable.
336
378
  export const ENGINE_VERSION = (() => {
@@ -368,6 +410,8 @@ export async function runIssue(opts) {
368
410
  waitLease = false,
369
411
  hostname = osHostname(),
370
412
  killFn = process.kill.bind(process),
413
+ // forge#3724: injectable orphaned-phase-child probe (tests).
414
+ childAliveFn = leaseChildAlive,
371
415
  onProgress = () => {},
372
416
  // forge#3696: per-attempt telemetry seam (FORGE:TELEMETRY emitter, #3834). No-op by
373
417
  // default; failures are swallowed so telemetry can never fail a phase.
@@ -400,7 +444,7 @@ export async function runIssue(opts) {
400
444
  let remote = await projector.readState(issue);
401
445
  let { state, action } = reconcileState(local, remote);
402
446
  const leaseHeld = (r) => !!(r?.lease && r.lease.until > now() && r.lease.by !== agentId &&
403
- !isLeaseHolderDead({ by: r.lease.by, host: hostname, killFn }));
447
+ !(isLeaseHolderDead({ by: r.lease.by, host: hostname, killFn }) && !childAliveFn({ by: r.lease.by })));
404
448
  const deferredResult = (r) => ({
405
449
  terminalReason: "deferred",
406
450
  code: OUTCOME_CODES.LEASE_HELD,
@@ -1233,6 +1277,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
1233
1277
  try {
1234
1278
  result = await runner({
1235
1279
  commandsDir, commandName: phase.command, args: phaseArgs,
1280
+ leaseHolder: agentId,
1236
1281
  // Only forwarded when explicitly provided — omitting them preserves
1237
1282
  // runner.mjs's existing default ("auto" backend / DEFAULT_MODEL).
1238
1283
  ...(backend ? { backend } : {}),
package/bin/runner.mjs CHANGED
@@ -154,6 +154,18 @@ export function withRunTmpdir(env, { issue, cwd = process.cwd() } = {}) {
154
154
  return env;
155
155
  }
156
156
 
157
+ const LEASE_HOLDER_ENV_RE = /^cli_[A-Za-z0-9._-]+_[0-9]+$/;
158
+
159
+ /**
160
+ * Stamp the phase child's env with `FORGE_LEASE_HOLDER=<engine lease id>` so the engine can tell, after a
161
+ * SIGKILL of the engine, whether an orphaned phase child is still running (env is inherited by grandchildren).
162
+ * An absent or malformed holder leaves the env untouched and never throws.
163
+ */
164
+ export function withLeaseHolder(env, holder) {
165
+ if (typeof holder === "string" && LEASE_HOLDER_ENV_RE.test(holder)) env.FORGE_LEASE_HOLDER = holder;
166
+ return env;
167
+ }
168
+
157
169
  /** First positional arg when it is an issue number (the phase arg convention), else null. */
158
170
  function issueFromArgs(args) {
159
171
  const first = Array.isArray(args) ? args[0] : String(args ?? "").trim().split(/\s+/)[0];
@@ -1430,6 +1442,7 @@ export function runCliBackend({
1430
1442
  spawnFn = spawnSync,
1431
1443
  model = null,
1432
1444
  maxTurns = null,
1445
+ leaseHolder = null,
1433
1446
  }) {
1434
1447
  // forge#3589: opt-in flags, validated again here as defence in depth. Callers
1435
1448
  // (runCommand) are responsible for the version gate. Unset -> argv unchanged.
@@ -1496,7 +1509,7 @@ export function runCliBackend({
1496
1509
  // Mirrors the existing scrub in the `run_bash` tool handler below.
1497
1510
  // GH_TOKEN/GITHUB_TOKEN and all other env vars are intentionally left
1498
1511
  // intact — only the Anthropic key is removed.
1499
- const childEnv = withRunTmpdir(withForgedockHome({ ...process.env }), { issue: issueFromArgs(args), cwd });
1512
+ const childEnv = withLeaseHolder(withRunTmpdir(withForgedockHome({ ...process.env }), { issue: issueFromArgs(args), cwd }), leaseHolder);
1500
1513
  delete childEnv.ANTHROPIC_API_KEY;
1501
1514
 
1502
1515
  // No `shell` option (defaults to false): argv is passed as discrete,
@@ -2305,9 +2318,10 @@ export function resolveBashShell() {
2305
2318
  *
2306
2319
  * @param {string} cwd
2307
2320
  * @param {string|null} [runIssueNumber] - issue number scoping the per-run TMPDIR (forge#3572)
2321
+ * @param {string|null} [leaseHolder] - engine lease id stamped as FORGE_LEASE_HOLDER in the child env
2308
2322
  * @returns {Record<string, (input: object) => string>}
2309
2323
  */
2310
- export function getToolHandlers(cwd, runIssueNumber = null) {
2324
+ export function getToolHandlers(cwd, runIssueNumber = null, leaseHolder = null) {
2311
2325
  return {
2312
2326
  read_file: ({ path }) => {
2313
2327
  if (!path) throw new Error("read_file requires a 'path'");
@@ -2327,7 +2341,7 @@ export function getToolHandlers(cwd, runIssueNumber = null) {
2327
2341
  // into the model loop, so a prompt-injection payload could otherwise issue
2328
2342
  // a run_bash command that exfiltrates the key. gh/git auth tokens
2329
2343
  // (GH_TOKEN/GITHUB_TOKEN) are intentionally left intact.
2330
- const childEnv = withRunTmpdir(withForgedockHome({ ...process.env }), { issue: runIssueNumber, cwd });
2344
+ const childEnv = withLeaseHolder(withRunTmpdir(withForgedockHome({ ...process.env }), { issue: runIssueNumber, cwd }), leaseHolder);
2331
2345
  delete childEnv.ANTHROPIC_API_KEY;
2332
2346
  // Run under bash when available so bash-style commands the model emits
2333
2347
  // (heredocs, scripts/*.sh, single-quote semantics) behave consistently
@@ -2585,6 +2599,7 @@ export async function runCommand(opts = {}) {
2585
2599
  backend = process.env.FORGEDOCK_BACKEND || "auto",
2586
2600
  runtimeEnv = process.env,
2587
2601
  logger = console,
2602
+ leaseHolder = null,
2588
2603
  } = opts;
2589
2604
 
2590
2605
  assertOpenCodeNativeRuntime({
@@ -2725,6 +2740,7 @@ export async function runCommand(opts = {}) {
2725
2740
  logger,
2726
2741
  model: cliModel,
2727
2742
  maxTurns: cliMaxTurns,
2743
+ leaseHolder,
2728
2744
  },
2729
2745
  });
2730
2746
  }
@@ -2767,7 +2783,7 @@ export async function runCommand(opts = {}) {
2767
2783
  // is a known Linux limitation at the OS level; closing it would require
2768
2784
  // a native module (e.g. prctl PR_SET_DUMPABLE) or process isolation.
2769
2785
  // See: issue #1370 for tracking this known limitation.
2770
- const handlers = getToolHandlers(cwd, issueFromArgs(args));
2786
+ const handlers = getToolHandlers(cwd, issueFromArgs(args), leaseHolder);
2771
2787
  const messages = [{ role: "user", content: userMessage }];
2772
2788
 
2773
2789
  // Accumulate token usage across all messages.create() calls in this run.
@@ -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:
@@ -353,7 +353,7 @@ Correct code that never runs because another layer blocks, shadows or reroutes i
353
353
 
354
354
  ### 3A-3B: Risk, scores and roster (computed by `review-prepare.sh`)
355
355
 
356
- **Raw line count never lowers review depth, and size never blocks a PR.** Size only ever ADDS depth, through the deterministic `review_weight` the build records in `FORGE:DIFF_SIZE` (forge#3862): a high `review_weight` or an out-of-plan scope flag sets `review_depth: EXTENDED` (read by `review-prepare.sh`, passed to `review-roster.sh --review-depth`; it only ever adds reviewers). The Security agent and the Phase 2 floor are unconditional at every depth and size. The roster is the pure function in `scripts/review-roster.sh`: tiers `full` (`--thorough` or milestone-to-staging), `fix-pr` (`MERGE_ISSUE` carries `review-finding`), `small` (60 lines or fewer, or one file of 150 or fewer: Security only), `medium` (61-150: plus triggers), `standard` (over 150 or unknown: top-2 domains by score plus triggers). Path and contract triggers (CONTRACT `HIGH_RISK`, cross-critical pairs such as auth + billing, churn or danger zone, `bin/engine*`, auth/session, CI/docker/infra, migration and SDK/OpenAPI files) escalate every tier but `full`; BILLING adds Concurrency; **General Security always runs**. <!-- Added: forge#3599 -->
356
+ **Raw line count never lowers review depth, and size never blocks a PR.** Size only ever ADDS depth, through the deterministic `review_weight` the build records in `FORGE:DIFF_SIZE` (forge#3862): a high `review_weight` or an out-of-plan scope flag sets `review_depth: EXTENDED` (read by `review-prepare.sh`, passed to `review-roster.sh --review-depth`; it only ever adds reviewers). The Security agent and the Phase 2 floor are unconditional at every depth and size. The roster is the pure function in `scripts/review-roster.sh`: tiers `full` (`--thorough` or milestone-to-staging), `fix-pr` (`MERGE_ISSUE` carries `review-finding` and the PR is 150 changed lines or fewer with a known size; a larger or unknown-size fix PR follows the size ladder), `small` (60 lines or fewer, or one file of 150 or fewer: Security only), `medium` (61-150: plus triggers), `standard` (over 150 or unknown: top-2 domains by score plus triggers). Path and contract triggers (CONTRACT `HIGH_RISK`, cross-critical pairs such as auth + billing, churn or danger zone, `bin/engine*`, auth/session, CI/docker/infra, migration and SDK/OpenAPI files) escalate every tier but `full`; BILLING adds Concurrency; **General Security always runs**. <!-- Added: forge#3599 -->
357
357
 
358
358
  **Roster label to marker domain (single source of truth)**: the marker domain in `<!-- FORGE:REVIEW-AGENT:{domain} -->` is always the canonical value, never the lowercased roster label, and Phase 4 accepts no alias: `Security`→`security`, `Auth`→`auth`, `Billing`→`billing`, `Concurrency`→`concurrency`, `Database`→`database`, `INFRA`/`Infrastructure`→`infra`, `SCRAPING`/`Scraping`/`Scraper`→`scraper`, `FRONTEND`/`Frontend`/`Web`→`frontend`, `API`→`api` (persona file `<domain>.md`; any other label falls back to lowercase). `SELECTED_AGENTS` from Phase 1 is already canonical. `agent_domain()` is defined inline in every block that needs it and in `scripts/review-roster.sh`; all copies must stay identical (`scripts/review-pr-domain-map.test.sh`). `spec-cli` is ForgeDock-internal: only for diffs touching `bin/**` or `commands/**` in a repo with `commands/review-pr-agents/spec-cli.md`. <!-- Added: forge#3485 -->
359
359
 
@@ -667,7 +667,7 @@ A standalone issue per LOW/POSSIBLE note makes the cascade amplify (each merged
667
667
  - **Review-finding lineage** (`MERGE_ISSUE` carries `review-finding`: this PR fixes a finding): only MEDIUM findings that are CONFIRMED, or LIKELY and safety-exempt, become issues; the rest are NOTEs. A MEDIUM tagged `**Fix efficacy**: fails|regresses` (the linked issue is not fixed) is never damped.
668
668
  - **Cross-head re-flag** (`NOTE reflag-*`): a finding an earlier head's trusted review or `FORGE:NOTE_DISPOSITION` already covered is not filed again; never for HIGH/CRITICAL or rows whose linked issue is closed.
669
669
  - **Per-review cap**: at most `review.max_filed_findings` ISSUEs (default 2; 1 under lineage; `0` valid), ranked by severity, confidence, safety domain; the overflow is `NOTE capped` (listed, never filed or dropped). HIGH/CRITICAL are never capped and still block via §7B.
670
- - **Contract scope**: a finding under a verified `deferred → #N` item of the trusted FORGE:CONTRACT (`scripts/check-contract-scope.sh list`) is `NOTE contract-deferred #N` (commented there, not filed), under `accepted-risk` it is `NOTE contract-accepted-risk`; never for HIGH/CRITICAL, `not-affected`, safety-exempt or this PR's own files, nor on missing, untrusted or closed contract input.
670
+ - **Contract scope**: a finding under a verified `deferred → #N` item (verified = open non-PR issue by a trusted author with the `FORGE:DEFERRED_FROM` marker and an exact checklist line `- [ ] \`path\`` for that path, never a body substring) of the trusted FORGE:CONTRACT (`scripts/check-contract-scope.sh list`) is `NOTE contract-deferred #N` (commented there, not filed; counted only after #N is still OPEN and the comment succeeded, otherwise the finding takes the disposition it would have had absent the contract, normally filed, a LOW/POSSIBLE one stays a NOTE), under `accepted-risk` it is `NOTE contract-accepted-risk`; never for HIGH/CRITICAL, `not-affected`, safety-exempt or this PR's own files, nor on missing, untrusted or closed contract input.
671
671
 
672
672
  Each NOTE gets one disposition: (1) **fix in this PR**, only comment, documentation or test-only changes as a follow-up commit (any executable change is filed or re-reviewed in full; never push unreviewed code under `--auto-merge`); (2) **list in the PR body** under `## Non-blocking notes` (edit, never replace; `mktemp` file named for the PR number, read back to confirm); (3) **drop** duplicates, stale-comment nits and speculation. NOTES never reach the filing call. The 6A dry run printed the NOTE list: decide the fixed and dropped counts (`NOTES_FIXED`, `NOTES_DROPPED`, default 0; other plain NOTEs count as listed), then the 6C fence runs `classify` for real.
673
673
 
@@ -816,7 +816,8 @@ VERDICT_OUT=$(bash "$RF" verdict --pr "$PR_NUMBER" --repo "$REPO" --review-sha "
816
816
  --merge-health "$MERGE_HEALTH" --merge-health-state "$MERGE_HEALTH_STATE" --is-milestone "{IS_MILESTONE_TO_STAGING}" \
817
817
  --has-purpose-regression "{HAS_PURPOSE_REGRESSION}" --purpose-findings-file "$OUT_DIR/purpose-findings.txt" \
818
818
  --result "{RESULT}" --agents-file "$OUT_DIR/agents.txt" --findings-count "{M}" --blocking-count "{N_BLOCKING}" \
819
- --intensity-tier "$INTENSITY_TIER" --table-cell-key "$TABLE_CELL_KEY" --trust-shadow-mode "$TRUST_SHADOW_MODE"); VERDICT_RC=$?
819
+ --intensity-tier "$INTENSITY_TIER" --table-cell-key "$TABLE_CELL_KEY" --trust-shadow-mode "$TRUST_SHADOW_MODE" \
820
+ --other-blocking "{OTHER_BLOCKING}" --out-dir "$OUT_DIR"); VERDICT_RC=$?
820
821
  printf '%s\n' "$VERDICT_OUT"
821
822
  [ "$VERDICT_RC" -eq 0 ] || exit 1 # STOP: REVIEW_RESULT: status: BLOCKED was printed
822
823
  # 7B.5: reads task type and confidence from the linked issue's FORGE:INVESTIGATOR, consults origin/forge-knowledge, records FORGE:CALIBRATION_CHECK.
@@ -824,7 +825,7 @@ bash "$RF" calibrate --pr "$PR_NUMBER" --repo "$REPO" --issue "$MERGE_ISSUE" --o
824
825
  --intensity-tier "$INTENSITY_TIER" --table-cell-key "$TABLE_CELL_KEY" --trust-shadow-mode "$TRUST_SHADOW_MODE" --trust-needs-human "$TRUST_NEEDS_HUMAN"
825
826
  ```
826
827
 
827
- `verdict` computes `BASE_CONFLICT` (the single source of truth for the Phase 8 guards), posts the verdict with the trust annotation, and never posts a clean verdict over a base conflict or purpose regression (it downgrades to CHANGES REQUESTED, `VERDICT_COERCED=true`). Read `VERDICT=`, `BASE_CONFLICT=`, `HAS_PURPOSE_REGRESSION=` and, from `calibrate`, `CALIBRATION_NEEDS_HUMAN=` / `CALIBRATION_NOTE=`: they are the Phase 8 flags. The note itself is also written to `$OUT_DIR/calibration-note.txt`, which Phase 8 passes by path; free text is never placed in a shell word.
828
+ `verdict` computes `BASE_CONFLICT` (the single source of truth for the Phase 8 guards), posts the verdict with the trust annotation, and never posts a clean verdict over a base conflict or purpose regression (it downgrades to CHANGES REQUESTED, `VERDICT_COERCED=true`). `{OTHER_BLOCKING}` is `true` when criterion 1, 2, 3 or 5 fired, else `false`; `verdict` forces it `true` for a blocking result that the base conflict and purpose regression do not explain, persists the resolved value to `$OUT_DIR/other-blocking.txt` and prints `OTHER_BLOCKING=`, and `merge-gate` ORs the persisted value in, so the reviewer cannot lower it. Read `VERDICT=`, `BASE_CONFLICT=`, `HAS_PURPOSE_REGRESSION=`, `OTHER_BLOCKING=` and, from `calibrate`, `CALIBRATION_NEEDS_HUMAN=` / `CALIBRATION_NOTE=`: they are the Phase 8 flags. The note itself is also written to `$OUT_DIR/calibration-note.txt`, which Phase 8 passes by path; free text is never placed in a shell word.
828
829
 
829
830
  ---
830
831
 
@@ -846,7 +847,7 @@ RF=$(forge_resolve review-finalize.sh) # install roots only, never the PR chec
846
847
  OUT_DIR="$(dirname "$CTX")/review-finalize-${PR_NUMBER}"
847
848
  GATE_OUT=$(bash "$RF" merge-gate --pr "$PR_NUMBER" --repo "$REPO" --issue "$MERGE_ISSUE" --base "$MERGE_BASE" \
848
849
  --verdict "$VERDICT" --has-purpose-regression "$HAS_PURPOSE_REGRESSION" --calibration-needs-human "$CALIBRATION_NEEDS_HUMAN" --calibration-note-file "$OUT_DIR/calibration-note.txt" \
849
- --trust-needs-human "$TRUST_NEEDS_HUMAN" --base-conflict "$BASE_CONFLICT" --other-blocking "$OTHER_BLOCKING" --table-cell-key "$TABLE_CELL_KEY"); GATE_RC=$?
850
+ --trust-needs-human "$TRUST_NEEDS_HUMAN" --base-conflict "$BASE_CONFLICT" --other-blocking "$OTHER_BLOCKING" --table-cell-key "$TABLE_CELL_KEY" --out-dir "$OUT_DIR"); GATE_RC=$?
850
851
  printf '%s\n' "$GATE_OUT"
851
852
  # 8B: only for a milestone->staging PR whose merge just succeeded: clears the milestone on its open review-finding issues so they flow through the fast lane.
852
853
  if [ "$GATE_RC" -eq 0 ] && printf '%s\n' "$GATE_OUT" | grep -q '^MERGE_STATE=MERGED$'; then
@@ -857,7 +858,7 @@ exit "$GATE_RC"
857
858
 
858
859
  **Outcome mapping.** Report the script's output; never re-run its gates by hand and never `gh pr merge` yourself.
859
860
  - Exit 0, `MERGE_OUTCOME=merged` (`MERGE_STATE=MERGED`): merged. `merge-failed`: the merge did not land (the script commented on the issue). `parked-awaiting-merge`: parked per the previously-escalated guard; the script performs the transition itself (`transition-label.sh ... awaiting-merge`, prose fallback `gh issue edit --add-label "workflow:awaiting-merge"`).
860
- - Exit 3: print the `REVIEW_RESULT: status: BLOCKED, blocker: ...` line and stop; `MERGE_OUTCOME=` names the case (`base-conflict`, `needs-human`, `protection-blocked`, `stale-review`, `ci-not-green`). A CI gate TIMEOUT prints `CI_GATE_TIMEOUT`: re-run the same fence up to 3 more times. A phase-trail blocker with no MISSING lines (`phase trail unreadable (rc=N)`, `auto-merge requires --issue`) is an infrastructure or usage failure: callers neither re-run phases nor merge manually.
861
+ - Exit 3: print the `REVIEW_RESULT: status: BLOCKED, blocker: ...` line and stop; `MERGE_OUTCOME=` names the case (`base-conflict`, `needs-human`, `protection-blocked`, `stale-review`, `ci-not-green`). A `base-conflict` blocker carries no label (the autonomous base sync is pipeline work, not a human decision), so every caller of `--auto-merge` MUST map it explicitly: `work-on:review` R4, the router's Phase 0A.1 re-review fallback and `orchestrate` re-review Step 3 each set `workflow:remediating` (falling back to `needs-human`), and a standalone caller runs `/work-on <issue> --remediate`. A caller never leaves the issue unlabelled at `workflow:in-review`. A CI gate TIMEOUT prints `CI_GATE_TIMEOUT`: re-run the same fence up to 3 more times. A phase-trail blocker with no MISSING lines (`phase trail unreadable (rc=N)`, `auto-merge requires --issue`) is an infrastructure or usage failure: callers neither re-run phases nor merge manually.
861
862
  - Exit 3 with `blocker: degraded-gate-unreviewed` (`needs-human` added): the linked issue's gate is DEGRADED and no trusted review comment for the current head has a `### Degraded Paths Reviewed` section with a `- name: how reviewed` item for every degraded check. Not a code defect: `work-on:review` (R4) re-runs the review once so the section is added. Never merge by hand. <!-- Added: forge#3628 -->
862
863
  - Exit 4: `REVIEW_RESULT: status: PHASE_TRAIL_FAILED` plus the `MISSING:` lines. Return that status and stop: this phase never re-runs the missing phases and never escalates to `needs-human`; `work-on:review` (R4) alone owns re-dispatching them (forge#3102).
863
864
 
@@ -83,7 +83,7 @@ gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
83
83
  Before doing any work, select the architect comments with ONE anchored, trusted selector (body must start with the marker; author must be a Bot or OWNER/MEMBER/COLLABORATOR; mirrors `scripts/trusted-comments.sh`). A comment that merely mentions the marker (an INVESTIGATOR report, a CONTEXT briefing) never matches. <!-- Added: forge#3648 -->
84
84
 
85
85
  ```bash
86
- ARCH=$(gh api --paginate repos/{GH_REPO}/issues/{NUMBER}/comments | jq -s '[.[][] | select((.body // "") | startswith("<!-- FORGE:ARCHITECT -->")) | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | {id: .id, complete: (.body | contains("<!-- FORGE:ARCHITECT:COMPLETE -->")), other: (.body | test("<!-- FORGE:(INVESTIGATOR|CONTEXT|CONTRACT|BUILDER)"))}]') || ARCH=""
86
+ ARCH=$(gh api --paginate repos/{GH_REPO}/issues/{NUMBER}/comments | jq -s '[.[][] | select((.body // "") | startswith("<!-- FORGE:ARCHITECT -->")) | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | {id: .id, complete: (.body | contains("<!-- FORGE:ARCHITECT:COMPLETE -->")), other: (.body | test("\\n[ \\t]*<!-- FORGE:(INVESTIGATOR|CONTEXT|CONTRACT|BUILDER)"))}]') || ARCH=""
87
87
  ```
88
88
 
89
89
  - If `ARCH` is empty (unparsable) → fail closed: delete nothing and treat as no plan.
@@ -44,6 +44,7 @@ Parse from $ARGUMENTS:
44
44
 
45
45
  - `--fix-consumers "<UNADDRESSED lines>"` — optional. Set only by build B6 after validate returned `consumer_unaddressed`. Skip I1–I2 planning: for each listed `UNADDRESSED: <identifier> <file:line>` site, either update that file so it no longer depends on the old identifier (the file then joins the change set) or add a `- <path>[:<line>] unaffected: <reason>` line to the `### Consumers` block of the FORGE:BUILDER comment (the match is by path, split on the literal ` unaffected: `, so any path characters work). Never edit the checker (`scripts/consumer-sweep.sh`) or its stop-lists, and never add `unaffected:` lines without reading the site. Stage the changes, return `IMPLEMENT_RESULT: status: COMPLETE`; if a site cannot be addressed, leave it: build records it as `CONSUMERS: unresolved`. This is the single repair round; there is no second one. <!-- Added: forge#4017 -->
46
46
  - `--fix-acceptance "<failed checks>"` — optional. Set only by build B6.5 after a failed acceptance gate. In this mode, skip I1–I2 planning: read the listed failing `ACCEPTANCE_CHECK` ids from the FORGE:INVESTIGATOR comment, change the code in `{WORKTREE_PATH}` until each check's target/matcher holds (do not edit the checks), update the BUILDER comment's `### Changes` list, and return `IMPLEMENT_RESULT: status: COMPLETE`. If a check is impossible as written (contradicts the contract), return `status: BLOCKED` with that reason. **Decomposition refusal** <!-- Added: forge#3543 -->: never implement work that the FORGE:INVESTIGATOR `### Decomposition Assessment` (`**YES**`) assigns to a separate sub-issue, even if a listed check targets it; return `status: BLOCKED` with blocker "check <id> belongs to decomposed item <N>" instead.
47
+ - `--fix-selfreview "<findings>"` — optional. Set only by build B5.6 after the build-time self-review returned CONFIRMED or LIKELY findings at MEDIUM or above. <!-- Added: forge#3971 --> Skip I1–I2 planning. `<findings>` is one line per finding (`SR-<n> <CONFIDENCE> <SEVERITY> <file:line> <summary>`). For each: read the cited code, fix it in `{WORKTREE_PATH}` (the file joins the change set), or decline it when it is wrong or out of the contract's scope. Never edit a test, check or fixture to silence a finding, never touch the reviewer's comments, and add no unrequested scope. Re-read every pattern-sweep sibling of a fixed defect. Stage the changes and update the FORGE:BUILDER comment's `### Changes` list and its `### Self-review` section (see I6): one `fixed:` line per fixed finding and one `declined: <id> <reason>` line per declined one, with a real reason. Return `IMPLEMENT_RESULT: status: COMPLETE`. This is the single repair round, build guards it with `FORGE:SELFREVIEW_REPAIR`; there is no second one. If a finding cannot be addressed without contradicting the contract, decline it with that reason rather than returning BLOCKED.
47
48
 
48
49
  ## Phase I1: Load Context from GitHub
49
50
 
@@ -93,7 +94,7 @@ gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
93
94
  Select with ONE anchored, trusted selector (body must start with the marker; author must be a Bot or OWNER/MEMBER/COLLABORATOR; mirrors `scripts/trusted-comments.sh`). A comment that merely mentions the marker (INVESTIGATOR, CONTEXT, ARCHITECT) never matches. Delete ONLY when exactly one non-complete candidate exists and it carries no other FORGE phase marker; unparsable, zero, or multiple matches delete nothing (fail closed). <!-- Added: forge#3668 -->
94
95
  ```bash
95
96
  # only a current-cycle partial is ever deleted; prior-cycle comments are never touched
96
- BLD=$(printf '%s' "$CYCLE_SLICE" | jq -s '[.[][] | select((.body // "") | startswith("<!-- FORGE:BUILDER -->")) | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | {id: .id, complete: (.body | contains("<!-- FORGE:BUILDER:COMPLETE -->")), other: (.body | test("<!-- FORGE:(INVESTIGATOR|CONTEXT|CONTRACT|ARCHITECT)"))}]') || BLD=""
97
+ BLD=$(printf '%s' "$CYCLE_SLICE" | jq -s '[.[][] | select((.body // "") | startswith("<!-- FORGE:BUILDER -->")) | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | {id: .id, complete: (.body | contains("<!-- FORGE:BUILDER:COMPLETE -->")), other: (.body | test("\\n[ \\t]*<!-- FORGE:(INVESTIGATOR|CONTEXT|CONTRACT|ARCHITECT)"))}]') || BLD=""
97
98
  PARTIAL_ID=$(printf '%s' "$BLD" | jq -r '[.[] | select(.complete | not)] | if length == 1 and (.[0].other | not) then .[0].id else empty end' 2>/dev/null)
98
99
  if [ -n "$PARTIAL_ID" ]; then
99
100
  gh api repos/{GH_REPO}/issues/comments/$PARTIAL_ID -X DELETE
@@ -374,6 +375,13 @@ EDGE_TEST: id=e2 untestable={reason}
374
375
 
375
376
  > **Edge case block**: one line per `EDGE:` id of the architect's `FORGE:EDGE_CASES` block, each line starting at the line start (no prose before `EDGE_TEST:`). `test=` names a file and a test name (a single token with no whitespace; name the test with underscores or hyphens) that occurs in a line this change adds to that file; a pre-existing test does not count. `untestable=` needs a non-empty reason. Omit the section when the plan has no edge cases.
376
377
 
378
+ ### Self-review
379
+ SELFREVIEW:
380
+ - fixed: {SR-id} {file:line} {one-line what changed}
381
+ - declined: {SR-id} {file:line} {reason}
382
+
383
+ > **Self-review block** (written only by a `--fix-selfreview` round, forge#3971): one line per finding build B5.6 handed over. `declined:` lines carry the reason so the official `/review-pr` sees the finding as known; a declined finding is not a defect to hide, it is a decision to review. Omit the section when no self-review round ran.
384
+
377
385
  ### Testing Checklist
378
386
  - [ ] {Test scenario 1} [type:api]
379
387
  - [ ] {Test scenario 2} [type:unit]
@@ -977,7 +977,7 @@ fi
977
977
 
978
978
  # Find the FORGE:BUILDER comment posted by implement.md Phase I6
979
979
  # Anchored, trusted, single-match selector (forge#3668): zero/many/unparsable → empty → append skipped.
980
- BLD=$(gh api --paginate repos/{GH_REPO}/issues/{NUMBER}/comments | jq -s '[.[][] | select((.body // "") | startswith("<!-- FORGE:BUILDER -->")) | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | {id: .id, complete: (.body | contains("<!-- FORGE:BUILDER:COMPLETE -->")), other: (.body | test("<!-- FORGE:(INVESTIGATOR|CONTEXT|CONTRACT|ARCHITECT)"))}]') || BLD=""
980
+ BLD=$(gh api --paginate repos/{GH_REPO}/issues/{NUMBER}/comments | jq -s '[.[][] | select((.body // "") | startswith("<!-- FORGE:BUILDER -->")) | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | {id: .id, complete: (.body | contains("<!-- FORGE:BUILDER:COMPLETE -->")), other: (.body | test("\\n[ \\t]*<!-- FORGE:(INVESTIGATOR|CONTEXT|CONTRACT|ARCHITECT)"))}]') || BLD=""
981
981
  BUILDER_COMMENT_ID=$(printf '%s' "$BLD" | jq -r '[.[] | select(.complete | not)] | if length == 1 and (.[0].other | not) then .[0].id else empty end' 2>/dev/null)
982
982
 
983
983
  if [ -n "$BUILDER_COMMENT_ID" ]; then
@@ -23,7 +23,7 @@ argument-hint: "{NUMBER} --repo {GH_REPO} --gh-flag \"{GH_FLAG}\" --base {PR_BAS
23
23
  **Agent model policy**: `model: "{DEFAULT_MODEL}"` — resolved from forge.yaml `agents.default_model`, else "sonnet" (standard tier). Fallback: `model: "opus"` if rate-limited. Feature gate: pass `effort` in Task/Skill spawns only on Claude Code >= 2.1.154. This file's mechanical bits (classification, label transitions) stay at this tier because they are interleaved with the reasoning-heavy child phases in the same run. <!-- Added: forge#1827 -->
24
24
  **NEVER use plan mode (EnterPlanMode).**
25
25
 
26
- **CRITICAL: You MUST execute ALL phases B0–B6.5 (including B5.5) in order (the one exception is the B0 gate-resume branch, which enters at B6.5 with its state verified). Every child (B3 context, B4 architect, B5 implement, B6 validate) is invoked via `Skill(...)` — each is a forked sub-skill with its own isolated context. B3 and B4 are invoked for every complexity band except where the band is TRIVIAL or INVESTIGATION (B2/B2.1/B2.5 are also skipped for INVESTIGATION); the children post their own skip markers, so a skipped child is still visible on the issue. Skipping a child without the band justification degrades build quality and fails the phase-trail check.**
26
+ **CRITICAL: You MUST execute ALL phases B0–B6.5 (including B5.5 and B5.6) in order (the one exception is the B0 gate-resume branch, which enters at B6.5 with its state verified). Every child (B3 context, B4 architect, B5 implement, B6 validate) is invoked via `Skill(...)` — each is a forked sub-skill with its own isolated context. B3 and B4 are invoked for every complexity band except where the band is TRIVIAL or INVESTIGATION (B2/B2.1/B2.5 are also skipped for INVESTIGATION); the children post their own skip markers, so a skipped child is still visible on the issue. Skipping a child without the band justification degrades build quality and fails the phase-trail check.**
27
27
 
28
28
  **Synchronous child consumption**: each child `Skill(...)` call (B3-B6) runs to completion in this phase's own turn, and you consume its result in that same turn. Never end or yield your turn to wait for a child, and never wait for a completion notification: notifications for forked children go to the root session, not to this phase, so a turn that yields is never resumed. A child return with no `*_RESULT:` block (running, backgrounded, empty) is not a result — re-read the GitHub markers for that child (`FORGE:CONTEXT`, `FORGE:ARCHITECT`, `FORGE:BUILDER`, `FORGE:VALIDATE`/`FORGE:QUALITY_GATE`) and apply the **bounded re-invoke rule**: re-invoke the same child with the same args at most 2 times per child on running/backgrounded/empty returns (the counter is per child and is not reset by a re-invoke). Before re-invoking, check state: if the child's marker is present, consume it instead of re-invoking; for implement and validate, also check the worktree (`git status --short` and `git log origin/{PR_BASE}..HEAD` in `{WORKTREE_PATH}`) — uncommitted in-flight edits or a fresh commit mean the original child may still be live, so wait for it by re-reading state rather than launching a second writer on the same worktree, and count that as a re-invoke attempt. After the cap is exhausted, take the Blocked exit (see Result emission and Blocked exit) with `blocker: child-stalled: <child>` (child = context|architect|implement|validate); it is terminal and is not re-invoked by the router.
29
29
 
@@ -184,7 +184,7 @@ EXISTING_FAST_PATH=$(printf '%s' "$CYCLE_SLICE" | bash "$TRUSTED_SCRIPT" bodies
184
184
  Select with ONE anchored, trusted selector (body must start with the marker; author must be a Bot or OWNER/MEMBER/COLLABORATOR). Delete ONLY when exactly one non-complete candidate exists and it carries no other FORGE phase marker; unparsable, zero, or multiple matches delete nothing (fail closed). <!-- Added: forge#3668 -->
185
185
  ```bash
186
186
  # <cycle_slice function from B0, verbatim> — only a current-cycle partial is ever deleted; prior-cycle comments are never touched
187
- BLD=$(gh api --paginate repos/{GH_REPO}/issues/{NUMBER}/comments | cycle_slice | jq -s '[.[][] | select((.body // "") | startswith("<!-- FORGE:BUILDER -->")) | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | {id: .id, complete: (.body | contains("<!-- FORGE:BUILDER:COMPLETE -->")), other: (.body | test("<!-- FORGE:(INVESTIGATOR|CONTEXT|CONTRACT|ARCHITECT)"))}]') || BLD=""
187
+ BLD=$(gh api --paginate repos/{GH_REPO}/issues/{NUMBER}/comments | cycle_slice | jq -s '[.[][] | select((.body // "") | startswith("<!-- FORGE:BUILDER -->")) | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | {id: .id, complete: (.body | contains("<!-- FORGE:BUILDER:COMPLETE -->")), other: (.body | test("\\n[ \\t]*<!-- FORGE:(INVESTIGATOR|CONTEXT|CONTRACT|ARCHITECT)"))}]') || BLD=""
188
188
  PARTIAL_ID=$(printf '%s' "$BLD" | jq -r '[.[] | select(.complete | not)] | if length == 1 and (.[0].other | not) then .[0].id else empty end' 2>/dev/null)
189
189
  if [ -n "$PARTIAL_ID" ]; then
190
190
  gh api repos/{GH_REPO}/issues/comments/$PARTIAL_ID -X DELETE
@@ -376,7 +376,7 @@ Every item in the contract's `### Out of Scope` section must carry exactly one d
376
376
  - **Known-flaw rule**: a contract that keeps a known-flawed design (a design the investigation or architect plan identified as flawed, a fail-open caller, an unsound threshold) must state the flaw and list it as a `deferred → #N` item with a linked follow-up. It may not hide behind `accepted-risk` or free text.
377
377
  - Free-text exclusions without a disposition are invalid. `->` is accepted for `→`.
378
378
 
379
- **Fold, then batch** <!-- Added: forge#3843 -->: before filing anything, fold small siblings into scope. A candidate deferred item that is the same mechanical change at a sibling path and fits the remaining scope budget (a small fraction of the plan's own estimate: at most 20% of the current `ESTIMATED_DIFF_LINES`, or 200 lines when no estimate exists) goes into the contract's Deliverables, not Out of Scope; count it as `folded`. Defer only genuinely separate work (different module, design or owner). All remaining deferred items of this build go into ONE follow-up issue with a checklist; `scripts/file-deferred-followups.sh` appends to an open trusted follow-up for the same parent (exact path match) or creates one.
379
+ **Fold, then batch** <!-- Added: forge#3843 -->: before filing anything, fold small siblings into scope. A candidate deferred item that is the same mechanical change at a sibling path and fits the remaining scope budget (a small fraction of the plan's own estimate: at most 20% of the current `ESTIMATED_DIFF_LINES`, or 200 lines when no estimate exists) goes into the contract's Deliverables, not Out of Scope; count it as `folded`. Defer only genuinely separate work (different module, design or owner). All remaining deferred items of this build go into ONE follow-up issue with a checklist; `scripts/file-deferred-followups.sh` appends to an existing trusted follow-up for the same parent (exact path match; a closed one is reopened) or creates one, then records a `<!-- FORGE:DEFERRED_ISSUE: #M -->` claim marker on the parent.
380
380
 
381
381
  **File the `deferred` follow-ups** (skip the filing on resume when the contract already exists):
382
382
 
@@ -392,10 +392,11 @@ esac
392
392
 
393
393
  - Output is one `path<TAB>issue` row per item (rows share the batched issue number: use it as the `#N` in each `deferred → #N` bullet) plus a `DEFERRED_TELEMETRY deferred_items=N folded=N batched_into=#M filed=N` line (forwarded to the #3834 emitter when present).
394
394
  - A known-flawed design is recorded as a `known-flaw` checklist item in that batched issue (pass the flag in the TSV); it is never dropped or hidden behind `accepted-risk`.
395
- - Only follow-ups by a trusted author (OWNER/MEMBER/COLLABORATOR or Bot; `FORGE_TRAIL_TRUSTED_*` honoured) are reused; an untrusted match is ignored.
395
+ - Only follow-ups by a trusted author (OWNER/MEMBER/COLLABORATOR or Bot; `FORGE_TRAIL_TRUSTED_*` honoured) are reused; an untrusted match, claim comment or issue is ignored.
396
+ - Lookup order: the `FORGE:DEFERRED_ISSUE` claim markers on the parent's REST comments (read-after-write consistent), then a search over all issue states (the search index lags, so it is only a fallback); each candidate is verified by number and the lowest number is canonical. After creating, the script posts the claim and re-checks once: a lower-numbered trusted follow-up from a concurrent build wins, the items move to it and the newer issue is closed as a duplicate. <!-- Added: forge#3606 -->
396
397
  - `DEFERRED_LOOKUP=FAILED` or `DEFERRED_FILE=FAILED` (non-zero `DEFERRED_RC`) → Blocked exit with blocker `github-unavailable: could not file or look up deferred follow-ups` (a transient failure follows the retry rule above; no `needs-human` for `github-unavailable:`).
397
- - Prose tier (script absent): create one issue by hand whose body has the exact `<!-- FORGE:DEFERRED_FROM: #{NUMBER} -->` marker and a `- [ ] \`path\` — why` checklist line per path, after a trusted-author marker lookup; a failed lookup is a Blocked exit.
398
- - On resume the lookup reuses the open issue carrying the marker and appends only missing paths, so a retried build never files a duplicate.
398
+ - Prose tier (script absent): create one issue by hand whose body has the exact `<!-- FORGE:DEFERRED_FROM: #{NUMBER} -->` marker and a `- [ ] \`path\` — why` checklist line per path, after a trusted-author lookup over all issue states (not only open; read the parent's comments for an existing `FORGE:DEFERRED_ISSUE` claim first); post `<!-- FORGE:DEFERRED_ISSUE: #M -->` on the parent after creating; a failed lookup is a Blocked exit.
399
+ - On resume the lookup reuses the issue named by the `FORGE:DEFERRED_ISSUE` claim (or carrying the marker, open or closed, reopening a closed one) and appends only missing paths, so a retried build never files a duplicate.
399
400
 
400
401
  **Validate the composed section** with `scripts/check-contract-scope.sh validate` (the exact text that will replace `{OUT_OF_SCOPE_ITEMS}`):
401
402
 
@@ -736,7 +737,115 @@ else run gh issue comment {NUMBER} {GH_FLAG} --body "$BODY"; fi
736
737
  `SCOPE_NOTE` carries, one per line, `out_of_plan: <path>` for each out-of-plan file (up to 50), `overrun: <ws_lines> vs estimate <N> (>{OVERRUN_PCT}%)` when `overrun=true`, the `trimmed:` line after a trim, the override justification for `OVERRIDDEN`, and `review-note: <what reviewers should check>` (for example, check the out-of-plan files first). `DEPRECATION_NOTE` is `deprecated_config: build.diff_size.*` plus a newline when an alias supplied a value. A `FORGE:DIFF_SIZE` comment never contains a `### Split Proposal` heading and never routes anywhere: no engine path reads it as a decompose signal.
737
738
 
738
739
  **Resume**: B5.5 is idempotent. A re-invoked build re-runs B5.5 only through the normal path (B0 resumes at B5/B6 by BUILDER/COMPLETE state); a repeated measure edits the same `FORGE:DIFF_SIZE` comment, and a repeated trim finds the out-of-plan files already gone from the diff.
739
- # MUST CONTINUE to Phase B6 — the scope check result is intermediate, NOT terminal (validation still required), except the Blocked exits above.
740
+ # MUST CONTINUE to Phase B5.6 (self-review, then B6) — the scope check result is intermediate, NOT terminal (validation still required), except the Blocked exits above.
741
+
742
+ ---
743
+
744
+ ## Phase B5.6: Self-Review (same roster, warm context; never the official review) <!-- Added: forge#3971 -->
745
+
746
+ **Goal**: move adversarial-completeness defects (trust-boundary and bypass forms, domain semantics) to the cheapest point. The risk-tier roster's domain agents review the **staged diff** with the same personas and protocols as `/review-pr` (`commands/review-pr-agents/protocols.md` § SELF-REVIEW MODE), the builder fixes CONFIRMED/LIKELY findings at MEDIUM or above in place (one round), and declined findings are recorded for the official review. This is **not** the official review: it posts no review marker, no verdict, no label, and `FORGE:SELFREVIEW-AGENT:*` never satisfies `review-finalize.sh` or `verify-phase-trail.sh`. It runs before B6, so validate's gate covers the change and any fix together. build is a dispatching phase (router-only, `docs/WORK-ON-RUNTIME.md` R1); its children stay non-dispatching.
747
+
748
+ **Degrade, never block**: every failure here (plan error, no `Agent` tool, no reviewer comment in time, repair trouble) records a skip or missing-domain reason in `build_selfreview` and continues to B6. Skip this phase entirely for Investigation (B5 already exited).
749
+
750
+ **Step 1 — plan.** Paste the Script resolution block and the `cycle_slice` function from B0 first (shell state does not persist). `AGENT_AVAILABLE` is `false` when you have no `Agent` tool or `spawn-depth-check.sh --router-layer 1` exits 1 (conservative: build's own layer is unknown), else `true`. The plan script wraps `scripts/review-roster.sh` (the same roster `/review-pr` picks for this diff) and reads the staged diff only (no network), so `DONE_TREES` comes from the trusted `FORGE:SELFREVIEW` summaries of the current cycle (their `Tree:` and `Final-Tree:` values):
751
+
752
+ ```bash
753
+ # <Script resolution block from above goes here, verbatim>
754
+ # <cycle_slice function from B0, verbatim>
755
+ WT="{WORKTREE_PATH}"; AGENT_AVAILABLE=true # set false when no Agent tool, or the depth check below fails
756
+ SD=$(forge_resolve spawn-depth-check.sh); [ -n "$SD" ] && { bash "$SD" --router-layer 1 >/dev/null 2>&1; [ $? -eq 1 ] && AGENT_AVAILABLE=false; }
757
+ TREE=$(git -C "$WT" write-tree 2>/dev/null) || TREE=""
758
+ FILES_FILE=$(mktemp "${TMPDIR:-/tmp}/forge-sr-{NUMBER}.XXXXXX")
759
+ git -C "$WT" diff --cached --name-only "origin/{PR_BASE}" > "$FILES_FILE" 2>/dev/null
760
+ NLINES=$(git -C "$WT" diff --cached --numstat "origin/{PR_BASE}" 2>/dev/null | awk '{ a += $(1); d += $(2) } END { print a + d + 0 }')
761
+ TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
762
+ SLICE=$(gh api --paginate repos/{GH_REPO}/issues/{NUMBER}/comments | cycle_slice) || SLICE="[]"
763
+ CFLAGS=""; CONTRACT=$(printf '%s' "$SLICE" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:CONTRACT' 2>/dev/null | jq -r 'select(type == "string")' 2>/dev/null)
764
+ [ -n "$TRUSTED_SCRIPT" ] || CFLAGS="HIGH_RISK" # cannot read the contract trust-filtered: widen, never narrow
765
+ printf '%s\n' "$CONTRACT" | grep -qiE 'Risk.*(HIGH|CRITICAL)' && CFLAGS="HIGH_RISK"
766
+ printf '%s\n' "$CONTRACT" | grep -qiE 'auth|session|jwt' && CFLAGS="$CFLAGS CONTRACT_AUTH"
767
+ DEPTH=STANDARD; printf '%s' "$SLICE" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:DIFF_SIZE' 2>/dev/null | jq -r 'select(type == "string")' | grep -qE '^review_depth: *EXTENDED' && DEPTH=EXTENDED
768
+ SPEC_CLI=false; [ -f "$WT/commands/review-pr-agents/spec-cli.md" ] && SPEC_CLI=true
769
+ DONE_TREES=$(printf '%s' "$SLICE" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:SELFREVIEW -->' 2>/dev/null | jq -r 'select(type == "string")' | sed -n 's/^\(Final-\)\{0,1\}Tree: *//p' | tr '\n' ' ')
770
+ PLAN_SCRIPT=$(forge_resolve selfreview-plan.sh)
771
+ if [ -n "$PLAN_SCRIPT" ]; then
772
+ bash "$PLAN_SCRIPT" --files-file "$FILES_FILE" --changed-lines "$NLINES" --contract-flags "$CFLAGS" --spec-cli "$SPEC_CLI" --review-depth "$DEPTH" --agent-available "$AGENT_AVAILABLE" --tree "$TREE" --done-trees "$DONE_TREES"
773
+ else echo "SELFREVIEW=skip"; echo "SKIP_REASON=plan-unavailable"; fi
774
+ echo "TREE=$TREE"
775
+ rm -f "$FILES_FILE"
776
+ ```
777
+
778
+ Keep the printed `TREE=`, `TIER=`, `SELECTED_AGENTS=` and `SKIP_REASON=` values: shell state does not persist, so Steps 2, 3 and 5 take them as the literal `{TREE}`, `{TIER}`, `{SELECTED_AGENTS}` and `{SKIP_REASON}` values. <!-- Added: forge#4068 review CQ-1 -->
779
+
780
+ `SELFREVIEW=skip` (any `SKIP_REASON`, including a missing plan script or a plan call that fails) → go to Step 5 with `skipped=<reason>`. A skip is never an error. `FORGE_SELFREVIEW=off` is the operator off switch.
781
+
782
+ **Step 2 — dispatch** (`SELFREVIEW=run`). In ONE message, spawn one reviewer per `SELECTED_AGENTS` domain with the `Agent` tool (`model: "{DEFAULT_MODEL}"`; the effort gate applies). Each prompt is the domain persona file `commands/review-pr-agents/{domain}.md` plus `protocols.md`, with `MODE: SELF-REVIEW`, `[AGENT_DOMAIN]`, `[ISSUE_NUMBER]={NUMBER}`, `[GH_REPO]`, `[WORKTREE_PATH]`, `[PR_BASE]`, `[TREE_HASH]=$TREE`, and `[DOMAIN_DIFF_SLICE]` cut from `git -C "{WORKTREE_PATH}" diff --cached "origin/{PR_BASE}"` per the protocol's input scoping (Security gets the full diff). Resolve the persona files from the install root (`$FORGE_ROOT/commands/review-pr-agents/`), never by `find`. Reviewers are read-only. Their completion notifications go to the root session (`docs/WORK-ON-RUNTIME.md` F6), so never wait on one: use Step 3.
783
+
784
+ **Step 3 — bounded wait, one poll pass per Bash call.** A reviewer is done when a trusted comment starting `<!-- FORGE:SELFREVIEW-AGENT:{domain} -->` carries `Tree: ${TREE}` and a findings block. Re-run the pass while it prints `SELFREVIEW_WAIT: WAITING`; paste the Script resolution block first. The first pass records an epoch deadline (`SELFREVIEW_WAIT_SECS`, default 600, max 1800) in a private file keyed by issue and tree; `DONE` and `TIMEOUT` remove it. A domain with no matching comment at the deadline, or only a stale-`Tree:` one, is **missing**: record it and continue (never re-review inline):
785
+
786
+ ```bash
787
+ # <Script resolution block from above goes here, verbatim>
788
+ ISSUE={NUMBER}; TREE="{TREE}"; SELECTED="{SELECTED_AGENTS}"
789
+ case "$TREE" in *[!0-9a-f]*|"") echo "SELFREVIEW_WAIT: DONE missing=all (bad tree)"; exit 0 ;; esac
790
+ WAIT_DIR="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/forge-selfreview-wait-$(id -u)"; mkdir -m 700 -p "$WAIT_DIR" 2>/dev/null
791
+ { [ -d "$WAIT_DIR" ] && [ -O "$WAIT_DIR" ] && [ ! -L "$WAIT_DIR" ]; } || { echo "SELFREVIEW_WAIT: DONE missing=all (no private wait dir)"; exit 0; }
792
+ DL="${WAIT_DIR}/${ISSUE}_${TREE}"; W="${SELFREVIEW_WAIT_SECS:-600}"; case "$W" in ''|*[!0-9]*) W=600 ;; esac; [ "$W" -gt 1800 ] && W=1800
793
+ NOW=$(date +%s); [ -s "$DL" ] || echo $(( NOW + W )) > "$DL"; DEADLINE=$(cat "$DL" 2>/dev/null); case "$DEADLINE" in ''|*[!0-9]*) DEADLINE=$NOW ;; esac
794
+ TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh); CJ=$(gh api --paginate repos/{GH_REPO}/issues/${ISSUE}/comments 2>/dev/null || echo "")
795
+ MISSING=""; GOT=0; OUTF=$(mktemp "${TMPDIR:-/tmp}/forge-sr-find.XXXXXX")
796
+ for D in $SELECTED; do
797
+ B=""; [ -n "$TRUSTED_SCRIPT" ] && [ -n "$CJ" ] && B=$(printf '%s' "$CJ" | bash "$TRUSTED_SCRIPT" bodies "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:SELFREVIEW-AGENT:${D} -->[\\s\\S]*(^|\\n)Tree: ${TREE}(\\r?\\n|$)" 2>/dev/null | jq -r 'select(type == "string")' 2>/dev/null)
798
+ if [ -n "$B" ]; then GOT=$((GOT+1)); printf '%s\n' "$B" | sed -n 's/^<!-- FINDING:\(.*\) -->$/SR_FINDING: '"$D"'|\1/p' >> "$OUTF"; else MISSING="$MISSING $D"; fi
799
+ done
800
+ N=$(printf '%s' "$SELECTED" | tr ' ' '\n' | grep -c . || true)
801
+ if [ -z "$MISSING" ]; then rm -f "$DL"; cat "$OUTF"; echo "SELFREVIEW_WAIT: DONE ${GOT}/${N}"
802
+ elif [ "$(date +%s)" -ge "$DEADLINE" ]; then rm -f "$DL"; cat "$OUTF"; echo "SELFREVIEW_WAIT: TIMEOUT ${GOT}/${N} missing:${MISSING}"
803
+ else echo "SELFREVIEW_WAIT: WAITING ${GOT}/${N} missing:${MISSING}"; sleep 30; fi
804
+ rm -f "$OUTF"
805
+ ```
806
+
807
+ `DONE` and `TIMEOUT` print one `SR_FINDING: {domain}|{id}|{CONFIDENCE}|{SEVERITY}|{file:line}|{summary}` line per finding of the reviewers that did deliver. Dedupe findings that name the same `file:line` and defect across domains.
808
+
809
+ **Step 4 — one repair round.** The actionable set is every finding with confidence CONFIRMED or LIKELY **and** severity MEDIUM, HIGH or CRITICAL. POSSIBLE and LOW findings are never fixed here; they go into the summary comment as `noted:`. Skip the repair when the set is empty. Otherwise run exactly one round guarded by a `<!-- FORGE:SELFREVIEW_REPAIR: issue={NUMBER} -->` marker (count trusted markers in the current-cycle slice first; if one exists the round is spent, so go to Step 5 with `declined` = the whole set, reason `repair round already spent`):
810
+ 1. Post the marker.
811
+ 2. `Skill(skill="{FORGE_SKILL_PREFIX}work-on:build:implement", args="{NUMBER} --repo {GH_REPO} --gh-flag \"{GH_FLAG}\" --worktree {WORKTREE_PATH} --branch {BRANCH} --base {PR_BASE} --fix-selfreview \"<one SR-<n> line per finding>\"")`. Apply the bounded re-invoke rule if it returns running/empty. `BLOCKED` or a skill-not-found result does not block the build: record `skipped=repair-failed` and continue.
812
+ 3. Re-derive `{CHANGED_FILES}` with the B6 command (never reuse the pre-repair value), read `fixed` and `declined` from the `### Self-review` section of the FORGE:BUILDER comment, and re-stage (`git add`). Do not re-run the reviewers: validate gates the result.
813
+
814
+ **Step 5 — record.** Post (or edit, on resume, the latest trusted) one `<!-- FORGE:SELFREVIEW -->` summary comment, then emit the event. `Final-Tree` is `git write-tree` after the repair (equal to `Tree` when none ran); both feed `DONE_TREES` so a re-run never dispatches again:
815
+
816
+ ```bash
817
+ # <Script resolution block from above goes here, verbatim>
818
+ # Inline the values from Steps 1-4 (shell state does not persist); counts are integers, 0 when none.
819
+ TREE="{TREE}"; TIER="{TIER}"; SELECTED_AGENTS="{SELECTED_AGENTS}"; SKIP_REASON="{SKIP_REASON}"; MISSING="{MISSING}"
820
+ FINDINGS="{FINDINGS}"; FIXED="{FIXED}"; DECLINED="{DECLINED}"; AGENT_COUNT="{AGENT_COUNT}"
821
+ for _v in FINDINGS FIXED DECLINED AGENT_COUNT; do eval "_x=\${$_v}"; case "$_x" in ''|*[!0-9]*) eval "$_v=0" ;; esac; done
822
+ case "$TREE" in *[!0-9a-f]*) TREE="" ;; esac
823
+ NOTED_LINES=$(cat <<'NOTED_EOF'
824
+ {NOTED_LINES}
825
+ NOTED_EOF
826
+ )
827
+ FINAL_TREE=$(git -C "{WORKTREE_PATH}" write-tree 2>/dev/null || echo "")
828
+ BODY="<!-- FORGE:SELFREVIEW -->
829
+ ## Self-review (build-time; not the official review)
830
+
831
+ Tree: ${TREE}
832
+ Final-Tree: ${FINAL_TREE:-$TREE}
833
+ Tier: ${TIER:-none}
834
+ Agents: ${SELECTED_AGENTS:-none}
835
+ Missing: ${MISSING:-none}
836
+ Skipped: ${SKIP_REASON:-none}
837
+ Findings: ${FINDINGS:-0} (fixed ${FIXED:-0}, declined ${DECLINED:-0})
838
+ ${NOTED_LINES:-}"
839
+ CID=$(gh api --paginate "repos/{GH_REPO}/issues/{NUMBER}/comments" --jq '[.[] | select(.body | startswith("<!-- FORGE:SELFREVIEW -->")) | select((.author_association | IN("OWNER","MEMBER","COLLABORATOR")) or (.user.type == "Bot"))] | last | .id // ""' 2>/dev/null || true)
840
+ if [ -n "$CID" ]; then gh api "repos/{GH_REPO}/issues/comments/${CID}" -X PATCH -f body="$BODY" >/dev/null 2>&1 || gh issue comment {NUMBER} {GH_FLAG} --body "$BODY" # allowlist:check-command-side-effects
841
+ else gh issue comment {NUMBER} {GH_FLAG} --body "$BODY"; fi # allowlist:check-command-side-effects
842
+ _t=$(forge_resolve lib/telemetry.sh); FORGE_REPO="{GH_REPO}" FORGE_ISSUE="{NUMBER}" FORGE_PHASE=build bash "${_t:-/dev/null}" build_selfreview tier="${TIER:-none}" agents="${AGENT_COUNT:-0}" findings="${FINDINGS:-0}" fixed="${FIXED:-0}" declined="${DECLINED:-0}" skipped="${SKIP_REASON:-}" >/dev/null 2>&1 || true # forge#3971 telemetry (ids and counts only; compare with review-found in-PR fixes)
843
+ ```
844
+
845
+ Emit on **every** outcome, including skips (`skipped=<reason>`, counts 0). Fill `{NOTED_LINES}` (the heredoc body) with one `noted: <id> <file:line> <summary>` line per sub-threshold finding and one `missing: <domain>` per reviewer that never delivered; the official `/review-pr` reads this comment as known context, never as review evidence. If the repair changed the tree, the next B6 `Skill(...)` call (with the re-derived `{CHANGED_FILES}`) gates it.
846
+
847
+ **Resume**: B5.6 is idempotent. A re-invoked build finds the trusted `FORGE:SELFREVIEW` summary, the plan script reports `already-done` for its `Tree:`/`Final-Tree:`, and the spent `FORGE:SELFREVIEW_REPAIR` marker forbids a second repair round.
848
+ # MUST CONTINUE to Phase B6 — the self-review result is intermediate, NOT terminal (validation still required). Any failure here is a recorded skip, never a Blocked exit.
740
849
 
741
850
  ---
742
851
 
@@ -1096,6 +1205,7 @@ BUILD_RESULT:
1096
1205
  - `COMPLETE` — B0–B6.5 all passed (a DEGRADED acceptance gate, `FORGE:ACCEPTANCE_GATE:DEGRADED`, counts as passed here); `FORGE:BUILDER:COMPLETE` is on the issue.
1097
1206
  - `ALREADY_DONE` — B0 found `FORGE:BUILDER:COMPLETE`.
1098
1207
  - `INVESTIGATION_COMPLETE` — the implement child created the deliverable issues (B5); no review follows.
1208
+ - B5.6 never produces a `BUILD_RESULT` status of its own either: every outcome (ran, skipped, degraded) is recorded in `FORGE:SELFREVIEW` and the `build_selfreview` event, then the build continues to B6. <!-- Added: forge#3971 -->
1099
1209
  - B5.5 never produces a `BUILD_RESULT` status of its own: its scope-check outcome (`OK`, `EXTENDED_REVIEW`, `SCOPE_FLAGGED`, `OVERRIDDEN`) is recorded in `FORGE:DIFF_SIZE`, then the build continues to B6 and ends `COMPLETE`. A finished build is never routed to decompose or discarded. <!-- Added: forge#3862 -->
1100
1210
  - `BLOCKED` — any guard or failure; `needs-human` is set and the blocker is posted.
1101
1211
 
@@ -1106,7 +1216,7 @@ BUILD_RESULT:
1106
1216
  Position in the work-on pipeline (label `workflow:ready-to-build` or `workflow:building`):
1107
1217
 
1108
1218
  ```
1109
- investigate → [THIS MODULE] worktree + classify + contract + context + architect + implement + validate + acceptance-gate
1219
+ investigate → [THIS MODULE] worktree + classify + contract + context + architect + implement + self-review + validate + acceptance-gate
1110
1220
  → posts FORGE:FAST_PATH, FORGE:CONTRACT, FORGE:BUILDER(:COMPLETE), FORGE:ACCEPTANCE_GATE; writes FORGE:CHECKPOINT next_phase=REVIEW
1111
1221
  → work-on:review (push branch, create PR, review, merge)
1112
1222
  → work-on:close
@@ -1256,7 +1256,7 @@ Where:
1256
1256
 
1257
1257
  **Goal**: Re-index this issue's knowledge cards and regenerate cost-priors.json so that economic scheduling (orchestrate Step 3E.5) has up-to-date data for future runs. The actual-vs-prior delta recorded in the TRAJECTORY above is the write side of the self-correction loop — this step performs the read/recompute.
1258
1258
 
1259
- **This phase is non-blocking** — if the indexer fails, log the reason and continue to Phase C5.2. Never stall close for the cost-prior update.
1259
+ **This phase is non-blocking** — if the indexer fails, log the reason (last 20 lines of its output) and always continue to Phase C5.2–C5.4. Never stall close for the cost-prior update.
1260
1260
 
1261
1261
  **Skip if**: Terminal state is `INVALID` (no useful cost data from invalid issues).
1262
1262
 
@@ -1267,9 +1267,14 @@ INDEXER_PATH=""
1267
1267
  [ -n "${FORGE_ROOT:-}" ] && INDEXER_PATH="$FORGE_ROOT/scripts/build-knowledge-index.mjs"
1268
1268
  if node --version >/dev/null 2>&1 && [ -f "$INDEXER_PATH" ]; then
1269
1269
  echo "[cost-prior] Re-indexing issue #${NUMBER} and regenerating cost priors..."
1270
- node "$INDEXER_PATH" --issue {NUMBER} --no-mirror 2>&1 | tail -5 \
1271
- && echo "[cost-prior] Cost priors updated" \
1272
- || echo "WARNING: Cost prior update failed — continuing (non-blocking)"
1270
+ # Capture output and take the status from node itself (a pipe to tail would mask it); --repo targets the repo being closed
1271
+ INDEXER_OUT=$(node "$INDEXER_PATH" --repo {GH_REPO} --issue {NUMBER} --no-mirror 2>&1) && INDEXER_RC=0 || INDEXER_RC=$?
1272
+ if [ "$INDEXER_RC" -eq 0 ]; then
1273
+ echo "[cost-prior] Cost priors updated"
1274
+ else
1275
+ echo "WARNING: Cost prior update failed (exit $INDEXER_RC) — continuing (non-blocking). Last output:"
1276
+ printf '%s\n' "$INDEXER_OUT" | tail -20
1277
+ fi
1273
1278
  else
1274
1279
  echo "[cost-prior] Indexer not available — skipping cost prior update (non-blocking)"
1275
1280
  fi