gentle-pi 2.3.0 → 2.5.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 (153) hide show
  1. package/README.md +195 -11
  2. package/assets/agents/gentle-ai-worker.md +9 -0
  3. package/assets/agents/sdd-explore.md +1 -0
  4. package/assets/orchestrator-delegation.md +21 -10
  5. package/assets/orchestrator.md +8 -12
  6. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  7. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
  8. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
  9. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
  10. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
  11. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
  12. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
  13. package/docs/delegated-verification.md +25 -0
  14. package/docs/review-integration.md +1 -1
  15. package/docs/telemetry.md +38 -0
  16. package/extensions/ask-user-choice.ts +26 -20
  17. package/extensions/codegraph-tools.ts +94 -5
  18. package/extensions/gentle-agents.ts +588 -0
  19. package/extensions/gentle-ai.ts +1421 -143
  20. package/extensions/gentle-shell.ts +547 -0
  21. package/extensions/gentle-todo.ts +199 -0
  22. package/extensions/quiet-tools.ts +1 -1
  23. package/lib/agent-home.ts +8 -0
  24. package/lib/agents-config.ts +318 -0
  25. package/lib/agents-history.ts +80 -0
  26. package/lib/agents-protocol.ts +429 -0
  27. package/lib/agents-runner.ts +490 -0
  28. package/lib/agents-transcript.ts +87 -0
  29. package/lib/agents-view.ts +557 -0
  30. package/lib/agents-widget.ts +222 -0
  31. package/lib/gentle-ai-renderer.ts +142 -26
  32. package/lib/native-choice-list.ts +194 -0
  33. package/lib/native-fullscreen-interaction.ts +47 -0
  34. package/lib/native-pointer-region.ts +164 -0
  35. package/lib/native-review-cli.ts +103 -12
  36. package/lib/provider-contract-bundle.ts +88 -6
  37. package/lib/review-candidate-view-owner.ts +177 -0
  38. package/lib/review-candidate-view.ts +127 -35
  39. package/lib/review-consent-ui.ts +65 -0
  40. package/lib/review-host-relay.ts +146 -60
  41. package/lib/review-integration-v2.ts +92 -13
  42. package/lib/review-last-event-controller.ts +1 -0
  43. package/lib/review-relay-contract.ts +11 -0
  44. package/lib/review-repository.ts +2 -2
  45. package/lib/review-risk-assessment.ts +339 -0
  46. package/lib/review-session-standing-permission-ipc.ts +309 -0
  47. package/lib/review-session-standing-permission.ts +219 -0
  48. package/lib/sdd-preflight.ts +2 -2
  49. package/lib/shell-bar.ts +138 -0
  50. package/lib/shell-card.ts +136 -0
  51. package/lib/shell-changes-view.ts +205 -0
  52. package/lib/shell-changes.ts +210 -0
  53. package/lib/shell-gauge.ts +40 -0
  54. package/lib/shell-prompt.ts +119 -0
  55. package/lib/shell-todo.ts +280 -0
  56. package/lib/shell-usage-view.ts +76 -0
  57. package/lib/shell-usage.ts +246 -0
  58. package/lib/telemetry-trigger.ts +151 -0
  59. package/package.json +4 -4
  60. package/runtime/native-review-cli.mjs +102 -11
  61. package/runtime/review-integration-v2.mjs +92 -13
  62. package/runtime/review-relay-contract.mjs +11 -0
  63. package/runtime/review-risk-assessment.mjs +340 -0
  64. package/runtime/telemetry-trigger.mjs +152 -0
  65. package/scripts/build-runtime-modules.mjs +2 -0
  66. package/scripts/gentle-ai-installer.mjs +10 -10
  67. package/scripts/test-packed-runner.mjs +22 -0
  68. package/scripts/verify-package-files.mjs +18 -13
  69. package/skills/_shared/review-ledger-contract.md +9 -1
  70. package/skills/issue-creation/SKILL.md +53 -93
  71. package/tests/agents-config.test.ts +143 -0
  72. package/tests/agents-fake-child.ts +52 -0
  73. package/tests/agents-history.test.ts +54 -0
  74. package/tests/agents-protocol.test.ts +153 -0
  75. package/tests/agents-runner-process.test.ts +111 -0
  76. package/tests/agents-runner.test.ts +402 -0
  77. package/tests/agents-transcript.test.ts +30 -0
  78. package/tests/agents-view.test.ts +274 -0
  79. package/tests/agents-widget.test.ts +111 -0
  80. package/tests/ask-user-choice.test.ts +157 -3
  81. package/tests/codegraph-tools.test.ts +110 -1
  82. package/tests/devbinary/native-review-parity.devtest.ts +108 -0
  83. package/tests/fixtures/agents-process-child.mjs +23 -0
  84. package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
  85. package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
  86. package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
  87. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
  88. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
  89. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
  90. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
  91. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
  92. package/tests/gentle-agents.test.ts +741 -0
  93. package/tests/gentle-ai-binary.test.ts +1 -1
  94. package/tests/gentle-ai-installer.test.ts +47 -47
  95. package/tests/gentle-ai-renderer.test.ts +65 -0
  96. package/tests/gentle-ai.test.ts +31 -14
  97. package/tests/gentle-card-text.ts +35 -0
  98. package/tests/gentle-shell.test.ts +527 -0
  99. package/tests/gentle-todo.test.ts +182 -0
  100. package/tests/issue-creation-skill.test.ts +103 -0
  101. package/tests/native-choice-list.test.ts +202 -0
  102. package/tests/native-fullscreen-interaction.test.ts +125 -0
  103. package/tests/native-pointer-region.test.ts +245 -0
  104. package/tests/native-review-capability-contract.test.ts +33 -1
  105. package/tests/native-review-cli.test.ts +40 -0
  106. package/tests/native-review-consent.test.ts +91 -0
  107. package/tests/native-review-parity-runtime.test.ts +8 -2
  108. package/tests/native-review-parity.test.ts +29 -22
  109. package/tests/orchestrator-budget.test.ts +71 -2
  110. package/tests/orchestrator-rdd-ownership.test.ts +10 -1
  111. package/tests/package-manifest.test.ts +134 -9
  112. package/tests/provider-contract-bundle.test.ts +76 -0
  113. package/tests/provider-contract-mirror.test.ts +19 -0
  114. package/tests/quiet-tool-rendering.test.ts +96 -37
  115. package/tests/rdd-aware-verification-contract.test.ts +216 -0
  116. package/tests/rdd-status-line.test.ts +286 -0
  117. package/tests/review-agent-end-preflight.test.ts +408 -0
  118. package/tests/review-candidate-view.test.ts +452 -6
  119. package/tests/review-contract-prompt.test.ts +142 -0
  120. package/tests/review-controller-native-recovery.test.ts +29 -4
  121. package/tests/review-controller-native-routing.test.ts +321 -4
  122. package/tests/review-controller-workspace-root.test.ts +45 -2
  123. package/tests/review-controller.test.ts +26 -1
  124. package/tests/review-host-relay-routing.test.ts +229 -11
  125. package/tests/review-host-relay.test.ts +195 -7
  126. package/tests/review-integration-v2-forward.test.ts +47 -0
  127. package/tests/review-integration-v2.test.ts +112 -0
  128. package/tests/review-last-event-closure.test.ts +7 -2
  129. package/tests/review-ledger-contract.test.ts +1 -1
  130. package/tests/review-relay-contract.test.ts +26 -0
  131. package/tests/review-repository.test.ts +28 -1
  132. package/tests/review-risk-assessment.test.ts +626 -0
  133. package/tests/review-session-standing-permission-controller.test.ts +608 -0
  134. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  135. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  136. package/tests/review-session-standing-permission.test.ts +126 -0
  137. package/tests/runtime-harness.mjs +1 -0
  138. package/tests/shell-bar.test.ts +176 -0
  139. package/tests/shell-card.test.ts +118 -0
  140. package/tests/shell-changes-view.test.ts +146 -0
  141. package/tests/shell-changes.test.ts +182 -0
  142. package/tests/shell-prompt.test.ts +118 -0
  143. package/tests/shell-todo.test.ts +170 -0
  144. package/tests/shell-usage-view.test.ts +62 -0
  145. package/tests/shell-usage.test.ts +197 -0
  146. package/tests/telemetry-trigger.test.ts +349 -0
  147. package/tests/writer-edit-surface-scope.test.ts +153 -17
  148. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
  149. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
  150. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
  151. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
  152. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
  153. /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
