audit-tools 0.50.10 → 0.50.12

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 (54) hide show
  1. package/dist/remediate/contractPipeline/derive.d.ts +7 -7
  2. package/dist/remediate/contractPipeline/derive.d.ts.map +1 -1
  3. package/dist/remediate/contractPipeline/derive.js +19 -21
  4. package/dist/remediate/contractPipeline/derive.js.map +1 -1
  5. package/dist/remediate/contractPipeline/phaseCut.d.ts +46 -8
  6. package/dist/remediate/contractPipeline/phaseCut.d.ts.map +1 -1
  7. package/dist/remediate/contractPipeline/phaseCut.js +71 -40
  8. package/dist/remediate/contractPipeline/phaseCut.js.map +1 -1
  9. package/dist/remediate/contractPipeline/phaseCutArtifact.d.ts +1 -1
  10. package/dist/remediate/contractPipeline/phaseCutArtifact.js +1 -1
  11. package/dist/remediate/contractPipeline/repairState.d.ts +108 -0
  12. package/dist/remediate/contractPipeline/repairState.d.ts.map +1 -0
  13. package/dist/remediate/contractPipeline/repairState.js +184 -0
  14. package/dist/remediate/contractPipeline/repairState.js.map +1 -0
  15. package/dist/remediate/index.d.ts.map +1 -1
  16. package/dist/remediate/index.js +4 -0
  17. package/dist/remediate/index.js.map +1 -1
  18. package/dist/remediate/state/types.d.ts +62 -0
  19. package/dist/remediate/state/types.d.ts.map +1 -1
  20. package/dist/remediate/state/types.js +17 -0
  21. package/dist/remediate/state/types.js.map +1 -1
  22. package/dist/remediate/steps/contractPipeline.d.ts +8 -0
  23. package/dist/remediate/steps/contractPipeline.d.ts.map +1 -1
  24. package/dist/remediate/steps/contractPipeline.js +192 -58
  25. package/dist/remediate/steps/contractPipeline.js.map +1 -1
  26. package/dist/remediate/steps/contractPipelinePrompts.d.ts.map +1 -1
  27. package/dist/remediate/steps/contractPipelinePrompts.js +3 -2
  28. package/dist/remediate/steps/contractPipelinePrompts.js.map +1 -1
  29. package/dist/remediate/steps/dispatch/hostHandoff.d.ts +19 -0
  30. package/dist/remediate/steps/dispatch/hostHandoff.d.ts.map +1 -1
  31. package/dist/remediate/steps/dispatch/hostHandoff.js +69 -0
  32. package/dist/remediate/steps/dispatch/hostHandoff.js.map +1 -1
  33. package/dist/remediate/steps/nextStep.d.ts +1 -1
  34. package/dist/remediate/steps/nextStep.d.ts.map +1 -1
  35. package/dist/remediate/steps/nextStep.js +151 -84
  36. package/dist/remediate/steps/nextStep.js.map +1 -1
  37. package/dist/remediate/steps/prompts.d.ts.map +1 -1
  38. package/dist/remediate/steps/prompts.js +20 -2
  39. package/dist/remediate/steps/prompts.js.map +1 -1
  40. package/dist/remediate/steps/stepUtils.d.ts +1 -37
  41. package/dist/remediate/steps/stepUtils.d.ts.map +1 -1
  42. package/dist/remediate/steps/stepUtils.js +0 -79
  43. package/dist/remediate/steps/stepUtils.js.map +1 -1
  44. package/dist/remediate/validation/artifacts.d.ts.map +1 -1
  45. package/dist/remediate/validation/artifacts.js +4 -0
  46. package/dist/remediate/validation/artifacts.js.map +1 -1
  47. package/dist/remediate/validation/contractPipeline.d.ts.map +1 -1
  48. package/dist/remediate/validation/contractPipeline.js +14 -0
  49. package/dist/remediate/validation/contractPipeline.js.map +1 -1
  50. package/dist/remediate/validation/contractPipelineGates.d.ts +9 -2
  51. package/dist/remediate/validation/contractPipelineGates.d.ts.map +1 -1
  52. package/dist/remediate/validation/contractPipelineGates.js +13 -7
  53. package/dist/remediate/validation/contractPipelineGates.js.map +1 -1
  54. package/package.json +1 -1
@@ -15,19 +15,19 @@ import { runTriagePhase } from "../phases/triage.js";
15
15
  import { runClosePhase } from "../phases/close.js";
