forgedock 1.8.3 → 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.
Files changed (38) hide show
  1. package/bin/engine/admission.mjs +23 -4
  2. package/bin/engine/admission.test.mjs +37 -0
  3. package/commands/orchestrate/phase-1-resolve.md +40 -24
  4. package/commands/orchestrate/phase-3-dependency.md +56 -11
  5. package/commands/orchestrate/phase-4-execution.md +351 -111
  6. package/commands/orchestrate/phase-5-cleanup.md +17 -0
  7. package/commands/orchestrate/phase-6-report.md +1 -1
  8. package/commands/quality-gate.md +51 -0
  9. package/commands/recover-orphans.md +3 -3
  10. package/commands/review-pr.md +92 -19
  11. package/commands/work-on/build/context.md +25 -4
  12. package/commands/work-on/build/validate.md +1 -0
  13. package/commands/work-on/build.md +29 -9
  14. package/commands/work-on/close.md +107 -40
  15. package/commands/work-on/decompose.md +16 -7
  16. package/commands/work-on/investigate.md +16 -7
  17. package/commands/work-on/remediate.md +18 -4
  18. package/commands/work-on/review.md +51 -19
  19. package/commands/work-on.md +68 -9
  20. package/docs/CODEX.md +12 -0
  21. package/docs/CONFIG.md +49 -1
  22. package/docs/RELEASE-NOTES.md +52 -0
  23. package/package.json +1 -1
  24. package/scripts/amplification-breaker.sh +89 -0
  25. package/scripts/amplification-breaker.test.sh +39 -0
  26. package/scripts/check-admission-parity.mjs +74 -1
  27. package/scripts/check-spec-markers.sh +67 -3
  28. package/scripts/classify-finding.sh +120 -0
  29. package/scripts/classify-finding.test.sh +74 -0
  30. package/scripts/close-dossier-no-main-commit.test.sh +139 -0
  31. package/scripts/forge-root.test.sh +84 -27
  32. package/scripts/lint-build-validate-files.sh +29 -0
  33. package/scripts/lint-build-validate-files.test.sh +25 -0
  34. package/scripts/lint-dispatch-prompt.test.sh +2 -1
  35. package/scripts/recovery-claim-live.sh +73 -0
  36. package/scripts/recovery-claim-live.test.sh +66 -0
  37. package/scripts/verify-phase-trail.sh +183 -4
  38. package/scripts/verify-phase-trail.test.sh +211 -1
@@ -25,6 +25,8 @@ Orchestrator for the full issue lifecycle: investigate → decompose (if needed)
25
25
 
26
26
  3. **Follow the Phase Dispatcher.** Do not skip, reorder, or treat an intermediate completion as terminal. Only the terminal states listed in the Dispatcher allow stopping. Between phases: no narration, no summary, no end of turn — invoke the next phase immediately.
27
27
 
28
+ 3a. **Phase calls run synchronously to completion in your own turn.** Every phase `Skill(...)` call is consumed by you, in the turn that issued it. Never end or yield your turn, narrate that you are waiting, or "wait for the notification" — completion notifications for forked phases are delivered to the root session, never to this worker, so a turn that ends while a phase is pending is never resumed. A non-final, "running", backgrounded or empty return that carries no `*_RESULT:` block is not a phase result: re-read the issue's labels and `FORGE:*` markers on GitHub (consume a present marker instead of re-invoking), then re-invoke the router or the same phase at most 2 times per phase. After the cap, stop with BLOCKED / the existing GATE_FAILURE path (needs-human via the existing exit) using `child-stalled: <phase>`; a build `BLOCKED` with `child-stalled:` is terminal and is not re-invoked. While the issue is non-terminal, keep working until a terminal state is reached.
29
+
28
30
  4. **PRs NEVER target `main`.** Target `staging` (fast lane) or `milestone/{slug}` (feature lane). The router computes and validates the target (Lane Resolution) and passes it to the phases as `--base`.
29
31
 
30
32
  5. **`needs-human` is for genuine decisions and external actions only.** A phase that can fix its own problem (failing checks, review findings, a moved PR head, a missing marker it can produce) does so inside its own fork; the router never adds `needs-human` for a condition a phase reported as fixable.
@@ -219,6 +221,8 @@ Extract project prefix and issue number. If `next`/`pick`: list open issues sort
219
221
 
220
222
  **Resolve `UNDER_ORCHESTRATION`**: `true` if the invocation args contain `--under-orchestration`, else `false`. This is a single parse done once, here — every later gated block (heartbeats) just checks this variable, no re-parsing.
221
223
 
224
+ **Resolve `RECOVERY_SWEEP_ID`**: the value after `--recovery-sweep` in the invocation args, else empty (consumed by 0A.4; only `/recover-orphans` passes it).
225
+
222
226
  **Optional pre-flight**: Before committing to the full pipeline, run `/scope {NUMBER}` to get a complexity estimate (affected files, blast radius, risk flags, and decomposition recommendation). Especially useful for large or ambiguous issues.
223
227
 
224
228
  ### 0A.1: Remediation Mode Detection (`--remediate`) <!-- Added: forge#1813 -->
@@ -249,12 +253,55 @@ If detected, dispatch immediately and STOP — do NOT fall through to Phase 0B's
249
253
  Skill(skill="{FORGE_SKILL_PREFIX}work-on:remediate", args="${REMEDIATE_PR_NUMBER} ${REMEDIATE_ISSUE_FLAG} --repo {GH_REPO} --gh-flag {GH_FLAG}")
