gentle-pi 3.7.0 → 4.0.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 (316) hide show
  1. package/README.md +37 -6
  2. package/assets/agents/gentle-ai-explore.md +4 -4
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -9
  5. package/assets/orchestrator-delegation.md +40 -41
  6. package/assets/orchestrator-memory.md +1 -22
  7. package/assets/orchestrator-skills.md +1 -1
  8. package/assets/orchestrator.md +12 -24
  9. package/assets/support/strict-tdd-verify.md +4 -266
  10. package/assets/support/strict-tdd.md +8 -360
  11. package/bin/gentle-shell.mjs +254 -29
  12. package/docs/delegated-verification.md +26 -1
  13. package/docs/gentle-agents-activity.md +24 -0
  14. package/docs/gentle-shell.md +92 -28
  15. package/docs/native-authority-architecture.md +2 -2
  16. package/docs/prompt-history.md +280 -0
  17. package/docs/readme-reference.md +112 -206
  18. package/docs/telemetry.md +1 -1
  19. package/docs/yolo-mode.md +86 -0
  20. package/extensions/child-context.ts +26 -0
  21. package/extensions/child-safety.ts +23 -0
  22. package/extensions/gentle-agents.ts +429 -262
  23. package/extensions/gentle-ai.ts +781 -683
  24. package/extensions/gentle-shell.ts +1375 -88
  25. package/extensions/gentle-stats.ts +101 -0
  26. package/extensions/gentle-todo.ts +18 -7
  27. package/extensions/history/atomic-write.ts +38 -0
  28. package/extensions/history/hide-prompts.ts +183 -0
  29. package/extensions/history/index.ts +1419 -0
  30. package/extensions/history/load-shared-history.ts +39 -0
  31. package/extensions/history/selector-helpers.ts +538 -0
  32. package/extensions/history/session-scan.ts +233 -0
  33. package/extensions/history/store.ts +1119 -0
  34. package/extensions/nan-provider.ts +6 -0
  35. package/extensions/quiet-tools.ts +179 -87
  36. package/extensions/resume-hint.ts +60 -0
  37. package/extensions/skill-registry.ts +16 -12
  38. package/extensions/startup-banner.ts +60 -31
  39. package/lib/agent-assets.ts +604 -0
  40. package/lib/agent-profile-pin.ts +12 -0
  41. package/lib/agents-message-delivery.ts +181 -0
  42. package/lib/agents-protocol.ts +60 -0
  43. package/lib/agents-runner.ts +96 -98
  44. package/lib/agents-view.ts +26 -3
  45. package/lib/agents-widget.ts +16 -9
  46. package/lib/append-system-prompt.ts +21 -0
  47. package/lib/bounded-writer-admission.ts +147 -0
  48. package/lib/card-style-policy.ts +60 -0
  49. package/lib/child-context-files.ts +166 -0
  50. package/lib/codemode-renderer.ts +185 -0
  51. package/lib/command-palette-catalog.ts +3 -9
  52. package/lib/command-palette.ts +25 -14
  53. package/lib/destructive-command-guard.ts +144 -0
  54. package/lib/gentle-ai-elapsed-store.ts +87 -0
  55. package/lib/gentle-ai-renderer.ts +196 -39
  56. package/lib/gentle-shell-launcher.ts +24 -13
  57. package/lib/gentle-shell-resume-hint.ts +176 -0
  58. package/lib/history-capture-policy.ts +95 -0
  59. package/lib/model-routing-authority.ts +5 -1
  60. package/lib/nan-provider.ts +227 -0
  61. package/lib/native-review-cli.ts +49 -108
  62. package/lib/odd-phase-inference.ts +231 -0
  63. package/lib/odd-phase.ts +141 -0
  64. package/lib/overlay-repaint.ts +26 -0
  65. package/lib/pi-tui-keys.ts +53 -0
  66. package/lib/review-candidate-view-owner.ts +67 -17
  67. package/lib/review-candidate-view.ts +112 -25
  68. package/lib/review-reminder-receipt.ts +48 -8
  69. package/lib/review-risk-assessment.ts +156 -11
  70. package/lib/review-sidebar-state.ts +223 -0
  71. package/lib/selection-engine.ts +515 -0
  72. package/lib/session-messaging-grants.ts +135 -0
  73. package/lib/session-worktree-registry.ts +14 -2
  74. package/lib/shell-bar.ts +179 -24
  75. package/lib/shell-card.ts +287 -18
  76. package/lib/shell-changes-view.ts +2 -1
  77. package/lib/shell-prompt.ts +102 -6
  78. package/lib/shell-sidebar-layout.ts +78 -14
  79. package/lib/shell-sidebar.ts +15 -1
  80. package/lib/shell-todo.ts +23 -13
  81. package/lib/shell-usage-view.ts +9 -4
  82. package/lib/shell-usage.ts +66 -10
  83. package/lib/stats-collector.ts +381 -0
  84. package/lib/stats-view.ts +431 -0
  85. package/lib/theme-customization.ts +52 -0
  86. package/lib/vim-editor-adapter.ts +379 -0
  87. package/lib/vim-normal-engine.ts +154 -0
  88. package/lib/vim-operator-engine.ts +416 -0
  89. package/lib/vim-policy.ts +49 -0
  90. package/lib/vim-visual-engine.ts +107 -0
  91. package/lib/visual-customization-policy.ts +108 -0
  92. package/lib/visual-customize-view.ts +330 -0
  93. package/lib/visual-profiles.ts +228 -0
  94. package/lib/yolo-session-policy.ts +240 -0
  95. package/package.json +20 -8
  96. package/runtime/gentle-shell-launcher.mjs +23 -12
  97. package/runtime/gentle-shell-resume-hint.mjs +177 -0
  98. package/runtime/native-review-cli.mjs +49 -108
  99. package/runtime/review-risk-assessment.mjs +154 -9
  100. package/scripts/build-runtime-modules.mjs +1 -0
  101. package/scripts/gentle-ai-installer.mjs +14 -13
  102. package/scripts/mirror-odd-routing.mjs +2 -2
  103. package/scripts/run-test-suite.mjs +76 -0
  104. package/scripts/test-packed-runner.mjs +31 -14
  105. package/scripts/verify-package-files.mjs +11 -22
  106. package/skills/branch-pr/SKILL.md +24 -52
  107. package/skills/chained-pr/SKILL.md +31 -15
  108. package/skills/chained-pr/references/chaining-details.md +31 -20
  109. package/skills/gentle-ai/SKILL.md +9 -15
  110. package/skills/issue-creation/SKILL.md +8 -2
  111. package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
  112. package/skills/work-unit-commits/SKILL.md +4 -3
  113. package/tests/agent-profiles.test.ts +18 -0
  114. package/tests/agents-fake-child.ts +2 -2
  115. package/tests/agents-message-delivery.test.ts +106 -0
  116. package/tests/agents-protocol.test.ts +40 -0
  117. package/tests/agents-runner.test.ts +378 -89
  118. package/tests/agents-view-thread-identity.test.ts +169 -0
  119. package/tests/agents-view.test.ts +8 -2
  120. package/tests/agents-widget.test.ts +154 -15
  121. package/tests/append-system-prompt-route.test.ts +160 -0
  122. package/tests/append-system-prompt.test.ts +46 -0
  123. package/tests/artifact-language.test.ts +19 -213
  124. package/tests/ask-user-question.test.ts +44 -1
  125. package/tests/asset-installation-runtime.test.ts +5 -16
  126. package/tests/autonomous-guard.test.ts +69 -1
  127. package/tests/bounded-writer-admission.test.ts +95 -0
  128. package/tests/branch-pr-skill.test.ts +43 -0
  129. package/tests/card-style-policy.test.ts +55 -0
  130. package/tests/chained-pr-skill.test.ts +124 -0
  131. package/tests/child-context-files.test.ts +255 -0
  132. package/tests/child-safety.test.ts +82 -0
  133. package/tests/codemode-rendering.test.ts +491 -0
  134. package/tests/command-palette.test.ts +39 -3
  135. package/tests/delegated-key-learnings-contract.test.ts +0 -76
  136. package/tests/destructive-command-guard.test.ts +84 -0
  137. package/tests/devbinary/native-review-parity.devtest.ts +170 -2
  138. package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
  139. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
  140. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
  141. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
  142. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
  143. package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
  144. package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
  145. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
  146. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
  147. package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
  148. package/tests/generic-agent-tools.test.ts +54 -0
  149. package/tests/gentle-agents.test.ts +1047 -252
  150. package/tests/gentle-ai-binary.test.ts +3 -3
  151. package/tests/gentle-ai-elapsed-store.test.ts +68 -0
  152. package/tests/gentle-ai-installer.test.ts +68 -54
  153. package/tests/gentle-ai-renderer.test.ts +487 -8
  154. package/tests/gentle-ai.test.ts +288 -65
  155. package/tests/gentle-card-text.ts +2 -1
  156. package/tests/gentle-shell-bin.test.ts +651 -114
  157. package/tests/gentle-shell-launcher.test.ts +99 -52
  158. package/tests/gentle-shell-resume-hint.test.ts +270 -0
  159. package/tests/gentle-shell.test.ts +3839 -187
  160. package/tests/gentle-stats.test.ts +152 -0
  161. package/tests/gentle-theme.test.ts +4 -1
  162. package/tests/gentle-todo.test.ts +80 -8
  163. package/tests/history-atomic-write.test.ts +57 -0
  164. package/tests/history-capture-policy.test.ts +102 -0
  165. package/tests/history-command-registration.test.ts +164 -0
  166. package/tests/history-dedupe-entries.test.ts +123 -0
  167. package/tests/history-delete-backfill.test.ts +190 -0
  168. package/tests/history-delete-confirm.test.ts +460 -0
  169. package/tests/history-dispatch.test.ts +180 -0
  170. package/tests/history-drain-hidden.test.ts +110 -0
  171. package/tests/history-drain-order.test.ts +98 -0
  172. package/tests/history-expanded-globals.test.ts +62 -0
  173. package/tests/history-gc.test.ts +832 -0
  174. package/tests/history-header-layout.test.ts +265 -0
  175. package/tests/history-hide-prompts.test.ts +275 -0
  176. package/tests/history-lazy-windowing.test.ts +508 -0
  177. package/tests/history-legacy-migrate-v2.test.ts +297 -0
  178. package/tests/history-load-shared-history.test.ts +53 -0
  179. package/tests/history-max-results-cap.test.ts +76 -0
  180. package/tests/history-multi-reader.test.ts +203 -0
  181. package/tests/history-off-path.test.ts +170 -0
  182. package/tests/history-openflow-integration.test.ts +173 -0
  183. package/tests/history-overlay-margin.test.ts +326 -0
  184. package/tests/history-preview-layout.test.ts +93 -0
  185. package/tests/history-registry.test.ts +143 -0
  186. package/tests/history-scope-delete.test.ts +411 -0
  187. package/tests/history-search-caret-keys.test.ts +142 -0
  188. package/tests/history-seed-bootstrap.test.ts +170 -0
  189. package/tests/history-seed-regen.test.ts +129 -0
  190. package/tests/history-selector-windowing.test.ts +94 -0
  191. package/tests/history-session-scan-directory.test.ts +87 -0
  192. package/tests/history-session-scan-extract.test.ts +583 -0
  193. package/tests/history-session-writer.test.ts +351 -0
  194. package/tests/history-store-paths.test.ts +79 -0
  195. package/tests/history-tombstone-exact.test.ts +139 -0
  196. package/tests/history-wheel-mouse.test.ts +242 -0
  197. package/tests/inprocess-reviewer.test.ts +29 -19
  198. package/tests/issue-creation-skill.test.ts +61 -0
  199. package/tests/model-routing-authority.test.ts +16 -0
  200. package/tests/nan-provider.test.ts +471 -0
  201. package/tests/native-review-capability-contract.test.ts +7 -1
  202. package/tests/native-review-cli.test.ts +6 -120
  203. package/tests/native-review-parity-runtime.test.ts +100 -3
  204. package/tests/odd-integration.test.ts +67 -0
  205. package/tests/odd-phase-inference.test.ts +213 -0
  206. package/tests/odd-phase-loader.test.ts +253 -0
  207. package/tests/odd-phase.test.ts +307 -0
  208. package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
  209. package/tests/odd-routing-contract.test.ts +86 -35
  210. package/tests/orchestrator-budget.test.ts +14 -39
  211. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  212. package/tests/overlay-repaint.test.ts +74 -0
  213. package/tests/package-manifest.test.ts +252 -115
  214. package/tests/packed-runner-owned-path.test.ts +46 -0
  215. package/tests/persona-single-channel.test.ts +6 -6
  216. package/tests/provider-defect-handoff.test.ts +3 -11
  217. package/tests/quiet-bash-runtime.test.ts +76 -0
  218. package/tests/quiet-tool-rendering.test.ts +409 -184
  219. package/tests/rdd-aware-verification-contract.test.ts +76 -1
  220. package/tests/rdd-status-line.test.ts +9 -4
  221. package/tests/resume-hint-extension.test.ts +122 -0
  222. package/tests/review-agent-end-preflight.test.ts +176 -12
  223. package/tests/review-candidate-owner-retry.test.ts +22 -1
  224. package/tests/review-candidate-view.test.ts +298 -0
  225. package/tests/review-contract-prompt.test.ts +108 -43
  226. package/tests/review-controller-lock-status.test.ts +0 -1
  227. package/tests/review-controller-native-routing.test.ts +611 -5
  228. package/tests/review-controller-workspace-root.test.ts +163 -4
  229. package/tests/review-host-relay-routing.test.ts +338 -2
  230. package/tests/review-integration-v2-forward.test.ts +200 -0
  231. package/tests/review-ledger-contract.test.ts +10 -34
  232. package/tests/review-reminder-receipt.test.ts +47 -1
  233. package/tests/review-risk-assessment.test.ts +498 -6
  234. package/tests/review-sidebar-state.test.ts +402 -0
  235. package/tests/run-test-suite.test.ts +124 -0
  236. package/tests/runtime-harness.mjs +145 -786
  237. package/tests/runtime-metrics-children.test.ts +16 -23
  238. package/tests/selection-engine.test.ts +421 -0
  239. package/tests/session-messaging-grants.test.ts +255 -0
  240. package/tests/session-worktree-registry.test.ts +77 -0
  241. package/tests/shell-bar.test.ts +382 -1
  242. package/tests/shell-card.test.ts +353 -1
  243. package/tests/shell-changes-view.test.ts +52 -0
  244. package/tests/shell-prompt.test.ts +94 -2
  245. package/tests/shell-sidebar-layout.test.ts +325 -21
  246. package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
  247. package/tests/shell-todo.test.ts +87 -1
  248. package/tests/shell-usage-view.test.ts +27 -0
  249. package/tests/shell-usage.test.ts +73 -0
  250. package/tests/skill-registry.test.ts +50 -1
  251. package/tests/startup-banner.test.ts +130 -2
  252. package/tests/stats-collector.test.ts +195 -0
  253. package/tests/stats-view.test.ts +202 -0
  254. package/tests/telemetry-trigger.test.ts +81 -20
  255. package/tests/theme-customization.test.ts +72 -0
  256. package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
  257. package/tests/vim-editor-adapter.test.ts +804 -0
  258. package/tests/vim-normal-engine.test.ts +101 -0
  259. package/tests/vim-operator-engine.test.ts +215 -0
  260. package/tests/vim-policy.test.ts +19 -0
  261. package/tests/vim-visual-engine.test.ts +52 -0
  262. package/tests/visual-customization-policy.test.ts +110 -0
  263. package/tests/visual-customize-view.test.ts +418 -0
  264. package/tests/visual-profiles.test.ts +87 -0
  265. package/tests/yolo-customize.test.ts +256 -0
  266. package/tests/yolo-mode-runtime.test.ts +161 -0
  267. package/tests/yolo-mode.test.ts +261 -0
  268. package/tests/yolo-session-policy.test.ts +59 -0
  269. package/themes/Gentle.json +2 -1
  270. package/themes/Gentleman-Cute.json +2 -1
  271. package/themes/Gentleman-Sexy.json +2 -1
  272. package/assets/agents/sdd-apply.md +0 -159
  273. package/assets/agents/sdd-archive.md +0 -228
  274. package/assets/agents/sdd-design.md +0 -49
  275. package/assets/agents/sdd-explore.md +0 -48
  276. package/assets/agents/sdd-init.md +0 -56
  277. package/assets/agents/sdd-onboard.md +0 -52
  278. package/assets/agents/sdd-proposal.md +0 -64
  279. package/assets/agents/sdd-remediate.md +0 -37
  280. package/assets/agents/sdd-research.md +0 -49
  281. package/assets/agents/sdd-spec.md +0 -192
  282. package/assets/agents/sdd-status.md +0 -54
  283. package/assets/agents/sdd-tasks.md +0 -108
  284. package/assets/agents/sdd-verify.md +0 -124
  285. package/assets/chains/sdd-full.chain.md +0 -83
  286. package/assets/chains/sdd-plan.chain.md +0 -56
  287. package/assets/chains/sdd-verify.chain.md +0 -43
  288. package/assets/sdd-orchestrator-workflow.md +0 -319
  289. package/assets/support/sdd-status-contract.md +0 -77
  290. package/docs/assets/diagrams/sdd-cycle.svg +0 -14
  291. package/extensions/sdd-init.ts +0 -816
  292. package/lib/openspec-deltas.ts +0 -156
  293. package/lib/sdd-preflight.ts +0 -1066
  294. package/lib/sdd-research-capabilities.ts +0 -94
  295. package/lib/sdd-status.ts +0 -26
  296. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
  297. package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
  298. package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
  299. package/tests/openspec-deltas.test.ts +0 -209
  300. package/tests/sdd-agent-tools.test.ts +0 -156
  301. package/tests/sdd-archive-replay.test.ts +0 -82
  302. package/tests/sdd-classical-continuation.test.ts +0 -74
  303. package/tests/sdd-execution-routing-contract.test.ts +0 -44
  304. package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
  305. package/tests/sdd-native-managed-uptake.test.ts +0 -243
  306. package/tests/sdd-no-attempts-contract.test.ts +0 -15
  307. package/tests/sdd-odd-integration.test.ts +0 -33
  308. package/tests/sdd-optional-research.test.ts +0 -124
  309. package/tests/sdd-planning-routing-contract.test.ts +0 -45
  310. package/tests/sdd-preflight-rpc-input.test.ts +0 -125
  311. package/tests/sdd-preflight.test.ts +0 -541
  312. package/tests/sdd-research-capabilities.test.ts +0 -114
  313. package/tests/sdd-research-live.test.ts +0 -241
  314. package/tests/sdd-selection-transport.test.ts +0 -653
  315. package/tests/sdd-status.test.ts +0 -9
  316. package/tests/sdd-task-truth.test.ts +0 -43
