gentle-pi 4.2.0 → 4.4.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 (334) hide show
  1. package/README.md +340 -138
  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/design-canvas/canvas.css +648 -0
  6. package/assets/design-canvas/canvas.js +1497 -0
  7. package/assets/design-canvas/index.html +88 -0
  8. package/assets/design-canvas/inspector.js +290 -0
  9. package/assets/orchestrator-delegation-reference.md +163 -0
  10. package/assets/orchestrator-delegation.md +25 -176
  11. package/assets/orchestrator-tracking.md +1 -1
  12. package/assets/orchestrator-verification.md +14 -8
  13. package/assets/orchestrator-writer.md +5 -1
  14. package/assets/orchestrator.md +11 -11
  15. package/bin/engram-guard +1 -1
  16. package/bin/gentle-shell.mjs +237 -139
  17. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  18. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  19. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  20. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  21. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  22. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  23. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  24. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  25. package/docs/assets/diagrams/measured-routing.svg +49 -0
  26. package/docs/assets/diagrams/native-review.svg +52 -12
  27. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  28. package/docs/assets/diagrams/permission-modes.svg +51 -0
  29. package/docs/assets/features/audio-notifications.png +0 -0
  30. package/docs/assets/features/command-palette.png +0 -0
  31. package/docs/bundled-install.md +65 -30
  32. package/docs/delegated-verification.md +16 -8
  33. package/docs/gentle-agents-activity.md +16 -6
  34. package/docs/gentle-music.md +61 -0
  35. package/docs/gentle-shell-uninstall.md +8 -1
  36. package/docs/gentle-shell.md +80 -9
  37. package/docs/install-wizard.md +37 -6
  38. package/docs/permission-modes.md +8 -3
  39. package/docs/readme-reference.md +120 -28
  40. package/docs/session-profile-format.md +26 -0
  41. package/docs/voice.md +259 -0
  42. package/docs/yolo-mode.md +38 -10
  43. package/extensions/ask-user-choice.ts +4 -1
  44. package/extensions/codegraph-tools.ts +68 -6
  45. package/extensions/gentle-agents.ts +270 -48
  46. package/extensions/gentle-ai.ts +490 -113
  47. package/extensions/gentle-design.ts +158 -0
  48. package/extensions/gentle-herdr-activity.ts +2 -3
  49. package/extensions/gentle-music.ts +289 -0
  50. package/extensions/gentle-shell.ts +359 -85
  51. package/extensions/gentle-todo.ts +43 -9
  52. package/extensions/gentle-voice.ts +11 -0
  53. package/extensions/quiet-tools.ts +1 -1
  54. package/extensions/session-scratch.ts +384 -0
  55. package/extensions/skill-registry.ts +63 -8
  56. package/extensions/startup-banner.ts +2 -3
  57. package/lib/agent-home.ts +8 -3
  58. package/lib/agent-profiles.ts +26 -0
  59. package/lib/agents-config.ts +35 -0
  60. package/lib/agents-protocol.ts +60 -1
  61. package/lib/agents-runner.ts +107 -9
  62. package/lib/agents-session-transport.ts +101 -6
  63. package/lib/agents-view.ts +3 -1
  64. package/lib/agents-widget.ts +14 -1
  65. package/lib/auto-permission-memory.ts +48 -18
  66. package/lib/auto-permission-runtime.ts +21 -6
  67. package/lib/bounded-writer-admission.ts +51 -0
  68. package/lib/clipboard-image.ts +147 -0
  69. package/lib/command-palette-catalog.ts +2 -4
  70. package/lib/commit-slice-signal.ts +100 -0
  71. package/lib/context-backstop.ts +120 -0
  72. package/lib/delivered-test-lint.ts +80 -0
  73. package/lib/design-canvas-server.ts +947 -0
  74. package/lib/design-comments.ts +437 -0
  75. package/lib/design-handoffs.ts +313 -0
  76. package/lib/design-open-browser.ts +58 -0
  77. package/lib/design-sources.ts +120 -0
  78. package/lib/destructive-command-guard.ts +120 -9
  79. package/lib/external-editor.ts +31 -0
  80. package/lib/gauge-theme.ts +146 -0
  81. package/lib/gentle-ai-binary.ts +28 -7
  82. package/lib/gentle-design.ts +513 -0
  83. package/lib/gentle-shell-bundled.ts +146 -36
  84. package/lib/gentle-shell-import-pi.ts +233 -0
  85. package/lib/gentle-shell-launcher.ts +154 -15
  86. package/lib/gentle-shell-startup-update.ts +61 -9
  87. package/lib/gentle-shell-uninstall.ts +51 -2
  88. package/lib/herdr-activity.ts +24 -0
  89. package/lib/image-attachments.ts +150 -0
  90. package/lib/inprocess-reviewer.ts +33 -2
  91. package/lib/nan-provider.ts +3 -0
  92. package/lib/native-review-cli.ts +20 -5
  93. package/lib/notification-audio-native.ts +149 -13
  94. package/lib/notification-audio.ts +4 -0
  95. package/lib/notification-pulse-worker.ts +82 -18
  96. package/lib/notification-service.ts +5 -1
  97. package/lib/orchestrator-discovery.ts +5 -1
  98. package/lib/orchestrator-presence.ts +87 -8
  99. package/lib/orchestrator-scope.ts +31 -3
  100. package/lib/orchestrator-state.ts +11 -3
  101. package/lib/permission-mode-policy.ts +2 -2
  102. package/lib/provider-contract-bundle.ts +99 -53
  103. package/lib/review-candidate-view-owner.ts +29 -19
  104. package/lib/review-candidate-view.ts +36 -2
  105. package/lib/review-host-relay.ts +11 -2
  106. package/lib/review-reminder-receipt.ts +24 -1
  107. package/lib/review-risk-assessment.ts +43 -38
  108. package/lib/review-session-standing-permission.ts +3 -1
  109. package/lib/session-profile-append-controller.ts +713 -0
  110. package/lib/session-profile-disk-reader.ts +182 -0
  111. package/lib/session-profile-freeze.ts +30 -1
  112. package/lib/session-profile-persistence.ts +15 -7
  113. package/lib/session-scratch.ts +523 -0
  114. package/lib/session-worktree-registry.ts +7 -0
  115. package/lib/shell-bar.ts +29 -28
  116. package/lib/shell-changes-view.ts +80 -13
  117. package/lib/shell-changes.ts +81 -5
  118. package/lib/shell-gauge.ts +49 -6
  119. package/lib/shell-prompt.ts +4 -4
  120. package/lib/shell-sidebar-layout.ts +26 -6
  121. package/lib/shell-todo.ts +24 -5
  122. package/lib/shell-usage-view.ts +5 -1
  123. package/lib/shell-usage.ts +7 -7
  124. package/lib/verifier-admission.ts +30 -0
  125. package/lib/vim-editor-adapter.ts +11 -8
  126. package/lib/voice-artifacts.ts +188 -0
  127. package/lib/voice-command.ts +576 -0
  128. package/lib/voice-install.ts +455 -0
  129. package/lib/voice-platform.ts +99 -0
  130. package/lib/voice-push-to-talk.ts +262 -0
  131. package/lib/voice-session.ts +460 -0
  132. package/lib/voice-settings.ts +50 -0
  133. package/lib/voice-ui.ts +158 -0
  134. package/lib/yolo-session-policy.ts +58 -22
  135. package/lib/yolo-temp-scratch.ts +69 -0
  136. package/package.json +1 -2
  137. package/runtime/gentle-ai-binary.mjs +28 -7
  138. package/runtime/gentle-shell-bundled.mjs +145 -35
  139. package/runtime/gentle-shell-import-pi.mjs +234 -0
  140. package/runtime/gentle-shell-launcher.mjs +152 -13
  141. package/runtime/gentle-shell-startup-update.mjs +61 -9
  142. package/runtime/gentle-shell-uninstall.mjs +51 -2
  143. package/runtime/native-review-cli.mjs +19 -4
  144. package/runtime/review-risk-assessment.mjs +43 -38
  145. package/scripts/build-runtime-modules.mjs +1 -0
  146. package/scripts/bundled-engram-migration.mjs +119 -0
  147. package/scripts/gentle-ai-installer.mjs +10 -10
  148. package/scripts/installer-downloads.mjs +7 -7
  149. package/scripts/installer-preflight.mjs +5 -3
  150. package/scripts/installer-server.mjs +14 -4
  151. package/scripts/installer-windows.mjs +37 -7
  152. package/scripts/main-channel.mjs +94 -19
  153. package/scripts/pi-update.mjs +34 -6
  154. package/scripts/run-test-suite.mjs +5 -0
  155. package/scripts/verify-package-files.mjs +43 -13
  156. package/scripts/voice-worker.cjs +476 -0
  157. package/skills/design/SKILL.md +110 -0
  158. package/skills/gentle-ai/SKILL.md +3 -3
  159. package/skills/work-unit-commits/SKILL.md +5 -3
  160. package/tests/agent-profiles.test.ts +24 -0
  161. package/tests/agent-risk-escalation.test.ts +37 -10
  162. package/tests/agents-config.test.ts +61 -0
  163. package/tests/agents-fake-child.ts +8 -1
  164. package/tests/agents-protocol.test.ts +32 -0
  165. package/tests/agents-runner.test.ts +485 -7
  166. package/tests/agents-session-transport.test.ts +53 -3
  167. package/tests/ask-user-choice.test.ts +30 -0
  168. package/tests/auto-mode.test.ts +17 -0
  169. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  170. package/tests/auto-permission-runtime.test.ts +85 -1
  171. package/tests/autonomous-guard.test.ts +142 -0
  172. package/tests/bounded-writer-admission.test.ts +11 -3
  173. package/tests/bundled-install.test.ts +3 -3
  174. package/tests/card-content-policy.test.ts +19 -9
  175. package/tests/child-safety.test.ts +39 -0
  176. package/tests/clipboard-image.test.ts +114 -0
  177. package/tests/codegraph-tools.test.ts +123 -2
  178. package/tests/command-palette.test.ts +15 -5
  179. package/tests/commit-slice-signal.test.ts +162 -0
  180. package/tests/context-backstop.test.ts +225 -0
  181. package/tests/delegate-for-reason-contract.test.ts +92 -24
  182. package/tests/delivered-test-lint.test.ts +105 -0
  183. package/tests/design-canvas-assets.test.ts +710 -0
  184. package/tests/design-canvas-server.test.ts +1096 -0
  185. package/tests/design-comments.test.ts +201 -0
  186. package/tests/design-handoffs.test.ts +282 -0
  187. package/tests/design-open-browser.test.ts +70 -0
  188. package/tests/design-sources.test.ts +132 -0
  189. package/tests/destructive-command-guard.test.ts +85 -1
  190. package/tests/engram-safety.test.ts +132 -57
  191. package/tests/external-editor.test.ts +84 -0
  192. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  193. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  194. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  195. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  196. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  197. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  198. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  199. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  200. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  201. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  202. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  203. package/tests/gauge-theme.test.ts +120 -0
  204. package/tests/gentle-agents.test.ts +645 -21
  205. package/tests/gentle-ai-binary.test.ts +1 -1
  206. package/tests/gentle-ai-child-guards.test.ts +61 -0
  207. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  208. package/tests/gentle-ai-installer.test.ts +51 -51
  209. package/tests/gentle-ai.test.ts +292 -13
  210. package/tests/gentle-design.test.ts +1313 -0
  211. package/tests/gentle-music.test.ts +335 -0
  212. package/tests/gentle-shell-bin.test.ts +163 -0
  213. package/tests/gentle-shell-bundled.test.ts +392 -176
  214. package/tests/gentle-shell-import-pi.test.ts +262 -0
  215. package/tests/gentle-shell-launcher.test.ts +107 -2
  216. package/tests/gentle-shell-startup-update.test.ts +77 -1
  217. package/tests/gentle-shell-uninstall.test.ts +89 -0
  218. package/tests/gentle-shell.test.ts +225 -17
  219. package/tests/gentle-todo.test.ts +190 -0
  220. package/tests/headless-delivery-question.test.ts +58 -0
  221. package/tests/herdr-activity.test.ts +34 -1
  222. package/tests/image-attachments.test.ts +119 -0
  223. package/tests/image-paste-chips.test.ts +236 -0
  224. package/tests/inprocess-reviewer.test.ts +55 -2
  225. package/tests/installer-bundles.test.ts +12 -6
  226. package/tests/installer-posix-bootstrap.test.ts +8 -8
  227. package/tests/installer-preflight.test.ts +16 -0
  228. package/tests/installer-server.test.ts +10 -2
  229. package/tests/installer-windows-bootstrap.test.ts +94 -7
  230. package/tests/installer-windows-diagnostics.test.ts +111 -0
  231. package/tests/main-channel-upgrade.test.ts +232 -2
  232. package/tests/main-channel.test.ts +140 -21
  233. package/tests/nan-provider.test.ts +36 -0
  234. package/tests/native-review-capability-contract.test.ts +17 -4
  235. package/tests/native-review-parity.test.ts +23 -2
  236. package/tests/no-self-pause-contract.test.ts +5 -2
  237. package/tests/notification-audio-native.test.ts +128 -9
  238. package/tests/notification-customize.test.ts +17 -5
  239. package/tests/odd-integration.test.ts +5 -1
  240. package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
  241. package/tests/odd-routing-contract.test.ts +115 -30
  242. package/tests/orchestrator-budget.test.ts +22 -8
  243. package/tests/orchestrator-discovery.test.ts +29 -0
  244. package/tests/orchestrator-presence.test.ts +100 -2
  245. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  246. package/tests/orchestrator-scope.test.ts +80 -0
  247. package/tests/orchestrator-state.test.ts +29 -0
  248. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  249. package/tests/package-files.test.ts +34 -0
  250. package/tests/package-manifest.test.ts +24 -20
  251. package/tests/permission-mode-policy.test.ts +10 -1
  252. package/tests/pi-update-launcher.test.ts +21 -2
  253. package/tests/pi-update.test.ts +99 -4
  254. package/tests/primary-session-prompt.test.ts +101 -0
  255. package/tests/provider-contract-bundle.test.ts +118 -0
  256. package/tests/provider-contract-mirror.test.ts +24 -0
  257. package/tests/quiet-tool-rendering.test.ts +23 -0
  258. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  259. package/tests/rdd-status-line.test.ts +79 -0
  260. package/tests/review-agent-end-preflight.test.ts +6 -0
  261. package/tests/review-candidate-view.test.ts +49 -0
  262. package/tests/review-contract-prompt.test.ts +5 -2
  263. package/tests/review-controller-native-routing.test.ts +14 -1
  264. package/tests/review-controller.test.ts +1 -1
  265. package/tests/review-host-relay-routing.test.ts +147 -3
  266. package/tests/review-host-relay.test.ts +9 -2
  267. package/tests/review-json-arguments.test.ts +20 -3
  268. package/tests/review-loadout-rdd-off-sdk.test.ts +258 -0
  269. package/tests/review-owner-identity.test.ts +132 -0
  270. package/tests/review-relay-transport-agent.test.ts +26 -0
  271. package/tests/review-reminder-receipt.test.ts +31 -1
  272. package/tests/review-risk-assessment.test.ts +163 -76
  273. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  274. package/tests/review-session-standing-permission.test.ts +27 -1
  275. package/tests/run-test-suite.test.ts +15 -0
  276. package/tests/runtime-harness.mjs +16 -6
  277. package/tests/session-changes-shell.test.ts +1 -1
  278. package/tests/session-changes.test.ts +2 -1
  279. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  280. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  281. package/tests/session-profile-append-controller.test.ts +731 -0
  282. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  283. package/tests/session-profile-disk-reader.test.ts +311 -0
  284. package/tests/session-profile-freeze.test.ts +23 -0
  285. package/tests/session-scratch.test.ts +1011 -0
  286. package/tests/session-subject-routing-contract.test.ts +2 -1
  287. package/tests/session-worktree-registry.test.ts +13 -1
  288. package/tests/shell-bar.test.ts +21 -2
  289. package/tests/shell-changes-view.test.ts +137 -1
  290. package/tests/shell-changes.test.ts +148 -1
  291. package/tests/shell-gauge.test.ts +42 -0
  292. package/tests/shell-prompt.test.ts +3 -3
  293. package/tests/shell-render-cache.test.ts +134 -1
  294. package/tests/shell-sidebar-layout.test.ts +50 -0
  295. package/tests/short-check-routing-contract.test.ts +1 -1
  296. package/tests/skill-collision-prefixes.test.ts +3 -1
  297. package/tests/skill-registry-watcher.test.ts +17 -5
  298. package/tests/skill-registry.test.ts +47 -3
  299. package/tests/small-task-inline-contract.test.ts +37 -0
  300. package/tests/standalone-config-home.test.ts +324 -0
  301. package/tests/startup-banner.test.ts +5 -4
  302. package/tests/startup-home-repo.test.ts +34 -0
  303. package/tests/support/orchestrator-modules.ts +1 -0
  304. package/tests/task-size-routing-contract.test.ts +70 -10
  305. package/tests/test-scratch-contract.test.ts +102 -0
  306. package/tests/test-strength-contract.test.ts +36 -1
  307. package/tests/tool-batching-contract.test.ts +64 -0
  308. package/tests/verification-proportionality.test.ts +46 -0
  309. package/tests/verifier-admission.test.ts +154 -0
  310. package/tests/verify-agent-contract.test.ts +8 -8
  311. package/tests/voice-command.test.ts +773 -0
  312. package/tests/voice-install.test.ts +323 -0
  313. package/tests/voice-platform.test.ts +176 -0
  314. package/tests/voice-push-to-talk.test.ts +444 -0
  315. package/tests/voice-session.test.ts +434 -0
  316. package/tests/voice-settings.test.ts +49 -0
  317. package/tests/voice-ui.test.ts +97 -0
  318. package/tests/voice-worker.test.ts +445 -0
  319. package/tests/weak-model-economy-prompt.test.ts +32 -0
  320. package/tests/windows-hidden-processes.test.ts +30 -2
  321. package/tests/writer-edit-surface-scope.test.ts +3 -0
  322. package/tests/writer-feature-document-admission.test.ts +156 -0
  323. package/tests/yolo-customize.test.ts +5 -1
  324. package/tests/yolo-mode.test.ts +19 -1
  325. package/tests/yolo-session-policy-capture.test.ts +104 -0
  326. package/tests/yolo-temp-scratch.test.ts +71 -0
  327. package/scripts/mirror-odd-routing.mjs +0 -242
  328. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  329. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  330. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  331. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  332. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  333. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  334. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -15,7 +15,7 @@ export interface NotificationServiceDependencies {
15
15
  policy?: NotificationOptions;
16
16
  read?: () => NotificationResolution;
17
17
  write?: (settings: NotificationSettings) => string;
18
- backend?: Pick<NotificationPlayer, "play" | "availability" | "capabilities">;
18
+ backend?: Pick<NotificationPlayer, "play" | "availability" | "capabilities"> & { dispose?: () => void };
19
19
  setTimeout?: SchedulerOptions["setTimeout"];
20
20
  clearTimeout?: SchedulerOptions["clearTimeout"];
21
21
  }
