audit-tools 0.28.2 → 0.28.4

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 (114) hide show
  1. package/dist/audit/cli/auditStep.d.ts +12 -1
  2. package/dist/audit/cli/auditStep.d.ts.map +1 -1
  3. package/dist/audit/cli/auditStep.js +1 -0
  4. package/dist/audit/cli/auditStep.js.map +1 -1
  5. package/dist/audit/cli/confirmIntentStep.d.ts.map +1 -1
  6. package/dist/audit/cli/confirmIntentStep.js +46 -35
  7. package/dist/audit/cli/confirmIntentStep.js.map +1 -1
  8. package/dist/audit/cli/dispatch/packetFilter.d.ts +1 -1
  9. package/dist/audit/cli/nextStepHelpers.d.ts.map +1 -1
  10. package/dist/audit/cli/nextStepHelpers.js +5 -1
  11. package/dist/audit/cli/nextStepHelpers.js.map +1 -1
  12. package/dist/audit/cli/steps.d.ts +2 -2
  13. package/dist/audit/contracts/workerSchemas.d.ts +14 -14
  14. package/dist/audit/contracts/wrapperResponse.d.ts +4 -4
  15. package/dist/audit/extractors/analyzers/types.d.ts +3 -1
  16. package/dist/audit/extractors/analyzers/types.d.ts.map +1 -1
  17. package/dist/audit/extractors/analyzers/types.js +10 -1
  18. package/dist/audit/extractors/analyzers/types.js.map +1 -1
  19. package/dist/audit/orchestrator/advanceTypes.d.ts +8 -0
  20. package/dist/audit/orchestrator/advanceTypes.d.ts.map +1 -1
  21. package/dist/audit/orchestrator/executorRunners.d.ts.map +1 -1
  22. package/dist/audit/orchestrator/executorRunners.js +1 -1
  23. package/dist/audit/orchestrator/executorRunners.js.map +1 -1
  24. package/dist/audit/orchestrator/intentCheckpointExecutor.d.ts +19 -8
  25. package/dist/audit/orchestrator/intentCheckpointExecutor.d.ts.map +1 -1
  26. package/dist/audit/orchestrator/intentCheckpointExecutor.js +64 -40
  27. package/dist/audit/orchestrator/intentCheckpointExecutor.js.map +1 -1
  28. package/dist/audit/orchestrator/requeueCommand.d.ts +1 -1
  29. package/dist/audit/reporting/synthesis.d.ts.map +1 -1
  30. package/dist/audit/reporting/synthesis.js +15 -27
  31. package/dist/audit/reporting/synthesis.js.map +1 -1
  32. package/dist/audit/types/analyzerCapability.d.ts +69 -12
  33. package/dist/audit/types/analyzerCapability.d.ts.map +1 -1
  34. package/dist/audit/types/analyzerCapability.js +25 -1
  35. package/dist/audit/types/analyzerCapability.js.map +1 -1
  36. package/dist/audit/types/auditScope.d.ts +51 -12
  37. package/dist/audit/types/auditScope.d.ts.map +1 -1
  38. package/dist/audit/types/auditScope.js +49 -1
  39. package/dist/audit/types/auditScope.js.map +1 -1
  40. package/dist/audit/types/auditState.d.ts +60 -11
  41. package/dist/audit/types/auditState.d.ts.map +1 -1
  42. package/dist/audit/types/auditState.js +24 -1
  43. package/dist/audit/types/auditState.js.map +1 -1
  44. package/dist/audit/types/flowCoverage.d.ts +65 -8
  45. package/dist/audit/types/flowCoverage.d.ts.map +1 -1
  46. package/dist/audit/types/flowCoverage.js +21 -0
  47. package/dist/audit/types/flowCoverage.js.map +1 -1
  48. package/dist/audit/types/reviewPlanning.d.ts +2 -2
  49. package/dist/audit/types.d.ts +12 -12
  50. package/dist/remediate/contractPipeline/semanticProjection.d.ts.map +1 -1
  51. package/dist/remediate/contractPipeline/semanticProjection.js +36 -14
  52. package/dist/remediate/contractPipeline/semanticProjection.js.map +1 -1
  53. package/dist/remediate/phases/close.d.ts.map +1 -1
  54. package/dist/remediate/phases/close.js +27 -8
  55. package/dist/remediate/phases/close.js.map +1 -1
  56. package/dist/remediate/state/itemStatus.d.ts +5 -3
  57. package/dist/remediate/state/itemStatus.d.ts.map +1 -1
  58. package/dist/remediate/state/itemStatus.js +7 -3
  59. package/dist/remediate/state/itemStatus.js.map +1 -1
  60. package/dist/remediate/state/types.d.ts +7 -2
  61. package/dist/remediate/state/types.d.ts.map +1 -1
  62. package/dist/remediate/state/types.js +7 -0
  63. package/dist/remediate/state/types.js.map +1 -1
  64. package/dist/remediate/steps/dispatch.d.ts +109 -0
  65. package/dist/remediate/steps/dispatch.d.ts.map +1 -1
  66. package/dist/remediate/steps/dispatch.js +348 -55
  67. package/dist/remediate/steps/dispatch.js.map +1 -1
  68. package/dist/remediate/steps/nextStep.d.ts.map +1 -1
  69. package/dist/remediate/steps/nextStep.js +215 -21
  70. package/dist/remediate/steps/nextStep.js.map +1 -1
  71. package/dist/remediate/steps/prompts.d.ts +12 -0
  72. package/dist/remediate/steps/prompts.d.ts.map +1 -1
  73. package/dist/remediate/steps/prompts.js +83 -12
  74. package/dist/remediate/steps/prompts.js.map +1 -1
  75. package/dist/remediate/steps/rollingSession.d.ts.map +1 -1
  76. package/dist/remediate/steps/rollingSession.js +52 -5
  77. package/dist/remediate/steps/rollingSession.js.map +1 -1
  78. package/dist/remediate/steps/types.d.ts +10 -1
  79. package/dist/remediate/steps/types.d.ts.map +1 -1
  80. package/dist/remediate/steps/types.js.map +1 -1
  81. package/dist/remediate/validation/artifacts.d.ts.map +1 -1
  82. package/dist/remediate/validation/artifacts.js +5 -2
  83. package/dist/remediate/validation/artifacts.js.map +1 -1
  84. package/dist/remediate/validation/contractPipelineGates.d.ts.map +1 -1
  85. package/dist/remediate/validation/contractPipelineGates.js +25 -7
  86. package/dist/remediate/validation/contractPipelineGates.js.map +1 -1
  87. package/dist/shared/engine/obligationEngine.d.ts +16 -5
  88. package/dist/shared/engine/obligationEngine.d.ts.map +1 -1
  89. package/dist/shared/engine/obligationEngine.js +21 -0
  90. package/dist/shared/engine/obligationEngine.js.map +1 -1
  91. package/dist/shared/index.d.ts +6 -2
  92. package/dist/shared/index.d.ts.map +1 -1
  93. package/dist/shared/index.js +5 -2
  94. package/dist/shared/index.js.map +1 -1
  95. package/dist/shared/intent/freeFormIntentInterpreter.d.ts.map +1 -1
  96. package/dist/shared/intent/freeFormIntentInterpreter.js +5 -1
  97. package/dist/shared/intent/freeFormIntentInterpreter.js.map +1 -1
  98. package/dist/shared/reporting/findingDisplay.d.ts +71 -0
  99. package/dist/shared/reporting/findingDisplay.d.ts.map +1 -0
  100. package/dist/shared/reporting/findingDisplay.js +133 -0
  101. package/dist/shared/reporting/findingDisplay.js.map +1 -0
  102. package/dist/shared/types/intentCheckpoint.d.ts +151 -34
  103. package/dist/shared/types/intentCheckpoint.d.ts.map +1 -1
  104. package/dist/shared/types/intentCheckpoint.js +113 -1
  105. package/dist/shared/types/intentCheckpoint.js.map +1 -1
  106. package/dist/shared/types/remediationOutcome.d.ts +203 -24
  107. package/dist/shared/types/remediationOutcome.d.ts.map +1 -1
  108. package/dist/shared/types/remediationOutcome.js +65 -1
  109. package/dist/shared/types/remediationOutcome.js.map +1 -1
  110. package/dist/shared/types/sessionConfig.d.ts +3 -1
  111. package/dist/shared/types/sessionConfig.d.ts.map +1 -1
  112. package/dist/shared/types/sessionConfig.js +2 -0
  113. package/dist/shared/types/sessionConfig.js.map +1 -1
  114. package/package.json +1 -1
