forgedock 1.8.1 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/bin/engine/admission.mjs +57 -1
  2. package/bin/engine/admission.test.mjs +60 -0
  3. package/commands/analytics.md +3 -1
  4. package/commands/audit-agents.md +3 -1
  5. package/commands/audit.md +10 -8
  6. package/commands/autopilot.md +16 -14
  7. package/commands/ci-audit.md +4 -2
  8. package/commands/cleanup.md +1 -1
  9. package/commands/deploy-pr.md +31 -11
  10. package/commands/geo-audit.md +3 -1
  11. package/commands/incident-response.md +3 -1
  12. package/commands/issue.md +4 -2
  13. package/commands/milestone.md +8 -6
  14. package/commands/orchestrate/config.md +2 -2
  15. package/commands/orchestrate/phase-1-resolve.md +56 -12
  16. package/commands/orchestrate/phase-3-dependency.md +15 -5
  17. package/commands/orchestrate/phase-4-execution.md +593 -78
  18. package/commands/orchestrate/phase-5-cleanup.md +4 -2
  19. package/commands/orchestrate/phase-6-report.md +7 -3
  20. package/commands/orchestrate.md +13 -9
  21. package/commands/pipeline-health/phase-4-proposals.md +6 -4
  22. package/commands/pipeline-health/phase-5-report.md +4 -2
  23. package/commands/pipeline-health.md +9 -7
  24. package/commands/pipeline-resume.md +4 -2
  25. package/commands/qa-sweep.md +3 -1
  26. package/commands/quality-gate.md +4 -3
  27. package/commands/recover-orphans.md +172 -13
  28. package/commands/review-pr-agents.md +6 -4
  29. package/commands/review-pr-staging.md +13 -5
  30. package/commands/review-pr.md +187 -19
  31. package/commands/rollback.md +3 -1
  32. package/commands/security-audit.md +3 -1
  33. package/commands/signal-planner.md +6 -4
  34. package/commands/sync-ecosystem.md +4 -2
  35. package/commands/test-gate.md +4 -2
  36. package/commands/upgrade-deps.md +3 -1
  37. package/commands/validate.md +3 -1
  38. package/commands/work-on/build/architect.md +81 -19
  39. package/commands/work-on/build/context.md +61 -18
  40. package/commands/work-on/build/implement.md +62 -18
  41. package/commands/work-on/build/validate.md +240 -60
  42. package/commands/work-on/build.md +344 -146
  43. package/commands/work-on/close.md +617 -246
  44. package/commands/work-on/decompose.md +239 -41
  45. package/commands/work-on/investigate.md +377 -61
  46. package/commands/work-on/remediate.md +42 -9
  47. package/commands/work-on/review.md +240 -29
  48. package/commands/work-on-monolithic.md +48 -7
  49. package/commands/work-on.md +180 -2014
  50. package/docs/CODEX.md +2 -0
  51. package/docs/CONFIG.md +11 -0
  52. package/package.json +1 -1
  53. package/scripts/build-spec-graph.mjs +11 -4
  54. package/scripts/check-spec-markers.sh +11 -1
  55. package/scripts/checkbox-sections.test.sh +8 -23
  56. package/scripts/extract-affected-files.sh +5 -2
  57. package/scripts/extract-affected-files.test.sh +27 -0
  58. package/scripts/forge-root.test.sh +176 -0
  59. package/scripts/is-docs-only.sh +31 -0
  60. package/scripts/is-docs-only.test.sh +64 -0
  61. package/scripts/lint-dispatch-prompt.sh +217 -0
  62. package/scripts/lint-dispatch-prompt.test.sh +307 -0
  63. package/scripts/validate-spec-graph.sh +3 -2
  64. package/scripts/verify-phase-trail.sh +282 -0
  65. package/scripts/verify-phase-trail.test.sh +298 -0
  66. package/scripts/wait-ci-green.sh +98 -0
  67. package/scripts/wait-ci-green.test.sh +99 -0
package/docs/CODEX.md CHANGED
@@ -48,6 +48,8 @@ Examples:
48
48
  | `commands/work-on/investigate.md` | `forge-work-on-investigate` |
49
49
  | `commands/work-on/build/context.md` | `forge-work-on-build-context` |
50
50
 
51
+ Nested `Skill("{FORGE_SKILL_PREFIX}work-on:build")` references in the specs resolve under Codex to `forge-work-on-build` (prefix `forge-`, `-` nesting); see `Skill Name Resolution` in `commands/work-on.md`.
52
+
51
53
  `commands/review-pr-agents.md` is excluded because it is a read-on-demand catalog, not a standalone entrypoint.
52
54
 
53
55
  ## How the Adapter Works
package/docs/CONFIG.md CHANGED
@@ -379,6 +379,14 @@ orchestration:
379
379
  # Number of consecutive merged-unit observations at ratio >= 1.0 before
380
380
  # printing a convergence warning. Default: 3.
381
381
  convergence_window: 3
