forgedock 1.12.1 → 1.13.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/phases.mjs +54 -12
- package/bin/engine/runlog.mjs +12 -0
- package/bin/engine-cli.mjs +3 -1
- package/bin/engine.mjs +64 -16
- package/bin/hooks/interactive-engine.mjs +135 -212
- package/bin/hooks/pre-tool-use.mjs +16 -4
- package/bin/labels.json +5 -0
- package/bin/opencode-adapter.mjs +1 -1
- package/bin/runner.mjs +234 -21
- package/commands/autopilot.md +6 -6
- package/commands/cleanup.md +7 -4
- package/commands/orchestrate/phase-1-resolve.md +1 -1
- package/commands/orchestrate/phase-3-dependency.md +14 -19
- package/commands/orchestrate/phase-4-execution.md +50 -38
- package/commands/orchestrate/phase-6-report.md +6 -0
- package/commands/orchestrate/safety.md +1 -1
- package/commands/pipeline-health/phase-2-metrics.md +39 -0
- package/commands/pipeline-health/phase-5-report.md +1 -0
- package/commands/pipeline-resume.md +1 -1
- package/commands/pipeline-status.md +2 -2
- package/commands/review-pr-agents/api.md +1 -1
- package/commands/review-pr-agents/auth.md +1 -1
- package/commands/review-pr-agents/infra.md +3 -3
- package/commands/review-pr-agents/protocols.md +14 -5
- package/commands/review-pr-agents/scraper.md +1 -1
- package/commands/review-pr-agents/security.md +5 -5
- package/commands/review-pr-agents.md +1 -1
- package/commands/review-pr-staging.md +226 -8
- package/commands/review-pr.md +239 -29
- package/commands/work-on/build/architect.md +36 -0
- package/commands/work-on/build/validate.md +88 -4
- package/commands/work-on/build.md +95 -3
- package/commands/work-on/remediate.md +115 -13
- package/commands/work-on.md +4 -4
- package/docs/CONFIG.md +29 -0
- package/docs/RELEASE-NOTES.md +45 -0
- package/docs/spec/forge-protocol-v1.md +22 -1
- package/docs/spec/review-protocol.md +19 -4
- package/package.json +2 -1
- package/scripts/check-blast-radius.sh +164 -0
- package/scripts/check-blast-radius.test.sh +183 -0
- package/scripts/check-contract-scope.sh +137 -0
- package/scripts/check-contract-scope.test.sh +83 -0
- package/scripts/check-spec-bash.sh +60 -3
- package/scripts/check-spec-bash.test.sh +56 -0
- package/scripts/check-spec-markers.sh +29 -0
- package/scripts/check-test-wiring.sh +57 -0
- package/scripts/check-test-wiring.test.sh +70 -0
- package/scripts/classify-finding.sh +72 -8
- package/scripts/classify-finding.test.sh +77 -8
- package/scripts/close-dossier-no-main-commit.test.sh +12 -12
- package/scripts/diff-size.test.sh +3 -2
- package/scripts/doctor-pipeline-state.sh +35 -0
- package/scripts/forge-annotation.sh +3 -0
- package/scripts/forge-root.test.sh +70 -1
- package/scripts/forge-run.sh +2 -2
- package/scripts/recovery-claim-live.test.sh +10 -7
- package/scripts/remediate-basesync.test.sh +103 -0
- package/scripts/review-delta.sh +206 -0
- package/scripts/review-delta.test.sh +213 -0
- package/scripts/review-pr-gate2.test.sh +84 -0
- package/scripts/review-pr-staging-scope.test.sh +99 -0
- package/scripts/run-shell-tests.sh +115 -0
- package/scripts/select-fix-targets.sh +17 -5
|
@@ -495,6 +495,8 @@ grep -rn "$GATED_FUNCTION" "$SERVICE_DIR" --include="*.py" | grep -v "#"
|
|
|
495
495
|
|
|
496
496
|
For each call site found: read the surrounding gate condition (±10 lines). If the condition includes fields that do NOT require the gated resource (e.g., `extraction_schema` gated behind an LLM key check when `extraction_schema` is processed without an LLM), add that caller file to the FORGE:ARCHITECT affected-paths table with a note explaining the incorrect gate condition.
|
|
497
497
|
|
|
498
|
+
When Phase A2.8 builds the blast-radius manifest, run the sweep over the whole repository (`git grep`), not only `$SERVICE_DIR` — siblings in other directories are the ones a directory-scoped sweep misses.
|
|
499
|
+
|
|
498
500
|
**Do NOT** omit sibling callers from the affected-paths table simply because they were not listed in the issue spec. The architect's scope is determined by code correctness, not by the issue spec's file list. A gate-condition bug that exists identically in 3 router files must be fixed in all 3 — even if the issue only named 1.
|
|
499
501
|
|
|
500
502
|
---
|
|
@@ -575,6 +577,30 @@ The comment below passes the table through in a `### Pattern Sweep` section so t
|
|
|
575
577
|
|
|
576
578
|
---
|
|
577
579
|
|
|
580
|
+
## Phase A2.8: Blast-Radius Manifest *(skip when no existing symbol, field, flag, schema or contract changes)* <!-- Added: forge#3446 -->
|
|
581
|
+
|
|
582
|
+
The caller and sibling sweep (A1, A2.2) must leave a record a machine can check. For every **existing** symbol, field, flag, schema or contract the change modifies, emit one `SYMBOL:` record and one `HIT:` record per file that mentions it. `scripts/check-blast-radius.sh` re-runs each query against the final tree during validate (Phase V4.5) and fails when a matching file is neither changed nor marked `verified-unaffected`.
|
|
583
|
+
|
|
584
|
+
1. **Pick a distinctive query per symbol** — a fixed string matching `^[A-Za-z0-9_.:/-]{3,80}$`, not starting with `-` (for example `billing_principal`, not `id`). The checker runs it as `git grep -l -F -e "$Q"`: a literal search, no regex. A query matching more than 200 files is rejected as too broad, so narrow it.
|
|
585
|
+
2. **Run each query repo-wide** and record every hit file:
|
|
586
|
+
```bash
|
|
587
|
+
Q="{SYMBOL_QUERY}"
|
|
588
|
+
git grep -l -F -e "$Q" -- .
|
|
589
|
+
```
|
|
590
|
+
A grep failure (exit above 1) is not "zero hits": report it and stop instead of emitting an empty manifest.
|
|
591
|
+
3. **Give every hit a disposition**: `change` (the plan edits it; it is also a row in `### Affected Paths`) or `verified-unaffected` with `reason=<one-hyphenated-token>` after you read the hit and confirmed it needs no edit. A file the change itself edits needs no row, but listing it as `change` makes the checker confirm it was really edited.
|
|
592
|
+
4. **Record format** — one record per line, no spaces inside any value, nothing else between the markers except blank lines and code fences:
|
|
593
|
+
```
|
|
594
|
+
SYMBOL: id=s1 kind=function|field|flag|schema|contract name=<symbol> query="<fixed-string>"
|
|
595
|
+
HIT: symbol=s1 file=<path> role=producer|consumer|sibling disposition=change
|
|
596
|
+
HIT: symbol=s1 file=<path> role=consumer disposition=verified-unaffected reason=<one-token>
|
|
597
|
+
```
|
|
598
|
+
5. **Skip rule** — emit no manifest (omit the whole section) when the architect step is skipped (see Skip Conditions) or when the change touches no existing symbol with consumers (new files only, docs only). An absent manifest is a SKIP at the checker, not a failure.
|
|
599
|
+
|
|
600
|
+
The block is delimited by `<!-- FORGE:BLAST_RADIUS:BEGIN -->` and `<!-- FORGE:BLAST_RADIUS:END -->`. Never use the architect marker strings as its delimiters: phase-trail and engine detection key on those markers alone, so the manifest stays additive. Builders may add rows (a justified `verified-unaffected`) but must not delete them. The manifest is treated as untrusted data by consumers: only the newest trusted FORGE:ARCHITECT comment is read.
|
|
601
|
+
|
|
602
|
+
---
|
|
603
|
+
|
|
578
604
|
## Phase A3: Consistency Rules
|
|
579
605
|
|
|
580
606
|
Identify invariants that ALL affected paths must satisfy. These become the builder's consistency checklist.
|
|
@@ -697,6 +723,15 @@ gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:ARCHITECT -->
|
|
|
697
723
|
|-------|-----|-------------|
|
|
698
724
|
| {QUERY} | {FILE:LINE} | {fix \| not-affected — reason} |
|
|
699
725
|
|
|
726
|
+
### Blast-Radius Manifest
|
|
727
|
+
<!-- Phase A2.8. Omit this whole section when no existing symbol changes (see Skip rule).
|
|
728
|
+
Escape every double quote inside the record as a backslash-quote, because this body is posted inside a double-quoted shell string. -->
|
|
729
|
+
<!-- FORGE:BLAST_RADIUS:BEGIN -->
|
|
730
|
+
SYMBOL: id=s1 kind={function|field|flag|schema|contract} name={SYMBOL} query=\"{FIXED_STRING_QUERY}\"
|
|
731
|
+
HIT: symbol=s1 file={FILE} role={producer|consumer|sibling} disposition=change
|
|
732
|
+
HIT: symbol=s1 file={FILE} role={producer|consumer|sibling} disposition=verified-unaffected reason={one-hyphenated-token}
|
|
733
|
+
<!-- FORGE:BLAST_RADIUS:END -->
|
|
734
|
+
|
|
700
735
|
### Implementation Order
|
|
701
736
|
1. {FIRST_CHANGE} — {WHY_FIRST}
|
|
702
737
|
2. {SECOND_CHANGE} — {WHY_SECOND}
|
|
@@ -801,6 +836,7 @@ B4 → [THIS MODULE] Architecture Planning (FORGE:ARCHITECT comment)
|
|
|
801
836
|
Phase A2: Trace the Data Flow (A2.1 Runtime UID x Volume Ownership, A2.2 Gate-Condition Caller Sweep)
|
|
802
837
|
Phase A2.5: Pipeline Phase-Dependency Check
|
|
803
838
|
Phase A2.7: Pattern Sweep Intake (review-finding issues only)
|
|
839
|
+
Phase A2.8: Blast-Radius Manifest (enforced by validate V4.5, passed to review-pr)
|
|
804
840
|
Phase A3: Consistency Rules
|
|
805
841
|
Phase A4: Sequence the Implementation
|
|
806
842
|
Phase A5: Risk Assessment
|
|
@@ -43,7 +43,7 @@ Parse from $ARGUMENTS:
|
|
|
43
43
|
|
|
44
44
|
## Skip Conditions
|
|
45
45
|
|
|
46
|
-
Skip Phases V0–V4 (set `GATE_PASSED=true`, iterations 0) and go straight to Phase V5 (which posts the skip-path quality-gate marker, commits, and marks the build complete) if:
|
|
46
|
+
Skip Phases V0–V4.5 (set `GATE_PASSED=true`, iterations 0) and go straight to Phase V5 (which posts the skip-path quality-gate marker, commits, and marks the build complete) if:
|
|
47
47
|
- Only 1 file was changed AND the shared predicate classifies it as documentation: `printf '%s\n' "<that file>" | bash "$FORGE_ROOT/scripts/is-docs-only.sh"` exits 0 (resolve `FORGE_ROOT` with the canonical block; unresolvable → the gate runs). A single file under `commands/`, `.claude/`, `.agents/`, `hooks/`, … or any agent-instruction Markdown is **code** for this purpose (it contains executable shell and agent instructions) and is never skipped. <!-- field test: phase-4-execution.md was skipped as "docs" -->
|
|
48
48
|
|
|
49
49
|
In all other cases, the gate MUST run.
|
|
@@ -508,6 +508,86 @@ docker exec {CONTAINER_NAME} env | grep {VAR_NAME}
|
|
|
508
508
|
|
|
509
509
|
---
|
|
510
510
|
|
|
511
|
+
## Phase V4.5: Blast-Radius Check *(runs after V4, before V5 — skipped only on the docs-only skip path)* <!-- Added: forge#3446 -->
|
|
512
|
+
|
|
513
|
+
The architect records the caller and sibling sweep as a machine-readable manifest between `<!-- FORGE:BLAST_RADIUS:BEGIN -->` and `<!-- FORGE:BLAST_RADIUS:END -->` inside its FORGE:ARCHITECT comment. This phase runs `check-blast-radius.sh`, a script and not a prompt, which re-runs every manifest query against the final tree and fails on any matching file that is neither changed nor marked `verified-unaffected`.
|
|
514
|
+
|
|
515
|
+
- **Trust**: the manifest drives a gate, so it is read only from the newest comment that `trusted-comments.sh` accepts (the same trust predicate as the phase-trail verifier). An untrusted comment is never parsed.
|
|
516
|
+
- **Install root only**: both scripts resolve from `FORGE_ROOT` and never from the worktree, because the branch under audit must not supply its own gate. An unresolvable script is DEGRADED, never a pass.
|
|
517
|
+
- **Exit codes**: `0` covered or no manifest (SKIP); `1` an `UNLISTED:` or `NOT_DONE:` line was printed, so set `GATE_PASSED=false`, fix the code (or add a justified `verified-unaffected` row to the plan by editing the FORGE:ARCHITECT comment; never delete a row), and re-run V1 onward; anything else, a failed fetch, or an unresolvable script is DEGRADED: append `blast-radius` to `SKIPPED_CHECKS` so it appears in `verification_skipped`, and print the reason. DEGRADED never passes silently and a failed fetch is never treated as "no manifest".
|
|
518
|
+
- **Carry-forward**: each Bash call is a fresh shell, so V4.5's variables do not reach V5. The block prints its result as explicit lines: `BLAST_RADIUS_STATE: <PASS|SKIP|FAIL|DEGRADED>`, plus `GATE_PASSED: false` on FAIL and `SKIPPED_CHECKS+=blast-radius` on DEGRADED. Record those lines in your notes; V5 Step 1 sets `GATE_PASSED` and V5 Step 4 sets `SKIPPED_CHECKS` from them, alongside the V2 notes.
|
|
519
|
+
|
|
520
|
+
```bash
|
|
521
|
+
cd {WORKTREE_PATH}
|
|
522
|
+
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
523
|
+
FORGE_ROOT=""
|
|
524
|
+
# Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
|
|
525
|
+
_h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
|
|
526
|
+
# Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
|
|
527
|
+
_mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
|
|
528
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
|
|
529
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
|
|
530
|
+
_l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
|
|
531
|
+
# Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
|
|
532
|
+
_cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
|
|
533
|
+
# newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
|
|
534
|
+
_v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
|
|
535
|
+
_m="$HOME/.claude/plugins/marketplaces/$_mk"
|
|
536
|
+
# '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
|
|
537
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
|
|
538
|
+
while IFS= read -r _c; do
|
|
539
|
+
case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
|
|
540
|
+
done <<< "$_k"
|
|
541
|
+
fi
|
|
542
|
+
BR_SCRIPT="${FORGE_ROOT:+$FORGE_ROOT/scripts/check-blast-radius.sh}"; [ -f "$BR_SCRIPT" ] || BR_SCRIPT=""
|
|
543
|
+
TRUST_SCRIPT="${FORGE_ROOT:+$FORGE_ROOT/scripts/trusted-comments.sh}"; [ -f "$TRUST_SCRIPT" ] || TRUST_SCRIPT=""
|
|
544
|
+
BR_STATE=""; BR_NOTE=""; BR_FILE=$(mktemp "${TMPDIR:-/tmp}/forge-blast-radius.XXXXXX")
|
|
545
|
+
if [ -z "$BR_SCRIPT" ] || [ -z "$TRUST_SCRIPT" ]; then
|
|
546
|
+
BR_STATE="DEGRADED"; BR_NOTE="check-blast-radius.sh or trusted-comments.sh not found under FORGE_ROOT"
|
|
547
|
+
else
|
|
548
|
+
COMMENTS_JSON=$(gh api --paginate repos/{GH_REPO}/issues/{NUMBER}/comments 2>/dev/null); FETCH_RC=$?
|
|
549
|
+
if [ "$FETCH_RC" -ne 0 ]; then
|
|
550
|
+
BR_STATE="DEGRADED"; BR_NOTE="could not fetch issue comments (rc=${FETCH_RC})"
|
|
551
|
+
else
|
|
552
|
+
BODIES=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUST_SCRIPT" bodies '^<!-- FORGE:ARCHITECT -->'); TRUST_RC=$?
|
|
553
|
+
if [ "$TRUST_RC" -ne 0 ]; then
|
|
554
|
+
BR_STATE="DEGRADED"; BR_NOTE="trusted-comments.sh failed (rc=${TRUST_RC})"
|
|
555
|
+
elif [ -z "$BODIES" ]; then
|
|
556
|
+
BR_STATE="SKIP"; BR_NOTE="no trusted FORGE:ARCHITECT comment"
|
|
557
|
+
else
|
|
558
|
+
# one JSON string per line, newest last
|
|
559
|
+
printf '%s\n' "$BODIES" | tail -n 1 | jq -r . > "$BR_FILE"; JQ_RC=$?
|
|
560
|
+
if [ "$JQ_RC" -ne 0 ]; then
|
|
561
|
+
BR_STATE="DEGRADED"; BR_NOTE="could not decode the FORGE:ARCHITECT comment (rc=${JQ_RC})"
|
|
562
|
+
else
|
|
563
|
+
git fetch origin {PR_BASE} >/dev/null 2>&1 || true
|
|
564
|
+
BR_OUT=$(bash "$BR_SCRIPT" --manifest "$BR_FILE" --base "origin/{PR_BASE}" --repo "{WORKTREE_PATH}" 2>&1); BR_RC=$?
|
|
565
|
+
printf '%s\n' "$BR_OUT"
|
|
566
|
+
case "$BR_RC" in
|
|
567
|
+
0) BR_STATE="PASS" ;;
|
|
568
|
+
1) BR_STATE="FAIL"; GATE_PASSED=false ;;
|
|
569
|
+
*) BR_STATE="DEGRADED"; BR_NOTE="check-blast-radius.sh exited ${BR_RC}" ;;
|
|
570
|
+
esac
|
|
571
|
+
fi
|
|
572
|
+
fi
|
|
573
|
+
fi
|
|
574
|
+
fi
|
|
575
|
+
rm -f "$BR_FILE"
|
|
576
|
+
echo "BLAST_RADIUS: ${BR_STATE}${BR_NOTE:+ — $BR_NOTE}"
|
|
577
|
+
# Carry-forward lines for V5 (fresh shell): read them back in V5 Step 1 and Step 4.
|
|
578
|
+
echo "BLAST_RADIUS_STATE: ${BR_STATE}"
|
|
579
|
+
if [ "$BR_STATE" = "FAIL" ]; then
|
|
580
|
+
echo "GATE_PASSED: false"
|
|
581
|
+
elif [ "$BR_STATE" = "DEGRADED" ]; then
|
|
582
|
+
SKIPPED_CHECKS="${SKIPPED_CHECKS:+$SKIPPED_CHECKS, }blast-radius"
|
|
583
|
+
echo "SKIPPED_CHECKS+=blast-radius"
|
|
584
|
+
fi
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
On `FAIL`, the blocker line for `VALIDATE_RESULT` is `blast-radius: <UNLISTED/NOT_DONE lines>`; the repair loop (build B6.5) fixes the listed files, not the checker or the manifest.
|
|
588
|
+
|
|
589
|
+
---
|
|
590
|
+
|
|
511
591
|
## Phase V5: Marker, Commit, Audit, Complete (always — after GATE_PASSED=true)
|
|
512
592
|
|
|
513
593
|
Phase V5 runs in this exact order: **(1)** post the `FORGE:QUALITY_GATE` marker, **(2)** commit, **(3)** ancestry audit, **(4)** append `FORGE:BUILDER:COMPLETE` and the verification status. Do not reorder.
|
|
@@ -518,7 +598,10 @@ The quality gate must leave a checkable artifact. `scripts/verify-phase-trail.sh
|
|
|
518
598
|
|
|
519
599
|
**Skip-path marker**: when the Skip Conditions above set `GATE_PASSED=true` early (single config/docs file), still post the marker with `**Result**: PASS (skipped — single config/docs file)` and `**Iterations**: 0`, so the verifier never has to guess. The verifier's `--docs-only` waiver additionally covers diffs accepted by `scripts/is-docs-only.sh` (allowlisted Markdown only: `docs/**` or root README/CHANGELOG/CONTRIBUTING/SECURITY/GOVERNANCE; nested `AGENTS.md`/`CLAUDE.md`/`SKILL.md`/`GEMINI.md` and the instruction directories `commands/`, `devdocs/`, `templates/`, `skills/`, `agents/`, `hooks/`, `.claude/`, `.claude-plugin/`, `.agents/`, `.codex/`, `.cursor/`, `.github/`, `.opencode/`, `.gemini/`, `.kiro/` excluded; callers feed both sides of renames).
|
|
520
600
|
|
|
601
|
+
Set `GATE_PASSED` from the V1 loop result, then apply V4.5's printed carry-forward line: if V4.5 printed `GATE_PASSED: false` (`BLAST_RADIUS_STATE: FAIL`), `GATE_PASSED` is `false` here even if the V1 loop passed.
|
|
602
|
+
|
|
521
603
|
```bash
|
|
604
|
+
# GATE_PASSED: set from the V1 loop result, and forced to false when V4.5 printed "GATE_PASSED: false"
|
|
522
605
|
GATE_RESULT=$([ "$GATE_PASSED" = "true" ] && echo PASS || echo FAIL)
|
|
523
606
|
# Bind the PASS to what was actually gated: the staged tree is exactly the tree the V5 commit will have.
|
|
524
607
|
# scripts/verify-phase-trail.sh --head-tree (work-on/review.md R1.5) rejects a PASS recorded for a different tree. <!-- Added: forge#3149 -->
|
|
@@ -647,12 +730,12 @@ If the output contains `ANCESTRY_FAILED=1`: do NOT append `:COMPLETE` and do NOT
|
|
|
647
730
|
|
|
648
731
|
After the ancestry audit passes (or is skipped), patch the existing FORGE:BUILDER comment: add the `**Verification Status**` line, the best-effort `cost_usd:` line, and the `<!-- FORGE:BUILDER:COMPLETE -->` marker. This is the **only** place the marker is written — it signals that a real commit exists on the branch and the build is safe to resume-skip. <!-- Added: forge#1305 -->
|
|
649
732
|
|
|
650
|
-
`SKIPPED_CHECKS` comes from Phase V2. Shell state may not persist between Bash calls, so set it here from your V2 notes before running the block (comma-separated check names, empty when every configured check ran).
|
|
733
|
+
`SKIPPED_CHECKS` comes from Phase V2 and Phase V4.5. Shell state may not persist between Bash calls, so set it here from your V2 notes before running the block (comma-separated check names, empty when every configured check ran), and append `blast-radius` when V4.5 printed `SKIPPED_CHECKS+=blast-radius` (`BLAST_RADIUS_STATE: DEGRADED`).
|
|
651
734
|
|
|
652
735
|
**Cost line reconciliation**: the machine-readable `cost_usd:` line (best-effort; only when `PHASE_COST_USD` is available, never blocking) is the single cost signal. Do not add a separate `**Cost (build phase)**` line.
|
|
653
736
|
|
|
654
737
|
```bash
|
|
655
|
-
# SKIPPED_CHECKS: set from the V2 notes, e.g. SKIPPED_CHECKS="python.format,
|
|
738
|
+
# SKIPPED_CHECKS: set from the V2 notes plus V4.5's carry-forward line, e.g. SKIPPED_CHECKS="python.format, blast-radius"
|
|
656
739
|
SKIPPED_CHECKS="${SKIPPED_CHECKS:-}"
|
|
657
740
|
if [ -z "$SKIPPED_CHECKS" ]; then
|
|
658
741
|
VERIFICATION_STATUS="✅ All configured verification commands passed"
|
|
@@ -708,7 +791,7 @@ VALIDATE_RESULT:
|
|
|
708
791
|
blocker: {description if gate_passed=false}
|
|
709
792
|
verification_skipped: [] # empty when all configured checks ran; list of skipped check names otherwise
|
|
710
793
|
# e.g. ["python.format", "typescript.typecheck/build"]
|
|
711
|
-
# populated from SKIPPED_CHECKS in Phase V2
|
|
794
|
+
# populated from SKIPPED_CHECKS in Phase V2 and V4.5
|
|
712
795
|
```
|
|
713
796
|
|
|
714
797
|
---
|
|
@@ -722,6 +805,7 @@ implement (work-on:build:implement) — code written, staged (not committed), FO
|
|
|
722
805
|
→ [THIS MODULE] validate
|
|
723
806
|
V0 self-check → V1 quality-gate loop (Skill quality-gate, forked) → V2 format/verify + known-slow/learned tests
|
|
724
807
|
→ V3 proxy wiring → V3.5 DB advisory → V3.6 browser signals → V4 deploy completeness
|
|
808
|
+
→ V4.5 blast-radius manifest check (check-blast-radius.sh, install-root only)
|
|
725
809
|
→ V5 marker → commit → ancestry audit → BUILDER:COMPLETE
|
|
726
810
|
review (work-on:review) — push, PR, merge
|
|
727
811
|
```
|
|
@@ -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. Every child (B3 context, B4 architect, B5 implement, B6 validate) is invoked via `Skill(...)` — each is a forked sub-skill with its own isolated context. B3 and B4 are invoked for every complexity band except where the band is TRIVIAL or INVESTIGATION (B2/B2.1/B2.5 are also skipped for INVESTIGATION); the children post their own skip markers, so a skipped child is still visible on the issue. Skipping a child without the band justification degrades build quality and fails the phase-trail check.**
|
|
26
|
+
**CRITICAL: You MUST execute ALL phases B0–B6.5 (including B5.5) in order (the one exception is the B0 size-gate resume branch, which enters at B5.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
|
|
|
@@ -173,6 +173,20 @@ EXISTING_FAST_PATH=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments --paginate
|
|
|
173
173
|
|
|
174
174
|
**Resume check**:
|
|
175
175
|
- If `<!-- FORGE:BUILDER:COMPLETE -->` is present in a BUILDER comment → build already complete. Derive BRANCH/WORKTREE_PATH as in B1A/B1C (without creating anything), then print `BUILD_RESULT: status: ALREADY_DONE`.
|
|
176
|
+
- **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`, the build stopped at a deliberate B5.5 exit (`NEEDS_DECOMPOSE` or the decompose-loop-guard Blocked exit), not in a 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):
|
|
177
|
+
```bash
|
|
178
|
+
# <Script resolution block from above goes here, verbatim>
|
|
179
|
+
set -o pipefail
|
|
180
|
+
TRUSTED_SCRIPT="${UNIVERSAL_DIR:+$UNIVERSAL_DIR/trusted-comments.sh}"
|
|
181
|
+
[ -n "$TRUSTED_SCRIPT" ] && [ -f "$TRUSTED_SCRIPT" ] || { echo "DIFF_SIZE_LOOKUP=FAILED (trusted-comments.sh unresolved)"; exit 0; }
|
|
182
|
+
DS=$(gh api --paginate "repos/{GH_REPO}/issues/{NUMBER}/comments" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:DIFF_SIZE') \
|
|
183
|
+
|| { echo "DIFF_SIZE_LOOKUP=FAILED"; exit 0; }
|
|
184
|
+
LAST_RESULT=$(printf '%s\n' "$DS" | jq -r 'select(type == "string")' | sed -n 's/^result: *\([A-Z]*\).*/\1/p' | tail -n 1)
|
|
185
|
+
echo "DIFF_SIZE_RESULT=${LAST_RESULT:-<none>}"
|
|
186
|
+
```
|
|
187
|
+
- `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".
|
|
188
|
+
- `DIFF_SIZE_RESULT=OVER` → 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 / `NEEDS_DECOMPOSE` / `OK` logic).
|
|
189
|
+
- 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
190
|
- 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 -->
|
|
177
191
|
```bash
|
|
178
192
|
PARTIAL_ID=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments --paginate \
|
|
@@ -347,7 +361,83 @@ esac
|
|
|
347
361
|
|
|
348
362
|
**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
363
|
|
|
350
|
-
Post `<!-- FORGE:CONTRACT -->` comment documenting what will be built and why:
|
|
364
|
+
Post `<!-- FORGE:CONTRACT -->` comment documenting what will be built and why. Run B2.0 first: it files the follow-up issues for `deferred` out-of-scope items and validates the section, so `{OUT_OF_SCOPE_ITEMS}` below already holds real issue numbers.
|
|
365
|
+
|
|
366
|
+
### B2.0: Out-of-scope dispositions — file deferred follow-ups, validate the section <!-- Added: forge#3447 -->
|
|
367
|
+
|
|
368
|
+
Every item in the contract's `### Out of Scope` section must carry exactly one disposition, or the section is the single line `None.`. Each bullet names at least one backticked path or symbol so review can match findings against it:
|
|
369
|
+
|
|
370
|
+
```
|
|
371
|
+
- `path/or/symbol` — deferred → #N: <why it is deferred>
|
|
372
|
+
- `path/or/symbol` — not-affected: <evidence, e.g. the grep or call-graph check that shows no impact>
|
|
373
|
+
- `path/or/symbol` — accepted-risk: <reason the risk is acceptable>
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
- **`deferred → #N`**: a follow-up issue `#N` already exists (filed in this step, before the contract is posted). Use it for a sibling path the work leaves alone but that still needs the change.
|
|
377
|
+
- **`not-affected: <evidence>`**: the path was checked and the change does not reach it. The evidence must be concrete. Review never demotes a finding against a `not-affected` item: a finding that contradicts the evidence means the contract was wrong.
|
|
378
|
+
- **`accepted-risk: <reason>`**: a known gap that is knowingly kept. Review demotes a non-security finding on this path to a note.
|
|
379
|
+
- **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.
|
|
380
|
+
- Free-text exclusions without a disposition are invalid. `->` is accepted for `→`.
|
|
381
|
+
|
|
382
|
+
**File the `deferred` follow-ups** (skip the filing on resume when the contract already exists):
|
|
383
|
+
|
|
384
|
+
```bash
|
|
385
|
+
# <Script resolution block from work-on/build.md, verbatim>
|
|
386
|
+
run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
|
|
387
|
+
# For each deferred item (DEFERRED_PATH = the backticked path, DEFERRED_WHY = one line), repeat:
|
|
388
|
+
DEFERRED_MARKER="<!-- FORGE:DEFERRED_FROM: #{NUMBER} -->"
|
|
389
|
+
LOOKUP=$(gh issue list {GH_FLAG} --state open --search "\"FORGE:DEFERRED_FROM: #{NUMBER}\" in:body" \
|
|
390
|
+
--json number,body --limit 50 2>/dev/null) || LOOKUP="FAILED"
|
|
391
|
+
if [ "$LOOKUP" = "FAILED" ]; then
|
|
392
|
+
echo "DEFERRED_LOOKUP=FAILED" # a failed lookup is not "none found": Blocked exit, never re-file blind
|
|
393
|
+
else
|
|
394
|
+
DEFERRED_NUM=$(printf '%s' "$LOOKUP" | jq -r --arg p "$DEFERRED_PATH" \
|
|
395
|
+
'[.[] | select(.body | contains($p))] | first | .number // empty' 2>/dev/null)
|
|
396
|
+
if [ -z "$DEFERRED_NUM" ]; then
|
|
397
|
+
DEFERRED_URL=$(run gh issue create {GH_FLAG} --title "follow-up: ${DEFERRED_PATH} (deferred from #{NUMBER})" \
|
|
398
|
+
--body "## Problem
|
|
399
|
+
|
|
400
|
+
${DEFERRED_WHY}
|
|
401
|
+
|
|
402
|
+
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.
|
|
403
|
+
|
|
404
|
+
## Affected Files
|
|
405
|
+
|
|
406
|
+
- \`${DEFERRED_PATH}\`
|
|
407
|
+
|
|
408
|
+
## Acceptance Criteria
|
|
409
|
+
|
|
410
|
+
- [ ] The concern above is resolved for \`${DEFERRED_PATH}\`
|
|
411
|
+
|
|
412
|
+
${DEFERRED_MARKER}")
|
|
413
|
+
DEFERRED_NUM=$(printf '%s' "$DEFERRED_URL" | grep -oE '[0-9]+$')
|
|
414
|
+
[ -n "${DRY_RUN:-}" ] && DEFERRED_NUM="0"
|
|
415
|
+
fi
|
|
416
|
+
echo "DEFERRED_ISSUE=${DEFERRED_NUM:-}"
|
|
417
|
+
fi
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
- `DEFERRED_LOOKUP=FAILED`, or an empty `DEFERRED_NUM` after a real (non-`DRY_RUN`) create → Blocked exit with blocker `github-unavailable: could not file or look up deferred follow-up for <path>` (a transient failure follows the retry rule above; no `needs-human` for `github-unavailable:`).
|
|
421
|
+
- On resume the lookup reuses the open issue that carries the marker and the same path, so a retried build never files a duplicate.
|
|
422
|
+
- Prefer filing through the `/issue` create-hook's programmatic contract (`Skill(skill="{FORGE_SKILL_PREFIX}issue", ...)`) when it is available; the raw create above is the fallback and must keep the `FORGE:DEFERRED_FROM` marker in the body.
|
|
423
|
+
|
|
424
|
+
**Validate the composed section** with `scripts/check-contract-scope.sh validate` (the exact text that will replace `{OUT_OF_SCOPE_ITEMS}`):
|
|
425
|
+
|
|
426
|
+
```bash
|
|
427
|
+
# <Script resolution block from work-on/build.md, verbatim>
|
|
428
|
+
SCOPE_FILE=$(mktemp "${TMPDIR:-/tmp}/contract-scope.XXXXXX")
|
|
429
|
+
printf '### Out of Scope\n\n%s\n' "{OUT_OF_SCOPE_ITEMS}" > "$SCOPE_FILE"
|
|
430
|
+
SCRIPT_REF=$(resolve_script 'check-contract-scope')
|
|
431
|
+
case "$SCRIPT_REF" in
|
|
432
|
+
prose:*) echo "SCOPE_CHECK=PROSE" ;; # apply the three-disposition rules above by hand
|
|
433
|
+
*) bash "${SCRIPT_REF#*:}" validate "$SCOPE_FILE"; echo "SCOPE_CHECK_RC=$?" ;;
|
|
434
|
+
esac
|
|
435
|
+
rm -f "$SCOPE_FILE"
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
- `SCOPE_CHECK_RC=0` (or `SCOPE_CHECK=PROSE` after a manual check) → post the contract.
|
|
439
|
+
- `SCOPE_CHECK_RC=1` → the stderr lists each bad item. Fix the items (add the missing disposition, file the missing follow-up, name a backticked path), recompose, and re-run once. Still invalid → Blocked exit with blocker `contract-scope-invalid: <first bad item>`.
|
|
440
|
+
- `SCOPE_CHECK_RC=2` → a usage error in this step: fix the invocation and retry once, then Blocked exit.
|
|
351
441
|
|
|
352
442
|
**Before posting, read the attribution config**:
|
|
353
443
|
```bash
|
|
@@ -387,6 +477,8 @@ run gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:CONTRACT -->
|
|
|
387
477
|
${ATTRIBUTION_LINE}"
|
|
388
478
|
```
|
|
389
479
|
|
|
480
|
+
`{OUT_OF_SCOPE_ITEMS}` is the validated B2.0 section body: `None.`, or typed bullets using the literal dispositions `deferred → #N`, `not-affected:` and `accepted-risk:`. Never post free text there.
|
|
481
|
+
|
|
390
482
|
Contract must be grounded in the investigation report. Every deliverable file must appear in the affected files list from the investigator. Adversarially validate the proposed fix against adjacent system layers before posting.
|
|
391
483
|
|
|
392
484
|
### B2.1: Post FORGE:CLAIM on coordination issue (conditional — when running under orchestration batch) <!-- Added: forge#1736 -->
|
|
@@ -608,7 +700,7 @@ BUILD_RESULT:
|
|
|
608
700
|
blocker: diff-size-over-threshold: {diff_lines} > {threshold}; split proposal posted in FORGE:DIFF_SIZE
|
|
609
701
|
```
|
|
610
702
|
|
|
611
|
-
**Resume**: a re-invoked build
|
|
703
|
+
**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` plus a staged diff in the kept worktree), so it does not re-run implement; this covers both exits that leave BUILDER incomplete, `NEEDS_DECOMPOSE` and the decompose-loop-guard Blocked exit. B5.5 re-measures: if still over with no override it 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`.
|
|
612
704
|
# MUST CONTINUE to Phase B6 — the size gate result is intermediate, NOT terminal (validation still required), except the NEEDS_DECOMPOSE and Blocked exits above.
|
|
613
705
|
|
|
614
706
|
---
|
|
@@ -114,7 +114,7 @@ fi
|
|
|
114
114
|
PARTIAL_PR_COMMENT_IDS=$(printf '%s' "$PR_COMMENTS" | jq -rs '[.[][] | select((.body // "") | startswith("<!-- FORGE:REMEDIATION -->")) | select((.body | contains("<!-- FORGE:REMEDIATION:COMPLETE -->")) | not) | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | .id] | .[]' 2>/dev/null || true)
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
-
**Per-kind scoping of the single-attempt guard** (forge#3496): the guard is scoped per remediation kind so a base-sync round is not swallowed by an earlier `ci-gate` / `inpr-fix` remediation on the same PR. A base-sync handoff is recognised by the router's `FORGE:BASESYNC_REMEDIATION: pr={PR_NUMBER}` bound marker on the issue (the router posts it immediately before invoking this phase); a completed base-sync round is recognised by a `FORGE:REMEDIATION:COMPLETE` trail carrying the `**Base sync**: ran` line that Phase M8 posts. The
|
|
117
|
+
**Per-kind scoping of the single-attempt guard** (forge#3496): the guard is scoped per remediation kind so a base-sync round is not swallowed by an earlier `ci-gate` / `inpr-fix` remediation on the same PR. A base-sync handoff is recognised by the router's `FORGE:BASESYNC_REMEDIATION: pr={PR_NUMBER}` bound marker on the issue (the router posts it immediately before invoking this phase); a completed base-sync round is recognised by a `FORGE:REMEDIATION:COMPLETE` trail carrying the `**Base sync**: ran` line that Phase M8 posts. The router handoff is bounded to one per PR (the marker count in `work-on/review.md` R4, unchanged) and this check; inside remediation the sync is bounded per distinct base head and hard-capped at `BASESYNC_MAX_ROUNDS` pushed sync merges on the branch, counted from git rather than shell state (see Phase M3 and M6):
|
|
118
118
|
|
|
119
119
|
```bash
|
|
120
120
|
if [ -n "$TRUSTED_SCRIPT" ] && [ -n "$PR_COMMENTS" ]; then
|
|
@@ -203,12 +203,14 @@ If the worktree/branch checkout fails for any reason (branch deleted, force-push
|
|
|
203
203
|
|
|
204
204
|
## Phase M3: Apply Fixes
|
|
205
205
|
|
|
206
|
-
For each FIXABLE item from Phase M1: read the affected file(s) in `{WORKTREE_PATH}` before editing (never assume current state), apply the fix. Follow the same implementation discipline as `work-on/build/implement.md` I3 (cross-lane import guard, library-callback verification, deliverable-type consistency, no unrequested scope) — this file does not restate those rules, it inherits them.
|
|
206
|
+
For each FIXABLE item from Phase M1 (the base-sync item, when present, goes first; see below): read the affected file(s) in `{WORKTREE_PATH}` before editing (never assume current state), apply the fix. Follow the same implementation discipline as `work-on/build/implement.md` I3 (cross-lane import guard, library-callback verification, deliverable-type consistency, no unrequested scope) — this file does not restate those rules, it inherits them.
|
|
207
207
|
|
|
208
208
|
**Never delete, skip, or weaken a failing check to go green.** Removing, skipping (`skip`, `xfail`, `continue-on-error`, `if: false`, commenting out) or weakening a failing test or CI step is never a valid fix. Fix the cause in the code under test, or classify the item UNFIXABLE, add/re-affirm `needs-human`, and post a comment that names the failing tests and failing assertions (test title plus assertion text from the CI log). The only exception is a deletion justified by removal of the tested code itself (see quality-gate 2U). Enforcement is the quality gate's coverage-reduction check (2U): a `COVERAGE-1` finding is fixed by restoring the test or step, never by suppressing the finding. <!-- Added: forge#3257 -->
|
|
209
209
|
|
|
210
210
|
**Base-sync (merge-only)** <!-- Added: forge#3496 -->: runs when the block reason was a base conflict (`base-conflict`, or `CONFLICTING`/`DIRTY`, or `BEHIND` under required-up-to-date protection). This step is authorized by the operator's batch dispatch and by the router's `FORGE:BASESYNC_REMEDIATION` bound; it applies to the PR's own branch in `{WORKTREE_PATH}` only. Never rebase, never force-push, never merge into or push to a shared branch.
|
|
211
211
|
|
|
212
|
+
**Ordering**: the base sync runs first in M3, right after M2's clean checkout and before any other FIXABLE edit. `git merge` needs a clean tree, and merging over uncommitted edits is refused (rc=1, no merge in progress) or leaves a merge that cannot be safely aborted. Apply the other FIXABLE items only after the sync has concluded (clean merge, or conflicts resolved and committed). Runs with no base-conflict block reason skip this step and are unchanged. <!-- Added: forge#3515 -->
|
|
213
|
+
|
|
212
214
|
```bash
|
|
213
215
|
cd {WORKTREE_PATH}
|
|
214
216
|
CUR_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
|
@@ -216,20 +218,48 @@ case "{HEAD_BRANCH}" in
|
|
|
216
218
|
"{PR_BASE}"|main|master|staging|milestone/*) echo "refusing base sync: {HEAD_BRANCH} is a shared branch"; BASESYNC_REFUSED=true ;;
|
|
217
219
|
esac
|
|
218
220
|
[ "$CUR_BRANCH" = "{HEAD_BRANCH}" ] || BASESYNC_REFUSED=true
|
|
221
|
+
# Clean-tree precondition: never merge over pending edits.
|
|
222
|
+
if ! { git diff --quiet && git diff --cached --quiet; }; then
|
|
223
|
+
echo "refusing base sync: working tree has uncommitted changes"; BASESYNC_REFUSED=true
|
|
224
|
+
fi
|
|
225
|
+
git fetch origin {PR_BASE} {HEAD_BRANCH} # allowlist:check-command-side-effects
|
|
226
|
+
# Durable sync state (forge#3517): every block runs in a fresh shell, so never carry rounds or the base head in shell vars.
|
|
227
|
+
# Rounds = merge commits on the PUSHED branch's first-parent chain that are not on the base (a round counts only once pushed;
|
|
228
|
+
# a manual merge counts too, fail closed). Recorded base head = the newest such merge's second parent, when that is a base commit.
|
|
229
|
+
# An unreadable count is the cap.
|
|
230
|
+
BASESYNC_MAX_ROUNDS=2 # hard cap on sync rounds per PR (M3 plus M6 re-syncs)
|
|
231
|
+
BASESYNC_ROUNDS=$(git rev-list --first-parent --merges --count origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null) || BASESYNC_ROUNDS=$BASESYNC_MAX_ROUNDS
|
|
232
|
+
_lm=$(git rev-list --first-parent --merges -1 origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null || true); _p2=""
|
|
233
|
+
[ -n "$_lm" ] && _p2=$(git rev-parse -q --verify "$_lm^2" 2>/dev/null) && git merge-base --is-ancestor "$_p2" origin/{PR_BASE} 2>/dev/null || _p2=""
|
|
234
|
+
BASESYNC_BASE_SHA="$_p2"
|
|
235
|
+
if [ "$BASESYNC_ROUNDS" -ge "$BASESYNC_MAX_ROUNDS" ]; then
|
|
236
|
+
echo "refusing base sync: ${BASESYNC_ROUNDS} sync round(s) already on {HEAD_BRANCH} (cap ${BASESYNC_MAX_ROUNDS})"; BASESYNC_REFUSED=true
|
|
237
|
+
fi
|
|
219
238
|
if [ "${BASESYNC_REFUSED:-false}" != "true" ]; then
|
|
220
|
-
git
|
|
221
|
-
git merge origin/{PR_BASE} --no-edit # allowlist:check-command-side-effects
|
|
222
|
-
|
|
239
|
+
BASESYNC_BASE_SHA=$(git rev-parse origin/{PR_BASE}) # the base head this round merges; persisted by M8 as `Base sync**: ran (base=<sha>)`
|
|
240
|
+
MERGE_OUT=$(git merge origin/{PR_BASE} --no-edit 2>&1); MERGE_RC=$? # allowlist:check-command-side-effects
|
|
241
|
+
echo "$MERGE_OUT"
|
|
242
|
+
# rc alone cannot tell a conflict from a refusal (both exit 1); MERGE_HEAD can.
|
|
243
|
+
if [ "$MERGE_RC" -eq 0 ]; then
|
|
244
|
+
BASESYNC_RAN=true
|
|
245
|
+
elif git rev-parse -q --verify MERGE_HEAD >/dev/null; then
|
|
246
|
+
BASESYNC_CONFLICTED=true # merge in progress with conflicts: resolve or park below
|
|
247
|
+
else
|
|
248
|
+
BASESYNC_MERGE_REFUSED=true # rc!=0 and no MERGE_HEAD: refused, nothing was merged
|
|
249
|
+
fi
|
|
223
250
|
fi
|
|
224
251
|
```
|
|
225
252
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
-
|
|
253
|
+
Exactly one outcome applies. `BASESYNC_RAN=true` is set only on a clean merge (or after a conflicted merge is concluded below); a refused merge is never treated as synced.
|
|
254
|
+
|
|
255
|
+
- Clean merge (`MERGE_RC=0`): nothing more to resolve; the merge commit is the sync.
|
|
256
|
+
- Conflicts (`MERGE_RC` nonzero, `MERGE_HEAD` present): read BOTH sides of every conflicted hunk and the surrounding code, then write the combined result by hand. No `-X ours`/`-X theirs`, no wholesale `git checkout --ours/--theirs`, no blanket take-one-side. After resolving, `git add` the files and conclude with `git commit -s --no-edit` (the merge commit), then set `BASESYNC_RAN=true`.
|
|
257
|
+
- **Unresolvable with confidence** (a hunk whose intent on either side you cannot reconcile, or the branch/tree is refused above): capture the file list first, then abort only if a merge is in progress, and park:
|
|
229
258
|
```bash
|
|
230
259
|
run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
|
|
231
260
|
CONFLICT_FILES=$(git diff --name-only --diff-filter=U)
|
|
232
|
-
|
|
261
|
+
[ -n "$CONFLICT_FILES" ] || CONFLICT_FILES="(no conflicted files reported)"
|
|
262
|
+
if git rev-parse -q --verify MERGE_HEAD >/dev/null; then git merge --abort; fi # allowlist:check-command-side-effects
|
|
233
263
|
run gh issue comment {ISSUE_NUMBER} {GH_FLAG} --body "<!-- FORGE:BASESYNC_FAILED -->
|
|
234
264
|
Base sync of PR #{PR_NUMBER} (\`origin/{PR_BASE}\` into \`{HEAD_BRANCH}\`) could not be resolved with confidence. Conflicting files:
|
|
235
265
|
|
|
@@ -239,6 +269,19 @@ fi
|
|
|
239
269
|
gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
|
|
240
270
|
```
|
|
241
271
|
EXIT `REMEDIATE_RESULT: status: BLOCKED` with blocker "base-sync: unresolvable conflicts". Only that issue is parked.
|
|
272
|
+
- **Refused merge** (`MERGE_RC` nonzero and no `MERGE_HEAD`, i.e. `BASESYNC_MERGE_REFUSED=true`): git declined to start the merge (for example local changes would be overwritten). This is not a conflict and not a sync. There is nothing to abort. Park with the git output:
|
|
273
|
+
```bash
|
|
274
|
+
run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
|
|
275
|
+
run gh issue comment {ISSUE_NUMBER} {GH_FLAG} --body "<!-- FORGE:BASESYNC_FAILED -->
|
|
276
|
+
Base sync of PR #{PR_NUMBER} (\`origin/{PR_BASE}\` into \`{HEAD_BRANCH}\`) was refused by git (rc=${MERGE_RC}); no merge was started. Git output:
|
|
277
|
+
|
|
278
|
+
\`\`\`
|
|
279
|
+
${MERGE_OUT}
|
|
280
|
+
\`\`\`" # allowlist:check-command-side-effects
|
|
281
|
+
gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
|
|
282
|
+
```
|
|
283
|
+
EXIT `REMEDIATE_RESULT: status: BLOCKED` with blocker "base-sync: merge refused (rc=N)" (N = `MERGE_RC`). Never fall through to M4 as synced.
|
|
284
|
+
- **Re-entry (per distinct base head)**: Phase M3's sync is re-enterable from Phase M6, but only when the base advanced past `BASESYNC_BASE_SHA` and `BASESYNC_ROUNDS` is below `BASESYNC_MAX_ROUNDS`. A re-sync round runs this same block on the new head with the same guards (shared-branch refusal, clean tree, merge-only, no force-push, resolve-or-park, `FORGE:BASESYNC_FAILED`). The block derives `BASESYNC_ROUNDS` and `BASESYNC_BASE_SHA` from git on every entry (pushed sync merges on the branch), never from shell state, and refuses at the cap; an unreadable count is the cap. `BASESYNC_RAN=true` is still set only on a clean or concluded merge.
|
|
242
285
|
- After a sync, the quality gate below re-runs on the merged tree, and Phase M6's re-review covers the new head (the reviewed-head guard forces a fresh review).
|
|
243
286
|
|
|
244
287
|
**Quality Gate** (same loop as Phase 3G, max 3 iterations):
|
|
@@ -284,7 +327,7 @@ ${MERGE_COMMITS}
|
|
|
284
327
|
fi
|
|
285
328
|
```
|
|
286
329
|
|
|
287
|
-
Then commit and push.
|
|
330
|
+
Then commit and push. When a base sync ran, it ran first in Phase M3 on a clean tree and its merge commit already exists; commit only the additional quality-gate or fix edits on top of it.
|
|
288
331
|
|
|
289
332
|
```bash
|
|
290
333
|
cd {WORKTREE_PATH}
|
|
@@ -345,6 +388,41 @@ Note the marker is `<!-- FORGE:REMEDIATION -->` with **no** `:COMPLETE` suffix y
|
|
|
345
388
|
|
|
346
389
|
If no dispatch tool resolves, a missing tool is not a human decision, so do NOT invoke `review-pr`, do NOT add `needs-human`, and leave `workflow:in-review` in place. Set `RE_GATE_OUTCOME="REREVIEW-REQUIRED"`, skip Phase M7, go to Phase M8 (which posts `FORGE:REMEDIATION:COMPLETE` with a `REREVIEW-REQUIRED` re-gate line), and return `REMEDIATE_RESULT: status: REREVIEW_REQUIRED`. The caller re-runs `/review-pr {PR_NUMBER} --auto-merge --issue {ISSUE_NUMBER} --base {PR_BASE}` from a session that has dispatch. The caller owns the terminal fallback if that re-review cannot run (`commands/work-on.md` Phase 0A.1); remediation itself stays single-attempt and adds no `needs-human` here. If a dispatch tool resolves, continue below.
|
|
347
390
|
|
|
391
|
+
**Pre-review freshness pass** (not in dry-run): the quality gate, push and trail took minutes, so re-fetch right before the re-review. The block derives its state from git, as M3 does (a fresh shell has no `BASESYNC_*` vars, forge#3612): it runs only when the pushed branch already carries a sync merge (`BASESYNC_BASE_SHA` non-empty), the base advanced past it, and the round count is below the cap.
|
|
392
|
+
|
|
393
|
+
```bash
|
|
394
|
+
cd {WORKTREE_PATH}
|
|
395
|
+
git fetch origin {PR_BASE} {HEAD_BRANCH} # allowlist:check-command-side-effects
|
|
396
|
+
BASESYNC_MAX_ROUNDS=2
|
|
397
|
+
BASESYNC_ROUNDS=$(git rev-list --first-parent --merges --count origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null) || BASESYNC_ROUNDS=$BASESYNC_MAX_ROUNDS
|
|
398
|
+
_lm=$(git rev-list --first-parent --merges -1 origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null || true); _p2=""
|
|
399
|
+
[ -n "$_lm" ] && _p2=$(git rev-parse -q --verify "$_lm^2" 2>/dev/null) && git merge-base --is-ancestor "$_p2" origin/{PR_BASE} 2>/dev/null || _p2=""
|
|
400
|
+
BASESYNC_BASE_SHA="$_p2"
|
|
401
|
+
NOW_BASE_SHA=$(git rev-parse origin/{PR_BASE})
|
|
402
|
+
FRESH_MERGED=false
|
|
403
|
+
if [ -n "$BASESYNC_BASE_SHA" ] && [ "$NOW_BASE_SHA" != "$BASESYNC_BASE_SHA" ] && [ "$BASESYNC_ROUNDS" -lt "$BASESYNC_MAX_ROUNDS" ] \
|
|
404
|
+
&& [ "$(git rev-parse HEAD)" = "$(git rev-parse origin/{HEAD_BRANCH})" ] && git diff --quiet && git diff --cached --quiet; then
|
|
405
|
+
echo "base advanced since the last sync (${BASESYNC_BASE_SHA} -> ${NOW_BASE_SHA}); re-merging once"
|
|
406
|
+
if git merge origin/{PR_BASE} --no-edit; then # allowlist:check-command-side-effects
|
|
407
|
+
FRESH_MERGED=true
|
|
408
|
+
elif git rev-parse -q --verify MERGE_HEAD >/dev/null; then git merge --abort; fi # allowlist:check-command-side-effects
|
|
409
|
+
fi
|
|
410
|
+
echo "FRESH_MERGED=$FRESH_MERGED"
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
Clean merges only: a conflicted or refused freshness merge is aborted (MERGE_HEAD-guarded) and left to the `base-conflict` handling below. A clean freshness merge (`FRESH_MERGED=true`) is local and unpushed, and it goes through the same gates as any other push before it leaves the worktree:
|
|
414
|
+
|
|
415
|
+
1. Re-run the Phase M3 quality-gate loop on the merged tree.
|
|
416
|
+
2. Run the Phase M4 pre-push ancestry guard block verbatim.
|
|
417
|
+
3. Push with a checked rc:
|
|
418
|
+
|
|
419
|
+
```bash
|
|
420
|
+
cd {WORKTREE_PATH}
|
|
421
|
+
if git push origin HEAD:{HEAD_BRANCH}; then echo "FRESH_PUSHED=true"; else echo "FRESH_PUSHED=false"; fi # allowlist:check-command-side-effects
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
If the gate does not pass, the ancestry guard fails, or the push fails, drop the unpushed merge (`git reset --hard origin/{HEAD_BRANCH}`; local only, the pushed head is untouched, never a force-push) and continue to the re-review on the pushed head. The `base-conflict` handling below then decides. The round counts against `BASESYNC_MAX_ROUNDS` only once pushed, because the count is read from `origin/{HEAD_BRANCH}`. An ancestry-guard failure still parks at `needs-human` as M4 specifies.
|
|
425
|
+
|
|
348
426
|
```
|
|
349
427
|
if DRY_RUN=true:
|
|
350
428
|
record "Would invoke review-pr --auto-merge for PR #{PR_NUMBER}; skipped (dry-run)."
|
|
@@ -374,7 +452,24 @@ If the retained `REVIEW_RESULT` is `PHASE_TRAIL_FAILED` (forge#3102): the gate r
|
|
|
374
452
|
|
|
375
453
|
If the retained `REVIEW_RESULT` is `BLOCKED` with a blocker mentioning the phase trail (e.g. "phase trail unreadable (rc=N)": the Phase 8 verifier exited ≥2 / 127, forge#3147): the gate could not run, so nothing is missing and nothing is re-run. Keep `needs-human` (review-pr Phase 8 already re-asserted it), do not merge, and exit `REMEDIATE_RESULT: status: BLOCKED` with the same blocker.
|
|
376
454
|
|
|
377
|
-
If the retained `REVIEW_RESULT` is `BLOCKED` with blocker "base-conflict" (forge#3496): the base moved again, or the sync did not clear the conflict.
|
|
455
|
+
If the retained `REVIEW_RESULT` is `BLOCKED` with blocker "base-conflict" (forge#3496): either the base moved again during the re-review, or the sync did not clear the conflict. Tell the two apart by base head, not by attempt count:
|
|
456
|
+
|
|
457
|
+
```bash
|
|
458
|
+
cd {WORKTREE_PATH}
|
|
459
|
+
git fetch origin {PR_BASE} {HEAD_BRANCH} # allowlist:check-command-side-effects
|
|
460
|
+
CUR_BASE_SHA=$(git rev-parse origin/{PR_BASE})
|
|
461
|
+
# Recorded head and rounds come from the pushed branch's sync merges (same derivation as M3), never from shell vars or
|
|
462
|
+
# the M8 trail line, which is written after this phase (forge#3517). No recorded head is treated as advanced, but only below the cap.
|
|
463
|
+
BASESYNC_MAX_ROUNDS=2
|
|
464
|
+
BASESYNC_ROUNDS=$(git rev-list --first-parent --merges --count origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null) || BASESYNC_ROUNDS=$BASESYNC_MAX_ROUNDS
|
|
465
|
+
_lm=$(git rev-list --first-parent --merges -1 origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null || true); _p2=""
|
|
466
|
+
[ -n "$_lm" ] && _p2=$(git rev-parse -q --verify "$_lm^2" 2>/dev/null) && git merge-base --is-ancestor "$_p2" origin/{PR_BASE} 2>/dev/null || _p2=""
|
|
467
|
+
BASESYNC_BASE_SHA="$_p2"
|
|
468
|
+
echo "recorded=${BASESYNC_BASE_SHA:-none} current=${CUR_BASE_SHA} rounds=${BASESYNC_ROUNDS}/${BASESYNC_MAX_ROUNDS}"
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
- **Base advanced** (`CUR_BASE_SHA` differs from the recorded `BASESYNC_BASE_SHA`) and `BASESYNC_ROUNDS` is below `BASESYNC_MAX_ROUNDS` (2 pushed sync merges on the branch): this is a new conflict a human did not cause. Re-run the Phase M3 base-sync on the new head (same resolve-or-park rules), then the Phase M4 quality gate, ancestry guard and push, the M5 trail, and this Phase M6 once more. The pushed merge raises the derived count and moves the recorded head, so nothing is carried in shell state. Do not post a second `FORGE:REMEDIATION:COMPLETE`; only M8 does, once.
|
|
472
|
+
- **Base unchanged** (the sync did not clear the conflict) or `BASESYNC_ROUNDS` has reached `BASESYNC_MAX_ROUNDS` (a base that never settles): park. Add `needs-human`, post a comment naming the PR, the base SHA(s) tried (recorded and current) and the conflicting files (`git diff --name-only --diff-filter=U` against a trial merge, or the GitHub mergeability report), and exit `REMEDIATE_RESULT: status: BLOCKED` with blocker "base conflict persists after base-sync remediation".
|
|
378
473
|
|
|
379
474
|
This re-runs the full review (domain agents → verdict → Phase 8 auto-merge gate). The FIXABLE transition above left the issue at the non-terminal `workflow:in-review` state. `review-pr.md` recognizes the in-progress `FORGE:REMEDIATION` marker posted in Phase M5 as evidence of the prior escalation, so one of two things happens inside Phase 8:
|
|
380
475
|
|
|
@@ -543,7 +638,12 @@ esac
|
|
|
543
638
|
REMEDIATED_HEAD_SHA=$(gh pr view {PR_NUMBER} {GH_FLAG} --json headRefOid --jq '.headRefOid' 2>/dev/null || true)
|
|
544
639
|
|
|
545
640
|
# forge#3496: mark a base-sync round so Phase M0 scopes the single-attempt guard per kind.
|
|
546
|
-
|
|
641
|
+
# Persist the merged base head so it survives across blocks: fall back to the merge's second parent when the shell var is unset.
|
|
642
|
+
BASESYNC_LINE=""
|
|
643
|
+
if [ "${BASESYNC_RAN:-false}" = "true" ]; then
|
|
644
|
+
BASESYNC_BASE_SHA="${BASESYNC_BASE_SHA:-$(git -C {WORKTREE_PATH} rev-parse -q --verify 'HEAD^2' 2>/dev/null || true)}"
|
|
645
|
+
BASESYNC_LINE="**Base sync**: ran (base=${BASESYNC_BASE_SHA:-unknown})"
|
|
646
|
+
fi
|
|
547
647
|
|
|
548
648
|
REMEDIATION_BODY="<!-- FORGE:REMEDIATION -->
|
|
549
649
|
## Remediation Complete for PR #{PR_NUMBER}
|
|
@@ -568,6 +668,8 @@ Skill("{FORGE_SKILL_PREFIX}work-on:close", args="{ISSUE_NUMBER} --repo {GH_REPO}
|
|
|
568
668
|
|
|
569
669
|
`work-on:close` handles project board update, final issue body, parent tracker, trajectory log, and worktree cleanup (including the remediation worktree at `{WORKTREE_PATH}`) — do not duplicate any of that here.
|
|
570
670
|
|
|
671
|
+
**Under the headless engine** (forge#3624): the engine does not trust that this close ran. After `AUTO-LANDED` it reads the issue's real state; if the issue is not CLOSED with `workflow:merged`, the engine runs `work-on/close` itself. Close is idempotent (it skips work already done), so the self-driven close above stays correct for the Agent-spawn path.
|
|
672
|
+
|
|
571
673
|
**If the outcome was `REREVIEW-REQUIRED`** (forge#3240): do not invoke close and do not add `needs-human`. Leave the worktree and `workflow:in-review` in place and return `REMEDIATE_RESULT: status: REREVIEW_REQUIRED`; the caller runs the re-review and, on `AUTO-LANDED`, drives close itself; if the re-review cannot run, the caller applies the terminal fallback (`commands/work-on.md` Phase 0A.1).
|
|
572
674
|
|
|
573
675
|
**If the outcome was `HELD-AWAITING-MERGE`, `RE-ESCALATED`, or `UNFIXABLE`**: leave the worktree in place (a human may need it for manual inspection/merge) and return the structured result below without invoking close. Do not close the issue.
|
|
@@ -588,4 +690,4 @@ REMEDIATE_RESULT:
|
|
|
588
690
|
blocker: {description if status=BLOCKED}
|
|
589
691
|
```
|
|
590
692
|
|
|
591
|
-
**Caller behavior**: this Skill already drives its own close phase when `re_gate_outcome: AUTO-LANDED` (see Phase M8) — the caller does not need to invoke close itself. For `re_gate_outcome: REREVIEW-REQUIRED` (`status: REREVIEW_REQUIRED`, forge#3240) the fix is pushed but no review ran because this session has no sub-agent dispatch tool: the caller MUST run `review-pr {PR_NUMBER} --auto-merge --issue {ISSUE_NUMBER} --base {PR_BASE}` from a session that has dispatch (never inline) and then drive close itself on a merge. For every other `re_gate_outcome`, this result is terminal for the current invocation: the issue is left at `needs-human` or `workflow:awaiting-merge`, both already recognized as terminal states in the Universal Phase Dispatcher (see `work-on.md`). Whether invoked standalone (`/work-on <pr> --remediate`) or via the orchestrator's item 6.4 dispatch, no further action is required from the caller.
|
|
693
|
+
**Caller behavior**: this Skill already drives its own close phase when `re_gate_outcome: AUTO-LANDED` (see Phase M8) — the caller does not need to invoke close itself (under the headless engine, the engine verifies the issue is CLOSED + `workflow:merged` and runs close if it is missing). For `re_gate_outcome: REREVIEW-REQUIRED` (`status: REREVIEW_REQUIRED`, forge#3240) the fix is pushed but no review ran because this session has no sub-agent dispatch tool: the caller MUST run `review-pr {PR_NUMBER} --auto-merge --issue {ISSUE_NUMBER} --base {PR_BASE}` from a session that has dispatch (never inline) and then drive close itself on a merge. For every other `re_gate_outcome`, this result is terminal for the current invocation: the issue is left at `needs-human` or `workflow:awaiting-merge`, both already recognized as terminal states in the Universal Phase Dispatcher (see `work-on.md`). Whether invoked standalone (`/work-on <pr> --remediate`) or via the orchestrator's item 6.4 dispatch, no further action is required from the caller.
|
package/commands/work-on.md
CHANGED
|
@@ -224,7 +224,7 @@ fi
|
|
|
224
224
|
Worktree/branch-already-exists and stale-label conditions are surfaced later (by the build phase's worktree step and the `## Error Handling` section) with their own recovery guidance in `docs/site/troubleshooting.md`.
|
|
225
225
|
|
|
226
226
|
### 0A: Parse input
|
|
227
|
-
Extract project prefix and issue number. If `next`/`pick`: list open issues sorted by priority, skip `needs-human`, `workflow:decomposed`, and `workflow:awaiting-merge`, pick highest priority.
|
|
227
|
+
Extract project prefix and issue number. If `next`/`pick`: list open issues sorted by priority, skip `needs-human`, `workflow:remediating`, `workflow:decomposed`, and `workflow:awaiting-merge`, pick highest priority.
|
|
228
228
|
|
|
229
229
|
**Resolve `UNDER_ORCHESTRATION`**: `true` if the invocation args contain `--under-orchestration`, else `false`. This is a single parse done once, here — every later gated block (heartbeats) just checks this variable, no re-parsing.
|
|
230
230
|
|
|
@@ -300,9 +300,9 @@ Skill(skill="{FORGE_SKILL_PREFIX}work-on:remediate", args="${REMEDIATE_PR_NUMBER
|
|
|
300
300
|
1. **Labels first**: read the issue's state and labels (`gh issue view ${ISSUE} {GH_FLAG} --json labels,state`). If the issue is closed, already has `needs-human`, or lacks `workflow:in-review`, do NOT dispatch and do NOT apply the fallback (something else already advanced it); STOP.
|
|
301
301
|
2. **Existing claim**: list the PR's comments and apply the trusted-author, leading-marker predicate. Bash blocks are independent executions and a standalone router session has none of the orchestrator's shell functions, so every claim, read-back, classification and release block MUST paste verbatim, at its top, the **required helper set** from `phase-4-execution.md`: `trail_cache_dir` and `resolve_orch_login` (defined earlier in that file; transitive deps of `rereview_trusted_rows`), then the Step 1 SHARED HELPERS `rereview_trusted_rows`, `REREVIEW_CLAIMS`, `REREVIEW_DONE_FILTER`, `REREVIEW_LEASE_SECS` / `REREVIEW_MAX_SECS`, `REREVIEW_LEASE_FILTER`, `REREVIEW_FIRST_LEASE_FILTER` and `REREVIEW_RELEASED_FILTER`. Never improvise the predicate: too loose lets an untrusted commenter suppress a re-review, too strict drops the router's own bot-authored claim. If a trusted claim (lowest comment id wins) already embeds the PR's current head SHA (`headRefOid`), classify it with the single definition in `phase-4-execution.md` Step 1 (forge#3428, forge#3486: an unexpired or renewed lease, or an expired lease with liveness such as a recent trusted `FORGE:REVIEW*` comment or running checks, means another actor is live (`CLAIMED`); a merged/closed PR or an issue advanced past in-review is done (`NONE`)): for live or done do NOT dispatch; STOP without the fallback. A `FORGE:REREVIEW_RELEASED` marker (`Outcome: SETTLED`) is not a stand-down: step 1 guarantees the issue is still at `workflow:in-review`, so a SETTLED release reads `STALE`, as does an expired lease with no liveness and an open PR (including an unparseable `Lease:`); `STALE` goes to the terminal fallback with reason `stale re-review claim`; a failed liveness lookup fails closed to STOP (never `STALE`). Do not duplicate the predicate here (reference and paste, never re-write).
|
|
302
302
|
2b. **Unresolved login (fail closed, before the claim)**: with the helpers pasted, run `resolve_orch_login`. If it prints an empty login (and `FORGE_BOT_LOGIN` is unset), the router's own claim could never be verified: do NOT post the claim and do NOT dispatch. Treat it as `UNREADABLE` (writes nothing, no terminal fallback write, never `STALE`), report the unresolved login in the operator-visible report line, and STOP; the orchestrator re-checks next cycle (mirrors `phase-4-execution.md` Step 1 SEC-8).
|
|
303
|
-
3. **Claim**: unless `DRY_RUN=true` (then record "would claim and re-review" and STOP), post the marker with `Actor: router`, the head SHA, a unique `Claim:` id and a UTC `Lease:` timestamp, in the exact format defined in `phase-4-execution.md` Step 1 (`<!-- FORGE:REREVIEW_DISPATCHED -->` as the first line, then `Head: <sha>`, `Actor: router`, `Claim: router-<epoch>-<pid>`, `Lease: <UTC ISO-8601>`), with the `gh pr comment` call checked (a failed post is a failure, never ignored).
|
|
304
|
-
4. **Read back** the PR comments (trusted rows only, projecting the comment `id`, after a short settle delay): the lowest-comment-id trusted claim for this head (`.[0]` of the id-sorted `REREVIEW_CLAIMS`) must carry this router's `Claim:` id; re-read once after verifying and treat a late lower-id claim as `LOST`. A missing row, an API failure, or a different earliest claim (`LOST`) means do NOT dispatch, and none of them stands down blindly: re-run the shared Step 1 classification from `phase-4-execution.md` (forge#3431; the same required helper set from step 2, including `trail_cache_dir` and `resolve_orch_login`, pasted at the top of the block, no router-local lease helper or constant) for this head and map the printed `REREVIEW_STATE`: `STALE` (an expired lease with no liveness and an open PR, a lease past `REREVIEW_MAX_SECS`, or an unparseable `Lease:` on the winning claim; a crashed earlier claimant) goes to the terminal fallback with reason `stale re-review claim`; `CLAIMED` (another actor's live claim) and `NONE` (merged, closed, or advanced past in-review) stand down without the fallback; `UNREADABLE` writes nothing (fail closed, never `STALE`); `PENDING` goes to the fallback only for the missing-row / API-failure case. For the missing-row / API-failure case only, the printed `FIRST_CLAIM_ID` (the winning `.[0]` claim) equal to this router's own `
|
|
305
|
-
Only after the read-back verifies does the router invoke `review-pr`. After `review-pr` returns (any result) and the outcome is settled (a labelled terminal state, or the terminal fallback's label write verified; never before, forge#3486), the router posts a checked, DRY_RUN-guarded `<!-- FORGE:REREVIEW_RELEASED -->` marker (`Head: <sha>`, `Actor: router`, `Claim: <
|
|
303
|
+
3. **Claim**: unless `DRY_RUN=true` (then record "would claim and re-review" and STOP), post the marker with `Actor: router`, the head SHA, a unique `Claim:` id and a UTC `Lease:` timestamp, in the exact format defined in `phase-4-execution.md` Step 1 (`<!-- FORGE:REREVIEW_DISPATCHED -->` as the first line, then `Head: <sha>`, `Actor: router`, `Claim: router-<epoch>-<pid>`, `Lease: <UTC ISO-8601>`), with the `gh pr comment` call checked (a failed post is a failure, never ignored). The claim block MUST declare `DRY_RUN` explicitly (a fresh shell has none) and MUST print its claim id as a labelled final line, `CLAIM_ID=router-<epoch>-<pid>` (forge#3472), because step 4 and the release run in later independent blocks that can only use that printed value.
|
|
304
|
+
4. **Read back** the PR comments (trusted rows only, projecting the comment `id`, after a short settle delay): the lowest-comment-id trusted claim for this head (`.[0]` of the id-sorted `REREVIEW_CLAIMS`) must carry this router's `Claim:` id; re-read once after verifying and treat a late lower-id claim as `LOST`. A missing row, an API failure, or a different earliest claim (`LOST`) means do NOT dispatch, and none of them stands down blindly: re-run the shared Step 1 classification from `phase-4-execution.md` (forge#3431; the same required helper set from step 2, including `trail_cache_dir` and `resolve_orch_login`, pasted at the top of the block, no router-local lease helper or constant) for this head and map the printed `REREVIEW_STATE`: `STALE` (an expired lease with no liveness and an open PR, a lease past `REREVIEW_MAX_SECS`, or an unparseable `Lease:` on the winning claim; a crashed earlier claimant) goes to the terminal fallback with reason `stale re-review claim`; `CLAIMED` (another actor's live claim) and `NONE` (merged, closed, or advanced past in-review) stand down without the fallback; `UNREADABLE` writes nothing (fail closed, never `STALE`); `PENDING` goes to the fallback only for the missing-row / API-failure case. For the missing-row / API-failure case only, the printed `FIRST_CLAIM_ID` (the winning `.[0]` claim) equal to this router's own `CLAIM_ID` printed by step 3 goes to the fallback (our own unverified claim must not stand the router down on itself), while another actor's id is classified like `LOST`. On `LOST` the winner is by definition another actor's claim, so a `STALE` winner is never excused as "owned by another actor".
|
|
305
|
+
Only after the read-back verifies does the router invoke `review-pr`. After `review-pr` returns (any result) and the outcome is settled (a labelled terminal state, or the terminal fallback's label write verified; never before, forge#3486), the router posts a checked, DRY_RUN-guarded `<!-- FORGE:REREVIEW_RELEASED -->` marker (`Head: <sha>`, `Actor: router`, `Claim: <the CLAIM_ID printed by step 3>`, `Outcome: SETTLED`) in the format defined in `phase-4-execution.md` Step 2 (Renew and release; the release block also pastes the same required helper set). A release found while the issue is still at `workflow:in-review` is not terminal and reads `STALE`, so the fallback re-applies; the router may also renew the lease between phases by re-posting the claim with the same `Claim:` id and a fresh `Lease:`.
|
|
306
306
|
|
|
307
307
|
**Terminal fallback** (canonical definition; Phase 4R and `commands/orchestrate/phase-4-execution.md` item 6.4 reference it, so the review and remediate paths share one): a re-review that cannot run must never leave the issue at `workflow:in-review` with nothing to advance it. First re-read the issue's labels: if `needs-human` is already set and `workflow:in-review` is absent, the fallback has already been applied (or something else escalated it), so skip the edit and report that state (idempotent; forge#3413). Otherwise post a comment naming the PR and the reason (e.g. `review-pr could not be dispatched: <reason>`), then in ONE `gh issue edit` add `needs-human` and remove `workflow:in-review` (never one without the other), and re-read the labels to verify the write. If the label write failed, report that failure as the outcome instead of treating the fallback as done. Before commenting, re-check the PR state and liveness (forge#3428): a merged or closed PR, or another actor's claimant with recent activity per `phase-4-execution.md` Step 1 (this actor's own `Claim:` id never counts as that claimant, or an own-claim fallback would cancel itself), means skip the escalation and report that state. Never review inline, and never re-nest remediation to retry.
|
|
308
308
|
|