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
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { execFileSync } from "node:child_process";
3
- import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import test from "node:test";
@@ -28,6 +28,7 @@ type ToolCallHandler = (
28
28
  interface Runtime {
29
29
  controller: RegisteredTool;
30
30
  toolCall: ToolCallHandler;
31
+ lifecycle(name: string, event: unknown, ctx: ExtensionContext): Promise<void>;
31
32
  }
32
33
 
33
34
  function runtime(
@@ -36,10 +37,12 @@ function runtime(
36
37
  ): Runtime {
37
38
  const tools = new Map<string, RegisteredTool>();
38
39
  let toolCall: ToolCallHandler | undefined;
40
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => unknown>();
39
41
  const dependencies = { nativeReviewCli, candidateViews } as unknown as Parameters<typeof createGentleAiExtension>[0];
40
42
  createGentleAiExtension(dependencies)({
41
43
  on(name: string, handler: ToolCallHandler) {
42
44
  if (name === "tool_call") toolCall = handler;
45
+ handlers.set(name, handler as unknown as (event: unknown, ctx: ExtensionContext) => unknown);
43
46
  },
44
47
  registerTool(definition: RegisteredTool & { name: string }) { tools.set(definition.name, definition); },
45
48
  registerCommand() {},
@@ -47,7 +50,7 @@ function runtime(
47
50
  const controller = tools.get("gentle_review");
48
51
  assert.ok(controller);
49
52
  assert.ok(toolCall);
50
- return { controller, toolCall };
53
+ return { controller, toolCall, async lifecycle(name, event, ctx) { await handlers.get(name)?.(event, ctx); } };
51
54
  }
52
55
 
53
56
  function context(cwd: string): ExtensionContext {
@@ -69,6 +72,156 @@ function repository(t: test.TestContext, prefix = "gentle-pi-workspace-root-"):
69
72
  return cwd;
70
73
  }
71
74
 
75
+ for (const operation of ["inspect", "start"] as const) {
76
+ for (const mode of ["on", "off", "unknown", "missing", "malformed", "throws"] as const) {
77
+ test(`non-Git ${operation} requires validated RDD on: ${mode}`, async () => {
78
+ const cwd = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-bootstrap-target-")));
79
+ writeFileSync(join(cwd, "candidate.txt"), "unchanged candidate\n");
80
+ const requests: string[] = [];
81
+ const native = fakeNative({
82
+ reviewMode: mode === "missing" ? undefined : (async () => {
83
+ if (mode === "throws") throw new Error("mode unavailable");
84
+ return { operation: "status", scope: "clone", status: { global: "", cloneLocal: "", effective: mode === "malformed" ? "on" : mode, source: mode === "malformed" ? "invalid" : "global" } };
85
+ }) as NonNullable<NativeReviewCli["reviewMode"]>,
86
+ targetStatus: async (request) => {
87
+ requests.push(request.cwd);
88
+ // Stop at entry: this contract does not exercise START or consent.
89
+ throw new Error("native preparation reached");
90
+ },
91
+ });
92
+ for (const explicit of [false, true]) {
93
+ requests.length = 0;
94
+ const result = await __testing.executeReviewControllerOperation({
95
+ operation, ...(explicit ? { workspaceRoot: cwd } : {}),
96
+ ...(operation === "start" ? { input: JSON.stringify({ mode: "ordinary" }) } : {}),
97
+ }, explicit ? process.cwd() : cwd, native);
98
+ assert.equal(result.status, "blocked");
99
+ assert.deepEqual(requests, mode === "on" ? [cwd] : []);
100
+ }
101
+ assert.equal(readFileSync(join(cwd, "candidate.txt"), "utf8"), "unchanged candidate\n");
102
+ });
103
+ }
104
+ }
105
+
106
+ for (const operation of ["inspect", "start"] as const) {
107
+ for (const transition of ["shutdown", "replacement", "same-manager-rebind", "changed-id", "cancelled", "live"] as const) {
108
+ test(`explicit non-Git ${operation} pins lifecycle across held mode: ${transition}`, async t => {
109
+ const fixture = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-explicit-lifecycle-")));
110
+ const home = join(fixture, "home");
111
+ const project = join(fixture, "project");
112
+ mkdirSync(home); mkdirSync(project);
113
+ writeFileSync(join(project, "candidate.txt"), "unchanged candidate\n");
114
+ const previous = { HOME: process.env.HOME, GENTLE_PI_AGENT_HOME: process.env.GENTLE_PI_AGENT_HOME, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME };
115
+ Object.assign(process.env, { HOME: home, GENTLE_PI_AGENT_HOME: join(fixture, "agent"), GENTLE_PI_CONFIG_HOME: join(fixture, "config") });
116
+ t.after(() => {
117
+ for (const [key, value] of Object.entries(previous)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; }
118
+ rmSync(fixture, { recursive: true, force: true });
119
+ });
120
+ let entered!: () => void;
121
+ const modeEntered = new Promise<void>(resolve => { entered = resolve; });
122
+ let release!: () => void;
123
+ const heldMode = new Promise<void>(resolve => { release = resolve; });
124
+ let hold = false;
125
+ let statuses = 0;
126
+ let starts = 0;
127
+ const native = fakeNative({
128
+ reviewMode: async () => {
129
+ if (hold) { entered(); await heldMode; }
130
+ return { operation: "status", scope: "global", status: { global: "on", cloneLocal: "", effective: "on", source: "global" } };
131
+ },
132
+ targetStatus: async request => {
133
+ statuses++;
134
+ assert.equal(request.cwd, project);
135
+ // Only the fake provider mutates this disposable project. Stop before consent/START.
136
+ execFileSync("git", ["init", "--quiet", project]);
137
+ throw new Error("fixture preparation completed");
138
+ },
139
+ start: async () => { starts++; throw new Error("unexpected START"); },
140
+ });
141
+ const h = runtime(native);
142
+ const session = { ...context(home), sessionManager: { getSessionId: () => "same-id", getCwd: () => home, getEntries: () => [], getBranch: () => [] } } as unknown as ExtensionContext;
143
+ await h.lifecycle("session_start", { reason: "startup" }, session);
144
+ assert.equal(statuses, 0);
145
+ hold = true;
146
+ const abort = new AbortController();
147
+ const pending = h.controller.execute("held-explicit", { operation, workspaceRoot: project, ...(operation === "start" ? { input: JSON.stringify({ mode: "ordinary" }) } : {}) }, abort.signal, undefined, session);
148
+ // Observe rejection immediately, so a revoked call cannot become unhandled.
149
+ const settled = pending.then(result => ({ result }), error => ({ error }));
150
+ await modeEntered;
151
+ if (transition === "shutdown") await h.lifecycle("session_shutdown", { reason: "quit" }, session);
152
+ if (transition === "replacement") {
153
+ const successor = { ...session, sessionManager: { getSessionId: () => "same-id", getCwd: () => home, getEntries: () => [], getBranch: () => [] } } as unknown as ExtensionContext;
154
+ assert.notEqual(successor.sessionManager, session.sessionManager);
155
+ await h.lifecycle("session_start", { reason: "new" }, successor);
156
+ }
157
+ if (transition === "same-manager-rebind") await h.lifecycle("session_start", { reason: "reload" }, session);
158
+ if (transition === "changed-id") session.sessionManager.getSessionId = () => "changed-id";
159
+ if (transition === "cancelled") abort.abort();
160
+ release();
161
+ await settled;
162
+ assert.equal(abort.signal.aborted, transition === "cancelled", "lifecycle revocation is independent of caller abort");
163
+ assert.equal(statuses, transition === "live" ? 1 : 0, "revoked original context cannot reach native bootstrap STATUS");
164
+ assert.equal(starts, 0);
165
+ assert.equal(existsSync(join(project, ".git")), transition === "live");
166
+ assert.equal(existsSync(join(home, ".git")), false, "explicit selection never initializes HOME");
167
+ assert.equal(readFileSync(join(project, "candidate.txt"), "utf8"), "unchanged candidate\n");
168
+ await h.lifecycle("session_shutdown", { reason: "quit" }, session);
169
+ });
170
+ }
171
+ }
172
+
173
+ test("invalid bootstrap target selectors reject before native STATUS", async () => {
174
+ const cwd = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-bootstrap-invalid-")));
175
+ let statusCalls = 0;
176
+ const native = fakeNative({
177
+ reviewMode: async () => ({ operation: "status", scope: "clone", status: { global: "on", cloneLocal: "", effective: "on", source: "global" } }),
178
+ targetStatus: async () => { statusCalls += 1; throw new Error("must not reach STATUS"); },
179
+ });
180
+ for (const parameters of [
181
+ { operation: "inspect", input: JSON.stringify({ baseRef: "HEAD" }) },
182
+ { operation: "inspect", input: JSON.stringify({ baseRef: "missing-ref", committedOnly: true }) },
183
+ { operation: "start", input: JSON.stringify({ mode: "ordinary", focus: "invalid" }) },
184
+ { operation: "start", input: JSON.stringify({ mode: "ordinary", policyPath: "../outside" }) },
185
+ ]) {
186
+ const result = await __testing.executeReviewControllerOperation(parameters, cwd, native);
187
+ assert.equal(result.status, "blocked");
188
+ }
189
+ for (const workspaceRoot of ["relative", join(cwd, "missing")]) {
190
+ await assert.rejects(__testing.executeReviewControllerOperation({ operation: "inspect", workspaceRoot }, cwd, native));
191
+ }
192
+ assert.equal(statusCalls, 0);
193
+ });
194
+
195
+ test("repository discovery ignores ambient Git root overrides", async (t) => {
196
+ const sessionRoot = repository(t);
197
+ const targetRoot = repository(t, "gentle-pi-isolated-target-");
198
+ const previous = { GIT_DIR: process.env.GIT_DIR, GIT_WORK_TREE: process.env.GIT_WORK_TREE };
199
+ t.after(() => {
200
+ for (const [key, value] of Object.entries(previous)) {
201
+ if (value === undefined) delete process.env[key]; else process.env[key] = value;
202
+ }
203
+ });
204
+ process.env.GIT_DIR = join(sessionRoot, ".git");
205
+ process.env.GIT_WORK_TREE = sessionRoot;
206
+ const requests: string[] = [];
207
+ await __testing.executeReviewControllerOperation({ operation: "inspect", workspaceRoot: targetRoot }, sessionRoot,
208
+ fakeNative({ targetStatus: async (request) => { requests.push(request.cwd); throw new Error("entry only"); } }));
209
+ assert.deepEqual(requests, [targetRoot]);
210
+ });
211
+
212
+ test("non-Git bound and maintenance routes cannot use preparation entry", async () => {
213
+ const cwd = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-bootstrap-bound-")));
214
+ let statusCalls = 0;
215
+ const native = fakeNative({ targetStatus: async () => { statusCalls += 1; throw new Error("must not reach STATUS"); } });
216
+ for (const parameters of [
217
+ { operation: "status" },
218
+ { operation: "inspect", lineageId: "bound" },
219
+ { operation: "start", input: JSON.stringify({ mode: "judgment-day" }) },
220
+ { operation: "reset", input: "{}" },
221
+ ]) await assert.rejects(__testing.executeReviewControllerOperation(parameters, cwd, native));
222
+ assert.equal(statusCalls, 0);
223
+ });
224
+
72
225
  function addWorktree(t: test.TestContext, cwd: string, branch: string): string {
73
226
  const parent = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-workspace-worktrees-")));
74
227
  t.after(() => {
@@ -83,7 +236,6 @@ function addWorktree(t: test.TestContext, cwd: string, branch: string): string {
83
236
  function fakeNative(overrides: Partial<NativeReviewCli> = {}): NativeReviewCli {
84
237
  return {
85
238
  start: async () => ({ lineageId: "native-lineage", state: "reviewing", riskLevel: "medium", selectedLenses: ["review-reliability"], changedFiles: 2, changedLines: 7, correctionBudget: 4, action: "created", lensesRequired: true }),
86
- sddStatus: async () => ({ ready: false }),
87
239
  reviewStatus: async () => ({ schema: "gentle-ai.review-authority-status/v1", repository: "/repo", complete: true, authoritative: true, status: "clean", entries: [], locks: [], diagnostics: [], raw: { schema: "gentle-ai.review-authority-status/v1", operation: "review/status", repository: "/repo", complete: true, authoritative: true, status: "clean", entries: [], locks: [], diagnostics: [] } }),
88
240
  targetStatus: async (request) => request.lineageId === undefined
89
241
  ? candidateStartTargetStatus(request)
@@ -487,7 +639,6 @@ test("workspaceRoot fails closed before any native call for invalid target paths
487
639
  });
488
640
  const { controller } = runtime(counting);
489
641
  const rejected: Array<{ label: string; workspaceRoot: string }> = [
490
- { label: "non-git directory", workspaceRoot: nonGit },
491
642
  { label: "missing directory", workspaceRoot: join(nonGit, "missing") },
492
643
  { label: "file path", workspaceRoot: filePath },
493
644
  { label: "relative path", workspaceRoot: "relative/worktree" },
@@ -506,6 +657,14 @@ test("workspaceRoot fails closed before any native call for invalid target paths
506
657
  );
507
658
  }
508
659
  }
660
+ for (const operation of ["inspect", "start"] as const) {
661
+ const result = await controller.execute(`non-git-${operation}`, {
662
+ operation, workspaceRoot: nonGit,
663
+ ...(operation === "start" ? { input: JSON.stringify({ mode: "ordinary" }) } : {}),
664
+ }, undefined, undefined, context(sessionCwd));
665
+ assert.equal((result.details as Record<string, unknown>).outcome, "native-repository-bootstrap-not-authorized");
666
+ }
667
+ await assert.rejects(controller.execute("non-git-bound", { operation: "status", workspaceRoot: nonGit, lineageId: "bound" }, undefined, undefined, context(sessionCwd)), /workspaceRoot/);
509
668
  assert.equal(nativeCalls, 0);
510
669
  });
511
670
 
@@ -5,9 +5,15 @@ import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import test from "node:test";
7
7
  import { __testing } from "../extensions/gentle-ai.ts";
8
+ import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
9
+ import { localProfilePinPath, repoProfileDeclarationPath, writeProfilePinSync } from "../lib/agent-profile-pin.ts";
10
+ import { createProfile, emptyProfilesFile, profilesFilePath, writeProfilesFileSync } from "../lib/agent-profiles.ts";
11
+ import type { AgentModelConfig } from "../lib/model-routing-authority.ts";
12
+ import { GENTLE_AI_DEV_BINARY_ENV, resolveGentleAiDevBinaryOverride } from "../lib/gentle-ai-binary.ts";
8
13
  import type { NativeReviewCli, NativeReviewUnachievableLensCaptureArtifact, NativeReviewUnachievableLensCaptureRequest } from "../lib/native-review-cli.ts";
9
14
  import { REVIEW_HOST_RELAY_FAILURE, REVIEW_HOST_RELAY_PI_TIMEOUT_ENV, REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS, REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE, REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, ReviewHostRelayError, type ReviewHostRelayRequest } from "../lib/review-host-relay.ts";
10
15
  import { decodeReviewNextTransitionV3, decodeReviewStatusV3, type ReviewArtifactSubjectV2, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3, type ReviewStatusV3 } from "../lib/review-integration-v2.ts";
16
+ import type { InProcessReviewerRegistry } from "../lib/inprocess-reviewer.ts";
11
17
 
12
18
  // One-slot capture routing: the host relay runs only when the selected
13
19
  // provider-returned collect input carries the --materialize token. Every
@@ -216,16 +222,133 @@ function nativeHarness(statuses: readonly ReviewStatusV3[], unachievableResponde
216
222
  return harness;
217
223
  }
218
224
 
219
- async function runCapture(cwd: string, harness: RoutingHarness, lineageId: string, input: Record<string, unknown> = { reviewerRunAcknowledged: true }): Promise<Record<string, unknown>> {
225
+ async function runCapture(cwd: string, harness: RoutingHarness, lineageId: string, input: Record<string, unknown> = { reviewerRunAcknowledged: true }, modelRegistry?: InProcessReviewerRegistry): Promise<Record<string, unknown>> {
220
226
  const selected = harness.statusQueue[0]?.nextTransition?.collect?.inputs[0];
221
227
  if (selected === undefined) throw new Error("capture test requires one current collect input");
222
228
  return await __testing.executeReviewCaptureOperation(
223
229
  { lineageId, collectBinding: JSON.stringify(selected), ...input },
224
230
  cwd,
225
231
  harness.native,
232
+ undefined, undefined, undefined, false, modelRegistry,
226
233
  ) as Record<string, unknown>;
227
234
  }
228
235
 
236
+ // Fake STATUS/relay evidence exercises facade admission, not native-provider E2E.
237
+ test("capture route recovery revalidates a single forecast after route loss", async (t) => {
238
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
239
+ const cwd = repository(t), lineageId = "recovered-single", input = relayCollectInput(lineageId, "review-risk", 0);
240
+ const selections = new Map(), current = finalizeStatus(lineageId, [input]);
241
+ const harness = nativeHarness([current, current, current]);
242
+ let relays = 0;
243
+ __testing.setReviewHostRelayRunnerForTesting(async () => {
244
+ relays += 1;
245
+ return { promptByteLength: 64, resultByteLength: 32, submission: '{"admission_decision":"completed"}' };
246
+ });
247
+ await __testing.executeReviewControllerOperation({ operation: "status", lineageId }, cwd, harness.native, undefined, null, undefined, selections);
248
+ const parameters = { lineageId, collectBinding: JSON.stringify(input) };
249
+ const forecast = await __testing.executeReviewCaptureOperation(parameters, cwd, harness.native, undefined, null, selections, true);
250
+ assert.equal(forecast.outcome, "reviewer-model-run-forecast");
251
+ assert.equal(relays, 0);
252
+ selections.clear();
253
+ const result = await __testing.executeReviewCaptureOperation({ ...parameters, reviewerRunAcknowledged: true }, cwd, harness.native, undefined, null, selections, true);
254
+ assert.equal(result.status, "captured");
255
+ assert.equal(relays, 1);
256
+ assert.equal(harness.statusCalls.length, 3);
257
+ assert.ok(harness.statusCalls.every((request) => request.agent === "pi"));
258
+ });
259
+
260
+ test("capture route recovery revalidates a complete group after forecast eviction", async (t) => {
261
+ t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
262
+ const cwd = repository(t), lineageId = "recovered-group", inputs = groupInputs(lineageId), selections = new Map();
263
+ const harness = nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), ...inputs.map((_input, index) => finalizeStatus(lineageId, inputs.slice(index))), finalizeStatus(lineageId)]);
264
+ let relays = 0, submissions = 0;
265
+ __testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { relays += requests.length; return requests.map(prepared); }, async () => {
266
+ submissions += 1;
267
+ return { promptByteLength: 64, resultByteLength: 32, submission: "{}" };
268
+ });
269
+ await __testing.executeReviewControllerOperation({ operation: "status", lineageId }, cwd, harness.native, undefined, null, undefined, selections);
270
+ const parameters = { lineageId, collectBindings: inputs.map((input) => JSON.stringify(input)) };
271
+ const forecast = await __testing.executeReviewCaptureGroupOperation(parameters, cwd, harness.native, undefined, null, selections, true);
272
+ assert.equal(forecast.outcome, "reviewer-model-run-forecast");
273
+ assert.equal(relays, 0);
274
+ selections.clear();
275
+ const result = await __testing.executeReviewCaptureGroupOperation({ ...parameters, reviewerRunAcknowledged: true }, cwd, harness.native, undefined, null, selections, true);
276
+ assert.equal(result.outcome, "native-reviewer-group-status-reconciled");
277
+ assert.equal(relays, 4);
278
+ assert.equal(submissions, 4);
279
+ assert.equal(harness.statusCalls.length, 8);
280
+ });
281
+
282
+ test("capture route recovery preserves the committed group selector after partial route loss", async (t) => {
283
+ const candidateViews = new CandidateViewRegistry();
284
+ t.after(() => { candidateViews.cleanupAll(); __testing.setReviewHostRelayGroupRunnersForTesting(); });
285
+ const cwd = repository(t), lineageId = "recovered-committed-group", inputs = groupInputs(lineageId), selections = new Map();
286
+ const view = candidateViews.create({ contributorRoot: cwd, baseRef: execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim(), committedOnly: true });
287
+ candidateViews.retain(view.token, lineageId);
288
+ const target = candidateViews.resolveProjection(lineageId, cwd), requests: Array<Record<string, unknown>> = [];
289
+ const statuses = [finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), ...inputs.map((_input, index) => finalizeStatus(lineageId, inputs.slice(index))), finalizeStatus(lineageId)];
290
+ const native = { targetStatus: async (request: Record<string, unknown>) => { requests.push(request); return statuses.shift()!; } } as unknown as NativeReviewCli;
291
+ __testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => requests.map(prepared), async () => ({ promptByteLength: 64, resultByteLength: 32, submission: "{}" }));
292
+ const parameters = { lineageId, collectBindings: inputs.map((input) => JSON.stringify(input)) };
293
+ const forecast = await __testing.executeReviewCaptureGroupOperation(parameters, cwd, native, undefined, candidateViews, selections, true);
294
+ assert.equal(forecast.outcome, "reviewer-model-run-forecast");
295
+ // Leave only a later matching route, rather than assuming the first survives.
296
+ for (const key of [...selections.keys()].slice(0, 3)) selections.delete(key);
297
+ const result = await __testing.executeReviewCaptureGroupOperation({ ...parameters, reviewerRunAcknowledged: true }, cwd, native, undefined, candidateViews, selections, true);
298
+ assert.equal(result.outcome, "native-reviewer-group-status-reconciled");
299
+ assert.equal(requests.length, 7);
300
+ assert.ok(requests.every((request) => request.baseRef === target.baseCommit && request.committedOnly === true && request.agent === "pi"));
301
+ });
302
+
303
+ test("capture route recovery rejects known workspace, lineage and selector route mismatches", async (t) => {
304
+ const cwd = repository(t), other = repository(t), lineageId = "known-route-boundaries", inputs = groupInputs(lineageId), selections = new Map();
305
+ const harness = nativeHarness([finalizeStatus(lineageId, inputs)]);
306
+ await __testing.executeReviewControllerOperation({ operation: "status", lineageId, input: JSON.stringify({ baseRef: "trusted-base", committedOnly: true }) }, cwd, harness.native, undefined, null, undefined, selections);
307
+ for (const parameters of [{ lineageId, workspaceRoot: other }, { lineageId: "foreign" }]) {
308
+ const single = await __testing.executeReviewCaptureOperation({ ...parameters, collectBinding: JSON.stringify(inputs[0]), reviewerRunAcknowledged: true }, cwd, harness.native, undefined, null, selections, true);
309
+ assert.equal(single.outcome, "capture-binding-rejected");
310
+ const group = await __testing.executeReviewCaptureGroupOperation({ ...parameters, collectBindings: inputs.map((input) => JSON.stringify(input)), reviewerRunAcknowledged: true }, cwd, harness.native, undefined, null, selections, true);
311
+ assert.equal(group.outcome, "capture-group-rejected");
312
+ }
313
+ const key = [...selections.keys()][1]!;
314
+ selections.set(key, { ...selections.get(key), baseRef: "wrong-base" });
315
+ const mixed = await __testing.executeReviewCaptureGroupOperation({ lineageId, collectBindings: inputs.map((input) => JSON.stringify(input)), reviewerRunAcknowledged: true }, cwd, harness.native, undefined, null, selections, true);
316
+ assert.equal(mixed.outcome, "capture-group-rejected");
317
+ assert.equal(harness.statusCalls.length, 1, "known route mismatches never retarget native STATUS or launch capture");
318
+ });
319
+
320
+ test("capture route recovery rejects fresh foreign, terminal, stale and malformed groups without relay", async (t) => {
321
+ t.after(() => { __testing.setReviewHostRelayRunnerForTesting(); __testing.setReviewHostRelayGroupRunnersForTesting(); });
322
+ const cwd = repository(t), lineageId = "recovery-negatives", inputs = groupInputs(lineageId);
323
+ let relays = 0;
324
+ __testing.setReviewHostRelayRunnerForTesting(async () => { relays += 1; throw new Error("unexpected relay"); });
325
+ __testing.setReviewHostRelayGroupRunnersForTesting(async () => { relays += 1; throw new Error("unexpected group"); }, async () => { throw new Error("unexpected submission"); });
326
+ const current = finalizeStatus(lineageId, inputs);
327
+ for (const fresh of [
328
+ finalizeStatus(lineageId), finalizeStatus("foreign", inputs),
329
+ { ...current, targetIdentity: `sha256:${"9".repeat(64)}` },
330
+ { ...current, applicability: "unrelated" },
331
+ { ...current, authority: { ...current.authority!, state: "approved" } },
332
+ finalizeStatus(lineageId, groupInputs(lineageId, PHASE_REVISION)),
333
+ ] as ReviewStatusV3[]) {
334
+ const single = await __testing.executeReviewCaptureOperation({ lineageId, collectBinding: JSON.stringify(inputs[0]), reviewerRunAcknowledged: true }, cwd, nativeHarness([fresh]).native, undefined, null, new Map(), true);
335
+ assert.equal(single.outcome, "capture-binding-rejected");
336
+ if (fresh.applicability !== "current_target" || fresh.authority?.state === "approved") {
337
+ const routes = new Map();
338
+ const listed = await __testing.executeReviewControllerOperation({ operation: "status", lineageId }, cwd, nativeHarness([fresh]).native, undefined, null, undefined, routes);
339
+ assert.deepEqual(listed.collectBindings, [], "publication follows route-registration eligibility");
340
+ assert.equal(routes.size, 0);
341
+ }
342
+ const group = await __testing.executeReviewCaptureGroupOperation({ lineageId, collectBindings: inputs.map((input) => JSON.stringify(input)), reviewerRunAcknowledged: true }, cwd, nativeHarness([fresh]).native, undefined, null, new Map(), true);
343
+ assert.equal(group.outcome, "capture-group-rejected");
344
+ }
345
+ for (const submitted of [inputs.slice(1), [inputs[0], inputs[0], ...inputs.slice(2)], [...inputs].reverse(), [...inputs.slice(0, 3), relayCollectInput("foreign", "review-reliability", 3)]]) {
346
+ const group = await __testing.executeReviewCaptureGroupOperation({ lineageId, collectBindings: submitted.map((input) => JSON.stringify(input)), reviewerRunAcknowledged: true }, cwd, nativeHarness([current]).native, undefined, null, new Map(), true);
347
+ assert.equal(group.outcome, "capture-group-rejected");
348
+ }
349
+ assert.equal(relays, 0);
350
+ });
351
+
229
352
  test("one materialize binding routes exactly one provider slot through the host relay", async (t) => {
230
353
  t.after(() => __testing.setReviewHostRelayRunnerForTesting());
231
354
  const cwd = repository(t);
@@ -315,7 +438,7 @@ test("Pi-authored review documents are rejected at the capture input boundary",
315
438
 
316
439
  function groupInputs(lineageId: string, revision = SHA): ReviewCollectInputV3[] { return ["review-risk", "review-resilience", "review-readability", "review-reliability"].map((lens, order) => relayCollectInput(lineageId, lens, order, true, "provider", revision)); }
317
440
 
318
- async function runCaptureGroup(cwd: string, harness: RoutingHarness, lineageId: string, inputs: readonly ReviewCollectInputV3[], reviewerRunAcknowledged = true): Promise<Record<string, unknown>> { return await __testing.executeReviewCaptureGroupOperation({ lineageId, collectBindings: inputs.map((input) => JSON.stringify(input)), reviewerRunAcknowledged }, cwd, harness.native) as Record<string, unknown>; }
441
+ async function runCaptureGroup(cwd: string, harness: RoutingHarness, lineageId: string, inputs: readonly ReviewCollectInputV3[], reviewerRunAcknowledged = true, modelRegistry?: InProcessReviewerRegistry): Promise<Record<string, unknown>> { return await __testing.executeReviewCaptureGroupOperation({ lineageId, collectBindings: inputs.map((input) => JSON.stringify(input)), reviewerRunAcknowledged }, cwd, harness.native, undefined, undefined, undefined, false, modelRegistry) as Record<string, unknown>; }
319
442
 
320
443
  function prepared(request: ReviewHostRelayRequest) { return { request, promptByteLength: 64, resultByteLength: 32 }; }
321
444
 
@@ -1005,3 +1128,216 @@ test("collect inputs without the provider-issued materialize token never reach t
1005
1128
  }
1006
1129
  assert.equal(relayCalls, 0);
1007
1130
  });
1131
+
1132
+ // gentle-shell#1544: the relay's reviewer selection must honor the repository
1133
+ // profile pin. These tests isolate the global config home via
1134
+ // GENTLE_PI_CONFIG_HOME so the pin's precedence over (and independence from)
1135
+ // models.json is observable without touching the operator's real
1136
+ // configuration; repository(t) supplies the Git identity the pin resolver reads.
1137
+ const GLOBAL_MODELS: AgentModelConfig = {
1138
+ "review-risk": { model: "global/risk-model" },
1139
+ "review-resilience": { model: "global/resilience-model" },
1140
+ "review-readability": { model: "global/readability-model" },
1141
+ "review-reliability": { model: "global/reliability-model" },
1142
+ };
1143
+
1144
+ const PINNED_COMPLETE: AgentModelConfig = {
1145
+ "review-risk": { model: "pinned/risk-model" },
1146
+ "review-resilience": { model: "pinned/resilience-model" },
1147
+ "review-readability": { model: "pinned/readability-model" },
1148
+ "review-reliability": { model: "pinned/reliability-model" },
1149
+ };
1150
+
1151
+ // A pin is a whole profile, not a per-role patch: this one deliberately omits
1152
+ // three reviewer roles that models.json still configures.
1153
+ const PINNED_PARTIAL: AgentModelConfig = { "review-risk": { model: "pinned/risk-model" } };
1154
+
1155
+ function pinFixture(t: test.TestContext): string {
1156
+ const configHome = mkdtempSync(join(tmpdir(), "gentle-pi-relay-pin-config-"));
1157
+ const previous = process.env.GENTLE_PI_CONFIG_HOME;
1158
+ const previousDevBinary = process.env[GENTLE_AI_DEV_BINARY_ENV];
1159
+ // Redirecting the config home hides the ambient dev-binary registration, and
1160
+ // the pinned runtime refuses a symlinked .gentle-ai directory; resolve the
1161
+ // override while the ambient config home is still visible, then carry it
1162
+ // through the env activation path so the real relay path resolves its
1163
+ // executable the same way it does outside this fixture.
1164
+ const devBinary = resolveGentleAiDevBinaryOverride()?.path;
1165
+ process.env.GENTLE_PI_CONFIG_HOME = configHome;
1166
+ if (devBinary !== undefined) process.env[GENTLE_AI_DEV_BINARY_ENV] = devBinary;
1167
+ t.after(() => {
1168
+ if (previous === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
1169
+ else process.env.GENTLE_PI_CONFIG_HOME = previous;
1170
+ if (previousDevBinary === undefined) delete process.env[GENTLE_AI_DEV_BINARY_ENV];
1171
+ else process.env[GENTLE_AI_DEV_BINARY_ENV] = previousDevBinary;
1172
+ rmSync(configHome, { recursive: true, force: true });
1173
+ });
1174
+ let store = emptyProfilesFile();
1175
+ store = createProfile(store, "pinned-complete", PINNED_COMPLETE);
1176
+ store = createProfile(store, "pinned-partial", PINNED_PARTIAL);
1177
+ writeProfilesFileSync(profilesFilePath(configHome), store);
1178
+ writeFileSync(join(configHome, "models.json"), `${JSON.stringify(GLOBAL_MODELS, null, 2)}\n`);
1179
+ return repository(t);
1180
+ }
1181
+
1182
+ function pinLocal(cwd: string, profile: string): void {
1183
+ writeProfilePinSync(localProfilePinPath(join(cwd, ".git")), profile);
1184
+ }
1185
+
1186
+ function pinRepoDeclaration(cwd: string, profile: string): void {
1187
+ writeProfilePinSync(repoProfileDeclarationPath(cwd), profile);
1188
+ }
1189
+
1190
+ test("a valid local profile pin routes a single capture's reviewer selection through the pinned profile", async (t) => {
1191
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
1192
+ const cwd = pinFixture(t);
1193
+ pinLocal(cwd, "pinned-complete");
1194
+ const lineageId = "relay-pin-single";
1195
+ const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)])]);
1196
+ const relayed: ReviewHostRelayRequest[] = [];
1197
+ __testing.setReviewHostRelayRunnerForTesting(async (request) => {
1198
+ relayed.push(request);
1199
+ return { promptByteLength: 64, resultByteLength: 32, submission: '{"admission_decision":"completed"}' };
1200
+ });
1201
+
1202
+ const result = await runCapture(cwd, harness, lineageId);
1203
+
1204
+ assert.equal(result.status, "captured");
1205
+ assert.equal(relayed.length, 1);
1206
+ assert.equal(relayed[0]!.routingKey, "review-risk");
1207
+ assert.equal(relayed[0]!.selection, "pinned/risk-model", "the pin re-anchors the reviewer routing away from models.json");
1208
+ });
1209
+
1210
+ test("a valid profile pin routes every grouped reviewer slot through the pinned profile", async (t) => {
1211
+ t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
1212
+ const cwd = pinFixture(t);
1213
+ pinLocal(cwd, "pinned-complete");
1214
+ const lineageId = "relay-pin-group";
1215
+ const inputs = groupInputs(lineageId);
1216
+ // After each submission the authoritative STATUS offers exactly the
1217
+ // unsubmitted suffix, so the loop's per-slot re-queries stay consistent.
1218
+ const harness = nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs.slice(1)), finalizeStatus(lineageId, inputs.slice(2)), finalizeStatus(lineageId, inputs.slice(3))]);
1219
+ const relayed: ReviewHostRelayRequest[] = [];
1220
+ let submissions = 0;
1221
+ __testing.setReviewHostRelayGroupRunnersForTesting(
1222
+ async (requests) => { relayed.push(...requests); return requests.map(prepared); },
1223
+ async (result) => {
1224
+ submissions += 1;
1225
+ return {
1226
+ promptByteLength: result.promptByteLength,
1227
+ resultByteLength: result.resultByteLength,
1228
+ submission: submissions === inputs.length
1229
+ ? JSON.stringify({ schema: "gentle-ai.review-last-event-closure/v1", operation: "review/capture-result", lineage_id: lineageId, state: "approved", store_revision: SHA, action: "native last event closed the review" })
1230
+ : "{}",
1231
+ };
1232
+ },
1233
+ );
1234
+
1235
+ const result = await runCaptureGroup(cwd, harness, lineageId, inputs);
1236
+
1237
+ assert.equal(result.status, "closed");
1238
+ assert.deepEqual(relayed.map((request) => [request.routingKey, request.selection]), [
1239
+ ["review-risk", "pinned/risk-model"],
1240
+ ["review-resilience", "pinned/resilience-model"],
1241
+ ["review-readability", "pinned/readability-model"],
1242
+ ["review-reliability", "pinned/reliability-model"],
1243
+ ]);
1244
+ });
1245
+
1246
+ test("without a pin the relay keeps the global models.json routing", async (t) => {
1247
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
1248
+ const cwd = pinFixture(t);
1249
+ const lineageId = "relay-pin-absent";
1250
+ const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)])]);
1251
+ const relayed: ReviewHostRelayRequest[] = [];
1252
+ __testing.setReviewHostRelayRunnerForTesting(async (request) => {
1253
+ relayed.push(request);
1254
+ return { promptByteLength: 64, resultByteLength: 32, submission: '{"admission_decision":"completed"}' };
1255
+ });
1256
+
1257
+ const result = await runCapture(cwd, harness, lineageId);
1258
+
1259
+ assert.equal(result.status, "captured");
1260
+ assert.equal(relayed[0]!.selection, "global/risk-model", "no pin is exactly the pre-pin global behavior");
1261
+ });
1262
+
1263
+ test("a repository profile declaration routes the relay when no local pin exists", async (t) => {
1264
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
1265
+ const cwd = pinFixture(t);
1266
+ pinRepoDeclaration(cwd, "pinned-complete");
1267
+ const lineageId = "relay-pin-repo-declaration";
1268
+ const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)])]);
1269
+ const relayed: ReviewHostRelayRequest[] = [];
1270
+ __testing.setReviewHostRelayRunnerForTesting(async (request) => {
1271
+ relayed.push(request);
1272
+ return { promptByteLength: 64, resultByteLength: 32, submission: '{"admission_decision":"completed"}' };
1273
+ });
1274
+
1275
+ const result = await runCapture(cwd, harness, lineageId);
1276
+
1277
+ assert.equal(result.status, "captured");
1278
+ assert.equal(relayed[0]!.selection, "pinned/risk-model", "the committed declaration reaches the reviewer routing through the same resolver");
1279
+ });
1280
+
1281
+ test("a stale pin naming a dropped profile falls back to the global routing", async (t) => {
1282
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
1283
+ const cwd = pinFixture(t);
1284
+ pinLocal(cwd, "ghost-profile");
1285
+ const lineageId = "relay-pin-stale";
1286
+ const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)])]);
1287
+ const relayed: ReviewHostRelayRequest[] = [];
1288
+ __testing.setReviewHostRelayRunnerForTesting(async (request) => {
1289
+ relayed.push(request);
1290
+ return { promptByteLength: 64, resultByteLength: 32, submission: '{"admission_decision":"completed"}' };
1291
+ });
1292
+
1293
+ const result = await runCapture(cwd, harness, lineageId);
1294
+
1295
+ assert.equal(result.status, "captured");
1296
+ assert.equal(relayed[0]!.selection, "global/risk-model", "a stale chain degrades to the global routing instead of blocking");
1297
+ });
1298
+
1299
+ test("a pinned profile missing a single-capture role sends no selection instead of borrowing models.json's role", async (t) => {
1300
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
1301
+ const cwd = pinFixture(t);
1302
+ pinLocal(cwd, "pinned-partial");
1303
+ const lineageId = "relay-pin-missing-single";
1304
+ const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-reliability", 0)])]);
1305
+ const relayed: ReviewHostRelayRequest[] = [];
1306
+ __testing.setReviewHostRelayRunnerForTesting(async (request) => {
1307
+ relayed.push(request);
1308
+ return { promptByteLength: 64, resultByteLength: 32, submission: '{"admission_decision":"completed"}' };
1309
+ });
1310
+
1311
+ await runCapture(cwd, harness, lineageId);
1312
+
1313
+ assert.equal(relayed.length, 1);
1314
+ assert.equal(relayed[0]!.selection, undefined, "whole-profile precedence: models.json must not fill the pinned profile's gaps");
1315
+ });
1316
+
1317
+ test("a pinned profile missing a required group role is refused typed before any reviewer launch", async (t) => {
1318
+ const cwd = pinFixture(t);
1319
+ pinLocal(cwd, "pinned-partial");
1320
+ const lineageId = "relay-pin-missing-group";
1321
+ // One group slot whose role the pin omits: models.json still configures it,
1322
+ // so a per-role fallback would launch instead of refusing typed.
1323
+ const inputs = [relayCollectInput(lineageId, "review-resilience", 0)];
1324
+ const harness = nativeHarness([finalizeStatus(lineageId, inputs)]);
1325
+ // Instrument the real model-start/auth boundary without bypassing the live
1326
+ // group runner: a reviewer model can only start after resolving through
1327
+ // registry.find and authenticating through registry.getApiKeyAndHeaders, so
1328
+ // counting those seams proves no model resolution, auth, or completion ran.
1329
+ let modelLookups = 0, authAttempts = 0;
1330
+ const registry: InProcessReviewerRegistry = {
1331
+ find: () => { modelLookups += 1; return undefined; },
1332
+ getApiKeyAndHeaders: async () => { authAttempts += 1; return { ok: true }; },
1333
+ };
1334
+
1335
+ const result = await runCaptureGroup(cwd, harness, lineageId, inputs, true, registry);
1336
+
1337
+ assert.equal(result.outcome, "pi-host-relay-transport-failure");
1338
+ const failure = result.failure as { kind?: string } | undefined;
1339
+ assert.equal(failure?.kind, "reviewer-config-invalid");
1340
+ assert.match(String(result.reason), /no model is configured for review-resilience/, "whole-profile precedence: models.json must not fill the pinned profile's gaps");
1341
+ assert.equal(modelLookups, 0, "the typed refusal fires before any model resolution");
1342
+ assert.equal(authAttempts, 0, "the typed refusal fires before any provider auth");
1343
+ });