@@ -1,13 +1,14 @@
1
1
  import { mkdir, rename } from "node:fs/promises";
2
- import { existsSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync } from "node:fs";
3
- import { join, relative, dirname, resolve } from "node:path";
2
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync } from "node:fs";
3
+ import { join, relative, dirname, resolve, isAbsolute } from "node:path";
4
4
  import { OwnershipRegistry } from "../dispatch/ownershipRegistry.js";
5
5
  import { routeAmendmentRequest } from "../dispatch/amendmentClaim.js";
6
6
  import { toBlockId, fromBlockId } from "../contractPipeline/idRegistry.js";
7
7
  import { spawnSync } from "node:child_process";
8
8
  import { StateStore } from "../state/store.js";
9
- import { REMEDIATION_STEP, } from "../state/types.js";
9
+ import { REMEDIATION_STEP, isClarificationCategory, } from "../state/types.js";
10
10
  import { resolveWindowsShimSpawnCommand } from "audit-tools/shared";
11
+ import { findingLead, renderFindingBadgeBody } from "audit-tools/shared";
11
12
  import { AGENT_FEEDBACK_FILENAME, readJsonFile, readOptionalJsonFile, writeJsonFile, writeTextFile, withFsRetry, formatValidationIssues, detectRepoConventions, formatRepoConventions, toPromptPathToken, estimateTokensFromBytes, buildQuotaSource, severityRank, findingNeedsVerificationBeforeFix, compareTier, mostCapableTier, normalizeRepoPath, hasConfiguredOpenAiCompatible, } from "audit-tools/shared";
12
13
  import { validateImplementWorkerResult } from "../validation/artifacts.js";
13
14
  import { REMEDIATION_DISPATCH_PLAN_CONTRACT_VERSION, REMEDIATION_DISPATCH_QUOTA_CONTRACT_VERSION, REMEDIATION_WORKER_RESULT_CONTRACT_VERSION, } from "./types.js";
@@ -371,6 +372,40 @@ export function createWorktree(root, worktreePath, branchName) {
371
372
  throw new Error(`git worktree add failed (exit ${result.status ?? "unknown"}):\n${stderr || stdout}`);
372
373
  }
