forgedock 1.16.1 → 1.16.2
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 +22 -2
- package/bin/engine.mjs +10 -3
- package/commands/orchestrate/phase-1-resolve.md +2 -2
- package/commands/orchestrate/phase-4-execution.md +8 -8
- package/commands/quality-gate.md +4 -4
- package/commands/review-pr-staging.md +32 -32
- package/commands/review-pr.md +16 -16
- package/commands/work-on/build/validate.md +2 -2
- package/commands/work-on/build.md +51 -19
- package/commands/work-on/close.md +2 -2
- package/commands/work-on/decompose.md +5 -4
- package/commands/work-on/investigate.md +10 -7
- package/commands/work-on/remediate.md +20 -10
- package/commands/work-on/review.md +20 -3
- package/commands/work-on.md +6 -5
- package/docs/CONFIG.md +4 -2
- package/docs/WORK-ON-RUNTIME.md +1 -1
- package/docs/spec/forge-protocol-v1.md +1 -1
- package/package.json +1 -1
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/diff-size.sh +36 -3
- package/scripts/diff-size.test.sh +47 -0
- package/scripts/forge-root.test.sh +10 -2
- package/scripts/lib/forge-resolve.sh +13 -4
- package/scripts/remediate-liveness.sh +96 -0
- package/scripts/review-acceptance-gate.sh +132 -0
- package/scripts/review-acceptance-gate.test.sh +90 -0
|
@@ -77,10 +77,10 @@ fi
|
|
|
77
77
|
# ForgeDock's own install root (holds scripts/ and commands/), resolved ONCE by the shared forge_resolve stub
|
|
78
78
|
# below — never the consumer repo: plugin installs set neither FORGE_HOME nor FORGEDOCK_HOME, and a
|
|
79
79
|
# repo-relative fallback would execute (or miss) a same-named script controlled by the consumer repo.
|
|
80
|
-
# Resolution: $FORGEDOCK_HOME (authoritative when set) >
|
|
80
|
+
# Resolution: $FORGEDOCK_HOME (authoritative when set) > the running plugin root (FORGE_SOURCED_ROOT) > $FORGE_HOME > the
|
|
81
81
|
# ~/.claude/commands symlink target > the Claude Code plugin cache/marketplace dirs. <!-- forge#3098 -->
|
|
82
82
|
# FORGE_ROOT: shared resolver scripts/lib/forge-resolve.sh (empty when unresolved => callers fail closed)
|
|
83
|
-
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
83
|
+
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
84
84
|
FORGE_ROOT="$(forge_root 2>/dev/null || true)"
|
|
85
85
|
UNIVERSAL_DIR="${FORGE_ROOT:+$FORGE_ROOT/scripts}" # empty => tier 3 skipped, prose tier
|
|
86
86
|
# NOTE: never resolve this via `which` or `find` — universal scripts are
|
|
@@ -588,7 +588,7 @@ bash {REPO_PATH}/scripts/code-index.sh query --domain {DOMAIN_LABEL} --repo-path
|
|
|
588
588
|
- Per affected file: a new file counts about its full size; an edit counts about its hunks (added plus deleted lines). Add roughly 1:1 test lines for changed behavior. Skip lockfiles, generated files and fixtures (the gate excludes them).
|
|
589
589
|
- `INDEPENDENT_SEAMS`: the number of independently buildable pieces. For a batch issue (several review findings or checklist members) this is the member count; otherwise count distinct directory/phase seams. Minimum 1.
|
|
590
590
|
- Resolve config (defaults when absent): `THRESHOLD=$(yq '.build.diff_size.threshold // 1000' forge.yaml 2>/dev/null || echo 1000)`, `FACTOR=$(yq '.build.diff_size.estimate_factor // 1.2' forge.yaml 2>/dev/null || echo 1.2)`, `SEAM_MIN=$(yq '.build.diff_size.batch_seam_min // 3' forge.yaml 2>/dev/null || echo 3)`.
|
|
591
|
-
- Rule: when `build.diff_size.threshold` is `0` the rule is skipped (emit `GATE_THRESHOLD: 0`). Otherwise the Decomposition Assessment MUST be `**YES**` when `ESTIMATED_DIFF_LINES` > threshold x `FACTOR` (`estimate_factor`, default 1.2), OR when `INDEPENDENT_SEAMS` >= `batch_seam_min` and `ESTIMATED_DIFF_LINES` > threshold / 2 (not well under the gate). The seam clause does NOT apply to an issue produced by review-finding batching (it has the `batch` label or a `FORGE:BATCH_MEMBERS` block): a batch was formed to be built as one unit, so it is `**YES**` only when `ESTIMATED_DIFF_LINES` > threshold x `FACTOR`, otherwise `**NO**`. <!-- Added: forge#3786 --> When YES, propose `max(2, ceil(ESTIMATED_DIFF_LINES / (0.8 x threshold)))` sub-issues, packing related seams together so each sub-issue's estimate (code plus tests) is at or under 0.8 x threshold; each sub-issue lists the seams it covers (migration-ordering seams may stay separate). Do not propose one sub-issue per seam. A YES that neither condition requires (estimate at or under threshold x `FACTOR` and not the seam clause) needs an own-line `DECOMPOSE_JUSTIFICATION: <reason>` line; the engine treats a YES without one as NO. A pure under-gate issue may stay `**NO**`.
|
|
591
|
+
- Rule: when `build.diff_size.threshold` is `0` the rule is skipped (emit `GATE_THRESHOLD: 0`). Otherwise the Decomposition Assessment MUST be `**YES**` when `ESTIMATED_DIFF_LINES` > threshold x `FACTOR` (`estimate_factor`, default 1.2), OR when `INDEPENDENT_SEAMS` >= `batch_seam_min` and `ESTIMATED_DIFF_LINES` > threshold / 2 (not well under the gate). The seam clause does NOT apply to an issue produced by review-finding batching (it has the `batch` label or a `FORGE:BATCH_MEMBERS` block): a batch was formed to be built as one unit, so it is `**YES**` only when `ESTIMATED_DIFF_LINES` > threshold x `FACTOR`, otherwise `**NO**`. <!-- Added: forge#3786 --> When YES, propose `max(2, ceil(ESTIMATED_DIFF_LINES / (0.8 x threshold)))` sub-issues, packing related seams together so each sub-issue's estimate (code plus tests) is at or under 0.8 x threshold; each sub-issue lists the seams it covers (migration-ordering seams may stay separate). Do not propose one sub-issue per seam. Every proposed sub-issue estimate MUST be at or under 0.8 x threshold (never at the gate size, e.g. a "~1000 lines" sub-issue against a 1000-line gate); a seam that can't fit under 0.8 x threshold (one cohesive module plus its tests with no natural split) MUST be called out explicitly in the assessment, with its estimate, so the later build gate can treat the overage as expected. <!-- Added: forge#3790 --> A YES that neither condition requires (estimate at or under threshold x `FACTOR` and not the seam clause) needs an own-line `DECOMPOSE_JUSTIFICATION: <reason>` line; the engine treats a YES without one as NO. A pure under-gate issue may stay `**NO**`.
|
|
592
592
|
- **Exemption** <!-- Added: forge#3676 -->: emit `**NO**` (state the guard in the reason; still emit the numeric fields) instead of `**YES**` when the issue is already a sub-issue (its body has a `**Parent**: #N` line), a `FORGE:DECOMPOSED` comment exists, or a justified `FORGE:SIZE_OVERRIDE` exists. Decompose Guard 1 refuses these issues, so a YES would stall the run; the over-threshold YES rule applies only otherwise. The engine applies the same guard when it reads the verdict.
|
|
593
593
|
- Emit the numbers in the comment as the own-line fields in Phase 1C. A pure estimate is a heuristic; state it in the reason, do not pad it.
|
|
594
594
|
8. **Fix-approach validation** — if the issue proposes a fix, don't adopt it as spec. Trace through the target system's middleware, auth, routing, config. Cross-domain: if fix in domain A interacts with domain B, read domain B's files too.
|
|
@@ -706,15 +706,18 @@ ESTIMATE_FACTOR: {FACTOR — resolved build.diff_size.estimate_factor, positive
|
|
|
706
706
|
**Quoting (MANDATORY)**: `target=` and `matcher=` MUST always be wrapped in double quotes — `target="..."` / `matcher="..."` — even when the value is a single token (e.g. a plain file path). The downstream Phase B6.5 parser only extracts quoted values; an unquoted `target=`/`matcher=` will silently truncate at the first space and cause a false-negative gate failure for any multi-word value (shell commands with flags/arguments/pipes are almost always multi-word). Neither `target` nor `matcher` may contain a literal `"` character — use single quotes for any embedded string/regex literal inside the value, as shown below. `id=` and `type=` are always single tokens and are never quoted. `description=` is always the last field on the line and is captured to end-of-line — it does not need quoting.
|
|
707
707
|
|
|
708
708
|
```
|
|
709
|
-
ACCEPTANCE_CHECK: id={ac-1} type={exists|contains|command|behavior} target="{file_path|command|url}" matcher="{string|exit_0|regex}" description={one-line human description}
|
|
710
|
-
ACCEPTANCE_CHECK: id={ac-2} type={exists|contains|command|behavior} target="{file_path|command|url}" matcher="{string|exit_0|regex}" description={one-line human description}
|
|
709
|
+
ACCEPTANCE_CHECK: id={ac-1} type={exists|contains|command|behavior} target="{file_path|command|url}" matcher="{string|exit_0|regex}" phase={build|review} description={one-line human description}
|
|
710
|
+
ACCEPTANCE_CHECK: id={ac-2} type={exists|contains|command|behavior} target="{file_path|command|url}" matcher="{string|exit_0|regex}" phase={build|review} description={one-line human description}
|
|
711
711
|
```
|
|
712
712
|
|
|
713
713
|
Example with a multi-word shell command target (the case that previously broke — note the embedded single quotes around the regex, and the double quotes wrapping the whole target):
|
|
714
714
|
```
|
|
715
|
-
ACCEPTANCE_CHECK: id=ac-4 type=command target="grep -qE '(>= ?2|2\+)' commands/orchestrate/phase-1-resolve.md" matcher="exit_0" description=Fan-out cap is documented as >=2 or 2+
|
|
715
|
+
ACCEPTANCE_CHECK: id=ac-4 type=command target="grep -qE '(>= ?2|2\+)' commands/orchestrate/phase-1-resolve.md" matcher="exit_0" phase=build description=Fan-out cap is documented as >=2 or 2+
|
|
716
|
+
ACCEPTANCE_CHECK: id=ac-7 type=command target="gh pr view --json body --jq .body" matcher="time-to-clear|P95" phase=review description=PR body attaches the time-to-clear distribution
|
|
716
717
|
```
|
|
717
718
|
|
|
719
|
+
**Phase tag (MANDATORY)** <!-- Added: forge#3793 -->: `phase=` is an optional token placed after `matcher="..."` and before `description=`; allowed values are `build` (default when absent) and `review`. `build` checks run in the build acceptance gate, against the worktree. `review` checks are deferred by the build gate and evaluated by review after the PR exists. Any check whose target reads a PR artifact (`gh pr view`, `gh pr checks`, `gh pr diff`, review comments) MUST be `phase=review`: the PR does not exist until the review phase opens it, so such a check can never pass at build time. Do not put `phase=` inside `target`, `matcher` or `description` text.
|
|
720
|
+
|
|
718
721
|
**Check types**:
|
|
719
722
|
- `exists` — assert a file or directory exists (`target` = path, `matcher` = ignored)
|
|
720
723
|
- `contains` — assert a file contains a string or regex (`target` = file path, `matcher` = string/regex). **Prefer a literal string** for code: the matcher is applied as an extended regex first, so `$`, `(`, `[`, `.`, `*`, `+`, `?`, `|` must be escaped if you mean them literally (e.g. `return \"\$HELD\"`). The acceptance gate falls back to a literal match, but a correct matcher avoids a needless repair round. For a `command` check that looks for code, use `grep -qF '<literal>' <file>`, not `grep -qE`.
|
|
@@ -727,7 +730,7 @@ ACCEPTANCE_CHECK: id=ac-4 type=command target="grep -qE '(>= ?2|2\+)' commands/o
|
|
|
727
730
|
|
|
728
731
|
**Class-wide checks (review-finding issues)**: when a Pattern Sweep was recorded, emit at least one `ACCEPTANCE_CHECK` that verifies the fix covers every `fix` row (for example a `command` check that the defective pattern no longer matches anywhere in the swept paths) and one that verifies the added test covers a representative set of instances rather than the reviewer's single repro. <!-- Added: forge#3449 -->
|
|
729
732
|
|
|
730
|
-
**Skipping**: if the issue has no verifiable acceptance criteria and none can be derived from the recommendation, emit a single sentinel: `ACCEPTANCE_CHECK: id=ac-skip type=skipped target="none" matcher="none" description=No machine-checkable criteria available — human review required`
|
|
733
|
+
**Skipping**: if the issue has no verifiable acceptance criteria and none can be derived from the recommendation, emit a single sentinel: `ACCEPTANCE_CHECK: id=ac-skip type=skipped target="none" matcher="none" phase=build description=No machine-checkable criteria available — human review required`
|
|
731
734
|
${ANNOTATION_LINK_FOOTER}
|
|
732
735
|
${INVESTIGATION_SENTINEL}"
|
|
733
736
|
```
|
|
@@ -43,6 +43,7 @@ Parse from $ARGUMENTS:
|
|
|
43
43
|
- `--repo {GH_REPO}` — GitHub repo (resolved from `forge.yaml → project` if omitted)
|
|
44
44
|
- `--gh-flag {GH_FLAG}` — gh CLI repo flag
|
|
45
45
|
- `--base {PR_BASE}` — PR target branch (optional; resolved from the PR's `baseRefName` if omitted)
|
|
46
|
+
- `--lease-holder {LEASE_HOLDER}` — the dispatching engine's own lease holder id (optional); lets the Phase M0 liveness step tell ANOTHER run's lease from the dispatcher's own.
|
|
46
47
|
|
|
47
48
|
---
|
|
48
49
|
|
|
@@ -81,7 +82,7 @@ ISSUE_LABELS=$(echo "$ISSUE_STATE" | jq -r '[.labels[].name] | join(",")')
|
|
|
81
82
|
|
|
82
83
|
- If `PR_ALREADY_MERGED` is not `true` and none of `needs-human`, `workflow:remediating` or `workflow:awaiting-merge` is among `ISSUE_LABELS` → EXIT `REMEDIATE_RESULT: status: BLOCKED`, blocker: "issue #{ISSUE_NUMBER} is none of `needs-human`, `workflow:remediating` or `workflow:awaiting-merge` — remediation mode only targets PRs in one of those states; use the normal `/work-on {ISSUE_NUMBER}` resume path instead." This keeps blast radius scoped to exactly the gap this mode fills — it is not a general-purpose re-review trigger. `workflow:remediating` is the non-human state review sets when it hands an autonomous ci-gate / in-pr-fix / base-sync fix to this phase (forge#3541); a bare `needs-human` (older runs, genuine escalations being re-driven) is still accepted. <!-- Added: forge#3541 -->
|
|
83
84
|
- **Resume-at-M7 (forge#3647, hardened forge#3684)**: `workflow:awaiting-merge` is admitted ONLY as a resume of an interrupted M7 auto-land: the PR is OPEN, `needs-human` and `workflow:remediating` are both absent, the idempotency check below finds NO completed `FORGE:REMEDIATION:COMPLETE` trail (`REMEDIATION_COMPLETE_N` = 0), AND there is evidence the PR was engine-parked for the M7 bar rather than parked for a human: the live base is non-empty, non-`null` and not the deploy gate (literal `main`, the GitHub `default_branch`, or the default branch's committed `branches.default`; never read from the PR worktree, forge#3753), and the LATEST trusted (OWNER/MEMBER/COLLABORATOR or Bot) `review-pr.md` Phase 8 park comment on the issue is the non-default-branch "parked at `workflow:awaiting-merge` for the remediation auto-land bar" comment AND names this PR (`PR #{PR_NUMBER} was previously escalated`, so a park for a different PR never admits the resume; M7 then binds approval to the live head SHA, not a timestamp). When all hold, set `RESUME_AT_M7=true`: skip Phases M1-M6 entirely (no checkout, no fixes, no re-review) and continue at Phase M7, which re-verifies the bar from scratch. Without park evidence, return `BLOCKED` (blocker: "awaiting-merge PR is not engine-parked for the remediation auto-land bar"); the issue stays at `workflow:awaiting-merge`. A COMPLETE trail still wins via the `ALREADY_DONE` guard below. A resume that ends `HELD-AWAITING-MERGE` (CI pending, stale approval head, deploy-gate base) writes NO `FORGE:REMEDIATION:COMPLETE` (Phase M8 posts a `FORGE:REMEDIATION_HELD` record instead), so the next invocation re-enters this same resume path rather than exiting `ALREADY_DONE` (forge#3685). On a BLOCKED exit from this path the issue stays at `workflow:awaiting-merge` (it never gains `workflow:remediating`).
|
|
84
|
-
- **Stranded-state rule**: `workflow:remediating` must never outlive this run. Every exit that returns `BLOCKED` or `UNFIXABLE` (including exits before Phase M1; except a `RESUME_AT_M7` run, which leaves the issue at `workflow:awaiting-merge` untouched) removes `workflow:remediating` and adds `needs-human`, because at that point a human really is needed:
|
|
85
|
+
- **Stranded-state rule**: `workflow:remediating` must never outlive this run. Every exit that returns `BLOCKED` or `UNFIXABLE` (including exits before Phase M1; except a `RESUME_AT_M7` run, which leaves the issue at `workflow:awaiting-merge` untouched, and except a `deferred-live-run:` exit, which leaves every label untouched: a live run owns the issue) removes `workflow:remediating` and adds `needs-human`, because at that point a human really is needed:
|
|
85
86
|
|
|
86
87
|
```bash
|
|
87
88
|
gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "needs-human" --remove-label "workflow:remediating" 2>/dev/null || true # allowlist:check-command-side-effects
|
|
@@ -93,7 +94,7 @@ ISSUE_LABELS=$(echo "$ISSUE_STATE" | jq -r '[.labels[].name] | join(",")')
|
|
|
93
94
|
# Trust predicate: ONE shared copy (scripts/trusted-comments.sh). Markers are matched as the comment's LEADING
|
|
94
95
|
# text (^ anchors at the start of the body, not of a line), never with a bare contains(): a review comment or
|
|
95
96
|
# an INPR_FIX work order that merely QUOTES FORGE:REMEDIATION:COMPLETE must not read as a remediation trail (forge#3412).
|
|
96
|
-
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
97
|
+
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
97
98
|
TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
|
|
98
99
|
PR_COMMENTS=$(gh api --paginate repos/{GH_REPO}/issues/{PR_NUMBER}/comments 2>/dev/null) || PR_COMMENTS=""
|
|
99
100
|
if [ -n "$TRUSTED_SCRIPT" ] && [ -n "$PR_COMMENTS" ]; then
|
|
@@ -128,7 +129,16 @@ Completed trails of other kinds are left in place (never deleted); only the inte
|
|
|
128
129
|
- If `REMEDIATION_COMPLETE_N` or `REMEDIATION_TRAIL_N` is empty (comments or `scripts/trusted-comments.sh` unreadable or unresolvable) → fail closed: EXIT `REMEDIATE_RESULT: status: BLOCKED`, blocker: "github-unavailable: remediation trail unreadable (comments or scripts/trusted-comments.sh unresolvable)". Never treat unreadable as "no trail" and never as `ALREADY_DONE`.
|
|
129
130
|
- If `REMEDIATION_COMPLETE_N` is at least 1 (a trusted comment that starts with `<!-- FORGE:REMEDIATION -->` and contains `<!-- FORGE:REMEDIATION:COMPLETE -->`) → EXIT `REMEDIATE_RESULT: status: ALREADY_DONE`. **Single-attempt semantics (AC5)**: once a genuine `FORGE:REMEDIATION:COMPLETE` trail comment exists for this PR, do NOT re-attempt fixes on a subsequent invocation, regardless of the prior verdict — this is what prevents an infinite remediation retry loop on a genuinely-blocked PR. A comment that only quotes the marker mid-body (review findings, an `INPR_FIX` work order, a disposition record) or comes from an untrusted author never counts.
|
|
130
131
|
- **Merged-PR routing (forge#3685)**: if `PR_ALREADY_MERGED=true` and `REMEDIATION_COMPLETE_N` is 0 (the fail-closed unreadable-trail exit above already ran, so the count is known): if the linked issue is already `CLOSED` with the `workflow:merged` label (`ISSUE_STATE`), EXIT `REMEDIATE_RESULT: status: ALREADY_DONE` (landed and finished, nothing to do). Otherwise (not yet `workflow:merged`/CLOSED, no completed trail) set `RESUME_AT_M7=true` and `PR_ALREADY_MERGED=true`: skip Phases M1-M6 and continue at Phase M7, whose merged-first branch is exclusive, runs the idempotent `merged` label bookkeeping, sets `AUTO-LANDED` and goes to M8, which posts the trail and drives close. No park evidence is required. A `FORGE:REMEDIATION_HELD` record never counts toward `REMEDIATION_COMPLETE_N`.
|
|
131
|
-
-
|
|
132
|
+
- **Liveness step (forge#3792)**: when the PR is open, the issue carries `needs-human` or `workflow:remediating` and `REMEDIATION_COMPLETE_N` is 0, run this BEFORE any partial comment is deleted or anything is pushed. Partial trail comments are not evidence of a live run; the script decides from the `FORGE:STATE` lease and the routed re-review. Pass `{LEASE_HOLDER}` only if it matches `^[A-Za-z0-9._-]{1,128}$`, else an empty string.
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
LV_SCRIPT=$(forge_resolve remediate-liveness.sh)
|
|
136
|
+
if [ -n "$LV_SCRIPT" ]; then LV_OUT=$(bash "$LV_SCRIPT" {PR_NUMBER} {ISSUE_NUMBER} {GH_REPO} "{LEASE_HOLDER}"); LV_RC=$?; else LV_OUT="LIVENESS: ERROR script unresolvable"; LV_RC=2; fi
|
|
137
|
+
echo "$LV_OUT"
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`LV_RC` 0 (dead): continue with the cleanup below. 10 (`DEFER`, the script posted a `FORGE:REMEDIATION_DEFERRED` record): EXIT `REMEDIATE_RESULT: status: BLOCKED`, blocker `deferred-live-run: {detail}`, touching nothing (no deletes, labels or push; the plain `git push` is the atomic backstop, a non-fast-forward race is rejected and parks at M4). 11 (`EXHAUSTED`, 3 deferrals already): a normal `BLOCKED` exit with `needs-human`. Any other rc: EXIT `BLOCKED`, blocker `github-unavailable: liveness: {detail}`.
|
|
141
|
+
- If `REMEDIATION_COMPLETE_N` is 0 and `REMEDIATION_TRAIL_N` is at least 1 and the liveness step above said **dead** → a prior attempt was interrupted mid-flight (same failure mode as the investigation phase's partial-comment case). Delete the partial comment(s) on both the PR and the issue (ids from `PARTIAL_PR_COMMENT_IDS` above and the same anchored filter over the issue's comments), then continue below as a fresh attempt:
|
|
132
142
|
```bash
|
|
133
143
|
for _id in $PARTIAL_PR_COMMENT_IDS; do gh api repos/{GH_REPO}/issues/comments/$_id -X DELETE 2>/dev/null || true; done
|
|
134
144
|
ISSUE_PARTIAL_IDS=$(gh api --paginate repos/{GH_REPO}/issues/{ISSUE_NUMBER}/comments 2>/dev/null | 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)
|
|
@@ -143,7 +153,7 @@ Completed trails of other kinds are left in place (never deleted); only the inte
|
|
|
143
153
|
PARK_PR_MARK="PR #{PR_NUMBER} was previously escalated" # fixed string: PR #1 never matches PR #12
|
|
144
154
|
PARK_LAST=$(gh api --paginate repos/{GH_REPO}/issues/{ISSUE_NUMBER}/comments 2>/dev/null | jq -rs '[.[][] | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | select((.body // "") | test("for the remediation auto-land bar|for a human merge decision"))] | last | .body // ""' 2>/dev/null || echo "")
|
|
145
155
|
# forge#3753: trusted resolver only; never forge.yaml from $PWD (the PR worktree is author-controlled); unresolved script => strict.
|
|
146
|
-
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
156
|
+
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
147
157
|
GATE_SCRIPT=$(forge_resolve resolve-deploy-gate.sh)
|
|
148
158
|
if [ -n "$GATE_SCRIPT" ]; then PARK_GATE=$(bash "$GATE_SCRIPT" "$PARK_BASE" {GH_REPO} 2>/dev/null || echo true); else PARK_GATE=true; fi; [ "$PARK_GATE" = "false" ] || PARK_GATE=true
|
|
149
159
|
if [ "$PARK_GATE" = "false" ] && printf '%s' "$PARK_LAST" | grep -qF 'for the remediation auto-land bar' && printf '%s' "$PARK_LAST" | grep -qF "$PARK_PR_MARK"; then
|
|
@@ -541,7 +551,7 @@ fi
|
|
|
541
551
|
|
|
542
552
|
```bash
|
|
543
553
|
LIVE_BASE_REF=$(gh pr view {PR_NUMBER} {GH_FLAG} --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
|
|
544
|
-
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
554
|
+
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
545
555
|
GATE_SCRIPT=$(forge_resolve resolve-deploy-gate.sh)
|
|
546
556
|
if [ -n "$GATE_SCRIPT" ]; then IS_DEPLOY_GATE=$(bash "$GATE_SCRIPT" "$LIVE_BASE_REF" {GH_REPO} 2>/dev/null || echo true); else IS_DEPLOY_GATE=true; fi; [ "${IS_DEPLOY_GATE:-true}" = "false" ] || IS_DEPLOY_GATE=true
|
|
547
557
|
```
|
|
@@ -551,7 +561,7 @@ if [ -n "$GATE_SCRIPT" ]; then IS_DEPLOY_GATE=$(bash "$GATE_SCRIPT" "$LIVE_BASE_
|
|
|
551
561
|
```bash
|
|
552
562
|
# Fresh shell: re-derive the live base (as above); re-fetch PR_LIVE if empty. Unresolved => strict.
|
|
553
563
|
LIVE_BASE_REF=$(gh pr view {PR_NUMBER} {GH_FLAG} --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
|
|
554
|
-
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
564
|
+
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
555
565
|
GATE_SCRIPT=$(forge_resolve resolve-deploy-gate.sh)
|
|
556
566
|
if [ -n "$GATE_SCRIPT" ]; then IS_DEPLOY_GATE=$(bash "$GATE_SCRIPT" "$LIVE_BASE_REF" {GH_REPO} 2>/dev/null || echo true); else IS_DEPLOY_GATE=true; fi; [ "${IS_DEPLOY_GATE:-true}" = "false" ] || IS_DEPLOY_GATE=true
|
|
557
567
|
[ -n "${PR_LIVE:-}" ] || PR_LIVE=$(gh pr view {PR_NUMBER} {GH_FLAG} --json state,mergedAt,headRefOid 2>/dev/null || echo "")
|
|
@@ -625,7 +635,7 @@ A `RESUME_AT_M7=true` run on a deploy-gate base NEVER meets this bar (`BAR_MET=f
|
|
|
625
635
|
```bash
|
|
626
636
|
# Fresh shell: re-derive the live base (as above); :-true fail-closed default.
|
|
627
637
|
LIVE_BASE_REF=$(gh pr view {PR_NUMBER} {GH_FLAG} --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
|
|
628
|
-
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
638
|
+
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
629
639
|
GATE_SCRIPT=$(forge_resolve resolve-deploy-gate.sh)
|
|
630
640
|
if [ -n "$GATE_SCRIPT" ]; then IS_DEPLOY_GATE=$(bash "$GATE_SCRIPT" "$LIVE_BASE_REF" {GH_REPO} 2>/dev/null || echo true); else IS_DEPLOY_GATE=true; fi; [ "${IS_DEPLOY_GATE:-true}" = "false" ] || IS_DEPLOY_GATE=true
|
|
631
641
|
if [ "${IS_DEPLOY_GATE:-true}" = "true" ]; then
|
|
@@ -643,7 +653,7 @@ fi
|
|
|
643
653
|
# CI gate (MANDATORY before any autonomous merge): merge only when every check on the PR is
|
|
644
654
|
# green. Field test: PRs merged to staging with checks pending or red (#3165), because branch
|
|
645
655
|
# protection required none and an auto-merge waits only for *required* checks.
|
|
646
|
-
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
656
|
+
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
647
657
|
CI_GATE_SCRIPT=$(forge_resolve wait-ci-green.sh)
|
|
648
658
|
if [ -n "$CI_GATE_SCRIPT" ]; then CI_GATE_OUT=$(bash "$CI_GATE_SCRIPT" {PR_NUMBER} {GH_FLAG}); CI_GATE_RC=$?
|
|
649
659
|
else CI_GATE_OUT="CI_GATE: ERROR — scripts/wait-ci-green.sh not resolvable (fail closed)"; CI_GATE_RC=2; fi
|
|
@@ -699,7 +709,7 @@ Post the completion body to **both** `{PR_NUMBER}` and `{ISSUE_NUMBER}` — this
|
|
|
699
709
|
```bash
|
|
700
710
|
# Fresh shell: re-derive the live base (as above); :-true fail-closed default.
|
|
701
711
|
LIVE_BASE_REF=$(gh pr view {PR_NUMBER} {GH_FLAG} --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
|
|
702
|
-
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
712
|
+
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
703
713
|
GATE_SCRIPT=$(forge_resolve resolve-deploy-gate.sh)
|
|
704
714
|
if [ -n "$GATE_SCRIPT" ]; then IS_DEPLOY_GATE=$(bash "$GATE_SCRIPT" "$LIVE_BASE_REF" {GH_REPO} 2>/dev/null || echo true); else IS_DEPLOY_GATE=true; fi; [ "${IS_DEPLOY_GATE:-true}" = "false" ] || IS_DEPLOY_GATE=true
|
|
705
715
|
case "$RE_GATE_OUTCOME" in
|
|
@@ -809,7 +819,7 @@ REMEDIATE_RESULT:
|
|
|
809
819
|
issue_number: {ISSUE_NUMBER}
|
|
810
820
|
re_gate_outcome: AUTO-LANDED | HELD-AWAITING-MERGE | RE-ESCALATED | UNFIXABLE | REREVIEW-REQUIRED | N/A
|
|
811
821
|
findings_addressed: [{finding_number}, ...]
|
|
812
|
-
blocker: {description if status=BLOCKED}
|
|
822
|
+
blocker: {description if status=BLOCKED; `deferred-live-run: ...` when M0 deferred to a live run}
|
|
813
823
|
```
|
|
814
824
|
|
|
815
825
|
**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 (a held resume, which wrote `FORGE:REMEDIATION_HELD` and not COMPLETE, can be re-invoked by an operator later and re-enters Phase M7): 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`).
|
|
@@ -72,10 +72,10 @@ fi
|
|
|
72
72
|
# ForgeDock's own install root (holds scripts/ and commands/), resolved ONCE by the shared forge_resolve stub
|
|
73
73
|
# below — never the consumer repo: plugin installs set neither FORGE_HOME nor FORGEDOCK_HOME, and a
|
|
74
74
|
# repo-relative fallback would execute (or miss) a same-named script controlled by the consumer repo.
|
|
75
|
-
# Resolution: $FORGEDOCK_HOME (authoritative when set) >
|
|
75
|
+
# Resolution: $FORGEDOCK_HOME (authoritative when set) > the running plugin root (FORGE_SOURCED_ROOT) > $FORGE_HOME > the
|
|
76
76
|
# ~/.claude/commands symlink target > the Claude Code plugin cache/marketplace dirs. <!-- forge#3098 -->
|
|
77
77
|
# FORGE_ROOT: shared resolver scripts/lib/forge-resolve.sh (empty when unresolved => callers fail closed)
|
|
78
|
-
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
78
|
+
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
79
79
|
FORGE_ROOT="$(forge_root 2>/dev/null || true)"
|
|
80
80
|
UNIVERSAL_DIR="${FORGE_ROOT:+$FORGE_ROOT/scripts}" # empty => tier 3 skipped, prose tier
|
|
81
81
|
# NOTE: never resolve this via `which` or `find` — universal scripts are
|
|
@@ -315,7 +315,7 @@ A PR must not be opened for work whose earlier phases were skipped. Run the dete
|
|
|
315
315
|
CHANGED=$(git -C {WORKTREE_PATH} diff --name-only --no-renames origin/{PR_BASE}...HEAD)
|
|
316
316
|
# The verifier ships with ForgeDock (not the consumer repo): same resolution as every universal script.
|
|
317
317
|
# FORGE_ROOT: shared resolver scripts/lib/forge-resolve.sh (empty when unresolved => callers fail closed)
|
|
318
|
-
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
318
|
+
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
319
319
|
FORGE_ROOT="$(forge_root 2>/dev/null || true)"
|
|
320
320
|
TRAIL_SCRIPT="$FORGE_ROOT/scripts/verify-phase-trail.sh"
|
|
321
321
|
# Docs-only predicate: ONE shared copy (scripts/is-docs-only.sh, forge#3134). Fail closed: unresolved script or empty diff -> no flag.
|
|
@@ -446,10 +446,27 @@ esac
|
|
|
446
446
|
|
|
447
447
|
---
|
|
448
448
|
|
|
449
|
+
## Phase R2.5: Review Acceptance Gate <!-- Added: forge#3793 -->
|
|
450
|
+
|
|
451
|
+
Evaluates the `phase=review` acceptance checks B6.5 deferred (no PR existed at build time) before `/review-pr --auto-merge`: after R2D, and at the top of R3 on resume. Idempotent per head; a no-op without deferred checks; own marker `FORGE:ACCEPTANCE_GATE_REVIEW`, never the build gate's sentinels.
|
|
452
|
+
|
|
453
|
+
```bash
|
|
454
|
+
# <Script resolution block, verbatim>
|
|
455
|
+
RESOLUTION=$(resolve_script 'review-acceptance-gate'); TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
|
|
456
|
+
[ "$TIER" != "prose" ] || { echo "review-acceptance-gate not resolvable (fail closed)"; exit 1; }
|
|
457
|
+
bash "$SCRIPT_PATH" {NUMBER} {GH_REPO} "{GH_FLAG}" "{WORKTREE_PATH}" || exit 1
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
On non-zero exit do NOT invoke `/review-pr`; the script posted FAILED, added `needs-human` and printed the `REVIEW_RESULT: status: BLOCKED` block as the final reply.
|
|
461
|
+
|
|
462
|
+
---
|
|
463
|
+
|
|
449
464
|
## Phase R3: Invoke /review-pr with --auto-merge
|
|
450
465
|
|
|
451
466
|
**First re-run the Phase R1.5 phase-trail preflight** (resume entry can skip R1.5). Do not invoke `/review-pr` while it fails. <!-- Added: forge#3061 -->
|
|
452
467
|
|
|
468
|
+
Then re-run **Phase R2.5** (the review acceptance gate); do not invoke `/review-pr` while it fails. <!-- Added: forge#3793 -->
|
|
469
|
+
|
|
453
470
|
Re-read the PR number (from creation or from resume check):
|
|
454
471
|
|
|
455
472
|
```bash
|
package/commands/work-on.md
CHANGED
|
@@ -232,7 +232,7 @@ Extract project prefix and issue number. If `next`/`pick`: list open issues sort
|
|
|
232
232
|
|
|
233
233
|
```bash
|
|
234
234
|
# FORGE_ROOT: shared resolver scripts/lib/forge-resolve.sh (empty when unresolved => callers fail closed)
|
|
235
|
-
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
235
|
+
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
236
236
|
FORGE_ROOT="$(forge_root 2>/dev/null || true)"
|
|
237
237
|
ROUTER_LAYER=0; [ "$UNDER_ORCHESTRATION" = "true" ] && ROUTER_LAYER=1
|
|
238
238
|
if [ -n "$FORGE_ROOT" ] && [ -f "$FORGE_ROOT/scripts/spawn-depth-check.sh" ]; then
|
|
@@ -304,7 +304,7 @@ A `/recover-orphans` sweep that resumes this issue inline holds an issue-scoped
|
|
|
304
304
|
```bash
|
|
305
305
|
# Shell state does not persist: the bootstrap is repeated here.
|
|
306
306
|
# FORGE_ROOT: shared resolver scripts/lib/forge-resolve.sh (empty when unresolved => callers fail closed)
|
|
307
|
-
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
307
|
+
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
308
308
|
FORGE_ROOT="$(forge_root 2>/dev/null || true)"
|
|
309
309
|
RECOVERY_SWEEP_ID=$(printf '%s' "$ARGUMENTS" | sed -n 's/.*--recovery-sweep[[:space:]][[:space:]]*\([^[:space:]][^[:space:]]*\).*/\1/p' | head -1)
|
|
310
310
|
if [ -n "$FORGE_ROOT" ] && [ -f "$FORGE_ROOT/scripts/recovery-claim-live.sh" ]; then
|
|
@@ -441,10 +441,10 @@ fi
|
|
|
441
441
|
# ForgeDock's own install root (holds scripts/ and commands/), resolved ONCE by the shared forge_resolve stub
|
|
442
442
|
# below — never the consumer repo: plugin installs set neither FORGE_HOME nor FORGEDOCK_HOME, and a
|
|
443
443
|
# repo-relative fallback would execute (or miss) a same-named script controlled by the consumer repo.
|
|
444
|
-
# Resolution: $FORGEDOCK_HOME (authoritative when set) >
|
|
444
|
+
# Resolution: $FORGEDOCK_HOME (authoritative when set) > the running plugin root (FORGE_SOURCED_ROOT) > $FORGE_HOME > the
|
|
445
445
|
# ~/.claude/commands symlink target > the Claude Code plugin cache/marketplace dirs. <!-- forge#3098 -->
|
|
446
446
|
# FORGE_ROOT: shared resolver scripts/lib/forge-resolve.sh (empty when unresolved => callers fail closed)
|
|
447
|
-
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
447
|
+
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
448
448
|
FORGE_ROOT="$(forge_root 2>/dev/null || true)"
|
|
449
449
|
UNIVERSAL_DIR="${FORGE_ROOT:+$FORGE_ROOT/scripts}" # empty => tier 3 skipped, prose tier
|
|
450
450
|
# NOTE: never resolve this via `which` or `find` — universal scripts are
|
|
@@ -718,6 +718,7 @@ Skill(skill="{FORGE_SKILL_PREFIX}work-on:remediate", args="{PR_NUMBER} --issue {
|
|
|
718
718
|
| `re_gate_outcome: AUTO-LANDED` | Merged. Remediation's Phase M8 already ran `work-on:close`; if the issue is still open, run Phase 5 with `--terminal-state merged`. Then done. |
|
|
719
719
|
| `status: REREVIEW_REQUIRED` | Fallback only (should not occur at this depth): run the re-review from this router as in 0A.1 (labels checked and the per-head `FORGE:REREVIEW_DISPATCHED` claim posted and read back first, then `FORGE:REREVIEW_RELEASED` with `Outcome: SETTLED` posted only after the outcome is settled, forge#3486), then Phase 5 on `REVIEW_RESULT: status: COMPLETE`. If the re-review cannot run or returns anything else, apply the terminal fallback defined in 0A.1 (labels re-read first, comment with reason, add `needs-human`, remove `workflow:in-review`), then STOP; do not poll. |
|
|
720
720
|
| `status: BLOCKED`, blocker starts with `github-unavailable:` | Transient, not a decision (see Transient GitHub failures). Re-assert `workflow:remediating` with a short comment (remediation's Phase M0 only accepts `needs-human` or `workflow:remediating` issues and its M1 swaps the label for `workflow:in-review` on a FIXABLE run; a transient outage is not a human decision, so do not add `needs-human`), wait 2 min (5 min before the second retry), then re-invoke `work-on:remediate` with the same args; do NOT re-post the bound marker. At most 2 retries; if the third attempt still returns `github-unavailable:`, fall through to the `status: BLOCKED` (any kind) row below. |
|
|
721
|
+
| `status: BLOCKED`, blocker starts with `deferred-live-run:` | A live earlier remediation owns the PR (remediation's Phase M0 liveness step). Not a human decision: do NOT add `needs-human` and do NOT count it as an attempt. Make sure `workflow:remediating` is on the issue (re-add it if absent) and `needs-human` is not added by this row. STOP; the live run finishes, or its lease expires and a later invocation proceeds normally. |
|
|
721
722
|
| `status: BLOCKED` (any kind) | Make sure `needs-human` is on the issue and `workflow:remediating` is not (add/remove accordingly, with the blocker as a comment if absent: remediation can exit after its M1 cleared the label). STOP. |
|
|
722
723
|
| `status: ALREADY_DONE` (single-attempt guard: an earlier remediation already completed on this PR), `remediation: inpr-fix` | Same as any other `inpr-fix` outcome below: re-invoke Phase 4 once, which waives the in-PR gate. |
|
|
723
724
|
| `status: ALREADY_DONE`, `remediation: base-sync` | Make sure `needs-human` is on the issue and `workflow:remediating` is not (add/remove accordingly, with a comment naming the PR, that a base sync already ran and the conflicting files if known, if absent). STOP. Do NOT re-invoke Phase 4: a still-conflicting PR would loop. |
|
|
@@ -726,7 +727,7 @@ Skill(skill="{FORGE_SKILL_PREFIX}work-on:remediate", args="{PR_NUMBER} --issue {
|
|
|
726
727
|
| any other outcome, `remediation: base-sync` | Terminal: make sure `needs-human` is on the issue and `workflow:remediating` is not (add/remove accordingly, with a comment naming the PR and the conflicting files from remediation's `FORGE:BASESYNC_FAILED` comment, if absent). STOP. Do NOT re-invoke Phase 4: the bound is used and a still-conflicting PR would loop. |
|
|
727
728
|
| any other outcome, `remediation: ci-gate` | Terminal: remediation left `needs-human` (or `workflow:awaiting-merge`) with its reason. STOP. |
|
|
728
729
|
|
|
729
|
-
Row order matters: the `github-unavailable:`
|
|
730
|
+
Row order matters: the `github-unavailable:` and `deferred-live-run:` rows are evaluated before the generic `status: BLOCKED` (any kind) row, which only handles non-transient blockers and the exhausted retry budget (see Transient GitHub failures). <!-- Added: forge#3402 -->
|
|
730
731
|
|
|
731
732
|
---
|
|
732
733
|
|
package/docs/CONFIG.md
CHANGED
|
@@ -521,7 +521,9 @@ build:
|
|
|
521
521
|
|
|
522
522
|
**Built-in default exclusions** (always applied, defined once in `scripts/diff-size.sh`): lockfiles (`package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `Cargo.lock`, `poetry.lock`, `uv.lock`, `go.sum`, `Gemfile.lock`, `composer.lock`), `*.min.*`, `*.snap`, and the directories `dist/`, `build/`, `generated/`, `fixtures/`, `vendor/` (anchored to the repo root, so first-party directories such as `commands/work-on/build/` are counted) plus `__generated__/` and `__snapshots__/` (any depth). The diff is measured against the merge base of `HEAD` and `origin/<base>`, so commits that landed on the base after the branch point are not counted. A configured glob ending in `/` matches that directory at any depth; any other glob is matched against the file basename and the full path. Globs are treated as data, never evaluated.
|
|
523
523
|
|
|
524
|
-
**Over the threshold**: the build posts a `FORGE:DIFF_SIZE` comment with a split proposal and exits `BUILD_RESULT: status: NEEDS_DECOMPOSE`; the router then runs `work-on:decompose`. To build the oversized diff anyway, post a `FORGE:SIZE_OVERRIDE` comment whose body has a non-empty justification on the line after the marker (only comments from trusted authors count). An issue that is already a decomposed child, or that already has `FORGE:DECOMPOSED`, is not decomposed again: the build blocks until an override is posted. The staged diff is also persisted before the exit (pushed ref `forge/wip/{NUMBER}`, or a patch file when the push fails) and each split-proposal line links its hunks, so they survive worktree cleanup; decompose copies the link into each sub-issue. The measured `diff_lines` is recorded in the `FORGE:TRAJECTORY` comment.
|
|
524
|
+
**Over the threshold**: the build posts a `FORGE:DIFF_SIZE` comment with a split proposal and exits `BUILD_RESULT: status: NEEDS_DECOMPOSE`; the router then runs `work-on:decompose`. To build the oversized diff anyway, post a `FORGE:SIZE_OVERRIDE` comment whose body has a non-empty justification on the line after the marker (only comments from trusted authors count). An issue that is already a decomposed child, or that already has `FORGE:DECOMPOSED`, is not decomposed again: the build blocks until an override is posted, unless the diff is cohesive (next paragraph). The staged diff is also persisted before the exit (pushed ref `forge/wip/{NUMBER}`, or a patch file when the push fails) and each split-proposal line links its hunks, so they survive worktree cleanup; decompose copies the link into each sub-issue. The measured `diff_lines` is recorded in the `FORGE:TRAJECTORY` comment.
|
|
525
|
+
|
|
526
|
+
**Cohesive sub-issue overage**: a sub-issue (or an issue with `FORGE:DECOMPOSED`) that is over the threshold with no override continues instead of blocking when `scripts/diff-size.sh` reports `cohesive=true`: at least 70% of the counted lines are in files added by the diff or in test files (test directories `test/`, `tests/`, `__tests__/`, or `*.test.*`, `*.spec.*`, `test_*`, `*_test.*`), and `diff_lines` is at most `threshold` x 1.5. The build records `result: COHESIVE` with the cohesion figures and a reviewer note in `FORGE:DIFF_SIZE`, adds no `needs-human`, and continues to validation. Test lines are classified, not discounted. The 70% and 1.5x constants live in the script and are not configurable. A non-sub-issue over the threshold still routes to `NEEDS_DECOMPOSE`, and `FORGE:SIZE_OVERRIDE` still takes precedence. Each sub-issue is packed to at most `0.8 x threshold` and carries an own-line `ESTIMATED_DIFF_LINES: N` in its body, which `FORGE:DIFF_SIZE` records next to the actual `diff_lines` as `estimated_diff_lines`.
|
|
525
527
|
|
|
526
528
|
**Pre-build estimate**: `work-on:investigate` emits `ESTIMATED_DIFF_LINES`, `INDEPENDENT_SEAMS`, `GATE_THRESHOLD` and `ESTIMATE_FACTOR` inside `### Decomposition Assessment`. An estimate above `threshold` x `estimate_factor` (or, for a non-batch issue, `batch_seam_min`+ seams whose estimate is over `threshold` / 2) answers `**YES**`, so the issue is decomposed before any build into `max(2, ceil(estimate / (0.8 x threshold)))` packed sub-issues (the parent is a terminal tracker). The engine downgrades a `**YES**` that neither condition requires to NO unless the report has an own-line `DECOMPOSE_JUSTIFICATION:` line (it assumes `batch_seam_min` 3 and cannot see the `batch` label, so it is lenient for batches). The estimate rule is skipped when `threshold` is `0`.
|
|
527
529
|
|
|
@@ -940,7 +942,7 @@ These variables are read from the operator's runner environment, not from `forge
|
|
|
940
942
|
|
|
941
943
|
### Install root resolution
|
|
942
944
|
|
|
943
|
-
`FORGE_ROOT` (where the merge trail gate, dispatch lint and engine modules are resolved) is computed in this order: `FORGEDOCK_HOME` (authoritative: when set, nothing else is consulted, and a relative value leaves `FORGE_ROOT` empty so the gate fails closed) > the running plugin's own root (substituted by Claude Code
|
|
945
|
+
`FORGE_ROOT` (where the merge trail gate, dispatch lint and engine modules are resolved) is computed in this order: `FORGEDOCK_HOME` (authoritative: when set, nothing else is consulted, and a relative value leaves `FORGE_ROOT` empty so the gate fails closed) > the running plugin's own root (substituted by Claude Code, not a runtime env var; the stub hands it to the helper as `FORGE_SOURCED_ROOT`, so it wins over `FORGE_ROOT`, `FORGE_HOME` and any newer cached version) > `FORGE_HOME` > the `~/.claude/commands/work-on.md` symlink target > the clone path recorded in `$CODEX_HOME/forge-home` (Codex; a relative `CODEX_HOME` is ignored) > the newest semver directory in the Claude plugin cache (official `forgedock` marketplace only; `FORGEDOCK_MARKETPLACE` pins a differently named one) > the marketplace directory. If nothing resolves, `FORGE_ROOT` stays empty and the trail gate refuses to merge (fail closed). See [CODEX.md](CODEX.md#forge_home-and-install-root-resolution).
|
|
944
946
|
|
|
945
947
|
### Keeping the phase-trail merge gate satisfiable
|
|
946
948
|
|
package/docs/WORK-ON-RUNTIME.md
CHANGED
|
@@ -108,7 +108,7 @@ remediate L2, reviewers L3, same as the table.
|
|
|
108
108
|
|
|
109
109
|
| Phase result | Router action |
|
|
110
110
|
|--------------|---------------|
|
|
111
|
-
| `REVIEW_RESULT: status: NEXT, next: remediate` | Phase 4R: post the bound marker (`CI_REMEDIATION` / `INPR_REMEDIATION` / `BASESYNC_REMEDIATION`, the last for `remediation: base-sync`, a merge-only base sync) immediately before invoking `work-on:remediate`, so an interrupted handoff is retried, not counted. `AUTO-LANDED` → done (remediation already ran close). `REREVIEW_REQUIRED` → fallback re-review from the router. `ALREADY_DONE` (single-attempt guard) → `inpr-fix`: re-invoke review (waive); `ci-gate`: ensure `needs-human`, stop. `BLOCKED` → ensure `needs-human`, stop. Resume after an interrupted remediation (no `needs-human`, no later `REMEDIATION:COMPLETE`) re-adds `needs-human` and returns to 4R, not to review. Other outcome: `inpr-fix` → re-invoke review once (it waives the in-PR gate); `ci-gate` → terminal. |
|
|
111
|
+
| `REVIEW_RESULT: status: NEXT, next: remediate` | Phase 4R: post the bound marker (`CI_REMEDIATION` / `INPR_REMEDIATION` / `BASESYNC_REMEDIATION`, the last for `remediation: base-sync`, a merge-only base sync) immediately before invoking `work-on:remediate`, so an interrupted handoff is retried, not counted. `AUTO-LANDED` → done (remediation already ran close). `REREVIEW_REQUIRED` → fallback re-review from the router. `ALREADY_DONE` (single-attempt guard) → `inpr-fix`: re-invoke review (waive); `ci-gate`: ensure `needs-human`, stop. `BLOCKED` with a `deferred-live-run:` blocker (a live earlier remediation owns the PR) → no `needs-human`, keep `workflow:remediating`, stop. Any other `BLOCKED` → ensure `needs-human`, stop. Resume after an interrupted remediation (no `needs-human`, no later `REMEDIATION:COMPLETE`) re-adds `needs-human` and returns to 4R, not to review. Other outcome: `inpr-fix` → re-invoke review once (it waives the in-PR gate); `ci-gate` → terminal. |
|
|
112
112
|
| `REMEDIATE_RESULT` from `--remediate` entry | unchanged (Phase 0A.1) |
|
|
113
113
|
|
|
114
114
|
## 6. What does not change
|
|
@@ -535,7 +535,7 @@ needed.
|
|
|
535
535
|
|
|
536
536
|
| Marker | Category | Meaning |
|
|
537
537
|
|--------|----------|---------|
|
|
538
|
-
| `<!-- FORGE:DIFF_SIZE -->` | Lifecycle (no completion sentinel) | Measured diff size of the staged build: `diff_lines`, `excluded_lines`, `threshold`, `result: OK \| OVERRIDDEN \| OVER`, and on `OVER` a `### Split Proposal`. Refreshed in place; readers take the latest trusted comment. Consumed by decompose (plan source) and close (records `diff_lines` in `FORGE:TRAJECTORY`) |
|
|
538
|
+
| `<!-- FORGE:DIFF_SIZE -->` | Lifecycle (no completion sentinel) | Measured diff size of the staged build: `diff_lines`, `excluded_lines`, `threshold`, `result: OK \| OVERRIDDEN \| COHESIVE \| OVER`, optional `estimated_diff_lines` (sub-issues), cohesion figures and a reviewer note on `COHESIVE`, and on `OVER` a `### Split Proposal`. Refreshed in place; readers take the latest trusted comment. Consumed by decompose (plan source) and close (records `diff_lines` in `FORGE:TRAJECTORY`) |
|
|
539
539
|
| `<!-- FORGE:SIZE_OVERRIDE -->` | Control | Posted by a human to let an over-threshold build proceed. Valid only from a trusted author and with a non-empty justification on the line after the marker |
|
|
540
540
|
|
|
541
541
|
Both lookups are anchored at the start of the comment body and trust-filtered
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forgedock",
|
|
3
|
-
"version": "1.16.
|
|
3
|
+
"version": "1.16.2",
|
|
4
4
|
"description": "Autonomous development pipeline for coding agents. GitHub as institutional memory, with native Claude Code and OpenCode entrypoints.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"forgedock": "bin/forgedock.mjs"
|
|
@@ -75,6 +75,7 @@ fi
|
|
|
75
75
|
# ---------------------------------------------------------------------------
|
|
76
76
|
MARKER_REGISTRY="
|
|
77
77
|
ACCEPTANCE_GATE
|
|
78
|
+
ACCEPTANCE_GATE_REVIEW
|
|
78
79
|
ADR_EXTRACTED
|
|
79
80
|
ANCESTRY_FAILED
|
|
80
81
|
ARCHITECT
|
|
@@ -163,6 +164,7 @@ MARKER_REGISTRY="
|
|
|
163
164
|
RECOVERY_CLAIM_RELEASED
|
|
164
165
|
REMEDIATION
|
|
165
166
|
REMEDIATION_HELD
|
|
167
|
+
REMEDIATION_DEFERRED
|
|
166
168
|
REREVIEW_DISPATCHED
|
|
167
169
|
REREVIEW_RELEASED
|
|
168
170
|
REREVIEW_SKIPPED
|
package/scripts/diff-size.sh
CHANGED
|
@@ -19,8 +19,14 @@
|
|
|
19
19
|
# lockfiles (package-lock.json yarn.lock pnpm-lock.yaml Cargo.lock poetry.lock uv.lock go.sum Gemfile.lock
|
|
20
20
|
# composer.lock), *.min.*, *.snap, root-anchored dist/ build/ generated/ fixtures/ vendor/, any-depth
|
|
21
21
|
# __generated__/ __snapshots__/
|
|
22
|
-
# Output (stdout, key=value lines): diff_lines=N excluded_lines=M threshold=T over=true|false, then
|
|
22
|
+
# Output (stdout, key=value lines): diff_lines=N excluded_lines=M threshold=T over=true|false, then the cohesion
|
|
23
|
+
# measure new_lines=N test_lines=N cohesive_lines=N cohesive=true|false, then up to 10
|
|
23
24
|
# "top=<lines> <path>" lines, largest first. threshold 0 disables the gate (over=false always).
|
|
25
|
+
# Cohesion (forge#3790) counts only non-excluded files. new_lines = lines in files ADDED by the diff;
|
|
26
|
+
# test_lines = lines in test files (a test/ tests/ __tests__/ directory segment, *.test.* *.spec.* test_* *_test.*
|
|
27
|
+
# by basename); cohesive_lines = the union (a new test file counts once). cohesive=true when diff_lines>0 and
|
|
28
|
+
# cohesive_lines*100 >= 70*diff_lines and diff_lines <= threshold*3/2 (integer math; threshold 0 -> false).
|
|
29
|
+
# Test lines are classified, never discounted from diff_lines.
|
|
24
30
|
# Exit: 0 measured (even when over); 2 usage or git failure with EMPTY stdout (fail closed: unknown is not zero).
|
|
25
31
|
|
|
26
32
|
set -euo pipefail
|
|
@@ -62,7 +68,21 @@ MB="$(git -C "$REPO_PATH" merge-base HEAD "origin/$BASE" 2>/dev/null)" && [ -n "
|
|
|
62
68
|
RAW="$(git -C "$REPO_PATH" diff --cached --numstat --no-renames -z "$MB" -- | tr '\0' '\001')" \
|
|
63
69
|
|| die "git diff against merge base $MB failed"
|
|
64
70
|
|
|
65
|
-
|
|
71
|
+
ADDED="$(git -C "$REPO_PATH" diff --cached --diff-filter=A --no-renames --name-only -z "$MB" -- | tr '\0' '\001')" \
|
|
72
|
+
|| die "git diff --diff-filter=A against merge base $MB failed"
|
|
73
|
+
is_added() { # path -> 0 when the diff adds the file
|
|
74
|
+
case "$ADDED_SET" in *$'\001'"$1"$'\001'*) return 0 ;; esac
|
|
75
|
+
return 1
|
|
76
|
+
}
|
|
77
|
+
is_test() { # path -> 0 when it is a test file (directory segments exact, name patterns on the basename)
|
|
78
|
+
local p="$1" b="${1##*/}"
|
|
79
|
+
case "/$p" in */test/*|*/tests/*|*/__tests__/*) return 0 ;; esac
|
|
80
|
+
case "$b" in *.test.*|*.spec.*|test_*|*_test.*) return 0 ;; esac
|
|
81
|
+
return 1
|
|
82
|
+
}
|
|
83
|
+
ADDED_SET=$'\001'"$ADDED"
|
|
84
|
+
|
|
85
|
+
TOTAL=0; EXCL=0; LIST=""; NEWL=0; TESTL=0; COHL=0
|
|
66
86
|
# -z numstat records are "added<TAB>deleted<TAB>path" separated by NUL (translated to \001 above).
|
|
67
87
|
while IFS= read -r -d $'\001' rec; do
|
|
68
88
|
[ -n "$rec" ] || continue
|
|
@@ -70,7 +90,13 @@ while IFS= read -r -d $'\001' rec; do
|
|
|
70
90
|
case "$a" in ''|*[!0-9]*) a=0 ;; esac
|
|
71
91
|
case "$d" in ''|*[!0-9]*) d=0 ;; esac
|
|
72
92
|
n=$((a + d))
|
|
73
|
-
if excluded "$path"; then EXCL=$((EXCL + n)); else
|
|
93
|
+
if excluded "$path"; then EXCL=$((EXCL + n)); else
|
|
94
|
+
TOTAL=$((TOTAL + n)); LIST="${LIST}${n} ${path}"$'\n'
|
|
95
|
+
isn=0; ist=0
|
|
96
|
+
if is_added "$path"; then isn=1; NEWL=$((NEWL + n)); fi
|
|
97
|
+
if is_test "$path"; then ist=1; TESTL=$((TESTL + n)); fi
|
|
98
|
+
if [ "$isn" = 1 ] || [ "$ist" = 1 ]; then COHL=$((COHL + n)); fi
|
|
99
|
+
fi
|
|
74
100
|
done <<< "$RAW"
|
|
75
101
|
|
|
76
102
|
OVER=false
|
|
@@ -79,6 +105,13 @@ echo "diff_lines=$TOTAL"
|
|
|
79
105
|
echo "excluded_lines=$EXCL"
|
|
80
106
|
echo "threshold=$THRESHOLD"
|
|
81
107
|
echo "over=$OVER"
|
|
108
|
+
COHESIVE=false
|
|
109
|
+
if [ "$THRESHOLD" -gt 0 ] && [ "$TOTAL" -gt 0 ] && [ $((COHL * 100)) -ge $((70 * TOTAL)) ] \
|
|
110
|
+
&& [ $((TOTAL * 2)) -le $((THRESHOLD * 3)) ]; then COHESIVE=true; fi
|
|
111
|
+
echo "new_lines=$NEWL"
|
|
112
|
+
echo "test_lines=$TESTL"
|
|
113
|
+
echo "cohesive_lines=$COHL"
|
|
114
|
+
echo "cohesive=$COHESIVE"
|
|
82
115
|
if [ -n "$LIST" ]; then
|
|
83
116
|
printf '%s' "$LIST" | sort -t' ' -k1,1nr | head -n 10 | sed 's/^/top=/' || true
|
|
84
117
|
fi
|
|
@@ -95,6 +95,53 @@ reset; gen 3 > s.txt; gen 9 > l.txt; git add -A
|
|
|
95
95
|
OUT="$(run)"; FIRST="$(printf '%s\n' "$OUT" | sed -n 's/^top=//p' | head -n1)"
|
|
96
96
|
[ "$FIRST" = "9 l.txt" ] && ok || bad "top ordering (got '$FIRST')"
|
|
97
97
|
|
|
98
|
+
# cohesion (forge#3790): new files + test files >= 70% of diff_lines and diff_lines <= threshold*3/2
|
|
99
|
+
shape() { # all-new fixture: $1=new module lines, $2=new test file lines
|
|
100
|
+
reset; mkdir -p src tests; gen "$1" > src/newmod.py; gen "$2" > tests/test_newmod.py; git add -A
|
|
101
|
+
}
|
|
102
|
+
shape 685 458; OUT="$(run --threshold 1000)"
|
|
103
|
+
check "34844 shape cohesive" "$OUT" cohesive true; check "34844 shape new (test file is also new)" "$OUT" new_lines 1143
|
|
104
|
+
check "34844 shape test" "$OUT" test_lines 458; check "34844 shape cohesive_lines" "$OUT" cohesive_lines 1143
|
|
105
|
+
shape 471 593; OUT="$(run --threshold 1000)"; check "35124 shape cohesive" "$OUT" cohesive true
|
|
106
|
+
shape 516 697; OUT="$(run --threshold 1000)"; check "35143 shape cohesive" "$OUT" cohesive true
|
|
107
|
+
shape 1478 0; OUT="$(run --threshold 1000)"; check "35100 all-new at 1.5x cohesive" "$OUT" cohesive true
|
|
108
|
+
shape 1500 1; OUT="$(run --threshold 1000)"; check "over 1.5x not cohesive" "$OUT" cohesive false
|
|
109
|
+
shape 1000 0; OUT="$(run --threshold 0)"; check "threshold 0 not cohesive" "$OUT" cohesive false
|
|
110
|
+
|
|
111
|
+
# new test file counts once (union), under the threshold still reports cohesive
|
|
112
|
+
reset; mkdir -p tests; gen 50 > tests/test_x.py; git add -A
|
|
113
|
+
OUT="$(run --threshold 1000)"; check "new test file new" "$OUT" new_lines 50; check "new test file test" "$OUT" test_lines 50
|
|
114
|
+
check "new test file union once" "$OUT" cohesive_lines 50
|
|
115
|
+
|
|
116
|
+
# a modified (not added) test file counts as test, not new
|
|
117
|
+
reset; mkdir -p tests; gen 40 > tests/test_old.py
|
|
118
|
+
git add -A; git commit -qm t; git push -q origin feat:staging 2>/dev/null
|
|
119
|
+
gen 10 | sed 's/line/chg/' > tests/test_old.py; gen 30 > src_new.py; git add -A
|
|
120
|
+
OUT="$(run)"; check "modified test new_lines" "$OUT" new_lines 30; check "modified test test_lines" "$OUT" test_lines 50
|
|
121
|
+
check "modified test union" "$OUT" cohesive_lines 80
|
|
122
|
+
git reset -q --hard origin/staging
|
|
123
|
+
|
|
124
|
+
# sprawl: 1300 lines across 15 modified files is not cohesive
|
|
125
|
+
reset; for i in $(seq 1 15); do gen 50 > "m$i.txt"; done; git add -A; git commit -qm m; git push -q origin feat:staging 2>/dev/null
|
|
126
|
+
for i in $(seq 1 15); do gen 43 | sed 's/line/edit/' > "m$i.txt"; done; git add -A
|
|
127
|
+
OUT="$(run --threshold 1000)"
|
|
128
|
+
check "sprawl lines" "$OUT" diff_lines 1395; check "sprawl cohesive_lines" "$OUT" cohesive_lines 0; check "sprawl not cohesive" "$OUT" cohesive false
|
|
129
|
+
git reset -q --hard origin/staging
|
|
130
|
+
|
|
131
|
+
# modified (not added) test file is test-classified; modified non-test stays out; basename patterns, no substring match
|
|
132
|
+
reset; mkdir -p t; gen 10 > latest.sh; gen 10 > contest.txt; gen 10 > a.spec.ts; gen 10 > b_test.go; gen 10 > test_c.py; git add -A
|
|
133
|
+
OUT="$(run)"; check "latest.sh and contest.txt are new, all counted" "$OUT" new_lines 50
|
|
134
|
+
check "test name patterns" "$OUT" test_lines 30
|
|
135
|
+
|
|
136
|
+
# excluded files are not counted by cohesion
|
|
137
|
+
reset; gen 20 > keep.py; gen 900 > package-lock.json; git add -A
|
|
138
|
+
OUT="$(run --threshold 1000)"; check "excluded not in new_lines" "$OUT" new_lines 20
|
|
139
|
+
|
|
140
|
+
# existing key order is unchanged: cohesion keys sit between over= and top=
|
|
141
|
+
reset; gen 3 > s.txt; git add -A
|
|
142
|
+
KEYS="$(run | sed -n 's/^\([a-z_]*\)=.*/\1/p' | tr '\n' ' ')"
|
|
143
|
+
[ "$KEYS" = "diff_lines excluded_lines threshold over new_lines test_lines cohesive_lines cohesive top " ] && ok || bad "key order ($KEYS)"
|
|
144
|
+
|
|
98
145
|
# fail closed: bad repo path, bad base, bad threshold, unknown arg, missing base
|
|
99
146
|
OUT="$(bash "$S" --repo-path "$TMP/nope" --base staging 2>/dev/null)"; rc=$?
|
|
100
147
|
{ [ "$rc" = 2 ] && [ -z "$OUT" ]; } && ok || bad "bad repo path (rc=$rc out='$OUT')"
|
|
@@ -190,12 +190,20 @@ mkdir -p "$T/h10/.claude/plugins/cache"
|
|
|
190
190
|
expect "empty cache, no match, no abort" "" "$(run "$T/h10")"
|
|
191
191
|
expect "no ~/.claude at all, no abort" "" "$(run "$T/empty")"
|
|
192
192
|
expect "unmatched glob does not break later candidates" "$T/h1/.claude/plugins/cache/forgedock/forgedock/1.0.0" "$(run "$T/h10" CLAUDE_PLUGIN_ROOT="$T/h1/.claude/plugins/cache/forgedock/forgedock/1.0.0")"
|
|
193
|
-
# substituted plugin root alone resolves (the locator loads that root's helper;
|
|
193
|
+
# substituted plugin root alone resolves (the locator loads that root's helper; the stub also hands it to the helper as FORGE_SOURCED_ROOT, so it outranks FORGE_HOME and any newer cache; see forge-resolve.sh)
|
|
194
194
|
mkscripts "$T/h11/running-plugin"; mkscripts "$T/h11/stale-clone"
|
|
195
195
|
expect "substituted plugin root resolves" "$T/h11/running-plugin" "$(run "$T/h11" CLAUDE_PLUGIN_ROOT="$T/h11/running-plugin")"
|
|
196
196
|
# unsubstituted placeholder (non-Claude runtime) is a literal, rejected by the /* check, never expanded
|
|
197
197
|
expect "unsubstituted placeholder ignored, FORGE_HOME used" "$T/h11/stale-clone" "$(run "$T/h11" FORGE_HOME="$T/h11/stale-clone")"
|
|
198
198
|
expect "unsubstituted placeholder safe under set -u" "" "$( cd "$T/consumer" && env -i PATH="$PATH" HOME="$T/empty" "$SH" -c "set -u; $(cat "$T/canon"); printf %s \"\$FORGE_ROOT\"" 2>&1 )"
|
|
199
|
+
# running plugin root (substituted) beats a newer cache version and an exported FORGE_HOME (version skew)
|
|
200
|
+
mkscripts "$T/h12/.claude/plugins/cache/forgedock/forgedock/1.16.0"; mkscripts "$T/h12/.claude/plugins/cache/forgedock/forgedock/1.17.0"; mkscripts "$T/h12/stale-clone"
|
|
201
|
+
expect "running plugin root beats newer cache" "$T/h12/.claude/plugins/cache/forgedock/forgedock/1.16.0" "$(run "$T/h12" CLAUDE_PLUGIN_ROOT="$T/h12/.claude/plugins/cache/forgedock/forgedock/1.16.0")"
|
|
202
|
+
expect "running plugin root beats exported FORGE_HOME" "$T/h11/running-plugin" "$(run "$T/h11" CLAUDE_PLUGIN_ROOT="$T/h11/running-plugin" FORGE_HOME="$T/h11/stale-clone")"
|
|
203
|
+
expect "running plugin root beats exported FORGE_ROOT" "$T/h11/running-plugin" "$(run "$T/h11" CLAUDE_PLUGIN_ROOT="$T/h11/running-plugin" FORGE_ROOT="$T/h11/stale-clone")"
|
|
204
|
+
expect "FORGEDOCK_HOME still authoritative over running root" "$T/h11/stale-clone" "$(run "$T/h11" CLAUDE_PLUGIN_ROOT="$T/h11/running-plugin" FORGEDOCK_HOME="$T/h11/stale-clone")"
|
|
205
|
+
expect "unsubstituted literal ignored, newest cache wins" "$T/h12/.claude/plugins/cache/forgedock/forgedock/1.17.0" "$(run "$T/h12")"
|
|
206
|
+
expect "relative running root rejected" "$T/h11/stale-clone" "$(run "$T/h11" CLAUDE_PLUGIN_ROOT="relative/running-plugin" FORGE_HOME="$T/h11/stale-clone")"
|
|
199
207
|
# the env var alone (no substitution) must NOT be relied on: Claude Code does not export it to Bash
|
|
200
208
|
expect "CLAUDE_PLUGIN_ROOT env var alone is not a candidate" "" "$( cd "$T/consumer" && env -i PATH="$PATH" HOME="$T/empty" CLAUDE_PLUGIN_ROOT="$T/h11/running-plugin" "$SH" -c "$(cat "$T/canon"); printf %s \"\$FORGE_ROOT\"" )"
|
|
201
209
|
done
|
|
@@ -488,7 +496,7 @@ mk_install "$CFG2/1.16.0"; mk_install "$CFG2/2.0.0"; : > "$CFG2/2.0.0/scripts/re
|
|
|
488
496
|
expect_path "lexical-vs-numeric: 2.0.0 outranks 1.16.0 (stub-loaded helper re-resolves numerically)" "$CFG2/2.0.0" "$(fr_run "$T/fr/lexical" 'forge_root')"
|
|
489
497
|
expect_path "lexical-vs-numeric: a script only in 2.0.0 resolves" "$CFG2/2.0.0/scripts/review-delta.sh" "$(fr_run "$T/fr/lexical" 'forge_resolve review-delta.sh')"
|
|
490
498
|
mk_install "$T/fr/subst"
|
|
491
|
-
expect_path "plugin-only layout: substituted plugin root is a locator tier" "$T/fr/subst/scripts/trusted-comments.sh" "$(sed "s|'\${CLAUDE_PLUGIN_ROOT}'|'$T/fr/subst'|" "$T/locator.sh" > "$T/locator_subst.sh"; cd "$T/fr/cwdplant" && env -i PATH="$PATH" HOME="$T/fr/h0" bash -c "$(cat "$T/locator_subst.sh"); forge_resolve trusted-comments.sh" 2>/dev/null)"
|
|
499
|
+
expect_path "plugin-only layout: substituted plugin root is a locator tier" "$T/fr/subst/scripts/trusted-comments.sh" "$(sed "s|'\${CLAUDE_PLUGIN_ROOT}'|'$T/fr/subst'|g" "$T/locator.sh" > "$T/locator_subst.sh"; cd "$T/fr/cwdplant" && env -i PATH="$PATH" HOME="$T/fr/h0" bash -c "$(cat "$T/locator_subst.sh"); forge_resolve trusted-comments.sh" 2>/dev/null)"
|
|
492
500
|
mk_install "$T/fr/repo"; mkdir -p "$T/fr/instl/.claude/commands"
|
|
493
501
|
if ln -s "$T/fr/repo/commands/work-on.md" "$T/fr/instl/.claude/commands/work-on.md" 2>/dev/null && [ -L "$T/fr/instl/.claude/commands/work-on.md" ]; then
|
|
494
502
|
expect_path "install.sh layout: commands symlink resolves to the repo root" "$T/fr/repo" "$(fr_run "$T/fr/instl" 'forge_root')"
|