forgedock 1.19.0 → 1.21.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 (53) hide show
  1. package/README.md +2 -0
  2. package/bin/engine/phases.mjs +4 -2
  3. package/bin/engine/telemetry-seam.mjs +1 -1
  4. package/bin/engine-cli.mjs +40 -1
  5. package/bin/engine.mjs +48 -3
  6. package/bin/forgedock.mjs +1 -1
  7. package/bin/lean/effects.mjs +51 -0
  8. package/bin/lean/lean.mjs +352 -0
  9. package/bin/lean/lean.test.mjs +525 -0
  10. package/bin/lean/prompts.mjs +62 -0
  11. package/bin/lean/risk.mjs +49 -0
  12. package/bin/runner.mjs +99 -6
  13. package/commands/orchestrate/phase-4-execution.md +1 -1
  14. package/commands/review-pr-agents/protocols.md +19 -0
  15. package/commands/review-pr.md +7 -6
  16. package/commands/work-on/build/architect.md +1 -1
  17. package/commands/work-on/build/implement.md +9 -1
  18. package/commands/work-on/build/validate.md +14 -7
  19. package/commands/work-on/build.md +118 -8
  20. package/commands/work-on/close.md +9 -4
  21. package/commands/work-on/remediate.md +11 -4
  22. package/commands/work-on/review.md +12 -4
  23. package/commands/work-on.md +4 -4
  24. package/docs/CONFIG.md +8 -1
  25. package/docs/RELEASE-NOTES.md +21 -0
  26. package/docs/TELEMETRY.md +2 -2
  27. package/docs/WORK-ON-RUNTIME.md +11 -3
  28. package/package.json +2 -2
  29. package/scripts/architect-resume-match.test.sh +12 -3
  30. package/scripts/build-knowledge-index.mjs +8 -5
  31. package/scripts/builder-resume-match.test.sh +12 -3
  32. package/scripts/calibration.mjs +5 -2
  33. package/scripts/check-spec-markers.sh +2 -0
  34. package/scripts/ci-local/lib.mjs +11 -3
  35. package/scripts/ci-local/run.mjs +155 -49
  36. package/scripts/ci-local/scoping.mjs +27 -4
  37. package/scripts/ci-local.sh +3 -2
  38. package/scripts/ci-local.test.sh +189 -0
  39. package/scripts/classify-finding.sh +1 -1
  40. package/scripts/file-deferred-followups.sh +91 -30
  41. package/scripts/file-deferred-followups.test.sh +101 -8
  42. package/scripts/lib/telemetry.sh +2 -0
  43. package/scripts/review-finalize.sh +101 -12
  44. package/scripts/review-finalize.test.sh +136 -5
  45. package/scripts/review-roster.sh +18 -5
  46. package/scripts/review-roster.test.sh +57 -1
  47. package/scripts/selfreview-plan.sh +86 -0
  48. package/scripts/selfreview-plan.test.sh +90 -0
  49. package/scripts/spawn-depth-check.sh +5 -4
  50. package/scripts/spawn-depth-check.test.sh +3 -0
  51. package/scripts/telemetry.test.sh +8 -0
  52. package/scripts/transition-label.sh +26 -0
  53. package/scripts/verify-phase-trail.test.sh +6 -0
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];
@@ -659,6 +671,23 @@ export function isClaudeCliAvailable(
659
671
  // gated on the cached `claude --version` probe, never on help-text scraping.
660
672
  const CLI_MIN_VERSION_MODEL_FLAG = [1, 0, 0];
661
673
  const CLI_MIN_VERSION_MAX_TURNS_FLAG = [1, 0, 0];
674
+ // forge#4065: minimal phase sessions. Conservative floor: the oldest version
675
+ // known to accept `--strict-mcp-config`, `--mcp-config` and
676
+ // `--disable-slash-commands` together. Unparseable/older => flags omitted.
677
+ const CLI_MIN_VERSION_MINIMAL_SESSION = [2, 1, 296];
678
+
679
+ // Phase commands whose spec never invokes `Skill(...)`; only these run with
680
+ // `--disable-slash-commands` (spec-coverage test enforces the invariant).
681
+ export const SKILL_FREE_PHASE_COMMANDS = Object.freeze(["work-on/investigate", "work-on/close"]);
682
+
683
+ // Engine phase commands (mirrors PHASES in bin/engine/phases.mjs; a test enforces it). Only these
684
+ // get the minimal-session group: other `forgedock run` commands (qa-sweep, analytics, geo-audit,
685
+ // autopilot) may need the operator's MCP servers and skills.
686
+ export const ENGINE_PHASE_COMMANDS = Object.freeze([
687
+ "work-on/investigate", "work-on/decompose", "work-on/build", "work-on/review", "work-on/remediate", "work-on/close",
688
+ ]);
689
+
690
+ const MINIMAL_SESSION_MCP_CONFIG = '{"mcpServers":{}}';
662
691
 
663
692
  /**
664
693
  * Extract a `major.minor.patch` version from probe output (the text after the
@@ -690,7 +719,7 @@ function versionAtLeast(version, min) {
690
719
  *
691
720
  * @param {string} [cwd]
692
721
  * @param {object} [opts] - Forwarded to `isClaudeCliAvailable()` (test seams).
693
- * @returns {{model: boolean, maxTurns: boolean}}
722
+ * @returns {{model: boolean, maxTurns: boolean, strictMcp: boolean, disableSlash: boolean}}
694
723
  */
695
724
  export function getClaudeCliCapabilities(cwd = process.cwd(), opts = {}) {
696
725
  isClaudeCliAvailable(cwd, opts);
@@ -698,9 +727,53 @@ export function getClaudeCliCapabilities(cwd = process.cwd(), opts = {}) {
698
727
  return {
699
728
  model: versionAtLeast(version, CLI_MIN_VERSION_MODEL_FLAG),
700
729
  maxTurns: versionAtLeast(version, CLI_MIN_VERSION_MAX_TURNS_FLAG),
730
+ strictMcp: versionAtLeast(version, CLI_MIN_VERSION_MINIMAL_SESSION),
731
+ disableSlash: versionAtLeast(version, CLI_MIN_VERSION_MINIMAL_SESSION),
701
732
  };
702
733
  }
703
734
 
735
+ /**
736
+ * Build the minimal-session argv group (forge#4065): no operator MCP servers
737
+ * and, for Skill-free phases only, no slash-command/skill listing. Operator
738
+ * settings are deliberately NOT skipped (the CLI setting-sources flag is not passed) so
739
+ * ForgeDock's user-scope PreToolUse/SubagentStop hooks keep running.
740
+ * Non-engine commands, `FORGEDOCK_FULL_SESSION=1` (exactly) or an unsupported CLI yield `[]`.
741
+ *
742
+ * @param {string} commandName
743
+ * @param {{strictMcp: boolean, disableSlash: boolean}} caps
744
+ * @param {NodeJS.ProcessEnv} [env]
745
+ * @returns {string[]}
746
+ */
747
+ export function buildMinimalSessionFlags(commandName, caps, env = process.env) {
748
+ if (env?.FORGEDOCK_FULL_SESSION === "1" || !caps) return [];
749
+ const name = String(commandName ?? "").trim().replace(/^\/+/, "").replace(/\.md$/i, "");
750
+ if (!ENGINE_PHASE_COMMANDS.includes(name)) return [];
751
+ const flags = [];
752
+ if (caps.strictMcp) flags.push("--strict-mcp-config", "--mcp-config", MINIMAL_SESSION_MCP_CONFIG);
753
+ if (caps.disableSlash && SKILL_FREE_PHASE_COMMANDS.includes(name)) flags.push("--disable-slash-commands");
754
+ return flags;
755
+ }
756
+
757
+ /**
758
+ * Lean-path (#4081) minimal session flags: reuses the #4065 capability probe. Every phase is Skill-free, so slash
759
+ * commands are off too; user-scope settings and hooks still load. Unsupported CLI or FORGEDOCK_FULL_SESSION=1 yields [].
760
+ */
761
+ export function buildLeanSessionFlags(caps, env = process.env) {
762
+ // Same group as the Skill-free engine phases (no MCP, no slash commands). Operator settings stay loaded so the
763
+ // user-scope safety hooks keep running, exactly like #4077.
764
+ return buildMinimalSessionFlags("work-on/investigate", caps, env);
765
+ }
766
+
767
+ /**
768
+ * Read-only reviewer tools (#4081): only Read, Grep and Glob. Uses `--tools` where the capability probe says the
769
+ * CLI is recent enough, else denies the mutating tools with `--disallowedTools`.
770
+ */
771
+ export function buildReadOnlyToolFlags(caps) {
772
+ return caps?.strictMcp
773
+ ? ["--tools", "Read,Grep,Glob"]
774
+ : ["--disallowedTools", "Bash", "Edit", "Write", "MultiEdit", "NotebookEdit"];
775
+ }
776
+
704
777
  /**
705
778
  * Return the exact absolute `claude` executable path that
706
779
  * `isClaudeCliAvailable()` validated for `cwd` (issue #2741), ensuring the
@@ -1430,6 +1503,10 @@ export function runCliBackend({
1430
1503
  spawnFn = spawnSync,
1431
1504
  model = null,
1432
1505
  maxTurns = null,
1506
+ leaseHolder = null,
1507
+ sessionFlags = [],
1508
+ // #4081: extra argv (e.g. ["--resume", id]) appended verbatim, never dropped by FORGEDOCK_FULL_SESSION.
1509
+ extraArgs = [],
1433
1510
  }) {
1434
1511
  // forge#3589: opt-in flags, validated again here as defence in depth. Callers
1435
1512
  // (runCommand) are responsible for the version gate. Unset -> argv unchanged.
@@ -1438,6 +1515,12 @@ export function runCliBackend({
1438
1515
  const optionalFlags = [
1439
1516
  ...(modelFlag ? ["--model", modelFlag] : []),
1440
1517
  ...(maxTurnsFlag ? ["--max-turns", String(maxTurnsFlag)] : []),
1518
+ // forge#4065: minimal-session group, built by buildMinimalSessionFlags().
1519
+ // Re-checked here so FORGEDOCK_FULL_SESSION=1 always wins.
1520
+ ...(process.env.FORGEDOCK_FULL_SESSION === "1" || !Array.isArray(sessionFlags)
1521
+ ? []
1522
+ : sessionFlags.map(String)),
1523
+ ...(Array.isArray(extraArgs) ? extraArgs.map(String) : []),
1441
1524
  ];
1442
1525
  const rawTimeout = parseInt(process.env.FORGEDOCK_CLI_TIMEOUT_MS, 10);
1443
1526
  const configuredTimeout = resolveConfiguredCliTimeout(cwd);
@@ -1496,7 +1579,7 @@ export function runCliBackend({
1496
1579
  // Mirrors the existing scrub in the `run_bash` tool handler below.
1497
1580
  // GH_TOKEN/GITHUB_TOKEN and all other env vars are intentionally left
1498
1581
  // intact — only the Anthropic key is removed.
1499
- const childEnv = withRunTmpdir(withForgedockHome({ ...process.env }), { issue: issueFromArgs(args), cwd });
1582
+ const childEnv = withLeaseHolder(withRunTmpdir(withForgedockHome({ ...process.env }), { issue: issueFromArgs(args), cwd }), leaseHolder);
1500
1583
  delete childEnv.ANTHROPIC_API_KEY;
1501
1584
 
1502
1585
  // No `shell` option (defaults to false): argv is passed as discrete,
@@ -1725,6 +1808,7 @@ export function runCliBackend({
1725
1808
  // bin/batch-runner.mjs's `tokenCost()` (`input + output`).
1726
1809
  const stdoutTrimmed = stdout.trim();
1727
1810
  let parsedResult = null;
1811
+ let sessionId = null;
1728
1812
  let usage = null;
1729
1813
  let turns = null;
1730
1814
  // forge#3696: envelope cost/duration per attempt (null when absent or non-finite).
@@ -1737,6 +1821,7 @@ export function runCliBackend({
1737
1821
  if (Number.isFinite(parsed.total_cost_usd)) costUsd = parsed.total_cost_usd;
1738
1822
  if (Number.isFinite(parsed.duration_ms)) durationMs = parsed.duration_ms;
1739
1823
  parsedResult = typeof parsed.result === "string" ? parsed.result : null;
1824
+ if (typeof parsed.session_id === "string" && parsed.session_id) sessionId = parsed.session_id;
1740
1825
  if (parsed.usage && typeof parsed.usage === "object") {
1741
1826
  usage = {
1742
1827
  input_tokens: toFiniteUsageNumber(parsed.usage.input_tokens),
@@ -1822,6 +1907,7 @@ export function runCliBackend({
1822
1907
  turns,
1823
1908
  costUsd,
1824
1909
  durationMs,
1910
+ sessionId,
1825
1911
  backend: "cli",
1826
1912
  // forge#3521: the phase's final reply (the CLI envelope's `.result`), so
1827
1913
  // callers can read its structured `*_RESULT` block. Additive; "" when absent.
@@ -2305,9 +2391,10 @@ export function resolveBashShell() {
2305
2391
  *
2306
2392
  * @param {string} cwd
2307
2393
  * @param {string|null} [runIssueNumber] - issue number scoping the per-run TMPDIR (forge#3572)
2394
+ * @param {string|null} [leaseHolder] - engine lease id stamped as FORGE_LEASE_HOLDER in the child env
2308
2395
  * @returns {Record<string, (input: object) => string>}
2309
2396
  */
2310
- export function getToolHandlers(cwd, runIssueNumber = null) {
2397
+ export function getToolHandlers(cwd, runIssueNumber = null, leaseHolder = null) {
2311
2398
  return {
2312
2399
  read_file: ({ path }) => {
2313
2400
  if (!path) throw new Error("read_file requires a 'path'");
@@ -2327,7 +2414,7 @@ export function getToolHandlers(cwd, runIssueNumber = null) {
2327
2414
  // into the model loop, so a prompt-injection payload could otherwise issue
2328
2415
  // a run_bash command that exfiltrates the key. gh/git auth tokens
2329
2416
  // (GH_TOKEN/GITHUB_TOKEN) are intentionally left intact.
2330
- const childEnv = withRunTmpdir(withForgedockHome({ ...process.env }), { issue: runIssueNumber, cwd });
2417
+ const childEnv = withLeaseHolder(withRunTmpdir(withForgedockHome({ ...process.env }), { issue: runIssueNumber, cwd }), leaseHolder);
2331
2418
  delete childEnv.ANTHROPIC_API_KEY;
2332
2419
  // Run under bash when available so bash-style commands the model emits
2333
2420
  // (heredocs, scripts/*.sh, single-quote semantics) behave consistently
@@ -2585,6 +2672,7 @@ export async function runCommand(opts = {}) {
2585
2672
  backend = process.env.FORGEDOCK_BACKEND || "auto",
2586
2673
  runtimeEnv = process.env,
2587
2674
  logger = console,
2675
+ leaseHolder = null,
2588
2676
  } = opts;
2589
2677
 
2590
2678
  assertOpenCodeNativeRuntime({
@@ -2700,10 +2788,13 @@ export async function runCommand(opts = {}) {
2700
2788
  : null;
2701
2789
  let cliModel = commandSettings.model ?? runLevelModel;
2702
2790
  let cliMaxTurns = commandSettings.maxTurns;
2703
- if (resolvedBin && (cliModel || cliMaxTurns)) {
2791
+ // forge#4065: minimal phase session flags, computed per call (never cached).
2792
+ let sessionFlags = [];
2793
+ if (resolvedBin) {
2704
2794
  const caps = getClaudeCliCapabilities(cwd);
2705
2795
  if (cliModel && !caps.model) cliModel = null;
2706
2796
  if (cliMaxTurns && !caps.maxTurns) cliMaxTurns = null;
2797
+ sessionFlags = buildMinimalSessionFlags(commandName, caps);
2707
2798
  }
2708
2799
  if (!resolvedBin && backend === "auto") {
2709
2800
  resolvedBackend = "api";
@@ -2725,6 +2816,8 @@ export async function runCommand(opts = {}) {
2725
2816
  logger,
2726
2817
  model: cliModel,
2727
2818
  maxTurns: cliMaxTurns,
2819
+ leaseHolder,
2820
+ sessionFlags,
2728
2821
  },
2729
2822
  });
2730
2823
  }
@@ -2767,7 +2860,7 @@ export async function runCommand(opts = {}) {
2767
2860
  // is a known Linux limitation at the OS level; closing it would require
2768
2861
  // a native module (e.g. prctl PR_SET_DUMPABLE) or process isolation.
2769
2862
  // See: issue #1370 for tracking this known limitation.
2770
- const handlers = getToolHandlers(cwd, issueFromArgs(args));
2863
+ const handlers = getToolHandlers(cwd, issueFromArgs(args), leaseHolder);
2771
2864
  const messages = [{ role: "user", content: userMessage }];
2772
2865
 
2773
2866
  // 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]
@@ -842,11 +842,18 @@ DEGRADED_CHECKS="${DEGRADED_CHECKS:-}"
842
842
  GATE_STATUS=$([ "$GATE_PASSED" != "true" ] && echo FAIL || { [ -n "$DEGRADED_CHECKS" ] && echo DEGRADED || echo PASS; })
843
843
  # Bind the PASS to what was actually gated: the staged tree is exactly the tree the V5 commit will have.
844
844
  # scripts/verify-phase-trail.sh --head-tree (work-on/review.md R1.5) rejects a PASS recorded for a different tree. <!-- Added: forge#3149 -->
845
- git -C {WORKTREE_PATH} add -u # same staging as the commit below; valid only because V2-V4 are done
846
- GATE_TREE=$(git -C {WORKTREE_PATH} write-tree)
847
- # Marker-only re-gate (invoked by work-on/review.md after a base sync or a stale re-review): HEAD is already committed and
848
- # the index is clean, so the gated tree is HEAD's tree. Use it instead of write-tree, and post the marker only (no V5 Step 2 commit).
849
- [ "${REGATE_MERGED_HEAD:-}" = "1" ] && GATE_TREE=$(git -C {WORKTREE_PATH} rev-parse 'HEAD^{tree}')
845
+ if [ "${REGATE_MERGED_HEAD:-}" = "1" ]; then
846
+ # Marker-only re-gate (invoked by work-on/review.md after a base sync or a stale re-review): HEAD is already committed, so the
847
+ # gated tree must be HEAD's tree. Never stage here: require a clean worktree so no uncommitted fix sits outside the marked tree.
848
+ if [ -n "$(git -C {WORKTREE_PATH} status --porcelain --untracked-files=no)" ]; then
849
+ echo "QUALITY_GATE: marker-only re-gate needs a clean worktree (uncommitted tracked changes) - commit the fix, re-run the scoped V1 gate on the committed head, then re-run this step; not posting a marker" >&2
850
+ exit 1
851
+ fi
852
+ GATE_TREE=$(git -C {WORKTREE_PATH} rev-parse 'HEAD^{tree}')
853
+ else
854
+ git -C {WORKTREE_PATH} add -u # same staging as the commit below; valid only because V2-V4 are done
855
+ GATE_TREE=$(git -C {WORKTREE_PATH} write-tree)
856
+ fi
850
857
  [ -n "$GATE_TREE" ] || { echo "QUALITY_GATE: tree unresolved - not posting a marker" >&2; exit 1; }
851
858
  QG_BODY="<!-- FORGE:QUALITY_GATE -->
852
859
  ## Quality Gate Result
@@ -860,7 +867,7 @@ QG_BODY="<!-- FORGE:QUALITY_GATE -->
860
867
  gh issue comment {NUMBER} {GH_FLAG} --body "$QG_BODY" # allowlist:check-command-side-effects
861
868
  ```
862
869
 
863
- **Marker-only re-gate mode** <!-- Added: forge#3983 -->: when `work-on/review.md` re-gates a merged head (Pre-Handoff Base Sync or stale re-review), it runs the scoped V1 gate on the committed head first, then sets `REGATE_MERGED_HEAD=1` and runs only this Step 1 with the real `GATE_RESULT` (FAIL stays FAIL). Steps 2-4 are skipped: there is nothing staged and no commit to make.
870
+ **Marker-only re-gate mode** <!-- Added: forge#3983 -->: when `work-on/review.md` re-gates a merged head (Pre-Handoff Base Sync or stale re-review), it runs the scoped V1 gate on the committed head first, then sets `REGATE_MERGED_HEAD=1` and runs only this Step 1 with the real `GATE_RESULT` (FAIL stays FAIL). Steps 2-4 are skipped: there is nothing staged and no commit to make. **Precondition**: a clean worktree (no uncommitted tracked changes, untracked files ignored); a dirty tracked file or staged change exits 1 with no marker, so a fix made after a FAIL must be committed and the scoped V1 gate re-run on the committed head before this step is re-run, keeping the gated tree equal to the marked `HEAD^{tree}`.
864
871
 
865
872
  ### V5 Step 2: Commit
866
873
 
@@ -977,7 +984,7 @@ fi
977
984
 
978
985
  # Find the FORGE:BUILDER comment posted by implement.md Phase I6
979
986
  # 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=""
987
+ 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
988
  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
989
 
983
990
  if [ -n "$BUILDER_COMMENT_ID" ]; then