gentle-pi 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +51 -80
  2. package/assets/agents/review-readability.md +0 -2
  3. package/assets/agents/review-reliability.md +0 -2
  4. package/assets/agents/review-resilience.md +0 -2
  5. package/assets/agents/review-risk.md +0 -2
  6. package/assets/agents/sdd-apply.md +5 -3
  7. package/assets/agents/sdd-proposal.md +2 -0
  8. package/assets/agents/sdd-research.md +54 -0
  9. package/assets/agents/sdd-status.md +4 -4
  10. package/assets/agents/sdd-tasks.md +2 -3
  11. package/assets/agents/sdd-verify.md +21 -1
  12. package/assets/chains/sdd-full.chain.md +1 -1
  13. package/assets/chains/sdd-verify.chain.md +1 -1
  14. package/assets/orchestrator-delegation.md +54 -248
  15. package/assets/orchestrator-memory.md +2 -0
  16. package/assets/orchestrator.md +22 -38
  17. package/assets/sdd-orchestrator-workflow.md +30 -37
  18. package/assets/support/sdd-status-contract.md +7 -7
  19. package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
  20. package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
  21. package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
  22. package/docs/native-authority-architecture.md +9 -11
  23. package/docs/review-integration.md +27 -373
  24. package/extensions/ask-user-choice.ts +151 -0
  25. package/extensions/gentle-ai.ts +1778 -3673
  26. package/extensions/quiet-tools.ts +515 -32
  27. package/extensions/sdd-init.ts +4 -8
  28. package/lib/gentle-ai-renderer.ts +70 -0
  29. package/lib/model-routing-authority.ts +133 -0
  30. package/lib/native-review-cli.ts +372 -940
  31. package/lib/opaque-pi-reviewer-adapter.ts +284 -0
  32. package/lib/review-candidate-view.ts +341 -132
  33. package/lib/review-host-relay.ts +210 -68
  34. package/lib/review-integration-v2.ts +839 -307
  35. package/lib/review-last-event-controller.ts +35 -0
  36. package/lib/sdd-preflight.ts +177 -66
  37. package/lib/sdd-status.ts +66 -111
  38. package/lib/terminal-theme.ts +1 -1
  39. package/package.json +83 -82
  40. package/runtime/gentle-ai-binary.mjs +1 -1
  41. package/runtime/native-review-cli.mjs +322 -890
  42. package/runtime/review-integration-v2.mjs +789 -257
  43. package/runtime/review-relay-contract.mjs +1 -1
  44. package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +4 -5
  45. package/scripts/gentle-ai-installer.mjs +75 -22
  46. package/scripts/maintainer/provider-relay-matrix.mjs +195 -11
  47. package/scripts/test-packed-runner.mjs +4 -7
  48. package/scripts/verify-package-files.mjs +12 -13
  49. package/skills/_shared/review-ledger-contract.md +8 -14
  50. package/skills/chained-pr/SKILL.md +3 -0
  51. package/skills/cognitive-doc-design/SKILL.md +1 -1
  52. package/skills/comment-writer/SKILL.md +1 -1
  53. package/skills/gentle-ai/SKILL.md +7 -74
  54. package/skills/judgment-day/SKILL.md +5 -7
  55. package/skills/rdd-defect-workflow/SKILL.md +3 -3
  56. package/skills/release/SKILL.md +3 -3
  57. package/skills/skill-registry/SKILL.md +1 -1
  58. package/skills/work-unit-commits/SKILL.md +3 -1
  59. package/tests/artifact-language.test.ts +24 -11
  60. package/tests/ask-user-choice.test.ts +264 -0
  61. package/tests/codegraph-tools.test.ts +3 -3
  62. package/tests/crosslane/cross-lane.mjs +15 -1168
  63. package/tests/delegated-key-learnings-contract.test.ts +8 -6
  64. package/tests/devbinary/native-review-parity.devtest.ts +167 -250
  65. package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
  66. package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
  67. package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
  68. package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
  69. package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
  70. package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
  71. package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
  72. package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
  73. package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
  74. package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
  75. package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
  76. package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
  77. package/tests/gentle-ai-binary.test.ts +83 -13
  78. package/tests/gentle-ai-dev-binary.test.ts +51 -228
  79. package/tests/gentle-ai-installer.test.ts +137 -47
  80. package/tests/gentle-ai.test.ts +820 -131
  81. package/tests/gentle-theme.test.ts +133 -0
  82. package/tests/maintainer/provider-relay.maintest.ts +340 -4
  83. package/tests/model-routing-authority.test.ts +257 -0
  84. package/tests/native-review-capability-contract.test.ts +28 -1
  85. package/tests/native-review-cli.test.ts +586 -962
  86. package/tests/native-review-consent.test.ts +150 -66
  87. package/tests/native-review-parity-runtime.test.ts +80 -359
  88. package/tests/native-review-parity.test.ts +674 -925
  89. package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
  90. package/tests/orchestrator-budget.test.ts +132 -88
  91. package/tests/orchestrator-rdd-ownership.test.ts +103 -0
  92. package/tests/package-manifest.test.ts +37 -35
  93. package/tests/provider-defect-handoff.test.ts +15 -118
  94. package/tests/quiet-tool-rendering.test.ts +1055 -28
  95. package/tests/review-authority-recovery-docs.test.ts +1 -2
  96. package/tests/review-candidate-view.test.ts +409 -3
  97. package/tests/review-compact-contract.test.ts +4 -30
  98. package/tests/review-controller-lock-status.test.ts +2 -2
  99. package/tests/review-controller-native-recovery.test.ts +363 -1450
  100. package/tests/review-controller-native-routing.test.ts +1251 -5642
  101. package/tests/review-controller-retired-ops.test.ts +1 -1
  102. package/tests/review-controller-workspace-root.test.ts +222 -71
  103. package/tests/review-controller.test.ts +26 -816
  104. package/tests/review-corrected-finalize-binding.test.ts +112 -153
  105. package/tests/review-dispatch-hydration-gap.test.ts +1 -53
  106. package/tests/review-gate.test.ts +0 -45
  107. package/tests/review-host-relay-restart-parity.test.ts +360 -0
  108. package/tests/review-host-relay-routing.test.ts +117 -82
  109. package/tests/review-host-relay.test.ts +242 -8
  110. package/tests/review-integration-v2-forward.test.ts +576 -553
  111. package/tests/review-integration-v2.test.ts +239 -165
  112. package/tests/review-last-event-closure.test.ts +408 -0
  113. package/tests/review-ledger-contract.test.ts +97 -35
  114. package/tests/review-recovered-lineage-routing.test.ts +0 -47
  115. package/tests/review-relay-transport-agent.test.ts +119 -46
  116. package/tests/review-snapshot.test.ts +3 -2
  117. package/tests/runtime-harness.mjs +361 -196
  118. package/tests/sdd-agent-tools.test.ts +36 -0
  119. package/tests/sdd-preflight.test.ts +81 -15
  120. package/tests/sdd-status.test.ts +109 -110
  121. package/tests/skill-collision-prefixes.test.ts +5 -8
  122. package/tests/writer-edit-surface-scope.test.ts +230 -0
  123. package/themes/Gentleman-Cute.json +94 -0
  124. package/themes/Gentleman-Sexy.json +92 -0
  125. package/lib/git-commit-transaction.ts +0 -861
  126. package/runtime/git-commit-transaction.mjs +0 -862
  127. package/scripts/run-git-commit-transaction.mjs +0 -35
  128. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
  129. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
  130. package/tests/git-commit-transaction.test.ts +0 -530
@@ -1,6 +1,6 @@
1
1
  import { execFileSync, type ExecFileSyncOptions } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