@@ -0,0 +1,177 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { closeSync, fsyncSync, lstatSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import { conservativeOwnerDeathProofV1 } from "./review-lock.ts";
6
+
7
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
8
+ const BOOT_UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
9
+ export interface CandidateViewOwner {
10
+ version: 1;
11
+ uuid: string;
12
+ token: string;
13
+ pid: number;
14
+ host: string | null;
15
+ root: string;
16
+ commonDir: string;
17
+ }
18
+ type Git = (args: readonly string[]) => string;
19
+
20
+ // A hostname or a repository-local nonce cannot prove that a PID is local.
21
+ // Bind to the kernel boot and, on Linux, its PID namespace. Unsupported or
22
+ // unavailable provenance disables reclamation (including across reboots).
23
+ function localHost(): string | null {
24
+ try {
25
+ if (process.platform === "darwin") {
26
+ const boot = execFileSync("/usr/sbin/sysctl", ["-n", "kern.bootsessionuuid"], { encoding: "utf8", timeout: 1000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"] }).trim();
27
+ if (BOOT_UUID.test(boot)) return `darwin:${boot.toLowerCase()}`;
28
+ }
29
+ if (process.platform === "linux") {
30
+ const boot = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
31
+ const namespace = readlinkSync("/proc/self/ns/pid");
32
+ if (BOOT_UUID.test(boot) && /^pid:\[\d+\]$/.test(namespace)) return `linux:${boot}:${namespace}`;
33
+ }
34
+ } catch { /* No remote/PID-only fallback. */ }
35
+ return null;
36
+ }
37
+
38
+ function directory(path: string, privateMode = false): string {
39
+ const stat = lstatSync(path);
40
+ if (!stat.isDirectory() || stat.isSymbolicLink() || realpathSync(path) !== path ||
41
+ (privateMode && (stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0))) throw new Error("Unsafe candidate owner directory");
42
+ return `${stat.dev}:${stat.ino}`;
43
+ }
44
+
45
+ export function assertCandidateOwnerParent(commonDir: string): string {
46
+ directory(commonDir);
47
+ directory(join(commonDir, "gentle-ai"));
48
+ const parent = join(commonDir, "gentle-ai", "candidate-views");
49
+ directory(parent, true);
50
+ return parent;
51
+ }
52
+
53
+ function regular(path: string, privateMode = false): string {
54
+ const stat = lstatSync(path);
55
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || realpathSync(path) !== path || stat.size > 16384 ||
56
+ (privateMode && (stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o600))) throw new Error("Unsafe candidate owner file");
57
+ return `${stat.dev}:${stat.ino}`;
58
+ }
59
+
60
+ function syncDirectory(path: string): void {
61
+ const fd = openSync(path, "r");
62
+ try { fsyncSync(fd); } finally { closeSync(fd); }
63
+ }
64
+
65
+ function exclusiveFile(path: string, content: string): void {
66
+ const fd = openSync(path, "wx", 0o600);
67
+ try { writeFileSync(fd, content); fsyncSync(fd); } finally { closeSync(fd); }
68
+ syncDirectory(dirname(path));
69
+ }
70
+
71
+ function markerPath(root: string): string { return `${root}.owner.json`; }
72
+
73
+ export function createCandidateOwner(commonDir: string, root: string): CandidateViewOwner {
74
+ const parent = assertCandidateOwnerParent(commonDir);
75
+ const uuid = basename(root);
76
+ if (!UUID.test(uuid) || root !== join(parent, uuid) || lstatSync(root, { throwIfNoEntry: false })) throw new Error("Unsafe candidate owner root");
77
+ const owner: CandidateViewOwner = { version: 1, uuid, token: randomUUID(), pid: process.pid, host: localHost(), root, commonDir };
78
+ // Any write/fsync failure aborts creation BEFORE Git can register the view.
79
+ // An incomplete marker is retained, never guessed into ownership.
80
+ exclusiveFile(markerPath(root), JSON.stringify(owner));
81
+ syncDirectory(dirname(parent));
82
+ syncDirectory(commonDir);
83
+ return Object.freeze(owner);
84
+ }
85
+
86
+ function readOwner(commonDir: string, root: string): CandidateViewOwner {
87
+ const parent = assertCandidateOwnerParent(commonDir);
88
+ if (!UUID.test(basename(root)) || root !== join(parent, basename(root))) throw new Error("Candidate owner escaped parent");
89
+ regular(markerPath(root), true);
90
+ const owner = JSON.parse(readFileSync(markerPath(root), "utf8")) as CandidateViewOwner;
91
+ if (!owner || Object.keys(owner).sort().join(",") !== "commonDir,host,pid,root,token,uuid,version" || owner.version !== 1 ||
92
+ owner.uuid !== basename(root) || !UUID.test(owner.token) || !Number.isSafeInteger(owner.pid) || owner.pid <= 0 ||
93
+ owner.root !== root || owner.commonDir !== commonDir || !(owner.host === null || typeof owner.host === "string")) throw new Error("Malformed candidate owner");
94
+ return owner;
95
+ }
96
+
97
+ function dead(owner: CandidateViewOwner): boolean {
98
+ return owner.host !== null && owner.host === localHost() && conservativeOwnerDeathProofV1({
99
+ pid: owner.pid, token: owner.token, owner_hash: "", repository_id: owner.commonDir, authority_id: owner.uuid,
100
+ });
101
+ }
102
+
103
+ function registration(root: string, commonDir: string, git: Git): string {
104
+ const rows = git(["worktree", "list", "--porcelain", "-z"]).split("\0\0");
105
+ const matching = rows.filter((row) => row.split("\0")[0] === `worktree ${root}`);
106
+ if (matching.length !== 1 || matching[0]!.split("\0").some((field) => /^(locked|prunable)( |$)/.test(field))) throw new Error("Candidate registration is ambiguous");
107
+ const gitFile = join(root, ".git");
108
+ regular(gitFile);
109
+ const pointer = readFileSync(gitFile, "utf8");
110
+ if (!pointer.startsWith("gitdir: ") || !pointer.endsWith("\n")) throw new Error("Unsafe candidate Git pointer");
111
+ const admin = pointer.slice(8, -1);
112
+ if (dirname(admin) !== join(commonDir, "worktrees")) throw new Error("Candidate admin escaped common directory");
113
+ directory(join(commonDir, "worktrees"));
114
+ directory(admin);
115
+ regular(join(admin, "gitdir"));
116
+ regular(join(admin, "commondir"));
117
+ if (readFileSync(join(admin, "gitdir"), "utf8") !== `${gitFile}\n` ||
118
+ resolve(admin, readFileSync(join(admin, "commondir"), "utf8").trim()) !== commonDir) throw new Error("Candidate Git backlinks changed");
119
+ return matching[0]! + pointer;
120
+ }
121
+
122
+ export function removeCandidateOwner(owner: CandidateViewOwner, git: Git, makeWritable: (root: string) => void, orphan = false): void {
123
+ const { root, commonDir } = owner;
124
+ const parent = assertCandidateOwnerParent(commonDir);
125
+ const expected = JSON.stringify(owner);
126
+ const parentIdentity = directory(parent, true);
127
+ const markerIdentity = regular(markerPath(root), true);
128
+ const checkOwner = (): void => {
129
+ if (directory(parent, true) !== parentIdentity || regular(markerPath(root), true) !== markerIdentity ||
130
+ JSON.stringify(readOwner(commonDir, root)) !== expected || (orphan ? !dead(owner) : owner.pid !== process.pid)) throw new Error("Candidate ownership changed");
131
+ };
132
+ checkOwner();
133
+ const lock = `${root}.reaper-lock`;
134
+ const token = randomUUID();
135
+ exclusiveFile(lock, token); // EEXIST is final; unknown/stale locks are never stolen.
136
+ const lockIdentity = regular(lock, true);
137
+ const checkLock = (): void => {
138
+ if (regular(lock, true) !== lockIdentity || readFileSync(lock, "utf8") !== token) throw new Error("Candidate reaper lock changed");
139
+ };
140
+ try {
141
+ checkOwner();
142
+ const identity = [directory(parent, true), directory(root), regular(markerPath(root), true)];
143
+ const registered = registration(root, commonDir, git);
144
+ checkOwner();
145
+ checkLock();
146
+ makeWritable(root);
147
+ // Git and chmod are race boundaries: repeat path, owner, lock, and exact
148
+ // registration proofs immediately before asking Git to remove this root.
149
+ if (registration(root, commonDir, git) !== registered ||
150
+ JSON.stringify([directory(parent, true), directory(root), regular(markerPath(root), true)]) !== JSON.stringify(identity)) throw new Error("Candidate cleanup identity changed");
151
+ checkOwner();
152
+ checkLock();
153
+ git(["worktree", "remove", "--force", root]);
154
+ // Git failure or incomplete removal must never trigger recursive rm.
155
+ if (lstatSync(root, { throwIfNoEntry: false }) || git(["worktree", "list", "--porcelain", "-z"]).split("\0").includes(`worktree ${root}`)) throw new Error("Candidate removal is incomplete");
156
+ checkOwner();
157
+ checkLock();
158
+ unlinkSync(markerPath(root));
159
+ syncDirectory(parent);
160
+ } finally {
161
+ // Only release this exact lock, even when the deletion itself failed.
162
+ try { assertCandidateOwnerParent(commonDir); checkLock(); unlinkSync(lock); syncDirectory(parent); } catch {}
163
+ }
164
+ }
165
+
166
+ export function sweepCandidateOwners(commonDir: string, git: Git, makeWritable: (root: string) => void): void {
167
+ try {
168
+ const parent = assertCandidateOwnerParent(commonDir);
169
+ for (const name of readdirSync(parent)) {
170
+ if (!name.endsWith(".owner.json")) continue;
171
+ try {
172
+ const owner = readOwner(commonDir, join(parent, name.slice(0, -11)));
173
+ if (dead(owner)) removeCandidateOwner(owner, git, makeWritable, true);
174
+ } catch { /* Unknown, legacy, unsafe, unregistered, and contended entries survive. */ }
175
+ }
176
+ } catch { /* Startup/materialization sweeps are best-effort; never create a store. */ }
177
+ }
@@ -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, removeCandidateOwner, 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;
@@ -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)) {
@@ -603,11 +635,15 @@ function makeWritableForCleanup(path: string): void {
603
635
  }
604
636
 
605
637
  function candidateViewParent(commonDir: string): string {
606
- const parent = join(commonDir, "gentle-ai", "candidate-views");
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
+ return assertCandidateOwnerParent(commonDir);
611
647
  }
612
648
 
613
649
  export interface ResolvedCandidateBase {
@@ -780,11 +816,29 @@ function isErrnoCode(error: unknown, code: string): boolean {
780
816
  return typeof error === "object" && error !== null && "code" in error && error.code === code;
781
817
  }
782
818
 
819
+ function timestampSeconds(timestampNs: bigint): number {
820
+ // Node's utimes API takes Unix seconds as a number. Splitting the value keeps
821
+ // the seconds conversion exact; the explicit racy-clean backdate below is far
822
+ // larger than the fractional precision a Number can represent at this epoch.
823
+ return Number(timestampNs / 1_000_000_000n) + Number(timestampNs % 1_000_000_000n) / 1_000_000_000;
824
+ }
825
+
783
826
  function seedPrivateIndexFromLiveIndex(cwd: string, indexPath: string, executor: CandidateGitExecutor): boolean {
784
827
  const liveIndex = resolve(cwd, git(cwd, ["rev-parse", "--path-format=absolute", "--git-path", "index"], process.env, executor));
785
- const entry = lstatSync(liveIndex, { throwIfNoEntry: false });
828
+ const entry = lstatSync(liveIndex, { bigint: true, throwIfNoEntry: false });
786
829
  if (entry === undefined) return false; if (!entry.isFile()) throw new CandidateViewError("candidate live Git index is not a regular file");
830
+ if (entry.mtimeNs < PRIVATE_INDEX_RACY_BACKDATE_NS) throw new CandidateViewError("candidate live Git index timestamp is too early for racy-clean protection");
787
831
  copyFileSync(liveIndex, indexPath);
832
+ // Git's racy-clean check compares index and tracked-file mtimes. copyFileSync
833
+ // gives the private index a new timestamp; restoring a Date can also lose
834
+ // nanoseconds. Backdate the bigint live timestamp instead, then verify the
835
+ // filesystem applied enough of that bounded interval for Git to refresh
836
+ // content rather than trusting a racy-clean stat match.
837
+ utimesSync(indexPath, timestampSeconds(entry.atimeNs), timestampSeconds(entry.mtimeNs - PRIVATE_INDEX_RACY_BACKDATE_NS));
838
+ const privateMtimeNs = lstatSync(indexPath, { bigint: true }).mtimeNs;
839
+ if (privateMtimeNs >= entry.mtimeNs || entry.mtimeNs - privateMtimeNs < PRIVATE_INDEX_RACY_SAFETY_NS) {
840
+ throw new CandidateViewError("candidate private Git index timestamp cannot preserve racy-clean protection");
841
+ }
788
842
  for (const name of readdirSync(dirname(liveIndex))) if (/^sharedindex\.[0-9a-f]+$/.test(name)) {
789
843
  try {
790
844
  const sharedIndex = join(dirname(liveIndex), name);
@@ -809,6 +863,7 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
809
863
  ? resolveCandidateBase(contributorRoot, "HEAD", process.env, executor)
810
864
  : base;
811
865
  const parent = candidateViewParent(canonicalCommonDir);
866
+ sweepCandidateOwners(canonicalCommonDir, (args) => git(canonicalCommonDir, args, process.env, executor), makeWritableForCleanup);
812
867
  const index = mkdtempSync(join(tmpdir(), "gentle-ai-candidate-index-"));
813
868
  const indexPath = join(index, "index");
814
869
  const environment = { ...process.env, GIT_INDEX_FILE: indexPath };
@@ -832,6 +887,7 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
832
887
  }
833
888
  const candidateTree = git(contributorRoot, ["write-tree"], environment, executor);
834
889
  const root = join(parent, randomUUID());
890
+ const owner = createCandidateOwner(canonicalCommonDir, root);
835
891
  // The worktree is created under the same try/catch cleanup boundary as
836
892
  // the read-tree materialization that follows. addUnbornWorktree's
837
893
  // fallback path can register a worktree with `worktree add` and then
@@ -852,9 +908,9 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
852
908
  const scope = deriveChangedScope(contributorRoot, base.tree, candidateTree, [...tree.entries, ...tree.gitlinks], executor);
853
909
  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
910
  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 };
911
+ 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
912
  } catch (error) {
857
- try { git(contributorRoot, ["worktree", "remove", "--force", root], process.env, executor); } catch { rmSync(root, { recursive: true, force: true }); }
913
+ try { removeCandidateOwner(owner, (args) => git(canonicalCommonDir, args, process.env, executor), makeWritableForCleanup); } catch { /* Preserve the marker and any partial worktree for conservative recovery. */ }
858
914
  throw error;
859
915
  }
860
916
  } finally {
@@ -862,7 +918,7 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
862
918
  }
863
919
  }
