forgedock 1.13.1 → 1.15.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 (45) hide show
  1. package/bin/engine/phases-args.test.mjs +11 -0
  2. package/bin/engine/phases.mjs +268 -25
  3. package/bin/engine/phases.test.mjs +24 -0
  4. package/bin/engine-cli.mjs +18 -5
  5. package/bin/engine.mjs +149 -12
  6. package/bin/forgedock.mjs +4 -1
  7. package/commands/issue.md +17 -2
  8. package/commands/orchestrate/phase-3-dependency.md +5 -2
  9. package/commands/orchestrate/phase-4-execution.md +154 -27
  10. package/commands/pipeline-health/phase-2-metrics.md +5 -3
  11. package/commands/review-pr-staging.md +554 -54
  12. package/commands/review-pr.md +662 -51
  13. package/commands/validate.md +11 -3
  14. package/commands/work-on/build/architect.md +11 -8
  15. package/commands/work-on/build/implement.md +15 -1
  16. package/commands/work-on/build.md +34 -10
  17. package/commands/work-on/close.md +5 -4
  18. package/commands/work-on/decompose.md +7 -2
  19. package/commands/work-on/investigate.md +13 -0
  20. package/commands/work-on/remediate.md +332 -91
  21. package/commands/work-on/review.md +3 -3
  22. package/docs/CONFIG.md +14 -1
  23. package/docs/RELEASE-NOTES.md +20 -0
  24. package/package.json +1 -1
  25. package/packages/protocol/src/phases.js +6 -0
  26. package/scripts/architect-resume-match.test.sh +53 -0
  27. package/scripts/check-spec-bash.sh +73 -3
  28. package/scripts/check-spec-bash.test.sh +50 -0
  29. package/scripts/check-spec-markers.sh +2 -0
  30. package/scripts/checkbox-sections.test.sh +19 -0
  31. package/scripts/classify-finding.sh +103 -1
  32. package/scripts/classify-finding.test.sh +56 -0
  33. package/scripts/forge-root.test.sh +86 -2
  34. package/scripts/orchestrate-soft-edge.test.sh +58 -0
  35. package/scripts/remediate-approved-head.test.sh +43 -0
  36. package/scripts/remediate-basesync.test.sh +62 -3
  37. package/scripts/remediation-marker-match.test.sh +6 -0
  38. package/scripts/resolve-deploy-gate.sh +51 -0
  39. package/scripts/resolve-deploy-gate.test.sh +59 -0
  40. package/scripts/review-delta.sh +3 -2
  41. package/scripts/review-delta.test.sh +1 -1
  42. package/scripts/review-pr-delta-wiring.test.sh +274 -0
  43. package/scripts/review-pr-domain-map.test.sh +10 -0
  44. package/scripts/review-pr-staging-scope.test.sh +159 -0
  45. package/scripts/trusted-comments.test.sh +11 -0
package/bin/engine.mjs CHANGED
@@ -4,8 +4,9 @@
4
4
  * GitHub state (phase.detectOutcome). All effects are injected → fully testable.
5
5
  */
6
6
  import { fileURLToPath } from "node:url";
7
+ import { hostname as osHostname } from "node:os";
7
8
  import { appendEvent, readLog, deriveState, rewriteLog } from "./engine/runlog.mjs";
8
- import { pickPhase, TERMINAL_REASONS, issueSnapshot } from "./engine/phases.mjs";
9
+ import { pickPhase, TERMINAL_REASONS, issueSnapshot, hasInvestigationExit, postCycleMarker, nextCycleNumber } from "./engine/phases.mjs";
9
10
  import { reconcileState } from "./engine/reconcile.mjs";
10
11
  import { makeProjector } from "./engine/projector.mjs";
11
12
  import { VALID_BACKENDS } from "./runner.mjs";
@@ -177,6 +178,48 @@ function makeProgressEmitter(onProgress) {
177
178
  };
178
179
  }
179
180
 
