forgedock 1.8.1 → 1.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/bin/engine/admission.mjs +57 -1
  2. package/bin/engine/admission.test.mjs +60 -0
  3. package/commands/analytics.md +3 -1
  4. package/commands/audit-agents.md +3 -1
  5. package/commands/audit.md +10 -8
  6. package/commands/autopilot.md +16 -14
  7. package/commands/ci-audit.md +4 -2
  8. package/commands/cleanup.md +1 -1
  9. package/commands/deploy-pr.md +31 -11
  10. package/commands/design-bench.md +1 -0
  11. package/commands/design-render-critique-loop.md +1 -0
  12. package/commands/design.md +1 -0
  13. package/commands/forge-stats.md +1 -0
  14. package/commands/geo-audit.md +3 -1
  15. package/commands/incident-response.md +3 -1
  16. package/commands/issue.md +4 -2
  17. package/commands/milestone.md +8 -6
  18. package/commands/orchestrate/config.md +3 -2
  19. package/commands/orchestrate/phase-1-resolve.md +57 -12
  20. package/commands/orchestrate/phase-2-triage.md +1 -0
  21. package/commands/orchestrate/phase-2.5-synthesis.md +1 -0
  22. package/commands/orchestrate/phase-3-dependency.md +17 -5
  23. package/commands/orchestrate/phase-4-execution.md +625 -78
  24. package/commands/orchestrate/phase-5-cleanup.md +5 -2
  25. package/commands/orchestrate/phase-6-report.md +8 -3
  26. package/commands/orchestrate/safety.md +1 -0
  27. package/commands/orchestrate.md +13 -9
  28. package/commands/pipeline-health/config.md +1 -0
  29. package/commands/pipeline-health/phase-1-context.md +1 -0
  30. package/commands/pipeline-health/phase-2-metrics.md +1 -0
  31. package/commands/pipeline-health/phase-3-analyze.md +1 -0
  32. package/commands/pipeline-health/phase-4-proposals.md +7 -4
  33. package/commands/pipeline-health/phase-5-report.md +5 -2
  34. package/commands/pipeline-health/phase-6-summary.md +1 -0
  35. package/commands/pipeline-health.md +9 -7
  36. package/commands/pipeline-resume.md +4 -2
  37. package/commands/qa-sweep.md +3 -1
  38. package/commands/quality-gate.md +4 -3
  39. package/commands/recover-orphans.md +219 -13
  40. package/commands/review-pr-agents/api.md +1 -0
  41. package/commands/review-pr-agents/auth.md +1 -0
  42. package/commands/review-pr-agents/billing.md +1 -0
  43. package/commands/review-pr-agents/concurrency.md +1 -0
  44. package/commands/review-pr-agents/database.md +1 -0
  45. package/commands/review-pr-agents/frontend.md +1 -0
  46. package/commands/review-pr-agents/infra.md +1 -0
  47. package/commands/review-pr-agents/protocols.md +1 -0
  48. package/commands/review-pr-agents/scraper.md +1 -0
  49. package/commands/review-pr-agents/security.md +1 -0
  50. package/commands/review-pr-agents/spec-cli.md +1 -0
  51. package/commands/review-pr-agents.md +10 -4
  52. package/commands/review-pr-staging.md +14 -5
  53. package/commands/review-pr.md +187 -19
  54. package/commands/rollback.md +3 -1
  55. package/commands/security-audit.md +3 -1
  56. package/commands/signal-planner.md +6 -4
  57. package/commands/sync-ecosystem.md +4 -2
  58. package/commands/test-gate.md +4 -2
  59. package/commands/upgrade-deps.md +3 -1
  60. package/commands/validate.md +3 -1
  61. package/commands/work-on/build/architect.md +82 -19
  62. package/commands/work-on/build/context.md +62 -18
  63. package/commands/work-on/build/implement.md +63 -18
  64. package/commands/work-on/build/validate.md +241 -60
  65. package/commands/work-on/build.md +345 -146
  66. package/commands/work-on/close.md +618 -246
  67. package/commands/work-on/decompose.md +240 -41
  68. package/commands/work-on/investigate.md +378 -61
  69. package/commands/work-on/remediate.md +43 -9
  70. package/commands/work-on/review.md +241 -29
  71. package/commands/work-on.md +180 -2014
  72. package/docs/CODEX.md +2 -0
  73. package/docs/CONFIG.md +11 -0
  74. package/package.json +1 -1
  75. package/scripts/build-spec-graph.mjs +11 -4
  76. package/scripts/check-spec-markers.sh +21 -1
  77. package/scripts/checkbox-sections.test.sh +8 -23
  78. package/scripts/extract-affected-files.sh +5 -2
  79. package/scripts/extract-affected-files.test.sh +27 -0
  80. package/scripts/forge-root.test.sh +176 -0
  81. package/scripts/is-docs-only.sh +31 -0
  82. package/scripts/is-docs-only.test.sh +64 -0
  83. package/scripts/lint-dispatch-prompt.sh +217 -0
  84. package/scripts/lint-dispatch-prompt.test.sh +307 -0
  85. package/scripts/validate-spec-graph.sh +3 -2
  86. package/scripts/verify-phase-trail.sh +282 -0
  87. package/scripts/verify-phase-trail.test.sh +298 -0
  88. package/scripts/wait-ci-green.sh +98 -0
  89. package/scripts/wait-ci-green.test.sh +99 -0
  90. package/commands/work-on-monolithic.md +0 -326
