forgedock 1.16.2 → 1.18.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.
- package/bin/engine/outcome-codes.mjs +114 -0
- package/bin/engine/phases.mjs +211 -100
- package/bin/engine/projector.mjs +48 -1
- package/bin/engine/runlog.mjs +20 -5
- package/bin/engine/telemetry-seam.mjs +62 -0
- package/bin/engine/telemetry-seam.test.mjs +60 -0
- package/bin/engine/telemetry.mjs +148 -0
- package/bin/engine/telemetry.test.mjs +120 -0
- package/bin/engine-cli.mjs +17 -1
- package/bin/engine.mjs +227 -48
- package/bin/forgedock.mjs +8 -2
- package/bin/hooks/interactive-engine.mjs +10 -0
- package/bin/hooks/pre-tool-use.mjs +220 -0
- package/bin/runner.mjs +70 -4
- package/bin/stats/aggregate.mjs +231 -0
- package/bin/stats/cache.mjs +40 -0
- package/bin/stats/github-source.mjs +211 -0
- package/bin/stats/index.mjs +154 -0
- package/bin/stats/local-source.mjs +79 -0
- package/bin/stats/package.json +6 -0
- package/bin/stats/render.mjs +82 -0
- package/bin/stats/stats.test.mjs +312 -0
- package/commands/cleanup.md +90 -2
- package/commands/deploy-info.md +8 -4
- package/commands/deploy-pr.md +64 -0
- package/commands/fix-ci.md +20 -6
- package/commands/milestone.md +34 -17
- package/commands/orchestrate/phase-1-resolve.md +4 -1
- package/commands/orchestrate/phase-4-execution.md +28 -6
- package/commands/orchestrate/phase-5-cleanup.md +18 -2
- package/commands/pipeline-health/phase-1-context.md +2 -0
- package/commands/pipeline-health/phase-2-metrics.md +12 -5
- package/commands/pipeline-status.md +21 -1
- package/commands/review-pr-agents/protocols.md +1 -0
- package/commands/review-pr-agents.md +10 -2
- package/commands/review-pr-staging.md +196 -496
- package/commands/review-pr.md +512 -3134
- package/commands/test-gate.md +25 -10
- package/commands/work-on/build/architect.md +13 -1
- package/commands/work-on/build/implement.md +28 -6
- package/commands/work-on/build/validate.md +5 -2
- package/commands/work-on/build.md +197 -145
- package/commands/work-on/close.md +66 -20
- package/commands/work-on/decompose.md +113 -34
- package/commands/work-on/investigate.md +15 -14
- package/commands/work-on/remediate.md +49 -19
- package/commands/work-on/review.md +7 -5
- package/commands/work-on.md +1 -2
- package/docs/CONFIG.md +76 -24
- package/docs/OUTCOME-CODES.md +53 -0
- package/docs/RELEASE-NOTES.md +24 -0
- package/docs/STATS.md +63 -0
- package/docs/TELEMETRY.md +65 -0
- package/docs/WORK-ON-RUNTIME.md +19 -0
- package/docs/site/command-reference.md +1 -1
- package/docs/spec/forge-protocol-v1.md +2 -2
- package/docs/spec/label-state-machine.md +1 -1
- package/package.json +3 -2
- package/packages/protocol/src/phases.js +5 -5
- package/scripts/builder-resume-match.test.sh +58 -0
- package/scripts/cap-findings.sh +110 -0
- package/scripts/cap-findings.test.sh +130 -0
- package/scripts/check-spec-bash.sh +5 -1
- package/scripts/check-spec-bash.test.sh +20 -0
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/classify-finding.sh +65 -16
- package/scripts/classify-finding.test.sh +43 -1
- package/scripts/classify-grep-exit.sh +20 -0
- package/scripts/classify-grep-exit.test.sh +46 -0
- package/scripts/close-parent-c3.test.sh +123 -0
- package/scripts/cost-report.sh +146 -0
- package/scripts/cost-report.test.sh +80 -0
- package/scripts/diff-size.sh +97 -3
- package/scripts/diff-size.test.sh +59 -2
- package/scripts/doctor-pipeline-state.sh +43 -0
- package/scripts/file-deferred-followups.sh +159 -0
- package/scripts/file-deferred-followups.test.sh +106 -0
- package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
- package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
- package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
- package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
- package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
- package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
- package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
- package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
- package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
- package/scripts/forge-root.test.sh +18 -11
- package/scripts/lib/fence-bootstrap.sh +4 -3
- package/scripts/lib/pr-issue-number.sh +18 -0
- package/scripts/lib/review-template-resolve.sh +41 -0
- package/scripts/lib/telemetry.sh +168 -0
- package/scripts/publish-staging-sync.test.sh +48 -0
- package/scripts/remediate-liveness.sh +42 -6
- package/scripts/resolve-deploy-gate.sh +32 -6
- package/scripts/resolve-deploy-gate.test.sh +42 -1
- package/scripts/review-acceptance-gate.sh +16 -7
- package/scripts/review-acceptance-gate.test.sh +13 -0
- package/scripts/review-finalize.sh +1427 -0
- package/scripts/review-finalize.test.sh +833 -0
- package/scripts/review-pr-delta-wiring.test.sh +19 -72
- package/scripts/review-pr-domain-map.test.sh +23 -13
- package/scripts/review-pr-gate2.test.sh +24 -65
- package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
- package/scripts/review-pr-staging-scope.test.sh +56 -80
- package/scripts/review-prepare.sh +553 -0
- package/scripts/review-prepare.test.sh +288 -0
- package/scripts/review-roster.sh +186 -0
- package/scripts/review-roster.test.sh +185 -0
- package/scripts/run-shell-tests.sh +3 -0
- package/scripts/telemetry.test.sh +106 -0
- package/scripts/trusted-comments.test.sh +9 -2
- package/scripts/wait-ci-green.sh +8 -1
- package/scripts/wait-ci-green.test.sh +3 -3
- package/templates/forge.yaml.minimal +9 -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
|
|
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.**
|
|
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
|
|
|
@@ -108,7 +108,7 @@ Every exit path of this skill — COMPLETE, ALREADY_DONE, INVESTIGATION_COMPLETE
|
|
|
108
108
|
|
|
109
109
|
```
|
|
110
110
|
BUILD_RESULT:
|
|
111
|
-
status: COMPLETE | ALREADY_DONE | INVESTIGATION_COMPLETE |
|
|
111
|
+
status: COMPLETE | ALREADY_DONE | INVESTIGATION_COMPLETE | BLOCKED
|
|
112
112
|
branch: {BRANCH}
|
|
113
113
|
worktree: {WORKTREE_PATH}
|
|
114
114
|
blocker: {description if status=BLOCKED, else empty}
|
|
@@ -141,45 +141,56 @@ Re-read current state before doing anything:
|
|
|
141
141
|
```bash
|
|
142
142
|
gh issue view {NUMBER} {GH_FLAG} --json number,title,body,labels,state,milestone
|
|
143
143
|
|
|
144
|
-
# Check investigation report
|
|
144
|
+
# Check investigation report — intentionally NOT cycle-scoped: investigate may commit CONFIRMED in a later cycle without re-posting INVESTIGATOR, so scoping it would block every new-cycle build
|
|
145
145
|
TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh) # script resolution block verbatim; unresolved/failed read = fail closed
|
|
146
146
|
gh api repos/{GH_REPO}/issues/{NUMBER}/comments --paginate | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:INVESTIGATOR -->'
|
|
147
147
|
|
|
148
|
-
#
|
|
149
|
-
|
|
150
|
-
|
|
148
|
+
# Current-cycle slice (forge#3666): every resume read below runs on the comments AFTER the newest TRUSTED
|
|
149
|
+
# `<!-- FORGE:CYCLE: n -->` marker (same predicate as `cycleNumber`/`sliceToCurrentCycle` in bin/engine/phases.mjs:
|
|
150
|
+
# trusted author incl. the FORGE_TRAIL_TRUSTED_ASSOCIATIONS / FORGE_TRAIL_TRUSTED_LOGINS overrides, same `${VAR-default}`
|
|
151
|
+
# handling as scripts/trusted-comments.sh; body starts with the header, marker on its own line); all comments when no marker exists.
|
|
152
|
+
# A cycle-1 BUILDER:COMPLETE must never yield ALREADY_DONE in cycle 2. Re-declare this function verbatim in every
|
|
153
|
+
# fence that uses it (shell functions do not survive across fences).
|
|
154
|
+
cycle_slice() {
|
|
155
|
+
jq -s --arg assoc "${FORGE_TRAIL_TRUSTED_ASSOCIATIONS-OWNER,MEMBER,COLLABORATOR}" --arg logins "${FORGE_TRAIL_TRUSTED_LOGINS-}" '[.[][]] as $c
|
|
156
|
+
| ($assoc | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $A
|
|
157
|
+
| ($logins | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $L
|
|
158
|
+
| ([$c | to_entries[]
|
|
159
|
+
| select(((.value.author_association // "") as $x | $A | index($x) != null) or ((.value.user.type // "") == "Bot") or ((.value.user.login // "") as $x | $L | index($x) != null))
|
|
160
|
+
| select((.value.body // "") | test("\\A\\s*<!--\\s*FORGE:CYCLE:") and test("(?m)^[ \\t]*<!--[ \\t]*FORGE:CYCLE:[ \\t]*[0-9]+[ \\t]*-->[ \\t]*$"))
|
|
161
|
+
| .key] | last) as $i
|
|
162
|
+
| if $i == null then $c else $c[($i + 1):] end'
|
|
163
|
+
}
|
|
164
|
+
COMMENTS_JSON=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments --paginate) \
|
|
165
|
+
&& CYCLE_SLICE=$(printf '%s' "$COMMENTS_JSON" | cycle_slice) && [ -n "$CYCLE_SLICE" ] \
|
|
166
|
+
|| echo "CYCLE_LOOKUP=FAILED" # a failed read is NEVER "no marker"
|
|
167
|
+
|
|
168
|
+
# Check if build already completed (require FORGE:BUILDER:COMPLETE — not just FORGE:BUILDER); current-cycle slice only
|
|
169
|
+
printf '%s' "$CYCLE_SLICE" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:BUILDER'
|
|
151
170
|
|
|
152
|
-
# Existing classification from a prior run (resume path)
|
|
153
|
-
EXISTING_FAST_PATH=$(
|
|
154
|
-
|
|
171
|
+
# Existing classification from a prior run of THIS cycle (resume path); none in a fresh cycle, so B0.5 re-classifies and re-posts
|
|
172
|
+
EXISTING_FAST_PATH=$(printf '%s' "$CYCLE_SLICE" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:FAST_PATH' 2>/dev/null \
|
|
173
|
+
| head -n 1 | jq -r 'select(type == "string")')
|
|
155
174
|
```
|
|
156
175
|
|
|
176
|
+
`CYCLE_LOOKUP=FAILED` (or `trusted-comments.sh` unresolved) → Blocked exit with blocker `cycle-lookup-unavailable: could not read FORGE:CYCLE` (a transient GitHub failure uses the `github-unavailable:` prefix); never read a failed lookup as "no marker". Below, "present" and "exists" for BUILDER, BUILDER:COMPLETE, FAST_PATH, DIFF_SIZE, ACCEPTANCE_GATE and BUILD_BLOCKED comments always mean present in the **current-cycle slice** (comments after the newest trusted `FORGE:CYCLE` marker); a cycle-1 `BUILDER:COMPLETE` is invisible in cycle 2, so a new cycle with no slice COMPLETE falls through to B1 and builds fresh.
|
|
177
|
+
|
|
157
178
|
**Resume check**:
|
|
158
|
-
- If `<!-- FORGE:BUILDER:COMPLETE -->` is present in a BUILDER comment → the builder finished, but COMPLETE is posted at validate V5, BEFORE the B6.5 acceptance gate, so it is not proof the build passed. **Gate resume** (checked first; first match wins) <!-- Added: forge#3725 -->: read the newest trusted,
|
|
179
|
+
- If `<!-- FORGE:BUILDER:COMPLETE -->` is present in a BUILDER comment of the current-cycle slice → the builder finished, but COMPLETE is posted at validate V5, BEFORE the B6.5 acceptance gate, so it is not proof the build passed. **Gate resume** (checked first; first match wins) <!-- Added: forge#3725 -->: read the newest trusted, comment of the current-cycle slice that starts with `FORGE:ACCEPTANCE_GATE` or `FORGE:BUILD_BLOCKED`, through the same anchored, trust-filtered lookup as the FAST_PATH read above (script resolution block verbatim; pipe `cycle_slice` into `bodies '^<!-- FORGE:(ACCEPTANCE_GATE|BUILD_BLOCKED)'`, and compare by comment order, never by presence):
|
|
159
180
|
- Lookup failed (`trusted-comments.sh` unresolved or `gh` error) → Blocked exit with blocker `gate-lookup-unavailable: could not read FORGE:ACCEPTANCE_GATE` (a transient GitHub failure uses the `github-unavailable:` prefix). Never read a failed lookup as "no gate marker".
|
|
160
181
|
- Newest is `FORGE:ACCEPTANCE_GATE:FAILED`, `FORGE:ACCEPTANCE_GATE:BLOCKED`, or a `FORGE:BUILD_BLOCKED` comment → the gate is blocked and the engine fails closed on it, so returning ALREADY_DONE would deadlock every re-run. Derive BRANCH/WORKTREE_PATH/PR_BASE as in B1A/B1C (without creating anything), verify the branch and worktree exist and that `git -C "$WORKTREE_PATH" branch --show-current` equals BRANCH (missing or mismatched → Blocked exit `worktree-missing: cannot re-enter B6.5`; never recreate silently), recover `COMPLEXITY_BAND` from `EXISTING_FAST_PATH` with the B0.5 resume extraction, then skip B0.5-B6 and re-enter at **Phase B6.5**, which posts a fresh PASSED/FAILED/BLOCKED verdict (the one-repair `FORGE:ACCEPTANCE_REPAIR` bound still applies).
|
|
161
182
|
- Newest is `FORGE:ACCEPTANCE_GATE:PASSED`, or no gate marker exists → build already complete. Derive BRANCH/WORKTREE_PATH as in B1A/B1C (without creating anything), then print `BUILD_RESULT: status: ALREADY_DONE`.
|
|
162
|
-
-
|
|
163
|
-
|
|
164
|
-
# <Script resolution block from above goes here, verbatim>
|
|
165
|
-
set -o pipefail
|
|
166
|
-
TRUSTED_SCRIPT="${UNIVERSAL_DIR:+$UNIVERSAL_DIR/trusted-comments.sh}"
|
|
167
|
-
[ -n "$TRUSTED_SCRIPT" ] && [ -f "$TRUSTED_SCRIPT" ] || { echo "DIFF_SIZE_LOOKUP=FAILED (trusted-comments.sh unresolved)"; exit 0; }
|
|
168
|
-
DS=$(gh api --paginate "repos/{GH_REPO}/issues/{NUMBER}/comments" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:DIFF_SIZE') \
|
|
169
|
-
|| { echo "DIFF_SIZE_LOOKUP=FAILED"; exit 0; }
|
|
170
|
-
LAST_RESULT=$(printf '%s\n' "$DS" | jq -r 'select(type == "string")' | sed -n 's/^result: *\([A-Z]*\).*/\1/p' | tail -n 1)
|
|
171
|
-
echo "DIFF_SIZE_RESULT=${LAST_RESULT:-<none>}"
|
|
172
|
-
```
|
|
173
|
-
- `DIFF_SIZE_LOOKUP=FAILED` → Blocked exit with blocker `size-gate-unavailable: could not read FORGE:DIFF_SIZE` (a transient GitHub failure uses the `github-unavailable:` prefix). Never read a failed lookup as "no DIFF_SIZE".
|
|
174
|
-
- `DIFF_SIZE_RESULT=OVER` or `DIFF_SIZE_RESULT=COHESIVE` → verify, without creating anything, that the branch and worktree exist (derive BRANCH/WORKTREE_PATH as in B1A/B1C), that `git -C "$WORKTREE_PATH" branch --show-current` equals BRANCH, and that a staged diff is present (`! git -C "$WORKTREE_PATH" diff --cached --quiet`). Recover `COMPLEXITY_BAND` from `EXISTING_FAST_PATH` with the B0.5 resume extraction. When all hold, skip B0.5-B5 and continue at B5.5 (it re-measures, then applies the override / `COHESIVE` / `NEEDS_DECOMPOSE` / `OK` logic; never re-run implement).
|
|
175
|
-
- Anything else (no `FORGE:DIFF_SIZE`, `result` is not `OVER`, worktree or branch missing or mismatched, nothing staged, band unrecoverable) → never skip ahead on missing state; fall through to the next rule.
|
|
176
|
-
- If `<!-- FORGE:BUILDER -->` exists BUT `<!-- FORGE:BUILDER:COMPLETE -->` is ABSENT → build was interrupted after the comment was posted but before the commit (validate V5). Delete the partial comment and restart from Phase B2 (contract): <!-- Added: forge#1305 -->
|
|
183
|
+
- If `<!-- FORGE:BUILDER -->` exists in the current-cycle slice BUT `<!-- FORGE:BUILDER:COMPLETE -->` is ABSENT → build was interrupted after the comment was posted but before the commit (validate V5). Delete the partial comment and restart from Phase B2 (contract): <!-- Added: forge#1305 -->
|
|
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 -->
|
|
177
185
|
```bash
|
|
178
|
-
|
|
179
|
-
|
|
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=""
|
|
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)
|
|
180
189
|
if [ -n "$PARTIAL_ID" ]; then
|
|
181
190
|
gh api repos/{GH_REPO}/issues/comments/$PARTIAL_ID -X DELETE
|
|
182
191
|
echo "Deleted partial FORGE:BUILDER comment (no FORGE:BUILDER:COMPLETE) — restarting build from Phase B2"
|
|
192
|
+
else
|
|
193
|
+
echo "builder resume: no single safe partial BUILDER comment — deleting nothing"
|
|
183
194
|
fi
|
|
184
195
|
```
|
|
185
196
|
- If there is no `<!-- FORGE:INVESTIGATOR -->` comment with `<!-- INVESTIGATION:COMPLETE -->` → Blocked exit with blocker "Investigation not complete — run investigate first".
|
|
@@ -267,7 +278,7 @@ Whatever path was taken, set `COMPLEXITY_BAND` (uppercase) for the rest of this
|
|
|
267
278
|
|
|
268
279
|
### B1A: Derive branch name
|
|
269
280
|
|
|
270
|
-
If `--branch` was passed, or a local/remote branch already exists whose name ends in `-{NUMBER}` (`git branch -a --list "*-{NUMBER}"`), reuse it (resume). Otherwise derive from the issue title: lowercase, hyphenated, max 40 chars (truncate if needed).
|
|
281
|
+
If `--branch` was passed, or a local/remote branch already exists whose name ends in `-{NUMBER}` (`git branch -a --list "*-{NUMBER}"`), reuse it (resume). **New-cycle guard** <!-- Added: forge#3666 -->: when a trusted `FORGE:CYCLE` marker exists and the current-cycle slice has no `FORGE:BUILDER`, never re-adopt a prior-cycle branch whose PR is already merged or closed (`gh pr list {GH_FLAG} --head "<branch>" --state all --json state --jq '.[].state'` shows `MERGED`/`CLOSED`, or the branch is already merged into `origin/{PR_BASE}`); derive a fresh name instead (append `-c<cycle>` before `-{NUMBER}`, e.g. `fix/foo-c2-{NUMBER}`). Otherwise derive from the issue title: lowercase, hyphenated, max 40 chars (truncate if needed).
|
|
271
282
|
- Bug / fix issues → prefix `fix/`
|
|
272
283
|
- Feature issues → prefix `feat/`
|
|
273
284
|
- Refactor / maintenance → prefix `fix/` or `refactor/`
|
|
@@ -347,7 +358,7 @@ esac
|
|
|
347
358
|
|
|
348
359
|
**Skip if COMPLEXITY_BAND: INVESTIGATION** — go to B5. For every other band (TRIVIAL included) the contract is mandatory. If a `FORGE:CONTRACT` comment already exists on the issue (resume), do not post a second one — continue to B2.1.
|
|
349
360
|
|
|
350
|
-
Post `<!-- FORGE:CONTRACT -->` comment documenting what will be built and why. Run B2.0 first: it files
|
|
361
|
+
Post `<!-- FORGE:CONTRACT -->` comment documenting what will be built and why. Run B2.0 first: it folds small siblings and files one batched follow-up for the `deferred` out-of-scope items and validates the section, so `{OUT_OF_SCOPE_ITEMS}` below already holds real issue numbers.
|
|
351
362
|
|
|
352
363
|
### B2.0: Out-of-scope dispositions — file deferred follow-ups, validate the section <!-- Added: forge#3447 -->
|
|
353
364
|
|
|
@@ -365,51 +376,26 @@ Every item in the contract's `### Out of Scope` section must carry exactly one d
|
|
|
365
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.
|
|
366
377
|
- Free-text exclusions without a disposition are invalid. `->` is accepted for `→`.
|
|
367
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.
|
|
380
|
+
|
|
368
381
|
**File the `deferred` follow-ups** (skip the filing on resume when the contract already exists):
|
|
369
382
|
|
|
370
383
|
```bash
|
|
371
384
|
# <Script resolution block from work-on/build.md, verbatim>
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
LOOKUP=$(gh api -X GET search/issues -f per_page=50 \
|
|
379
|
-
-f q="\"FORGE:DEFERRED_FROM: #{NUMBER}\" in:body repo:{GH_REPO} is:issue is:open" --jq '[.items[] | select(((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR")) or ((.user.type // "") == "Bot"))]' 2>/dev/null) || LOOKUP="FAILED"
|
|
380
|
-
if [ "$LOOKUP" = "FAILED" ]; then
|
|
381
|
-
echo "DEFERRED_LOOKUP=FAILED" # a failed lookup is not "none found": Blocked exit, never re-file blind
|
|
382
|
-
else
|
|
383
|
-
DEFERRED_NUM=$(printf '%s' "$LOOKUP" | jq -r --arg p "$DEFERRED_PATH" \
|
|
384
|
-
'[.[] | select((.body // "") | contains($p))] | first | .number // empty' 2>/dev/null)
|
|
385
|
-
if [ -z "$DEFERRED_NUM" ]; then
|
|
386
|
-
DEFERRED_URL=$(run gh issue create {GH_FLAG} --title "follow-up: ${DEFERRED_PATH} (deferred from #{NUMBER})" \
|
|
387
|
-
--body "## Problem
|
|
388
|
-
|
|
389
|
-
${DEFERRED_WHY}
|
|
390
|
-
|
|
391
|
-
Deferred from #{NUMBER}: \`${DEFERRED_PATH}\` was declared out of scope in its contract and is tracked here instead of surfacing later as a review finding.
|
|
392
|
-
|
|
393
|
-
## Affected Files
|
|
394
|
-
|
|
395
|
-
- \`${DEFERRED_PATH}\`
|
|
396
|
-
|
|
397
|
-
## Acceptance Criteria
|
|
398
|
-
|
|
399
|
-
- [ ] The concern above is resolved for \`${DEFERRED_PATH}\`
|
|
400
|
-
|
|
401
|
-
${DEFERRED_MARKER}")
|
|
402
|
-
DEFERRED_NUM=$(printf '%s' "$DEFERRED_URL" | grep -oE '[0-9]+$')
|
|
403
|
-
[ -n "${DRY_RUN:-}" ] && DEFERRED_NUM="0"
|
|
404
|
-
fi
|
|
405
|
-
echo "DEFERRED_ISSUE=${DEFERRED_NUM:-}"
|
|
406
|
-
fi
|
|
385
|
+
# DEFERRED_TSV: one line per remaining deferred item: path<TAB>one-line why<TAB>known-flaw (or empty)
|
|
386
|
+
SCRIPT_REF=$(resolve_script 'file-deferred-followups')
|
|
387
|
+
case "$SCRIPT_REF" in
|
|
388
|
+
prose:*) echo "DEFERRED_SCRIPT=PROSE" ;; # file ONE issue by hand: marker + checklist of every path (see below)
|
|
389
|
+
*) printf '%s\n' "$DEFERRED_TSV" | bash "${SCRIPT_REF#*:}" --parent {NUMBER} --repo {GH_REPO} --folded "${FOLDED_COUNT:-0}"; echo "DEFERRED_RC=$?" ;;
|
|
390
|
+
esac
|
|
407
391
|
```
|
|
408
392
|
|
|
409
|
-
-
|
|
410
|
-
-
|
|
411
|
-
-
|
|
412
|
-
-
|
|
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
|
+
- 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.
|
|
396
|
+
- `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.
|
|
413
399
|
|
|
414
400
|
**Validate the composed section** with `scripts/check-contract-scope.sh validate` (the exact text that will replace `{OUT_OF_SCOPE_ITEMS}`):
|
|
415
401
|
|
|
@@ -592,50 +578,91 @@ Skill(skill="{FORGE_SKILL_PREFIX}work-on:build:implement", args="{NUMBER} --repo
|
|
|
592
578
|
|
|
593
579
|
**After subcommand returns** (read its `IMPLEMENT_RESULT:` block):
|
|
594
580
|
- `status: COMPLETE` → continue to B5.5
|
|
595
|
-
- `status: ALREADY_DONE` → continue to B5.5 (
|
|
581
|
+
- `status: ALREADY_DONE` → continue to B5.5 (scope-check what's already there)
|
|
596
582
|
- `status: INVESTIGATION_COMPLETE` → issues were created as deliverables and the original closed; verify a `<!-- FORGE:INVESTIGATION:DELIVERABLES -->` comment exists on the issue (the implement child posts it and removes `workflow:building`; if it is missing, post it listing the created issues and remove the label), then print `BUILD_RESULT: status: INVESTIGATION_COMPLETE` (skip B5.5/B6/B6.5). The engine keys off that marker, not the result text. <!-- Added: forge#3534 -->
|
|
597
583
|
- `status: BLOCKED` → Blocked exit with the child's `blocker`
|
|
598
584
|
- Skill not found → Blocked exit, blocker "skill not found: work-on:build:implement"
|
|
599
585
|
- 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`)
|
|
600
|
-
# MUST CONTINUE to Phase B5.5 — implement result is intermediate, NOT terminal (the
|
|
586
|
+
# MUST CONTINUE to Phase B5.5 — implement result is intermediate, NOT terminal (the scope check and validation still follow).
|
|
601
587
|
|
|
602
588
|
---
|
|
603
589
|
|
|
604
|
-
## Phase B5.5:
|
|
590
|
+
## Phase B5.5: Scope Check
|
|
591
|
+
|
|
592
|
+
A finished build is never discarded and never routed to decompose: decomposition is a planning decision (investigate / architect), not a post-build one. This phase compares the staged diff with the plan, measures a deterministic `review_weight`, and records a scope outcome. Size never blocks; it scales review depth. It measures the staged diff (implement staged everything in I4; nothing is committed until validate V5) and edits code only for the opt-in out-of-plan trim in Step 4. <!-- Added: forge#3862 -->
|
|
593
|
+
|
|
594
|
+
**Skip** (continue to B6) only when the task type is Investigation (B5 already exited). Never skip because a lookup failed: unknown is not in plan.
|
|
595
|
+
|
|
596
|
+
**Step 1 — baseline.** The plan is the scope baseline: the latest trusted `FORGE:ARCHITECT` comment of the current-cycle slice carries `PLANNED_FILES:` (a `- path` list; a trailing `/` is a directory prefix) and `ESTIMATED_DIFF_LINES: N` under `### Scope Baseline`. An architect skip marker has an empty list, which means "no plan baseline": proceed with no out-of-plan check. The estimate falls back to the issue body's own-line `ESTIMATED_DIFF_LINES: N` (written by `work-on:decompose` D3), then to the latest trusted `FORGE:INVESTIGATOR` comment's. Paste the Script resolution block and the `cycle_slice` function from B0 first (shell state does not persist); quote every interpolation:
|
|
605
597
|
|
|
606
|
-
|
|
598
|
+
```bash
|
|
599
|
+
# <Script resolution block from above goes here, verbatim>
|
|
600
|
+
# <cycle_slice function from B0, verbatim>
|
|
601
|
+
set -o pipefail
|
|
602
|
+
TRUSTED_SCRIPT="${UNIVERSAL_DIR:+$UNIVERSAL_DIR/trusted-comments.sh}"
|
|
603
|
+
[ -n "$TRUSTED_SCRIPT" ] && [ -f "$TRUSTED_SCRIPT" ] || { echo "BASELINE_LOOKUP=FAILED (trusted-comments.sh unresolved)"; exit 0; }
|
|
604
|
+
SLICE=$(gh api --paginate "repos/{GH_REPO}/issues/{NUMBER}/comments" | cycle_slice) && [ -n "$SLICE" ] || { echo "BASELINE_LOOKUP=FAILED"; exit 0; }
|
|
605
|
+
ARCH_BODY=$(printf '%s' "$SLICE" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:ARCHITECT' | jq -rs 'map(select(type == "string")) | last // ""') || { echo "BASELINE_LOOKUP=FAILED"; exit 0; }
|
|
606
|
+
INV_BODY=$(printf '%s' "$SLICE" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:INVESTIGATOR' | jq -rs 'map(select(type == "string")) | last // ""') || { echo "BASELINE_LOOKUP=FAILED"; exit 0; }
|
|
607
|
+
ISSUE_BODY=$(gh issue view {NUMBER} {GH_FLAG} --json body --jq .body) || { echo "BASELINE_LOOKUP=FAILED"; exit 0; }
|
|
608
|
+
SCOPE_DIR="${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}"; mkdir -p "$SCOPE_DIR"
|
|
609
|
+
PLAN_FILE="$SCOPE_DIR/scope-plan-{NUMBER}" # fixed path: Step 2 runs in a fresh shell and re-derives it
|
|
610
|
+
printf '%s\n' "$ARCH_BODY" | awk '/^PLANNED_FILES:[ \t]*$/{f=1;next} f&&/^[ \t]*[-*][ \t]+/{print;next} f{exit}' > "$PLAN_FILE"
|
|
611
|
+
EST_RE='s/^ESTIMATED_DIFF_LINES: *\([0-9][0-9]*\) *$/\1/p'
|
|
612
|
+
ESTIMATE=$(printf '%s\n' "$ARCH_BODY" | sed -n "$EST_RE" | tail -n 1)
|
|
613
|
+
[ -n "$ESTIMATE" ] || ESTIMATE=$(printf '%s\n' "$ISSUE_BODY" | sed -n "$EST_RE" | tail -n 1)
|
|
614
|
+
[ -n "$ESTIMATE" ] || ESTIMATE=$(printf '%s\n' "$INV_BODY" | sed -n "$EST_RE" | tail -n 1)
|
|
615
|
+
printf '%s' "${ESTIMATE:-}" > "$SCOPE_DIR/scope-estimate-{NUMBER}"
|
|
616
|
+
echo "BASELINE_LOOKUP=ok PLAN_FILE=${PLAN_FILE} ESTIMATE=${ESTIMATE:-none}"
|
|
617
|
+
```
|
|
607
618
|
|
|
608
|
-
|
|
619
|
+
`BASELINE_LOOKUP=FAILED` → Blocked exit with blocker `github-unavailable: could not read the scope baseline (FORGE:ARCHITECT)` (transient; the router retry rule applies; no `needs-human`). Never read a failed lookup as "no plan" or "in plan". A read that succeeds with no `PLANNED_FILES:` entries is a legitimate "no plan baseline".
|
|
609
620
|
|
|
610
|
-
**Step
|
|
621
|
+
**Step 2 — measure.** Config (all values are config, none is a magic number; defaults are generous and are calibrated from telemetry, forge#3834): `build.scope.test_weight` (percent weight of test lines in `review_weight`, default 50), `build.scope.module_depth` (path segments that define a module, default 2), `build.scope.overrun_pct` (actual whitespace-insensitive lines over the plan estimate, in percent, that count as scope creep, default 200), `build.scope.exclude_globs`, `review.depth.extended_weight` (`review_weight` at or above which review is extended, default 1500) and `review.depth.extended_modules` (modules touched at or above which review is extended, default 6). The deprecated `build.diff_size.exclude_globs` is read as an alias of `build.scope.exclude_globs` and a positive `build.diff_size.threshold` as an alias of `review.depth.extended_weight` for one release; when an alias supplied the value, add `deprecated_config: build.diff_size.*` to the comment (Step 5). The threshold no longer blocks anything.
|
|
611
622
|
|
|
612
623
|
```bash
|
|
613
624
|
# <Script resolution block from above goes here, verbatim>
|
|
625
|
+
# Step 1's variables do not survive into this fence: reload the baseline it persisted.
|
|
626
|
+
SCOPE_DIR="${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}"
|
|
627
|
+
PLAN_FILE="$SCOPE_DIR/scope-plan-{NUMBER}"; [ -f "$PLAN_FILE" ] || : > "$PLAN_FILE"
|
|
628
|
+
ESTIMATE=$(cat "$SCOPE_DIR/scope-estimate-{NUMBER}" 2>/dev/null || true)
|
|
614
629
|
# Fresh origin/base for the merge-base measurement: retry with 10s/30s/60s backoff, record whether it ever succeeded
|
|
615
630
|
FETCH_OK=0
|
|
616
631
|
for _delay in 0 10 30 60; do
|
|
617
632
|
[ "$_delay" -gt 0 ] && sleep "$_delay"
|
|
618
633
|
if git -C "{WORKTREE_PATH}" fetch origin "{PR_BASE}" 2>/dev/null; then FETCH_OK=1; break; fi
|
|
619
634
|
done
|
|
620
|
-
|
|
635
|
+
TEST_WEIGHT=$(yq '.build.scope.test_weight // 50' forge.yaml 2>/dev/null || echo 50)
|
|
636
|
+
MODULE_DEPTH=$(yq '.build.scope.module_depth // 2' forge.yaml 2>/dev/null || echo 2)
|
|
637
|
+
OVERRUN_PCT=$(yq '.build.scope.overrun_pct // 200' forge.yaml 2>/dev/null || echo 200)
|
|
638
|
+
EXT_WEIGHT=$(yq '.review.depth.extended_weight // ""' forge.yaml 2>/dev/null || true)
|
|
639
|
+
EXT_MODULES=$(yq '.review.depth.extended_modules // 6' forge.yaml 2>/dev/null || echo 6)
|
|
640
|
+
DEPRECATED=""
|
|
641
|
+
if [ -z "$EXT_WEIGHT" ] || [ "$EXT_WEIGHT" = "null" ]; then
|
|
642
|
+
OLD_THRESHOLD=$(yq '.build.diff_size.threshold // 0' forge.yaml 2>/dev/null || echo 0)
|
|
643
|
+
case "$OLD_THRESHOLD" in ''|*[!0-9]*|0) EXT_WEIGHT=1500 ;; *) EXT_WEIGHT="$OLD_THRESHOLD"; DEPRECATED="build.diff_size.threshold" ;; esac
|
|
644
|
+
fi
|
|
621
645
|
EXCLUDES=()
|
|
622
646
|
while IFS= read -r _g; do [ -n "$_g" ] && [ "$_g" != "null" ] && EXCLUDES+=(--exclude-glob "$_g"); done \
|
|
623
|
-
< <(yq '.build.diff_size.exclude_globs // [] | .[]' forge.yaml 2>/dev/null || true)
|
|
647
|
+
< <(yq '(.build.scope.exclude_globs // []) + (.build.diff_size.exclude_globs // []) | .[]' forge.yaml 2>/dev/null || true)
|
|
624
648
|
SCRIPT_REF=$(resolve_script 'diff-size')
|
|
625
649
|
case "$SCRIPT_REF" in
|
|
626
|
-
prose:*) echo "
|
|
650
|
+
prose:*) echo "SCOPE_CHECK: scripts/diff-size.sh unresolved (prose tier) — check skipped with a note in the FORGE:BUILDER comment" ;;
|
|
627
651
|
*)
|
|
628
|
-
SIZE_OUT=$(bash "${SCRIPT_REF#*:}" --repo-path "{WORKTREE_PATH}" --base "{PR_BASE}" --
|
|
652
|
+
SIZE_OUT=$(bash "${SCRIPT_REF#*:}" --repo-path "{WORKTREE_PATH}" --base "{PR_BASE}" --test-weight "$TEST_WEIGHT" --module-depth "$MODULE_DEPTH" --overrun-pct "$OVERRUN_PCT" \
|
|
653
|
+
--plan-files "$PLAN_FILE" ${ESTIMATE:+--estimate "$ESTIMATE"} ${EXCLUDES[@]+"${EXCLUDES[@]}"}) && SIZE_RC=0 || SIZE_RC=$?
|
|
629
654
|
echo "SIZE_RC=${SIZE_RC}"; echo "FETCH_OK=${FETCH_OK}"; printf '%s\n' "$SIZE_OUT" ;;
|
|
630
655
|
esac
|
|
631
656
|
```
|
|
632
657
|
|
|
633
|
-
- Prose tier (script unresolvable) → skip the
|
|
634
|
-
- Exit 2, empty output, or output without `diff_lines=`, `
|
|
635
|
-
- The same unmeasurable result with `FETCH_OK=1`
|
|
636
|
-
- Otherwise parse `diff_lines`, `excluded_lines`, `
|
|
658
|
+
- Prose tier (script unresolvable) → skip the check, continue to B6 (the script is part of the install; a missing install must not strand every build).
|
|
659
|
+
- Exit 2, empty output, or output without `diff_lines=`, `review_weight=`, `modules=` and `scope=`, AND `FETCH_OK=0` (the base fetch failed after the 10s/30s/60s retries) → Blocked exit with blocker `github-unavailable: could not fetch origin/{PR_BASE} for scope measurement` (transient; the router retry rule applies).
|
|
660
|
+
- The same unmeasurable result with `FETCH_OK=1` → Blocked exit with blocker `scope-check-unavailable: diff-size.sh failed (rc=<N>)`. Either way, do NOT treat an unmeasurable diff as in plan.
|
|
661
|
+
- Otherwise parse `diff_lines`, `excluded_lines`, `ws_lines`, `review_weight`, `modules`, `scope` (`ok` | `out_of_plan` | `no_baseline`), `plan_files`, `out_of_plan_count`, `estimate`, `overrun` and the `out_of_plan=` and `top=` lines.
|
|
662
|
+
|
|
663
|
+
**Step 3 — classify.** `review_depth` is `EXTENDED` when `review_weight` >= `EXT_WEIGHT` or `modules` >= `EXT_MODULES`, else `STANDARD`. The scope is flagged when `scope=out_of_plan` or `overrun=true` (an unparseable `scope=`/`overrun=` reads as flagged, never as clean). A flagged scope also sets `review_depth: EXTENDED`.
|
|
637
664
|
|
|
638
|
-
**Step
|
|
665
|
+
**Step 4 — look up an override and decide** (override lookup only when the scope is flagged). The lookup is anchored, trust-filtered through `scripts/trusted-comments.sh` (trusted association, Bot, or `FORGE_TRAIL_TRUSTED_LOGINS`), and fails closed:
|
|
639
666
|
|
|
640
667
|
```bash
|
|
641
668
|
# <Script resolution block from above goes here, verbatim>
|
|
@@ -649,69 +676,67 @@ JUSTIFICATION=$(printf '%s\n' "$OVR" | jq -r 'select(type == "string") | split("
|
|
|
649
676
|
if [ -n "$JUSTIFICATION" ]; then echo "OVERRIDE=VALID"; else echo "OVERRIDE=NONE"; fi
|
|
650
677
|
```
|
|
651
678
|
|
|
652
|
-
- `OVERRIDE_LOOKUP=FAILED` → Blocked exit with blocker `
|
|
653
|
-
-
|
|
679
|
+
- `OVERRIDE_LOOKUP=FAILED` → Blocked exit with blocker `scope-check-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".
|
|
680
|
+
- A marker with an empty justification is not valid.
|
|
654
681
|
|
|
655
|
-
|
|
682
|
+
| Condition | Result | Action |
|
|
683
|
+
|-----------|--------|--------|
|
|
684
|
+
| scope not flagged, `review_depth=STANDARD` | `OK` | Post/refresh `FORGE:DIFF_SIZE`, continue to B6 |
|
|
685
|
+
| scope not flagged, `review_depth=EXTENDED` | `EXTENDED_REVIEW` | Post/refresh `FORGE:DIFF_SIZE` (review extends: more reviewers or per-file slices, read by review-pr), continue to B6. Never a block, never a split |
|
|
686
|
+
| scope flagged, valid `FORGE:SIZE_OVERRIDE` | `OVERRIDDEN` | Post/refresh `FORGE:DIFF_SIZE` quoting the justification, `review_depth` as classified (a valid override suppresses only the scope flag, not extended review), continue to B6 |
|
|
687
|
+
| scope flagged, no valid override | `SCOPE_FLAGGED` | Flag: list every `out_of_plan=` path and the overrun in the comment so review checks them (a scope-creep note, not a block), `review_depth: EXTENDED`, continue to B6. When `build.scope.trim_out_of_plan` is `true`, also trim (below) |
|
|
688
|
+
|
|
689
|
+
**Trim out-of-plan scope** (opt-in: `build.scope.trim_out_of_plan: true`, default `false`; only when `out_of_plan_count` is at most `build.scope.trim_max_files`, default 3; never for `overrun=true` alone). Each out-of-plan path's hunks move out of this PR into the batched follow-up checklist (forge#3843) instead of being discarded, so no work is lost:
|
|
656
690
|
|
|
657
691
|
```bash
|
|
692
|
+
# <Script resolution block from above goes here, verbatim>
|
|
693
|
+
MB=$(git -C "{WORKTREE_PATH}" merge-base HEAD "origin/{PR_BASE}") || { echo "TRIM=FAILED"; exit 0; }
|
|
694
|
+
PATCH_PATH="$(git -C "{WORKTREE_PATH}" rev-parse --git-common-dir)/forge-trimmed-{NUMBER}.patch"
|
|
695
|
+
DEFERRED_TSV=""
|
|
696
|
+
# OOP_PATHS: the out_of_plan= values, one per line (data, never evaluated)
|
|
697
|
+
while IFS= read -r _p; do
|
|
698
|
+
[ -n "$_p" ] || continue
|
|
699
|
+
git -C "{WORKTREE_PATH}" diff --cached --binary "$MB" -- "$_p" >> "$PATCH_PATH" || { echo "TRIM=FAILED"; exit 0; }
|
|
700
|
+
if git -C "{WORKTREE_PATH}" cat-file -e "${MB}:${_p}" 2>/dev/null; then
|
|
701
|
+
git -C "{WORKTREE_PATH}" restore --source="$MB" --staged --worktree -- "$_p" || { echo "TRIM=FAILED"; exit 0; }
|
|
702
|
+
else
|
|
703
|
+
git -C "{WORKTREE_PATH}" rm -q -f --cached -- "$_p" && rm -f -- "{WORKTREE_PATH}/$_p" || { echo "TRIM=FAILED"; exit 0; }
|
|
704
|
+
fi
|
|
705
|
+
DEFERRED_TSV="${DEFERRED_TSV}${_p}"$'\t'"out-of-plan hunks trimmed from #{NUMBER}; saved in ${PATCH_PATH}"$'\t'$'\n'
|
|
706
|
+
done <<< "$OOP_PATHS"
|
|
707
|
+
printf '%s' "$DEFERRED_TSV" | bash "$(resolve_script 'file-deferred-followups' | sed 's/^[^:]*://')" --parent {NUMBER} --repo {GH_REPO}; echo "DEFERRED_RC=$?"
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
`TRIM=FAILED` or a non-zero `DEFERRED_RC` → do not trim further; keep whatever state is staged, fall back to the flag outcome (`SCOPE_FLAGGED`, nothing lost), and say so in the comment. After a successful trim re-run Step 2 once so `FORGE:DIFF_SIZE` records the trimmed numbers, set `trimmed: <N> file(s) -> #<follow-up>` and result `SCOPE_FLAGGED`.
|
|
711
|
+
|
|
712
|
+
**Step 5 — record.** Set `DIFF_LINES`, `EXCLUDED_LINES`, `WS_LINES`, `REVIEW_WEIGHT`, `MODULES`, `ESTIMATE`, `SCOPE`, `REVIEW_DEPTH` and `RESULT` 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). `diff_lines` stays the first field close.md and the protocol read:
|
|
713
|
+
|
|
714
|
+
```bash
|
|
715
|
+
# <Script resolution block from above goes here, verbatim>
|
|
658
716
|
BODY="<!-- FORGE:DIFF_SIZE -->
|
|
659
|
-
## Diff Size
|
|
717
|
+
## Diff Size and Scope
|
|
660
718
|
|
|
661
719
|
diff_lines: ${DIFF_LINES}
|
|
662
720
|
excluded_lines: ${EXCLUDED_LINES}
|
|
663
|
-
|
|
721
|
+
ws_lines: ${WS_LINES}
|
|
722
|
+
review_weight: ${REVIEW_WEIGHT}
|
|
723
|
+
modules: ${MODULES}
|
|
724
|
+
estimated_diff_lines: ${ESTIMATE:-none}
|
|
725
|
+
scope: ${SCOPE}
|
|
726
|
+
review_depth: ${REVIEW_DEPTH}
|
|
664
727
|
result: ${RESULT}
|
|
665
|
-
${
|
|
728
|
+
${DEPRECATION_NOTE:-}${SCOPE_NOTE:-}"
|
|
729
|
+
_t=$(forge_resolve lib/telemetry.sh); FORGE_REPO="{GH_REPO}" FORGE_ISSUE="{NUMBER}" FORGE_PHASE=build bash "${_t:-/dev/null}" diff_size lines="$DIFF_LINES" excluded="$EXCLUDED_LINES" estimate="${ESTIMATE:-0}" actual="$WS_LINES" review_weight="$REVIEW_WEIGHT" modules="$MODULES" scope="$SCOPE" review_depth="$REVIEW_DEPTH" result="$RESULT" >/dev/null 2>&1 || true # forge#3834 telemetry (final result: estimate vs actual, review_weight, scope outcome, depth)
|
|
666
730
|
run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
|
|
667
731
|
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)
|
|
668
732
|
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"
|
|
669
733
|
else run gh issue comment {NUMBER} {GH_FLAG} --body "$BODY"; fi
|
|
670
734
|
```
|
|
671
735
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
**Step 4 — decide.**
|
|
675
|
-
|
|
676
|
-
| Condition | Result | Action |
|
|
677
|
-
|-----------|--------|--------|
|
|
678
|
-
| `over=false` | `OK` | Post/refresh `FORGE:DIFF_SIZE`, continue to B6 |
|
|
679
|
-
| `over=true`, valid override | `OVERRIDDEN` | Post/refresh `FORGE:DIFF_SIZE` (quote the justification), continue to B6 |
|
|
680
|
-
| `over=true`, no override, issue body has a `**Parent**: #N` line OR a trusted `FORGE:DECOMPOSED` comment exists, AND `cohesive=true` | `COHESIVE` | Cohesive-overage path <!-- Added: forge#3790 -->: a sub-issue that is over the gate because one new module plus its tests dominate the diff (at least 70% of counted lines are new or test files, and `diff_lines` is at most `threshold` x 1.5, both computed by `diff-size.sh`) has no seam left to split and the loop guard would only strand it. Post/refresh `FORGE:DIFF_SIZE` with the cohesion note, do NOT add `needs-human`, continue to B6. Test lines are classified, never discounted. A `FORGE:SIZE_OVERRIDE` still takes precedence (`OVERRIDDEN`) |
|
|
681
|
-
| `over=true`, no override, issue body has a `**Parent**: #N` line OR a trusted `FORGE:DECOMPOSED` comment exists (and not `cohesive=true`) | `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` |
|
|
682
|
-
| `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) |
|
|
683
|
-
|
|
684
|
-
**`### 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 at or under `0.8 x threshold` (code plus tests; a seam that can't fit says so explicitly), in dependency order, one `- **{title}** — {files} — wip: {WIP_REF}@{WIP_SHA} (git checkout {WIP_SHA} -- {files})` line each (this is the plan `work-on:decompose` consumes in D0/D2). The `wip:` part comes from the persist step below; when persistence fell back to a patch file write `patch: {PATCH_PATH}` instead, and when both failed write `wip: unavailable`. Lines without the suffix stay valid.
|
|
685
|
-
|
|
686
|
-
**Persist the staged hunks** (before the `NEEDS_DECOMPOSE` exit; runs once the `FORGE:DIFF_SIZE` result is `OVER` and the decompose-loop guard did not fire) <!-- Added: forge#3573 -->: commit the staged index to a standalone commit object WITHOUT moving the build branch or the index, and push it to a dedicated ref named only from the issue number (never interpolate a title or file name into a ref). Re-running overwrites the same ref, so resume is idempotent. Never block `NEEDS_DECOMPOSE` on a persistence failure.
|
|
687
|
-
|
|
688
|
-
```bash
|
|
689
|
-
WIP_REF="forge/wip/{NUMBER}"
|
|
690
|
-
WIP_SHA=""; PATCH_PATH=""
|
|
691
|
-
TREE=$(git -C "{WORKTREE_PATH}" write-tree) \
|
|
692
|
-
&& WIP_SHA=$(git -C "{WORKTREE_PATH}" commit-tree "$TREE" -p HEAD -m "wip: staged hunks for #{NUMBER} (diff-size OVER)") || WIP_SHA=""
|
|
693
|
-
if [ -n "$WIP_SHA" ] && git -C "{WORKTREE_PATH}" push --force origin "${WIP_SHA}:refs/heads/${WIP_REF}" 2>/dev/null; then
|
|
694
|
-
echo "WIP_REF=${WIP_REF}@${WIP_SHA}"
|
|
695
|
-
else
|
|
696
|
-
PATCH_PATH="$(git -C "{WORKTREE_PATH}" rev-parse --git-common-dir)/forge-wip-{NUMBER}.patch"
|
|
697
|
-
git -C "{WORKTREE_PATH}" diff --cached --binary > "$PATCH_PATH" 2>/dev/null && echo "PATCH_PATH=${PATCH_PATH}" || echo "WIP=unavailable"
|
|
698
|
-
fi
|
|
699
|
-
```
|
|
700
|
-
|
|
701
|
-
The checkout command in each line restores that seam's files from the persisted commit (`git fetch origin {WIP_REF}` first in a fresh clone); `git diff {WIP_SHA}^ {WIP_SHA} -- {files}` shows them. Post `FORGE:DIFF_SIZE` (Step 3) after this step so the proposal carries the references.
|
|
736
|
+
`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.
|
|
702
737
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
```
|
|
706
|
-
BUILD_RESULT:
|
|
707
|
-
status: NEEDS_DECOMPOSE
|
|
708
|
-
branch: {BRANCH}
|
|
709
|
-
worktree: {WORKTREE_PATH}
|
|
710
|
-
blocker: diff-size-over-threshold: {diff_lines} > {threshold}; split proposal posted in FORGE:DIFF_SIZE
|
|
711
|
-
```
|
|
712
|
-
|
|
713
|
-
**Resume**: a re-invoked build re-enters here via the B0 size-gate resume branch (partial `FORGE:BUILDER` plus latest trusted `FORGE:DIFF_SIZE` `result: OVER` or `COHESIVE` plus a staged diff in the kept worktree), so it does not re-run implement; this covers the exits that leave BUILDER incomplete, `NEEDS_DECOMPOSE` and the decompose-loop-guard Blocked exit (a `COHESIVE` marker re-enters the same way after a crash and re-decides without a duplicate comment). B5.5 re-measures: if still over with no override it re-pushes the same `forge/wip/{NUMBER}` ref (idempotent) and re-emits `NEEDS_DECOMPOSE` without a duplicate comment (the loop-guard case re-emits the same Blocked exit); when a valid `FORGE:SIZE_OVERRIDE` has appeared since, it continues via `OVERRIDDEN`.
|
|
714
|
-
# MUST CONTINUE to Phase B6 (also after `COHESIVE`) — the size gate result is intermediate, NOT terminal (validation still required), except the NEEDS_DECOMPOSE and Blocked exits above.
|
|
738
|
+
**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.
|
|
715
740
|
|
|
716
741
|
---
|
|
717
742
|
|
|
@@ -791,6 +816,8 @@ The acceptance spec contains only a skip sentinel (\`type=skipped\`). No automat
|
|
|
791
816
|
**Otherwise — execute each check**: <!-- Added: forge#1829 -->
|
|
792
817
|
|
|
793
818
|
```bash
|
|
819
|
+
# <Script resolution block, verbatim>
|
|
820
|
+
GREP_CLS=$(forge_resolve classify-grep-exit.sh) # empty/unresolved = every grep exit>=2 is a real FAIL (fail closed)
|
|
794
821
|
GATE_PASS=true
|
|
795
822
|
CORRECTED=""
|
|
796
823
|
FAILED_CHECKS=""
|
|
@@ -798,6 +825,8 @@ VERIFIED_COUNT=0 # checks actually executed and judged (PASS or FAIL); mal
|
|
|
798
825
|
MALFORMED_CHECKS="" # malformed-check: generated check that can never pass; reported, never repaired, never needs-human
|
|
799
826
|
DEFERRED_COUNT=0 # phase=review checks (and legacy "no pull requests found" results): evaluated by review R2.5, never counted here
|
|
800
827
|
DEFERRED_CHECKS="" # <!-- Added: forge#3793 -->
|
|
828
|
+
MANUAL_CHECKS="" # per-check type=skipped (manual / post-deploy): never executed, listed as unchecked follow-ups <!-- Added: forge#3825 -->
|
|
829
|
+
SPEC_DEFECT=false # nothing verifiable: a spec defect that code repair cannot fix <!-- Added: forge#3825 -->
|
|
801
830
|
|
|
802
831
|
while IFS= read -r check_line; do
|
|
803
832
|
# Fields are extracted by anchoring each sed pattern to `^ACCEPTANCE_CHECK:` and walking
|
|
@@ -837,7 +866,7 @@ while IFS= read -r check_line; do
|
|
|
837
866
|
PHASE=$(printf '%s\n' "$check_line" | sed -nE 's/^ACCEPTANCE_CHECK: id=[^ ]* type=[^ ]* target=("(([^"\\]|\\.)*)"|[^ ]*) matcher=("(([^"\\]|\\.)*)"|[^ ]*) phase=(build|review)( .*)?$/\7/p')
|
|
838
867
|
[ "$PHASE" = "review" ] || PHASE=build
|
|
839
868
|
|
|
840
|
-
[ "$TYPE" = "skipped" ] && continue
|
|
869
|
+
[ "$TYPE" = "skipped" ] && { MANUAL_CHECKS="${MANUAL_CHECKS}\n- [ ] **$ID** ($DESC)"; continue; }
|
|
841
870
|
|
|
842
871
|
# Deferred (review) <!-- Added: forge#3793 -->: never executed, never VERIFIED, never GATE_PASS=false, never in FAILED_CHECKS / --fix-acceptance.
|
|
843
872
|
if [ "$PHASE" = "review" ]; then DEFERRED_COUNT=$((DEFERRED_COUNT+1)); DEFERRED_CHECKS="${DEFERRED_CHECKS}\n- **$ID** ($DESC): DEFERRED (review)"; continue; fi
|
|
@@ -846,6 +875,7 @@ while IFS= read -r check_line; do
|
|
|
846
875
|
# check defect, not an implementation failure. Static detection only parses (bash -n), never executes.
|
|
847
876
|
MALFORMED=""
|
|
848
877
|
{ [ "$TYPE" = "command" ] || [ "$TYPE" = "behavior" ]; } && { case "$TARGET" in *'\\\\'*) MALFORMED="run of 4+ backslashes (over-escaped target)";; esac; [ -n "$MALFORMED" ] || bash -n -c "$TARGET" 2>/dev/null || MALFORMED="shell syntax error (unbalanced quotes)"; }
|
|
878
|
+
{ [ "$TYPE" = "command" ] || [ "$TYPE" = "behavior" ]; } && [ -z "$MALFORMED" ] && case "$(printf '%s' "$TARGET" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" in none) MALFORMED='target "none" is not a command (a manual criterion must be type=skipped)';; esac
|
|
849
879
|
if [ -n "$MALFORMED" ]; then MALFORMED_CHECKS="${MALFORMED_CHECKS}\n- **$ID** ($DESC): $MALFORMED"; continue; fi
|
|
850
880
|
|
|
851
881
|
RESULT="PASS"
|
|
@@ -868,12 +898,14 @@ while IFS= read -r check_line; do
|
|
|
868
898
|
if [ "$MATCHER" = "exit_0" ]; then
|
|
869
899
|
ERR=$(eval "$TARGET" 2>&1 >/dev/null); RC=$?
|
|
870
900
|
# grep exit >= 2 is a usage/syntax error (e.g. a pattern starting with `-`), never a verdict on the code; exit 1 stays FAIL.
|
|
871
|
-
#
|
|
872
|
-
[ "$RC" -ge 2 ] &&
|
|
901
|
+
# Malformed only when stderr positively names a usage error (classify-grep-exit.sh); empty stderr (`2>/dev/null`, `-s`) or any operand error is a REAL failure.
|
|
902
|
+
[ "$RC" -ge 2 ] && MALFORMED=$(bash "$GREP_CLS" "$RC" "$ERR" "$TARGET" 2>/dev/null)
|
|
903
|
+
[ "$RC" -eq 127 ] && [ -z "$MALFORMED" ] && printf '%s' "$ERR" | grep -qF 'command not found' && MALFORMED="command not found (rc 127): the target is not an executable command"
|
|
873
904
|
[ "$RC" -eq 0 ] || [ -n "$MALFORMED" ] || { RESULT="FAIL"; DETAIL="command exited non-zero: $TARGET"; }
|
|
874
905
|
else
|
|
875
|
-
OUTPUT=$(eval "$TARGET" 2
|
|
876
|
-
[ "$RC" -ge 2 ] &&
|
|
906
|
+
ERRF=$(mktemp); OUTPUT=$(eval "$TARGET" 2>"$ERRF"); RC=$?; ERR=$(cat "$ERRF"); rm -f "$ERRF"; [ -z "$ERR" ] || OUTPUT="${OUTPUT}${OUTPUT:+$'\n'}${ERR}" # classify on stderr only, match on both
|
|
907
|
+
[ "$RC" -ge 2 ] && MALFORMED=$(bash "$GREP_CLS" "$RC" "$ERR" "$TARGET" 2>/dev/null)
|
|
908
|
+
[ "$RC" -eq 127 ] && [ -z "$MALFORMED" ] && printf '%s' "$ERR" | grep -qF 'command not found' && MALFORMED="command not found (rc 127): the target is not an executable command"
|
|
877
909
|
if [ -n "$MALFORMED" ]; then :
|
|
878
910
|
elif ! printf '%s\n' "$OUTPUT" | grep -qE "$MATCHER"; then
|
|
879
911
|
if printf '%s\n' "$OUTPUT" | grep -qF -- "$MATCHER"; then CORRECTED="${CORRECTED}\n- **$ID**: output matched as a literal string"
|
|
@@ -888,7 +920,7 @@ while IFS= read -r check_line; do
|
|
|
888
920
|
|
|
889
921
|
if [ -n "$MALFORMED" ]; then MALFORMED_CHECKS="${MALFORMED_CHECKS}\n- **$ID** ($DESC): $MALFORMED"; continue; fi
|
|
890
922
|
|
|
891
|
-
# Legacy fallback (
|
|
923
|
+
# Legacy fallback (any phase tag): a gh pr check that finds no PR is waiting on the review phase, not failing.
|
|
892
924
|
if [ "$RESULT" = "FAIL" ] && printf '%s' "$ERR" | grep -qF 'no pull requests found'; then
|
|
893
925
|
case "$TARGET" in *'gh pr'*) DEFERRED_COUNT=$((DEFERRED_COUNT+1)); DEFERRED_CHECKS="${DEFERRED_CHECKS}\n- **$ID** ($DESC): DEFERRED (review) (no pull request exists yet)"; continue;; esac
|
|
894
926
|
fi
|
|
@@ -903,21 +935,38 @@ done <<< "$ACCEPTANCE_CHECKS"
|
|
|
903
935
|
# A gate that verified nothing must not pass: with every check skipped as malformed, PASSED would be a green light on zero evidence.
|
|
904
936
|
# A spec whose checks are all deferred to review (DEFERRED_COUNT > 0) is not vacuous: review R2.5 evaluates them before merge.
|
|
905
937
|
if [ "$VERIFIED_COUNT" -eq 0 ] && [ "$DEFERRED_COUNT" -eq 0 ]; then
|
|
938
|
+
# spec-defect <!-- Added: forge#3825 -->: only the spec is wrong, so this is a BLOCKED verdict that never enters the --fix-acceptance repair (#3774).
|
|
906
939
|
GATE_PASS=false
|
|
907
|
-
|
|
940
|
+
SPEC_DEFECT=true
|
|
908
941
|
fi
|
|
909
942
|
```
|
|
910
943
|
|
|
911
944
|
**Post gate result comment**:
|
|
912
945
|
|
|
913
946
|
```bash
|
|
947
|
+
# <Script resolution block, verbatim>
|
|
914
948
|
run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
|
|
915
|
-
|
|
949
|
+
_nf=$(printf '%b' "$FAILED_CHECKS" | grep -c '^- ' || true); _nm=$(printf '%b' "$MALFORMED_CHECKS" | grep -c '^- ' || true); _t=$(forge_resolve lib/telemetry.sh)
|
|
950
|
+
[ -n "${DRY_RUN:-}" ] || FORGE_REPO="{GH_REPO}" FORGE_ISSUE="{NUMBER}" FORGE_PHASE=build bash "${_t:-/dev/null}" acceptance_gate phase=build verified="$VERIFIED_COUNT" failed="${_nf:-0}" malformed="${_nm:-0}" deferred="$DEFERRED_COUNT" >/dev/null 2>&1 || true # forge#3834 telemetry
|
|
951
|
+
if [ "$SPEC_DEFECT" = "true" ]; then
|
|
952
|
+
run gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:ACCEPTANCE_GATE -->
|
|
953
|
+
## Acceptance Gate — BLOCKED (spec-defect)
|
|
954
|
+
|
|
955
|
+
No acceptance check could be verified: every check was malformed, manual, or empty. The implementation is not at fault and a code repair cannot fix this, so the repair step is skipped.
|
|
956
|
+
$( [ -n "$MALFORMED_CHECKS" ] && printf '\n**malformed-check**:%b\n' "$MALFORMED_CHECKS" )
|
|
957
|
+
$( [ -n "$MANUAL_CHECKS" ] && printf '\n**Manual follow-up (type=skipped; not verified at build time)**:%b\n' "$MANUAL_CHECKS" )
|
|
958
|
+
|
|
959
|
+
**Gate result: BLOCKED** — regenerate the acceptance spec by re-running \`/work-on:investigate {NUMBER}\`, then retry the build.
|
|
960
|
+
|
|
961
|
+
<!-- FORGE:ACCEPTANCE_GATE:BLOCKED -->"
|
|
962
|
+
run gh issue edit {NUMBER} {GH_FLAG} --add-label "needs-human"
|
|
963
|
+
elif [ "$GATE_PASS" = "true" ]; then
|
|
916
964
|
run gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:ACCEPTANCE_GATE -->
|
|
917
965
|
## Acceptance Gate — PASSED
|
|
918
966
|
|
|
919
967
|
All machine-checkable build-phase acceptance criteria verified against real behavior.
|
|
920
968
|
$( [ -n "$DEFERRED_CHECKS" ] && printf '\n**Deferred to review (phase=review; evaluated against the PR before merge)**:%b\n' "$DEFERRED_CHECKS" )
|
|
969
|
+
$( [ -n "$MANUAL_CHECKS" ] && printf '\n**Manual follow-up (type=skipped; not verified at build time, confirm after merge/deploy)**:%b\n' "$MANUAL_CHECKS" )
|
|
921
970
|
$( [ -n "$CORRECTED" ] && printf '\n**Checks corrected (malformed matcher, literal text present)**:%b\n' "$CORRECTED" )
|
|
922
971
|
$( [ -n "$MALFORMED_CHECKS" ] && printf '\n**malformed-check (not counted as failures; regenerate the acceptance spec by re-running investigate)**:%b\n' "$MALFORMED_CHECKS" )
|
|
923
972
|
|
|
@@ -938,11 +987,15 @@ Merge is blocked. Fix the failing criteria and re-run the validate phase.
|
|
|
938
987
|
fi
|
|
939
988
|
```
|
|
940
989
|
|
|
941
|
-
A `malformed-check` (a command/behavior target with a run of 4+ backslashes or a `bash -n` syntax error, or a grep target exiting >= 2 for a reason other than a missing/unreadable operand file, which stays a real FAIL) is reported in the comment and never sets `GATE_PASS=false` by itself, never enters `FAILED_CHECKS`, is never passed to `--fix-acceptance`, and never adds `needs-human` by itself; the gate still posts exactly one of PASSED/FAILED. The gate never posts PASSED with zero verified checks: if every check was malformed (`VERIFIED_COUNT=0` and `DEFERRED_COUNT=0`) it posts
|
|
990
|
+
A `malformed-check` (a command/behavior target with a run of 4+ backslashes or a `bash -n` syntax error, or a grep target exiting >= 2 for a reason other than a missing/unreadable operand file, which stays a real FAIL) is reported in the comment and never sets `GATE_PASS=false` by itself, never enters `FAILED_CHECKS`, is never passed to `--fix-acceptance`, and never adds `needs-human` by itself; the gate still posts exactly one of PASSED/FAILED. The gate never posts PASSED with zero verified checks: if every check was malformed (`VERIFIED_COUNT=0` and `DEFERRED_COUNT=0`) it is a spec-defect: it posts `FORGE:ACCEPTANCE_GATE:BLOCKED`, adds `needs-human`, prints `BUILD_RESULT: status: BLOCKED` (blocker: "regenerate acceptance spec via investigate") and skips the `--fix-acceptance` repair entirely, because implement cannot fix a spec defect. <!-- Added: forge#3694, forge#3825 -->
|
|
991
|
+
|
|
992
|
+
Also `malformed-check` <!-- Added: forge#3825 -->: a `command`/`behavior` target that is literally `none` (any case), or that exits 127 with `command not found` on stderr. Classification keys on that stderr text, never on the code alone: a missing script (a `bash` run of a nonexistent script path, with `No such file or directory`) also exits 127 and stays a real FAIL, so the missing-operand class is not reopened. A per-check `type=skipped` is never executed, never counted, and is listed under "Manual follow-up" as unchecked items in the PASSED comment.
|
|
993
|
+
|
|
994
|
+
A `phase=review` check (or, for legacy specs, tagged or not, a `command`/`behavior` check on a `gh pr` target whose output contains `no pull requests found`) is recorded as `DEFERRED (review)` in the PASSED comment: it is never executed here, never counted in `VERIFIED_COUNT`, never sets `GATE_PASS=false`, never enters `FAILED_CHECKS` or `--fix-acceptance`, and never spends the repair. A spec whose checks are all deferred posts PASSED (it is not "No Spec Found"). Review R2.5 evaluates the deferred checks against the real PR and blocks merge on failure. <!-- Added: forge#3793 -->
|
|
942
995
|
|
|
943
|
-
|
|
996
|
+
If `SPEC_DEFECT = true`: leave `needs-human`, print `BUILD_RESULT: status: BLOCKED`, blocker: "regenerate acceptance spec via investigate (spec-defect: no check could be verified)", and do NOT run the repair below.
|
|
944
997
|
|
|
945
|
-
If `GATE_PASS = false`, the build repairs itself **once** before anything escalates (a failing acceptance check is pipeline work, not a human decision):
|
|
998
|
+
If `GATE_PASS = false` and `SPEC_DEFECT` is not `true`, the build repairs itself **once** before anything escalates (a failing acceptance check is pipeline work, not a human decision):
|
|
946
999
|
|
|
947
1000
|
1. Count `<!-- FORGE:ACCEPTANCE_REPAIR: issue={NUMBER} -->` comments on the issue. If one already exists, skip to step 4.
|
|
948
1001
|
1.5. **Decomposition guard** <!-- Added: forge#3543 -->: read the `### Decomposition Assessment` of the latest FORGE:INVESTIGATOR comment. If it is `**YES**`, a failing check that belongs to a sub-item the assessment assigns to a separate issue (named in the check's `description`/`# item-N` tag, or matching a listed sub-issue title) is NOT repaired: exclude it from the `--fix-acceptance` list. When ambiguous, treat the check as decomposed-out (fail safe). If every remaining failure is decomposed-out, do not invoke implement: leave `needs-human` and print `BUILD_RESULT: status: BLOCKED`, blocker: "acceptance check {ID} belongs to decomposed item {N} — repair refuses to implement work the investigator assigned to a separate issue".
|
|
@@ -977,7 +1030,7 @@ Print this structured block as your final reply (and nothing after it) — the r
|
|
|
977
1030
|
|
|
978
1031
|
```
|
|
979
1032
|
BUILD_RESULT:
|
|
980
|
-
status: COMPLETE | ALREADY_DONE | INVESTIGATION_COMPLETE |
|
|
1033
|
+
status: COMPLETE | ALREADY_DONE | INVESTIGATION_COMPLETE | BLOCKED
|
|
981
1034
|
branch: {BRANCH}
|
|
982
1035
|
worktree: {WORKTREE_PATH}
|
|
983
1036
|
blocker: {description if status=BLOCKED}
|
|
@@ -986,8 +1039,7 @@ BUILD_RESULT:
|
|
|
986
1039
|
- `COMPLETE` — B0–B6.5 all passed; `FORGE:BUILDER:COMPLETE` is on the issue.
|
|
987
1040
|
- `ALREADY_DONE` — B0 found `FORGE:BUILDER:COMPLETE`.
|
|
988
1041
|
- `INVESTIGATION_COMPLETE` — the implement child created the deliverable issues (B5); no review follows.
|
|
989
|
-
-
|
|
990
|
-
- `COHESIVE` is not a `BUILD_RESULT` status: B5.5 records `result: COHESIVE` in `FORGE:DIFF_SIZE` for an over-gate sub-issue dominated by one new module plus tests, then the build continues to B6 and ends `COMPLETE`.
|
|
1042
|
+
- 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 -->
|
|
991
1043
|
- `BLOCKED` — any guard or failure; `needs-human` is set and the blocker is posted.
|
|
992
1044
|
|
|
993
1045
|
---
|