forgedock 1.17.0 → 1.19.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 +37 -2
- package/bin/engine/phases.mjs +207 -106
- package/bin/engine/runlog.mjs +17 -2
- package/bin/engine/telemetry-seam.mjs +3 -0
- package/bin/engine/telemetry.test.mjs +3 -2
- package/bin/engine-cli.mjs +37 -2
- package/bin/engine.mjs +221 -15
- package/bin/forgedock.mjs +1 -1
- package/bin/hooks/pre-tool-use.mjs +65 -32
- package/bin/runner.mjs +125 -6
- package/bin/stats/aggregate.mjs +7 -3
- package/bin/stats/cache.mjs +7 -3
- package/bin/stats/github-source.mjs +203 -24
- package/bin/stats/index.mjs +46 -9
- package/bin/stats/render.mjs +1 -1
- package/bin/stats/stats.test.mjs +264 -15
- package/commands/cleanup.md +10 -3
- package/commands/deploy-pr.md +64 -0
- package/commands/fix-ci.md +20 -6
- package/commands/milestone.md +34 -17
- package/commands/orchestrate/phase-4-execution.md +11 -4
- package/commands/review-pr-staging.md +145 -522
- package/commands/review-pr.md +496 -3124
- package/commands/test-gate.md +25 -10
- package/commands/work-on/build/architect.md +66 -1
- package/commands/work-on/build/implement.md +21 -1
- package/commands/work-on/build/validate.md +322 -28
- package/commands/work-on/build.md +173 -95
- package/commands/work-on/close.md +50 -15
- package/commands/work-on/decompose.md +72 -34
- package/commands/work-on/investigate.md +16 -12
- package/commands/work-on/remediate.md +58 -25
- package/commands/work-on/review.md +36 -21
- package/commands/work-on.md +1 -2
- package/docs/CONFIG.md +132 -26
- package/docs/OUTCOME-CODES.md +12 -3
- package/docs/RELEASE-NOTES.md +12 -0
- package/docs/STATS.md +13 -5
- package/docs/TELEMETRY.md +7 -4
- package/docs/WORK-ON-RUNTIME.md +24 -0
- package/docs/spec/forge-protocol-v1.md +2 -2
- package/package.json +2 -2
- package/packages/protocol/src/phases.js +5 -5
- package/packages/protocol/src/types.js +1 -1
- package/scripts/acceptance-red-green.sh +90 -0
- package/scripts/acceptance-red-green.test.sh +74 -0
- package/scripts/cap-findings.sh +1 -1
- package/scripts/cap-findings.test.sh +3 -1
- package/scripts/check-contract-scope.test.sh +2 -0
- package/scripts/check-edge-cases.sh +143 -0
- package/scripts/check-edge-cases.test.sh +175 -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/check-test-tmp-cleanup.sh +56 -0
- package/scripts/check-test-tmp-cleanup.test.sh +69 -0
- package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
- package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
- package/scripts/ci-local/adapters/buildkite.mjs +58 -0
- package/scripts/ci-local/adapters/circleci.mjs +101 -0
- package/scripts/ci-local/adapters/common.mjs +115 -0
- package/scripts/ci-local/adapters/explicit.mjs +21 -0
- package/scripts/ci-local/adapters/github-actions.mjs +349 -0
- package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
- package/scripts/ci-local/adapters/portability.mjs +104 -0
- package/scripts/ci-local/adapters/task-runners.mjs +67 -0
- package/scripts/ci-local/cli.mjs +50 -0
- package/scripts/ci-local/lib.mjs +371 -0
- package/scripts/ci-local/plan.mjs +186 -0
- package/scripts/ci-local/run.mjs +345 -0
- package/scripts/ci-local/scoping.mjs +508 -0
- package/scripts/ci-local.sh +97 -0
- package/scripts/ci-local.test.sh +919 -0
- package/scripts/classify-finding.sh +32 -6
- package/scripts/classify-finding.test.sh +23 -0
- package/scripts/classify-test-run.sh +69 -0
- package/scripts/classify-test-run.test.sh +90 -0
- package/scripts/close-parent-c3.test.sh +71 -0
- package/scripts/consumer-sweep.sh +569 -0
- package/scripts/consumer-sweep.test.sh +357 -0
- package/scripts/detect-append-registries.sh +106 -0
- package/scripts/detect-append-registries.test.sh +64 -0
- package/scripts/diff-size.sh +93 -4
- package/scripts/diff-size.test.sh +59 -2
- package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
- package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
- package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
- package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
- package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
- package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
- package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
- package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
- package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
- package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
- package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
- package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
- package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
- package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
- package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
- package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
- package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
- package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
- package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
- package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
- package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
- package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
- package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
- package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
- package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
- package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
- package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
- package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
- package/scripts/forge-root.test.sh +14 -12
- package/scripts/lib/telemetry.sh +1 -0
- package/scripts/publish-staging-sync.test.sh +48 -0
- package/scripts/remediate-approved-head.test.sh +2 -0
- package/scripts/remediate-basesync.test.sh +1 -1
- package/scripts/resolve-deploy-gate.sh +32 -6
- package/scripts/resolve-deploy-gate.test.sh +42 -1
- package/scripts/review-acceptance-gate.sh +2 -2
- package/scripts/review-acceptance-gate.test.sh +20 -0
- package/scripts/review-delta.sh +8 -1
- package/scripts/review-delta.test.sh +19 -0
- package/scripts/review-finalize.sh +1579 -0
- package/scripts/review-finalize.test.sh +1040 -0
- package/scripts/review-pr-delta-wiring.test.sh +32 -72
- package/scripts/review-pr-domain-map.test.sh +18 -13
- package/scripts/review-pr-gate2.test.sh +9 -65
- package/scripts/review-pr-staging-scope.test.sh +14 -98
- package/scripts/review-prepare.sh +53 -15
- package/scripts/review-prepare.test.sh +37 -3
- package/scripts/review-roster.sh +16 -2
- package/scripts/review-roster.test.sh +22 -0
- package/scripts/run-shell-tests.sh +5 -0
- package/scripts/telemetry.test.sh +2 -0
- package/scripts/trusted-comments.test.sh +11 -3
- package/scripts/verify-phase-trail.test.sh +8 -0
- package/scripts/wait-ci-green.sh +61 -9
- package/scripts/wait-ci-green.test.sh +44 -6
- package/scripts/with-run-tmpdir.sh +20 -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}
|
|
@@ -176,25 +176,10 @@ EXISTING_FAST_PATH=$(printf '%s' "$CYCLE_SLICE" | bash "$TRUSTED_SCRIPT" bodies
|
|
|
176
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
177
|
|
|
178
178
|
**Resume check**:
|
|
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
|
|
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):
|
|
180
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".
|
|
181
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).
|
|
182
|
-
- Newest is `FORGE:ACCEPTANCE_GATE:PASSED
|
|
183
|
-
- **Size-gate resume** (checked BEFORE the partial-BUILDER rule below; first match wins) <!-- Added: forge#3463 -->: if a `<!-- FORGE:BUILDER -->` comment exists without `<!-- FORGE:BUILDER:COMPLETE -->` AND the latest trusted `FORGE:DIFF_SIZE` comment has `result: OVER` or `result: COHESIVE`, the build stopped at a deliberate B5.5 exit (`NEEDS_DECOMPOSE`, the decompose-loop-guard Blocked exit, or a crash right after the `COHESIVE` comment was posted), not in an implement crash. Keep the BUILDER comment, do not re-run B2-B5, and re-enter at **Phase B5.5**. Read the marker through the same anchored, trust-filtered lookup B5.5 uses (script resolution block verbatim):
|
|
184
|
-
```bash
|
|
185
|
-
# <Script resolution block from above goes here, verbatim>
|
|
186
|
-
set -o pipefail
|
|
187
|
-
TRUSTED_SCRIPT="${UNIVERSAL_DIR:+$UNIVERSAL_DIR/trusted-comments.sh}"
|
|
188
|
-
[ -n "$TRUSTED_SCRIPT" ] && [ -f "$TRUSTED_SCRIPT" ] || { echo "DIFF_SIZE_LOOKUP=FAILED (trusted-comments.sh unresolved)"; exit 0; }
|
|
189
|
-
# <cycle_slice function from B0, verbatim>
|
|
190
|
-
DS=$(gh api --paginate "repos/{GH_REPO}/issues/{NUMBER}/comments" | cycle_slice | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:DIFF_SIZE') \
|
|
191
|
-
|| { echo "DIFF_SIZE_LOOKUP=FAILED"; exit 0; }
|
|
192
|
-
LAST_RESULT=$(printf '%s\n' "$DS" | jq -r 'select(type == "string")' | sed -n 's/^result: *\([A-Z]*\).*/\1/p' | tail -n 1)
|
|
193
|
-
echo "DIFF_SIZE_RESULT=${LAST_RESULT:-<none>}"
|
|
194
|
-
```
|
|
195
|
-
- `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".
|
|
196
|
-
- `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).
|
|
197
|
-
- 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.
|
|
182
|
+
- Newest is `FORGE:ACCEPTANCE_GATE:PASSED` or `FORGE:ACCEPTANCE_GATE:DEGRADED` (pass-class: the build completed, review gates the merge on the degraded paths), 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`.
|
|
198
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 -->
|
|
199
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 -->
|
|
200
185
|
```bash
|
|
@@ -391,7 +376,7 @@ Every item in the contract's `### Out of Scope` section must carry exactly one d
|
|
|
391
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.
|
|
392
377
|
- Free-text exclusions without a disposition are invalid. `->` is accepted for `→`.
|
|
393
378
|
|
|
394
|
-
**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
|
|
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.
|
|
395
380
|
|
|
396
381
|
**File the `deferred` follow-ups** (skip the filing on resume when the contract already exists):
|
|
397
382
|
|
|
@@ -593,50 +578,91 @@ Skill(skill="{FORGE_SKILL_PREFIX}work-on:build:implement", args="{NUMBER} --repo
|
|
|
593
578
|
|
|
594
579
|
**After subcommand returns** (read its `IMPLEMENT_RESULT:` block):
|
|
595
580
|
- `status: COMPLETE` → continue to B5.5
|
|
596
|
-
- `status: ALREADY_DONE` → continue to B5.5 (
|
|
581
|
+
- `status: ALREADY_DONE` → continue to B5.5 (scope-check what's already there)
|
|
597
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 -->
|
|
598
583
|
- `status: BLOCKED` → Blocked exit with the child's `blocker`
|
|
599
584
|
- Skill not found → Blocked exit, blocker "skill not found: work-on:build:implement"
|
|
600
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`)
|
|
601
|
-
# 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).
|
|
602
587
|
|
|
603
588
|
---
|
|
604
589
|
|
|
605
|
-
## 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.
|
|
606
595
|
|
|
607
|
-
|
|
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:
|
|
597
|
+
|
|
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
|
+
```
|
|
608
618
|
|
|
609
|
-
|
|
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".
|
|
610
620
|
|
|
611
|
-
**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.
|
|
612
622
|
|
|
613
623
|
```bash
|
|
614
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)
|
|
615
629
|
# Fresh origin/base for the merge-base measurement: retry with 10s/30s/60s backoff, record whether it ever succeeded
|
|
616
630
|
FETCH_OK=0
|
|
617
631
|
for _delay in 0 10 30 60; do
|
|
618
632
|
[ "$_delay" -gt 0 ] && sleep "$_delay"
|
|
619
633
|
if git -C "{WORKTREE_PATH}" fetch origin "{PR_BASE}" 2>/dev/null; then FETCH_OK=1; break; fi
|
|
620
634
|
done
|
|
621
|
-
|
|
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
|
|
622
645
|
EXCLUDES=()
|
|
623
646
|
while IFS= read -r _g; do [ -n "$_g" ] && [ "$_g" != "null" ] && EXCLUDES+=(--exclude-glob "$_g"); done \
|
|
624
|
-
< <(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)
|
|
625
648
|
SCRIPT_REF=$(resolve_script 'diff-size')
|
|
626
649
|
case "$SCRIPT_REF" in
|
|
627
|
-
prose:*) echo "
|
|
650
|
+
prose:*) echo "SCOPE_CHECK: scripts/diff-size.sh unresolved (prose tier) — check skipped with a note in the FORGE:BUILDER comment" ;;
|
|
628
651
|
*)
|
|
629
|
-
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=$?
|
|
630
654
|
echo "SIZE_RC=${SIZE_RC}"; echo "FETCH_OK=${FETCH_OK}"; printf '%s\n' "$SIZE_OUT" ;;
|
|
631
655
|
esac
|
|
632
656
|
```
|
|
633
657
|
|
|
634
|
-
- Prose tier (script unresolvable) → skip the
|
|
635
|
-
- Exit 2, empty output, or output without `diff_lines=`, `
|
|
636
|
-
- The same unmeasurable result with `FETCH_OK=1`
|
|
637
|
-
- 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.
|
|
638
662
|
|
|
639
|
-
**Step
|
|
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`.
|
|
664
|
+
|
|
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:
|
|
640
666
|
|
|
641
667
|
```bash
|
|
642
668
|
# <Script resolution block from above goes here, verbatim>
|
|
@@ -650,71 +676,67 @@ JUSTIFICATION=$(printf '%s\n' "$OVR" | jq -r 'select(type == "string") | split("
|
|
|
650
676
|
if [ -n "$JUSTIFICATION" ]; then echo "OVERRIDE=VALID"; else echo "OVERRIDE=NONE"; fi
|
|
651
677
|
```
|
|
652
678
|
|
|
653
|
-
- `OVERRIDE_LOOKUP=FAILED` → Blocked exit with blocker `
|
|
654
|
-
-
|
|
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.
|
|
681
|
+
|
|
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:
|
|
690
|
+
|
|
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`.
|
|
655
711
|
|
|
656
|
-
**Step
|
|
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:
|
|
657
713
|
|
|
658
714
|
```bash
|
|
659
715
|
# <Script resolution block from above goes here, verbatim>
|
|
660
716
|
BODY="<!-- FORGE:DIFF_SIZE -->
|
|
661
|
-
## Diff Size
|
|
717
|
+
## Diff Size and Scope
|
|
662
718
|
|
|
663
719
|
diff_lines: ${DIFF_LINES}
|
|
664
720
|
excluded_lines: ${EXCLUDED_LINES}
|
|
665
|
-
|
|
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}
|
|
666
727
|
result: ${RESULT}
|
|
667
|
-
${
|
|
668
|
-
_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"
|
|
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)
|
|
669
730
|
run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
|
|
670
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)
|
|
671
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"
|
|
672
733
|
else run gh issue comment {NUMBER} {GH_FLAG} --body "$BODY"; fi
|
|
673
734
|
```
|
|
674
735
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
**Step 4 — decide.**
|
|
678
|
-
|
|
679
|
-
| Condition | Result | Action |
|
|
680
|
-
|-----------|--------|--------|
|
|
681
|
-
| `over=false` | `OK` | Post/refresh `FORGE:DIFF_SIZE`, continue to B6 |
|
|
682
|
-
| `over=true`, valid override | `OVERRIDDEN` | Post/refresh `FORGE:DIFF_SIZE` (quote the justification), continue to B6 |
|
|
683
|
-
| `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`) |
|
|
684
|
-
| `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` |
|
|
685
|
-
| `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) |
|
|
686
|
-
|
|
687
|
-
**`### 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.
|
|
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.
|
|
688
737
|
|
|
689
|
-
**
|
|
690
|
-
|
|
691
|
-
```bash
|
|
692
|
-
WIP_REF="forge/wip/{NUMBER}"
|
|
693
|
-
WIP_SHA=""; PATCH_PATH=""
|
|
694
|
-
TREE=$(git -C "{WORKTREE_PATH}" write-tree) \
|
|
695
|
-
&& WIP_SHA=$(git -C "{WORKTREE_PATH}" commit-tree "$TREE" -p HEAD -m "wip: staged hunks for #{NUMBER} (diff-size OVER)") || WIP_SHA=""
|
|
696
|
-
if [ -n "$WIP_SHA" ] && git -C "{WORKTREE_PATH}" push --force origin "${WIP_SHA}:refs/heads/${WIP_REF}" 2>/dev/null; then
|
|
697
|
-
echo "WIP_REF=${WIP_REF}@${WIP_SHA}"
|
|
698
|
-
else
|
|
699
|
-
PATCH_PATH="$(git -C "{WORKTREE_PATH}" rev-parse --git-common-dir)/forge-wip-{NUMBER}.patch"
|
|
700
|
-
git -C "{WORKTREE_PATH}" diff --cached --binary > "$PATCH_PATH" 2>/dev/null && echo "PATCH_PATH=${PATCH_PATH}" || echo "WIP=unavailable"
|
|
701
|
-
fi
|
|
702
|
-
```
|
|
703
|
-
|
|
704
|
-
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.
|
|
705
|
-
|
|
706
|
-
**`NEEDS_DECOMPOSE` exit**: leave the staged worktree and branch as they are (the persisted WIP ref or patch is an additional durable copy, not a replacement); 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:
|
|
707
|
-
|
|
708
|
-
```
|
|
709
|
-
BUILD_RESULT:
|
|
710
|
-
status: NEEDS_DECOMPOSE
|
|
711
|
-
branch: {BRANCH}
|
|
712
|
-
worktree: {WORKTREE_PATH}
|
|
713
|
-
blocker: diff-size-over-threshold: {diff_lines} > {threshold}; split proposal posted in FORGE:DIFF_SIZE
|
|
714
|
-
```
|
|
715
|
-
|
|
716
|
-
**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`.
|
|
717
|
-
# 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.
|
|
718
740
|
|
|
719
741
|
---
|
|
720
742
|
|
|
@@ -733,8 +755,13 @@ CHANGED_FILES=$(git -C "{WORKTREE_PATH}" diff --name-only "origin/{PR_BASE}...HE
|
|
|
733
755
|
```
|
|
734
756
|
|
|
735
757
|
**After subcommand returns** (read its `VALIDATE_RESULT:` block):
|
|
736
|
-
- `gate_passed: true` →
|
|
737
|
-
- `gate_passed: false` → the
|
|
758
|
+
- `gate_passed: true` → record `gate_status` (`PASS` or `DEGRADED`) and `degraded_checks` from the `VALIDATE_RESULT:` block for B6.5 (the FORGE:QUALITY_GATE `**Degraded**:` line is the source of truth); a DEGRADED gate never blocks the build. Verify the `FORGE:QUALITY_GATE` marker exists on the issue (posted by validate V5; docs-only changes exempt). If absent, re-derive `{CHANGED_FILES}` with the B6 command above and re-invoke validate once with the full B6 `Skill(...)` call, including `--files "{CHANGED_FILES}"` (never omit `--files`; an empty list falls back to the git diff); a missing marker is not a pass. Read the job ids under `### Deferred to CI` in the trusted `FORGE:BUILDER` comment (jobs the local CI gate could not prove; remote CI at merge must) for `ci_deferred` in the result. Then continue to Phase B6.5 (acceptance gate)
|
|
759
|
+
- `gate_passed: false` with a `blocker` starting `consumer_unaddressed` → the consumer sweep failed in `blocking` mode. This is a repair, never BLOCKED and never `needs-human` <!-- Added: forge#4017 -->: remove `needs-human` if one is present, then run **one** repair round guarded by the `<!-- FORGE:CONSUMER_REPAIR: issue={NUMBER} -->` marker (count the markers first; if one already exists the round is spent, so go to step 3):
|
|
760
|
+
1. Post the marker with the `UNADDRESSED:` site list from the blocker.
|
|
761
|
+
2. Invoke `Skill(skill="{FORGE_SKILL_PREFIX}work-on:build:implement", args="{NUMBER} --repo {GH_REPO} --gh-flag \"{GH_FLAG}\" --worktree {WORKTREE_PATH} --branch {BRANCH} --base {PR_BASE} --fix-consumers \"<UNADDRESSED lines>\"")`, then re-derive `{CHANGED_FILES}` with the B6 command above (never reuse the pre-repair value).
|
|
762
|
+
3. Re-invoke validate with the full B6 `Skill(...)` call plus `--consumers-final`. That run sweeps in `advisory` mode, so sites that are still unaddressed are recorded in the FORGE:BUILDER comment as `CONSUMERS: unresolved ...` and travel to review as known risk; the gate then passes or fails on its other checks only. Handle its `VALIDATE_RESULT` with the other rules in this list (a `consumer_unaddressed` blocker cannot recur, because `--consumers-final` never fails the sweep).
|
|
763
|
+
- `gate_passed: false` with a `blocker` starting `ci-local:` → the subcommand has already posted its comment and added `needs-human`; print `BUILD_RESULT: status: BLOCKED` with the child's `blocker` (run the Blocked exit if no comment was posted). This blocker (validate V4.8, the local CI gate) is final: validate already spent its 2 fix iterations in the same warm context, so do not re-invoke validate or repair it here <!-- Added: forge#3973 -->
|
|
764
|
+
- `gate_passed: false` (any other blocker) → the subcommand has already posted its comment and added `needs-human`; print `BUILD_RESULT: status: BLOCKED` with the child's `blocker` (run the Blocked exit if no comment was posted)
|
|
738
765
|
- Skill not found → Blocked exit, blocker "skill not found: work-on:build:validate"
|
|
739
766
|
- Returned running/backgrounded/empty (no `VALIDATE_RESULT:`) → do not end the turn to wait; re-read the `FORGE:QUALITY_GATE` marker (consume it if present) and the worktree state before re-invoking, then re-invoke the same child under the bounded re-invoke rule (at most 2, then `child-stalled: validate`); this cap is separate from the single marker-absent re-invoke above
|
|
740
767
|
|
|
@@ -796,6 +823,9 @@ The acceptance spec contains only a skip sentinel (\`type=skipped\`). No automat
|
|
|
796
823
|
```bash
|
|
797
824
|
# <Script resolution block, verbatim>
|
|
798
825
|
GREP_CLS=$(forge_resolve classify-grep-exit.sh) # empty/unresolved = every grep exit>=2 is a real FAIL (fail closed)
|
|
826
|
+
RG_SCRIPT=$(forge_resolve acceptance-red-green.sh) # empty/unresolved = every test= check is DEGRADED, never PASS <!-- Added: forge#3630 -->
|
|
827
|
+
RG_ROWS="" # per-AC red-green record rows for `test=` checks: | AC | test | base | head |
|
|
828
|
+
RG_DEGRADED_LIST="" # test= checks whose base run cannot execute; merged into DEGRADED_LIST after the gate-status block
|
|
799
829
|
GATE_PASS=true
|
|
800
830
|
CORRECTED=""
|
|
801
831
|
FAILED_CHECKS=""
|
|
@@ -843,6 +873,8 @@ while IFS= read -r check_line; do
|
|
|
843
873
|
# Anchored after the matcher field so `phase=` text inside description/target/matcher cannot hijack it; any other value or an absent token is `build` (fail closed).
|
|
844
874
|
PHASE=$(printf '%s\n' "$check_line" | sed -nE 's/^ACCEPTANCE_CHECK: id=[^ ]* type=[^ ]* target=("(([^"\\]|\\.)*)"|[^ ]*) matcher=("(([^"\\]|\\.)*)"|[^ ]*) phase=(build|review)( .*)?$/\7/p')
|
|
845
875
|
[ "$PHASE" = "review" ] || PHASE=build
|
|
876
|
+
# test= (optional, after phase=, before description=): the runnable test that proves the AC, red-green checked below (forge#3630). Absent = no change.
|
|
877
|
+
TEST_RG=$(printf '%s\n' "$check_line" | sed -nE 's/^ACCEPTANCE_CHECK: id=[^ ]* type=[^ ]* target=("(([^"\\]|\\.)*)"|[^ ]*) matcher=("(([^"\\]|\\.)*)"|[^ ]*) phase=(build|review) test="(([^"\\]|\\.)*)"( description=.*)?$/\8/p' | sed 's/\\"/"/g')
|
|
846
878
|
|
|
847
879
|
[ "$TYPE" = "skipped" ] && { MANUAL_CHECKS="${MANUAL_CHECKS}\n- [ ] **$ID** ($DESC)"; continue; }
|
|
848
880
|
|
|
@@ -854,6 +886,8 @@ while IFS= read -r check_line; do
|
|
|
854
886
|
MALFORMED=""
|
|
855
887
|
{ [ "$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)"; }
|
|
856
888
|
{ [ "$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
|
|
889
|
+
# A test= value that would break description= extraction or fails the same static parse is a check defect (never executed).
|
|
890
|
+
if [ -n "$TEST_RG" ]; then case "$TEST_RG" in *description=*) MALFORMED="test= contains description= (ambiguous field boundary)";; esac; [ -n "$MALFORMED" ] || bash -n -c "$TEST_RG" 2>/dev/null || MALFORMED="test= shell syntax error (unbalanced quotes)"; fi
|
|
857
891
|
if [ -n "$MALFORMED" ]; then MALFORMED_CHECKS="${MALFORMED_CHECKS}\n- **$ID** ($DESC): $MALFORMED"; continue; fi
|
|
858
892
|
|
|
859
893
|
RESULT="PASS"
|
|
@@ -878,12 +912,12 @@ while IFS= read -r check_line; do
|
|
|
878
912
|
# grep exit >= 2 is a usage/syntax error (e.g. a pattern starting with `-`), never a verdict on the code; exit 1 stays FAIL.
|
|
879
913
|
# 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.
|
|
880
914
|
[ "$RC" -ge 2 ] && MALFORMED=$(bash "$GREP_CLS" "$RC" "$ERR" "$TARGET" 2>/dev/null)
|
|
881
|
-
[ "$RC" -eq 127 ] && [ -z "$MALFORMED" ] && printf '%s' "$ERR" | grep -
|
|
915
|
+
[ "$RC" -eq 127 ] && [ -z "$MALFORMED" ] && { FIRST=${TARGET#"${TARGET%%[![:space:]]*}"}; FIRST=${FIRST%%[[:space:]]*}; [ -n "$FIRST" ] && [ "${FIRST#*/}" = "$FIRST" ] && ! command -v -- "$FIRST" >/dev/null 2>&1 && printf '%s\n' "$ERR" | grep -qE "(^|: )$(printf '%s' "$FIRST" | sed 's/[][\.*^$(){}?+|/]/\\&/g'):"; } && MALFORMED="command not found (rc 127): the target is not an executable command"
|
|
882
916
|
[ "$RC" -eq 0 ] || [ -n "$MALFORMED" ] || { RESULT="FAIL"; DETAIL="command exited non-zero: $TARGET"; }
|
|
883
917
|
else
|
|
884
918
|
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
|
|
885
919
|
[ "$RC" -ge 2 ] && MALFORMED=$(bash "$GREP_CLS" "$RC" "$ERR" "$TARGET" 2>/dev/null)
|
|
886
|
-
[ "$RC" -eq 127 ] && [ -z "$MALFORMED" ] && printf '%s' "$ERR" | grep -
|
|
920
|
+
[ "$RC" -eq 127 ] && [ -z "$MALFORMED" ] && { FIRST=${TARGET#"${TARGET%%[![:space:]]*}"}; FIRST=${FIRST%%[[:space:]]*}; [ -n "$FIRST" ] && [ "${FIRST#*/}" = "$FIRST" ] && ! command -v -- "$FIRST" >/dev/null 2>&1 && printf '%s\n' "$ERR" | grep -qE "(^|: )$(printf '%s' "$FIRST" | sed 's/[][\.*^$(){}?+|/]/\\&/g'):"; } && MALFORMED="command not found (rc 127): the target is not an executable command"
|
|
887
921
|
if [ -n "$MALFORMED" ]; then :
|
|
888
922
|
elif ! printf '%s\n' "$OUTPUT" | grep -qE "$MATCHER"; then
|
|
889
923
|
if printf '%s\n' "$OUTPUT" | grep -qF -- "$MATCHER"; then CORRECTED="${CORRECTED}\n- **$ID**: output matched as a literal string"
|
|
@@ -903,6 +937,24 @@ while IFS= read -r check_line; do
|
|
|
903
937
|
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
|
|
904
938
|
fi
|
|
905
939
|
|
|
940
|
+
# Red-green proof <!-- Added: forge#3630 -->: only for a build-phase check with test= whose own head result is PASS.
|
|
941
|
+
# The helper runs test= on head, then (head RAN only) on a detached base worktree (`git worktree add --detach` at the merge-base) with head's changed test files overlaid;
|
|
942
|
+
# the worktree is removed on every exit path (trap + explicit `git worktree remove`). Output: `BASE=<..> HEAD=<..>`.
|
|
943
|
+
if [ "$RESULT" = "PASS" ] && [ -n "$TEST_RG" ]; then
|
|
944
|
+
RG_OUT=""; [ -n "$RG_SCRIPT" ] && RG_OUT=$(bash "$RG_SCRIPT" --repo-path "{WORKTREE_PATH}" --base-ref "origin/{PR_BASE}" --test-cmd "$TEST_RG" 2>/dev/null)
|
|
945
|
+
RG_BASE=$(printf '%s\n' "$RG_OUT" | sed -n 's/^BASE=\([A-Z_]*\) HEAD=\([A-Z_]*\)$/\1/p' | head -n 1)
|
|
946
|
+
RG_HEAD=$(printf '%s\n' "$RG_OUT" | sed -n 's/^BASE=\([A-Z_]*\) HEAD=\([A-Z_]*\)$/\2/p' | head -n 1)
|
|
947
|
+
[ -n "$RG_BASE" ] && [ -n "$RG_HEAD" ] || { RG_BASE=UNRUNNABLE; RG_HEAD=UNRUNNABLE; } # helper missing or unparsable: DEGRADED, never PASS
|
|
948
|
+
RG_ID=$(printf '%s' "$TEST_RG" | tr '\n\r|`\\' ' ' | cut -c1-80) # table-safe: no pipe, backtick, backslash or newline; truncated
|
|
949
|
+
RG_ROWS="${RG_ROWS}${RG_ROWS:+$'\n'}| $ID | $RG_ID | $RG_BASE | $RG_HEAD |"
|
|
950
|
+
case "$RG_HEAD:$RG_BASE" in
|
|
951
|
+
RAN:FAILED) : ;; # red on base, green on head: the test proves the AC
|
|
952
|
+
RAN:RAN) RESULT="FAIL"; DETAIL="test passes on base (it never exercised the defect): rewrite the test so it fails on the base commit and passes on head" ;;
|
|
953
|
+
FAILED:*) RESULT="FAIL"; DETAIL="test= command fails on head: $RG_ID" ;;
|
|
954
|
+
*) RG_DEGRADED_LIST="${RG_DEGRADED_LIST}${RG_DEGRADED_LIST:+; }$ID — base run cannot execute (base=$RG_BASE head=$RG_HEAD), so the test is unproven" ;; # a base run that cannot execute is DEGRADED, never PASS
|
|
955
|
+
esac
|
|
956
|
+
fi
|
|
957
|
+
|
|
906
958
|
VERIFIED_COUNT=$((VERIFIED_COUNT+1))
|
|
907
959
|
if [ "$RESULT" = "FAIL" ]; then
|
|
908
960
|
GATE_PASS=false
|
|
@@ -919,6 +971,26 @@ if [ "$VERIFIED_COUNT" -eq 0 ] && [ "$DEFERRED_COUNT" -eq 0 ]; then
|
|
|
919
971
|
fi
|
|
920
972
|
```
|
|
921
973
|
|
|
974
|
+
**Gate status (DEGRADED)** <!-- Added: forge#3628 -->: `GATE_STATUS` and `DEGRADED_LIST` come from the B6 `VALIDATE_RESULT` (`gate_status`, `degraded_checks`). When B6.5 is entered without one (B0 gate resume), read them from the `**Degraded**:` line of the newest trusted `FORGE:QUALITY_GATE` comment; an unreadable lookup is DEGRADED, never a pass. A DEGRADED gate means a test run exercised nothing, a known-slow test was skipped, or the blast-radius check did not complete. It does not fail the build: when every check passes, the gate closes with `<!-- FORGE:ACCEPTANCE_GATE:DEGRADED -->` instead of `:PASSED` and lists the degraded checks. Review must cover each one before merge (`scripts/review-finalize.sh`, `degraded-gate-unreviewed`).
|
|
975
|
+
|
|
976
|
+
```bash
|
|
977
|
+
# <Script resolution block, verbatim>
|
|
978
|
+
TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
|
|
979
|
+
if [ -z "${GATE_STATUS:-}" ]; then
|
|
980
|
+
GATE_STATUS=PASS; DEGRADED_LIST=""
|
|
981
|
+
if [ -z "$TRUSTED_SCRIPT" ] || ! QG_COMMENTS=$(gh api --paginate repos/{GH_REPO}/issues/{NUMBER}/comments 2>/dev/null) \
|
|
982
|
+
|| ! QG_BODIES=$(printf '%s' "$QG_COMMENTS" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:QUALITY_GATE -->'); then
|
|
983
|
+
GATE_STATUS=DEGRADED; DEGRADED_LIST="quality-gate — degraded state unreadable"
|
|
984
|
+
else
|
|
985
|
+
DEGRADED_LIST=$(printf '%s\n' "$QG_BODIES" | tail -n 1 | jq -r 'select(type == "string")' | sed -n 's/^\*\*Degraded\*\*: //p' | head -n 1)
|
|
986
|
+
case "$DEGRADED_LIST" in ''|none) DEGRADED_LIST="" ;; *) GATE_STATUS=DEGRADED ;; esac
|
|
987
|
+
fi
|
|
988
|
+
fi
|
|
989
|
+
# test= checks whose base run cannot execute (forge#3630): merged after the lookup above so it cannot wipe them
|
|
990
|
+
[ -n "$RG_DEGRADED_LIST" ] && { GATE_STATUS=DEGRADED; DEGRADED_LIST="${DEGRADED_LIST:+$DEGRADED_LIST; }$RG_DEGRADED_LIST"; }
|
|
991
|
+
[ "$GATE_STATUS" = "DEGRADED" ] && [ -z "${DEGRADED_LIST:-}" ] && DEGRADED_LIST="unspecified (see FORGE:QUALITY_GATE)"
|
|
992
|
+
```
|
|
993
|
+
|
|
922
994
|
**Post gate result comment**:
|
|
923
995
|
|
|
924
996
|
```bash
|
|
@@ -939,16 +1011,19 @@ $( [ -n "$MANUAL_CHECKS" ] && printf '\n**Manual follow-up (type=skipped; not ve
|
|
|
939
1011
|
<!-- FORGE:ACCEPTANCE_GATE:BLOCKED -->"
|
|
940
1012
|
run gh issue edit {NUMBER} {GH_FLAG} --add-label "needs-human"
|
|
941
1013
|
elif [ "$GATE_PASS" = "true" ]; then
|
|
1014
|
+
GATE_MARKER=PASSED; [ "${GATE_STATUS:-PASS}" = "DEGRADED" ] && GATE_MARKER=DEGRADED
|
|
942
1015
|
run gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:ACCEPTANCE_GATE -->
|
|
943
|
-
## Acceptance Gate —
|
|
1016
|
+
## Acceptance Gate — ${GATE_MARKER}
|
|
944
1017
|
|
|
945
1018
|
All machine-checkable build-phase acceptance criteria verified against real behavior.
|
|
1019
|
+
$( [ "$GATE_MARKER" = "DEGRADED" ] && printf '\n**Degraded checks (verified nothing; the review must name each one under `### Degraded Paths Reviewed` before merge)**: %s\n' "$DEGRADED_LIST" )
|
|
1020
|
+
$( [ -n "$RG_ROWS" ] && printf '\n**Red-green proof (test= checks: must FAIL on base, PASS on head)**:\n\n| AC | test | base | head |\n|----|------|------|------|\n%s\n' "$RG_ROWS" )
|
|
946
1021
|
$( [ -n "$DEFERRED_CHECKS" ] && printf '\n**Deferred to review (phase=review; evaluated against the PR before merge)**:%b\n' "$DEFERRED_CHECKS" )
|
|
947
1022
|
$( [ -n "$MANUAL_CHECKS" ] && printf '\n**Manual follow-up (type=skipped; not verified at build time, confirm after merge/deploy)**:%b\n' "$MANUAL_CHECKS" )
|
|
948
1023
|
$( [ -n "$CORRECTED" ] && printf '\n**Checks corrected (malformed matcher, literal text present)**:%b\n' "$CORRECTED" )
|
|
949
1024
|
$( [ -n "$MALFORMED_CHECKS" ] && printf '\n**malformed-check (not counted as failures; regenerate the acceptance spec by re-running investigate)**:%b\n' "$MALFORMED_CHECKS" )
|
|
950
1025
|
|
|
951
|
-
<!-- FORGE:ACCEPTANCE_GATE
|
|
1026
|
+
<!-- FORGE:ACCEPTANCE_GATE:${GATE_MARKER} -->"
|
|
952
1027
|
else
|
|
953
1028
|
run gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:ACCEPTANCE_GATE -->
|
|
954
1029
|
## Acceptance Gate — FAILED
|
|
@@ -956,6 +1031,7 @@ else
|
|
|
956
1031
|
The following acceptance checks did not pass:
|
|
957
1032
|
|
|
958
1033
|
$(echo -e "$FAILED_CHECKS")
|
|
1034
|
+
$( [ -n "$RG_ROWS" ] && printf '\n**Red-green proof (test= checks: must FAIL on base, PASS on head)**:\n\n| AC | test | base | head |\n|----|------|------|------|\n%s\n' "$RG_ROWS" )
|
|
959
1035
|
$( [ -n "$MALFORMED_CHECKS" ] && printf '\n**malformed-check (not counted as failures; regenerate the acceptance spec by re-running investigate)**:%b\n' "$MALFORMED_CHECKS" )
|
|
960
1036
|
|
|
961
1037
|
Merge is blocked. Fix the failing criteria and re-run the validate phase.
|
|
@@ -967,7 +1043,7 @@ fi
|
|
|
967
1043
|
|
|
968
1044
|
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 -->
|
|
969
1045
|
|
|
970
|
-
Also `malformed-check` <!-- Added: forge#3825 -->: a `command`/`behavior` target that is literally `none` (any case), or
|
|
1046
|
+
Also `malformed-check` <!-- Added: forge#3825 -->: a `command`/`behavior` target that is literally `none` (any case), or whose own first word is not a command (exit 127 with `<first word>:` leading the stderr message and the word a bare name (no `/`) not resolvable via `command -v`; the target runs in its native locale, so verdict-bearing output is never altered and the check does not depend on the English message text). Classification keys on that stderr text, never on the code alone, and only on the target's first word: a script whose inner command is missing (`bash x.sh` exiting 127 on a tool inside it) stays a real FAIL, as does a leading env assignment or compound (`FOO=1 cmd`, `a && missing`). 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.
|
|
971
1047
|
|
|
972
1048
|
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 -->
|
|
973
1049
|
|
|
@@ -988,6 +1064,8 @@ If `GATE_PASS = false` and `SPEC_DEFECT` is not `true`, the build repairs itself
|
|
|
988
1064
|
3. Re-run this whole B6.5 gate. PASS → continue to the checkpoint.
|
|
989
1065
|
4. Still failing (or already repaired once) → leave `needs-human` and print `BUILD_RESULT: status: BLOCKED`, blocker: "Acceptance gate failed after one repair — see FORGE:ACCEPTANCE_GATE comment".
|
|
990
1066
|
|
|
1067
|
+
**Red-green proof (`test=`)** <!-- Added: forge#3630 -->: a `phase=build` check carrying `test="<cmd>"` whose own head result is PASS also runs `scripts/acceptance-red-green.sh`: the test must FAIL on the merge-base commit (head's changed test files overlaid, so a new test file is not a false red) and PASS on head. Every such check gets a row in the gate comment's `| AC | test | base | head |` table. A test that passes on base FAILS the gate (`passes on base`) and enters the one-repair `--fix-acceptance` path with the instruction to rewrite the test so it fails on base. A base run that cannot execute (worktree or overlay failure, timeout, rc 126/127, a runner that exercised nothing, or an unresolved helper) is DEGRADED, never PASS: it is appended to `DEGRADED_LIST` and the gate closes `FORGE:ACCEPTANCE_GATE:DEGRADED` (#3628). A `test=` that contains `description=` or fails `bash -n` is a `malformed-check`. Checks without `test=` evaluate exactly as before.
|
|
1068
|
+
|
|
991
1069
|
If `GATE_PASS = true`: continue to write the phase checkpoint below.
|
|
992
1070
|
|
|
993
1071
|
**When the gate passed — write machine-readable phase checkpoint before returning (MANDATORY)**:
|
|
@@ -1008,17 +1086,17 @@ Print this structured block as your final reply (and nothing after it) — the r
|
|
|
1008
1086
|
|
|
1009
1087
|
```
|
|
1010
1088
|
BUILD_RESULT:
|
|
1011
|
-
status: COMPLETE | ALREADY_DONE | INVESTIGATION_COMPLETE |
|
|
1089
|
+
status: COMPLETE | ALREADY_DONE | INVESTIGATION_COMPLETE | BLOCKED
|
|
1012
1090
|
branch: {BRANCH}
|
|
1013
1091
|
worktree: {WORKTREE_PATH}
|
|
1092
|
+
ci_deferred: [{job id}, ...] # deferred-to-ci jobs from FORGE:BUILDER; empty when none
|
|
1014
1093
|
blocker: {description if status=BLOCKED}
|
|
1015
1094
|
```
|
|
1016
1095
|
|
|
1017
|
-
- `COMPLETE` — B0–B6.5 all passed; `FORGE:BUILDER:COMPLETE` is on the issue.
|
|
1096
|
+
- `COMPLETE` — B0–B6.5 all passed (a DEGRADED acceptance gate, `FORGE:ACCEPTANCE_GATE:DEGRADED`, counts as passed here); `FORGE:BUILDER:COMPLETE` is on the issue.
|
|
1018
1097
|
- `ALREADY_DONE` — B0 found `FORGE:BUILDER:COMPLETE`.
|
|
1019
1098
|
- `INVESTIGATION_COMPLETE` — the implement child created the deliverable issues (B5); no review follows.
|
|
1020
|
-
-
|
|
1021
|
-
- `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`.
|
|
1099
|
+
- 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 -->
|
|
1022
1100
|
- `BLOCKED` — any guard or failure; `needs-human` is set and the blocker is posted.
|
|
1023
1101
|
|
|
1024
1102
|
---
|