373
374
  }
375
+ /**
376
+ * Materialize into a fresh worktree any of the node's declared target paths that
377
+ * exist in the main tree but are absent from the worktree — i.e. git-untracked or
378
+ * gitignored files that `git worktree add HEAD` does not bring over. Without this
379
+ * a node whose scope names an untracked config file (the dogfood hit
380
+ * `opencode.json` and an uncommitted `.gemini/commands/*.toml`) cannot see its own
381
+ * target, so the edit silently no-ops. The "absent in worktree" test is the
382
+ * discriminator: a tracked path is already materialized from HEAD, so only the
383
+ * genuinely-missing untracked/ignored declarations are copied — a tracked-but-dirty
384
+ * file keeps its clean-from-HEAD worktree content and is never clobbered. Paths are
385
+ * repo-relative (the declared scope contract); absolute/escaping paths are skipped.
386
+ * Best-effort: a copy failure must not abort the dispatch (logged, not thrown).
387
+ */
388
+ export function seedUntrackedDeclaredPaths(root, worktreeRoot, declaredPaths) {
389
+ for (const rel of new Set(declaredPaths)) {
390
+ if (!rel || isAbsolute(rel))
391
+ continue;
392
+ // Reject paths that escape the root (defence-in-depth; declared scope is
393
+ // repo-relative and never `..`-prefixed in practice).
394
+ const dst = join(worktreeRoot, rel);
395
+ const src = join(root, rel);
396
+ if (relative(worktreeRoot, dst).startsWith(".."))
397
+ continue;
398
+ if (!existsSync(src) || existsSync(dst))
399
+ continue;
400
+ try {
401
+ mkdirSync(dirname(dst), { recursive: true });
402
+ cpSync(src, dst, { recursive: true });
403
+ }
404
+ catch (err) {
405
+ process.stderr.write(`[remediate-code] worktree seed: could not copy untracked declared path ${rel}: ${err.message}\n`);
406
+ }
407
+ }
408
+ }
374
409
  /** Remove a git worktree. Best-effort: logs but does not throw on failure. */
375
410
  export function removeWorktree(root, worktreePath) {
376
411
  const result = spawnSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: root, encoding: "utf8", shell: false });