382
+
383
+ # On-by-default amplification circuit breaker (forge#3060). Counts ALL
384
+ # cascade findings (new surface as well as same-lineage refinements). When
385
+ # the ratio stays >= 1.0 for convergence_window merged units, P3-and-below
386
+ # admission pauses, the operator gets a report (ratio + queue size), and the
387
+ # paused P3s wait for bounded P3 batches. P1/P2 are never affected, and
388
+ # `policy: all` / unlimited overrides do not disable it. "on" (default) | "off".
389
+ amplification_breaker: on
382
390
  ```
383
391
 
384
392
  | Field | Type | Required | Description |
@@ -394,6 +402,9 @@ orchestration:
394
402
  | `cascade.p3_same_file_defer` | boolean | No | Defer P3 findings sharing a file with the active batch. Default: `true` |
395
403
  | `cascade.max_amplification` | positive number or `"off"` | No | Opt-in ceiling for same-lineage refinement dispatch. Default: `"off"` |
396
404
  | `cascade.convergence_window` | positive integer | No | Consecutive ratio observations at or above 1.0 before warning. Default: 3 |
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
+
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.
397
408
 
398
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.
399
410
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.8.1",
3
+ "version": "1.8.2",
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"
@@ -224,13 +224,14 @@ function commandNodeFromPath(rel) {
224
224
  };
225
225
  }
226
226
 
227
- /** Map a Skill(skill="X") target to a command node id. The skill name uses the
227
+ /** Map a Skill(skill="X") target to a command node id (an optional leading
228
+ * `{FORGE_SKILL_PREFIX}` namespace placeholder is stripped). The skill name uses the
228
229
  * same `:`-delimited convention as our sub-phase names (e.g. "work-on:build").
229
230
  * Specs also write the `/`-delimited form (e.g. "work-on/review"); normalize it
230
231
  * to the colon form so both resolve to the same node (kept in sync with the
231
232
  * dangling-ref check in validate-spec-graph.sh). */
232
233
  function skillTargetToId(skill) {
233
- return `cmd:${skill.replace(/\//g, ":")}`;
234
+ return `cmd:${skill.replace(/^\{FORGE_SKILL_PREFIX\}/, "").replace(/\//g, ":")}`;
234
235
  }
235
236
 
236
237
  // ---------------------------------------------------------------------------
@@ -325,6 +326,12 @@ function build() {
325
326
  }
326
327
  }
327
328
 
329
+ // WRITES via the protocol codec (forge#1727): `cli.js emit X` / `forge-annotation.sh write X`
330
+ // produce the `<!-- FORGE:X -->` opening tag that is then posted, so they count as writes.
331
+ for (const m of line.matchAll(/(?:cli\.js|forge-annotation\.sh)\s+(?:emit|write)\s+([A-Z][A-Z0-9_]*)\b/g)) {
332
+ addEdge(fromId, "WRITES", `ann:FORGE:${m[1]}`, { file: rel, line: li + 1 });
333
+ }
334
+
328
335
  // READS: command consumes a FORGE annotation.
329
336
  // jq forms: contains("FORGE:X"), contains("FORGE:X:"),
330
337
  // test("<!-- FORGE:X: ..."), capture("<!-- FORGE:X: ...")