864
920
 
865
- function assertRecordSafe(record: CandidateViewRecord): void {
921
+ function assertRecordSafe(record: CandidateViewRecord, platform: NodeJS.Platform = process.platform): void {
866
922
  try {
867
923
  const contributor = lstatSync(record.contributorRoot);
868
924
  if (!contributor.isDirectory() || contributor.isSymbolicLink() || realpathSync(record.contributorRoot) !== record.contributorRoot) {
@@ -902,7 +958,7 @@ function assertRecordSafe(record: CandidateViewRecord): void {
902
958
  if (!item) throw new CandidateViewError("candidate view entry is missing or moved");
903
959
  if (entry.mode === "120000") {
904
960
  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))) {
961
+ } else if (!item.isFile() || item.isSymbolicLink() || (item.mode & 0o222) !== 0 || !hasExpectedExecutableBits(item.mode, entry.mode, platform)) {
906
962
  throw new CandidateViewError("candidate view entry is unsafe, writable, or has a changed mode");
907
963
  }
908
964
  const actualHash = entryContentHash(root, entry);
@@ -913,8 +969,10 @@ function assertRecordSafe(record: CandidateViewRecord): void {
913
969
  export class CandidateViewRegistry {
914
970
  private readonly records = new Map<string, CandidateViewRecord>();
915
971
  private readonly gitExecutor: CandidateGitExecutor;
916
- constructor(gitExecutor: CandidateGitExecutor = defaultCandidateGitExecutor) {
972
+ private readonly platform: NodeJS.Platform;
973
+ constructor(gitExecutor: CandidateGitExecutor = defaultCandidateGitExecutor, platform: NodeJS.Platform = process.platform) {
917
974
  this.gitExecutor = gitExecutor;
975
+ this.platform = platform;
918
976
  }
919
977
  // Lifecycle state is scoped to the canonical target worktree as well as the
920
978
  // provider lineage. Lineage text is repository-local and may legitimately be
@@ -1000,15 +1058,25 @@ export class CandidateViewRegistry {
1000
1058
  const key = this.lineageKey(contributorRoot, lineageId);
1001
1059
  const token = this.lineages.get(key);
1002
1060
  const record = token === undefined ? undefined : this.records.get(token);
1003
- if (record !== undefined) assertRecordSafe(record);
1061
+ if (record !== undefined) assertRecordSafe(record, this.platform);
1004
1062
  }
1005
1063
 
1006
1064
  create(request: CreateCandidateViewRequest): CandidateView {
1007
1065
  return this.createOrReuse(request);
1008
1066
  }
1009
1067
 
1068
+ sweepOrphans(contributorRoot: string): void {
1069
+ try {
1070
+ const cwd = realpathSync(contributorRoot);
1071
+ const common = realpathSync(resolve(cwd, git(cwd, ["rev-parse", "--git-common-dir"], process.env, this.gitExecutor)));
1072
+ sweepCandidateOwners(common, (args) => git(common, args, process.env, this.gitExecutor), makeWritableForCleanup);
1073
+ } catch { /* Non-repositories and unavailable ownership are preserved. */ }
1074
+ }
1075
+
1010
1076
  cleanupAll(): void {
1011
- for (const token of [...this.records.keys()]) this.cleanup(token);
1077
+ for (const token of [...this.records.keys()]) {
1078
+ try { this.cleanup(token); } catch { /* Continue other owned views; failed records remain retryable. */ }
1079
+ }
1012
1080
  }
1013
1081
 
1014
1082
  createOrReuse(request: CreateCandidateViewRequest): CandidateView {
@@ -1017,7 +1085,7 @@ export class CandidateViewRegistry {
1017
1085
  const scopedReplayKey = normalizedRequest.replayKey === undefined ? undefined : this.replayKey(contributorRoot, normalizedRequest.replayKey);
1018
1086
  const token = scopedReplayKey === undefined ? undefined : this.replays.get(scopedReplayKey);
1019
1087
  const existing = token === undefined ? undefined : this.records.get(token);
1020
- if (existing) { assertRecordSafe(existing); return this.expose(existing); }
1088
+ if (existing) { assertRecordSafe(existing, this.platform); return this.expose(existing); }
1021
1089
  const record = materializeCandidateView(normalizedRequest, this.gitExecutor);
1022
1090
  this.records.set(record.token, record);
1023
1091
  if (scopedReplayKey !== undefined) this.replays.set(scopedReplayKey, record.token);
@@ -1030,6 +1098,32 @@ export class CandidateViewRegistry {
1030
1098
 
1031
1099
  bindCurrent(request: BindCandidateViewRequest): void {
1032
1100
  const selectedLenses = this.validateSelectedLenses(request.selectedLenses);
1101
+ const candidate = this.records.get(request.token);
1102
+ const key = candidate === undefined ? undefined : this.lineageKey(candidate.contributorRoot, request.lineageId);
1103
+ const existingToken = key === undefined ? undefined : this.lineages.get(key);
1104
+ // A retried native START for a lineage this controller already bound
1105
+ // (native reports it "resumed") re-materializes a fresh, content-
1106
+ // identical candidate view under a new token before it reaches here.
1107
+ // Rebinding that duplicate to the same lineage key used to fail closed
1108
+ // with "candidate view lineage binding is missing or ambiguous" even
1109
+ // though nothing is actually ambiguous: it is the exact same reviewable
1110
+ // content, re-verified from Git. Discard the redundant duplicate and
1111
+ // keep the already-bound view current instead of failing the retry
1112
+ // (gentle-pi candidate-view rebind defect, ga#4085 / ga#4050).
1113
+ if (candidate !== undefined && existingToken !== undefined && existingToken !== request.token) {
1114
+ const existing = this.records.get(existingToken);
1115
+ let existingSafe = false;
1116
+ if (existing !== undefined && existing.lineageId === request.lineageId) {
1117
+ try { assertRecordSafe(existing, this.platform); existingSafe = true; } catch { existingSafe = false; }
1118
+ }
1119
+ if (existing !== undefined && existingSafe && candidateRecordsShareIdentity(existing, candidate)) {
1120
+ existing.selectedLenses = selectedLenses;
1121
+ this.remove(candidate);
1122
+ this.forget(candidate);
1123
+ this.current.set(existing.contributorRoot, { lineageId: request.lineageId, token: existingToken });
1124
+ return;
1125
+ }
1126
+ }
1033
1127
  const record = this.bindRecord(request.token, request.lineageId, selectedLenses);
1034
1128
  this.current.set(record.contributorRoot, { lineageId: request.lineageId, token: request.token });
1035
1129
  }
@@ -1043,7 +1137,7 @@ export class CandidateViewRegistry {
1043
1137
  const record = this.records.get(request.token);
1044
1138
  if (!record || record.lineageId !== undefined) throw new CandidateViewError("native reviewing candidate view is missing or already bound", "authoritative-current-match-missing");
1045
1139
  if (this.current.has(record.contributorRoot)) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established");
1046
- assertRecordSafe(record);
1140
+ assertRecordSafe(record, this.platform);
1047
1141
  this.assertCurrentBindingMatchesLiveCandidate(record);
1048
1142
  this.bindCurrent(request);
1049
1143
  }
@@ -1085,7 +1179,7 @@ export class CandidateViewRegistry {
1085
1179
  const existing = existingToken === undefined ? undefined : this.records.get(existingToken);
1086
1180
  if (existing) {
1087
1181
  if (existing.lineageId !== undefined) throw new CandidateViewError("corrected candidate replay is no longer pending");
1088
- assertRecordSafe(existing);
1182
+ assertRecordSafe(existing, this.platform);
1089
1183
  return this.expose(existing);
1090
1184
  }
1091
1185
  const record = materializeCandidateView({ contributorRoot: root, baseRef: projection.baseCommit, committedOnly: projection.committedOnly, ...(projection.intendedUntracked === undefined ? {} : { intendedUntracked: projection.intendedUntracked }) }, this.gitExecutor);
@@ -1115,8 +1209,8 @@ export class CandidateViewRegistry {
1115
1209
  if (currentBinding !== undefined && currentBinding.lineageId !== lineageId) {
1116
1210
  throw new CandidateViewError("corrected candidate replacement conflicts with the current lineage binding");
1117
1211
  }
1118
- assertRecordSafe(replacement);
1119
- if (current) assertRecordSafe(current);
1212
+ assertRecordSafe(replacement, this.platform);
1213
+ if (current) assertRecordSafe(current, this.platform);
1120
1214
  if (
1121
1215
  replacement.contributorRoot !== projection.contributorRoot ||
1122
1216
  replacement.baseCommit !== projection.baseCommit ||
@@ -1177,7 +1271,7 @@ export class CandidateViewRegistry {
1177
1271
  const record = this.records.get(token);
1178
1272
  const key = record === undefined ? undefined : this.lineageKey(record.contributorRoot, lineageId);
1179
1273
  if (!record || record.lineageId !== undefined || !key || this.lineages.has(key)) throw new CandidateViewError("candidate view lineage binding is missing or ambiguous");
1180
- assertRecordSafe(record);
1274
+ assertRecordSafe(record, this.platform);
1181
1275
  record.lineageId = lineageId;
1182
1276
  record.selectedLenses = selectedLenses;
1183
1277
  this.lineages.set(key, record.token);
@@ -1276,21 +1370,18 @@ export class CandidateViewRegistry {
1276
1370
  * correction.
1277
1371
  */
1278
1372
  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);
1373
+ return this.restoreForFinalizeFromNative(lineageId, contributorRoot, descriptor);
1289
1374
  }
1290
1375
 
1376
+ /**
1377
+ * Restores a lineage's FINALIZE binding (gentle-pi #185): the stale entry
1378
+ * is only detached, not destroyed, so a failed restore can undo it.
1379
+ */
1291
1380
  restoreForFinalizeFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor): CandidateView {
1292
1381
  const root = this.canonicalRoot(contributorRoot);
1293
1382
  const key = this.lineageKey(root, lineageId);
1383
+ const staleToken = this.lineages.get(key), staleProjection = this.projections.get(key);
1384
+ this.lineages.delete(key); this.projections.delete(key);
1294
1385
  let projectionRestored = false;
1295
1386
  let record: CandidateViewRecord | undefined;
1296
1387
  try {
@@ -1308,6 +1399,8 @@ export class CandidateViewRegistry {
1308
1399
  if (!matchesProjection(record)) throw new CandidateViewError("live candidate does not match the native frozen projection");
1309
1400
  this.records.set(record.token, record);
1310
1401
  this.bindRecord(record.token, lineageId, []);
1402
+ const stale = staleToken === undefined ? undefined : this.records.get(staleToken);
1403
+ if (stale !== undefined) { this.remove(stale); this.forget(stale); }
1311
1404
  return this.expose(record);
1312
1405
  } catch (error) {
1313
1406
  if (projectionRestored) this.projections.delete(key);
@@ -1315,6 +1408,8 @@ export class CandidateViewRegistry {
1315
1408
  this.forget(record);
1316
1409
  this.remove(record);
1317
1410
  }
1411
+ if (staleToken !== undefined) this.lineages.set(key, staleToken);
1412
+ if (staleProjection !== undefined) this.projections.set(key, staleProjection);
1318
1413
  throw error;
1319
1414
  }
1320
1415
  }
@@ -1375,7 +1470,7 @@ export class CandidateViewRegistry {
1375
1470
  const token = this.lineages.get(key);
1376
1471
  const record = token === undefined ? undefined : this.records.get(token);
1377
1472
  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);
1473
+ assertRecordSafe(record, this.platform);
1379
1474
  return this.expose(record);
1380
1475
  }
1381
1476
 
@@ -1426,7 +1521,7 @@ export class CandidateViewRegistry {
1426
1521
  const current = this.currentBinding(contributorRoot);
1427
1522
  const record = this.records.get(current.token);
1428
1523
  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);
1524
+ assertRecordSafe(record, this.platform);
1430
1525
  this.assertCurrentBindingMatchesLiveCandidate(record);
1431
1526
  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
1527
  return lenses.map(() => this.expose(record));
@@ -1455,7 +1550,7 @@ export class CandidateViewRegistry {
1455
1550
  const token = this.lineages.get(key);
1456
1551
  const record = token === undefined ? undefined : this.records.get(token);
1457
1552
  if (!record || record.lineageId !== lineageId) throw new CandidateViewError("candidate view context is missing or ambiguous for FINALIZE");
1458
- assertRecordSafe(record);
1553
+ assertRecordSafe(record, this.platform);
1459
1554
  return this.expose(record);
1460
1555
  }
1461
1556
 
@@ -1476,10 +1571,7 @@ export class CandidateViewRegistry {
1476
1571
 
1477
1572
  private remove(record: CandidateViewRecord): void {
1478
1573
  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 });
1574
+ removeCandidateOwner(record.owner, (args) => git(record.commonDir, args, process.env, record.gitExecutor), makeWritableForCleanup);
1483
1575
  }
1484
1576
 
1485
1577
  private forget(record: CandidateViewRecord): void {
@@ -1511,7 +1603,7 @@ export class CandidateViewRegistry {
1511
1603
  modes: record.scope.modes,
1512
1604
  gitlinks: record.scope.gitlinks,
1513
1605
  deletedPaths: record.scope.deletedPaths,
1514
- verify: () => assertRecordSafe(record),
1606
+ verify: () => assertRecordSafe(record, this.platform),
1515
1607
  cleanup: () => this.cleanup(record.token),
1516
1608
  };
1517
1609
  }
@@ -0,0 +1,65 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ReviewConsentEnvelope, ReviewConsentV3 } from "./review-integration-v2.ts";
3
+
4
+ export const HOST_REVIEW_SESSION_PERMISSION_LABEL = "Run this review and allow reviews for this Pi session";
5
+
6
+ const HOST_REVIEW_SESSION_PERMISSION_EFFECT =
7
+ "Runs the provider's exact grant for this frozen candidate, then lets the Pi host use each later candidate's fresh validated provider grant once while this exact Pi session and canonical Git repository identity, including sibling worktrees, remain active. Reload keeps it; new, resume, fork, quit, process restart, or explicit revocation ends it. It grants no verdict, acknowledgement, maintenance, delivery, or cross-repository authority.";
8
+
9
+ export type ReviewConsentUiSelection =
10
+ | { kind: "provider"; answer: "granted" | "declined" }
11
+ | { kind: "host-session" };
12
+
13
+ export interface ReviewConsentUiModel {
14
+ readonly title: string;
15
+ readonly options: readonly [string, string, string];
16
+ }
17
+
18
+ export function isPiConsentV3(consent: ReviewConsentEnvelope): consent is ReviewConsentV3 {
19
+ return consent.schema === "gentle-ai.review-integration.consent/v3" && consent.agent === "pi";
20
+ }
21
+
22
+ export function formatReviewConsentUi(consent: ReviewConsentV3): ReviewConsentUiModel {
23
+ const evidence = consent.riskEvidence.map((item) => `- ${item}`).join("\n");
24
+ const title = [
25
+ consent.headline,
26
+ "",
27
+ `Reason: ${consent.reason}`,
28
+ `Value: ${consent.value}`,
29
+ `Risk: ${consent.riskLevel}; ${consent.changedFiles} changed file(s), ${consent.changedLines} changed line(s).`,
30
+ `Target: ${consent.targetIdentity}`,
31
+ `Projection: ${consent.projection}`,
32
+ "Risk evidence:",
33
+ evidence,
34
+ "",
35
+ "Ownership: The first two actions are provider-owned and apply only to this candidate. The third action is owned by the Pi host and controls only an in-memory permission for this exact Pi session and canonical Git repository identity, including sibling worktrees.",
36
+ "",
37
+ `Off-path note: ${consent.offPath.note}`,
38
+ `Off-path command: ${consent.offPath.command}`,
39
+ ].join("\n");
40
+ return {
41
+ title,
42
+ options: [
43
+ `1. ${consent.choices[0].label}\nEffect: ${consent.choices[0].effect}`,
44
+ `2. ${consent.choices[1].label}\nEffect: ${consent.choices[1].effect}`,
45
+ `3. ${HOST_REVIEW_SESSION_PERMISSION_LABEL}\nEffect: ${HOST_REVIEW_SESSION_PERMISSION_EFFECT}`,
46
+ ],
47
+ };
48
+ }
49
+
50
+ export async function presentReviewConsentUi(
51
+ context: ExtensionContext,
52
+ consent: ReviewConsentEnvelope,
53
+ ): Promise<ReviewConsentUiSelection | undefined> {
54
+ if (!isPiConsentV3(consent)) return undefined;
55
+ const model = formatReviewConsentUi(consent);
56
+ try {
57
+ const selected = await context.ui.select(model.title, [...model.options]);
58
+ if (selected === model.options[0]) return { kind: "provider", answer: "granted" };
59
+ if (selected === model.options[1]) return { kind: "provider", answer: "declined" };
60
+ if (selected === model.options[2]) return { kind: "host-session" };
61
+ return undefined;
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ }