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
@@ -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`],
@@ -208,19 +208,19 @@ function enforcePrivateWindowsDacl(path: string, identity: WindowsAclIdentity =
208
208
  assertPrivateWindowsDacl(path, "directory", true, identity);
209
209
  }
210
210
 
211
- function privateWindowsDacl(path: string, kind: WindowsObjectKind, protectedDacl: boolean, enforce = false): void {
211
+ function privateWindowsDacl(path: string, kind: WindowsObjectKind, protectedDacl: boolean, enforce = false, windowsIdentity?: WindowsAclIdentity): void {
212
212
  if (testingWindowsAclAuthority !== undefined) return testingWindowsAclAuthority(path);
213
- if (enforce) enforcePrivateWindowsDacl(path);
214
- else assertPrivateWindowsDacl(path, kind, protectedDacl);
213
+ if (enforce) enforcePrivateWindowsDacl(path, windowsIdentity);
214
+ else assertPrivateWindowsDacl(path, kind, protectedDacl, windowsIdentity);
215
215
  }
216
216
 
217
- function privateWindowsCandidateOwnerBoundary(commonDir: string, enforce = false): void {
217
+ function privateWindowsCandidateOwnerBoundary(commonDir: string, enforce = false, windowsIdentity?: WindowsAclIdentity): void {
218
218
  const boundary = [commonDir, join(commonDir, "gentle-ai"), join(commonDir, "gentle-ai", "candidate-views")];
219
219
  if (testingWindowsAclAuthority !== undefined) {
220
220
  for (const path of boundary) testingWindowsAclAuthority(path);
221
221
  return;
222
222
  }
223
- const identity = windowsAclIdentity();
223
+ const identity = windowsIdentity ?? windowsAclIdentity();
224
224
  if (enforce) {
225
225
  for (const path of boundary) validatePrivateWindowsOwner(windowsOwnerSid(path, "directory"), identity.user);
226
226
  for (const path of boundary) enforcePrivateWindowsDacl(path, identity);
@@ -304,16 +304,23 @@ function directory(path: string, privateMode = false, platform: NodeJS.Platform
304
304
  return `${stat.dev}:${stat.ino}`;
305
305
  }
306
306
 
307
- export function assertCandidateOwnerParent(commonDir: string, platform: NodeJS.Platform = process.platform): string {
307
+ function checkedCandidateOwnerParent(commonDir: string, platform: NodeJS.Platform): { parent: string; windowsIdentity?: WindowsAclIdentity } {
308
308
  directory(commonDir, false, platform);
309
309
  const control = join(commonDir, "gentle-ai");
310
310
  directory(control, false, platform);
311
311
  const parent = join(commonDir, "gentle-ai", "candidate-views");
312
312
  if (platform === "win32") {
313
313
  directory(parent, false, platform);
314
- privateWindowsCandidateOwnerBoundary(commonDir);
315
- } else directory(parent, true, platform, true);
316
- return parent;
314
+ const windowsIdentity = testingWindowsAclAuthority === undefined ? windowsAclIdentity() : undefined;
315
+ privateWindowsCandidateOwnerBoundary(commonDir, false, windowsIdentity);
316
+ return { parent, windowsIdentity };
317
+ }
318
+ directory(parent, true, platform, true);
319
+ return { parent };
320
+ }
321
+
322
+ export function assertCandidateOwnerParent(commonDir: string, platform: NodeJS.Platform = process.platform): string {
323
+ return checkedCandidateOwnerParent(commonDir, platform).parent;
317
324
  }
318
325
 
319
326
  export function prepareCandidateOwnerParent(commonDir: string, platform: NodeJS.Platform = process.platform): string {
@@ -329,13 +336,13 @@ export function prepareCandidateOwnerParent(commonDir: string, platform: NodeJS.
329
336
  return assertCandidateOwnerParent(commonDir, platform);
330
337
  }
331
338
 
332
- function regular(path: string, privateMode = false, platform: NodeJS.Platform = process.platform): string {
339
+ function regular(path: string, privateMode = false, platform: NodeJS.Platform = process.platform, windowsIdentity?: WindowsAclIdentity): string {
333
340
  // Keep 64-bit identity exact without a second stat lookup racing the privacy checks.
334
341
  const stat = lstatSync(path, { bigint: true });
335
342
  const uid = process.getuid?.();
336
343
  if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1n || !samePath(realpathSync(path), path, platform) || stat.size > 16384n ||
337
344
  (privateMode && platform !== "win32" && (uid === undefined || stat.uid !== BigInt(uid) || (stat.mode & 0o777n) !== 0o600n))) throw new Error("Unsafe candidate owner file");
338
- if (privateMode && platform === "win32") privateWindowsDacl(path, "file", false);
345
+ if (privateMode && platform === "win32") privateWindowsDacl(path, "file", false, false, windowsIdentity);
339
346
  return `${stat.dev}:${stat.ino}`;
340
347
  }
341
348
 
@@ -347,27 +354,27 @@ function syncDirectory(path: string): void {
347
354
  try { fsyncSync(fd); } finally { closeSync(fd); }
348
355
  }
349
356
 
350
- function removeExactPrivateFile(path: string, identity: string, platform: NodeJS.Platform): void {
357
+ function removeExactPrivateFile(path: string, identity: string, platform: NodeJS.Platform, windowsIdentity?: WindowsAclIdentity): void {
351
358
  try {
352
- if (regular(path, true, platform) !== identity) return;
359
+ if (regular(path, true, platform, windowsIdentity) !== identity) return;
353
360
  unlinkSync(path);
354
361
  syncDirectory(dirname(path));
355
362
  } catch { /* An unproven or replaced file survives. */ }
356
363
  }
357
364
 
358
- function exclusiveFile(path: string, content: string, platform: NodeJS.Platform = process.platform, rollbackOnFailure = false): string {
365
+ function exclusiveFile(path: string, content: string, platform: NodeJS.Platform = process.platform, rollbackOnFailure = false, windowsIdentity?: WindowsAclIdentity): string {
359
366
  let identity: string | undefined;
360
367
  try {
361
368
  const fd = openSync(path, "wx", 0o600);
362
369
  try {
363
- identity = regular(path, true, platform);
370
+ identity = regular(path, true, platform, windowsIdentity);
364
371
  writeFileSync(fd, content);
365
372
  fsyncSync(fd);
366
373
  } finally { closeSync(fd); }
367
374
  syncDirectory(dirname(path));
368
375
  return identity;
369
376
  } catch (error) {
370
- if (rollbackOnFailure && identity !== undefined) removeExactPrivateFile(path, identity, platform);
377
+ if (rollbackOnFailure && identity !== undefined) removeExactPrivateFile(path, identity, platform, windowsIdentity);
371
378
  throw error;
372
379
  }
373
380
  }
@@ -375,7 +382,10 @@ function exclusiveFile(path: string, content: string, platform: NodeJS.Platform
375
382
  function markerPath(root: string): string { return `${root}.owner.json`; }
376
383
 
377
384
  export function createCandidateOwner(commonDir: string, root: string, platform: NodeJS.Platform = process.platform): CandidateViewOwner {
378
- const parent = assertCandidateOwnerParent(commonDir, platform);
385
+ // Reuse only identity within this module-controlled synchronous creation.
386
+ // Owner/DACL observations stay fresh, including rollback. Nothing is retained
387
+ // across calls or passed into callback-based removal/sweeping operations.
388
+ const { parent, windowsIdentity } = checkedCandidateOwnerParent(commonDir, platform);
379
389
  const uuid = basename(root);
380
390
  if (!UUID.test(uuid) || root !== join(parent, uuid) || lstatSync(root, { throwIfNoEntry: false })) throw new Error("Unsafe candidate owner root");
381
391
  const owner: CandidateViewOwner = { version: 1, uuid, token: randomUUID(), pid: process.pid, host: localHost(), root, commonDir };
@@ -383,11 +393,11 @@ export function createCandidateOwner(commonDir: string, root: string, platform:
383
393
  const marker = markerPath(root);
384
394
  let markerIdentity: string | undefined;
385
395
  try {
386
- markerIdentity = exclusiveFile(marker, JSON.stringify(owner), platform, true);
396
+ markerIdentity = exclusiveFile(marker, JSON.stringify(owner), platform, true, windowsIdentity);
387
397
  syncDirectory(dirname(parent));
388
398
  syncDirectory(commonDir);
389
399
  } catch (error) {
390
- if (markerIdentity !== undefined) removeExactPrivateFile(marker, markerIdentity, platform);
400
+ if (markerIdentity !== undefined) removeExactPrivateFile(marker, markerIdentity, platform, windowsIdentity);
391
401
  throw error;
392
402
  }
393
403
  return Object.freeze(owner);
@@ -1,6 +1,6 @@
1
1
  import { execFileSync, type ExecFileSyncOptions } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
- import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, symlinkSync, utimesSync } from "node:fs";
3
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, symlinkSync, utimesSync, type Stats } from "node:fs";
4
4
  import { mkdtempSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -650,6 +650,32 @@ function makeReadonly(root: string, entries: readonly CandidateViewEntry[]): voi
650
650
  for (const directory of candidateDirectories(root, entries)) chmodSync(directory, 0o555);
651
651
  }
652
652
 
653
+ // macOS File Provider (iCloud-synced Desktop/Documents) asynchronously resets
654
+ // directory modes to exactly 0700 while leaving file modes intact, so a view
655
+ // made read-only at creation later reads as writable. Only that exact shape on
656
+ // a directory this user owns is re-tightened; any group or other bit still
657
+ // refuses. Owner-only write grants nothing a same-user process could not do by
658
+ // chmod anyway, and the caller then proves the directory holds exactly the
659
+ // frozen children, closing the ignored-file injection the untracked scan misses.
660
+ function repairFileProviderDirectoryMode(directory: string, metadata: Stats, platform: NodeJS.Platform): boolean {
661
+ if (platform === "win32" || !metadata.isDirectory() || metadata.isSymbolicLink() || (metadata.mode & 0o7777) !== 0o700) return false;
662
+ if (typeof process.getuid !== "function" || metadata.uid !== process.getuid()) return false;
663
+ chmodSync(directory, 0o555);
664
+ return true;
665
+ }
666
+
667
+ function expectedDirectoryChildren(root: string, entries: readonly CandidateViewEntry[]): ReadonlyMap<string, ReadonlySet<string>> {
668
+ const children = new Map<string, Set<string>>([[root, new Set([".git"])]]);
669
+ for (const entry of entries) {
670
+ for (let path = join(root, entry.path); path !== root && isWithin(root, path); path = dirname(path)) {
671
+ const parent = dirname(path);
672
+ if (!children.has(parent)) children.set(parent, new Set());
673
+ children.get(parent)!.add(basename(path));
674
+ }
675
+ }
676
+ return children;
677
+ }
678
+
653
679
  function makeWritableForCleanup(path: string): void {
654
680
  const entry = lstatSync(path, { throwIfNoEntry: false });
655
681
  if (!entry || entry.isSymbolicLink()) return;
@@ -1050,8 +1076,16 @@ function assertRecordSafe(record: CandidateViewRecord, platform: NodeJS.Platform
1050
1076
  if (!isWithin(record.parent, root) || !existsSync(root)) throw new CandidateViewError("candidate view is missing or moved");
1051
1077
  const rootStat = lstatSync(root);
1052
1078
  if (!rootStat.isDirectory() || rootStat.isSymbolicLink() || realpathSync(root) !== root) throw new CandidateViewError("candidate view root is unsafe");
1079
+ let expectedChildren: ReadonlyMap<string, ReadonlySet<string>> | undefined;
1053
1080
  for (const directory of candidateDirectories(root, record.entries)) {
1054
- const metadata = lstatSync(directory);
1081
+ let metadata = lstatSync(directory);
1082
+ if (repairFileProviderDirectoryMode(directory, metadata, platform)) {
1083
+ expectedChildren ??= expectedDirectoryChildren(root, record.entries);
1084
+ const expected = expectedChildren.get(directory) ?? new Set<string>();
1085
+ const actual = readdirSync(directory);
1086
+ if (actual.length !== expected.size || actual.some((name) => !expected.has(name))) throw new CandidateViewError("candidate view directory contains unexpected entries");
1087
+ metadata = lstatSync(directory);
1088
+ }
1055
1089
  if (!metadata.isDirectory() || metadata.isSymbolicLink() || (metadata.mode & 0o222) !== 0) throw new CandidateViewError("candidate view directory is unsafe or writable");
1056
1090
  }
1057
1091
  const gitFile = lstatSync(join(root, ".git"));
@@ -267,8 +267,9 @@ export type VerificationTier = (typeof VERIFICATION_TIER)[keyof typeof VERIFICAT
267
267
  // ---------------------------------------------------------------------------
268
268
  // Agent-raised risk (gentle-shell#1494). The agent that made the change may
269
269
  // raise its risk to high by citing one item of the high-risk list in the
270
- // always-on Task Size section; it can never lower a tier. Lowering stays with
271
- // the deterministic native assessment.
270
+ // always-on Task Size section; it can never lower a tier. That escalation is
271
+ // the only way a plan gets an independent verifier: a native high or a failed
272
+ // assessment informs but never adds one by itself.
272
273
  // ---------------------------------------------------------------------------
273
274
 
274
275
  export const HIGH_RISK_ITEMS = Object.freeze({
@@ -305,10 +306,17 @@ export function decodeAgentRiskEscalation(value: unknown): AgentRiskEscalation {
305
306
  return Object.freeze({ item: item as HighRiskItem, reason });
306
307
  }
307
308
 
308
- /** Raises passive or medium to high when the agent escalated; never lowers. */
309
- export function escalatedRisk(risk: VerificationTier, escalation: AgentRiskEscalation | undefined): VerificationTier {
310
- if (escalation === undefined) return risk;
311
- return risk === VERIFICATION_TIER.PASSIVE || risk === VERIFICATION_TIER.MEDIUM ? VERIFICATION_TIER.HIGH : risk;
309
+ /**
310
+ * The risk tier the verification plan follows. The independent verifier is
311
+ * for concrete occasions only: the plan is high only when the agent escalated
312
+ * a named Task Size high-risk item. Without an escalation a native passive
313
+ * stays passive, and every other native tier -- medium, high, or unassessable
314
+ * (a failed or unavailable assessment) -- is medium: it informs, but adds no
315
+ * verifier by itself. The native tier stays visible to callers as `nativeRisk`.
316
+ */
317
+ export function verificationRisk(nativeRisk: VerificationTier, escalation: AgentRiskEscalation | undefined): VerificationTier {
318
+ if (escalation !== undefined) return VERIFICATION_TIER.HIGH;
319
+ return nativeRisk === VERIFICATION_TIER.PASSIVE ? VERIFICATION_TIER.PASSIVE : VERIFICATION_TIER.MEDIUM;
312
320
  }
313
321
 
314
322
  export const RDD_LINE = {
@@ -342,7 +350,9 @@ export type NativeReviewOutcome = (typeof NATIVE_REVIEW_OUTCOME)[keyof typeof NA
342
350
 
343
351
  export interface VerificationPlanInput {
344
352
  readonly rddLine: RddLine;
353
+ /** The plan risk from `verificationRisk`, not the raw native tier. */
345
354
  readonly risk: VerificationTier;
355
+ /** Reported context only: the writer's model or effort never changes the plan. */
346
356
  readonly writerProfile: WriterProfile;
347
357
  /**
348
358
  * Native review outcome for the current candidate. Only consulted when
@@ -366,7 +376,7 @@ export interface VerificationPlan {
366
376
  * naming which branch fired, up to and including the word "and"; the
367
377
  * tier-specific clause is appended after it.
368
378
  */
369
- function riskGatedPlan(gatePrefix: string, risk: VerificationTier, writerProfile: WriterProfile): VerificationPlan {
379
+ function riskGatedPlan(gatePrefix: string, risk: VerificationTier): VerificationPlan {
370
380
  if (risk === VERIFICATION_TIER.PASSIVE) {
371
381
  return Object.freeze({
372
382
  writerSelfVerification: false,
@@ -376,26 +386,22 @@ function riskGatedPlan(gatePrefix: string, risk: VerificationTier, writerProfile
376
386
  });
377
387
  }
378
388
 
379
- if (risk === VERIFICATION_TIER.MEDIUM) {
380
- const smallModelBias = writerProfile === WRITER_PROFILE.SMALL;
389
+ if (risk === VERIFICATION_TIER.HIGH) {
381
390
  return Object.freeze({
382
391
  writerSelfVerification: true,
383
392
  structuralReadbackOnly: false,
384
- independentVerifier: smallModelBias,
385
- reason: smallModelBias
386
- ? `${gatePrefix} the native assessment reports medium risk, but the small-model bias raises it one tier to high for verification purposes: the writer self-verifies and a separate independent verifier also runs.`
387
- : `${gatePrefix} the native assessment reports medium risk with a large writer profile: the writer's self-verification stands and no separate verifier is required.`,
393
+ independentVerifier: true,
394
+ reason: `${gatePrefix} an agent escalation names a Task Size high-risk item: the writer self-verifies and a separate independent verifier runs.`,
388
395
  });