@@ -351,7 +358,7 @@ function build() {
351
358
  // Skill(skill="X"), and colon Skill(skill: "X") — with `/`-delimited
352
359
  // sub-phase targets and uppercase/leading-digit names. Kept in sync with
353
360
  // the dangling-ref check in validate-spec-graph.sh.
354
- for (const m of line.matchAll(/Skill\(\s*(?:skill\s*[:=]\s*)?["']([A-Za-z0-9][A-Za-z0-9:_/-]*)["']/g)) {
361
+ for (const m of line.matchAll(/Skill\(\s*(?:skill\s*[:=]\s*)?["']((?:\{FORGE_SKILL_PREFIX\})?[A-Za-z0-9][A-Za-z0-9:_/-]*)["']/g)) {
355
362
  const targetId = skillTargetToId(m[1]);
356
363
  // Only link if the target resolves to a known command/sub-phase node.
357
364
  if (nodes.has(targetId)) {
@@ -457,7 +464,7 @@ function selfCheck(graph, quiet) {
457
464
 
458
465
  const hash = graph.graph.builtFromHash;
459
466
  const checks = [
460
- ["work-on WRITES FORGE:TRAJECTORY", has("cmd:work-on", "WRITES", "ann:FORGE:TRAJECTORY")],
467
+ ["work-on:close WRITES FORGE:TRAJECTORY", has("cmd:work-on:close", "WRITES", "ann:FORGE:TRAJECTORY")],
461
468
  ["review-pr READS FORGE:CONTRACT", has("cmd:review-pr", "READS", "ann:FORGE:CONTRACT")],
462
469
  ["builtFromHash is a sha256 hex digest", typeof hash === "string" && /^[0-9a-f]{64}$/.test(hash)],
463
470
  ];
@@ -89,18 +89,21 @@ MARKER_REGISTRY="
89
89
  BLOCKED_ON_HUMAN_MERGE
90
90
  BODY
91
91
  BUILDER
92
+ BUILD_BLOCKED
92
93
  CALIBRATION_CHECK
93
94
  CARD
94
- CLASS
95
95
  CHECKPOINT
96
+ CI_REMEDIATION
96
97
  CLAIM
97
98
  CLAIM_RELEASED
99
+ CLASS
98
100
  CONTEXT
99
101
  CONTRACT
100
102
  COORD_ISSUE
101
103
  CRITIQUE
102
104
  DECISION_RECORD
103
105
  DECOMPOSED
106
+ DECOMPOSE_BLOCKED
104
107
  DESIGN
105
108
  DISPATCH
106
109
  DISPATCHER
@@ -133,6 +136,9 @@ MARKER_REGISTRY="
133
136
  PATTERN
134
137
  PHASE
135
138
  PHASE_COMPLETE
139
+ PHASE_TRAIL_ERROR
140
+ PHASE_TRAIL_FAILED
141
+ PHASE_TRAIL_RELEASED
136
142
  PLAN_DAG
137
143
  PRIOR_DECISIONS
138
144
  PRIOR_GIST
@@ -140,6 +146,9 @@ MARKER_REGISTRY="
140
146
  PUSH_BLOCKED
141
147
  PUSH_BLOCKED_EMPTY_BRANCH
142
148
  PUSH_FAILED
149
+ QUALITY_GATE
150
+ RECOVERY_CLAIM
151
+ RECOVERY_CLAIM_RELEASED
143
152
  REMEDIATION
144
153
  REREVIEW_SKIPPED
145
154
  REVIEW
@@ -152,6 +161,7 @@ MARKER_REGISTRY="
152
161
  SPAWN_POLICY
153
162
  SPEC_DOCTOR_COMPLETE
154
163
  SPEC_LOADED
164
+ STALE_REREVIEW
155
165
  STALL_DETECTED
156
166
  STATE
157
167
  SYNTHESIS_BRIEF
@@ -20,33 +20,18 @@ extract_classifier() {
20
20
  }
21
21
 
22
22
  CLOSE_BLOCK=$(extract_block "$ROOT/commands/work-on/close.md")
23
- INLINE_BLOCK=$(extract_block "$ROOT/commands/work-on.md")
24
- [[ "$CLOSE_BLOCK" == "$INLINE_BLOCK" ]] || {
25
- printf 'FAIL: mirrored classifier blocks differ\n' >&2
26
- exit 1
27
- }
28
-
23
+ [ -n "$CLOSE_BLOCK" ] || { printf 'FAIL: close.md structural computation block not found\n' >&2; exit 1; }
29
24
  CLOSE_CLASSIFIER=$(extract_classifier "$ROOT/commands/work-on/close.md")
30
- INLINE_CLASSIFIER=$(extract_classifier "$ROOT/commands/work-on.md")
31
- [[ "$CLOSE_CLASSIFIER" == "$INLINE_CLASSIFIER" ]] || {
32
- printf 'FAIL: mirrored classifier implementations differ\n' >&2
25
+ [ -n "$CLOSE_CLASSIFIER" ] || { printf 'FAIL: close.md classifier not found\n' >&2; exit 1; }
26
+ # close.md is the single owner of the classifier; the work-on.md router must not carry a copy.
27
+ if grep -q '^FENCE_COUNT=' "$ROOT/commands/work-on.md"; then
28
+ printf 'FAIL: work-on.md (router) must not contain a copy of the close-path classifier\n' >&2
33
29
  exit 1
34
- }
35
-
36
- if grep -q 'echo "\$BODY\|echo "\$BODY_STRIPPED' \
37
- "$ROOT/commands/work-on/close.md" "$ROOT/commands/work-on.md"; then
30
+ fi
31
+ if grep -q 'echo "\$BODY\|echo "\$BODY_STRIPPED' "$ROOT/commands/work-on/close.md"; then
38
32
  printf 'FAIL: arbitrary issue bodies must be piped with printf, not echo\n' >&2
39
33
  exit 1
40
34
  fi
41
-
42
- grep -Fq 'Keep the structural computation above and its consuming multi-phase guard' "$ROOT/commands/work-on.md" || {
43
- printf 'FAIL: Phase 6A sync invariant must cover the consuming guard\n' >&2
44
- exit 1
45
- }
46
- grep -Fq 'The guard is `CHECKBOX_SECTIONS >= 2 OR SUBISSUE_ITEMS > 0`' "$ROOT/commands/work-on.md" || {
47
- printf 'FAIL: Phase 6A must document the Phase C1 guard\n' >&2
48
- exit 1
49
- }
50
35
  grep -Fq 'if [ "${CHECKBOX_SECTIONS:-0}" -ge 2 ] || [ "${SUBISSUE_ITEMS:-0}" -gt 0 ]; then' "$ROOT/commands/work-on/close.md" || {
51
36
  printf 'FAIL: Phase C1 multi-phase guard changed unexpectedly\n' >&2
52
37
  exit 1
@@ -64,4 +49,4 @@ SINGLE_BODY=$'## Acceptance Criteria\n- [ ] complete this work'
64
49
  exit 1
65
50
  }
66
51
 
67
- printf 'PASS: mirrored classifier supports setext section boundaries\n'
52
+ printf 'PASS: close-path classifier supports setext section boundaries\n'
@@ -83,7 +83,10 @@
83
83
 
84
84
  set -euo pipefail
85
85
 
86
- EXT_REGEX='`[^`]*\.(py|tsx?|jsx?|sql|json|ya?ml|mjs|js|sh|md)`'
86
+ # An optional line-reference suffix is accepted and stripped: review findings cite
87
+ # `file.md:211`, `file.sh:104-114,162` or `file.md:~1685`, which the strict form missed,
88
+ # leaving every finding-batch DAG on the low-confidence body fallback (field test).
89
+ EXT_REGEX='`[^`]*\.(py|tsx?|jsx?|sql|json|ya?ml|mjs|js|sh|md)(:~?[0-9][0-9,~-]*)?`'
87
90
 
88
91
  # --------------------------------------------------------------------------- #
89
92
  # Argument parsing — mirrors scripts/issue-dedup.sh's -R / -R\ * case arms
@@ -190,7 +193,7 @@ extract_paths() {
190
193
  # locales` otherwise) — a portability trap the original inline pseudocode
191
194
  # in phase-3-dependency.md carried (forge#2436) and that this script fixes
192
195
  # by not needing PCRE at all.
193
- grep -oE "$EXT_REGEX" <<< "$1" 2>/dev/null | tr -d '`' | sort -u || true
196
+ grep -oE "$EXT_REGEX" <<< "$1" 2>/dev/null | tr -d '`' | sed -E 's/:~?[0-9][0-9,~-]*$//' | sort -u || true
194
197
  }
195
198
 
196
199
  PROVENANCE="none"
@@ -472,6 +472,33 @@ MOCK_GH_CONTRACT_FAIL=1 MOCK_GH_COMMENTS="$INV_DIFFERENT" assert_output \
472
472
 
473
473
  unset MOCK_GH_CONTRACT_FAIL
474
474
 
475
+ # --------------------------------------------------------------------------- #
476
+ # Line-reference suffixes (field test, orchestrate #3149 / stress batch): review
477
+ # findings cite paths as `file:line`, `file:a-b,c` or `file:~n`. Those must be
478
+ # extracted with the suffix stripped, and the same file cited twice deduplicated.
479
+ # --------------------------------------------------------------------------- #
480
+ BODY_LINESUFFIX="$TMP_FIXTURES/body-linesuffix.md"
481
+ cat > "$BODY_LINESUFFIX" <<'EOF'
482
+ ## Problem
483
+
484
+ Gate trusts agent-chosen band.
485
+
486
+ ## Affected Files
487
+
488
+ 1. `scripts/verify-phase-trail.sh:211` — band not cross-checked
489
+ 2. `scripts/verify-phase-trail.sh:104-114,162` — pagination
490
+ 3. `commands/orchestrate/phase-4-execution.md:~1685` — completion check
491
+ 4. `commands/work-on/build/validate.md:433` — QG PASS not SHA-bound
492
+
493
+ ## Source Branch Context
494
+
495
+ **Code branch**: `staging`
496
+ EOF
497
+ unset MOCK_GH_COMMENTS
498
+ MOCK_GH_BODY="$BODY_LINESUFFIX" assert_output \
499
+ "line-reference suffixes are stripped and deduplicated" \
500
+ "body-fallback" "commands/orchestrate/phase-4-execution.md,commands/work-on/build/validate.md,scripts/verify-phase-trail.sh" 2390 -R test/repo
501
+
475
502
  # --------------------------------------------------------------------------- #
476
503
  # Summary
477
504
  # --------------------------------------------------------------------------- #
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env bash
2
+ # forge-root.test.sh — guards the canonical FORGE_ROOT bootstrap (forge#3098).
3
+ # Plugin installs set neither FORGEDOCK_HOME nor FORGE_HOME; the specs must resolve ForgeDock's OWN
4
+ # install root (never the consumer repo) and fail closed when it cannot be found.
5
+ # Usage: bash scripts/forge-root.test.sh
6
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
7
+ # SPDX-License-Identifier: AGPL-3.0-or-later
8
+ set -uo pipefail
9
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; ROOT="$HERE/.."
10
+ T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
11
+ PASS=0; FAILN=0
12
+ ok() { PASS=$((PASS+1)); }
13
+ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
14
+ expect() { [ "$2" = "$3" ] && ok || bad "$1 (got '$3' want '$2')"; }
15
+
16
+ SITES="commands/work-on.md commands/work-on/review.md commands/work-on/investigate.md commands/work-on/decompose.md commands/work-on/build.md commands/work-on/close.md commands/review-pr.md commands/orchestrate/phase-1-resolve.md commands/orchestrate/phase-4-execution.md"
17
+
18
+ # extract the bootstrap block (comment line .. closing top-level fi), indentation stripped
19
+ # Block ends at the first line (after the marker) that starts with `fi` at the marker's own indentation.
20
+ # extract <file> <n> prints the n-th (1-based) bootstrap copy in the file.
21
+ extract() { awk -v want="$2" '/# FORGE_ROOT bootstrap/{c++; if(c==want){f=1; match($0,/^[ ]*/); ind=RLENGTH}} f{l=$0; sub(/^[ ]+/,"",l); print l} f&&match($0,/^[ ]*fi$/)&&RLENGTH-2==ind{exit}' "$ROOT/$1"; }
22
+ count_copies() { grep -c '# FORGE_ROOT bootstrap' "$ROOT/$1"; }
23
+ extract commands/work-on.md 1 > "$T/canon"
24
+ [ -s "$T/canon" ] && ok || bad "canonical bootstrap not found in work-on.md"
25
+ # Expected copies per file are DERIVED from the number of FORGE_ROOT resolution loops
26
+ # (`FORGE_ROOT="$_c"` lines) so adding a site never needs a hardcoded count here; count_copies
27
+ # (marker comments) must still agree with it, which catches a loop pasted without its marker.
28
+ want_copies() { grep -c 'FORGE_ROOT="\$_c"' "$ROOT/$1"; }
29
+ for f in $SITES; do
30
+ n=$(count_copies "$f"); expect "exact bootstrap copy count in $f" "$(want_copies "$f")" "$n"
31
+ i=1
32
+ while [ "$i" -le "$n" ]; do # compare EVERY copy
33
+ extract "$f" "$i" > "$T/s"
34
+ cmp -s "$T/canon" "$T/s" && ok || bad "bootstrap copy $i in $f differs from canonical"
35
+ i=$((i+1))
36
+ done
37
+ done
38
+ # the canonical block must not contain the stale lexical glob / unfiltered marketplaces glob
39
+ grep -v '^[[:space:]]*#' "$T/canon" > "$T/canon.code" # code only: the comments name the banned constructs
40
+ grep -qE 'mapfile|readarray|declare -A|sort[^|]* -[a-zA-Z]*V' "$T/canon.code" && bad "canonical bootstrap uses a bash-4+/non-portable construct" || ok
41
+ grep -q 'sort -k1,1nr -k2,2nr -k3,3nr' "$T/canon.code" && ok || bad "canonical bootstrap lacks numeric component version sort"
42
+ grep -qF 'marketplaces/*; do' "$T/canon" && bad "canonical bootstrap globs all marketplaces" || ok
43
+ # review.md must hard-exit on an unreadable trail
44
+ grep -qE 'TRAIL_RC.* -ge 2 .*exit 1' "$ROOT/commands/work-on/review.md" && ok || bad "work-on/review.md lacks hard exit guard for TRAIL_RC>=2"
45
+ # behavioral: execute the guard line itself. rc>=2 => BLOCKED on STDOUT + exit 1; rc 0/1 => falls through.
46
+ grep -E 'TRAIL_RC.* -ge 2 .*exit 1' "$ROOT/commands/work-on/review.md" | head -1 > "$T/guard"
47
+ for rc in 2 127; do
48
+ out=$(TRAIL_RC=$rc bash -c "$(cat "$T/guard")" 2>/dev/null); grc=$?
49
+ expect "review guard exits 1 for TRAIL_RC=$rc" 1 "$grc"
50
+ # The guard prints the full REVIEW_RESULT block (multi-line) to STDOUT.
51
+ expect "review guard prints REVIEW_RESULT block for TRAIL_RC=$rc" "REVIEW_RESULT:| status: BLOCKED| blocker: phase trail unreadable (rc=$rc)" "$(printf '%s\n' "$out" | grep -E '^(REVIEW_RESULT:| status:| blocker:)' | paste -sd'|' -)"
52
+ done
53
+ for rc in 0 1; do
54
+ out=$(TRAIL_RC=$rc bash -c "$(cat "$T/guard"); echo passed" 2>&1); expect "review guard falls through for TRAIL_RC=$rc" passed "$out"
55
+ done
56
+
57
+ # no consumer-repo fallback / fail-open remains in the gates
58
+ for f in $SITES; do
59
+ grep -nE 'FORGE_HOME:-\$?\{?\{?REPO_PATH\}*/scripts' "$ROOT/$f" >/dev/null && bad "repo-path fallback in $f" || ok
60
+ done
61
+ for f in commands/review-pr.md commands/work-on/review.md; do
62
+ grep -nE '^[[:space:]]*TRAIL_RC=0[[:space:]]*$' "$ROOT/$f" >/dev/null && bad "fail-open TRAIL_RC=0 in $f" || ok
63
+ done
64
+
65
+ run() { # run <home> [env assignments...] -> prints FORGE_ROOT
66
+ # CLAUDE_PLUGIN_ROOT=<v> is NOT an env var in a real session: Claude Code substitutes the literal
67
+ # text ${CLAUDE_PLUGIN_ROOT} when it loads a plugin spec. Simulate exactly that; every other
68
+ # assignment is passed through as environment.
69
+ local h="$1"; shift; local code; code="$(cat "$T/canon")"; local a; local envs=()
70
+ for a in "$@"; do
71
+ case "$a" in
72
+ CLAUDE_PLUGIN_ROOT=*) code="$(printf '%s' "$code" | awk -v v="${a#CLAUDE_PLUGIN_ROOT=}" '{gsub(/\$\{CLAUDE_PLUGIN_ROOT\}/, v); print}')" ;;
73
+ *) envs+=("$a") ;;
74
+ esac
75
+ done
76
+ ( cd "$T/consumer" && env -i PATH="$PATH" HOME="$h" ${envs[@]+"${envs[@]}"} "$SH" -c "$code; printf %s \"\$FORGE_ROOT\"" )
77
+ }
78
+ # Run the behavioral cases under every available shell: bash always, zsh when installed (macOS ships it;
79
+ # zsh aborts on an unmatched glob, which the bootstrap must not trigger). FORGE_ROOT_TEST_SHELLS overrides.
80
+ SHELLS="${FORGE_ROOT_TEST_SHELLS:-bash}"
81
+ if [ -z "${FORGE_ROOT_TEST_SHELLS:-}" ] && command -v zsh >/dev/null 2>&1; then SHELLS="bash zsh"; fi
82
+ for SH in $SHELLS; do
83
+ echo "== bootstrap behavior under: $SH"
84
+ mkdir -p "$T/consumer/scripts"; : > "$T/consumer/scripts/verify-phase-trail.sh" # hostile/lookalike consumer repo
85
+ mkscripts() {
86
+ mkdir -p "$1/scripts" "$1/bin/engine"
87
+ : > "$1/scripts/verify-phase-trail.sh"; : > "$1/scripts/lint-dispatch-prompt.sh"; : > "$1/scripts/is-docs-only.sh"
88
+ : > "$1/bin/engine/resolve.mjs"; : > "$1/bin/engine/orchestrate-canary.mjs"; : > "$1/bin/engine/admission.mjs"
89
+ }
90
+ # plugin install: no env vars at all
91
+ mkscripts "$T/h1/.claude/plugins/cache/mk/forgedock/1.0.0"
92
+ expect "plugin cache resolves with no env" "$T/h1/.claude/plugins/cache/mk/forgedock/1.0.0" "$(run "$T/h1")"
93
+ expect "CLAUDE_PLUGIN_ROOT resolves" "$T/h1/.claude/plugins/cache/mk/forgedock/1.0.0" "$(run "$T/empty" CLAUDE_PLUGIN_ROOT="$T/h1/.claude/plugins/cache/mk/forgedock/1.0.0")"
94
+ # nothing installed: empty (never the consumer repo, never a relative path)
95
+ expect "unresolvable stays empty" "" "$(run "$T/empty")"
96
+ expect "relative FORGE_HOME ignored" "" "$(run "$T/empty" FORGE_HOME=.)"
97
+ # explicit FORGEDOCK_HOME is authoritative, even if bogus (caller then fails closed)
98
+ expect "FORGEDOCK_HOME authoritative" "$T/nowhere" "$(run "$T/h1" FORGEDOCK_HOME="$T/nowhere")"
99
+ # symlink install (install.sh): ~/.claude/commands/work-on.md -> <clone>/commands/work-on.md
100
+ mkdir -p "$T/clone/commands" "$T/h2/.claude/commands"; mkscripts "$T/clone"; : > "$T/clone/commands/work-on.md"
101
+ ln -sf "$T/clone/commands/work-on.md" "$T/h2/.claude/commands/work-on.md"
102
+ expect "install.sh symlink resolves" "$(cd "$T/clone" && pwd -P)" "$(cd "$(run "$T/h2")" && pwd -P)"
103
+ expect "FORGE_HOME without scripts falls through" "$(cd "$T/clone" && pwd -P)" "$(cd "$(run "$T/h2" FORGE_HOME="$T/h2/.claude")" && pwd -P)"
104
+
105
+ # newest cached version wins (1.10.0 must beat 1.9.0)
106
+ for v in 1.9.0 1.10.0 1.2.0; do mkscripts "$T/h3/.claude/plugins/cache/mk/forgedock/$v"; done
107
+ expect "newest cached version wins" "$T/h3/.claude/plugins/cache/mk/forgedock/1.10.0" "$(run "$T/h3")"
108
+ # HOME containing a space still resolves
109
+ mkscripts "$T/sp ace/.claude/plugins/cache/mk/forgedock/2.0.0"
110
+ expect "space in HOME resolves" "$T/sp ace/.claude/plugins/cache/mk/forgedock/2.0.0" "$(run "$T/sp ace")"
111
+ # relative FORGEDOCK_HOME / CLAUDE_PLUGIN_ROOT rejected (fail closed => empty)
112
+ expect "relative FORGEDOCK_HOME rejected" "" "$(run "$T/h1" FORGEDOCK_HOME=scripts/..)"
113
+ expect "relative CLAUDE_PLUGIN_ROOT ignored" "" "$(run "$T/empty" CLAUDE_PLUGIN_ROOT=../consumer)"
114
+ # a candidate with only one of the two needed scripts is rejected
115
+ mkdir -p "$T/h4/.claude/plugins/cache/mk/forgedock/1.0.0/scripts"; : > "$T/h4/.claude/plugins/cache/mk/forgedock/1.0.0/scripts/verify-phase-trail.sh"
116
+ expect "partial install (missing lint script) rejected" "" "$(run "$T/h4")"
117
+ # stale install: has the two original gate scripts but lacks is-docs-only.sh / engine modules => rejected
118
+ for missing in scripts/is-docs-only.sh bin/engine/resolve.mjs bin/engine/orchestrate-canary.mjs bin/engine/admission.mjs; do
119
+ rm -rf "$T/h12"; mkscripts "$T/h12/.claude/plugins/cache/mk/forgedock/1.0.0"; rm -f "$T/h12/.claude/plugins/cache/mk/forgedock/1.0.0/$missing"
120
+ expect "stale install missing $missing rejected" "" "$(run "$T/h12")"
121
+ done
122
+ # a stale newest version is skipped in favor of a complete older one
123
+ rm -rf "$T/h13"; mkscripts "$T/h13/.claude/plugins/cache/mk/forgedock/1.0.0"; mkscripts "$T/h13/.claude/plugins/cache/mk/forgedock/2.0.0"
124
+ rm -f "$T/h13/.claude/plugins/cache/mk/forgedock/2.0.0/scripts/is-docs-only.sh"
125
+ expect "stale newest skipped for complete older" "$T/h13/.claude/plugins/cache/mk/forgedock/1.0.0" "$(run "$T/h13")"
126
+ # non-ForgeDock marketplace dirs are ignored; ForgeDock-named ones resolve
127
+ mkscripts "$T/h5/.claude/plugins/marketplaces/other-tool"
128
+ expect "non-ForgeDock marketplace ignored" "" "$(run "$T/h5")"
129
+ mkscripts "$T/h6/.claude/plugins/marketplaces/forgedock"
130
+ expect "forgedock marketplace resolves" "$T/h6/.claude/plugins/marketplaces/forgedock" "$(run "$T/h6")"
131
+
132
+ # release outranks its own pre-release; a newer pre-release core still beats an older release
133
+ rm -rf "$T/h7" # fixture is mutated below; reset per shell
134
+ mkscripts "$T/h7/.claude/plugins/cache/mk/forgedock/1.9.0-rc1"; mkscripts "$T/h7/.claude/plugins/cache/mk/forgedock/1.9.0"
135
+ expect "release beats its pre-release" "$T/h7/.claude/plugins/cache/mk/forgedock/1.9.0" "$(run "$T/h7")"
136
+ mkscripts "$T/h7/.claude/plugins/cache/mk/forgedock/2.0.0-rc1"
137
+ expect "newer-core pre-release beats older release" "$T/h7/.claude/plugins/cache/mk/forgedock/2.0.0-rc1" "$(run "$T/h7")"
138
+ # version component decides, not the marketplace name (zz sorts after aa lexically but holds the older version)
139
+ mkscripts "$T/h8/.claude/plugins/cache/zz-market/forgedock/1.0.0"; mkscripts "$T/h8/.claude/plugins/cache/aa-market/forgedock/1.1.0"
140
+ expect "version beats marketplace name" "$T/h8/.claude/plugins/cache/aa-market/forgedock/1.1.0" "$(run "$T/h8")"
141
+ # a non-forgedock plugin in the cache is ignored even with a higher version
142
+ mkscripts "$T/h9/.claude/plugins/cache/mk/otherplugin/9.9.9"; mkscripts "$T/h9/.claude/plugins/cache/mk/forgedock/1.0.0"
143
+ expect "non-forgedock cache plugin ignored" "$T/h9/.claude/plugins/cache/mk/forgedock/1.0.0" "$(run "$T/h9")"
144
+ # unmatched globs: empty cache dir, absent marketplaces dir, absent ~/.claude entirely (must not abort under zsh)
145
+ mkdir -p "$T/h10/.claude/plugins/cache"
146
+ expect "empty cache, no match, no abort" "" "$(run "$T/h10")"
147
+ expect "no ~/.claude at all, no abort" "" "$(run "$T/empty")"
148
+ expect "unmatched glob does not break later candidates" "$T/h1/.claude/plugins/cache/mk/forgedock/1.0.0" "$(run "$T/h10" CLAUDE_PLUGIN_ROOT="$T/h1/.claude/plugins/cache/mk/forgedock/1.0.0")"
149
+ # plugin root (textually substituted) outranks an exported FORGE_HOME that also has the scripts:
150
+ # the running plugin's own files win over a stale clone named by a global env var (forge#3147 field test)
151
+ mkscripts "$T/h11/stale-clone"; mkscripts "$T/h11/running-plugin"
152
+ expect "substituted plugin root beats FORGE_HOME" "$T/h11/running-plugin" "$(run "$T/h11" FORGE_HOME="$T/h11/stale-clone" CLAUDE_PLUGIN_ROOT="$T/h11/running-plugin")"
153
+ # unsubstituted placeholder (non-Claude runtime) is a literal, rejected by the /* check, never expanded
154
+ expect "unsubstituted placeholder ignored, FORGE_HOME used" "$T/h11/stale-clone" "$(run "$T/h11" FORGE_HOME="$T/h11/stale-clone")"
155
+ expect "unsubstituted placeholder safe under set -u" "" "$( cd "$T/consumer" && env -i PATH="$PATH" HOME="$T/empty" "$SH" -c "set -u; $(cat "$T/canon"); printf %s \"\$FORGE_ROOT\"" 2>&1 )"
156
+ # the env var alone (no substitution) must NOT be relied on: Claude Code does not export it to Bash
157
+ expect "CLAUDE_PLUGIN_ROOT env var alone is not a candidate" "" "$( cd "$T/consumer" && env -i PATH="$PATH" HOME="$T/empty" CLAUDE_PLUGIN_ROOT="$T/h11/running-plugin" "$SH" -c "$(cat "$T/canon"); printf %s \"\$FORGE_ROOT\"" )"
158
+ done
159
+ # the canonical block must use the exact substitutable spelling, single-quoted (no :- form, which Claude Code leaves verbatim)
160
+ grep -qF "'\${CLAUDE_PLUGIN_ROOT}'" "$T/canon.code" && ok || bad "canonical bootstrap lacks the substitutable '\${CLAUDE_PLUGIN_ROOT}' candidate"
161
+ grep -qF 'CLAUDE_PLUGIN_ROOT:-' "$T/canon.code" && bad "canonical bootstrap uses \${CLAUDE_PLUGIN_ROOT:-}, which Claude Code never substitutes" || ok
162
+
163
+ # no hand-built file:// URL from FORGE_ROOT remains; imports go through pathToFileURL (a '#', '?' or space in the path must survive)
164
+ for f in $SITES commands/work-on/close.md; do
165
+ grep -nE 'file://\$\{?(FORGE_ROOT|\(pwd\))' "$ROOT/$f" >/dev/null && bad "concatenated file:// URL in $f" || ok
166
+ done
167
+ if command -v node >/dev/null 2>&1; then
168
+ for d in "we#ird" "sp ace" "q?x"; do
169
+ mkdir -p "$T/url/$d/bin/engine"; echo 'export const v = 42;' > "$T/url/$d/bin/engine/resolve.mjs"
170
+ out=$(node -e 'import(require("node:url").pathToFileURL(process.argv[1]).href).then(m => process.stdout.write(String(m.v)))' "$T/url/$d/bin/engine/resolve.mjs" 2>&1)
171
+ expect "pathToFileURL import survives path '$d'" 42 "$out"
172
+ done
173
+ fi
174
+
175
+ echo "forge-root tests: pass=$PASS fail=$FAILN"
176
+ [ "$FAILN" -eq 0 ]
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env bash
2
+ # is-docs-only.sh — the single docs-only predicate shared by review-pr.md Phase 8,
3
+ # work-on/review.md R1.5 and orchestrate/phase-4-execution.md (forge#3134, forge#3145).
4
+ # Reads a newline-separated changed-file list on stdin. Callers MUST feed BOTH sides of every rename
5
+ # (git diff --no-renames, or filename + previous_filename from the PR files API): a rename-collapsed
6
+ # list hides the source path of a file moved into docs/.
7
+ # Exit 0 iff the list is non-empty and EVERY path is a Markdown file that is positively allowlisted AND
8
+ # is not executable pipeline/agent instruction content:
9
+ # - only *.md counts (docs/** non-markdown assets do NOT); matching is case-insensitive, leading ./ ignored
10
+ # - allowlist (unknown paths fail closed): docs/**/*.md, or a root-level README*/CHANGELOG*/CONTRIBUTING/SECURITY/GOVERNANCE *.md
11
+ # - any ".." path segment is rejected
12
+ # - excluded at ANY depth (even under docs/): commands/, devdocs/, templates/, skills/, agents/, hooks/,
13
+ # .claude/, .claude-plugin/, .agents/, .codex/, .cursor/, .github/, .opencode/, .gemini/, .kiro/ directories
14
+ # - excluded at ANY depth by exact basename: agents.md, claude.md, claude.local.md, skill.md, gemini.md
15
+ # (and dotted variants such as AGENTS.override.md); docs like skillset.md or agents-guide.md are fine
16
+ # Exit 1 otherwise (including empty input: fail closed).
17
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
18
+ # SPDX-License-Identifier: AGPL-3.0-or-later
19
+ awk '
20
+ NF==0 {next}
21
+ { n++; p=tolower($0); sub(/^\.\//, "", p) }
22
+ !(p ~ /\.md$/) {bad=1; next}
23
+ p ~ /(^|\/)\.\.(\/|$)/ {bad=1; next}
24
+ p ~ /(^|\/)(commands|devdocs|templates|skills|agents|hooks|\.claude|\.claude-plugin|\.agents|\.codex|\.cursor|\.github|\.opencode|\.gemini|\.kiro)\// {bad=1; next}
25
+ p ~ /(^|\/)(agents|claude|skill|gemini)(\.[^\/]*)?\.md$/ {bad=1; next}
26
+ p ~ /^docs\/.+\.md$/ {next}
27
+ p ~ /^(readme|changelog)[^\/]*\.md$/ {next}
28
+ p ~ /^(contributing|security|governance)\.md$/ {next}
29
+ {bad=1}
30
+ END{exit (bad || n==0)}
31
+ '
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env bash
2
+ # is-docs-only.test.sh — cases for scripts/is-docs-only.sh (forge#3134).
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ set -uo pipefail
6
+ S="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/is-docs-only.sh"
7
+ PASS=0; FAILN=0
8
+ t() { # name want files...
9
+ local name="$1" want="$2"; shift 2
10
+ printf '%s\n' "$@" | bash "$S"; local rc=$?
11
+ if [ "$rc" = "$want" ]; then PASS=$((PASS+1)); else FAILN=$((FAILN+1)); echo "FAIL: $name (rc=$rc want $want)"; fi
12
+ }
13
+ t "docs md" 0 docs/a.md README.md docs/sub/b.md
14
+ t "root AGENTS.md" 1 AGENTS.md
15
+ t "root CLAUDE.md" 1 docs/a.md CLAUDE.md
16
+ t "nested AGENTS.md" 1 sub/AGENTS.md
17
+ t "nested CLAUDE.md" 1 pkg/x/CLAUDE.md
18
+ t ".codex md" 1 .codex/x.md
19
+ t "SKILL.md" 1 skills/a/SKILL.md
20
+ t ".github md" 1 .github/x.md
21
+ t ".agents md" 1 .agents/skills/x.md
22
+ t ".claude md" 1 .claude/x.md
23
+ t "commands md" 1 commands/work-on.md
24
+ t "nested commands md" 1 pkg/commands/x.md
25
+ t "docs non-md" 1 docs/diagram.png
26
+ t "docs yml" 1 docs/a.md docs/site/mkdocs.yml
27
+ t "code file" 1 docs/a.md bin/x.mjs
28
+ t "empty" 1
29
+ t "blank only" 1 "" ""
30
+ t "case agents.md" 1 agents.md
31
+ t "case Skill.md" 1 Skill.md
32
+ t "case Commands dir" 1 Commands/x.md
33
+ t "case .GitHub" 1 .GitHub/x.md
34
+ t "CLAUDE.local.md" 1 CLAUDE.local.md
35
+ t "GEMINI.md" 1 GEMINI.md
36
+ t ".cursor rules" 1 .cursor/rules/x.md
37
+ t "hooks md" 1 hooks/README.md
38
+ t ".claude-plugin" 1 .claude-plugin/x.md
39
+ t "upper .MD (case-insensitive markdown)" 0 docs/a.MD
40
+ t "leading ./ docs" 0 ./docs/a.md
41
+ t "leading ./ excluded" 1 ./commands/x.md
42
+ t "docs traversal" 1 docs/../commands/x.md
43
+ t "valid + blank lines" 0 docs/a.md "" README.md
44
+ t "rename pair (source in commands/)" 1 commands/x.md docs/x.md
45
+ t "rename collapsed dest only (caller bug, predicate alone cannot see)" 0 docs/x.md
46
+ t "devdocs md" 1 devdocs/agent/custom-instructions.md
47
+ t "templates devdocs agent md" 1 templates/devdocs/agent/x.md
48
+ t ".opencode md" 1 .opencode/x.md
49
+ t ".gemini md" 1 .gemini/x.md
50
+ t ".kiro md" 1 .kiro/steering/x.md
51
+ t "skills dir md" 1 skills/a/readme.md
52
+ t "templates md" 1 templates/x.md
53
+ t "unknown dir md fails closed" 1 notes/x.md
54
+ t "nested unknown README fails closed" 1 pkg/README.md
55
+ t "docs/skillset.md accepted" 0 docs/skillset.md
56
+ t "docs/Agents-guide.md accepted" 0 docs/Agents-guide.md
57
+ t "docs/AGENTS.md rejected" 1 docs/AGENTS.md
58
+ t "AGENTS.override.md rejected" 1 AGENTS.override.md
59
+ t "docs/skill.md rejected" 1 docs/skill.md
60
+ t "root CHANGELOG" 0 CHANGELOG.md
61
+ t "root CONTRIBUTING" 0 CONTRIBUTING.md
62
+ t "docs under devdocs-named dir" 1 docs/devdocs/x.md
63
+ echo "is-docs-only: $PASS passed, $FAILN failed"
64
+ [ "$FAILN" -eq 0 ]