audit-tools 0.28.1 → 0.28.3

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 (124) 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 +91 -26
  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/artifactStore.d.ts +7 -0
  51. package/dist/remediate/contractPipeline/artifactStore.d.ts.map +1 -1
  52. package/dist/remediate/contractPipeline/artifactStore.js +13 -1
  53. package/dist/remediate/contractPipeline/artifactStore.js.map +1 -1
  54. package/dist/remediate/contractPipeline/semanticProjection.d.ts.map +1 -1
  55. package/dist/remediate/contractPipeline/semanticProjection.js +36 -14
  56. package/dist/remediate/contractPipeline/semanticProjection.js.map +1 -1
  57. package/dist/remediate/index.d.ts.map +1 -1
  58. package/dist/remediate/index.js +7 -3
  59. package/dist/remediate/index.js.map +1 -1
  60. package/dist/remediate/phases/close.d.ts.map +1 -1
  61. package/dist/remediate/phases/close.js +27 -8
  62. package/dist/remediate/phases/close.js.map +1 -1
  63. package/dist/remediate/state/itemStatus.d.ts +5 -3
  64. package/dist/remediate/state/itemStatus.d.ts.map +1 -1
  65. package/dist/remediate/state/itemStatus.js +7 -3
  66. package/dist/remediate/state/itemStatus.js.map +1 -1
  67. package/dist/remediate/state/types.d.ts +7 -2
  68. package/dist/remediate/state/types.d.ts.map +1 -1
  69. package/dist/remediate/state/types.js +7 -0
  70. package/dist/remediate/state/types.js.map +1 -1
  71. package/dist/remediate/steps/contractPipeline.d.ts.map +1 -1
  72. package/dist/remediate/steps/contractPipeline.js +1 -7
  73. package/dist/remediate/steps/contractPipeline.js.map +1 -1
  74. package/dist/remediate/steps/dispatch.d.ts +109 -1
  75. package/dist/remediate/steps/dispatch.d.ts.map +1 -1
  76. package/dist/remediate/steps/dispatch.js +370 -55
  77. package/dist/remediate/steps/dispatch.js.map +1 -1
  78. package/dist/remediate/steps/nextStep.d.ts.map +1 -1
  79. package/dist/remediate/steps/nextStep.js +215 -21
  80. package/dist/remediate/steps/nextStep.js.map +1 -1
  81. package/dist/remediate/steps/prompts.d.ts +12 -0
  82. package/dist/remediate/steps/prompts.d.ts.map +1 -1
  83. package/dist/remediate/steps/prompts.js +61 -1
  84. package/dist/remediate/steps/prompts.js.map +1 -1
  85. package/dist/remediate/steps/rollingSession.d.ts.map +1 -1
  86. package/dist/remediate/steps/rollingSession.js +59 -7
  87. package/dist/remediate/steps/rollingSession.js.map +1 -1
  88. package/dist/remediate/steps/types.d.ts +10 -1
  89. package/dist/remediate/steps/types.d.ts.map +1 -1
  90. package/dist/remediate/steps/types.js.map +1 -1
  91. package/dist/remediate/validation/artifacts.d.ts.map +1 -1
  92. package/dist/remediate/validation/artifacts.js +5 -2
  93. package/dist/remediate/validation/artifacts.js.map +1 -1
  94. package/dist/remediate/validation/contractPipelineGates.d.ts.map +1 -1
  95. package/dist/remediate/validation/contractPipelineGates.js +45 -8
  96. package/dist/remediate/validation/contractPipelineGates.js.map +1 -1
  97. package/dist/shared/engine/obligationEngine.d.ts +16 -5
  98. package/dist/shared/engine/obligationEngine.d.ts.map +1 -1
  99. package/dist/shared/engine/obligationEngine.js +21 -0
  100. package/dist/shared/engine/obligationEngine.js.map +1 -1
  101. package/dist/shared/index.d.ts +5 -3
  102. package/dist/shared/index.d.ts.map +1 -1
  103. package/dist/shared/index.js +5 -3
  104. package/dist/shared/index.js.map +1 -1
  105. package/dist/shared/intent/freeFormIntentInterpreter.d.ts.map +1 -1
  106. package/dist/shared/intent/freeFormIntentInterpreter.js +5 -1
  107. package/dist/shared/intent/freeFormIntentInterpreter.js.map +1 -1
  108. package/dist/shared/providers/openAiCompatibleProvider.d.ts +5 -2
  109. package/dist/shared/providers/openAiCompatibleProvider.d.ts.map +1 -1
  110. package/dist/shared/providers/openAiCompatibleProvider.js +86 -25
  111. package/dist/shared/providers/openAiCompatibleProvider.js.map +1 -1
  112. package/dist/shared/types/intentCheckpoint.d.ts +151 -34
  113. package/dist/shared/types/intentCheckpoint.d.ts.map +1 -1
  114. package/dist/shared/types/intentCheckpoint.js +113 -1
  115. package/dist/shared/types/intentCheckpoint.js.map +1 -1
  116. package/dist/shared/types/remediationOutcome.d.ts +203 -24
  117. package/dist/shared/types/remediationOutcome.d.ts.map +1 -1
  118. package/dist/shared/types/remediationOutcome.js +65 -1
  119. package/dist/shared/types/remediationOutcome.js.map +1 -1
  120. package/dist/shared/types/sessionConfig.d.ts +8 -2
  121. package/dist/shared/types/sessionConfig.d.ts.map +1 -1
  122. package/dist/shared/types/sessionConfig.js +2 -0
  123. package/dist/shared/types/sessionConfig.js.map +1 -1
  124. package/package.json +1 -1
