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
@@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";
3
3
  import { createHash } from "node:crypto";
4
4
  import fs from "node:fs";
5
5
  import { syncBuiltinESMExports } from "node:module";
6
- import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
6
+ import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
7
7
  import { tmpdir } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import test from "node:test";
@@ -11,6 +11,7 @@ import { gzipSync } from "node:zlib";
11
11
  import {
12
12
  CandidateViewRegistry,
13
13
  CandidateViewError,
14
+ hasExpectedExecutableBits,
14
15
  type CandidateGitExecutor,
15
16
  createCandidateView,
16
17
  decodeCandidateContextManifest,
@@ -18,6 +19,7 @@ import {
18
19
  digestChangedPathManifest,
19
20
  injectReviewCandidateView,
20
21
  readCandidateContextManifestPage,
22
+ type NativeCandidateProjectionDescriptor,
21
23
  } from "../lib/review-candidate-view.ts";
22
24
 
23
25
  function git(cwd: string, ...arguments_: string[]): string {
@@ -26,7 +28,10 @@ function git(cwd: string, ...arguments_: string[]): string {
26
28
 
27
29
  function repository(t: test.TestContext): string {
28
30
  const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-candidate-view-"));
29
- t.after(() => rmSync(cwd, { recursive: true, force: true }));
31
+ t.after(() => {
32
+ execFileSync("chmod", ["-R", "u+rwx", cwd]);
33
+ rmSync(cwd, { recursive: true, force: true });
34
+ });
30
35
  git(cwd, "init", "-b", "main");
31
36
  writeFileSync(join(cwd, "tracked.txt"), "base\n");
32
37
  git(cwd, "add", "tracked.txt");
@@ -34,6 +39,233 @@ function repository(t: test.TestContext): string {
34
39
  return cwd;
35
40
  }
36
41
 
42
+ function ownerMarker(root: string): string { return `${root}.owner.json`; }
43
+
44
+ function orphanFixture(t: test.TestContext) {
45
+ const cwd = repository(t);
46
+ const registry = new CandidateViewRegistry();
47
+ const view = registry.create({ contributorRoot: cwd });
48
+ const marker = ownerMarker(view.root);
49
+ const owner = JSON.parse(readFileSync(marker, "utf8"));
50
+ owner.pid = 2147483000;
51
+ writeFileSync(marker, JSON.stringify(owner));
52
+ return { cwd, registry, view, marker, owner };
53
+ }
54
+
55
+ function mockOwnerProbe(t: test.TestContext, code?: string): void {
56
+ t.mock.method(process, "kill", () => {
57
+ if (code) throw Object.assign(new Error("fixture probe"), { code });
58
+ return true;
59
+ });
60
+ }
61
+
62
+ test("candidate ownership is private and durable before worktree add; ordinary cleanup removes its marker", (t) => {
63
+ const cwd = repository(t);
64
+ let observed = false;
65
+ let syncs = 0;
66
+ const fsync = fs.fsyncSync;
67
+ t.mock.method(fs, "fsyncSync", (fd: number) => { syncs++; fsync(fd); });
68
+ syncBuiltinESMExports();
69
+ t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
70
+ const registry = new CandidateViewRegistry((file, args, options) => {
71
+ if (args[0] === "worktree" && args[1] === "add") {
72
+ const root = args[args.length - 2]!;
73
+ const marker = ownerMarker(root);
74
+ const owner = JSON.parse(readFileSync(marker, "utf8"));
75
+ assert.equal(owner.root, root);
76
+ assert.equal(owner.pid, process.pid);
77
+ assert.match(owner.token, /^[0-9a-f-]{36}$/);
78
+ assert.equal(lstatSync(marker).mode & 0o777, 0o600);
79
+ assert.equal(existsSync(root), false);
80
+ assert.ok(syncs >= 4, "marker and every newly created ancestor must be durable before add");
81
+ observed = true;
82
+ }
83
+ return execFileSync(file, args, options);
84
+ });
85
+ const view = registry.create({ contributorRoot: cwd });
86
+ assert.equal(observed, true);
87
+ view.cleanup();
88
+ assert.equal(existsSync(view.root), false);
89
+ assert.equal(existsSync(ownerMarker(view.root)), false);
90
+ });
91
+
92
+ test("marked registered definitively dead candidate is reclaimed before another materialization", (t) => {
93
+ const { cwd, view, marker } = orphanFixture(t);
94
+ mockOwnerProbe(t, "ESRCH");
95
+ const next = new CandidateViewRegistry().create({ contributorRoot: cwd });
96
+ assert.equal(existsSync(view.root), false);
97
+ assert.equal(existsSync(marker), false);
98
+ assert.ok(!git(cwd, "worktree", "list", "--porcelain").includes(view.root));
99
+ next.cleanup();
100
+ });
101
+
102
+ for (const scenario of ["self", "live", "EPERM", "unknown", "malformed", "foreign-host", "unknown-host", "invalid-pid", "extra-key", "legacy", "unregistered", "root-symlink", "marker-symlink", "parent-symlink", "escape", "unknown-lock", "public-marker", "locked", "backlink", "admin-symlink", "missing-root"] as const) {
103
+ test(`orphan sweep preserves ${scenario} ownership`, (t) => {
104
+ const { cwd, view, marker, owner } = orphanFixture(t);
105
+ mockOwnerProbe(t, scenario === "live" ? undefined : scenario === "EPERM" ? "EPERM" : scenario === "unknown" ? "EIO" : "ESRCH");
106
+ if (scenario === "self") owner.pid = process.pid;
107
+ if (scenario === "foreign-host") owner.host = "another-host";
108
+ if (scenario === "unknown-host") owner.host = null;
109
+ if (scenario === "invalid-pid") owner.pid = -1;
110
+ if (scenario === "extra-key") owner.extra = true;
111
+ if (scenario === "escape") owner.root = cwd;
112
+ writeFileSync(marker, scenario === "malformed" ? "{" : JSON.stringify(owner));
113
+ if (scenario === "legacy") rmSync(marker);
114
+ if (scenario === "public-marker") chmodSync(marker, 0o644);
115
+ if (scenario === "locked") git(cwd, "worktree", "lock", view.root);
116
+ if (scenario === "backlink" || scenario === "admin-symlink") {
117
+ const admin = readFileSync(join(view.root, ".git"), "utf8").slice(8).trim();
118
+ if (scenario === "backlink") writeFileSync(join(admin, "gitdir"), `${cwd}/.git\n`);
119
+ else { renameSync(admin, `${admin}.saved`); symlinkSync(`${admin}.saved`, admin); }
120
+ }
121
+ if (scenario === "missing-root") renameSync(view.root, `${view.root}.saved`);
122
+ if (scenario === "unknown-lock") writeFileSync(`${view.root}.reaper-lock`, "unknown");
123
+ if (scenario === "unregistered") {
124
+ execFileSync("chmod", ["-R", "u+rwx", view.root]);
125
+ git(cwd, "worktree", "remove", "--force", view.root);
126
+ mkdirSync(view.root);
127
+ writeFileSync(join(view.root, "preserve.txt"), "unregistered");
128
+ }
129
+ if (scenario === "root-symlink") { renameSync(view.root, `${view.root}.saved`); symlinkSync(`${view.root}.saved`, view.root); }
130
+ if (scenario === "marker-symlink") { renameSync(marker, `${marker}.saved`); symlinkSync(`${marker}.saved`, marker); }
131
+ if (scenario === "parent-symlink") {
132
+ const parent = join(view.root, "..");
133
+ renameSync(parent, `${parent}.saved`);
134
+ symlinkSync(`${parent}.saved`, parent);
135
+ }
136
+ new CandidateViewRegistry().sweepOrphans(cwd);
137
+ assert.equal(existsSync(view.root), scenario !== "missing-root");
138
+ assert.equal(existsSync(marker), scenario !== "legacy");
139
+ assert.equal(readFileSync(join(cwd, "tracked.txt"), "utf8"), "base\n");
140
+ });
141
+ }
142
+
143
+ test("reaper lock excludes concurrent sweeps and rechecks marker identity before removal", (t) => {
144
+ const { cwd, view, marker, owner } = orphanFixture(t);
145
+ mockOwnerProbe(t, "ESRCH");
146
+ let lists = 0;
147
+ const racing = new CandidateViewRegistry((file, args, options) => {
148
+ if (args.includes("list")) {
149
+ lists++;
150
+ new CandidateViewRegistry().sweepOrphans(cwd);
151
+ if (lists === 2) writeFileSync(marker, JSON.stringify({ ...owner, token: "00000000-0000-4000-8000-000000000000" }));
152
+ }
153
+ return execFileSync(file, args, options);
154
+ });
155
+ racing.sweepOrphans(cwd);
156
+ assert.ok(lists >= 2);
157
+ assert.equal(existsSync(view.root), true);
158
+ assert.equal(existsSync(`${view.root}.reaper-lock`), false);
159
+ });
160
+
161
+ test("failed Git removal preserves owned candidate and marker without recursive fallback", (t) => {
162
+ const cwd = repository(t);
163
+ const registry = new CandidateViewRegistry((file, args, options) => {
164
+ if (args.includes("remove")) throw new Error("fixture removal failure");
165
+ return execFileSync(file, args, options);
166
+ });
167
+ const view = registry.create({ contributorRoot: cwd });
168
+ assert.throws(() => view.cleanup());
169
+ assert.equal(existsSync(view.root), true);
170
+ assert.equal(existsSync(ownerMarker(view.root)), true);
171
+ });
172
+
173
+ test("failed creation retains a recoverable marker when Git removal also fails", (t) => {
174
+ const cwd = repository(t);
175
+ let root = "";
176
+ const registry = new CandidateViewRegistry((file, args, options) => {
177
+ if (args[0] === "worktree" && args[1] === "add") root = args[args.length - 2]!;
178
+ if (args.includes("remove") || (args[0] === "read-tree" && options.cwd === root)) throw new Error("fixture failure");
179
+ return execFileSync(file, args, options);
180
+ });
181
+ assert.throws(() => registry.create({ contributorRoot: cwd }));
182
+ assert.equal(existsSync(root), true);
183
+ const owner = JSON.parse(readFileSync(ownerMarker(root), "utf8"));
184
+ writeFileSync(ownerMarker(root), JSON.stringify({ ...owner, pid: 2147483000 }));
185
+ mockOwnerProbe(t, "ESRCH");
186
+ new CandidateViewRegistry().sweepOrphans(cwd);
187
+ assert.equal(existsSync(root), false);
188
+ });
189
+
190
+ test("materialization rejects a symlinked owner ancestor without creating an external store", (t) => {
191
+ const cwd = repository(t);
192
+ const outside = join(cwd, "outside");
193
+ mkdirSync(outside);
194
+ symlinkSync(outside, join(cwd, ".git", "gentle-ai"));
195
+ assert.throws(() => new CandidateViewRegistry().create({ contributorRoot: cwd }));
196
+ assert.equal(existsSync(join(outside, "candidate-views")), false);
197
+ });
198
+
199
+ test("owner fsync failure prevents worktree registration", (t) => {
200
+ const cwd = repository(t);
201
+ let adds = 0;
202
+ t.mock.method(fs, "fsyncSync", () => { throw new Error("fixture fsync failure"); });
203
+ syncBuiltinESMExports();
204
+ t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
205
+ const registry = new CandidateViewRegistry((file, args, options) => {
206
+ if (args[0] === "worktree" && args[1] === "add") adds++;
207
+ return execFileSync(file, args, options);
208
+ });
209
+ assert.throws(() => registry.create({ contributorRoot: cwd }), /fsync/);
210
+ assert.equal(adds, 0);
211
+ });
212
+
213
+ test("ordinary cleanup preserves mismatched owner and cleanupAll continues other records", (t) => {
214
+ const cwd = repository(t);
215
+ const registry = new CandidateViewRegistry();
216
+ const first = registry.create({ contributorRoot: cwd });
217
+ const second = registry.create({ contributorRoot: cwd });
218
+ const marker = ownerMarker(first.root);
219
+ const bytes = readFileSync(marker, "utf8");
220
+ writeFileSync(marker, JSON.stringify({ ...JSON.parse(bytes), token: "00000000-0000-4000-8000-000000000000" }));
221
+ registry.cleanupAll();
222
+ assert.equal(existsSync(first.root), true);
223
+ assert.equal(existsSync(marker), true);
224
+ assert.equal(existsSync(second.root), false);
225
+ writeFileSync(marker, bytes);
226
+ registry.cleanupAll();
227
+ assert.equal(existsSync(first.root), false);
228
+ });
229
+
230
+ for (const race of ["root", "registration", "lock"] as const) {
231
+ test(`orphan reaper preserves a ${race} replacement at the Git race boundary`, (t) => {
232
+ const { cwd, view, marker } = orphanFixture(t);
233
+ mockOwnerProbe(t, "ESRCH");
234
+ let lists = 0;
235
+ const registry = new CandidateViewRegistry((file, args, options) => {
236
+ const result = execFileSync(file, args, options);
237
+ if (args.includes("list") && ++lists === 2) {
238
+ if (race === "root") { renameSync(view.root, `${view.root}.saved`); mkdirSync(view.root); }
239
+ if (race === "registration") return String(result).replace(`worktree ${view.root}`, `worktree ${view.root}.other`);
240
+ if (race === "lock") writeFileSync(`${view.root}.reaper-lock`, "replacement");
241
+ }
242
+ return result;
243
+ });
244
+ registry.sweepOrphans(cwd);
245
+ assert.equal(existsSync(view.root), true);
246
+ assert.equal(existsSync(marker), true);
247
+ if (race === "lock") assert.equal(readFileSync(`${view.root}.reaper-lock`, "utf8"), "replacement");
248
+ });
249
+ }
250
+
251
+ test("ordinary cleanup never unlinks a replaced sidecar after Git removal", (t) => {
252
+ const cwd = repository(t);
253
+ let marker = "";
254
+ const registry = new CandidateViewRegistry((file, args, options) => {
255
+ const result = execFileSync(file, args, options);
256
+ if (args[0] === "worktree" && args[1] === "remove") {
257
+ const bytes = readFileSync(marker);
258
+ renameSync(marker, `${marker}.saved`);
259
+ writeFileSync(marker, bytes, { mode: 0o600 });
260
+ }
261
+ return result;
262
+ });
263
+ const view = registry.create({ contributorRoot: cwd });
264
+ marker = ownerMarker(view.root);
265
+ assert.throws(() => view.cleanup(), /ownership changed/i);
266
+ assert.equal(existsSync(marker), true);
267
+ });
268
+
37
269
  function compactCandidateContextManifest(task: string): { encoded: string; sha256: string } {
38
270
  const match = /Frozen changed scope manifest \(gzip\+base64url\): `([A-Za-z0-9_-]+)`\.\nFrozen changed scope manifest SHA-256: `([0-9a-f]{64})`\./.exec(task);
39
271
  assert.ok(match, "expected a compact candidate context manifest");
@@ -379,6 +611,50 @@ test("candidate view preserves staged additions with explicit intended-untracked
379
611
  }
380
612
  });
381
613
 
614
+ test("candidate view detects a same-stat tracked rewrite with selected untracked content when trustctime is disabled", (t) => {
615
+ const contributorRoot = repository(t);
616
+ const tracked = join(contributorRoot, "tracked.txt");
617
+ const index = join(contributorRoot, ".git", "index");
618
+ // A fractional timestamp exposes Date-based timestamp restoration, which can
619
+ // collapse the private index onto the live index's racy-clean boundary.
620
+ const fixedTimestampSeconds = 946_684_800.654_321;
621
+ git(contributorRoot, "config", "core.trustctime", "false");
622
+ writeFileSync(tracked, "fixed\n");
623
+ utimesSync(tracked, fixedTimestampSeconds, fixedTimestampSeconds);
624
+ git(contributorRoot, "add", "tracked.txt");
625
+ git(contributorRoot, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "fixed timestamp base");
626
+ writeFileSync(tracked, "other\n");
627
+ utimesSync(tracked, fixedTimestampSeconds, fixedTimestampSeconds);
628
+ utimesSync(index, fixedTimestampSeconds, fixedTimestampSeconds);
629
+ const liveIndexMtimeNs = lstatSync(index, { bigint: true }).mtimeNs;
630
+ if (liveIndexMtimeNs % 1_000_000n === 0n) t.diagnostic("filesystem does not retain sub-millisecond index mtimes; retaining the same-stat rewrite assertion");
631
+ else assert.notEqual(liveIndexMtimeNs % 1_000_000_000n, 0n, "sub-millisecond filesystem must retain the requested non-whole-second index mtime");
632
+ writeFileSync(join(contributorRoot, "selected.txt"), "selected\n");
633
+
634
+ let privateIndexMtimeNs: bigint | undefined;
635
+ const registry = new CandidateViewRegistry((file, arguments_, options) => {
636
+ if (arguments_[0] === "add" && privateIndexMtimeNs === undefined) {
637
+ const privateIndexPath = options.env?.GIT_INDEX_FILE;
638
+ assert.equal(typeof privateIndexPath, "string", "private candidate index must be set before Git refreshes tracked entries");
639
+ privateIndexMtimeNs = lstatSync(privateIndexPath, { bigint: true }).mtimeNs;
640
+ }
641
+ return execFileSync(file, arguments_, options);
642
+ });
643
+ const view = registry.create({ contributorRoot, intendedUntracked: ["selected.txt"] });
644
+ try {
645
+ assert.notEqual(privateIndexMtimeNs, undefined, "private index timestamp must be observed before Git add");
646
+ assert.ok(privateIndexMtimeNs! < liveIndexMtimeNs, "private index must be strictly earlier than the live index");
647
+ assert.ok(liveIndexMtimeNs - privateIndexMtimeNs! >= 1_000_000_000n, "private index must leave a bounded racy-clean safety interval");
648
+ assert.deepEqual(view.intendedUntracked, ["selected.txt"]);
649
+ assert.deepEqual(view.paths, ["selected.txt", "tracked.txt"]);
650
+ assert.notEqual(view.candidateTree, view.baseTree);
651
+ assert.equal(readFileSync(join(view.root, "tracked.txt"), "utf8"), "other\n");
652
+ assert.equal(readFileSync(join(view.root, "selected.txt"), "utf8"), "selected\n");
653
+ } finally {
654
+ view.cleanup();
655
+ }
656
+ });
657
+
382
658
  test("candidate view skips a shared index that disappears during stat or copy", (t) => {
383
659
  const contributorRoot = repository(t);
384
660
  writeFileSync(join(contributorRoot, "tracked.txt"), "tracked selection\n");
@@ -490,6 +766,76 @@ test("candidate registry isolates replay, projection, current, and cleanup state
490
766
  registry.cleanupTerminal("same-lineage", "approved", rootA);
491
767
  });
492
768
 
769
+ // ga#4085 / ga#4050: after a valid `granted` consent, a retried native START
770
+ // for a lineage this controller already bound (native reports it "resumed")
771
+ // re-materializes a fresh, content-identical candidate view under a new
772
+ // token -- for example after a transport hiccup between START and the
773
+ // follow-up STATUS call. Rebinding that duplicate to the same lineage used to
774
+ // fail closed with "candidate view lineage binding is missing or ambiguous"
775
+ // even though nothing was actually ambiguous. It must reuse the already-bound
776
+ // view instead of failing the retry.
777
+ test("candidate registry rebinds a retried native START for an already-bound lineage instead of failing closed", (t) => {
778
+ const contributorRoot = repository(t);
779
+ writeFileSync(join(contributorRoot, "tracked.txt"), "candidate\n");
780
+ const registry = new CandidateViewRegistry();
781
+ t.after(() => registry.cleanupAll());
782
+ const first = registry.create({ contributorRoot });
783
+ registry.bindCurrent({ token: first.token, lineageId: "retry-lineage", selectedLenses: ["review-risk"] });
784
+ assert.equal(registry.hasCurrentBinding(contributorRoot), true);
785
+
786
+ const retry = registry.create({ contributorRoot });
787
+ assert.notEqual(retry.token, first.token, "the retry must materialize a distinct physical worktree/token");
788
+ assert.doesNotThrow(() => registry.bindCurrent({ token: retry.token, lineageId: "retry-lineage", selectedLenses: ["review-risk"] }));
789
+
790
+ // The already-bound view stays current; the redundant duplicate is discarded.
791
+ assert.equal(registry.resolveForLens("retry-lineage", "review-risk", contributorRoot).root, first.root);
792
+ assert.equal(registry.resolveCurrentForLens("review-risk", contributorRoot).root, first.root);
793
+ assert.equal(existsSync(retry.root), false, "the redundant duplicate candidate view must be cleaned up");
794
+ registry.cleanupTerminal("retry-lineage", "approved", contributorRoot);
795
+ });
796
+
797
+ test("candidate registry still fails closed rebinding the same lineage to genuinely different content", (t) => {
798
+ const contributorRoot = repository(t);
799
+ const registry = new CandidateViewRegistry();
800
+ t.after(() => registry.cleanupAll());
801
+ const first = registry.create({ contributorRoot });
802
+ registry.bindCurrent({ token: first.token, lineageId: "conflict-lineage", selectedLenses: ["review-risk"] });
803
+ writeFileSync(join(contributorRoot, "tracked.txt"), "different content\n");
804
+ const conflicting = registry.create({ contributorRoot });
805
+ assert.notEqual(conflicting.candidateTree, first.candidateTree);
806
+ assert.throws(
807
+ () => registry.bindCurrent({ token: conflicting.token, lineageId: "conflict-lineage", selectedLenses: ["review-risk"] }),
808
+ CandidateViewError,
809
+ );
810
+ registry.cleanup(conflicting.token);
811
+ registry.cleanupTerminal("conflict-lineage", "approved", contributorRoot);
812
+ });
813
+
814
+ // gentle-pi#323: `createOrReuse` reuses whatever view a replay key maps to,
815
+ // with no awareness of live candidate content -- a content-independent key
816
+ // reuses a stale view even after the candidate content it was frozen from
817
+ // has changed. The fix lives at the START call site (extensions/gentle-ai.ts),
818
+ // which now folds the current candidate tree into the replay key; this test
819
+ // documents both the registry's plain-key reuse contract and that folding
820
+ // content identity into the key produces a fresh view once content changes.
821
+ test("candidate registry reuses a replay-keyed view verbatim regardless of live content, so callers must fold content identity into the key", (t) => {
822
+ const registry = new CandidateViewRegistry();
823
+ t.after(() => registry.cleanupAll());
824
+ const contributorRoot = repository(t);
825
+ const contentIndependentKey = "cwd-and-lineage-only";
826
+ const first = registry.createOrReuse({ contributorRoot, replayKey: contentIndependentKey });
827
+ writeFileSync(join(contributorRoot, "tracked.txt"), "candidate two\n");
828
+ const staleReuse = registry.createOrReuse({ contributorRoot, replayKey: contentIndependentKey });
829
+ assert.equal(staleReuse.token, first.token, "a content-independent replay key reuses the stale view");
830
+ const freshForCurrentContent = registry.create({ contributorRoot });
831
+ assert.notEqual(staleReuse.candidateTree, freshForCurrentContent.candidateTree, "the stale reused view no longer reflects live candidate content");
832
+ const contentScopedKey = `${contentIndependentKey}${freshForCurrentContent.candidateTree}`;
833
+ const rekeyed = registry.createOrReuse({ contributorRoot, replayKey: contentScopedKey });
834
+ assert.notEqual(rekeyed.token, staleReuse.token, "folding candidate content into the replay key mints a fresh view once content changes");
835
+ assert.equal(rekeyed.candidateTree, freshForCurrentContent.candidateTree);
836
+ registry.cleanup(freshForCurrentContent.token);
837
+ });
838
+
493
839
  test("candidate registry rejects a lineage target whose authorized symlink was replaced", (t) => {
494
840
  const root = repository(t);
495
841
  const replacement = repository(t);
@@ -546,14 +892,15 @@ test("candidate view cleanup is confined and idempotent", (t) => {
546
892
  assert.equal(lstatSync(view.root, { throwIfNoEntry: false }), undefined);
547
893
  });
548
894
 
549
- test("candidate cleanup removes a readonly root when Git reports success without deleting it", (t) => {
895
+ test("candidate cleanup preserves a root when Git reports success without deleting it", (t) => {
550
896
  const registry = new CandidateViewRegistry((file, arguments_, options) =>
551
897
  arguments_[0] === "worktree" && arguments_[1] === "remove" ? "" : execFileSync(file, arguments_, options));
552
898
  const view = registry.create({ contributorRoot: repository(t) });
553
899
  assert.equal(lstatSync(view.root).mode & 0o222, 0);
554
- view.cleanup();
555
- view.cleanup();
556
- assert.equal(lstatSync(view.root, { throwIfNoEntry: false }), undefined);
900
+ assert.throws(() => view.cleanup(), /incomplete/);
901
+ assert.throws(() => view.cleanup(), /incomplete/);
902
+ assert.equal(existsSync(view.root), true);
903
+ assert.equal(existsSync(ownerMarker(view.root)), true);
557
904
  });
558
905
 
559
906
  test("corrected views stay within frozen scope and replace projections only when promoted", (t) => {
@@ -711,6 +1058,32 @@ test("candidate view verifies unchanged tree entries even when they are absent f
711
1058
  }
712
1059
  });
713
1060
 
1061
+ test("candidate executable-mode validation accepts a readonly Git executable on Windows and rejects it on POSIX", () => {
1062
+ assert.equal(hasExpectedExecutableBits(0o444, "100755", "win32"), true);
1063
+ assert.equal(hasExpectedExecutableBits(0o444, "100755", "linux"), false);
1064
+ assert.equal(hasExpectedExecutableBits(0o555, "100755", "linux"), true);
1065
+ assert.equal(hasExpectedExecutableBits(0o555, "100644", "win32"), false);
1066
+ });
1067
+
1068
+ test("candidate registry forwards explicit Windows mode validation to view verification", (t) => {
1069
+ if (process.platform === "win32") return t.skip("requires POSIX candidate-owner directory permissions unavailable on Windows");
1070
+ const contributorRoot = repository(t);
1071
+ writeFileSync(join(contributorRoot, "unchanged-executable.sh"), "#!/bin/sh\necho base\n");
1072
+ git(contributorRoot, "add", "unchanged-executable.sh");
1073
+ git(contributorRoot, "update-index", "--chmod=+x", "unchanged-executable.sh");
1074
+ chmodSync(join(contributorRoot, "unchanged-executable.sh"), 0o755);
1075
+ git(contributorRoot, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "executable base");
1076
+ writeFileSync(join(contributorRoot, "tracked.txt"), "changed\n");
1077
+ const view = new CandidateViewRegistry(undefined, "win32").createOrReuse({ contributorRoot, baseRef: "HEAD" });
1078
+ try {
1079
+ assert.deepEqual(view.paths, ["tracked.txt"]);
1080
+ chmodSync(view.root, 0o755); chmodSync(join(view.root, "unchanged-executable.sh"), 0o444); chmodSync(view.root, 0o555);
1081
+ assert.doesNotThrow(() => view.verify());
1082
+ } finally {
1083
+ view.cleanup();
1084
+ }
1085
+ });
1086
+
714
1087
  test("candidate view compacts an oversized non-ASCII scope losslessly and deterministically", (t) => {
715
1088
  const contributorRoot = repository(t);
716
1089
  writeFileSync(join(contributorRoot, "deleted.txt"), "delete me\n");
@@ -1925,3 +2298,76 @@ test("candidate view cleans up a partially registered unborn fallback worktree w
1925
2298
  const leftover = existsSync(parent) ? readdirSync(parent).filter((entry) => lstatSync(join(parent, entry)).isDirectory()) : [];
1926
2299
  assert.deepEqual(leftover, [], "no candidate directory remains after a partial unborn fallback failure");
1927
2300
  });
2301
+
2302
+ function stagedFinalizeDescriptor(cwd: string, path: string, content: string): NativeCandidateProjectionDescriptor {
2303
+ writeFileSync(join(cwd, path), content);
2304
+ git(cwd, "add", path);
2305
+ const baseTree = git(cwd, "rev-parse", "HEAD^{tree}");
2306
+ const currentCandidateTree = git(cwd, "write-tree");
2307
+ return { baseTree, currentCandidateTree, paths: [path], intendedUntracked: [], projection: "staged" };
2308
+ }
2309
+
2310
+ function candidateViewWorktreeCount(cwd: string): number {
2311
+ return git(cwd, "worktree", "list").split("\n").filter((line) => line.includes("candidate-views")).length;
2312
+ }
2313
+
2314
+ test("gentle-pi#185: a forecast-restored FINALIZE worktree does not leak across the next restore for the same lineage", (t) => {
2315
+ // gentle-pi#185: `restoreForFinalizeFromNative` materializes a real Git
2316
+ // worktree to restore a lineage's FINALIZE binding from the native frozen
2317
+ // projection. A forecast-only FINALIZE after a process restart uses this
2318
+ // exact path, but its outcome is non-terminal, so `cleanupTerminal` (which
2319
+ // only fires for "approved"/"escalated") never runs for it. Before the fix,
2320
+ // `restoreForFinalizeFromNative` never cleared the stale binding it had
2321
+ // just created, so restoring the same lineage's FINALIZE binding again
2322
+ // (the lineage's later approved FINALIZE, or another restart-time
2323
+ // forecast) threw "native frozen projection is invalid or already
2324
+ // restored" from `restoreProjectionFromNative` — leaving the first
2325
+ // worktree registered forever and breaking the very finalize that should
2326
+ // have succeeded.
2327
+ const cwd = repository(t);
2328
+ const registry = new CandidateViewRegistry();
2329
+ const lineageId = "forecast-lineage-185";
2330
+ const descriptor = stagedFinalizeDescriptor(cwd, "tracked.txt", "forecast change\n");
2331
+
2332
+ assert.equal(candidateViewWorktreeCount(cwd), 0, "no candidate worktree exists before any restore");
2333
+
2334
+ // Reproduces the leak symptom: a forecast-only FINALIZE restore
2335
+ // materializes a worktree that is still registered with Git once the
2336
+ // (non-terminal) forecast finalize returns.
2337
+ registry.restoreForFinalizeFromNative(lineageId, cwd, descriptor);
2338
+ assert.equal(candidateViewWorktreeCount(cwd), 1, "the forecast restore leaves its worktree registered with Git after it returns");
2339
+
2340
+ // A later restore for the same lineage (its approved FINALIZE, or another
2341
+ // restart-time forecast) must clean up the stale worktree and replace it
2342
+ // deterministically instead of throwing or leaking a parallel sibling.
2343
+ assert.doesNotThrow(
2344
+ () => registry.restoreForFinalizeFromNative(lineageId, cwd, descriptor),
2345
+ "restoring the same lineage's FINALIZE binding again must not fail on the stale binding it left behind",
2346
+ );
2347
+ assert.equal(candidateViewWorktreeCount(cwd), 1, "the second restore must replace the first worktree, never accumulate a parallel sibling");
2348
+
2349
+ // The subsequent approved FINALIZE's terminal cleanup must still work for
2350
+ // the worktree that is actually registered after the restore.
2351
+ registry.cleanupTerminal(lineageId, "approved", cwd);
2352
+ assert.equal(candidateViewWorktreeCount(cwd), 0, "approved cleanup removes the restored worktree, leaving no candidate worktree registered");
2353
+ });
2354
+
2355
+ test("gentle-pi#185 review correction: a restore whose materialization fails leaves the previous binding intact", (t) => {
2356
+ const cwd = repository(t);
2357
+ const lineageId = "forecast-lineage-185-rollback";
2358
+ const descriptor = stagedFinalizeDescriptor(cwd, "tracked.txt", "forecast change\n");
2359
+ let worktreeAdds = 0;
2360
+ const executor: CandidateGitExecutor = (file, args, options) => {
2361
+ if (args[0] === "worktree" && args[1] === "add" && ++worktreeAdds === 2) throw Object.assign(new Error("simulated worktree add failure"), { status: 128 });
2362
+ return execFileSync(file, args, options);
2363
+ };
2364
+ const registry = new CandidateViewRegistry(executor);
2365
+ const first = registry.restoreForFinalizeFromNative(lineageId, cwd, descriptor);
2366
+ assert.equal(candidateViewWorktreeCount(cwd), 1);
2367
+ let failure: unknown;
2368
+ try { registry.restoreForFinalizeFromNative(lineageId, cwd, descriptor); } catch (error) { failure = error; }
2369
+ assert.equal((failure as CandidateViewError)?.reason, "candidate-view-git-failure", "a failed materialization must propagate, not silently succeed");
2370
+ assert.equal(candidateViewWorktreeCount(cwd), 1, "the previously registered worktree must survive the failed restore");
2371
+ assert.equal(registry.resolveForFinalize(lineageId, cwd).token, first.token, "the lineage must still resolve to its original worktree");
2372
+ registry.cleanupTerminal(lineageId, "approved", cwd);
2373
+ });