@@ -476,6 +511,116 @@ export function mergeWorktree(root, worktreePath, branchName) {
476
511
  export function worktreePath(root, blockId, runId) {
477
512
  return join(root, ".audit-tools", "worktrees", `remediate-${blockId}-${runId}`);
478
513
  }
514
+ /**
515
+ * Deterministic name of the dedicated remediation branch for a run. Derived from
516
+ * the stable run id (= the plan id, constant for the whole remediation) so every
517
+ * wave and the final report resolve the SAME branch without persisting it. Ref-safe:
518
+ * any character outside [A-Za-z0-9._-] collapses to '-'. Distinct from the per-node
519
+ * worktree branches (`remediate-<blockId>-<runId>`) — this uses a `remediation/` ref
520
+ * namespace so the two never collide.
521
+ */
522
+ function refSafeSegment(s, fallback) {
523
+ return (s
524
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
525
+ .replace(/\.{2,}/g, ".") // ".." is invalid in a git ref name
526
+ .replace(/^[-.]+|[-.]+$/g, "") || fallback);
527
+ }
528
+ export function remediationBranchName(runId) {
529
+ return `remediation/${refSafeSegment(runId, "run")}`;
530
+ }
531
+ /** Durable ref under which a failed-but-committed node's commit is preserved. */
532
+ function quarantineRef(runId, blockId) {
533
+ return `refs/remediation-quarantine/${refSafeSegment(runId, "run")}/${refSafeSegment(blockId, "node")}`;
534
+ }
535
+ /**
536
+ * Preserve a failed-but-committed node's work so it can never be lost. A node that
537
+ * committed real edits to its worktree branch but then failed verify / the
538
+ * write-scope gate / the cherry-pick is about to have its worktree removed and (on
539
+ * the next re-dispatch) its branch force-deleted — orphaning the commit. The dogfood
540
+ * lost a verified fix exactly this way (the worktree was pruned before recovery).
541
+ * Point a durable ref at the branch tip: a ref under refs/remediation-quarantine/
542
+ * survives `git branch -D` and `git worktree prune`, so the work stays reachable for
543
+ * a manual `git cherry-pick`. Best-effort; returns the ref + commit, or null.
544
+ */
545
+ export function quarantineFailedNodeCommit(root, branch, runId, blockId) {
546
+ const rev = spawnSync("git", ["rev-parse", "--verify", "--quiet", `${branch}^{commit}`], {
547
+ cwd: root,
548
+ encoding: "utf8",
549
+ shell: false,
550
+ });
551
+ if (rev.status !== 0)
552
+ return null;
553
+ const commit = (rev.stdout ?? "").trim();
554
+ const ref = quarantineRef(runId, blockId);
555
+ const upd = spawnSync("git", ["update-ref", ref, commit], {
556
+ cwd: root,
557
+ encoding: "utf8",
558
+ shell: false,
559
+ });
560
+ if (upd.status !== 0) {
561
+ process.stderr.write(`[remediate-code] could not quarantine ${branch}: ${(upd.stderr ?? "").trim()}\n`);
562
+ return null;
563
+ }
564
+ process.stderr.write(`[remediate-code] preserved failed node ${blockId} commit ${commit.slice(0, 8)} at ${ref} for recovery\n`);
565
+ return { ref, commit };
566
+ }
567
+ /** Clear a node's quarantine ref (e.g. once a later re-dispatch landed successfully). Best-effort. */
568
+ export function clearQuarantinedCommit(root, runId, blockId) {
569
+ spawnSync("git", ["update-ref", "-d", quarantineRef(runId, blockId)], {
570
+ cwd: root,
571
+ encoding: "utf8",
572
+ shell: false,
573
+ });
574
+ }
575
+ /** Quarantined failed-node commits still preserved for a run, for recovery surfacing in the report. */
576
+ export function listQuarantinedCommits(root, runId) {
577
+ const prefix = `refs/remediation-quarantine/${refSafeSegment(runId, "run")}/`;
578
+ const res = spawnSync("git", ["for-each-ref", "--format=%(refname) %(objectname)", prefix], {
579
+ cwd: root,
580
+ encoding: "utf8",
581
+ shell: false,
582
+ });
583
+ if (res.status !== 0 || !res.stdout)
584
+ return [];
585
+ const out = [];
586
+ for (const line of res.stdout.split("\n")) {
587
+ const [ref, commit] = line.trim().split(/\s+/);
588
+ if (!ref || !commit)
589
+ continue;
590
+ out.push({ block: ref.slice(prefix.length), ref, commit });
591
+ }
592
+ return out;
593
+ }
594
+ /**
595
+ * Ensure the main checkout is on the dedicated remediation branch BEFORE any node
596
+ * commit is cherry-picked, so accepted work lands there and the user's base branch
597
+ * is NEVER modified — the run leaves a feature branch for review (it does not merge
598
+ * back). Idempotent across waves: creates the branch from the current HEAD (the base)
599
+ * the first time, checks it out on later waves. Best-effort on a non-git root (the
600
+ * worktree dispatch flow can't run there anyway): returns null without throwing so
601
+ * non-git callers/tests are unaffected. Returns the branch name on success.
602
+ */
603
+ export function ensureRemediationBranchCheckedOut(root, runId) {
604
+ const top = gitTopLevel(root);
605
+ if (top === null || canonicalPathKey(top) !== canonicalPathKey(root))
606
+ return null;
607
+ const branch = remediationBranchName(runId);
608
+ const current = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
609
+ cwd: root,
610
+ encoding: "utf8",
611
+ shell: false,
612
+ });
613
+ if (current.status === 0 && (current.stdout ?? "").trim() === branch)
614
+ return branch;
615
+ const args = gitBranchExists(root, branch) ? ["checkout", branch] : ["checkout", "-b", branch];
616
+ const co = spawnSync("git", args, { cwd: root, encoding: "utf8", shell: false });
617
+ if (co.status !== 0) {
618
+ process.stderr.write(`[remediate-code] could not switch to remediation branch ${branch}: ${(co.stderr ?? "").trim()}\n`);
619
+ return null;
620
+ }
621
+ process.stderr.write(`[remediate-code] remediation changes land on branch ${branch} (base branch left untouched)\n`);
622
+ return branch;
623
+ }
479
624
  /**
480
625
  * Stage and commit all of a worktree's edits onto its branch. The TOOL owns this
481
626
  * commit (never the worker/host) so that the branch has a real commit for two
@@ -572,7 +717,7 @@ export function acceptNodeWorktree(params) {
572
717
  if (commit.error) {
573
718
  // Could not commit the worker's edits → cannot safely land; drop it.
574
719
  removeWorktree(root, wt);
575
- return { outcome: "error", verifyPassed, merged };
720
+ return { outcome: "error", verifyPassed, merged, diagnostic: commit.error };
576
721
  }
577
722
  if (!commit.committed) {
578
723
  // Worker reported success but made no tracked edits — nothing to verify or merge.
@@ -586,14 +731,48 @@ export function acceptNodeWorktree(params) {
586
731
  verifyPassed = verify.passed;
587
732
  if (!verify.passed) {
588
733
  // Verify failed: do not merge; drop the worktree so the main tree stays clean.
734
+ // The node DID commit real edits, so preserve them under a durable quarantine
735
+ // ref before the worktree/branch go away — a tool-verify false-negative must
736
+ // not destroy a good fix (the dogfood lost one this way). Carry the failing
737
+ // command + output so triage isn't blind on outcome:error.
738
+ quarantineFailedNodeCommit(root, branch, runId, blockId);
589
739
  removeWorktree(root, wt);
590
- return { outcome: "error", verifyPassed, merged };
740
+ return { outcome: "error", verifyPassed, merged, diagnostic: verify.output };
741
+ }
742
+ // Write-scope gate (OBL-DS-06), BEFORE the cherry-pick: an out-of-scope or
743
+ // seam-conflicting edit must never land in the main tree, so it is adjudicated
744
+ // against the branch's git diff (the ground truth) here rather than reported
745
+ // after `mergeWorktree` already merged it. A worker that legitimately needed a
746
+ // file outside its declared scope declares it via `amended_files`; an unowned
747
+ // amendment widens the effective scope, while one owned by another block blocks.
748
+ if (params.scope) {
749
+ const decision = enforceAcceptWriteScope({
750
+ root,
751
+ branch,
752
+ blockId,
753
+ allBlockScopes: params.scope.allBlockScopes,
754
+ amendedFiles: params.scope.amendedFiles,
755
+ });
756
+ if (decision.blocked) {
757
+ // Scope-blocked but the node committed real work — preserve it for recovery.
758
+ quarantineFailedNodeCommit(root, branch, runId, blockId);
759
+ removeWorktree(root, wt);
760
+ return { outcome: "error", verifyPassed, merged: false, diagnostic: decision.reason };
761
+ }
591
762
  }
592
763
  // mergeWorktree cherry-picks the verified branch and removes the worktree (on
593
764
  // success AND on conflict-abort), so no explicit cleanup is needed afterwards.
594
765
  const mergeRes = mergeWorktree(root, wt, branch);
595
766
  merged = mergeRes.success;
596
- return { outcome: mergeRes.success ? "success" : "error", verifyPassed, merged };
767
+ if (!mergeRes.success) {
768
+ // Cherry-pick conflict: the committed work would otherwise be orphaned — preserve it.
769
+ quarantineFailedNodeCommit(root, branch, runId, blockId);
770
+ return { outcome: "error", verifyPassed, merged, diagnostic: mergeRes.error };
771
+ }
772
+ // Landed successfully: clear any quarantine ref left by a prior failed attempt
773
+ // for this node so the recovery report lists only genuinely-unrecovered work.
774
+ clearQuarantinedCommit(root, runId, blockId);
775
+ return { outcome: "success", verifyPassed, merged };
597
776
  }
598
777
  /**
599
778
  * Sidecar path for a node's tool-owned accept (verify/merge) outcome. Written by
@@ -619,6 +798,8 @@ export async function recordNodeAcceptOutcome(artifactsDir, runId, blockId, resu
619
798
  outcome: result.outcome,
620
799
  verify_passed: result.verifyPassed,
621
800
  merged: result.merged,
801
+ // Only present on a failure outcome; gives triage the failing command + output.
802
+ ...(result.diagnostic !== undefined ? { diagnostic: result.diagnostic } : {}),
622
803
  });
623
804
  }
624
805
  /** Load a node's recorded accept outcome, or null when none was written. */