@@ -7,13 +7,15 @@ argument-hint: "[--dry-run | --since <hours> | --issue <number>]"
7
7
 
8
8
  # /recover-orphans — Pipeline Orphan Recovery
9
9
 
10
+ > **Skill names**: `{FORGE_SKILL_PREFIX}` is `forgedock:` (plugin install) or empty (`install.sh`), resolved once per run by `commands/work-on.md` § Skill Name Resolution. If the skill is not found under either name, STOP and report "skill not found" — never run the phase inline.
11
+
10
12
  **Input**: $ARGUMENTS
11
13
 
12
14
  Scan ALL open issues with intermediate workflow labels for orphaned state — issues where the agent died mid-pipeline (context expired, rate-limited, crashed) and no active agent is continuing. Diagnose each orphan's actual GitHub state and apply the appropriate recovery action.
13
15
 
14
16
  **Agent model policy**: `model: "{DEFAULT_MODEL}"` — resolved from forge.yaml `agents.default_model`, else "sonnet". Fallback: `model: "opus"` if rate-limited.
15
17
  **NEVER use plan mode (EnterPlanMode).**
16
- **NEVER use the Agent tool** — recover-orphans re-enters the pipeline via `Skill(skill="work-on", ...)` and `Skill(skill="review-pr", ...)` only.
18
+ **NEVER use the Agent tool** — recover-orphans re-enters the pipeline via `Skill(skill="{FORGE_SKILL_PREFIX}work-on", ...)` and `Skill(skill="{FORGE_SKILL_PREFIX}review-pr", ...)` only.
17
19
 
18
20
  <!-- FORGE:SPEC_LOADED — recover-orphans.md loaded and active. Agent is bound by this spec. -->
19
21
 
@@ -171,7 +173,6 @@ for NUM in $ORPHAN_LIST; do
171
173
  DIAG_REASON[$NUM]="Already in terminal state: $ISSUE_LABELS"
172
174
  continue
173
175
  fi
174
-
175
176
  # Check for merged PR referencing this issue
