gentle-pi 4.1.0 → 4.3.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 (249) hide show
  1. package/README.md +352 -128
  2. package/assets/agents/gentle-ai-explore.md +1 -0
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -1
  5. package/assets/orchestrator-delegation-reference.md +163 -0
  6. package/assets/orchestrator-delegation.md +25 -176
  7. package/assets/orchestrator-tracking.md +1 -1
  8. package/assets/orchestrator-verification.md +14 -8
  9. package/assets/orchestrator-writer.md +5 -1
  10. package/assets/orchestrator.md +9 -9
  11. package/bin/engram-guard +1 -1
  12. package/bin/gentle-shell.mjs +188 -35
  13. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  14. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  15. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  16. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  17. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  18. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  19. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  20. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  21. package/docs/assets/diagrams/measured-routing.svg +49 -0
  22. package/docs/assets/diagrams/native-review.svg +52 -12
  23. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  24. package/docs/assets/diagrams/permission-modes.svg +51 -0
  25. package/docs/assets/features/audio-notifications.png +0 -0
  26. package/docs/assets/features/command-palette.png +0 -0
  27. package/docs/auto-permission-answers.md +25 -0
  28. package/docs/auto-permission-evidence.md +39 -0
  29. package/docs/bundled-install.md +40 -8
  30. package/docs/delegated-verification.md +10 -8
  31. package/docs/gentle-agents-activity.md +11 -5
  32. package/docs/gentle-shell-uninstall.md +8 -1
  33. package/docs/gentle-shell.md +20 -12
  34. package/docs/install-wizard.md +17 -10
  35. package/docs/permission-modes.md +51 -6
  36. package/docs/readme-reference.md +155 -31
  37. package/docs/session-profile-format.md +39 -0
  38. package/docs/yolo-mode.md +21 -7
  39. package/extensions/ask-user-choice.ts +4 -0
  40. package/extensions/ask-user-question.ts +6 -1
  41. package/extensions/codegraph-tools.ts +68 -6
  42. package/extensions/gentle-agents.ts +146 -28
  43. package/extensions/gentle-ai.ts +380 -68
  44. package/extensions/gentle-herdr-activity.ts +2 -3
  45. package/extensions/gentle-shell.ts +53 -43
  46. package/extensions/gentle-todo.ts +62 -18
  47. package/extensions/quiet-tools.ts +1 -1
  48. package/extensions/session-scratch.ts +365 -0
  49. package/extensions/startup-banner.ts +2 -3
  50. package/lib/agent-home.ts +8 -3
  51. package/lib/agents-protocol.ts +3 -0
  52. package/lib/agents-runner.ts +55 -6
  53. package/lib/agents-view.ts +3 -1
  54. package/lib/auto-permission-answers.ts +133 -0
  55. package/lib/auto-permission-classifier.ts +215 -36
  56. package/lib/auto-permission-evidence.ts +88 -0
  57. package/lib/auto-permission-memory.ts +69 -0
  58. package/lib/auto-permission-runtime.ts +251 -35
  59. package/lib/auto-permission-tool-context.ts +61 -0
  60. package/lib/bounded-writer-admission.ts +51 -0
  61. package/lib/command-palette-catalog.ts +0 -4
  62. package/lib/commit-slice-signal.ts +100 -0
  63. package/lib/context-backstop.ts +84 -0
  64. package/lib/delivered-test-lint.ts +80 -0
  65. package/lib/destructive-command-guard.ts +215 -28
  66. package/lib/gauge-theme.ts +146 -0
  67. package/lib/gentle-ai-binary.ts +28 -7
  68. package/lib/gentle-shell-bundled.ts +62 -8
  69. package/lib/gentle-shell-import-pi.ts +233 -0
  70. package/lib/gentle-shell-launcher.ts +146 -12
  71. package/lib/gentle-shell-uninstall.ts +18 -1
  72. package/lib/herdr-activity.ts +24 -0
  73. package/lib/model-routing-authority.ts +6 -1
  74. package/lib/native-review-cli.ts +9 -1
  75. package/lib/orchestrator-discovery.ts +5 -1
  76. package/lib/orchestrator-presence.ts +9 -0
  77. package/lib/orchestrator-scope.ts +21 -3
  78. package/lib/provider-contract-bundle.ts +99 -53
  79. package/lib/review-candidate-view-owner.ts +29 -19
  80. package/lib/review-candidate-view.ts +36 -2
  81. package/lib/review-risk-assessment.ts +43 -38
  82. package/lib/review-session-standing-permission.ts +3 -1
  83. package/lib/selection-engine.ts +4 -1
  84. package/lib/session-profile-freeze.ts +40 -16
  85. package/lib/session-profile-persistence.ts +187 -0
  86. package/lib/session-scratch.ts +474 -0
  87. package/lib/shell-bar.ts +25 -25
  88. package/lib/shell-changes-view.ts +80 -13
  89. package/lib/shell-changes.ts +2 -0
  90. package/lib/shell-gauge.ts +49 -6
  91. package/lib/shell-prompt.ts +6 -4
  92. package/lib/shell-todo.ts +39 -8
  93. package/lib/shell-usage-view.ts +5 -1
  94. package/lib/shell-usage.ts +7 -7
  95. package/lib/verifier-admission.ts +29 -0
  96. package/lib/vim-editor-adapter.ts +22 -4
  97. package/lib/yolo-temp-scratch.ts +69 -0
  98. package/package.json +5 -6
  99. package/runtime/gentle-ai-binary.mjs +28 -7
  100. package/runtime/gentle-shell-bundled.mjs +61 -7
  101. package/runtime/gentle-shell-import-pi.mjs +234 -0
  102. package/runtime/gentle-shell-launcher.mjs +144 -10
  103. package/runtime/gentle-shell-uninstall.mjs +18 -1
  104. package/runtime/native-review-cli.mjs +8 -0
  105. package/runtime/review-risk-assessment.mjs +43 -38
  106. package/scripts/build-runtime-modules.mjs +1 -0
  107. package/scripts/bundled-engram-migration.mjs +125 -0
  108. package/scripts/gentle-ai-installer.mjs +10 -10
  109. package/scripts/install-wizard-preview.mjs +2 -2
  110. package/scripts/installer-downloads.mjs +7 -7
  111. package/scripts/installer-preflight.mjs +1 -1
  112. package/scripts/installer-server.mjs +14 -4
  113. package/scripts/main-channel.mjs +3 -2
  114. package/scripts/pi-update.mjs +223 -0
  115. package/scripts/run-test-suite.mjs +5 -0
  116. package/scripts/verify-package-files.mjs +19 -13
  117. package/skills/gentle-ai/SKILL.md +3 -3
  118. package/skills/work-unit-commits/SKILL.md +5 -3
  119. package/tests/agent-risk-escalation.test.ts +37 -10
  120. package/tests/agents-runner.test.ts +260 -3
  121. package/tests/append-system-prompt-route.test.ts +18 -1
  122. package/tests/auto-mode-human-answers.test.ts +68 -0
  123. package/tests/auto-mode-invariants.test.ts +23 -5
  124. package/tests/auto-mode-sdk.test.ts +29 -2
  125. package/tests/auto-permission-answers.test.ts +170 -0
  126. package/tests/auto-permission-classifier.test.ts +353 -2
  127. package/tests/auto-permission-compound.test.ts +24 -0
  128. package/tests/auto-permission-evidence.test.ts +88 -0
  129. package/tests/auto-permission-memory.test.ts +46 -0
  130. package/tests/auto-permission-public-regressions.test.ts +111 -0
  131. package/tests/auto-permission-runtime.test.ts +270 -12
  132. package/tests/autonomous-guard.test.ts +208 -16
  133. package/tests/bounded-writer-admission.test.ts +11 -3
  134. package/tests/bundled-install.test.ts +3 -3
  135. package/tests/card-content-policy.test.ts +19 -9
  136. package/tests/child-safety.test.ts +39 -0
  137. package/tests/codegraph-tools.test.ts +123 -2
  138. package/tests/command-palette.test.ts +14 -4
  139. package/tests/commit-slice-signal.test.ts +162 -0
  140. package/tests/context-backstop.test.ts +159 -0
  141. package/tests/delegate-for-reason-contract.test.ts +91 -24
  142. package/tests/delivered-test-lint.test.ts +105 -0
  143. package/tests/destructive-command-guard.test.ts +85 -1
  144. package/tests/engram-safety.test.ts +35 -7
  145. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  146. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  147. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  148. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  149. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  150. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  151. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  152. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  153. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  154. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  155. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  156. package/tests/gauge-theme.test.ts +120 -0
  157. package/tests/gentle-agents.test.ts +443 -8
  158. package/tests/gentle-ai-binary.test.ts +1 -1
  159. package/tests/gentle-ai-child-guards.test.ts +61 -0
  160. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  161. package/tests/gentle-ai-installer.test.ts +51 -51
  162. package/tests/gentle-shell-bin.test.ts +180 -17
  163. package/tests/gentle-shell-bundled.test.ts +235 -38
  164. package/tests/gentle-shell-import-pi.test.ts +262 -0
  165. package/tests/gentle-shell-launcher.test.ts +97 -13
  166. package/tests/gentle-shell-uninstall.test.ts +30 -0
  167. package/tests/gentle-shell.test.ts +113 -14
  168. package/tests/gentle-todo.test.ts +258 -27
  169. package/tests/headless-delivery-question.test.ts +58 -0
  170. package/tests/herdr-activity.test.ts +34 -1
  171. package/tests/install-wizard.test.ts +2 -2
  172. package/tests/installer-posix-bootstrap.test.ts +8 -8
  173. package/tests/installer-preflight.test.ts +3 -3
  174. package/tests/installer-probes.test.ts +8 -8
  175. package/tests/installer-runner.test.ts +14 -13
  176. package/tests/installer-server.test.ts +13 -5
  177. package/tests/installer-windows-bootstrap.test.ts +33 -4
  178. package/tests/main-channel-upgrade.test.ts +7 -1
  179. package/tests/native-review-capability-contract.test.ts +7 -1
  180. package/tests/native-review-parity.test.ts +22 -1
  181. package/tests/no-self-pause-contract.test.ts +5 -2
  182. package/tests/odd-integration.test.ts +5 -1
  183. package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
  184. package/tests/odd-routing-contract.test.ts +115 -30
  185. package/tests/odd-runtime-delegation-gate.test.ts +4 -1
  186. package/tests/orchestrator-budget.test.ts +19 -8
  187. package/tests/orchestrator-discovery.test.ts +29 -0
  188. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  189. package/tests/orchestrator-scope.test.ts +66 -0
  190. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  191. package/tests/package-manifest.test.ts +28 -24
  192. package/tests/pi-update-launcher.test.ts +106 -0
  193. package/tests/pi-update.test.ts +186 -0
  194. package/tests/primary-session-prompt.test.ts +101 -0
  195. package/tests/provider-contract-bundle.test.ts +118 -0
  196. package/tests/provider-contract-mirror.test.ts +24 -0
  197. package/tests/quiet-tool-rendering.test.ts +23 -0
  198. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  199. package/tests/rdd-status-line.test.ts +131 -0
  200. package/tests/review-agent-end-preflight.test.ts +6 -0
  201. package/tests/review-candidate-view.test.ts +49 -0
  202. package/tests/review-contract-prompt.test.ts +5 -2
  203. package/tests/review-controller-native-routing.test.ts +14 -1
  204. package/tests/review-controller.test.ts +1 -1
  205. package/tests/review-host-relay-routing.test.ts +59 -2
  206. package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
  207. package/tests/review-owner-identity.test.ts +132 -0
  208. package/tests/review-risk-assessment.test.ts +163 -76
  209. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  210. package/tests/review-session-standing-permission.test.ts +27 -1
  211. package/tests/run-test-suite.test.ts +15 -0
  212. package/tests/runtime-harness.mjs +16 -6
  213. package/tests/selection-engine.test.ts +35 -5
  214. package/tests/session-changes-shell.test.ts +1 -1
  215. package/tests/session-profile-freeze.test.ts +37 -1
  216. package/tests/session-profile-persistence.test.ts +334 -0
  217. package/tests/session-scratch.test.ts +945 -0
  218. package/tests/session-subject-routing-contract.test.ts +2 -1
  219. package/tests/shell-bar.test.ts +7 -0
  220. package/tests/shell-changes-view.test.ts +137 -1
  221. package/tests/shell-changes.test.ts +18 -0
  222. package/tests/shell-gauge.test.ts +42 -0
  223. package/tests/shell-todo.test.ts +15 -12
  224. package/tests/small-task-inline-contract.test.ts +37 -0
  225. package/tests/standalone-config-home.test.ts +324 -0
  226. package/tests/startup-banner.test.ts +5 -4
  227. package/tests/startup-home-repo.test.ts +34 -0
  228. package/tests/support/orchestrator-modules.ts +1 -0
  229. package/tests/task-size-routing-contract.test.ts +69 -9
  230. package/tests/test-scratch-contract.test.ts +102 -0
  231. package/tests/test-strength-contract.test.ts +36 -1
  232. package/tests/tool-batching-contract.test.ts +64 -0
  233. package/tests/verification-proportionality.test.ts +46 -0
  234. package/tests/verifier-admission.test.ts +137 -0
  235. package/tests/verify-agent-contract.test.ts +8 -8
  236. package/tests/vim-editor-adapter.test.ts +41 -15
  237. package/tests/windows-hidden-processes.test.ts +5 -0
  238. package/tests/writer-edit-surface-scope.test.ts +3 -0
  239. package/tests/writer-feature-document-admission.test.ts +156 -0
  240. package/tests/yolo-mode.test.ts +24 -1
  241. package/tests/yolo-temp-scratch.test.ts +71 -0
  242. package/scripts/mirror-odd-routing.mjs +0 -242
  243. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  244. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  245. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  246. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  247. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  248. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  249. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -133,13 +133,13 @@ test("public docs and metadata advertise ODD and review without retired phase wo
133
133
 
134
134
  test("technical reference declares the tested Pi minimum required for agent_settled", () => {
135
135
  const manifest = readPackageJson();
136
- assert.equal(manifest.peerDependencies?.["@earendil-works/pi-coding-agent"], ">=0.99.1");
137
- assert.equal(manifest.devDependencies?.["@earendil-works/pi-coding-agent"], ">=1.0.0");
136
+ assert.equal(manifest.peerDependencies?.["@earendil-works/pi-coding-agent"], ">=1.2.0");
137
+ assert.equal(manifest.devDependencies?.["@earendil-works/pi-coding-agent"], ">=1.2.0");
138
138
  assert.equal(manifest.peerDependenciesMeta?.["@earendil-works/pi-coding-agent"]?.optional, true);
139
139
  assert.equal(manifest.engines?.node, ">=22.19.0");
140
140
  for (const path of ["docs/readme-reference.md", "docs/gentle-shell.md"]) {
141
141
  const source = readFileSync(join(PACKAGE_ROOT, path), "utf8");
142
- assert.match(source, /Pi 0\.99\.1 or newer/, path);
142
+ assert.match(source, /Pi 1\.2\.0 or newer/, path);
143
143
  assert.match(source, /open `>=1\.0\.0` development range/, path);
144
144
  assert.doesNotMatch(source, /development tests pin Pi/, path);
145
145
  // Docs name exactly the audited Vim editor releases, never a future one.
@@ -162,7 +162,7 @@ test("packed runtime uses optional Pi host peers with one open development range
162
162
  // one exact release shared by every Pi host package.
163
163
  const installed = new Set<string>();
164
164
  for (const name of ["@earendil-works/pi-coding-agent", "@earendil-works/pi-ai", "@earendil-works/pi-tui"]) {
165
- assert.equal(manifest.devDependencies?.[name], ">=1.0.0", name);
165
+ assert.equal(manifest.devDependencies?.[name], ">=1.2.0", name);
166
166
  const metadata = JSON.parse(readFileSync(join(PACKAGE_ROOT, "node_modules", name, "package.json"), "utf8")) as { name: string; version: string };
167
167
  assert.equal(metadata.name, name);
168
168
  installed.add(metadata.version);
@@ -232,7 +232,7 @@ test("package verification excludes the retired init extension while retaining O
232
232
  assert.equal(existsSync(join(PACKAGE_ROOT, "extensions", "sdd-init.ts")), false);
233
233
  assert.doesNotMatch(verifier, /^\s*"extensions\/sdd-init\.ts",?$/m);
234
234
  assert.match(verifier, /existsSync\(join\(root, "extensions\/sdd-init\.ts"\)\)/);
235
- for (const resource of ["extensions/gentle-ai.ts", "extensions/skill-registry.ts", "assets/orchestrator.md", "assets/orchestrator-delegation.md", "assets/agents/gentle-ai-worker.md", "assets/agents/review-risk.md", "assets/chains/4r-review.chain.md"]) {
235
+ for (const resource of ["extensions/gentle-ai.ts", "extensions/skill-registry.ts", "assets/orchestrator.md", "assets/orchestrator-delegation.md", "assets/orchestrator-delegation-reference.md", "assets/agents/gentle-ai-worker.md", "assets/agents/review-risk.md", "assets/chains/4r-review.chain.md"]) {
236
236
  assert.ok(verifier.includes(`"${resource}"`), `${resource} must remain required`);
237
237
  }
238
238
  });
@@ -431,20 +431,20 @@ test("package manifest installs pi-pretty through a wrapper without bundling nat
431
431
  );
432
432
  });
433
433
 
434
- test("package verification binds the published Gentle AI v4.1.1 runtime pin", () => {
434
+ test("package verification binds the published Gentle AI v4.2.0 runtime pin", () => {
435
435
  const installer = readFileSync(join(PACKAGE_ROOT, "scripts", "gentle-ai-installer.mjs"), "utf8");
436
436
  const binary = readFileSync(join(PACKAGE_ROOT, "lib", "gentle-ai-binary.ts"), "utf8");
437
437
  const verifier = readFileSync(join(PACKAGE_ROOT, "scripts", "verify-package-files.mjs"), "utf8");
438
438
 
439
- assert.match(installer, /INSTALLER_VERSION = "4\.1\.1"/);
439
+ assert.match(installer, /INSTALLER_VERSION = "4\.2\.0"/);
440
440
  assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_PACKAGE.*GENTLE_AI_WINDOWS_SOURCE_MODULE/);
441
- assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:\+WmZ2IvIOiJ8qtHa7wsjnYKNasem\+PByeOZ6HIjoAL8="/);
441
+ assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:J8EluS7QrfM\+m3vmQlF26B\/shZRjD6rBfsKtrABL5mc="/);
442
442
  assert.match(installer, /GOTOOLCHAIN: "local"/);
443
443
  assert.match(installer, /GOSUMDB: "sum\.golang\.org"/);
444
444
  assert.match(binary, /GENTLE_AI_VERSION = INSTALLER_VERSION/);
445
445
  assert.match(binary, /GO_SUMDB_SOURCE_BUILD/);
446
446
  assert.match(binary, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM/);
447
- assert.match(verifier, /v4\.1\.1/);
447
+ assert.match(verifier, /v4\.2\.0/);
448
448
  });
449
449
 
450
450
 
@@ -1722,27 +1722,29 @@ test("normal and forced installation copy generic agents with complete role cont
1722
1722
  });
1723
1723
 
1724
1724
  test("bounded implementation routing resolves the explicit canonical fallback reference", () => {
1725
- const routing = readFileSync(
1726
- join(PACKAGE_ROOT, "assets", "orchestrator-delegation.md"),
1727
- "utf8",
1728
- );
1729
- const reference = "For bounded writes, follow the canonical Writer rule under Mandatory Delegation Triggers.";
1725
+ // S11 T2: the Delegation Triggers live in the delegation card and the Work Routing Ladder (with its
1726
+ // Simple Delegation pointer back to them) in the reference, so the pointer resolves across both files.
1727
+ const routing = ["orchestrator-delegation.md", "orchestrator-delegation-reference.md"]
1728
+ .map((file) => readFileSync(join(PACKAGE_ROOT, "assets", file), "utf8"))
1729
+ .join("\n\n");
1730
+ // S9 canon change (gentle-ai 87a3b2eb): the canonical heading is "Delegation Triggers" (no "Mandatory").
1731
+ const reference = "For bounded writes, follow the canonical Writer rule under Delegation Triggers.";
1730
1732
  const resolveSimpleDelegation = (source: string): string => {
1731
1733
  const simpleDelegation = readMarkdownSection(source, "2. Simple Delegation");
1732
1734
  assert.ok(simpleDelegation.split("\n").includes(reference), "Simple Delegation must name the exact canonical Writer rule");
1733
- const canonical = readMarkdownSection(source, "Mandatory Delegation Triggers");
1735
+ const canonical = readMarkdownSection(source, "Delegation Triggers");
1734
1736
  assertWorkerFallbackRouting(canonical, "resolved Simple Delegation");
1735
1737
  return canonical;
1736
1738
  };
1737
- const mandatoryDelegation = readMarkdownSection(routing, "Mandatory Delegation Triggers");
1739
+ const delegationTriggers = readMarkdownSection(routing, "Delegation Triggers");
1738
1740
 
1739
1741
  assertWorkerFallbackRouting(resolveSimpleDelegation(routing), "Simple Delegation");
1740
- assertWorkerFallbackRouting(mandatoryDelegation, "Mandatory Delegation Triggers");
1742
+ assertWorkerFallbackRouting(delegationTriggers, "Delegation Triggers");
1741
1743
  assert.throws(() => resolveSimpleDelegation(routing.replace(reference, "")), /must name the exact canonical Writer rule/);
1742
- assert.throws(() => resolveSimpleDelegation(routing.replace(reference, reference.replace("Mandatory Delegation Triggers", "Other Rule"))),
1744
+ assert.throws(() => resolveSimpleDelegation(routing.replace(reference, reference.replace("Delegation Triggers", "Other Rule"))),
1743
1745
  /must name the exact canonical Writer rule/);
1744
- assert.throws(() => resolveSimpleDelegation(routing.replace("#### Mandatory Delegation Triggers", "#### Missing Canonical Rule")),
1745
- /exactly one Mandatory Delegation Triggers section/);
1746
+ assert.throws(() => resolveSimpleDelegation(routing.replace("#### Delegation Triggers", "#### Missing Canonical Rule")),
1747
+ /exactly one Delegation Triggers section/);
1746
1748
  assert.throws(() => resolveSimpleDelegation(routing.replace("user-configured `worker`", "unspecified worker")),
1747
1749
  /must prefer the package-owned worker before a user-configured worker/);
1748
1750
  assert.doesNotMatch(
@@ -1758,8 +1760,10 @@ test("bounded implementation routing resolves the explicit canonical fallback re
1758
1760
  });
1759
1761
 
1760
1762
  test("orchestrator routes generic roles without static RDD lens routing", () => {
1761
- for (const file of ["orchestrator.md", "orchestrator-delegation.md"]) {
1762
- const routing = readFileSync(join(PACKAGE_ROOT, "assets", file), "utf8");
1763
+ // S11 T2: the explorer fallback binding moved from the delegation card to its reference; the card and
1764
+ // the reference together are the lazy delegation route.
1765
+ for (const files of [["orchestrator.md"], ["orchestrator-delegation.md", "orchestrator-delegation-reference.md"]]) {
1766
+ const routing = files.map((file) => readFileSync(join(PACKAGE_ROOT, "assets", file), "utf8")).join("\n\n");
1763
1767
  assert.match(routing, /`gentle-ai-explore`/);
1764
1768
  assert.match(routing, /`gentle-ai-worker`/);
1765
1769
  assert.match(routing, /`gentle-ai-verify`/);
@@ -1789,9 +1793,9 @@ test("pi-pretty wrapper uses cached ESM loading for compiled and pnpm symlink in
1789
1793
  assert.match(wrapper, /quietToolsEnabled/);
1790
1794
  });
1791
1795
 
1792
- test("Gentle Shell v4.1.0 package manifest declares the release version", () => {
1796
+ test("Gentle Shell v4.3.0 package manifest declares the release version", () => {
1793
1797
  const packageJson = readPackageJson();
1794
- assert.equal(packageJson.version, "4.1.0", "the release manifest must be explicitly pinned to v4.1.0");
1798
+ assert.equal(packageJson.version, "4.3.0", "the release manifest must be explicitly pinned to v4.3.0");
1795
1799
  assert.equal(packageJson.scripts?.test, "node scripts/run-test-suite.mjs");
1796
1800
  assert.ok(packageJson.files?.includes("assets/"));
1797
1801
  assert.ok(packageJson.files?.includes("contracts/"));
@@ -0,0 +1,106 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { test } from 'node:test';
4
+
5
+ const PI = '@earendil-works/pi-coding-agent';
6
+ // A virtual symlink filesystem: listed global links point into a pnpm store.
7
+ // The store's adjacent peer is a sibling, not Shell/node_modules/Pi.
8
+ function launch(args: string[], scenario = 'owned', piVersion = '1.2.0') {
9
+ const adjacent = scenario.startsWith('adjacent');
10
+ const npm = scenario.startsWith('npm-');
11
+ const nodeModules = '/global/v11/selected/node_modules';
12
+ const shellRoot = '/store/shell-peer/node_modules/gentle-pi';
13
+ const piRoot = adjacent ? '/store/shell-peer/node_modules/' + PI : '/store/pi/node_modules/' + PI;
14
+ const target = adjacent ? 'gentle-pi' : PI;
15
+ const dependencies: Record<string, object> = {[target]:{path: `${nodeModules}/${target}`,
16
+ ...(scenario === 'npm-link' ? {link:true} : {}),
17
+ ...(scenario === 'npm-file' ? {resolved:'file:/source/pi'} : {})}};
18
+ if (scenario.endsWith('shared')) dependencies['unrelated'] = {path:`${nodeModules}/unrelated`};
19
+ if (scenario === 'separate') dependencies['unrelated'] = {path:'/global/v11/other/node_modules/unrelated'};
20
+ const groups = [{path:'/global/v11',dependencies}, ...(scenario === 'ambiguous' ? [{path:'/global/other',dependencies}] : [])];
21
+ const listing = scenario === 'malformed' ? '{' : JSON.stringify(npm ? groups[0] : groups);
22
+ // pnpm links only the top-level package into the global group; the peer
23
+ // is reachable solely as the Shell package's store sibling.
24
+ const links = {[`${nodeModules}/${target}`]:adjacent ? shellRoot : piRoot};
25
+ const roots = [nodeModules, '/global/v11/other/node_modules', shellRoot, piRoot, `${piRoot}/dist/bundle/cli.js`];
26
+ const bootstrap = `import { registerHooks } from 'node:module';
27
+ const original = ${JSON.stringify(new URL('../bin/gentle-shell.mjs', import.meta.url).href)};
28
+ registerHooks({load(url, context, next) {
29
+ if (url === original) {
30
+ let source = next(url, context).source.toString();
31
+ source = source.replace('const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));', ${JSON.stringify(`const packageRoot = '${shellRoot}';`)});
32
+ source = source.replace('const bundled = resolveBundledInstall({ packageRoot, platform: process.platform, fs: bundledFs, verifying });', 'const bundled = undefined;');
33
+ source = source.replace('runtime = resolvePiRuntime({', ${JSON.stringify(`runtime = ({ kind: '${adjacent ? 'bundled' : 'env'}', command: '${adjacent ? 'node' : piRoot + '/dist/bundle/cli.js'}', args: ${JSON.stringify(adjacent ? [piRoot + '/dist/bundle/cli.js'] : [])} }); /*`)}).replace('nodeExecPath: process.execPath,\\n\\t\\t});', 'nodeExecPath: process.execPath, */');
34
+ source = source.replace('const versionProbe = spawnSync(versionProbePlan.command, versionProbePlan.args, {', 'const versionProbe = { stdout: "${piVersion}" }; /*').replace('env: bundledEnv ?? process.env,\\n\\t});', 'env: bundledEnv ?? process.env, */');
35
+ source = source.replace('async function packageManagerAdapters() {', 'async function packageManagerAdapters() { return globalThis.fakeAdapters; } async function unusedAdapters() {');
36
+ source = source.replace('let homeHadContentBeforeBootstrap = false;', 'if (passthrough.length === 1) { console.error("error: pi cannot self-update this installation. This installation is not managed by a global pnpm install."); process.exitCode = 1; return; } console.log("FORWARD " + JSON.stringify(passthrough)); return; let homeHadContentBeforeBootstrap = false;');
37
+ return {format:'module', source, shortCircuit:true};
38
+ }
39
+ return next(url,context);
40
+ }});
41
+ const links = ${JSON.stringify(links)}, roots = ${JSON.stringify(roots)};
42
+ globalThis.fakeAdapters = {
43
+ fs: { realpath: async p => { if (links[p]) return links[p]; if (roots.includes(p)) return p; throw new Error('ENOENT ' + p); },
44
+ readFile: async p => { if (p === '${shellRoot}/package.json') return JSON.stringify({name:'gentle-pi',version:'4.1.0'}); if(p === '${piRoot}/package.json') return JSON.stringify({name:'${PI}',version:'1.2.0',bin:{pi:'dist/bundle/cli.js'}}); throw new Error('ENOENT ' + p); } },
45
+ which: async n => n === '${npm ? 'npm' : 'pnpm'}' ? '/fake/' + n : null,
46
+ run: async (cmd, args) => {
47
+ if(args.includes('list') || args.includes('ls')) return {code:0,stdout:${JSON.stringify(listing)}};
48
+ console.log('MUTATION ' + JSON.stringify(args)); return {code:0};
49
+ }
50
+ };
51
+ await import(original);`;
52
+ return spawnSync(process.execPath, ['--import', `data:text/javascript,${encodeURIComponent(bootstrap)}`, 'bin/gentle-shell.mjs', ...args], {encoding:'utf8'});
53
+ }
54
+
55
+ for (const scenario of ['owned','adjacent','separate']) test(`public linked-store update succeeds for ${scenario}`, () => {
56
+ const result = launch(['update'], scenario);
57
+ assert.equal(result.status, 0, result.stderr);
58
+ assert.match(result.stdout, scenario === 'adjacent' ? /MUTATION \["add","-g","gentle-pi@4\.1\.0","--allow-build=gentle-pi"\]/ : /MUTATION \["add","-g","@earendil-works\/pi-coding-agent@latest"\]/);
59
+ assert.doesNotMatch(result.stdout, /FORWARD/);
60
+ // The fake manager changes nothing, so the readback reports the same version and pnpm's release-age rule.
61
+ assert.match(result.stdout, /the selected Pi is still 1\.2\.0\./);
62
+ assert.match(result.stdout, /minimumReleaseAge/);
63
+ assert.equal(result.stderr, '');
64
+ });
65
+
66
+ test('public all updates only its owner and then forwards extension updates', () => {
67
+ const result = launch(['update', '--all']);
68
+ assert.equal(result.status, 0, result.stderr);
69
+ assert.match(result.stdout, /MUTATION .*add.*@earendil-works\/pi-coding-agent@latest/);
70
+ assert.match(result.stdout, /FORWARD \["update","--extensions"\]/);
71
+ assert.equal(result.stderr, '');
72
+ });
73
+
74
+ for (const scenario of ['ambiguous', 'malformed', 'npm-link', 'npm-file', 'shared', 'adjacent-shared']) test(`public update rejects ${scenario} ownership without mutation`, () => {
75
+ const result = launch(['update'], scenario);
76
+ assert.equal(result.status, 1, result.stdout);
77
+ assert.equal(result.stdout, '');
78
+ assert.match(result.stderr, /ownership/);
79
+ assert.doesNotMatch(result.stdout + result.stderr, /MUTATION/);
80
+ });
81
+
82
+ // `update` is how an old Pi gets replaced, so the minimum-version gate must not block it.
83
+ test('public update replaces a Pi older than the required minimum', () => {
84
+ const result = launch(['update'], 'owned', '1.1.0');
85
+ assert.equal(result.status, 0, result.stderr);
86
+ assert.match(result.stdout, /MUTATION \["add","-g","@earendil-works\/pi-coding-agent@latest"\]/);
87
+ assert.doesNotMatch(result.stdout + result.stderr, /older than the required minimum/);
88
+ assert.equal(result.stderr, '');
89
+ });
90
+
91
+ test('an old Pi still fails the minimum-version gate outside a self update', () => {
92
+ for (const args of [['list'], ['update', '--extensions-only']]) {
93
+ const result = launch(args, 'owned', '1.1.0');
94
+ assert.equal(result.status, 1, result.stdout);
95
+ assert.equal(result.stdout, '');
96
+ assert.match(result.stderr, /pi version 1\.1\.0 is older than the required minimum 1\.2\.0\./);
97
+ assert.doesNotMatch(result.stdout, /MUTATION/);
98
+ }
99
+ });
100
+
101
+ test('public extensions-only update preserves the forwarded command', () => {
102
+ const result = launch(['update', '--extensions-only']);
103
+ assert.equal(result.status, 0, result.stderr);
104
+ assert.equal(result.stdout, 'FORWARD ["update","--extensions-only"]\n');
105
+ assert.equal(result.stderr, '');
106
+ });
@@ -0,0 +1,186 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { managedUpdatePlan, updateSelectedPi } from '../scripts/pi-update.mjs';
4
+ import { bundledUpdatePlan, bundledUpdateNext } from '../runtime/gentle-shell-bundled.mjs';
5
+
6
+ const PI = '@earendil-works/pi-coding-agent';
7
+ const CLI = 'dist/bundle/cli.js';
8
+
9
+ // The layouts pnpm v11 and npm really produce. pnpm links each top-level
10
+ // package from its own global group into the linked store, where Shell's
11
+ // peer is a sibling of the Shell package; a separate global Pi links to the
12
+ // same store entry. npm nests the peer under the Shell package.
13
+ function fixture({windows = false, npm = false, runtime = 'adjacent', groups = 'both', peerAfter = '1.2.0', shellVersion = '4.1.0',
14
+ badManifest = false, failed = false, mismatch = false, linked = false, readback = true, unrelatedPeer = false, shim = ''} = {}) {
15
+ const base = windows ? 'C:/pnpm' : '/pnpm';
16
+ const storePi = (version: string) => `${base}/store/links/@earendil-works/pi-coding-agent/${version}/h/node_modules/${PI}`;
17
+ const shellModules = npm ? `${base}/npm/lib/node_modules` : `${base}/store/links/@/gentle-pi/4.1.0/h1/node_modules`;
18
+ const shell = `${shellModules}/gentle-pi`;
19
+ const nestedPeer = `${shell}/node_modules/${PI}`;
20
+ const g1 = npm ? shellModules : `${base}/global/v11/g1/node_modules`;
21
+ const g2 = npm ? shellModules : `${base}/global/v11/g2/node_modules`;
22
+ const g3 = `${base}/global/v11/g3/node_modules`;
23
+ const state = {peer: '1.1.0', group: '1.1.0'};
24
+ const peerRoot = () => npm ? nestedPeer : storePi(state.peer);
25
+ const selectedCli = `${npm ? (runtime === 'adjacent' ? nestedPeer : `${shellModules}/${PI}`) : storePi('1.1.0')}/${CLI}`;
26
+ // A package-manager shim on PATH: its text names the global Pi group's cli.js.
27
+ const shimFile = `${base}/bin/pi${windows ? '.CMD' : ''}`;
28
+ const shimText = shim === 'posix' ? `#!/bin/sh\nbasedir=$(dirname "$0")\nexec node "$basedir/../global/v11/g2/node_modules/${PI}/${CLI}" "$@"\n`
29
+ : shim === 'windows' ? `@"%~dp0\\..\\global\\v11\\g2\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\bundle\\cli.js" %*\r\n`
30
+ : shim === 'two' ? `"$basedir/../a/dist/bundle/cli.js" "$basedir/../b/dist/bundle/cli.js"` : shim === 'none' ? '#!/bin/sh\nexec something\n' : '';
31
+ const mutations: string[][] = [];
32
+ const out: string[] = [];
33
+ const cwds: unknown[] = [];
34
+ const entry = (pkg: string, modules: string, extra = {}) => ({[pkg]: {path: `${modules}/${pkg}`, ...extra}});
35
+ const shellEntry = entry('gentle-pi', g1, linked ? {link: true} : {});
36
+ const piEntry = entry(PI, g2, linked ? {resolved: 'file:/src/pi'} : {});
37
+ const listing = () => {
38
+ const deps = {...(groups !== 'pi' ? shellEntry : {}), ...(groups !== 'shell' ? piEntry : {}),
39
+ ...(groups === 'shared' ? entry('unrelated-tool', g1) : {}), ...(groups === 'separate' ? entry('unrelated-tool', g3) : {})};
40
+ const group = {path: npm ? shellModules : `${base}/global/v11`, dependencies: deps};
41
+ return npm ? group : [group];
42
+ };
43
+ const exists = (n: string) => [g1, g2, g3, shellModules, shell, storePi(state.peer), storePi(state.group), storePi('1.1.0'), nestedPeer]
44
+ .some(root => n === root || n.startsWith(root + '/'));
45
+ const realpath = async (value: string) => {
46
+ const n = value.replaceAll('\\', '/');
47
+ if (n === shimFile) return n;
48
+ // Symlinked components inside a longer path resolve like the real filesystem does.
49
+ if (n.startsWith(`${g2}/${PI}/`)) return (npm ? `${g2}/${PI}` : storePi(state.group)) + n.slice(`${g2}/${PI}`.length);
50
+ if (n === `${g1}/gentle-pi` || n === `${g1}/unrelated-tool` || n === `${g3}/unrelated-tool`) return n === `${g1}/gentle-pi` ? shell : n;
51
+ if (n === `${g2}/${PI}`) return npm ? n : storePi(state.group);
52
+ if (!npm && n === `${shellModules}/${PI}`) return storePi(state.peer);
53
+ if (!npm && n === nestedPeer) throw new Error('ENOENT nested peer');
54
+ // Only joined (backslash) paths mismatch: the selected CLI itself stays canonical.
55
+ if (mismatch && n.endsWith(CLI) && value.includes('\\')) return `${base}/other/cli.js`;
56
+ if (exists(n)) return n;
57
+ throw new Error(`ENOENT ${n}`);
58
+ };
59
+ const readFile = async (value: string) => {
60
+ const n = value.replaceAll('\\', '/');
61
+ if (n === shimFile) return shimText;
62
+ if (n === `${shell}/package.json`) return JSON.stringify({name: 'gentle-pi', ...(shellVersion ? {version: shellVersion} : {})});
63
+ if (n === `${g1}/unrelated-tool/package.json`) return JSON.stringify({name: 'unrelated-tool'});
64
+ const version = n.startsWith(nestedPeer) ? state.peer : /pi-coding-agent\/([\d.]+)\/h\//.exec(n)?.[1];
65
+ if (badManifest || unrelatedPeer) return JSON.stringify({name: unrelatedPeer ? 'other' : PI, version});
66
+ return JSON.stringify({name: PI, version, bin: {pi: CLI}});
67
+ };
68
+ const run = async (_command: string, args: string[], options?: {cwd?: unknown}) => {
69
+ cwds.push(options?.cwd);
70
+ if (args.includes('list') || args.includes('ls')) return {code: failed ? 1 : 0, stdout: readback || mutations.length === 0 ? JSON.stringify(listing()) : '{'};
71
+ mutations.push(args);
72
+ if (args.some(arg => arg.startsWith('gentle-pi@'))) state.peer = peerAfter;
73
+ if (args.some(arg => arg.startsWith(PI + '@'))) state.group = peerAfter;
74
+ return {code: 0};
75
+ };
76
+ return {mutations, out, cwds, state, shell, selectedCli, input: {platform: windows ? 'win32' : 'linux', packageRoot: `${g1}/gentle-pi`, currentVersion: '1.1.0', cwd: '/neutral',
77
+ runtime: runtime === 'adjacent' ? {kind: 'bundled', command: 'node', args: [selectedCli]} : {kind: runtime, command: shim ? shimFile : selectedCli, args: []},
78
+ out: (line: string) => out.push(line),
79
+ adapters: {which: async (name: string) => name === (npm ? 'npm' : 'pnpm') ? `/fake/${name}` : null, fs: {realpath, readFile}, run}}};
80
+ }
81
+
82
+ test('prefix distribution still redirects self update and forwards only extensions for all', () => {
83
+ assert.deepEqual(bundledUpdatePlan(['update']), {self: true});
84
+ assert.deepEqual(bundledUpdateNext(bundledUpdatePlan(['update']), 0), {exitCode: 0});
85
+ assert.deepEqual(bundledUpdatePlan(['update', '--all']), {self: true, piArgs: ['update', '--extensions']});
86
+ assert.deepEqual(bundledUpdatePlan(['update', '--extensions-only']), {self: false, piArgs: ['update', '--extensions-only']});
87
+ });
88
+
89
+ for (const args of [['update', '--extensions-only'], ['update', '--extensions'], ['update', 'npm:custom'], ['update', '--models'], ['update', '--help'], ['update', '--unknown']]) test(`no ownership interception for ${args.join(' ')}`, () => {
90
+ assert.deepEqual(managedUpdatePlan(args), {self: false, piArgs: args});
91
+ });
92
+
93
+ test('all strips only self update before forwarding extension flags', () => {
94
+ assert.deepEqual(managedUpdatePlan(['update', '--all', '--approve']), {self: true, piArgs: ['update', '--extensions', '--approve']});
95
+ });
96
+
97
+ for (const windows of [false, true]) test(`an adjacent peer is refreshed by reinstalling its Shell package at the same version, never the separate global Pi (windows: ${windows})`, async () => {
98
+ const f = fixture({windows});
99
+ assert.equal(await updateSelectedPi(f.input), 0);
100
+ assert.deepEqual(f.mutations, [['add', '-g', 'gentle-pi@4.1.0', '--allow-build=gentle-pi']]);
101
+ assert.deepEqual(f.out, ['pnpm installed gentle-pi@4.1.0; the selected Pi is now 1.2.0 (was 1.1.0).']);
102
+ assert.equal(f.state.group, '1.1.0');
103
+ });
104
+
105
+ for (const runtime of ['path', 'env']) test(`a ${runtime} runtime sharing the store entry is updated through its own global Pi package`, async () => {
106
+ const f = fixture({runtime});
107
+ assert.equal(await updateSelectedPi(f.input), 0);
108
+ assert.deepEqual(f.mutations, [['add', '-g', `${PI}@latest`]]);
109
+ assert.deepEqual(f.out, [`pnpm installed ${PI}@latest; the selected Pi is now 1.2.0 (was 1.1.0).`]);
110
+ assert.equal(f.state.peer, '1.1.0');
111
+ });
112
+
113
+ test('npm nests the peer under Shell and reinstalls Shell for it', async () => {
114
+ const f = fixture({npm: true});
115
+ assert.equal(await updateSelectedPi(f.input), 0);
116
+ assert.deepEqual(f.mutations, [['install', '-g', 'gentle-pi@4.1.0']]);
117
+ assert.deepEqual(f.out, ['npm installed gentle-pi@4.1.0; the selected Pi is now 1.2.0 (was 1.1.0).']);
118
+ });
119
+
120
+ test('an npm PATH runtime updates the global Pi without its scripts', async () => {
121
+ const f = fixture({npm: true, runtime: 'path'});
122
+ assert.equal(await updateSelectedPi(f.input), 0);
123
+ assert.deepEqual(f.mutations, [['install', '-g', `${PI}@latest`, '--ignore-scripts']]);
124
+ });
125
+
126
+ test('an unchanged pnpm result names the minimum-release-age rule instead of claiming latest', async () => {
127
+ const f = fixture({peerAfter: '1.1.0'});
128
+ assert.equal(await updateSelectedPi(f.input), 0);
129
+ assert.equal(f.out[0], 'pnpm installed gentle-pi@4.1.0, and the selected Pi is still 1.1.0.');
130
+ assert.match(f.out[1], /minimumReleaseAge/);
131
+ assert.equal(f.out.length, 2);
132
+ });
133
+
134
+ test('every package manager call runs from the neutral directory, never the project cwd', async () => {
135
+ const f = fixture();
136
+ assert.equal(await updateSelectedPi(f.input), 0);
137
+ assert.deepEqual(f.cwds, ['/neutral', '/neutral', '/neutral']);
138
+ });
139
+
140
+ test('an unreadable readback is reported, not invented', async () => {
141
+ const f = fixture({readback: false});
142
+ assert.equal(await updateSelectedPi(f.input), 0);
143
+ assert.deepEqual(f.out, ['pnpm installed gentle-pi@4.1.0. The Pi version it now supplies could not be read back; run \'gentle-shell --version\' to check it.']);
144
+ });
145
+
146
+ test('separate aggregated groups do not block the Shell reinstall', async () => {
147
+ const f = fixture({groups: 'separate'});
148
+ assert.equal(await updateSelectedPi(f.input), 0);
149
+ assert.deepEqual(f.mutations, [['add', '-g', 'gentle-pi@4.1.0', '--allow-build=gentle-pi']]);
150
+ });
151
+
152
+ for (const [name, options] of Object.entries({
153
+ 'listing failed': {failed: true},
154
+ 'peer identity differs': {badManifest: true},
155
+ 'peer package is not Pi': {unrelatedPeer: true},
156
+ 'selected CLI mismatches on Windows': {windows: true, mismatch: true},
157
+ 'linked or file suppliers': {linked: true},
158
+ 'Shell shares its global group': {groups: 'shared'},
159
+ 'Shell version is missing': {shellVersion: ''},
160
+ 'only a separate global Pi is listed for an adjacent runtime': {groups: 'pi'},
161
+ 'only Shell is listed for a PATH runtime': {runtime: 'path', groups: 'shell'},
162
+ })) test(`ownership rejects without mutation when ${name}`, async () => {
163
+ const f = fixture(options);
164
+ await assert.rejects(updateSelectedPi(f.input), /ownership/);
165
+ assert.deepEqual(f.mutations, []);
166
+ assert.deepEqual(f.out, []);
167
+ });
168
+
169
+ for (const [name, options] of Object.entries({'pnpm POSIX shim': {shim: 'posix', runtime: 'path'}, 'Windows .CMD shim': {shim: 'windows', runtime: 'path', windows: true}, 'GENTLE_SHELL_PI shim override': {shim: 'posix', runtime: 'env'}})) test(`a ${name} resolves to the global Pi it runs`, async () => {
170
+ const f = fixture(options);
171
+ assert.equal(await updateSelectedPi(f.input), 0);
172
+ assert.deepEqual(f.mutations, [['add', '-g', `${PI}@latest`]]);
173
+ });
174
+
175
+ for (const [name, shim] of Object.entries({'names several targets': 'two', 'is not a Pi entry': 'none'})) test(`a shim that ${name} is refused without mutation`, async () => {
176
+ const f = fixture({shim, runtime: 'path'});
177
+ await assert.rejects(updateSelectedPi(f.input), /ownership/);
178
+ assert.deepEqual(f.mutations, []);
179
+ });
180
+
181
+ test('an unrelated selected override cannot authorize the adjacent Shell group', async () => {
182
+ const f = fixture();
183
+ f.input.runtime = {kind: 'env', command: '/unrelated/pi', args: []};
184
+ await assert.rejects(updateSelectedPi(f.input), /ownership/);
185
+ assert.deepEqual(f.mutations, []);
186
+ });
@@ -0,0 +1,101 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import test, { after, before } from "node:test";
7
+ import { __testing } from "../extensions/gentle-ai.ts";
8
+
9
+ // gentle-ai R3 (odd/tasks/pi-routing-from-bundle.md): the harness ODD step
10
+ // lines 1-7, the phase-reporting line, and the resume line come from the
11
+ // mirrored provider contract bundle's routing/pi.md instead of a hand-copied
12
+ // literal, and the rendered primary-session prompt stays byte-identical.
13
+ //
14
+ // The golden was captured from the base commit (main 4372ff2d, after R1) before the steps moved into the
15
+ // bundle, with the inputs below and the package assets root normalized. When a
16
+ // prompt change is intended, regenerate it with
17
+ // UPDATE_PRIMARY_SESSION_PROMPT_GOLDEN=1 and review the golden diff.
18
+
19
+ const REPO_ROOT = join(import.meta.dirname, "..");
20
+ const GOLDEN_PATH = join(REPO_ROOT, "tests", "fixtures", "primary-session-prompt", "gentleman.golden.md");
21
+ const ACTIVE_TOOLS = ["read", "bash", "edit", "write", "subagent_run"] as const;
22
+ const MIRROR_ROOT = join(REPO_ROOT, "contracts", "review-provider-contract-mirror");
23
+
24
+ let scratch: string;
25
+ const previousEnvironment = new Map<string, string | undefined>();
26
+ before(() => {
27
+ scratch = mkdtempSync(join(tmpdir(), "gentle-pi-primary-prompt-"));
28
+ for (const key of ["GENTLE_PI_CONFIG_HOME", "GENTLE_PI_BACKGROUND_SUBAGENTS"]) previousEnvironment.set(key, process.env[key]);
29
+ process.env.GENTLE_PI_CONFIG_HOME = join(scratch, "config");
30
+ delete process.env.GENTLE_PI_BACKGROUND_SUBAGENTS;
31
+ });
32
+ after(() => {
33
+ for (const [key, value] of previousEnvironment) {
34
+ if (value === undefined) delete process.env[key];
35
+ else process.env[key] = value;
36
+ }
37
+ rmSync(scratch, { recursive: true, force: true });
38
+ });
39
+
40
+ function renderPrimaryPrompt(): string {
41
+ const prompt = __testing.buildGentlePrompt("gentleman", join(scratch, "project"), ACTIVE_TOOLS);
42
+ return prompt.replaceAll(join(REPO_ROOT, "assets"), "{{GENTLE_PI_ASSETS_ROOT}}");
43
+ }
44
+
45
+ function mirroredRoutingText(): string {
46
+ const lock = JSON.parse(readFileSync(join(MIRROR_ROOT, "provider-contract.lock.json"), "utf8")) as { contract_semver: string };
47
+ return readFileSync(join(MIRROR_ROOT, `v${lock.contract_semver}`, "bundle", "routing", "pi.md"), "utf8");
48
+ }
49
+
50
+ // Writes a one-file mirror whose lock records `entrySha256` for routing/pi.md.
51
+ function tempRoutingMirror(text: string, entrySha256 = createHash("sha256").update(text, "utf8").digest("hex")): string {
52
+ const root = mkdtempSync(join(scratch, "mirror-"));
53
+ const routingDir = join(root, "v9.9.9", "bundle", "routing");
54
+ mkdirSync(routingDir, { recursive: true });
55
+ writeFileSync(join(routingDir, "pi.md"), text, "utf8");
56
+ writeFileSync(join(root, "provider-contract.lock.json"), JSON.stringify({ contract_semver: "9.9.9", entries: { "routing/pi.md": entrySha256 } }), "utf8");
57
+ return root;
58
+ }
59
+
60
+ test("the rendered primary-session prompt is byte-identical to the golden captured from the base", () => {
61
+ const rendered = renderPrimaryPrompt();
62
+ if (process.env.UPDATE_PRIMARY_SESSION_PROMPT_GOLDEN === "1") writeFileSync(GOLDEN_PATH, rendered, "utf8");
63
+ assert.equal(rendered, readFileSync(GOLDEN_PATH, "utf8"));
64
+ });
65
+
66
+ test("the ODD steps, phase reporting, and resume lines come from the mirrored routing/pi.md without its blank lines", () => {
67
+ const routing = mirroredRoutingText();
68
+ const section = routing.slice(routing.indexOf("### ODD protocol"), routing.indexOf("\n### Task Size"));
69
+ const expected = section.split("\n").slice(1).filter((line) => line !== "");
70
+ assert.equal(expected.length, 9);
71
+ assert.equal(__testing.readMirroredOddProtocolSteps(), expected.join("\n"));
72
+ assert.ok(renderPrimaryPrompt().includes(`Run these steps in this order on every request:\n${expected.join("\n")}\n\nHarness principles:`));
73
+ assert.ok(routing.includes(`${expected[6]}\n\n${expected[7]}\n\n${expected[8]}`), "the bundle separates step 7, phase reporting, and resume with blank lines");
74
+ });
75
+
76
+ test("extensions/gentle-ai.ts no longer carries the ODD step lines as string literals", () => {
77
+ const source = readFileSync(join(REPO_ROOT, "extensions", "gentle-ai.ts"), "utf8");
78
+ for (const literal of ["1. **Authorize.**", "5. **Track before the first write.**", "7. **Close.**", "Phase reporting: the Gentle Shell prompt label", "Resume an interrupted feature with"]) {
79
+ assert.ok(!source.includes(literal), `extensions/gentle-ai.ts still hard-codes ${JSON.stringify(literal)}`);
80
+ }
81
+ });
82
+
83
+ test("a changed mirrored routing block changes the rendered steps", () => {
84
+ const routing = mirroredRoutingText().replace("1. **Authorize.** ", "1. **Authorize.** Changed upstream. ");
85
+ const mirror = tempRoutingMirror(routing);
86
+ assert.match(__testing.readMirroredOddProtocolSteps(mirror), /^1\. \*\*Authorize\.\*\* Changed upstream\. /);
87
+ });
88
+
89
+ test("a tampered or missing mirrored routing/pi.md fails closed with an explicit error", () => {
90
+ const unavailable = /Gentle Shell cannot render its ODD steps: the mirrored provider bundle's routing\/pi\.md is missing, unreadable, or fails digest verification/;
91
+ assert.throws(() => __testing.readMirroredOddProtocolSteps(tempRoutingMirror(mirroredRoutingText(), "0".repeat(64))), unavailable);
92
+ assert.throws(() => __testing.readMirroredOddProtocolSteps(join(scratch, "absent-mirror")), unavailable);
93
+ });
94
+
95
+ test("a routing block whose ODD protocol section changed shape fails closed", () => {
96
+ const unexpectedShape = /routing\/pi\.md ODD protocol section has an unexpected shape/;
97
+ const withoutResume = mirroredRoutingText().replace(/\nResume an interrupted feature[^\n]*\n/, "\n");
98
+ assert.throws(() => __testing.readMirroredOddProtocolSteps(tempRoutingMirror(withoutResume)), unexpectedShape);
99
+ const withoutSection = mirroredRoutingText().replace("### ODD protocol (MANDATORY, in this order, on every request)", "### ODD protocol");
100
+ assert.throws(() => __testing.readMirroredOddProtocolSteps(tempRoutingMirror(withoutSection)), unexpectedShape);
101
+ });