gentle-pi 2.4.0 → 2.6.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 (227) hide show
  1. package/README.md +292 -25
  2. package/assets/agents/gentle-ai-worker.md +13 -0
  3. package/assets/agents/jd-fix-agent.md +18 -0
  4. package/assets/agents/jd-judge-a.md +1 -1
  5. package/assets/agents/jd-judge-b.md +1 -1
  6. package/assets/agents/sdd-apply.md +7 -5
  7. package/assets/agents/sdd-archive.md +5 -3
  8. package/assets/agents/sdd-design.md +4 -0
  9. package/assets/agents/sdd-explore.md +4 -0
  10. package/assets/agents/sdd-init.md +4 -0
  11. package/assets/agents/sdd-onboard.md +4 -0
  12. package/assets/agents/sdd-proposal.md +4 -0
  13. package/assets/agents/sdd-remediate.md +37 -0
  14. package/assets/agents/sdd-research.md +26 -3
  15. package/assets/agents/sdd-spec.md +4 -0
  16. package/assets/agents/sdd-status.md +9 -75
  17. package/assets/agents/sdd-sync.md +4 -0
  18. package/assets/agents/sdd-tasks.md +4 -0
  19. package/assets/agents/sdd-verify.md +5 -3
  20. package/assets/chains/sdd-full.chain.md +4 -0
  21. package/assets/chains/sdd-plan.chain.md +4 -0
  22. package/assets/chains/sdd-verify.chain.md +4 -0
  23. package/assets/migrations/managed-assets-v2.5.0.json +7 -0
  24. package/assets/orchestrator-delegation.md +39 -11
  25. package/assets/orchestrator.md +5 -5
  26. package/assets/sdd-orchestrator-workflow.md +54 -21
  27. package/assets/support/sdd-status-contract.md +34 -90
  28. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  29. package/docs/delegated-verification.md +25 -0
  30. package/docs/telemetry.md +94 -0
  31. package/docs/windows-startup-console-visibility.md +18 -0
  32. package/extensions/ask-user-choice.ts +159 -25
  33. package/extensions/codegraph-tools.ts +95 -5
  34. package/extensions/gentle-agents.ts +1337 -0
  35. package/extensions/gentle-ai.ts +2916 -386
  36. package/extensions/gentle-shell.ts +650 -0
  37. package/extensions/gentle-todo.ts +234 -0
  38. package/extensions/quiet-tools.ts +2 -1
  39. package/extensions/runtime-metrics.ts +130 -0
  40. package/extensions/sdd-init.ts +2 -2
  41. package/extensions/startup-banner.ts +52 -75
  42. package/lib/agent-profiles.ts +550 -0
  43. package/lib/agents-completion-delivery.ts +72 -0
  44. package/lib/agents-config.ts +315 -0
  45. package/lib/agents-history.ts +88 -0
  46. package/lib/agents-messaging.ts +187 -0
  47. package/lib/agents-protocol.ts +501 -0
  48. package/lib/agents-runner.ts +1012 -0
  49. package/lib/agents-thread-view.ts +57 -0
  50. package/lib/agents-transcript.ts +87 -0
  51. package/lib/agents-view-layout.ts +40 -0
  52. package/lib/agents-view.ts +914 -0
  53. package/lib/agents-widget.ts +241 -0
  54. package/lib/gentle-ai-binary.ts +3 -1
  55. package/lib/gentle-ai-renderer.ts +143 -25
  56. package/lib/native-choice-list.ts +194 -0
  57. package/lib/native-fullscreen-interaction.ts +47 -0
  58. package/lib/native-pointer-region.ts +164 -0
  59. package/lib/native-review-cli.ts +371 -13
  60. package/lib/orchestrator-presence.ts +337 -0
  61. package/lib/profiles-orchestrator.ts +203 -0
  62. package/lib/review-candidate-view-owner.ts +427 -0
  63. package/lib/review-candidate-view.ts +150 -48
  64. package/lib/review-consent-component.ts +247 -0
  65. package/lib/review-consent-ui.ts +110 -0
  66. package/lib/review-host-relay.ts +28 -0
  67. package/lib/review-integration-v2.ts +243 -11
  68. package/lib/review-last-event-controller.ts +8 -4
  69. package/lib/review-relay-contract.ts +11 -0
  70. package/lib/review-reminder-receipt.ts +74 -0
  71. package/lib/review-repository.ts +2 -2
  72. package/lib/review-risk-assessment.ts +339 -0
  73. package/lib/review-session-standing-permission-ipc.ts +309 -0
  74. package/lib/review-session-standing-permission.ts +240 -0
  75. package/lib/runtime-metrics-children.ts +199 -0
  76. package/lib/runtime-metrics-delivery.ts +68 -0
  77. package/lib/runtime-metrics-native.ts +166 -0
  78. package/lib/runtime-metrics-pi-identity.ts +113 -0
  79. package/lib/runtime-metrics-policy.ts +51 -0
  80. package/lib/runtime-metrics.ts +255 -0
  81. package/lib/sdd-preflight.ts +362 -81
  82. package/lib/sdd-research-capabilities.ts +228 -0
  83. package/lib/sdd-status.ts +29 -7
  84. package/lib/session-worktree-registry.ts +118 -0
  85. package/lib/shell-bar.ts +184 -0
  86. package/lib/shell-card.ts +133 -0
  87. package/lib/shell-changes-view.ts +530 -0
  88. package/lib/shell-changes.ts +290 -0
  89. package/lib/shell-gauge.ts +40 -0
  90. package/lib/shell-prompt.ts +115 -0
  91. package/lib/shell-sidebar-banner.ts +11 -0
  92. package/lib/shell-sidebar-layout.ts +213 -0
  93. package/lib/shell-sidebar.ts +41 -0
  94. package/lib/shell-todo.ts +297 -0
  95. package/lib/shell-usage-view.ts +76 -0
  96. package/lib/shell-usage.ts +246 -0
  97. package/lib/telemetry-trigger.ts +153 -0
  98. package/package.json +8 -5
  99. package/runtime/gentle-ai-binary.mjs +3 -1
  100. package/runtime/native-review-cli.mjs +370 -12
  101. package/runtime/review-integration-v2.mjs +243 -11
  102. package/runtime/review-relay-contract.mjs +11 -0
  103. package/runtime/review-risk-assessment.mjs +340 -0
  104. package/runtime/telemetry-trigger.mjs +154 -0
  105. package/scripts/build-runtime-modules.mjs +11 -1
  106. package/scripts/check-types.mjs +125 -0
  107. package/scripts/gentle-ai-installer.mjs +10 -10
  108. package/scripts/install-gentle-ai.mjs +12 -0
  109. package/scripts/install-tui-mode-setting.mjs +114 -0
  110. package/scripts/test-packed-runner.mjs +38 -2
  111. package/scripts/types-baseline.json +99 -0
  112. package/scripts/verify-package-files.mjs +8 -2
  113. package/skills/_shared/review-ledger-contract.md +20 -2
  114. package/skills/issue-creation/SKILL.md +3 -3
  115. package/skills/judgment-day/SKILL.md +17 -3
  116. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  117. package/tests/agent-profiles.test.ts +722 -0
  118. package/tests/agents-completion-delivery.test.ts +94 -0
  119. package/tests/agents-config.test.ts +205 -0
  120. package/tests/agents-fake-child.ts +66 -0
  121. package/tests/agents-grouping.test.ts +179 -0
  122. package/tests/agents-history.test.ts +54 -0
  123. package/tests/agents-integration.test.ts +100 -0
  124. package/tests/agents-messaging.test.ts +94 -0
  125. package/tests/agents-protocol.test.ts +198 -0
  126. package/tests/agents-queries.test.ts +190 -0
  127. package/tests/agents-responsive.test.ts +43 -0
  128. package/tests/agents-runner-process.test.ts +111 -0
  129. package/tests/agents-runner.test.ts +959 -0
  130. package/tests/agents-thread-view.test.ts +45 -0
  131. package/tests/agents-transcript.test.ts +30 -0
  132. package/tests/agents-view.test.ts +685 -0
  133. package/tests/agents-widget.test.ts +141 -0
  134. package/tests/artifact-language.test.ts +25 -2
  135. package/tests/ask-user-choice.test.ts +325 -5
  136. package/tests/asset-installation-runtime.test.ts +108 -0
  137. package/tests/autonomous-guard.test.ts +116 -1
  138. package/tests/codegraph-tools.test.ts +112 -2
  139. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  140. package/tests/devbinary/native-review-parity.devtest.ts +110 -0
  141. package/tests/feature-request-form.test.ts +67 -0
  142. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  143. package/tests/fixtures/agents-process-child.mjs +23 -0
  144. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  145. package/tests/gentle-agents.test.ts +2168 -0
  146. package/tests/gentle-ai-binary.test.ts +7 -2
  147. package/tests/gentle-ai-installer.test.ts +47 -47
  148. package/tests/gentle-ai-renderer.test.ts +103 -0
  149. package/tests/gentle-ai.test.ts +971 -15
  150. package/tests/gentle-card-text.ts +35 -0
  151. package/tests/gentle-shell.test.ts +818 -0
  152. package/tests/gentle-todo.test.ts +226 -0
  153. package/tests/install-tui-mode-setting.test.ts +324 -0
  154. package/tests/issue-creation-skill.test.ts +22 -0
  155. package/tests/model-routing-authority.test.ts +12 -0
  156. package/tests/native-choice-list.test.ts +202 -0
  157. package/tests/native-fullscreen-interaction.test.ts +125 -0
  158. package/tests/native-pointer-region.test.ts +245 -0
  159. package/tests/native-review-capability-contract.test.ts +27 -1
  160. package/tests/native-review-cli.test.ts +317 -3
  161. package/tests/native-review-consent.test.ts +91 -0
  162. package/tests/native-review-parity-runtime.test.ts +8 -2
  163. package/tests/native-review-parity.test.ts +43 -29
  164. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  165. package/tests/orchestrator-budget.test.ts +69 -0
  166. package/tests/orchestrator-presence.test.ts +389 -0
  167. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  168. package/tests/package-manifest.test.ts +243 -7
  169. package/tests/profiles-orchestrator.test.ts +208 -0
  170. package/tests/quiet-tool-rendering.test.ts +97 -37
  171. package/tests/rdd-aware-verification-contract.test.ts +226 -0
  172. package/tests/rdd-status-line.test.ts +286 -0
  173. package/tests/review-agent-end-preflight.test.ts +332 -24
  174. package/tests/review-candidate-view.test.ts +751 -7
  175. package/tests/review-consent-ui.test.ts +352 -0
  176. package/tests/review-contract-prompt.test.ts +17 -0
  177. package/tests/review-controller-native-recovery.test.ts +29 -4
  178. package/tests/review-controller-native-routing.test.ts +884 -7
  179. package/tests/review-controller-workspace-root.test.ts +45 -2
  180. package/tests/review-controller.test.ts +26 -1
  181. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  182. package/tests/review-host-relay-routing.test.ts +384 -8
  183. package/tests/review-host-relay.test.ts +29 -0
  184. package/tests/review-integration-v2-forward.test.ts +44 -0
  185. package/tests/review-integration-v2.test.ts +276 -0
  186. package/tests/review-last-event-closure.test.ts +112 -3
  187. package/tests/review-ledger-contract.test.ts +61 -6
  188. package/tests/review-relay-contract.test.ts +26 -0
  189. package/tests/review-reminder-receipt.test.ts +62 -0
  190. package/tests/review-repository.test.ts +28 -1
  191. package/tests/review-risk-assessment.test.ts +626 -0
  192. package/tests/review-session-standing-permission-controller.test.ts +656 -0
  193. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  194. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  195. package/tests/review-session-standing-permission.test.ts +156 -0
  196. package/tests/runtime-harness.mjs +447 -39
  197. package/tests/runtime-metrics-children.test.ts +206 -0
  198. package/tests/runtime-metrics-delivery.test.ts +85 -0
  199. package/tests/runtime-metrics-extension.test.ts +187 -0
  200. package/tests/runtime-metrics-native.test.ts +209 -0
  201. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  202. package/tests/runtime-metrics-policy.test.ts +62 -0
  203. package/tests/runtime-metrics.test.ts +184 -0
  204. package/tests/sdd-agent-tools.test.ts +10 -1
  205. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  206. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  207. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  208. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  209. package/tests/sdd-preflight.test.ts +252 -8
  210. package/tests/sdd-research-capabilities.test.ts +256 -0
  211. package/tests/sdd-research-live.test.ts +241 -0
  212. package/tests/sdd-selection-transport.test.ts +504 -0
  213. package/tests/sdd-status.test.ts +51 -0
  214. package/tests/session-worktree-registry.test.ts +135 -0
  215. package/tests/shell-bar.test.ts +176 -0
  216. package/tests/shell-card.test.ts +139 -0
  217. package/tests/shell-changes-view.test.ts +609 -0
  218. package/tests/shell-changes.test.ts +350 -0
  219. package/tests/shell-prompt.test.ts +140 -0
  220. package/tests/shell-sidebar-banner.test.ts +23 -0
  221. package/tests/shell-sidebar-layout.test.ts +387 -0
  222. package/tests/shell-sidebar.test.ts +50 -0
  223. package/tests/shell-todo.test.ts +259 -0
  224. package/tests/shell-usage-view.test.ts +62 -0
  225. package/tests/shell-usage.test.ts +197 -0
  226. package/tests/startup-banner.test.ts +126 -0
  227. package/tests/telemetry-trigger.test.ts +351 -0