176
177
  MERGED_PR=$(gh pr list ${GH_FLAG} \
177
178
  --state merged \
@@ -198,6 +199,15 @@ for NUM in $ORPHAN_LIST; do
198
199
  continue
199
200
  fi
200
201
 
202
+ # forge#3148: checked after the merged-PR label cleanup above, so a merged orphan is still closed out.
203
+ # An escalated orphan is waiting on a human — re-running a recovery action every sweep
204
+ # (e.g. re-invoking /review-pr against an unrepaired phase trail) only repeats the same refusal.
205
+ if echo ", $ISSUE_LABELS," | grep -q ", needs-human,"; then
206
+ DIAG_ACTION[$NUM]="skip"
207
+ DIAG_REASON[$NUM]="Escalated (needs-human) — waiting on a human"
208
+ continue
209
+ fi
210
+
201
211
  # Check for an open PR on any branch associated with this issue
202
212
  # Strategy: look for branches with the issue number in the name
203
213
  ISSUE_BRANCH=$(git ls-remote --heads origin 2>/dev/null | grep "/$NUM" | sed 's|.*refs/heads/||' | head -1)
@@ -307,6 +317,117 @@ done
307
317
 
308
318
  For each diagnosed issue, apply the recovery action. All mutating actions are skipped when `DRY_RUN=true`.
309
319
 
320
+ **Claim before re-entering the pipeline** (forge#3158): the `review-pr` first-refusal resume and `create-pr` re-enter `/work-on` inline. A repeated or concurrent sweep, or a live `/orchestrate` agent, could otherwise run the pipeline twice on one issue. Every inline `/work-on` resume MUST go through `claim_orphan` first and run inside `resume_orphan_inline`, which releases the claim on every exit. A sweep is not an orchestrator, so it takes an issue-scoped `FORGE:RECOVERY_CLAIM` marker (visible to other sweeps) and defers to any live orchestrator signal (a fresh `FORGE:HEARTBEAT`, which `/work-on --under-orchestration` posts at every phase entry).
321
+
322
+ **Claim refresh and guaranteed release** (forge#3171): liveness is judged from the claim comment's `updated_at`, and an inline resume posts no `FORGE:HEARTBEAT` (that is gated on `--under-orchestration`, which sweeps do not pass). So the claim is kept alive by editing it: `claim_orphan` records the claim comment id, and `start_claim_keepalive` runs `refresh_orphan` in a background loop every `RECOVERY_CLAIM_TTL_MIN / 3` minutes while the resume runs. `refresh_orphan` PATCHes the claim body, keeping the `**Sweep: id**` line and appending a changing `Refreshed: <UTC>` line, so `updated_at` always advances. Refresh is best-effort; if the keepalive is lost the claim degrades to the plain TTL behavior. `resume_orphan_inline` registers `trap ... release_orphan ... EXIT` in a subshell, so the claim is released always, on success and when the `Skill(...)` call fails, and `release_orphan` stops the keepalive first and is idempotent (it never posts the release marker twice). The keepalive and the `Skill(...)` call belong to the same sweep run. DRY_RUN makes every one of these helpers a no-op.
323
+
324
+ ```bash
325
+ RECOVERY_CLAIM_TTL_MIN="${RECOVERY_CLAIM_TTL_MIN:-30}" # a claim or heartbeat older than this is treated as dead
326
+ SWEEP_ID="sweep-$(date -u +%Y%m%dT%H%M%SZ)-$$"
327
+
328
+ # claim_orphan <issue> — returns 0 if this sweep now holds the claim, 1 if it must skip (reason in CLAIM_SKIP_REASON).
329
+ # Fails closed: an unreadable comment list (initial read or race-check re-read) is treated as "held by someone else".
330
+ # Both reads run under pipefail in a subshell: the pipeline status would otherwise be jq's, and a gh failure
331
+ # (rate limit, 403) would yield "[]" with exit 0, indistinguishable from "no live holder".
332
+ claim_orphan() {
333
+ local num="$1" now cutoff comments live resp
334
+ [ "$DRY_RUN" = "true" ] && return 0 # dry-run: no claim comment is posted (callers already skip dry-run resumes)
335
+ now=$(date -u +%s); cutoff=$(( now - RECOVERY_CLAIM_TTL_MIN * 60 ))
336
+ comments=$(set -o pipefail; gh api --paginate "repos/${GH_REPO}/issues/${num}/comments" 2>/dev/null \
337
+ | jq -s 'add // []') || { CLAIM_SKIP_REASON="could not read comments to check for a live holder"; return 1; }
338
+
339
+ # Live orchestrator signal: a recent heartbeat from /work-on --under-orchestration, or a recent claim of any sweep.
340
+ # A claim is "released" only by a later RELEASED marker naming its sweep id.
341
+ live=$(echo "$comments" | jq --argjson cutoff "$cutoff" '
342
+ ([.[] | select(.body | contains("FORGE:RECOVERY_CLAIM_RELEASED")) | .body | capture("Sweep: (?<id>[^ \n]+)").id]) as $rel
343
+ | [ .[] | select(
344
+ ((.body | contains("FORGE:HEARTBEAT")) or (.body | contains("<!-- FORGE:RECOVERY_CLAIM -->")))
345
+ and ((.body | contains("FORGE:RECOVERY_CLAIM_RELEASED")) | not)
346
+ and ((.updated_at | fromdateiso8601) >= $cutoff)
347
+ and ((.body | capture("Sweep: (?<id>[^ \n]+)")? // {id:""}).id as $sid | ($rel | index($sid)) == null)
348
+ ) ] | length') || live=1
349
+ if [ "${live:-1}" -gt 0 ]; then
350
+ CLAIM_SKIP_REASON="live holder (heartbeat or unreleased recovery claim within ${RECOVERY_CLAIM_TTL_MIN}m)"
351
+ return 1
352
+ fi
353
+
354
+ # Post via the API so the comment id is known: refresh_orphan needs it. The Sweep line must stay line 2.
355
+ CLAIM_BODY="<!-- FORGE:RECOVERY_CLAIM -->
356
+ **Sweep: ${SWEEP_ID}**
357
+ Holder: /recover-orphans — resuming /work-on #${num}. Other sweeps and dispatchers: do not re-enter this issue until the matching release marker is posted."
358
+ resp=$(gh api -X POST "repos/${GH_REPO}/issues/${num}/comments" -f body="$CLAIM_BODY" --jq '.id' 2>/dev/null) \
359
+ && [ -n "$resp" ] || { CLAIM_SKIP_REASON="could not post FORGE:RECOVERY_CLAIM"; return 1; }
360
+ CLAIM_COMMENT_ID="$resp"; CLAIM_HELD_NUM="$num" # set before the race check so release_orphan covers the race-lost paths
361
+
362
+ # Race check: re-read; the earliest unreleased claim wins. Losing means another sweep claimed first.
363
+ local first
364
+ first=$(set -o pipefail; gh api --paginate "repos/${GH_REPO}/issues/${num}/comments" 2>/dev/null | jq -rs --argjson cutoff "$cutoff" '
365
+ add // [] | ([.[] | select(.body | contains("FORGE:RECOVERY_CLAIM_RELEASED")) | .body | capture("Sweep: (?<id>[^ \n]+)").id]) as $rel
366
+ | [.[] | select(.body | contains("<!-- FORGE:RECOVERY_CLAIM -->"))
367
+ | select((.updated_at | fromdateiso8601) >= $cutoff)
368
+ | select((.body | capture("Sweep: (?<id>[^ \n]+)").id) as $sid | ($rel | index($sid)) == null)]
369
+ | first | .body // ""' | sed -n 's/^\*\*Sweep: \(.*\)\*\*$/\1/p') \
370
+ || { release_orphan "$num"; CLAIM_SKIP_REASON="could not re-read comments to confirm the claim race"; return 1; }
371
+ if [ "$first" != "$SWEEP_ID" ]; then
372
+ release_orphan "$num"
373
+ CLAIM_SKIP_REASON="lost claim race to ${first:-unknown}"
374
+ return 1
375
+ fi
376
+ return 0
377
+ }
378
+
379
+ # refresh_orphan <issue> — best-effort: edit the claim comment so its updated_at advances. The body must change
380
+ # every time (an identical-body edit may not bump updated_at). Never carries FORGE:HEARTBEAT or the RELEASED marker.
381
+ refresh_orphan() {
382
+ [ "$DRY_RUN" = "true" ] && return 0
383
+ [ -n "${CLAIM_COMMENT_ID:-}" ] && [ "${CLAIM_HELD_NUM:-}" = "$1" ] || return 0
384
+ gh api -X PATCH "repos/${GH_REPO}/issues/comments/${CLAIM_COMMENT_ID}" \
385
+ -f body="${CLAIM_BODY}
386
+ Refreshed: $(date -u +%Y-%m-%dT%H:%M:%SZ)" >/dev/null 2>&1 || true
387
+ }
388
+
389
+ # start_claim_keepalive <issue> — non-blocking background refresh every TTL/3 (strictly under the TTL).
390
+ start_claim_keepalive() {
391
+ [ "$DRY_RUN" = "true" ] && return 0
392
+ stop_claim_keepalive
393
+ local num="$1" interval=$(( RECOVERY_CLAIM_TTL_MIN * 60 / 3 ))
394
+ [ "$interval" -ge 1 ] || interval=1
395
+ ( while sleep "$interval"; do refresh_orphan "$num"; done ) >/dev/null 2>&1 &
396
+ KEEPALIVE_PID=$!
397
+ }
398
+
399
+ stop_claim_keepalive() {
400
+ [ -n "${KEEPALIVE_PID:-}" ] || return 0
401
+ pkill -P "$KEEPALIVE_PID" 2>/dev/null || true # the pending sleep
402
+ kill "$KEEPALIVE_PID" 2>/dev/null || true
403
+ wait "$KEEPALIVE_PID" 2>/dev/null || true
404
+ unset KEEPALIVE_PID
405
+ }
406
+
407
+ # release_orphan <issue> — stops the keepalive first; idempotent (the release marker is posted at most once per claim).
408
+ release_orphan() {
409
+ [ "$DRY_RUN" = "true" ] && return 0
410
+ stop_claim_keepalive
411
+ [ "${CLAIM_HELD_NUM:-}" = "$1" ] || return 0
412
+ unset CLAIM_HELD_NUM CLAIM_COMMENT_ID
413
+ gh issue comment "$1" ${GH_FLAG} --body "<!-- FORGE:RECOVERY_CLAIM_RELEASED -->
414
+ **Sweep: ${SWEEP_ID}**
415
+ Released by /recover-orphans." >/dev/null 2>&1 || true
416
+ }
417
+
418
+ # resume_orphan_inline <issue> — run after claim_orphan succeeded. The subshell EXIT trap releases the claim always:
419
+ # on success and when the Skill call fails. The keepalive runs alongside the Skill call and is stopped by the release.
420
+ resume_orphan_inline() {
421
+ local num="$1"
422
+ (
423
+ trap 'release_orphan "$num"' EXIT
424
+ start_claim_keepalive "$num"
425
+ refresh_orphan "$num"
426
+ Skill(skill="{FORGE_SKILL_PREFIX}work-on", args="${num}")
427
+ )
428
+ }
429
+ ```
430
+
310
431
  ```bash
311
432
  RECOVERY_RESULTS=""
312
433
 
@@ -352,8 +473,31 @@ for NUM in $ORPHAN_LIST; do
352
473
  if [ "$DRY_RUN" = "true" ]; then
353
474
  echo " [DRY-RUN] Would: gh pr merge $PR_NUM --merge --auto"
354
475
  else
355
- MERGE_RESULT=$(gh pr merge "$PR_NUM" ${GH_FLAG} --merge --auto 2>&1)
356
- MERGE_EXIT=$?
476
+ # CI gate (MANDATORY before any autonomous merge): merge only when every check on the PR is
477
+ # green. Field test: PRs merged to staging with checks pending or red (#3165), because branch
478
+ # protection required none and an auto-merge waits only for *required* checks.
479
+ CI_GATE_SCRIPT=""
480
+ _l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
481
+ for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l"; do
482
+ case "$_c" in /*) [ -z "$CI_GATE_SCRIPT" ] && [ -f "$_c/scripts/wait-ci-green.sh" ] && CI_GATE_SCRIPT="$_c/scripts/wait-ci-green.sh" ;; esac
483
+ done
484
+ if [ -n "$CI_GATE_SCRIPT" ]; then CI_GATE_OUT=$(bash "$CI_GATE_SCRIPT" "$PR_NUM" ${GH_FLAG}); CI_GATE_RC=$?
485
+ else CI_GATE_OUT="CI_GATE: ERROR — scripts/wait-ci-green.sh not resolvable (fail closed)"; CI_GATE_RC=2; fi
486
+ echo "$CI_GATE_OUT"
487
+ GATED_HEAD=$(printf '%s\n' "$CI_GATE_OUT" | sed -n 's/^CI_GATE_HEAD: //p' | head -1)
488
+ # rc 3 = CI still running: re-run this block (up to 3 more times) before treating it as a failure.
489
+ if [ "$CI_GATE_RC" -eq 0 ]; then
490
+ MERGE_RESULT=$(gh pr merge "$PR_NUM" ${GH_FLAG} --merge --auto --match-head-commit "$GATED_HEAD" 2>&1)
491
+ MERGE_EXIT=$?
492
+ else
493
+ MERGE_RESULT="not merged: CI gate rc=${CI_GATE_RC}"; MERGE_EXIT=1
494
+ CI_MSG="⛔ /recover-orphans did not merge PR #${PR_NUM}: CI is not green.
495
+ \`\`\`
496
+ ${CI_GATE_OUT}
497
+ \`\`\`"
498
+ gh issue comment "$NUM" ${GH_FLAG} --body "$CI_MSG" 2>/dev/null || true # allowlist:check-command-side-effects
499
+ gh issue edit "$NUM" ${GH_FLAG} --add-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
500
+ fi
357
501
  echo " Merge result (exit $MERGE_EXIT): $MERGE_RESULT"
358
502
  if [ $MERGE_EXIT -eq 0 ]; then
359
503
  # Close the issue explicitly (Closes # only auto-closes on default branch)
@@ -373,25 +517,87 @@ for NUM in $ORPHAN_LIST; do
373
517
  # Open PR awaiting review — invoke /review-pr
374
518
  echo " Applying review-pr: invoking /review-pr on PR #$PR_NUM"
375
519
  if [ "$DRY_RUN" = "true" ]; then
376
- echo " [DRY-RUN] Would: Skill(skill='review-pr', args='$PR_NUM --auto-merge --issue $NUM --gh-flag $GH_FLAG')"
520
+ echo " [DRY-RUN] Would: Skill(skill='{FORGE_SKILL_PREFIX}review-pr', args='$PR_NUM --auto-merge --issue $NUM --gh-flag $GH_FLAG')"
377
521
  else
378
- Skill(skill="review-pr", args="${PR_NUM} --auto-merge --issue ${NUM} --gh-flag ${GH_FLAG}")
379
- # After review: update label
380
- gh issue edit "$NUM" ${GH_FLAG} --add-label "workflow:in-review" \
381
- --remove-label "workflow:building,workflow:awaiting-merge" 2>/dev/null || true
522
+ # forge#3148: count trail refusals that predate this sweep, so a refusal from an earlier sweep escalates
523
+ # instead of re-reviewing the same unrepaired trail every run.
524
+ # forge#3159: count only TRUSTED comments (pipeline bot, or OWNER/MEMBER/COLLABORATOR) newer than the latest
525
+ # repair (the most recent needs-human unlabel event), so a stranger's comment or a refusal a human already
526
+ # repaired never forces an escalation. Epoch fallback on a failed lookup counts more, i.e. fails toward escalation.
527
+ # --paginate: the comments endpoint returns 30 per page, oldest first. An unreadable count fails toward escalation.
528
+ PRIOR_TRAIL_CUTOFF=$(gh api --paginate "repos/${GH_REPO}/issues/${NUM}/events" \
529
+ --jq '.[] | select(.event == "unlabeled" and .label.name == "needs-human") | .created_at' 2>/dev/null \
530
+ | sort | tail -1)
531
+ PRIOR_TRAIL_CUTOFF="${PRIOR_TRAIL_CUTOFF:-1970-01-01T00:00:00Z}"
532
+ # gh api --jq takes one expression and no --arg, so the cutoff goes through a real jq pipe.
533
+ if PRIOR_TRAIL_IDS=$(set -o pipefail; gh api --paginate "repos/${GH_REPO}/issues/${NUM}/comments" 2>/dev/null \
534
+ | jq -r --arg cutoff "$PRIOR_TRAIL_CUTOFF" '.[] | select(.body | contains("FORGE:PHASE_TRAIL_FAILED"))
535
+ | select(.created_at > $cutoff)
536
+ | select(.user.type == "Bot" or (.author_association | IN("OWNER","MEMBER","COLLABORATOR"))) | .id'); then
537
+ PRIOR_TRAIL_FAILS=$(printf '%s\n' "$PRIOR_TRAIL_IDS" | grep -c '[0-9]')
538
+ else
539
+ PRIOR_TRAIL_FAILS=1
540
+ fi
541
+ REVIEW_STATUS=""; REVIEW_BLOCKER="" # reset per orphan so a previous orphan's result never leaks into this one
542
+ Skill(skill="{FORGE_SKILL_PREFIX}review-pr", args="${PR_NUM} --auto-merge --issue ${NUM} --gh-flag ${GH_FLAG}")
543
+ # REVIEW_STATUS = the `status:` field of the REVIEW_RESULT block the Skill call returned.
544
+ if [ "$REVIEW_STATUS" = "PHASE_TRAIL_FAILED" ]; then
545
+ # Phase 8 refused the merge: the issue's phase trail is incomplete (review-pr posted the
546
+ # MISSING lines as FORGE:PHASE_TRAIL_FAILED). Do NOT set workflow:in-review — that only re-queues
547
+ # the same refusal for the next sweep. Never hand-post the missing markers.
548
+ if [ "${PRIOR_TRAIL_FAILS:-0}" -gt 0 ]; then
549
+ gh issue edit "$NUM" ${GH_FLAG} --add-label "needs-human" 2>/dev/null || true
550
+ gh issue comment "$NUM" ${GH_FLAG} --body "<!-- FORGE:ORPHAN_RECOVERED -->
551
+ ## Orphan Recovery — Escalated
552
+
553
+ **Action**: \`/review-pr\` refused to merge PR #${PR_NUM} again because the phase trail is incomplete, and an earlier refusal was not repaired.
554
+ **Recovered by**: /recover-orphans
555
+
556
+ See the latest FORGE:PHASE_TRAIL_FAILED comment for the missing phases. Re-run each one via its Skill (or \`/work-on ${NUM}\`), then remove \`needs-human\`." 2>/dev/null || true
557
+ RECOVERY_RESULTS="${RECOVERY_RESULTS}| #${NUM} | review-pr | PR #${PR_NUM} refused (phase trail) again — needs-human added |\n"
558
+ else
559
+ # First refusal: resume /work-on, whose resume preflight re-runs each missing phase via Skill(...)
560
+ # and then re-enters review (work-on.md Phase 0B resume → work-on/review.md).
561
+ if claim_orphan "$NUM"; then
562
+ resume_orphan_inline "$NUM" # releases the claim on every exit
563
+ RECOVERY_RESULTS="${RECOVERY_RESULTS}| #${NUM} | review-pr | PR #${PR_NUM} refused (phase trail) — resumed /work-on to re-run missing phases |\n"
564
+ else
565
+ RECOVERY_RESULTS="${RECOVERY_RESULTS}| #${NUM} | review-pr | PR #${PR_NUM} refused (phase trail) — /work-on resume skipped: ${CLAIM_SKIP_REASON} |\n"
566
+ fi
567
+ fi
568
+ elif [ "$REVIEW_STATUS" = "BLOCKED" ] && echo "$REVIEW_BLOCKER" | grep -qE 'phase trail|auto-merge requires --issue'; then
569
+ # REVIEW_BLOCKER = the `blocker:` field of the same REVIEW_RESULT block. The gate could not run (verifier
570
+ # rc>=2 / unresolvable) or had no issue to verify (forge#3147). review-pr already added needs-human for the
571
+ # rc>=2 case; add it here too so the diagnosis skip above stops re-sweeping this orphan.
572
+ gh issue edit "$NUM" ${GH_FLAG} --add-label "needs-human" 2>/dev/null || true
573
+ RECOVERY_RESULTS="${RECOVERY_RESULTS}| #${NUM} | review-pr | PR #${PR_NUM} blocked by the merge gate — needs-human added |\n"
574
+ elif case "$REVIEW_STATUS" in COMPLETE|ALREADY_MERGED|BLOCKED) false ;; *) true ;; esac; then
575
+ # forge#3159: an empty or unrecognized status means the review result could not be read. Do not report
576
+ # "submitted for review" or re-queue it; escalate to a human.
577
+ gh issue edit "$NUM" ${GH_FLAG} --add-label "needs-human" 2>/dev/null || true
578
+ RECOVERY_RESULTS="${RECOVERY_RESULTS}| #${NUM} | review-pr | PR #${PR_NUM} unrecognized review status '${REVIEW_STATUS}' — needs-human added |\n"
579
+ else
580
+ # After review: update label
581
+ gh issue edit "$NUM" ${GH_FLAG} --add-label "workflow:in-review" \
582
+ --remove-label "workflow:building,workflow:awaiting-merge" 2>/dev/null || true
583
+ RECOVERY_RESULTS="${RECOVERY_RESULTS}| #${NUM} | review-pr | PR #${PR_NUM} submitted for review |\n"
584
+ fi
382
585
  fi
383
- RECOVERY_RESULTS="${RECOVERY_RESULTS}| #${NUM} | review-pr | PR #${PR_NUM} submitted for review |\n"
586
+ [ "$DRY_RUN" = "true" ] && RECOVERY_RESULTS="${RECOVERY_RESULTS}| #${NUM} | review-pr | PR #${PR_NUM} would be submitted for review |\n"
384
587
  ;;
385
588
 
386
589
  create-pr)
387
590
  # Branch has commits but no PR — resume /work-on to create PR
388
591
  echo " Applying create-pr: resuming /work-on to advance from build to PR creation"
389
592
  if [ "$DRY_RUN" = "true" ]; then
390
- echo " [DRY-RUN] Would: Skill(skill='work-on', args='$NUM')"
593
+ echo " [DRY-RUN] Would: Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='$NUM')"
594
+ RECOVERY_RESULTS="${RECOVERY_RESULTS}| #${NUM} | create-pr | Would resume /work-on — branch $BRANCH has commits, no PR |\n"
595
+ elif claim_orphan "$NUM"; then
596
+ resume_orphan_inline "$NUM" # releases the claim on every exit
597
+ RECOVERY_RESULTS="${RECOVERY_RESULTS}| #${NUM} | create-pr | Resumed /work-on — branch $BRANCH has commits, no PR |\n"
391
598
  else
392
- Skill(skill="work-on", args="${NUM}")
599
+ RECOVERY_RESULTS="${RECOVERY_RESULTS}| #${NUM} | create-pr | /work-on resume skipped: ${CLAIM_SKIP_REASON} |\n"
393
600
  fi
394
- RECOVERY_RESULTS="${RECOVERY_RESULTS}| #${NUM} | create-pr | Resumed /work-on — branch $BRANCH has commits, no PR |\n"
395
601
  ;;
396
602
 
397
603
  reset-labels)
@@ -1,4 +1,5 @@
1
1
  ---
2
+ user-invocable: false
2
3
  install: core
3
4
  ---
4
5
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
@@ -1,4 +1,5 @@
1
1
  ---
2
+ user-invocable: false
2
3
  install: core
3
4
  ---
4
5
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
@@ -1,4 +1,5 @@
1
1
  ---
2
+ user-invocable: false
2
3
  install: core
3
4
  ---
4
5
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
@@ -1,4 +1,5 @@
1
1
  ---
2
+ user-invocable: false
2
3
  install: core
3
4
  ---
4
5
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
@@ -1,4 +1,5 @@
1
1
  ---
2
+ user-invocable: false
2
3
  install: core
3
4
  ---
4
5
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
@@ -1,4 +1,5 @@
1
1
  ---
2
+ user-invocable: false
2
3
  install: core
3
4
  ---
4
5
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
@@ -1,4 +1,5 @@
1
1
  ---
2
+ user-invocable: false
2
3
  install: core
3
4
  ---
4
5
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
@@ -1,4 +1,5 @@
1
1
  ---
2
+ user-invocable: false
2
3
  install: core
3
4
  ---
4
5
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
@@ -1,4 +1,5 @@
1
1
  ---
2
+ user-invocable: false
2
3
  install: core
3
4
  ---
4
5
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
@@ -1,4 +1,5 @@
1
1
  ---
2
+ user-invocable: false
2
3
  install: core
3
4
  ---
4
5
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
@@ -1,4 +1,5 @@
1
1
  ---
2
+ user-invocable: false
2
3
  install: internal
3
4
  ---
4
5
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
@@ -1,3 +1,7 @@
1
+ ---
2
+ description: Review agent catalog (read by /review-pr; not a user entrypoint)
3
+ user-invocable: false
4
+ ---
1
5
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
2
6
  <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
3
7
 
@@ -37,7 +41,7 @@ For each agent selected in Phase 3B, read **only** the files relevant to that ag
37
41
 
38
42
  1. **Always read first** (shared protocols, all agents require this):
39
43
  ```
40
- Read: $FORGE_HOME/commands/review-pr-agents/protocols.md
44
+ Read: ${CLAUDE_PLUGIN_ROOT}/commands/review-pr-agents/protocols.md
41
45
  ```
42
46
 
43
47
  2. **Then read the persona file for each selected agent**:
@@ -70,11 +74,11 @@ For each agent selected in Phase 3B, read **only** the files relevant to that ag
70
74
 
71
75
  ## Loading Instructions for Phase 3C
72
76
 
73
- Replace the single `Read: $FORGE_HOME/commands/review-pr-agents.md` call with:
77
+ Replace the single `Read: ${CLAUDE_PLUGIN_ROOT}/commands/review-pr-agents.md` call with:
74
78
 
75
79
  ```
76
- Read: $FORGE_HOME/commands/review-pr-agents/protocols.md
77
- Read: $FORGE_HOME/commands/review-pr-agents/<persona>.md (one per selected agent)
80
+ Read: ${CLAUDE_PLUGIN_ROOT}/commands/review-pr-agents/protocols.md
81
+ Read: ${CLAUDE_PLUGIN_ROOT}/commands/review-pr-agents/<persona>.md (one per selected agent)
78
82
  ```
79
83
 
80
84
  Each persona file contains:
@@ -87,3 +91,5 @@ The `protocols.md` file contains:
87
91
  - Tool-Result Truncation Discipline
88
92
  - Evidence-Based Review Protocol (all agents follow)
89
93
  - Structured Findings Protocol (machine-readable findings block format)
94
+
95
+ **Spec root (MANDATORY)**: read every file named in this catalog from `${CLAUDE_PLUGIN_ROOT}` — the install root of the ForgeDock plugin that is running this command (Claude Code fills it in when it loads the spec). Only if that path does not start with `/` (not a Claude Code plugin session: install.sh, Codex, OpenCode) use `$FORGE_HOME` instead. Never read sub-files from `$FORGE_HOME` when the plugin root resolved: an exported `FORGE_HOME` can point at a different, older ForgeDock checkout, and mixing roots runs stale phase specs.
@@ -1,4 +1,5 @@
1
1
  ---
2
+ user-invocable: false
2
3
  description: Staging review mode — comprehensive review of staging branch before deploy to main
3
4
  argument-hint: "[PR number or \"staging\"]"
4
5
  allowed-tools: Task, Agent, Bash, Read, Grep, Glob, WebFetch
@@ -9,7 +10,9 @@ install: core
9
10
 
10
11
  # Staging Review
11
12
 
12
- **Trigger**: Invoked by the orchestrator via `Skill("review-pr-staging", $ARGUMENTS)`, or directly with `$ARGUMENTS` = "staging", a PR number targeting main, or "staging:feature".
13
+ > **Skill names**: `{FORGE_SKILL_PREFIX}` is `forgedock:` (plugin install) or empty (`install.sh`), resolved once per run by `commands/work-on.md` § Skill Name Resolution. If the skill is not found under either name, STOP and report "skill not found" — never run the phase inline.
14
+
15
+ **Trigger**: Invoked by the orchestrator via `Skill("{FORGE_SKILL_PREFIX}review-pr-staging", $ARGUMENTS)`, or directly with `$ARGUMENTS` = "staging", a PR number targeting main, or "staging:feature".
13
16
 
14
17
  Performs comprehensive review of `staging` before merging to `main`. Handles large diffs (1,000-10,000+ lines), diverse changes, deep analysis, and business impact assessment.
15
18
 
@@ -502,7 +505,13 @@ Missing persona templates are a fatal setup error, not permission to skip multi-
502
505
 
503
506
  ```bash
504
507
  TEMPLATE_BASE=""
505
- if [[ -f "$FORGE_HOME/commands/review-pr-agents/protocols.md" ]]; then
508
+ # Tier 0: the running plugin's own root (Claude Code substitutes this exact spelling; elsewhere it stays a
509
+ # literal that the /* check rejects). Wins over an exported FORGE_HOME, which may name an older checkout.
510
+ _PLUGIN_ROOT='${CLAUDE_PLUGIN_ROOT}'; case "$_PLUGIN_ROOT" in /*) ;; *) _PLUGIN_ROOT="" ;; esac
511
+ if [[ -n "$_PLUGIN_ROOT" && -f "$_PLUGIN_ROOT/commands/review-pr-agents/protocols.md" ]]; then
512
+ TEMPLATE_BASE="$_PLUGIN_ROOT/commands/review-pr-agents"
513
+ TEMPLATE_SOURCE="plugin_root"
514
+ elif [[ -f "$FORGE_HOME/commands/review-pr-agents/protocols.md" ]]; then
506
515
  TEMPLATE_BASE="$FORGE_HOME/commands/review-pr-agents"
507
516
  TEMPLATE_SOURCE="forge_home"
508
517
  else
@@ -530,7 +539,7 @@ fi
530
539
 
531
540
  **If `TEMPLATE_SOURCE` is `none`**: HARD STOP. Post a PR comment explaining the setup is broken, instructing the user to run `npx forgedock update` to repair the install, add `needs-human`, and exit without posting any findings or a verdict. **NEVER perform the review inline in the main agent context as a substitute.**
532
541
 
533
- **If `TEMPLATE_SOURCE` is `forge_home` or `repo_path`** (normal cases — behavior unchanged): `Read: $TEMPLATE_BASE/protocols.md` and `Read: $TEMPLATE_BASE/<persona>.md` per selected agent.
542
+ **If `TEMPLATE_SOURCE` is `plugin_root`, `forge_home` or `repo_path`** (normal cases — behavior unchanged): `Read: $TEMPLATE_BASE/protocols.md` and `Read: $TEMPLATE_BASE/<persona>.md` per selected agent.
534
543
 
535
544
  **If `TEMPLATE_SOURCE` is `monolithic_catalog`** (last resort): `Read: $MONOLITHIC_CATALOG` and extract the shared protocols section plus each selected persona's section from within that single file.
536
545
 
@@ -593,7 +602,7 @@ TEST_GATE_REASON="Phase 6.5 not yet run"
593
602
 
594
603
  # Invoke /test-gate with the bundle PRs already computed in Phase 0A
595
604
  # ALL_PR_NUMBERS is the de-duplicated union of both scan methods (commit log + merge subjects)
596
- GATE_OUTPUT=$(Skill("test-gate", "--prs \"$(echo $ALL_PR_NUMBERS | tr '\n' ' ' | xargs)\" --base $DEFAULT_BRANCH"))
605
+ GATE_OUTPUT=$(Skill("{FORGE_SKILL_PREFIX}test-gate", "--prs \"$(echo $ALL_PR_NUMBERS | tr '\n' ' ' | xargs)\" --base $DEFAULT_BRANCH"))
597
606
 
598
607
  # Extract machine-readable verdict from Skill output
599
608
  TEST_GATE_VERDICT=$(echo "$GATE_OUTPUT" | grep -oP '(?<=FORGE:TEST_GATE:RESULT=)(BLOCK|PASS|SKIP)' | tail -1 || echo "SKIP")
@@ -872,7 +881,7 @@ else
872
881
  # --label is repeatable (not comma-joined) per the /issue programmatic contract.
873
882
  # ${MILESTONE_FLAG} carries the Phase 7D derivation through — empty string is
874
883
  # a no-op arg when the reviewed branch has no milestone (plain staging→main).
875
- ISSUE_SKILL_OUTPUT=$(Skill(skill="issue", args="--title \"$STAGING_FINDING_TITLE\" --body-file \"$STAGING_FINDING_BODY_FILE\" --label review-finding --label needs-validation --label staging-review --label \"$STAGING_FINDING_PRIORITY\" ${MILESTONE_FLAG}"))
884
+ ISSUE_SKILL_OUTPUT=$(Skill(skill="{FORGE_SKILL_PREFIX}issue", args="--title \"$STAGING_FINDING_TITLE\" --body-file \"$STAGING_FINDING_BODY_FILE\" --label review-finding --label needs-validation --label staging-review --label \"$STAGING_FINDING_PRIORITY\" ${MILESTONE_FLAG}"))
876
885
  # /issue re-reads the created issue and hard-fails unless this exact marker is present.
877
886
  rm -f "$STAGING_FINDING_BODY_FILE"
878
887