181
+
182
+ // forge#3574: lease holder ids are `cli_<host>_<pid>`. The host component lets a
183
+ // relaunch on the SAME host probe whether the holder pid is still alive and
184
+ // reclaim a dead holder's lease without waiting out the TTL. Strict regex: the
185
+ // host cannot contain `_` (the separator) and the pid is digits only, so a
186
+ // malformed or legacy `cli_<pid>` id never parses as reclaimable.
187
+ const LEASE_HOLDER_RE = /^cli_([A-Za-z0-9.-]+)_(\d+)$/;
188
+
189
+ /** Sanitize a hostname for use inside a lease id (no `_`, only `[A-Za-z0-9.-]`). */
190
+ export function sanitizeLeaseHost(host) {
191
+ return String(host || "unknown").replace(/[^A-Za-z0-9.-]/g, "-") || "unknown";
192
+ }
193
+
194
+ /** Parse a `cli_<host>_<pid>` lease holder id. Returns null for anything else. */
195
+ export function parseLeaseHolder(by) {
196
+ const m = LEASE_HOLDER_RE.exec(String(by ?? ""));
197
+ if (!m) return null;
198
+ const pid = Number(m[2]);
199
+ return Number.isSafeInteger(pid) && pid > 0 ? { host: m[1], pid } : null;
200
+ }
201
+
202
+ /**
203
+ * True only when the lease holder is provably dead: id parses, host equals this
204
+ * host, and `kill(pid, 0)` throws ESRCH. EPERM (exists, not ours) and any other
205
+ * result mean alive. Legacy/other-host/unparseable ids are never reclaimed (TTL
206
+ * fallback). Best-effort like the I3 guard itself: PID reuse and containers
207
+ * sharing a hostname are accepted residual risk.
208
+ */
209
+ export function isLeaseHolderDead({ by, host = osHostname(), killFn = process.kill.bind(process) } = {}) {
210
+ const parsed = parseLeaseHolder(by);
211
+ if (!parsed || parsed.host !== sanitizeLeaseHost(host)) return false;
212
+ try {
213
+ killFn(parsed.pid, 0);
214
+ return false;
215
+ } catch (e) {
216
+ return e?.code === "ESRCH";
217
+ }
218
+ }
219
+
220
+ /** Max startup wait for a persisted rate-limit reset (guards a corrupt far-future resetAt). */
221
+ export const MAX_RESUME_RATE_LIMIT_WAIT_MS = 6 * 60 * 60 * 1000;
222
+
180
223
  /**
181
224
  * @param {object} opts
182
225
  * @param {string} [opts.backend] - "cli" | "api" | "auto" (forge#2028). Forwarded
@@ -228,8 +271,15 @@ function makeProgressEmitter(onProgress) {
228
271
  * the last committed phase. forge#3562: when it is `phase-complete` (a
229
272
  * multi-phase issue whose phase merged with more remaining), start a new
230
273
  * cycle (RUN_NEXT_CYCLE) that resets committed/branch/pr and restarts at
231
- * investigate. Any other terminal reason is left untouched and returns
274
+ * investigate. forge#3571: `needs-human` is reopened (RUN_REOPEN) only when
275
+ * a live read shows the issue OPEN with the label cleared. Any other terminal
276
+ * reason (or an unreadable snapshot) is left untouched and returns
232
277
  * `not-retryable`.
278
+ * @param {boolean} [opts.waitLease] - forge#3574: when another live holder owns
279
+ * the lease, poll (via `sleep`, bounded by `leaseTtlMs`) until it expires or
280
+ * its holder is reclaimable, then continue; `deferred` on timeout.
281
+ * @param {string} [opts.hostname] - injectable host name (tests).
282
+ * @param {Function} [opts.killFn] - injectable `process.kill` (tests).
233
283
  */
