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.
- package/bin/engine/phases-args.test.mjs +11 -0
- package/bin/engine/phases.mjs +268 -25
- package/bin/engine/phases.test.mjs +24 -0
- package/bin/engine-cli.mjs +18 -5
- package/bin/engine.mjs +149 -12
- package/bin/forgedock.mjs +4 -1
- package/commands/issue.md +17 -2
- package/commands/orchestrate/phase-3-dependency.md +5 -2
- package/commands/orchestrate/phase-4-execution.md +154 -27
- package/commands/pipeline-health/phase-2-metrics.md +5 -3
- package/commands/review-pr-staging.md +554 -54
- package/commands/review-pr.md +662 -51
- package/commands/validate.md +11 -3
- package/commands/work-on/build/architect.md +11 -8
- package/commands/work-on/build/implement.md +15 -1
- package/commands/work-on/build.md +34 -10
- package/commands/work-on/close.md +5 -4
- package/commands/work-on/decompose.md +7 -2
- package/commands/work-on/investigate.md +13 -0
- package/commands/work-on/remediate.md +332 -91
- package/commands/work-on/review.md +3 -3
- package/docs/CONFIG.md +14 -1
- package/docs/RELEASE-NOTES.md +20 -0
- package/package.json +1 -1
- package/packages/protocol/src/phases.js +6 -0
- package/scripts/architect-resume-match.test.sh +53 -0
- package/scripts/check-spec-bash.sh +73 -3
- package/scripts/check-spec-bash.test.sh +50 -0
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/checkbox-sections.test.sh +19 -0
- package/scripts/classify-finding.sh +103 -1
- package/scripts/classify-finding.test.sh +56 -0
- package/scripts/forge-root.test.sh +86 -2
- package/scripts/orchestrate-soft-edge.test.sh +58 -0
- package/scripts/remediate-approved-head.test.sh +43 -0
- package/scripts/remediate-basesync.test.sh +62 -3
- package/scripts/remediation-marker-match.test.sh +6 -0
- package/scripts/resolve-deploy-gate.sh +51 -0
- package/scripts/resolve-deploy-gate.test.sh +59 -0
- package/scripts/review-delta.sh +3 -2
- package/scripts/review-delta.test.sh +1 -1
- package/scripts/review-pr-delta-wiring.test.sh +274 -0
- package/scripts/review-pr-domain-map.test.sh +10 -0
- package/scripts/review-pr-staging-scope.test.sh +159 -0
- 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.
|
|
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
|
-
|
|
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 (
|
|
281
|
-
|
|
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`.
|
|
302
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
|
|
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
|