16
16
  import { validateRemediationPlan } from "../validation/remediationState.js";
17
17
  import { readExtractedPlanIfPresent, } from "./dispatch.js";
18
- import { ingestRemediationHostResults, hostDependencyLevels, precomputeRecoveryTestVerdicts, prepareRemediationHostHandoff, } from "./dispatch/hostHandoff.js";
18
+ import { ingestRemediationHostResults, hostDependencyLevels, permanentlyDeadPendingBlocks, precomputeRecoveryTestVerdicts, prepareRemediationHostHandoff, } from "./dispatch/hostHandoff.js";
19
19
  import { FileLockTimeoutError, withFileLock, } from "../../shared/io/fileLock.js";
20
20
  import { AUDIT_FINDINGS_FILENAME, AUDIT_REPORT_FILENAME, auditArtifactsDir, auditFindingsPath, auditReportPath, promotedAuditFindingsPath, promotedAuditReportPath, remediationArtifactsDir, } from "../../shared/io/auditToolsPaths.js";
21
21
  import { callerWorkingDirectory, discoverRepoRoot, resolveRepoRoot, } from "../../shared/io/repoRoot.js";
22
22
  import { writeCurrentStep } from "./stepWriter.js";
23
- import { dependencyAwaitingClarification, dependencyVerifiedComplete, } from "./stepUtils.js";
24
23
  import { isTerminalStatus, isVerifiedCompleteStatus, } from "../state/itemStatus.js";
25
24
  import { deduplicateCrossLensFindings, fixupBlocksAfterDedup, } from "../dedup/crossLensDedup.js";
26
25
  import { checkAffectedFileIntegrity } from "../utils/fileIntegrity.js";
27
26
  import { applyIntentOrdering } from "../intent/intentOrdering.js";
28
27
  import { resolveIntakeStep } from "./intakeResolver.js";
29
28
  import { runToolOwnedFinalGate, writeFinalGateRedRecord, writeFinalGateOutcomeRecord, } from "./finalGate.js";
30
- import { buildNextContractPipelineStep, shouldEnterContractPipeline, writePathASeedFromFindings, } from "./contractPipeline.js";
29
+ import { buildNextContractPipelineStep, shouldEnterContractPipeline, writePathASeedFromFindings, normalizeBlockTouchedFiles, checkWriteScopePathsAgainstTrackedTree, } from "./contractPipeline.js";
30
+ import { compareCodeUnits } from "../../shared/compareCodeUnits.js";
31
31
  import { contractArtifactExists, contractPipelineDir, } from "../contractPipeline/artifactStore.js";
32
32
  import { buildReviewRequest, applyReviewResolution, isResolutionForRequest, screenResolutionIds, REVIEW_REQUEST_SCHEMA_VERSION, } from "../review/reviewGate.js";
33
33
  import { buildAutonomousReviewDecision } from "../review/autonomousGate.js";
@@ -219,7 +219,7 @@ function suppliedInputMatchesRun(inputResolution, manifest) {
219
219
  return false;
220
220
  return true;
221
221
  }
222
- export { dependencyVerifiedComplete, classifyFindingRisk, } from "./stepUtils.js";
222
+ export { classifyFindingRisk } from "./stepUtils.js";
223
223
  export { isTerminalStatus, isVerifiedCompleteStatus };
224
224
  export { hostDependencyLevels };