@@ -1,12 +1,13 @@
1
1
  import { mkdir, rename } from "node:fs/promises";
2
- import { existsSync, readdirSync, readFileSync, realpathSync, 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
+ import { resolveWindowsShimSpawnCommand } from "audit-tools/shared";
10
11
  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";
11
12
  import { validateImplementWorkerResult } from "../validation/artifacts.js";
12
13
  import { REMEDIATION_DISPATCH_PLAN_CONTRACT_VERSION, REMEDIATION_DISPATCH_QUOTA_CONTRACT_VERSION, REMEDIATION_WORKER_RESULT_CONTRACT_VERSION, } from "./types.js";
@@ -370,6 +371,40 @@ export function createWorktree(root, worktreePath, branchName) {
370
371
  throw new Error(`git worktree add failed (exit ${result.status ?? "unknown"}):\n${stderr || stdout}`);
371
372
  }
372
373
  }
374
+ /**
375
+ * Materialize into a fresh worktree any of the node's declared target paths that
376
+ * exist in the main tree but are absent from the worktree — i.e. git-untracked or
377
+ * gitignored files that `git worktree add HEAD` does not bring over. Without this
378
+ * a node whose scope names an untracked config file (the dogfood hit
379
+ * `opencode.json` and an uncommitted `.gemini/commands/*.toml`) cannot see its own
380
+ * target, so the edit silently no-ops. The "absent in worktree" test is the
381
+ * discriminator: a tracked path is already materialized from HEAD, so only the
382
+ * genuinely-missing untracked/ignored declarations are copied — a tracked-but-dirty
383
+ * file keeps its clean-from-HEAD worktree content and is never clobbered. Paths are
384
+ * repo-relative (the declared scope contract); absolute/escaping paths are skipped.
385
+ * Best-effort: a copy failure must not abort the dispatch (logged, not thrown).
386
+ */
387
+ export function seedUntrackedDeclaredPaths(root, worktreeRoot, declaredPaths) {
388
+ for (const rel of new Set(declaredPaths)) {
389
+ if (!rel || isAbsolute(rel))
390
+ continue;
391
+ // Reject paths that escape the root (defence-in-depth; declared scope is
392
+ // repo-relative and never `..`-prefixed in practice).
393
+ const dst = join(worktreeRoot, rel);
394
+ const src = join(root, rel);
395
+ if (relative(worktreeRoot, dst).startsWith(".."))
396
+ continue;
397
+ if (!existsSync(src) || existsSync(dst))
398
+ continue;
399
+ try {
400
+ mkdirSync(dirname(dst), { recursive: true });
401
+ cpSync(src, dst, { recursive: true });
402
+ }
403
+ catch (err) {
404
+ process.stderr.write(`[remediate-code] worktree seed: could not copy untracked declared path ${rel}: ${err.message}\n`);
405
+ }
406
+ }
407
+ }
373
408
  /** Remove a git worktree. Best-effort: logs but does not throw on failure. */
374
409
  export function removeWorktree(root, worktreePath) {
375
410
  const result = spawnSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: root, encoding: "utf8", shell: false });
@@ -398,17 +433,42 @@ export function resetNodeWorktreeAndBranch(root, worktreePath, branchName) {
398
433
  spawnSync("git", ["worktree", "prune"], { cwd: root, shell: false });
399
434
  // Force-delete the leftover branch from a prior attempt so `-b` recreates it.
400
435
  spawnSync("git", ["branch", "-D", branchName], { cwd: root, shell: false });
436
+ // Force-remove a leftover worktree DIRECTORY: when a prior attempt's worktree
437
+ // became an orphaned dir (registered admin entry gone but files remain),
438
+ // `git worktree remove` no-ops ("is not a working tree") and `git worktree add`
439
+ // then refuses because the path already exists. Deleting the dir makes the
440
+ // re-create succeed. Best-effort.
441
+ if (existsSync(worktreePath)) {
442
+ rmSync(worktreePath, { recursive: true, force: true });
443
+ }
401
444
  }
402
445
  /** Run each targeted command in the worktree directory. Returns pass/fail and combined output. */
