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
@@ -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
@@ -85,7 +85,10 @@ ISSUE_LABELS=$(echo "$ISSUE_STATE" | jq -r '[.labels[].name] | join(",")')
85
85
  - **Stranded-state rule**: `workflow:remediating` must never outlive this run. Every exit that returns `BLOCKED` or `UNFIXABLE` (including exits before Phase M1; except a `RESUME_AT_M7` run, which leaves the issue at `workflow:awaiting-merge` untouched, and except a `deferred-live-run:` exit, which leaves every label untouched: a live run owns the issue) removes `workflow:remediating` and adds `needs-human`, because at that point a human really is needed:
86
86
 
87
87
  ```bash
88
- gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "needs-human" --remove-label "workflow:remediating" 2>/dev/null || true # allowlist:check-command-side-effects
88
+ # forge#3567: ensure the label, add first, then remove (a missing label cancels a whole edit)
89
+ gh label create "needs-human" --color "E4E669" --force {GH_FLAG} 2>/dev/null || true # allowlist:check-command-side-effects
90
+ gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
91
+ gh issue edit {ISSUE_NUMBER} {GH_FLAG} --remove-label "workflow:remediating" 2>/dev/null || true # allowlist:check-command-side-effects
89
92
  ```
90
93
 
91
94
  **Idempotency / resume check** — the paper trail lives on **both** the PR (primary — checked by the orchestrator's item 6.4 dispatch guard) and the linked issue (mirror — keeps `/work-on`'s standard FORGE-annotation trajectory and resume logic consistent with every other phase):
@@ -212,9 +215,11 @@ BLOCK_COMMENTS=$(gh api repos/{GH_REPO}/issues/{ISSUE_NUMBER}/comments \
212
215
  if [ "${DRY_RUN:-false}" = "true" ]; then
213
216
  echo "DRY_RUN: would replace needs-human / workflow:remediating with workflow:in-review on issue #{ISSUE_NUMBER}"
214
217
  else
215
- gh issue edit {ISSUE_NUMBER} {GH_FLAG} \
216
- --add-label "workflow:in-review" \
217
- --remove-label "needs-human,workflow:remediating" 2>/dev/null || true # <!-- allowlist:check-command-side-effects -->
218
+ # forge#3567: ensure label, add first, single-label removes
219
+ gh label create "workflow:in-review" --color "5319E7" --force {GH_FLAG} 2>/dev/null || true # <!-- allowlist:check-command-side-effects -->
220
+ gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "workflow:in-review" 2>/dev/null || true # <!-- allowlist:check-command-side-effects -->
221
+ gh issue edit {ISSUE_NUMBER} {GH_FLAG} --remove-label "needs-human" 2>/dev/null || true # <!-- allowlist:check-command-side-effects -->
222
+ gh issue edit {ISSUE_NUMBER} {GH_FLAG} --remove-label "workflow:remediating" 2>/dev/null || true # <!-- allowlist:check-command-side-effects -->
218
223
  fi
219
224
  ```
220
225
 
@@ -566,6 +571,7 @@ if [ "$(printf '%s' "$PR_LIVE" | jq -r '.state // ""' 2>/dev/null)" = "MERGED" ]
566
571
  case "$TIER" in
567
572
  universal) bash "$TRANSITION_SCRIPT" {ISSUE_NUMBER} {GH_FLAG} merged ;;
568
573
  prose|*)
574
+ gh label create "workflow:remediating" --color "C5DEF5" --force {GH_FLAG} 2>/dev/null || true # allowlist:check-command-side-effects
569
575
  gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "workflow:merged" --remove-label "workflow:awaiting-merge,needs-human,workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:remediating,workflow:invalid,workflow:decomposed" 2>/dev/null || true # allowlist:check-command-side-effects
570
576
  ;;
571
577
  esac
@@ -730,6 +736,7 @@ if [ "$MERGE_STATE" = "MERGED" ]; then
730
736
  case "$TIER" in
731
737
  universal) bash "$TRANSITION_SCRIPT" {ISSUE_NUMBER} {GH_FLAG} merged ;;
732
738
  prose|*)
739
+ gh label create "workflow:remediating" --color "C5DEF5" --force {GH_FLAG} 2>/dev/null || true # allowlist:check-command-side-effects
733
740
  gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "workflow:merged" --remove-label "workflow:awaiting-merge,needs-human,workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:remediating,workflow:invalid,workflow:decomposed" 2>/dev/null || true # allowlist:check-command-side-effects
734
741
  ;;
735
742
  esac
@@ -205,7 +205,7 @@ On conflict, resolve every file, keep both sides' intent, `git add`, `git commit
205
205
  **Re-gate the merged head** <!-- Added: forge#3983 -->: the `FORGE:QUALITY_GATE` PASS is bound to `HEAD^{tree}` (R1.5 `--head-tree`), so a merge that changes the tree invalidates it. After the merge and any conflict resolution, compare `PRE_SYNC_TREE` with the current `HEAD^{tree}` (fail closed if either cannot be resolved):
206
206
 
207
207
  - Unchanged: do nothing; never re-gate.
208
- - Changed: run the scoped quality gate on the merged head here (`work-on/build/validate` V1 loop over the PR's changed files), then post the marker via validate.md V5 Step 1 "Marker-only re-gate mode" (`**Tree**` = merged `HEAD^{tree}`, `**Commands run**` = what ran). Post the real result: on FAIL, fix and re-run within the validate bounds, else add `needs-human` and return `REVIEW_RESULT: status: BLOCKED`, blocker "post-sync quality gate failed"; never post PASS without a run.
208
+ - Changed: run the scoped quality gate on the merged head here (`work-on/build/validate` V1 loop over the PR's changed files), then post the marker via validate.md V5 Step 1 "Marker-only re-gate mode" (`**Tree**` = merged `HEAD^{tree}`, `**Commands run**` = what ran). Post the real result: on FAIL, fix, commit the fix (marker-only mode requires a clean worktree so the re-gated tree is the committed `HEAD^{tree}`), and re-run within the validate bounds, else add `needs-human` and return `REVIEW_RESULT: status: BLOCKED`, blocker "post-sync quality gate failed"; never post PASS without a run.
209
209
 
210
210
  ## Phase R1: Pre-Push Ancestry Guard
211
211
 
@@ -459,7 +459,9 @@ Use the existing PR number and continue.
459
459
  RESOLUTION=$(resolve_script 'transition-label'); TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
460
460
  case "$TIER" in
461
461
  adaptive|universal) bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} in-review ;;
462
- prose) gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:in-review" --remove-label "workflow:investigating" --remove-label "workflow:ready-to-build" --remove-label "workflow:building" --remove-label "workflow:awaiting-merge" --remove-label "workflow:remediating" --remove-label "workflow:merged" --remove-label "workflow:invalid" --remove-label "workflow:decomposed" 2>/dev/null || true # allowlist:check-command-side-effects ;;
462
+ prose) gh label create "workflow:remediating" --color "C5DEF5" --force {GH_FLAG} 2>/dev/null || true # allowlist:check-command-side-effects
463
+ gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:in-review" 2>/dev/null || true # allowlist:check-command-side-effects
464
+ gh issue edit {NUMBER} {GH_FLAG} --remove-label "workflow:investigating" --remove-label "workflow:ready-to-build" --remove-label "workflow:building" --remove-label "workflow:awaiting-merge" --remove-label "workflow:remediating" --remove-label "workflow:merged" --remove-label "workflow:invalid" --remove-label "workflow:decomposed" 2>/dev/null || true # allowlist:check-command-side-effects ;;
463
465
  esac