@@ -364,6 +364,164 @@ test("candidate owner publication never removes a replaced marker", (t) => {
364
364
  assert.equal(readdirSync(parent).some((name) => name.endsWith(".reaper-lock")), false);
365
365
  });
366
366
 
367
+ test("public POSIX candidate-views parent reports bounded privacy guidance without changing permissions or adding a worktree", { skip: process.platform === "win32" }, (t) => {
368
+ const cwd = repository(t);
369
+ const parent = join(cwd, ".git", "gentle-ai", "candidate-views");
370
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
371
+ chmodSync(parent, 0o777);
372
+ let adds = 0;
373
+ const registry = new CandidateViewRegistry((file, args, options) => {
374
+ if (args[0] === "worktree" && args[1] === "add") adds++;
375
+ return execFileSync(file, args, options);
376
+ });
377
+ assert.throws(() => registry.create({ contributorRoot: cwd }), (error: unknown) => {
378
+ assert.ok(error instanceof CandidateViewError);
379
+ assert.equal(error.reason, "candidate-owner-preparation-failed");
380
+ assert.deepEqual(error.diagnostics, {
381
+ code: "candidate-owner-parent-privacy",
382
+ message: "candidate-views parent must be owned by the current user and inaccessible to group and others; inspect its ownership and permissions, then correct them out of band before retrying START",
383
+ });
384
+ assert.doesNotMatch(JSON.stringify(error.diagnostics), new RegExp(cwd));
385
+ return true;
386
+ });
387
+ assert.equal(lstatSync(parent).mode & 0o777, 0o777);
388
+ assert.equal(adds, 0);
389
+ chmodSync(parent, 0o700);
390
+ const view = registry.create({ contributorRoot: cwd });
391
+ view.cleanup();
392
+ });
393
+
394
+ test("POSIX chmod-ineffective parent reports filesystem capability guidance before worktree creation", { skip: process.platform === "win32" }, (t) => {
395
+ const cwd = repository(t);
396
+ const parent = join(cwd, ".git", "gentle-ai", "candidate-views");
397
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
398
+ chmodSync(parent, 0o777);
399
+ const originalLstat = fs.lstatSync;
400
+ t.mock.method(fs, "lstatSync", (path: Parameters<typeof fs.lstatSync>[0], ...args: unknown[]) => {
401
+ const stat = (originalLstat as (...arguments_: unknown[]) => ReturnType<typeof fs.lstatSync>)(path, ...args);
402
+ return typeof path === "string" && path.includes(".gentle-ai-chmod-probe-")
403
+ ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, { mode: (Number(stat.mode) & ~0o777) | 0o777, uid: process.getuid?.() })
404
+ : stat;
405
+ });
406
+ syncBuiltinESMExports();
407
+ t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
408
+ let adds = 0;
409
+ const registry = new CandidateViewRegistry((file, args, options) => {
410
+ if (args[0] === "worktree" && args[1] === "add") adds++;
411
+ return execFileSync(file, args, options);
412
+ });
413
+ assert.throws(() => registry.create({ contributorRoot: cwd }), (error: unknown) => {
414
+ assert.ok(error instanceof CandidateViewError);
415
+ assert.equal(error.reason, "candidate-owner-preparation-failed");
416
+ assert.deepEqual(error.diagnostics, {
417
+ code: "candidate-owner-parent-chmod-ineffective",
418
+ message: "candidate-views parent filesystem does not honor POSIX permission changes; move this repository's Git common directory to a POSIX-metadata filesystem or enable metadata support before retrying START",
419
+ });
420
+ assert.doesNotMatch(JSON.stringify(error.diagnostics), new RegExp(cwd));
421
+ return true;
422
+ });
423
+ assert.equal(adds, 0);
424
+ assert.equal(readdirSync(parent).some((name) => name.includes(".gentle-ai-chmod-probe-")), false);
425
+ });
426
+
427
+ test("inconclusive private-mode probe keeps bounded privacy guidance instead of claiming ineffective chmod", { skip: process.platform === "win32" }, (t) => {
428
+ const cwd = repository(t);
429
+ const parent = join(cwd, ".git", "gentle-ai", "candidate-views");
430
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
431
+ chmodSync(parent, 0o777);
432
+ const originalMkdir = fs.mkdirSync;
433
+ t.mock.method(fs, "mkdirSync", (path: Parameters<typeof fs.mkdirSync>[0], ...args: unknown[]) => {
434
+ if (typeof path === "string" && path.includes(".gentle-ai-chmod-probe-")) throw Object.assign(new Error("probe creation refused"), { code: "EACCES" });
435
+ return (originalMkdir as (...arguments_: unknown[]) => ReturnType<typeof fs.mkdirSync>)(path, ...args);
436
+ });
437
+ syncBuiltinESMExports();
438
+ t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
439
+ let adds = 0;
440
+ const registry = new CandidateViewRegistry((file, args, options) => {
441
+ if (args[0] === "worktree" && args[1] === "add") adds++;
442
+ return execFileSync(file, args, options);
443
+ });
444
+ assert.throws(() => registry.create({ contributorRoot: cwd }), (error: unknown) => {
445
+ assert.ok(error instanceof CandidateViewError);
446
+ assert.equal(error.reason, "candidate-owner-preparation-failed");
447
+ assert.deepEqual(error.diagnostics, {
448
+ code: "candidate-owner-parent-privacy",
449
+ message: "candidate-views parent must be owned by the current user and inaccessible to group and others; inspect its ownership and permissions, then correct them out of band before retrying START",
450
+ });
451
+ assert.doesNotMatch(JSON.stringify(error.diagnostics), new RegExp(cwd));
452
+ return true;
453
+ });
454
+ assert.equal(adds, 0);
455
+ assert.equal(readdirSync(parent).some((name) => name.includes(".gentle-ai-chmod-probe-")), false);
456
+ chmodSync(parent, 0o700);
457
+ const view = registry.create({ contributorRoot: cwd });
458
+ view.cleanup();
459
+ });
460
+
461
+ test("POSIX parent privacy classification does not probe a replaced path after validating it", { skip: process.platform === "win32" }, (t) => {
462
+ const cwd = repository(t);
463
+ const commonDir = realpathSync(join(cwd, ".git"));
464
+ const parent = join(commonDir, "gentle-ai", "candidate-views");
465
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
466
+ const originalLstat = fs.lstatSync;
467
+ let parentProbes = 0;
468
+ t.mock.method(fs, "lstatSync", (path: Parameters<typeof fs.lstatSync>[0], ...args: unknown[]) => {
469
+ if (path === parent && ++parentProbes === 2) {
470
+ const moved = `${parent}-moved`;
471
+ renameSync(parent, moved);
472
+ symlinkSync(moved, parent, "dir");
473
+ }
474
+ return (originalLstat as (...args: unknown[]) => ReturnType<typeof fs.lstatSync>)(path, ...args);
475
+ });
476
+ syncBuiltinESMExports();
477
+ t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
478
+ let failure: unknown;
479
+ try { assertCandidateOwnerParent(commonDir); } catch (error) { failure = error; }
480
+ assert.equal(parentProbes, 1, "the privacy decision must share the checked directory probe");
481
+ assert.equal(failure, undefined);
482
+ });
483
+
484
+ test("POSIX non-directory and symlink parents never receive privacy guidance", { skip: process.platform === "win32" }, (t) => {
485
+ const cwd = repository(t);
486
+ const commonDir = realpathSync(join(cwd, ".git"));
487
+ const control = join(commonDir, "gentle-ai");
488
+ const parent = join(control, "candidate-views");
489
+ mkdirSync(control, { recursive: true, mode: 0o700 });
490
+ for (const kind of ["file", "symlink"] as const) {
491
+ if (kind === "file") writeFileSync(parent, "not a directory");
492
+ else symlinkSync(control, parent, "dir");
493
+ assert.throws(() => assertCandidateOwnerParent(commonDir), (error: unknown) => {
494
+ assert.notEqual((error as Error).name, "PosixCandidateOwnerParentPrivacyError");
495
+ return true;
496
+ });
497
+ rmSync(parent);
498
+ }
499
+ });
500
+
501
+ test("owner privacy diagnostic sanitizer rejects injected text", () => {
502
+ for (const code of ["candidate-owner-parent-privacy", "candidate-owner-parent-chmod-ineffective"] as const) {
503
+ const forged = new CandidateViewError("rejected", "candidate-owner-preparation-failed", {
504
+ code,
505
+ message: "private-fixture-path-and-user",
506
+ } as unknown as ConstructorParameters<typeof CandidateViewError>[2]);
507
+ assert.equal(forged.diagnostics, undefined);
508
+ }
509
+ });
510
+
511
+ test("unknown owner failures never expose arbitrary messages in diagnostics", (t) => {
512
+ const cwd = repository(t);
513
+ const secret = "private-fixture-path-and-user";
514
+ t.mock.method(fs, "fsyncSync", () => { throw new Error(secret); });
515
+ syncBuiltinESMExports();
516
+ t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
517
+ assert.throws(() => new CandidateViewRegistry().create({ contributorRoot: cwd }), (error: unknown) => {
518
+ assert.ok(error instanceof CandidateViewError);
519
+ assert.equal(error.diagnostics, undefined);
520
+ assert.doesNotMatch(error.message, /private-fixture-path-and-user/);
521
+ return true;
522
+ });
523
+ });
524
+
367
525
  test("candidate owner rejects an absent POSIX getuid before worktree creation", { skip: process.platform === "win32" }, (t) => {
368
526
  const originalGetuid = process.getuid;
369
527
  Object.defineProperty(process, "getuid", { configurable: true, value: undefined });
@@ -1619,6 +1777,100 @@ test("candidate view accepts internal relative symlink targets and rejects unsaf
1619
1777
  }
1620
1778
  });