250
254
  ```
251
255
 
252
- **After `REMEDIATE_RESULT` returns, STOP unconditionally** — do not run any further Phase 0–7 logic in this file. `work-on/remediate.md` is self-contained: a FIXABLE remediation replaces `needs-human` with the active `workflow:in-review` state only while it is running, then ends at `workflow:merged`, `workflow:awaiting-merge`, or a newly asserted `needs-human` label. When `re_gate_outcome: AUTO-LANDED`, it drives its own close phase internally (Phase M8 invokes `Skill("{FORGE_SKILL_PREFIX}work-on:close", ...)` directly) before returning. For every other outcome (`HELD-AWAITING-MERGE`, `RE-ESCALATED`, `UNFIXABLE`, `BLOCKED`, `ALREADY_DONE`), the issue is already at a terminal state (`workflow:awaiting-merge` or `needs-human`, or already closed) per the Universal Phase Dispatcher — nothing further to do.
256
+ **If `REMEDIATE_RESULT: status: REREVIEW_REQUIRED`** (forge#3240: fix pushed, but the forked remediation had no sub-agent dispatch tool): run the re-review from this top-level session, which has dispatch — `Skill(skill="{FORGE_SKILL_PREFIX}review-pr", args="${REMEDIATE_PR_NUMBER} --auto-merge --issue ${ISSUE} --base ${PR_BASE} --gh-flag {GH_FLAG}")` (`ISSUE` and `PR_BASE` from the result and the PR) — never review inline. If it returns `REVIEW_RESULT: status: COMPLETE` (merged), invoke `Skill("{FORGE_SKILL_PREFIX}work-on:close", ...)` as remediate M8 does for `AUTO-LANDED`. Then STOP.
257
+
258
+ **After `REMEDIATE_RESULT` returns (any other status), STOP unconditionally** — do not run any further Phase 0–7 logic in this file. `work-on/remediate.md` is self-contained: a FIXABLE remediation replaces `needs-human` with the active `workflow:in-review` state only while it is running, then ends at `workflow:merged`, `workflow:awaiting-merge`, or a newly asserted `needs-human` label. When `re_gate_outcome: AUTO-LANDED`, it drives its own close phase internally (Phase M8 invokes `Skill("{FORGE_SKILL_PREFIX}work-on:close", ...)` directly) before returning. For every other outcome (`HELD-AWAITING-MERGE`, `RE-ESCALATED`, `UNFIXABLE`, `BLOCKED`, `ALREADY_DONE`), the issue is already at a terminal state (`workflow:awaiting-merge` or `needs-human`, or already closed) per the Universal Phase Dispatcher — nothing further to do.
253
259
 
254
260
  This mode is reachable both standalone (a human or script running `/work-on <pr> --remediate` directly) and via the orchestrator (`commands/orchestrate/phase-4-execution.md` item 6.4 auto-dispatches the identical `Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='{PR} --remediate --issue {N} ...')` invocation against a `needs-human`-gated predecessor's own PR).
255
261
 
256
262
  **Skip this entire section if `--remediate` is absent from `$ARGUMENTS`** — proceed to the normal parse below.
257
263
 
264
+ ### 0A.4: Recovery-Claim Gate (MANDATORY — before any heartbeat, label, or comment write) <!-- Added: forge#3172 -->
265
+
266
+ A `/recover-orphans` sweep that resumes this issue inline holds an issue-scoped `<!-- FORGE:RECOVERY_CLAIM -->` comment (see `commands/recover-orphans.md` Phase 3). Starting the pipeline underneath a live claim double-runs the issue. Before the 0A.5 heartbeat and before 0B, ask the shared predicate `scripts/recovery-claim-live.sh` (the single copy of the claim logic: unreleased claim, `updated_at` within `RECOVERY_CLAIM_TTL_MIN` (default 30), and no `FORGE:RECOVERY_CLAIM_RELEASED` marker naming the claim's sweep id).
267
+
268
+ **Resolve `RECOVERY_SWEEP_ID`**: the value following `--recovery-sweep` in the invocation args, else empty. Only `/recover-orphans` passes it (its inline resume passes its own `SWEEP_ID`); it exempts the claim held by that sweep so the holder is not blocked by its own claim. Never combine it with `--under-orchestration`.
269
+
270
+ ```bash
271
+ # Shell state does not persist: the bootstrap is repeated here.
272
+ # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
273
+ FORGE_ROOT=""
274
+ # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
275
+ _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
276
+ # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
277
+ _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
278
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
279
+ # 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.
280
+ _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")")"
281
+ # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
282
+ _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
283
+ # 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)
284
+ _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)"
285
+ _m="$HOME/.claude/plugins/marketplaces/$_mk"
286
+ # '${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.
287
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
288
+ while IFS= read -r _c; do
289
+ 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
290
+ done <<< "$_k"
291
+ fi
292
+ RECOVERY_SWEEP_ID=$(printf '%s' "$ARGUMENTS" | sed -n 's/.*--recovery-sweep[[:space:]][[:space:]]*\([^[:space:]][^[:space:]]*\).*/\1/p' | head -1)
293
+ if [ -n "$FORGE_ROOT" ] && [ -f "$FORGE_ROOT/scripts/recovery-claim-live.sh" ]; then
294
+ CLAIM_OUT=$(bash "$FORGE_ROOT/scripts/recovery-claim-live.sh" {NUMBER} -R {GH_REPO} ${RECOVERY_SWEEP_ID:+--exempt-sweep "$RECOVERY_SWEEP_ID"}); CLAIM_RC=$?
295
+ else
296
+ CLAIM_OUT="CLAIM: ERROR"; CLAIM_RC=2 # fail closed: an unresolvable predicate is never treated as "free"
297
+ fi
298
+ echo "$CLAIM_OUT"
299
+ ```
300
+
301
+ - `CLAIM_RC=0` (`CLAIM: FREE`): continue to 0A.5 / 0B.
302
+ - `CLAIM_RC=1` (`CLAIM: LIVE <sweep-id>`): STOP with "issue #{NUMBER} is held by recovery sweep <sweep-id>; retry after its `FORGE:RECOVERY_CLAIM_RELEASED` marker or `RECOVERY_CLAIM_TTL_MIN` expiry". Write nothing: no heartbeat, no label change, and NO `needs-human` (the claim is transient).
303
+ - `CLAIM_RC=2` (unreadable comments or missing script): fail closed, same STOP and same no-write rule (transient, not `needs-human`).
304
+
258
305
  ### 0A.5: Post Heartbeat Annotation (orchestration-only)
259
306
 
260
307
  **Skip entirely if `UNDER_ORCHESTRATION` is `false`** — a solo run has no stall detector polling comment timestamps, so this write has zero consumer. Do not post it "just in case."
@@ -381,14 +428,20 @@ fi
381
428
  # ~/.claude/commands symlink target > the Claude Code plugin cache/marketplace dirs. <!-- forge#3098 -->
382
429
  # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
383
430
  FORGE_ROOT=""
384
- if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
385
- # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
386
- _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")")"
387
- # 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)
388
- _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-)"
389
- _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
431
+ # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
432
+ _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
433
+ # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
434
+ _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
435
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
436
+ # 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.
437
+ _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")")"
438
+ # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
439
+ _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
440
+ # 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)
441
+ _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)"
442
+ _m="$HOME/.claude/plugins/marketplaces/$_mk"
390
443
  # '${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.
391
- _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
444
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
392
445
  while IFS= read -r _c; do
393
446
  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
394
447
  done <<< "$_k"
@@ -507,6 +560,12 @@ gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:HEARTBEAT -->
507
560
 
508
561
  ---
509
562
 
563
+ ## Transient GitHub failures (router-owned retry)
564
+
565
+ Any phase result whose blocker starts with `github-unavailable:` is an infrastructure outage, not a decision: do not add `needs-human`. Wait 2 minutes, then re-invoke the **same phase with the same args**; repeat at most twice (5 minutes before the second retry). Phases are idempotent and resume from GitHub state. If the third attempt still returns `github-unavailable:`, post one comment with the blocker, add `needs-human`, and STOP — at that point GitHub has been unavailable for 10+ minutes.
566
+
567
+ ---
568
+
510
569
  ## Phase 1: Investigate
511
570
 
512
571
  **Skip if**: a `<!-- FORGE:INVESTIGATOR -->` comment contains `<!-- INVESTIGATION:COMPLETE -->` or `<!-- INVESTIGATION:INVALID -->` (route on the existing verdict as below — the skill's own resume check returns it if invoked).
@@ -649,7 +708,7 @@ Omit `--pr/--branch/--worktree` when the value is unknown (PR-less terminals).
649
708
 
650
709
  ## Remediation entry (`--remediate`)
651
710
 
652
- Handled in Phase 0A.1: `/work-on <pr> --remediate [--issue N]` dispatches `Skill(skill="{FORGE_SKILL_PREFIX}work-on:remediate", ...)` (forked) and STOPS after its `REMEDIATE_RESULT`.
711
+ Handled in Phase 0A.1: `/work-on <pr> --remediate [--issue N]` dispatches `Skill(skill="{FORGE_SKILL_PREFIX}work-on:remediate", ...)` (forked) and STOPS after its `REMEDIATE_RESULT`, except that `REREVIEW_REQUIRED` makes the router run `review-pr` itself first (see 0A.1).
653
712
 
654
713
  ---
655
714
 
package/docs/CODEX.md CHANGED
@@ -73,6 +73,18 @@ When a repo-local adapter exists under `.agents/skills/`, it becomes authoritati
73
73
  - Forge-specific review heuristics
74
74
  - best-effort handling of project-board steps that may be project-specific upstream
75
75
 
76
+ ## FORGE_HOME and Install Root Resolution
77
+
78
+ Forge scripts (the merge trail gate, dispatch lint, engine modules) are resolved from ForgeDock's own install root, never from the consumer repo. Codex has no `~/.claude` fallback, so it relies on `FORGE_HOME`:
79
+
80
+ - `FORGE_HOME` must be an absolute path to the ForgeDock clone (the directory holding `scripts/` and `commands/`). Relative values are ignored.
81
+ - `FORGEDOCK_HOME`, when set, is authoritative over everything else. Windows drive-letter forms (`C:/forge` or `C:\forge`) are accepted and normalized to `/c/forge`.
82
+ - `FORGE_SKILL_NAMESPACE=codex` forces the `forge-` skill prefix with `-` nesting (`forge-work-on-build`). Set it when skill auto-detection from the available-skills list is ambiguous; other values are `forgedock`, `none` and `opencode`. When unset, `FORGE_RUNTIME=codex` selects the same row.
83
+ - Resolution order is `FORGEDOCK_HOME` (authoritative: when set, nothing else is consulted, and a relative value leaves `FORGE_ROOT` empty) > the running plugin's own root (Claude Code plugin installs only) > `FORGE_HOME` > the `work-on.md` symlink target > the `$CODEX_HOME/forge-home` file (skipped when `CODEX_HOME` is set to a relative path) > the plugin cache > the marketplace directory. See the Environment Variables section of `CONFIG.md` for the full variable reference, including the `FORGE_TRAIL_*` trust settings for the merge gate.
84
+ - `./install-codex.sh` writes `FORGE_HOME` to `~/.bashrc`/`~/.zshrc` (interactive shells), to `~/.zshenv` and `~/.profile` (non-interactive shells), each only when the file already exists and with the path POSIX single-quote-escaped, and records the clone path in `$CODEX_HOME/forge-home` (default `~/.codex/forge-home`), which the bootstrap reads even when no shell profile is sourced.
85
+ - For Claude Code plugin installs only the official `forgedock` marketplace is trusted; set `FORGEDOCK_MARKETPLACE` (trusted environment only) to pin a differently named marketplace. Cache directories with non-semver names are ignored.
86
+ - If none of these resolve, `FORGE_ROOT` stays empty and the trail gate refuses to merge (fail closed).
87
+
76
88
  ## Trust and Global Config
77
89
 
78
90
  Codex may require the repo directory to be trusted in `~/.codex/config.toml`.
package/docs/CONFIG.md CHANGED
@@ -404,7 +404,7 @@ orchestration:
404
404
  | `cascade.convergence_window` | positive integer | No | Consecutive ratio observations at or above 1.0 before warning. Default: 3 |
405
405
  | `cascade.amplification_breaker` | `on` \| `off` | No | Pause P3-and-below cascade admission while the findings-spawned / merged-units ratio stays at or above 1.0 for `convergence_window` units. Counts all cascade findings (not only same-lineage); P1/P2 unaffected; independent of `cascade.policy`, so `policy: all` does not disable it. Paused P3s route to bounded P3 batches. Default: `on`. Opt out with `off` (boolean `false` is accepted as `off`, `true` as `on`). |
406
406
 
407
- **Upgrade note — `cascade.amplification_breaker` is on by default**: existing configs that do not set this key now get the breaker automatically. During a batch, once the findings-spawned / merged-units ratio stays at or above 1.0 for `convergence_window` units (it can trip at exactly 1.0 over 3 units), P3-and-below cascade admission pauses; paused P3s are routed to bounded P3 batches rather than dispatched individually. P1/P2 are unaffected. To keep the previous behavior, set `orchestration.cascade.amplification_breaker: off` (or `false`). Release notes for the version that introduced this default should call out the change.
407
+ **Upgrade note — `cascade.amplification_breaker` is on by default**: existing configs that do not set this key now get the breaker automatically. During a batch, once the findings-spawned / merged-units ratio stays at or above 1.0 for `convergence_window` units (it can trip at exactly 1.0 over 3 units), P3-and-below cascade admission pauses; paused P3s are routed to bounded P3 batches rather than dispatched individually. P1/P2 are unaffected. To keep the previous behavior, set `orchestration.cascade.amplification_breaker: off` (or `false`). See [RELEASE-NOTES.md](RELEASE-NOTES.md) for the release that introduced this default.
408
408
 
409
409
  **Hard invariant — not configurable**: safety exclusions (findings whose `## Problem` section indicates security/billing/anti-bot/auth concerns) are never batched and never auto-admitted by any `cascade.policy`, including `all`. That exclusion is enforced upstream of this section (the P3 batching eligibility check) and has no corresponding key here by design.