464
466
  ```
465
467
 
@@ -570,10 +572,13 @@ gh issue view {NUMBER} {GH_FLAG} --json state --jq '.state'
570
572
 
571
573
  - **Remediation-pending handoff (shared by the ci-gate, in-pr-fix and base-conflict cases below)** <!-- Added: forge#3541 -->: these three cases are autonomous pipeline work, not a human decision, so the issue must NOT carry `needs-human` while remediation is pending. `needs-human` is a human-gate signal (operators alert on it, and orchestrate holds DAG dependents as GATED on it). Mark the handoff with the non-human state `workflow:remediating` instead, and clear any `needs-human` that review-pr Phase 8 already set for the ci-gate refusal. `work-on:remediate` accepts either label (a bare `needs-human` from an older run still works), clears `workflow:remediating` on every exit, and re-asserts `needs-human` only when it cannot fix the PR. Run this block, then return `REVIEW_RESULT: status: NEXT`:
572
574
  ```bash
575
+ # forge#3567: ensure the label first, add before remove
576
+ gh label create "workflow:remediating" --color "C5DEF5" --force {GH_FLAG} 2>/dev/null || true # allowlist:check-command-side-effects
573
577
  RESOLUTION=$(resolve_script 'transition-label'); TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
574
578
  case "$TIER" in
575
579
  adaptive|universal) bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} remediating ;;
576
- prose) gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:remediating" --remove-label "workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:awaiting-merge,workflow:merged,workflow:invalid,workflow:decomposed" 2>/dev/null || true ;; # allowlist:check-command-side-effects
580
+ prose) gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:remediating" 2>/dev/null || true # allowlist:check-command-side-effects
581
+ gh issue edit {NUMBER} {GH_FLAG} --remove-label "workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:awaiting-merge,workflow:merged,workflow:invalid,workflow:decomposed" 2>/dev/null || true ;; # allowlist:check-command-side-effects
577
582
  esac
578
583
  gh issue edit {NUMBER} {GH_FLAG} --remove-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
579
584
  gh pr edit {PR_NUMBER} {GH_FLAG} --remove-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
@@ -590,7 +595,10 @@ gh issue view {NUMBER} {GH_FLAG} --json state --jq '.state'
590
595
  - **Bound already used** (including the re-invocation after a remediation that did not land): first read `max_severity=` from the latest trusted `FORGE:INPR_FIX` marker on the PR (`gh pr view {PR_NUMBER} {GH_FLAG} --json comments`, author-trusted as elsewhere in this phase; absent means MEDIUM). **HIGH/CRITICAL are never waived**: remove `workflow:remediating`, label the issue `needs-human`, leave the PR unmerged and return `REVIEW_RESULT: status: BLOCKED` with blocker "in-pr fix not landed (HIGH/CRITICAL, no waiver)". The `FORGE:INPR_FIX` work order stays on the PR for the human. For MEDIUM, waive the gate for the current head. Remove `needs-human` and `workflow:remediating` (restoring `workflow:in-review`), post `<!-- FORGE:INPR_FIX_WAIVED: head=<current head SHA> -->` on the **PR**, then re-invoke Phase R3 once under the `STALE_REREVIEW` bound. That review files the remaining findings as issues and merges as it would have before this gate. Only if `STALE_REREVIEW` is also exhausted, label the issue `needs-human`.
591
596
  ```bash
592
597
  INPR_WAIVE_HEAD=$(gh pr view {PR_NUMBER} {GH_FLAG} --json headRefOid --jq .headRefOid)
593
- gh issue edit {NUMBER} {GH_FLAG} --remove-label "needs-human,workflow:remediating" --add-label "workflow:in-review" 2>/dev/null || true # allowlist:check-command-side-effects
598
+ gh label create "workflow:in-review" --color "5319E7" --force {GH_FLAG} 2>/dev/null || true # allowlist:check-command-side-effects
599
+ gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:in-review" 2>/dev/null || true # allowlist:check-command-side-effects
600
+ gh issue edit {NUMBER} {GH_FLAG} --remove-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
601
+ gh issue edit {NUMBER} {GH_FLAG} --remove-label "workflow:remediating" 2>/dev/null || true # allowlist:check-command-side-effects
594
602
  gh pr comment {PR_NUMBER} {GH_FLAG} --body "<!-- FORGE:INPR_FIX_WAIVED: head=${INPR_WAIVE_HEAD} -->
595
603
  In-PR fix round did not land; the remaining CONFIRMED MEDIUM findings are filed as issues on the next review instead of blocking the merge." # allowlist:check-command-side-effects
596
604
  ```
