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.
- package/bin/engine/admission.mjs +57 -1
- package/bin/engine/admission.test.mjs +60 -0
- package/commands/analytics.md +3 -1
- package/commands/audit-agents.md +3 -1
- package/commands/audit.md +10 -8
- package/commands/autopilot.md +16 -14
- package/commands/ci-audit.md +4 -2
- package/commands/cleanup.md +1 -1
- package/commands/deploy-pr.md +31 -11
- package/commands/design-bench.md +1 -0
- package/commands/design-render-critique-loop.md +1 -0
- package/commands/design.md +1 -0
- package/commands/forge-stats.md +1 -0
- package/commands/geo-audit.md +3 -1
- package/commands/incident-response.md +3 -1
- package/commands/issue.md +4 -2
- package/commands/milestone.md +8 -6
- package/commands/orchestrate/config.md +3 -2
- package/commands/orchestrate/phase-1-resolve.md +57 -12
- package/commands/orchestrate/phase-2-triage.md +1 -0
- package/commands/orchestrate/phase-2.5-synthesis.md +1 -0
- package/commands/orchestrate/phase-3-dependency.md +17 -5
- package/commands/orchestrate/phase-4-execution.md +625 -78
- package/commands/orchestrate/phase-5-cleanup.md +5 -2
- package/commands/orchestrate/phase-6-report.md +8 -3
- package/commands/orchestrate/safety.md +1 -0
- package/commands/orchestrate.md +13 -9
- package/commands/pipeline-health/config.md +1 -0
- package/commands/pipeline-health/phase-1-context.md +1 -0
- package/commands/pipeline-health/phase-2-metrics.md +1 -0
- package/commands/pipeline-health/phase-3-analyze.md +1 -0
- package/commands/pipeline-health/phase-4-proposals.md +7 -4
- package/commands/pipeline-health/phase-5-report.md +5 -2
- package/commands/pipeline-health/phase-6-summary.md +1 -0
- package/commands/pipeline-health.md +9 -7
- package/commands/pipeline-resume.md +4 -2
- package/commands/qa-sweep.md +3 -1
- package/commands/quality-gate.md +4 -3
- package/commands/recover-orphans.md +219 -13
- package/commands/review-pr-agents/api.md +1 -0
- package/commands/review-pr-agents/auth.md +1 -0
- package/commands/review-pr-agents/billing.md +1 -0
- package/commands/review-pr-agents/concurrency.md +1 -0
- package/commands/review-pr-agents/database.md +1 -0
- package/commands/review-pr-agents/frontend.md +1 -0
- package/commands/review-pr-agents/infra.md +1 -0
- package/commands/review-pr-agents/protocols.md +1 -0
- package/commands/review-pr-agents/scraper.md +1 -0
- package/commands/review-pr-agents/security.md +1 -0
- package/commands/review-pr-agents/spec-cli.md +1 -0
- package/commands/review-pr-agents.md +10 -4
- package/commands/review-pr-staging.md +14 -5
- package/commands/review-pr.md +187 -19
- package/commands/rollback.md +3 -1
- package/commands/security-audit.md +3 -1
- package/commands/signal-planner.md +6 -4
- package/commands/sync-ecosystem.md +4 -2
- package/commands/test-gate.md +4 -2
- package/commands/upgrade-deps.md +3 -1
- package/commands/validate.md +3 -1
- package/commands/work-on/build/architect.md +82 -19
- package/commands/work-on/build/context.md +62 -18
- package/commands/work-on/build/implement.md +63 -18
- package/commands/work-on/build/validate.md +241 -60
- package/commands/work-on/build.md +345 -146
- package/commands/work-on/close.md +618 -246
- package/commands/work-on/decompose.md +240 -41
- package/commands/work-on/investigate.md +378 -61
- package/commands/work-on/remediate.md +43 -9
- package/commands/work-on/review.md +241 -29
- package/commands/work-on.md +180 -2014
- package/docs/CODEX.md +2 -0
- package/docs/CONFIG.md +11 -0
- package/package.json +1 -1
- package/scripts/build-spec-graph.mjs +11 -4
- package/scripts/check-spec-markers.sh +21 -1
- package/scripts/checkbox-sections.test.sh +8 -23
- package/scripts/extract-affected-files.sh +5 -2
- package/scripts/extract-affected-files.test.sh +27 -0
- package/scripts/forge-root.test.sh +176 -0
- package/scripts/is-docs-only.sh +31 -0
- package/scripts/is-docs-only.test.sh +64 -0
- package/scripts/lint-dispatch-prompt.sh +217 -0
- package/scripts/lint-dispatch-prompt.test.sh +307 -0
- package/scripts/validate-spec-graph.sh +3 -2
- package/scripts/verify-phase-trail.sh +282 -0
- package/scripts/verify-phase-trail.test.sh +298 -0
- package/scripts/wait-ci-green.sh +98 -0
- package/scripts/wait-ci-green.test.sh +99 -0
- package/commands/work-on-monolithic.md +0 -326
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
user-invocable: false
|
|
2
3
|
install: core
|
|
3
4
|
---
|
|
4
5
|
<!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
|
|
@@ -29,7 +30,7 @@ if [ -n "${FORGE_COORD_ISSUE:-}" ] && [ -n "${COORD_ISSUE_NUMBER:-}" ] && [ -n "
|
|
|
29
30
|
exit 1
|
|
30
31
|
;;
|
|
31
32
|
free|self)
|
|
32
|
-
HOSTNAME_ID=$(hostname 2>/dev/null ||
|
|
33
|
+
HOSTNAME_ID=$(hostname 2>/dev/null || uname -n 2>/dev/null || true); HOSTNAME_ID="${HOSTNAME_ID:-unknown-host}"
|
|
33
34
|
# GOVERNOR-exempt: intentional coordination side-effect (best-effort lease/board/finding post), DRY_RUN-safe — reviewed & accepted for the check-command-side-effects gate. Flagged only by the staging->main full-diff; passes on every feature PR. forge#2627
|
|
34
35
|
gh issue comment "$COORD_ISSUE_NUMBER" -R {GH_REPO} --body "<!-- FORGE:LEASE -->
|
|
35
36
|
**Holder Batch ID**: ${BATCH_ID}
|
|
@@ -392,10 +393,12 @@ resolve_classify_lane() {
|
|
|
392
393
|
[ -n "${OPENCODE_SESSION_ID:-}" ] ||
|
|
393
394
|
[ -n "${OPENCODE_PID:-}" ] ||
|
|
394
395
|
[ -n "${OPENCODE:-}" ]; then
|
|
396
|
+
case '${CLAUDE_PLUGIN_ROOT}' in /*) candidates+=('${CLAUDE_PLUGIN_ROOT}/scripts/classify-lane.sh') ;; esac # running plugin first
|
|
395
397
|
[ -n "${FORGE_HOME:-}" ] && candidates+=("$FORGE_HOME/scripts/classify-lane.sh")
|
|
396
398
|
[ -n "${REPO_PATH:-}" ] && candidates+=("$REPO_PATH/scripts/classify-lane.sh")
|
|
397
399
|
candidates+=("$HOME/.opencode/scripts/classify-lane.sh")
|
|
398
400
|
else
|
|
401
|
+
case '${CLAUDE_PLUGIN_ROOT}' in /*) candidates+=('${CLAUDE_PLUGIN_ROOT}/scripts/classify-lane.sh') ;; esac # running plugin first
|
|
399
402
|
[ -n "${FORGE_HOME:-}" ] && candidates+=("$FORGE_HOME/scripts/classify-lane.sh")
|
|
400
403
|
candidates+=("$HOME/.claude/scripts/classify-lane.sh")
|
|
401
404
|
[ -n "${REPO_PATH:-}" ] && candidates+=("$REPO_PATH/scripts/classify-lane.sh")
|
|
@@ -444,8 +447,11 @@ declare -A DEFERRED_REASONS
|
|
|
444
447
|
# makes the reported amplification ratio look better than it was.
|
|
445
448
|
FINDINGS_SPAWNED=0
|
|
446
449
|
MERGED_UNITS=0
|
|
450
|
+
UNIT_MERGED_THIS_CYCLE=false # set per completed unit by the Step 4C accounting block (forge#3060)
|
|
447
451
|
AMPLIFICATION_RATIO_HISTORY=()
|
|
448
452
|
AMPLIFICATION_DEFERRED=()
|
|
453
|
+
AMPLIFICATION_BREAKER_DEFERRED=() # findings paused by the on-by-default breaker (forge#3060)
|
|
454
|
+
BREAKER_DEFER_TAG="amplification breaker" # single source for the defer-reason text matched in Step 4F
|
|
449
455
|
declare -A FINDINGS_BY_SOURCE_PR
|
|
450
456
|
declare -A REFINEMENT_FINDINGS
|
|
451
457
|
declare -A NEW_SURFACE_FINDINGS
|
|
@@ -504,6 +510,7 @@ declare -A EDGE_REDERIVE_ATTEMPTS
|
|
|
504
510
|
# copied ForgeDock's helper scripts into its own repository (#2794/#2791).
|
|
505
511
|
resolve_extract_affected_files() {
|
|
506
512
|
local candidates=()
|
|
513
|
+
case '${CLAUDE_PLUGIN_ROOT}' in /*) candidates+=('${CLAUDE_PLUGIN_ROOT}/scripts/extract-affected-files.sh') ;; esac # running plugin first
|
|
507
514
|
[ -n "${FORGE_HOME:-}" ] && candidates+=("$FORGE_HOME/scripts/extract-affected-files.sh")
|
|
508
515
|
[ -n "${REPO_PATH:-}" ] && candidates+=("$REPO_PATH/scripts/extract-affected-files.sh")
|
|
509
516
|
candidates+=("$PWD/scripts/extract-affected-files.sh")
|
|
@@ -596,7 +603,7 @@ fi
|
|
|
596
603
|
|
|
597
604
|
TOKEN_ESTIMATE_PER_FINDING=$(yq '.pipeline.token_estimate_per_finding // 150000' forge.yaml 2>/dev/null || echo 150000)
|
|
598
605
|
|
|
599
|
-
# Amplification is observational by default. The optional ceiling is evaluated
|
|
606
|
+
# Amplification is observational by default for the same-lineage ceiling (the breaker below is not). The optional ceiling is evaluated
|
|
600
607
|
# only against same-lineage refinements in Step 4C, never against new surface.
|
|
601
608
|
CASCADE_MAX_AMPLIFICATION=$(yq '.orchestration.cascade.max_amplification // "off"' forge.yaml 2>/dev/null || echo "off")
|
|
602
609
|
if [ "$CASCADE_MAX_AMPLIFICATION" != "off" ]; then
|
|
@@ -611,7 +618,23 @@ if ! echo "$CONVERGENCE_WINDOW" | grep -qE '^[1-9][0-9]*$'; then
|
|
|
611
618
|
echo "WARNING: forge.yaml → orchestration.cascade.convergence_window must be a positive integer — falling back to 3"
|
|
612
619
|
CONVERGENCE_WINDOW=3
|
|
613
620
|
fi
|
|
614
|
-
|
|
621
|
+
# On-by-default amplification circuit breaker (forge#3060). Unlike max_amplification (opt-in,
|
|
622
|
+
# same-lineage refinements only), this counts ALL cascade findings and pauses P3-and-below
|
|
623
|
+
# admission when the ratio stays >= 1.0 for CONVERGENCE_WINDOW merged units. P1/P2 are never
|
|
624
|
+
# affected. It is independent of orchestration.cascade.policy, so `policy: all` and the
|
|
625
|
+
# --max-generation/--token-budget unlimited CLI overrides do NOT disable it. Opt out only
|
|
626
|
+
# with an explicit `orchestration.cascade.amplification_breaker: off`.
|
|
627
|
+
# NOTE: do NOT use yq's `// "on"` here — the alternative operator treats boolean `false` as falsy and
|
|
628
|
+
# would turn `amplification_breaker: false` into "on". Read the raw value and default null/empty to "on".
|
|
629
|
+
AMPLIFICATION_BREAKER=$(yq '.orchestration.cascade.amplification_breaker' forge.yaml 2>/dev/null || echo "on")
|
|
630
|
+
case "$AMPLIFICATION_BREAKER" in ""|null) AMPLIFICATION_BREAKER="on" ;; esac
|
|
631
|
+
case "$(echo "$AMPLIFICATION_BREAKER" | tr '[:upper:]' '[:lower:]')" in
|
|
632
|
+
on|true) AMPLIFICATION_BREAKER="on" ;;
|
|
633
|
+
off|false) AMPLIFICATION_BREAKER="off" ;;
|
|
634
|
+
*) echo "WARNING: forge.yaml → orchestration.cascade.amplification_breaker must be on or off (\"${AMPLIFICATION_BREAKER}\") — keeping the breaker on"; AMPLIFICATION_BREAKER="on" ;;
|
|
635
|
+
esac
|
|
636
|
+
AMPLIFICATION_BREAKER_TRIPPED=false
|
|
637
|
+
echo "Cascade amplification: max_amplification=${CASCADE_MAX_AMPLIFICATION} convergence_window=${CONVERGENCE_WINDOW} amplification_breaker=${AMPLIFICATION_BREAKER} (breaker is on by default; max_amplification off preserves same-lineage behavior)"
|
|
615
638
|
|
|
616
639
|
# Independent boolean levers — each accepts an explicit granular override on
|
|
617
640
|
# top of the resolved preset (preset supplies the default, not a hard value).
|
|
@@ -674,17 +697,21 @@ Use `${ISSUE_LANE[$NUM]}` and `${ISSUE_PR_BASE[$NUM]}` to populate `{LANE}` and
|
|
|
674
697
|
### Step 4A.0: Probe Knowledge Gist capability once
|
|
675
698
|
|
|
676
699
|
Probe the authenticated identity once for this orchestration run before any engine, Agent, or
|
|
677
|
-
OpenCode worker is dispatched. A GitHub App installation token
|
|
678
|
-
|
|
679
|
-
|
|
700
|
+
OpenCode worker is dispatched. A GitHub App installation token cannot use the Gists API (and cannot even read `/user`); cache
|
|
701
|
+
that fact rather than letting every worker rediscover it by attempting a write. The probe fails
|
|
702
|
+
closed: Gists stay enabled only when `/user` positively reports a `User` account.
|
|
680
703
|
|
|
681
704
|
```bash
|
|
682
705
|
if [ -z "${FORGE_GIST_CAPABLE+x}" ]; then
|
|
706
|
+
# Fail closed: only a positively identified user account can use the Gists API. A GitHub App
|
|
707
|
+
# installation token cannot read /user at all (HTTP 403, empty type), so "not Bot" is NOT
|
|
708
|
+
# evidence of capability; an unavailable probe disables Gists rather than letting every
|
|
709
|
+
# worker fail on its first Gist write.
|
|
683
710
|
GIST_AUTH_TYPE=$(gh api user --jq '.type' 2>/dev/null || true)
|
|
684
|
-
if [ "$GIST_AUTH_TYPE" = "
|
|
685
|
-
FORGE_GIST_CAPABLE=false
|
|
686
|
-
else
|
|
711
|
+
if [ "$GIST_AUTH_TYPE" = "User" ]; then
|
|
687
712
|
FORGE_GIST_CAPABLE=true
|
|
713
|
+
else
|
|
714
|
+
FORGE_GIST_CAPABLE=false
|
|
688
715
|
fi
|
|
689
716
|
export FORGE_GIST_CAPABLE
|
|
690
717
|
fi
|
|
@@ -782,7 +809,7 @@ task(
|
|
|
782
809
|
description="Work on {PROJECT_PREFIX}#{NUMBER}",
|
|
783
810
|
subagent_type="general",
|
|
784
811
|
background=true,
|
|
785
|
-
prompt="Use the same Phase 4A work-on template below. Before invoking it, run `export FORGE_GIST_CAPABLE={FORGE_GIST_CAPABLE}` so the cached orchestration capability is preserved. Invoke Skill(skill='work-on', args='{PROJECT_PREFIX}{NUMBER} --under-orchestration') and continue until a terminal workflow state."
|
|
812
|
+
prompt="Use the same Phase 4A work-on template below. Before invoking it, run `export FORGE_GIST_CAPABLE={FORGE_GIST_CAPABLE}` so the cached orchestration capability is preserved. Invoke Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='{PROJECT_PREFIX}{NUMBER} --under-orchestration') and continue until a terminal workflow state."
|
|
786
813
|
)
|
|
787
814
|
```
|
|
788
815
|
|
|
@@ -922,7 +949,7 @@ if [ "$FORGEDOCK_AVAILABLE" = "true" ]; then
|
|
|
922
949
|
# Lease heartbeat refresh (forge#2627) — once per dispatch chunk, so a long-running batch's
|
|
923
950
|
# lease never goes stale purely from elapsed wall-clock time while dispatch is still active.
|
|
924
951
|
if [ "${#DISPATCH_NOW[@]}" -gt 0 ] && [ -n "${FORGE_COORD_ISSUE:-}" ] && [ -n "${COORD_ISSUE_NUMBER:-}" ] && [ -n "${BATCH_ID:-}" ]; then
|
|
925
|
-
HOSTNAME_ID=$(hostname 2>/dev/null ||
|
|
952
|
+
HOSTNAME_ID=$(hostname 2>/dev/null || uname -n 2>/dev/null || true); HOSTNAME_ID="${HOSTNAME_ID:-unknown-host}"
|
|
926
953
|
# GOVERNOR-exempt: intentional coordination side-effect (best-effort lease/board/finding post), DRY_RUN-safe — reviewed & accepted for the check-command-side-effects gate. Flagged only by the staging->main full-diff; passes on every feature PR. forge#2627
|
|
927
954
|
gh issue comment "$COORD_ISSUE_NUMBER" -R {GH_REPO} --body "<!-- FORGE:LEASE -->
|
|
928
955
|
**Holder Batch ID**: ${BATCH_ID}
|
|
@@ -976,6 +1003,8 @@ For each **ready** issue (all predecessors resolved or no predecessors), spawn a
|
|
|
976
1003
|
|
|
977
1004
|
**One agent per issue.** Do NOT group multiple issues into a single agent. `/work-on` handles branching, labels, and PRs per-issue.
|
|
978
1005
|
|
|
1006
|
+
**Resolve `{FORGE_SKILL_PREFIX}` once before dispatching** (`forgedock:` for the plugin install, empty for `install.sh`; rule in `commands/work-on.md` § Skill Name Resolution) and substitute it literally into every copy of the template below (4A, 4B, 4F, remediation and finding dispatches). Subagents cannot see this resolution, so never leave the placeholder unfilled.
|
|
1007
|
+
|
|
979
1008
|
**Copy this template. Fill in variables. Do not modify the structure:**
|
|
980
1009
|
|
|
981
1010
|
```
|
|
@@ -999,12 +1028,13 @@ Agent(
|
|
|
999
1028
|
**HOW REVIEW FINDINGS WORK**: /review-pr may create GitHub issues (with `review-finding` label) for findings it discovers. These are NOT blockers — they are separate work items that will go through their own /work-on pipeline later. The original PR should ALWAYS merge after review. The only exception is build errors (code doesn't compile) — those must be fixed before merging.
|
|
1000
1029
|
|
|
1001
1030
|
**IMPORTANT RULES**:
|
|
1002
|
-
- **MANDATORY**: You MUST use the Skill tool to invoke 'work-on' with args '{PROJECT_PREFIX}{NUMBER}'. Do NOT implement manually — /work-on handles the full pipeline including label state machine (workflow:investigating → workflow:building → workflow:in-review → workflow:merged), investigation reports, PR creation, and cleanup.
|
|
1003
|
-
- For default repo issues: `Skill(skill='work-on', args='{NUMBER} --under-orchestration')`
|
|
1004
|
-
- For satellite repo issues: `Skill(skill='work-on', args='{
|
|
1031
|
+
- **MANDATORY**: You MUST use the Skill tool to invoke '{FORGE_SKILL_PREFIX}work-on' with args '{PROJECT_PREFIX}{NUMBER}'. Do NOT implement manually — /work-on handles the full pipeline including label state machine (workflow:investigating → workflow:building → workflow:in-review → workflow:merged), investigation reports, PR creation, and cleanup.
|
|
1032
|
+
- For default repo issues: `Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='{NUMBER} --under-orchestration')`
|
|
1033
|
+
- For satellite repo issues: `Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='<satellite-prefix>:{NUMBER} --under-orchestration')` (`<satellite-prefix>` from forge.yaml → repos.satellites)
|
|
1034
|
+
- **If `{FORGE_SKILL_PREFIX}work-on` is reported unknown or not found, this is a HARD ERROR: STOP and report 'skill not found: {FORGE_SKILL_PREFIX}work-on' as your final result. Do NOT run the pipeline phases inline or with the Agent tool — an inline run bypasses the phase trail.**
|
|
1005
1035
|
- The `--under-orchestration` flag tells `/work-on` to post its phase-entry `FORGE:HEARTBEAT` comments (Phases 0/1/3/5) — this orchestrator's Step 4B.5 stall detector depends on those timestamps. A solo `/work-on` run omits the flag and skips those writes entirely (see `commands/work-on.md` → Orchestration Flag).
|
|
1006
1036
|
- NEVER bypass /work-on with manual git/gh commands — the label updates and structured comments are critical for tracking
|
|
1007
|
-
- **File-backed GitHub bodies — entity scope plus read-back is mandatory**: Never stage a `gh --body-file` body at a generic shared `/tmp` path, including one made by bare `mktemp`, and never hand-roll a root-level path such as `/tmp_invbody_31076.txt`, which can hang unattended cleanup. Prefer the session scratchpad or a repo-relative scratch directory on Windows: a native Windows `gh` may not resolve Git Bash `/tmp` reliably. Create a filename that contains the target issue/PR number and an agent-unique token, then use `mktemp` for its random suffix. Put one caller-chosen marker such as `<!-- FORGE:BODY-INTEGRITY
|
|
1037
|
+
- **File-backed GitHub bodies — entity scope plus read-back is mandatory**: Never stage a `gh --body-file` body at a generic shared `/tmp` path, including one made by bare `mktemp`, and never hand-roll a root-level path such as `/tmp_invbody_31076.txt`, which can hang unattended cleanup. Prefer the session scratchpad or a repo-relative scratch directory on Windows: a native Windows `gh` may not resolve Git Bash `/tmp` reliably. Create a filename that contains the target issue/PR number and an agent-unique token, then use `mktemp` for its random suffix. Put one caller-chosen marker such as `<!-- FORGE:BODY-INTEGRITY:<issue-number>_investigator_<agent-token> -->` in the body. After every `gh issue create|edit` or `gh pr create|comment` using `--body-file`, re-read the target object and assert the exact marker is present; a mismatch is a hard error. Unique names reduce collisions, but only read-back detects a collision that substitutes plausible-looking content from another agent. Do not rely on eyeballing. (forge#2843, forge#2855) <!-- allowlist:check-command-side-effects -->
|
|
1008
1038
|
- **GitHub secondary rate limit**: If a GitHub API call returns HTTP 403 with `secondary rate limit` in its response, do NOT retry it or start a polling loop. Stop GitHub content creation for this phase, report the status and response body to the orchestrator in your final result, and wait for a later batch resume. Retrying extends the throttle for every sibling.
|
|
1009
1039
|
- **Temp files — ALWAYS use `mktemp`, NEVER hand-roll a path**: You are one of several agents running concurrently on this host and you share its `/tmp` with all of them. Any time you stage content in a temp file before passing it to `gh` (e.g. `--body-file`), create that path with `mktemp` (e.g. `BODY_FILE="$(mktemp)"`). Never write a temp file to a single-segment root path such as `/tmp_invbody_31076.txt`: Claude Code treats removing it as dangerous and requires an explicit approval that bypass mode cannot clear, hanging unattended runs. Also never use a fixed literal such as `/tmp/body.md` or `/tmp/issue.json`: a fixed path collides with another concurrently-running agent and can silently overwrite the content you staged (or you can silently overwrite theirs) before either of you reads it back. Safe: `BODY_FILE="$(mktemp)"`. Unsafe: `/tmp_invbody_31076.txt` (a missing slash that creates a root-level path) and `/tmp/body.md` (a fixed path). `mktemp` costs nothing and prevents both failures. (forge#2198, forge#2855)
|
|
1010
1040
|
- **`docker cp` — NEVER write into a bind-mounted shared container**: If the project you're working on runs its dev/test containers with a bind mount to the main (non-worktree) checkout, `docker cp` into that container writes through the mount into the main checkout — not your isolated per-issue worktree. Before running `docker cp` into any container, confirm its mount source is your own worktree, not a shared/main one; if you can't confirm that, don't assume an in-container test run reflects your feature branch. (forge#2198)
|
|
@@ -1016,30 +1046,115 @@ Agent(
|
|
|
1016
1046
|
- **NEVER ask the user questions** — you are a background agent. If review finds issues, auto-fix simple ones and proceed, then let `/review-pr`'s own verdict decide: APPROVED (no unresolved CONFIRMED HIGH/CRITICAL finding) → merge to `staging` and create follow-up issues for the rest, **regardless of domain**. Domain alone (AUTH, BILLING, DATABASE, or any domain tagged as security-critical in Step 3B) is NOT a reason to add `needs-human` and stop — `staging` is reversible; the real human deploy gate is `staging → main`, not this merge. <!-- Added: forge#1815 --> `needs-human` is reserved for what the pipeline genuinely cannot do itself: spend/procurement decisions, real-environment validation it has no access to, product/architecture judgment calls a human must make, or `/review-pr`'s existing evidence-based escalations (spec-evolution guard, novel task-type/module-combo trust escalation, calibration-based overconfidence routing). An unresolved CONFIRMED HIGH/CRITICAL finding is `/review-pr`'s own withheld-APPROVED case, not a domain-driven `needs-human` halt — `/review-pr` already refuses to return APPROVED when that's true, so there is no separate domain check to perform here.
|
|
1017
1047
|
|
|
1018
1048
|
**LABEL-STATE LOOP CONTRACT — enforce after EVERY Skill return**:
|
|
1019
|
-
After EVERY `Skill(skill='work-on', ...)` call returns, immediately check the issue's current workflow label:
|
|
1049
|
+
After EVERY `Skill(skill='{FORGE_SKILL_PREFIX}work-on', ...)` call returns, immediately check the issue's current workflow label:
|
|
1020
1050
|
```bash
|
|
1021
1051
|
gh issue view {NUMBER} -R {GH_REPO} --json labels --jq '[.labels[].name | select(startswith("workflow:"))]'
|
|
1022
1052
|
```
|
|
1023
1053
|
**Terminal labels** (only these allow you to stop): `workflow:merged`, `workflow:invalid`
|
|
1024
1054
|
**Terminal condition also**: `needs-human` label present, `workflow:awaiting-merge` label present, OR issue state is `closed`
|
|
1025
1055
|
`needs-human` and `workflow:awaiting-merge` are terminal-FOR-THIS-AGENT (this individual `/work-on` run stops here — a human decision or merge is now the blocking step) but are NOT "done" from the DAG's point of view; see Predecessor Classification in Step 4B for how the orchestrator's dependency logic treats them (`GATED`, not `DONE`).
|
|
1026
|
-
If the label is NOT terminal (e.g., `workflow:investigating`, `workflow:ready-to-build`, `workflow:building`, `workflow:in-review`), invoke `Skill(skill='work-on', args='{NUMBER} --under-orchestration')` again immediately. The `/work-on` skill will re-read GitHub state and advance to the next phase. Do NOT output a summary, do NOT pause, do NOT ask for confirmation — just invoke it again.
|
|
1056
|
+
If the label is NOT terminal (e.g., `workflow:investigating`, `workflow:ready-to-build`, `workflow:building`, `workflow:in-review`), invoke `Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='{NUMBER} --under-orchestration')` again immediately. The `/work-on` skill will re-read GitHub state and advance to the next phase. Do NOT output a summary, do NOT pause, do NOT ask for confirmation — just invoke it again.
|
|
1027
1057
|
|
|
1028
1058
|
**CRITICAL — SOURCE BRANCH DETECTION**:
|
|
1029
1059
|
- If the issue has the `review-finding` label, read the issue body for `**Code branch**: \`{branch}\``
|
|
1030
|
-
- If found, that is the
|
|
1031
|
-
- Investigation MUST use `git show origin
|
|
1032
|
-
- Worktree MUST branch from `origin
|
|
1033
|
-
- PR target is
|
|
1060
|
+
- If found, that branch is the source branch (`<source-branch>` below) — the code ONLY exists on that branch (e.g., `staging`), NOT on `origin/main`
|
|
1061
|
+
- Investigation MUST use `git show origin/<source-branch>:<filepath>` to verify the code exists
|
|
1062
|
+
- Worktree MUST branch from `origin/<source-branch>`, NOT `origin/main`
|
|
1063
|
+
- PR target is `<source-branch>` (the fix goes back to where the code lives)
|
|
1034
1064
|
|
|
1035
1065
|
**LANE**: {LANE} (PR target: {PR_BASE})
|
|
1036
1066
|
**Issue title**: {ISSUE_TITLE}
|
|
1067
|
+
<!-- DISPATCH_CONTEXT:BEGIN -->
|
|
1037
1068
|
{GIST_CONTEXT}
|
|
1038
1069
|
{SOURCE_PR_HINT_CONTEXT}
|
|
1070
|
+
<!-- DISPATCH_CONTEXT:END -->
|
|
1039
1071
|
"
|
|
1040
1072
|
)
|
|
1041
1073
|
```
|
|
1042
1074
|
|
|
1075
|
+
**Prompt lint (MANDATORY before every dispatch — a best-effort, self-run guard for Hard Rule 1; no hook gates `Agent(...)`, so it is orchestrator-policed, not tamper-proof)** <!-- Added: forge#3062 -->: the only variable text allowed in the rendered prompt is the `{GIST_CONTEXT}`/`{SOURCE_PR_HINT_CONTEXT}` blocks between the `DISPATCH_CONTEXT` markers (which already carry the claims-board URL and the same-file "what changed" brief). Before every `Agent(...)`/`task(...)` launch built from the Step 4A template — initial dispatch, Step 4B resume/relaunch, Step 4F sweep — write the fully rendered prompt to a `mktemp` file and run the lint. A non-zero exit means the dispatch is REFUSED (the snippet below sets the refusal by appending to `LINT_REFUSED_ISSUES[]` and skipping that issue with `continue`): do not launch, do not "fix" the prompt by adding text — re-render from the unmodified template, drop the offending context block, and report every `LINT_REFUSED_ISSUES[]` entry in the operator status update (`DISPATCH REFUSED: #{NUMBER} — {reason}`). A missing/unreadable `scripts/lint-dispatch-prompt.sh` is a distinct `lint-script-not-found` refusal (fail closed, never a silent pass); do not conflate it with a lint verdict. **Exempt (not Step 4A-template prompts, no free-text variable slot, so the template-anchor lint does not apply)**: the Codex/OpenCode `task()` wrapper prompt in the engine-fallback section and the remediation `Agent()` prompt in Step 4B item 6.4 — both are fixed one-purpose wrappers that only invoke `Skill(...work-on...)` with substituted identifiers; never add free text to them.
|
|
1076
|
+
|
|
1077
|
+
```bash
|
|
1078
|
+
# Bind the lint inputs FIRST (forge#3149). Every value is the SAME one substituted into the Step 4A template for this
|
|
1079
|
+
# issue (config.md / Step 4A resolve them) - substitute the literal values, never re-derive them. The rendered
|
|
1080
|
+
# prompt goes in through a quoted heredoc so no shell expansion touches it. A value containing a single quote is
|
|
1081
|
+
# written as '\'' . PROJECT_PREFIX, SATELLITE_PREFIX, FORGE_SKILL_PREFIX and SOURCE_BRANCH may be empty.
|
|
1082
|
+
PROJECT_NAME='{PROJECT_NAME}'; GH_REPO='{GH_REPO}'; REPO_PATH='{REPO_PATH}'
|
|
1083
|
+
LANE='{LANE}'; PR_BASE='{PR_BASE}'; STAGING_BRANCH='{STAGING_BRANCH}'
|
|
1084
|
+
SOURCE_BRANCH='{SOURCE_BRANCH}'; FORGE_GIST_CAPABLE='{FORGE_GIST_CAPABLE}'; SUBAGENT_MODEL='{SUBAGENT_MODEL}'
|
|
1085
|
+
PROJECT_PREFIX='{PROJECT_PREFIX}'; SATELLITE_PREFIX='{SATELLITE_PREFIX}'; FORGE_SKILL_PREFIX='{FORGE_SKILL_PREFIX}'
|
|
1086
|
+
# Heredoc into a file, then read it back: a heredoc body inside $( ) is parsed for quote balance by bash 3.2 (macOS),
|
|
1087
|
+
# and the rendered prompt contains apostrophes and parentheses.
|
|
1088
|
+
# The delimiter MUST be a string that occurs nowhere in the rendered prompt (the GIST/SOURCE_PR_HINT context blocks carry
|
|
1089
|
+
# untrusted text): replace RENDERED_PROMPT_EOF below with RENDERED_PROMPT_EOF_ plus 16 random hex chars, in BOTH places.
|
|
1090
|
+
RENDERED_PROMPT_SRC="$(mktemp)"
|
|
1091
|
+
cat > "$RENDERED_PROMPT_SRC" <<'RENDERED_PROMPT_EOF'
|
|
1092
|
+
{the fully rendered Step 4A prompt text for this issue, exactly as passed to Agent(prompt=...)}
|
|
1093
|
+
RENDERED_PROMPT_EOF
|
|
1094
|
+
RENDERED_PROMPT=$(cat "$RENDERED_PROMPT_SRC"); rm -f "$RENDERED_PROMPT_SRC"
|
|
1095
|
+
```
|
|
1096
|
+
|
|
1097
|
+
```bash
|
|
1098
|
+
# Runs inside the per-issue dispatch loop (same loop as claim_conflicts_with_live_holder above),
|
|
1099
|
+
# immediately before that issue's Agent()/task() call. LINT_REFUSED_ISSUES is initialised once, empty.
|
|
1100
|
+
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
1101
|
+
FORGE_ROOT=""
|
|
1102
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
|
|
1103
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
|
|
1104
|
+
_l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
|
|
1105
|
+
# newest cached version first: numeric major.minor.patch of the version dir name only; a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
|
|
1106
|
+
_v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ '$(NF-1)=="forgedock"{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5-)"
|
|
1107
|
+
_m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
|
|
1108
|
+
# '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
|
|
1109
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
|
|
1110
|
+
while IFS= read -r _c; do
|
|
1111
|
+
case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
|
|
1112
|
+
done <<< "$_k"
|
|
1113
|
+
fi
|
|
1114
|
+
# Fail closed on unbound inputs (forge#3149): an empty rendered prompt or a missing resolved value would lint an empty file
|
|
1115
|
+
# (rc=2) or compare against nothing; refuse with a distinct reason instead of a misleading lint verdict.
|
|
1116
|
+
if [ -z "$RENDERED_PROMPT" ] || [ -z "$PROJECT_NAME" ] || [ -z "$GH_REPO" ] || [ -z "$REPO_PATH" ] || [ -z "$LANE" ] \
|
|
1117
|
+
|| [ -z "$PR_BASE" ] || [ -z "$STAGING_BRANCH" ] || [ -z "$FORGE_GIST_CAPABLE" ] || [ -z "$SUBAGENT_MODEL" ]; then
|
|
1118
|
+
echo "DISPATCH REFUSED for #{NUMBER}: lint inputs unbound (RENDERED_PROMPT/PROJECT_NAME/GH_REPO/REPO_PATH/LANE/PR_BASE/STAGING_BRANCH/FORGE_GIST_CAPABLE/SUBAGENT_MODEL must be set by the binding block above)" >&2
|
|
1119
|
+
LINT_REFUSED_ISSUES+=("{NUMBER}:lint-inputs-unbound")
|
|
1120
|
+
continue
|
|
1121
|
+
fi
|
|
1122
|
+
LINT_SCRIPT="$FORGE_ROOT/scripts/lint-dispatch-prompt.sh"
|
|
1123
|
+
if [ -z "$FORGE_ROOT" ] || [ ! -r "$LINT_SCRIPT" ]; then
|
|
1124
|
+
# Fail closed: a missing lint script is NOT a pass and NOT a lint verdict.
|
|
1125
|
+
echo "DISPATCH REFUSED for #{NUMBER}: lint script not found at $LINT_SCRIPT (set FORGEDOCK_HOME/FORGE_HOME) — cannot verify Hard Rule 1 (ForgeDock install root unresolved: set FORGEDOCK_HOME)" >&2
|
|
1126
|
+
LINT_REFUSED_ISSUES+=("{NUMBER}:lint-script-not-found")
|
|
1127
|
+
continue
|
|
1128
|
+
fi
|
|
1129
|
+
PROMPT_FILE="$(mktemp)"
|
|
1130
|
+
printf '%s' "$RENDERED_PROMPT" > "$PROMPT_FILE"
|
|
1131
|
+
# Exact-match expectations: every slot the orchestrator resolved itself must equal, byte for byte, the
|
|
1132
|
+
# value it substituted into the template (the SAME variables, never re-derived). One quoted argument per
|
|
1133
|
+
# slot, so spaces, parentheses, backslashes and non-ASCII are safe. A slot present in the prompt with no
|
|
1134
|
+
# --expect is refused (fail closed). PROJECT_PREFIX / FORGE_SKILL_PREFIX may legitimately be empty.
|
|
1135
|
+
LINT_EXPECT=(
|
|
1136
|
+
--expect "PROJECT_NAME=$PROJECT_NAME" --expect "GH_REPO=$GH_REPO"
|
|
1137
|
+
--expect "REPO_PATH=$REPO_PATH" --expect "LANE=$LANE"
|
|
1138
|
+
--expect "PR_BASE=$PR_BASE" --expect "STAGING_BRANCH=$STAGING_BRANCH"
|
|
1139
|
+
--expect "SOURCE_BRANCH=$SOURCE_BRANCH" --expect "NUMBER={NUMBER}"
|
|
1140
|
+
--expect "FORGE_GIST_CAPABLE=$FORGE_GIST_CAPABLE" --expect "SUBAGENT_MODEL=$SUBAGENT_MODEL"
|
|
1141
|
+
--expect "PROJECT_PREFIX=$PROJECT_PREFIX" --expect "SATELLITE_PREFIX=$SATELLITE_PREFIX"
|
|
1142
|
+
--expect "FORGE_SKILL_PREFIX=$FORGE_SKILL_PREFIX"
|
|
1143
|
+
)
|
|
1144
|
+
bash "$LINT_SCRIPT" "${LINT_EXPECT[@]}" "$PROMPT_FILE"; LINT_RC=$?
|
|
1145
|
+
rm -f "$PROMPT_FILE"
|
|
1146
|
+
if [ "$LINT_RC" -ne 0 ]; then
|
|
1147
|
+
# rc 1 = template deviation (violations printed above); rc 2 = unreadable/empty prompt; any other rc = lint crashed.
|
|
1148
|
+
echo "DISPATCH REFUSED for #{NUMBER}: prompt lint rc=$LINT_RC (Hard Rule 1)" >&2
|
|
1149
|
+
LINT_REFUSED_ISSUES+=("{NUMBER}:lint-rc-$LINT_RC")
|
|
1150
|
+
continue # skip this issue's Agent()/task() call; never launch the failing prompt
|
|
1151
|
+
fi
|
|
1152
|
+
```
|
|
1153
|
+
|
|
1154
|
+
**Exact-match placeholders**: the lint does not guess whether a slot "looks like" a project name, path or branch (every charset/stopword heuristic was bypassed within a round). Each orchestrator-resolved slot (`{PROJECT_NAME}`, `{GH_REPO}`, `{REPO_PATH}`, `{LANE}`, `{PR_BASE}`, `{STAGING_BRANCH}`, `{SOURCE_BRANCH}`, `{NUMBER}`, `{FORGE_GIST_CAPABLE}`, `{SUBAGENT_MODEL}`, `{PROJECT_PREFIX}`, `{SATELLITE_PREFIX}`, `{FORGE_SKILL_PREFIX}`) must equal the `--expect KEY=VALUE` passed for it, and a slot with no `--expect` is refused. Only the free-text slots (issue title line, `DISPATCH_CONTEXT` block) are scanned for directive phrases. When you add a new resolved placeholder to the 4A template, add its `--expect` here and its key to `scripts/lint-dispatch-prompt.test.sh`.
|
|
1155
|
+
|
|
1156
|
+
The lint rejects: template deviations, any resolved placeholder that differs from its expected value, any text outside the context block after the issue title, and prompts that pre-decide a verdict or fix ("likely already resolved — verify and close", "the fix is ...", "make X terminal", "finish what the previous agent left"). The orchestrator never diagnoses, designs fixes, or summarizes what an issue "probably" needs — investigation is the agent's job via `work-on/investigate`.
|
|
1157
|
+
|
|
1043
1158
|
**`{GIST_CONTEXT}` generation**: For each issue being dispatched, build the context block. **Prefer the deconflicted `FORGE:SYNTHESIS_BRIEF` (from Phase 2.5) when one exists** — it is a per-issue, already-reconciled brief that carries only the arbitration decisions and sibling investigation Gists relevant to *this* issue. Injecting it instead of the full aggregated milestone-index gist means the agent does not re-arbitrate the same contradictions (less token spend, less nondeterminism). Only when Phase 2.5 did not run (0/1 investigations — no brief exists) does this fall back to the raw parent-investigation + milestone-index gist behavior. <!-- Added: forge#1192 -->
|
|
1044
1159
|
|
|
1045
1160
|
```bash
|
|
@@ -1111,7 +1226,7 @@ Claims board issue URL: ${FORGE_COORD_ISSUE}
|
|
|
1111
1226
|
|
|
1112
1227
|
On build start (Phase B2 / Phase 3C of /work-on), post a FORGE:CLAIM annotation on the
|
|
1113
1228
|
coordination issue above. Required fields:
|
|
1114
|
-
Holder
|
|
1229
|
+
**Holder**: #{NUMBER} / batch-$(date -u +%Y%m%dT%H%M%S) (exact form — read_active_claims parses `**Holder**: #N`)
|
|
1115
1230
|
Files: (list of files from your FORGE:CONTRACT deliverables table, one per line)
|
|
1116
1231
|
Interfaces: (public function/type signatures you will modify or that callers must preserve)
|
|
1117
1232
|
TTL: terminal state of Holder issue ##{NUMBER}
|
|
@@ -1226,9 +1341,106 @@ For an OpenCode completion, retain `OPENCODE_DISPATCH_MAP[{NUMBER}]` until the t
|
|
|
1226
1341
|
**Predecessor Classification (DONE / GATED / FAILED)** <!-- Added: forge#1812 --> — every check in this file that asks "is predecessor X resolved enough for its dependents to proceed" MUST classify X into exactly one of three states below — never a single binary terminal/non-terminal grep. Earlier versions of this file independently patched `grep -qE 'workflow:merged|workflow:invalid|needs-human|CLOSED'` in multiple places, and the copies drifted: the readiness check (this step) treated `needs-human` as done-enough-to-dispatch-through, while the failure handler (item 6 below) treated the identical label as a hard failure that skips dependents. Both cannot be right at once — `needs-human` means the predecessor's code is paused pending a human decision and is NOT yet in the base branch, so dispatching a dependent against it is unsafe, but permanently skipping the dependent is also wrong once the human resolves the block. The fix is a third state:
|
|
1227
1342
|
|
|
1228
1343
|
```bash
|
|
1344
|
+
# hold_merged_trail <issue> <trail-text> (forge#3156): the single escalation path for a merged issue whose phase
|
|
1345
|
+
# trail failed or could not be verified. Used by Step 4B item 0 and by the classifier's wake re-verification.
|
|
1346
|
+
# Order matters (forge#3169): the escalation COMMENT is posted FIRST (re-read immediately before each post, skipped when
|
|
1347
|
+
# an ACTIVE escalation already exists, verified ACTIVE by re-read after), then the `needs-human` label is written
|
|
1348
|
+
# (read-back verified, 3 tries). The comment is the durable audit record, independent of the verifier, so a label-only
|
|
1349
|
+
# hold is not the normal state and a human release always has an anchor to be recorded against. Returns 0 only when both
|
|
1350
|
+
# halves are verified; otherwise returns 1 and stderr names which half failed (the label is still attempted after a
|
|
1351
|
+
# comment failure, because holding dependents is the safe side).
|
|
1352
|
+
hold_merged_trail() {
|
|
1353
|
+
local N="$1" TXT="$2" TRY COMMENTED=1 HELD=1
|
|
1354
|
+
for TRY in 1 2; do
|
|
1355
|
+
# forge#3157: dedupe only against an ACTIVE escalation, so a re-escalation after a recorded release posts a new one.
|
|
1356
|
+
# forge#3169: re-read right before each post (cache entry removed) to narrow the concurrent-classifier duplicate window.
|
|
1357
|
+
[ -n "${TRAIL_CACHE_DIR:-}" ] && rm -f "$TRAIL_CACHE_DIR/trail-$N"
|
|
1358
|
+
if [ "$(trail_escalation_state "$N")" = "ACTIVE" ]; then COMMENTED=0; break; fi
|
|
1359
|
+
gh issue comment "$N" -R {GH_REPO} --body "<!-- FORGE:PHASE_TRAIL_FAILED -->
|
|
1360
|
+
#${N} merged with an incomplete phase trail (or one that could not be verified), detected by /orchestrate.
|
|
1361
|
+
|
|
1362
|
+
\`\`\`
|
|
1363
|
+
${TXT}
|
|
1364
|
+
\`\`\`
|
|
1365
|
+
|
|
1366
|
+
The PR is already merged, so \`/work-on\` cannot re-run the missing phases. Review the merged change, then remove \`needs-human\` to release dependents held as \`blocked-on-human-merge\`." >/dev/null 2>&1 || true # <!-- allowlist:check-command-side-effects -->
|
|
1367
|
+
[ -n "${TRAIL_CACHE_DIR:-}" ] && rm -f "$TRAIL_CACHE_DIR/trail-$N"
|
|
1368
|
+
if [ "$(trail_escalation_state "$N")" = "ACTIVE" ]; then COMMENTED=0; break; fi
|
|
1369
|
+
done
|
|
1370
|
+
for TRY in 1 2 3; do
|
|
1371
|
+
gh issue edit "$N" -R {GH_REPO} --add-label "needs-human" >/dev/null 2>&1 || true # <!-- allowlist:check-command-side-effects -->
|
|
1372
|
+
if gh issue view "$N" -R {GH_REPO} --json labels --jq '[.labels[].name] | join(",")' 2>/dev/null \
|
|
1373
|
+
| grep -qE '(^|,)needs-human(,|$)'; then HELD=0; break; fi
|
|
1374
|
+
done
|
|
1375
|
+
[ -n "${TRAIL_CACHE_DIR:-}" ] && rm -f "$TRAIL_CACHE_DIR/trail-$N"
|
|
1376
|
+
[ "$COMMENTED" -ne 0 ] && echo "PHASE TRAIL HOLD FAILED: #${N} — escalation comment could not be verified (audit record missing); hold dependents manually." >&2
|
|
1377
|
+
[ "$HELD" -ne 0 ] && echo "PHASE TRAIL HOLD FAILED: #${N} — needs-human label could not be verified; dependents are NOT held. Hold them manually." >&2
|
|
1378
|
+
[ "$COMMENTED" -eq 0 ] && [ "$HELD" -eq 0 ]
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
# resolve_orch_login (forge#3168): prints the login of the identity that authors the orchestrator's own FAILED/RELEASED
|
|
1382
|
+
# marker comments, so trail_escalation_state can trust them even when the bot reports author_association NONE or
|
|
1383
|
+
# CONTRIBUTOR. Resolution order: FORGE_BOT_LOGIN env override, then the git identity (`git config user.name`, which for a
|
|
1384
|
+
# GitHub App is its `<app>[bot]` login). Never calls `/user` (403s for App tokens). An unresolved value prints nothing and
|
|
1385
|
+
# the caller fails closed: only the author_association set below is trusted, never "trust every author".
|
|
1386
|
+
resolve_orch_login() {
|
|
1387
|
+
local L="${FORGE_BOT_LOGIN:-}"
|
|
1388
|
+
[ -n "$L" ] || L=$(git config user.name 2>/dev/null || true)
|
|
1389
|
+
printf '%s' "$L"
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
# trail_escalation_state <issue> (forge#3157): prints ABSENT (no merged-trail escalation comment), ACTIVE (newest
|
|
1393
|
+
# FORGE:PHASE_TRAIL_FAILED comment id is greater than the newest FORGE:PHASE_TRAIL_RELEASED id), RELEASED (a human
|
|
1394
|
+
# release was recorded after the last escalation, so a later unrelated `needs-human` no longer gates), or UNREADABLE.
|
|
1395
|
+
# forge#3168: only TRUSTED comments count, for BOTH markers. Trusted = author_association OWNER/MEMBER/COLLABORATOR, or
|
|
1396
|
+
# `.user.login` equal to the orchestrator identity (resolve_orch_login, used only when non-empty). A marker string posted
|
|
1397
|
+
# by anyone else is ignored (treated as absent), so an outside commenter can neither forge a release nor a failure.
|
|
1398
|
+
# The comment read is cached per issue for the current monitoring cycle: the cache is a directory of files
|
|
1399
|
+
# (`$(...)` subshells lose shell arrays), `TRAIL_CACHE_DIR=$(mktemp -d)` and is recreated at the start of every
|
|
1400
|
+
# monitoring cycle (Step 4B item 6.6), so a human edit made between cycles is always seen. Only successful reads are cached.
|
|
1401
|
+
trail_escalation_state() {
|
|
1402
|
+
local N="$1" F="${TRAIL_CACHE_DIR:+$TRAIL_CACHE_DIR/trail-$1}" RAW OUT FAILED_ID RELEASED_ID RES
|
|
1403
|
+
if [ -n "$F" ] && [ -s "$F" ]; then cat "$F"; return; fi
|
|
1404
|
+
if ! RAW=$(gh api --paginate "repos/{GH_REPO}/issues/${N}/comments" --jq '.[] | {id: .id, body: (.body // ""), login: (.user.login // ""), assoc: (.author_association // "")}' 2>/dev/null); then
|
|
1405
|
+
echo "UNREADABLE"; return
|
|
1406
|
+
fi
|
|
1407
|
+
if ! OUT=$(printf '%s\n' "$RAW" | jq -r --arg L "$(resolve_orch_login)" 'select((.assoc | IN("OWNER","MEMBER","COLLABORATOR")) or ($L != "" and .login == $L)) | if (.body | contains("FORGE:PHASE_TRAIL_FAILED") and contains("merged with an incomplete phase trail")) then "F \(.id)" elif (.body | contains("<!-- FORGE:PHASE_TRAIL_RELEASED -->")) then "R \(.id)" else empty end' 2>/dev/null); then
|
|
1408
|
+
echo "UNREADABLE"; return
|
|
1409
|
+
fi
|
|
1410
|
+
FAILED_ID=$(printf '%s\n' "$OUT" | awk '$1=="F"&&$2+0>m{m=$2+0}END{print m+0}')
|
|
1411
|
+
RELEASED_ID=$(printf '%s\n' "$OUT" | awk '$1=="R"&&$2+0>m{m=$2+0}END{print m+0}')
|
|
1412
|
+
if [ "$FAILED_ID" -eq 0 ]; then RES="ABSENT"
|
|
1413
|
+
elif [ "$RELEASED_ID" -gt "$FAILED_ID" ]; then RES="RELEASED"
|
|
1414
|
+
else RES="ACTIVE"; fi
|
|
1415
|
+
[ -n "$F" ] && printf '%s\n' "$RES" > "$F"
|
|
1416
|
+
echo "$RES"
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
# release_merged_trail <issue> (forge#3157): record that a human cleared a merged-trail hold, so the escalation
|
|
1420
|
+
# stops gating. Call only after classify_predecessor_state reported DONE for a merged issue whose escalation is ACTIVE.
|
|
1421
|
+
# Only an ACTIVE escalation is released here: hold_merged_trail posts the comment before the label (forge#3169), so a
|
|
1422
|
+
# human release of a label-only (ABSENT) hold is not expected and is not recorded (avoids a stray RELEASED marker).
|
|
1423
|
+
# Idempotent (no-op unless ACTIVE) and never run in dry-run. The comment must not contain the escalation marker text.
|
|
1424
|
+
release_merged_trail() {
|
|
1425
|
+
local N="$1"
|
|
1426
|
+
[ "${DRY_RUN:-false}" = "true" ] && return 0
|
|
1427
|
+
[ "$(trail_escalation_state "$N")" = "ACTIVE" ] || return 0
|
|
1428
|
+
gh issue comment "$N" -R {GH_REPO} --body "<!-- FORGE:PHASE_TRAIL_RELEASED -->
|
|
1429
|
+
The merged-trail hold on #${N} was released by a human (needs-human cleared or trail re-verified). Dependents held as \`blocked-on-human-merge\` are released. A later, unrelated \`needs-human\` on this merged issue no longer gates them." >/dev/null 2>&1 || true # <!-- allowlist:check-command-side-effects -->
|
|
1430
|
+
[ -n "${TRAIL_CACHE_DIR:-}" ] && rm -f "$TRAIL_CACHE_DIR/trail-$N"
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
# classify_predecessor_state <issue> (forge#3168): the helpers hold_merged_trail, resolve_orch_login,
|
|
1434
|
+
# trail_escalation_state and release_merged_trail above MUST be declared in the SAME shell before this function runs
|
|
1435
|
+
# (reverify_merged_trail is nested below). It has exactly ONE guarded write (the DRY_RUN-guarded hold_merged_trail
|
|
1436
|
+
# call in the unverified-merged branch); every other path is read-only.
|
|
1229
1437
|
classify_predecessor_state() {
|
|
1230
1438
|
local PRED="$1"
|
|
1231
|
-
local PRED_INFO
|
|
1439
|
+
local PRED_INFO H
|
|
1440
|
+
for H in hold_merged_trail resolve_orch_login trail_escalation_state release_merged_trail; do
|
|
1441
|
+
# Fail loudly (stderr) but closed: a missing helper must never read as DONE.
|
|
1442
|
+
type "$H" >/dev/null 2>&1 || { echo "classify_predecessor_state: helper $H is not declared in this shell; classifying #${PRED} GATED" >&2; echo "GATED"; return; }
|
|
1443
|
+
done
|
|
1232
1444
|
# NOTE: the `workflow` array below deliberately also keeps `needs-human` — that label has NO
|
|
1233
1445
|
# `workflow:` prefix (see bin/labels.json), so a bare `select(startswith("workflow:"))` would
|
|
1234
1446
|
# drop it and the GATED branch's `needs-human` case would be dead code (forge#1812 primary case).
|
|
@@ -1238,10 +1450,83 @@ classify_predecessor_state() {
|
|
|
1238
1450
|
PRED_STATE=$(echo "$PRED_INFO" | jq -r '.state // "OPEN"')
|
|
1239
1451
|
PRED_LABELS=$(echo "$PRED_INFO" | jq -r '.workflow[]?' 2>/dev/null)
|
|
1240
1452
|
|
|
1453
|
+
# forge#3156: an empty/failed label read must never fall through to IN_PROGRESS/DONE for a merged issue. PRED_INFO
|
|
1454
|
+
# is '{}' on a failed read; re-read once, then fail closed (GATED) rather than guessing from absent labels.
|
|
1455
|
+
if [ "$PRED_INFO" = '{}' ]; then
|
|
1456
|
+
PRED_INFO=$(gh issue view "$PRED" -R {GH_REPO} --json labels,state \
|
|
1457
|
+
--jq '{state: .state, workflow: [.labels[].name | select(startswith("workflow:") or . == "needs-human")]}' 2>/dev/null || echo '{}')
|
|
1458
|
+
if [ "$PRED_INFO" = '{}' ]; then echo "GATED"; return; fi
|
|
1459
|
+
PRED_STATE=$(echo "$PRED_INFO" | jq -r '.state // "OPEN"')
|
|
1460
|
+
PRED_LABELS=$(echo "$PRED_INFO" | jq -r '.workflow[]?' 2>/dev/null)
|
|
1461
|
+
fi
|
|
1462
|
+
|
|
1463
|
+
# reverify_merged_trail: re-run the phase-trail verifier for a merged predecessor that has NO escalation
|
|
1464
|
+
# record (forge#3156), so wake reconstruction does not trust the `workflow:merged` label alone. Prints OK
|
|
1465
|
+
# when the trail verifies, UNVERIFIED otherwise. Fails closed: a wake runs in a fresh shell where FORGE_ROOT is
|
|
1466
|
+
# unset, so the helper resolves it itself (canonical bootstrap, only when unset); if it is still unresolved or
|
|
1467
|
+
# the verifier is missing, the result is UNVERIFIED for every predecessor, never OK.
|
|
1468
|
+
reverify_merged_trail() {
|
|
1469
|
+
local P="$1" RC
|
|
1470
|
+
if [ -z "${FORGE_ROOT:-}" ]; then
|
|
1471
|
+
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
1472
|
+
FORGE_ROOT=""
|
|
1473
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
|
|
1474
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
|
|
1475
|
+
_l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
|
|
1476
|
+
# newest cached version first: numeric major.minor.patch of the version dir name only; a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
|
|
1477
|
+
_v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ '$(NF-1)=="forgedock"{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5-)"
|
|
1478
|
+
_m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
|
|
1479
|
+
# '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
|
|
1480
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
|
|
1481
|
+
while IFS= read -r _c; do
|
|
1482
|
+
case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
|
|
1483
|
+
done <<< "$_k"
|
|
1484
|
+
fi
|
|
1485
|
+
fi
|
|
1486
|
+
if [ -z "${FORGE_ROOT:-}" ] || [ ! -f "$FORGE_ROOT/scripts/verify-phase-trail.sh" ]; then
|
|
1487
|
+
echo "UNVERIFIED"; return
|
|
1488
|
+
fi
|
|
1489
|
+
bash "$FORGE_ROOT/scripts/verify-phase-trail.sh" "$P" -R {GH_REPO} --code-diff >/dev/null 2>&1; RC=$?
|
|
1490
|
+
[ "$RC" -eq 0 ] && echo "OK" || echo "UNVERIFIED"
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1241
1493
|
if echo "$PRED_LABELS" | grep -qx "workflow:invalid"; then
|
|
1242
1494
|
echo "FAILED"
|
|
1495
|
+
elif echo "$PRED_LABELS" | grep -qx "workflow:merged" && echo "$PRED_LABELS" | grep -qx "needs-human"; then
|
|
1496
|
+
# forge#3148: GATED when Step 4B item 0 escalated a merged issue with an incomplete phase trail
|
|
1497
|
+
# (the code is in the base branch, but /work-on cannot repair a merged issue, so a human must review it;
|
|
1498
|
+
# dependents wait until a human clears needs-human). forge#3169: the hold is NOT keyed on the verifier. A passing
|
|
1499
|
+
# verifier does not mean "not held" (an agent-reported bypass verifies clean), and hold_merged_trail posts the
|
|
1500
|
+
# comment before the label, so merged + needs-human with no record is a failed/partial hold: fail closed (GATED).
|
|
1501
|
+
# A human release is the removal of needs-human, recorded by the release sweep via release_merged_trail.
|
|
1502
|
+
local TRAIL_ESC
|
|
1503
|
+
TRAIL_ESC=$(trail_escalation_state "$PRED")
|
|
1504
|
+
case "$TRAIL_ESC" in
|
|
1505
|
+
ABSENT) echo "GATED" ;; # forge#3169: no record yet; never trust the verifier to release a labelled hold
|
|
1506
|
+
RELEASED) echo "DONE" ;; # forge#3157: a human release was recorded after the last escalation; this needs-human is unrelated
|
|
1507
|
+
*) echo "GATED" ;; # ACTIVE or UNREADABLE: fail closed
|
|
1508
|
+
esac
|
|
1243
1509
|
elif echo "$PRED_LABELS" | grep -qx "workflow:merged"; then
|
|
1244
|
-
|
|
1510
|
+
# forge#3156: no needs-human. A recorded escalation (ACTIVE or RELEASED) means a human already released the hold
|
|
1511
|
+
# (DONE, forge#3157: the sweep records it via release_merged_trail); otherwise re-verify the trail so a merged
|
|
1512
|
+
# predecessor that item 0 never got to escalate (orchestrator died mid-hold, wake reconstruction) is not trusted
|
|
1513
|
+
# on the label alone. forge#3169: hold only when the record re-reads exactly ABSENT immediately before (an
|
|
1514
|
+
# UNREADABLE read classifies GATED without writing), so a concurrent classifier or a recorded release is not
|
|
1515
|
+
# overridden and at most one escalation comment is posted in the common case.
|
|
1516
|
+
case "$(trail_escalation_state "$PRED")" in
|
|
1517
|
+
ACTIVE|RELEASED) echo "DONE" ;;
|
|
1518
|
+
UNREADABLE) echo "GATED" ;;
|
|
1519
|
+
*)
|
|
1520
|
+
if [ "$(reverify_merged_trail "$PRED")" = "OK" ]; then
|
|
1521
|
+
echo "DONE"
|
|
1522
|
+
else
|
|
1523
|
+
# Unverified trail with no hold on record: establish it now so a human can release it.
|
|
1524
|
+
# Output is discarded (stdout is the classification), but a failed hold is reported on stderr, not swallowed.
|
|
1525
|
+
[ -n "${TRAIL_CACHE_DIR:-}" ] && rm -f "$TRAIL_CACHE_DIR/trail-$PRED"
|
|
1526
|
+
[ "${DRY_RUN:-false}" = "true" ] || [ "$(trail_escalation_state "$PRED")" != "ABSENT" ] || hold_merged_trail "$PRED" "PHASE_TRAIL: FAIL (re-verified at wake classification)" >/dev/null || echo "classify_predecessor_state: hold on #${PRED} could not be verified" >&2
|
|
1527
|
+
echo "GATED"
|
|
1528
|
+
fi ;;
|
|
1529
|
+
esac
|
|
1245
1530
|
elif echo "$PRED_LABELS" | grep -qxE "needs-human|workflow:awaiting-merge"; then
|
|
1246
1531
|
echo "GATED"
|
|
1247
1532
|
elif echo "$PRED_LABELS" | grep -qx "workflow:engine-error"; then
|
|
@@ -1271,12 +1556,12 @@ classify_predecessor_state() {
|
|
|
1271
1556
|
}
|
|
1272
1557
|
```
|
|
1273
1558
|
|
|
1274
|
-
- **DONE** — predecessor's code is in the base branch (`workflow:merged
|
|
1275
|
-
- **GATED** — predecessor is paused pending a human decision (`needs-human`) or pending only a human merge click (`workflow:awaiting-merge`). Its code is NOT yet in the base branch. Dependents are neither dispatched nor skipped — they move to the `blocked-on-human-merge` tracked state (item 6.5 below).
|
|
1559
|
+
- **DONE** — predecessor's code is in the base branch (`workflow:merged`, unless gated by a Step 4B item 0 trail escalation), or the predecessor is closed with no pending code. Safe for dependents to dispatch.
|
|
1560
|
+
- **GATED** — predecessor is paused pending a human decision (`needs-human`) or pending only a human merge click (`workflow:awaiting-merge`). Its code is NOT yet in the base branch — except for a `workflow:merged` issue that also carries `needs-human` and the Step 4B item 0 trail-escalation comment (merged with an incomplete phase trail), whose code is merged but whose trail needs human review. Dependents are neither dispatched nor skipped — they move to the `blocked-on-human-merge` tracked state (item 6.5 below).
|
|
1276
1561
|
- **FAILED** — predecessor was closed as `workflow:invalid`, or the agent explicitly reported a build/test error. Dependents are marked "skipped — dependency failed" (item 6 below) — unchanged from prior behavior.
|
|
1277
1562
|
- **IN_PROGRESS** — predecessor is still mid-pipeline (`investigating`/`ready-to-build`/`building`/`in-review`), or terminated `workflow:engine-error` (forge#2261 — an engine/tool failure, not a human-judgment block or a genuine content failure; treated as still-in-flight since stall-detection auto-resumes it). Dependent simply continues waiting; no special tracking needed.
|
|
1278
1563
|
|
|
1279
|
-
A GATED predecessor whose PR later merges reclassifies to DONE the next time `classify_predecessor_state` runs (its label flips to `workflow:merged`) — this is exactly what the merge-triggered wake check (item 6.6 below) relies on.
|
|
1564
|
+
A GATED predecessor whose PR later merges reclassifies to DONE the next time `classify_predecessor_state` runs (its label flips to `workflow:merged`; a merged predecessor gated on its phase trail reclassifies to DONE once a human removes `needs-human`) — this is exactly what the merge-triggered wake check (item 6.6 below) relies on.
|
|
1280
1565
|
|
|
1281
1566
|
**File-overlap edge re-verification** <!-- Added: forge#1904 --> — `classify_predecessor_state()` answers "is the predecessor resolved enough to proceed," but it says nothing about whether a specific Layer 1/2/3 file-overlap edge (`EDGE_KIND`/`EDGE_FILES`, tagged in `phase-3-dependency.md` Step 3C/3D) was ever real. Both were built from a **pre-build guess** — the predecessor's `FORGE:INVESTIGATOR` "Affected Files" list, or a raw issue-body parse if no investigation existed yet. Once a predecessor reaches FAILED, GATED, **or DONE**, that guess must be checked against ground truth (the predecessor's actual PR diff, or the absence of any PR) before the edge is allowed to keep blocking a dependent or to inject a same-file brief. Without this, a predecessor that reaches `needs-human` or `workflow:invalid` having never touched the guessed shared file (or never opened a PR at all) leaves its dependents gated/skipped on a conflict that never existed — and on the happy path, an over-serialized chain is never reconciled at all and stays serialized for its entire life (forge#2848).
|
|
1282
1567
|
|
|
@@ -1570,6 +1855,85 @@ done
|
|
|
1570
1855
|
|
|
1571
1856
|
**CRITICAL — Stall detection and recovery**: Background agents sometimes stop mid-pipeline (`stop_reason=end_turn`) after completing a sub-phase (e.g., investigation completes but build never starts). This causes the agent to "complete" from the Agent tool's perspective even though the `/work-on` pipeline is only partially done. When you receive a completion notification:
|
|
1572
1857
|
|
|
1858
|
+
0. **Phase-trail verification (MANDATORY on EVERY merge completion, before classifying DONE)** <!-- Added: forge#3062 --> — a `workflow:merged` label alone is not proof the pipeline ran. **Scope (forge#3102)**: run it only when the issue carries `workflow:merged` (a failed label read fails closed). Issues in other terminal-for-this-agent states (`workflow:invalid`, `workflow:decomposed`, `needs-human`, `workflow:awaiting-merge`, paused) legitimately lack a full trail; classify them by label per Predecessor Classification, skip this verifier, and do NOT flag them as bypass or re-dispatch them. Run the deterministic verifier (add `--docs-only` for docs-only diffs):
|
|
1859
|
+
```bash
|
|
1860
|
+
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
1861
|
+
FORGE_ROOT=""
|
|
1862
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
|
|
1863
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
|
|
1864
|
+
_l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
|
|
1865
|
+
# newest cached version first: numeric major.minor.patch of the version dir name only; a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
|
|
1866
|
+
_v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ '$(NF-1)=="forgedock"{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5-)"
|
|
1867
|
+
_m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
|
|
1868
|
+
# '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
|
|
1869
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
|
|
1870
|
+
while IFS= read -r _c; do
|
|
1871
|
+
case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
|
|
1872
|
+
done <<< "$_k"
|
|
1873
|
+
fi
|
|
1874
|
+
# forge#3102: only merged completions carry a full trail; invalid/decomposed/needs-human/awaiting-merge/paused do not.
|
|
1875
|
+
# Fail closed: a failed label read is an ERROR, never a skip.
|
|
1876
|
+
TRAIL_LABELS_READ_OK=0 # forge#3168: 1 only after a successful label read, so a failed read is distinct from an empty label set
|
|
1877
|
+
if ! TRAIL_LABELS=$(gh issue view {NUMBER} -R {GH_REPO} --json labels --jq '[.labels[].name] | join(",")' 2>/dev/null); then
|
|
1878
|
+
TRAIL="PHASE_TRAIL: ERROR (could not read issue labels)"; TRAIL_RC=2 # infrastructure error, not a bypass (forge#3147)
|
|
1879
|
+
elif TRAIL_LABELS_READ_OK=1; ! echo ",$TRAIL_LABELS," | grep -q ',workflow:merged,'; then
|
|
1880
|
+
TRAIL="PHASE_TRAIL: SKIPPED (issue not workflow:merged — classified by label)"; TRAIL_RC=0
|
|
1881
|
+
elif [ -z "$FORGE_ROOT" ] || [ ! -f "$FORGE_ROOT/scripts/verify-phase-trail.sh" ]; then
|
|
1882
|
+
TRAIL="PHASE_TRAIL: ERROR (verify-phase-trail.sh not resolvable; set FORGEDOCK_HOME)"; TRAIL_RC=127 # fail closed
|
|
1883
|
+
else
|
|
1884
|
+
# forge#3123: a docs-only merged diff legitimately has no FORGE:QUALITY_GATE marker. Derive --docs-only from the
|
|
1885
|
+
# merged PR's diff with the SAME shared predicate as review-pr.md Phase 8 / work-on/review.md R1.5 (scripts/is-docs-only.sh, forge#3134). Fail closed: no PR / unreadable diff -> no flag.
|
|
1886
|
+
TRAIL_DOCS_FLAG="--code-diff" # forge#3149: fail closed - no matched PR / unreadable or empty diff means NO docs-only waiver and no usable INVESTIGATION band
|
|
1887
|
+
# forge#3133: the text search is fuzzy and unordered, so `.[0]` can be an unrelated docs-only PR. Evaluate EVERY
|
|
1888
|
+
# matching merged PR and set the flag only if at least one matched AND all matched diffs are non-empty and docs-only.
|
|
1889
|
+
# forge#3149: the search is a fuzzy prefilter; the jq test below keeps only PRs whose body has the exact token
|
|
1890
|
+
# "Closes #{NUMBER}" NOT followed by another digit (so #31490 never matches #3149). --limit 100 is a generous cap on
|
|
1891
|
+
# merged PRs mentioning one issue (a pipeline issue normally has one closing PR).
|
|
1892
|
+
TRAIL_PRS=$(gh pr list -R {GH_REPO} --state merged --limit 100 --search "\"Closes #{NUMBER}\" in:body" --json number,body --jq '.[] | select(.body | test("Closes #{NUMBER}([^0-9]|$)"; "i")) | .number' 2>/dev/null)
|
|
1893
|
+
if [ -n "$TRAIL_PRS" ]; then
|
|
1894
|
+
TRAIL_ALL_DOCS=1
|
|
1895
|
+
for TRAIL_PR in $TRAIL_PRS; do
|
|
1896
|
+
# forge#3145: both sides of a rename (filename + previous_filename); `gh pr diff --name-only` collapses a rename to its destination.
|
|
1897
|
+
TRAIL_FILES=$(gh api --paginate "repos/{GH_REPO}/pulls/${TRAIL_PR}/files" --jq '.[] | .filename, (.previous_filename // empty)' 2>/dev/null) || TRAIL_FILES=""
|
|
1898
|
+
# Fail closed on truncation: files API caps at 3000 files; at/over the cap or an unreadable count -> not docs-only.
|
|
1899
|
+
TRAIL_CHANGED_N=$(gh api "repos/{GH_REPO}/pulls/${TRAIL_PR}" --jq .changed_files 2>/dev/null) || TRAIL_CHANGED_N=""
|
|
1900
|
+
case "$TRAIL_CHANGED_N" in ''|*[!0-9]*) TRAIL_FILES="" ;; *) [ "$TRAIL_CHANGED_N" -ge 3000 ] && TRAIL_FILES="" ;; esac
|
|
1901
|
+
if [ -z "$TRAIL_FILES" ] || [ ! -f "$FORGE_ROOT/scripts/is-docs-only.sh" ] || ! echo "$TRAIL_FILES" | bash "$FORGE_ROOT/scripts/is-docs-only.sh"; then TRAIL_ALL_DOCS=0; break; fi
|
|
1902
|
+
done
|
|
1903
|
+
[ "$TRAIL_ALL_DOCS" = "1" ] && TRAIL_DOCS_FLAG="--docs-only"
|
|
1904
|
+
fi
|
|
1905
|
+
TRAIL=$(bash "$FORGE_ROOT/scripts/verify-phase-trail.sh" {NUMBER} -R {GH_REPO} $TRAIL_DOCS_FLAG 2>&1); TRAIL_RC=$?
|
|
1906
|
+
fi
|
|
1907
|
+
echo "$TRAIL"
|
|
1908
|
+
```
|
|
1909
|
+
Route on `TRAIL_RC` (forge#3147). Both non-zero cases fail closed: never `DONE`, never cascaded as a satisfied predecessor.
|
|
1910
|
+
- `TRAIL_RC -ge 2` (`PHASE_TRAIL: ERROR`: unreadable trail, unreadable labels, or 127 when the verifier is unresolvable) → classify as **BLOCKED (infrastructure)**, NOT a bypass. A re-dispatched `/work-on` would hit the same unreadable or unresolvable verifier in its resume preflight, so do NOT re-dispatch. Surface `PHASE TRAIL ERROR: #{NUMBER} — <TRAIL line>` in the operator status update, add `needs-human`, and continue with the rest of the batch.
|
|
1911
|
+
- `TRAIL_RC=1` (`PHASE_TRAIL: FAIL`) → classify as a **bypass** (handling below).
|
|
1912
|
+
|
|
1913
|
+
On `PHASE_TRAIL: FAIL`, classify the issue as a **bypass**, never `DONE`. Classify ONLY on objective evidence — the trail verdict above and the issue's labels. Do NOT reclassify an issue from a worker's own narrative ("ran inline", "dispatched reviewers directly", "skipped an optional step"): every phase runs forked and the merge itself is gated (CI green on the exact merged commit, reviewed head or docs-only delta, phase trail), so a merged issue whose trail passes is DONE. Field evidence: narrative-based holds flagged spec-compliant behaviour and put 5 of 6 merged issues in a stress batch on needs-human. Surface it in the operator status update (`PHASE BYPASS: #{NUMBER} — missing: ...`) and do not cascade it as a satisfied predecessor.
|
|
1914
|
+
|
|
1915
|
+
**Do NOT re-dispatch a merged issue** (forge#3148): `/work-on` Phase 0B stops on a closed or `workflow:merged` issue before its resume preflight, so a fresh agent exits without repairing anything and the predecessor would stay unresolved forever. The PR is already merged, so the gap can only be resolved by a human. Escalate it instead, which classifies it **GATED** (`classify_predecessor_state` holds `workflow:merged` + `needs-human` with no recorded release, regardless of the verifier; the comment is posted first, then the label, both verified), so item 6.5 tracks its dependents as `blocked-on-human-merge` instead of dispatching them. A merged issue whose trail could not be read (`TRAIL_RC -ge 2`, handled above) is escalated by the same block, so it is gated the same way. A bypass reported by the agent on a `workflow:merged` issue whose trail verifies is handled the same way (set `TRAIL_RC=1` before this block):
|
|
1916
|
+
```bash
|
|
1917
|
+
# Only a merged issue is escalated here; an unmerged bypass is resumed by item 2 (see below).
|
|
1918
|
+
# Any non-zero rc on a merged issue escalates: rc 1 (missing phases) and rc >= 2 (unverifiable trail) alike,
|
|
1919
|
+
# because classify_predecessor_state() gates a merged predecessor on the escalation record plus the needs-human label.
|
|
1920
|
+
# forge#3156: also escalate when the label read itself failed: an unreadable issue cannot be proven unmerged, so
|
|
1921
|
+
# fail closed instead of posting nothing. forge#3168: key on TRAIL_LABELS_READ_OK, not an empty TRAIL_LABELS, so a
|
|
1922
|
+
# successfully read, label-less, non-merged issue is never escalated (read OK + empty labels => no escalation).
|
|
1923
|
+
if [ "$TRAIL_RC" -ne 0 ] && { [ "${TRAIL_LABELS_READ_OK:-0}" != "1" ] || echo ",${TRAIL_LABELS:-}," | grep -q ',workflow:merged,'; }; then
|
|
1924
|
+
if [ "${DRY_RUN:-false}" = "true" ]; then
|
|
1925
|
+
echo "[DRY-RUN] Would add needs-human and post FORGE:PHASE_TRAIL_FAILED on merged #{NUMBER}"
|
|
1926
|
+
else
|
|
1927
|
+
# hold_merged_trail (defined with classify_predecessor_state) posts the escalation comment FIRST (re-read before
|
|
1928
|
+
# posting, verified), then writes `needs-human` (read-back verified, retried). A merged + needs-human issue with no
|
|
1929
|
+
# comment classifies GATED regardless of the verifier. A failed half is reported on stderr and the operator must
|
|
1930
|
+
# hold dependents manually (residual: a human clearing the label after both comment attempts failed).
|
|
1931
|
+
hold_merged_trail {NUMBER} "${TRAIL}" || echo "PHASE TRAIL ESCALATION FAILED: #{NUMBER} — comment or needs-human not verified." >&2
|
|
1932
|
+
fi
|
|
1933
|
+
fi
|
|
1934
|
+
```
|
|
1935
|
+
Never hand-post missing markers. A bypass report on an issue that is NOT merged (the agent stopped before merge) is not covered here: classify it by label and let item 2 resume it, because `/work-on`'s resume preflight re-runs each missing phase via `Skill(...)` before PR creation and merge.
|
|
1936
|
+
|
|
1573
1937
|
1. **Check if the agent completed the FULL pipeline** — not just one phase:
|
|
1574
1938
|
```bash
|
|
1575
1939
|
# Check final workflow state — workflow:merged, workflow:invalid, needs-human, and
|
|
@@ -1591,10 +1955,12 @@ done
|
|
|
1591
1955
|
resume=AGENT_ISSUE_MAP[{NUMBER}],
|
|
1592
1956
|
description="Resume #{NUMBER} pipeline",
|
|
1593
1957
|
run_in_background=true,
|
|
1594
|
-
prompt="
|
|
1958
|
+
prompt="{UNMODIFIED_STEP_4A_TEMPLATE_RENDERED_FOR_{NUMBER}}"
|
|
1595
1959
|
)
|
|
1596
1960
|
```
|
|
1597
1961
|
|
|
1962
|
+
**Resume/relaunch prompts are the verbatim Step 4A template** <!-- Added: forge#3062 -->: never write free-text resume instructions such as "continue from where you left off" or "finish the uncommitted work". Render the unmodified 4A template (and pass it through the prompt lint above) so the agent re-enters via `Skill(skill='{FORGE_SKILL_PREFIX}work-on', ...)` and `/work-on`'s own checkpoint routing plus the phase-trail resume preflight pick the earliest missing phase. A recovered worktree with uncommitted work is never a substitute for the skipped phases.
|
|
1963
|
+
|
|
1598
1964
|
**2b. Engine-first-dispatched issue (`ENGINE_DISPATCH_MAP[{NUMBER}]` set, no `AGENT_ISSUE_MAP` entry)** — `Agent(resume=...)` has nothing to resume here; the completed task was a backgrounded `Bash(command="forgedock run-issue ...")` call, not an `Agent()`. Re-issuing the identical `forgedock run-issue` command would just reproduce the same environmental failure. Instead, fall back to the Agent-spawn path for this ONE issue — but only when it is safe to do so:
|
|
1599
1965
|
```bash
|
|
1600
1966
|
# Safety gate: only fall back when the engine run committed NOTHING — no branch, no PR, no
|
|
@@ -1784,7 +2150,7 @@ done
|
|
|
1784
2150
|
run_in_background=true,
|
|
1785
2151
|
prompt="You are remediating GitHub PR #{GATING_PR} for the {PROJECT_NAME} project (repo: {GH_REPO}), which is currently held at `needs-human` on its linked issue #{PRED}.
|
|
1786
2152
|
|
|
1787
|
-
**YOUR MISSION**: Invoke `Skill(skill='work-on', args='{GATING_PR} --remediate --issue {PRED} --repo {GH_REPO} --gh-flag {GH_FLAG}')` and let it run to completion. This is a self-contained flow: it classifies the gate first, replaces `needs-human` with `workflow:in-review` only for fixable remediation, checks out the PR branch, fixes any fixable review findings, re-reviews, and either auto-lands the PR, holds it at `workflow:awaiting-merge` for a human, re-escalates back to `needs-human`, or reports the block as policy-level and unfixable. Do NOT intervene manually — do not run raw git/gh commands yourself.
|
|
2153
|
+
**YOUR MISSION**: Invoke `Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='{GATING_PR} --remediate --issue {PRED} --repo {GH_REPO} --gh-flag {GH_FLAG}')` and let it run to completion. This is a self-contained flow: it classifies the gate first, replaces `needs-human` with `workflow:in-review` only for fixable remediation, checks out the PR branch, fixes any fixable review findings, re-reviews, and either auto-lands the PR, holds it at `workflow:awaiting-merge` for a human, re-escalates back to `needs-human`, or reports the block as policy-level and unfixable. Do NOT intervene manually — do not run raw git/gh commands yourself.
|
|
1788
2154
|
|
|
1789
2155
|
**DO NOT STOP EARLY**: if the Skill call returns without a terminal `REMEDIATE_RESULT.status`, invoke it again — it re-reads GitHub state and resumes. Terminal statuses are: `COMPLETE`, `ALREADY_DONE`, `UNFIXABLE`, `BLOCKED`.
|
|
1790
2156
|
|
|
@@ -1836,33 +2202,43 @@ Do not ask the user questions — you are running autonomously in the background
|
|
|
1836
2202
|
if [ "$ALREADY_TRACKED" -eq 0 ]; then
|
|
1837
2203
|
gh issue comment "$DEP" -R {GH_REPO} --body "<!-- FORGE:BLOCKED_ON_HUMAN_MERGE -->
|
|
1838
2204
|
**Gating predecessor**: #${PRED} (state: \`${PRED_LABEL}\`${GATING_PR:+, open PR #${GATING_PR}})
|
|
1839
|
-
**Status**: This issue is ready to dispatch
|
|
2205
|
+
**Status**: This issue is ready to dispatch once #${PRED} is resolved: when its gating PR merges, or — if #${PRED} is already merged and held on its phase trail — when a human clears \`needs-human\` on it. No action needed — the orchestrator (live session via the item 6.6 sweep on every monitoring cycle, or the next \`/orchestrate\` invocation via phase-3-dependency.md's wake reconstruction) will auto-dispatch it as soon as #${PRED} classifies DONE."
|
|
1840
2206
|
gh issue edit "$DEP" -R {GH_REPO} --add-label "blocked-on-human-merge" 2>/dev/null || true
|
|
1841
2207
|
fi
|
|
1842
2208
|
```
|
|
1843
2209
|
Do NOT dispatch `DEP`. Do NOT mark it skipped — it remains visibly tracked as `blocked-on-human-merge` in the DAG, re-evaluated on the next completion event, stall-detection pass, or session wake.
|
|
1844
2210
|
|
|
1845
|
-
6.6. **
|
|
2211
|
+
6.6. **Release sweep for blocked-on-human-merge dependents** <!-- Added: forge#1812, reworked: forge#3157 --> — run this sweep on **every monitoring cycle** (after items 5-6 of each completion cycle and in each stall-detection pass), not only when an agent just merged: a human clearing `needs-human` on an already-merged, trail-held predecessor (Step 4B item 0) fires no completion event, so a sweep keyed on the just-completed `$NUM` would leave its dependents held until the next `/orchestrate` wake. Every hold the orchestrator creates needs a matching release check on each cycle. The sweep re-classifies the gating predecessors of every `blocked-on-human-merge` dependent and releases a dependent once all of them classify `DONE`:
|
|
1846
2212
|
```bash
|
|
2213
|
+
# New per-cycle comment cache for trail_escalation_state (human edits between cycles are always re-read).
|
|
2214
|
+
[ -n "${TRAIL_CACHE_DIR:-}" ] && rm -rf "$TRAIL_CACHE_DIR"
|
|
2215
|
+
TRAIL_CACHE_DIR=$(mktemp -d)
|
|
1847
2216
|
WOKEN=$(gh issue list -R {GH_REPO} --state open --label "blocked-on-human-merge" --json number \
|
|
1848
2217
|
--jq '.[].number' 2>/dev/null || echo "")
|
|
1849
2218
|
for DEP in $WOKEN; do
|
|
1850
|
-
|
|
1851
|
-
--jq
|
|
1852
|
-
[ "$
|
|
2219
|
+
GATING_PREDS=$(gh api repos/{GH_REPO}/issues/${DEP}/comments \
|
|
2220
|
+
--jq '[.[] | select(.body | contains("FORGE:BLOCKED_ON_HUMAN_MERGE")) | (.body | capture("Gating predecessor\\*\\*: #(?<p>[0-9]+)").p)] | unique | .[]' 2>/dev/null || echo "")
|
|
2221
|
+
[ -n "$GATING_PREDS" ] || continue
|
|
2222
|
+
STILL_GATED=false
|
|
2223
|
+
for GPRED in $GATING_PREDS; do
|
|
2224
|
+
[ "$(classify_predecessor_state "$GPRED")" = "DONE" ] || STILL_GATED=true
|
|
2225
|
+
done
|
|
2226
|
+
[ "$STILL_GATED" = "false" ] || continue
|
|
1853
2227
|
# Idempotency: only dispatch if DEP hasn't already been dispatched by another path.
|
|
1854
2228
|
DEP_ALREADY_DISPATCHED=$(gh issue view "$DEP" -R {GH_REPO} --json labels \
|
|
1855
2229
|
--jq '[.labels[].name | select(startswith("workflow:"))] | length' 2>/dev/null || echo "0")
|
|
1856
2230
|
if [ "$DEP_ALREADY_DISPATCHED" -eq 0 ]; then
|
|
2231
|
+
# Record a human release of a trail-held merged predecessor so the escalation stops gating (decay).
|
|
2232
|
+
for GPRED in $GATING_PREDS; do release_merged_trail "$GPRED"; done
|
|
1857
2233
|
gh issue edit "$DEP" -R {GH_REPO} --remove-label "blocked-on-human-merge" 2>/dev/null || true
|
|
1858
2234
|
gh issue comment "$DEP" -R {GH_REPO} --body "<!-- FORGE:UNBLOCKED -->
|
|
1859
|
-
|
|
1860
|
-
echo "#{DEP} unblocked
|
|
2235
|
+
All gating predecessor(s) (#$(echo $GATING_PREDS | sed 's/ /, #/g')) are now resolved — merged, or released by a human after a phase-trail hold — dispatching now. (Was tracked via a prior FORGE:BLOCKED_ON_HUMAN_MERGE comment.)"
|
|
2236
|
+
echo "#{DEP} unblocked (gating predecessors resolved) — dispatching immediately (Steps 4A.pre.0 → 4A.pre → 4A)."
|
|
1861
2237
|
# Add DEP to the same-response dispatch batch
|
|
1862
2238
|
fi
|
|
1863
2239
|
done
|
|
1864
2240
|
```
|
|
1865
|
-
|
|
2241
|
+
`release_merged_trail` is a no-op unless the predecessor carries an ACTIVE merged-trail escalation, so ordinary merges are unaffected. The classifier's per-cycle comment cache bounds the escalation-comment reads to one per predecessor per cycle. For the case where the hold is released after the orchestrator session has already ended, the equivalent check runs in `phase-3-dependency.md`'s wake/compaction reconstruction on the next `/orchestrate` invocation — see that file's "Orchestrator state reconstruction on wake / after compaction" section.
|
|
1866
2242
|
|
|
1867
2243
|
6.7. **Human-gated idle/backpressure check** (`BATCH_FULLY_GATED`) <!-- Added: forge#1814 --> — run this after every completion cycle, once the per-issue classification above (items 5-6.6) has been applied for this cycle. It answers a different question than the paused-drain/blocked-on-human-merge tracking above: those items handle *individual* gated predecessors and their *direct* dependents; this check asks whether the **entire original batch** has now exhausted into human-gated states, which is the condition under which continuing to dispatch cascade-spawned review findings (Step 4C) produces net-negative churn — closing 1 issue while opening 2-4 more, with the real blockers (the GATED issues) unresolved:
|
|
1868
2244
|
|
|
@@ -1991,9 +2367,29 @@ Gating predecessor #${NUM} reached \`workflow:merged\` — dispatching now. (Was
|
|
|
1991
2367
|
RERESOLVE_ENABLED=$(yq '.orchestration.reresolve.enabled // true' forge.yaml 2>/dev/null || echo "true")
|
|
1992
2368
|
RERESOLVE_MAX_ROUNDS=$(yq '.orchestration.reresolve.max_rounds // "unbounded"' forge.yaml 2>/dev/null || echo "unbounded")
|
|
1993
2369
|
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
2370
|
+
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
2371
|
+
FORGE_ROOT=""
|
|
2372
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
|
|
2373
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
|
|
2374
|
+
_l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
|
|
2375
|
+
# newest cached version first: numeric major.minor.patch of the version dir name only; a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
|
|
2376
|
+
_v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ '$(NF-1)=="forgedock"{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5-)"
|
|
2377
|
+
_m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
|
|
2378
|
+
# '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
|
|
2379
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
|
|
2380
|
+
while IFS= read -r _c; do
|
|
2381
|
+
case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
|
|
2382
|
+
done <<< "$_k"
|
|
2383
|
+
fi
|
|
2384
|
+
# Run ForgeDock's OWN orchestrate-canary.mjs from FORGE_ROOT, never from the consumer {REPO_PATH}.
|
|
2385
|
+
# Unresolved/missing => no result, so reResolve is treated as false for this cycle (fail closed, loudly).
|
|
2386
|
+
if [ -n "$FORGE_ROOT" ] && [ -r "$FORGE_ROOT/bin/engine/orchestrate-canary.mjs" ]; then
|
|
2387
|
+
node "$FORGE_ROOT/bin/engine/orchestrate-canary.mjs" \
|
|
2388
|
+
"$ORIGINATING_QUERY_KIND" "$ORIGINATING_QUERY_PATTERN" \
|
|
2389
|
+
"$RERESOLVE_ENABLED" "$RERESOLVE_MAX_ROUNDS" "$RERESOLVE_ROUNDS_SO_FAR"
|
|
2390
|
+
else
|
|
2391
|
+
echo "WARNING: ForgeDock install root unresolved or orchestrate-canary.mjs missing (set FORGEDOCK_HOME) — skipping re-resolution this cycle" >&2
|
|
2392
|
+
fi
|
|
1997
2393
|
```
|
|
1998
2394
|
|
|
1999
2395
|
If the result's `reResolve` is `false` (off switch, or `RERESOLVE_ROUNDS_SO_FAR` has reached `max_rounds` — bounded termination per the issue's acceptance criteria), skip this step for the current cycle and log the reason. `RERESOLVE_ROUNDS_SO_FAR` starts at 0 for the batch and increments once per cycle this step actually runs (declare it alongside the other Step 4A.pre batch-scope accumulators — do not re-initialize per completion).
|
|
@@ -2003,13 +2399,33 @@ If the result's `reResolve` is `false` (off switch, or `RERESOLVE_ROUNDS_SO_FAR`
|
|
|
2003
2399
|
**Fold new matches through the existing admission path — never a bypass**:
|
|
2004
2400
|
|
|
2005
2401
|
```bash
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2402
|
+
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
2403
|
+
FORGE_ROOT=""
|
|
2404
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
|
|
2405
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
|
|
2406
|
+
_l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
|
|
2407
|
+
# newest cached version first: numeric major.minor.patch of the version dir name only; a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
|
|
2408
|
+
_v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ '$(NF-1)=="forgedock"{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5-)"
|
|
2409
|
+
_m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
|
|
2410
|
+
# '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
|
|
2411
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
|
|
2412
|
+
while IFS= read -r _c; do
|
|
2413
|
+
case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
|
|
2414
|
+
done <<< "$_k"
|
|
2415
|
+
fi
|
|
2416
|
+
# Import ForgeDock's OWN resolve.mjs from FORGE_ROOT, never from the consumer {REPO_PATH}.
|
|
2417
|
+
# Unresolved/missing => no output, so no new matches are folded this cycle (fail closed, loudly).
|
|
2418
|
+
if [ -n "$FORGE_ROOT" ] && [ -r "$FORGE_ROOT/bin/engine/resolve.mjs" ]; then
|
|
2419
|
+
node -e '
|
|
2420
|
+
import(require("node:url").pathToFileURL(process.argv[1]).href).then(({ foldNewMatches }) => {
|
|
2421
|
+
const reResolved = JSON.parse(process.argv[2]);
|
|
2422
|
+
const processed = JSON.parse(process.argv[3]);
|
|
2423
|
+
console.log(JSON.stringify(foldNewMatches(reResolved, processed)));
|
|
2424
|
+
}, () => process.exit(0));
|
|
2425
|
+
' "$FORGE_ROOT/bin/engine/resolve.mjs" "$RERESOLVED_NUMBERS_JSON" "$ALL_BATCH_ISSUE_NUMBERS_JSON"
|
|
2426
|
+
else
|
|
2427
|
+
echo "WARNING: ForgeDock install root unresolved or resolve.mjs missing (set FORGEDOCK_HOME) — re-resolved matches not folded this cycle" >&2
|
|
2428
|
+
fi
|
|
2013
2429
|
```
|
|
2014
2430
|
|
|
2015
2431
|
`newMatches` from `foldNewMatches` are candidate issues, not admitted ones. Each one MUST be dispatched through the exact same path a T0-resolved issue takes — DAG dependency analysis (`phase-3-dependency.md`), then Step 4A/4B's standard `dispatch_headroom`-gated dispatch — **not** through Step 4C's review-finding-specific `evaluateCascadeFinding` chain (that gate's rules, e.g. the comment/typo keyword heuristic, are shaped for cascade-spawned findings, not arbitrary re-resolved issues). This is the same non-bypass requirement Step 4C already satisfies for its own admission stream; this step must not become a second, ungated entry point into the DAG. Add every `newMatch` to `ALL_BATCH_ISSUE_NUMBERS` (so a later re-resolution round or Step 4C sees it as already processed) and to `SORTED_READY_SET`/the DAG exactly as Step 4A.pre.0 processes a T0-resolved issue.
|
|
@@ -2186,32 +2602,38 @@ gh issue list -R {GH_REPO} --state open --search "label:review-finding created:>
|
|
|
2186
2602
|
--json number,title,body,createdAt
|
|
2187
2603
|
```
|
|
2188
2604
|
|
|
2189
|
-
**If review-finding issues were spawned:**
|
|
2190
|
-
|
|
2191
|
-
**Amplification accounting (MANDATORY, before cascade admission):** Count every finding created by this batch, including findings later deferred by any cascade rule. After each merged unit, compute `FINDINGS_SPAWNED / MERGED_UNITS`, append the value to `AMPLIFICATION_RATIO_HISTORY`, and print a convergence warning when the latest `CONVERGENCE_WINDOW` observations are all `>= 1.0`. A high ratio is a signal, not a failure: valuable deep review findings can legitimately raise it.
|
|
2192
|
-
|
|
2193
|
-
For each finding, extract its source PR from `**Source**: PR #N` and increment `FINDINGS_BY_SOURCE_PR[N]`. Classify it as a **same-lineage refinement** only when that source PR closes a `review-finding` issue and both that parent finding and this finding name the same affected file; otherwise classify it as **new surface**. Record the finding number in `REFINEMENT_FINDINGS` or `NEW_SURFACE_FINDINGS` respectively. If provenance or a file path cannot be established, classify as new surface conservatively; never suppress it as a refinement.
|
|
2194
|
-
|
|
2195
|
-
When `CASCADE_MAX_AMPLIFICATION` is not `off`, and the current ratio is greater than that ceiling, defer only newly discovered entries in `REFINEMENT_FINDINGS` with reason `amplification bound exceeded (same-lineage refinement)`. Add them to `DEFERRED_FINDINGS` and `DEFERRED_REASONS` so Step 4F re-evaluates them after the batch drains. Do not apply this bound to new-surface findings, P1/P2 findings, or any finding when the option is `off`.
|
|
2196
|
-
|
|
2197
2605
|
```bash
|
|
2198
2606
|
# Run once for each completed issue before collecting its findings. Only merged
|
|
2199
2607
|
# units advance the denominator; invalid/skipped units do not imply delivered work.
|
|
2608
|
+
# The ratio observation for this unit is recorded AFTER its findings are counted, in the
|
|
2609
|
+
# "Amplification settle" block that follows the Step 4C rule loop (forge#3060) — recording it
|
|
2610
|
+
# here would lag by one unit and could never reach 1.0 at exactly one finding per merged unit.
|
|
2611
|
+
UNIT_MERGED_THIS_CYCLE=false
|
|
2200
2612
|
if gh issue view "$NUM" -R {GH_REPO} --json labels \
|
|
2201
2613
|
--jq '[.labels[].name | select(. == "workflow:merged")] | length' | grep -qx '1'; then
|
|
2202
2614
|
MERGED_UNITS=$((MERGED_UNITS + 1))
|
|
2203
|
-
|
|
2204
|
-
AMPLIFICATION_RATIO_HISTORY+=("$AMPLIFICATION_RATIO")
|
|
2205
|
-
if [ "${#AMPLIFICATION_RATIO_HISTORY[@]}" -ge "$CONVERGENCE_WINDOW" ]; then
|
|
2206
|
-
RECENT_RATIOS=("${AMPLIFICATION_RATIO_HISTORY[@]: -$CONVERGENCE_WINDOW}")
|
|
2207
|
-
if printf '%s\n' "${RECENT_RATIOS[@]}" | awk '$1 < 1 { exit 1 }'; then
|
|
2208
|
-
echo "CONVERGENCE WARNING: amplification has remained >= 1.0 for ${CONVERGENCE_WINDOW} merged units (${AMPLIFICATION_RATIO} current). This can be productive review refinement, but the batch is not shrinking."
|
|
2209
|
-
fi
|
|
2210
|
-
fi
|
|
2615
|
+
UNIT_MERGED_THIS_CYCLE=true
|
|
2211
2616
|
fi
|
|
2212
2617
|
|
|
2213
2618
|
```
|
|
2214
2619
|
|
|
2620
|
+
**Unconditional unit accounting (forge#3060):** the merged-unit accounting block and the "Amplification settle" block below run for EVERY completed unit, including a merged unit that spawned ZERO review-findings (an empty `{spawned_finding_numbers}` list simply makes the rule loop a no-op). Skipping them for finding-free units would leave the ratio history containing only finding-producing units (every observation >= 1.0) and the breaker could never release.
|
|
2621
|
+
|
|
2622
|
+
**If review-finding issues were spawned:**
|
|
2623
|
+
|
|
2624
|
+
**Amplification accounting (MANDATORY, before cascade admission):** Count every finding created by this batch, including findings later deferred by any cascade rule. After each merged unit, compute `FINDINGS_SPAWNED / MERGED_UNITS`, append the value to `AMPLIFICATION_RATIO_HISTORY`, and print a convergence warning when the latest `CONVERGENCE_WINDOW` observations are all `>= 1.0`. A high ratio is a signal, not a failure: valuable deep review findings can legitimately raise it.
|
|
2625
|
+
|
|
2626
|
+
For each finding, extract its source PR from `**Source**: PR #N` and increment `FINDINGS_BY_SOURCE_PR[N]`. Classify it as a **same-lineage refinement** only when that source PR closes a `review-finding` issue and both that parent finding and this finding name the same affected file; otherwise classify it as **new surface**. Record the finding number in `REFINEMENT_FINDINGS` or `NEW_SURFACE_FINDINGS` respectively. If provenance or a file path cannot be established, classify as new surface conservatively; never suppress it as a refinement.
|
|
2627
|
+
|
|
2628
|
+
When `CASCADE_MAX_AMPLIFICATION` is not `off`, and the current ratio is greater than that ceiling, defer only newly discovered entries in `REFINEMENT_FINDINGS` with reason `amplification bound exceeded (same-lineage refinement)`. Add them to `DEFERRED_FINDINGS` and `DEFERRED_REASONS` so Step 4F re-evaluates them after the batch drains. Do not apply this bound to new-surface findings, P1/P2 findings, or any finding when the option is `off`.
|
|
2629
|
+
|
|
2630
|
+
**Amplification breaker (on by default — forge#3060):** `max_amplification` above only ever sees same-lineage refinements, but most cascade growth is *new-surface* P3 hardening notes on code the batch just wrote. The breaker therefore counts **all** cascade findings: while `AMPLIFICATION_BREAKER_TRIPPED=true` (the latest `CONVERGENCE_WINDOW` ratios are all `>= 1.0`; see the block below), every P3-and-below finding is deferred under rule 6 (below) regardless of lineage. Deferred P3s are not discarded — they stay eligible for the P3 batch sweep (`planP3BatchGroups()`, with `batchExclusionReason()` unchanged: security/billing/domain findings stay unbatched and individually triaged) and for Step 4F's post-drain re-evaluation. P1/P2 findings are admitted exactly as before. The breaker releases when the newest ratio drops below 1.0. It is independent of `orchestration.cascade.policy`; only an explicit `amplification_breaker: off` disables it.
|
|
2631
|
+
|
|
2632
|
+
**Override guardrail (MANDATORY — forge#3060):** An operator directive that lifts the generation cap or token budget (`policy: all`, `--max-generation unlimited`, `--token-budget unlimited`, or a free-text request like "pick up any new issues") widens what is admitted at Phase 1/rule 1/rule 5; it MUST NOT leave P3 cascade unbounded. When such an override is active: (a) keep `AMPLIFICATION_BREAKER=on` unless the operator explicitly set `amplification_breaker: off`; (b) P1/P2 stay unlimited; (c) P3-and-below findings are never dispatched individually — they route through `planP3BatchGroups()` into bounded batches (`batch_max_generation` stays finite); (d) state the replacement bound in the Step 4B/4C status output. An orchestrator MUST NOT satisfy an override by simply deleting a bound without a replacement.
|
|
2633
|
+
|
|
2634
|
+
**Status updates (MANDATORY):** every Step 4B/4C status update must include `amplification={FINDINGS_SPAWNED}/{MERGED_UNITS}={AMPLIFICATION_RATIO} breaker={AMPLIFICATION_BREAKER}/{tripped|clear} p3_paused={#AMPLIFICATION_BREAKER_DEFERRED[@]}` so the operator sees divergence before the pipeline does.
|
|
2635
|
+
|
|
2636
|
+
|
|
2215
2637
|
**Cascade control (MANDATORY — run before folding findings into the DAG):**
|
|
2216
2638
|
|
|
2217
2639
|
For each spawned finding, determine whether it should be **executed** or **deferred**:
|
|
@@ -2224,7 +2646,9 @@ For each spawned finding, determine whether it should be **executed** or **defer
|
|
|
2224
2646
|
4. **P3 + same-file overlap**: Finding is labeled `P3` AND the file it targets overlaps with ANY file already in the current batch (active or queued in the DAG). Rationale: same-file P3 findings add predecessor edges that serialize agents — one finding per original issue increases wall-clock time with no proportional value. **Configurable** via `orchestration.cascade.p3_same_file_defer` (default `true`; `false` under `policy: all` — forge#2234).
|
|
2225
2647
|
5. **Per-batch token budget** (P3 and below only, applied AFTER surface-area batching below — see "Per-batch token budget gate") <!-- Added: forge#1858 -->: Once `BATCH_TOKEN_SPEND` would exceed `TOKEN_BUDGET`, additional P3-and-below units (an unclubbed finding, or an already-clubbed batch issue) defer rather than dispatch. This is NOT part of the per-finding rule 0-4 chain immediately below — it is a quantity gate applied to the POST-clubbing `QUEUED_FINDINGS` list, so a same-run surface-area batch issue (surface-area batching below) is charged once for the whole cluster, not once per member. P1/P2 are NEVER gated by it (they are excluded by rule 2 before reaching this gate, same as they are excluded from rules 3-4). **Configurable** via `orchestration.cascade.token_budget` (default `900000`, deprecated alias `pipeline.token_budget_per_batch`; `unlimited` under `policy: all` — forge#2234). This is a distinct, independent lever from rule 1's generation cap — "admit gen-2, stop at gen-3" (`max_generation: 3`) and "admit cascade until N tokens" (`token_budget: N`) can each be set without the other.
|
|
2226
2648
|
|
|
2227
|
-
**
|
|
2649
|
+
6. **Amplification breaker** (P3 and below only — P0/P1/P2 and untriaged findings are never gated; forge#3060): `AMPLIFICATION_BREAKER=on` AND the breaker is tripped (re-evaluated live per finding). Defer with reason `amplification breaker tripped` (appended to any earlier defer reason); the finding is routed to the bounded P3 batch planner and Step 4F never dispatches it individually. Checked last, after the other rules. **Configurable** via `orchestration.cascade.amplification_breaker` (default `on`, not changed by any `cascade.policy` preset).
|
|
2650
|
+
|
|
2651
|
+
**Defer** (do NOT add to the DAG) if rules 0, 1, 3, 4, 5, or 6 match.
|
|
2228
2652
|
|
|
2229
2653
|
**Execute** (add to the DAG) if:
|
|
2230
2654
|
- Rule 2 matches (P1 or P2) — AND rule 0 did not already match (rule 0 is checked first and overrides rule 2)
|
|
@@ -2516,6 +2940,65 @@ Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${R
|
|
|
2516
2940
|
AMPLIFICATION_DEFERRED+=("$FINDING_NUM")
|
|
2517
2941
|
fi
|
|
2518
2942
|
|
|
2943
|
+
# Rule 6 (forge#3060): on-by-default breaker over ALL cascade findings. Applies ONLY to
|
|
2944
|
+
# P3-and-below (P0/P1/P2 and untriaged/unparseable priorities are never gated by it) and NEVER
|
|
2945
|
+
# to security/billing-class findings (classifyBatchSafety != routine): those can neither batch
|
|
2946
|
+
# with routine work nor be individually dispatched by the sweep, so pausing them would starve
|
|
2947
|
+
# them; they keep their pre-existing individual triage path unchanged.
|
|
2948
|
+
# Evaluated live per finding. When a unit merged this cycle (UNIT_MERGED_THIS_CYCLE), its ratio
|
|
2949
|
+
# observation is not yet in the history (recorded by "Amplification settle"), so the live
|
|
2950
|
+
# unrounded ratio plus the last CONVERGENCE_WINDOW-1 recorded observations form the window.
|
|
2951
|
+
# Applied even when another rule already deferred the finding, and APPENDED to the reason, so
|
|
2952
|
+
# Step 4F's `*"amplification breaker"*` hold (checked FIRST) still catches it.
|
|
2953
|
+
if [ "$AMPLIFICATION_BREAKER" = "on" ] && echo "$PRIORITY" | grep -qE '^P[3-9]$'; then
|
|
2954
|
+
# Fail CLOSED: if the classifier cannot run (missing module, bad cwd), treat the finding as
|
|
2955
|
+
# non-routine so a security/billing finding is never paused. Import via {REPO_PATH} and pass
|
|
2956
|
+
# the text through the environment (argv text beginning with "-" is parsed as a node option).
|
|
2957
|
+
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
2958
|
+
FORGE_ROOT=""
|
|
2959
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
|
|
2960
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
|
|
2961
|
+
_l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
|
|
2962
|
+
# newest cached version first: numeric major.minor.patch of the version dir name only; a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
|
|
2963
|
+
_v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ '$(NF-1)=="forgedock"{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5-)"
|
|
2964
|
+
_m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
|
|
2965
|
+
# '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
|
|
2966
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
|
|
2967
|
+
while IFS= read -r _c; do
|
|
2968
|
+
case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
|
|
2969
|
+
done <<< "$_k"
|
|
2970
|
+
fi
|
|
2971
|
+
# Import ForgeDock's OWN admission.mjs from FORGE_ROOT, never from the consumer {REPO_PATH}
|
|
2972
|
+
# (inert on non-ForgeDock repos, and would execute repo-controlled JS). Unresolved/missing => fail closed, loudly.
|
|
2973
|
+
BREAKER_SAFETY="unclassified"
|
|
2974
|
+
if [ -n "$FORGE_ROOT" ] && [ -r "$FORGE_ROOT/bin/engine/admission.mjs" ]; then
|
|
2975
|
+
BREAKER_SAFETY=$(FINDING_TEXT="$(echo "$FINDING_DATA" | jq -r '(.title // "") + "\n" + (.body // "")')" \
|
|
2976
|
+
node -e 'import(require("node:url").pathToFileURL(process.argv[1]).href).then(({ classifyBatchSafety }) => process.stdout.write(classifyBatchSafety(process.env.FINDING_TEXT) || "routine"))' "$FORGE_ROOT/bin/engine/admission.mjs" 2>/dev/null || echo "unclassified")
|
|
2977
|
+
else
|
|
2978
|
+
echo "WARNING: ForgeDock install root unresolved or admission.mjs missing (set FORGEDOCK_HOME) — breaker classifying #${FINDING_NUM} as unclassified (fail closed: not deferred)" >&2
|
|
2979
|
+
fi
|
|
2980
|
+
BREAKER_LIVE_TRIPPED="$AMPLIFICATION_BREAKER_TRIPPED"
|
|
2981
|
+
if [ "$BREAKER_LIVE_TRIPPED" = "false" ] && [ "$UNIT_MERGED_THIS_CYCLE" = "true" ] && [ "$MERGED_UNITS" -gt 0 ] && \
|
|
2982
|
+
awk "BEGIN { exit !($FINDINGS_SPAWNED / $MERGED_UNITS >= 1) }"; then
|
|
2983
|
+
if [ "$CONVERGENCE_WINDOW" -le 1 ] || \
|
|
2984
|
+
{ [ "${#AMPLIFICATION_RATIO_HISTORY[@]}" -ge $((CONVERGENCE_WINDOW - 1)) ] && \
|
|
2985
|
+
printf '%s\n' "${AMPLIFICATION_RATIO_HISTORY[@]: -$((CONVERGENCE_WINDOW - 1))}" | awk '$1 < 1 { exit 1 }'; }; then
|
|
2986
|
+
BREAKER_LIVE_TRIPPED=true
|
|
2987
|
+
AMPLIFICATION_BREAKER_TRIPPED=true
|
|
2988
|
+
fi
|
|
2989
|
+
fi
|
|
2990
|
+
if [ "$BREAKER_LIVE_TRIPPED" = "true" ] && [ "$BREAKER_SAFETY" = "routine" ]; then
|
|
2991
|
+
DEFER_REASON="${DEFER_REASON:+${DEFER_REASON}; }${BREAKER_DEFER_TAG} tripped (ratio >= 1.0 for ${CONVERGENCE_WINDOW} merged units) — held for bounded P3 batches"
|
|
2992
|
+
DEFER=true
|
|
2993
|
+
case " ${AMPLIFICATION_BREAKER_DEFERRED[*]} " in *" $FINDING_NUM "*) ;; *) AMPLIFICATION_BREAKER_DEFERRED+=("$FINDING_NUM") ;; esac
|
|
2994
|
+
# Route to the bounded P3 batch planner (never individual dispatch); dedupe so a finding
|
|
2995
|
+
# already added by the generation rule is not listed twice.
|
|
2996
|
+
if [ "$PRIORITY" = "P3" ]; then
|
|
2997
|
+
case " ${BATCHABLE_DEFERRED_P3[*]} " in *" $FINDING_NUM "*) ;; *) BATCHABLE_DEFERRED_P3+=("$FINDING_NUM") ;; esac
|
|
2998
|
+
fi
|
|
2999
|
+
fi
|
|
3000
|
+
fi
|
|
3001
|
+
|
|
2519
3002
|
if [ "$DEFER" = "true" ]; then
|
|
2520
3003
|
DEFERRED_FINDINGS+=($FINDING_NUM)
|
|
2521
3004
|
DEFERRED_REASONS[$FINDING_NUM]="$DEFER_REASON"
|
|
@@ -2526,6 +3009,30 @@ Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${R
|
|
|
2526
3009
|
done
|
|
2527
3010
|
```
|
|
2528
3011
|
|
|
3012
|
+
**Amplification settle (MANDATORY — after the rule loop above, before batching/dispatch; forge#3060):** now that this unit's findings are counted, record its ratio observation (unrounded; the printed value is rounded for display only) and evaluate the breaker over the latest `CONVERGENCE_WINDOW` observations. Release as soon as the newest observation is below 1.0.
|
|
3013
|
+
|
|
3014
|
+
```bash
|
|
3015
|
+
if [ "$UNIT_MERGED_THIS_CYCLE" = "true" ]; then
|
|
3016
|
+
AMPLIFICATION_RATIO=$(awk "BEGIN { printf \"%.2f\", $FINDINGS_SPAWNED / $MERGED_UNITS }")
|
|
3017
|
+
AMPLIFICATION_RATIO_HISTORY+=("$(awk "BEGIN { printf \"%.6f\", $FINDINGS_SPAWNED / $MERGED_UNITS }")")
|
|
3018
|
+
if [ "${#AMPLIFICATION_RATIO_HISTORY[@]}" -ge "$CONVERGENCE_WINDOW" ]; then
|
|
3019
|
+
RECENT_RATIOS=("${AMPLIFICATION_RATIO_HISTORY[@]: -$CONVERGENCE_WINDOW}")
|
|
3020
|
+
if printf '%s\n' "${RECENT_RATIOS[@]}" | awk '$1 < 1 { exit 1 }'; then
|
|
3021
|
+
echo "CONVERGENCE WARNING: amplification has remained >= 1.0 for ${CONVERGENCE_WINDOW} merged units (${AMPLIFICATION_RATIO} current). This can be productive review refinement, but the batch is not shrinking."
|
|
3022
|
+
if [ "$AMPLIFICATION_BREAKER" = "on" ] && [ "$AMPLIFICATION_BREAKER_TRIPPED" = "false" ]; then
|
|
3023
|
+
AMPLIFICATION_BREAKER_TRIPPED=true
|
|
3024
|
+
# One operator report per trip: ratio and queue size, no per-finding spam.
|
|
3025
|
+
echo "AMPLIFICATION BREAKER TRIPPED: ratio ${AMPLIFICATION_RATIO} (${FINDINGS_SPAWNED} findings / ${MERGED_UNITS} merged units) stayed >= 1.0 for ${CONVERGENCE_WINDOW} units. P3 cascade admission is paused; ${#AMPLIFICATION_BREAKER_DEFERRED[@]} finding(s) paused so far. P1/P2 are still admitted. Paused P3s are routed to the bounded P3 batch planner (planP3BatchGroups) and are never dispatched individually. Operator decision: batch them or defer. Opt out: orchestration.cascade.amplification_breaker: off"
|
|
3026
|
+
fi
|
|
3027
|
+
else
|
|
3028
|
+
# Newest observation fell below 1.0 — the cascade is converging again; release the breaker.
|
|
3029
|
+
AMPLIFICATION_BREAKER_TRIPPED=false
|
|
3030
|
+
fi
|
|
3031
|
+
fi
|
|
3032
|
+
fi
|
|
3033
|
+
UNIT_MERGED_THIS_CYCLE=false
|
|
3034
|
+
```
|
|
3035
|
+
|
|
2529
3036
|
**Concern-level batching for queued P3 findings (MANDATORY check before dispatch):** <!-- Added: forge#1818 -->
|
|
2530
3037
|
|
|
2531
3038
|
Cascade-spawned findings collected within a single `/orchestrate` run must be reconsidered against the complete open, unbatched, undispatched candidate registry, not only `QUEUED_FINDINGS` from this completion cycle. At initial resolution and after every five completions (or whenever `DEFERRED_FINDINGS` reaches `MAX_CONCURRENT`), collect retained ungrouped candidates, new findings, and open batch membership; call `planP3BatchGroups()` with that registry and `batchExclusionReason()` danger-zone inputs. Execute returned `extensions` before new groups, retain `ungrouped` members for the next sweep, and record group kinds or exclusion predicates in the run summary. <!-- Added: forge#2858; extended: forge#2851, forge#2852 -->
|
|
@@ -2607,7 +3114,27 @@ for FINDING_NUM in "${BATCHING_CANDIDATES[@]}"; do
|
|
|
2607
3114
|
FINDING_FILE=$(echo "$FINDING_DATA" | jq -r '.body' | grep -oE '`[^`]+\.(py|tsx?|jsx?|sql|json|ya?ml|sh|md)`' | head -1 | tr -d '`')
|
|
2608
3115
|
[ -z "$FINDING_FILE" ] && continue
|
|
2609
3116
|
|
|
2610
|
-
|
|
3117
|
+
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
3118
|
+
FORGE_ROOT=""
|
|
3119
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
|
|
3120
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
|
|
3121
|
+
_l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
|
|
3122
|
+
# newest cached version first: numeric major.minor.patch of the version dir name only; a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
|
|
3123
|
+
_v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ '$(NF-1)=="forgedock"{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5-)"
|
|
3124
|
+
_m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
|
|
3125
|
+
# '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
|
|
3126
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
|
|
3127
|
+
while IFS= read -r _c; do
|
|
3128
|
+
case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
|
|
3129
|
+
done <<< "$_k"
|
|
3130
|
+
fi
|
|
3131
|
+
# Import from ForgeDock's own root, never the consumer cwd; fail closed (non-routine) when unresolved.
|
|
3132
|
+
SAFETY_CLASS="unclassified"
|
|
3133
|
+
if [ -n "$FORGE_ROOT" ] && [ -r "$FORGE_ROOT/bin/engine/admission.mjs" ]; then
|
|
3134
|
+
SAFETY_CLASS=$(node -e 'import(require("node:url").pathToFileURL(process.argv[1]).href).then(({ classifyBatchSafety }) => process.stdout.write(classifyBatchSafety(process.argv[2]) || "routine"))' "$FORGE_ROOT/bin/engine/admission.mjs" "$(echo "$FINDING_DATA" | jq -r '.title + "\n" + .body + "\n" + (.labels | join(" "))')" 2>/dev/null || echo "unclassified")
|
|
3135
|
+
else
|
|
3136
|
+
echo "WARNING: ForgeDock install root unresolved or admission.mjs missing (set FORGEDOCK_HOME) — #${FINDING_NUM} classified unclassified (non-routine: may batch only with other unclassified findings on the same file, capped at 3)" >&2
|
|
3137
|
+
fi
|
|
2611
3138
|
# Same file is not enough for security work: the class key prevents a
|
|
2612
3139
|
# credential finding from sharing a batch with injection/auth hardening.
|
|
2613
3140
|
SURFACE_KEY="${SAFETY_CLASS}:${FINDING_FILE}"
|
|
@@ -2619,7 +3146,7 @@ done
|
|
|
2619
3146
|
# the batch issue in QUEUED_FINDINGS so the dispatch step below never double-dispatches
|
|
2620
3147
|
# them. This is what makes SURFACE_BATCHED_FINDINGS a live control, not dead wiring. <!-- forge#1832, forge#1834 -->
|
|
2621
3148
|
for FILE in "${!SURFACE_FILE_MEMBERS[@]}"; do
|
|
2622
|
-
MEMBERS=(${SURFACE_FILE_MEMBERS[$FILE]})
|
|
3149
|
+
MEMBERS=($(printf '%s\n' ${SURFACE_FILE_MEMBERS[$FILE]} | sort -un)) # dedupe: a finding listed twice must not form a self-batch
|
|
2623
3150
|
[ "${#MEMBERS[@]}" -ge 2 ] || continue
|
|
2624
3151
|
|
|
2625
3152
|
SAFETY_CLASS=${FILE%%:*}
|
|
@@ -2831,7 +3358,7 @@ A finding that fails classify-lane is removed from `QUEUED_FINDINGS` (mirrors St
|
|
|
2831
3358
|
|
|
2832
3359
|
Track them in `DEFERRED_FINDINGS` for re-evaluation in Step 4F (Completion Sweep) after the DAG drains. Do NOT close or label them yet — the sweep will determine their final disposition.
|
|
2833
3360
|
|
|
2834
|
-
**If no review-finding issues were spawned:** Continue monitoring for the next agent completion.
|
|
3361
|
+
**If no review-finding issues were spawned:** Still run the "Amplification settle" block above with an empty finding list (it records this unit's ratio observation, which is what lets the breaker release), then Continue monitoring for the next agent completion.
|
|
2835
3362
|
|
|
2836
3363
|
### Step 4C.5: Milestone lane-consistency check (periodic) <!-- Added: forge#901 -->
|
|
2837
3364
|
|
|
@@ -2992,12 +3519,19 @@ PERMANENT_DEFERRED=()
|
|
|
2992
3519
|
SWEEP_CANDIDATES=()
|
|
2993
3520
|
IDLE_DEFERRED=() # <!-- Added: forge#1814 -->
|
|
2994
3521
|
TOKEN_GATED=() # <!-- Added: forge#1858 -->
|
|
3522
|
+
: "${BREAKER_DEFER_TAG:=amplification breaker}" # default if this block runs in a fresh shell (an empty tag would match every defer reason)
|
|
3523
|
+
AMPLIFICATION_BREAKER_HELD=() # amplification-breaker holds: bounded P3 batches only, never individually dispatched (forge#3060)
|
|
2995
3524
|
|
|
2996
3525
|
for FINDING_NUM in "${DEFERRED_FINDINGS[@]}"; do
|
|
2997
3526
|
DEFER_REASON="${DEFERRED_REASONS[$FINDING_NUM]}"
|
|
2998
3527
|
|
|
3528
|
+
# Amplification-breaker holds (forge#3060) are checked FIRST: the breaker reason is appended to
|
|
3529
|
+
# whatever rule deferred the finding earlier (idle policy, generation, same-file, token budget),
|
|
3530
|
+
# and such a finding must never fall into a bucket whose re-evaluation dispatches it individually.
|
|
3531
|
+
if echo "$DEFER_REASON" | grep -qiF "$BREAKER_DEFER_TAG"; then
|
|
3532
|
+
AMPLIFICATION_BREAKER_HELD+=($FINDING_NUM)
|
|
2999
3533
|
# Generation >= 2 deferrals are PERMANENT — unbounded cascade prevention
|
|
3000
|
-
|
|
3534
|
+
elif echo "$DEFER_REASON" | grep -qi "generation"; then
|
|
3001
3535
|
PERMANENT_DEFERRED+=($FINDING_NUM)
|
|
3002
3536
|
# "Batch fully human-gated" deferrals (forge#1814) are their OWN bucket — they must NOT be
|
|
3003
3537
|
# re-evaluated by the file-overlap logic in Step 4F.2 below, because the reason they were
|
|
@@ -3020,7 +3554,7 @@ for FINDING_NUM in "${DEFERRED_FINDINGS[@]}"; do
|
|
|
3020
3554
|
fi
|
|
3021
3555
|
done
|
|
3022
3556
|
|
|
3023
|
-
echo "Completion sweep: ${#SWEEP_CANDIDATES[@]} re-evaluable, ${#PERMANENT_DEFERRED[@]} permanent, ${#IDLE_DEFERRED[@]} idle-gated, ${#TOKEN_GATED[@]} token-gated"
|
|
3557
|
+
echo "Completion sweep: ${#SWEEP_CANDIDATES[@]} re-evaluable, ${#PERMANENT_DEFERRED[@]} permanent, ${#IDLE_DEFERRED[@]} idle-gated, ${#TOKEN_GATED[@]} token-gated, ${#AMPLIFICATION_BREAKER_HELD[@]} breaker-held (P3 batches only)"
|
|
3024
3558
|
```
|
|
3025
3559
|
|
|
3026
3560
|
**Step 4F.2: Re-evaluate sweep candidates**
|
|
@@ -3030,6 +3564,7 @@ Re-run the Step 4C heuristics against the now-empty DAG. Since all original batc
|
|
|
3030
3564
|
```bash
|
|
3031
3565
|
SWEEP_EXECUTE=()
|
|
3032
3566
|
SWEEP_STILL_DEFERRED=()
|
|
3567
|
+
AMPLIFICATION_BREAKER_SWEEP_HELD=()
|
|
3033
3568
|
|
|
3034
3569
|
for FINDING_NUM in "${SWEEP_CANDIDATES[@]}"; do
|
|
3035
3570
|
FINDING_DATA=$(gh issue view $FINDING_NUM -R {GH_REPO} --json labels,title,body,state \
|
|
@@ -3051,6 +3586,17 @@ for FINDING_NUM in "${SWEEP_CANDIDATES[@]}"; do
|
|
|
3051
3586
|
"" | ltrimstr("priority:")')
|
|
3052
3587
|
TITLE=$(echo "$FINDING_DATA" | jq -r '.title')
|
|
3053
3588
|
|
|
3589
|
+
# Amplification-breaker deferrals (forge#3060) are NEVER individually dispatched by the sweep:
|
|
3590
|
+
# draining the DAG does not make a P3 cascade bounded. Leave them open for the bounded P3
|
|
3591
|
+
# batch sweep (planP3BatchGroups) and report them to the operator instead.
|
|
3592
|
+
case "${DEFERRED_REASONS[$FINDING_NUM]:-}" in
|
|
3593
|
+
*"$BREAKER_DEFER_TAG"*)
|
|
3594
|
+
SWEEP_STILL_DEFERRED+=($FINDING_NUM)
|
|
3595
|
+
AMPLIFICATION_BREAKER_SWEEP_HELD+=($FINDING_NUM)
|
|
3596
|
+
echo "Sweep: #${FINDING_NUM} held for P3 batching (amplification breaker) — not dispatched individually"
|
|
3597
|
+
continue ;;
|
|
3598
|
+
esac
|
|
3599
|
+
|
|
3054
3600
|
# Re-apply heuristics against the drained DAG (no active batch files)
|
|
3055
3601
|
# Comment/typo heuristic still applies — these are cosmetic regardless of DAG state
|
|
3056
3602
|
if echo "$TITLE" | grep -qi "comment\|typo"; then
|
|
@@ -3232,9 +3778,10 @@ The context-gathering phase can fetch this index to discover all investigation G
|
|
|
3232
3778
|
**HOW REVIEW FINDINGS WORK**: /review-pr may create GitHub issues (with \`review-finding\` label) for findings it discovers. These are NOT blockers — they are separate work items that will go through their own /work-on pipeline later. The original PR should ALWAYS merge after review. The only exception is build errors (code doesn't compile) — those must be fixed before merging.
|
|
3233
3779
|
|
|
3234
3780
|
**IMPORTANT RULES**:
|
|
3235
|
-
- **MANDATORY**: You MUST use the Skill tool to invoke 'work-on' with args '${FINDING_NUM}'. Do NOT implement manually — /work-on handles the full pipeline including label state machine (workflow:investigating → workflow:building → workflow:in-review → workflow:merged), investigation reports, PR creation, and cleanup.
|
|
3236
|
-
- For default repo issues: \`Skill(skill='work-on', args='${FINDING_NUM} --under-orchestration')\`
|
|
3237
|
-
- For satellite repo issues: \`Skill(skill='work-on', args='{
|
|
3781
|
+
- **MANDATORY**: You MUST use the Skill tool to invoke '{FORGE_SKILL_PREFIX}work-on' with args '${FINDING_NUM}'. Do NOT implement manually — /work-on handles the full pipeline including label state machine (workflow:investigating → workflow:building → workflow:in-review → workflow:merged), investigation reports, PR creation, and cleanup.
|
|
3782
|
+
- For default repo issues: \`Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='${FINDING_NUM} --under-orchestration')\`
|
|
3783
|
+
- For satellite repo issues: \`Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='<satellite-prefix>:${FINDING_NUM} --under-orchestration')\` (\`<satellite-prefix>\` from forge.yaml → repos.satellites)
|
|
3784
|
+
- **If \`{FORGE_SKILL_PREFIX}work-on\` is reported unknown or not found, this is a HARD ERROR: STOP and report 'skill not found: {FORGE_SKILL_PREFIX}work-on' as your final result. Do NOT run the pipeline phases inline or with the Agent tool — an inline run bypasses the phase trail.**
|
|
3238
3785
|
- NEVER bypass /work-on with manual git/gh commands — the label updates and structured comments are critical for tracking
|
|
3239
3786
|
- NEVER target \`main\` for PRs targeting the default repo. Use \`{STAGING_BRANCH}\` for fast-lane issues, or \`milestone/{slug}\` for milestone issues.
|
|
3240
3787
|
- Satellite repos (MCP, n8n) have no staging branch — fast-lane PRs go to \`main\` for those.
|
|
@@ -3244,20 +3791,20 @@ The context-gathering phase can fetch this index to discover all investigation G
|
|
|
3244
3791
|
- **NEVER ask the user questions** — you are a background agent. If review finds issues, auto-fix simple ones and proceed, then let `/review-pr`'s own verdict decide: APPROVED (no unresolved CONFIRMED HIGH/CRITICAL finding) → merge to `staging` and create follow-up issues for the rest, **regardless of domain**. Domain alone (AUTH, BILLING, DATABASE, or any domain tagged as security-critical in Step 3B) is NOT a reason to add `needs-human` and stop — `staging` is reversible; the real human deploy gate is `staging → main`, not this merge. <!-- Added: forge#1815 --> `needs-human` is reserved for what the pipeline genuinely cannot do itself: spend/procurement decisions, real-environment validation it has no access to, product/architecture judgment calls a human must make, or `/review-pr`'s existing evidence-based escalations (spec-evolution guard, novel task-type/module-combo trust escalation, calibration-based overconfidence routing). An unresolved CONFIRMED HIGH/CRITICAL finding is `/review-pr`'s own withheld-APPROVED case, not a domain-driven `needs-human` halt — `/review-pr` already refuses to return APPROVED when that's true, so there is no separate domain check to perform here.
|
|
3245
3792
|
|
|
3246
3793
|
**LABEL-STATE LOOP CONTRACT — enforce after EVERY Skill return**:
|
|
3247
|
-
After EVERY \`Skill(skill='work-on', ...)\` call returns, immediately check the issue's current workflow label:
|
|
3794
|
+
After EVERY \`Skill(skill='{FORGE_SKILL_PREFIX}work-on', ...)\` call returns, immediately check the issue's current workflow label:
|
|
3248
3795
|
\`\`\`bash
|
|
3249
3796
|
gh issue view ${FINDING_NUM} -R {GH_REPO} --json labels --jq '[.labels[].name | select(startswith(\"workflow:\"))]'
|
|
3250
3797
|
\`\`\`
|
|
3251
3798
|
**Terminal labels** (only these allow you to stop): \`workflow:merged\`, \`workflow:invalid\`
|
|
3252
3799
|
**Terminal condition also**: \`needs-human\` label present, \`workflow:awaiting-merge\` label present, OR issue state is \`closed\`
|
|
3253
|
-
If the label is NOT terminal (e.g., \`workflow:investigating\`, \`workflow:ready-to-build\`, \`workflow:building\`, \`workflow:in-review\`), invoke \`Skill(skill='work-on', args='${FINDING_NUM} --under-orchestration')\` again immediately. The \`/work-on\` skill will re-read GitHub state and advance to the next phase. Do NOT output a summary, do NOT pause, do NOT ask for confirmation — just invoke it again.
|
|
3800
|
+
If the label is NOT terminal (e.g., \`workflow:investigating\`, \`workflow:ready-to-build\`, \`workflow:building\`, \`workflow:in-review\`), invoke \`Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='${FINDING_NUM} --under-orchestration')\` again immediately. The \`/work-on\` skill will re-read GitHub state and advance to the next phase. Do NOT output a summary, do NOT pause, do NOT ask for confirmation — just invoke it again.
|
|
3254
3801
|
|
|
3255
3802
|
**CRITICAL — SOURCE BRANCH DETECTION**:
|
|
3256
3803
|
- If the issue has the \`review-finding\` label, read the issue body for \`**Code branch**: \\\`{branch}\\\`\`
|
|
3257
|
-
- If found, that is the
|
|
3258
|
-
- Investigation MUST use \`git show origin
|
|
3259
|
-
- Worktree MUST branch from \`origin
|
|
3260
|
-
- PR target is
|
|
3804
|
+
- If found, that branch is the source branch (`<source-branch>` below) — the code ONLY exists on that branch (e.g., \`staging\`), NOT on \`origin/main\`
|
|
3805
|
+
- Investigation MUST use \`git show origin/<source-branch>:<filepath>\` to verify the code exists
|
|
3806
|
+
- Worktree MUST branch from \`origin/<source-branch>\`, NOT \`origin/main\`
|
|
3807
|
+
- PR target is \`<source-branch>\` (the fix goes back to where the code lives)
|
|
3261
3808
|
|
|
3262
3809
|
**LANE**: ${SWEEP_LANE[$FINDING_NUM]} (PR target: ${SWEEP_PR_BASE[$FINDING_NUM]})
|
|
3263
3810
|
**Issue title**: ${FINDING_TITLE}
|