forgedock 1.10.2 → 1.11.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 (47) hide show
  1. package/bin/forgedock.mjs +9 -0
  2. package/commands/ci-audit.md +3 -3
  3. package/commands/compat-audit.md +1 -1
  4. package/commands/deploy-pr.md +6 -6
  5. package/commands/explain.md +2 -2
  6. package/commands/fix-ci.md +2 -2
  7. package/commands/forge-stats.md +1 -1
  8. package/commands/forgedock-init.md +3 -3
  9. package/commands/optimize.md +5 -5
  10. package/commands/orchestrate/phase-1-resolve.md +2 -2
  11. package/commands/orchestrate/phase-4-execution.md +232 -28
  12. package/commands/quality-gate.md +1 -1
  13. package/commands/recover-orphans.md +6 -6
  14. package/commands/review-pr-agents/protocols.md +1 -0
  15. package/commands/review-pr-staging.md +28 -6
  16. package/commands/review-pr.md +434 -120
  17. package/commands/security-audit.md +1 -1
  18. package/commands/spec-doctor.md +2 -2
  19. package/commands/test-gate.md +7 -7
  20. package/commands/work-on/build/architect.md +21 -0
  21. package/commands/work-on/build/context.md +1 -1
  22. package/commands/work-on/build/implement.md +17 -0
  23. package/commands/work-on/build/validate.md +2 -2
  24. package/commands/work-on/build.md +98 -9
  25. package/commands/work-on/close.md +33 -11
  26. package/commands/work-on/decompose.md +19 -5
  27. package/commands/work-on/investigate.md +34 -4
  28. package/commands/work-on/remediate.md +32 -8
  29. package/commands/work-on/review.md +10 -8
  30. package/commands/work-on.md +21 -10
  31. package/docs/CONFIG.md +30 -0
  32. package/docs/RELEASE-NOTES.md +9 -0
  33. package/docs/WORK-ON-RUNTIME.md +22 -2
  34. package/docs/site/architecture.md +1 -1
  35. package/docs/spec/forge-protocol-v1.md +10 -0
  36. package/docs/spec/review-protocol.md +1 -0
  37. package/package.json +1 -1
  38. package/packages/protocol/src/types.js +17 -0
  39. package/scripts/check-spec-bash.sh +45 -2
  40. package/scripts/check-spec-bash.test.sh +24 -1
  41. package/scripts/check-spec-markers.sh +53 -0
  42. package/scripts/diff-size.sh +84 -0
  43. package/scripts/diff-size.test.sh +117 -0
  44. package/scripts/forge-root.test.sh +15 -0
  45. package/scripts/remediation-marker-match.test.sh +51 -0
  46. package/scripts/review-pr-arguments.test.sh +180 -0
  47. package/scripts/trusted-comments.test.sh +23 -0
@@ -196,7 +196,7 @@ while IFS= read -r f; do
196
196
  awk '
197
197
  /trivy|snyk|owasp|dependency.check|gitleaks|semgrep|gosec|bandit/ {
198
198
  scanner_line = NR
199
- scanner_name = $0
199
+ scanner_name = $(0)
200
200
  }