225
225
  function documentableFindings(state) {
@@ -228,41 +228,50 @@ function documentableFindings(state) {
228
228
  return state.plan.findings.filter((finding) => state.items?.[finding.id]?.status === "pending");
229
229
  }
230
230
  /**
231
- * Blocks eligible for the next host handoff: those with pending
232
- * work AND every dependency VERIFIED-COMPLETE (INV-RS-01 — a SKIP or blocked
233
- * dependency never makes a dependent eligible). This dependency-frontier gate
234
- * replaced the old `dependenciesSatisfied` (any-terminal) check so a block whose
235
- * prerequisite was skipped/blocked is held back until its required surface lands.
231
+ * The dispatch frontier: the level-0 draw of the SAME dependency/phase
232
+ * partition the host workload builder emits from ({@link hostDependencyLevels}).
233
+ * The guard and the builder reading ONE computation is what keeps the
234
+ * "Cannot prepare an empty remediation host workload" throw unreachable from
235
+ * the scheduler: whenever this is non-empty, the builder derives the identical
236
+ * non-empty set (the 2026-08-23 empty-frontier incident — the retired
237
+ * edge-only predicate ignored the phase barrier and dependency existence, so
238
+ * the guard dispatched frontiers the builder refused, and next-step died
239
+ * instead of pausing).
236
240
  */
237
- function implementableBlocks(state) {
238
- if (!state.plan || !state.items)
239
- return [];
240
- return state.plan.blocks.filter((block) => dependencyVerifiedComplete(block, state) &&
241
- block.items.some((findingId) => {
242
- const item = state.items?.[findingId];
243
- return item?.status === "pending";
244
- }));
241
+ function dispatchFrontier(state) {
242
+ return hostDependencyLevels(state)[0] ?? [];
245
243
  }
246
244
  /**
247
- * Pending nodes that are genuinely DEAD-ENDED: at least one dependency did not
248
- * reach a verified-complete disposition and never will (a prerequisite was
249
- * skipped or blocked, or the edges are cyclic). Once no eligible block remains,
250
- * these are marked `blocked` — their upstream surface never landed — rather than
251
- * looping forever.
252
- *
253
- * A node held only by an UNANSWERED WORKER QUESTION is deliberately excluded
254
- * (`dependencyAwaitingClarification`): since the clarification round is deferred
255
- * to the end of the implement phase, such a node reaches this sweep while its
256
- * answer is still outstanding, and blocking it would report "upstream failed"
257
- * for what is really "awaiting an answer". It stays `pending` and is re-decided
258
- * once the answer lands.
245
+ * The ONE dead-end sweep, shared by the planning→implementing transition and
246
+ * the implementing obligation: marks the pending items of every permanently
247
+ * dead block `blocked` with the INV-RS-01 reason, and reports whether anything
248
+ * changed. Deadness is the workload boundary's own liveness analysis
249
+ * ({@link permanentlyDeadPendingBlocks}), so a node held by an unanswered
250
+ * clarification — or by a lower phase that is merely still working — is NEVER
251
+ * mis-reported as an upstream failure (the 175cfb89 pin).
259
252
  */
260
- function blockedByUnsatisfiedDependency(state) {
261
- if (!state.plan || !state.items)
262
- return [];
263
- return state.plan.blocks.filter((block) => !dependencyVerifiedComplete(block, state) &&
264
- !dependencyAwaitingClarification(block, state) &&
265
- block.items.some((findingId) => state.items?.[findingId]?.status === "pending"));
253
+ function sweepPermanentlyDeadBlocks(state) {
254
+ let changed = false;
255
+ const now = new Date().toISOString();
256
+ for (const block of permanentlyDeadPendingBlocks(state)) {
257
+ for (const findingId of block.items) {
258
+ const it = state.items?.[findingId];
259
+ if (!it || it.status !== "pending")
260
+ continue;
261
+ it.status = "blocked";
262
+ it.started_at ??= now;
263
+ it.completed_at = now;
264
+ it.failure_reason =
265
+ it.failure_reason ??
266
+ "A prerequisite can never reach a verified-complete disposition " +
267
+ "(a dependency or lower-phase block was skipped, blocked, or abandoned; " +
268
+ "a declared dependency resolves to no block; or the dependencies are " +
269
+ "cyclic); the host handoff will not expose this node against an " +
270
+ "upstream surface that never landed (INV-RS-01).";
271
+ changed = true;
272
+ }
273
+ }
274
+ return changed;
266
275
  }
267
276
  /**
268
277
  * Whether any item is paused on a worker question that has not been answered yet.
@@ -1590,6 +1599,14 @@ function isPlanClarificationAction(value) {
1590
1599
  return (typeof value === "string" &&
1591
1600
  PLAN_CLARIFICATION_ACTIONS.includes(value));
1592
1601
  }
1602
+ /** Carry `scope_additions` through when it is an array; keep string entries. */
1603
+ function normalizedScopeAdditions(entry) {
1604
+ if (!Array.isArray(entry.scope_additions))
1605
+ return {};
1606
+ return {
1607
+ scope_additions: entry.scope_additions.filter((p) => typeof p === "string"),
1608
+ };
1609
+ }
1593
1610
  function normalizePlanClarificationResolutions(value) {
1594
1611
  if (Array.isArray(value)) {
1595
1612
  return value.filter(isRecord).flatMap((entry) => {
@@ -1599,6 +1616,7 @@ function normalizePlanClarificationResolutions(value) {
1599
1616
  finding_id: entry.finding_id,
1600
1617
  action: entry.action,
1601
1618
  rationale: typeof entry.rationale === "string" ? entry.rationale : undefined,
1619
+ ...normalizedScopeAdditions(entry),
1602
1620
  },
1603
1621
  ];
1604
1622
  }
@@ -1622,9 +1640,56 @@ function normalizePlanClarificationResolutions(value) {
1622
1640
  finding_id: typeof entry.finding_id === "string" ? entry.finding_id : findingId,
1623
1641
  action: entry.action,
1624
1642
  rationale: typeof entry.rationale === "string" ? entry.rationale : undefined,
1643
+ ...normalizedScopeAdditions(entry),
1625
1644
  }];
1626
1645
  });
1627
1646
  }
1647
+ /**
1648
+ * Validate every `clarified` resolution's `scope_additions` BEFORE anything is
1649
+ * applied: each entry must normalize beneath the repository root against the
1650
+ * finding's owning block (the one write-scope normalizer), must clear the SAME
1651
+ * tracked-tree rule the promotion gate enforced (the delta lane must not
1652
+ * bypass it), and a finding with no owning block has no scope to widen. ANY
1653
+ * refusal refuses the WHOLE resolution file — the uniform id-join contract's
1654
+ * shape: a half-applied decision record is worse than a re-ask.
1655
+ */
1656
+ async function validateClarificationScopeAdditions(root, state, resolutions) {
1657
+ const refusals = [];
1658
+ for (const res of resolutions) {
1659
+ if (res.action !== "clarified" || !res.scope_additions?.length)
1660
+ continue;
1661
+ const block = state.plan?.blocks?.find((b) => b.items.includes(res.finding_id));
1662
+ if (!block) {
1663
+ refusals.push(`scope_additions for \`${res.finding_id}\`: no plan block owns this finding, so ` +
1664
+ `there is no write scope to widen.`);
1665
+ continue;
1666
+ }
1667
+ const normalized = normalizeBlockTouchedFiles(root, res.scope_additions, block.block_id);
1668
+ refusals.push(...normalized.refusals);
1669
+ refusals.push(...(await checkWriteScopePathsAgainstTrackedTree(root, normalized.touched_files, `scope_additions for \`${res.finding_id}\` (block "${block.block_id}")`)));
1670
+ }
1671
+ return refusals;
1672
+ }
1673
+ /**
1674
+ * Apply one validated scope delta: union the normalized additions into the
1675
+ * owning block's `touched_files` (content-sorted — an incidentally-ordered
1676
+ * scope would churn the plan hash). The workload-binding invalidation is the
1677
+ * caller's existing `delete state.host_handoff` on any applied resolution, so
1678
+ * the next prepare re-mints the work item with the widened `allowed_files`
1679
+ * (the open-bugs :661 wedge class); the up-front ambiguity gate has no binding
1680
+ * by construction.
1681
+ */
1682
+ function applyClarificationScopeAdditions(root, state, res) {
1683
+ if (res.action !== "clarified" || !res.scope_additions?.length)
1684
+ return;
1685
+ const block = state.plan?.blocks?.find((b) => b.items.includes(res.finding_id));
1686
+ if (!block)
1687
+ return; // refused upstream by validateClarificationScopeAdditions
1688
+ const normalized = normalizeBlockTouchedFiles(root, res.scope_additions, block.block_id);
1689
+ block.touched_files = [
1690
+ ...new Set([...block.touched_files, ...normalized.touched_files]),
1691
+ ].sort((left, right) => compareCodeUnits(left, right));
1692
+ }
1628
1693
  /**
1629
1694
  * Apply one clarification resolution to its item. Single-sourced so the up-front
1630
1695
  * ambiguity gate (part A) and the mid-run clarification round (part B) settle an
@@ -1677,6 +1742,17 @@ async function applyPlanClarificationResolution(root, artifactsDir, state, store
1677
1742
  step: await handleWaitingForClarification(root, artifactsDir, state, `finding id(s) not in the plan: ${unknownIds.map((i) => `\`${i}\``).join(", ")}`),
1678
1743
  };
1679
1744
  }
1745
+ // Scope deltas ride the same whole-file fail-closed contract as unknown ids:
1746
+ // an invalid addition refuses everything, so a decision record never
1747
+ // half-applies (open-bugs.md:110).
1748
+ const scopeRefusals = await validateClarificationScopeAdditions(root, state, resolutions);
1749
+ if (scopeRefusals.length > 0) {
1750
+ await withFsRetry(() => rename(resolutionPath, `${resolutionPath}.refused-${Date.now()}`));
1751
+ return {
1752
+ kind: "refused",
1753
+ step: await handleWaitingForClarification(root, artifactsDir, state, `invalid scope_additions — fix and re-submit: ${scopeRefusals.join(" | ")}`),
1754
+ };
1755
+ }
1680
1756
  const now = new Date().toISOString();
1681
1757
  let appliedCount = 0;
1682
1758
  for (const res of resolutions) {
@@ -1684,6 +1760,7 @@ async function applyPlanClarificationResolution(root, artifactsDir, state, store
1684
1760
  if (!item || isTerminalStatus(item.status))
1685
1761
  continue;
1686
1762
  applyClarificationActionToItem(item, res, now);
1763
+ applyClarificationScopeAdditions(root, state, res);
1687
1764
  appliedCount += 1;
1688
1765
  }
1689
1766
  // An applied answer mutates item state that is baked into the dispatch
@@ -1947,6 +2024,29 @@ async function runPlanAmbiguityGate(root, artifactsDir, state, store) {
1947
2024
  },
1948
2025
  });
1949
2026
  }
2027
+ // Scope deltas: the same whole-file fail-closed contract as unknown ids
2028
+ // (open-bugs.md:110). At this up-front gate no workload binding exists yet,
2029
+ // so an applied widening simply flows into the first dispatch.
2030
+ const scopeRefusals = await validateClarificationScopeAdditions(root, state, resolutions);
2031
+ if (scopeRefusals.length > 0) {
2032
+ await withFsRetry(() => rename(resolutionPath, `${resolutionPath}.refused-${Date.now()}`));
2033
+ const candidates = (await readOptionalJsonFile(requestPath)) ??
2034
+ detectPlanAmbiguities(findings, state.items);
2035
+ return writeCurrentStep({
2036
+ stepKind: "collect_clarifications",
2037
+ status: "blocked",
2038
+ runId: stateRunId(state),
2039
+ repoRoot: root,
2040
+ artifactsDir,
2041
+ prompt: ambiguityReviewPrompt(candidates, resolutionPath, findings.map((f) => f.id), `invalid scope_additions — fix and re-submit: ${scopeRefusals.join(" | ")}`),
2042
+ allowedCommands: [loaderCommand("next-step")],
2043
+ stopCondition: "Stop after re-submitting a corrected ambiguity resolution, unless it is already written and the prompt told you to continue.",
2044
+ artifactPaths: {
2045
+ ambiguity_request: requestPath,
2046
+ ambiguity_resolution: resolutionPath,
2047
+ },
2048
+ });
2049
+ }
1950
2050
  const now = new Date().toISOString();
1951
2051
  let changed = false;
1952
2052
  for (const res of resolutions) {
@@ -1954,6 +2054,7 @@ async function runPlanAmbiguityGate(root, artifactsDir, state, store) {
1954
2054
  if (!item || isTerminalStatus(item.status))
1955
2055
  continue;
1956
2056
  applyClarificationActionToItem(item, res, now);
2057
+ applyClarificationScopeAdditions(root, state, res);
1957
2058
  changed = true;
1958
2059
  }
1959
2060
  await writeJsonFile(decisionPath, { resolved_at: now, resolution_count: resolutions.length });
@@ -1989,7 +2090,7 @@ async function handlePlanning(root, artifactsDir, state, store) {
1989
2090
  }
1990
2091
  // Document phase dissolved (N-R13): planning transitions directly to
1991
2092
  // implementing, and the host workload reads finding context directly.
1992
- const implementBlocks = implementableBlocks(state);
2093
+ const implementBlocks = dispatchFrontier(state);
1993
2094
  if (implementBlocks.length > 0) {
1994
2095
  if (state.plan) {
1995
2096
  const integrity = await checkAffectedFileIntegrity(root, state.plan.findings);
@@ -2024,28 +2125,15 @@ async function handlePlanning(root, artifactsDir, state, store) {
2024
2125
  }
2025
2126
  }
2026
2127
  // Transition directly to implementing — no separate document round.
2027
- // Any pending item outside every attainable host dependency frontier is
2128
+ // Any pending item that can never enter the host dependency frontier is
2028
2129
  // dead-ended (INV-RS-01): a prerequisite was skipped/blocked, so its
2029
2130
  // verified-complete edge can never be satisfied — never dispatch a dependent
2030
2131
  // against an upstream surface that did not land. Mark it blocked so the run
2031
2132
  // advances to close rather than looping forever. A node that is merely
2032
- // waiting on a still-running prerequisite is NOT here (it would appear in a
2033
- // later eligible pass); only nodes with a permanently-unsatisfiable edge are.
2133
+ // waiting on a still-running prerequisite — or on a clarification answer —
2134
+ // is NOT dead; the liveness analysis behind the sweep holds it pending.
2034
2135
  if (implementBlocks.length === 0) {
2035
- for (const block of blockedByUnsatisfiedDependency(state)) {
2036
- for (const findingId of block.items) {
2037
- const it = state.items?.[findingId];
2038
- if (!it || it.status !== "pending")
2039
- continue;
2040
- it.status = "blocked";
2041
- it.failure_reason =
2042
- it.failure_reason ??
2043
- "A dependency node did not reach a verified-complete disposition " +
2044
- "(a prerequisite was skipped, blocked, or the dependencies are cyclic); " +
2045
- "the host handoff will not expose this node against an upstream " +
2046
- "surface that never landed (INV-RS-01).";
2047
- }
2048
- }
2136
+ sweepPermanentlyDeadBlocks(state);
2049
2137
  }
2050
2138
  state.status = "implementing";
2051
2139
  await store.saveState(state);
@@ -3236,7 +3324,7 @@ export function buildMainObligations(ctx) {
3236
3324
  derive: (state) => state != null &&
3237
3325
  (state.status === "implementing" || state.status === "triage") &&
3238
3326
  hasUnansweredClarification(state) &&
3239
- implementableBlocks(state).length === 0
3327
+ dispatchFrontier(state).length === 0
3240
3328
  ? "missing"
3241
3329
  : "satisfied",
3242
3330
  execute: async (state) => {
@@ -3251,9 +3339,9 @@ export function buildMainObligations(ctx) {
3251
3339
  derive: (state) => state?.status === "implementing" ? "missing" : "satisfied",
3252
3340
  execute: async (state) => {
3253
3341
  const s = requireState(state);
3254
- // Pending implementable blocks dispatch; triage only runs once every item
3255
- // has left "pending".
3256
- const pendingBlocks = implementableBlocks(s);
3342
+ // A non-empty dispatch frontier dispatches; triage only runs once every
3343
+ // item has left "pending".
3344
+ const pendingBlocks = dispatchFrontier(s);
3257
3345
  if (pendingBlocks.length > 0) {
3258
3346
  // Per-phase boundary gate (T3): before opening a phase P > 0, run the
3259
3347
  // whole-repo suite once over the just-landed foundations. A red PAUSES
@@ -3277,33 +3365,12 @@ export function buildMainObligations(ctx) {
3277
3365
  runLogger,
3278
3366
  });
3279
3367
  }
3280
- // Dead-end pending nodes whose dependency never reached verified-complete
3281
- // (INV-RS-01) so the implementing→triage loop can't livelock; transition
3282
- // so the engine re-scans on the updated state.
3283
- const deadEnded = blockedByUnsatisfiedDependency(s);
3284
- if (deadEnded.length > 0) {
3285
- const now = new Date().toISOString();
3286
- let changed = false;
3287
- for (const block of deadEnded) {
3288
- for (const findingId of block.items) {
3289
- const it = s.items?.[findingId];
3290
- if (!it || it.status !== "pending")
3291
- continue;
3292
- it.status = "blocked";
3293
- it.started_at ??= now;
3294
- it.completed_at = now;
3295
- it.failure_reason =
3296
- it.failure_reason ??
3297
- "A dependency node did not reach a verified-complete disposition " +
3298
- "(a prerequisite was skipped, blocked, or the dependencies are cyclic); " +
3299
- "the host handoff will not expose this node (INV-RS-01).";
3300
- changed = true;
3301
- }
3302
- }
3303
- if (changed) {
3304
- await store.saveState(s);
3305
- return { kind: "transition", state: s };
3306
- }
3368
+ // Dead-end pending nodes that can never enter the frontier (INV-RS-01)
3369
+ // so the implementing→triage loop can't livelock; transition so the
3370
+ // engine re-scans on the updated state.
3371
+ if (sweepPermanentlyDeadBlocks(s)) {
3372
+ await store.saveState(s);
3373
+ return { kind: "transition", state: s };
3307
3374
  }
3308
3375
  return handleImplementing(root, artifactsDir, s, runLogger, store, options);
3309
3376
  },