forgedock 1.8.4 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/admission.mjs +23 -4
- package/bin/engine/admission.test.mjs +37 -0
- package/commands/orchestrate/phase-1-resolve.md +40 -24
- package/commands/orchestrate/phase-3-dependency.md +56 -11
- package/commands/orchestrate/phase-4-execution.md +349 -111
- package/commands/orchestrate/phase-5-cleanup.md +17 -0
- package/commands/orchestrate/phase-6-report.md +1 -1
- package/commands/quality-gate.md +51 -0
- package/commands/recover-orphans.md +3 -3
- package/commands/review-pr.md +87 -16
- package/commands/work-on/build/context.md +25 -4
- package/commands/work-on/build/validate.md +1 -0
- package/commands/work-on/build.md +26 -9
- package/commands/work-on/close.md +104 -40
- package/commands/work-on/decompose.md +13 -7
- package/commands/work-on/investigate.md +13 -7
- package/commands/work-on/remediate.md +15 -4
- package/commands/work-on/review.md +48 -19
- package/commands/work-on.md +62 -9
- package/docs/CODEX.md +12 -0
- package/docs/CONFIG.md +49 -1
- package/docs/RELEASE-NOTES.md +52 -0
- package/package.json +1 -1
- package/scripts/amplification-breaker.sh +89 -0
- package/scripts/amplification-breaker.test.sh +39 -0
- package/scripts/check-admission-parity.mjs +74 -1
- package/scripts/check-spec-markers.sh +67 -3
- package/scripts/classify-finding.sh +120 -0
- package/scripts/classify-finding.test.sh +74 -0
- package/scripts/close-dossier-no-main-commit.test.sh +139 -0
- package/scripts/forge-root.test.sh +84 -27
- package/scripts/lint-build-validate-files.sh +29 -0
- package/scripts/lint-build-validate-files.test.sh +25 -0
- package/scripts/lint-dispatch-prompt.test.sh +2 -1
- package/scripts/recovery-claim-live.sh +73 -0
- package/scripts/recovery-claim-live.test.sh +66 -0
- package/scripts/verify-phase-trail.sh +183 -4
- package/scripts/verify-phase-trail.test.sh +211 -1
package/bin/engine/admission.mjs
CHANGED
|
@@ -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
|
|
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
|
|
482
|
-
// P0/P1/P2 already returned above.
|
|
483
|
-
if (policy.amplificationBreaker && finding.amplificationBreakerTripped &&
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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,
|
|
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
|
|
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
|
-
#
|
|
387
|
-
#
|
|
388
|
-
#
|
|
389
|
-
#
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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(({
|
|
410
|
-
const plan =
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
1512
|
-
|
|
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 $
|
|
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
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
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
|