201
201
  /continue-on-error:\s*true/ && NR - scanner_line <= 5 && scanner_line > 0 {
202
202
  print FILENAME ":" NR ": continue-on-error: true near security scanner at line " scanner_line
@@ -133,12 +133,12 @@ cat /tmp/spec_ranked.txt | head -10
133
133
  ```bash
134
134
  if [ "$SELECT_MODE" = "auto" ]; then
135
135
  # Pick the top-ranked spec file with ≥ 5 findings
136
- TARGET_SPEC=$(awk '$1 >= 5 {print $2; exit}' /tmp/spec_ranked.txt || echo "")
136
+ TARGET_SPEC=$(awk '$(1) >= 5 {print $(2); exit}' /tmp/spec_ranked.txt || echo "")
137
137
  if [ -z "$TARGET_SPEC" ]; then
138
138
  echo "No spec file has ≥5 findings in the last 30 days. Top offender:"
139
139
  head -1 /tmp/spec_ranked.txt
140
140
  # Use top offender even if below threshold for explicit runs
141
- TARGET_SPEC=$(awk 'NR==1{print $2}' /tmp/spec_ranked.txt || echo "")
141
+ TARGET_SPEC=$(awk 'NR==1{print $(2)}' /tmp/spec_ranked.txt || echo "")
142
142
  if [ -z "$TARGET_SPEC" ]; then
143
143
  echo "ERROR: No finding data available. Run pipeline-health first to accumulate findings."
144
144
  exit 1
@@ -65,9 +65,9 @@ BASE_BRANCH="${DEFAULT_BRANCH}"
65
65
 
66
66
  # Parse --prs and --base from $ARGUMENTS
67
67
  while [[ "$@" ]]; do
68
- case "$1" in
69
- --prs) shift; BUNDLE_PRS="$1";;
70
- --base) shift; BASE_BRANCH="$1";;
68
+ case "${1}" in
69
+ --prs) shift; BUNDLE_PRS="${1}";;
70
+ --base) shift; BASE_BRANCH="${1}";;
71
71
  esac
72
72
  shift
73
73
  done
@@ -630,11 +630,11 @@ echo ""
630
630
 
631
631
  # Helper: classify a single criterion line into one of four buckets:
632
632
  # covered, uncovered, untestable, manual
633
- # Arguments: $1 = criterion text, $2 = source_issue, $3 = source_pr
633
+ # Arguments: ${1} = criterion text, ${2} = source_issue, ${3} = source_pr
634
634
  classify_criterion() {
635
- local criterion="$1"
636
- local source_issue="$2"
637
- local source_pr="$3"
635
+ local criterion="${1}"
636
+ local source_issue="${2}"
637
+ local source_pr="${3}"
638
638
 
639
639
  # --- Step 1: Detect explicitly manual criteria ---
640
640
  if echo "$criterion" | grep -qP '\[type:manual\]'; then
@@ -562,6 +562,19 @@ If no downstream phases are found (the change is additive only — new sections,
562
562
 
563
563
  ---
564
564
 
565
+ ## Phase A2.7: Pattern Sweep Intake *(conditional — skip silently when the investigation report has no `### Pattern Sweep` section)* <!-- Added: forge#3449 -->
566
+
567
+ For `review-finding` issues the investigator records a `### Pattern Sweep` table (`Query | Hit | Disposition`, one row per hit) listing every instance of the finding's defect class. Treat it as the seed for the plan:
568
+
569
+ 1. Extract the section from `{INVESTIGATION_REPORT}` (the text between `### Pattern Sweep` and the next `###` heading). If it says `Pattern sweep skipped`, note that in the plan and continue.
570
+ 2. Every hit with disposition `fix` becomes a **required row** in `### Affected Paths` — the plan must cover the whole class, not only the cited instance. A `truncated at 30` row means the sweep was capped: re-run the query yourself with a higher cap before sequencing.
571
+ 3. Every `not-affected` hit needs its justification carried into the plan; if the reason does not hold after you trace the code, promote it to `fix`.
572
+ 4. Add a Consistency Check: "all `fix` rows in the Pattern Sweep are changed and the test exercises a representative set of instances, not only the reviewer's repro".
573
+
574
+ The comment below passes the table through in a `### Pattern Sweep` section so the builder reads it from the plan. Absent table means this phase is a no-op.
575
+
576
+ ---
577
+
565
578
  ## Phase A3: Consistency Rules
566
579
 
567
580
  Identify invariants that ALL affected paths must satisfy. These become the builder's consistency checklist.
@@ -677,6 +690,13 @@ gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:ARCHITECT -->
677
690
  |---|------|----------------|-----------------|-----|
678
691
  | 1 | {FILE} | {FUNCTION} | {CHANGE} | {REASON} |
679
692
 
693
+ ### Pattern Sweep
694
+ <!-- Pass-through from the FORGE:INVESTIGATOR '### Pattern Sweep' table (Phase A2.7).
695
+ Omit this section when the investigation report had none. -->
696
+ | Query | Hit | Disposition |
697
+ |-------|-----|-------------|
698
+ | {QUERY} | {FILE:LINE} | {fix \| not-affected — reason} |
699
+
680
700
  ### Implementation Order
681
701
  1. {FIRST_CHANGE} — {WHY_FIRST}
682
702
  2. {SECOND_CHANGE} — {WHY_SECOND}
@@ -780,6 +800,7 @@ B4 → [THIS MODULE] Architecture Planning (FORGE:ARCHITECT comment)
780
800
  Phase A1: Read Entry Points (A1.5 Prior Decision Injection, A1.6 Route-Tree Classification)
781
801
  Phase A2: Trace the Data Flow (A2.1 Runtime UID x Volume Ownership, A2.2 Gate-Condition Caller Sweep)
782
802
  Phase A2.5: Pipeline Phase-Dependency Check
803
+ Phase A2.7: Pattern Sweep Intake (review-finding issues only)
783
804
  Phase A3: Consistency Rules
784
805
  Phase A4: Sequence the Implementation
785
806
  Phase A5: Risk Assessment
@@ -970,7 +970,7 @@ EXISTING_JSON=$(set -o pipefail; gh api "repos/{GH_REPO}/issues/{NUMBER}/comment
970
970
  or ((.user.type // "") == "Bot")
971
971
  or ((.user.login // "") as $x | $L | index($x) != null)
972
972
  )] | length') || LOOKUP_FAILED=1
973
- EXISTING_CONTEXT=$(printf '%s\n' "$EXISTING_JSON" | awk '{s+=$1} END {print s+0}')
973
+ EXISTING_CONTEXT=$(printf '%s\n' "$EXISTING_JSON" | awk '{s+=$(1)} END {print s+0}')
974
974
  if [ "${DRY_RUN:-false}" = "true" ]; then
975
975
  echo "[DRY_RUN] would post minimal FORGE:CONTEXT marker on #{NUMBER}"
976
976
  elif [ "$LOOKUP_FAILED" -eq 1 ]; then
@@ -136,6 +136,7 @@ Work in `{WORKTREE_PATH}`. Follow the contract deliverables table exactly — im
136
136
  - **Cross-lane import guard**: Before adding any `import` or `from X import Y` statement for a service-internal module (`app.*`), verify the module exists on the PR's base branch (`{PR_BASE}`) — NOT just on your local disk or a milestone branch. Run `git show origin/{PR_BASE}:{module_path}.py` (replacing dots with slashes) to confirm. If the module only exists on a milestone branch, do NOT import it — find an alternative implementation or make the import conditional with a `try/except ImportError` fallback. A milestone-only import on a fast-lane PR will crash production on every request with `ModuleNotFoundError`. <!-- Added: forge#277 -->
137
137
  - **Deliverable-type consistency check**: Before committing, compare the actual output against the CONTRACT's deliverable list. If the CONTRACT explicitly states "no code changes required", "docs only", or "configuration update only" AND the diff introduces new executable files (`.py`, `.js`, `.ts`, `.sh` — not test, config, or documentation files), STOP. Do NOT commit. Re-read the CONTRACT, the investigation recommendation, and the ARCHITECT plan. If all three agree that code is needed, update the CONTRACT comment to reflect the new deliverable type before proceeding. If only the builder decided to add code without contract support, discard the code change and implement the contracted deliverable instead. <!-- Added: forge#279 -->
138
138
  - **Pipeline check documentation — generalization rule**: When writing or updating pipeline check documentation (in `commands/*.md`), describe the **bug class**, not a specific incident. Do NOT embed: PR numbers, issue numbers, run IDs, timestamps, function names, dollar amounts, or multi-sentence incident timelines in check prose or `**Evidence**:` blocks. One brief HTML comment `<!-- Added: forge#NNN -->` is acceptable per check for traceability. `**Evidence**:` blocks must describe the vulnerability pattern (what the class of bug looks like, why it's dangerous) — not narrate a single historical occurrence. CHANGELOG entries may reference originating issues; command prompt text must not.
139
+ - **Pattern sweep — class-wide fix and test**: When the FORGE:ARCHITECT plan or FORGE:INVESTIGATOR report contains a `### Pattern Sweep` table (`Query | Hit | Disposition`), fix EVERY hit with disposition `fix`, not only the instance the reviewer cited. Write the test against a representative set of instances from the table, not the reviewer's single repro, so it fails for any sibling still carrying the defect. Do not tune a rule, matcher, or fixture to the cited example. If a `fix` row cannot be changed, or a `not-affected` row turns out to be affected, say so in the `### Approach` of the FORGE:BUILDER comment instead of dropping it silently. Absent table = no-op. <!-- Added: forge#3449 -->
139
140
  - **Endpoint response contract consumer tracing**: When changing an endpoint's response body shape or status field values (e.g., changing `"status": "healthy"` to `"status": "ok"`, renaming response keys, removing fields), grep the full repo for ALL consumers of that response body before committing. Consumers are not limited to the service being changed — they include deploy scripts, CI health checks, monitoring configs, docker-compose healthcheck definitions, Traefik probes, and any script that parses or pattern-matches on the response body. Run: `grep -rn "{old_value}\|{endpoint_path}" scripts/ infra/ .github/ docker-compose*.yml traefik/ 2>/dev/null`. All consumers whose behavior depends on the old response format MUST be updated in the same PR — a response contract change that updates only one consumer while leaving others on the old format is a deploy-time breakage. <!-- Added: forge#321 -->
140
141
  - If you discover the contract is wrong (e.g. a file doesn't exist, a function has a different signature): STOP, post a comment on the issue explaining the discrepancy, add label `needs-human`, and EXIT with `IMPLEMENT_RESULT: status: BLOCKED` and a `blocker` describing the discrepancy
141
142
 
@@ -269,6 +270,20 @@ git add {CHANGED_FILES}
269
270
 
270
271
  Do NOT run `git commit` here. The commit (with conventional commit message and issue reference) is made by validate.md Phase V5 after `GATE_PASSED=true`.
271
272
 
273
+ **Informational diff size** <!-- Added: forge#3450 -->: after staging, read the changed-line count so the builder comment and result carry it. This is a hint only. Implement never gates or decides: the diff-size gate lives in build B5.5, which re-measures authoritatively. Leave `DIFF_LINES` empty when the script is unavailable or fails.
274
+
275
+ ```bash
276
+ # <Script resolution block from work-on/build.md, verbatim>
277
+ SCRIPT_REF=$(resolve_script 'diff-size')
278
+ DIFF_LINES=""
279
+ case "$SCRIPT_REF" in
280
+ prose:*) ;;
281
+ *) DIFF_LINES=$(bash "${SCRIPT_REF#*:}" --repo-path "{WORKTREE_PATH}" --base "{PR_BASE}" 2>/dev/null | sed -n 's/^diff_lines=//p' | head -n 1) || DIFF_LINES="" ;;
282
+ esac
283
+ case "$DIFF_LINES" in ''|*[!0-9]*) DIFF_LINES="" ;; esac
284
+ echo "DIFF_LINES=${DIFF_LINES}"
285
+ ```
286
+
272
287
  ---
273
288
 
274
289
  ## Phase I5: Update Issue Body
@@ -293,6 +308,7 @@ gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:BUILDER -->
293
308
  **Branch**: \`{BRANCH}\`
294
309
  **Commits**: {COMMIT_SHA(S)}
295
310
  **Files changed**: {COUNT}
311
+ **Diff lines**: {DIFF_LINES or —}
296
312
 
297
313
  ### Approach
298
314
  {One paragraph: what was built, key decisions, why this approach over alternatives}
@@ -325,6 +341,7 @@ IMPLEMENT_RESULT:
325
341
  branch: {BRANCH}
326
342
  commits: [{SHA}, ...]
327
343
  files_changed: [{file}, ...]
344
+ diff_lines: {N — informational, from scripts/diff-size.sh after I4; empty when unavailable}
328
345
  comment_url: {url of FORGE:BUILDER comment}
329
346
  blocker: {description if status=BLOCKED}
330
347
  ```
@@ -161,7 +161,7 @@ SKIPPED_CHECKS=""
161
161
  # formatter, typechecker, or build cannot block validation indefinitely.
162
162
  # Set FORGEDOCK_VERIFICATION_TIMEOUT_SECONDS to override the 120-second default.
163
163
  run_verification_command() {
164
- local label="$1" command="$2"
164
+ local label="${1}" command="${2}"
165
165
  local timeout_seconds="${FORGEDOCK_VERIFICATION_TIMEOUT_SECONDS:-120}"
166
166
  if ! [[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]]; then
167
167
  echo "VERIFY-CONFIG | HIGH | timeout | FORGEDOCK_VERIFICATION_TIMEOUT_SECONDS must be a positive integer (got '$timeout_seconds')"
@@ -245,7 +245,7 @@ KNOWN_SLOW_TESTS=$(yq -o=json -I=0 '.verification.known_slow_tests // []' "$FORG
245
245
  # skip it entirely. Matching is substring match of `pattern` against the full
246
246
  # command text. Exactly one of skip/subset is expected per matched entry.
247
247
  apply_known_slow_filter() {
248
- local cmd="$1"
248
+ local cmd="${1}"
249
249
  local out="$cmd"
250
250
  if [ -n "$KNOWN_SLOW_TESTS" ] && [ "$KNOWN_SLOW_TESTS" != "[]" ] && [ "$KNOWN_SLOW_TESTS" != "null" ]; then
251
251
  while IFS= read -r entry; do
@@ -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 in order. 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) in order. 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
 
@@ -82,7 +82,7 @@ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac
82
82
  # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
83
83
  _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
84
84
  # newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
85
- _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
85
+ _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
86
86
  _m="$HOME/.claude/plugins/marketplaces/$_mk"
87
87
  # '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
88
88
  _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
@@ -101,7 +101,7 @@ UNIVERSAL_DIR="${FORGE_ROOT:+$FORGE_ROOT/scripts}" # empty => tier 3 skipped,
101
101
  # to search the filesystem. <!-- Added: forge#1984 -->
102
102
 
103
103
  resolve_script() {
104
- local operation="$1"
104
+ local operation="${1}"
105
105
  # Tier 2: per-repo adaptive (skip if disabled)
106
106
  if [ "$ADAPTIVE_ENABLED" != "false" ] && [ -f "${ADAPTIVE_DIR}/${operation}.sh" ]; then
107
107
  echo "adaptive:${ADAPTIVE_DIR}/${operation}.sh"
@@ -125,7 +125,7 @@ Every exit path of this skill — COMPLETE, ALREADY_DONE, INVESTIGATION_COMPLETE
125
125
 
126
126
  ```
127
127
  BUILD_RESULT:
128
- status: COMPLETE | ALREADY_DONE | INVESTIGATION_COMPLETE | BLOCKED
128
+ status: COMPLETE | ALREADY_DONE | INVESTIGATION_COMPLETE | NEEDS_DECOMPOSE | BLOCKED
129
129
  branch: {BRANCH}
130
130
  worktree: {WORKTREE_PATH}
131
131
  blocker: {description if status=BLOCKED, else empty}
@@ -509,13 +509,101 @@ Skill(skill="{FORGE_SKILL_PREFIX}work-on:build:implement", args="{NUMBER} --repo
509
509
  ```
510
510
 
511
511
  **After subcommand returns** (read its `IMPLEMENT_RESULT:` block):
512
- - `status: COMPLETE` → continue to B6
513
- - `status: ALREADY_DONE` → continue to B6 (validate what's already there)
514
- - `status: INVESTIGATION_COMPLETE` → issues were created as deliverables and the original closed; print `BUILD_RESULT: status: INVESTIGATION_COMPLETE` (skip B6/B6.5)
512
+ - `status: COMPLETE` → continue to B5.5
513
+ - `status: ALREADY_DONE` → continue to B5.5 (size-check what's already there)
514
+ - `status: INVESTIGATION_COMPLETE` → issues were created as deliverables and the original closed; print `BUILD_RESULT: status: INVESTIGATION_COMPLETE` (skip B5.5/B6/B6.5)
515
515
  - `status: BLOCKED` → Blocked exit with the child's `blocker`
516
516
  - Skill not found → Blocked exit, blocker "skill not found: work-on:build:implement"
517
517
  - Returned running/backgrounded/empty (no `IMPLEMENT_RESULT:`) → do not end the turn to wait; re-read the `FORGE:BUILDER` comment and the worktree state; if `FORGE:BUILDER:COMPLETE` is present consume it, and if the worktree shows in-flight edits or a fresh commit treat the child as possibly live; before re-invoking, apply the bounded re-invoke rule (at most 2, then `child-stalled: implement`)
518
- # MUST CONTINUE to Phase B6 — implement result is intermediate, NOT terminal (validation still required).
518
+ # MUST CONTINUE to Phase B5.5 — implement result is intermediate, NOT terminal (the size gate and validation still follow).
519
+
520
+ ---
521
+
522
+ ## Phase B5.5: Diff-Size Gate
523
+
524
+ Review findings per PR rise steeply with diff size, so an oversized diff is split before it is validated. This phase measures the staged diff (implement staged everything in I4; nothing is committed until validate V5) and either continues, routes to decompose, or requires a justified override. It never edits code. <!-- Added: forge#3450 -->
525
+
526
+ **Skip** (continue to B6) when the task type is Investigation (B5 already exited), or when `build.diff_size.threshold` resolves to `0`. Never skip because a lookup failed: unknown is not under threshold.
527
+
528
+ **Step 1 — measure.** Paste the Script resolution block first (shell state does not persist); quote every interpolation:
529
+
530
+ ```bash
531
+ # <Script resolution block from above goes here, verbatim>
532
+ git -C "{WORKTREE_PATH}" fetch origin "{PR_BASE}" 2>/dev/null || true # fresh origin/base for the merge-base measurement
533
+ THRESHOLD=$(yq '.build.diff_size.threshold // 1000' forge.yaml 2>/dev/null || echo 1000)
534
+ EXCLUDES=()
535
+ while IFS= read -r _g; do [ -n "$_g" ] && [ "$_g" != "null" ] && EXCLUDES+=(--exclude-glob "$_g"); done \
536
+ < <(yq '.build.diff_size.exclude_globs // [] | .[]' forge.yaml 2>/dev/null || true)
537
+ SCRIPT_REF=$(resolve_script 'diff-size')
538
+ case "$SCRIPT_REF" in
539
+ prose:*) echo "SIZE_GATE: scripts/diff-size.sh unresolved (prose tier) — gate skipped with a note in the FORGE:BUILDER comment" ;;
540
+ *)
541
+ SIZE_OUT=$(bash "${SCRIPT_REF#*:}" --repo-path "{WORKTREE_PATH}" --base "{PR_BASE}" --threshold "$THRESHOLD" ${EXCLUDES[@]+"${EXCLUDES[@]}"}) && SIZE_RC=0 || SIZE_RC=$?
542
+ echo "SIZE_RC=${SIZE_RC}"; printf '%s\n' "$SIZE_OUT" ;;
543
+ esac
544
+ ```
545
+
546
+ - Prose tier (script unresolvable) → skip the gate, continue to B6 (the script is part of the install; a missing install must not strand every build).
547
+ - Exit 2, empty output, or output without `diff_lines=`, `threshold=` and `over=` → Blocked exit with blocker `size-gate-unavailable: diff-size.sh failed (rc=<N>)`. Do NOT treat an unmeasurable diff as under threshold.
548
+ - Otherwise parse `diff_lines`, `excluded_lines`, `threshold`, `over` and the `top=` lines.
549
+
550
+ **Step 2 — look up an override** (only when `over=true`). Both lookups are anchored, trust-filtered through `scripts/trusted-comments.sh` (trusted association, Bot, or `FORGE_TRAIL_TRUSTED_LOGINS`), and fail closed:
551
+
552
+ ```bash
553
+ # <Script resolution block from above goes here, verbatim>
554
+ set -o pipefail
555
+ TRUSTED_SCRIPT="${UNIVERSAL_DIR:+$UNIVERSAL_DIR/trusted-comments.sh}"
556
+ [ -n "$TRUSTED_SCRIPT" ] && [ -f "$TRUSTED_SCRIPT" ] || { echo "OVERRIDE_LOOKUP=FAILED (trusted-comments.sh unresolved)"; exit 0; }
557
+ OVR=$(gh api --paginate "repos/{GH_REPO}/issues/{NUMBER}/comments" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:SIZE_OVERRIDE') \
558
+ || { echo "OVERRIDE_LOOKUP=FAILED"; exit 0; }
559
+ # A valid override has a non-empty justification: the first non-blank, non-marker line after the marker line.
560
+ JUSTIFICATION=$(printf '%s\n' "$OVR" | jq -r 'select(type == "string") | split("\n") | .[1:] | map(select(test("\\S") and (test("^\\s*<!--") | not))) | first // empty' | tail -n 1)
561
+ if [ -n "$JUSTIFICATION" ]; then echo "OVERRIDE=VALID"; else echo "OVERRIDE=NONE"; fi
562
+ ```
563
+
564
+ - `OVERRIDE_LOOKUP=FAILED` → Blocked exit with blocker `size-gate-unavailable: could not read FORGE:SIZE_OVERRIDE` (a transient GitHub failure uses the `github-unavailable:` prefix per the retry rule above). Never read a failed lookup as "no override".
565
+ - `OVERRIDE=VALID` → result `OVERRIDDEN`. A marker with an empty justification is not valid.
566
+
567
+ **Step 3 — record.** Set `DIFF_LINES`, `EXCLUDED_LINES`, `THRESHOLD`, `RESULT` (`OK`, `OVERRIDDEN` or `OVER`) and `SPLIT_PROPOSAL` from the steps above, then post one `<!-- FORGE:DIFF_SIZE -->` comment, idempotently: find the latest trusted comment starting with the marker and edit it, otherwise create it (on resume never stack duplicates; readers use the latest trusted one):
568
+
569
+ ```bash
570
+ BODY="<!-- FORGE:DIFF_SIZE -->
571
+ ## Diff Size
572
+
573
+ diff_lines: ${DIFF_LINES}
574
+ excluded_lines: ${EXCLUDED_LINES}
575
+ threshold: ${THRESHOLD}
576
+ result: ${RESULT}
577
+ ${SPLIT_PROPOSAL:-}"
578
+ run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
579
+ CID=$(gh api --paginate "repos/{GH_REPO}/issues/{NUMBER}/comments" --jq '[.[] | select(.body | startswith("<!-- FORGE:DIFF_SIZE")) | select((.author_association | IN("OWNER","MEMBER","COLLABORATOR")) or (.user.type == "Bot"))] | last | .id // ""' 2>/dev/null || true)
580
+ if [ -n "$CID" ]; then run gh api "repos/{GH_REPO}/issues/comments/${CID}" -X PATCH -f body="$BODY" >/dev/null 2>&1 || run gh issue comment {NUMBER} {GH_FLAG} --body "$BODY"
581
+ else run gh issue comment {NUMBER} {GH_FLAG} --body "$BODY"; fi
582
+ ```
583
+
584
+ **Step 4 — decide.**
585
+
586
+ | Condition | Result | Action |
587
+ |-----------|--------|--------|
588
+ | `over=false` | `OK` | Post/refresh `FORGE:DIFF_SIZE`, continue to B6 |
589
+ | `over=true`, valid override | `OVERRIDDEN` | Post/refresh `FORGE:DIFF_SIZE` (quote the justification), continue to B6 |
590
+ | `over=true`, no override, issue body has a `**Parent**: #N` line OR a trusted `FORGE:DECOMPOSED` comment exists | `OVER` | Decompose-loop guard: do NOT route to decompose. Post/refresh `FORGE:DIFF_SIZE`, then Blocked exit with blocker `diff-size-over-threshold: {diff_lines} > {threshold}; post a FORGE:SIZE_OVERRIDE with a justification to proceed` |
591
+ | `over=true`, no override, otherwise | `OVER` | Put a `### Split Proposal` in the `FORGE:DIFF_SIZE` comment, then print `BUILD_RESULT: status: NEEDS_DECOMPOSE` (see below) |
592
+
593
+ **`### Split Proposal`** (written into the `FORGE:DIFF_SIZE` comment): list the top files by changed lines from the `top=` output, then propose 2-4 sub-issues split along directory or phase seams, each independently buildable and no more than about half the threshold, in dependency order, one `- **{title}** — {files}` line each (this is the plan `work-on:decompose` consumes in D0/D2).
594
+
595
+ **`NEEDS_DECOMPOSE` exit**: leave the staged worktree and branch as they are; do not add `needs-human`; do not post `FORGE:BUILDER:COMPLETE`; do NOT call `work-on:decompose` here (the router is the sole dispatcher). The labels stay `workflow:building`; decompose owns the `workflow:decomposed` transition. Print:
596
+
597
+ ```
598
+ BUILD_RESULT:
599
+ status: NEEDS_DECOMPOSE
600
+ branch: {BRANCH}
601
+ worktree: {WORKTREE_PATH}
602
+ blocker: diff-size-over-threshold: {diff_lines} > {threshold}; split proposal posted in FORGE:DIFF_SIZE
603
+ ```
604
+
605
+ **Resume**: a re-invoked build whose latest trusted `FORGE:DIFF_SIZE` says `result: OVER` re-measures; if still over with no override it re-emits `NEEDS_DECOMPOSE` without a duplicate comment. When a valid `FORGE:SIZE_OVERRIDE` has appeared since, B5.5 re-measures and continues via `OVERRIDDEN`.
606
+ # MUST CONTINUE to Phase B6 — the size gate result is intermediate, NOT terminal (validation still required), except the NEEDS_DECOMPOSE and Blocked exits above.
519
607
 
520
608
  ---
521
609
 
@@ -725,7 +813,7 @@ Print this structured block as your final reply (and nothing after it) — the r
725
813
 
726
814
  ```
727
815
  BUILD_RESULT:
728
- status: COMPLETE | ALREADY_DONE | INVESTIGATION_COMPLETE | BLOCKED
816
+ status: COMPLETE | ALREADY_DONE | INVESTIGATION_COMPLETE | NEEDS_DECOMPOSE | BLOCKED
729
817
  branch: {BRANCH}
730
818
  worktree: {WORKTREE_PATH}
731
819
  blocker: {description if status=BLOCKED}
@@ -734,6 +822,7 @@ BUILD_RESULT:
734
822
  - `COMPLETE` — B0–B6.5 all passed; `FORGE:BUILDER:COMPLETE` is on the issue.
735
823
  - `ALREADY_DONE` — B0 found `FORGE:BUILDER:COMPLETE`.
736
824
  - `INVESTIGATION_COMPLETE` — the implement child created the deliverable issues (B5); no review follows.
825
+ - `NEEDS_DECOMPOSE` — B5.5 measured the staged diff over `build.diff_size.threshold` with no valid `FORGE:SIZE_OVERRIDE`; a split proposal is in `FORGE:DIFF_SIZE`. The router runs `work-on:decompose`. Not a failure: no `needs-human`.
737
826
  - `BLOCKED` — any guard or failure; `needs-human` is set and the blocker is posted.
738
827
 
739
828
  ---
@@ -112,7 +112,7 @@ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac
112
112
  # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
113
113
  _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
114
114
  # newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
115
- _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
115
+ _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
116
116
  _m="$HOME/.claude/plugins/marketplaces/$_mk"
117
117
  # '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
118
118
  _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
@@ -131,7 +131,7 @@ UNIVERSAL_DIR="${FORGE_ROOT:+$FORGE_ROOT/scripts}" # empty => tier 3 skipped,
131
131
  # to search the filesystem. <!-- Added: forge#1984 -->
132
132
 
133
133
  resolve_script() {
134
- local operation="$1"
134
+ local operation="${1}"
135
135
  # Tier 2: per-repo adaptive (skip if disabled)
136
136
  if [ "$ADAPTIVE_ENABLED" != "false" ] && [ -f "${ADAPTIVE_DIR}/${operation}.sh" ]; then
137
137
  echo "adaptive:${ADAPTIVE_DIR}/${operation}.sh"
@@ -151,7 +151,7 @@ Workflow-state transitions use the tiered `transition-label` script (adaptive
151
151
 
152
152
  ```bash
153
153
  set_workflow_state() {
154
- local _issue="$1" _state="$2" _res _tier _path _s _rm=""
154
+ local _issue="${1}" _state="${2}" _res _tier _path _s _rm=""
155
155
  _res=$(resolve_script 'transition-label'); _tier="${_res%%:*}"; _path="${_res#*:}"
156
156
  case "$_tier" in
157
157
  adaptive|universal) bash "$_path" "$_issue" $GH_FLAG "$_state" ;;
@@ -189,8 +189,8 @@ fi
189
189
  # All agent comments (to reconstruct pipeline results) — one paginated read, reused below
190
190
  COMMENTS_JSON=$(gh api --paginate repos/{GH_REPO}/issues/{NUMBER}/comments 2>/dev/null | jq -s 'add // []' 2>/dev/null || echo '[]')
191
191
  [ -n "$COMMENTS_JSON" ] || COMMENTS_JSON='[]'
192
- last_comment_body() { # $1 = marker substring; prints the newest matching comment body (or empty)
193
- printf '%s' "$COMMENTS_JSON" | jq -r --arg m "$1" '[.[] | select(.body | contains($m)) | .body] | last // ""' 2>/dev/null
192
+ last_comment_body() { # ${1} = marker substring; prints the newest matching comment body (or empty)
193
+ printf '%s' "$COMMENTS_JSON" | jq -r --arg m "${1}" '[.[] | select(.body | contains($m)) | .body] | last // ""' 2>/dev/null
194
194
  }
195
195
  INVESTIGATOR_BODY=$(last_comment_body "FORGE:INVESTIGATOR")
196
196
  BUILDER_BODY=$(last_comment_body "FORGE:BUILDER")
@@ -207,7 +207,7 @@ FAST_PATH_BODY=$(last_comment_body "FORGE:FAST_PATH")
207
207
 
208
208
  ```bash
209
209
  # --- Lane: --base wins (staging -> fast, milestone/* -> feature); otherwise the PR's baseRefName; otherwise unknown ---
210
- lane_from_base() { case "$1" in staging) echo fast ;; milestone/*) echo feature ;; *) echo "" ;; esac; }
210
+ lane_from_base() { case "${1}" in staging) echo fast ;; milestone/*) echo feature ;; *) echo "" ;; esac; }
211
211
  LANE=$(lane_from_base "$PR_BASE")
212
212
  [ -n "$LANE" ] || LANE=$(lane_from_base "$PR_BASE_REF")
213
213
  if [ -z "$LANE" ]; then
@@ -390,7 +390,7 @@ fi
390
390
  CHECKBOX_SECTIONS=$(printf '%s\n' "$BODY_STRIPPED" | awk '
391
391
  /^#+ / { if (in_section && has) n++; in_section=1; has=0; previous=""; next }
392
392
  /^(=+|-+)$/ && previous != "" { if (in_section && has) n++; in_section=1; has=0; previous=""; next }
393
- { if (in_section && /^[-*+] \[[ xX]\]/) has=1; previous=$0 }
393
+ { if (in_section && /^[-*+] \[[ xX]\]/) has=1; previous=$(0) }
394
394
  END { if (in_section && has) n++; print n+0 }
395
395
  ')
396
396
 
@@ -967,6 +967,23 @@ exceeds the field; pad shorter lines with spaces so the right border `║` stays
967
967
 
968
968
  ### C4.5c: Build the machine-readable twin
969
969
 
970
+ First read the build's diff size (forge#3450). `FORGE:DIFF_SIZE` is posted by `work-on:build` B5.5; use the latest TRUSTED one (anchored, through `scripts/trusted-comments.sh`; the Script resolution block applies). Absent data, or any lookup failure, renders `—` and is never `0`:
971
+
972
+ ```bash
973
+ # <Script resolution block, verbatim>
974
+ DIFF_LINES="—"; DIFF_THRESHOLD="—"; SIZE_OVERRIDE_USED="no"
975
+ TRUSTED_SCRIPT="${UNIVERSAL_DIR:+$UNIVERSAL_DIR/trusted-comments.sh}"
976
+ if [ -n "$TRUSTED_SCRIPT" ] && [ -f "$TRUSTED_SCRIPT" ]; then
977
+ _ds=$(gh api --paginate "repos/{GH_REPO}/issues/{NUMBER}/comments" 2>/dev/null \
978
+ | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:DIFF_SIZE' 2>/dev/null | tail -n 1 | jq -r 'select(type == "string")' 2>/dev/null) || _ds=""
979
+ _n=$(printf '%s\n' "$_ds" | sed -n 's/^diff_lines: *\([0-9][0-9]*\).*/\1/p' | head -n 1)
980
+ _t=$(printf '%s\n' "$_ds" | sed -n 's/^threshold: *\([0-9][0-9]*\).*/\1/p' | head -n 1)
981
+ [ -n "$_n" ] && DIFF_LINES="$_n"
982
+ [ -n "$_t" ] && DIFF_THRESHOLD="$_t"
983
+ printf '%s\n' "$_ds" | grep -q '^result: *OVERRIDDEN' && SIZE_OVERRIDE_USED="yes"
984
+ fi
985
+ ```
986
+
970
987
  Assemble the JSON object below. Its field set is exactly what Phase C5 passes to the codec `emit CARD --b64` call (including `title` and `blockers`); the JSON itself is for local use/debugging and is not embedded. Numeric stats that were `—`
971
988
  become `null` in JSON; never emit `"—"` as a number.
972
989
 
@@ -981,12 +998,14 @@ CARD_JSON=$(jq -nc \
981
998
  --arg commits "$COMMITS" --arg adds "$ADDITIONS" --arg dels "$DELETIONS" \
982
999
  --arg review "$REVIEW_SUMMARY" --argjson blockers "${BLOCKERS:-0}" \
983
1000
  --argjson elapsed "${ELAPSED_SECS:-0}" \
1001
+ --arg diff_lines "$DIFF_LINES" \
984
1002
  '{issue:$issue, title:$title, status:$status, pipeline:$pipeline,
985
1003
  pr:($pr|tonumber? // null), pr_target:$target,
986
1004
  commits:($commits|tonumber? // null),
987
1005
  additions:($adds|tonumber? // null),
988
1006
  deletions:($dels|tonumber? // null),
989
- review:$review, blockers:$blockers, elapsed_seconds:$elapsed}')
1007
+ review:$review, blockers:$blockers, elapsed_seconds:$elapsed,
1008
+ diff_lines:($diff_lines|tonumber? // null)}')
990
1009
  ```
991
1010
 
992
1011
  ---
@@ -1135,7 +1154,8 @@ if [ -n "$CODEC_CLI" ]; then
1135
1154
  --field deletions="${DELETIONS}" \
1136
1155
  --field review="${REVIEW_SUMMARY}" \
1137
1156
  --field blockers="${BLOCKERS:-0}" \
1138
- --field elapsed="${ELAPSED_SECS:-0}" 2>/dev/null) || CARD_LINE=""
1157
+ --field elapsed="${ELAPSED_SECS:-0}" \
1158
+ --field diff_lines="${DIFF_LINES:-—}" 2>/dev/null) || CARD_LINE=""
1139
1159
  fi
1140
1160
 
1141
1161
  TRAJ_FILE=$(mktemp)
@@ -1163,6 +1183,8 @@ ${DECISIONS_BLOCK}
1163
1183
 
1164
1184
  **Cost (economic scheduling)**: ${COST_DELTA_NOTE}
1165
1185
 
1186
+ **Diff size**: ${DIFF_LINES:-—} lines (threshold ${DIFF_THRESHOLD:-—}, override: ${SIZE_OVERRIDE_USED:-no})
1187
+
1166
1188
  **Pipeline completed**: ${TIMESTAMP}
1167
1189
 
1168
1190
  ${CARD_LINE}
@@ -1184,7 +1206,7 @@ If `CLOSE_FAILED` is set, STOP: print `CLOSE_RESULT: status: FAILED` with `block
1184
1206
 
1185
1207
  The `**Decisions**:` block MUST stay a bullet list and `**Decisions**:` must precede `**Anomalies**:` — the Phase C5.4 ADR extractor reads the lines between those two markers.
1186
1208
 
1187
- The `<!-- FORGE:CARD: v1 sha:... b64:... -->` line carries the machine-readable summary (the Phase C4.5c fields plus `title` and `blockers`), encoded as Base64url (design decision 2026-07-08: encoding beats escaping — the Base64url alphabet cannot contain HTML comment delimiters by construction). It is wrapped in the inline-value annotation form `<!-- FORGE:CARD: ... -->` so `parse()` extracts the encoded payload. Platform consumers (e.g. `/orchestrate`) decode via `node "$CODEC_CLI" parse --type CARD [--field <key>]`. This block is **additive**: all existing `FORGE:TRAJECTORY` consumers select via `contains("FORGE:TRAJECTORY")` and parse the markdown table, so the embedded CARD line does not affect them.
1209
+ The `<!-- FORGE:CARD: v1 sha:... b64:... -->` line carries the machine-readable summary (the Phase C4.5c fields plus `title` and `blockers`, including `diff_lines`), encoded as Base64url (design decision 2026-07-08: encoding beats escaping — the Base64url alphabet cannot contain HTML comment delimiters by construction). It is wrapped in the inline-value annotation form `<!-- FORGE:CARD: ... -->` so `parse()` extracts the encoded payload. Platform consumers (e.g. `/orchestrate`) decode via `node "$CODEC_CLI" parse --type CARD [--field <key>]`. This block is **additive**: all existing `FORGE:TRAJECTORY` consumers select via `contains("FORGE:TRAJECTORY")` and parse the markdown table, so the embedded CARD line does not affect them.
1188
1210
 
1189
1211
  **CODEC PATH (forge#1727)**: the `emit CARD --b64` call replaces the previous `<!-- FORGE:CARD ${CARD_JSON} -->` inline-JSON form. The Base64url form is safe against all HTML comment injection vectors and includes a sha8 integrity prefix for truncation detection. Consumers that parsed the old inline-JSON form must migrate to the codec parse path: `echo '...' | node "$CODEC_CLI" parse --type CARD --field <key>`.
1190
1212
 
@@ -1206,7 +1228,7 @@ Where:
1206
1228
 
1207
1229
  ```bash
1208
1230
  # Re-index this issue and regenerate cost priors — non-blocking
1209
- # Resolve from the ForgeDock install root (FORGE_ROOT, from the script-resolution block) — never from $0, which is meaningless inside a skill
1231
+ # Resolve from the ForgeDock install root (FORGE_ROOT, from the script-resolution block) — never from ${0}, which is meaningless inside a skill
1210
1232
  INDEXER_PATH=""
1211
1233
  [ -n "${FORGE_ROOT:-}" ] && INDEXER_PATH="$FORGE_ROOT/scripts/build-knowledge-index.mjs"
1212
1234
  [ -f "$INDEXER_PATH" ] || INDEXER_PATH="$REPO_PATH/scripts/build-knowledge-index.mjs"
@@ -76,7 +76,7 @@ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac
76
76
  # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
77
77
  _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
78
78
  # newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
79
- _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
79
+ _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
80
80
  _m="$HOME/.claude/plugins/marketplaces/$_mk"
81
81
  # '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
82
82
  _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
@@ -95,7 +95,7 @@ UNIVERSAL_DIR="${FORGE_ROOT:+$FORGE_ROOT/scripts}" # empty => tier 3 skipped,
95
95
  # to search the filesystem. <!-- Added: forge#1984 -->
96
96
 
97
97
  resolve_script() {
98
- local operation="$1"
98
+ local operation="${1}"
99
99
  # Tier 2: per-repo adaptive (skip if disabled)
100
100
  if [ "$ADAPTIVE_ENABLED" != "false" ] && [ -f "${ADAPTIVE_DIR}/${operation}.sh" ]; then
101
101
  echo "adaptive:${ADAPTIVE_DIR}/${operation}.sh"
@@ -164,7 +164,7 @@ gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
164
164
  --jq '.[] | select(.body | contains("FORGE:INVESTIGATOR") | not) | {author: .user.login, body: .body}'
165
165
  ```
166
166
 
167
- Scan non-agent comments for override signals — phrases like "do not", "do NOT", "instead", "revert", "remove this", "override", "actually", or explicit disagreement with the investigation's recommendation. If an override comment is found from a repo owner or admin (not a bot):
167
+ Ignore machine-posted marker comments (`FORGE:DIFF_SIZE`, `FORGE:SIZE_OVERRIDE`) in this scan: a `FORGE:DIFF_SIZE` comment is a plan source (below), never an owner override direction. Scan non-agent comments for override signals — phrases like "do not", "do NOT", "instead", "revert", "remove this", "override", "actually", or explicit disagreement with the investigation's recommendation. If an override comment is found from a repo owner or admin (not a bot):
168
168
 
169
169
  1. **Document the override**: Note which direction the owner is steering (e.g., "remove the feature" vs. investigation's "keep with warnings")
170
170
  2. **Re-derive sub-issue scopes**: Derive sub-issue titles, bodies, and file scope from the override direction — NOT from the original investigation recommendation. The investigation's Decomposition Assessment may list sub-issues that are now stale or contradictory with the override.
@@ -177,6 +177,20 @@ Scan non-agent comments for override signals — phrases like "do not", "do NOT"
177
177
 
178
178
  Extract from investigation report:
179
179
  - Decomposition Assessment section: list of proposed sub-issues with titles and dependencies
180
+
181
+ **Size-gate plan source** <!-- Added: forge#3450 -->: when the build's diff-size gate routed here, the investigation said no decomposition, so its assessment lists no sub-issues. Read the latest TRUSTED `FORGE:DIFF_SIZE` comment (anchored, through `scripts/trusted-comments.sh`; the Script resolution block applies) and use it when it has `result: OVER`:
182
+
183
+ ```bash
184
+ # <Script resolution block, verbatim>
185
+ set -o pipefail
186
+ TRUSTED_SCRIPT="${UNIVERSAL_DIR:+$UNIVERSAL_DIR/trusted-comments.sh}"
187
+ if [ -n "$TRUSTED_SCRIPT" ] && [ -f "$TRUSTED_SCRIPT" ]; then
188
+ SIZE_PLAN=$(gh api --paginate "repos/{GH_REPO}/issues/{NUMBER}/comments" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:DIFF_SIZE' | jq -r 'select(type == "string")' ) \
189
+ && echo "SIZE_PLAN_READ=ok" || echo "SIZE_PLAN_READ=FAILED"
190
+ else echo "SIZE_PLAN_READ=FAILED"; fi
191
+ ```
192
+
193
+ A `FAILED` read or a comment without `result: OVER` means there is no size-gate plan (the assessment rules below then apply unchanged). Its `### Split Proposal` lines (`- **{title}** — {files}`) are the proposed sub-issues, in dependency order, for Phase D2.
180
194
  - Milestone (from issue metadata)
181
195
  - Priority label (P0/P1/P2) from issue labels
182
196
 
@@ -203,7 +217,7 @@ If `PARENT_REF` is non-empty → BLOCKED, blocker: "Issue #{NUMBER} is a sub-iss
203
217
 
204
218
  **Guard 2 — investigation report present**: If the FORGE:INVESTIGATOR comment is absent → BLOCKED, blocker: "No investigation report found — run investigate first".
205
219
 
206
- **Guard 3 — decomposition plan present**: If the investigation report has no Decomposition Assessment section, OR the assessment does not list any sub-issues → BLOCKED, blocker: "Investigation report has no decomposition plan — re-run investigate with explicit decomposition scope".
220
+ **Guard 3 — decomposition plan present**: If the investigation report has no Decomposition Assessment section, OR the assessment does not list any sub-issues, AND there is no trusted `FORGE:DIFF_SIZE` comment with `result: OVER` and a `### Split Proposal` (the size-gate plan source from D0) → BLOCKED, blocker: "Investigation report has no decomposition plan — re-run investigate with explicit decomposition scope".
207
221
 
208
222
  **Resume check**:
209
223
 
@@ -246,7 +260,7 @@ If `GIST_URLS` is non-empty, a `## Prior Investigation` section will be appended
246
260
 
247
261
  ## Phase D2: Design Sub-Issues
248
262
 
249
- From the Decomposition Assessment in the investigation report (adjusted for any owner override detected in Phase D0), extract:
263
+ From the Decomposition Assessment in the investigation report (adjusted for any owner override detected in Phase D0), or, when the assessment lists none, from the size-gate `### Split Proposal` in the trusted `FORGE:DIFF_SIZE` comment (Phase D0), extract:
250
264
  1. Sub-issue titles (in dependency order — independent issues first)
251
265
  2. Dependencies between sub-issues (if issue B depends on issue A, A is created first)
252
266
  3. Brief description for each sub-issue body