1621
1779
 
1780
+ test("unchanged committed symlinks materialize as links with core.symlinks disabled or enabled", (t) => {
1781
+ const contributorRoot = repository(t);
1782
+ mkdirSync(join(contributorRoot, "nested"));
1783
+ mkdirSync(join(contributorRoot, "target-dir"));
1784
+ writeFileSync(join(contributorRoot, "target-dir", "file.txt"), "frozen target\n");
1785
+ try {
1786
+ symlinkSync("../target-dir/file.txt", join(contributorRoot, "nested", "link"));
1787
+ symlinkSync("../target-dir", join(contributorRoot, "nested", "directory-link"), "dir");
1788
+ symlinkSync("../missing-file", join(contributorRoot, "nested", "dangling-link"));
1789
+ }
1790
+ catch { t.skip("native symlink creation unavailable"); return; }
1791
+ git(contributorRoot, "add", "nested/link", "nested/directory-link", "nested/dangling-link", "target-dir/file.txt");
1792
+ git(contributorRoot, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "link base");
1793
+ for (const setting of ["false", "true"]) {
1794
+ git(contributorRoot, "config", "core.symlinks", setting);
1795
+ writeFileSync(join(contributorRoot, "tracked.txt"), `changed ${setting}\n`);
1796
+ const view = createCandidateView({ contributorRoot });
1797
+ try {
1798
+ assert.deepEqual(view.paths, ["tracked.txt"]);
1799
+ assert.equal(lstatSync(join(view.root, "nested", "link")).isSymbolicLink(), true);
1800
+ assert.equal(readFileSync(join(view.root, "nested", "link"), "utf8"), "frozen target\n");
1801
+ assert.equal(readFileSync(join(view.root, "nested", "directory-link", "file.txt"), "utf8"), "frozen target\n");
1802
+ assert.equal(lstatSync(join(view.root, "nested", "dangling-link")).isSymbolicLink(), true);
1803
+ assert.equal(readFileSync(join(view.root, "tracked.txt"), "utf8"), `changed ${setting}\n`);
1804
+ assert.equal(git(contributorRoot, "config", "core.symlinks"), setting);
1805
+ view.verify();
1806
+ } finally { view.cleanup(); }
1807
+ }
1808
+ });
1809
+
1810
+ test("frozen symlink bytes survive disabled checkout, while replaced links and regular files fail verification", (t) => {
1811
+ const cwd = repository(t);
1812
+ try { symlinkSync("tracked.txt", join(cwd, "alias")); }
1813
+ catch { t.skip("native symlink creation unavailable"); return; }
1814
+ git(cwd, "add", "alias");
1815
+ git(cwd, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "link base");
1816
+ git(cwd, "config", "core.symlinks", "false");
1817
+ writeFileSync(join(cwd, "tracked.txt"), "changed\n");
1818
+ const view = createCandidateView({ contributorRoot: cwd });
1819
+ try {
1820
+ assert.deepEqual(readFileSync(join(view.root, "alias"), "utf8"), "changed\n");
1821
+ assert.equal(git(cwd, "cat-file", "blob", git(cwd, "rev-parse", "HEAD:alias")), "tracked.txt");
1822
+ for (const replacement of ["other.txt", "../escape", null]) {
1823
+ chmodSync(view.root, 0o755);
1824
+ rmSync(join(view.root, "alias"));
1825
+ if (replacement === null) writeFileSync(join(view.root, "alias"), "tracked.txt");
1826
+ else symlinkSync(replacement, join(view.root, "alias"));
1827
+ assert.throws(() => view.verify(), CandidateViewError);
1828
+ }
1829
+ } finally { view.cleanup(); }
1830
+ });
1831
+
1832
+ test("unsafe frozen symlink blobs are rejected before any link is created with core.symlinks disabled", (t) => {
1833
+ for (const target of ["/absolute", "../escape", ".git/config", "bad\\\\target", "bad//target"]) {
1834
+ const cwd = repository(t);
1835
+ git(cwd, "config", "core.symlinks", "false");
1836
+ const safe = execFileSync("git", ["hash-object", "-w", "--stdin"], { cwd, input: "tracked.txt", encoding: "utf8" }).trim();
1837
+ const unsafe = execFileSync("git", ["hash-object", "-w", "--stdin"], { cwd, input: target, encoding: "utf8" }).trim();
1838
+ git(cwd, "update-index", "--add", "--cacheinfo", `120000,${safe},a-safe-link`);
1839
+ git(cwd, "update-index", "--add", "--cacheinfo", `120000,${unsafe},z-unsafe-link`);
1840
+ const base = git(cwd, "rev-parse", "HEAD");
1841
+ git(cwd, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "unsafe frozen link");
1842
+ let creations = 0;
1843
+ const original = fs.symlinkSync;
1844
+ t.mock.method(fs, "symlinkSync", (...args: Parameters<typeof fs.symlinkSync>) => { creations++; return original(...args); });
1845
+ syncBuiltinESMExports();
1846
+ try { assert.throws(() => createCandidateView({ contributorRoot: cwd, baseRef: base, committedOnly: true }), CandidateViewError, target); }
1847
+ finally { t.mock.restoreAll(); syncBuiltinESMExports(); }
1848
+ assert.equal(creations, 0, target);
1849
+ assert.equal(git(cwd, "config", "core.symlinks"), "false");
1850
+ }
1851
+ });
1852
+
1853
+ test("unavailable native symlink capability fails with bounded reason and cleans owned view", (t) => {
1854
+ const cwd = repository(t);
1855
+ try { symlinkSync("tracked.txt", join(cwd, "alias")); }
1856
+ catch { t.skip("native symlink creation unavailable for fixture"); return; }
1857
+ git(cwd, "add", "alias");
1858
+ git(cwd, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "link base");
1859
+ git(cwd, "config", "core.symlinks", "false");
1860
+ writeFileSync(join(cwd, "tracked.txt"), "changed\n");
1861
+ const original = fs.symlinkSync;
1862
+ t.mock.method(fs, "symlinkSync", (...args: Parameters<typeof fs.symlinkSync>) => {
1863
+ if (String(args[1]).includes("candidate-views")) throw Object.assign(new Error("private path and user"), { code: "EPERM" });
1864
+ return original(...args);
1865
+ });
1866
+ syncBuiltinESMExports();
1867
+ t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
1868
+ assert.throws(() => createCandidateView({ contributorRoot: cwd }), (error: unknown) => error instanceof CandidateViewError && error.reason === "symlink-materialization-failed" && !error.message.includes("private path and user"));
1869
+ const parent = join(cwd, ".git", "gentle-ai", "candidate-views");
1870
+ assert.deepEqual(readdirSync(parent), []);
1871
+ assert.equal(git(cwd, "worktree", "list", "--porcelain").includes("candidate-views"), false);
1872
+ });
1873
+
1622
1874
  test("candidate view detects symlink target-byte tampering after materialization", (t) => {
1623
1875
  const contributorRoot = repository(t);
1624
1876
  const link = join(contributorRoot, "candidate-link");
@@ -1843,6 +2095,52 @@ test("native projections recover a committed range base from its frozen tree", (
1843
2095
  assert.equal(projection.committedOnly, true);
1844
2096
  });
1845
2097
 
2098
+ test("provider-owned tree remains the exact base across reuse, correction and native dispatch restoration", (t) => {
2099
+ const contributorRoot = repository(t);
2100
+ const baseTree = git(contributorRoot, "rev-parse", "HEAD^{tree}");
2101
+ writeFileSync(join(contributorRoot, "tracked.txt"), "tree-backed candidate\n");
2102
+ git(contributorRoot, "add", "tracked.txt");
2103
+ git(contributorRoot, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "candidate");
2104
+ const head = git(contributorRoot, "rev-parse", "HEAD");
2105
+ const candidateTree = git(contributorRoot, "rev-parse", "HEAD^{tree}");
2106
+ const registry = new CandidateViewRegistry();
2107
+ const first = registry.createOrReuse({ contributorRoot, providerBaseTree: baseTree, committedOnly: true, replayKey: "provider-tree" });
2108
+ try {
2109
+ const reused = registry.createOrReuse({ contributorRoot, providerBaseTree: baseTree, committedOnly: true, replayKey: "provider-tree" });
2110
+ assert.equal(reused.token, first.token);
2111
+ assert.equal(first.baseTree, baseTree);
2112
+ assert.equal(first.baseCommit, head, "HEAD anchors the worktree, not the diff base");
2113
+ registry.retain(first.token, "tree-lineage");
2114
+ const corrected = registry.createCorrected("tree-lineage", contributorRoot, "tree-correction");
2115
+ assert.equal(corrected.baseTree, baseTree);
2116
+ registry.promoteCorrected("tree-lineage", corrected.token, contributorRoot);
2117
+ assert.equal(registry.resolveProjection("tree-lineage", contributorRoot).providerBaseTree, baseTree);
2118
+ const restored = new CandidateViewRegistry();
2119
+ try {
2120
+ restored.restoreCurrentForDispatchFromNative("restored-tree", contributorRoot, { baseTree, currentCandidateTree: candidateTree, paths: ["tracked.txt"], intendedUntracked: [], projection: "staged" }, ["review-reliability"], baseTree);
2121
+ const view = restored.resolveCurrentForLens("review-reliability", contributorRoot);
2122
+ assert.equal(view.baseTree, baseTree);
2123
+ assert.equal(restored.resolveProjection("restored-tree", contributorRoot).providerBaseTree, baseTree);
2124
+ } finally { restored.cleanupAll(); }
2125
+ const finalized = new CandidateViewRegistry();
2126
+ try {
2127
+ const view = finalized.restoreForFinalizeFromNative("finalized-tree", contributorRoot, { baseTree, currentCandidateTree: candidateTree, paths: ["tracked.txt"], intendedUntracked: [], projection: "staged" }, baseTree);
2128
+ assert.equal(view.baseTree, baseTree);
2129
+ assert.equal(finalized.resolveProjection("finalized-tree", contributorRoot).providerBaseTree, baseTree);
2130
+ } finally { finalized.cleanupAll(); }
2131
+ } finally { registry.cleanupAll(); }
2132
+ });
2133
+
2134
+ test("provider tree materialization and restoration reject wrong objects and mismatched frozen bases", (t) => {
2135
+ const contributorRoot = repository(t);
2136
+ const head = git(contributorRoot, "rev-parse", "HEAD");
2137
+ const baseTree = git(contributorRoot, "rev-parse", "HEAD^{tree}");
2138
+ assert.throws(() => new CandidateViewRegistry().create({ contributorRoot, providerBaseTree: head, committedOnly: true }), (error: unknown) => error instanceof CandidateViewError && error.reason === "provider-base-tree-invalid");
2139
+ assert.throws(() => new CandidateViewRegistry().create({ contributorRoot, baseRef: head, providerBaseTree: baseTree, committedOnly: true }), (error: unknown) => error instanceof CandidateViewError && error.reason === "provider-base-tree-invalid");
2140
+ const registry = new CandidateViewRegistry();
2141
+ assert.throws(() => registry.restoreProjectionFromNative("wrong-tree", contributorRoot, { baseTree, currentCandidateTree: baseTree, paths: [], intendedUntracked: [], projection: "workspace" }, head), (error: unknown) => error instanceof CandidateViewError && error.reason === "provider-base-tree-invalid");
2142
+ });
2143
+
1846
2144
  test("fresh registries restore only one exact authoritative reviewing candidate and reject zero or multiple matches", (t) => {
1847
2145
  const contributorRoot = repository(t);
1848
2146
  writeFileSync(join(contributorRoot, "tracked.txt"), "reviewing\n");
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
- import test from "node:test";
6
+ import test, { after, before } from "node:test";
7
7
  import { pathToFileURL } from "node:url";
8
8
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
9
  import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
@@ -16,12 +16,43 @@ import type { NativeReviewCli } from "../lib/native-review-cli.ts";
16
16
  // These tests exercise the real committed mirror (contracts/review-provider-contract-mirror/)
17
17
  // rather than a fake one: the mirror IS the package under test.
18
18
 
19
- type BeforeAgentStartResult = { systemPrompt: string };
19
+ type BeforeAgentStartResult = undefined;
20
20
  type BeforeAgentStartHandler = (event: unknown, ctx: ExtensionContext) => Promise<BeforeAgentStartResult>;
21
+ type MutableEvent = { agentName?: string; systemPrompt: string; systemPromptOptions: { appendSystemPrompt: string } };
21
22
 
22
23
  const REPO_ROOT = join(import.meta.dirname, "..");
23
24
  const MIRROR_LOCK_PATH = join(REPO_ROOT, "contracts", "review-provider-contract-mirror", "provider-contract.lock.json");
24
25
 
26
+ let fixtureRoot: string | undefined;
27
+ let fixtureCwd: string;
28
+ const fixtureEnvironment: NodeJS.ProcessEnv = {};
29
+ const previousEnvironment = new Map<string, string | undefined>();
30
+ before(() => {
31
+ fixtureRoot = mkdtempSync(join(tmpdir(), "gentle-pi-review-prompt-"));
32
+ fixtureCwd = join(fixtureRoot, "project");
33
+ const home = join(fixtureRoot, "home");
34
+ mkdirSync(fixtureCwd);
35
+ mkdirSync(home);
36
+ Object.assign(fixtureEnvironment, {
37
+ HOME: home, USERPROFILE: home,
38
+ GENTLE_PI_CONFIG_HOME: join(home, "config"),
39
+ GENTLE_PI_AGENT_HOME: join(home, "agents"),
40
+ PI_CODING_AGENT_DIR: join(home, "pi"),
41
+ XDG_CONFIG_HOME: join(home, "xdg"),
42
+ });
43
+ for (const [key, value] of Object.entries(fixtureEnvironment)) {
44
+ previousEnvironment.set(key, process.env[key]);
45
+ process.env[key] = value;
46
+ }
47
+ });
48
+ after(() => {
49
+ for (const [key, value] of previousEnvironment) {
50
+ if (value === undefined) delete process.env[key];
51
+ else process.env[key] = value;
52
+ }
53
+ if (fixtureRoot !== undefined) rmSync(fixtureRoot, { recursive: true, force: true });
54
+ });
55
+
25
56
  function mirroredPiOrchestrationText(): string {
26
57
  const lock = JSON.parse(readFileSync(MIRROR_LOCK_PATH, "utf8")) as { contract_semver: string };
27
58
  return readFileSync(
@@ -30,7 +61,7 @@ function mirroredPiOrchestrationText(): string {
30
61
  ).trim();
31
62
  }
32
63
 
33
- function harness(nativeReviewCli: NativeReviewCli | null): { beforeAgentStart: BeforeAgentStartHandler } {
64
+ function harness(nativeReviewCli: NativeReviewCli | null, processEnv?: NodeJS.ProcessEnv): { beforeAgentStart: BeforeAgentStartHandler } {
34
65
  const handlers = new Map<string, BeforeAgentStartHandler>();
35
66
  const pi = {
36
67
  on(name: string, handler: BeforeAgentStartHandler) {
@@ -40,7 +71,12 @@ function harness(nativeReviewCli: NativeReviewCli | null): { beforeAgentStart: B
40
71
  registerCommand() {},
41
72
  registerTool() {},
42
73
  } as unknown as ExtensionAPI;
43
- createGentleAiExtension({ nativeReviewCli })(pi);
74
+ createGentleAiExtension({
75
+ nativeReviewCli,
76
+ processEnv: { ...fixtureEnvironment, GENTLE_PI_AGENTS_CHILD: "0", ...processEnv, GENTLE_AI_TELEMETRY: "0" },
77
+ resolveTelemetryTriggerBinary: () => join(fixtureCwd, "never-executed"),
78
+ telemetryTriggerSpawn: () => assert.fail("Review prompt fixtures must not spawn telemetry"),
79
+ })(pi);
44
80
  const beforeAgentStart = handlers.get("before_agent_start");
45
81
  assert.equal(typeof beforeAgentStart, "function");
46
82
  return { beforeAgentStart: beforeAgentStart as BeforeAgentStartHandler };
@@ -48,7 +84,7 @@ function harness(nativeReviewCli: NativeReviewCli | null): { beforeAgentStart: B
48
84
 
49
85
  function ctx(overrides: Record<string, unknown> = {}): ExtensionContext {
50
86
  return {
51
- cwd: process.cwd(),
87
+ cwd: fixtureCwd,
52
88
  hasUI: true,
53
89
  ui: { notify() {} },
54
90
  sessionManager: { getSessionId: () => "review-contract-prompt-session" },
@@ -56,28 +92,35 @@ function ctx(overrides: Record<string, unknown> = {}): ExtensionContext {
56
92
  } as unknown as ExtensionContext;
57
93
  }
58
94
 
59
- const primaryEvent = { systemPrompt: "base" };
95
+ function primaryEvent(overrides: Partial<MutableEvent> = {}): MutableEvent {
96
+ return { systemPrompt: "base", systemPromptOptions: { appendSystemPrompt: "" }, ...overrides };
97
+ }
60
98
 
61
- test("before_agent_start injects the mirrored review execution contract for the primary session", async () => {
99
+ test("before_agent_start injects the mirrored review execution contract for the primary session through appendSystemPrompt, never a returned systemPrompt", async () => {
62
100
  const { beforeAgentStart } = harness({} as NativeReviewCli);
63
- const result = await beforeAgentStart(primaryEvent, ctx());
101
+ const event = primaryEvent();
102
+ const result = await beforeAgentStart(event, ctx());
103
+ assert.equal(result, undefined, "the handler must not return a replacement systemPrompt");
104
+ const appended = event.systemPromptOptions.appendSystemPrompt;
64
105
  const expected = mirroredPiOrchestrationText();
65
- assert.match(result.systemPrompt, /Substantial authorized work: use ODD/);
66
- assert.match(result.systemPrompt, /Use configured TDD mode, source, and exact runner/);
67
- assert.match(result.systemPrompt, /test presence does not enable it/);
68
- assert.doesNotMatch(result.systemPrompt, /If tests exist, use strict TDD/);
69
- assert.match(result.systemPrompt, /ODD \(Default Workflow, harness section above\) is mandatory on every request/);
70
- assert.doesNotMatch(result.systemPrompt, /Prefer SDD\/OpenSpec artifacts/);
71
- assert.match(result.systemPrompt, /## Gentle AI review execution contract \(mirrored provider bundle 1\.2\.0\)/);
72
- assert.ok(result.systemPrompt.includes(expected), "the mirrored orchestration/pi.md text must appear verbatim");
73
- assert.match(result.systemPrompt, /call `gentle_review` with {"operation":"inspect"}/);
74
- assert.match(result.systemPrompt, /call `gentle_review` with operation `status`, the exact retained `lineageId`, and `workspaceRoot`/);
75
- assert.match(result.systemPrompt, /Use `gentle_review_capture` for one current returned slot or `gentle_review_capture_group` for the complete current reviewer group/);
76
- assert.match(result.systemPrompt, /An eligible interactive Pi host may resolve `gentle-ai\.review-integration\.consent\/v3` before the envelope reaches the model/);
77
- assert.match(result.systemPrompt, /If `gentle_review` returns the envelope unresolved, it is still the original provider-owned two-choice contract/);
78
- assert.match(result.systemPrompt, /Never add the host action to a decoded or relayed provider envelope/);
79
- assert.match(result.systemPrompt, /An approved capture awaits acknowledgement; it is not burned\. On `approved`, use bound facade STATUS to obtain or replay the exact provider-issued `acknowledge-approved` continuation, then execute it unchanged\. Only its successful returned envelope burns authority; do not issue STATUS after that burn\./);
80
- let previousLifecycleIndex = result.systemPrompt.indexOf("## Gentle AI review execution contract");
106
+ assert.match(appended, /Substantial authorized work: use ODD/);
107
+ assert.match(appended, /For behavior changes with applicable runnable deterministic tests and a clear expected outcome, use test-first by default: observe RED, GREEN, then refactor with focused checks/);
108
+ assert.match(appended, /Test presence alone does not establish applicability; no chat or TUI toggle activates it/);
109
+ assert.match(appended, /no meaningful RED, explain why and run proportionate ordinary functional or structural verification/);
110
+ assert.match(appended, /Never invent lifecycle evidence or skip checks/);
111
+ assert.doesNotMatch(appended, /If tests exist, use strict TDD/);
112
+ assert.match(appended, /ODD \(Default Workflow, harness section above\) is mandatory on every request/);
113
+ assert.doesNotMatch(appended, /Prefer SDD\/OpenSpec artifacts/);
114
+ assert.match(appended, /## Gentle AI review execution contract \(mirrored provider bundle 1\.2\.0\)/);
115
+ assert.ok(appended.includes(expected), "the mirrored orchestration/pi.md text must appear verbatim");
116
+ assert.match(appended, /call `gentle_review` with {"operation":"inspect"}/);
117
+ assert.match(appended, /call `gentle_review` with operation `status`, the exact retained `lineageId`, and `workspaceRoot`/);
118
+ assert.match(appended, /Use `gentle_review_capture` for one current returned slot or `gentle_review_capture_group` for the complete current reviewer group/);
119
+ assert.match(appended, /An eligible interactive Pi host may resolve `gentle-ai\.review-integration\.consent\/v3` before the envelope reaches the model/);
120
+ assert.match(appended, /If `gentle_review` returns the envelope unresolved, it is still the original provider-owned two-choice contract/);
121
+ assert.match(appended, /Never add the host action to a decoded or relayed provider envelope/);
122
+ assert.match(appended, /An approved capture awaits acknowledgement; it is not burned\. On `approved`, use bound facade STATUS to obtain or replay the exact provider-issued `acknowledge-approved` continuation, then execute it unchanged\. Only its successful returned envelope burns authority; do not issue STATUS after that burn\./);
123
+ let previousLifecycleIndex = appended.indexOf("## Gentle AI review execution contract");
81
124
  for (const marker of [
82
125
  'call `gentle_review` with {"operation":"inspect"}',
83
126
  "2. **Freeze once.**",
@@ -85,47 +128,69 @@ test("before_agent_start injects the mirrored review execution contract for the
85
128
  "Use `gentle_review_capture` for one current returned slot",
86
129
  "5. **Acknowledge exactly.**",
87
130
  ]) {
88
- const markerIndex = result.systemPrompt.indexOf(marker, previousLifecycleIndex + 1);
131
+ const markerIndex = appended.indexOf(marker, previousLifecycleIndex + 1);
89
132
  assert.ok(markerIndex > previousLifecycleIndex, `${marker} must follow the previous lifecycle step`);
90
133
  previousLifecycleIndex = markerIndex;
91
134
  }
92
- assert.doesNotMatch(result.systemPrompt, /authority is already burned/);
93
- assert.doesNotMatch(result.systemPrompt, /gentle-ai review status\b.*--agent pi/);
135
+ assert.doesNotMatch(appended, /authority is already burned/);
136
+ assert.doesNotMatch(appended, /gentle-ai review status\b.*--agent pi/);
94
137
  });
95
138
 
96
139
  test("before_agent_start does not inject the review execution contract for a named agent session", async () => {
97
140
  const { beforeAgentStart } = harness({} as NativeReviewCli);
98
- const result = await beforeAgentStart({ agentName: "review-readability", systemPrompt: "base" }, ctx());
99
- assert.doesNotMatch(result.systemPrompt, /Gentle AI review execution contract/);
141
+ const event = primaryEvent({ agentName: "review-readability" });
142
+ await beforeAgentStart(event, ctx());
143
+ assert.doesNotMatch(event.systemPromptOptions.appendSystemPrompt, /Gentle AI review execution contract/);
100
144
  });
101
145
 
102
146
  test("before_agent_start does not inject the review execution contract for gentle-ai-worker", async () => {
103
147
  const { beforeAgentStart } = harness({} as NativeReviewCli);
104
- const result = await beforeAgentStart({ agentName: "gentle-ai-worker", systemPrompt: "base" }, ctx());
105
- assert.equal(result.systemPrompt, "base");
106
- assert.doesNotMatch(result.systemPrompt, /Substantial authorized work: use ODD/);
107
- assert.doesNotMatch(result.systemPrompt, /Gentle AI review execution contract/);
148
+ const event = primaryEvent({ agentName: "gentle-ai-worker" });
149
+ await beforeAgentStart(event, ctx());
150
+ assert.equal(event.systemPromptOptions.appendSystemPrompt, "", "a named agent gets nothing appended");
108
151
  });
109
152
 
110
153
  test("before_agent_start does not inject the review execution contract for jd-fix-agent", async () => {
111
154
  const { beforeAgentStart } = harness({} as NativeReviewCli);
112
- const result = await beforeAgentStart({ agentName: "jd-fix-agent", systemPrompt: "base" }, ctx());
113
- assert.equal(result.systemPrompt, "base");
114
- assert.doesNotMatch(result.systemPrompt, /Substantial authorized work: use ODD/);
115
- assert.doesNotMatch(result.systemPrompt, /Gentle AI review execution contract/);
155
+ const event = primaryEvent({ agentName: "jd-fix-agent" });
156
+ await beforeAgentStart(event, ctx());
157
+ assert.equal(event.systemPromptOptions.appendSystemPrompt, "", "a named agent gets nothing appended");
116
158
  });
117
159
 
118
- test("before_agent_start does not inject the review execution contract for an SDD executor session", async () => {
160
+ test("before_agent_start does not let legacy prompt text bypass primary ODD and review injection", async () => {
119
161
  const { beforeAgentStart } = harness({} as NativeReviewCli);
120
- const result = await beforeAgentStart({ systemPrompt: "SDD apply executor body" }, ctx());
121
- assert.doesNotMatch(result.systemPrompt, /Substantial authorized work: use ODD/);
122
- assert.doesNotMatch(result.systemPrompt, /Gentle AI review execution contract/);
162
+ const event = primaryEvent({ systemPrompt: "SDD apply executor body" });
163
+ await beforeAgentStart(event, ctx());
164
+ const appended = event.systemPromptOptions.appendSystemPrompt;
165
+ assert.match(appended, /Substantial authorized work: use ODD/);
166
+ assert.match(appended, /Gentle AI review execution contract/);
167
+ assert.doesNotMatch(appended, /### 3\. SDD \(optional\)/);
168
+ });
169
+
170
+ test("before_agent_start does not inject the review execution contract or gentlePrompt for a child session (GENTLE_PI_AGENTS_CHILD=1)", async () => {
171
+ const { beforeAgentStart } = harness({} as NativeReviewCli, { GENTLE_PI_AGENTS_CHILD: "1" });
172
+ const event = primaryEvent({ systemPromptOptions: { appendSystemPrompt: "Worker-specific instructions" } });
173
+ const result = await beforeAgentStart(event, ctx());
174
+ assert.equal(result, undefined, "the handler must not return a replacement systemPrompt");
175
+ assert.equal(event.systemPromptOptions.appendSystemPrompt, "Worker-specific instructions", "child instructions must remain unchanged, without primary harness or review injection");
123
176
  });
124
177
 
125
178
  test("before_agent_start injects nothing when nativeReviewCli is null", async () => {
126
179
  const { beforeAgentStart } = harness(null);
127
- const result = await beforeAgentStart(primaryEvent, ctx());
128
- assert.doesNotMatch(result.systemPrompt, /Gentle AI review execution contract/);
180
+ const event = primaryEvent();
181
+ await beforeAgentStart(event, ctx());
182
+ assert.doesNotMatch(event.systemPromptOptions.appendSystemPrompt, /Gentle AI review execution contract/);
183
+ });
184
+
185
+ test("before_agent_start is idempotent: running twice on the same systemPromptOptions never duplicates the harness", async () => {
186
+ const { beforeAgentStart } = harness({} as NativeReviewCli);
187
+ const event = primaryEvent();
188
+ await beforeAgentStart(event, ctx());
189
+ const firstLength = event.systemPromptOptions.appendSystemPrompt.length;
190
+ await beforeAgentStart(event, ctx());
191
+ assert.equal(event.systemPromptOptions.appendSystemPrompt.length, firstLength, "a second run on the same options object must not append again");
192
+ const occurrences = event.systemPromptOptions.appendSystemPrompt.split("Gentle AI review execution contract").length - 1;
193
+ assert.equal(occurrences, 1);
129
194
  });
130
195
 
131
196
  // gentle-ai R1/R3: a tampered mirrored orchestration/pi.md must never be spliced into the system prompt.
@@ -101,7 +101,6 @@ function fakeNative(status: NativeReviewStatusResult, onStart?: (request: Parame
101
101
  finalize: async () => { throw new Error("finalize must not run"); },
102
102
  validate: async () => { throw new Error("validate must not run"); },
103
103
  bindSdd: async () => { throw new Error("bindSdd must not run"); },
104
- sddStatus: async () => ({ ready: false }),
105
104
  reviewStatus: async () => { throw new Error("inventory status must not run"); },
106
105
  targetStatus: async () => ({
107
106
  applicability: blocking ? "corrupted" : "unrelated",