446
+ /**
447
+ * Package-manager / runner shim base names that must be spawned through the
448
+ * command shell on Windows (their PATH entries are `.cmd` wrappers that
449
+ * `spawnSync(..., { shell: false })` cannot exec). Routed through the shared
450
+ * `resolveWindowsShimSpawnCommand` so the verify step is OS-agnostic — on
451
+ * non-win32 these spawn directly. Without this, every node's first verify
452
+ * command (`npm run build`) errors on Windows and the whole rolling implement
453
+ * path blocks at triage.
454
+ */
455
+ const VERIFY_SHIM_BASE_NAMES = ["npm", "npx", "pnpm", "yarn"];
403
456
  export function verifyNodeInWorktree(worktreePath, targetedCommands) {
404
457
  const outputs = [];
405
458
  for (const cmd of targetedCommands) {
406
- const [bin, ...args] = cmd.split(" ");
407
- const r = spawnSync(bin, args, {
459
+ const [bin, ...rawArgs] = cmd.split(" ");
460
+ const { command, args } = resolveWindowsShimSpawnCommand(bin, rawArgs, VERIFY_SHIM_BASE_NAMES);
461
+ const r = spawnSync(command, args, {
408
462
  cwd: worktreePath,
409
463
  encoding: "utf8",
410
464
  shell: false,
411
465
  });
466
+ if (r.error) {
467
+ // Spawn itself failed (e.g. a shim not exec'able without a shell) — surface
468
+ // it as a verify failure with the error text rather than a silent status.
469
+ outputs.push(`$ ${cmd}\n${r.error.message}`);
470
+ return { passed: false, output: outputs.join("\n---\n") };
471
+ }
412
472
  const combined = [r.stdout ?? "", r.stderr ?? ""].filter(Boolean).join("\n");
413
473
  outputs.push(`$ ${cmd}\n${combined}`);
414
474
  if (r.status !== 0) {
@@ -450,6 +510,116 @@ export function mergeWorktree(root, worktreePath, branchName) {
450
510
  export function worktreePath(root, blockId, runId) {
451
511
  return join(root, ".audit-tools", "worktrees", `remediate-${blockId}-${runId}`);
452
512
  }
513
+ /**
514
+ * Deterministic name of the dedicated remediation branch for a run. Derived from
515
+ * the stable run id (= the plan id, constant for the whole remediation) so every
516
+ * wave and the final report resolve the SAME branch without persisting it. Ref-safe:
517
+ * any character outside [A-Za-z0-9._-] collapses to '-'. Distinct from the per-node
518
+ * worktree branches (`remediate-<blockId>-<runId>`) — this uses a `remediation/` ref
519
+ * namespace so the two never collide.
520
+ */
521
+ function refSafeSegment(s, fallback) {
522
+ return (s
523
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
524
+ .replace(/\.{2,}/g, ".") // ".." is invalid in a git ref name
525
+ .replace(/^[-.]+|[-.]+$/g, "") || fallback);
526
+ }
527
+ export function remediationBranchName(runId) {
528
+ return `remediation/${refSafeSegment(runId, "run")}`;
529
+ }
530
+ /** Durable ref under which a failed-but-committed node's commit is preserved. */
531
+ function quarantineRef(runId, blockId) {
532
+ return `refs/remediation-quarantine/${refSafeSegment(runId, "run")}/${refSafeSegment(blockId, "node")}`;
533
+ }
534
+ /**
535
+ * Preserve a failed-but-committed node's work so it can never be lost. A node that
536
+ * committed real edits to its worktree branch but then failed verify / the
537
+ * write-scope gate / the cherry-pick is about to have its worktree removed and (on
538
+ * the next re-dispatch) its branch force-deleted — orphaning the commit. The dogfood
539
+ * lost a verified fix exactly this way (the worktree was pruned before recovery).
540
+ * Point a durable ref at the branch tip: a ref under refs/remediation-quarantine/
541
+ * survives `git branch -D` and `git worktree prune`, so the work stays reachable for
542
+ * a manual `git cherry-pick`. Best-effort; returns the ref + commit, or null.
543
+ */
544
+ export function quarantineFailedNodeCommit(root, branch, runId, blockId) {
545
+ const rev = spawnSync("git", ["rev-parse", "--verify", "--quiet", `${branch}^{commit}`], {
546
+ cwd: root,
547
+ encoding: "utf8",
548
+ shell: false,
549
+ });
550
+ if (rev.status !== 0)
551
+ return null;
552
+ const commit = (rev.stdout ?? "").trim();
553
+ const ref = quarantineRef(runId, blockId);
554
+ const upd = spawnSync("git", ["update-ref", ref, commit], {
555
+ cwd: root,
556
+ encoding: "utf8",
557
+ shell: false,
558
+ });
559
+ if (upd.status !== 0) {
560
+ process.stderr.write(`[remediate-code] could not quarantine ${branch}: ${(upd.stderr ?? "").trim()}\n`);
561
+ return null;
562
+ }
563
+ process.stderr.write(`[remediate-code] preserved failed node ${blockId} commit ${commit.slice(0, 8)} at ${ref} for recovery\n`);
564
+ return { ref, commit };
565
+ }
566
+ /** Clear a node's quarantine ref (e.g. once a later re-dispatch landed successfully). Best-effort. */
567
+ export function clearQuarantinedCommit(root, runId, blockId) {
568
+ spawnSync("git", ["update-ref", "-d", quarantineRef(runId, blockId)], {
569
+ cwd: root,
570
+ encoding: "utf8",
571
+ shell: false,
572
+ });
573
+ }
574
+ /** Quarantined failed-node commits still preserved for a run, for recovery surfacing in the report. */
575
+ export function listQuarantinedCommits(root, runId) {
576
+ const prefix = `refs/remediation-quarantine/${refSafeSegment(runId, "run")}/`;
577
+ const res = spawnSync("git", ["for-each-ref", "--format=%(refname) %(objectname)", prefix], {
578
+ cwd: root,
579
+ encoding: "utf8",
580
+ shell: false,
581
+ });
582
+ if (res.status !== 0 || !res.stdout)
583
+ return [];
584
+ const out = [];
585
+ for (const line of res.stdout.split("\n")) {
586
+ const [ref, commit] = line.trim().split(/\s+/);
587
+ if (!ref || !commit)
588
+ continue;
589
+ out.push({ block: ref.slice(prefix.length), ref, commit });
590
+ }
591
+ return out;
592
+ }
593
+ /**
594
+ * Ensure the main checkout is on the dedicated remediation branch BEFORE any node
595
+ * commit is cherry-picked, so accepted work lands there and the user's base branch
596
+ * is NEVER modified — the run leaves a feature branch for review (it does not merge
597
+ * back). Idempotent across waves: creates the branch from the current HEAD (the base)
598
+ * the first time, checks it out on later waves. Best-effort on a non-git root (the
599
+ * worktree dispatch flow can't run there anyway): returns null without throwing so
600
+ * non-git callers/tests are unaffected. Returns the branch name on success.
601
+ */
602
+ export function ensureRemediationBranchCheckedOut(root, runId) {
603
+ const top = gitTopLevel(root);
604
+ if (top === null || canonicalPathKey(top) !== canonicalPathKey(root))
605
+ return null;
606
+ const branch = remediationBranchName(runId);
607
+ const current = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
608
+ cwd: root,
609
+ encoding: "utf8",
610
+ shell: false,
611
+ });
612
+ if (current.status === 0 && (current.stdout ?? "").trim() === branch)
613
+ return branch;
614
+ const args = gitBranchExists(root, branch) ? ["checkout", branch] : ["checkout", "-b", branch];
615
+ const co = spawnSync("git", args, { cwd: root, encoding: "utf8", shell: false });
616
+ if (co.status !== 0) {
617
+ process.stderr.write(`[remediate-code] could not switch to remediation branch ${branch}: ${(co.stderr ?? "").trim()}\n`);
618
+ return null;
619
+ }
620
+ process.stderr.write(`[remediate-code] remediation changes land on branch ${branch} (base branch left untouched)\n`);
621
+ return branch;
622
+ }
453
623
  /**
454
624
  * Stage and commit all of a worktree's edits onto its branch. The TOOL owns this
455
625
  * commit (never the worker/host) so that the branch has a real commit for two
@@ -546,7 +716,7 @@ export function acceptNodeWorktree(params) {
546
716
  if (commit.error) {
547
717
  // Could not commit the worker's edits → cannot safely land; drop it.
548
718
  removeWorktree(root, wt);
549
- return { outcome: "error", verifyPassed, merged };
719
+ return { outcome: "error", verifyPassed, merged, diagnostic: commit.error };
550
720
  }
551
721
  if (!commit.committed) {
552
722
  // Worker reported success but made no tracked edits — nothing to verify or merge.
@@ -560,14 +730,48 @@ export function acceptNodeWorktree(params) {
560
730
  verifyPassed = verify.passed;
561
731
  if (!verify.passed) {
562
732
  // Verify failed: do not merge; drop the worktree so the main tree stays clean.
733
+ // The node DID commit real edits, so preserve them under a durable quarantine
734
+ // ref before the worktree/branch go away — a tool-verify false-negative must
735
+ // not destroy a good fix (the dogfood lost one this way). Carry the failing
736
+ // command + output so triage isn't blind on outcome:error.
737
+ quarantineFailedNodeCommit(root, branch, runId, blockId);
563
738
  removeWorktree(root, wt);
564
- return { outcome: "error", verifyPassed, merged };
739
+ return { outcome: "error", verifyPassed, merged, diagnostic: verify.output };
740
+ }
741
+ // Write-scope gate (OBL-DS-06), BEFORE the cherry-pick: an out-of-scope or
742
+ // seam-conflicting edit must never land in the main tree, so it is adjudicated
743
+ // against the branch's git diff (the ground truth) here rather than reported
744
+ // after `mergeWorktree` already merged it. A worker that legitimately needed a
745
+ // file outside its declared scope declares it via `amended_files`; an unowned
746
+ // amendment widens the effective scope, while one owned by another block blocks.
747
+ if (params.scope) {
748
+ const decision = enforceAcceptWriteScope({
749
+ root,
750
+ branch,
751
+ blockId,
752
+ allBlockScopes: params.scope.allBlockScopes,
753
+ amendedFiles: params.scope.amendedFiles,
754
+ });
755
+ if (decision.blocked) {
756
+ // Scope-blocked but the node committed real work — preserve it for recovery.
757
+ quarantineFailedNodeCommit(root, branch, runId, blockId);
758
+ removeWorktree(root, wt);
759
+ return { outcome: "error", verifyPassed, merged: false, diagnostic: decision.reason };
760
+ }
565
761
  }
566
762
  // mergeWorktree cherry-picks the verified branch and removes the worktree (on
567
763
  // success AND on conflict-abort), so no explicit cleanup is needed afterwards.
568
764
  const mergeRes = mergeWorktree(root, wt, branch);
569
765
  merged = mergeRes.success;
570
- return { outcome: mergeRes.success ? "success" : "error", verifyPassed, merged };
766
+ if (!mergeRes.success) {
767
+ // Cherry-pick conflict: the committed work would otherwise be orphaned — preserve it.
768
+ quarantineFailedNodeCommit(root, branch, runId, blockId);
769
+ return { outcome: "error", verifyPassed, merged, diagnostic: mergeRes.error };
770
+ }
771
+ // Landed successfully: clear any quarantine ref left by a prior failed attempt
772
+ // for this node so the recovery report lists only genuinely-unrecovered work.
773
+ clearQuarantinedCommit(root, runId, blockId);
774
+ return { outcome: "success", verifyPassed, merged };
571
775
  }
572
776
  /**
573
777
  * Sidecar path for a node's tool-owned accept (verify/merge) outcome. Written by
@@ -593,6 +797,8 @@ export async function recordNodeAcceptOutcome(artifactsDir, runId, blockId, resu
593
797
  outcome: result.outcome,
594
798
  verify_passed: result.verifyPassed,
595
799
  merged: result.merged,
800
+ // Only present on a failure outcome; gives triage the failing command + output.
801
+ ...(result.diagnostic !== undefined ? { diagnostic: result.diagnostic } : {}),
596
802
  });
597
803
  }
598
804
  /** Load a node's recorded accept outcome, or null when none was written. */
@@ -600,7 +806,12 @@ export async function loadNodeAcceptOutcome(artifactsDir, runId, blockId) {
600
806
  const raw = await readOptionalJsonFile(nodeAcceptOutcomePath(artifactsDir, runId, blockId));
601
807
  if (!raw)
602
808
  return null;
603
- return { outcome: raw.outcome, verifyPassed: raw.verify_passed, merged: raw.merged };
809
+ return {
810
+ outcome: raw.outcome,
811
+ verifyPassed: raw.verify_passed,
812
+ merged: raw.merged,
813
+ ...(raw.diagnostic !== undefined ? { diagnostic: raw.diagnostic } : {}),
814
+ };
604
815
  }
605
816
  /** Read the promoted DAG-node overlay fields off a Finding (all optional). */
606
817
  function nodeFieldsOf(finding) {
@@ -1157,7 +1368,10 @@ function implementPrompt(block, state, resultPath, conventions, repoRoot, feedba
1157
1368
  return [];
1158
1369
  // item_spec may be pre-populated from the plan DAG node or absent;
1159
1370
  // either way the implementer receives finding context directly.
1160
- return [{ finding, spec: item.item_spec }];
1371
+ // clarification_context carries the user's answer when this item was re-opened
1372
+ // from a clarification round (up-front gate or mid-run) — thread it through so
1373
+ // the retry acts on the decided scope, not the original ambiguity.
1374
+ return [{ finding, spec: item.item_spec, clarification: item.clarification_context }];
1161
1375
  });
1162
1376
  // When a worktreeRoot is supplied, the worker operates in the worktree, not
1163
1377
  // the main repo root. Source file paths are prefixed with the worktree root.
@@ -1197,12 +1411,12 @@ Set the shell/tool workdir to the repository root when running commands; do not
1197
1411
  ## Items
1198
1412
 
1199
1413
  ${items
1200
- .map(({ finding, spec }) => `
1414
+ .map(({ finding, spec, clarification }) => `
1201
1415
  ### ${finding.id} - ${finding.title}
1202
1416
 
1203
1417
  - Files: ${itemReadFiles(finding, spec).map(resolveFilePath).join(", ")}
1204
1418
  - Summary: ${finding.summary}
1205
- ${groundingVerificationBullet(finding)}
1419
+ ${clarification ? `- Clarified scope (decided with the user — act on THIS): ${clarification}\n` : ""}${groundingVerificationBullet(finding)}
1206
1420
  ${spec ? `- Concrete change: ${spec.concrete_change}
1207
1421
  - Tests to write: ${spec.tests_to_write
1208
1422
  .map((test) => `${test.name}: ${test.assertions.join("; ")}`)
@@ -1253,8 +1467,15 @@ ${items
1253
1467
  }
1254
1468
  \`\`\`
1255
1469
 
1256
- For an item you cannot safely finish, set \`status\` to \`blocked\` and include
1257
- \`failure_reason\`. Stop after writing the result JSON.
1470
+ For an item you cannot safely finish because of an EXECUTION failure (a test
1471
+ won't pass, a build breaks, the change is infeasible), set \`status\` to
1472
+ \`blocked\` and include \`failure_reason\`. If instead you are stuck on a SCOPING
1473
+ or JUDGMENT question — how far the fix should reach, which of several valid
1474
+ behaviors is intended, or whether the issue is real — do NOT guess and do NOT
1475
+ block: set \`status\` to \`needs_clarification\` and put the question in
1476
+ \`clarification_question\` (optionally \`clarification_category\`). It is routed to
1477
+ the user as a real question, then re-dispatched with the answer. Stop after
1478
+ writing the result JSON.
1258
1479
 
1259
1480
  ## File access
1260
1481
 
@@ -1310,6 +1531,13 @@ export async function prepareImplementDispatch(options, runId, onlyBlockId, wave
1310
1531
  }
1311
1532
  return false;
1312
1533
  });
1534
+ // Before any node is dispatched (and therefore before any accepted commit is
1535
+ // cherry-picked into the main tree), switch the main checkout onto the dedicated
1536
+ // remediation branch so all landed work accumulates there and the base branch is
1537
+ // never modified. Idempotent across waves; only when there is work to land.
1538
+ if (candidateBlocks.length > 0 && options.root) {
1539
+ ensureRemediationBranchCheckedOut(options.root, runId);
1540
+ }
1313
1541
  // Walk the repo for test files ONCE per dispatch (not once per block) and cache
1314
1542
  // their contents; collectReferencingTests then matches in memory.
1315
1543
  const testIndex = buildTestFileIndex(options.root);
@@ -1528,6 +1756,68 @@ export function enforceWriteScope(declaredWritePaths, edited, root) {
1528
1756
  `amended_files set is not trusted for this gate.`,
1529
1757
  };
1530
1758
  }
1759
+ /** Each block's declared write scope from a dispatch plan — the seed for the
1760
+ * accept-time write-scope gate's ownership registry (so an amended path owned by
1761
+ * a sibling block is recognised as a seam conflict, not silently granted). */
1762
+ export function blockScopesFromPlan(plan) {
1763
+ return plan.items.flatMap((item) => item.block_id && item.access
1764
+ ? [{ block_id: item.block_id, write_paths: item.access.write_paths }]
1765
+ : []);
1766
+ }
1767
+ /**
1768
+ * A block's declared target paths (write ∪ read) from the persisted dispatch plan
1769
+ * — the single source of the scope the worker actually received (same authority
1770
+ * the accept-time write-scope gate reads). Used to seed untracked declared targets
1771
+ * into a fresh worktree (see {@link seedUntrackedDeclaredPaths}).
1772
+ */
1773
+ export function declaredPathsFromPlan(plan, blockId) {
1774
+ const item = plan.items.find((i) => i.block_id === blockId);
1775
+ if (!item?.access)
1776
+ return [];
1777
+ return [...(item.access.write_paths ?? []), ...(item.access.read_paths ?? [])];
1778
+ }
1779
+ /**
1780
+ * Accept-time write-scope gate (OBL-DS-06), run from `acceptNodeWorktree` AFTER
1781
+ * the verify and BEFORE the cherry-pick so a violation PREVENTS the merge rather
1782
+ * than being reported once the edit already landed in main. It adjudicates the
1783
+ * worker's self-reported `amended_files` against all blocks' declared scopes via
1784
+ * an ephemeral `OwnershipRegistry` seeded from `allBlockScopes`: an unowned
1785
+ * amended path is granted and widens this node's effective scope (the surfaced
1786
+ * amend path — a too-narrow declared scope no longer blocks a correct fix); a path
1787
+ * owned by another block is a seam conflict that blocks. The gate then diffs the
1788
+ * worktree branch (the git ground truth, never the self-report) against the
1789
+ * effective scope. Cross-sibling contention on a file two live nodes both amend is
1790
+ * left to the merge-time lost-update detector (`detectOverlappingEdits`), which
1791
+ * sees the full set of merged blocks a single accept cannot.
1792
+ */
1793
+ export function enforceAcceptWriteScope(params) {
1794
+ const { root, branch, blockId, allBlockScopes, amendedFiles } = params;
1795
+ const registry = new OwnershipRegistry();
1796
+ registry.initialize(allBlockScopes.map((b) => ({ node_id: b.block_id, write_paths: b.write_paths })));
1797
+ if (amendedFiles.length > 0) {
1798
+ const { seam_routed } = routeAmendmentRequest(registry, blockId, amendedFiles);
1799
+ if (seam_routed.length > 0) {
1800
+ const detail = seam_routed
1801
+ .map((r) => {
1802
+ const reason = r.reason;
1803
+ if (reason.outcome === "owned")
1804
+ return `${r.path} owned by ${reason.owner_node_id}`;
1805
+ if (reason.outcome === "contended") {
1806
+ return `${r.path} contended by ${reason.sibling_node_id}`;
1807
+ }
1808
+ return r.path;
1809
+ })
1810
+ .join("; ");
1811
+ return {
1812
+ blocked: true,
1813
+ reason: `Worker amended files owned by another block (seam conflict): ${detail}. ` +
1814
+ `Resolve via the seam protocol before this node can land.`,
1815
+ };
1816
+ }
1817
+ }
1818
+ const effective = registry.getScope(blockId);
1819
+ return enforceWriteScope(effective, gitEditedFilesForBranch(root, branch), root);
1820
+ }
1531
1821
  // ---------------------------------------------------------------------------
1532
1822
  // Merge-seam: obligation-id → node remap + multi-entry collapse (tolerance)
1533
1823
  // ---------------------------------------------------------------------------
@@ -1608,18 +1898,28 @@ export function collapseItemResults(itemResults, aliasMap, knownFindingIds) {
1608
1898
  byFinding.set(targetId, normalized);
1609
1899
  continue;
1610
1900
  }
1611
- // Collapse: blocked dominates; merge evidence; keep first failure_reason.
1901
+ // Collapse precedence: blocked > needs_clarification > resolved >
1902
+ // resolved_no_change. A hard failure dominates an unanswered scoping question,
1903
+ // which dominates an actual change, which dominates a no-change claim (a
1904
+ // no-change claim only survives if every entry agreed nothing changed). Merge
1905
+ // evidence; keep first failure_reason / clarification question.
1612
1906
  const mergedEvidence = [
1613
1907
  ...new Set([...(existing.evidence ?? []), ...(normalized.evidence ?? [])]),
1614
1908
  ];
1615
1909
  const status = existing.status === "blocked" || normalized.status === "blocked"
1616
1910
  ? "blocked"
1617
- : "resolved";
1911
+ : existing.status === "needs_clarification" || normalized.status === "needs_clarification"
1912
+ ? "needs_clarification"
1913
+ : existing.status === "resolved" || normalized.status === "resolved"
1914
+ ? "resolved"
1915
+ : "resolved_no_change";
1618
1916
  byFinding.set(targetId, {
1619
1917
  finding_id: targetId,
1620
1918
  status,
1621
1919
  evidence: mergedEvidence.length > 0 ? mergedEvidence : undefined,
1622
1920
  failure_reason: existing.failure_reason ?? normalized.failure_reason,
1921
+ clarification_question: existing.clarification_question ?? normalized.clarification_question,
1922
+ clarification_category: existing.clarification_category ?? normalized.clarification_category,
1623
1923
  });
1624
1924
  }
1625
1925
  return { collapsed: [...byFinding.values()], unresolved };
@@ -1928,9 +2228,11 @@ async function mergeImplementResultsIntoState(options, runId, plan, state) {
1928
2228
  if (isTerminalStatus(stateItem.status)) {
1929
2229
  continue;
1930
2230
  }
1931
- if (itemResult.status === "resolved") {
2231
+ if (itemResult.status === "resolved" || itemResult.status === "resolved_no_change") {
1932
2232
  const spec = stateItem.item_spec;
1933
- const isNoChange = specIndicatesNoChange(spec);
2233
+ // The worker's explicit `resolved_no_change` is a no-change signal in its
2234
+ // own right; the spec heuristic is the fallback for a plain `resolved`.
2235
+ const isNoChange = itemResult.status === "resolved_no_change" || specIndicatesNoChange(spec);
1934
2236
  if (isNoChange && !hasExecutableEvidence(itemResult.evidence)) {
1935
2237
  // No-prose closure: a "verified-already-satisfied" (no-change) claim must
1936
2238
  // be backed by an executable assertion (a test/build/check command +
@@ -1964,6 +2266,31 @@ async function mergeImplementResultsIntoState(options, runId, plan, state) {
1964
2266
  }
1965
2267
  }
1966
2268
  }
2269
+ else if (itemResult.status === "needs_clarification") {
2270
+ // Mid-run escape hatch (note 3, part B): the worker hit scoping/judgment
2271
+ // ambiguity. Route it to a clarification round (a real user question), not
2272
+ // to triage's retry/ignore/halt. NOT terminal — the answer re-opens it.
2273
+ stateItem.status = "needs_clarification";
2274
+ const question = itemResult.clarification_question ??
2275
+ itemResult.failure_reason ??
2276
+ "The worker reported unresolved scoping/judgment ambiguity.";
2277
+ stateItem.failure_reason = question;
2278
+ const category = isClarificationCategory(itemResult.clarification_category)
2279
+ ? itemResult.clarification_category
2280
+ : "scope_of_fix";
2281
+ const clarifications = state.clarifications ?? [];
2282
+ if (!clarifications.some((c) => c.finding_id === itemResult.finding_id)) {
2283
+ clarifications.push({
2284
+ finding_id: itemResult.finding_id,
2285
+ category,
2286
+ description: question,
2287
+ });
2288
+ }
2289
+ state.clarifications = clarifications;
2290
+ // The run is paused for the batched clarification round at the single
2291
+ // post-loop status decision below (a needs_clarification item outranks
2292
+ // implementing/triage), so the answer is applied before any more work.
2293
+ }
1967
2294
  else {
1968
2295
  stateItem.status = "blocked";
1969
2296
  markTerminal(stateItem);
@@ -1971,44 +2298,22 @@ async function mergeImplementResultsIntoState(options, runId, plan, state) {
1971
2298
  itemResult.failure_reason ?? "Implementation worker blocked.";
1972
2299
  }
1973
2300
  }
1974
- // Write-scope enforcement against the worker's ACTUAL git edits
1975
- // (OBL-DS-06): never trust self-reported amended_files for the gate. The
1976
- // enforcement ground truth is the worker's worktree diff; it is applied when
1977
- // this block was dispatched into an isolated worktree (the rolling-dispatch
1978
- // flow). On the interim main-tree path there is no per-worker isolation to
1979
- // diff against, so the gate is skipped here rather than mistaking ambient
1980
- // working-tree state for this worker's edits. The decision is a pure function
1981
- // (`enforceWriteScope`) so it is unit-tested directly against a known edit
1982
- // set, and fail-closed semantics apply when git is a repo but the probe fails.
2301
+ // Per-block ACTUAL worktree-branch edits, collected for the post-loop
2302
+ // lost-update / overlapping-edit detection (a file edited by more than one
2303
+ // merged block). The write-scope gate itself is NOT applied here: it runs at
2304
+ // ACCEPT time (`acceptNodeWorktree` `enforceAcceptWriteScope`), BEFORE the
2305
+ // cherry-pick, so an out-of-scope edit is prevented from landing rather than
2306
+ // reported once already merged and a node it blocks reaches the merge as
2307
+ // `merged:false`, routed to triage by the merge-state gate below (with the
2308
+ // write-scope reason carried in its diagnostic). A missing branch means the
2309
+ // interim main-tree path was used (no per-worker diff): nothing to collect.
1983
2310
  const worktreeBranch = worktreeBranchForBlock(blockId, runId);
1984
- // Resolve this block's ACTUAL worktree-branch edits ONCE when the branch
1985
- // exists — reused for the write-scope gate below AND the post-loop
1986
- // lost-update detection. A missing branch means the interim main-tree path
1987
- // was used (no per-worker diff): skip both checks for this block.
1988
2311
  const branchEdited = gitBranchExists(options.root, worktreeBranch)
1989
2312
  ? gitEditedFilesForBranch(options.root, worktreeBranch)
1990
2313
  : null;
1991
2314
  if (branchEdited?.available) {
1992
2315
  editedByBlock.push({ block_id: blockId, files: branchEdited.files });
1993
2316
  }
1994
- // Activate the write-scope gate only when this block was actually dispatched
1995
- // through an isolated worktree (its branch exists). A missing branch means
1996
- // the interim main-tree path was used — there is no per-worker diff to
1997
- // enforce against, so the gate is skipped (NOT fail-closed: fail-closed is
1998
- // for a present repo whose diff genuinely errors).
1999
- if (resolvedFindingIds.length > 0 && item.access && branchEdited) {
2000
- const decision = enforceWriteScope(item.access.write_paths, branchEdited, options.root);
2001
- if (decision.blocked) {
2002
- for (const findingId of resolvedFindingIds) {
2003
- const stateItem = state.items[findingId];
2004
- if (!stateItem)
2005
- continue;
2006
- stateItem.status = "blocked";
2007
- markTerminal(stateItem);
2008
- stateItem.failure_reason = decision.reason;
2009
- }
2010
- }
2011
- }
2012
2317
  // Merge-state gate (authoritative, OBL-DS-06): a node that self-reported a
2013
2318
  // finding "resolved" but whose tool-owned verify/merge did NOT land its edits
2014
2319
  // (acceptNodeWorktree returned merged:false — verify failed, a cherry-pick
@@ -2031,7 +2336,10 @@ async function mergeImplementResultsIntoState(options, runId, plan, state) {
2031
2336
  `Node ${blockId} reported finding ${findingId} resolved, but its tool-owned ` +
2032
2337
  `verify/merge did not land the edits (outcome=${acceptOutcome.outcome}, ` +
2033
2338
  `verify_passed=${acceptOutcome.verifyPassed}, merged=false); the fix is not in ` +
2034
- `the main tree. Routed to triage.`;
2339
+ `the main tree. Routed to triage.` +
2340
+ (acceptOutcome.diagnostic
2341
+ ? `\nFailing command output:\n${acceptOutcome.diagnostic}`
2342
+ : "");
2035
2343
  }
2036
2344
  }
2037
2345
  }
@@ -2161,12 +2469,19 @@ async function mergeImplementResultsIntoState(options, runId, plan, state) {
2161
2469
  }
2162
2470
  process.stderr.write(`[remediate-code] dispatch: merged ${implementResolved} implement result(s), ` +
2163
2471
  `${implementRejected} rejected\n`);
2164
- // Route back to implementing while pending work remains (later dependency
2165
- // waves, or blocks deferred this wave because a prerequisite was still
2166
- // running) so the next next-step dispatches the now-ready blocks; otherwise
2167
- // advance to implementing triage.
2472
+ // A worker that reported needs_clarification (note 3, part B) outranks both
2473
+ // implementing and triage: pause the run for the batched clarification round so
2474
+ // the user's answer is applied before any more work is dispatched or triaged.
2475
+ // Otherwise route back to implementing while pending work remains (later
2476
+ // dependency waves, or blocks deferred this wave because a prerequisite was
2477
+ // still running); else advance to triage.
2478
+ const needsClarification = Object.values(state.items).some((it) => it.status === "needs_clarification");
2168
2479
  const moreToImplement = Object.values(state.items).some((it) => it.status === "pending");
2169
- state.status = moreToImplement ? "implementing" : "triage";
2480
+ state.status = needsClarification
2481
+ ? "waiting_for_clarification"
2482
+ : moreToImplement
2483
+ ? "implementing"
2484
+ : "triage";
2170
2485
  // Single commit: StateStore.mutate writes the returned state once, under the
2171
2486
  // lock it already holds (OBL-INV-RSD-02 / OBL-SEAM-RSD-04). No saveState here.
2172
2487
  return state;