410
410
 
@@ -850,6 +850,54 @@ marketing:
850
850
 
851
851
  ---
852
852
 
853
+ ## Environment Variables
854
+
855
+ These variables are read from the operator's runner environment, not from `forge.yaml`. Set them in a trusted environment only (shell profile, CI runner settings); never from PR content, issue text or contributor-controlled CI variables.
856
+
857
+ | Variable | Default | Purpose |
858
+ |----------|---------|---------|
859
+ | `FORGEDOCK_HOME` | unset | Absolute path to the ForgeDock install root (the directory holding `scripts/` and `commands/`). Authoritative over every other source when set. Relative values are rejected; Windows drive-letter forms (`C:/forge`, `C:\forge`) are normalized to `/c/forge`. |
860
+ | `FORGE_HOME` | unset | Absolute path to the ForgeDock clone. Used when `FORGEDOCK_HOME` is unset. Required for Codex, which has no `~/.claude` fallback. Relative values are ignored. |
861
+ | `FORGE_SKILL_NAMESPACE` | auto-detected | Forces the skill prefix and nesting separator for `Skill(...)` calls. `forgedock` = `forgedock:` / `:`; `none` = empty / `:`; `codex` = `forge-` / `-`; `opencode` = empty / `-`. Any other value is an error. When unset, `FORGE_RUNTIME` (`codex` or `opencode`) is used, then the available-skills list. |
862
+ | `FORGE_TRAIL_TRUSTED_LOGINS` | empty | Comma-separated GitHub logins whose FORGE markers count toward the phase-trail merge gate. Matching is case-sensitive. |
863
+ | `FORGE_TRAIL_TRUSTED_ASSOCIATIONS` | `OWNER,MEMBER,COLLABORATOR` | Comma-separated `author_association` values whose FORGE markers count toward the merge gate. Do not add `CONTRIBUTOR` here: any listed association can post FORGE markers that satisfy the gate. |
864
+ | `FORGE_TRAIL_OVERRIDE_ASSOCIATIONS` | the resolved `FORGE_TRAIL_TRUSTED_ASSOCIATIONS` value | Comma-separated `author_association` values prefiltering break-glass override candidates (the permission API stays authoritative). Never affects which FORGE markers count toward the gate. An approver with concealed org membership can show `CONTRIBUTOR`/`NONE`, so widen this variable (not the trail list) to accept them. An explicitly empty value means no override candidates. |
865
+ | `FORGE_TRAIL_PIPELINE_LOGINS` | empty | Comma-separated GitHub logins (case-insensitive) that are the pipeline's own identity and can never approve a phase-trail break-glass override. The authors of trusted FORGE marker comments on the issue are added automatically. |
866
+ | `FORGE_TRAIL_QG_SINCE` | `2026-10-07T03:40:12Z` | ISO-8601 UTC cutoff for the legacy `FORGE:QUALITY_GATE` grace. An issue whose trusted `FORGE:BUILDER:COMPLETE` comment was last updated before this time has `QUALITY_GATE` waived. Set to an empty string to disable the grace. A malformed or future value (including a runner clock earlier than this default) fails closed: the verifier exits 2, an infrastructure block rather than `MISSING`. |
867
+
868
+ ### Install root resolution
869
+
870
+ `FORGE_ROOT` (where the merge trail gate, dispatch lint and engine modules are resolved) is computed in this order: `FORGEDOCK_HOME` (authoritative: when set, nothing else is consulted, and a relative value leaves `FORGE_ROOT` empty so the gate fails closed) > the running plugin's own root (substituted by Claude Code; not a runtime env var) > `FORGE_HOME` > the `~/.claude/commands/work-on.md` symlink target > the clone path recorded in `$CODEX_HOME/forge-home` (Codex; a relative `CODEX_HOME` is ignored) > the newest semver directory in the Claude plugin cache (official `forgedock` marketplace only; `FORGEDOCK_MARKETPLACE` pins a differently named one) > the marketplace directory. If nothing resolves, `FORGE_ROOT` stays empty and the trail gate refuses to merge (fail closed). See [CODEX.md](CODEX.md#forge_home-and-install-root-resolution).
871
+
872
+ ### Keeping the phase-trail merge gate satisfiable
873
+
874
+ The merge gate (`scripts/verify-phase-trail.sh`) ignores FORGE markers posted by untrusted commenters. A commenter is trusted when ANY of these hold: `author_association` is in `FORGE_TRAIL_TRUSTED_ASSOCIATIONS`; the account type is `Bot` (the pipeline's GitHub App identity); or the login is in `FORGE_TRAIL_TRUSTED_LOGINS`.
875
+
876
+ Pipelines that run under a human login with association `CONTRIBUTOR`, `NONE` or `FIRST_TIME_CONTRIBUTOR` will see every marker reported `MISSING`, and the gate prints a NOTE when untrusted-author markers were seen. Fix it by either:
877
+
878
+ ```bash
879
+ export FORGE_TRAIL_TRUSTED_LOGINS="my-pipeline-login,other-login" # preferred: name the identity
880
+ export FORGE_TRAIL_TRUSTED_ASSOCIATIONS="OWNER,MEMBER,COLLABORATOR,CONTRIBUTOR" # broader
881
+ ```
882
+
883
+ Warning: widening `FORGE_TRAIL_TRUSTED_ASSOCIATIONS` to include `CONTRIBUTOR` lets any contributor post forged `FORGE:*` markers that satisfy the merge gate. To only accept override approvers with concealed org membership, widen `FORGE_TRAIL_OVERRIDE_ASSOCIATIONS` instead.
884
+
885
+ #### Break-glass override
886
+
887
+ A misfiring phase-trail gate can be cleared by a human without a revert or manual merge. Post a comment on the issue that starts with `<!-- FORGE:PHASE_TRAIL_OVERRIDE -->` and has these lines:
888
+
889
+ ```
890
+ **Head**: <full 40/64-hex head commit>
891
+ **Missing**: <the MISSING marker names, comma separated, e.g. CONTEXT,ARCHITECT>
892
+ **Reason**: <one line>
893
+ ```
894
+
895
+ `scripts/verify-phase-trail.sh` honours it only when the verifier would otherwise exit 1 and every rule holds: the author is `user.type == "User"` with repo `admin` or `write` permission; the author is not a pipeline identity (`FORGE_TRAIL_PIPELINE_LOGINS` plus the authors of the issue's trusted FORGE marker comments, so a pipeline running under a human token cannot approve itself, and a solo operator needs a second human with write access); the comment was never edited (`updated_at == created_at`); `**Head**` equals the commit being gated (passed as `--head-sha`) and `**Missing**` equals the current MISSING set; and the comment is newer than the latest `FORGE:BUILDER:COMPLETE`. Any API error or missing field rejects it. An unreadable trail (exit 2) is never overridable. The reason is sanitized (control characters stripped, comment markers neutralized, capped at 200 characters) and `/review-pr` and `/work-on` record the accepted override as `FORGE:PHASE_TRAIL_OVERRIDE_APPLIED` from the verifier's output. A new commit invalidates the override: post a new one for the new head. Operational limits: the collaborator-permission lookup (`gh api repos/.../collaborators/<login>/permission`) needs a token with push access, otherwise it returns 403 and the override is rejected (a `NOTE:` is written to stderr); at most 5 eligible override comments are checked per run (comments failing the head, missing-set, reason, identity or edit checks do not count toward the cap); logins may contain letters, digits, `-` and `_` (Enterprise Managed User logins are accepted); the login the verifier itself runs under counts as a pipeline identity and cannot approve; with no trusted `FORGE:BUILDER:COMPLETE`, the override must be newer than the latest trusted FORGE marker instead; and `/review-pr` refuses to merge any commit other than the exact head the override was bound to.
896
+
897
+ Limits: `Bot` trusts any GitHub App or bot able to comment on the repo, and `COLLABORATOR` includes read-level collaborators. Tighten with the two variables above if that is too broad. An earlier `FORGE_TRAIL_QG_SINCE` waives `QUALITY_GATE` for every build completed before it, so never derive it from untrusted input.
898
+
899
+ ---
900
+
853
901
  ## Config Reading Conventions
854
902
 
855
903
  ForgeDock commands use two accepted patterns for reading `forge.yaml`:
@@ -0,0 +1,52 @@
1
+ # Release Notes
2
+
3
+ Operator-facing notes for behavior changes that need action or awareness. Newest first.
4
+
5
+ ## Review-finding cascade is now bounded by code, not prose
6
+
7
+ An audit of one `/orchestrate` batch found 93 `review-finding` issues filed for 46 merges, a ratio of 2.02. The §6B.5 note-disposition step was supposed to stop LOW/POSSIBLE findings from becoming issues. It was bypassed in two ways: every finding from the always-on General Security & Quality reviewer was exempt by origin, and reviewers often skipped the step entirely.
8
+
9
+ - `scripts/classify-finding.sh` now decides ISSUE versus NOTE deterministically. The safety exemption is content-based (security/billing keywords) or applies to dedicated Auth/Billing/Concurrency/Database agents. Origin from the always-on reviewer no longer exempts a finding. On PRs that fix a review finding, LOW and POSSIBLE findings are always notes. HIGH and CRITICAL findings always file, as before. Replayed on the audited batch, 41 of the 93 would be filed, which is a ratio of 0.89, and no HIGH finding is demoted.
10
+ - `/review-pr` posts a `FORGE:NOTE_DISPOSITION` record. Auto-merge, in `/review-pr` Phase 8 and `/work-on` review, refuses to merge a PR that has findings but no disposition record. `/work-on` re-runs the review once instead of escalating to `needs-human`.
11
+ - `scripts/amplification-breaker.sh` measures findings per merged unit since `BATCH_T0` from GitHub state. `/orchestrate` must run it before dispatching any P3 review finding, on every dispatch path including the Agent-spawn fallback. Exit 3 (tripped) or 4 (unreadable) defers P3 findings to bounded batches.
12
+
13
+ Action: none required. LOW review notes now appear in the PR's `Non-blocking notes` section and the disposition comment instead of as issues. A PR reviewed by an older plugin version gets one re-review before auto-merge.
14
+
15
+ ## Override candidate filter hardening (forge#3307)
16
+
17
+ The break-glass override prefilter now uses `FORGE_TRAIL_OVERRIDE_ASSOCIATIONS` instead of a hardcoded `OWNER,MEMBER,COLLABORATOR` list. It defaults to the resolved `FORGE_TRAIL_TRUSTED_ASSOCIATIONS` value, so default behavior is unchanged, and it never affects which FORGE markers count toward the merge gate. An empty list yields no override candidates. The verifier prints `NOTE: N override candidate(s) ignored: author_association not in override-approver set` to stderr when candidates are filtered this way, so an approver with concealed org membership (shown as `CONTRIBUTOR`/`NONE`) is diagnosable.
18
+
19
+ Action: if your override approvers have concealed membership, add `CONTRIBUTOR` to `FORGE_TRAIL_OVERRIDE_ASSOCIATIONS`. Do not add it to the trail list: that lets any contributor forge FORGE markers.
20
+
21
+ ## Phase-trail break-glass override (forge#3152)
22
+
23
+ A human can now clear a misfiring phase-trail gate with a `FORGE:PHASE_TRAIL_OVERRIDE` comment instead of a revert or manual merge. The override is bound to the head commit and the exact MISSING set, must be newer than the latest `FORGE:BUILDER:COMPLETE`, must be unedited, and must come from a human with repo write/admin permission who is not a pipeline identity. The verifier takes a new `--head-sha` flag, and the accepted override is recorded on the PR by code. Exit 2 (unreadable trail) is never overridable.
24
+
25
+ Action: optionally set `FORGE_TRAIL_PIPELINE_LOGINS` to the pipeline's own login(s). A solo operator running the pipeline under their own token cannot self-approve; a second human with write access must post the override. Format and rules are in [CONFIG.md](CONFIG.md).
26
+
27
+ ## Staging to main bundle (PR #3120)
28
+
29
+ ### Amplification breaker is on by default
30
+
31
+ `orchestration.cascade.amplification_breaker` now defaults to `on`. Existing configs that do not set the key get the breaker automatically: once the findings-spawned / merged-units ratio stays at or above 1.0 for `convergence_window` units (it can trip at exactly 1.0 over 3 units), P3-and-below cascade admission pauses and paused P3s route to bounded P3 batches. P1/P2 are unaffected.
32
+
33
+ Action: to keep the previous behavior, set `orchestration.cascade.amplification_breaker: off` (or `false`). See the upgrade note in [CONFIG.md](CONFIG.md).
34
+
35
+ ### Phase-trail merge gate and new markers
36
+
37
+ Merging now requires a complete phase trail, verified by `scripts/verify-phase-trail.sh`. The trail includes the `FORGE:QUALITY_GATE` marker. Only markers from trusted commenters count: `author_association` in `OWNER,MEMBER,COLLABORATOR`, a `Bot` account, or a login listed in `FORGE_TRAIL_TRUSTED_LOGINS`. Markers from anyone else are ignored and reported `MISSING`. The verifier exits 0 on pass, 1 when artifacts are missing and 2 when the trail cannot be read (fail closed). If the install root cannot be resolved, the gate refuses to merge.
38
+
39
+ Issues whose `FORGE:BUILDER:COMPLETE` comment was last updated before `FORGE_TRAIL_QG_SINCE` (default `2026-10-07T03:40:12Z`) have `QUALITY_GATE` waived. Set it to an empty string to disable the grace.
40
+
41
+ Actions:
42
+ - Pipelines that run under a non-Bot login without OWNER/MEMBER/COLLABORATOR association must set `FORGE_TRAIL_TRUSTED_LOGINS` (preferred; widening `FORGE_TRAIL_TRUSTED_ASSOCIATIONS` also lets those associations forge markers).
43
+ - Make sure `FORGEDOCK_HOME` or `FORGE_HOME` resolves to the ForgeDock install (required on Codex).
44
+ - Set `FORGE_SKILL_NAMESPACE` if skill-prefix auto-detection picks the wrong runtime.
45
+
46
+ All variables, defaults and trust caveats are documented in the Environment Variables section of [CONFIG.md](CONFIG.md); Codex specifics are in [CODEX.md](CODEX.md).
47
+
48
+ ### Review step 6B.5: non-blocking note disposition
49
+
50
+ `/review-pr` no longer files a standalone `review-finding` issue for every low-signal finding. Findings with `Severity: LOW`, or `Confidence: POSSIBLE` below HIGH severity, become notes. This includes CONFIRMED LOW findings. Notes are fixed in the PR (comment, documentation or test-only changes), listed under `## Non-blocking notes` in the PR body, or dropped, and each disposition is recorded in the review summary (`notes_fixed`, `notes_listed`, `notes_dropped`, `findings_filed`). Findings from the Security, Auth, Billing, Concurrency or Database agents, or matching the safety keyword set, are never demoted. On PRs that fix a `review-finding` + `priority:P3` issue, the rule is stricter.
51
+
52
+ Impact: `review-finding` issue volume, and metrics derived from it (findings per PR, `/pipeline-health` finding rates, amplification ratio), drop relative to earlier history. Compare against the `notes_*` counts in the review summary when judging review depth.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.8.3",
3
+ "version": "1.9.0",
4
4
  "description": "Autonomous development pipeline for coding agents. GitHub as institutional memory, with native Claude Code and OpenCode entrypoints.",
5
5
  "bin": {
6
6
  "forgedock": "bin/forgedock.mjs"
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # amplification-breaker.sh — Deterministic review-finding cascade breaker for /orchestrate.
6
+ #
7
+ # Measures, from GitHub state alone, how many `review-finding` issues a batch has
8
+ # spawned per merged unit, and reports whether the forge#3060 amplification breaker
9
+ # is tripped. It exists so the breaker runs on EVERY dispatch path — the engine CLI,
10
+ # the Agent-spawn fallback and a hand-driven orchestrator alike. The 2026-10-08
11
+ # audit found a batch that ran on the fallback path never evaluated the in-spec
12
+ # breaker and reached ~2 findings per merge.
13
+ #
14
+ # Usage:
15
+ # amplification-breaker.sh --since <ISO8601> [-R owner/repo] [--threshold <ratio>] [--min-units <n>]
16
+ #
17
+ # --since Batch start (BATCH_T0), e.g. 2026-10-07T17:46:58Z. Required.
18
+ # -R Repository (default: current gh repo).
19
+ # --threshold Trip when findings/merged >= this ratio (default 1.0).
20
+ # --min-units Do not trip before this many merged units (default 3) so the
21
+ # first merge of a batch cannot trip it on noise.
22
+ #
23
+ # Test hook: AMP_FINDINGS and AMP_MERGED, when both set, replace the GitHub queries.
24
+ #
25
+ # Output (stdout, one line):
26
+ # AMPLIFICATION: findings=<n> merged=<m> ratio=<r> threshold=<t> tripped=<yes|no>
27
+ #
28
+ # Exit codes:
29
+ # 0 clear — P3 findings may be admitted
30
+ # 3 tripped — defer P3-and-below findings to bounded batches (P1/P2 unaffected)
31
+ # 2 usage error
32
+ # 4 counts unreadable — fail closed: treat as tripped for P3 admission
33
+ #
34
+ # Portable: bash 3.2, BSD/GNU awk. Requires gh + jq unless the test hook is used.
35
+
36
+ set -u
37
+
38
+ SINCE=""; REPO_FLAG=""; THRESHOLD="1.0"; MIN_UNITS=3
39
+
40
+ usage() {
41
+ echo "ERROR: Usage: amplification-breaker.sh --since <ISO8601> [-R owner/repo] [--threshold <ratio>] [--min-units <n>]" >&2
42
+ exit 2
43
+ }
44
+
45
+ while [ "$#" -gt 0 ]; do
46
+ case "$1" in
47
+ --since) [ "$#" -ge 2 ] || usage; SINCE="$2"; shift 2 ;;
48
+ -R|--repo) [ "$#" -ge 2 ] || usage; REPO_FLAG="-R $2"; shift 2 ;;
49
+ --threshold) [ "$#" -ge 2 ] || usage; THRESHOLD="$2"; shift 2 ;;
50
+ --min-units) [ "$#" -ge 2 ] || usage; MIN_UNITS="$2"; shift 2 ;;
51
+ *) usage ;;
52
+ esac
53
+ done
54
+
55
+ [ -n "$SINCE" ] || usage
56
+ case "$SINCE" in [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*) ;; *) echo "ERROR: --since must be ISO8601 (got '$SINCE')" >&2; exit 2 ;; esac
57
+ case "$MIN_UNITS" in ''|*[!0-9]*) echo "ERROR: --min-units must be a non-negative integer" >&2; exit 2 ;; esac
58
+ case "$THRESHOLD" in ''|*[!0-9.]*|*.*.*) echo "ERROR: --threshold must be a positive number" >&2; exit 2 ;; esac
59
+
60
+ if [ -n "${AMP_FINDINGS:-}" ] && [ -n "${AMP_MERGED:-}" ]; then
61
+ FINDINGS="$AMP_FINDINGS"; MERGED="$AMP_MERGED"
62
+ else
63
+ # shellcheck disable=SC2086 # REPO_FLAG is intentionally word-split into "-R owner/repo"
64
+ FINDINGS=$(gh issue list $REPO_FLAG --state all --label review-finding \
65
+ --search "created:>=${SINCE}" --limit 1000 --json number --jq 'length' 2>/dev/null) || FINDINGS=""
66
+ # shellcheck disable=SC2086
67
+ MERGED=$(gh issue list $REPO_FLAG --state closed --label workflow:merged \
68
+ --search "closed:>=${SINCE}" --limit 1000 --json number --jq 'length' 2>/dev/null) || MERGED=""
69
+ fi
70
+
71
+ case "$FINDINGS" in ''|*[!0-9]*) FINDINGS="" ;; esac
72
+ case "$MERGED" in ''|*[!0-9]*) MERGED="" ;; esac
73
+ if [ -z "$FINDINGS" ] || [ -z "$MERGED" ]; then
74
+ echo "AMPLIFICATION: findings=? merged=? ratio=? threshold=${THRESHOLD} tripped=unknown"
75
+ echo "WARNING: amplification counts unreadable — fail closed (defer P3 admission)" >&2
76
+ exit 4
77
+ fi
78
+
79
+ RESULT=$(awk -v f="$FINDINGS" -v m="$MERGED" -v t="$THRESHOLD" -v u="$MIN_UNITS" 'BEGIN {
80
+ r = (m > 0) ? f / m : (f > 0 ? f : 0)
81
+ trip = (m >= u && r >= t) ? "yes" : "no"
82
+ printf "%.2f %s\n", r, trip
83
+ }')
84
+ RATIO=${RESULT% *}
85
+ TRIPPED=${RESULT#* }
86
+
87
+ echo "AMPLIFICATION: findings=${FINDINGS} merged=${MERGED} ratio=${RATIO} threshold=${THRESHOLD} tripped=${TRIPPED}"
88
+ [ "$TRIPPED" = "yes" ] && exit 3
89
+ exit 0
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # Fixtures for scripts/amplification-breaker.sh. No network: uses the
6
+ # AMP_FINDINGS/AMP_MERGED test hook. Run: bash scripts/amplification-breaker.test.sh
7
+
8
+ set -u
9
+ DIR=$(cd "$(dirname "$0")" && pwd)
10
+ S="$DIR/amplification-breaker.sh"
11
+ T0="2026-10-07T17:46:58Z"
12
+ pass=0; fail=0
13
+
14
+ check() { # check <want_exit> <want_substring> <desc> -- env... -- args...
15
+ want_rc="$1"; want_out="$2"; desc="$3"; shift 4
16
+ envs=""
17
+ while [ "$#" -gt 0 ] && [ "$1" != "--" ]; do envs="$envs $1"; shift; done
18
+ [ "$#" -gt 0 ] && shift
19
+ out=$(env $envs bash "$S" "$@" 2>/dev/null); rc=$?
20
+ case "$out" in *"$want_out"*) ok=1 ;; *) ok=0 ;; esac
21
+ if [ "$rc" = "$want_rc" ] && [ "$ok" = 1 ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: $desc (rc=$rc out='$out')"; fi
22
+ }
23
+
24
+ check 3 "ratio=2.02 threshold=1.0 tripped=yes" "audited batch (93/46) trips" -- AMP_FINDINGS=93 AMP_MERGED=46 -- --since "$T0"
25
+ check 0 "ratio=0.89 threshold=1.0 tripped=no" "post-fix projection (41/46) clear" -- AMP_FINDINGS=41 AMP_MERGED=46 -- --since "$T0"
26
+ check 3 "ratio=1.00" "exactly at threshold trips" -- AMP_FINDINGS=5 AMP_MERGED=5 -- --since "$T0"
27
+ check 0 "tripped=no" "below min-units never trips" -- AMP_FINDINGS=8 AMP_MERGED=2 -- --since "$T0"
28
+ check 3 "tripped=yes" "min-units 1 trips early" -- AMP_FINDINGS=8 AMP_MERGED=2 -- --since "$T0" --min-units 1
29
+ check 0 "ratio=0.00" "empty batch clear" -- AMP_FINDINGS=0 AMP_MERGED=0 -- --since "$T0"
30
+ check 0 "threshold=1.5 tripped=no" "custom threshold respected" -- AMP_FINDINGS=6 AMP_MERGED=5 -- --since "$T0" --threshold 1.5
31
+ check 4 "tripped=unknown" "unreadable counts fail closed" -- AMP_FINDINGS=x AMP_MERGED=3 -- --since "$T0"
32
+ check 2 "" "missing --since" -- AMP_FINDINGS=1 AMP_MERGED=1 --
33
+ check 2 "" "bad --since" -- AMP_FINDINGS=1 AMP_MERGED=1 -- --since yesterday
34
+ check 2 "" "bad --min-units" -- AMP_FINDINGS=1 AMP_MERGED=1 -- --since "$T0" --min-units -1
35
+ check 2 "" "bad --threshold" -- AMP_FINDINGS=1 AMP_MERGED=1 -- --since "$T0" --threshold 1.2.3
36
+ check 2 "" "unknown flag" -- AMP_FINDINGS=1 AMP_MERGED=1 -- --since "$T0" --bogus
37
+
38
+ echo "amplification-breaker.test.sh: passed=$pass failed=$fail"
39
+ [ "$fail" -eq 0 ]
@@ -13,7 +13,7 @@
13
13
  * Usage: node scripts/check-admission-parity.mjs