@@ -626,7 +807,12 @@ export async function loadNodeAcceptOutcome(artifactsDir, runId, blockId) {
626
807
  const raw = await readOptionalJsonFile(nodeAcceptOutcomePath(artifactsDir, runId, blockId));
627
808
  if (!raw)
628
809
  return null;
629
- return { outcome: raw.outcome, verifyPassed: raw.verify_passed, merged: raw.merged };
810
+ return {
811
+ outcome: raw.outcome,
812
+ verifyPassed: raw.verify_passed,
813
+ merged: raw.merged,
814
+ ...(raw.diagnostic !== undefined ? { diagnostic: raw.diagnostic } : {}),
815
+ };
630
816
  }
631
817
  /** Read the promoted DAG-node overlay fields off a Finding (all optional). */
632
818
  function nodeFieldsOf(finding) {
@@ -1183,7 +1369,10 @@ function implementPrompt(block, state, resultPath, conventions, repoRoot, feedba
1183
1369
  return [];
1184
1370
  // item_spec may be pre-populated from the plan DAG node or absent;
1185
1371
  // either way the implementer receives finding context directly.
1186
- return [{ finding, spec: item.item_spec }];
1372
+ // clarification_context carries the user's answer when this item was re-opened
1373
+ // from a clarification round (up-front gate or mid-run) — thread it through so
1374
+ // the retry acts on the decided scope, not the original ambiguity.
1375
+ return [{ finding, spec: item.item_spec, clarification: item.clarification_context }];
1187
1376
  });
1188
1377
  // When a worktreeRoot is supplied, the worker operates in the worktree, not
1189
1378
  // the main repo root. Source file paths are prefixed with the worktree root.
@@ -1223,12 +1412,15 @@ Set the shell/tool workdir to the repository root when running commands; do not
1223
1412
  ## Items
1224
1413
 
