forgedock 1.8.4 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/admission.mjs +23 -4
- package/bin/engine/admission.test.mjs +37 -0
- package/commands/orchestrate/phase-1-resolve.md +40 -24
- package/commands/orchestrate/phase-3-dependency.md +56 -11
- package/commands/orchestrate/phase-4-execution.md +349 -111
- package/commands/orchestrate/phase-5-cleanup.md +17 -0
- package/commands/orchestrate/phase-6-report.md +1 -1
- package/commands/quality-gate.md +51 -0
- package/commands/recover-orphans.md +3 -3
- package/commands/review-pr.md +87 -16
- package/commands/work-on/build/context.md +25 -4
- package/commands/work-on/build/validate.md +1 -0
- package/commands/work-on/build.md +26 -9
- package/commands/work-on/close.md +104 -40
- package/commands/work-on/decompose.md +13 -7
- package/commands/work-on/investigate.md +13 -7
- package/commands/work-on/remediate.md +15 -4
- package/commands/work-on/review.md +48 -19
- package/commands/work-on.md +62 -9
- package/docs/CODEX.md +12 -0
- package/docs/CONFIG.md +49 -1
- package/docs/RELEASE-NOTES.md +52 -0
- package/package.json +1 -1
- package/scripts/amplification-breaker.sh +89 -0
- package/scripts/amplification-breaker.test.sh +39 -0
- package/scripts/check-admission-parity.mjs +74 -1
- package/scripts/check-spec-markers.sh +67 -3
- package/scripts/classify-finding.sh +120 -0
- package/scripts/classify-finding.test.sh +74 -0
- package/scripts/close-dossier-no-main-commit.test.sh +139 -0
- package/scripts/forge-root.test.sh +84 -27
- package/scripts/lint-build-validate-files.sh +29 -0
- package/scripts/lint-build-validate-files.test.sh +25 -0
- package/scripts/lint-dispatch-prompt.test.sh +2 -1
- package/scripts/recovery-claim-live.sh +73 -0
- package/scripts/recovery-claim-live.test.sh +66 -0
- package/scripts/verify-phase-trail.sh +183 -4
- package/scripts/verify-phase-trail.test.sh +211 -1
|
@@ -82,7 +82,7 @@ release_orchestrator_lease() {
|
|
|
82
82
|
|
|
83
83
|
1. Enumerate every task id still tracked in `ENGINE_DISPATCH_MAP` whose issue has not yet reached a terminal `workflow:*` label (per Step 4B's `classify_predecessor_state()`).
|
|
84
84
|
2. Call `TaskStop(task_id)` (the harness tool) for each one, in the same turn, before ending the session.
|
|
85
|
-
3. Call `release_orchestrator_lease()` (Step 4A-pre.-1) so a future resume or a different operator is not blocked by a stale lease from this now-stopped session.
|
|
85
|
+
3. Call `cleanup_trail_cache` (Step 4B) and `release_orchestrator_lease()` (Step 4A-pre.-1) so a future resume or a different operator is not blocked by a stale lease from this now-stopped session.
|
|
86
86
|
4. Report which issues had in-flight dispatch stopped mid-pipeline (their `workflow:*` label will reflect whatever phase they reached — a future `/work-on {NUMBER}` or orchestrator resume picks them back up from GitHub state, per the Universal Phase Dispatcher in `commands/work-on.md`).
|
|
87
87
|
|
|
88
88
|
**This is a documentation/behavioral contract, not a bash block**: unlike the lease gate above, there is no shell construct that reliably intercepts "the orchestrator's own session is being stopped" — that is a harness-level event the orchestrating agent must handle in its own turn when it observes an interrupt, using its own tools (`TaskStop`), exactly the way `commands/orchestrate/phase-5-cleanup.md` already documents cleanup as an agent-driven procedure rather than a background script.
|
|
@@ -789,6 +789,48 @@ if [ -n "${FORGE_COORD_ISSUE:-}" ] && [ -n "${COORD_ISSUE_NUMBER:-}" ]; then
|
|
|
789
789
|
fi
|
|
790
790
|
```
|
|
791
791
|
|
|
792
|
+
**Recovery-claim dispatch gate (MANDATORY, before every individual dispatch)** <!-- Added: forge#3172 -->: a `/recover-orphans` sweep that is resuming an issue inline holds an issue-scoped `<!-- FORGE:RECOVERY_CLAIM -->` comment. Dispatching a worker underneath a live claim double-runs the issue. This gate is independent of the claims board above (it applies even when `FORGE_COORD_ISSUE` is unset) and uses the shared predicate `scripts/recovery-claim-live.sh` (unreleased claim, within `RECOVERY_CLAIM_TTL_MIN`, no `FORGE:RECOVERY_CLAIM_RELEASED` for its sweep id). It fails closed: `CLAIM: LIVE` and `CLAIM: ERROR` (exit 1 or 2, or an unresolvable script) both defer. A deferral writes nothing to GitHub (no `needs-human`, no label), the claim being transient. The issue goes into `DEFERRED_CONCURRENCY_ISSUES[]`, so it re-enters the candidate set on the next dispatch computation and is never dropped; report each deferral as `RECOVERY CLAIM DEFER` in the status update. Shell state does not persist between Bash calls, so the canonical `FORGE_ROOT` bootstrap is repeated in the block; if it resolves empty, every issue defers.
|
|
793
|
+
|
|
794
|
+
```bash
|
|
795
|
+
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
796
|
+
FORGE_ROOT=""
|
|
797
|
+
# Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
|
|
798
|
+
_h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
|
|
799
|
+
# Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
|
|
800
|
+
_mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
|
|
801
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
|
|
802
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
|
|
803
|
+
_l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
|
|
804
|
+
# Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
|
|
805
|
+
_cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
|
|
806
|
+
# newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
|
|
807
|
+
_v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
|
|
808
|
+
_m="$HOME/.claude/plugins/marketplaces/$_mk"
|
|
809
|
+
# '${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.
|
|
810
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
|
|
811
|
+
while IFS= read -r _c; do
|
|
812
|
+
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
|
|
813
|
+
done <<< "$_k"
|
|
814
|
+
fi
|
|
815
|
+
recovery_claim_blocks() {
|
|
816
|
+
local NUM="$1" OUT RC
|
|
817
|
+
if [ -z "${FORGE_ROOT:-}" ] || [ ! -f "$FORGE_ROOT/scripts/recovery-claim-live.sh" ]; then
|
|
818
|
+
echo "RECOVERY CLAIM DEFER: #${NUM} (recovery-claim-live.sh unresolved — failing closed)" >&2
|
|
819
|
+
return 0
|
|
820
|
+
fi
|
|
821
|
+
OUT=$(bash "$FORGE_ROOT/scripts/recovery-claim-live.sh" "$NUM" -R {GH_REPO}); RC=$?
|
|
822
|
+
[ "$RC" -eq 0 ] && return 1
|
|
823
|
+
echo "RECOVERY CLAIM DEFER: #${NUM} (${OUT}) — retried on the next dispatch computation"
|
|
824
|
+
return 0
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
# Same sites as claim_conflicts_with_live_holder: engine-first loop, Agent/task launch, Step 4B newly-ready (via Step 4A).
|
|
828
|
+
if recovery_claim_blocks "$NUM"; then
|
|
829
|
+
DEFERRED_CONCURRENCY_ISSUES+=("$NUM")
|
|
830
|
+
continue
|
|
831
|
+
fi
|
|
832
|
+
```
|
|
833
|
+
|
|
792
834
|
**Engine-first dispatch (default)**: When `forgedock` is in PATH, dispatch each ready issue via the durable engine rather than spawning prose Agent sub-agents. The engine's phase table enforces gate semantics in code — its fail-closed review gate and deterministic phase ordering are not subject to LLM interpretation.
|
|
793
835
|
|
|
794
836
|
**OpenCode dispatch (runtime-neutral adapter)**: When `FORGE_RUNTIME=opencode` or
|
|
@@ -925,6 +967,10 @@ if [ "$FORGEDOCK_AVAILABLE" = "true" ]; then
|
|
|
925
967
|
# instruction below re-checks immediately before each launch to close the remaining TOCTOU gap.
|
|
926
968
|
CLAIM_SAFE_DISPATCH=()
|
|
927
969
|
for NUM in "${DISPATCH_NOW[@]}"; do
|
|
970
|
+
if recovery_claim_blocks "$NUM"; then # forge#3172: live FORGE:RECOVERY_CLAIM (fails closed)
|
|
971
|
+
DEFERRED_CONCURRENCY_ISSUES+=("$NUM")
|
|
972
|
+
continue
|
|
973
|
+
fi
|
|
928
974
|
if [ -n "${FORGE_COORD_ISSUE:-}" ] && [ -n "${COORD_ISSUE_NUMBER:-}" ] && claim_conflicts_with_live_holder "$NUM"; then
|
|
929
975
|
DEFERRED_CLAIM_ISSUES+=("$NUM")
|
|
930
976
|
continue
|
|
@@ -969,7 +1015,7 @@ fi
|
|
|
969
1015
|
fi
|
|
970
1016
|
```
|
|
971
1017
|
|
|
972
|
-
**Dispatch each issue in `DISPATCH_NOW` via its own backgrounded `Bash` call (MANDATORY when `FORGEDOCK_AVAILABLE=true`) — never shell `&`/`wait`.** Immediately before each call, run `claim_conflicts_with_live_holder "{NUM}"`; if
|
|
1018
|
+
**Dispatch each issue in `DISPATCH_NOW` via its own backgrounded `Bash` call (MANDATORY when `FORGEDOCK_AVAILABLE=true`) — never shell `&`/`wait`.** Immediately before each call, run `recovery_claim_blocks "{NUM}"` and `claim_conflicts_with_live_holder "{NUM}"`; if either returns success, defer that issue rather than dispatching it. Issue one `Bash(...)` call per remaining issue in `DISPATCH_NOW`, all in the same message, so they run concurrently within the headroom already computed above:
|
|
973
1019
|
|
|
974
1020
|
```
|
|
975
1021
|
Bash(command="FORGE_GIST_CAPABLE=${FORGE_GIST_CAPABLE} forgedock run-issue {NUM} --lane {PR_BASE}", run_in_background=true, description="Engine-drive issue #{NUM}")
|
|
@@ -1054,6 +1100,7 @@ gh issue view {NUMBER} -R {GH_REPO} --json labels --jq '[.labels[].name | select
|
|
|
1054
1100
|
**Terminal condition also**: `needs-human` label present, `workflow:awaiting-merge` label present, OR issue state is `closed`
|
|
1055
1101
|
`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`).
|
|
1056
1102
|
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.
|
|
1103
|
+
**NEVER end a turn while the label is non-terminal.** Every `Skill(...)` call runs synchronously to completion in your own turn. Never end or yield your turn to wait for a background phase or a completion notification — notifications go to the orchestrator's root session, never to you, so a turn that ends non-terminal is never resumed. A turn that ends with a non-terminal label and no `*_RESULT:` block seen is a contract violation. A "running", backgrounded or empty return is not a result: re-read the labels and `FORGE:*` markers on GitHub and invoke `work-on` again immediately — never wait.
|
|
1057
1104
|
|
|
1058
1105
|
**CRITICAL — SOURCE BRANCH DETECTION**:
|
|
1059
1106
|
- If the issue has the `review-finding` label, read the issue body for `**Code branch**: \`{branch}\``
|
|
@@ -1099,14 +1146,20 @@ RENDERED_PROMPT=$(cat "$RENDERED_PROMPT_SRC"); rm -f "$RENDERED_PROMPT_SRC"
|
|
|
1099
1146
|
# immediately before that issue's Agent()/task() call. LINT_REFUSED_ISSUES is initialised once, empty.
|
|
1100
1147
|
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
1101
1148
|
FORGE_ROOT=""
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1149
|
+
# Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
|
|
1150
|
+
_h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
|
|
1151
|
+
# Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
|
|
1152
|
+
_mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
|
|
1153
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
|
|
1154
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
|
|
1155
|
+
_l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
|
|
1156
|
+
# Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
|
|
1157
|
+
_cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
|
|
1158
|
+
# newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
|
|
1159
|
+
_v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
|
|
1160
|
+
_m="$HOME/.claude/plugins/marketplaces/$_mk"
|
|
1108
1161
|
# '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
|
|
1109
|
-
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
|
|
1162
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
|
|
1110
1163
|
while IFS= read -r _c; do
|
|
1111
1164
|
case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
|
|
1112
1165
|
done <<< "$_k"
|
|
@@ -1311,7 +1364,7 @@ fi
|
|
|
1311
1364
|
echo "Dispatching ${#DISPATCH_NOW[@]} issue(s) this message (headroom was ${HEADROOM})"
|
|
1312
1365
|
```
|
|
1313
1366
|
|
|
1314
|
-
Immediately before every Claude `Agent()` or OpenCode `task()` call, run `
|
|
1367
|
+
Immediately before every Claude `Agent()` or OpenCode `task()` call, run `recovery_claim_blocks "{NUM}"` (success: append to `DEFERRED_CONCURRENCY_ISSUES[]`, forge#3172) and `claim_conflicts_with_live_holder "{NUM}"` (success: append the issue to `DEFERRED_CLAIM_ISSUES[]`); on either, do not make that call. Spawn `Agent()` for every remaining issue in `DISPATCH_NOW` (not the raw ready set) using the template above. After the batch spawn, increment `ACTIVE_DISPATCH_COUNT` by the number actually dispatched — this happens in addition to, not instead of, capturing each returned agent ID into `AGENT_ISSUE_MAP` above.
|
|
1315
1368
|
|
|
1316
1369
|
If `DISPATCH_NOW` is empty (headroom is 0), do not spawn any agents this cycle — wait for the next `agent_completed` notification, which frees a slot and re-triggers this dispatch computation (Step 4B).
|
|
1317
1370
|
|
|
@@ -1338,23 +1391,60 @@ For an OpenCode completion, retain `OPENCODE_DISPATCH_MAP[{NUMBER}]` until the t
|
|
|
1338
1391
|
|
|
1339
1392
|
**Successor dispatch latency is measured from `agent_completed`, not from orchestrator polling.** The moment you receive an `agent_completed` notification for issue N, that is t=0 for dispatching N's successors. Any successor whose predecessors are all now terminal MUST be dispatched in the same response that processes the notification — not after a poll cycle, not after a sleep. This is the design property that makes streaming DAG execution faster than wave-based execution. <!-- Added: forge#1251 -->
|
|
1340
1393
|
|
|
1394
|
+
**Cycle-start cache reset (MANDATORY — before item 0 (phase-trail verification), before item 5, and before Step 4B.5 stall detection)** <!-- Added: forge#3182 -->: at the very start of every monitoring cycle, call `init_trail_cache` once so no `classify_predecessor_state` call in this cycle (item 0 phase-trail verification, item 5/6 readiness checks, stall detection, item 6.6) reads the previous cycle's cached trail state. Re-declare `trail_cache_dir` / `init_trail_cache` in the same Bash block (same-shell rule as `classify_predecessor_state`) and run it before the first classify call. Item 6.6 does not reset the cache itself. `cleanup_trail_cache` removes the directory on every exit path (Termination condition, Step 4F, "Stopping the orchestrator").
|
|
1395
|
+
|
|
1341
1396
|
**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:
|
|
1342
1397
|
|
|
1343
1398
|
```bash
|
|
1399
|
+
# Trail cache lifecycle (forge#3182). Each Bash tool call is a fresh shell, so a persisted TRAIL_CACHE_DIR, a `$$`-keyed
|
|
1400
|
+
# path and a bare `rm -rf "$TRAIL_CACHE_DIR"` all differ or no-op per call (and a per-call `mktemp -d` leaks a directory every
|
|
1401
|
+
# cycle). The path is therefore derived from a STABLE key that every block can recompute without shell state: the numeric uid
|
|
1402
|
+
# plus the target repo (never `$$`; a pre-set TRAIL_CACHE_DIR / env value is deliberately ignored so `rm -rf` can never be
|
|
1403
|
+
# pointed at an arbitrary path). The dir is private (mode 700, owned by us, not a symlink) under a per-user base.
|
|
1404
|
+
# init_trail_cache resets it (call once at the TOP of each monitoring cycle, see "Cycle-start cache reset");
|
|
1405
|
+
# cleanup_trail_cache removes it on every orchestrator exit path. The cache holds only ABSENT/ACTIVE/RELEASED words.
|
|
1406
|
+
trail_cache_dir() {
|
|
1407
|
+
printf '%s' "${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/forge-trail-$(id -u)-$(printf '%s' "{GH_REPO}" | tr -c 'A-Za-z0-9._-' '_')"
|
|
1408
|
+
}
|
|
1409
|
+
init_trail_cache() {
|
|
1410
|
+
local D; D="$(trail_cache_dir)"
|
|
1411
|
+
case "$D" in */forge-trail-*) ;; *) echo "init_trail_cache: refusing unexpected path" >&2; return 1 ;; esac
|
|
1412
|
+
case "$D" in *..*) echo "init_trail_cache: refusing unexpected path" >&2; return 1 ;; esac
|
|
1413
|
+
rm -rf -- "$D"
|
|
1414
|
+
mkdir -m 700 -p -- "$D" && [ -O "$D" ] && [ ! -L "$D" ] || { echo "init_trail_cache: could not create a private cache dir" >&2; return 1; }
|
|
1415
|
+
TRAIL_CACHE_DIR="$D"
|
|
1416
|
+
}
|
|
1417
|
+
cleanup_trail_cache() {
|
|
1418
|
+
local D; D="$(trail_cache_dir)"
|
|
1419
|
+
case "$D" in */forge-trail-*) ;; *) return 0 ;; esac
|
|
1420
|
+
case "$D" in *..*) return 0 ;; esac
|
|
1421
|
+
rm -rf -- "$D"; unset TRAIL_CACHE_DIR; return 0
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1344
1424
|
# hold_merged_trail <issue> <trail-text> (forge#3156): the single escalation path for a merged issue whose phase
|
|
1345
1425
|
# trail failed or could not be verified. Used by Step 4B item 0 and by the classifier's wake re-verification.
|
|
1346
|
-
# Order matters (forge#
|
|
1347
|
-
#
|
|
1348
|
-
#
|
|
1349
|
-
#
|
|
1350
|
-
#
|
|
1351
|
-
#
|
|
1426
|
+
# Order matters (forge#3223): the `needs-human` label is written FIRST (read-back verified, 3 tries). Only when the
|
|
1427
|
+
# label is verified is the escalation comment posted (re-read immediately before each post, skipped when an ACTIVE
|
|
1428
|
+
# escalation already exists, verified ACTIVE by re-read after, 2 tries). A comment can therefore only exist after the label
|
|
1429
|
+
# did, so the merged + no-label + ACTIVE state (which classifies as a human release) is unreachable through this path.
|
|
1430
|
+
# If the label cannot be verified, NO comment is posted and the function returns 1. If the comment fails after a verified
|
|
1431
|
+
# label, the label hold stays (a labelled ABSENT hold classifies GATED, fail-closed). Returns 0 only when both halves are
|
|
1432
|
+
# verified; otherwise returns 1 and stderr names which half failed.
|
|
1352
1433
|
hold_merged_trail() {
|
|
1353
1434
|
local N="$1" TXT="$2" TRY COMMENTED=1 HELD=1
|
|
1435
|
+
for TRY in 1 2 3; do
|
|
1436
|
+
gh issue edit "$N" -R {GH_REPO} --add-label "needs-human" >/dev/null 2>&1 || true # <!-- allowlist:check-command-side-effects -->
|
|
1437
|
+
if gh issue view "$N" -R {GH_REPO} --json labels --jq '[.labels[].name] | join(",")' 2>/dev/null \
|
|
1438
|
+
| grep -qE '(^|,)needs-human(,|$)'; then HELD=0; break; fi
|
|
1439
|
+
done
|
|
1440
|
+
if [ "$HELD" -ne 0 ]; then
|
|
1441
|
+
echo "PHASE TRAIL HOLD FAILED: #${N} — needs-human label could not be verified; no escalation comment posted (a comment without the label would read as a released hold); dependents are NOT held. Hold them manually." >&2
|
|
1442
|
+
return 1
|
|
1443
|
+
fi
|
|
1354
1444
|
for TRY in 1 2; do
|
|
1355
1445
|
# forge#3157: dedupe only against an ACTIVE escalation, so a re-escalation after a recorded release posts a new one.
|
|
1356
1446
|
# forge#3169: re-read right before each post (cache entry removed) to narrow the concurrent-classifier duplicate window.
|
|
1357
|
-
|
|
1447
|
+
rm -f "$(trail_cache_dir)/trail-$N"
|
|
1358
1448
|
if [ "$(trail_escalation_state "$N")" = "ACTIVE" ]; then COMMENTED=0; break; fi
|
|
1359
1449
|
gh issue comment "$N" -R {GH_REPO} --body "<!-- FORGE:PHASE_TRAIL_FAILED -->
|
|
1360
1450
|
#${N} merged with an incomplete phase trail (or one that could not be verified), detected by /orchestrate.
|
|
@@ -1364,28 +1454,35 @@ ${TXT}
|
|
|
1364
1454
|
\`\`\`
|
|
1365
1455
|
|
|
1366
1456
|
The PR is already merged, so \`/work-on\` cannot re-run the missing phases. Review the merged change, then remove \`needs-human\` to release dependents held as \`blocked-on-human-merge\`." >/dev/null 2>&1 || true # <!-- allowlist:check-command-side-effects -->
|
|
1367
|
-
|
|
1457
|
+
rm -f "$(trail_cache_dir)/trail-$N"
|
|
1368
1458
|
if [ "$(trail_escalation_state "$N")" = "ACTIVE" ]; then COMMENTED=0; break; fi
|
|
1369
1459
|
done
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
| grep -qE '(^|,)needs-human(,|$)'; then HELD=0; break; fi
|
|
1374
|
-
done
|
|
1375
|
-
[ -n "${TRAIL_CACHE_DIR:-}" ] && rm -f "$TRAIL_CACHE_DIR/trail-$N"
|
|
1376
|
-
[ "$COMMENTED" -ne 0 ] && echo "PHASE TRAIL HOLD FAILED: #${N} — escalation comment could not be verified (audit record missing); hold dependents manually." >&2
|
|
1377
|
-
[ "$HELD" -ne 0 ] && echo "PHASE TRAIL HOLD FAILED: #${N} — needs-human label could not be verified; dependents are NOT held. Hold them manually." >&2
|
|
1378
|
-
[ "$COMMENTED" -eq 0 ] && [ "$HELD" -eq 0 ]
|
|
1460
|
+
rm -f "$(trail_cache_dir)/trail-$N"
|
|
1461
|
+
[ "$COMMENTED" -ne 0 ] && echo "PHASE TRAIL HOLD FAILED: #${N} — escalation comment could not be verified (audit record missing); the needs-human label hold stays (fail-closed)." >&2
|
|
1462
|
+
[ "$COMMENTED" -eq 0 ]
|
|
1379
1463
|
}
|
|
1380
1464
|
|
|
1381
|
-
# resolve_orch_login (forge#3168): prints the login of the identity that authors the orchestrator's own
|
|
1382
|
-
# marker comments, so trail_escalation_state can trust them even when the bot reports author_association
|
|
1383
|
-
# CONTRIBUTOR. Resolution order: FORGE_BOT_LOGIN env override, then the
|
|
1384
|
-
#
|
|
1385
|
-
#
|
|
1465
|
+
# resolve_orch_login (forge#3168, forge#3205): prints the login of the identity that authors the orchestrator's own
|
|
1466
|
+
# FAILED/RELEASED marker comments, so trail_escalation_state can trust them even when the bot reports author_association
|
|
1467
|
+
# NONE or CONTRIBUTOR. Resolution order: FORGE_BOT_LOGIN env override, then the per-cycle cached value, then the identity
|
|
1468
|
+
# gh actually authenticates as (GraphQL `viewer { login }`, which works for App tokens), then `git config user.name` as a
|
|
1469
|
+
# last resort (a commit-author setting that can differ from the posting identity). Never calls `/user` (403s for App
|
|
1470
|
+
# tokens). A non-empty result is cached in `orch-login` inside the owner-checked trail cache dir (a `$(...)` subshell
|
|
1471
|
+
# cannot keep a shell variable; `init_trail_cache` / `cleanup_trail_cache` remove it with the dir); an empty result is
|
|
1472
|
+
# never cached so it is retried. An unresolved value warns on stderr and prints nothing; the caller fails closed: only
|
|
1473
|
+
# the author_association set below is trusted, never "trust every author".
|
|
1386
1474
|
resolve_orch_login() {
|
|
1387
|
-
local L="${FORGE_BOT_LOGIN:-}"
|
|
1475
|
+
local L="${FORGE_BOT_LOGIN:-}" D C; D="$(trail_cache_dir)"; C="$D/orch-login"
|
|
1476
|
+
if [ -z "$L" ] && [ -d "$D" ] && [ -O "$D" ] && [ ! -L "$D" ] && [ -f "$C" ] && [ ! -L "$C" ] && [ -O "$C" ]; then
|
|
1477
|
+
L=$(head -n 1 "$C" 2>/dev/null || true)
|
|
1478
|
+
fi
|
|
1479
|
+
[ -n "$L" ] || L=$(gh api graphql -f query='{viewer{login}}' --jq '.data.viewer.login' 2>/dev/null || true)
|
|
1388
1480
|
[ -n "$L" ] || L=$(git config user.name 2>/dev/null || true)
|
|
1481
|
+
if [ -z "$L" ]; then
|
|
1482
|
+
echo "resolve_orch_login: could not resolve the orchestrator login (set FORGE_BOT_LOGIN); trusting only OWNER/MEMBER/COLLABORATOR trail markers" >&2
|
|
1483
|
+
elif [ -z "${FORGE_BOT_LOGIN:-}" ] && [ -d "$D" ] && [ -O "$D" ] && [ ! -L "$D" ] && [ ! -e "$C" ]; then
|
|
1484
|
+
printf '%s\n' "$L" > "$C" 2>/dev/null || true
|
|
1485
|
+
fi
|
|
1389
1486
|
printf '%s' "$L"
|
|
1390
1487
|
}
|
|
1391
1488
|
|
|
@@ -1393,33 +1490,57 @@ resolve_orch_login() {
|
|
|
1393
1490
|
# FORGE:PHASE_TRAIL_FAILED comment id is greater than the newest FORGE:PHASE_TRAIL_RELEASED id), RELEASED (a human
|
|
1394
1491
|
# release was recorded after the last escalation, so a later unrelated `needs-human` no longer gates), or UNREADABLE.
|
|
1395
1492
|
# forge#3168: only TRUSTED comments count, for BOTH markers. Trusted = author_association OWNER/MEMBER/COLLABORATOR, or
|
|
1396
|
-
# `.user.login` equal to the orchestrator identity (resolve_orch_login, used only when non-empty). A marker string posted
|
|
1493
|
+
# `.user.login` equal to the orchestrator identity (resolve_orch_login, used only when non-empty; compared case-insensitively). A marker string posted
|
|
1397
1494
|
# by anyone else is ignored (treated as absent), so an outside commenter can neither forge a release nor a failure.
|
|
1495
|
+
# forge#3207: MEMBER/COLLABORATOR association is not write access, so those authors additionally need admin/maintain/write on
|
|
1496
|
+
# `collaborators/<login>/permission` (OWNER and the orchestrator login pass directly; a failed lookup fails closed = untrusted).
|
|
1497
|
+
# forge#3181: both markers must also START the comment body (startswith, not contains), so a trusted comment that merely
|
|
1498
|
+
# quotes the marker text is not counted. hold_merged_trail / release_merged_trail emit the marker as the first line.
|
|
1398
1499
|
# The comment read is cached per issue for the current monitoring cycle: the cache is a directory of files
|
|
1399
|
-
# (`$(...)` subshells lose shell arrays)
|
|
1400
|
-
#
|
|
1500
|
+
# (`$(...)` subshells lose shell arrays) at the stable per-user/per-repo path from `trail_cache_dir` (created only by `init_trail_cache`; reads/writes skip it if it is absent or not owned by us), and is reset by
|
|
1501
|
+
# `init_trail_cache` at the TOP of every monitoring cycle (Step 4B "Cycle-start cache reset", before item 5 and stall
|
|
1502
|
+
# detection), so a human edit made between cycles is always seen. Only successful reads are cached.
|
|
1401
1503
|
trail_escalation_state() {
|
|
1402
|
-
local N="$1" F="$
|
|
1403
|
-
if [ -
|
|
1504
|
+
local N="$1" F="$(trail_cache_dir)/trail-$1" RAW OUT FAILED_ID RELEASED_ID RES
|
|
1505
|
+
if [ -s "$F" ] && [ -O "$F" ]; then cat "$F"; return; fi
|
|
1404
1506
|
if ! RAW=$(gh api --paginate "repos/{GH_REPO}/issues/${N}/comments" --jq '.[] | {id: .id, body: (.body // ""), login: (.user.login // ""), assoc: (.author_association // "")}' 2>/dev/null); then
|
|
1405
1507
|
echo "UNREADABLE"; return
|
|
1406
1508
|
fi
|
|
1407
|
-
|
|
1509
|
+
local ORCH; ORCH=$(resolve_orch_login)
|
|
1510
|
+
if ! OUT=$(printf '%s\n' "$RAW" | jq -r --arg L "$ORCH" 'select((.assoc | IN("OWNER","MEMBER","COLLABORATOR")) or ($L != "" and (.login | ascii_downcase) == ($L | ascii_downcase))) | if (.body | startswith("<!-- FORGE:PHASE_TRAIL_FAILED -->") and contains("merged with an incomplete phase trail")) then "F \(.id) \(.assoc) \(.login)" elif (.body | startswith("<!-- FORGE:PHASE_TRAIL_RELEASED -->")) then "R \(.id) \(.assoc) \(.login)" else empty end' 2>/dev/null); then
|
|
1408
1511
|
echo "UNREADABLE"; return
|
|
1409
1512
|
fi
|
|
1513
|
+
# forge#3207: MEMBER / COLLABORATOR association is NOT write access (read-only org members, triage/read collaborators).
|
|
1514
|
+
# Keep only marker rows whose author is OWNER, the orchestrator login, or has admin/maintain/write permission per
|
|
1515
|
+
# collaborators/<login>/permission (one lookup per login, memoised in $PERMS for this call). Fail closed: a failed lookup drops the row.
|
|
1516
|
+
local PERMS="" KEPT="" KIND KID KASSOC KLOGIN KP
|
|
1517
|
+
while read -r KIND KID KASSOC KLOGIN; do
|
|
1518
|
+
[ -n "$KID" ] || continue
|
|
1519
|
+
if [ "$KASSOC" = "OWNER" ] || { [ -n "$ORCH" ] && [ "$(printf '%s' "$KLOGIN" | tr '[:upper:]' '[:lower:]')" = "$(printf '%s' "$ORCH" | tr '[:upper:]' '[:lower:]')" ]; }; then
|
|
1520
|
+
KEPT="${KEPT}${KIND} ${KID}"$'\n'; continue
|
|
1521
|
+
fi
|
|
1522
|
+
KP=$(printf '%s\n' "$PERMS" | awk -v l="$KLOGIN" '$1==l{print $2; exit}')
|
|
1523
|
+
if [ -z "$KP" ]; then
|
|
1524
|
+
KP=$(gh api "repos/{GH_REPO}/collaborators/${KLOGIN}/permission" --jq '.permission' 2>/dev/null || true)
|
|
1525
|
+
[ -n "$KP" ] || KP="none"
|
|
1526
|
+
PERMS="${PERMS}${KLOGIN} ${KP}"$'\n'
|
|
1527
|
+
fi
|
|
1528
|
+
case "$KP" in admin|maintain|write) KEPT="${KEPT}${KIND} ${KID}"$'\n' ;; esac
|
|
1529
|
+
done <<< "$OUT"
|
|
1530
|
+
OUT="$KEPT"
|
|
1410
1531
|
FAILED_ID=$(printf '%s\n' "$OUT" | awk '$1=="F"&&$2+0>m{m=$2+0}END{print m+0}')
|
|
1411
1532
|
RELEASED_ID=$(printf '%s\n' "$OUT" | awk '$1=="R"&&$2+0>m{m=$2+0}END{print m+0}')
|
|
1412
1533
|
if [ "$FAILED_ID" -eq 0 ]; then RES="ABSENT"
|
|
1413
1534
|
elif [ "$RELEASED_ID" -gt "$FAILED_ID" ]; then RES="RELEASED"
|
|
1414
1535
|
else RES="ACTIVE"; fi
|
|
1415
|
-
[ -
|
|
1536
|
+
[ -d "$(trail_cache_dir)" ] && [ -O "$(trail_cache_dir)" ] && printf '%s\n' "$RES" > "$F"
|
|
1416
1537
|
echo "$RES"
|
|
1417
1538
|
}
|
|
1418
1539
|
|
|
1419
1540
|
# release_merged_trail <issue> (forge#3157): record that a human cleared a merged-trail hold, so the escalation
|
|
1420
1541
|
# stops gating. Call only after classify_predecessor_state reported DONE for a merged issue whose escalation is ACTIVE.
|
|
1421
|
-
# Only an ACTIVE escalation is released here: hold_merged_trail
|
|
1422
|
-
# human
|
|
1542
|
+
# Only an ACTIVE escalation is released here: hold_merged_trail writes the label before the comment (forge#3223), so an
|
|
1543
|
+
# ACTIVE escalation with no label only arises from a human removing needs-human, and a label-only (ABSENT) hold is not recorded (avoids a stray RELEASED marker).
|
|
1423
1544
|
# Idempotent (no-op unless ACTIVE) and never run in dry-run. The comment must not contain the escalation marker text.
|
|
1424
1545
|
release_merged_trail() {
|
|
1425
1546
|
local N="$1"
|
|
@@ -1427,7 +1548,8 @@ release_merged_trail() {
|
|
|
1427
1548
|
[ "$(trail_escalation_state "$N")" = "ACTIVE" ] || return 0
|
|
1428
1549
|
gh issue comment "$N" -R {GH_REPO} --body "<!-- FORGE:PHASE_TRAIL_RELEASED -->
|
|
1429
1550
|
The merged-trail hold on #${N} was released by a human (needs-human cleared or trail re-verified). Dependents held as \`blocked-on-human-merge\` are released. A later, unrelated \`needs-human\` on this merged issue no longer gates them." >/dev/null 2>&1 || true # <!-- allowlist:check-command-side-effects -->
|
|
1430
|
-
|
|
1551
|
+
rm -f "$(trail_cache_dir)/trail-$N"
|
|
1552
|
+
return 0
|
|
1431
1553
|
}
|
|
1432
1554
|
|
|
1433
1555
|
# classify_predecessor_state <issue> (forge#3168): the helpers hold_merged_trail, resolve_orch_login,
|
|
@@ -1470,14 +1592,20 @@ classify_predecessor_state() {
|
|
|
1470
1592
|
if [ -z "${FORGE_ROOT:-}" ]; then
|
|
1471
1593
|
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
1472
1594
|
FORGE_ROOT=""
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1595
|
+
# Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
|
|
1596
|
+
_h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
|
|
1597
|
+
# Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
|
|
1598
|
+
_mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
|
|
1599
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
|
|
1600
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
|
|
1601
|
+
_l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
|
|
1602
|
+
# Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
|
|
1603
|
+
_cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
|
|
1604
|
+
# newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
|
|
1605
|
+
_v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
|
|
1606
|
+
_m="$HOME/.claude/plugins/marketplaces/$_mk"
|
|
1479
1607
|
# '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
|
|
1480
|
-
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
|
|
1608
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
|
|
1481
1609
|
while IFS= read -r _c; do
|
|
1482
1610
|
case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
|
|
1483
1611
|
done <<< "$_k"
|
|
@@ -1496,8 +1624,8 @@ classify_predecessor_state() {
|
|
|
1496
1624
|
# forge#3148: GATED when Step 4B item 0 escalated a merged issue with an incomplete phase trail
|
|
1497
1625
|
# (the code is in the base branch, but /work-on cannot repair a merged issue, so a human must review it;
|
|
1498
1626
|
# dependents wait until a human clears needs-human). forge#3169: the hold is NOT keyed on the verifier. A passing
|
|
1499
|
-
# verifier does not mean "not held" (an agent-reported bypass verifies clean), and hold_merged_trail
|
|
1500
|
-
#
|
|
1627
|
+
# verifier does not mean "not held" (an agent-reported bypass verifies clean), and hold_merged_trail writes the
|
|
1628
|
+
# label before the comment (forge#3223), so merged + needs-human with no record is a partial hold: fail closed (GATED).
|
|
1501
1629
|
# A human release is the removal of needs-human, recorded by the release sweep via release_merged_trail.
|
|
1502
1630
|
local TRAIL_ESC
|
|
1503
1631
|
TRAIL_ESC=$(trail_escalation_state "$PRED")
|
|
@@ -1522,7 +1650,7 @@ classify_predecessor_state() {
|
|
|
1522
1650
|
else
|
|
1523
1651
|
# Unverified trail with no hold on record: establish it now so a human can release it.
|
|
1524
1652
|
# Output is discarded (stdout is the classification), but a failed hold is reported on stderr, not swallowed.
|
|
1525
|
-
|
|
1653
|
+
rm -f "$(trail_cache_dir)/trail-$PRED"
|
|
1526
1654
|
[ "${DRY_RUN:-false}" = "true" ] || [ "$(trail_escalation_state "$PRED")" != "ABSENT" ] || hold_merged_trail "$PRED" "PHASE_TRAIL: FAIL (re-verified at wake classification)" >/dev/null || echo "classify_predecessor_state: hold on #${PRED} could not be verified" >&2
|
|
1527
1655
|
echo "GATED"
|
|
1528
1656
|
fi ;;
|
|
@@ -1859,14 +1987,20 @@ done
|
|
|
1859
1987
|
```bash
|
|
1860
1988
|
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
1861
1989
|
FORGE_ROOT=""
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1990
|
+
# Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
|
|
1991
|
+
_h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
|
|
1992
|
+
# Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
|
|
1993
|
+
_mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
|
|
1994
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
|
|
1995
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
|
|
1996
|
+
_l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
|
|
1997
|
+
# Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
|
|
1998
|
+
_cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
|
|
1999
|
+
# newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
|
|
2000
|
+
_v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
|
|
2001
|
+
_m="$HOME/.claude/plugins/marketplaces/$_mk"
|
|
1868
2002
|
# '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
|
|
1869
|
-
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
|
|
2003
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
|
|
1870
2004
|
while IFS= read -r _c; do
|
|
1871
2005
|
case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
|
|
1872
2006
|
done <<< "$_k"
|
|
@@ -1914,7 +2048,7 @@ done
|
|
|
1914
2048
|
|
|
1915
2049
|
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.
|
|
1916
2050
|
|
|
1917
|
-
**Do NOT re-dispatch a merged issue** (forge#3148): `/work-on` Phase 0B stops on a closed or `workflow:merged` issue before its resume preflight, so a fresh agent exits without repairing anything and the predecessor would stay unresolved forever. The PR is already merged, so the gap can only be resolved by a human. Escalate it instead, which classifies it **GATED** (`classify_predecessor_state` holds `workflow:merged` + `needs-human` with no recorded release, regardless of the verifier; the
|
|
2051
|
+
**Do NOT re-dispatch a merged issue** (forge#3148): `/work-on` Phase 0B stops on a closed or `workflow:merged` issue before its resume preflight, so a fresh agent exits without repairing anything and the predecessor would stay unresolved forever. The PR is already merged, so the gap can only be resolved by a human. Escalate it instead, which classifies it **GATED** (`classify_predecessor_state` holds `workflow:merged` + `needs-human` with no recorded release, regardless of the verifier; the label is written and verified first, then the comment, which is posted only after the label is verified), so item 6.5 tracks its dependents as `blocked-on-human-merge` instead of dispatching them. A merged issue whose trail could not be read (`TRAIL_RC -ge 2`, handled above) is escalated by the same block, so it is gated the same way. A bypass reported by the agent on a `workflow:merged` issue whose trail verifies is handled the same way (set `TRAIL_RC=1` before this block):
|
|
1918
2052
|
```bash
|
|
1919
2053
|
# Only a merged issue is escalated here; an unmerged bypass is resumed by item 2 (see below).
|
|
1920
2054
|
# Any non-zero rc on a merged issue escalates: rc 1 (missing phases) and rc >= 2 (unverifiable trail) alike,
|
|
@@ -1926,10 +2060,10 @@ done
|
|
|
1926
2060
|
if [ "${DRY_RUN:-false}" = "true" ]; then
|
|
1927
2061
|
echo "[DRY-RUN] Would add needs-human and post FORGE:PHASE_TRAIL_FAILED on merged #{NUMBER}"
|
|
1928
2062
|
else
|
|
1929
|
-
# hold_merged_trail (defined with classify_predecessor_state)
|
|
1930
|
-
#
|
|
1931
|
-
# comment
|
|
1932
|
-
#
|
|
2063
|
+
# hold_merged_trail (defined with classify_predecessor_state) writes `needs-human` FIRST (read-back verified,
|
|
2064
|
+
# retried), then posts the escalation comment (re-read before posting, verified). If the label is not verified no
|
|
2065
|
+
# comment is posted. A merged + needs-human issue with no comment classifies GATED regardless of the verifier. A
|
|
2066
|
+
# failed half is reported on stderr and the operator must hold dependents manually when the label failed.
|
|
1933
2067
|
hold_merged_trail {NUMBER} "${TRAIL}" || echo "PHASE TRAIL ESCALATION FAILED: #{NUMBER} — comment or needs-human not verified." >&2
|
|
1934
2068
|
fi
|
|
1935
2069
|
fi
|
|
@@ -2154,7 +2288,7 @@ done
|
|
|
2154
2288
|
|
|
2155
2289
|
**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.
|
|
2156
2290
|
|
|
2157
|
-
**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`.
|
|
2291
|
+
**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`, `REREVIEW_REQUIRED`.
|
|
2158
2292
|
|
|
2159
2293
|
Do not ask the user questions — you are running autonomously in the background."
|
|
2160
2294
|
)
|
|
@@ -2163,6 +2297,28 @@ Do not ask the user questions — you are running autonomously in the background
|
|
|
2163
2297
|
fi
|
|
2164
2298
|
```
|
|
2165
2299
|
|
|
2300
|
+
**Re-review handoff (forge#3240)**: a remediation worker can finish with `REMEDIATE_RESULT: status: REREVIEW_REQUIRED` — the fix is pushed and CI is green, but the forked remediation had no sub-agent dispatch tool, so `review-pr` (which refuses to review inline) never ran. The issue is then at `workflow:in-review` WITHOUT `needs-human`, so the trigger above does not fire and the `ALREADY_REMEDIATED` guard (which counts any `FORGE:REMEDIATION`) is irrelevant. This is not a human gate. On the next completion-monitoring cycle, for the issue that just completed, detect it from the paper trail and run the re-review from THIS session (which has dispatch), exactly once:
|
|
2301
|
+
|
|
2302
|
+
```bash
|
|
2303
|
+
PRED="$NUM"
|
|
2304
|
+
REREVIEW_PR=$(gh pr list -R {GH_REPO} --state open --search "\"Closes #${PRED}\" in:body" \
|
|
2305
|
+
--json number --jq '.[0].number // empty' 2>/dev/null || echo "")
|
|
2306
|
+
if [ -n "$REREVIEW_PR" ]; then
|
|
2307
|
+
NEEDS_REREVIEW=$(gh api repos/{GH_REPO}/issues/${REREVIEW_PR}/comments --paginate \
|
|
2308
|
+
--jq '[.[] | select((.body | contains("FORGE:REMEDIATION:COMPLETE")) and (.body | contains("REREVIEW-REQUIRED")))] | length' 2>/dev/null | awk '{s+=$1} END {print s+0}')
|
|
2309
|
+
REREVIEW_DONE=$(gh api repos/{GH_REPO}/issues/${REREVIEW_PR}/comments --paginate \
|
|
2310
|
+
--jq '[.[] | select(.body | contains("FORGE:REREVIEW_DISPATCHED"))] | length' 2>/dev/null | awk '{s+=$1} END {print s+0}')
|
|
2311
|
+
if [ "$NEEDS_REREVIEW" -ge 1 ] && [ "$REREVIEW_DONE" -eq 0 ] && [ "${DRY_RUN:-false}" != "true" ]; then
|
|
2312
|
+
gh pr comment "$REREVIEW_PR" -R {GH_REPO} --body "<!-- FORGE:REREVIEW_DISPATCHED -->
|
|
2313
|
+
Re-review dispatched by the orchestrator: remediation had no sub-agent dispatch tool."
|
|
2314
|
+
# then, from this session:
|
|
2315
|
+
# Skill(skill="{FORGE_SKILL_PREFIX}review-pr", args="${REREVIEW_PR} --auto-merge --issue ${PRED} --base {BASE} --gh-flag -R {GH_REPO}")
|
|
2316
|
+
fi
|
|
2317
|
+
fi
|
|
2318
|
+
```
|
|
2319
|
+
|
|
2320
|
+
The `FORGE:REREVIEW_DISPATCHED` marker bounds this to a single re-review per PR (no loop). On `REVIEW_RESULT: status: COMPLETE` (merged), drive `work-on:close` and let item 6.6 wake dependents as normal; on any other result, review-pr's own verdict/labels (CHANGES REQUESTED, `needs-human` for genuine blockers) apply and the issue is `GATED` or failed per the existing rules. Never review inline.
|
|
2321
|
+
|
|
2166
2322
|
This satisfies #1809 Q2 (the orchestrator auto-dispatches remediation against the gated issue itself — the exact gap forge#1812's item 6.5/6.6 left open, since those items only ever track and wake *dependents*, never the gated PR's own remediation) — and closes forge#2243 (the gap that #1812's fix left open for *leaf* issues with no dependents: because this item's `$PRED` binding was never made explicit and self-contained, it was only ever reached while walking a dependent's predecessor list, so a `needs-human` issue that has no dependents never got remediation dispatched and its CHANGES-REQUESTED PR re-reviewed the same unchanged commit forever). The remediation agent's outcome is picked up on the **next** completion-monitoring cycle of this same Step 4B loop: if it lands (`workflow:merged`), item 6.6 below fires normally and wakes any `blocked-on-human-merge` dependents (if any exist — a leaf issue simply has none to wake); if it holds/re-escalates, the issue simply remains `GATED` and item 6.5 continues tracking its dependents (if any) unchanged.
|
|
2167
2323
|
|
|
2168
2324
|
6.5. **Handle predecessor gating** (`GATED` — `needs-human` or `workflow:awaiting-merge`) <!-- Added: forge#1812 --> — if a completed agent's issue classifies as `GATED`, its direct dependents are neither dispatched nor marked failed/skipped. For each direct dependent `DEP` of the gated predecessor `PRED`:
|
|
@@ -2212,15 +2368,14 @@ Do not ask the user questions — you are running autonomously in the background
|
|
|
2212
2368
|
|
|
2213
2369
|
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`:
|
|
2214
2370
|
```bash
|
|
2215
|
-
#
|
|
2216
|
-
|
|
2217
|
-
TRAIL_CACHE_DIR=$(mktemp -d)
|
|
2371
|
+
# The per-cycle trail cache was already reset by init_trail_cache at the top of this cycle (see "Cycle-start cache
|
|
2372
|
+
# reset" in Step 4B); do NOT reset it here, that would be after items 5-6 and stall detection already read it.
|
|
2218
2373
|
WOKEN=$(gh issue list -R {GH_REPO} --state open --label "blocked-on-human-merge" --json number \
|
|
2219
2374
|
--jq '.[].number' 2>/dev/null || echo "")
|
|
2220
2375
|
for DEP in $WOKEN; do
|
|
2221
2376
|
GATING_PREDS=$(gh api repos/{GH_REPO}/issues/${DEP}/comments \
|
|
2222
|
-
--jq '[.[] | select(.body | contains("FORGE:BLOCKED_ON_HUMAN_MERGE")) | (.body | capture("Gating predecessor\\*\\*: #(?<p>[0-9]+)").p)] | unique | .[]' 2>/dev/null || echo "")
|
|
2223
|
-
[ -n "$GATING_PREDS" ] || continue
|
|
2377
|
+
--jq '[.[] | select((.body // "") | contains("FORGE:BLOCKED_ON_HUMAN_MERGE")) | ((.body // "") | capture("Gating predecessor\\*\\*: #(?<p>[0-9]+)").p)] | unique | .[]' 2>/dev/null || echo "")
|
|
2378
|
+
[ -n "$GATING_PREDS" ] || { echo "WARNING: #${DEP} has blocked-on-human-merge but no parseable Gating predecessor line - leaving blocked" >&2; continue; }
|
|
2224
2379
|
STILL_GATED=false
|
|
2225
2380
|
for GPRED in $GATING_PREDS; do
|
|
2226
2381
|
[ "$(classify_predecessor_state "$GPRED")" = "DONE" ] || STILL_GATED=true
|
|
@@ -2230,17 +2385,36 @@ Do not ask the user questions — you are running autonomously in the background
|
|
|
2230
2385
|
DEP_ALREADY_DISPATCHED=$(gh issue view "$DEP" -R {GH_REPO} --json labels \
|
|
2231
2386
|
--jq '[.labels[].name | select(startswith("workflow:"))] | length' 2>/dev/null || echo "0")
|
|
2232
2387
|
if [ "$DEP_ALREADY_DISPATCHED" -eq 0 ]; then
|
|
2388
|
+
if [ "${DRY_RUN:-false}" = "true" ]; then echo "DRY_RUN: would release #${DEP} (remove blocked-on-human-merge, post FORGE:UNBLOCKED, dispatch)"; continue; fi
|
|
2389
|
+
# Durable marker FIRST (forge#3182 CONC-3): a crash between label removal and dispatch would otherwise strand DEP with no
|
|
2390
|
+
# hold label and no workflow:* label. Post FORGE:UNBLOCKED before removing the label (deduped against the last comment so a
|
|
2391
|
+
# retried cycle does not repeat it), so an interrupted release leaves durable evidence for a human or later recovery.
|
|
2392
|
+
LAST_DEP_COMMENT=$(gh issue view "$DEP" -R {GH_REPO} --json comments --jq '.comments[-1].body // ""' 2>/dev/null || echo "")
|
|
2393
|
+
case "$LAST_DEP_COMMENT" in "<!-- FORGE:UNBLOCKED -->"*) ;; *)
|
|
2394
|
+
gh issue comment "$DEP" -R {GH_REPO} --body "<!-- FORGE:UNBLOCKED -->
|
|
2395
|
+
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.)" >/dev/null 2>&1 || { echo "WARNING: #${DEP} UNBLOCKED marker not posted — not releasing; retrying next cycle." >&2; continue; } ;;
|
|
2396
|
+
esac
|
|
2397
|
+
# Remove the hold label and verify it is gone (forge#3182). Fail CLOSED: a failed edit OR a failed/empty `view` is
|
|
2398
|
+
# "unverified" — skip dispatch and retry next cycle (the view result is captured separately, never piped into grep).
|
|
2399
|
+
gh issue edit "$DEP" -R {GH_REPO} --remove-label "blocked-on-human-merge" >/dev/null 2>&1 \
|
|
2400
|
+
|| { echo "WARNING: #${DEP} label removal failed — not dispatching; retrying next cycle." >&2; continue; }
|
|
2401
|
+
if ! DEP_LABELS=$(gh issue view "$DEP" -R {GH_REPO} --json labels --jq '.labels[].name' 2>/dev/null) \
|
|
2402
|
+
|| printf '%s\n' "$DEP_LABELS" | grep -qx "blocked-on-human-merge"; then
|
|
2403
|
+
echo "WARNING: #${DEP} label removal unverified — not dispatching; retrying next cycle." >&2
|
|
2404
|
+
continue
|
|
2405
|
+
fi
|
|
2406
|
+
# Re-check after removal: another path may have labelled DEP workflow:* in the meantime.
|
|
2407
|
+
DEP_ALREADY_DISPATCHED=$(gh issue view "$DEP" -R {GH_REPO} --json labels \
|
|
2408
|
+
--jq '[.labels[].name | select(startswith("workflow:"))] | length' 2>/dev/null || echo "0")
|
|
2409
|
+
[ "$DEP_ALREADY_DISPATCHED" -eq 0 ] || continue
|
|
2233
2410
|
# Record a human release of a trail-held merged predecessor so the escalation stops gating (decay).
|
|
2234
2411
|
for GPRED in $GATING_PREDS; do release_merged_trail "$GPRED"; done
|
|
2235
|
-
gh issue edit "$DEP" -R {GH_REPO} --remove-label "blocked-on-human-merge" 2>/dev/null || true
|
|
2236
|
-
gh issue comment "$DEP" -R {GH_REPO} --body "<!-- FORGE:UNBLOCKED -->
|
|
2237
|
-
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.)"
|
|
2238
2412
|
echo "#{DEP} unblocked (gating predecessors resolved) — dispatching immediately (Steps 4A.pre.0 → 4A.pre → 4A)."
|
|
2239
2413
|
# Add DEP to the same-response dispatch batch
|
|
2240
2414
|
fi
|
|
2241
2415
|
done
|
|
2242
2416
|
```
|
|
2243
|
-
`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.
|
|
2417
|
+
`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 (reset at cycle start by `init_trail_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.
|
|
2244
2418
|
|
|
2245
2419
|
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:
|
|
2246
2420
|
|
|
@@ -2338,7 +2512,7 @@ Do not ask the user questions — you are running autonomously in the background
|
|
|
2338
2512
|
|
|
2339
2513
|
9. **Run staging integrity check** (from Step 4A-pre) if the completed agent merged a PR targeting staging.
|
|
2340
2514
|
|
|
2341
|
-
**Termination condition**: All issues in the DAG have reached `DONE` or `FAILED` (merged, invalid, or skipped due to dependency failure) — OR are `blocked-on-human-merge` (item 6.5) with no further dispatchable work remaining in the batch. These two outcomes are reported differently: a batch where every issue is `DONE`/`FAILED` is a **clean drain**; a batch where one or more issues remain `blocked-on-human-merge` is a **paused drain** — the active dispatch loop stops (there is nothing left to do until a human merges a gating PR) but this MUST be reported as paused, not as fully complete (see `phase-6-report.md`'s `🔗 Blocked-on-Merge` section). `needs-human` predecessors with no open PR are neither — they remain GATED indefinitely until either a PR appears (dependent moves to blocked-on-human-merge) or the predecessor itself resolves; do not treat isolated `needs-human` issues with no dependents as blocking termination. When either drain condition is met, check whether deferred review-spawned findings exist (accumulated in `DEFERRED_FINDINGS` during Step 4C). If deferred findings exist → proceed to Step 4F (Completion Sweep). If no deferred findings → **call `release_orchestrator_lease()` (Step 4A-pre.-1)** — this is a clean/paused drain of this session's own dispatch loop, so the lease should not be left held for a now-idle session — then proceed to Phase 5. <!-- Added: forge#2627 -->
|
|
2515
|
+
**Termination condition**: All issues in the DAG have reached `DONE` or `FAILED` (merged, invalid, or skipped due to dependency failure) — OR are `blocked-on-human-merge` (item 6.5) with no further dispatchable work remaining in the batch. These two outcomes are reported differently: a batch where every issue is `DONE`/`FAILED` is a **clean drain**; a batch where one or more issues remain `blocked-on-human-merge` is a **paused drain** — the active dispatch loop stops (there is nothing left to do until a human merges a gating PR) but this MUST be reported as paused, not as fully complete (see `phase-6-report.md`'s `🔗 Blocked-on-Merge` section). `needs-human` predecessors with no open PR are neither — they remain GATED indefinitely until either a PR appears (dependent moves to blocked-on-human-merge) or the predecessor itself resolves; do not treat isolated `needs-human` issues with no dependents as blocking termination. When either drain condition is met, check whether deferred review-spawned findings exist (accumulated in `DEFERRED_FINDINGS` during Step 4C). If deferred findings exist → proceed to Step 4F (Completion Sweep). If no deferred findings → **call `cleanup_trail_cache` (Step 4B) and `release_orchestrator_lease()` (Step 4A-pre.-1)** — this is a clean/paused drain of this session's own dispatch loop, so the lease should not be left held for a now-idle session — then proceed to Phase 5. <!-- Added: forge#2627 -->
|
|
2342
2516
|
|
|
2343
2517
|
**Reminder — this is the normal-exit lease release, distinct from the interrupted-stop procedure**: whether the drain is clean or paused, this session is no longer actively dispatching, so its lease must be released here (or, for a paused drain, at minimum have its heartbeat refresh stop — see Step 4A-pre.-1). This complements, but does not replace, the **Stopping the orchestrator** procedure (Step 4A-pre.-0.5) which handles the abnormal case of a mid-dispatch interrupt.
|
|
2344
2518
|
|
|
@@ -2371,14 +2545,20 @@ RERESOLVE_MAX_ROUNDS=$(yq '.orchestration.reresolve.max_rounds // "unbounded"' f
|
|
|
2371
2545
|
|
|
2372
2546
|
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
2373
2547
|
FORGE_ROOT=""
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2548
|
+
# Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
|
|
2549
|
+
_h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
|
|
2550
|
+
# Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
|
|
2551
|
+
_mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
|
|
2552
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
|
|
2553
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
|
|
2554
|
+
_l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
|
|
2555
|
+
# Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
|
|
2556
|
+
_cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
|
|
2557
|
+
# newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
|
|
2558
|
+
_v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
|
|
2559
|
+
_m="$HOME/.claude/plugins/marketplaces/$_mk"
|
|
2380
2560
|
# '${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.
|
|
2381
|
-
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
|
|
2561
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
|
|
2382
2562
|
while IFS= read -r _c; do
|
|
2383
2563
|
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
|
|
2384
2564
|
done <<< "$_k"
|
|
@@ -2403,14 +2583,20 @@ If the result's `reResolve` is `false` (off switch, or `RERESOLVE_ROUNDS_SO_FAR`
|
|
|
2403
2583
|
```bash
|
|
2404
2584
|
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
2405
2585
|
FORGE_ROOT=""
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2586
|
+
# Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
|
|
2587
|
+
_h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
|
|
2588
|
+
# Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
|
|
2589
|
+
_mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
|
|
2590
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
|
|
2591
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
|
|
2592
|
+
_l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
|
|
2593
|
+
# Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
|
|
2594
|
+
_cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
|
|
2595
|
+
# newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
|
|
2596
|
+
_v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
|
|
2597
|
+
_m="$HOME/.claude/plugins/marketplaces/$_mk"
|
|
2412
2598
|
# '${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.
|
|
2413
|
-
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
|
|
2599
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
|
|
2414
2600
|
while IFS= read -r _c; do
|
|
2415
2601
|
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
|
|
2416
2602
|
done <<< "$_k"
|
|
@@ -2484,21 +2670,36 @@ for NUM in {active_issue_numbers}; do
|
|
|
2484
2670
|
CURRENT_STATE=$(gh issue view $NUM -R {GH_REPO} --json labels \
|
|
2485
2671
|
--jq '[.labels[].name | select(startswith("workflow:"))] | .[0] // "unknown"')
|
|
2486
2672
|
|
|
2673
|
+
# Liveness check (worker-idle-no-live-child): a live child is a harness-tracked task for this
|
|
2674
|
+
# issue that has not reported completion (ENGINE_DISPATCH_MAP[NUM] / OPENCODE_DISPATCH_MAP[NUM]
|
|
2675
|
+
# task still running, or an Agent-spawned worker still executing a tool call), or a FORGE:HEARTBEAT
|
|
2676
|
+
# newer than STALL_TIMEOUT. Set CHILD_LIVE=true when any holds, else CHILD_LIVE=false.
|
|
2677
|
+
# No live child + non-terminal label = the worker yielded waiting for a notification that was
|
|
2678
|
+
# delivered to the root session; its background state is dead.
|
|
2679
|
+
|
|
2487
2680
|
if [ "$STALL_COUNT" -lt 2 ]; then
|
|
2488
|
-
# Auto-
|
|
2681
|
+
# Auto-recover: post stall annotation, then resume (live child) or relaunch fresh (no live child)
|
|
2489
2682
|
RESUME_ATTEMPT=$(( STALL_COUNT + 1 ))
|
|
2683
|
+
if [ "$CHILD_LIVE" = "true" ]; then STALL_CAUSE="worker-idle-child-live"; else STALL_CAUSE="worker-idle-no-live-child"; fi
|
|
2490
2684
|
gh issue comment $NUM -R {GH_REPO} --body "<!-- FORGE:STALL_DETECTED -->
|
|
2491
2685
|
## Stall Detected
|
|
2492
2686
|
|
|
2493
2687
|
**Issue**: #${NUM}
|
|
2494
2688
|
**Elapsed since last activity**: ${ELAPSED_MIN} min (threshold: ${STALL_TIMEOUT} min)
|
|
2495
2689
|
**Current workflow state**: ${CURRENT_STATE}
|
|
2690
|
+
**Cause**: ${STALL_CAUSE}
|
|
2496
2691
|
**Auto-resume attempt**: ${RESUME_ATTEMPT} of 2
|
|
2497
2692
|
**Timestamp**: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
2498
2693
|
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2694
|
+
if [ "$CHILD_LIVE" = "true" ]; then
|
|
2695
|
+
# Live child: resuming the same agent is safe — collect all resumes and launch in a single message (see Step 4B rule)
|
|
2696
|
+
# STALL_RESUME_LIST is accumulated and launched in parallel after the loop
|
|
2697
|
+
STALL_RESUME_LIST="$STALL_RESUME_LIST $NUM"
|
|
2698
|
+
else
|
|
2699
|
+
# No live child: do NOT resume (a resumed worker keeps its false "background build is running" belief).
|
|
2700
|
+
# STALL_FRESH_LIST is relaunched as a fresh worker after the loop.
|
|
2701
|
+
STALL_FRESH_LIST="$STALL_FRESH_LIST $NUM"
|
|
2702
|
+
fi
|
|
2502
2703
|
else
|
|
2503
2704
|
# 2+ prior stalls — auto-resume exhausted, escalate to needs-human
|
|
2504
2705
|
gh issue edit $NUM -R {GH_REPO} --add-label "needs-human"
|
|
@@ -2519,7 +2720,9 @@ done
|
|
|
2519
2720
|
# For each NUM in $STALL_RESUME_LIST, call Agent(resume=AGENT_ISSUE_MAP[NUM], run_in_background=true, ...)
|
|
2520
2721
|
```
|
|
2521
2722
|
|
|
2522
|
-
**Resume
|
|
2723
|
+
**Resume stalled agents that still have a live child** (`STALL_RESUME_LIST`) in a single message (parallel). Use the same `Agent(resume=...)` pattern as Step 4B — do not wait between individual resumes.
|
|
2724
|
+
|
|
2725
|
+
**Relaunch a fresh worker for every issue in `STALL_FRESH_LIST`** (cause `worker-idle-no-live-child`) instead of resuming. A fresh worker/agent is spawned through the exact Step 4A dispatch path for that issue (Agent / OpenCode `task()` / engine variant, same template) — all pipeline state lives in GitHub, so nothing is lost. Append this corrective note to the worker prompt: "The previous worker's background state is dead; no phase is running. Invoke `Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='{NUM} --under-orchestration')` now and run every phase synchronously to completion — never end your turn to wait for a notification." Before each relaunch run `recovery_claim_blocks "{NUM}"` and `claim_conflicts_with_live_holder "{NUM}"` exactly as in Step 4B (on either, defer instead of launching). Keep slot accounting symmetric: release the stale worker's slot (decrement `ACTIVE_DISPATCH_COUNT`, drop its `AGENT_ISSUE_MAP`/`ENGINE_DISPATCH_MAP`/`OPENCODE_DISPATCH_MAP` entry), then re-increment on the fresh launch and record the new id in the map. The `STALL_COUNT < 2` cap still applies to fresh relaunches.
|
|
2523
2726
|
|
|
2524
2727
|
**Track stall resume cycles separately** from completion-event resumes (Step 4B). If the same issue accumulates ≥ 2 `FORGE:STALL_DETECTED` comments AND still hasn't reached terminal state, do not resume again — the `needs-human` label is already set.
|
|
2525
2728
|
|
|
@@ -2631,6 +2834,28 @@ When `CASCADE_MAX_AMPLIFICATION` is not `off`, and the current ratio is greater
|
|
|
2631
2834
|
|
|
2632
2835
|
**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.
|
|
2633
2836
|
|
|
2837
|
+
**Deterministic breaker check (MANDATORY on every dispatch path — engine CLI, Agent-spawn fallback, or a hand-driven orchestrator):** the in-spec bookkeeping above only runs when the Step 4B/4C bash runs. A batch driven through the Agent-spawn fallback never evaluated it and reached about 2 findings per merge (2026-10-08 audit). Before dispatching ANY `review-finding` issue at `priority:P3` or below, and after every completed merge, run `scripts/amplification-breaker.sh` against GitHub state. Its exit code is authoritative, and it overrides an in-memory `AMPLIFICATION_BREAKER_TRIPPED=false`:
|
|
2838
|
+
|
|
2839
|
+
```bash
|
|
2840
|
+
AMP_SCRIPT=""
|
|
2841
|
+
for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}"; do
|
|
2842
|
+
case "$_c" in /*) [ -z "$AMP_SCRIPT" ] && [ -f "$_c/scripts/amplification-breaker.sh" ] && AMP_SCRIPT="$_c/scripts/amplification-breaker.sh" ;; esac
|
|
2843
|
+
done
|
|
2844
|
+
if [ "$AMPLIFICATION_BREAKER" != "off" ]; then
|
|
2845
|
+
if [ -n "$AMP_SCRIPT" ]; then
|
|
2846
|
+
AMP_LINE=$(bash "$AMP_SCRIPT" --since "$BATCH_T0" -R {GH_REPO}); AMP_RC=$?
|
|
2847
|
+
else
|
|
2848
|
+
AMP_LINE="AMPLIFICATION: script not resolvable — fail closed"; AMP_RC=4
|
|
2849
|
+
fi
|
|
2850
|
+
echo "$AMP_LINE" # include this line verbatim in the Step 4B/4C status update
|
|
2851
|
+
# rc 3 (tripped) or 4 (unreadable / unresolved): defer every P3-and-below finding under rule 6
|
|
2852
|
+
# into planP3BatchGroups() bounded batches. P1/P2 findings are admitted as before.
|
|
2853
|
+
[ "$AMP_RC" -eq 3 ] || [ "$AMP_RC" -eq 4 ] && AMPLIFICATION_BREAKER_TRIPPED=true
|
|
2854
|
+
fi
|
|
2855
|
+
```
|
|
2856
|
+
|
|
2857
|
+
A run that did not record `BATCH_T0` uses the coordination issue's creation time. An operator instruction such as "everything" or "pick up new issues" does not skip this check (see the override guardrail below).
|
|
2858
|
+
|
|
2634
2859
|
**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.
|
|
2635
2860
|
|
|
2636
2861
|
**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.
|
|
@@ -2952,20 +3177,26 @@ Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${R
|
|
|
2952
3177
|
# unrounded ratio plus the last CONVERGENCE_WINDOW-1 recorded observations form the window.
|
|
2953
3178
|
# Applied even when another rule already deferred the finding, and APPENDED to the reason, so
|
|
2954
3179
|
# Step 4F's `*"amplification breaker"*` hold (checked FIRST) still catches it.
|
|
2955
|
-
if [ "$AMPLIFICATION_BREAKER" = "on" ] &&
|
|
3180
|
+
if [ "$AMPLIFICATION_BREAKER" = "on" ] && [ "$(printf '%s' "$PRIORITY" | tr -d '[:space:]' | tr '[:lower:]' '[:upper:]')" = "P3" ]; then
|
|
2956
3181
|
# Fail CLOSED: if the classifier cannot run (missing module, bad cwd), treat the finding as
|
|
2957
3182
|
# non-routine so a security/billing finding is never paused. Import via {REPO_PATH} and pass
|
|
2958
3183
|
# the text through the environment (argv text beginning with "-" is parsed as a node option).
|
|
2959
3184
|
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
2960
3185
|
FORGE_ROOT=""
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
3186
|
+
# Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
|
|
3187
|
+
_h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
|
|
3188
|
+
# Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
|
|
3189
|
+
_mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
|
|
3190
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
|
|
3191
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
|
|
3192
|
+
_l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
|
|
3193
|
+
# Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
|
|
3194
|
+
_cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
|
|
3195
|
+
# newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
|
|
3196
|
+
_v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
|
|
3197
|
+
_m="$HOME/.claude/plugins/marketplaces/$_mk"
|
|
2967
3198
|
# '${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.
|
|
2968
|
-
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
|
|
3199
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
|
|
2969
3200
|
while IFS= read -r _c; do
|
|
2970
3201
|
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
|
|
2971
3202
|
done <<< "$_k"
|
|
@@ -3118,14 +3349,20 @@ for FINDING_NUM in "${BATCHING_CANDIDATES[@]}"; do
|
|
|
3118
3349
|
|
|
3119
3350
|
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
3120
3351
|
FORGE_ROOT=""
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3352
|
+
# Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
|
|
3353
|
+
_h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
|
|
3354
|
+
# Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
|
|
3355
|
+
_mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
|
|
3356
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
|
|
3357
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
|
|
3358
|
+
_l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
|
|
3359
|
+
# Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
|
|
3360
|
+
_cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
|
|
3361
|
+
# newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
|
|
3362
|
+
_v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
|
|
3363
|
+
_m="$HOME/.claude/plugins/marketplaces/$_mk"
|
|
3127
3364
|
# '${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.
|
|
3128
|
-
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
|
|
3365
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
|
|
3129
3366
|
while IFS= read -r _c; do
|
|
3130
3367
|
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
|
|
3131
3368
|
done <<< "$_k"
|
|
@@ -3800,6 +4037,7 @@ gh issue view ${FINDING_NUM} -R {GH_REPO} --json labels --jq '[.labels[].name |
|
|
|
3800
4037
|
**Terminal labels** (only these allow you to stop): \`workflow:merged\`, \`workflow:invalid\`
|
|
3801
4038
|
**Terminal condition also**: \`needs-human\` label present, \`workflow:awaiting-merge\` label present, OR issue state is \`closed\`
|
|
3802
4039
|
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.
|
|
4040
|
+
**NEVER end a turn while the label is non-terminal.** Every \`Skill(...)\` call runs synchronously to completion in your own turn. Never end or yield your turn to wait for a background phase or a completion notification — notifications go to the orchestrator's root session, never to you, so a turn that ends non-terminal is never resumed. A turn that ends with a non-terminal label and no \`*_RESULT:\` block seen is a contract violation. A \"running\", backgrounded or empty return is not a result: re-read the labels and \`FORGE:*\` markers on GitHub and invoke \`work-on\` again immediately — never wait.
|
|
3803
4041
|
|
|
3804
4042
|
**CRITICAL — SOURCE BRANCH DETECTION**:
|
|
3805
4043
|
- If the issue has the \`review-finding\` label, read the issue body for \`**Code branch**: \\\`{branch}\\\`\`
|
|
@@ -3834,7 +4072,7 @@ Completion Sweep Results:
|
|
|
3834
4072
|
Token-gated — still deferred: #{H} (sweep allowance also exhausted — re-evaluable next run)
|
|
3835
4073
|
```
|
|
3836
4074
|
|
|
3837
|
-
**After sweep agents complete** (or if no findings were dispatched): output the budget deferred-issues report (if applicable), **call `release_orchestrator_lease()` (Step 4A-pre.-1)** — the sweep is this session's last dispatch activity, so the lease must not be left held for a now-idle session (the Termination condition's own release call, above, only fires on the no-deferred-findings branch; this is the equivalent release for the deferred-findings/Completion Sweep branch) <!-- Added: forge#2627 -->, then proceed to Phase 5.
|
|
4075
|
+
**After sweep agents complete** (or if no findings were dispatched): output the budget deferred-issues report (if applicable), **call `cleanup_trail_cache` (Step 4B) and `release_orchestrator_lease()` (Step 4A-pre.-1)** — the sweep is this session's last dispatch activity, so the lease must not be left held for a now-idle session (the Termination condition's own release call, above, only fires on the no-deferred-findings branch; this is the equivalent release for the deferred-findings/Completion Sweep branch) <!-- Added: forge#2627 -->, then proceed to Phase 5.
|
|
3838
4076
|
|
|
3839
4077
|
**Anti-patterns — DO NOT DO THIS:**
|
|
3840
4078
|
- Re-sweeping findings spawned during the sweep itself — this creates unbounded recursion. Sweep is a single pass.
|