14
14
  */
15
15
 
16
- import { readFileSync } from "node:fs";
16
+ import { readFileSync, readdirSync } from "node:fs";
17
17
  import { fileURLToPath, pathToFileURL } from "node:url";
18
18
  import path from "node:path";
19
19
 
@@ -78,6 +78,56 @@ function extractBashMirrorPresets(specText) {
78
78
  return presets;
79
79
  }
80
80
 
81
+ /** Recursively list *.md files under a directory. */
82
+ function listMarkdown(dir) {
83
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
84
+ const full = path.join(dir, entry.name);
85
+ if (entry.isDirectory()) return listMarkdown(full);
86
+ return entry.name.endsWith(".md") ? [full] : [];
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Collect identifiers the orchestrate specs name as admission.mjs APIs:
92
+ * (a) names destructured from an admission.mjs import snippet (`({ a, b }) =>`), and (b) backticked `name()` calls
93
+ * on any line that mentions admission.mjs. Returns [{ name, file, line }], plus
94
+ * `{ unattributed: true, file, line }` for destructure snippets that name no module.
95
+ *
96
+ * Heuristic limits: a destructure snippet is attributed to the first `engine/<name>.mjs` path found between its
97
+ * `.then(({` and the next `.then(({` (or end of text), capped at 1500 chars. A snippet naming no module in that
98
+ * window is reported as unattributable (the lint fails closed). The backtick-call rule is line-scoped: it only
99
+ * fires on lines that themselves mention admission.mjs, so `plan*()` calls in other prose are not linted.
100
+ */
101
+ function extractSpecNamedIdentifiers(file, text) {
102
+ const found = [];
103
+ const lineOf = (index) => text.slice(0, index).split("\n").length;
104
+ // The module loaded by a snippet is the first `engine/<name>.mjs` path passed after its `.then(({...}) =>`.
105
+ const destructure = /\.then\(\(\s*\{([^}]*)\}\s*\)\s*=>/g;
106
+ const starts = [...text.matchAll(destructure)].map((x) => x.index);
107
+ let m;
108
+ while ((m = destructure.exec(text))) {
109
+ const next = starts.find((s) => s > m.index) ?? text.length;
110
+ const target = text.slice(m.index, Math.min(next, m.index + 1500)).match(/engine\/([\w-]+)\.mjs/)?.[1];
111
+ if (!target) {
112
+ found.push({ unattributed: true, file, line: lineOf(m.index) });
113
+ continue;
114
+ }
115
+ if (target !== "admission") continue;
116
+ for (const part of m[1].split(",")) {
117
+ const name = part.trim().split(/\s*:\s*/)[0];
118
+ if (/^[A-Za-z_$][\w$]*$/.test(name)) found.push({ name, file, line: lineOf(m.index) });
119
+ }
120
+ }
121
+ const lines = text.split("\n");
122
+ lines.forEach((lineText, i) => {
123
+ if (!lineText.includes("admission.mjs")) return;
124
+ for (const call of lineText.matchAll(/`([A-Za-z_$][\w$]*)\(\)`/g)) {
125
+ found.push({ name: call[1], file, line: i + 1 });
126
+ }
127
+ });
128
+ return found;
129
+ }
130
+
81
131
  async function main() {
82
132
  let admissionModule;
83
133
  try {
@@ -149,6 +199,28 @@ async function main() {
149
199
  }
150
200
  }
151
201
 
202
+ // Spec-lint: every admission.mjs identifier named in commands/orchestrate/** must be exported.
203
+ const specDir = path.join(REPO_ROOT, "commands/orchestrate");
204
+ let namedCount = 0;
205
+ for (const file of listMarkdown(specDir)) {
206
+ for (const { name, line, unattributed } of extractSpecNamedIdentifiers(file, readFileSync(file, "utf8"))) {
207
+ if (unattributed) {
208
+ console.error(
209
+ `UNATTRIBUTABLE: ${path.relative(REPO_ROOT, file)}:${line} could not attribute .then(({...}) => snippet to an engine/<module>.mjs path`,
210
+ );
211
+ failed = true;
212
+ continue;
213
+ }
214
+ namedCount++;
215
+ if (!(name in admissionModule)) {
216
+ console.error(
217
+ `MISSING EXPORT: ${path.relative(REPO_ROOT, file)}:${line} names "${name}" from admission.mjs, but it is not exported`,
218
+ );
219
+ failed = true;
220
+ }
221
+ }
222
+ }
223
+
152
224
  if (failed) {
153
225
  console.error("");
154
226
  console.error(
@@ -160,6 +232,7 @@ async function main() {
160
232
  process.exit(1);
161
233
  }
162
234
 
235
+ console.log(`OK: all ${namedCount} admission.mjs identifiers named in commands/orchestrate/** are exported`);
163
236
  console.log(
164
237
  `OK: admission.mjs CASCADE_PRESETS matches the phase-4-execution.md bash mirror for: ${mjsNames.join(", ")}`,
165
238
  );