1225
1414
  ${items
1226
- .map(({ finding, spec }) => `
1227
- ### ${finding.id} - ${finding.title}
1415
+ .map(({ finding, spec, clarification }) => `
1416
+ ### ${finding.id} ${finding.title}
1417
+
1418
+ ${findingLead(finding.summary)}
1228
1419
 
1420
+ ${renderFindingBadgeBody(finding, { showGrounding: false, showFiles: false, showDetails: false, showEvidence: false }).join("\n")}
1229
1421
  - Files: ${itemReadFiles(finding, spec).map(resolveFilePath).join(", ")}
1230
- - Summary: ${finding.summary}
1231
- ${groundingVerificationBullet(finding)}
1422
+ - Details: ${finding.summary}
1423
+ ${clarification ? `- Clarified scope (decided with the user — act on THIS): ${clarification}\n` : ""}${groundingVerificationBullet(finding)}
1232
1424
  ${spec ? `- Concrete change: ${spec.concrete_change}
1233
1425
  - Tests to write: ${spec.tests_to_write
1234
1426
  .map((test) => `${test.name}: ${test.assertions.join("; ")}`)
@@ -1279,8 +1471,15 @@ ${items
1279
1471
  }
1280
1472
  \`\`\`
1281
1473
 
1282
- For an item you cannot safely finish, set \`status\` to \`blocked\` and include
1283
- \`failure_reason\`. Stop after writing the result JSON.
1474
+ For an item you cannot safely finish because of an EXECUTION failure (a test
1475
+ won't pass, a build breaks, the change is infeasible), set \`status\` to
1476
+ \`blocked\` and include \`failure_reason\`. If instead you are stuck on a SCOPING
1477
+ or JUDGMENT question — how far the fix should reach, which of several valid
1478
+ behaviors is intended, or whether the issue is real — do NOT guess and do NOT
1479
+ block: set \`status\` to \`needs_clarification\` and put the question in
1480
+ \`clarification_question\` (optionally \`clarification_category\`). It is routed to
1481
+ the user as a real question, then re-dispatched with the answer. Stop after
1482
+ writing the result JSON.
1284
1483
 
1285
1484
  ## File access
1286
1485
 
@@ -1336,6 +1535,13 @@ export async function prepareImplementDispatch(options, runId, onlyBlockId, wave
1336
1535
  }
1337
1536
  return false;
1338
1537
  });
1538
+ // Before any node is dispatched (and therefore before any accepted commit is
1539
+ // cherry-picked into the main tree), switch the main checkout onto the dedicated
1540
+ // remediation branch so all landed work accumulates there and the base branch is
1541
+ // never modified. Idempotent across waves; only when there is work to land.
1542
+ if (candidateBlocks.length > 0 && options.root) {
1543
+ ensureRemediationBranchCheckedOut(options.root, runId);
1544
+ }
1339
1545
  // Walk the repo for test files ONCE per dispatch (not once per block) and cache
1340
1546
  // their contents; collectReferencingTests then matches in memory.
1341
1547
  const testIndex = buildTestFileIndex(options.root);
@@ -1554,6 +1760,68 @@ export function enforceWriteScope(declaredWritePaths, edited, root) {
1554
1760
  `amended_files set is not trusted for this gate.`,
1555
1761
  };
1556
1762
  }
1763
+ /** Each block's declared write scope from a dispatch plan — the seed for the
1764
+ * accept-time write-scope gate's ownership registry (so an amended path owned by
1765
+ * a sibling block is recognised as a seam conflict, not silently granted). */
1766
+ export function blockScopesFromPlan(plan) {
1767
+ return plan.items.flatMap((item) => item.block_id && item.access
1768
+ ? [{ block_id: item.block_id, write_paths: item.access.write_paths }]
1769
+ : []);
1770
+ }
1771
+ /**
1772
+ * A block's declared target paths (write ∪ read) from the persisted dispatch plan
1773
+ * — the single source of the scope the worker actually received (same authority
1774
+ * the accept-time write-scope gate reads). Used to seed untracked declared targets
1775
+ * into a fresh worktree (see {@link seedUntrackedDeclaredPaths}).
1776
+ */
1777
+ export function declaredPathsFromPlan(plan, blockId) {
1778
+ const item = plan.items.find((i) => i.block_id === blockId);
1779
+ if (!item?.access)
1780
+ return [];
1781
+ return [...(item.access.write_paths ?? []), ...(item.access.read_paths ?? [])];
1782
+ }
1783
+ /**
1784
+ * Accept-time write-scope gate (OBL-DS-06), run from `acceptNodeWorktree` AFTER
1785
+ * the verify and BEFORE the cherry-pick so a violation PREVENTS the merge rather
1786
+ * than being reported once the edit already landed in main. It adjudicates the
1787
+ * worker's self-reported `amended_files` against all blocks' declared scopes via
1788
+ * an ephemeral `OwnershipRegistry` seeded from `allBlockScopes`: an unowned
1789
+ * amended path is granted and widens this node's effective scope (the surfaced
1790
+ * amend path — a too-narrow declared scope no longer blocks a correct fix); a path
1791
+ * owned by another block is a seam conflict that blocks. The gate then diffs the
1792
+ * worktree branch (the git ground truth, never the self-report) against the
1793
+ * effective scope. Cross-sibling contention on a file two live nodes both amend is
1794
+ * left to the merge-time lost-update detector (`detectOverlappingEdits`), which
1795
+ * sees the full set of merged blocks a single accept cannot.
1796
+ */
1797
+ export function enforceAcceptWriteScope(params) {
1798
+ const { root, branch, blockId, allBlockScopes, amendedFiles } = params;
1799
+ const registry = new OwnershipRegistry();
1800
+ registry.initialize(allBlockScopes.map((b) => ({ node_id: b.block_id, write_paths: b.write_paths })));
1801
+ if (amendedFiles.length > 0) {
1802
+ const { seam_routed } = routeAmendmentRequest(registry, blockId, amendedFiles);
1803
+ if (seam_routed.length > 0) {
1804
+ const detail = seam_routed
1805
+ .map((r) => {
1806
+ const reason = r.reason;
1807
+ if (reason.outcome === "owned")
1808
+ return `${r.path} owned by ${reason.owner_node_id}`;
1809
+ if (reason.outcome === "contended") {
1810
+ return `${r.path} contended by ${reason.sibling_node_id}`;
1811
+ }
1812
+ return r.path;
1813
+ })
1814
+ .join("; ");
1815
+ return {
1816
+ blocked: true,
1817
+ reason: `Worker amended files owned by another block (seam conflict): ${detail}. ` +
1818
+ `Resolve via the seam protocol before this node can land.`,
1819
+ };
1820
+ }
1821
+ }
1822
+ const effective = registry.getScope(blockId);
1823
+ return enforceWriteScope(effective, gitEditedFilesForBranch(root, branch), root);
1824
+ }
1557
1825
  // ---------------------------------------------------------------------------
1558
1826
  // Merge-seam: obligation-id → node remap + multi-entry collapse (tolerance)
1559
1827
  // ---------------------------------------------------------------------------
@@ -1634,18 +1902,28 @@ export function collapseItemResults(itemResults, aliasMap, knownFindingIds) {
1634
1902
  byFinding.set(targetId, normalized);
1635
1903
  continue;
1636
1904
  }
1637
- // Collapse: blocked dominates; merge evidence; keep first failure_reason.
1905
+ // Collapse precedence: blocked > needs_clarification > resolved >
1906
+ // resolved_no_change. A hard failure dominates an unanswered scoping question,
1907
+ // which dominates an actual change, which dominates a no-change claim (a
1908
+ // no-change claim only survives if every entry agreed nothing changed). Merge
1909
+ // evidence; keep first failure_reason / clarification question.
1638
1910
  const mergedEvidence = [
1639
1911
  ...new Set([...(existing.evidence ?? []), ...(normalized.evidence ?? [])]),
1640
1912
  ];
1641
1913
  const status = existing.status === "blocked" || normalized.status === "blocked"
1642
1914
  ? "blocked"
1643
- : "resolved";
1915
+ : existing.status === "needs_clarification" || normalized.status === "needs_clarification"
1916
+ ? "needs_clarification"
1917
+ : existing.status === "resolved" || normalized.status === "resolved"
1918
+ ? "resolved"
1919
+ : "resolved_no_change";
1644
1920
  byFinding.set(targetId, {
1645
1921
  finding_id: targetId,
1646
1922
  status,
1647
1923
  evidence: mergedEvidence.length > 0 ? mergedEvidence : undefined,
1648
1924
  failure_reason: existing.failure_reason ?? normalized.failure_reason,
1925
+ clarification_question: existing.clarification_question ?? normalized.clarification_question,
1926
+ clarification_category: existing.clarification_category ?? normalized.clarification_category,
1649
1927
  });
1650
1928
  }
1651
1929
  return { collapsed: [...byFinding.values()], unresolved };
@@ -1954,9 +2232,11 @@ async function mergeImplementResultsIntoState(options, runId, plan, state) {
1954
2232
  if (isTerminalStatus(stateItem.status)) {
1955
2233
  continue;
1956
2234
  }
1957
- if (itemResult.status === "resolved") {
2235
+ if (itemResult.status === "resolved" || itemResult.status === "resolved_no_change") {
1958
2236
  const spec = stateItem.item_spec;
1959
- const isNoChange = specIndicatesNoChange(spec);
2237
+ // The worker's explicit `resolved_no_change` is a no-change signal in its
2238
+ // own right; the spec heuristic is the fallback for a plain `resolved`.
2239
+ const isNoChange = itemResult.status === "resolved_no_change" || specIndicatesNoChange(spec);
1960
2240
  if (isNoChange && !hasExecutableEvidence(itemResult.evidence)) {
1961
2241
  // No-prose closure: a "verified-already-satisfied" (no-change) claim must
1962
2242
  // be backed by an executable assertion (a test/build/check command +
@@ -1990,6 +2270,31 @@ async function mergeImplementResultsIntoState(options, runId, plan, state) {
1990
2270
  }
1991
2271
  }
1992
2272
  }
2273
+ else if (itemResult.status === "needs_clarification") {
2274
+ // Mid-run escape hatch (note 3, part B): the worker hit scoping/judgment
2275
+ // ambiguity. Route it to a clarification round (a real user question), not
2276
+ // to triage's retry/ignore/halt. NOT terminal — the answer re-opens it.
2277
+ stateItem.status = "needs_clarification";
2278
+ const question = itemResult.clarification_question ??
2279
+ itemResult.failure_reason ??
2280
+ "The worker reported unresolved scoping/judgment ambiguity.";
2281
+ stateItem.failure_reason = question;
2282
+ const category = isClarificationCategory(itemResult.clarification_category)
2283
+ ? itemResult.clarification_category
2284
+ : "scope_of_fix";
2285
+ const clarifications = state.clarifications ?? [];
2286
+ if (!clarifications.some((c) => c.finding_id === itemResult.finding_id)) {
2287
+ clarifications.push({
2288
+ finding_id: itemResult.finding_id,
2289
+ category,
2290
+ description: question,
2291
+ });
2292
+ }
2293
+ state.clarifications = clarifications;
2294
+ // The run is paused for the batched clarification round at the single
2295
+ // post-loop status decision below (a needs_clarification item outranks
2296
+ // implementing/triage), so the answer is applied before any more work.
2297
+ }
1993
2298
  else {
1994
2299
  stateItem.status = "blocked";
1995
2300
  markTerminal(stateItem);
@@ -1997,44 +2302,22 @@ async function mergeImplementResultsIntoState(options, runId, plan, state) {
1997
2302
  itemResult.failure_reason ?? "Implementation worker blocked.";
1998
2303
  }
1999
2304
  }
2000
- // Write-scope enforcement against the worker's ACTUAL git edits
2001
- // (OBL-DS-06): never trust self-reported amended_files for the gate. The
2002
- // enforcement ground truth is the worker's worktree diff; it is applied when
2003
- // this block was dispatched into an isolated worktree (the rolling-dispatch
2004
- // flow). On the interim main-tree path there is no per-worker isolation to
2005
- // diff against, so the gate is skipped here rather than mistaking ambient
2006
- // working-tree state for this worker's edits. The decision is a pure function
2007
- // (`enforceWriteScope`) so it is unit-tested directly against a known edit
2008
- // set, and fail-closed semantics apply when git is a repo but the probe fails.
2305
+ // Per-block ACTUAL worktree-branch edits, collected for the post-loop
2306
+ // lost-update / overlapping-edit detection (a file edited by more than one
2307
+ // merged block). The write-scope gate itself is NOT applied here: it runs at
2308
+ // ACCEPT time (`acceptNodeWorktree` `enforceAcceptWriteScope`), BEFORE the
2309
+ // cherry-pick, so an out-of-scope edit is prevented from landing rather than
2310
+ // reported once already merged and a node it blocks reaches the merge as
2311
+ // `merged:false`, routed to triage by the merge-state gate below (with the
2312
+ // write-scope reason carried in its diagnostic). A missing branch means the
2313
+ // interim main-tree path was used (no per-worker diff): nothing to collect.
2009
2314
  const worktreeBranch = worktreeBranchForBlock(blockId, runId);
2010
- // Resolve this block's ACTUAL worktree-branch edits ONCE when the branch
2011
- // exists — reused for the write-scope gate below AND the post-loop
2012
- // lost-update detection. A missing branch means the interim main-tree path
2013
- // was used (no per-worker diff): skip both checks for this block.
2014
2315
  const branchEdited = gitBranchExists(options.root, worktreeBranch)
2015
2316
  ? gitEditedFilesForBranch(options.root, worktreeBranch)
2016
2317
  : null;
2017
2318
  if (branchEdited?.available) {
2018
2319
  editedByBlock.push({ block_id: blockId, files: branchEdited.files });
2019
2320
  }
2020
- // Activate the write-scope gate only when this block was actually dispatched
2021
- // through an isolated worktree (its branch exists). A missing branch means
2022
- // the interim main-tree path was used — there is no per-worker diff to
2023
- // enforce against, so the gate is skipped (NOT fail-closed: fail-closed is
2024
- // for a present repo whose diff genuinely errors).
2025
- if (resolvedFindingIds.length > 0 && item.access && branchEdited) {
2026
- const decision = enforceWriteScope(item.access.write_paths, branchEdited, options.root);
2027
- if (decision.blocked) {
2028
- for (const findingId of resolvedFindingIds) {
2029
- const stateItem = state.items[findingId];
2030
- if (!stateItem)
2031
- continue;
2032
- stateItem.status = "blocked";
2033
- markTerminal(stateItem);
2034
- stateItem.failure_reason = decision.reason;
2035
- }
2036
- }
2037
- }
2038
2321
  // Merge-state gate (authoritative, OBL-DS-06): a node that self-reported a
2039
2322
  // finding "resolved" but whose tool-owned verify/merge did NOT land its edits
2040
2323
  // (acceptNodeWorktree returned merged:false — verify failed, a cherry-pick
@@ -2057,7 +2340,10 @@ async function mergeImplementResultsIntoState(options, runId, plan, state) {
2057
2340
  `Node ${blockId} reported finding ${findingId} resolved, but its tool-owned ` +
2058
2341
  `verify/merge did not land the edits (outcome=${acceptOutcome.outcome}, ` +
2059
2342
  `verify_passed=${acceptOutcome.verifyPassed}, merged=false); the fix is not in ` +
2060
- `the main tree. Routed to triage.`;
2343
+ `the main tree. Routed to triage.` +
2344
+ (acceptOutcome.diagnostic
2345
+ ? `\nFailing command output:\n${acceptOutcome.diagnostic}`
2346
+ : "");
2061
2347
  }
2062
2348
  }
2063
2349
  }
@@ -2187,12 +2473,19 @@ async function mergeImplementResultsIntoState(options, runId, plan, state) {
2187
2473
  }
2188
2474
  process.stderr.write(`[remediate-code] dispatch: merged ${implementResolved} implement result(s), ` +
2189
2475
  `${implementRejected} rejected\n`);
2190
- // Route back to implementing while pending work remains (later dependency
2191
- // waves, or blocks deferred this wave because a prerequisite was still
2192
- // running) so the next next-step dispatches the now-ready blocks; otherwise
2193
- // advance to implementing triage.
2476
+ // A worker that reported needs_clarification (note 3, part B) outranks both
2477
+ // implementing and triage: pause the run for the batched clarification round so
2478
+ // the user's answer is applied before any more work is dispatched or triaged.
2479
+ // Otherwise route back to implementing while pending work remains (later
2480
+ // dependency waves, or blocks deferred this wave because a prerequisite was
2481
+ // still running); else advance to triage.
2482
+ const needsClarification = Object.values(state.items).some((it) => it.status === "needs_clarification");
2194
2483
  const moreToImplement = Object.values(state.items).some((it) => it.status === "pending");
2195
- state.status = moreToImplement ? "implementing" : "triage";
2484
+ state.status = needsClarification
2485
+ ? "waiting_for_clarification"
2486
+ : moreToImplement
2487
+ ? "implementing"
2488
+ : "triage";
2196
2489
  // Single commit: StateStore.mutate writes the returned state once, under the
2197
2490
  // lock it already holds (OBL-INV-RSD-02 / OBL-SEAM-RSD-04). No saveState here.
2198
2491
  return state;