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
@@ -123,7 +123,7 @@ export function canDeduplicateAutomatedAlert(canonical, candidate) {
123
123
  * amplification remains at or above 1.0.
124
124
  * @property {boolean} amplificationBreaker - On-by-default circuit breaker over ALL
125
125
  * cascade findings (not only same-lineage refinements): when the last
126
- * `convergenceWindow` amplification ratios are all >= 1.0, P3-and-below admission
126
+ * `convergenceWindow` amplification ratios are all >= 1.0, P3 admission
127
127
  * pauses (P1/P2 are never affected). Opt out with `amplification_breaker: off`.
128
128
  * It is deliberately independent of the cascade presets, so lifting the
129
129
  * generation cap or token budget (`policy: all`) never disables it.
@@ -478,9 +478,9 @@ export function evaluateCascadeFinding(finding, policy) {
478
478
  if (!admitsTokenSpend(finding.projectedTokenSpend, policy)) {
479
479
  return { admit: false, reason: `per-batch token budget exhausted (orchestration.cascade.token_budget=${policy.tokenBudget})` };
480
480
  }
481
- // Rule 6 (forge#3060) is evaluated last, mirroring the bash chain. It only gates P3-and-below:
482
- // P0/P1/P2 already returned above.
483
- if (policy.amplificationBreaker && finding.amplificationBreakerTripped && /^P[3-9]$/.test(finding.priority || "")) {
481
+ // Rule 6 (forge#3060) is evaluated last, mirroring the bash chain. It only gates P3 (whitespace stripped
482
+ // and upper-cased, matching the bash twin's `tr -d '[:space:]'`): P0/P1/P2 already returned above.
483
+ if (policy.amplificationBreaker && finding.amplificationBreakerTripped && String(finding.priority ?? "").replace(/\s+/g, "").toUpperCase() === "P3") {
484
484
  return {
485
485
  admit: false,
486
486
  reason: "amplification breaker tripped — P3 admission paused (routed to P3 batches / completion sweep)",
@@ -583,3 +583,22 @@ export function planP3BatchGroups(findings, rules = P3_BATCHING_RULES, { openBat
583
583
 
584
584
  return { groups, extensions, ungrouped: [...remaining.keys()] };
585
585
  }
586
+
587
+ /**
588
+ * Pure per-run counts over a `planP3BatchGroups()` result, for the batching
589
+ * summary: clusters formed, members absorbed, open batches extended, members
590
+ * added to extended batches, and ungrouped members. Tolerates missing keys.
591
+ */
592
+ export function summarizeP3BatchPlan(plan) {
593
+ const groups = Array.isArray(plan?.groups) ? plan.groups : [];
594
+ const extensions = Array.isArray(plan?.extensions) ? plan.extensions : [];
595
+ const ungrouped = Array.isArray(plan?.ungrouped) ? plan.ungrouped : [];
596
+ const count = (items) => items.reduce((sum, item) => sum + (Array.isArray(item?.members) ? item.members.length : 0), 0);
597
+ return {
598
+ clusters: groups.length,
599
+ absorbed: count(groups),
600
+ extended: extensions.length,
601
+ extensionMembers: count(extensions),
602
+ ungrouped: ungrouped.length,
603
+ };
604
+ }
@@ -16,6 +16,7 @@ import {
16
16
  evaluateAmplificationBreaker,
17
17
  batchExclusionReason,
18
18
  planP3BatchGroups,
19
+ summarizeP3BatchPlan,
19
20
  canDeduplicateAutomatedAlert,
20
21
  } from "./admission.mjs";
21
22
 
@@ -260,6 +261,18 @@ describe("evaluateAmplificationBreaker (forge#3060)", () => {
260
261
  assert.equal(evaluateCascadeFinding({ ...base, priority: "P3", amplificationBreakerTripped: false }, policy).admit, true);
261
262
  });
262
263
 
264
+ it("rule 6 normalizes case/whitespace on priority (fail closed)", () => {
265
+ const { policy } = resolveCascadePolicy({ policy: "all" });
266
+ const base = { generation: 1, title: "x", sameFileAsBatch: false, batchFullyGated: false, projectedTokenSpend: 0, amplificationBreakerTripped: true };
267
+ assert.equal(evaluateCascadeFinding({ ...base, priority: "p3" }, policy).admit, false);
268
+ assert.equal(evaluateCascadeFinding({ ...base, priority: " P3 " }, policy).admit, false);
269
+ assert.equal(evaluateCascadeFinding({ ...base, priority: "P2" }, policy).admit, true);
270
+ assert.equal(evaluateCascadeFinding({ ...base, priority: "P 3" }, policy).admit, false);
271
+ assert.equal(evaluateCascadeFinding({ ...base, priority: undefined }, policy).admit, true);
272
+ assert.equal(evaluateCascadeFinding({ ...base, priority: null }, policy).admit, true);
273
+ assert.equal(evaluateCascadeFinding({ ...base, priority: "P4" }, policy).admit, true);
274
+ });
275
+
263
276
  it("rule 6 never gates untriaged (empty/unknown) priorities", () => {
264
277
  const { policy } = resolveCascadePolicy();
265
278
  const f = { generation: 1, priority: "", title: "x", sameFileAsBatch: false, batchFullyGated: false, projectedTokenSpend: 0, amplificationBreakerTripped: true };
@@ -504,3 +517,27 @@ describe("planP3BatchGroups — concern-level P3 batching", () => {
504
517
  assert.equal(batchExclusionReason({ labels: ["priority:P2"], affectedFile: ".env.example" }), "high-blast-radius");
505
518
  });
506
519
  });
520
+
521
+ describe("summarizeP3BatchPlan", () => {
522
+ it("counts clusters, absorbed, extensions and ungrouped from a real plan", () => {
523
+ const f = (number, affectedFile) => ({ number, affectedFile, title: "t", body: "", labels: ["priority:P3"] });
524
+ const plan = planP3BatchGroups(
525
+ [f(1, "scripts/a.sh"), f(2, "scripts/a.sh"), f(3, "scripts/b.sh"), f(4, "docs/x.md")],
526
+ undefined,
527
+ { openBatches: [{ number: 99, affectedFile: "scripts/b.sh", members: [], memberCount: 1 }] },
528
+ );
529
+ const summary = summarizeP3BatchPlan(plan);
530
+ assert.equal(summary.clusters, plan.groups.length);
531
+ assert.equal(summary.absorbed, plan.groups.reduce((n, g) => n + g.members.length, 0));
532
+ assert.equal(summary.extended, 1);
533
+ assert.equal(summary.extensionMembers, 1);
534
+ assert.equal(summary.ungrouped, plan.ungrouped.length);
535
+ });
536
+
537
+ it("tolerates empty and partial plans", () => {
538
+ const zero = { clusters: 0, absorbed: 0, extended: 0, extensionMembers: 0, ungrouped: 0 };
539
+ assert.deepEqual(summarizeP3BatchPlan({}), zero);
540
+ assert.deepEqual(summarizeP3BatchPlan(undefined), zero);
541
+ assert.deepEqual(summarizeP3BatchPlan({ ungrouped: [5, 6] }), { ...zero, ungrouped: 2 });
542
+ });
543
+ });
@@ -206,14 +206,20 @@ Classify `$ARGUMENTS` and persist that classification alongside the resolved iss
206
206
  ```bash
207
207
  # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
208
208
  FORGE_ROOT=""
209
- if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
210
- # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
211
- _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")")"
212
- # 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)
213
- _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-)"
214
- _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
209
+ # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
210
+ _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
211
+ # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
212
+ _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
213
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
214
+ # 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.
215
+ _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")")"
216
+ # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
217
+ _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
218
+ # 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)
219
+ _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)"
220
+ _m="$HOME/.claude/plugins/marketplaces/$_mk"
215
221
  # '${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.
216
- _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
222
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
217
223
  while IFS= read -r _c; do
218
224
  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
219
225
  done <<< "$_k"
@@ -377,26 +383,33 @@ done
377
383
 
378
384
  Before finalizing the issue set, apply the shared batching rule to reduce full-pipeline overhead on routine review findings.
379
385
 
380
- **Single implementation (MANDATORY):** `bin/engine/admission.mjs` owns the eligibility, same-file, leaf-directory, age, eight-member, open-batch-extension, and singleton-reason rules through `planP3Batches()`. Every admission point supplies its complete current candidate registry plus parsed open batch metadata to that function, then executes only its returned `create`/`extend` actions. Do not independently reproduce thresholds or eligibility predicates in this file. The legacy shell examples below describe GitHub data collection and action execution only; their grouping decisions must be replaced by the evaluator result. Include original resolved issues, prior-cycle singletons, cascade findings, predicate re-resolution matches, and completion-sweep candidates in the registry. <!-- Added: forge#2851 -->
386
+ **Single implementation (MANDATORY):** `bin/engine/admission.mjs` owns the eligibility, same-file, source-PR, defect-class, leaf-directory, eight-member and open-batch-extension rules through `planP3BatchGroups()`. Every admission point supplies its complete current candidate registry plus parsed open batch metadata to that function, then executes only its returned `groups`/`extensions` (member issue numbers). Do not independently reproduce thresholds or eligibility predicates in this file. The legacy shell examples below describe GitHub data collection and action execution only; their grouping decisions must be replaced by the evaluator result. Include original resolved issues, prior-cycle singletons, cascade findings, predicate re-resolution matches, and completion-sweep candidates in the registry. <!-- Added: forge#2851 -->
381
387
 
382
- After executing the plan, retain every singleton in the registry for the next admission event and record `summarizeP3BatchPlan()` in the per-run batching summary: clusters formed, members absorbed, open batches extended, and ungrouped members with their reasons.
388
+ After executing the plan, retain every ungrouped finding in the registry for the next admission event and record `summarizeP3BatchPlan()` in the per-run batching summary: clusters formed, members absorbed, open batches extended, and ungrouped member count. The engine returns ungrouped members as issue numbers only; use `batchExclusionReason()` when the reason for an exclusion is needed.
383
389
 
384
390
  ```bash
385
391
  # BATCH_CANDIDATE_REGISTRY_JSON contains every candidate accumulated so far, with
386
- # a stable `id` (`{repo}:{number}`), repo, title, problem/body, labels, affectedFile,
387
- # createdAt, and isBatch. OPEN_BATCHES_JSON contains existing batch issue metadata.
388
- # Run this after initial resolution and every later admission event; execute returned
389
- # actions using memberIds, never bare issue numbers, so satellite issues cannot collide.
392
+ # `number` and `affectedFile` (required: the engine silently drops findings without
393
+ # them), plus repo, title, problem/body, labels. OPEN_BATCHES_JSON contains existing
394
+ # batch metadata as `{number, affectedFile, members, memberCount}`.
395
+ # Run this after initial resolution and every later admission event; execute the
396
+ # returned `groups`/`extensions` member numbers against the repo each candidate came from.
390
397
  # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
391
398
  FORGE_ROOT=""
392
- if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
393
- # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
394
- _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")")"
395
- # 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)
396
- _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-)"
397
- _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
399
+ # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
400
+ _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
401
+ # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
402
+ _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
403
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
404
+ # 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.
405
+ _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")")"
406
+ # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
407
+ _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
408
+ # 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)
409
+ _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)"
410
+ _m="$HOME/.claude/plugins/marketplaces/$_mk"
398
411
  # '${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.
399
- _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
412
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
400
413
  while IFS= read -r _c; do
401
414
  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
402
415
  done <<< "$_k"
@@ -406,11 +419,14 @@ fi
406
419
  BATCH_PLAN=""
407
420
  if [ -n "$FORGE_ROOT" ] && [ -r "$FORGE_ROOT/bin/engine/admission.mjs" ]; then
408
421
  BATCH_PLAN=$(node -e '
409
- import(require("node:url").pathToFileURL(process.argv[1]).href).then(({ planP3Batches, summarizeP3BatchPlan }) => {
410
- const plan = planP3Batches({ candidates: JSON.parse(process.argv[2]), openBatches: JSON.parse(process.argv[3]) });
422
+ import(require("node:url").pathToFileURL(process.argv[1]).href).then(({ planP3BatchGroups, summarizeP3BatchPlan }) => {
423
+ const plan = planP3BatchGroups(JSON.parse(process.argv[2]), undefined, { openBatches: JSON.parse(process.argv[3]) });
411
424
  console.log(JSON.stringify({ plan, summary: summarizeP3BatchPlan(plan) }));
412
- });
413
- ' "$FORGE_ROOT/bin/engine/admission.mjs" "$BATCH_CANDIDATE_REGISTRY_JSON" "$OPEN_BATCHES_JSON")
425
+ }).catch((e) => { console.error("WARNING: planP3Batches failed: " + e.message); process.exit(1); });
426
+ ' "$FORGE_ROOT/bin/engine/admission.mjs" "$BATCH_CANDIDATE_REGISTRY_JSON" "$OPEN_BATCHES_JSON") || BATCH_PLAN=""
427
+ if [ -z "$BATCH_PLAN" ]; then
428
+ echo "WARNING: P3 batch plan empty (admission.mjs import or JSON parse failed) — candidates retained as ungrouped singletons" >&2
429
+ fi
414
430
  else
415
431
  echo "WARNING: ForgeDock install root unresolved or admission.mjs missing (set FORGEDOCK_HOME) — no P3 batch plan; candidates retained as ungrouped singletons" >&2
416
432
  fi
@@ -1388,13 +1388,22 @@ if [ -n "${FORGE_COORD_ISSUE:-}" ] && [ -n "${COORD_ISSUE_NUMBER:-}" ] && [ -n "
1388
1388
  fi
1389
1389
 
1390
1390
  # 1. Re-fetch all issue labels and classify each into DONE / GATED / FAILED / IN_PROGRESS
1391
- # (same classify_predecessor_state() function defined in phase-4-execution.md Step 4B —
1392
- # re-declare it here if this block runs in a fresh context that hasn't sourced Step 4B yet).
1391
+ # (same classify_predecessor_state() function defined in phase-4-execution.md Step 4B).
1392
+ # MANDATORY in a fresh context (wake/compaction): re-declare byte-identically from phase-4-execution.md Step 4B,
1393
+ # in this order, hold_merged_trail, resolve_orch_login, trail_escalation_state, release_merged_trail and
1394
+ # classify_predecessor_state, in the SAME shell as the loop below (guarded by the declare -F check below).
1393
1395
  # forge#3168: classify_predecessor_state() also needs these Step 4B helpers declared in the SAME shell:
1394
1396
  # hold_merged_trail, resolve_orch_login, trail_escalation_state, release_merged_trail (reverify_merged_trail is
1395
1397
  # nested inside the classifier). It reports a missing helper on stderr and classifies GATED. The same trusted-author
1396
- # filter (OWNER/MEMBER/COLLABORATOR or the orchestrator login) applies to the release/fail markers on wake.
1398
+ # filter (OWNER, the orchestrator login, or a MEMBER/COLLABORATOR with admin/maintain/write on `collaborators/<login>/permission`; forge#3207) applies to the release/fail markers on wake.
1397
1399
  # forge#3169: merged + needs-human with no escalation record classifies GATED on wake (never keyed on the verifier).
1400
+ # The helpers cache trail lookups in the stable per-user/per-repo dir (trail_cache_dir / init_trail_cache, phase-4-execution.md Step 4B).
1401
+ # resolve_orch_login queries the GraphQL viewer login and caches it in that same dir (forge#3205), so init_trail_cache must run before it for the cache to apply (otherwise it re-queries per call).
1402
+ # Re-declare those helpers first (same Bash block), then reset the cache; if init fails the helpers simply skip caching.
1403
+ declare -F init_trail_cache >/dev/null && init_trail_cache || echo "WARNING: init_trail_cache not declared — trail lookups will be uncached" >&2
1404
+ for H in hold_merged_trail resolve_orch_login trail_escalation_state release_merged_trail classify_predecessor_state; do
1405
+ declare -F "$H" >/dev/null || echo "WARNING: $H not declared — re-declare it from phase-4-execution.md Step 4B before classifying; merged-trail predecessors will classify GATED" >&2
1406
+ done
1398
1407
  declare -A ISSUE_CLASS
1399
1408
  declare -A ISSUE_FILES
1400
1409
  DONE_ISSUES=()
@@ -1508,20 +1517,56 @@ BLOCKED_NOW=$(gh issue list -R {GH_REPO} --state open --label "blocked-on-human-
1508
1517
  --jq '.[].number' 2>/dev/null || echo "")
1509
1518
  for DEP in $BLOCKED_NOW; do
1510
1519
  # Read which predecessor(s) this DEP is tracked against
1511
- GATING_PREDS_RAW=$(gh api repos/{GH_REPO}/issues/${DEP}/comments \
1512
- --jq '[.[] | select(.body | contains("FORGE:BLOCKED_ON_HUMAN_MERGE")) | (.body | capture("Gating predecessor\\*\\*: #(?<p>[0-9]+)").p)]' 2>/dev/null || echo '[]')
1520
+ # Fetch is split from `sort`: in a pipeline, `|| echo ""` binds to sort's status (dead code), so a failed or
1521
+ # partially paginated gh api would be read as a (truncated) list and could release DEP. Capture gh's own status.
1522
+ GATING_PREDS_RAW=$(gh api --paginate repos/{GH_REPO}/issues/${DEP}/comments \
1523
+ --jq '.[] | select((.body // "") | contains("FORGE:BLOCKED_ON_HUMAN_MERGE")) | ((.body // "") | capture("Gating predecessor\\*\\*: #(?<p>[0-9]+)").p)' 2>/dev/null) \
1524
+ || { echo "WARNING: #${DEP} gating-predecessor lookup failed - keeping blocked; retrying next wake." >&2; continue; }
1525
+ GATING_PREDS_RAW=$(printf '%s\n' "$GATING_PREDS_RAW" | sort -u | sed '/^$/d')
1526
+ [ -n "$GATING_PREDS_RAW" ] || { echo "WARNING: #${DEP} has blocked-on-human-merge but no parseable Gating predecessor line - leaving blocked" >&2; continue; } # no tracked predecessor found: fail closed, stay blocked
1513
1527
  STILL_GATED=false
1514
- for GPRED in $(echo "$GATING_PREDS_RAW" | jq -r '.[]' 2>/dev/null); do
1528
+ for GPRED in $GATING_PREDS_RAW; do
1515
1529
  GPRED_CLASS=$(classify_predecessor_state "$GPRED")
1516
1530
  [ "$GPRED_CLASS" != "DONE" ] && STILL_GATED=true
1517
1531
  done
1518
1532
  if [ "$STILL_GATED" = "false" ]; then
1533
+ # Hardened release sequence — keep in sync with phase-4-execution.md Step 4B item 6.6 (same ordering and fail-closed semantics;
1534
+ # a fix to one copy MUST be applied to the other). <!-- Added: forge#3245 -->
1535
+ if [ "${DRY_RUN:-false}" = "true" ]; then echo "DRY_RUN: would release #${DEP} (remove blocked-on-human-merge, post FORGE:UNBLOCKED, dispatch)"; continue; fi
1536
+ # Idempotency: skip if another path already dispatched DEP.
1537
+ DEP_ALREADY_DISPATCHED=$(gh issue view "$DEP" -R {GH_REPO} --json labels \
1538
+ --jq '[.labels[].name | select(startswith("workflow:"))] | length' 2>/dev/null || echo "0")
1539
+ [ "$DEP_ALREADY_DISPATCHED" -eq 0 ] || continue
1540
+ # Durable marker FIRST (deduped against the last comment so a retried wake does not repeat it): an interrupted release
1541
+ # then leaves durable evidence instead of an unlabelled, undispatched issue.
1542
+ LAST_DEP_COMMENT=$(gh issue view "$DEP" -R {GH_REPO} --json comments --jq '.comments[-1].body // ""' 2>/dev/null || echo "")
1543
+ case "$LAST_DEP_COMMENT" in "<!-- FORGE:UNBLOCKED -->"*) ;; *)
1544
+ gh issue comment "$DEP" -R {GH_REPO} --body "<!-- FORGE:UNBLOCKED -->
1545
+ All gating predecessor(s) are now resolved — merged, or released by a human after a phase-trail hold (detected on orchestrator wake) — dispatching now." >/dev/null 2>&1 \
1546
+ || { echo "WARNING: #${DEP} UNBLOCKED marker not posted — not releasing; retrying next wake." >&2; continue; } ;;
1547
+ esac
1548
+ # Remove the hold label and verify it is gone. Fail CLOSED: a failed edit OR a failed/empty `view` is "unverified" —
1549
+ # skip dispatch and retry next wake (the view result is captured separately, never piped into grep).
1550
+ gh issue edit "$DEP" -R {GH_REPO} --remove-label "blocked-on-human-merge" >/dev/null 2>&1 \
1551
+ || { echo "WARNING: #${DEP} label removal failed — not dispatching; retrying next wake." >&2; continue; }
1552
+ if ! DEP_LABELS=$(gh issue view "$DEP" -R {GH_REPO} --json labels --jq '.labels[].name' 2>/dev/null) \
1553
+ || printf '%s\n' "$DEP_LABELS" | grep -qx "blocked-on-human-merge"; then
1554
+ echo "WARNING: #${DEP} label removal unverified — not dispatching; retrying next wake." >&2
1555
+ continue
1556
+ fi
1557
+ # Re-check after removal: another path may have labelled DEP workflow:* in the meantime.
1558
+ DEP_ALREADY_DISPATCHED=$(gh issue view "$DEP" -R {GH_REPO} --json labels \
1559
+ --jq '[.labels[].name | select(startswith("workflow:"))] | length' 2>/dev/null || echo "0")
1560
+ [ "$DEP_ALREADY_DISPATCHED" -eq 0 ] || continue
1519
1561
  # forge#3157: record a human release (FORGE:PHASE_TRAIL_RELEASED) of a trail-held merged predecessor (decay of the escalation marker;
1520
- # release_merged_trail is defined in phase-4-execution.md Step 4B and is a no-op unless the escalation is ACTIVE).
1521
- for GPRED in $(echo "$GATING_PREDS_RAW" | jq -r '.[]' 2>/dev/null); do release_merged_trail "$GPRED"; done
1522
- gh issue edit "$DEP" -R {GH_REPO} --remove-label "blocked-on-human-merge" 2>/dev/null || true
1523
- gh issue comment "$DEP" -R {GH_REPO} --body "<!-- FORGE:UNBLOCKED -->
1524
- All gating predecessor(s) are now resolved — merged, or released by a human after a phase-trail hold (detected on orchestrator wake) — dispatching now."
1562
+ # release_merged_trail is defined in phase-4-execution.md Step 4B and is a no-op unless the escalation is ACTIVE). It is a decay
1563
+ # record, not the dispatch gate, so a failure here is warned about but does not block dispatch.
1564
+ for GPRED in $GATING_PREDS_RAW; do
1565
+ if declare -F release_merged_trail >/dev/null; then
1566
+ release_merged_trail "$GPRED" \
1567
+ || echo "WARNING: release_merged_trail declared but failed for #${GPRED} — RELEASED record skipped" >&2
1568
+ else echo "WARNING: release_merged_trail not declared — RELEASED record for #${GPRED} skipped; re-declare from phase-4-execution.md Step 4B" >&2; fi
1569
+ done
1525
1570
  READY_ISSUES+=("$DEP")
1526
1571
  fi
1527
1572
  done