@@ -1,10 +1,11 @@
1
1
  import { execFileSync, type ExecFileSyncOptions } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
- import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync } from "node:fs";
3
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, utimesSync } 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";
7
7
  import { gunzipSync, gzipSync } from "node:zlib";
8
+ import { assertCandidateOwnerParent, createCandidateOwner, prepareCandidateOwnerParent, removeCandidateOwner, samePath, sweepCandidateOwners, type CandidateViewOwner } from "./review-candidate-view-owner.ts";
8
9
 
9
10
  const REVIEW_LENS = ["review-risk", "review-resilience", "review-readability", "review-reliability"] as const;
10
11
  export type ReviewLens = (typeof REVIEW_LENS)[number];
@@ -12,6 +13,11 @@ const CANDIDATE_GIT_TIMEOUT_MS = 10_000;
12
13
  const CANDIDATE_GIT_TIMEOUT_MAX_MS = 120_000;
13
14
  const CANDIDATE_GIT_TIMEOUT_ENV = "GENTLE_PI_CANDIDATE_GIT_TIMEOUT_MS";
14
15
  const CANDIDATE_GIT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
16
+ // Keep the copied index at least one timestamp tick behind its source. The
17
+ // two-second target also clears filesystems whose mtime granularity is a second
18
+ // or coarser without needlessly assigning an arbitrary historical timestamp.
19
+ const PRIVATE_INDEX_RACY_SAFETY_NS = 1_000_000_000n;
20
+ const PRIVATE_INDEX_RACY_BACKDATE_NS = 2_000_000_000n;
15
21
 