389
396
  }
390
397
 
391
- // high or unassessable.
398
+ // medium, or an unassessable tier that did not go through verificationRisk:
399
+ // both inform only.
392
400
  return Object.freeze({
393
401
  writerSelfVerification: true,
394
402
  structuralReadbackOnly: false,
395
- independentVerifier: true,
396
- reason: risk === VERIFICATION_TIER.UNASSESSABLE
397
- ? `${gatePrefix} the native risk assessment could not be produced, so the candidate is treated as high risk: the writer self-verifies and a separate independent verifier always runs.`
398
- : `${gatePrefix} the native assessment reports high risk: the writer self-verifies and a separate independent verifier always runs.`,
403
+ independentVerifier: false,
404
+ reason: `${gatePrefix} no Task Size high-risk item was escalated: the writer's self-verification stands and no separate verifier runs, whatever the native tier or the model that wrote the change.`,
399
405
  });
400
406
  }
401
407
 
@@ -416,24 +422,23 @@ function nonClosedOutcomeClause(outcome: NativeReviewOutcome): string {
416
422
 
417
423
  /**
418
424
  * Computes who verifies a delegated writer's change. Verification follows the
419
- * native risk tier whatever the RDD line and native review outcome
425
+ * plan risk tier whatever the RDD line and native review outcome
420
426
  * (verify-always-rdd-high S1, superseding the gentle-pi#662/#668 on-path): the
421
427
  * native review is an additional outside view and never replaces or skips the
422
428
  * tier's verification. `nativeReviewOutcome` only shapes the reason text.
423
429
  *
430
+ * `risk` is the plan risk from `verificationRisk`; `writerProfile` is never
431
+ * consulted, so the model or effort that wrote the change never adds a
432
+ * verifier.
433
+ *
424
434
  * - `passive`: structural readback by the parent only; no verifier, no tests.
425
- * - `medium`: the writer self-verifies; a separate independent verifier runs
426
- * only when `writerProfile` is `"small"` -- the small-model bias raises the
427
- * tier by one for verification purposes (medium -> high). An unknown or
428
- * omitted writer profile resolves to `"small"` before it reaches this
429
- * function (see `resolveWriterProfile`), so an unrecognized profile never
430
- * lowers a tier below what a known small model would get.
431
- * - `high` or `unassessable` (a failed or unrecognized native assessment is
432
- * always treated as high): the writer self-verifies and an independent
433
- * verifier always runs.
435
+ * - `medium` (also an `unassessable` tier passed directly): the writer
436
+ * self-verifies; no separate verifier.
437
+ * - `high` (only through an agent escalation naming a Task Size high-risk
438
+ * item): the writer self-verifies and an independent verifier runs.
434
439
  */
435
440
  export function verificationPlan(input: VerificationPlanInput): VerificationPlan {
436
- const { rddLine, risk, writerProfile } = input;
441
+ const { rddLine, risk } = input;
437
442
 
438
443
  if (rddLine === RDD_LINE.ON) {
439
444
  const outcome = input.nativeReviewOutcome ?? NATIVE_REVIEW_OUTCOME.UNKNOWN;
@@ -443,14 +448,13 @@ export function verificationPlan(input: VerificationPlanInput): VerificationPlan
443
448
  return riskGatedPlan(
444
449
  `receipt-driven development is on and ${clause}; the native review is an additional outside view that never replaces verification, so the risk-gated path applies exactly as receipt-driven development off, and`,
445
450
  risk,
446
- writerProfile,
447
451
  );
448
452
  }
449
453
 
450
454
  // rddLine is "off" or "unknown" here -- both gate on the native risk
451
455
  // assessment identically, so an unknown RDD line never lowers a tier, and
452
456
  // neither line ever consults nativeReviewOutcome.
453
- return riskGatedPlan(`receipt-driven development is ${rddLine} and`, risk, writerProfile);
457
+ return riskGatedPlan(`receipt-driven development is ${rddLine} and`, risk);
454
458
  }
455
459
 
456
460
  // ---------------------------------------------------------------------------
@@ -475,15 +479,16 @@ export interface WriterModelProfileLike {
475
479
  const SMALL_MODEL_ID_TOKEN = /(?:^|[-_./:\s])mini(?:$|[-_./:\s])/i;
476
480
 
477
481
  /**
478
- * A writer profile counts as a "small model" for the medium-risk bias when:
482
+ * A writer profile counts as a "small model" (reported by ASSESS as
483
+ * `writerProfile`; it never changes the verification plan) when:
479
484
  * - its resolved effort is `low`, or
480
485
  * - its resolved model id carries `mini` as a whole token (see
481
486
  * `SMALL_MODEL_ID_TOKEN`), or
482
487
  * - it carries NO signal at all -- no profile object, or a profile with
483
488
  * neither a model id nor a resolved effort. An unknown or omitted writer
484
- * profile fails closed to small rather than defaulting to large, so a
485
- * caller that cannot resolve the writer's profile never gets a weaker
486
- * verification plan than a known small model would (gentle-pi#662).
489
+ * profile fails closed to small rather than defaulting to large. The
490
+ * result is reported in the assessment only: the verification plan never
491
+ * reads the writer profile, so it cannot add or remove a verifier.
487
492
  *
488
493
  * A profile that carries a model id or an effort, just not a matching one
489
494
  * (for example `{model: {id: "claude-sonnet-5"}, thinking: "high"}`), is
@@ -501,9 +506,9 @@ export function isSmallWriterProfile(profile: WriterModelProfileLike | undefined
501
506
  }
502
507
 
503
508
  /**
504
- * Maps a resolved writer profile to the `writerProfile` input `verificationPlan`
505
- * expects. An unknown or omitted profile never lowers a tier: it resolves to
506
- * `"small"`, exactly like `isSmallWriterProfile` (gentle-pi#662).
509
+ * Maps a resolved writer profile to the reported `writerProfile`. An unknown
510
+ * or omitted profile resolves to `"small"`, exactly like
511
+ * `isSmallWriterProfile` (gentle-pi#662).
507
512
  */
508
513
  export function resolveWriterProfile(profile: WriterModelProfileLike | undefined): WriterProfile {
509
514
  return isSmallWriterProfile(profile) ? WRITER_PROFILE.SMALL : WRITER_PROFILE.LARGE;
@@ -15,6 +15,7 @@ export interface GitCommandOptions {
15
15
  timeout: number;
16
16
  maxBuffer: number;
17
17
  windowsHide?: boolean;
18
+ stdio?: ["ignore", "pipe", "pipe"];
18
19
  }
19
20
 
20
21
  export type GitAsyncRunner = (command: string, args: readonly string[], options: GitCommandOptions) => Promise<{ stdout: string }>;
@@ -133,7 +134,8 @@ export function resolveCanonicalGitRepositoryIdentitySync(
133
134
  run: GitSyncRunner = execFileSync as unknown as GitSyncRunner,
134
135
  ): string | undefined {
135
136
  try {
136
- const output = run("git", ["-C", cwd, "rev-parse", "--git-common-dir"], { encoding: "utf8", timeout: 5_000, maxBuffer: 64 * 1024, windowsHide: true }).trim();
137
+ // execFileSync otherwise echoes stderr on failure, even when caught below.
138
+ const output = run("git", ["-C", cwd, "rev-parse", "--git-common-dir"], { encoding: "utf8", timeout: 5_000, maxBuffer: 64 * 1024, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }).trim();
137
139
  if (output.length === 0 || output.includes("\n") || output.includes("\r")) return undefined;
138
140
  return canonicalRepositoryIdentity(realpathSync(resolve(cwd, output)));
139
141
  } catch {