@@ -175,6 +175,10 @@ export function claimNotificationOwner(retireRuntime: () => void, deps: Notifica
175
175
  const retire = () => {
176
176
  if (disposed) return;
177
177
  shutdown(); disposed = true; retireRuntime();
178
+ // End of life for the claim also ends the audio backend: the persistent
179
+ // serve worker (if one was spawned) is killed here; a re-bind through
180
+ // attach() keeps it, and an abandoned claim self-retires idle after 60 s.
181
+ backend.dispose?.();
178
182
  if (shared.ownerLease === lease) { shared.ownerLease = undefined; shared.retire = undefined; }
179
183
  };
180
184
  shared.retire = retire;
@@ -1,13 +1,16 @@
1
1
  import { readCatalog, type CatalogPage } from "./orchestrator-catalog.ts";
2
2
  import type { PublishedWork } from "./orchestrator-work.ts";
3
3
  import type { PresenceRecord } from "./agents-session-transport.ts";
4
- import { activationHash, listPresence, readDiscovery, sessionHash, type DiscoveryMetadata } from "./orchestrator-presence.ts";
4
+ import { activationHash, discoveryAbsent, listPresence, readDiscovery, sessionHash, type DiscoveryMetadata } from "./orchestrator-presence.ts";
5
5
 
6
6
  export interface OrchestratorCandidate {
7
7
  sessionId: string;
8
8
  reachability: "unknown";
9
9
  freshness: "unknown" | "recent" | "stale";
10
10
  label?: string;
11
+ /** Display-only label of a live peer whose code predates discovery publication:
12
+ * its schema-1 header heartbeats, but it never writes a discovery sidecar. */
13
+ legacyLabel?: string;
11
14
  aliases?: DiscoveryMetadata["aliases"];
12
15
  workspace?: string;
13
16
  tasks?: DiscoveryMetadata["tasks"];
@@ -36,6 +39,7 @@ export function discoverOrchestrators(profile: string, peers: readonly PresenceR
36
39
  if (matches.length !== 1) return unknown;
37
40
  const header = matches[0];
38
41
  const metadata = readDiscovery(profile, header);
42
+ if (!metadata && header.recent && discoveryAbsent(profile, header)) return { ...unknown, legacyLabel: header.label };
39
43
  if (metadata?.activation !== activationHash(activations[0])) return unknown;
40
44
  if (!header.recent) return { ...unknown, freshness: "stale" };
41
45
  const catalog = readCatalog(profile, header, metadata.activation, selection?.recipientSessionId === sessionId ? selection.cursor : undefined);
@@ -171,18 +171,25 @@ function reason(error: unknown) {
171
171
  return code === "ENOENT" ? "missing" : code ? "io-error" : error instanceof SyntaxError ? "malformed" : (error as Error).message;
172
172
  }
173
173
  function atomicWrite(root: string, name: string, bytes: string) {
174
- directory(root, true);
174
+ // Ancestor validation is the heartbeat's cached responsibility; the destination
175
+ // identity check remains per-write so a replaced or linked file is never overwritten.
175
176
  const destination = join(root, name);
176
177
  try { regular(fs.lstatSync(destination)); }
177
178
  catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
178
179
  const temp = join(root, `.${name}.${randomUUID()}.tmp`);
179
180
  const fd = fs.openSync(temp, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
181
+ let identity: { dev: number; ino: number };
182
+ try {
183
+ fs.writeFileSync(fd, bytes);
184
+ const stat = fs.fstatSync(fd);
185
+ identity = { dev: stat.dev, ino: stat.ino };
186
+ } finally { fs.closeSync(fd); }
180
187
  try {
181
- try { fs.writeFileSync(fd, bytes); } finally { fs.closeSync(fd); }
182
188
  fs.renameSync(temp, destination);
183
189
  } finally {
184
190
  try { fs.unlinkSync(temp); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
185
191
  }
192
+ return identity;
186
193
  }
187
194
 
188
195
  export interface PresencePage {
@@ -263,6 +270,15 @@ export function readActivity(profile: string, selection: Header): { activity?: A
263
270
  } catch (error) { return { unavailable: reason(error) }; }
264
271
  }
265
272
 
273
+ /** True only when no discovery sidecar exists for this header: the publisher
274
+ * predates discovery publication. A present but unreadable sidecar is not absent. */
275
+ export function discoveryAbsent(profile: string, h: Header): boolean {
276
+ try {
277
+ fs.lstatSync(join(rootFor(profile), filename(h, "discovery")));
278
+ return false;
279
+ } catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT"; }
280
+ }
281
+
266
282
  /** Optional derived sidecar; failures never invalidate the schema-1 header. */
267
283
  export function readDiscovery(profile: string, h: Header): DiscoveryMetadata | undefined {
268
284
  try {
@@ -292,6 +308,11 @@ export class PresencePublisher {
292
308
  private heartbeat?: ReturnType<typeof setInterval>;
293
309
  private disposed = false;
294
310
  private owned = new Map<string, { dev: number; ino: number }>();
311
+ /** Root paths whose ancestor chain was validated for this session identity.
312
+ * Seeded by the one synchronous rootFor() in start(); refreshed asynchronously
313
+ * by the heartbeat so writes never probe directories on the caller's thread. */
314
+ private readonly validRoots = new Set<string>();
315
+ private validating = false;
295
316
  /** Timer I/O failures stop publication; consumers still apply the recent TTL. */
296
317
  error?: string;
297
318
 
@@ -304,10 +325,10 @@ export class PresencePublisher {
304
325
  static start(options: { profile: string; sessionId: string; label: string; labelSource?: () => string; activity: readonly ActivityInput[] }) {
305
326
  const publisher = new PresencePublisher(options);
306
327
  try {
307
- rootFor(options.profile, true);
328
+ publisher.validRoots.add(rootFor(options.profile, true));
308
329
  publisher.pending = JSON.stringify(projectActivity(options.activity));
309
330
  publisher.flush();
310
- publisher.heartbeat = setInterval(() => publisher.guarded(() => publisher.publishHeader()), 5000);
331
+ publisher.heartbeat = setInterval(() => publisher.pulse(), 5000);
311
332
  publisher.heartbeat.unref();
312
333
  return publisher;
313
334
  } catch (error) { publisher.dispose(); throw error; }
@@ -375,12 +396,68 @@ export class PresencePublisher {
375
396
  if (this.disposed) return;
376
397
  try { action(); } catch (error) { this.error = reason(error); this.dispose(); }
377
398
  }
399
+ private rootPath(kind: "header" | "activity" | "discovery" | "catalog") {
400
+ return join(resolve(this.profile), "gentle-agents", kind === "catalog" ? "catalog" : "presence");
401
+ }
402
+ /** Publishes from the warm cache synchronously, then refreshes the validated
403
+ * ancestor chain asynchronously without ever overlapping itself. */
404
+ private pulse() {
405
+ this.guarded(() => this.publishHeader());
406
+ if (this.validating) return;
407
+ this.validating = true;
408
+ void this.revalidate().finally(() => { this.validating = false; }).catch(() => { /* revalidate never rejects */ });
409
+ }
410
+ /** Async mirror of rootFor(): same ancestor chain, modes, and ownership, so the
411
+ * sync write path stays cache-only. A failed validation only cools the cache. */
412
+ private async revalidate() {
413
+ const absolute = resolve(this.profile);
414
+ const shared = join(absolute, "gentle-agents");
415
+ const chains: { root: string; create: boolean }[] = [{ root: join(shared, "presence"), create: false }];
416
+ if (this.catalog) chains.push({ root: join(shared, "catalog"), create: true });
417
+ for (const { root, create } of chains) {
418
+ try {
419
+ for (let path = absolute;; path = dirname(path)) {
420
+ const stat = await fs.promises.lstat(path);
421
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("unsafe-directory");
422
+ if (dirname(path) === path) break;
423
+ }
424
+ if (create) {
425
+ try { await fs.promises.mkdir(root, { mode: 0o700 }); }
426
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
427
+ }
428
+ await this.checkedDirectory(shared, false);
429
+ await this.checkedDirectory(root, true);
430
+ this.validRoots.add(root);
431
+ } catch { this.validRoots.delete(root); }
432
+ }
433
+ }
434
+ private async checkedDirectory(path: string, privateMode: boolean) {
435
+ const stat = await fs.promises.lstat(path);
436
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (process.platform !== "win32"
437
+ && ((stat.mode & (privateMode ? 0o077 : 0o022)) !== 0 || stat.uid !== process.getuid?.()))) throw new Error("unsafe-directory");
438
+ }
378
439
  private write(kind: "header" | "activity" | "discovery" | "catalog", bytes: string) {
379
- const root = rootFor(this.profile, kind === "catalog", kind === "catalog");
440
+ // A cold cache validates synchronously exactly once for that path — the same
441
+ // rootFor() validation the old per-write path performed — then caches and
442
+ // writes, so the first publication after a cold start is never deferred to a
443
+ // heartbeat. A failed validation (vanished or unsafe tree) is the only skip
444
+ // path; the heartbeat's async refresh re-warms the cache once the tree returns.
445
+ const root = this.rootPath(kind);
446
+ if (!this.validRoots.has(root)) {
447
+ try { rootFor(this.profile, kind === "catalog", kind === "catalog"); }
448
+ catch { return; }
449
+ this.validRoots.add(root);
450
+ }
380
451
  const name = kind === "catalog" ? `${this.target.sessionHash}.${this.target.incarnation}.json` : filename(this.target, kind);
381
- atomicWrite(root, name, bytes);
382
- const { dev, ino } = fs.lstatSync(join(root, name));
383
- this.owned.set(join(root, name), { dev, ino });
452
+ let identity: { dev: number; ino: number };
453
+ try { identity = atomicWrite(root, name, bytes); }
454
+ catch (error) {
455
+ // A tree that vanished mid-flight is skipped, not fatal; other failures stay fatal.
456
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
457
+ this.validRoots.delete(root);
458
+ return;
459
+ }
460
+ this.owned.set(join(root, name), identity);
384
461
  }
385
462
  private writeDiscovery() {
386
463
  if (!this.discovery) return;
@@ -393,6 +470,7 @@ export class PresencePublisher {
393
470
  } catch { /* Metadata failure must not withdraw an existing activity peer. */ }
394
471
  }
395
472
  private publishHeader() {
473
+ if (!this.validRoots.has(this.rootPath("header"))) return;
396
474
  const header = { ...this.header, label: label(this.labelSource?.() ?? this.displayLabel), heartbeat: Date.now() };
397
475
  if (!validHeader(header)) throw new Error("malformed-header");
398
476
  this.write("header", JSON.stringify(header));
@@ -401,6 +479,7 @@ export class PresencePublisher {
401
479
  }
402
480
  private flush() {
403
481
  if (this.pending === this.published) return;
482
+ if (!this.validRoots.has(this.rootPath("activity"))) return;
404
483
  const activity: Activity = JSON.parse(this.pending);
405
484
  const generation = (this.header?.generation ?? 0) + 1;
406
485
  const bytes = JSON.stringify({ schema: 1, ...this.target, generation, activity });
@@ -41,9 +41,14 @@ export function validRecordedScope(value: any): value is RecordedScope {
41
41
  /** One bounded derivative snapshot, not a registry or authority. The caller supplies
42
42
  * only admitted, unfinished, non-restored owned children and durable registry roots.
43
43
  * Membership/cwd changes invalidate even unknown facts; session replacement clears.
44
- * No resolution runs on a stable heartbeat/token update. */
44
+ * Recency reorders rebuild caller-ordered snapshots without refreshing visible facts.
45
+ * Resolution stays bounded to the host and first eight tasks/registered roots;
46
+ * newly visible paths are resolved lazily, including after a reorder. */
45
47
  export class OrchestratorScopeCache {
46
48
  private key = "";
49
+ private membershipKey = "";
50
+ private facts = new Map<string, RepositoryFact>();
51
+ private resolvedAt = 0;
47
52
  private snapshot?: RecordedScope;
48
53
  private resolver: WorktreeResolver;
49
54
  private now: () => number;
@@ -53,14 +58,34 @@ export class OrchestratorScopeCache {
53
58
  }
54
59
  clear() {
55
60
  this.key = "";
61
+ this.membershipKey = "";
62
+ this.facts.clear();
56
63
  this.snapshot = undefined;
57
64
  }
65
+ /** Snapshot for deferred (render-cadence trailing) publications: it never
66
+ * touches the resolver. A deferred publish reuses the last event-time
67
+ * projection as-is even when membership changed since — its Git resolution
68
+ * must never race a peer metadata query, whose contract adds no caller Git
69
+ * probes. Returns undefined when nothing has been projected yet (or the
70
+ * cache was cleared), in which case the caller omits the scope rather than
71
+ * probing; the next event-time publication refreshes the facts. */
72
+ cachedScope(): RecordedScope | undefined {
73
+ return this.snapshot;
74
+ }
58
75
  project(cwd: string, tasks: readonly { id: string; cwd: string }[], roots: readonly string[]): RecordedScope {
59
76
  const registered = [...new Set(roots)];
60
77
  const key = createHash("sha256").update(JSON.stringify([cwd, tasks.map(t => [t.id, t.cwd]), registered])).digest("hex");
61
78
  if (key === this.key && this.snapshot) return this.snapshot;
62
- const resolvedAt = this.now();
63
- const facts = new Map<string, RepositoryFact>();
79
+ const membershipKey = createHash("sha256").update(JSON.stringify([
80
+ cwd, tasks.map(t => JSON.stringify([t.id, t.cwd])).sort(), [...registered].sort(),
81
+ ])).digest("hex");
82
+ if (membershipKey !== this.membershipKey) {
83
+ this.facts.clear();
84
+ this.resolvedAt = this.now();
85
+ this.membershipKey = membershipKey;
86
+ }
87
+ const resolvedAt = this.resolvedAt;
88
+ const facts = this.facts;
64
89
  const fact = (path: string) => {
65
90
  if (facts.has(path)) return facts.get(path)!;
66
91
  const unknown: RepositoryFact = { root: null, cloneHash: null, resolvedAt, source: "recorded-workspace/git" };
@@ -74,6 +99,9 @@ export class OrchestratorScopeCache {
74
99
  this.snapshot = { host: fact(cwd), tasks: tasks.slice(0, 8).map(t => ({ id: t.id, repository: fact(t.cwd) })),
75
100
  registered: registered.slice(0, 8).map(fact), omittedTasks: Math.max(0, tasks.length - 8),
76
101
  omittedRegistered: Math.max(0, registered.length - 8), complete: tasks.length <= 8 && registered.length <= 8 };
102
+ // Keep fact retention bounded even when an oversized task list rotates.
103
+ const visible = new Set([cwd, ...tasks.slice(0, 8).map(t => t.cwd), ...registered.slice(0, 8)]);
104
+ for (const path of facts.keys()) if (!visible.has(path)) facts.delete(path);
77
105
  this.key = key;
78
106
  return this.snapshot;
79
107
  }
@@ -89,11 +89,19 @@ export class OrchestratorStateCache {
89
89
  const previous = this.value?.aliases;
90
90
  // Null/null marks a new publication with no declaration yet; legacy absence
91
91
  // cannot establish historical first-task knowledge.
92
+ // An explicitly published work topic is owner-curated public task
93
+ // classification, so it refreshes the current alias when the caller omits
94
+ // the subject (#2553). State-only refreshes and automatic republications of
95
+ // unchanged state must not re-derive it, and an explicit subject in the
96
+ // same publication always wins.
97
+ const topic = input !== undefined && !declared && state?.work?.topic
98
+ ? normalizeTaskSubject(state.work.topic) : undefined;
92
99
  let aliases = previous;
93
- if (declared) {
100
+ if (declared || topic) {
101
+ const current = declared ?? topic;
94
102
  let initialAlias = previous?.initialAlias ?? null;
95
- if (!this.value || previous?.currentAlias === null) initialAlias = declared;
96
- aliases = { initialAlias, currentAlias: declared };
103
+ if (!this.value || previous?.currentAlias === null) initialAlias = current;
104
+ aliases = { initialAlias, currentAlias: current };
97
105
  } else if (!this.value) aliases = { initialAlias: null, currentAlias: null };
98
106
  const cwd = input === undefined && this.value ? this.value.cwd : manager.getCwd();
99
107
  const record = decodePublishedState({ schema: state?.work ? 2 : 1, sessionId: manager.getSessionId(),
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { join } from "node:path";
4
4
  import { gentlePiConfigHome } from "./agent-home.ts";
5
5
 
6
- export type PermissionMode = "normal" | "auto" | "yolo";
6
+ export type PermissionMode = "normal" | "auto" | "yolo" | "super-yolo";
7
7
  export const PERMISSION_MODE_SCHEMA = "gentle-pi.permission-mode/v1";
8
8
  export interface PermissionModeOptions { gentlePiConfigHome?: string }
9
9
  export interface PermissionModeResolution {
@@ -13,7 +13,7 @@ export interface PermissionModeResolution {
13
13
  globalFile: string;
14
14
  }
15
15
  export function isPermissionMode(value: unknown): value is PermissionMode {
16
- return value === "normal" || value === "auto" || value === "yolo";
16
+ return value === "normal" || value === "auto" || value === "yolo" || value === "super-yolo";
17
17
  }
18
18
  /** Never consult merged Pi settings or repository configuration. */
19
19
  export function permissionModePolicyFile(options: PermissionModeOptions = {}): string {
@@ -4,7 +4,9 @@
4
4
  // release bundle (or its extracted tree) and verifies it fail-closed before
5
5
  // any layout, role, schema, or capability inside it is trusted:
6
6
  //
7
- // - exact eight-entry inventory (README.md, manifest.json, 3 schemas, 3 vectors);
7
+ // - exact inventory: eight base entries (README.md, manifest.json, 3 schemas,
8
+ // 3 vectors) plus one `orchestration/<runtime>.md` (>= 1.2.0) and one
9
+ // `routing/<runtime>.md` (>= 1.4.0) per runtime the manifest lists;
8
10
  // - strict manifest decode: single JSON object, no duplicate keys, no unknown fields;
9
11
  // - per-file SHA-256 agreement between the manifest and the actual bytes;
10
12
  // - pinned transport capability and supported contract major (1);
@@ -33,6 +35,8 @@ export const SUPPORTED_PROVIDER_CONTRACT_MAJOR = 1;
33
35
  export const RUNTIMES_REQUIRED_SINCE_MINOR = 1;
34
36
  // `orchestration` became part of the manifest in contract 1.2.0.
35
37
  export const ORCHESTRATION_REQUIRED_SINCE_MINOR = 2;
38
+ // `routing` became part of the manifest in contract 1.4.0.
39
+ export const ROUTING_REQUIRED_SINCE_MINOR = 4;
36
40
  // Exactly the provider roles gentle-pi supports, in the manifest's strict order.
37
41
  export const PROVIDER_CONTRACT_ROLE_IDS = ["lens", "refuter", "targeted-validator"] as const;
38
42
  // Every mandatory capability a role may declare. An unknown mandatory
@@ -46,11 +50,11 @@ const MAX_MANIFEST_BYTES = 64 * 1024;
46
50
  const BUNDLE_ENTRY_COUNT = 8;
47
51
  // Generous fixed safety bound on raw archive/tree entries read BEFORE the
48
52
  // manifest itself is parsed (the exact expected count, base entries plus one
49
- // per `orchestration` runtime, is known only after manifest decode). Real
50
- // contracts register a handful of runtimes; this bound only rules out an
51
- // absurd smuggled inventory, it is not the exact-count check.
52
- const MAX_ORCHESTRATION_ENTRIES = 64;
53
- const MAX_RAW_BUNDLE_ENTRIES = BUNDLE_ENTRY_COUNT + MAX_ORCHESTRATION_ENTRIES;
53
+ // per `orchestration` and one per `routing` runtime, is known only after
54
+ // manifest decode). Real contracts register a handful of runtimes; this bound
55
+ // only rules out an absurd smuggled inventory, it is not the exact-count check.
56
+ const MAX_RUNTIME_FILE_ENTRIES = 64;
57
+ const MAX_RAW_BUNDLE_ENTRIES = BUNDLE_ENTRY_COUNT + 2 * MAX_RUNTIME_FILE_ENTRIES;
54
58
  // setuid/setgid/sticky, any execute bit, or world-writable content is never
55
59
  // acceptable for a data-only contract bundle.
56
60
  const FORBIDDEN_MODE_BITS = 0o7113;
@@ -99,7 +103,13 @@ export interface VerifiedProviderContractBundle {
99
103
  * 1.2.0, where `orchestration` does not exist in the manifest.
100
104
  */
101
105
  readonly orchestration: ReadonlyMap<string, string>;
102
- /** Canonical path -> exact verified bytes for all entries (base eight plus one per orchestration runtime). */
106
+ /**
107
+ * Verified ODD implementation-routing block, runtime identity -> UTF-8
108
+ * `routing/<runtime>.md` contents. Empty before contract 1.4.0, where
109
+ * `routing` does not exist in the manifest.
110
+ */
111
+ readonly routing: ReadonlyMap<string, string>;
112
+ /** Canonical path -> exact verified bytes for all entries (base eight plus one per orchestration and routing runtime). */
103
113
  readonly entries: ReadonlyMap<string, Buffer>;
104
114
  /** Canonical path -> SHA-256 of the exact verified bytes. */
105
115
  readonly entrySha256: ReadonlyMap<string, string>;
@@ -321,23 +331,65 @@ function parseManifestRole(value: unknown, index: number): ManifestRole {
321
331
  };
322
332
  }
323
333
 
324
- interface ManifestOrchestrationEntry {
334
+ // One entry of a runtime-file inventory: `orchestration` (>= 1.2.0) or
335
+ // `routing` (>= 1.4.0). Both share the shape `{ runtime, file }` with the file
336
+ // at `<inventory>/<runtime>.md`.
337
+ interface ManifestRuntimeFileEntry {
325
338
  readonly runtime: string;
326
339
  readonly file: ManifestFileReference;
327
340
  }
328
341
 
329
- function parseOrchestrationEntry(value: unknown, index: number): ManifestOrchestrationEntry {
330
- const where = `manifest.orchestration[${index}]`;
342
+ type RuntimeFileInventory = "orchestration" | "routing";
343
+
344
+ function parseRuntimeFileEntry(inventory: RuntimeFileInventory, value: unknown, index: number): ManifestRuntimeFileEntry {
345
+ const where = `manifest.${inventory}[${index}]`;
331
346
  const object = requireObject(value, where);
332
347
  requireExactKeys(object, ["runtime", "file"], ["runtime", "file"], where);
333
348
  const runtime = requireString(object.runtime, `${where}.runtime`);
334
349
  if (!RUNTIME_IDENTITY_PATTERN.test(runtime)) invalid(`${where}.runtime ${JSON.stringify(runtime)} is not a runtime identity`);
335
350
  const file = parseFileReference(object.file, `${where}.file`);
336
- const expectedPath = `orchestration/${runtime}.md`;
351
+ const expectedPath = `${inventory}/${runtime}.md`;
337
352
  if (file.path !== expectedPath) invalid(`${where}.file.path must be ${JSON.stringify(expectedPath)}`);
338
353
  return { runtime, file };
339
354
  }
340
355
 
356
+ // Decodes one runtime-file inventory: absent before the contract minor that
357
+ // introduced it, required from it on, strictly sorted, every runtime
358
+ // registered in manifest.runtimes, and `pi` always present.
359
+ function parseRuntimeFileInventory(
360
+ object: Record<string, unknown>,
361
+ inventory: RuntimeFileInventory,
362
+ requiredSinceMinor: number,
363
+ contractSemver: string,
364
+ minor: number,
365
+ runtimes: readonly string[] | undefined,
366
+ ): readonly ManifestRuntimeFileEntry[] {
367
+ const required = minor >= requiredSinceMinor;
368
+ if (!Object.prototype.hasOwnProperty.call(object, inventory)) {
369
+ if (required) invalid(`manifest.${inventory} is required from contract 1.${requiredSinceMinor}.0 (got ${contractSemver} without it)`);
370
+ return [];
371
+ }
372
+ if (!required) {
373
+ invalid(`manifest.${inventory} is not part of contract ${contractSemver}; it exists from 1.${requiredSinceMinor}.0`);
374
+ }
375
+ const value = object[inventory];
376
+ if (!Array.isArray(value) || value.length === 0) invalid(`manifest.${inventory} must be a non-empty array`);
377
+ const entries = value.map((entry, index) => parseRuntimeFileEntry(inventory, entry, index));
378
+ for (let position = 0; position < entries.length; position += 1) {
379
+ const entry = entries[position] as ManifestRuntimeFileEntry;
380
+ if (position > 0 && (entries[position - 1] as ManifestRuntimeFileEntry).runtime >= entry.runtime) {
381
+ invalid(`manifest.${inventory} must be strictly sorted by runtime and unique`);
382
+ }
383
+ if (runtimes === undefined || !runtimes.includes(entry.runtime)) {
384
+ invalid(`manifest.${inventory}[${position}].runtime ${JSON.stringify(entry.runtime)} is not registered in manifest.runtimes`);
385
+ }
386
+ }
387
+ if (!entries.some((entry) => entry.runtime === PI_RUNTIME_IDENTITY)) {
388
+ invalid(`manifest.${inventory} does not register the ${JSON.stringify(PI_RUNTIME_IDENTITY)} runtime identity`);
389
+ }
390
+ return entries;
391
+ }
392
+
341
393
  interface ParsedManifest {
342
394
  readonly contractSemver: string;
343
395
  readonly major: number;
@@ -347,14 +399,15 @@ interface ParsedManifest {
347
399
  readonly runtimes: readonly string[] | undefined;
348
400
  readonly readme: ManifestFileReference;
349
401
  readonly roles: readonly ManifestRole[];
350
- readonly orchestration: readonly ManifestOrchestrationEntry[];
402
+ readonly orchestration: readonly ManifestRuntimeFileEntry[];
403
+ readonly routing: readonly ManifestRuntimeFileEntry[];
351
404
  }
352
405
 
353
406
  function parseManifest(payload: Buffer): ParsedManifest {
354
407
  const object = decodeStrictManifestObject(payload);
355
408
  requireExactKeys(
356
409
  object,
357
- ["schema", "contract_semver", "transport_capability", "runtimes", "readme", "roles", "orchestration"],
410
+ ["schema", "contract_semver", "transport_capability", "runtimes", "readme", "roles", "orchestration", "routing"],
358
411
  ["schema", "contract_semver", "transport_capability", "readme", "roles"],
359
412
  "manifest",
360
413
  );
@@ -398,32 +451,8 @@ function parseManifest(payload: Buffer): ParsedManifest {
398
451
  invalid(`manifest.runtimes does not register the ${JSON.stringify(PI_RUNTIME_IDENTITY)} runtime identity`);
399
452
  }
400
453
 
401
- const orchestrationRequired = minor >= ORCHESTRATION_REQUIRED_SINCE_MINOR;
402
- let orchestration: readonly ManifestOrchestrationEntry[] = [];
403
- if (Object.prototype.hasOwnProperty.call(object, "orchestration")) {
404
- if (!orchestrationRequired) {
405
- invalid(`manifest.orchestration is not part of contract ${contractSemver}; it exists from 1.${ORCHESTRATION_REQUIRED_SINCE_MINOR}.0`);
406
- }
407
- if (!Array.isArray(object.orchestration) || object.orchestration.length === 0) {
408
- invalid("manifest.orchestration must be a non-empty array");
409
- }
410
- const parsedOrchestration = object.orchestration.map((entry, index) => parseOrchestrationEntry(entry, index));
411
- for (let position = 0; position < parsedOrchestration.length; position += 1) {
412
- const entry = parsedOrchestration[position] as ManifestOrchestrationEntry;
413
- if (position > 0 && (parsedOrchestration[position - 1] as ManifestOrchestrationEntry).runtime >= entry.runtime) {
414
- invalid("manifest.orchestration must be strictly sorted by runtime and unique");
415
- }
416
- if (runtimes === undefined || !runtimes.includes(entry.runtime)) {
417
- invalid(`manifest.orchestration[${position}].runtime ${JSON.stringify(entry.runtime)} is not registered in manifest.runtimes`);
418
- }
419
- }
420
- if (!parsedOrchestration.some((entry) => entry.runtime === PI_RUNTIME_IDENTITY)) {
421
- invalid(`manifest.orchestration does not register the ${JSON.stringify(PI_RUNTIME_IDENTITY)} runtime identity`);
422
- }
423
- orchestration = parsedOrchestration;
424
- } else if (orchestrationRequired) {
425
- invalid(`manifest.orchestration is required from contract 1.${ORCHESTRATION_REQUIRED_SINCE_MINOR}.0 (got ${contractSemver} without it)`);
426
- }
454
+ const orchestration = parseRuntimeFileInventory(object, "orchestration", ORCHESTRATION_REQUIRED_SINCE_MINOR, contractSemver, minor, runtimes);
455
+ const routing = parseRuntimeFileInventory(object, "routing", ROUTING_REQUIRED_SINCE_MINOR, contractSemver, minor, runtimes);
427
456
 
428
457
  if (!Array.isArray(object.roles)) invalid("manifest.roles must be an array");
429
458
  const roles = object.roles.map((role, index) => parseManifestRole(role, index));
@@ -446,6 +475,7 @@ function parseManifest(payload: Buffer): ParsedManifest {
446
475
  readme: parseFileReference(object.readme, "manifest.readme"),
447
476
  roles,
448
477
  orchestration,
478
+ routing,
449
479
  };
450
480
  }
451
481
 
@@ -541,8 +571,9 @@ export function verifyProviderContractBundleEntries(rawEntries: ReadonlyMap<stri
541
571
 
542
572
  // The exact expected inventory size is only known once the manifest is
543
573
  // decoded: base entries plus exactly one `orchestration/<runtime>.md` per
544
- // registered orchestration runtime.
545
- const expectedEntryCount = BUNDLE_ENTRY_COUNT + manifest.orchestration.length;
574
+ // registered orchestration runtime and one `routing/<runtime>.md` per
575
+ // registered routing runtime.
576
+ const expectedEntryCount = BUNDLE_ENTRY_COUNT + manifest.orchestration.length + manifest.routing.length;
546
577
  if (rawEntries.size !== expectedEntryCount) {
547
578
  invalid(`bundle inventory has ${rawEntries.size} files; exactly ${expectedEntryCount} are expected`);
548
579
  }
@@ -554,13 +585,18 @@ export function verifyProviderContractBundleEntries(rawEntries: ReadonlyMap<stri
554
585
  expected.add(`schemas/${role.id}.schema.json`);
555
586
  expected.add(`vectors/${role.id}.json`);
556
587
  }
557
- const orchestration = new Map<string, string>();
558
- for (const entry of manifest.orchestration) {
559
- const path = `orchestration/${entry.runtime}.md`;
560
- const payload = verifyFileReference(rawEntries, entry.file, path);
561
- orchestration.set(entry.runtime, payload.toString("utf8"));
562
- expected.add(path);
563
- }
588
+ const verifyRuntimeFiles = (inventory: RuntimeFileInventory, manifestEntries: readonly ManifestRuntimeFileEntry[]): Map<string, string> => {
589
+ const texts = new Map<string, string>();
590
+ for (const entry of manifestEntries) {
591
+ const path = `${inventory}/${entry.runtime}.md`;
592
+ const payload = verifyFileReference(rawEntries, entry.file, path);
593
+ texts.set(entry.runtime, payload.toString("utf8"));
594
+ expected.add(path);
595
+ }
596
+ return texts;
597
+ };
598
+ const orchestration = verifyRuntimeFiles("orchestration", manifest.orchestration);
599
+ const routing = verifyRuntimeFiles("routing", manifest.routing);
564
600
  for (const name of rawEntries.keys()) {
565
601
  if (!expected.has(name)) invalid(`bundle has unexpected file ${JSON.stringify(name)}`);
566
602
  }
@@ -591,6 +627,7 @@ export function verifyProviderContractBundleEntries(rawEntries: ReadonlyMap<stri
591
627
  vectorSha256: role.vector.sha256,
592
628
  })),
593
629
  orchestration,
630
+ routing,
594
631
  entries,
595
632
  entrySha256,
596
633
  treeSha256: bundleTreeSha256(entrySha256),
@@ -745,6 +782,18 @@ export const PROVIDER_CAPABILITIES_BASELINE_SCHEMA = "gentle-pi.provider-contrac
745
782
  export const PROVIDER_ROLES_BASELINE_FILE = "provider-roles.baseline.json";
746
783
  export const PROVIDER_CAPABILITIES_BASELINE_FILE = "provider-capabilities.baseline.json";
747
784
 
785
+ function runtimeFileBaseline(
786
+ bundle: VerifiedProviderContractBundle,
787
+ inventory: RuntimeFileInventory,
788
+ texts: ReadonlyMap<string, string>,
789
+ ): { runtime: string; path: string; sha256: string | undefined }[] {
790
+ return [...texts.keys()].sort().map((runtime) => ({
791
+ runtime,
792
+ path: `${inventory}/${runtime}.md`,
793
+ sha256: bundle.entrySha256.get(`${inventory}/${runtime}.md`),
794
+ }));
795
+ }
796
+
748
797
  /**
749
798
  * Deterministically derives the provider-owned consumer contract surface from
750
799
  * a verified bundle. Pi overlays never live in these files; they are pure
@@ -773,11 +822,8 @@ export function generateProviderContractBaselines(bundle: VerifiedProviderContra
773
822
  mandatory_capabilities: mandatory,
774
823
  runtimes: bundle.runtimes === undefined ? null : [...bundle.runtimes],
775
824
  pi_registered: piRuntimeRegistration(bundle).registered,
776
- orchestration: [...bundle.orchestration.keys()].sort().map((runtime) => ({
777
- runtime,
778
- path: `orchestration/${runtime}.md`,
779
- sha256: bundle.entrySha256.get(`orchestration/${runtime}.md`),
780
- })),
825
+ orchestration: runtimeFileBaseline(bundle, "orchestration", bundle.orchestration),
826
+ routing: runtimeFileBaseline(bundle, "routing", bundle.routing),
781
827
  };
782
828
  return new Map([
783
829
  [PROVIDER_ROLES_BASELINE_FILE, `${JSON.stringify(roles, null, 2)}\n`],