16
22
  // Candidate views may materialize full repository trees. Large repositories can
17
23
  // raise this bounded deadline without creating an unbounded child process.
@@ -94,6 +100,7 @@ interface CandidateViewScope {
94
100
  }
95
101
 
96
102
  interface CandidateViewRecord {
103
+ owner: CandidateViewOwner;
97
104
  token: string;
98
105
  root: string;
99
106
  parent: string;
@@ -231,8 +238,8 @@ export interface CandidateViewDiagnostic {
231
238
  export class CandidateViewError extends Error {
232
239
  readonly reason: string;
233
240
  readonly diagnostics?: CandidateViewDiagnostic;
234
- constructor(message: string, reason = "candidate-view-invalid", diagnostics?: CandidateViewDiagnostic) {
235
- super(message);
241
+ constructor(message: string, reason = "candidate-view-invalid", diagnostics?: CandidateViewDiagnostic, options?: ErrorOptions) {
242
+ super(message, options);
236
243
  this.name = "CandidateViewError";
237
244
  this.reason = reason;
238
245
  this.diagnostics = diagnostics === undefined ? undefined : sanitizeCandidateViewDiagnostic(diagnostics);
@@ -333,12 +340,37 @@ function isMaterializedCandidateMode(mode: string): boolean {
333
340
  return mode === "100644" || mode === "100755" || mode === "120000";
334
341
  }
335
342
 
343
+ export function hasExpectedExecutableBits(filesystemMode: number, gitMode: string, platform: NodeJS.Platform = process.platform): boolean {
344
+ return gitMode === "100755" && platform === "win32"
345
+ || (filesystemMode & 0o111) === (gitMode === "100755" ? 0o111 : 0);
346
+ }
347
+
336
348
  function isCanonicalObjectId(objectId: string): boolean { return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(objectId); }
337
349
  function gitlinkMapsEqual(left: Readonly<Record<string, string>>, right: Readonly<Record<string, string>>): boolean {
338
350
  const entries = Object.entries(left);
339
351
  return entries.length === Object.keys(right).length && entries.every(([path, objectId]) => right[path] === objectId);
340
352
  }
341
353
 
354
+ // Two materialized candidate views describe the exact same reviewable content
355
+ // when their base, candidate tree, commit-state, and changed scope all match.
356
+ // This is what makes a retried native START's freshly-materialized duplicate
357
+ // view safely discardable in favor of an already-bound one (gentle-pi
358
+ // candidate-view rebind defect, ga#4085 / ga#4050): the comparison never
359
+ // trusts a caller-supplied claim, only Git-derived identity already computed
360
+ // by materializeCandidateView for both records.
361
+ function candidateRecordsShareIdentity(left: CandidateViewRecord, right: CandidateViewRecord): boolean {
362
+ return left.contributorRoot === right.contributorRoot &&
363
+ left.baseCommit === right.baseCommit &&
364
+ left.baseTree === right.baseTree &&
365
+ left.candidateTree === right.candidateTree &&
366
+ left.committedOnly === right.committedOnly &&
367
+ JSON.stringify(left.intendedUntracked ?? null) === JSON.stringify(right.intendedUntracked ?? null) &&
368
+ JSON.stringify(left.scope.paths) === JSON.stringify(right.scope.paths) &&
369
+ JSON.stringify(left.scope.modes) === JSON.stringify(right.scope.modes) &&
370
+ gitlinkMapsEqual(left.scope.gitlinks, right.scope.gitlinks) &&
371
+ JSON.stringify(left.scope.deletedPaths) === JSON.stringify(right.scope.deletedPaths);
372
+ }
373
+
342
374
  function decodeCanonicalPath(value: Buffer): string {
343
375
  const path = value.toString("utf8");
344
376
  if (!Buffer.from(path, "utf8").equals(value) || !isSafeCandidatePath(path)) {
@@ -602,12 +634,20 @@ function makeWritableForCleanup(path: string): void {
602
634
  chmodSync(path, 0o644);
603
635
  }
604
636
 
605
- function candidateViewParent(commonDir: string): string {
606
- const parent = join(commonDir, "gentle-ai", "candidate-views");
637
+ function candidateViewParent(commonDir: string, platform: NodeJS.Platform): string {
638
+ const control = join(commonDir, "gentle-ai");
639
+ mkdirSync(control, { recursive: true, mode: 0o700 });
640
+ const controlStat = lstatSync(control);
641
+ if (!controlStat.isDirectory() || controlStat.isSymbolicLink() || realpathSync(control) !== control) throw new CandidateViewError("candidate view ancestor is unsafe");
642
+ const parent = join(control, "candidate-views");
607
643
  mkdirSync(parent, { recursive: true, mode: 0o700 });
608
644
  const stat = lstatSync(parent);
609
645
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new CandidateViewError("candidate view parent is unsafe");
610
- return realpathSync(parent);
646
+ try {
647
+ return prepareCandidateOwnerParent(commonDir, platform);
648
+ } catch (error) {
649
+ throw new CandidateViewError("candidate view owner preparation failed", "candidate-owner-preparation-failed", undefined, { cause: error });
650
+ }
611
651
  }
612
652
 
613
653
  export interface ResolvedCandidateBase {
@@ -780,11 +820,29 @@ function isErrnoCode(error: unknown, code: string): boolean {
780
820
  return typeof error === "object" && error !== null && "code" in error && error.code === code;
781
821
  }
782
822
 
823
+ function timestampSeconds(timestampNs: bigint): number {
824
+ // Node's utimes API takes Unix seconds as a number. Splitting the value keeps
825
+ // the seconds conversion exact; the explicit racy-clean backdate below is far
826
+ // larger than the fractional precision a Number can represent at this epoch.
827
+ return Number(timestampNs / 1_000_000_000n) + Number(timestampNs % 1_000_000_000n) / 1_000_000_000;
828
+ }
829
+
783
830
  function seedPrivateIndexFromLiveIndex(cwd: string, indexPath: string, executor: CandidateGitExecutor): boolean {
784
831
  const liveIndex = resolve(cwd, git(cwd, ["rev-parse", "--path-format=absolute", "--git-path", "index"], process.env, executor));
785
- const entry = lstatSync(liveIndex, { throwIfNoEntry: false });
832
+ const entry = lstatSync(liveIndex, { bigint: true, throwIfNoEntry: false });
786
833
  if (entry === undefined) return false; if (!entry.isFile()) throw new CandidateViewError("candidate live Git index is not a regular file");
834
+ if (entry.mtimeNs < PRIVATE_INDEX_RACY_BACKDATE_NS) throw new CandidateViewError("candidate live Git index timestamp is too early for racy-clean protection");
787
835
  copyFileSync(liveIndex, indexPath);
836
+ // Git's racy-clean check compares index and tracked-file mtimes. copyFileSync
837
+ // gives the private index a new timestamp; restoring a Date can also lose
838
+ // nanoseconds. Backdate the bigint live timestamp instead, then verify the
839
+ // filesystem applied enough of that bounded interval for Git to refresh
840
+ // content rather than trusting a racy-clean stat match.
841
+ utimesSync(indexPath, timestampSeconds(entry.atimeNs), timestampSeconds(entry.mtimeNs - PRIVATE_INDEX_RACY_BACKDATE_NS));
842
+ const privateMtimeNs = lstatSync(indexPath, { bigint: true }).mtimeNs;
843
+ if (privateMtimeNs >= entry.mtimeNs || entry.mtimeNs - privateMtimeNs < PRIVATE_INDEX_RACY_SAFETY_NS) {
844
+ throw new CandidateViewError("candidate private Git index timestamp cannot preserve racy-clean protection");
845
+ }
788
846
  for (const name of readdirSync(dirname(liveIndex))) if (/^sharedindex\.[0-9a-f]+$/.test(name)) {
789
847
  try {
790
848
  const sharedIndex = join(dirname(liveIndex), name);
@@ -796,7 +854,7 @@ function seedPrivateIndexFromLiveIndex(cwd: string, indexPath: string, executor:
796
854
  return true;
797
855
  }
798
856
 
799
- function materializeCandidateView(request: CreateCandidateViewRequest, executor: CandidateGitExecutor): CandidateViewRecord {
857
+ function materializeCandidateView(request: CreateCandidateViewRequest, executor: CandidateGitExecutor, platform: NodeJS.Platform = process.platform): CandidateViewRecord {
800
858
  const contributorRoot = realpathSync(request.contributorRoot);
801
859
  if (!lstatSync(contributorRoot).isDirectory()) throw new CandidateViewError("contributor root is not a directory");
802
860
  if (request.committedOnly === true && request.baseRef === undefined) throw new CandidateViewError("committed-only candidate views require an explicit base reference", "committed-only-base-required");
@@ -808,7 +866,8 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
808
866
  const candidateCommit = committedOnly
809
867
  ? resolveCandidateBase(contributorRoot, "HEAD", process.env, executor)
810
868
  : base;
811
- const parent = candidateViewParent(canonicalCommonDir);
869
+ const parent = candidateViewParent(canonicalCommonDir, platform);
870
+ sweepCandidateOwners(canonicalCommonDir, (args) => git(canonicalCommonDir, args, process.env, executor), makeWritableForCleanup, platform);
812
871
  const index = mkdtempSync(join(tmpdir(), "gentle-ai-candidate-index-"));
813
872
  const indexPath = join(index, "index");
814
873
  const environment = { ...process.env, GIT_INDEX_FILE: indexPath };
@@ -832,6 +891,12 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
832
891
  }
833
892
  const candidateTree = git(contributorRoot, ["write-tree"], environment, executor);
834
893
  const root = join(parent, randomUUID());
894
+ let owner: CandidateViewOwner;
895
+ try {
896
+ owner = createCandidateOwner(canonicalCommonDir, root, platform);
897
+ } catch (error) {
898
+ throw new CandidateViewError("candidate view owner preparation failed", "candidate-owner-preparation-failed", undefined, { cause: error });
899
+ }
835
900
  // The worktree is created under the same try/catch cleanup boundary as
836
901
  // the read-tree materialization that follows. addUnbornWorktree's
837
902
  // fallback path can register a worktree with `worktree add` and then
@@ -852,9 +917,9 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
852
917
  const scope = deriveChangedScope(contributorRoot, base.tree, candidateTree, [...tree.entries, ...tree.gitlinks], executor);
853
918
  for (const gitlink of tree.gitlinks) if (lstatSync(join(root, gitlink.path), { throwIfNoEntry: false })) throw new CandidateViewError("candidate view materialized a metadata-only gitlink");
854
919
  makeReadonly(root, entries);
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 };
920
+ return { owner, token: basename(root), root: realpathSync(root), parent, contributorRoot, commonDir: canonicalCommonDir, baseCommit, baseTree: base.tree, candidateTree, committedOnly, intendedUntracked, entries, gitlinks: tree.gitlinks, scope, gitExecutor: executor };
856
921
  } catch (error) {
857
- try { git(contributorRoot, ["worktree", "remove", "--force", root], process.env, executor); } catch { rmSync(root, { recursive: true, force: true }); }
922
+ try { removeCandidateOwner(owner, (args) => git(canonicalCommonDir, args, process.env, executor), makeWritableForCleanup, false, platform); } catch { /* Preserve the marker and any partial worktree for conservative recovery. */ }
858
923
  throw error;
859
924
  }
860
925
  } finally {
@@ -862,7 +927,7 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
862
927
  }
863
928
  }
864
929
 
865
- function assertRecordSafe(record: CandidateViewRecord): void {
930
+ function assertRecordSafe(record: CandidateViewRecord, platform: NodeJS.Platform = process.platform): void {
866
931
  try {
867
932
  const contributor = lstatSync(record.contributorRoot);
868
933
  if (!contributor.isDirectory() || contributor.isSymbolicLink() || realpathSync(record.contributorRoot) !== record.contributorRoot) {
@@ -870,7 +935,7 @@ function assertRecordSafe(record: CandidateViewRecord): void {
870
935
  }
871
936
  const toplevel = realpathSync(git(record.contributorRoot, ["rev-parse", "--show-toplevel"], process.env, record.gitExecutor));
872
937
  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) {
938
+ if (!samePath(toplevel, record.contributorRoot, platform) || !samePath(commonDir, record.commonDir, platform)) {
874
939
  throw new CandidateViewError("candidate contributor root Git identity changed", "contributor-root-drift");
875
940
  }
876
941
  } catch (error) {
@@ -878,6 +943,7 @@ function assertRecordSafe(record: CandidateViewRecord): void {
878
943
  throw new CandidateViewError("candidate contributor root identity cannot be verified", "contributor-root-drift");
879
944
  }
880
945
  const root = record.root;
946
+ assertCandidateOwnerParent(record.commonDir, platform);
881
947
  if (!isWithin(record.parent, root) || !existsSync(root)) throw new CandidateViewError("candidate view is missing or moved");
882
948
  const rootStat = lstatSync(root);
883
949
  if (!rootStat.isDirectory() || rootStat.isSymbolicLink() || realpathSync(root) !== root) throw new CandidateViewError("candidate view root is unsafe");
@@ -902,7 +968,7 @@ function assertRecordSafe(record: CandidateViewRecord): void {
902
968
  if (!item) throw new CandidateViewError("candidate view entry is missing or moved");
903
969
  if (entry.mode === "120000") {
904
970
  if (!item.isSymbolicLink()) throw new CandidateViewError("candidate view symlink is unsafe or changed");
905
- } else if (!item.isFile() || item.isSymbolicLink() || (item.mode & 0o222) !== 0 || ((item.mode & 0o111) !== (entry.mode === "100755" ? 0o111 : 0))) {
971
+ } else if (!item.isFile() || item.isSymbolicLink() || (item.mode & 0o222) !== 0 || !hasExpectedExecutableBits(item.mode, entry.mode, platform)) {
906
972
  throw new CandidateViewError("candidate view entry is unsafe, writable, or has a changed mode");
907
973
  }
908
974
  const actualHash = entryContentHash(root, entry);
@@ -913,8 +979,10 @@ function assertRecordSafe(record: CandidateViewRecord): void {
913
979
  export class CandidateViewRegistry {
914
980
  private readonly records = new Map<string, CandidateViewRecord>();
915
981
  private readonly gitExecutor: CandidateGitExecutor;
916
- constructor(gitExecutor: CandidateGitExecutor = defaultCandidateGitExecutor) {
982
+ private readonly platform: NodeJS.Platform;
983
+ constructor(gitExecutor: CandidateGitExecutor = defaultCandidateGitExecutor, platform: NodeJS.Platform = process.platform) {
917
984
  this.gitExecutor = gitExecutor;
985
+ this.platform = platform;
918
986
  }
919
987
  // Lifecycle state is scoped to the canonical target worktree as well as the
920
988
  // provider lineage. Lineage text is repository-local and may legitimately be
@@ -1000,15 +1068,25 @@ export class CandidateViewRegistry {
1000
1068
  const key = this.lineageKey(contributorRoot, lineageId);
1001
1069
  const token = this.lineages.get(key);
1002
1070
  const record = token === undefined ? undefined : this.records.get(token);
1003
- if (record !== undefined) assertRecordSafe(record);
1071
+ if (record !== undefined) assertRecordSafe(record, this.platform);
1004
1072
  }
1005
1073
 
1006
1074
  create(request: CreateCandidateViewRequest): CandidateView {
1007
1075
  return this.createOrReuse(request);
1008
1076
  }
1009
1077
 
1078
+ sweepOrphans(contributorRoot: string): void {
1079
+ try {
1080
+ const cwd = realpathSync(contributorRoot);
1081
+ const common = realpathSync(resolve(cwd, git(cwd, ["rev-parse", "--git-common-dir"], process.env, this.gitExecutor)));
1082
+ sweepCandidateOwners(common, (args) => git(common, args, process.env, this.gitExecutor), makeWritableForCleanup, this.platform);
1083
+ } catch { /* Non-repositories and unavailable ownership are preserved. */ }
1084
+ }
1085
+
1010
1086
  cleanupAll(): void {
1011
- for (const token of [...this.records.keys()]) this.cleanup(token);
1087
+ for (const token of [...this.records.keys()]) {
1088
+ try { this.cleanup(token); } catch { /* Continue other owned views; failed records remain retryable. */ }
1089
+ }
1012
1090
  }
1013
1091
 
1014
1092
  createOrReuse(request: CreateCandidateViewRequest): CandidateView {
@@ -1017,8 +1095,8 @@ export class CandidateViewRegistry {
1017
1095
  const scopedReplayKey = normalizedRequest.replayKey === undefined ? undefined : this.replayKey(contributorRoot, normalizedRequest.replayKey);
1018
1096
  const token = scopedReplayKey === undefined ? undefined : this.replays.get(scopedReplayKey);
1019
1097
  const existing = token === undefined ? undefined : this.records.get(token);
1020
- if (existing) { assertRecordSafe(existing); return this.expose(existing); }
1021
- const record = materializeCandidateView(normalizedRequest, this.gitExecutor);
1098
+ if (existing) { assertRecordSafe(existing, this.platform); return this.expose(existing); }
1099
+ const record = materializeCandidateView(normalizedRequest, this.gitExecutor, this.platform);
1022
1100
  this.records.set(record.token, record);
1023
1101
  if (scopedReplayKey !== undefined) this.replays.set(scopedReplayKey, record.token);
1024
1102
  return this.expose(record);
@@ -1030,6 +1108,32 @@ export class CandidateViewRegistry {
1030
1108
 
1031
1109
  bindCurrent(request: BindCandidateViewRequest): void {
1032
1110
  const selectedLenses = this.validateSelectedLenses(request.selectedLenses);
1111
+ const candidate = this.records.get(request.token);
1112
+ const key = candidate === undefined ? undefined : this.lineageKey(candidate.contributorRoot, request.lineageId);
1113
+ const existingToken = key === undefined ? undefined : this.lineages.get(key);
1114
+ // A retried native START for a lineage this controller already bound
1115
+ // (native reports it "resumed") re-materializes a fresh, content-
1116
+ // identical candidate view under a new token before it reaches here.
1117
+ // Rebinding that duplicate to the same lineage key used to fail closed
1118
+ // with "candidate view lineage binding is missing or ambiguous" even
1119
+ // though nothing is actually ambiguous: it is the exact same reviewable
1120
+ // content, re-verified from Git. Discard the redundant duplicate and
1121
+ // keep the already-bound view current instead of failing the retry
1122
+ // (gentle-pi candidate-view rebind defect, ga#4085 / ga#4050).
1123
+ if (candidate !== undefined && existingToken !== undefined && existingToken !== request.token) {
1124
+ const existing = this.records.get(existingToken);
1125
+ let existingSafe = false;
1126
+ if (existing !== undefined && existing.lineageId === request.lineageId) {
1127
+ try { assertRecordSafe(existing, this.platform); existingSafe = true; } catch { existingSafe = false; }
1128
+ }
1129
+ if (existing !== undefined && existingSafe && candidateRecordsShareIdentity(existing, candidate)) {
1130
+ existing.selectedLenses = selectedLenses;
1131
+ this.remove(candidate);
1132
+ this.forget(candidate);
1133
+ this.current.set(existing.contributorRoot, { lineageId: request.lineageId, token: existingToken });
1134
+ return;
1135
+ }
1136
+ }
1033
1137
  const record = this.bindRecord(request.token, request.lineageId, selectedLenses);
1034
1138
  this.current.set(record.contributorRoot, { lineageId: request.lineageId, token: request.token });
1035
1139
  }
@@ -1043,7 +1147,7 @@ export class CandidateViewRegistry {
1043
1147
  const record = this.records.get(request.token);
1044
1148
  if (!record || record.lineageId !== undefined) throw new CandidateViewError("native reviewing candidate view is missing or already bound", "authoritative-current-match-missing");
1045
1149
  if (this.current.has(record.contributorRoot)) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established");
1046
- assertRecordSafe(record);
1150
+ assertRecordSafe(record, this.platform);
1047
1151
  this.assertCurrentBindingMatchesLiveCandidate(record);
1048
1152
  this.bindCurrent(request);
1049
1153
  }
@@ -1060,7 +1164,7 @@ export class CandidateViewRegistry {
1060
1164
  if (this.current.has(root)) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established");
1061
1165
  if (states.length === 0) throw new CandidateViewError("no authoritative reviewing lineage exactly matches the live candidate", "authoritative-current-match-missing");
1062
1166
  if (states.length !== 1) throw new CandidateViewError("multiple authoritative reviewing lineages exactly match the live candidate", "authoritative-current-match-ambiguous");
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);
1167
+ const live = materializeCandidateView({ contributorRoot: root, baseRef: states[0]!.baseCommit, committedOnly: states[0]!.committedOnly === true, ...(states[0]!.intendedUntracked === undefined ? {} : { intendedUntracked: states[0]!.intendedUntracked }) }, this.gitExecutor, this.platform);
1064
1168
  try {
1065
1169
  const matches = states.filter((state) => this.matchesAuthoritativeState(live, state));
1066
1170
  if (matches.length === 0) throw new CandidateViewError("no authoritative reviewing lineage exactly matches the live candidate", "authoritative-current-match-missing");
@@ -1085,10 +1189,10 @@ export class CandidateViewRegistry {
1085
1189
  const existing = existingToken === undefined ? undefined : this.records.get(existingToken);
1086
1190
  if (existing) {
1087
1191
  if (existing.lineageId !== undefined) throw new CandidateViewError("corrected candidate replay is no longer pending");
1088
- assertRecordSafe(existing);
1192
+ assertRecordSafe(existing, this.platform);
1089
1193
  return this.expose(existing);
1090
1194
  }
1091
- const record = materializeCandidateView({ contributorRoot: root, baseRef: projection.baseCommit, committedOnly: projection.committedOnly, ...(projection.intendedUntracked === undefined ? {} : { intendedUntracked: projection.intendedUntracked }) }, this.gitExecutor);
1195
+ const record = materializeCandidateView({ contributorRoot: root, baseRef: projection.baseCommit, committedOnly: projection.committedOnly, ...(projection.intendedUntracked === undefined ? {} : { intendedUntracked: projection.intendedUntracked }) }, this.gitExecutor, this.platform);
1092
1196
  try {
1093
1197
  if (record.baseCommit !== projection.baseCommit || record.baseTree !== projection.baseTree) throw new CandidateViewError("corrected candidate base does not match the frozen genesis base");
1094
1198
  if (!record.scope.paths.every((path) => projection.paths.includes(path))) throw new CandidateViewError("corrected candidate scope escapes the frozen genesis paths");
@@ -1115,8 +1219,8 @@ export class CandidateViewRegistry {
1115
1219
  if (currentBinding !== undefined && currentBinding.lineageId !== lineageId) {
1116
1220
  throw new CandidateViewError("corrected candidate replacement conflicts with the current lineage binding");
1117
1221
  }
1118
- assertRecordSafe(replacement);
1119
- if (current) assertRecordSafe(current);
1222
+ assertRecordSafe(replacement, this.platform);
1223
+ if (current) assertRecordSafe(current, this.platform);
1120
1224
  if (
1121
1225
  replacement.contributorRoot !== projection.contributorRoot ||
1122
1226
  replacement.baseCommit !== projection.baseCommit ||
@@ -1177,7 +1281,7 @@ export class CandidateViewRegistry {
1177
1281
  const record = this.records.get(token);
1178
1282
  const key = record === undefined ? undefined : this.lineageKey(record.contributorRoot, lineageId);
1179
1283
  if (!record || record.lineageId !== undefined || !key || this.lineages.has(key)) throw new CandidateViewError("candidate view lineage binding is missing or ambiguous");
1180
- assertRecordSafe(record);
1284
+ assertRecordSafe(record, this.platform);
1181
1285
  record.lineageId = lineageId;
1182
1286
  record.selectedLenses = selectedLenses;
1183
1287
  this.lineages.set(key, record.token);
@@ -1276,21 +1380,18 @@ export class CandidateViewRegistry {
1276
1380
  * correction.
1277
1381
  */
1278
1382
  rebindForFinalizeFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor): CandidateView {
1279
- const root = this.canonicalRoot(contributorRoot);
1280
- const key = this.lineageKey(root, lineageId);
1281
- const staleToken = this.lineages.get(key);
1282
- const stale = staleToken === undefined ? undefined : this.records.get(staleToken);
1283
- this.projections.delete(key);
1284
- if (stale !== undefined) {
1285
- this.remove(stale);
1286
- this.forget(stale);
1287
- }
1288
- return this.restoreForFinalizeFromNative(lineageId, root, descriptor);
1383
+ return this.restoreForFinalizeFromNative(lineageId, contributorRoot, descriptor);
1289
1384
  }
1290
1385
 
1386
+ /**
1387
+ * Restores a lineage's FINALIZE binding (gentle-pi #185): the stale entry
1388
+ * is only detached, not destroyed, so a failed restore can undo it.
1389
+ */
1291
1390
  restoreForFinalizeFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor): CandidateView {
1292
1391
  const root = this.canonicalRoot(contributorRoot);
1293
1392
  const key = this.lineageKey(root, lineageId);
1393
+ const staleToken = this.lineages.get(key), staleProjection = this.projections.get(key);
1394
+ this.lineages.delete(key); this.projections.delete(key);
1294
1395
  let projectionRestored = false;
1295
1396
  let record: CandidateViewRecord | undefined;
1296
1397
  try {
@@ -1299,15 +1400,17 @@ export class CandidateViewRegistry {
1299
1400
  const projection = this.resolveProjection(lineageId, root);
1300
1401
  const matchesProjection = (candidate: CandidateViewRecord): boolean => candidate.baseTree === projection.baseTree && candidate.candidateTree === projection.candidateTree && JSON.stringify(candidate.scope.paths) === JSON.stringify(projection.paths);
1301
1402
  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);
1403
+ record = materializeCandidateView({ contributorRoot: root, baseRef: projection.baseCommit, committedOnly: projection.committedOnly, ...(!emptyIntendedUntracked && projection.intendedUntracked !== undefined ? { intendedUntracked: projection.intendedUntracked } : {}) }, this.gitExecutor, this.platform);
1303
1404
  if (!matchesProjection(record) && emptyIntendedUntracked) {
1304
1405
  this.remove(record);
1305
1406
  record = undefined;
1306
- record = materializeCandidateView({ contributorRoot: root, baseRef: projection.baseCommit, committedOnly: projection.committedOnly, intendedUntracked: [] }, this.gitExecutor);
1407
+ record = materializeCandidateView({ contributorRoot: root, baseRef: projection.baseCommit, committedOnly: projection.committedOnly, intendedUntracked: [] }, this.gitExecutor, this.platform);
1307
1408
  }
1308
1409
  if (!matchesProjection(record)) throw new CandidateViewError("live candidate does not match the native frozen projection");
1309
1410
  this.records.set(record.token, record);
1310
1411
  this.bindRecord(record.token, lineageId, []);
1412
+ const stale = staleToken === undefined ? undefined : this.records.get(staleToken);
1413
+ if (stale !== undefined) { this.remove(stale); this.forget(stale); }
1311
1414
  return this.expose(record);
1312
1415
  } catch (error) {
1313
1416
  if (projectionRestored) this.projections.delete(key);
@@ -1315,6 +1418,8 @@ export class CandidateViewRegistry {
1315
1418
  this.forget(record);
1316
1419
  this.remove(record);
1317
1420
  }
1421
+ if (staleToken !== undefined) this.lineages.set(key, staleToken);
1422
+ if (staleProjection !== undefined) this.projections.set(key, staleProjection);
1318
1423
  throw error;
1319
1424
  }
1320
1425
  }
@@ -1339,7 +1444,7 @@ export class CandidateViewRegistry {
1339
1444
  this.restoreProjectionFromNative(lineageId, root, descriptor);
1340
1445
  projectionRestored = true;
1341
1446
  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);
1447
+ record = materializeCandidateView({ contributorRoot: root, baseRef: projection.baseCommit, committedOnly: projection.committedOnly, ...(projection.intendedUntracked === undefined ? {} : { intendedUntracked: projection.intendedUntracked }) }, this.gitExecutor, this.platform);
1343
1448
  if (record.baseTree !== projection.baseTree || record.candidateTree !== projection.candidateTree || JSON.stringify(record.scope.paths) !== JSON.stringify(projection.paths)) {
1344
1449
  throw new CandidateViewError("live candidate does not match the native frozen projection");
1345
1450
  }
@@ -1375,7 +1480,7 @@ export class CandidateViewRegistry {
1375
1480
  const token = this.lineages.get(key);
1376
1481
  const record = token === undefined ? undefined : this.records.get(token);
1377
1482
  if (!record || record.lineageId !== lineageId || !record.selectedLenses?.includes(lens as ReviewLens)) throw new CandidateViewError("candidate view context is missing, ambiguous, stale, or lens-unselected");
1378
- assertRecordSafe(record);
1483
+ assertRecordSafe(record, this.platform);
1379
1484
  return this.expose(record);
1380
1485
  }
1381
1486
 
@@ -1426,14 +1531,14 @@ export class CandidateViewRegistry {
1426
1531
  const current = this.currentBinding(contributorRoot);
1427
1532
  const record = this.records.get(current.token);
1428
1533
  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");
1429
- assertRecordSafe(record);
1534
+ assertRecordSafe(record, this.platform);
1430
1535
  this.assertCurrentBindingMatchesLiveCandidate(record);
1431
1536
  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");
1432
1537
  return lenses.map(() => this.expose(record));
1433
1538
  }
1434
1539
 
1435
1540
  private assertCurrentBindingMatchesLiveCandidate(record: CandidateViewRecord): void {
1436
- const live = materializeCandidateView({ contributorRoot: record.contributorRoot, baseRef: record.baseCommit, committedOnly: record.committedOnly, ...(record.intendedUntracked === undefined ? {} : { intendedUntracked: record.intendedUntracked }) }, this.gitExecutor);
1541
+ const live = materializeCandidateView({ contributorRoot: record.contributorRoot, baseRef: record.baseCommit, committedOnly: record.committedOnly, ...(record.intendedUntracked === undefined ? {} : { intendedUntracked: record.intendedUntracked }) }, this.gitExecutor, this.platform);
1437
1542
  try {
1438
1543
  if (
1439
1544
  live.baseCommit !== record.baseCommit ||
@@ -1455,7 +1560,7 @@ export class CandidateViewRegistry {
1455
1560
  const token = this.lineages.get(key);
1456
1561
  const record = token === undefined ? undefined : this.records.get(token);
1457
1562
  if (!record || record.lineageId !== lineageId) throw new CandidateViewError("candidate view context is missing or ambiguous for FINALIZE");
1458
- assertRecordSafe(record);
1563
+ assertRecordSafe(record, this.platform);
1459
1564
  return this.expose(record);
1460
1565
  }
1461
1566
 
@@ -1476,10 +1581,7 @@ export class CandidateViewRegistry {
1476
1581
 
1477
1582
  private remove(record: CandidateViewRecord): void {
1478
1583
  if (!isWithin(record.parent, record.root)) throw new CandidateViewError("candidate view cleanup escaped its owned parent");
1479
- try { makeWritableForCleanup(record.root); } catch {}
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 });
1584
+ removeCandidateOwner(record.owner, (args) => git(record.commonDir, args, process.env, record.gitExecutor), makeWritableForCleanup, false, this.platform);
1483
1585
  }
1484
1586
 
1485
1587
  private forget(record: CandidateViewRecord): void {
@@ -1511,7 +1613,7 @@ export class CandidateViewRegistry {
1511
1613
  modes: record.scope.modes,
1512
1614
  gitlinks: record.scope.gitlinks,
1513
1615
  deletedPaths: record.scope.deletedPaths,
1514
- verify: () => assertRecordSafe(record),
1616
+ verify: () => assertRecordSafe(record, this.platform),
1515
1617
  cleanup: () => this.cleanup(record.token),
1516
1618
  };
1517
1619
  }