forgedock 1.8.0 → 1.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/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/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 +2 -2
- package/commands/orchestrate/phase-1-resolve.md +56 -12
- package/commands/orchestrate/phase-3-dependency.md +15 -5
- package/commands/orchestrate/phase-4-execution.md +593 -78
- package/commands/orchestrate/phase-5-cleanup.md +4 -2
- package/commands/orchestrate/phase-6-report.md +7 -3
- package/commands/orchestrate.md +13 -9
- package/commands/pipeline-health/phase-4-proposals.md +6 -4
- package/commands/pipeline-health/phase-5-report.md +4 -2
- 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 +172 -13
- package/commands/review-pr-agents.md +6 -4
- package/commands/review-pr-staging.md +13 -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 +4 -2
- package/commands/validate.md +3 -1
- package/commands/work-on/build/architect.md +81 -19
- package/commands/work-on/build/context.md +61 -18
- package/commands/work-on/build/implement.md +62 -18
- package/commands/work-on/build/validate.md +240 -60
- package/commands/work-on/build.md +344 -146
- package/commands/work-on/close.md +617 -246
- package/commands/work-on/decompose.md +239 -41
- package/commands/work-on/investigate.md +377 -61
- package/commands/work-on/remediate.md +42 -9
- package/commands/work-on/review.md +240 -29
- package/commands/work-on-monolithic.md +48 -7
- 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 +11 -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
|
@@ -29,7 +29,7 @@ if [ -n "${FORGE_COORD_ISSUE:-}" ] && [ -n "${COORD_ISSUE_NUMBER:-}" ] && [ -n "
|
|
|
29
29
|
exit 1
|
|
30
30
|
;;
|
|
31
31
|
free|self)
|
|
32
|
-
HOSTNAME_ID=$(hostname 2>/dev/null ||
|
|
32
|
+
HOSTNAME_ID=$(hostname 2>/dev/null || uname -n 2>/dev/null || true); HOSTNAME_ID="${HOSTNAME_ID:-unknown-host}"
|
|
33
33
|
# 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
34
|
gh issue comment "$COORD_ISSUE_NUMBER" -R {GH_REPO} --body "<!-- FORGE:LEASE -->
|
|
35
35
|
**Holder Batch ID**: ${BATCH_ID}
|
|
@@ -392,10 +392,12 @@ resolve_classify_lane() {
|
|
|
392
392
|
[ -n "${OPENCODE_SESSION_ID:-}" ] ||
|
|
393
393
|
[ -n "${OPENCODE_PID:-}" ] ||
|
|
394
394
|
[ -n "${OPENCODE:-}" ]; then
|
|
395
|
+
case '${CLAUDE_PLUGIN_ROOT}' in /*) candidates+=('${CLAUDE_PLUGIN_ROOT}/scripts/classify-lane.sh') ;; esac # running plugin first
|
|
395
396
|
[ -n "${FORGE_HOME:-}" ] && candidates+=("$FORGE_HOME/scripts/classify-lane.sh")
|
|
396
397
|
[ -n "${REPO_PATH:-}" ] && candidates+=("$REPO_PATH/scripts/classify-lane.sh")
|
|
397
398
|
candidates+=("$HOME/.opencode/scripts/classify-lane.sh")
|
|
398
399
|
else
|
|
400
|
+
case '${CLAUDE_PLUGIN_ROOT}' in /*) candidates+=('${CLAUDE_PLUGIN_ROOT}/scripts/classify-lane.sh') ;; esac # running plugin first
|
|
399
401
|
[ -n "${FORGE_HOME:-}" ] && candidates+=("$FORGE_HOME/scripts/classify-lane.sh")
|
|
400
402
|
candidates+=("$HOME/.claude/scripts/classify-lane.sh")
|
|
401
403
|
[ -n "${REPO_PATH:-}" ] && candidates+=("$REPO_PATH/scripts/classify-lane.sh")
|
|
@@ -444,8 +446,11 @@ declare -A DEFERRED_REASONS
|
|
|
444
446
|
# makes the reported amplification ratio look better than it was.
|
|
445
447
|
FINDINGS_SPAWNED=0
|
|
446
448
|
MERGED_UNITS=0
|
|
449
|
+
UNIT_MERGED_THIS_CYCLE=false # set per completed unit by the Step 4C accounting block (forge#3060)
|
|
447
450
|
AMPLIFICATION_RATIO_HISTORY=()
|
|
448
451
|
AMPLIFICATION_DEFERRED=()
|
|
452
|
+
AMPLIFICATION_BREAKER_DEFERRED=() # findings paused by the on-by-default breaker (forge#3060)
|
|
453
|
+
BREAKER_DEFER_TAG="amplification breaker" # single source for the defer-reason text matched in Step 4F
|
|
449
454
|
declare -A FINDINGS_BY_SOURCE_PR
|
|
450
455
|
declare -A REFINEMENT_FINDINGS
|
|
451
456
|
declare -A NEW_SURFACE_FINDINGS
|
|
@@ -504,6 +509,7 @@ declare -A EDGE_REDERIVE_ATTEMPTS
|
|
|
504
509
|
# copied ForgeDock's helper scripts into its own repository (#2794/#2791).
|
|
505
510
|
resolve_extract_affected_files() {
|
|
506
511
|
local candidates=()
|
|
512
|
+
case '${CLAUDE_PLUGIN_ROOT}' in /*) candidates+=('${CLAUDE_PLUGIN_ROOT}/scripts/extract-affected-files.sh') ;; esac # running plugin first
|
|
507
513
|
[ -n "${FORGE_HOME:-}" ] && candidates+=("$FORGE_HOME/scripts/extract-affected-files.sh")
|
|
508
514
|
[ -n "${REPO_PATH:-}" ] && candidates+=("$REPO_PATH/scripts/extract-affected-files.sh")
|
|
509
515
|
candidates+=("$PWD/scripts/extract-affected-files.sh")
|
|
@@ -596,7 +602,7 @@ fi
|
|
|
596
602
|
|
|
597
603
|
TOKEN_ESTIMATE_PER_FINDING=$(yq '.pipeline.token_estimate_per_finding // 150000' forge.yaml 2>/dev/null || echo 150000)
|
|
598
604
|
|
|
599
|
-
# Amplification is observational by default. The optional ceiling is evaluated
|
|
605
|
+
# Amplification is observational by default for the same-lineage ceiling (the breaker below is not). The optional ceiling is evaluated
|
|
600
606
|
# only against same-lineage refinements in Step 4C, never against new surface.
|
|
601
607
|
CASCADE_MAX_AMPLIFICATION=$(yq '.orchestration.cascade.max_amplification // "off"' forge.yaml 2>/dev/null || echo "off")
|
|
602
608
|
if [ "$CASCADE_MAX_AMPLIFICATION" != "off" ]; then
|
|
@@ -611,7 +617,23 @@ if ! echo "$CONVERGENCE_WINDOW" | grep -qE '^[1-9][0-9]*$'; then
|
|
|
611
617
|
echo "WARNING: forge.yaml → orchestration.cascade.convergence_window must be a positive integer — falling back to 3"
|
|
612
618
|
CONVERGENCE_WINDOW=3
|
|
613
619
|
fi
|
|
614
|
-
|
|
620
|
+
# On-by-default amplification circuit breaker (forge#3060). Unlike max_amplification (opt-in,
|
|
621
|
+
# same-lineage refinements only), this counts ALL cascade findings and pauses P3-and-below
|
|
622
|
+
# admission when the ratio stays >= 1.0 for CONVERGENCE_WINDOW merged units. P1/P2 are never
|
|
623
|
+
# affected. It is independent of orchestration.cascade.policy, so `policy: all` and the
|
|
624
|
+
# --max-generation/--token-budget unlimited CLI overrides do NOT disable it. Opt out only
|
|
625
|
+
# with an explicit `orchestration.cascade.amplification_breaker: off`.
|
|
626
|
+
# NOTE: do NOT use yq's `// "on"` here — the alternative operator treats boolean `false` as falsy and
|
|
627
|
+
# would turn `amplification_breaker: false` into "on". Read the raw value and default null/empty to "on".
|
|
628
|
+
AMPLIFICATION_BREAKER=$(yq '.orchestration.cascade.amplification_breaker' forge.yaml 2>/dev/null || echo "on")
|
|
629
|
+
case "$AMPLIFICATION_BREAKER" in ""|null) AMPLIFICATION_BREAKER="on" ;; esac
|
|
630
|
+
case "$(echo "$AMPLIFICATION_BREAKER" | tr '[:upper:]' '[:lower:]')" in
|
|
631
|
+
on|true) AMPLIFICATION_BREAKER="on" ;;
|
|
632
|
+
off|false) AMPLIFICATION_BREAKER="off" ;;
|
|
633
|
+
*) echo "WARNING: forge.yaml → orchestration.cascade.amplification_breaker must be on or off (\"${AMPLIFICATION_BREAKER}\") — keeping the breaker on"; AMPLIFICATION_BREAKER="on" ;;
|
|
634
|
+
esac
|
|
635
|
+
AMPLIFICATION_BREAKER_TRIPPED=false
|
|
636
|
+
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
637
|
|
|
616
638
|
# Independent boolean levers — each accepts an explicit granular override on
|
|
617
639
|
# top of the resolved preset (preset supplies the default, not a hard value).
|
|
@@ -674,17 +696,21 @@ Use `${ISSUE_LANE[$NUM]}` and `${ISSUE_PR_BASE[$NUM]}` to populate `{LANE}` and
|
|
|
674
696
|
### Step 4A.0: Probe Knowledge Gist capability once
|
|
675
697
|
|
|
676
698
|
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
|
-
|
|
699
|
+
OpenCode worker is dispatched. A GitHub App installation token cannot use the Gists API (and cannot even read `/user`); cache
|
|
700
|
+
that fact rather than letting every worker rediscover it by attempting a write. The probe fails
|
|
701
|
+
closed: Gists stay enabled only when `/user` positively reports a `User` account.
|
|
680
702
|
|
|
681
703
|
```bash
|
|
682
704
|
if [ -z "${FORGE_GIST_CAPABLE+x}" ]; then
|
|
705
|
+
# Fail closed: only a positively identified user account can use the Gists API. A GitHub App
|
|
706
|
+
# installation token cannot read /user at all (HTTP 403, empty type), so "not Bot" is NOT
|
|
707
|
+
# evidence of capability; an unavailable probe disables Gists rather than letting every
|
|
708
|
+
# worker fail on its first Gist write.
|
|
683
709
|
GIST_AUTH_TYPE=$(gh api user --jq '.type' 2>/dev/null || true)
|
|
684
|
-
if [ "$GIST_AUTH_TYPE" = "
|
|
685
|
-
FORGE_GIST_CAPABLE=false
|
|
686
|
-
else
|
|
710
|
+
if [ "$GIST_AUTH_TYPE" = "User" ]; then
|
|
687
711
|
FORGE_GIST_CAPABLE=true
|
|
712
|
+
else
|
|
713
|
+
FORGE_GIST_CAPABLE=false
|
|
688
714
|
fi
|
|
689
715
|
export FORGE_GIST_CAPABLE
|
|
690
716
|
fi
|
|
@@ -782,7 +808,7 @@ task(
|
|
|
782
808
|
description="Work on {PROJECT_PREFIX}#{NUMBER}",
|
|
783
809
|
subagent_type="general",
|
|
784
810
|
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."
|
|
811
|
+
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
812
|
)
|
|
787
813
|
```
|
|
788
814
|
|
|
@@ -922,7 +948,7 @@ if [ "$FORGEDOCK_AVAILABLE" = "true" ]; then
|
|
|
922
948
|
# Lease heartbeat refresh (forge#2627) — once per dispatch chunk, so a long-running batch's
|
|
923
949
|
# lease never goes stale purely from elapsed wall-clock time while dispatch is still active.
|
|
924
950
|
if [ "${#DISPATCH_NOW[@]}" -gt 0 ] && [ -n "${FORGE_COORD_ISSUE:-}" ] && [ -n "${COORD_ISSUE_NUMBER:-}" ] && [ -n "${BATCH_ID:-}" ]; then
|
|
925
|
-
HOSTNAME_ID=$(hostname 2>/dev/null ||
|
|
951
|
+
HOSTNAME_ID=$(hostname 2>/dev/null || uname -n 2>/dev/null || true); HOSTNAME_ID="${HOSTNAME_ID:-unknown-host}"
|
|
926
952
|
# 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
953
|
gh issue comment "$COORD_ISSUE_NUMBER" -R {GH_REPO} --body "<!-- FORGE:LEASE -->
|
|
928
954
|
**Holder Batch ID**: ${BATCH_ID}
|
|
@@ -976,6 +1002,8 @@ For each **ready** issue (all predecessors resolved or no predecessors), spawn a
|
|
|
976
1002
|
|
|
977
1003
|
**One agent per issue.** Do NOT group multiple issues into a single agent. `/work-on` handles branching, labels, and PRs per-issue.
|
|
978
1004
|
|
|
1005
|
+
**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.
|
|
1006
|
+
|
|
979
1007
|
**Copy this template. Fill in variables. Do not modify the structure:**
|
|
980
1008
|
|
|
981
1009
|
```
|
|
@@ -999,12 +1027,13 @@ Agent(
|
|
|
999
1027
|
**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
1028
|
|
|
1001
1029
|
**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='{
|
|
1030
|
+
- **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.
|
|
1031
|
+
- For default repo issues: `Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='{NUMBER} --under-orchestration')`
|
|
1032
|
+
- For satellite repo issues: `Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='<satellite-prefix>:{NUMBER} --under-orchestration')` (`<satellite-prefix>` from forge.yaml → repos.satellites)
|
|
1033
|
+
- **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
1034
|
- 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
1035
|
- 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
|
|
1036
|
+
- **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
1037
|
- **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
1038
|
- **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
1039
|
- **`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 +1045,115 @@ Agent(
|
|
|
1016
1045
|
- **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
1046
|
|
|
1018
1047
|
**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:
|
|
1048
|
+
After EVERY `Skill(skill='{FORGE_SKILL_PREFIX}work-on', ...)` call returns, immediately check the issue's current workflow label:
|
|
1020
1049
|
```bash
|
|
1021
1050
|
gh issue view {NUMBER} -R {GH_REPO} --json labels --jq '[.labels[].name | select(startswith("workflow:"))]'
|
|
1022
1051
|
```
|
|
1023
1052
|
**Terminal labels** (only these allow you to stop): `workflow:merged`, `workflow:invalid`
|
|
1024
1053
|
**Terminal condition also**: `needs-human` label present, `workflow:awaiting-merge` label present, OR issue state is `closed`
|
|
1025
1054
|
`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.
|
|
1055
|
+
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
1056
|
|
|
1028
1057
|
**CRITICAL — SOURCE BRANCH DETECTION**:
|
|
1029
1058
|
- 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
|
|
1059
|
+
- 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`
|
|
1060
|
+
- Investigation MUST use `git show origin/<source-branch>:<filepath>` to verify the code exists
|
|
1061
|
+
- Worktree MUST branch from `origin/<source-branch>`, NOT `origin/main`
|
|
1062
|
+
- PR target is `<source-branch>` (the fix goes back to where the code lives)
|
|
1034
1063
|
|
|
1035
1064
|
**LANE**: {LANE} (PR target: {PR_BASE})
|
|
1036
1065
|
**Issue title**: {ISSUE_TITLE}
|
|
1066
|
+
<!-- DISPATCH_CONTEXT:BEGIN -->
|
|
1037
1067
|
{GIST_CONTEXT}
|
|
1038
1068
|
{SOURCE_PR_HINT_CONTEXT}
|
|
1069
|
+
<!-- DISPATCH_CONTEXT:END -->
|
|
1039
1070
|
"
|
|
1040
1071
|
)
|
|
1041
1072
|
```
|
|
1042
1073
|
|
|
1074
|
+
**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.
|
|
1075
|
+
|
|
1076
|
+
```bash
|
|
1077
|
+
# Bind the lint inputs FIRST (forge#3149). Every value is the SAME one substituted into the Step 4A template for this
|
|
1078
|
+
# issue (config.md / Step 4A resolve them) - substitute the literal values, never re-derive them. The rendered
|
|
1079
|
+
# prompt goes in through a quoted heredoc so no shell expansion touches it. A value containing a single quote is
|
|
1080
|
+
# written as '\'' . PROJECT_PREFIX, SATELLITE_PREFIX, FORGE_SKILL_PREFIX and SOURCE_BRANCH may be empty.
|
|
1081
|
+
PROJECT_NAME='{PROJECT_NAME}'; GH_REPO='{GH_REPO}'; REPO_PATH='{REPO_PATH}'
|
|
1082
|
+
LANE='{LANE}'; PR_BASE='{PR_BASE}'; STAGING_BRANCH='{STAGING_BRANCH}'
|
|
1083
|
+
SOURCE_BRANCH='{SOURCE_BRANCH}'; FORGE_GIST_CAPABLE='{FORGE_GIST_CAPABLE}'; SUBAGENT_MODEL='{SUBAGENT_MODEL}'
|
|
1084
|
+
PROJECT_PREFIX='{PROJECT_PREFIX}'; SATELLITE_PREFIX='{SATELLITE_PREFIX}'; FORGE_SKILL_PREFIX='{FORGE_SKILL_PREFIX}'
|
|
1085
|
+
# Heredoc into a file, then read it back: a heredoc body inside $( ) is parsed for quote balance by bash 3.2 (macOS),
|
|
1086
|
+
# and the rendered prompt contains apostrophes and parentheses.
|
|
1087
|
+
# The delimiter MUST be a string that occurs nowhere in the rendered prompt (the GIST/SOURCE_PR_HINT context blocks carry
|
|
1088
|
+
# untrusted text): replace RENDERED_PROMPT_EOF below with RENDERED_PROMPT_EOF_ plus 16 random hex chars, in BOTH places.
|
|
1089
|
+
RENDERED_PROMPT_SRC="$(mktemp)"
|
|
1090
|
+
cat > "$RENDERED_PROMPT_SRC" <<'RENDERED_PROMPT_EOF'
|
|
1091
|
+
{the fully rendered Step 4A prompt text for this issue, exactly as passed to Agent(prompt=...)}
|
|
1092
|
+
RENDERED_PROMPT_EOF
|
|
1093
|
+
RENDERED_PROMPT=$(cat "$RENDERED_PROMPT_SRC"); rm -f "$RENDERED_PROMPT_SRC"
|
|
1094
|
+
```
|
|
1095
|
+
|
|
1096
|
+
```bash
|
|
1097
|
+
# Runs inside the per-issue dispatch loop (same loop as claim_conflicts_with_live_holder above),
|
|
1098
|
+
# immediately before that issue's Agent()/task() call. LINT_REFUSED_ISSUES is initialised once, empty.
|
|
1099
|
+
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
1100
|
+
FORGE_ROOT=""
|
|
1101
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
|
|
1102
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
|
|
1103
|
+
_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")")"
|
|
1104
|
+
# 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)
|
|
1105
|
+
_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-)"
|
|
1106
|
+
_m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
|
|
1107
|
+
# '${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.
|
|
1108
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
|
|
1109
|
+
while IFS= read -r _c; do
|
|
1110
|
+
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
|
|
1111
|
+
done <<< "$_k"
|
|
1112
|
+
fi
|
|
1113
|
+
# Fail closed on unbound inputs (forge#3149): an empty rendered prompt or a missing resolved value would lint an empty file
|
|
1114
|
+
# (rc=2) or compare against nothing; refuse with a distinct reason instead of a misleading lint verdict.
|
|
1115
|
+
if [ -z "$RENDERED_PROMPT" ] || [ -z "$PROJECT_NAME" ] || [ -z "$GH_REPO" ] || [ -z "$REPO_PATH" ] || [ -z "$LANE" ] \
|
|
1116
|
+
|| [ -z "$PR_BASE" ] || [ -z "$STAGING_BRANCH" ] || [ -z "$FORGE_GIST_CAPABLE" ] || [ -z "$SUBAGENT_MODEL" ]; then
|
|
1117
|
+
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
|
|
1118
|
+
LINT_REFUSED_ISSUES+=("{NUMBER}:lint-inputs-unbound")
|
|
1119
|
+
continue
|
|
1120
|
+
fi
|
|
1121
|
+
LINT_SCRIPT="$FORGE_ROOT/scripts/lint-dispatch-prompt.sh"
|
|
1122
|
+
if [ -z "$FORGE_ROOT" ] || [ ! -r "$LINT_SCRIPT" ]; then
|
|
1123
|
+
# Fail closed: a missing lint script is NOT a pass and NOT a lint verdict.
|
|
1124
|
+
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
|
|
1125
|
+
LINT_REFUSED_ISSUES+=("{NUMBER}:lint-script-not-found")
|
|
1126
|
+
continue
|
|
1127
|
+
fi
|
|
1128
|
+
PROMPT_FILE="$(mktemp)"
|
|
1129
|
+
printf '%s' "$RENDERED_PROMPT" > "$PROMPT_FILE"
|
|
1130
|
+
# Exact-match expectations: every slot the orchestrator resolved itself must equal, byte for byte, the
|
|
1131
|
+
# value it substituted into the template (the SAME variables, never re-derived). One quoted argument per
|
|
1132
|
+
# slot, so spaces, parentheses, backslashes and non-ASCII are safe. A slot present in the prompt with no
|
|
1133
|
+
# --expect is refused (fail closed). PROJECT_PREFIX / FORGE_SKILL_PREFIX may legitimately be empty.
|
|
1134
|
+
LINT_EXPECT=(
|
|
1135
|
+
--expect "PROJECT_NAME=$PROJECT_NAME" --expect "GH_REPO=$GH_REPO"
|
|
1136
|
+
--expect "REPO_PATH=$REPO_PATH" --expect "LANE=$LANE"
|
|
1137
|
+
--expect "PR_BASE=$PR_BASE" --expect "STAGING_BRANCH=$STAGING_BRANCH"
|
|
1138
|
+
--expect "SOURCE_BRANCH=$SOURCE_BRANCH" --expect "NUMBER={NUMBER}"
|
|
1139
|
+
--expect "FORGE_GIST_CAPABLE=$FORGE_GIST_CAPABLE" --expect "SUBAGENT_MODEL=$SUBAGENT_MODEL"
|
|
1140
|
+
--expect "PROJECT_PREFIX=$PROJECT_PREFIX" --expect "SATELLITE_PREFIX=$SATELLITE_PREFIX"
|
|
1141
|
+
--expect "FORGE_SKILL_PREFIX=$FORGE_SKILL_PREFIX"
|
|
1142
|
+
)
|
|
1143
|
+
bash "$LINT_SCRIPT" "${LINT_EXPECT[@]}" "$PROMPT_FILE"; LINT_RC=$?
|
|
1144
|
+
rm -f "$PROMPT_FILE"
|
|
1145
|
+
if [ "$LINT_RC" -ne 0 ]; then
|
|
1146
|
+
# rc 1 = template deviation (violations printed above); rc 2 = unreadable/empty prompt; any other rc = lint crashed.
|
|
1147
|
+
echo "DISPATCH REFUSED for #{NUMBER}: prompt lint rc=$LINT_RC (Hard Rule 1)" >&2
|
|
1148
|
+
LINT_REFUSED_ISSUES+=("{NUMBER}:lint-rc-$LINT_RC")
|
|
1149
|
+
continue # skip this issue's Agent()/task() call; never launch the failing prompt
|
|
1150
|
+
fi
|
|
1151
|
+
```
|
|
1152
|
+
|
|
1153
|
+
**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`.
|
|
1154
|
+
|
|
1155
|
+
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`.
|
|
1156
|
+
|
|
1043
1157
|
**`{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
1158
|
|
|
1045
1159
|
```bash
|
|
@@ -1111,7 +1225,7 @@ Claims board issue URL: ${FORGE_COORD_ISSUE}
|
|
|
1111
1225
|
|
|
1112
1226
|
On build start (Phase B2 / Phase 3C of /work-on), post a FORGE:CLAIM annotation on the
|
|
1113
1227
|
coordination issue above. Required fields:
|
|
1114
|
-
Holder
|
|
1228
|
+
**Holder**: #{NUMBER} / batch-$(date -u +%Y%m%dT%H%M%S) (exact form — read_active_claims parses `**Holder**: #N`)
|
|
1115
1229
|
Files: (list of files from your FORGE:CONTRACT deliverables table, one per line)
|
|
1116
1230
|
Interfaces: (public function/type signatures you will modify or that callers must preserve)
|
|
1117
1231
|
TTL: terminal state of Holder issue ##{NUMBER}
|
|
@@ -1226,9 +1340,97 @@ For an OpenCode completion, retain `OPENCODE_DISPATCH_MAP[{NUMBER}]` until the t
|
|
|
1226
1340
|
**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
1341
|
|
|
1228
1342
|
```bash
|
|
1343
|
+
# hold_merged_trail <issue> <trail-text> (forge#3156): the single escalation path for a merged issue whose phase
|
|
1344
|
+
# trail failed or could not be verified. Used by Step 4B item 0 and by the classifier's wake re-verification.
|
|
1345
|
+
# Order matters: the `needs-human` label is written FIRST (read-back verified, 3 tries) because the classifier
|
|
1346
|
+
# holds on merged + needs-human even before the comment exists; the comment follows (verified, 2 tries) and is
|
|
1347
|
+
# the audit record that lets a human release the hold. Returns 0 when the label hold is verified, 1 otherwise.
|
|
1348
|
+
hold_merged_trail() {
|
|
1349
|
+
local N="$1" TXT="$2" TRY HELD=1
|
|
1350
|
+
for TRY in 1 2 3; do
|
|
1351
|
+
gh issue edit "$N" -R {GH_REPO} --add-label "needs-human" >/dev/null 2>&1 || true # <!-- allowlist:check-command-side-effects -->
|
|
1352
|
+
if gh issue view "$N" -R {GH_REPO} --json labels --jq '[.labels[].name] | join(",")' 2>/dev/null \
|
|
1353
|
+
| grep -qE '(^|,)needs-human(,|$)'; then HELD=0; break; fi
|
|
1354
|
+
done
|
|
1355
|
+
[ "$HELD" -ne 0 ] && echo "PHASE TRAIL HOLD FAILED: #${N} — needs-human label could not be verified; dependents are NOT held. Hold them manually." >&2
|
|
1356
|
+
for TRY in 1 2; do
|
|
1357
|
+
# forge#3157: dedupe only against an ACTIVE escalation, so a re-escalation after a recorded release posts a new one.
|
|
1358
|
+
[ -n "${TRAIL_CACHE_DIR:-}" ] && rm -f "$TRAIL_CACHE_DIR/trail-$N"
|
|
1359
|
+
[ "$(trail_escalation_state "$N")" = "ACTIVE" ] && break
|
|
1360
|
+
gh issue comment "$N" -R {GH_REPO} --body "<!-- FORGE:PHASE_TRAIL_FAILED -->
|
|
1361
|
+
#${N} merged with an incomplete phase trail (or one that could not be verified), detected by /orchestrate.
|
|
1362
|
+
|
|
1363
|
+
\`\`\`
|
|
1364
|
+
${TXT}
|
|
1365
|
+
\`\`\`
|
|
1366
|
+
|
|
1367
|
+
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
|
|
1368
|
+
done
|
|
1369
|
+
[ -n "${TRAIL_CACHE_DIR:-}" ] && rm -f "$TRAIL_CACHE_DIR/trail-$N"
|
|
1370
|
+
return "$HELD"
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
# resolve_orch_login (forge#3168): prints the login of the identity that authors the orchestrator's own FAILED/RELEASED
|
|
1374
|
+
# marker comments, so trail_escalation_state can trust them even when the bot reports author_association NONE or
|
|
1375
|
+
# CONTRIBUTOR. Resolution order: FORGE_BOT_LOGIN env override, then the git identity (`git config user.name`, which for a
|
|
1376
|
+
# GitHub App is its `<app>[bot]` login). Never calls `/user` (403s for App tokens). An unresolved value prints nothing and
|
|
1377
|
+
# the caller fails closed: only the author_association set below is trusted, never "trust every author".
|
|
1378
|
+
resolve_orch_login() {
|
|
1379
|
+
local L="${FORGE_BOT_LOGIN:-}"
|
|
1380
|
+
[ -n "$L" ] || L=$(git config user.name 2>/dev/null || true)
|
|
1381
|
+
printf '%s' "$L"
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
# trail_escalation_state <issue> (forge#3157): prints ABSENT (no merged-trail escalation comment), ACTIVE (newest
|
|
1385
|
+
# FORGE:PHASE_TRAIL_FAILED comment id is greater than the newest FORGE:PHASE_TRAIL_RELEASED id), RELEASED (a human
|
|
1386
|
+
# release was recorded after the last escalation, so a later unrelated `needs-human` no longer gates), or UNREADABLE.
|
|
1387
|
+
# forge#3168: only TRUSTED comments count, for BOTH markers. Trusted = author_association OWNER/MEMBER/COLLABORATOR, or
|
|
1388
|
+
# `.user.login` equal to the orchestrator identity (resolve_orch_login, used only when non-empty). A marker string posted
|
|
1389
|
+
# by anyone else is ignored (treated as absent), so an outside commenter can neither forge a release nor a failure.
|
|
1390
|
+
# The comment read is cached per issue for the current monitoring cycle: the cache is a directory of files
|
|
1391
|
+
# (`$(...)` subshells lose shell arrays), `TRAIL_CACHE_DIR=$(mktemp -d)` and is recreated at the start of every
|
|
1392
|
+
# monitoring cycle (Step 4B item 6.6), so a human edit made between cycles is always seen. Only successful reads are cached.
|
|
1393
|
+
trail_escalation_state() {
|
|
1394
|
+
local N="$1" F="${TRAIL_CACHE_DIR:+$TRAIL_CACHE_DIR/trail-$1}" RAW OUT FAILED_ID RELEASED_ID RES
|
|
1395
|
+
if [ -n "$F" ] && [ -s "$F" ]; then cat "$F"; return; fi
|
|
1396
|
+
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
|
|
1397
|
+
echo "UNREADABLE"; return
|
|
1398
|
+
fi
|
|
1399
|
+
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
|
|
1400
|
+
echo "UNREADABLE"; return
|
|
1401
|
+
fi
|
|
1402
|
+
FAILED_ID=$(printf '%s\n' "$OUT" | awk '$1=="F"&&$2+0>m{m=$2+0}END{print m+0}')
|
|
1403
|
+
RELEASED_ID=$(printf '%s\n' "$OUT" | awk '$1=="R"&&$2+0>m{m=$2+0}END{print m+0}')
|
|
1404
|
+
if [ "$FAILED_ID" -eq 0 ]; then RES="ABSENT"
|
|
1405
|
+
elif [ "$RELEASED_ID" -gt "$FAILED_ID" ]; then RES="RELEASED"
|
|
1406
|
+
else RES="ACTIVE"; fi
|
|
1407
|
+
[ -n "$F" ] && printf '%s\n' "$RES" > "$F"
|
|
1408
|
+
echo "$RES"
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
# release_merged_trail <issue> (forge#3157): record that a human cleared a merged-trail hold, so the escalation
|
|
1412
|
+
# stops gating. Call only after classify_predecessor_state reported DONE for a merged issue whose escalation is ACTIVE.
|
|
1413
|
+
# Idempotent (no-op unless ACTIVE) and never run in dry-run. The comment must not contain the escalation marker text.
|
|
1414
|
+
release_merged_trail() {
|
|
1415
|
+
local N="$1"
|
|
1416
|
+
[ "${DRY_RUN:-false}" = "true" ] && return 0
|
|
1417
|
+
[ "$(trail_escalation_state "$N")" = "ACTIVE" ] || return 0
|
|
1418
|
+
gh issue comment "$N" -R {GH_REPO} --body "<!-- FORGE:PHASE_TRAIL_RELEASED -->
|
|
1419
|
+
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 -->
|
|
1420
|
+
[ -n "${TRAIL_CACHE_DIR:-}" ] && rm -f "$TRAIL_CACHE_DIR/trail-$N"
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
# classify_predecessor_state <issue> (forge#3168): the helpers hold_merged_trail, resolve_orch_login,
|
|
1424
|
+
# trail_escalation_state and release_merged_trail above MUST be declared in the SAME shell before this function runs
|
|
1425
|
+
# (reverify_merged_trail is nested below). It has exactly ONE guarded write (the DRY_RUN-guarded hold_merged_trail
|
|
1426
|
+
# call in the unverified-merged branch); every other path is read-only.
|
|
1229
1427
|
classify_predecessor_state() {
|
|
1230
1428
|
local PRED="$1"
|
|
1231
|
-
local PRED_INFO
|
|
1429
|
+
local PRED_INFO H
|
|
1430
|
+
for H in hold_merged_trail resolve_orch_login trail_escalation_state release_merged_trail; do
|
|
1431
|
+
# Fail loudly (stderr) but closed: a missing helper must never read as DONE.
|
|
1432
|
+
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; }
|
|
1433
|
+
done
|
|
1232
1434
|
# NOTE: the `workflow` array below deliberately also keeps `needs-human` — that label has NO
|
|
1233
1435
|
# `workflow:` prefix (see bin/labels.json), so a bare `select(startswith("workflow:"))` would
|
|
1234
1436
|
# drop it and the GATED branch's `needs-human` case would be dead code (forge#1812 primary case).
|
|
@@ -1238,10 +1440,62 @@ classify_predecessor_state() {
|
|
|
1238
1440
|
PRED_STATE=$(echo "$PRED_INFO" | jq -r '.state // "OPEN"')
|
|
1239
1441
|
PRED_LABELS=$(echo "$PRED_INFO" | jq -r '.workflow[]?' 2>/dev/null)
|
|
1240
1442
|
|
|
1443
|
+
# forge#3156: an empty/failed label read must never fall through to IN_PROGRESS/DONE for a merged issue. PRED_INFO
|
|
1444
|
+
# is '{}' on a failed read; re-read once, then fail closed (GATED) rather than guessing from absent labels.
|
|
1445
|
+
if [ "$PRED_INFO" = '{}' ]; then
|
|
1446
|
+
PRED_INFO=$(gh issue view "$PRED" -R {GH_REPO} --json labels,state \
|
|
1447
|
+
--jq '{state: .state, workflow: [.labels[].name | select(startswith("workflow:") or . == "needs-human")]}' 2>/dev/null || echo '{}')
|
|
1448
|
+
if [ "$PRED_INFO" = '{}' ]; then echo "GATED"; return; fi
|
|
1449
|
+
PRED_STATE=$(echo "$PRED_INFO" | jq -r '.state // "OPEN"')
|
|
1450
|
+
PRED_LABELS=$(echo "$PRED_INFO" | jq -r '.workflow[]?' 2>/dev/null)
|
|
1451
|
+
fi
|
|
1452
|
+
|
|
1453
|
+
# reverify_merged_trail: re-run the phase-trail verifier for a merged predecessor that has NO escalation
|
|
1454
|
+
# record (forge#3156), so wake reconstruction does not trust the `workflow:merged` label alone. Prints OK
|
|
1455
|
+
# when the trail verifies, UNVERIFIED otherwise. FORGE_ROOT comes from the Step 4A-pre/item 0 bootstrap; if
|
|
1456
|
+
# it is unresolved the verifier cannot run, so a predecessor already holding `needs-human` fails closed
|
|
1457
|
+
# (UNVERIFIED), while a plain merged one is trusted (OK) because item 0 verified it on its completion.
|
|
1458
|
+
reverify_merged_trail() {
|
|
1459
|
+
local P="$1" HAS_HUMAN="$2" RC
|
|
1460
|
+
if [ -z "${FORGE_ROOT:-}" ] || [ ! -f "$FORGE_ROOT/scripts/verify-phase-trail.sh" ]; then
|
|
1461
|
+
[ "$HAS_HUMAN" = "yes" ] && echo "UNVERIFIED" || echo "OK"; return
|
|
1462
|
+
fi
|
|
1463
|
+
bash "$FORGE_ROOT/scripts/verify-phase-trail.sh" "$P" -R {GH_REPO} --code-diff >/dev/null 2>&1; RC=$?
|
|
1464
|
+
[ "$RC" -eq 0 ] && echo "OK" || echo "UNVERIFIED"
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1241
1467
|
if echo "$PRED_LABELS" | grep -qx "workflow:invalid"; then
|
|
1242
1468
|
echo "FAILED"
|
|
1469
|
+
elif echo "$PRED_LABELS" | grep -qx "workflow:merged" && echo "$PRED_LABELS" | grep -qx "needs-human"; then
|
|
1470
|
+
# forge#3148: GATED when Step 4B item 0 escalated a merged issue with an incomplete phase trail
|
|
1471
|
+
# (the code is in the base branch, but /work-on cannot repair a merged issue, so a human must review it;
|
|
1472
|
+
# dependents wait until a human clears needs-human). Item 0 adds `needs-human` BEFORE posting the comment
|
|
1473
|
+
# (forge#3156), so merged + needs-human with no comment yet is either the in-flight window or a stale label:
|
|
1474
|
+
# re-verify the trail to tell them apart (clean trail => stale label => DONE). Unreadable comments fail closed.
|
|
1475
|
+
local TRAIL_ESC
|
|
1476
|
+
TRAIL_ESC=$(trail_escalation_state "$PRED")
|
|
1477
|
+
case "$TRAIL_ESC" in
|
|
1478
|
+
ABSENT) [ "$(reverify_merged_trail "$PRED" yes)" = "OK" ] && echo "DONE" || echo "GATED" ;;
|
|
1479
|
+
RELEASED) echo "DONE" ;; # forge#3157: a human release was recorded after the last escalation; this needs-human is unrelated
|
|
1480
|
+
*) echo "GATED" ;; # ACTIVE or UNREADABLE: fail closed
|
|
1481
|
+
esac
|
|
1243
1482
|
elif echo "$PRED_LABELS" | grep -qx "workflow:merged"; then
|
|
1244
|
-
|
|
1483
|
+
# forge#3156: no needs-human. A recorded escalation (ACTIVE or RELEASED) means a human already released the hold
|
|
1484
|
+
# (DONE, forge#3157: the sweep records it via release_merged_trail); otherwise re-verify the trail so a merged
|
|
1485
|
+
# predecessor that item 0 never got to escalate (orchestrator died mid-hold, wake reconstruction) is not trusted
|
|
1486
|
+
# on the label alone.
|
|
1487
|
+
case "$(trail_escalation_state "$PRED")" in
|
|
1488
|
+
ACTIVE|RELEASED) echo "DONE" ;;
|
|
1489
|
+
*)
|
|
1490
|
+
if [ "$(reverify_merged_trail "$PRED" no)" = "OK" ]; then
|
|
1491
|
+
echo "DONE"
|
|
1492
|
+
else
|
|
1493
|
+
# Unverified trail with no hold on record: establish it now so a human can release it.
|
|
1494
|
+
# Output is discarded (stdout is the classification), but a failed hold is reported on stderr, not swallowed.
|
|
1495
|
+
[ "${DRY_RUN:-false}" = "true" ] || 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
|
|
1496
|
+
echo "GATED"
|
|
1497
|
+
fi ;;
|
|
1498
|
+
esac
|
|
1245
1499
|
elif echo "$PRED_LABELS" | grep -qxE "needs-human|workflow:awaiting-merge"; then
|
|
1246
1500
|
echo "GATED"
|
|
1247
1501
|
elif echo "$PRED_LABELS" | grep -qx "workflow:engine-error"; then
|
|
@@ -1271,12 +1525,12 @@ classify_predecessor_state() {
|
|
|
1271
1525
|
}
|
|
1272
1526
|
```
|
|
1273
1527
|
|
|
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).
|
|
1528
|
+
- **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.
|
|
1529
|
+
- **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
1530
|
- **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
1531
|
- **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
1532
|
|
|
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.
|
|
1533
|
+
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
1534
|
|
|
1281
1535
|
**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
1536
|
|
|
@@ -1570,6 +1824,84 @@ done
|
|
|
1570
1824
|
|
|
1571
1825
|
**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
1826
|
|
|
1827
|
+
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):
|
|
1828
|
+
```bash
|
|
1829
|
+
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
1830
|
+
FORGE_ROOT=""
|
|
1831
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
|
|
1832
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
|
|
1833
|
+
_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")")"
|
|
1834
|
+
# 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)
|
|
1835
|
+
_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-)"
|
|
1836
|
+
_m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
|
|
1837
|
+
# '${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.
|
|
1838
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
|
|
1839
|
+
while IFS= read -r _c; do
|
|
1840
|
+
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
|
|
1841
|
+
done <<< "$_k"
|
|
1842
|
+
fi
|
|
1843
|
+
# forge#3102: only merged completions carry a full trail; invalid/decomposed/needs-human/awaiting-merge/paused do not.
|
|
1844
|
+
# Fail closed: a failed label read is an ERROR, never a skip.
|
|
1845
|
+
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
|
|
1846
|
+
if ! TRAIL_LABELS=$(gh issue view {NUMBER} -R {GH_REPO} --json labels --jq '[.labels[].name] | join(",")' 2>/dev/null); then
|
|
1847
|
+
TRAIL="PHASE_TRAIL: ERROR (could not read issue labels)"; TRAIL_RC=2 # infrastructure error, not a bypass (forge#3147)
|
|
1848
|
+
elif TRAIL_LABELS_READ_OK=1; ! echo ",$TRAIL_LABELS," | grep -q ',workflow:merged,'; then
|
|
1849
|
+
TRAIL="PHASE_TRAIL: SKIPPED (issue not workflow:merged — classified by label)"; TRAIL_RC=0
|
|
1850
|
+
elif [ -z "$FORGE_ROOT" ] || [ ! -f "$FORGE_ROOT/scripts/verify-phase-trail.sh" ]; then
|
|
1851
|
+
TRAIL="PHASE_TRAIL: ERROR (verify-phase-trail.sh not resolvable; set FORGEDOCK_HOME)"; TRAIL_RC=127 # fail closed
|
|
1852
|
+
else
|
|
1853
|
+
# forge#3123: a docs-only merged diff legitimately has no FORGE:QUALITY_GATE marker. Derive --docs-only from the
|
|
1854
|
+
# 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.
|
|
1855
|
+
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
|
|
1856
|
+
# forge#3133: the text search is fuzzy and unordered, so `.[0]` can be an unrelated docs-only PR. Evaluate EVERY
|
|
1857
|
+
# matching merged PR and set the flag only if at least one matched AND all matched diffs are non-empty and docs-only.
|
|
1858
|
+
# forge#3149: the search is a fuzzy prefilter; the jq test below keeps only PRs whose body has the exact token
|
|
1859
|
+
# "Closes #{NUMBER}" NOT followed by another digit (so #31490 never matches #3149). --limit 100 is a generous cap on
|
|
1860
|
+
# merged PRs mentioning one issue (a pipeline issue normally has one closing PR).
|
|
1861
|
+
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)
|
|
1862
|
+
if [ -n "$TRAIL_PRS" ]; then
|
|
1863
|
+
TRAIL_ALL_DOCS=1
|
|
1864
|
+
for TRAIL_PR in $TRAIL_PRS; do
|
|
1865
|
+
# forge#3145: both sides of a rename (filename + previous_filename); `gh pr diff --name-only` collapses a rename to its destination.
|
|
1866
|
+
TRAIL_FILES=$(gh api --paginate "repos/{GH_REPO}/pulls/${TRAIL_PR}/files" --jq '.[] | .filename, (.previous_filename // empty)' 2>/dev/null) || TRAIL_FILES=""
|
|
1867
|
+
# Fail closed on truncation: files API caps at 3000 files; at/over the cap or an unreadable count -> not docs-only.
|
|
1868
|
+
TRAIL_CHANGED_N=$(gh api "repos/{GH_REPO}/pulls/${TRAIL_PR}" --jq .changed_files 2>/dev/null) || TRAIL_CHANGED_N=""
|
|
1869
|
+
case "$TRAIL_CHANGED_N" in ''|*[!0-9]*) TRAIL_FILES="" ;; *) [ "$TRAIL_CHANGED_N" -ge 3000 ] && TRAIL_FILES="" ;; esac
|
|
1870
|
+
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
|
|
1871
|
+
done
|
|
1872
|
+
[ "$TRAIL_ALL_DOCS" = "1" ] && TRAIL_DOCS_FLAG="--docs-only"
|
|
1873
|
+
fi
|
|
1874
|
+
TRAIL=$(bash "$FORGE_ROOT/scripts/verify-phase-trail.sh" {NUMBER} -R {GH_REPO} $TRAIL_DOCS_FLAG 2>&1); TRAIL_RC=$?
|
|
1875
|
+
fi
|
|
1876
|
+
echo "$TRAIL"
|
|
1877
|
+
```
|
|
1878
|
+
Route on `TRAIL_RC` (forge#3147). Both non-zero cases fail closed: never `DONE`, never cascaded as a satisfied predecessor.
|
|
1879
|
+
- `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.
|
|
1880
|
+
- `TRAIL_RC=1` (`PHASE_TRAIL: FAIL`) → classify as a **bypass** (handling below).
|
|
1881
|
+
|
|
1882
|
+
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.
|
|
1883
|
+
|
|
1884
|
+
**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` unless the trail re-verifies clean; the label is written first, then the comment), 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):
|
|
1885
|
+
```bash
|
|
1886
|
+
# Only a merged issue is escalated here; an unmerged bypass is resumed by item 2 (see below).
|
|
1887
|
+
# Any non-zero rc on a merged issue escalates: rc 1 (missing phases) and rc >= 2 (unverifiable trail) alike,
|
|
1888
|
+
# because classify_predecessor_state() gates a merged predecessor only when this escalation comment exists.
|
|
1889
|
+
# forge#3156: also escalate when the label read itself failed: an unreadable issue cannot be proven unmerged, so
|
|
1890
|
+
# fail closed instead of posting nothing. forge#3168: key on TRAIL_LABELS_READ_OK, not an empty TRAIL_LABELS, so a
|
|
1891
|
+
# successfully read, label-less, non-merged issue is never escalated (read OK + empty labels => no escalation).
|
|
1892
|
+
if [ "$TRAIL_RC" -ne 0 ] && { [ "${TRAIL_LABELS_READ_OK:-0}" != "1" ] || echo ",${TRAIL_LABELS:-}," | grep -q ',workflow:merged,'; }; then
|
|
1893
|
+
if [ "${DRY_RUN:-false}" = "true" ]; then
|
|
1894
|
+
echo "[DRY-RUN] Would add needs-human and post FORGE:PHASE_TRAIL_FAILED on merged #{NUMBER}"
|
|
1895
|
+
else
|
|
1896
|
+
# hold_merged_trail (defined with classify_predecessor_state) writes `needs-human` FIRST, read-back verified
|
|
1897
|
+
# with retries, then the escalation comment (verified, retried). The label alone holds dependents, so there
|
|
1898
|
+
# is no window where a comment without the label reads as DONE. A failed verification is reported on stderr.
|
|
1899
|
+
hold_merged_trail {NUMBER} "${TRAIL}" || echo "PHASE TRAIL ESCALATION FAILED: #{NUMBER} — needs-human not verified." >&2
|
|
1900
|
+
fi
|
|
1901
|
+
fi
|
|
1902
|
+
```
|
|
1903
|
+
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.
|
|
1904
|
+
|
|
1573
1905
|
1. **Check if the agent completed the FULL pipeline** — not just one phase:
|
|
1574
1906
|
```bash
|
|
1575
1907
|
# Check final workflow state — workflow:merged, workflow:invalid, needs-human, and
|
|
@@ -1591,10 +1923,12 @@ done
|
|
|
1591
1923
|
resume=AGENT_ISSUE_MAP[{NUMBER}],
|
|
1592
1924
|
description="Resume #{NUMBER} pipeline",
|
|
1593
1925
|
run_in_background=true,
|
|
1594
|
-
prompt="
|
|
1926
|
+
prompt="{UNMODIFIED_STEP_4A_TEMPLATE_RENDERED_FOR_{NUMBER}}"
|
|
1595
1927
|
)
|
|
1596
1928
|
```
|
|
1597
1929
|
|
|
1930
|
+
**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.
|
|
1931
|
+
|
|
1598
1932
|
**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
1933
|
```bash
|
|
1600
1934
|
# Safety gate: only fall back when the engine run committed NOTHING — no branch, no PR, no
|
|
@@ -1784,7 +2118,7 @@ done
|
|
|
1784
2118
|
run_in_background=true,
|
|
1785
2119
|
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
2120
|
|
|
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.
|
|
2121
|
+
**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
2122
|
|
|
1789
2123
|
**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
2124
|
|
|
@@ -1836,33 +2170,43 @@ Do not ask the user questions — you are running autonomously in the background
|
|
|
1836
2170
|
if [ "$ALREADY_TRACKED" -eq 0 ]; then
|
|
1837
2171
|
gh issue comment "$DEP" -R {GH_REPO} --body "<!-- FORGE:BLOCKED_ON_HUMAN_MERGE -->
|
|
1838
2172
|
**Gating predecessor**: #${PRED} (state: \`${PRED_LABEL}\`${GATING_PR:+, open PR #${GATING_PR}})
|
|
1839
|
-
**Status**: This issue is ready to dispatch
|
|
2173
|
+
**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
2174
|
gh issue edit "$DEP" -R {GH_REPO} --add-label "blocked-on-human-merge" 2>/dev/null || true
|
|
1841
2175
|
fi
|
|
1842
2176
|
```
|
|
1843
2177
|
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
2178
|
|
|
1845
|
-
6.6. **
|
|
2179
|
+
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
2180
|
```bash
|
|
2181
|
+
# New per-cycle comment cache for trail_escalation_state (human edits between cycles are always re-read).
|
|
2182
|
+
[ -n "${TRAIL_CACHE_DIR:-}" ] && rm -rf "$TRAIL_CACHE_DIR"
|
|
2183
|
+
TRAIL_CACHE_DIR=$(mktemp -d)
|
|
1847
2184
|
WOKEN=$(gh issue list -R {GH_REPO} --state open --label "blocked-on-human-merge" --json number \
|
|
1848
2185
|
--jq '.[].number' 2>/dev/null || echo "")
|
|
1849
2186
|
for DEP in $WOKEN; do
|
|
1850
|
-
|
|
1851
|
-
--jq
|
|
1852
|
-
[ "$
|
|
2187
|
+
GATING_PREDS=$(gh api repos/{GH_REPO}/issues/${DEP}/comments \
|
|
2188
|
+
--jq '[.[] | select(.body | contains("FORGE:BLOCKED_ON_HUMAN_MERGE")) | (.body | capture("Gating predecessor\\*\\*: #(?<p>[0-9]+)").p)] | unique | .[]' 2>/dev/null || echo "")
|
|
2189
|
+
[ -n "$GATING_PREDS" ] || continue
|
|
2190
|
+
STILL_GATED=false
|
|
2191
|
+
for GPRED in $GATING_PREDS; do
|
|
2192
|
+
[ "$(classify_predecessor_state "$GPRED")" = "DONE" ] || STILL_GATED=true
|
|
2193
|
+
done
|
|
2194
|
+
[ "$STILL_GATED" = "false" ] || continue
|
|
1853
2195
|
# Idempotency: only dispatch if DEP hasn't already been dispatched by another path.
|
|
1854
2196
|
DEP_ALREADY_DISPATCHED=$(gh issue view "$DEP" -R {GH_REPO} --json labels \
|
|
1855
2197
|
--jq '[.labels[].name | select(startswith("workflow:"))] | length' 2>/dev/null || echo "0")
|
|
1856
2198
|
if [ "$DEP_ALREADY_DISPATCHED" -eq 0 ]; then
|
|
2199
|
+
# Record a human release of a trail-held merged predecessor so the escalation stops gating (decay).
|
|
2200
|
+
for GPRED in $GATING_PREDS; do release_merged_trail "$GPRED"; done
|
|
1857
2201
|
gh issue edit "$DEP" -R {GH_REPO} --remove-label "blocked-on-human-merge" 2>/dev/null || true
|
|
1858
2202
|
gh issue comment "$DEP" -R {GH_REPO} --body "<!-- FORGE:UNBLOCKED -->
|
|
1859
|
-
|
|
1860
|
-
echo "#{DEP} unblocked
|
|
2203
|
+
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.)"
|
|
2204
|
+
echo "#{DEP} unblocked (gating predecessors resolved) — dispatching immediately (Steps 4A.pre.0 → 4A.pre → 4A)."
|
|
1861
2205
|
# Add DEP to the same-response dispatch batch
|
|
1862
2206
|
fi
|
|
1863
2207
|
done
|
|
1864
2208
|
```
|
|
1865
|
-
|
|
2209
|
+
`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
2210
|
|
|
1867
2211
|
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
2212
|
|
|
@@ -1991,9 +2335,29 @@ Gating predecessor #${NUM} reached \`workflow:merged\` — dispatching now. (Was
|
|
|
1991
2335
|
RERESOLVE_ENABLED=$(yq '.orchestration.reresolve.enabled // true' forge.yaml 2>/dev/null || echo "true")
|
|
1992
2336
|
RERESOLVE_MAX_ROUNDS=$(yq '.orchestration.reresolve.max_rounds // "unbounded"' forge.yaml 2>/dev/null || echo "unbounded")
|
|
1993
2337
|
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
2338
|
+
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
2339
|
+
FORGE_ROOT=""
|
|
2340
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
|
|
2341
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
|
|
2342
|
+
_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")")"
|
|
2343
|
+
# 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)
|
|
2344
|
+
_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-)"
|
|
2345
|
+
_m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
|
|
2346
|
+
# '${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.
|
|
2347
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
|
|
2348
|
+
while IFS= read -r _c; do
|
|
2349
|
+
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
|
|
2350
|
+
done <<< "$_k"
|
|
2351
|
+
fi
|
|
2352
|
+
# Run ForgeDock's OWN orchestrate-canary.mjs from FORGE_ROOT, never from the consumer {REPO_PATH}.
|
|
2353
|
+
# Unresolved/missing => no result, so reResolve is treated as false for this cycle (fail closed, loudly).
|
|
2354
|
+
if [ -n "$FORGE_ROOT" ] && [ -r "$FORGE_ROOT/bin/engine/orchestrate-canary.mjs" ]; then
|
|
2355
|
+
node "$FORGE_ROOT/bin/engine/orchestrate-canary.mjs" \
|
|
2356
|
+
"$ORIGINATING_QUERY_KIND" "$ORIGINATING_QUERY_PATTERN" \
|
|
2357
|
+
"$RERESOLVE_ENABLED" "$RERESOLVE_MAX_ROUNDS" "$RERESOLVE_ROUNDS_SO_FAR"
|
|
2358
|
+
else
|
|
2359
|
+
echo "WARNING: ForgeDock install root unresolved or orchestrate-canary.mjs missing (set FORGEDOCK_HOME) — skipping re-resolution this cycle" >&2
|
|
2360
|
+
fi
|
|
1997
2361
|
```
|
|
1998
2362
|
|
|
1999
2363
|
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 +2367,33 @@ If the result's `reResolve` is `false` (off switch, or `RERESOLVE_ROUNDS_SO_FAR`
|
|
|
2003
2367
|
**Fold new matches through the existing admission path — never a bypass**:
|
|
2004
2368
|
|
|
2005
2369
|
```bash
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
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
|
+
# Import ForgeDock's OWN resolve.mjs from FORGE_ROOT, never from the consumer {REPO_PATH}.
|
|
2385
|
+
# Unresolved/missing => no output, so no new matches are folded this cycle (fail closed, loudly).
|
|
2386
|
+
if [ -n "$FORGE_ROOT" ] && [ -r "$FORGE_ROOT/bin/engine/resolve.mjs" ]; then
|
|
2387
|
+
node -e '
|
|
2388
|
+
import(require("node:url").pathToFileURL(process.argv[1]).href).then(({ foldNewMatches }) => {
|
|
2389
|
+
const reResolved = JSON.parse(process.argv[2]);
|
|
2390
|
+
const processed = JSON.parse(process.argv[3]);
|
|
2391
|
+
console.log(JSON.stringify(foldNewMatches(reResolved, processed)));
|
|
2392
|
+
}, () => process.exit(0));
|
|
2393
|
+
' "$FORGE_ROOT/bin/engine/resolve.mjs" "$RERESOLVED_NUMBERS_JSON" "$ALL_BATCH_ISSUE_NUMBERS_JSON"
|
|
2394
|
+
else
|
|
2395
|
+
echo "WARNING: ForgeDock install root unresolved or resolve.mjs missing (set FORGEDOCK_HOME) — re-resolved matches not folded this cycle" >&2
|
|
2396
|
+
fi
|
|
2013
2397
|
```
|
|
2014
2398
|
|
|
2015
2399
|
`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 +2570,38 @@ gh issue list -R {GH_REPO} --state open --search "label:review-finding created:>
|
|
|
2186
2570
|
--json number,title,body,createdAt
|
|
2187
2571
|
```
|
|
2188
2572
|
|
|
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
2573
|
```bash
|
|
2198
2574
|
# Run once for each completed issue before collecting its findings. Only merged
|
|
2199
2575
|
# units advance the denominator; invalid/skipped units do not imply delivered work.
|
|
2576
|
+
# The ratio observation for this unit is recorded AFTER its findings are counted, in the
|
|
2577
|
+
# "Amplification settle" block that follows the Step 4C rule loop (forge#3060) — recording it
|
|
2578
|
+
# here would lag by one unit and could never reach 1.0 at exactly one finding per merged unit.
|
|
2579
|
+
UNIT_MERGED_THIS_CYCLE=false
|
|
2200
2580
|
if gh issue view "$NUM" -R {GH_REPO} --json labels \
|
|
2201
2581
|
--jq '[.labels[].name | select(. == "workflow:merged")] | length' | grep -qx '1'; then
|
|
2202
2582
|
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
|
|
2583
|
+
UNIT_MERGED_THIS_CYCLE=true
|
|
2211
2584
|
fi
|
|
2212
2585
|
|
|
2213
2586
|
```
|
|
2214
2587
|
|
|
2588
|
+
**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.
|
|
2589
|
+
|
|
2590
|
+
**If review-finding issues were spawned:**
|
|
2591
|
+
|
|
2592
|
+
**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.
|
|
2593
|
+
|
|
2594
|
+
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.
|
|
2595
|
+
|
|
2596
|
+
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`.
|
|
2597
|
+
|
|
2598
|
+
**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.
|
|
2599
|
+
|
|
2600
|
+
**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.
|
|
2601
|
+
|
|
2602
|
+
**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.
|
|
2603
|
+
|
|
2604
|
+
|
|
2215
2605
|
**Cascade control (MANDATORY — run before folding findings into the DAG):**
|
|
2216
2606
|
|
|
2217
2607
|
For each spawned finding, determine whether it should be **executed** or **deferred**:
|
|
@@ -2224,7 +2614,9 @@ For each spawned finding, determine whether it should be **executed** or **defer
|
|
|
2224
2614
|
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
2615
|
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
2616
|
|
|
2227
|
-
**
|
|
2617
|
+
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).
|
|
2618
|
+
|
|
2619
|
+
**Defer** (do NOT add to the DAG) if rules 0, 1, 3, 4, 5, or 6 match.
|
|
2228
2620
|
|
|
2229
2621
|
**Execute** (add to the DAG) if:
|
|
2230
2622
|
- Rule 2 matches (P1 or P2) — AND rule 0 did not already match (rule 0 is checked first and overrides rule 2)
|
|
@@ -2516,6 +2908,65 @@ Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${R
|
|
|
2516
2908
|
AMPLIFICATION_DEFERRED+=("$FINDING_NUM")
|
|
2517
2909
|
fi
|
|
2518
2910
|
|
|
2911
|
+
# Rule 6 (forge#3060): on-by-default breaker over ALL cascade findings. Applies ONLY to
|
|
2912
|
+
# P3-and-below (P0/P1/P2 and untriaged/unparseable priorities are never gated by it) and NEVER
|
|
2913
|
+
# to security/billing-class findings (classifyBatchSafety != routine): those can neither batch
|
|
2914
|
+
# with routine work nor be individually dispatched by the sweep, so pausing them would starve
|
|
2915
|
+
# them; they keep their pre-existing individual triage path unchanged.
|
|
2916
|
+
# Evaluated live per finding. When a unit merged this cycle (UNIT_MERGED_THIS_CYCLE), its ratio
|
|
2917
|
+
# observation is not yet in the history (recorded by "Amplification settle"), so the live
|
|
2918
|
+
# unrounded ratio plus the last CONVERGENCE_WINDOW-1 recorded observations form the window.
|
|
2919
|
+
# Applied even when another rule already deferred the finding, and APPENDED to the reason, so
|
|
2920
|
+
# Step 4F's `*"amplification breaker"*` hold (checked FIRST) still catches it.
|
|
2921
|
+
if [ "$AMPLIFICATION_BREAKER" = "on" ] && echo "$PRIORITY" | grep -qE '^P[3-9]$'; then
|
|
2922
|
+
# Fail CLOSED: if the classifier cannot run (missing module, bad cwd), treat the finding as
|
|
2923
|
+
# non-routine so a security/billing finding is never paused. Import via {REPO_PATH} and pass
|
|
2924
|
+
# the text through the environment (argv text beginning with "-" is parsed as a node option).
|
|
2925
|
+
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
2926
|
+
FORGE_ROOT=""
|
|
2927
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
|
|
2928
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
|
|
2929
|
+
_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")")"
|
|
2930
|
+
# 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)
|
|
2931
|
+
_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-)"
|
|
2932
|
+
_m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
|
|
2933
|
+
# '${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.
|
|
2934
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
|
|
2935
|
+
while IFS= read -r _c; do
|
|
2936
|
+
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
|
|
2937
|
+
done <<< "$_k"
|
|
2938
|
+
fi
|
|
2939
|
+
# Import ForgeDock's OWN admission.mjs from FORGE_ROOT, never from the consumer {REPO_PATH}
|
|
2940
|
+
# (inert on non-ForgeDock repos, and would execute repo-controlled JS). Unresolved/missing => fail closed, loudly.
|
|
2941
|
+
BREAKER_SAFETY="unclassified"
|
|
2942
|
+
if [ -n "$FORGE_ROOT" ] && [ -r "$FORGE_ROOT/bin/engine/admission.mjs" ]; then
|
|
2943
|
+
BREAKER_SAFETY=$(FINDING_TEXT="$(echo "$FINDING_DATA" | jq -r '(.title // "") + "\n" + (.body // "")')" \
|
|
2944
|
+
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")
|
|
2945
|
+
else
|
|
2946
|
+
echo "WARNING: ForgeDock install root unresolved or admission.mjs missing (set FORGEDOCK_HOME) — breaker classifying #${FINDING_NUM} as unclassified (fail closed: not deferred)" >&2
|
|
2947
|
+
fi
|
|
2948
|
+
BREAKER_LIVE_TRIPPED="$AMPLIFICATION_BREAKER_TRIPPED"
|
|
2949
|
+
if [ "$BREAKER_LIVE_TRIPPED" = "false" ] && [ "$UNIT_MERGED_THIS_CYCLE" = "true" ] && [ "$MERGED_UNITS" -gt 0 ] && \
|
|
2950
|
+
awk "BEGIN { exit !($FINDINGS_SPAWNED / $MERGED_UNITS >= 1) }"; then
|
|
2951
|
+
if [ "$CONVERGENCE_WINDOW" -le 1 ] || \
|
|
2952
|
+
{ [ "${#AMPLIFICATION_RATIO_HISTORY[@]}" -ge $((CONVERGENCE_WINDOW - 1)) ] && \
|
|
2953
|
+
printf '%s\n' "${AMPLIFICATION_RATIO_HISTORY[@]: -$((CONVERGENCE_WINDOW - 1))}" | awk '$1 < 1 { exit 1 }'; }; then
|
|
2954
|
+
BREAKER_LIVE_TRIPPED=true
|
|
2955
|
+
AMPLIFICATION_BREAKER_TRIPPED=true
|
|
2956
|
+
fi
|
|
2957
|
+
fi
|
|
2958
|
+
if [ "$BREAKER_LIVE_TRIPPED" = "true" ] && [ "$BREAKER_SAFETY" = "routine" ]; then
|
|
2959
|
+
DEFER_REASON="${DEFER_REASON:+${DEFER_REASON}; }${BREAKER_DEFER_TAG} tripped (ratio >= 1.0 for ${CONVERGENCE_WINDOW} merged units) — held for bounded P3 batches"
|
|
2960
|
+
DEFER=true
|
|
2961
|
+
case " ${AMPLIFICATION_BREAKER_DEFERRED[*]} " in *" $FINDING_NUM "*) ;; *) AMPLIFICATION_BREAKER_DEFERRED+=("$FINDING_NUM") ;; esac
|
|
2962
|
+
# Route to the bounded P3 batch planner (never individual dispatch); dedupe so a finding
|
|
2963
|
+
# already added by the generation rule is not listed twice.
|
|
2964
|
+
if [ "$PRIORITY" = "P3" ]; then
|
|
2965
|
+
case " ${BATCHABLE_DEFERRED_P3[*]} " in *" $FINDING_NUM "*) ;; *) BATCHABLE_DEFERRED_P3+=("$FINDING_NUM") ;; esac
|
|
2966
|
+
fi
|
|
2967
|
+
fi
|
|
2968
|
+
fi
|
|
2969
|
+
|
|
2519
2970
|
if [ "$DEFER" = "true" ]; then
|
|
2520
2971
|
DEFERRED_FINDINGS+=($FINDING_NUM)
|
|
2521
2972
|
DEFERRED_REASONS[$FINDING_NUM]="$DEFER_REASON"
|
|
@@ -2526,6 +2977,30 @@ Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${R
|
|
|
2526
2977
|
done
|
|
2527
2978
|
```
|
|
2528
2979
|
|
|
2980
|
+
**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.
|
|
2981
|
+
|
|
2982
|
+
```bash
|
|
2983
|
+
if [ "$UNIT_MERGED_THIS_CYCLE" = "true" ]; then
|
|
2984
|
+
AMPLIFICATION_RATIO=$(awk "BEGIN { printf \"%.2f\", $FINDINGS_SPAWNED / $MERGED_UNITS }")
|
|
2985
|
+
AMPLIFICATION_RATIO_HISTORY+=("$(awk "BEGIN { printf \"%.6f\", $FINDINGS_SPAWNED / $MERGED_UNITS }")")
|
|
2986
|
+
if [ "${#AMPLIFICATION_RATIO_HISTORY[@]}" -ge "$CONVERGENCE_WINDOW" ]; then
|
|
2987
|
+
RECENT_RATIOS=("${AMPLIFICATION_RATIO_HISTORY[@]: -$CONVERGENCE_WINDOW}")
|
|
2988
|
+
if printf '%s\n' "${RECENT_RATIOS[@]}" | awk '$1 < 1 { exit 1 }'; then
|
|
2989
|
+
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."
|
|
2990
|
+
if [ "$AMPLIFICATION_BREAKER" = "on" ] && [ "$AMPLIFICATION_BREAKER_TRIPPED" = "false" ]; then
|
|
2991
|
+
AMPLIFICATION_BREAKER_TRIPPED=true
|
|
2992
|
+
# One operator report per trip: ratio and queue size, no per-finding spam.
|
|
2993
|
+
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"
|
|
2994
|
+
fi
|
|
2995
|
+
else
|
|
2996
|
+
# Newest observation fell below 1.0 — the cascade is converging again; release the breaker.
|
|
2997
|
+
AMPLIFICATION_BREAKER_TRIPPED=false
|
|
2998
|
+
fi
|
|
2999
|
+
fi
|
|
3000
|
+
fi
|
|
3001
|
+
UNIT_MERGED_THIS_CYCLE=false
|
|
3002
|
+
```
|
|
3003
|
+
|
|
2529
3004
|
**Concern-level batching for queued P3 findings (MANDATORY check before dispatch):** <!-- Added: forge#1818 -->
|
|
2530
3005
|
|
|
2531
3006
|
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 +3082,27 @@ for FINDING_NUM in "${BATCHING_CANDIDATES[@]}"; do
|
|
|
2607
3082
|
FINDING_FILE=$(echo "$FINDING_DATA" | jq -r '.body' | grep -oE '`[^`]+\.(py|tsx?|jsx?|sql|json|ya?ml|sh|md)`' | head -1 | tr -d '`')
|
|
2608
3083
|
[ -z "$FINDING_FILE" ] && continue
|
|
2609
3084
|
|
|
2610
|
-
|
|
3085
|
+
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
3086
|
+
FORGE_ROOT=""
|
|
3087
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
|
|
3088
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
|
|
3089
|
+
_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")")"
|
|
3090
|
+
# 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)
|
|
3091
|
+
_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-)"
|
|
3092
|
+
_m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
|
|
3093
|
+
# '${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.
|
|
3094
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
|
|
3095
|
+
while IFS= read -r _c; do
|
|
3096
|
+
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
|
|
3097
|
+
done <<< "$_k"
|
|
3098
|
+
fi
|
|
3099
|
+
# Import from ForgeDock's own root, never the consumer cwd; fail closed (non-routine) when unresolved.
|
|
3100
|
+
SAFETY_CLASS="unclassified"
|
|
3101
|
+
if [ -n "$FORGE_ROOT" ] && [ -r "$FORGE_ROOT/bin/engine/admission.mjs" ]; then
|
|
3102
|
+
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")
|
|
3103
|
+
else
|
|
3104
|
+
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
|
|
3105
|
+
fi
|
|
2611
3106
|
# Same file is not enough for security work: the class key prevents a
|
|
2612
3107
|
# credential finding from sharing a batch with injection/auth hardening.
|
|
2613
3108
|
SURFACE_KEY="${SAFETY_CLASS}:${FINDING_FILE}"
|
|
@@ -2619,7 +3114,7 @@ done
|
|
|
2619
3114
|
# the batch issue in QUEUED_FINDINGS so the dispatch step below never double-dispatches
|
|
2620
3115
|
# them. This is what makes SURFACE_BATCHED_FINDINGS a live control, not dead wiring. <!-- forge#1832, forge#1834 -->
|
|
2621
3116
|
for FILE in "${!SURFACE_FILE_MEMBERS[@]}"; do
|
|
2622
|
-
MEMBERS=(${SURFACE_FILE_MEMBERS[$FILE]})
|
|
3117
|
+
MEMBERS=($(printf '%s\n' ${SURFACE_FILE_MEMBERS[$FILE]} | sort -un)) # dedupe: a finding listed twice must not form a self-batch
|
|
2623
3118
|
[ "${#MEMBERS[@]}" -ge 2 ] || continue
|
|
2624
3119
|
|
|
2625
3120
|
SAFETY_CLASS=${FILE%%:*}
|
|
@@ -2831,7 +3326,7 @@ A finding that fails classify-lane is removed from `QUEUED_FINDINGS` (mirrors St
|
|
|
2831
3326
|
|
|
2832
3327
|
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
3328
|
|
|
2834
|
-
**If no review-finding issues were spawned:** Continue monitoring for the next agent completion.
|
|
3329
|
+
**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
3330
|
|
|
2836
3331
|
### Step 4C.5: Milestone lane-consistency check (periodic) <!-- Added: forge#901 -->
|
|
2837
3332
|
|
|
@@ -2992,12 +3487,19 @@ PERMANENT_DEFERRED=()
|
|
|
2992
3487
|
SWEEP_CANDIDATES=()
|
|
2993
3488
|
IDLE_DEFERRED=() # <!-- Added: forge#1814 -->
|
|
2994
3489
|
TOKEN_GATED=() # <!-- Added: forge#1858 -->
|
|
3490
|
+
: "${BREAKER_DEFER_TAG:=amplification breaker}" # default if this block runs in a fresh shell (an empty tag would match every defer reason)
|
|
3491
|
+
AMPLIFICATION_BREAKER_HELD=() # amplification-breaker holds: bounded P3 batches only, never individually dispatched (forge#3060)
|
|
2995
3492
|
|
|
2996
3493
|
for FINDING_NUM in "${DEFERRED_FINDINGS[@]}"; do
|
|
2997
3494
|
DEFER_REASON="${DEFERRED_REASONS[$FINDING_NUM]}"
|
|
2998
3495
|
|
|
3496
|
+
# Amplification-breaker holds (forge#3060) are checked FIRST: the breaker reason is appended to
|
|
3497
|
+
# whatever rule deferred the finding earlier (idle policy, generation, same-file, token budget),
|
|
3498
|
+
# and such a finding must never fall into a bucket whose re-evaluation dispatches it individually.
|
|
3499
|
+
if echo "$DEFER_REASON" | grep -qiF "$BREAKER_DEFER_TAG"; then
|
|
3500
|
+
AMPLIFICATION_BREAKER_HELD+=($FINDING_NUM)
|
|
2999
3501
|
# Generation >= 2 deferrals are PERMANENT — unbounded cascade prevention
|
|
3000
|
-
|
|
3502
|
+
elif echo "$DEFER_REASON" | grep -qi "generation"; then
|
|
3001
3503
|
PERMANENT_DEFERRED+=($FINDING_NUM)
|
|
3002
3504
|
# "Batch fully human-gated" deferrals (forge#1814) are their OWN bucket — they must NOT be
|
|
3003
3505
|
# re-evaluated by the file-overlap logic in Step 4F.2 below, because the reason they were
|
|
@@ -3020,7 +3522,7 @@ for FINDING_NUM in "${DEFERRED_FINDINGS[@]}"; do
|
|
|
3020
3522
|
fi
|
|
3021
3523
|
done
|
|
3022
3524
|
|
|
3023
|
-
echo "Completion sweep: ${#SWEEP_CANDIDATES[@]} re-evaluable, ${#PERMANENT_DEFERRED[@]} permanent, ${#IDLE_DEFERRED[@]} idle-gated, ${#TOKEN_GATED[@]} token-gated"
|
|
3525
|
+
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
3526
|
```
|
|
3025
3527
|
|
|
3026
3528
|
**Step 4F.2: Re-evaluate sweep candidates**
|
|
@@ -3030,6 +3532,7 @@ Re-run the Step 4C heuristics against the now-empty DAG. Since all original batc
|
|
|
3030
3532
|
```bash
|
|
3031
3533
|
SWEEP_EXECUTE=()
|
|
3032
3534
|
SWEEP_STILL_DEFERRED=()
|
|
3535
|
+
AMPLIFICATION_BREAKER_SWEEP_HELD=()
|
|
3033
3536
|
|
|
3034
3537
|
for FINDING_NUM in "${SWEEP_CANDIDATES[@]}"; do
|
|
3035
3538
|
FINDING_DATA=$(gh issue view $FINDING_NUM -R {GH_REPO} --json labels,title,body,state \
|
|
@@ -3051,6 +3554,17 @@ for FINDING_NUM in "${SWEEP_CANDIDATES[@]}"; do
|
|
|
3051
3554
|
"" | ltrimstr("priority:")')
|
|
3052
3555
|
TITLE=$(echo "$FINDING_DATA" | jq -r '.title')
|
|
3053
3556
|
|
|
3557
|
+
# Amplification-breaker deferrals (forge#3060) are NEVER individually dispatched by the sweep:
|
|
3558
|
+
# draining the DAG does not make a P3 cascade bounded. Leave them open for the bounded P3
|
|
3559
|
+
# batch sweep (planP3BatchGroups) and report them to the operator instead.
|
|
3560
|
+
case "${DEFERRED_REASONS[$FINDING_NUM]:-}" in
|
|
3561
|
+
*"$BREAKER_DEFER_TAG"*)
|
|
3562
|
+
SWEEP_STILL_DEFERRED+=($FINDING_NUM)
|
|
3563
|
+
AMPLIFICATION_BREAKER_SWEEP_HELD+=($FINDING_NUM)
|
|
3564
|
+
echo "Sweep: #${FINDING_NUM} held for P3 batching (amplification breaker) — not dispatched individually"
|
|
3565
|
+
continue ;;
|
|
3566
|
+
esac
|
|
3567
|
+
|
|
3054
3568
|
# Re-apply heuristics against the drained DAG (no active batch files)
|
|
3055
3569
|
# Comment/typo heuristic still applies — these are cosmetic regardless of DAG state
|
|
3056
3570
|
if echo "$TITLE" | grep -qi "comment\|typo"; then
|
|
@@ -3232,9 +3746,10 @@ The context-gathering phase can fetch this index to discover all investigation G
|
|
|
3232
3746
|
**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
3747
|
|
|
3234
3748
|
**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='{
|
|
3749
|
+
- **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.
|
|
3750
|
+
- For default repo issues: \`Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='${FINDING_NUM} --under-orchestration')\`
|
|
3751
|
+
- 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)
|
|
3752
|
+
- **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
3753
|
- NEVER bypass /work-on with manual git/gh commands — the label updates and structured comments are critical for tracking
|
|
3239
3754
|
- NEVER target \`main\` for PRs targeting the default repo. Use \`{STAGING_BRANCH}\` for fast-lane issues, or \`milestone/{slug}\` for milestone issues.
|
|
3240
3755
|
- Satellite repos (MCP, n8n) have no staging branch — fast-lane PRs go to \`main\` for those.
|
|
@@ -3244,20 +3759,20 @@ The context-gathering phase can fetch this index to discover all investigation G
|
|
|
3244
3759
|
- **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
3760
|
|
|
3246
3761
|
**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:
|
|
3762
|
+
After EVERY \`Skill(skill='{FORGE_SKILL_PREFIX}work-on', ...)\` call returns, immediately check the issue's current workflow label:
|
|
3248
3763
|
\`\`\`bash
|
|
3249
3764
|
gh issue view ${FINDING_NUM} -R {GH_REPO} --json labels --jq '[.labels[].name | select(startswith(\"workflow:\"))]'
|
|
3250
3765
|
\`\`\`
|
|
3251
3766
|
**Terminal labels** (only these allow you to stop): \`workflow:merged\`, \`workflow:invalid\`
|
|
3252
3767
|
**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.
|
|
3768
|
+
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
3769
|
|
|
3255
3770
|
**CRITICAL — SOURCE BRANCH DETECTION**:
|
|
3256
3771
|
- 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
|
|
3772
|
+
- 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\`
|
|
3773
|
+
- Investigation MUST use \`git show origin/<source-branch>:<filepath>\` to verify the code exists
|
|
3774
|
+
- Worktree MUST branch from \`origin/<source-branch>\`, NOT \`origin/main\`
|
|
3775
|
+
- PR target is \`<source-branch>\` (the fix goes back to where the code lives)
|
|
3261
3776
|
|
|
3262
3777
|
**LANE**: ${SWEEP_LANE[$FINDING_NUM]} (PR target: ${SWEEP_PR_BASE[$FINDING_NUM]})
|
|
3263
3778
|
**Issue title**: ${FINDING_TITLE}
|