@@ -21,7 +21,7 @@ Orchestrator for the full issue lifecycle: investigate → decompose (if needed)
21
21
 
22
22
  1. **This file is a router. Every phase runs as a forked sub-skill via `Skill(...)`; the router never executes phase logic itself.** Each phase sub-skill (`work-on:investigate`, `work-on:decompose`, `work-on:build`, `work-on:review`, `work-on:close`, `work-on:remediate`) declares `context: fork`, so Claude Code runs it in an isolated sub-agent context that holds only that phase's spec and the args you pass. The phase re-reads everything else from GitHub/git and returns exactly one `*_RESULT:` block — that block is all the router sees. Do not read phase spec files, do not re-implement a phase step here, and do not "help" a phase by posting its markers or labels. If a skill name does not resolve, STOP with "skill not found" (see Skill Name Resolution) — never run a phase inline.
23
23
 
24
- 1a. **Dispatching phases are invoked only by this router.** `work-on:review` and `work-on:remediate` spawn sub-agents (the `/review-pr` domain reviewers), and Claude Code only grants the `Agent` tool down to a fixed depth (see Depth Budget). Invoking them from here keeps them one level below the router, where `Agent` works. No phase may invoke them; a phase that needs one returns `status: NEXT` with `next: <phase>`, and the router runs it (Phase 4R). Non-dispatching phases may nest forks freely. <!-- forge#3398 -->
24
+ 1a. **Dispatching phases are invoked only by this router.** `work-on:review` and `work-on:remediate` spawn sub-agents (the `/review-pr` domain reviewers), and `work-on:build` spawns the build-time self-review reviewers (B5.6, forge#3971; it degrades to a recorded skip when `Agent` is withheld), and Claude Code only grants the `Agent` tool down to a fixed depth (see Depth Budget). Invoking them from here keeps them one level below the router, where `Agent` works. No phase may invoke them; a phase that needs one returns `status: NEXT` with `next: <phase>`, and the router runs it (Phase 4R). Non-dispatching phases may nest forks freely. <!-- forge#3398 -->
25
25
 
26
26
  2. **Route only on `*_RESULT` blocks and GitHub state.** After each phase returns, apply the routing table for that phase (below). Never infer success from a phase's prose; never continue past a `BLOCKED`/`FAILED` result.
27
27
 
@@ -103,11 +103,11 @@ Layers are counted below the main conversation (layer 0). Claude Code grants the
103
103
  |-----------------------------|-----------|-------------------|
104
104
  | 0 / 0 | solo `/work-on` router / `/orchestrate` | orchestrate: one worker per issue |
105
105
  | — / 1 | `/work-on` router (orchestrated worker) | no (`Skill` only) |
106
- | 1 / 2 | every phase invoked by the router, including `work-on:review` and `work-on:remediate` | review/remediate: `/review-pr` domain reviewers |
106
+ | 1 / 2 | every phase invoked by the router, including `work-on:build`, `work-on:review` and `work-on:remediate` | build: self-review reviewers (B5.6); review/remediate: `/review-pr` domain reviewers |
107
107
  | 2 / 3 | domain reviewers (leaves); build children (context, architect, implement, validate) | no |
108
108
  | 3 / 4 | quality gate (inside validate or review) | no |
109
109
 
110
- The only layer constraint that matters is the spawner: `work-on:review` and `work-on:remediate` must sit at layer ≤ 2, which Hard Rule 1a guarantees. The Phase 0A spawn-depth preflight checks the effective depth before any work starts.
110
+ The only layer constraint that matters is the spawner: `work-on:build`, `work-on:review` and `work-on:remediate` must sit at layer ≤ 2, which Hard Rule 1a guarantees. The Phase 0A spawn-depth preflight checks the effective depth before any work starts.
111
111
 
112
112
  ### Model and Effort Tiering — What Actually Applies
113
113
 
@@ -277,7 +277,7 @@ If detected, dispatch immediately and STOP — do NOT fall through to Phase 0B's
277
277
  Skill(skill="{FORGE_SKILL_PREFIX}work-on:remediate", args="${REMEDIATE_PR_NUMBER} ${REMEDIATE_ISSUE_FLAG} --repo {GH_REPO} --gh-flag {GH_FLAG}")
278
278
  ```
279
279
 
280
- **If `REMEDIATE_RESULT: status: REREVIEW_REQUIRED`** (forge#3240, fallback only since forge#3398: fix pushed, but remediation had no sub-agent dispatch tool, which cannot happen when the Phase 0A spawn-depth preflight passed and remediation is invoked from this router): run the re-review from this top-level session, which has dispatch, after the **claim pre-check** below — `Skill(skill="{FORGE_SKILL_PREFIX}review-pr", args="${REMEDIATE_PR_NUMBER} --auto-merge --issue ${ISSUE} --base ${PR_BASE} --gh-flag {GH_FLAG}")` (`ISSUE` and `PR_BASE` from the result and the PR) — never review inline. If it returns `REVIEW_RESULT: status: COMPLETE` (merged), invoke `Skill("{FORGE_SKILL_PREFIX}work-on:close", ...)` as remediate M8 does for `AUTO-LANDED`. If the claim cannot be recorded, the `review-pr` skill does not resolve, cannot dispatch, or returns anything other than `REVIEW_RESULT: status: COMPLETE` while the issue is still at `workflow:in-review` with no `needs-human` (no state anything will advance), apply the **terminal fallback**. Single attempt: no retry loop, no polling. Then STOP. <!-- Added: forge#3406; claim pre-check forge#3413 -->
280
+ **If `REMEDIATE_RESULT: status: REREVIEW_REQUIRED`** (forge#3240, fallback only since forge#3398: fix pushed, but remediation had no sub-agent dispatch tool, which cannot happen when the Phase 0A spawn-depth preflight passed and remediation is invoked from this router): run the re-review from this top-level session, which has dispatch, after the **claim pre-check** below — `Skill(skill="{FORGE_SKILL_PREFIX}review-pr", args="${REMEDIATE_PR_NUMBER} --auto-merge --issue ${ISSUE} --base ${PR_BASE} --gh-flag {GH_FLAG}")` (`ISSUE` and `PR_BASE` from the result and the PR) — never review inline. If it returns `REVIEW_RESULT: status: COMPLETE` (merged), invoke `Skill("{FORGE_SKILL_PREFIX}work-on:close", ...)` as remediate M8 does for `AUTO-LANDED`. If the claim cannot be recorded, the `review-pr` skill does not resolve, cannot dispatch, or returns anything other than `REVIEW_RESULT: status: COMPLETE` while the issue is still at `workflow:in-review` with no `needs-human` (no state anything will advance), apply the **terminal fallback**. **Base-conflict mapping (forge#3516)**: if `review-pr` returns `REVIEW_RESULT: status: BLOCKED` with a blocker containing `base-conflict`, it added no label (the base sync is pipeline work), so this router maps it before the claim release: re-read the labels first (closed, already `needs-human`, or no longer `workflow:in-review` means skip and report that state; idempotent); unless `DRY_RUN=true` (then record "would set workflow:remediating"), run `transition-label.sh ${ISSUE} {GH_FLAG} remediating` and re-read the labels to verify `workflow:remediating`. The next router pass (Phase 4R) runs the bounded `FORGE:BASESYNC_REMEDIATION` round via `work-on:remediate`. If the transition fails or is not verified, apply the **terminal fallback** (`needs-human`) and do not release the claim as settled. Never fall through to a manual merge. A `base-conflict` combined with other blockers is reported by the script as a `needs-human` outcome, so this mapping does not fire for it. Single attempt: no retry loop, no polling. Then STOP. <!-- Added: forge#3406; claim pre-check forge#3413 -->
281
281
 
282
282
  **Claim pre-check (forge#3413; one per-head marker shared with the orchestrator)**: the orchestrator's re-review handoff (`commands/orchestrate/phase-4-execution.md` Step 1/2) dedupes on `<!-- FORGE:REREVIEW_DISPATCHED -->`, so the router MUST write the SAME marker before `review-pr`, or the orchestrator re-dispatches after the router already re-reviewed. In order, failing closed (any failure = no dispatch, terminal fallback):
283
283
  1. **Labels first**: read the issue's state and labels (`gh issue view ${ISSUE} {GH_FLAG} --json labels,state`). If the issue is closed, already has `needs-human`, or lacks `workflow:in-review`, do NOT dispatch and do NOT apply the fallback (something else already advanced it); STOP.
package/docs/CONFIG.md CHANGED
@@ -481,6 +481,7 @@ engine:
481
481
  | `<key>_model` | string | No | Model alias (`sonnet`, `opus`, `haiku`) or full model id passed as `--model`. Invalid values are ignored. |
482
482
  | `<key>_max_turns` | integer | No | Positive integer passed as `--max-turns`. Invalid values are ignored. |
483
483
 
484
+ | `path` | string | No | `full` (default), `lean` or `auto`. The lean path (#4081, `bin/lean/`) runs validate, architect and implement in one warm builder session, deterministic gates, one independent reviewer and a no-model merge; `auto` uses it unless the risk tier is high and the diff is large. The `--path` flag overrides. `lean_reviewer_model` sets the reviewer model (default `claude-sonnet-5-5`); the builder uses `--model` or the CLI default. |
484
485
  | `ci_pending_max_minutes` | integer | No | Minutes the engine waits for CI when the review phase ends BLOCKED with "checks still pending", or the remediate phase holds at its M7 re-gate on "CI pending" (the merge gate's wait window ended while CI was still running). Default: 90. Invalid values are ignored. This key is flat like the others but is not part of the `<key>_model` / `<key>_max_turns` pattern. |
485
486
 
486
487
  **CI still pending**: the engine polls `scripts/wait-ci-green.sh` (no model calls) one read per poll on a 60 s, 2 min, 4 min, 5 min (cap) backoff with +/-20% jitter, while the lease keeps renewing, then re-dispatches the phase that was waiting, review or remediate (idempotent at an unchanged head: review re-runs the merge gate and merges; remediate resumes at its M7 re-gate). Before each poll it reads `gh api rate_limit` (not itself rate-limited) and, below 10% of the REST budget, sleeps until the window resets instead of polling; a rate-limited or transient (5xx, network) poll is retried and never re-dispatches the phase. Red CI takes the normal ci-gate path. When the budget is exhausted the run ends `needs-human` with outcome code `review.ci_pending_timeout`; `--retry` resumes. Each wait emits a `ci_pending_wait` telemetry event (`docs/TELEMETRY.md`) with the minutes waited, polls made and rate-limit sleeps.
@@ -493,6 +494,8 @@ engine:
493
494
 
494
495
  **Per-run scratch dir** (`tmp_root`): every engine child runs with `TMPDIR` and `CLAUDE_CODE_TMPDIR` set to `<tmp_root>/<repo-key>/<issue>` so venvs and Claude session data stay off a size-limited `/tmp`. `engine.tmp_root` is an absolute or `~`-prefixed path; default `~/.forge/tmp`. The `<repo-key>` is the checkout's directory name plus 8 hex characters of a hash of its real path, so the same issue number in two repos never shares a dir; the dir is created `0700` and an existing symlink or dir owned by another user is not used (the child keeps the inherited tmp). Old `<tmp_root>/<issue>` dirs from earlier versions are not migrated. When `tmp_root` is unset and the operator already exported `TMPDIR` or `CLAUDE_CODE_TMPDIR`, that choice is respected and never cleaned. The dir is removed on a terminal outcome and kept on `engine-error` so `--retry` resumes. A disk-full/quota failure (EDQUOT, ENOSPC, errno -28/-122) ends a retryable `engine-error` (outcome code `infra.disk_quota`), never `needs-human`.
495
496
 
497
+ **Minimal phase sessions** (forge#4065): on a supported CLI (`claude --version` >= 2.1.296, probed once and cached like the flags above) every engine phase (`work-on/*`) runs with `--strict-mcp-config --mcp-config '{"mcpServers":{}}'`, so operator MCP servers and connectors are not loaded. Phases whose spec never calls `Skill(...)` (`work-on/investigate`, `work-on/close`) also get `--disable-slash-commands`; skill-using phases keep skills. Operator settings are intentionally not skipped (no `--setting-sources`), because ForgeDock's user-scope PreToolUse/SubagentStop hooks must keep running. Set `FORGEDOCK_FULL_SESSION=1` to restore the previous full session (no flags). Other `forgedock run` commands (qa-sweep, analytics, geo-audit, autopilot) keep the full session because they may need MCP servers. An old or unparseable CLI version also omits the flags.
498
+
496
499
  **Commands that use this section**: CLI backend (`runCommand` in `bin/runner.mjs`)
497
500
 
498
501
  ---
@@ -823,7 +826,11 @@ import forms. `${{ github.* }}` expressions in `run:` scripts are passed to the
823
826
  substituted into the script text. CircleCI secondary containers run on a per-job docker network and publish only declared
824
827
  (or well-known default) ports on 127.0.0.1, never `--network host`.
825
828
  Passing results are cached by input hash under `~/.forge/ci-local/results` and reused across build iterations, lanes and
826
- issues on the same base.
829
+ issues on the same base. An identical check (command, cwd, shell, env, input hash) that appears in several jobs of one invocation
830
+ runs once; the later jobs reuse its result (`cache=dedup`, a failure is shared). Machine slots are granted in FIFO ticket order
831
+ across invocations (`~/.forge/ci-local.lock/queue`; tickets of dead processes are reaped), and a slot is taken only right before a
832
+ step really executes. Job lines, `CI_LOCAL_CHECK`, `CI_LOCAL_RESULT` and the `ci_local` telemetry report `wait_ms` (time queued
833
+ for a slot) separately from `duration_ms` (execution). Ready jobs run narrow/scoped first, then cheapest first (cached durations).
827
834
 
828
835
  **Environment** (all optional): `FORGE_CI_LOCAL_SLOTS` (machine-wide concurrent jobs across all runs, default
829
836
  `max(2, min(8, cpus/2))`; slots live under `~/.forge/ci-local.lock` and stale ones are reclaimed), `FORGE_CI_LOCAL_BUDGET_MINUTES`,
@@ -2,6 +2,27 @@
2
2
 
3
3
  Operator-facing notes for behavior changes that need action or awareness. Newest first.
4
4
 
5
+ ## Lean path engine (#4081)
6
+
7
+ `forgedock run-issue N --lane L --path lean|full|auto` (or `engine.path` in forge.yaml; default `full`) runs validate, architect and implement in one warm, minimal builder session, then deterministic gates (a test-file change unless docs-only, ci-local), one independent Sonnet reviewer (plus a security reviewer for auth, crypto, secret, permission, migration, payment or workflow paths), then PR, remote CI wait, merge and close with no model. Gate failures and blocking findings (CRITICAL/HIGH, or CONFIRMED MEDIUM introduced) resume the same builder session with `--resume`, at most 2 rounds each, then `needs-human`; a blocking round is followed by one delta re-review. Non-blocking findings are listed in `FORGE:REVIEW` and never filed. Each stage posts `FORGE:VALIDATE`, `FORGE:PLAN`, `FORGE:IMPLEMENT`, `FORGE:REVIEW`, `FORGE:CLOSE`; telemetry is one `phase_attempt` per stage plus `run_terminal`. `auto` goes lean unless the risk tier is high and the diff is large, in which case it falls back to `full`. Slow CI is polled with backoff and ends `ci-pending` (resumable), never `needs-human`. See docs/CONFIG.md (`engine.path`).
8
+
9
+ Action: none; the default path is unchanged.
10
+
11
+ ## v1.19.1: build-time self-review, ci-local under concurrency, review roster gates
12
+
13
+ - **Build-time self-review (B5.6, #3971).** After implement, the risk-tier review roster's domain agents review the staged diff, and the builder fixes CONFIRMED/LIKELY findings at MEDIUM or above in one repair round before validate. Small or docs-only diffs skip. Each build that runs it spends extra reviewer tokens, which the official review should save back through fewer findings and remediation rounds. Off switch: `FORGE_SELFREVIEW=off`. Wait budget: `SELFREVIEW_WAIT_SECS` (default 600, max 1800). Telemetry: `build_selfreview`.
14
+ - **ci-local (#4057).** The SEC-3 "every changed source reaches a test" check uses the step's own ecosystem tests, not only the step's candidates, so unrelated single-test steps are skipped instead of widened. Identical checks in several jobs run once per invocation (`cache=dedup`). Machine slots are granted in FIFO ticket order. Job lines and `ci_local_check` carry `wait_ms` apart from execution `duration_ms`. Jobs run shortest first.
15
+ - **Review roster.** The Auth path gate also matches common auth file names (session, token, permission, login, API key) (#3847). The Infra gate covers deploy scripts, entrypoints and `compose.yaml` (#3851). The Security-only fix-PR roster applies only to fix PRs of at most 150 changed lines with a known size; larger or unknown-size fix PRs get the standard roster (#3848).
16
+ - **Merge gate.** `review-finalize.sh verdict` derives OTHER_BLOCKING itself and persists it for `merge-gate`, so a blocking result that a base conflict or purpose regression does not explain can no longer be merged on a missing flag (#3514).
17
+
18
+ Action: none required. Expect more reviewer agents per build (self-review) and per large fix PR (roster cap).
19
+
20
+ ## Deferred follow-up lookup no longer duplicates issues (#3606)
21
+
22
+ `scripts/file-deferred-followups.sh` now finds an existing follow-up through the `FORGE:DEFERRED_ISSUE` claim marker it posts on the parent issue (REST comments, read-after-write consistent) before falling back to the lagging search index, searches closed issues as well as open ones (a closed follow-up is reopened), and after creating re-checks once: if a concurrent build filed a lower-numbered trusted follow-up, the items move to it and the newer issue is closed as a duplicate. Failed lookups still exit non-zero (`DEFERRED_LOOKUP=FAILED`).
23
+
24
+ Action: none. Output rows and the `DEFERRED_TELEMETRY` line are unchanged.
25
+
5
26
  ## Review specs call the prepare and finalize scripts (#3841)
6
27
 
7
28
  `commands/review-pr.md` is now under 100 KB: Phases 1-3B and 6 run through `scripts/review-prepare.sh` and `scripts/review-finalize.sh`, and `commands/review-pr-staging.md` reuses both (`review-prepare.sh --no-delta` in 0C, then `review-finalize.sh extract|classify|file-prepare|file-verify` with `--scope-files` and `--prior-prs` in Phase 7) instead of carrying its own template resolution, slicing, classifier and cap copies. A staging bundle whose API diff is too large is read from `origin/<base>...origin/<head>`. CI now runs `--fence-funcs` over `commands/review-pr*.md` and `--fence-state` over the staging spec. Engine: `close.detectOutcome` reports `merged` only for a CLOSED issue that also carries `workflow:merged` (a stale label on an open issue no longer counts).
package/docs/TELEMETRY.md CHANGED
@@ -37,8 +37,8 @@ is dropped. Payload keys are `[A-Za-z0-9_]+` and cannot shadow the shared keys;
37
37
  | `review_panel` | `review-pr.md` Phase 4 | `agents_selected`, `agents_completed`, `mode` = `FULL` \| `DELTA` \| `BASE_SYNC_ONLY` |
38
38
  | `ci_gate` | `scripts/wait-ci-green.sh` | `result` = `PASS` \| `FAIL` \| `ERROR` \| `TIMEOUT` \| `RATE_LIMITED` (rc 4: GitHub throttled or briefly unavailable for the whole window; retryable, never a verdict), `rc`, `checks` |
39
39
  | `ci_pending_wait` | the engine (`bin/engine/telemetry-seam.mjs`, one per finished CI-pending wait at the merge gate; `phase` = `review` \| `remediate`) | `pr`, `minutes_waited`, `result` = `green` \| `red` \| `error` \| `timeout`, `polls` (CI reads made), `rate_limit_sleeps` (polls that were rate-limited plus sleeps until the budget reset) |
40
- | `ci_local` | `scripts/ci-local.sh run` (validate V4.8), one per run | `result` = `PASS` \| `FAIL` \| `DEGRADED`, `jobs_run`, `jobs_deferred`, `jobs_failed`, `duration_ms`, `fix_iterations` (0 first run, 1 and 2 after fixes), `checks`, `cache_hits`, `narrowed` (checks run on a subset or skipped for no impact), `widened` (checks widened to the full command), `selected` / `total` (summed scope) |
41
- | `ci_local_check` | `scripts/ci-local.sh run`, one per scoped, widened or executed check (at most 20 per run) | `job`, `selector`, `selected`, `total`, `cache` = `hit` \| `miss` \| `na`, `duration_ms`, `widen` (reason or `none`). A remote-CI failure at merge on a check recorded here as narrowed is a selector bug |
40
+ | `ci_local` | `scripts/ci-local.sh run` (validate V4.8), one per run | `result` = `PASS` \| `FAIL` \| `DEGRADED`, `jobs_run`, `jobs_deferred`, `jobs_failed`, `duration_ms` (execution), `wait_ms` (time queued for a machine slot), `fix_iterations` (0 first run, 1 and 2 after fixes), `checks`, `cache_hits`, `narrowed` (checks run on a subset or skipped for no impact), `widened` (checks widened to the full command), `selected` / `total` (summed scope) |
41
+ | `ci_local_check` | `scripts/ci-local.sh run`, one per scoped, widened or executed check (at most 20 per run) | `job`, `selector`, `selected`, `total`, `cache` = `hit` \| `miss` \| `dedup` \| `na`, `duration_ms`, `wait_ms` (job slot wait), `widen` (reason or `none`). A remote-CI failure at merge on a check recorded here as narrowed is a selector bug |
42
42
  | `liveness` | `scripts/remediate-liveness.sh` | `decision` = `PROCEED` \| `DEFER` \| `EXHAUSTED`, `why` |
43
43
  | `phase_attempt` | the engine (`bin/engine/telemetry-seam.mjs`, one per phase attempt; `phase` = the phase id) | `attempt`, `model`, `turns`, `duration_ms`, `cost_usd`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens` (any value the runner did not report is omitted) |
44
44
  | `run_terminal` | the engine (`terminate()`, one per run segment) | `reason` (terminal reason), `code` (see `docs/OUTCOME-CODES.md`), `wall_ms` (this engine process, start to terminal) |
@@ -46,7 +46,8 @@ L3) works, which is why most PRs are reviewed normally.
46
46
  ## 3. Design rules
47
47
 
48
48
  **R1 — Dispatching phases are invoked only by the router.** A *dispatching* phase is one that spawns
49
- `Agent(...)` sub-agents directly or through `/review-pr`: `work-on:review` and `work-on:remediate`.
49
+ `Agent(...)` sub-agents directly or through `/review-pr`: `work-on:review`, `work-on:remediate` and, since forge#3971,
50
+ `work-on:build` (its B5.6 self-review dispatches the risk-tier roster's domain reviewers on the staged diff).
50
51
  Only the router may invoke them, so they always run at router layer + 1 (L2 under `/orchestrate`,
51
52
  L1 solo). When another phase needs one, it returns a handoff (`status: NEXT`, `next: <phase>`) and
52
53
  the router invokes it. Concretely: review returns `next: remediate` instead of calling remediation.
@@ -59,7 +60,8 @@ re-runs the missing phases. `/review-pr` (Phase 8, via `scripts/review-finalize.
59
60
 
60
61
  **R2 — Non-dispatching forks may nest.** Phases and children that never spawn sub-agents may invoke
61
62
  further forks at any depth (F5): build → context/architect/implement/validate → quality-gate,
62
- review → quality-gate / missing-phase re-dispatch, remediate → close. Forking them keeps each
63
+ review → quality-gate / missing-phase re-dispatch, remediate → close. The build children never spawn: the
64
+ self-review reviewers are spawned by `work-on:build` itself, and its repair round is an `implement` fork. Forking them keeps each
63
65
  context small. If one of them ever needs to spawn sub-agents, it becomes a dispatching phase and
64
66
  R1 applies.
65
67
 
@@ -99,13 +101,15 @@ phase hosting `/review-pr` cannot wait on a notification. Therefore:
99
101
  | Skill | Fork | Dispatches sub-agents | Invoked by | Layer solo / orchestrated |
100
102
  |-------|------|-----------------------|------------|---------------------------|
101
103
  | `work-on` (router) | — | no | user / orchestrator worker | 0 / 1 |
102
- | `work-on:investigate`, `work-on:decompose`, `work-on:build`, `work-on:close` | yes | no | router | 1 / 2 |
104
+ | `work-on:investigate`, `work-on:decompose`, `work-on:close` | yes | no | router | 1 / 2 |
105
+ | `work-on:build` (B5.6 self-review) | yes | **yes** (self-review reviewers; degrades to a recorded skip when `Agent` is withheld) | router only (R1) | 1 / 2 |
103
106
  | `work-on:build:{context,architect,implement,validate}` | yes | no | `work-on:build` (R2) | 2 / 3 |
104
107
  | `quality-gate` | yes | no | validate (R2) | 3 / 4 |
105
108
  | `quality-gate` (stale-review re-gate) | yes | no | review (R2) | 2 / 3 |
106
109
  | `work-on:review` → `/review-pr` | yes | **yes** | router only (R1) | 1 / 2 |
107
110
  | `work-on:remediate` → `/review-pr` | yes | **yes** | router only (R1) | 1 / 2 |
108
111
  | `/review-pr` domain reviewers | Agent | no (leaves) | `/review-pr` | 2 / 3 |
112
+ | build self-review reviewers (`FORGE:SELFREVIEW-AGENT:*`) | Agent | no (leaves) | `work-on:build` B5.6 | 2 / 3 |
109
113
 
110
114
  `/orchestrate` Step 4B item 6.4 dispatches a worker that runs `work-on <pr> --remediate`: worker L1,
111
115
  remediate L2, reviewers L3, same as the table.
@@ -114,6 +118,10 @@ remediate L2, reviewers L3, same as the table.
114
118
 
115
119
  `work-on:review` merges `origin/<base>` into the branch before the first push, so a PR is never born stale and a conflict is resolved while the build context is warm. Hand-maintained append-only registries (test lists, cron targets) are the usual conflict source; `scripts/detect-append-registries.sh` ranks them from history and proves a `.gitattributes` `merge=union` entry safe (`--check-union`). Union is allowed only for line-independent files, never YAML/JSON maps or ordered lists (see `.gitattributes`). `scripts/review-delta.sh` still treats a hand-resolved merge as not `BASE_SYNC_ONLY`, but accepts a merge whose tree is exactly what a mechanical `git merge-tree` of the same parents produces (a clean or union merge), so union registries take the no-panel fast path.
116
120
 
121
+ ### Build-time self-review (forge#3971)
122
+
123
+ `work-on:build` Phase B5.6 runs the risk-tier roster (`scripts/selfreview-plan.sh`, which calls `scripts/review-roster.sh` on the staged diff) before validate, with the same personas as `/review-pr` in SELF-REVIEW MODE (`commands/review-pr-agents/protocols.md`). It is not the official review: reviewers post `FORGE:SELFREVIEW-AGENT:{domain}` issue comments pinned to the staged tree (`Tree:`), never `FORGE:REVIEW-AGENT`, and `review-finalize.sh` / `verify-phase-trail.sh` ignore them. Like R6, completion is read from GitHub with a bounded wait, because reviewers spawned inside a fork report to the root session. A build at a layer where `Agent` is withheld, or any self-review failure, degrades to a recorded skip (`build_selfreview skipped=<reason>`) and never blocks the build.
124
+
117
125
  ## 5. Router routing
118
126
 
119
127
  | Phase result | Router action |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.19.0",
3
+ "version": "1.21.0",
4
4
  "description": "Autonomous development pipeline for coding agents. GitHub as institutional memory, with native Claude Code and OpenCode entrypoints.",
5
5
  "bin": {
6
6
  "forgedock": "bin/forgedock.mjs"
@@ -48,7 +48,7 @@
48
48
  "README.md"
49
49
  ],
50
50
  "scripts": {
51
- "test": "bash scripts/with-run-tmpdir.sh node --test \"bin/tests/**/*.test.mjs\" \"bin/engine/**/*.test.mjs\" \"bin/stats/**/*.test.mjs\" \"packages/protocol/test/**/*.test.mjs\"",
51
+ "test": "bash scripts/with-run-tmpdir.sh node --test \"bin/tests/**/*.test.mjs\" \"bin/engine/**/*.test.mjs\" \"bin/stats/**/*.test.mjs\" \"bin/lean/**/*.test.mjs\" \"packages/protocol/test/**/*.test.mjs\"",
52
52
  "test:sh": "bash scripts/run-shell-tests.sh",
53
53
  "docs:dev": "vitepress dev docs/site",
54
54
  "docs:build": "vitepress build docs/site",
@@ -15,7 +15,7 @@ ok() { PASS=$((PASS+1)); echo "ok - $1"; }
15
15
  bad() { FAILN=$((FAILN+1)); echo "FAIL - $1"; }
16
16
  eq() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (got '$2', want '$3')"; fi; }
17
17
 
18
- SELECT_JQ='[.[][] | 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)"))}]'
18
+ SELECT_JQ='[.[][] | 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)"))}]'
19
19
  DELETE_JQ='[.[] | select(.complete | not)] | if length == 1 and (.[0].other | not) then .[0].id else empty end'
20
20
 
21
21
  # c <id> <assoc> <type> <body>
@@ -28,7 +28,7 @@ PLAN=$'<!-- FORGE:ARCHITECT -->\n## Plan\n\n<!-- FORGE:ARCHITECT:COMPLETE -->'
28
28
  PARTIAL=$'<!-- FORGE:ARCHITECT -->\n## Plan (in progress)'
29
29
  INVEST=$'<!-- FORGE:INVESTIGATOR -->\nThe architect phase posts FORGE:ARCHITECT and FORGE:ARCHITECT:COMPLETE later.'
30
30
  CTX=$'<!-- FORGE:CONTEXT -->\nmentions <!-- FORGE:ARCHITECT -->'
31
- MIXED=$'<!-- FORGE:ARCHITECT -->\nquoted <!-- FORGE:INVESTIGATOR -->'
31
+ MIXED=$'<!-- FORGE:ARCHITECT -->\nquoted:\n<!-- FORGE:INVESTIGATOR -->'
32
32
 
33
33
  eq "INVESTIGATOR mentioning FORGE:ARCHITECT is not selected" "$(arch "$(c 1 NONE Bot "$INVEST")" | jq length)" 0
34
34
  eq "INVESTIGATOR mentioning FORGE:ARCHITECT is never deleted" "$(del_id "$(c 1 NONE Bot "$INVEST")")" ""
@@ -39,10 +39,19 @@ eq "single partial plan -> delete its id" "$(del_id "$(c 4 NONE Bot "$PARTIAL")"
39
39
  eq "two partial plans -> delete nothing" "$(del_id "$(c 4 NONE Bot "$PARTIAL")"$'\n'"$(c 5 NONE Bot "$PARTIAL")")" ""
40
40
  eq "untrusted author partial ignored" "$(del_id "$(c 6 NONE User "$PARTIAL")")" ""
41
41
  eq "untrusted author complete ignored" "$(n_complete "$(c 6 NONE User "$PLAN")")" 0
42
- eq "partial carrying another FORGE marker is not deleted" "$(del_id "$(c 7 NONE Bot "$MIXED")")" ""
42
+ eq "partial with another FORGE marker at a line start is not deleted" "$(del_id "$(c 7 NONE Bot "$MIXED")")" ""
43
43
  eq "investigator + partial plan -> only the plan id" "$(del_id "$(c 1 NONE Bot "$INVEST")"$'\n'"$(c 8 NONE Bot "$PARTIAL")")" 8
44
44
  eq "paginated concatenated arrays" "$(printf '[%s]\n[%s]' "$(c 1 NONE Bot "$INVEST")" "$(c 9 NONE Bot "$PLAN")" | jq -s "$SELECT_JQ" | jq '[.[] | select(.complete)] | length')" 1
45
45
 
46
+ INLINE=$'<!-- FORGE:ARCHITECT -->\n## Approach\nFollowed the plan in `<!-- FORGE:BUILDER -->` and <!-- FORGE:INVESTIGATOR --> notes.'
47
+ CRLF=$'<!-- FORGE:ARCHITECT -->\r\n<!-- FORGE:BUILDER -->\r\nbody'
48
+ eq "partial quoting a marker inline is picked and deleted e1_inline_picked" "$(del_id "$(c 11 NONE Bot "$INLINE")")" 11
49
+ eq "inline quoting partial is selected as non-other e1_inline_not_other" "$(arch "$(c 11 NONE Bot "$INLINE")" | jq -r '.[0].other')" false
50
+ eq "marker at the start of a later line is refused e2_line_start_refused" "$(arch "$(c 12 NONE Bot "$MIXED")" | jq -r '.[0].other')" true
51
+ eq "own first-line marker never counts as other e4_own_marker_not_other" "$(arch "$(c 13 NONE Bot "$PARTIAL")" | jq -r '.[0].other')" false
52
+ eq "two inline-quoting partials still delete nothing e3_two_partials_none" "$(del_id "$(c 11 NONE Bot "$INLINE")"$'\n'"$(c 14 NONE Bot "$INLINE")")" ""
53
+ eq "CRLF body with a marker line is still refused e5_crlf_refused" "$(del_id "$(c 15 NONE Bot "$CRLF")")" ""
54
+
46
55
  # Spec drift guard: the spec carries this exact predicate and no unanchored selector.
47
56
  ! grep -qF 'contains("FORGE:ARCHITECT")' "$SPEC" && ok "architect.md has no unanchored contains(FORGE:ARCHITECT)" || bad "architect.md still uses unanchored contains"
48
57
  grep -qF 'startswith("<!-- FORGE:ARCHITECT -->")' "$SPEC" && ok "architect.md anchors with startswith" || bad "architect.md lacks the startswith anchor"