- import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync } from "node:fs";
3
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync } from "node:fs";
4
4
  import { mkdtempSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -103,6 +103,7 @@ interface CandidateViewRecord {
103
103
  baseTree: string;
104
104
  candidateTree: string;
105
105
  committedOnly: boolean;
106
+ intendedUntracked?: readonly string[];
106
107
  entries: readonly CandidateViewEntry[];
107
108
  gitlinks: readonly CandidateGitlink[];
108
109
  scope: CandidateViewScope;
@@ -114,10 +115,12 @@ interface CandidateViewRecord {
114
115
  export interface CandidateView {
115
116
  token: string;
116
117
  root: string;
118
+ contributorRoot: string;
117
119
  baseCommit: string;
118
120
  baseTree: string;
119
121
  candidateTree: string;
120
122
  committedOnly: boolean;
123
+ intendedUntracked?: readonly string[];
121
124
  paths: readonly string[];
122
125
  modes: Readonly<Record<string, string>>;
123
126
  gitlinks: Readonly<Record<string, string>>;
@@ -159,6 +162,7 @@ export interface FrozenCandidateProjection {
159
162
  baseTree: string;
160
163
  candidateTree: string;
161
164
  committedOnly: boolean;
165
+ intendedUntracked?: readonly string[];
162
166
  paths: readonly string[];
163
167
  modes: Readonly<Record<string, string>>;
164
168
  gitlinks: Readonly<Record<string, string>>;
@@ -169,6 +173,8 @@ export interface CreateCandidateViewRequest {
169
173
  contributorRoot: string;
170
174
  baseRef?: string;
171
175
  committedOnly?: boolean;
176
+ /** Undefined keeps legacy all-untracked capture; [] excludes untracked files. */
177
+ intendedUntracked?: readonly string[];
172
178
  replayKey?: string;
173
179
  }
174
180
 
@@ -185,6 +191,7 @@ export interface AuthoritativeReviewingCandidateState {
185
191
  baseTree: string;
186
192
  candidateTree: string;
187
193
  committedOnly?: boolean;
194
+ intendedUntracked?: readonly string[];
188
195
  paths: readonly string[];
189
196
  modes: Readonly<Record<string, string>>;
190
197
  gitlinks?: Readonly<Record<string, string>>;
@@ -416,23 +423,30 @@ export interface ChangedPathEntry {
416
423
  readonly modeOnly: boolean;
417
424
  }
418
425
 
426
+ //
427
+ // gentle-pi#518: both derivations diff with `--no-renames`, exactly as the
428
+ // native provider does. A rename is then its source deletion plus its
429
+ // destination addition, one path each, so the projection identity Pi freezes
430
+ // is the identity native STATUS projects. Rename detection here previously
431
+ // kept only the destination, and an exact staged rename was rejected before
432
+ // native admission with candidate-target-projection-drift.
419
433
  export function deriveChangedPathManifest(cwd: string, baseTree: string, candidateTree: string, executor: CandidateGitExecutor = defaultCandidateGitExecutor): readonly ChangedPathEntry[] {
420
- const tokens = gitPathTokens(cwd, ["diff", "--raw", "-z", "--abbrev=40", "--no-ext-diff", "--find-renames=100%", baseTree, candidateTree], executor);
434
+ const tokens = gitPathTokens(cwd, ["diff", "--raw", "-z", "--abbrev=40", "--no-ext-diff", "--no-renames", baseTree, candidateTree], executor);
421
435
  const entries: ChangedPathEntry[] = [];
422
436
  for (let index = 0; index < tokens.length;) {
423
437
  const header = tokens[index++]?.toString("ascii");
424
438
  if (header === undefined) break;
425
- // `:<old_mode> <new_mode> <old_sha> <new_sha> <status>`
426
- const match = /^:([0-7]{6}) ([0-7]{6}) ([0-9a-f]{7,64}) ([0-9a-f]{7,64}) ([AMDT]|R[0-9]{3})$/.exec(header);
439
+ // `:<old_mode> <new_mode> <old_sha> <new_sha> <status>`; with rename
440
+ // detection off, Git never emits a two-path R or C record here.
441
+ const match = /^:([0-7]{6}) ([0-7]{6}) ([0-9a-f]{7,64}) ([0-9a-f]{7,64}) ([AMDT])$/.exec(header);
427
442
  if (match === null) throw new CandidateViewError("candidate manifest Git output contains an unsafe raw header", "manifest-derivation-invalid");
428
443
  const [, oldMode, newMode, oldSha, newSha, status] = match;
429
- const firstPath = tokens[index++];
430
- if (firstPath === undefined) throw new CandidateViewError("candidate manifest Git output is incomplete", "manifest-derivation-invalid");
431
- // A rename emits both the old and the new path; the new one is the scope.
432
- const path = status.startsWith("R") ? decodeCanonicalPath(tokens[index++] ?? firstPath) : decodeCanonicalPath(firstPath);
444
+ const rawPath = tokens[index++];
445
+ if (rawPath === undefined) throw new CandidateViewError("candidate manifest Git output is incomplete", "manifest-derivation-invalid");
446
+ const path = decodeCanonicalPath(rawPath);
433
447
  entries.push(Object.freeze({
434
448
  path,
435
- status: status.startsWith("R") ? "A" : status,
449
+ status,
436
450
  oldMode,
437
451
  newMode,
438
452
  deleted: status === "D",
@@ -508,20 +522,16 @@ function deriveChangedScope(cwd: string, baseTree: string, candidateTree: string
508
522
  const present = new Map(entries.map((entry) => [entry.path, entry]));
509
523
  const paths = new Set<string>();
510
524
  const deleted = new Set<string>();
511
- const tokens = gitPathTokens(cwd, ["diff", "--name-status", "-z", "--no-ext-diff", "--find-renames=100%", baseTree, candidateTree], executor);
525
+ // `--no-renames` mirrors the native projection: a rename is one deleted
526
+ // path plus one added path (gentle-pi#518), and every record carries
527
+ // exactly one path.
528
+ const tokens = gitPathTokens(cwd, ["diff", "--name-status", "-z", "--no-ext-diff", "--no-renames", baseTree, candidateTree], executor);
512
529
  for (let index = 0; index < tokens.length;) {
513
530
  const status = tokens[index++]?.toString("ascii");
514
- if (status === undefined || !/^(?:[AMDT]|R[0-9]{3})$/.test(status)) throw new CandidateViewError("candidate scope Git output contains an unsafe status");
515
- const oldPath = tokens[index++];
516
- if (oldPath === undefined) throw new CandidateViewError("candidate scope Git output is incomplete");
517
- const firstPath = decodeCanonicalPath(oldPath);
518
- const path = status.startsWith("R")
519
- ? (() => {
520
- const newPath = tokens[index++];
521
- if (newPath === undefined) throw new CandidateViewError("candidate scope rename output is incomplete");
522
- return decodeCanonicalPath(newPath);
523
- })()
524
- : firstPath;
531
+ if (status === undefined || !/^[AMDT]$/.test(status)) throw new CandidateViewError("candidate scope Git output contains an unsafe status");
532
+ const rawPath = tokens[index++];
533
+ if (rawPath === undefined) throw new CandidateViewError("candidate scope Git output is incomplete");
534
+ const path = decodeCanonicalPath(rawPath);
525
535
  if (paths.has(path) || deleted.has(path)) throw new CandidateViewError("candidate scope Git output contains duplicate paths");
526
536
  if (status === "D") {
527
537
  if (present.has(path)) throw new CandidateViewError("candidate scope deletion is present in the candidate tree");
@@ -758,6 +768,34 @@ function addUnbornWorktree(cwd: string, root: string, branch: string, env: NodeJ
758
768
  git(root, ["symbolic-ref", "HEAD", `refs/heads/${branch}`], env, executor);
759
769
  }
760
770
 
771
+ function normalizeIntendedUntracked(paths: readonly string[] | undefined): readonly string[] | undefined {
772
+ if (paths === undefined) return undefined;
773
+ if (!Array.isArray(paths) || paths.some((path) => !isSafeCandidatePath(path)) || new Set(paths).size !== paths.length) {
774
+ throw new CandidateViewError("candidate intended-untracked selection is invalid");
775
+ }
776
+ return Object.freeze([...paths]);
777
+ }
778
+
779
+ function isErrnoCode(error: unknown, code: string): boolean {
780
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
781
+ }
782
+
783
+ function seedPrivateIndexFromLiveIndex(cwd: string, indexPath: string, executor: CandidateGitExecutor): boolean {
784
+ const liveIndex = resolve(cwd, git(cwd, ["rev-parse", "--path-format=absolute", "--git-path", "index"], process.env, executor));
785
+ const entry = lstatSync(liveIndex, { throwIfNoEntry: false });
786
+ if (entry === undefined) return false; if (!entry.isFile()) throw new CandidateViewError("candidate live Git index is not a regular file");
787
+ copyFileSync(liveIndex, indexPath);
788
+ for (const name of readdirSync(dirname(liveIndex))) if (/^sharedindex\.[0-9a-f]+$/.test(name)) {
789
+ try {
790
+ const sharedIndex = join(dirname(liveIndex), name);
791
+ if (lstatSync(sharedIndex).isFile()) copyFileSync(sharedIndex, join(dirname(indexPath), name));
792
+ } catch (error) {
793
+ if (!isErrnoCode(error, "ENOENT")) throw error;
794
+ }
795
+ }
796
+ return true;
797
+ }
798
+
761
799
  function materializeCandidateView(request: CreateCandidateViewRequest, executor: CandidateGitExecutor): CandidateViewRecord {
762
800
  const contributorRoot = realpathSync(request.contributorRoot);
763
801
  if (!lstatSync(contributorRoot).isDirectory()) throw new CandidateViewError("contributor root is not a directory");
@@ -766,6 +804,7 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
766
804
  const canonicalCommonDir = realpathSync(commonDir);
767
805
  const base = resolveCandidateBase(contributorRoot, request.baseRef, process.env, executor);
768
806
  const committedOnly = request.committedOnly === true;
807
+ const intendedUntracked = normalizeIntendedUntracked(request.intendedUntracked);
769
808
  const candidateCommit = committedOnly
770
809
  ? resolveCandidateBase(contributorRoot, "HEAD", process.env, executor)
771
810
  : base;
@@ -776,11 +815,21 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
776
815
  try {
777
816
  const baseCommit = base.commit;
778
817
  const unborn = baseCommit === "HEAD";
779
- // For an unborn repository the base tree is Git's empty tree, so seed the
780
- // private candidate index from `--empty` instead of a non-existent commit.
781
- if (unborn) git(contributorRoot, ["read-tree", "--empty"], environment, executor);
782
- else git(contributorRoot, ["read-tree", candidateCommit.commit], environment, executor);
783
- if (!committedOnly) git(contributorRoot, ["add", "-A"], environment, executor);
818
+ // Workspace candidates seed their isolated index from the resolved live index; missing indexes use the frozen base.
819
+ const seededFromLiveIndex = !committedOnly && intendedUntracked !== undefined && seedPrivateIndexFromLiveIndex(contributorRoot, indexPath, executor);
820
+ if (!seededFromLiveIndex) {
821
+ // For an unborn repository the base tree is Git's empty tree, so seed the
822
+ // private candidate index from `--empty` instead of a non-existent commit.
823
+ if (unborn) git(contributorRoot, ["read-tree", "--empty"], environment, executor);
824
+ else git(contributorRoot, ["read-tree", candidateCommit.commit], environment, executor);
825
+ }
826
+ if (!committedOnly) {
827
+ if (intendedUntracked === undefined) git(contributorRoot, ["add", "-A"], environment, executor);
828
+ else {
829
+ git(contributorRoot, ["add", "-u"], environment, executor);
830
+ if (intendedUntracked.length > 0) git(contributorRoot, ["add", "--", ...intendedUntracked], { ...environment, GIT_LITERAL_PATHSPECS: "1" }, executor);
831
+ }
832
+ }
784
833
  const candidateTree = git(contributorRoot, ["write-tree"], environment, executor);
785
834
  const root = join(parent, randomUUID());
786
835
  // The worktree is created under the same try/catch cleanup boundary as
@@ -803,7 +852,7 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
803
852
  const scope = deriveChangedScope(contributorRoot, base.tree, candidateTree, [...tree.entries, ...tree.gitlinks], executor);
804
853
  for (const gitlink of tree.gitlinks) if (lstatSync(join(root, gitlink.path), { throwIfNoEntry: false })) throw new CandidateViewError("candidate view materialized a metadata-only gitlink");
805
854
  makeReadonly(root, entries);
806
- return { token: basename(root), root: realpathSync(root), parent, contributorRoot, commonDir: canonicalCommonDir, baseCommit, baseTree: base.tree, candidateTree, committedOnly, entries, gitlinks: tree.gitlinks, scope, gitExecutor: executor };
855
+ return { token: basename(root), root: realpathSync(root), parent, contributorRoot, commonDir: canonicalCommonDir, baseCommit, baseTree: base.tree, candidateTree, committedOnly, intendedUntracked, entries, gitlinks: tree.gitlinks, scope, gitExecutor: executor };
807
856
  } catch (error) {
808
857
  try { git(contributorRoot, ["worktree", "remove", "--force", root], process.env, executor); } catch { rmSync(root, { recursive: true, force: true }); }
809
858
  throw error;
@@ -814,6 +863,20 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
814
863
  }
815
864
 
816
865
  function assertRecordSafe(record: CandidateViewRecord): void {
866
+ try {
867
+ const contributor = lstatSync(record.contributorRoot);
868
+ if (!contributor.isDirectory() || contributor.isSymbolicLink() || realpathSync(record.contributorRoot) !== record.contributorRoot) {
869
+ throw new CandidateViewError("candidate contributor root identity changed", "contributor-root-drift");
870
+ }
871
+ const toplevel = realpathSync(git(record.contributorRoot, ["rev-parse", "--show-toplevel"], process.env, record.gitExecutor));
872
+ const commonDir = realpathSync(resolve(record.contributorRoot, git(record.contributorRoot, ["rev-parse", "--git-common-dir"], process.env, record.gitExecutor)));
873
+ if (toplevel !== record.contributorRoot || commonDir !== record.commonDir) {
874
+ throw new CandidateViewError("candidate contributor root Git identity changed", "contributor-root-drift");
875
+ }
876
+ } catch (error) {
877
+ if (error instanceof CandidateViewError) throw error;
878
+ throw new CandidateViewError("candidate contributor root identity cannot be verified", "contributor-root-drift");
879
+ }
817
880
  const root = record.root;
818
881
  if (!isWithin(record.parent, root) || !existsSync(root)) throw new CandidateViewError("candidate view is missing or moved");
819
882
  const rootStat = lstatSync(root);
@@ -853,27 +916,111 @@ export class CandidateViewRegistry {
853
916
  constructor(gitExecutor: CandidateGitExecutor = defaultCandidateGitExecutor) {
854
917
  this.gitExecutor = gitExecutor;
855
918
  }
919
+ // Lifecycle state is scoped to the canonical target worktree as well as the
920
+ // provider lineage. Lineage text is repository-local and may legitimately be
921
+ // identical in two repositories owned by one Pi session.
856
922
  private readonly lineages = new Map<string, string>();
857
923
  private readonly projections = new Map<string, FrozenCandidateProjection>();
858
924
  private readonly replays = new Map<string, string>();
859
- private current: { lineageId: string; token: string } | undefined;
925
+ private readonly current = new Map<string, { lineageId: string; token: string }>();
860
926
  // The last dispatch-binding hydration that was attempted and failed. A
861
927
  // swallowed hydration failure is its own defect (field report 2026-08-16):
862
928
  // without it the later dispatch refusal claims no binding was ever
863
929
  // available instead of naming the attempt and its typed cause.
864
- private lastHydrationFailure: { lineageId: string; reason: string; message: string } | undefined;
930
+ private readonly lastHydrationFailures = new Map<string, { lineageId: string; reason: string; message: string }>();
931
+
932
+ private canonicalRoot(contributorRoot: string): string {
933
+ try {
934
+ return realpathSync(contributorRoot);
935
+ } catch {
936
+ throw new CandidateViewError("candidate contributor root could not be resolved", "contributor-root-unresolvable");
937
+ }
938
+ }
939
+
940
+ private lineageKey(contributorRoot: string, lineageId: string): string {
941
+ return `${this.canonicalRoot(contributorRoot)}\u0000${lineageId}`;
942
+ }
943
+
944
+ private replayKey(contributorRoot: string, replayKey: string): string {
945
+ return `${this.canonicalRoot(contributorRoot)}\u0000${replayKey}`;
946
+ }
947
+
948
+ private uniqueKey(
949
+ entries: ReadonlyMap<string, unknown>,
950
+ lineageId: string,
951
+ contributorRoot: string | undefined,
952
+ ): string | undefined {
953
+ if (contributorRoot !== undefined) {
954
+ const key = this.lineageKey(contributorRoot, lineageId);
955
+ return entries.has(key) ? key : undefined;
956
+ }
957
+ const suffix = `\u0000${lineageId}`;
958
+ const matches = [...entries.keys()].filter((key) => key.endsWith(suffix));
959
+ if (matches.length === 0) return undefined;
960
+ if (matches.length !== 1) {
961
+ throw new CandidateViewError(`candidate lifecycle lineage ${lineageId} is ambiguous across target roots; pass an explicit workspaceRoot`, "lineage-root-ambiguous");
962
+ }
963
+ return matches[0]!;
964
+ }
965
+
966
+ private requireKey(
967
+ entries: ReadonlyMap<string, unknown>,
968
+ lineageId: string,
969
+ contributorRoot: string | undefined,
970
+ missing: string,
971
+ ): string {
972
+ return this.uniqueKey(entries, lineageId, contributorRoot)
973
+ ?? (() => { throw new CandidateViewError(missing); })();
974
+ }
975
+
976
+ /**
977
+ * Returns the one active target root bound to a lineage, or undefined.
978
+ * Throws when that lineage is bound across multiple roots; callers must pass
979
+ * an explicit workspaceRoot to resolve the ambiguity.
980
+ */
981
+ resolveWorkspaceRoot(lineageId: string): string | undefined {
982
+ const key = this.uniqueKey(this.lineages, lineageId, undefined);
983
+ return key === undefined ? undefined : key.slice(0, key.lastIndexOf("\u0000"));
984
+ }
985
+
986
+ assertWorkspaceRoot(lineageId: string, contributorRoot: string): void {
987
+ const root = this.canonicalRoot(contributorRoot);
988
+ const exactKey = this.lineageKey(root, lineageId);
989
+ if (this.lineages.has(exactKey)) {
990
+ this.assertLineageRootIdentity(lineageId, root);
991
+ return;
992
+ }
993
+ const bound = this.resolveWorkspaceRoot(lineageId);
994
+ if (bound !== undefined) {
995
+ throw new CandidateViewError(`candidate lifecycle lineage ${lineageId} is bound to ${bound}, not the requested workspaceRoot ${root}`, "lineage-root-drift");
996
+ }
997
+ }
998
+
999
+ private assertLineageRootIdentity(lineageId: string, contributorRoot: string): void {
1000
+ const key = this.lineageKey(contributorRoot, lineageId);
1001
+ const token = this.lineages.get(key);
1002
+ const record = token === undefined ? undefined : this.records.get(token);
1003
+ if (record !== undefined) assertRecordSafe(record);
1004
+ }
865
1005
 
866
1006
  create(request: CreateCandidateViewRequest): CandidateView {
867
1007
  return this.createOrReuse(request);
868
1008
  }
869
1009
 
1010
+ cleanupAll(): void {
1011
+ for (const token of [...this.records.keys()]) this.cleanup(token);
1012
+ }
1013
+
870
1014
  createOrReuse(request: CreateCandidateViewRequest): CandidateView {
871
- const token = request.replayKey === undefined ? undefined : this.replays.get(request.replayKey);
1015
+ const contributorRoot = this.canonicalRoot(request.contributorRoot);
1016
+ const normalizedRequest = { ...request, contributorRoot };
1017
+ const scopedReplayKey = normalizedRequest.replayKey === undefined ? undefined : this.replayKey(contributorRoot, normalizedRequest.replayKey);
1018
+ const token = scopedReplayKey === undefined ? undefined : this.replays.get(scopedReplayKey);
872
1019
  const existing = token === undefined ? undefined : this.records.get(token);
873
1020
  if (existing) { assertRecordSafe(existing); return this.expose(existing); }
874
- const record = materializeCandidateView(request, this.gitExecutor);
1021
+ const record = materializeCandidateView(normalizedRequest, this.gitExecutor);
875
1022
  this.records.set(record.token, record);
876
- if (request.replayKey !== undefined) this.replays.set(request.replayKey, record.token);
1023
+ if (scopedReplayKey !== undefined) this.replays.set(scopedReplayKey, record.token);
877
1024
  return this.expose(record);
878
1025
  }
879
1026
 
@@ -883,36 +1030,37 @@ export class CandidateViewRegistry {
883
1030
 
884
1031
  bindCurrent(request: BindCandidateViewRequest): void {
885
1032
  const selectedLenses = this.validateSelectedLenses(request.selectedLenses);
886
- this.bindRecord(request.token, request.lineageId, selectedLenses);
887
- this.current = { lineageId: request.lineageId, token: request.token };
1033
+ const record = this.bindRecord(request.token, request.lineageId, selectedLenses);
1034
+ this.current.set(record.contributorRoot, { lineageId: request.lineageId, token: request.token });
888
1035
  }
889
1036
 
890
1037
  retain(token: string, lineageId: string): void {
891
- this.bindRecord(token, lineageId, []);
892
- this.current = { lineageId, token };
1038
+ const record = this.bindRecord(token, lineageId, []);
1039
+ this.current.set(record.contributorRoot, { lineageId, token });
893
1040
  }
894
1041
 
895
1042
  restoreCurrentFromNativeStart(request: BindCandidateViewRequest): void {
896
- if (this.current !== undefined) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established");
897
1043
  const record = this.records.get(request.token);
898
1044
  if (!record || record.lineageId !== undefined) throw new CandidateViewError("native reviewing candidate view is missing or already bound", "authoritative-current-match-missing");
1045
+ if (this.current.has(record.contributorRoot)) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established");
899
1046
  assertRecordSafe(record);
900
1047
  this.assertCurrentBindingMatchesLiveCandidate(record);
901
1048
  this.bindCurrent(request);
902
1049
  }
903
1050
 
904
- hasCurrentBinding(): boolean {
905
- return this.current !== undefined;
1051
+ hasCurrentBinding(contributorRoot?: string): boolean {
1052
+ return contributorRoot === undefined ? this.current.size > 0 : this.current.has(this.canonicalRoot(contributorRoot));
906
1053
  }
907
1054
 
908
1055
  restoreCurrentFromAuthoritativeReviewingStates(
909
1056
  contributorRoot: string,
910
1057
  states: readonly AuthoritativeReviewingCandidateState[],
911
1058
  ): void {
912
- if (this.current !== undefined) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established");
1059
+ const root = this.canonicalRoot(contributorRoot);
1060
+ if (this.current.has(root)) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established");
913
1061
  if (states.length === 0) throw new CandidateViewError("no authoritative reviewing lineage exactly matches the live candidate", "authoritative-current-match-missing");
914
1062
  if (states.length !== 1) throw new CandidateViewError("multiple authoritative reviewing lineages exactly match the live candidate", "authoritative-current-match-ambiguous");
915
- const live = materializeCandidateView({ contributorRoot, baseRef: states[0]!.baseCommit, committedOnly: states[0]!.committedOnly === true }, this.gitExecutor);
1063
+ const live = materializeCandidateView({ contributorRoot: root, baseRef: states[0]!.baseCommit, committedOnly: states[0]!.committedOnly === true, ...(states[0]!.intendedUntracked === undefined ? {} : { intendedUntracked: states[0]!.intendedUntracked }) }, this.gitExecutor);
916
1064
  try {
917
1065
  const matches = states.filter((state) => this.matchesAuthoritativeState(live, state));
918
1066
  if (matches.length === 0) throw new CandidateViewError("no authoritative reviewing lineage exactly matches the live candidate", "authoritative-current-match-missing");
@@ -921,28 +1069,31 @@ export class CandidateViewRegistry {
921
1069
  const selectedLenses = this.validateSelectedLenses(state.selectedLenses);
922
1070
  this.records.set(live.token, live);
923
1071
  this.bindRecord(live.token, state.lineageId, selectedLenses);
924
- this.current = { lineageId: state.lineageId, token: live.token };
1072
+ this.current.set(root, { lineageId: state.lineageId, token: live.token });
925
1073
  } catch (error) {
926
- if (!this.records.has(live.token)) this.remove(live);
1074
+ this.records.delete(live.token);
1075
+ this.remove(live);
927
1076
  throw error;
928
1077
  }
929
1078
  }
930
1079
 
931
1080
  createCorrected(lineageId: string, contributorRoot: string, replayKey: string): CandidateView {
932
- const projection = this.resolveProjection(lineageId, contributorRoot);
933
- const existingToken = this.replays.get(replayKey);
1081
+ const root = this.canonicalRoot(contributorRoot);
1082
+ const projection = this.resolveProjection(lineageId, root);
1083
+ const scopedReplayKey = this.replayKey(root, replayKey);
1084
+ const existingToken = this.replays.get(scopedReplayKey);
934
1085
  const existing = existingToken === undefined ? undefined : this.records.get(existingToken);
935
1086
  if (existing) {
936
1087
  if (existing.lineageId !== undefined) throw new CandidateViewError("corrected candidate replay is no longer pending");
937
1088
  assertRecordSafe(existing);
938
1089
  return this.expose(existing);
939
1090
  }
940
- const record = materializeCandidateView({ contributorRoot, baseRef: projection.baseCommit, committedOnly: projection.committedOnly }, this.gitExecutor);
1091
+ const record = materializeCandidateView({ contributorRoot: root, baseRef: projection.baseCommit, committedOnly: projection.committedOnly, ...(projection.intendedUntracked === undefined ? {} : { intendedUntracked: projection.intendedUntracked }) }, this.gitExecutor);
941
1092
  try {
942
1093
  if (record.baseCommit !== projection.baseCommit || record.baseTree !== projection.baseTree) throw new CandidateViewError("corrected candidate base does not match the frozen genesis base");
943
1094
  if (!record.scope.paths.every((path) => projection.paths.includes(path))) throw new CandidateViewError("corrected candidate scope escapes the frozen genesis paths");
944
1095
  this.records.set(record.token, record);
945
- this.replays.set(replayKey, record.token);
1096
+ this.replays.set(scopedReplayKey, record.token);
946
1097
  return this.expose(record);
947
1098
  } catch (error) {
948
1099
  this.remove(record);
@@ -950,15 +1101,18 @@ export class CandidateViewRegistry {
950
1101
  }
951
1102
  }
952
1103
 
953
- promoteCorrected(lineageId: string, token: string): void {
1104
+ promoteCorrected(lineageId: string, token: string, contributorRoot?: string): void {
954
1105
  const replacement = this.records.get(token);
955
- const projection = this.projections.get(lineageId);
956
- const currentToken = this.lineages.get(lineageId);
1106
+ const root = contributorRoot === undefined ? replacement?.contributorRoot : this.canonicalRoot(contributorRoot);
1107
+ const key = root === undefined ? undefined : this.uniqueKey(this.projections, lineageId, root);
1108
+ const projection = key === undefined ? undefined : this.projections.get(key);
1109
+ const currentToken = key === undefined ? undefined : this.lineages.get(key);
957
1110
  const current = currentToken === undefined ? undefined : this.records.get(currentToken);
958
- if (!replacement || replacement.lineageId !== undefined || !projection || (currentToken !== undefined && (!current || current.lineageId !== lineageId))) {
1111
+ if (!replacement || replacement.lineageId !== undefined || !key || !projection || (currentToken !== undefined && (!current || current.lineageId !== lineageId))) {
959
1112
  throw new CandidateViewError("corrected candidate replacement is missing or ambiguous");
960
1113
  }
961
- if (this.current !== undefined && this.current.lineageId !== lineageId) {
1114
+ const currentBinding = this.current.get(root);
1115
+ if (currentBinding !== undefined && currentBinding.lineageId !== lineageId) {
962
1116
  throw new CandidateViewError("corrected candidate replacement conflicts with the current lineage binding");
963
1117
  }
964
1118
  assertRecordSafe(replacement);
@@ -968,26 +1122,28 @@ export class CandidateViewRegistry {
968
1122
  replacement.baseCommit !== projection.baseCommit ||
969
1123
  replacement.baseTree !== projection.baseTree ||
970
1124
  replacement.committedOnly !== projection.committedOnly ||
1125
+ JSON.stringify(replacement.intendedUntracked ?? null) !== JSON.stringify(projection.intendedUntracked ?? null) ||
971
1126
  !replacement.scope.paths.every((path) => projection.paths.includes(path))
972
1127
  ) {
973
1128
  throw new CandidateViewError("corrected candidate replacement does not preserve its frozen lineage projection");
974
1129
  }
975
1130
  replacement.lineageId = lineageId;
976
1131
  replacement.selectedLenses = [];
977
- this.lineages.set(lineageId, token);
978
- for (const [key, pendingToken] of this.replays) if (pendingToken === token) this.replays.delete(key);
979
- this.projections.set(lineageId, {
1132
+ this.lineages.set(key, token);
1133
+ for (const [pendingKey, pendingToken] of this.replays) if (pendingToken === token) this.replays.delete(pendingKey);
1134
+ this.projections.set(key, {
980
1135
  contributorRoot: replacement.contributorRoot,
981
1136
  baseCommit: replacement.baseCommit,
982
1137
  baseTree: replacement.baseTree,
983
1138
  candidateTree: replacement.candidateTree,
984
1139
  committedOnly: replacement.committedOnly,
1140
+ intendedUntracked: replacement.intendedUntracked,
985
1141
  paths: replacement.scope.paths,
986
1142
  modes: replacement.scope.modes,
987
1143
  gitlinks: replacement.scope.gitlinks,
988
1144
  deletedPaths: replacement.scope.deletedPaths,
989
1145
  });
990
- this.current = { lineageId, token };
1146
+ this.current.set(root, { lineageId, token });
991
1147
  if (current) {
992
1148
  this.remove(current);
993
1149
  this.forget(current);
@@ -1007,6 +1163,7 @@ export class CandidateViewRegistry {
1007
1163
  state.baseTree === record.baseTree &&
1008
1164
  state.candidateTree === record.candidateTree &&
1009
1165
  (state.committedOnly ?? false) === record.committedOnly &&
1166
+ (state.intendedUntracked === undefined || JSON.stringify(state.intendedUntracked) === JSON.stringify(record.intendedUntracked)) &&
1010
1167
  JSON.stringify(state.paths) === JSON.stringify(record.scope.paths) &&
1011
1168
  JSON.stringify(state.modes) === JSON.stringify(record.scope.modes) &&
1012
1169
  gitlinkMapsEqual(state.gitlinks ?? {}, record.scope.gitlinks) &&
@@ -1016,41 +1173,46 @@ export class CandidateViewRegistry {
1016
1173
  }
1017
1174
  }
1018
1175
 
1019
- private bindRecord(token: string, lineageId: string, selectedLenses: readonly ReviewLens[]): void {
1176
+ private bindRecord(token: string, lineageId: string, selectedLenses: readonly ReviewLens[]): CandidateViewRecord {
1020
1177
  const record = this.records.get(token);
1021
- if (!record || record.lineageId !== undefined || this.lineages.has(lineageId)) throw new CandidateViewError("candidate view lineage binding is missing or ambiguous");
1178
+ const key = record === undefined ? undefined : this.lineageKey(record.contributorRoot, lineageId);
1179
+ if (!record || record.lineageId !== undefined || !key || this.lineages.has(key)) throw new CandidateViewError("candidate view lineage binding is missing or ambiguous");
1022
1180
  assertRecordSafe(record);
1023
1181
  record.lineageId = lineageId;
1024
1182
  record.selectedLenses = selectedLenses;
1025
- this.lineages.set(lineageId, record.token);
1026
- this.projections.set(lineageId, {
1183
+ this.lineages.set(key, record.token);
1184
+ this.projections.set(key, {
1027
1185
  contributorRoot: record.contributorRoot,
1028
1186
  baseCommit: record.baseCommit,
1029
1187
  baseTree: record.baseTree,
1030
1188
  candidateTree: record.candidateTree,
1031
1189
  committedOnly: record.committedOnly,
1190
+ intendedUntracked: record.intendedUntracked,
1032
1191
  paths: record.scope.paths,
1033
1192
  modes: record.scope.modes,
1034
1193
  gitlinks: record.scope.gitlinks,
1035
1194
  deletedPaths: record.scope.deletedPaths,
1036
1195
  });
1037
- for (const [key, pendingToken] of this.replays) if (pendingToken === token) this.replays.delete(key);
1196
+ for (const [replayKey, pendingToken] of this.replays) if (pendingToken === token) this.replays.delete(replayKey);
1197
+ return record;
1038
1198
  }
1039
1199
 
1040
- hasProjection(lineageId: string): boolean {
1041
- return this.projections.has(lineageId);
1200
+ hasProjection(lineageId: string, contributorRoot?: string): boolean {
1201
+ return this.uniqueKey(this.projections, lineageId, contributorRoot) !== undefined;
1042
1202
  }
1043
1203
 
1044
1204
  restoreProjection(lineageId: string, contributorRoot: string, baseCommit: string, baseTree: string, candidateTree: string, paths: readonly string[]): void {
1045
- const root = realpathSync(contributorRoot);
1205
+ const root = this.canonicalRoot(contributorRoot);
1206
+ const key = this.lineageKey(root, lineageId);
1046
1207
  const base = resolveCandidateBase(root, baseCommit, process.env, this.gitExecutor);
1047
- if (!lineageId || this.projections.has(lineageId) || base.commit !== baseCommit || base.tree !== baseTree || !isFullCommitId(candidateTree) || paths.some((path) => !isSafeCandidatePath(path)) || new Set(paths).size !== paths.length) throw new CandidateViewError("frozen correction projection is invalid or already restored");
1048
- this.projections.set(lineageId, { contributorRoot: root, baseCommit, baseTree, candidateTree, committedOnly: false, paths: [...paths], modes: {}, gitlinks: {}, deletedPaths: [] });
1208
+ if (!lineageId || this.projections.has(key) || base.commit !== baseCommit || base.tree !== baseTree || !isFullCommitId(candidateTree) || paths.some((path) => !isSafeCandidatePath(path)) || new Set(paths).size !== paths.length) throw new CandidateViewError("frozen correction projection is invalid or already restored");
1209
+ this.projections.set(key, { contributorRoot: root, baseCommit, baseTree, candidateTree, committedOnly: false, paths: [...paths], modes: {}, gitlinks: {}, deletedPaths: [] });
1049
1210
  }
1050
1211
 
1051
1212
  restoreProjectionFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor): void {
1052
- const root = realpathSync(contributorRoot);
1053
- if (!lineageId || this.projections.has(lineageId) || !isFullCommitId(descriptor.baseTree) || !isFullCommitId(descriptor.currentCandidateTree)) throw new CandidateViewError("native frozen projection is invalid or already restored");
1213
+ const root = this.canonicalRoot(contributorRoot);
1214
+ const key = this.lineageKey(root, lineageId);
1215
+ if (!lineageId || this.projections.has(key) || !isFullCommitId(descriptor.baseTree) || !isFullCommitId(descriptor.currentCandidateTree)) throw new CandidateViewError("native frozen projection is invalid or already restored");
1054
1216
  if (descriptor.paths.some((path) => !isSafeCandidatePath(path)) || new Set(descriptor.paths).size !== descriptor.paths.length) throw new CandidateViewError("native frozen projection paths are invalid");
1055
1217
  if (descriptor.intendedUntracked.some((path) => !descriptor.paths.includes(path)) || new Set(descriptor.intendedUntracked).size !== descriptor.intendedUntracked.length) throw new CandidateViewError("native intended-untracked projection is invalid");
1056
1218
  const head = resolveCandidateBase(root, "HEAD", process.env, this.gitExecutor);
@@ -1079,12 +1241,13 @@ export class CandidateViewRegistry {
1079
1241
  } else if (JSON.stringify(scope.paths) !== JSON.stringify([...descriptor.paths].sort())) {
1080
1242
  throw new CandidateViewError("native projection paths do not match Git content");
1081
1243
  }
1082
- this.projections.set(lineageId, {
1244
+ this.projections.set(key, {
1083
1245
  contributorRoot: root,
1084
1246
  baseCommit: base.commit,
1085
1247
  baseTree: descriptor.baseTree,
1086
1248
  candidateTree: descriptor.currentCandidateTree,
1087
1249
  committedOnly,
1250
+ intendedUntracked: Object.freeze([...descriptor.intendedUntracked]),
1088
1251
  paths: scope.paths,
1089
1252
  modes: scope.modes,
1090
1253
  gitlinks: scope.gitlinks,
@@ -1113,30 +1276,45 @@ export class CandidateViewRegistry {
1113
1276
  * correction.
1114
1277
  */
1115
1278
  rebindForFinalizeFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor): CandidateView {
1116
- const staleToken = this.lineages.get(lineageId);
1279
+ const root = this.canonicalRoot(contributorRoot);
1280
+ const key = this.lineageKey(root, lineageId);
1281
+ const staleToken = this.lineages.get(key);
1117
1282
  const stale = staleToken === undefined ? undefined : this.records.get(staleToken);
1118
- this.projections.delete(lineageId);
1283
+ this.projections.delete(key);
1119
1284
  if (stale !== undefined) {
1120
1285
  this.remove(stale);
1121
1286
  this.forget(stale);
1122
1287
  }
1123
- return this.restoreForFinalizeFromNative(lineageId, contributorRoot, descriptor);
1288
+ return this.restoreForFinalizeFromNative(lineageId, root, descriptor);
1124
1289
  }
1125
1290
 
1126
1291
  restoreForFinalizeFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor): CandidateView {
1127
- this.restoreProjectionFromNative(lineageId, contributorRoot, descriptor);
1128
- const projection = this.resolveProjection(lineageId, contributorRoot);
1129
- const record = materializeCandidateView({ contributorRoot, baseRef: projection.baseCommit, committedOnly: projection.committedOnly }, this.gitExecutor);
1292
+ const root = this.canonicalRoot(contributorRoot);
1293
+ const key = this.lineageKey(root, lineageId);
1294
+ let projectionRestored = false;
1295
+ let record: CandidateViewRecord | undefined;
1130
1296
  try {
1131
- if (record.baseTree !== projection.baseTree || record.candidateTree !== projection.candidateTree || JSON.stringify(record.scope.paths) !== JSON.stringify(projection.paths)) {
1132
- throw new CandidateViewError("live candidate does not match the native frozen projection");
1297
+ this.restoreProjectionFromNative(lineageId, root, descriptor);
1298
+ projectionRestored = true;
1299
+ const projection = this.resolveProjection(lineageId, root);
1300
+ const matchesProjection = (candidate: CandidateViewRecord): boolean => candidate.baseTree === projection.baseTree && candidate.candidateTree === projection.candidateTree && JSON.stringify(candidate.scope.paths) === JSON.stringify(projection.paths);
1301
+ const emptyIntendedUntracked = projection.intendedUntracked?.length === 0;
1302
+ record = materializeCandidateView({ contributorRoot: root, baseRef: projection.baseCommit, committedOnly: projection.committedOnly, ...(!emptyIntendedUntracked && projection.intendedUntracked !== undefined ? { intendedUntracked: projection.intendedUntracked } : {}) }, this.gitExecutor);
1303
+ if (!matchesProjection(record) && emptyIntendedUntracked) {
1304
+ this.remove(record);
1305
+ record = undefined;
1306
+ record = materializeCandidateView({ contributorRoot: root, baseRef: projection.baseCommit, committedOnly: projection.committedOnly, intendedUntracked: [] }, this.gitExecutor);
1133
1307
  }
1308
+ if (!matchesProjection(record)) throw new CandidateViewError("live candidate does not match the native frozen projection");
1134
1309
  this.records.set(record.token, record);
1135
1310
  this.bindRecord(record.token, lineageId, []);
1136
1311
  return this.expose(record);
1137
1312
  } catch (error) {
1138
- this.projections.delete(lineageId);
1139
- this.remove(record);
1313
+ if (projectionRestored) this.projections.delete(key);
1314
+ if (record !== undefined) {
1315
+ this.forget(record);
1316
+ this.remove(record);
1317
+ }
1140
1318
  throw error;
1141
1319
  }
1142
1320
  }
@@ -1151,55 +1329,62 @@ export class CandidateViewRegistry {
1151
1329
  * pending lenses.
1152
1330
  */
1153
1331
  restoreCurrentForDispatchFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor, selectedLenses: readonly string[]): void {
1154
- if (this.current !== undefined) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established");
1332
+ const root = this.canonicalRoot(contributorRoot);
1333
+ const key = this.lineageKey(root, lineageId);
1334
+ if (this.current.has(root)) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established");
1335
+ let projectionRestored = false;
1336
+ let record: CandidateViewRecord | undefined;
1155
1337
  try {
1156
1338
  const lenses = this.validateSelectedLenses(selectedLenses);
1157
- this.restoreProjectionFromNative(lineageId, contributorRoot, descriptor);
1158
- const projection = this.resolveProjection(lineageId, contributorRoot);
1159
- const record = materializeCandidateView({ contributorRoot, baseRef: projection.baseCommit, committedOnly: projection.committedOnly }, this.gitExecutor);
1160
- try {
1161
- if (record.baseTree !== projection.baseTree || record.candidateTree !== projection.candidateTree || JSON.stringify(record.scope.paths) !== JSON.stringify(projection.paths)) {
1162
- throw new CandidateViewError("live candidate does not match the native frozen projection");
1163
- }
1164
- this.records.set(record.token, record);
1165
- this.bindRecord(record.token, lineageId, lenses);
1166
- this.current = { lineageId, token: record.token };
1167
- } catch (error) {
1168
- this.projections.delete(lineageId);
1169
- this.records.delete(record.token);
1170
- this.remove(record);
1171
- throw error;
1339
+ this.restoreProjectionFromNative(lineageId, root, descriptor);
1340
+ projectionRestored = true;
1341
+ const projection = this.resolveProjection(lineageId, root);
1342
+ record = materializeCandidateView({ contributorRoot: root, baseRef: projection.baseCommit, committedOnly: projection.committedOnly, ...(projection.intendedUntracked === undefined ? {} : { intendedUntracked: projection.intendedUntracked }) }, this.gitExecutor);
1343
+ if (record.baseTree !== projection.baseTree || record.candidateTree !== projection.candidateTree || JSON.stringify(record.scope.paths) !== JSON.stringify(projection.paths)) {
1344
+ throw new CandidateViewError("live candidate does not match the native frozen projection");
1172
1345
  }
1173
- this.lastHydrationFailure = undefined;
1346
+ this.records.set(record.token, record);
1347
+ this.bindRecord(record.token, lineageId, lenses);
1348
+ this.current.set(root, { lineageId, token: record.token });
1349
+ this.lastHydrationFailures.delete(root);
1174
1350
  } catch (error) {
1175
- this.lastHydrationFailure = {
1351
+ if (projectionRestored) this.projections.delete(key);
1352
+ if (record !== undefined) {
1353
+ this.forget(record);
1354
+ this.remove(record);
1355
+ }
1356
+ this.lastHydrationFailures.set(root, {
1176
1357
  lineageId,
1177
1358
  reason: error instanceof CandidateViewError ? error.reason : "candidate-view-invalid",
1178
1359
  message: error instanceof Error ? error.message : String(error),
1179
- };
1360
+ });
1180
1361
  throw error;
1181
1362
  }
1182
1363
  }
1183
1364
 
1184
1365
  resolveProjection(lineageId: string, contributorRoot: string): FrozenCandidateProjection {
1185
- const projection = this.projections.get(lineageId);
1186
- if (!projection || realpathSync(contributorRoot) !== projection.contributorRoot) {
1187
- throw new CandidateViewError("candidate projection is missing, ambiguous, or belongs to a different contributor root");
1188
- }
1366
+ const key = this.lineageKey(contributorRoot, lineageId);
1367
+ const projection = this.projections.get(key);
1368
+ if (!projection) throw new CandidateViewError("candidate projection is missing, ambiguous, or belongs to a different contributor root");
1369
+ this.assertLineageRootIdentity(lineageId, contributorRoot);
1189
1370
  return projection;
1190
1371
  }
1191
1372
 
1192
- resolveForLens(lineageId: string, lens: string): CandidateView {
1193
- const token = this.lineages.get(lineageId);
1373
+ resolveForLens(lineageId: string, lens: string, contributorRoot?: string): CandidateView {
1374
+ const key = this.requireKey(this.lineages, lineageId, contributorRoot, "candidate view context is missing, ambiguous, stale, or lens-unselected");
1375
+ const token = this.lineages.get(key);
1194
1376
  const record = token === undefined ? undefined : this.records.get(token);
1195
1377
  if (!record || record.lineageId !== lineageId || !record.selectedLenses?.includes(lens as ReviewLens)) throw new CandidateViewError("candidate view context is missing, ambiguous, stale, or lens-unselected");
1196
1378
  assertRecordSafe(record);
1197
1379
  return this.expose(record);
1198
1380
  }
1199
1381
 
1200
- currentLineageId(): string {
1201
- if (this.current === undefined) {
1202
- const failure = this.lastHydrationFailure;
1382
+ private currentBinding(contributorRoot?: string): { root: string; lineageId: string; token: string } {
1383
+ if (contributorRoot !== undefined) {
1384
+ const root = this.canonicalRoot(contributorRoot);
1385
+ const binding = this.current.get(root);
1386
+ if (binding !== undefined) return { root, ...binding };
1387
+ const failure = this.lastHydrationFailures.get(root);
1203
1388
  if (failure !== undefined) {
1204
1389
  throw new CandidateViewError(
1205
1390
  `review subagent dispatch has no current controller-owned candidate view lineage binding: hydration for lineage ${failure.lineageId} was attempted from authoritative native status and failed (${failure.reason}): ${failure.message}`,
@@ -1208,23 +1393,39 @@ export class CandidateViewRegistry {
1208
1393
  }
1209
1394
  throw new CandidateViewError("review subagent dispatch has no current controller-owned candidate view lineage binding", "current-binding-missing");
1210
1395
  }
1211
- return this.current.lineageId;
1396
+ if (this.current.size !== 1) {
1397
+ if (this.current.size > 1) throw new CandidateViewError("review subagent dispatch has multiple current lineage bindings across target roots; pass an explicit workspaceRoot", "current-binding-root-ambiguous");
1398
+ const failure = [...this.lastHydrationFailures.values()][0];
1399
+ if (failure !== undefined) {
1400
+ throw new CandidateViewError(
1401
+ `review subagent dispatch has no current controller-owned candidate view lineage binding: hydration for lineage ${failure.lineageId} was attempted from authoritative native status and failed (${failure.reason}): ${failure.message}`,
1402
+ "current-binding-hydration-failed",
1403
+ );
1404
+ }
1405
+ throw new CandidateViewError("review subagent dispatch has no current controller-owned candidate view lineage binding", "current-binding-missing");
1406
+ }
1407
+ const [root, binding] = this.current.entries().next().value as [string, { lineageId: string; token: string }];
1408
+ return { root, ...binding };
1409
+ }
1410
+
1411
+ currentLineageId(contributorRoot?: string): string {
1412
+ return this.currentBinding(contributorRoot).lineageId;
1212
1413
  }
1213
1414
 
1214
1415
  /** The last failed dispatch-binding hydration, for controller envelopes. */
1215
- lastDispatchHydrationFailure(): Readonly<{ lineageId: string; reason: string; message: string }> | undefined {
1216
- return this.lastHydrationFailure;
1416
+ lastDispatchHydrationFailure(contributorRoot?: string): Readonly<{ lineageId: string; reason: string; message: string }> | undefined {
1417
+ if (contributorRoot !== undefined) return this.lastHydrationFailures.get(this.canonicalRoot(contributorRoot));
1418
+ return this.lastHydrationFailures.size === 1 ? [...this.lastHydrationFailures.values()][0] : undefined;
1217
1419
  }
1218
1420
 
1219
- resolveCurrentForLens(lens: string): CandidateView {
1220
- return this.resolveCurrentForLenses([lens])[0]!;
1421
+ resolveCurrentForLens(lens: string, contributorRoot?: string): CandidateView {
1422
+ return this.resolveCurrentForLenses([lens], contributorRoot)[0]!;
1221
1423
  }
1222
1424
 
1223
- resolveCurrentForLenses(lenses: readonly string[]): CandidateView[] {
1224
- const lineageId = this.currentLineageId();
1225
- const token = this.current?.token;
1226
- const record = token === undefined ? undefined : this.records.get(token);
1227
- if (!record || record.lineageId !== lineageId || this.lineages.get(lineageId) !== token) throw new CandidateViewError("review subagent dispatch current lineage binding is stale or ambiguous", "current-binding-stale");
1425
+ resolveCurrentForLenses(lenses: readonly string[], contributorRoot?: string): CandidateView[] {
1426
+ const current = this.currentBinding(contributorRoot);
1427
+ const record = this.records.get(current.token);
1428
+ if (!record || record.lineageId !== current.lineageId || this.lineages.get(this.lineageKey(current.root, current.lineageId)) !== current.token) throw new CandidateViewError("review subagent dispatch current lineage binding is stale or ambiguous", "current-binding-stale");
1228
1429
  assertRecordSafe(record);
1229
1430
  this.assertCurrentBindingMatchesLiveCandidate(record);
1230
1431
  if (!lenses.every((lens) => record.selectedLenses?.includes(lens as ReviewLens))) throw new CandidateViewError("candidate view context is missing, ambiguous, stale, or lens-unselected", "current-binding-lens-unselected");
@@ -1232,7 +1433,7 @@ export class CandidateViewRegistry {
1232
1433
  }
1233
1434
 
1234
1435
  private assertCurrentBindingMatchesLiveCandidate(record: CandidateViewRecord): void {
1235
- const live = materializeCandidateView({ contributorRoot: record.contributorRoot, baseRef: record.baseCommit, committedOnly: record.committedOnly }, this.gitExecutor);
1436
+ const live = materializeCandidateView({ contributorRoot: record.contributorRoot, baseRef: record.baseCommit, committedOnly: record.committedOnly, ...(record.intendedUntracked === undefined ? {} : { intendedUntracked: record.intendedUntracked }) }, this.gitExecutor);
1236
1437
  try {
1237
1438
  if (
1238
1439
  live.baseCommit !== record.baseCommit ||
@@ -1249,8 +1450,9 @@ export class CandidateViewRegistry {
1249
1450
  }
1250
1451
  }
1251
1452
 
1252
- resolveForFinalize(lineageId: string): CandidateView {
1253
- const token = this.lineages.get(lineageId);
1453
+ resolveForFinalize(lineageId: string, contributorRoot?: string): CandidateView {
1454
+ const key = this.requireKey(this.lineages, lineageId, contributorRoot, "candidate view context is missing or ambiguous for FINALIZE");
1455
+ const token = this.lineages.get(key);
1254
1456
  const record = token === undefined ? undefined : this.records.get(token);
1255
1457
  if (!record || record.lineageId !== lineageId) throw new CandidateViewError("candidate view context is missing or ambiguous for FINALIZE");
1256
1458
  assertRecordSafe(record);
@@ -1264,40 +1466,47 @@ export class CandidateViewRegistry {
1264
1466
  this.forget(record);
1265
1467
  }
1266
1468
 
1267
- cleanupTerminal(lineageId: string, state: string): void {
1469
+ cleanupTerminal(lineageId: string, state: string, contributorRoot?: string): void {
1268
1470
  if (state !== "approved" && state !== "escalated") return;
1269
- const token = this.lineages.get(lineageId); if (token) this.cleanup(token);
1270
- if (state === "escalated") this.projections.delete(lineageId);
1471
+ const key = this.uniqueKey(this.lineages, lineageId, contributorRoot);
1472
+ const token = key === undefined ? undefined : this.lineages.get(key);
1473
+ if (token) this.cleanup(token);
1474
+ if (state === "escalated" && key !== undefined) this.projections.delete(key);
1271
1475
  }
1272
1476
 
1273
1477
  private remove(record: CandidateViewRecord): void {
1274
1478
  if (!isWithin(record.parent, record.root)) throw new CandidateViewError("candidate view cleanup escaped its owned parent");
1275
1479
  try { makeWritableForCleanup(record.root); } catch {}
1276
- try { git(record.contributorRoot, ["worktree", "remove", "--force", record.root], process.env, record.gitExecutor); } catch {
1277
- try { makeWritableForCleanup(record.root); } catch {}
1278
- rmSync(record.root, { recursive: true, force: true });
1279
- }
1480
+ try { git(record.contributorRoot, ["worktree", "remove", "--force", record.root], process.env, record.gitExecutor); } catch {}
1481
+ // Git removes worktree metadata; physical removal remains this owner's duty.
1482
+ rmSync(record.root, { recursive: true, force: true });
1280
1483
  }
1281
1484
 
1282
1485
  private forget(record: CandidateViewRecord): void {
1283
1486
  this.records.delete(record.token);
1284
- if (record.lineageId && this.lineages.get(record.lineageId) === record.token) this.lineages.delete(record.lineageId);
1285
- if (this.current?.token === record.token) this.current = undefined;
1286
- for (const [key, pendingToken] of this.replays) if (pendingToken === record.token) this.replays.delete(key);
1487
+ if (record.lineageId) {
1488
+ const key = this.lineageKey(record.contributorRoot, record.lineageId);
1489
+ if (this.lineages.get(key) === record.token) this.lineages.delete(key);
1490
+ }
1491
+ if (this.current.get(record.contributorRoot)?.token === record.token) this.current.delete(record.contributorRoot);
1492
+ for (const [replayKey, pendingToken] of this.replays) if (pendingToken === record.token) this.replays.delete(replayKey);
1287
1493
  }
1288
1494
 
1289
- consumeProjection(lineageId: string): void {
1290
- this.projections.delete(lineageId);
1495
+ consumeProjection(lineageId: string, contributorRoot?: string): void {
1496
+ const key = this.uniqueKey(this.projections, lineageId, contributorRoot);
1497
+ if (key !== undefined) this.projections.delete(key);
1291
1498
  }
1292
1499
 
1293
1500
  private expose(record: CandidateViewRecord): CandidateView {
1294
1501
  return {
1295
1502
  token: record.token,
1296
1503
  root: record.root,
1504
+ contributorRoot: record.contributorRoot,
1297
1505
  baseCommit: record.baseCommit,
1298
1506
  baseTree: record.baseTree,
1299
1507
  candidateTree: record.candidateTree,
1300
1508
  committedOnly: record.committedOnly,
1509
+ intendedUntracked: record.intendedUntracked,
1301
1510
  paths: record.scope.paths,
1302
1511
  modes: record.scope.modes,
1303
1512
  gitlinks: record.scope.gitlinks,