234
284
  export async function runIssue(opts) {
235
285
  const { issue, dir, agentId, lane = "staging", io, runner,
@@ -253,6 +303,9 @@ export async function runIssue(opts) {
253
303
  maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES,
254
304
  sessionLimitDefaultWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS,
255
305
  retry = false,
306
+ waitLease = false,
307
+ hostname = osHostname(),
308
+ killFn = process.kill.bind(process),
256
309
  onProgress = () => {} } = opts;
257
310
 
258
311
  // forge#2452: extracted into makeProgressEmitter() — see its docstring for
@@ -269,18 +322,38 @@ export async function runIssue(opts) {
269
322
 
270
323
  // 1. Load + reconcile (GitHub wins).
271
324
  const local = readLog(dir, issue).length ? deriveState(readLog(dir, issue)) : null;
272
- const remote = await projector.readState(issue);
325
+ let remote = await projector.readState(issue);
273
326
  let { state, action } = reconcileState(local, remote);
327
+ const leaseHeld = (r) => !!(r?.lease && r.lease.until > now() && r.lease.by !== agentId &&
328
+ !isLeaseHolderDead({ by: r.lease.by, host: hostname, killFn }));
329
+ const deferredResult = (r) => ({
330
+ terminalReason: "deferred",
331
+ leaseUntil: r.lease.until,
332
+ detail: `issue ${issue} leased by ${r.lease.by} until ${new Date(r.lease.until).toISOString()}`,
333
+ });
274
334
 
275
335
  // I3 (best-effort): GitHub issue-edit is not an atomic CAS, so this is a
276
336
  // courtesy check, not a hard mutual-exclusion guarantee — a concurrent
277
337
  // start can still race between this read and the next lease write.
278
338
  // MUST come before any projector.writeState() — remirror/hydrate branches
279
339
  // must not overwrite GitHub state when another agent holds a valid lease.
280
- if (remote?.lease && remote.lease.until > now() && remote.lease.by !== agentId) {
281
- return { terminalReason: "deferred", detail: `issue ${issue} leased by ${remote.lease.by}` };
340
+ if (leaseHeld(remote)) {
341
+ if (!waitLease) return deferredResult(remote);
342
+ // forge#3574: bounded wait — never longer than one lease TTL.
343
+ const deadline = now() + leaseTtlMs;
344
+ let cur = remote;
345
+ while (leaseHeld(cur)) {
346
+ const remaining = deadline - now();
347
+ if (remaining <= 0) return deferredResult(cur);
348
+ emitProgress({ event: "lease_wait", detail: `waiting for lease held by ${cur.lease.by}` });
349
+ await sleep(Math.min(remaining, Math.max(0, cur.lease.until - now()), 15000) || 1);
350
+ cur = await projector.readState(issue);
351
+ }
352
+ // The holder may have committed phases (or finished) while we waited: re-reconcile
353
+ // against the post-wait remote so the lease write below cannot regress its state.
354
+ remote = cur;
355
+ ({ state, action } = reconcileState(local, remote));
282
356
  }
283
-
284
357
  if (!state) {
285
358
  state = freshState(issue, lane);
286
359
  appendEvent(dir, issue, { event: "RUN_START", issue, run: state.run, lane });
@@ -298,18 +371,45 @@ export async function runIssue(opts) {
298
371
  // so the run resumes from its last committed phase. Placed AFTER the I3
299
372
  // lease guard (never weaken that ordering) and BEFORE the unconditional
300
373
  // lease write below, which then publishes `terminal:false` at the reopen's
301
- // higher `v`. Only `engine-error` is ever reopened; needs-human / merged /
302
- // decomposed / etc. terminals are left untouched.
374
+ // higher `v`. `engine-error` is reopened unconditionally; forge#3571:
375
+ // `needs-human` only once the label is cleared. merged / decomposed / etc.
376
+ // terminals are left untouched.
303
377
  // forge#3562: `phase-complete` is also retryable, but as a NEW cycle
304
378
  // (RUN_NEXT_CYCLE clears committed) rather than a reopen of the same one.
305
379
  let reopened = false;
306
380
  if (retry && state?.terminal) {
307
- if (state.terminalReason !== "engine-error" && state.terminalReason !== "phase-complete") {
381
+ if (state.terminalReason !== "engine-error" && state.terminalReason !== "phase-complete" &&
382
+ state.terminalReason !== "needs-human") {
308
383
  return {
309
384
  terminalReason: "not-retryable",
310
- detail: `issue ${issue} ended ${state.terminalReason}; --retry only resumes an engine-error or phase-complete run`,
385
+ detail: `issue ${issue} ended ${state.terminalReason}; --retry only resumes an engine-error, phase-complete or needs-human run`,
311
386
  };
312
387
  }
388
+ // forge#3571: a human clearing `needs-human` is the hand-back signal. Reopen
389
+ // only on a live read showing the issue OPEN and the label gone; a label
390
+ // still present keeps today's refusal, and an unreadable snapshot fails closed.
391
+ if (state.terminalReason === "needs-human") {
392
+ let snap;
393
+ try { snap = await issueSnapshot(issue, io); } catch { snap = { ok: false, state: null }; }
394
+ if (!snap.ok) {
395
+ return {
396
+ terminalReason: "not-retryable",
397
+ detail: `issue ${issue} state could not be read; --retry did not reopen the needs-human run`,
398
+ };
399
+ }
400
+ if (snap.state === "CLOSED") {
401
+ return {
402
+ terminalReason: "not-retryable",
403
+ detail: `issue ${issue} is CLOSED; --retry does not reopen a needs-human run on a closed issue`,
404
+ };
405
+ }
406
+ if ((snap.labels || []).includes("needs-human")) {
407
+ return {
408
+ terminalReason: "not-retryable",
409
+ detail: `issue ${issue} still carries needs-human; resolve it and remove the label before --retry`,
410
+ };
411
+ }
412
+ }
313
413
  // forge#3562 (SEC-3): a new cycle discards `committed`, so check the live
314
414
  // issue first. A CLOSED issue has nothing left to run; an unreadable
315
415
  // snapshot fails closed rather than resetting a possibly-finished run.
@@ -325,6 +425,17 @@ export async function runIssue(opts) {
325
425
  };
326
426
  }
327
427
  }
428
+ // forge#3657: publish the cycle boundary on GitHub BEFORE the log flips, so
429
+ // build's reconcilers never re-adopt cycle-1 evidence. Fail closed.
430
+ if (state.terminalReason === "phase-complete") {
431
+ const localCycles = readLog(dir, issue).filter((e) => e.event === "RUN_NEXT_CYCLE").length;
432
+ try { await postCycleMarker(issue, await nextCycleNumber(issue, localCycles, io), io); } catch {
433
+ return {
434
+ terminalReason: "not-retryable",
435
+ detail: `issue ${issue}: could not post FORGE:CYCLE marker; --retry did not start a new cycle`,
436
+ };
437
+ }
438
+ }
328
439
  appendEvent(dir, issue, {
329
440
  event: state.terminalReason === "phase-complete" ? "RUN_NEXT_CYCLE" : "RUN_REOPEN",
330
441
  issue,
@@ -376,6 +487,26 @@ export async function runIssue(opts) {
376
487
  } catch { return null; }
377
488
  })());
378
489
 
490
+ // forge#3574: a relaunch after a crash mid rate-limit pause must not re-run
491
+ // the phase early. Same-host resume reads the LOCAL run-log's lastRateLimit
492
+ // (the remote FORGE:STATE does not carry it). The lease is renewed while we
493
+ // wait so a second relaunch still defers.
494
+ const rl = local?.lastRateLimit;
495
+ const pendingPhase = pickPhase(state);
496
+ if (pendingPhase && rl && rl.phase === pendingPhase.id &&
497
+ typeof rl.resetAt === "number" && Number.isFinite(rl.resetAt) && rl.resetAt > now()) {
498
+ const waitMs = Math.min(rl.resetAt - now(), MAX_RESUME_RATE_LIMIT_WAIT_MS);
499
+ emitProgress({
500
+ event: "phase_paused", phase: pendingPhase.id, reason: "session-limit", resetAt: rl.resetAt,
501
+ detail: `resuming session-limit wait — pausing until ${new Date(rl.resetAt).toISOString()}`,
502
+ });
503
+ const resumeRenew = setInterval(() => {
504
+ projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + leaseTtlMs } }).catch(() => {});
505
+ }, leaseRenewIntervalMs);
506
+ if (typeof resumeRenew.unref === "function") resumeRenew.unref();
507
+ try { await sleep(waitMs); } finally { clearInterval(resumeRenew); }
508
+ }
509
+
379
510
  let phase;
380
511
  while ((phase = pickPhase(state))) {
381
512
  // forge#3506 (SPEC-1/SPEC-2): a crash after a terminal-after phase committed (remediate or
@@ -422,8 +553,13 @@ export async function runIssue(opts) {
422
553
  // Reuses the existing "invalid" terminal reason (TERMINAL_REASONS)
423
554
  // rather than inventing a new one; #2353 makes the same choice for
424
555
  // `close`'s own CLOSED-not-merged case.
425
- const isDead = snap.labels.includes("workflow:invalid") ||
556
+ let isDead = snap.labels.includes("workflow:invalid") ||
426
557
  (snap.state === "CLOSED" && !snap.labels.includes("workflow:merged"));
558
+ // forge#3534: an investigation exit closes the issue itself without `workflow:merged`.
559
+ // With the trusted deliverables marker present, a CLOSED (not invalid) issue is not
560
+ // divergent: build.reconcile must run so the run reaches close --terminal-state investigation.
561
+ if (isDead && phase.id === "build" && snap.state === "CLOSED" && !snap.labels.includes("workflow:invalid") &&
562
+ await hasInvestigationExit(issue, io)) isDead = false;
427
563
  if (isDead) {
428
564
  const detail = `issue ${issue} is ${snap.state === "CLOSED" ? "closed" : "open"} ` +
429
565
  `with divergent state before phase ${phase.id} (labels: ${snap.labels.join(", ") || "none"})`;
@@ -462,7 +598,8 @@ export async function runIssue(opts) {
462
598
  }
463
599
  let outcome;
464
600
  if (reconciled.satisfied) {
465
- outcome = { status: "committed", outputs: reconciled.outputs || {} };
601
+ outcome = { status: "committed", outputs: reconciled.outputs || {},
602
+ ...(reconciled.terminalReason ? { terminalReason: reconciled.terminalReason } : {}) };
466
603
  } else {
467
604
  if (reconciled.outputs?.pr) state.pr = reconciled.outputs.pr;
468
605
  // forge#2240: the phase is actually about to run (not a resume no-op)
package/bin/forgedock.mjs CHANGED
@@ -3207,7 +3207,7 @@ function help() {
3207
3207
  ["npx forgedock disable [dir]", "Opt a directory out of ForgeDock"],
3208
3208
  ["npx forgedock status [dir]", "Show ForgeDock state for a directory"],
3209
3209
  ["npx forgedock run <cmd> [args]", "Run a command headlessly (local claude CLI or Anthropic API)"],
3210
- ["npx forgedock run-issue <issue> --lane <lane> [--retry]", "Drive one issue through the durable engine (--retry reopens an engine-error run)"],
3210
+ ["npx forgedock run-issue <issue> --lane <lane> [--retry] [--wait-lease]", "Drive one issue through the durable engine (--retry reopens an engine-error run, or a needs-human run once the label is cleared; --wait-lease waits for a held lease; exit 75 = deferred, lease held)"],
3211
3211
  ["npx forgedock resume-stalled [--dry-run]", "Fleet stall recovery — re-dispatch expired-lease issues"],
3212
3212
  ["npx forgedock demo", "Set up a risk-free demo repo and print next steps"],
3213
3213
  ["npx forgedock labels [setup] [--repo owner/repo]", "Bootstrap ForgeDock-managed labels on a GitHub repo (idempotent)"],
@@ -3972,6 +3972,9 @@ switch (command) {
3972
3972
  // runIssue() resolves cleanly with a terminalReason instead of throwing,
3973
3973
  // preserve that same non-zero exit code for this reason too.
3974
3974
  if (result && (result.terminalReason === "needs-human" || result.terminalReason === "engine-error" || result.terminalReason === "not-retryable")) exitCode = 1;
3975
+ // forge#3574: a lease-held deferral did NOT run the issue; exit 75
3976
+ // (EX_TEMPFAIL) so orchestrators neither count it as done nor as a failure.
3977
+ if (result && result.terminalReason === "deferred") exitCode = 75;
3975
3978
  } catch (err) {
3976
3979
  process.stderr.write(`${RED}${err.message}${RESET}\n`);
3977
3980
  exitCode = 1;
package/commands/issue.md CHANGED
@@ -367,8 +367,23 @@ Run the deterministic dedup script first (authoritative check), then fall back t
367
367
  # empty, so this is a no-op for that path.
368
368
  DEDUP_EXCLUDE_ARGS=()
369
369
  [ -n "$PROGRAMMATIC_EXCLUDE" ] && DEDUP_EXCLUDE_ARGS=(--exclude "$PROGRAMMATIC_EXCLUDE")
370
- DEDUP_RESULT=$(scripts/issue-dedup.sh "{PROPOSED_TITLE}" {GH_FLAG} "${DEDUP_EXCLUDE_ARGS[@]}" 2>&1)
371
- DEDUP_EXIT=$?
370
+ # HELPER_SCRIPT resolver (DEDUP_SCRIPT; trusted-install tiers only, never the working directory or the target repo, #3400/#3483): plugin root, FORGE_ROOT, FORGEDOCK_HOME, FORGE_HOME, install symlink, marketplaces, newest pinned plugin cache under CLAUDE_CONFIG_DIR then ~/.claude. The cache scan matters because the plugin-root placeholder is not always substituted in forked runs.
371
+ _l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
372
+ _tc="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock")"
373
+ for _cfg in "${CLAUDE_CONFIG_DIR:-}" "$HOME/.claude"; do
374
+ [ -n "$_cfg" ] && _tc="$_tc"$'\n'"$(find -L "$_cfg/plugins/cache/forgedock/forgedock" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | awk -F/ '$NF ~ /^[0-9]+\.[0-9]+\.[0-9]+$/{split($NF,a,".");printf "%d %d %d %s\n",a[1],a[2],a[3],$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr | cut -d' ' -f4- || true)"
375
+ done
376
+ DEDUP_SCRIPT=""
377
+ while IFS= read -r _c; do
378
+ case "$_c" in /*) [ -z "$DEDUP_SCRIPT" ] && [ -f "$_c/scripts/issue-dedup.sh" ] && DEDUP_SCRIPT="$_c/scripts/issue-dedup.sh" ;; esac
379
+ done <<< "$_tc"
380
+ # Unresolved helper: no deterministic result (exit 0, empty) so the LLM fallback below still runs; never exit 1 (duplicate STOP).
381
+ if [ -n "$DEDUP_SCRIPT" ]; then
382
+ DEDUP_RESULT=$(bash "$DEDUP_SCRIPT" "{PROPOSED_TITLE}" {GH_FLAG} "${DEDUP_EXCLUDE_ARGS[@]}" 2>&1)
383
+ DEDUP_EXIT=$?
384
+ else
385
+ DEDUP_RESULT=""; DEDUP_EXIT=0
386
+ fi
372
387
 
373
388
  if [ "$DEDUP_EXIT" -eq 1 ]; then
374
389
  echo "Near-duplicate detected: $DEDUP_RESULT"
@@ -80,6 +80,8 @@ Each issue's extraction also carries a **provenance** tag, in descending order o
80
80
  LAYER1_FILES=()
81
81
  declare -A EDGE_KIND # "{PRED}:{SUCCESSOR}" → same-file | directory | shared-module (forge#1860)
82
82
  declare -A EDGE_FILES # "{PRED}:{SUCCESSOR}" → the specific file(s) that triggered the edge (forge#1860)
83
+ declare -A EDGE_SOFT # "{PRED}:{SUCCESSOR}" → true when the Layer 1 edge is SOFT (either endpoint's FILE_SOURCE is body-fallback); unset = hard (forge#3697)
84
+ declare -A EDGE_RELEASED # "{PRED}:{SUCCESSOR}" → true once a soft edge was released early in Step 4B (forge#3697)
83
85
  declare -A FILE_SOURCE # {NUM} → contract-deliverables | affected-files-section | body-fallback | none (forge#2436, forge#2848)
84
86
  declare -A ISSUE_FILES # {NUM} → newline-separated declared file set (forge#2844)
85
87
 
@@ -136,6 +138,7 @@ done
136
138
  - The issue with lower issue number goes first (stable ordering), unless an explicit `Depends on #` says otherwise
137
139
  - Add a conflict note to the DAG plan: "#{A} and #{B} both modify `{file}` — #{A} is predecessor of #{B}"
138
140
  - **Record the edge kind and shared file(s)** <!-- Added: forge#1860 -->: alongside the predecessor relationship, set `EDGE_KIND["${A}:${B}"]="same-file"` and `EDGE_FILES["${A}:${B}"]="{file}"` (space-separated if multiple files overlap). Layer 1 is the only point where the specific overlapping file is known — Step 3D's DAG only records that a predecessor relationship exists, not why. `EDGE_KIND`/`EDGE_FILES` have two consumers in `phase-4-execution.md` Step 4B: (1) the DONE-case "same-file current-state brief" forwarding (forge#1860, unchanged), and (2) `verify_file_overlap_edge()` (forge#1904, extended to the DONE path by forge#2848), which re-checks this guessed file list against the predecessor's *actual* PR diff once it reaches FAILED, GATED, **or DONE** — since Layer 1's file list is extracted from a pre-build investigation guess or a raw issue-body parse, never from real code, it can be wrong, and a predecessor that concludes without ever touching the guessed file should not keep a dependent gated/skipped on it, nor hand it a same-file brief about a file it never wrote. See `phase-4-execution.md`'s "File-overlap edge re-verification" section for the full mechanism.
141
+ - **Tag soft edges** <!-- Added: forge#3697 -->: a Layer 1 edge is **soft** when `FILE_SOURCE` of EITHER endpoint is `body-fallback` — set `EDGE_SOFT["${A}:${B}"]=true`. Edges whose both endpoints come from `contract-deliverables` or `affected-files-section` are **hard** (no `EDGE_SOFT` entry). Layer 2/3/4/5 edges, explicit `Depends on`, and the DATABASE chain are never soft. Soft edges rest on a pre-investigation body guess, so Step 4B may release one *early*, while the predecessor is still `IN_PROGRESS`, once the predecessor's open PR diff positively shows no overlap with `EDGE_FILES` (`verify_soft_edge_early` in `phase-4-execution.md`; no PR, a fetch error, or any overlap means HOLD, never drop). A predecessor's `FORGE:ARCHITECT` / `FORGE:BUILDER` planned-files list, once posted, outranks `body-fallback` as evidence about that predecessor's file set. `EDGE_SOFT` is in-memory only and, like `EDGE_KIND`/`EDGE_FILES`, is not retained across wake/compaction, so early release is live-session only and a reconstructed edge fails safe to HOLD.
139
142
 
140
143
  #### Layer 2: Directory-proximity detection
141
144
 
@@ -410,7 +413,7 @@ Build a **directed acyclic graph (DAG)** of per-issue dependencies. Each issue g
410
413
  - **Explicit dependencies**: If issue B says "Depends on #A" or "Blocked by #A", add A to B's predecessors
411
414
  - **File-conflict edges**: If two issues share affected files (from Step 3C Layer 1), add a directed edge: lower issue number → higher issue number (unless explicit deps say otherwise). The later issue has the earlier issue in its predecessors.
412
415
  - **Domain serialization edges**: DATABASE issues form a linear chain (each has the previous DATABASE issue as its predecessor). Same-small-directory issues (Layer 2) and high-fan-in file issues (Layer 3) get directed edges as per Step 3C rules.
413
- - **Edge-kind tagging for same-file/directory/shared-module briefing and re-verification** <!-- Added: forge#1860; extended forge#1904 -->: Layer 1/2/3 edges are additionally tagged with `EDGE_KIND` (`same-file` / `directory` / `shared-module`) and `EDGE_FILES` at the point they're added (see Step 3C). Explicit-dependency edges, the DATABASE domain chain, Layer 4 conservative-fallback edges, and Layer 5 co-change edges deliberately do NOT receive one of these three `EDGE_KIND` values. This is what lets Step 4B distinguish "predecessor edge came from Step 3C Layer 1/2/3" from every other edge type, for two purposes: a same-file current-state brief (DONE case, forge#1860) and edge re-verification against the predecessor's actual PR diff once it reaches FAILED, GATED, or DONE (`verify_file_overlap_edge()`, forge#1904, DONE path added by forge#2848 — see `phase-4-execution.md` Step 4B). On the DONE path the two purposes are ordered: re-verification runs first and, on a `DROP`, suppresses the brief entirely. Non-`EDGE_KIND` edges are eligible for neither — they always `KEEP` in the re-verification check and are never candidates for the same-file brief.
416
+ - **Edge-kind tagging for same-file/directory/shared-module briefing and re-verification** <!-- Added: forge#1860; extended forge#1904 -->: Layer 1/2/3 edges are additionally tagged with `EDGE_KIND` (`same-file` / `directory` / `shared-module`) and `EDGE_FILES` at the point they're added (see Step 3C). Explicit-dependency edges, the DATABASE domain chain, Layer 4 conservative-fallback edges, and Layer 5 co-change edges deliberately do NOT receive one of these three `EDGE_KIND` values. This is what lets Step 4B distinguish "predecessor edge came from Step 3C Layer 1/2/3" from every other edge type, for two purposes: a same-file current-state brief (DONE case, forge#1860) and edge re-verification against the predecessor's actual PR diff once it reaches FAILED, GATED, or DONE (`verify_file_overlap_edge()`, forge#1904, DONE path added by forge#2848 — see `phase-4-execution.md` Step 4B). On the DONE path the two purposes are ordered: re-verification runs first and, on a `DROP`, suppresses the brief entirely. Non-`EDGE_KIND` edges are eligible for neither — they always `KEEP` in the re-verification check and are never candidates for the same-file brief. <!-- Added: forge#3697 --> Layer 1 edges backed by `body-fallback` provenance are additionally tagged soft (`EDGE_SOFT`, Step 3C) so Step 4B can release them early against a live predecessor PR diff while the predecessor is still `IN_PROGRESS` (`verify_soft_edge_early()`); hard edges are never released early.
414
417
  - **Conservative fallback edges**: Low-confidence issues (Layer 4) get edges to same-domain issues as per Step 3C rules.
415
418
  - **Co-change coupling edges** <!-- Added: forge#1196 -->: High co-change file pairs (Layer 5, 3+ shared commits in the bounded window) that span two different issues get a directed edge using the same lower-issue-number-is-predecessor convention as Layer 1. Verified-independent pairs (Layer 5, zero shared commits) may instead REMOVE an edge that Layer 2 or Layer 4 would otherwise have added for that pair — Layer 1 and Layer 3 edges are never removed by a Layer 5 downgrade.
416
419
  - **Claims-board downgrade (Layer 2/4 edges only)** <!-- Added: forge#1736 -->: After dispatch begins (Phase 4A), when both issues in a Layer-2 or Layer-4 serialized pair post `FORGE:CLAIM` annotations on the coordination issue and their claimed file sets are **disjoint** (no path appears in both claims), the serialization edge for that pair MAY be relaxed — the blocked issue becomes ready. This downgrade is **never** applied to Layer-1 (same-file) or Layer-3 (high-fan-in) edges. See Step 4B: Claims-board relaxation sweep for the runtime check.
@@ -1286,7 +1289,7 @@ The orchestrator context window must stay small regardless of how many issues ha
1286
1289
 
1287
1290
  This reconstruction MUST use the same three-way **DONE / GATED / FAILED** predecessor classification defined in `phase-4-execution.md` Step 4B ("Predecessor Classification") — not a binary terminal/non-terminal grep. A binary grep is exactly the bug forge#1812 fixed: it let `needs-human` simultaneously satisfy "predecessor is done, dispatch the successor" (this block, pre-fix) and "predecessor failed, skip the successor" (Step 4B's failure handler, pre-fix) — with no way to represent "predecessor is human-gated, its PR is still open, and its dependents should wait but not be abandoned." That third case is exactly what wake/compaction reconstruction hits most often, since a merge approved by a human typically happens *after* the orchestrator session that dispatched the predecessor has already ended — this block, not the live Step 4B loop, is the realistic trigger point for "gating PR merged while nobody was watching."
1288
1291
 
1289
- For the same reason, this reconstruction also MUST call `verify_file_overlap_edge()` (also defined in `phase-4-execution.md` Step 4B, alongside `classify_predecessor_state()` — re-declare it here too if this block runs in a fresh context) before treating a GATED or FAILED predecessor's `EDGE_KIND` edge as still blocking. <!-- Added: forge#1904 --> A predecessor that reached `needs-human`/`workflow:invalid` with no PR, or whose PR never actually touched the guessed shared file, most realistically gets *discovered* at wake time — the session that would have caught it live has already ended. Re-verifying only in the live Step 4B loop and not here would leave this exact wake-time case unfixed.
1292
+ For the same reason, this reconstruction also MUST call `verify_file_overlap_edge()` (also defined in `phase-4-execution.md` Step 4B, alongside `classify_predecessor_state()` — re-declare it here too if this block runs in a fresh context) before treating a GATED or FAILED predecessor's `EDGE_KIND` edge as still blocking. <!-- Added: forge#1904 --> A predecessor that reached `needs-human`/`workflow:invalid` with no PR, or whose PR never actually touched the guessed shared file, most realistically gets *discovered* at wake time — the session that would have caught it live has already ended. Re-verifying only in the live Step 4B loop and not here would leave this exact wake-time case unfixed. <!-- Added: forge#3697 --> `EDGE_SOFT` is likewise not retained across wake/compaction: early release (`verify_soft_edge_early()`) is a live-session mechanism only, and a reconstructed edge simply holds until its predecessor concludes.
1290
1293
 
1291
1294
  ```bash
1292
1295
  # Reconstruct dispatch state from GitHub after compaction / wake