pi-daddy 0.22.0 → 0.23.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 (362) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/contracts/daily-view/v1/README.md +114 -0
  3. package/contracts/daily-view/v1/fixtures/view.json +525 -0
  4. package/contracts/daily-view/v1/fixtures/view.txt +75 -0
  5. package/contracts/daily-view/v1/fixtures/work.jsonl +17 -0
  6. package/contracts/daily-view/v1/p03/README.md +26 -0
  7. package/contracts/daily-view/v1/p03/fixtures/retained-executions.json +241 -0
  8. package/contracts/daily-view/v1/p03/projection.schema.json +194 -0
  9. package/contracts/daily-view/v1/p03/provenance.json +21 -0
  10. package/contracts/dashboard-host/v1/README.md +176 -0
  11. package/contracts/debrief/v1/README.md +146 -0
  12. package/contracts/debrief/v1/intervention/README.md +32 -0
  13. package/contracts/debrief/v1/intervention/fixtures/artifact-3ee11136beb864f518252e5024dae9197421fbf97b6e981d3243c782b5c44bd0.txt +1 -0
  14. package/contracts/debrief/v1/intervention/fixtures/artifact-58905d872dd3c54a9705f67b05fa82edbad7af6476ae8993faf1a7b9fadfb905.txt +1 -0
  15. package/contracts/debrief/v1/intervention/fixtures/assessment.json +29 -0
  16. package/contracts/debrief/v1/intervention/fixtures/blind-view.json +20 -0
  17. package/contracts/debrief/v1/intervention/fixtures/choice.json +7 -0
  18. package/contracts/debrief/v1/intervention/fixtures/manifest.json +57 -0
  19. package/contracts/debrief/v1/intervention/fixtures/reveal.json +39 -0
  20. package/contracts/debrief/v1/provenance.json +89 -0
  21. package/contracts/debrief/v1/work-capture/README.md +38 -0
  22. package/contracts/debrief/v1/work-capture/fixtures/batch.json +17 -0
  23. package/contracts/debrief/v1/work-capture/fixtures/cases.json +184 -0
  24. package/contracts/debrief/v1/work-capture/fixtures/review.json +184 -0
  25. package/contracts/debrief/v1/work-capture/review-request.schema.json +41 -0
  26. package/contracts/debrief/v1/work-capture/work-case.schema.json +190 -0
  27. package/contracts/debrief/v2/README.md +71 -0
  28. package/contracts/debrief/v2/input-pin.json +66 -0
  29. package/contracts/debrief/v2/intervention-upstream.md +40 -0
  30. package/contracts/debrief/v2/work-signals-upstream.md +22 -0
  31. package/contracts/dispatch-control/v1/README.md +109 -0
  32. package/contracts/dispatch-control/v1/request.schema.json +19 -0
  33. package/contracts/effect-profile/v1/README.md +124 -0
  34. package/contracts/execution-retention/v1/README.md +109 -0
  35. package/contracts/execution-retention/v2/README.md +148 -0
  36. package/contracts/execution-retention/v2/fixtures/interrupted-native-bytes.json +91 -0
  37. package/contracts/execution-retention/v2/fixtures/missing-native-session.json +91 -0
  38. package/contracts/execution-retention/v2/fixtures/native-file-unknown-branch.json +91 -0
  39. package/contracts/execution-retention/v2/fixtures/native-id-without-bytes.json +91 -0
  40. package/contracts/execution-retention/v2/fixtures/native-live-branch.json +90 -0
  41. package/contracts/execution-retention/v2/fixtures/replaced-native-session.json +92 -0
  42. package/contracts/execution-retention/v2/fixtures/result-82c620cdfeb8ca6b858cf653d7f5e2eedca5315d6244216d60e8b36f03223d6a.bin +1 -0
  43. package/contracts/execution-retention/v2/fixtures/session-2ec1f134bc739984434f7fca689e1c3d1c39fce2dcadae709e9b6faedfcc4ac7.bin +3 -0
  44. package/contracts/execution-retention/v2/fixtures/session-9bdb4bd16a901da31580acf056bda525225259e15ec409e0410480109011772f.bin +3 -0
  45. package/contracts/execution-retention/v2/manifest.schema.json +858 -0
  46. package/contracts/experiment/v1/README.md +164 -0
  47. package/contracts/factory-order/v1/README.md +151 -0
  48. package/contracts/factory-order/v1/adoption-pin.json +8 -0
  49. package/contracts/intent-control/v1/README.md +135 -0
  50. package/contracts/intent-control/v1/request.schema.json +1096 -0
  51. package/contracts/intent-control/v2/README.md +34 -0
  52. package/contracts/intent-control/v2/request.schema.json +1070 -0
  53. package/contracts/ledger/v4/README.md +241 -0
  54. package/contracts/ledger/v4/fixtures/layout-options.json +428 -0
  55. package/contracts/ledger/v4/fixtures/work-acceptance.json +57 -0
  56. package/contracts/ledger/v4/fixtures/work-occurrence.json +44 -0
  57. package/contracts/ledger/v4/fixtures/work-revision.json +26 -0
  58. package/contracts/ledger/v4/fixtures/work-snapshot.json +73 -0
  59. package/contracts/ledger/v4/ledger-event.schema.json +162 -0
  60. package/contracts/ordinary-control/v1/README.md +59 -0
  61. package/contracts/producer-ipc/v1/README.md +137 -0
  62. package/dist/check-runner.d.ts +5 -0
  63. package/dist/check-runner.d.ts.map +1 -1
  64. package/dist/check-runner.js +16 -2
  65. package/dist/check-runner.js.map +1 -1
  66. package/dist/control-journal.d.ts +20 -0
  67. package/dist/control-journal.d.ts.map +1 -0
  68. package/dist/control-journal.js +73 -0
  69. package/dist/control-journal.js.map +1 -0
  70. package/dist/daily-view-input.d.ts +44 -0
  71. package/dist/daily-view-input.d.ts.map +1 -0
  72. package/dist/daily-view-input.js +68 -0
  73. package/dist/daily-view-input.js.map +1 -0
  74. package/dist/daily-view-render.d.ts +4 -0
  75. package/dist/daily-view-render.d.ts.map +1 -0
  76. package/dist/daily-view-render.js +41 -0
  77. package/dist/daily-view-render.js.map +1 -0
  78. package/dist/daily-view.d.ts +74 -0
  79. package/dist/daily-view.d.ts.map +1 -0
  80. package/dist/daily-view.js +170 -0
  81. package/dist/daily-view.js.map +1 -0
  82. package/dist/dashboard-cli.d.ts +26 -1
  83. package/dist/dashboard-cli.d.ts.map +1 -1
  84. package/dist/dashboard-cli.js +121 -3
  85. package/dist/dashboard-cli.js.map +1 -1
  86. package/dist/dashboard-harness.d.ts +19 -0
  87. package/dist/dashboard-harness.d.ts.map +1 -0
  88. package/dist/dashboard-harness.js +75 -0
  89. package/dist/dashboard-harness.js.map +1 -0
  90. package/dist/dashboard-herdr.d.ts.map +1 -1
  91. package/dist/dashboard-herdr.js +7 -2
  92. package/dist/dashboard-herdr.js.map +1 -1
  93. package/dist/dashboard-host-contract.d.ts +111 -0
  94. package/dist/dashboard-host-contract.d.ts.map +1 -0
  95. package/dist/dashboard-host-contract.js +14 -0
  96. package/dist/dashboard-host-contract.js.map +1 -0
  97. package/dist/dashboard-host-transport.d.ts +15 -0
  98. package/dist/dashboard-host-transport.d.ts.map +1 -0
  99. package/dist/dashboard-host-transport.js +122 -0
  100. package/dist/dashboard-host-transport.js.map +1 -0
  101. package/dist/dashboard-host.d.ts +149 -0
  102. package/dist/dashboard-host.d.ts.map +1 -0
  103. package/dist/dashboard-host.js +329 -0
  104. package/dist/dashboard-host.js.map +1 -0
  105. package/dist/dashboard-observation.d.ts +28 -0
  106. package/dist/dashboard-observation.d.ts.map +1 -0
  107. package/dist/dashboard-observation.js +86 -0
  108. package/dist/dashboard-observation.js.map +1 -0
  109. package/dist/debrief-contract.d.ts +65 -0
  110. package/dist/debrief-contract.d.ts.map +1 -0
  111. package/dist/debrief-contract.js +151 -0
  112. package/dist/debrief-contract.js.map +1 -0
  113. package/dist/debrief-fixture.d.ts +16 -0
  114. package/dist/debrief-fixture.d.ts.map +1 -0
  115. package/dist/debrief-fixture.js +41 -0
  116. package/dist/debrief-fixture.js.map +1 -0
  117. package/dist/debrief-host.d.ts +59 -0
  118. package/dist/debrief-host.d.ts.map +1 -0
  119. package/dist/debrief-host.js +49 -0
  120. package/dist/debrief-host.js.map +1 -0
  121. package/dist/debrief-render.d.ts +5 -0
  122. package/dist/debrief-render.d.ts.map +1 -0
  123. package/dist/debrief-render.js +65 -0
  124. package/dist/debrief-render.js.map +1 -0
  125. package/dist/debrief.d.ts +90 -0
  126. package/dist/debrief.d.ts.map +1 -0
  127. package/dist/debrief.js +263 -0
  128. package/dist/debrief.js.map +1 -0
  129. package/dist/delegate-types.d.ts +2 -0
  130. package/dist/delegate-types.d.ts.map +1 -1
  131. package/dist/delegate-types.js.map +1 -1
  132. package/dist/delegate.d.ts.map +1 -1
  133. package/dist/delegate.js +1 -0
  134. package/dist/delegate.js.map +1 -1
  135. package/dist/dispatch-control.d.ts +45 -0
  136. package/dist/dispatch-control.d.ts.map +1 -0
  137. package/dist/dispatch-control.js +76 -0
  138. package/dist/dispatch-control.js.map +1 -0
  139. package/dist/effect-profile-runtime.d.ts +40 -0
  140. package/dist/effect-profile-runtime.d.ts.map +1 -0
  141. package/dist/effect-profile-runtime.js +51 -0
  142. package/dist/effect-profile-runtime.js.map +1 -0
  143. package/dist/effect-profile.d.ts +40 -0
  144. package/dist/effect-profile.d.ts.map +1 -0
  145. package/dist/effect-profile.js +152 -0
  146. package/dist/effect-profile.js.map +1 -0
  147. package/dist/execution-retention.d.ts +90 -0
  148. package/dist/execution-retention.d.ts.map +1 -0
  149. package/dist/execution-retention.js +247 -0
  150. package/dist/execution-retention.js.map +1 -0
  151. package/dist/experiment-contract.d.ts +56 -0
  152. package/dist/experiment-contract.d.ts.map +1 -0
  153. package/dist/experiment-contract.js +80 -0
  154. package/dist/experiment-contract.js.map +1 -0
  155. package/dist/experiment-state.d.ts +22 -0
  156. package/dist/experiment-state.d.ts.map +1 -0
  157. package/dist/experiment-state.js +79 -0
  158. package/dist/experiment-state.js.map +1 -0
  159. package/dist/experiment-store.d.ts +28 -0
  160. package/dist/experiment-store.d.ts.map +1 -0
  161. package/dist/experiment-store.js +130 -0
  162. package/dist/experiment-store.js.map +1 -0
  163. package/dist/experiment.d.ts +65 -0
  164. package/dist/experiment.d.ts.map +1 -0
  165. package/dist/experiment.js +357 -0
  166. package/dist/experiment.js.map +1 -0
  167. package/dist/factory-contract.d.ts +79 -0
  168. package/dist/factory-contract.d.ts.map +1 -0
  169. package/dist/factory-contract.js +89 -0
  170. package/dist/factory-contract.js.map +1 -0
  171. package/dist/factory-migration.d.ts +20 -0
  172. package/dist/factory-migration.d.ts.map +1 -0
  173. package/dist/factory-migration.js +55 -0
  174. package/dist/factory-migration.js.map +1 -0
  175. package/dist/factory-order.d.ts +848 -0
  176. package/dist/factory-order.d.ts.map +1 -0
  177. package/dist/factory-order.js +83 -0
  178. package/dist/factory-order.js.map +1 -0
  179. package/dist/factory-registry.d.ts +121 -0
  180. package/dist/factory-registry.d.ts.map +1 -0
  181. package/dist/factory-registry.js +133 -0
  182. package/dist/factory-registry.js.map +1 -0
  183. package/dist/file-lock.d.ts +8 -2
  184. package/dist/file-lock.d.ts.map +1 -1
  185. package/dist/file-lock.js +78 -19
  186. package/dist/file-lock.js.map +1 -1
  187. package/dist/herdr-poll.d.ts +8 -0
  188. package/dist/herdr-poll.d.ts.map +1 -1
  189. package/dist/herdr-poll.js +23 -0
  190. package/dist/herdr-poll.js.map +1 -1
  191. package/dist/index.d.ts +20 -0
  192. package/dist/index.d.ts.map +1 -1
  193. package/dist/index.js +20 -0
  194. package/dist/index.js.map +1 -1
  195. package/dist/intent-application.d.ts +17 -0
  196. package/dist/intent-application.d.ts.map +1 -0
  197. package/dist/intent-application.js +113 -0
  198. package/dist/intent-application.js.map +1 -0
  199. package/dist/intent-control.d.ts +201 -0
  200. package/dist/intent-control.d.ts.map +1 -0
  201. package/dist/intent-control.js +110 -0
  202. package/dist/intent-control.js.map +1 -0
  203. package/dist/intent-scheduling.d.ts +9 -0
  204. package/dist/intent-scheduling.d.ts.map +1 -0
  205. package/dist/intent-scheduling.js +15 -0
  206. package/dist/intent-scheduling.js.map +1 -0
  207. package/dist/ledger-append.d.ts +15 -0
  208. package/dist/ledger-append.d.ts.map +1 -0
  209. package/dist/ledger-append.js +94 -0
  210. package/dist/ledger-append.js.map +1 -0
  211. package/dist/ledger.d.ts +11 -0
  212. package/dist/ledger.d.ts.map +1 -1
  213. package/dist/ledger.js +3 -7
  214. package/dist/ledger.js.map +1 -1
  215. package/dist/native-session-target.d.ts +9 -0
  216. package/dist/native-session-target.d.ts.map +1 -0
  217. package/dist/native-session-target.js +29 -0
  218. package/dist/native-session-target.js.map +1 -0
  219. package/dist/native-session.d.ts +59 -0
  220. package/dist/native-session.d.ts.map +1 -0
  221. package/dist/native-session.js +154 -0
  222. package/dist/native-session.js.map +1 -0
  223. package/dist/order-schedule.d.ts +37 -0
  224. package/dist/order-schedule.d.ts.map +1 -0
  225. package/dist/order-schedule.js +81 -0
  226. package/dist/order-schedule.js.map +1 -0
  227. package/dist/ordinary-children.d.ts +52 -0
  228. package/dist/ordinary-children.d.ts.map +1 -0
  229. package/dist/ordinary-children.js +93 -0
  230. package/dist/ordinary-children.js.map +1 -0
  231. package/dist/producer-ipc-contract.d.ts +29 -0
  232. package/dist/producer-ipc-contract.d.ts.map +1 -0
  233. package/dist/producer-ipc-contract.js +29 -0
  234. package/dist/producer-ipc-contract.js.map +1 -0
  235. package/dist/producer-ipc.d.ts +55 -0
  236. package/dist/producer-ipc.d.ts.map +1 -0
  237. package/dist/producer-ipc.js +143 -0
  238. package/dist/producer-ipc.js.map +1 -0
  239. package/dist/resource-budget.d.ts +334 -0
  240. package/dist/resource-budget.d.ts.map +1 -0
  241. package/dist/resource-budget.js +415 -0
  242. package/dist/resource-budget.js.map +1 -0
  243. package/dist/retention-contract.d.ts +73 -0
  244. package/dist/retention-contract.d.ts.map +1 -0
  245. package/dist/retention-contract.js +104 -0
  246. package/dist/retention-contract.js.map +1 -0
  247. package/dist/retention-json.d.ts +3 -0
  248. package/dist/retention-json.d.ts.map +1 -0
  249. package/dist/retention-json.js +32 -0
  250. package/dist/retention-json.js.map +1 -0
  251. package/dist/run-child.d.ts +4 -0
  252. package/dist/run-child.d.ts.map +1 -1
  253. package/dist/run-child.js +16 -2
  254. package/dist/run-child.js.map +1 -1
  255. package/dist/run-herdr.d.ts +6 -0
  256. package/dist/run-herdr.d.ts.map +1 -1
  257. package/dist/run-herdr.js +19 -6
  258. package/dist/run-herdr.js.map +1 -1
  259. package/dist/vendor/adoption.d.ts +129 -0
  260. package/dist/vendor/adoption.d.ts.map +1 -0
  261. package/dist/vendor/adoption.js +102 -0
  262. package/dist/vendor/adoption.js.map +1 -0
  263. package/dist/work-ledger-destination.d.ts +20 -0
  264. package/dist/work-ledger-destination.d.ts.map +1 -0
  265. package/dist/work-ledger-destination.js +131 -0
  266. package/dist/work-ledger-destination.js.map +1 -0
  267. package/dist/work-ledger-json.d.ts +25 -0
  268. package/dist/work-ledger-json.d.ts.map +1 -0
  269. package/dist/work-ledger-json.js +267 -0
  270. package/dist/work-ledger-json.js.map +1 -0
  271. package/dist/work-ledger-occurrences.d.ts +227 -0
  272. package/dist/work-ledger-occurrences.d.ts.map +1 -0
  273. package/dist/work-ledger-occurrences.js +115 -0
  274. package/dist/work-ledger-occurrences.js.map +1 -0
  275. package/dist/work-ledger-projection.d.ts +295 -0
  276. package/dist/work-ledger-projection.d.ts.map +1 -0
  277. package/dist/work-ledger-projection.js +237 -0
  278. package/dist/work-ledger-projection.js.map +1 -0
  279. package/dist/work-ledger-snapshot.d.ts +29 -0
  280. package/dist/work-ledger-snapshot.d.ts.map +1 -0
  281. package/dist/work-ledger-snapshot.js +267 -0
  282. package/dist/work-ledger-snapshot.js.map +1 -0
  283. package/dist/work-ledger-types.d.ts +261 -0
  284. package/dist/work-ledger-types.d.ts.map +1 -0
  285. package/dist/work-ledger-types.js +13 -0
  286. package/dist/work-ledger-types.js.map +1 -0
  287. package/dist/work-ledger-validation.d.ts +14 -0
  288. package/dist/work-ledger-validation.d.ts.map +1 -0
  289. package/dist/work-ledger-validation.js +226 -0
  290. package/dist/work-ledger-validation.js.map +1 -0
  291. package/dist/work-ledger.d.ts +43 -0
  292. package/dist/work-ledger.d.ts.map +1 -0
  293. package/dist/work-ledger.js +225 -0
  294. package/dist/work-ledger.js.map +1 -0
  295. package/extensions/delegate-chain.ts +1 -0
  296. package/extensions/delegation-native.ts +12 -0
  297. package/extensions/delegation.ts +4 -3
  298. package/extensions/execute-child.ts +34 -4
  299. package/extensions/grants.ts +2 -0
  300. package/extensions/ordinary-runtime.ts +14 -0
  301. package/extensions/run-delegation.ts +7 -7
  302. package/extensions/session.ts +3 -1
  303. package/package.json +78 -4
  304. package/src/check-runner.ts +20 -2
  305. package/src/control-journal.ts +26 -0
  306. package/src/daily-view-input.ts +59 -0
  307. package/src/daily-view-render.ts +44 -0
  308. package/src/daily-view.ts +152 -0
  309. package/src/dashboard-cli.ts +103 -4
  310. package/src/dashboard-harness.ts +28 -0
  311. package/src/dashboard-herdr.ts +7 -2
  312. package/src/dashboard-host-contract.ts +42 -0
  313. package/src/dashboard-host-transport.ts +38 -0
  314. package/src/dashboard-host.ts +168 -0
  315. package/src/dashboard-observation.ts +44 -0
  316. package/src/debrief-contract.ts +127 -0
  317. package/src/debrief-fixture.ts +29 -0
  318. package/src/debrief-host.ts +53 -0
  319. package/src/debrief-render.ts +49 -0
  320. package/src/debrief.ts +198 -0
  321. package/src/delegate-types.ts +2 -0
  322. package/src/delegate.ts +1 -0
  323. package/src/dispatch-control.ts +81 -0
  324. package/src/effect-profile-runtime.ts +46 -0
  325. package/src/effect-profile.ts +129 -0
  326. package/src/execution-retention.ts +230 -0
  327. package/src/experiment-contract.ts +66 -0
  328. package/src/experiment-state.ts +44 -0
  329. package/src/experiment-store.ts +90 -0
  330. package/src/experiment.ts +231 -0
  331. package/src/factory-contract.ts +47 -0
  332. package/src/factory-migration.ts +29 -0
  333. package/src/factory-order.ts +56 -0
  334. package/src/factory-registry.ts +60 -0
  335. package/src/file-lock.ts +47 -11
  336. package/src/herdr-poll.ts +18 -0
  337. package/src/index.ts +32 -0
  338. package/src/intent-application.ts +90 -0
  339. package/src/intent-control.ts +107 -0
  340. package/src/intent-scheduling.ts +14 -0
  341. package/src/ledger-append.ts +72 -0
  342. package/src/ledger.ts +3 -9
  343. package/src/native-session-target.ts +23 -0
  344. package/src/native-session.ts +139 -0
  345. package/src/order-schedule.ts +42 -0
  346. package/src/ordinary-children.ts +59 -0
  347. package/src/producer-ipc-contract.ts +26 -0
  348. package/src/producer-ipc.ts +107 -0
  349. package/src/resource-budget.ts +349 -0
  350. package/src/retention-contract.ts +84 -0
  351. package/src/retention-json.ts +20 -0
  352. package/src/run-child.ts +13 -2
  353. package/src/run-herdr.ts +11 -7
  354. package/src/vendor/adoption.ts +109 -0
  355. package/src/work-ledger-destination.ts +113 -0
  356. package/src/work-ledger-json.ts +228 -0
  357. package/src/work-ledger-occurrences.ts +99 -0
  358. package/src/work-ledger-projection.ts +262 -0
  359. package/src/work-ledger-snapshot.ts +246 -0
  360. package/src/work-ledger-types.ts +229 -0
  361. package/src/work-ledger-validation.ts +174 -0
  362. package/src/work-ledger.ts +186 -0
@@ -0,0 +1,84 @@
1
+ import { Compile } from "typebox/compile";
2
+ import { parseWorkJson } from "./work-ledger-json.ts";
3
+ import type { ExecutionRetentionManifest } from "./execution-retention.ts";
4
+
5
+ const string = (maxLength = 512) => ({ type: "string", minLength: 1, maxLength, pattern: "^[^\\u0000-\\u001f\\u007f]+$" });
6
+ const nullable = (schema: object) => ({ anyOf: [schema, { type: "null" }] });
7
+ const literal = (value: unknown) => ({ const: value });
8
+ const closed = (properties: Record<string, object>) => ({ type: "object", properties, required: Object.keys(properties), additionalProperties: false });
9
+ const hash = { type: "string", pattern: "^[a-f0-9]{64}$" };
10
+ const integer = { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER };
11
+ const uuid = { type: "string", pattern: "^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$" };
12
+ export const RETENTION_CONTENT_KINDS = ["stdout", "stderr", "paneSnapshot", "checkReceipt", "result", "session"] as const;
13
+ export const RETENTION_SCHEMA = freeze({
14
+ $schema: "https://json-schema.org/draft/2020-12/schema",
15
+ $id: "https://pi-daddy.local/contracts/execution-retention/v2/manifest.schema.json",
16
+ ...closed({
17
+ version: literal("2.0"), archiveId: uuid,
18
+ identity: closed({ executionId: string(), parentExecutionId: nullable(string()), childId: string(), toolCallId: nullable(string()),
19
+ executor: { enum: ["process", "herdr", "check"] }, taskDigest: nullable(hash), definitionDigest: nullable(hash),
20
+ configurationDigest: hash, workspaceId: nullable(string()) }),
21
+ native: closed({ pid: nullable({ type: "integer", minimum: 1, maximum: Number.MAX_SAFE_INTEGER }), paneId: nullable(string(4096)),
22
+ agentName: nullable(string(4096)), tabId: nullable(string(4096)), sessionId: nullable(uuid), sessionPath: nullable(string(4096)), branchLeafId: nullable(string(128)) }),
23
+ nativeSession: closed({ source: { enum: [null, "herdr-id", "herdr-path", "pi-session-file", "pi-session-manager"] },
24
+ status: { enum: ["missing", "verified", "invalid", "changed", "truncated", "unsupported"] },
25
+ sessionId: nullable(uuid), sessionPath: nullable(string(4096)), parentSessionPath: nullable(string(4096)),
26
+ branchLeafId: nullable(string(128)), branchState: { enum: ["unknown", "observed"] }, lastPersistedEntryId: nullable(string(128)),
27
+ sha256: nullable(hash), reason: nullable(string()) }),
28
+ state: { enum: ["running", "terminal"] },
29
+ outcome: nullable(closed({ code: nullable({ type: "integer", minimum: -2147483648, maximum: 2147483647 }), signal: nullable(string(64)),
30
+ timedOut: { type: "boolean" }, aborted: { type: "boolean" }, truncated: { type: "boolean" }, failed: { type: "boolean" } })),
31
+ content: closed(Object.fromEntries(RETENTION_CONTENT_KINDS.map(kind => [kind, { oneOf: [
32
+ closed({ status: literal("missing"), path: literal(null), sha256: literal(null), bytes: literal(null) }),
33
+ closed({ status: literal("retained"), path: { type: "string", pattern: `^${kind}-[a-f0-9]{64}\\.bin$` }, sha256: hash,
34
+ bytes: { ...integer, maximum: 1024 * 1024 } }),
35
+ ] }]))),
36
+ coverage: closed({ complete: literal(false), losses: { type: "array", items: string(), maxItems: 64, uniqueItems: true } }),
37
+ acceptance: literal("not-assessed"),
38
+ }),
39
+ allOf: [
40
+ { if: { properties: { state: literal("running") } }, then: { properties: { outcome: literal(null) } },
41
+ else: { properties: { outcome: { type: "object" } } } },
42
+ { if: { properties: { nativeSession: { properties: { branchState: literal("unknown") } } } },
43
+ then: { properties: { nativeSession: { properties: { branchLeafId: literal(null) } }, native: { properties: { branchLeafId: literal(null) } } } } },
44
+ ],
45
+ });
46
+ const validator = Compile(RETENTION_SCHEMA);
47
+ function freeze<T>(value: T): T { if (value && typeof value === "object") { for (const child of Object.values(value)) freeze(child); Object.freeze(value); } return value; }
48
+
49
+ /** Closed, detached versioned wire builder; never authenticates the supplied observation. */
50
+ export function buildExecutionRetentionManifest(value: unknown): ExecutionRetentionManifest {
51
+ // Reject non-JSON objects/accessors instead of invoking serialization hooks while collecting evidence.
52
+ let nodes = 0;
53
+ const inspect = (x: unknown, depth: number): void => {
54
+ if (++nodes > 4096 || depth > 16) throw new TypeError("retention manifest exceeds bounds");
55
+ if (x === null || typeof x === "string" || typeof x === "boolean" || (typeof x === "number" && Number.isSafeInteger(x))) return;
56
+ if (!x || typeof x !== "object" || (!Array.isArray(x) && Object.getPrototypeOf(x) !== Object.prototype && Object.getPrototypeOf(x) !== null)) throw new TypeError("retention manifest must be JSON data");
57
+ if (Array.isArray(x) && (Object.getPrototypeOf(x) !== Array.prototype || x.length > 256 || Reflect.ownKeys(x).length !== x.length + 1 ||
58
+ Array.from({ length: x.length }, (_, i) => String(i)).some(key => !Object.hasOwn(x, key)))) throw new TypeError("retention arrays must be dense plain JSON arrays");
59
+ for (const key of Reflect.ownKeys(x)) {
60
+ if (Array.isArray(x) && key === "length") continue;
61
+ const d = Object.getOwnPropertyDescriptor(x, key)!;
62
+ if (typeof key !== "string" || !d.enumerable || !("value" in d)) throw new TypeError("retention manifest must be plain JSON data");
63
+ inspect(d.value, depth + 1);
64
+ }
65
+ };
66
+ inspect(value, 0);
67
+ if (!validator.Check(value)) throw new TypeError("invalid execution-retention 2.0 manifest");
68
+ const m = JSON.parse(JSON.stringify(value)) as ExecutionRetentionManifest;
69
+ if (m.native.sessionId !== m.nativeSession.sessionId || m.native.sessionPath !== m.nativeSession.sessionPath || m.native.branchLeafId !== m.nativeSession.branchLeafId) {
70
+ throw new TypeError("native session projection mismatch");
71
+ }
72
+ for (const kind of RETENTION_CONTENT_KINDS) { const ref = m.content[kind];
73
+ if (ref.status === "retained" && ref.path !== `${kind}-${ref.sha256}.bin`) throw new TypeError("retention content identity mismatch"); }
74
+ if (m.nativeSession.branchState === "observed" && (m.nativeSession.source !== "pi-session-manager" || m.nativeSession.status !== "verified")) throw new TypeError("unverified active native branch");
75
+ if (m.nativeSession.status === "verified" && (!m.nativeSession.sessionId || !m.nativeSession.sha256 || m.content.session.sha256 !== m.nativeSession.sha256)) throw new TypeError("verified native session requires retained bytes");
76
+ return freeze(m);
77
+ }
78
+ export function parseExecutionRetentionManifest(text: string): ExecutionRetentionManifest {
79
+ if (Buffer.byteLength(text) > 64 * 1024) throw new TypeError("retention manifest exceeds bounds");
80
+ // Manifest numbers are integers; use the existing exact-token parser before lossy Number conversion.
81
+ let value: unknown;
82
+ try { value = parseWorkJson(text); } catch { throw new TypeError("invalid retention JSON"); }
83
+ return buildExecutionRetentionManifest(value);
84
+ }
@@ -0,0 +1,20 @@
1
+ /** Strict JSON framing for retention metadata/native records. Floats in native usage remain supported. */
2
+ export function parseRetentionJson(text: string, maxBytes = 64 * 1024): unknown {
3
+ if (Buffer.byteLength(text) > maxBytes) throw new TypeError("retention JSON exceeds bounds");
4
+ const value: unknown = JSON.parse(text);
5
+ const stack: Array<{ object: boolean; key: boolean; seen: Set<string> }> = [];
6
+ for (const match of text.matchAll(/"(?:[^"\\]|\\[\s\S])*"|[{}\[\],:]|[^\s{}\[\],:]+/g)) {
7
+ const token = match[0], top = stack.at(-1);
8
+ if (token === "{" || token === "[") {
9
+ if (stack.length >= 64) throw new TypeError("retention JSON exceeds depth");
10
+ stack.push({ object: token === "{", key: token === "{", seen: new Set() });
11
+ } else if (token === "}" || token === "]") stack.pop();
12
+ else if (token === ",") { if (top?.object) top.key = true; }
13
+ else if (token.startsWith('"') && top?.object && top.key) {
14
+ const key = JSON.parse(token) as string;
15
+ if (top.seen.has(key)) throw new TypeError("duplicate retention JSON member");
16
+ top.seen.add(key); top.key = false;
17
+ } else if (/^-?\d/.test(token) && !Number.isFinite(Number(token))) throw new TypeError("non-finite retention JSON number");
18
+ }
19
+ return value;
20
+ }
package/src/run-child.ts CHANGED
@@ -92,6 +92,10 @@ export interface ChildRunRequest {
92
92
  * governed child mid-task.
93
93
  */
94
94
  onOutput?: (chunk: string) => void;
95
+ /** Optional raw observation; isolated from control, never awaited, separate from display text. */
96
+ onObservation?: (stream: "stdout" | "stderr", bytes: Uint8Array) => void;
97
+ /** Passive EOF observation. Never a replacement child/settlement handle. Exceptions are isolated. */
98
+ onStreamEnd?: (stream: "stdout" | "stderr") => void;
95
99
  /** Security hook called immediately after spawn, before output handling (for lease attachment). */
96
100
  onSpawn?: (pid: number) => void;
97
101
  }
@@ -244,8 +248,15 @@ export function runChild(request: ChildRunRequest): Promise<ChildRunResult> {
244
248
  flush();
245
249
  };
246
250
 
247
- child.stdout?.on("data", capture);
248
- child.stderr?.on("data", capture);
251
+ const observe = (stream: "stdout" | "stderr", chunk: Buffer) => {
252
+ capture(chunk);
253
+ try { request.onObservation?.(stream, chunk); } catch { /* observation only */ }
254
+ };
255
+ child.stdout?.on("data", (chunk: Buffer) => observe("stdout", chunk));
256
+ child.stderr?.on("data", (chunk: Buffer) => observe("stderr", chunk));
257
+ for (const stream of ["stdout", "stderr"] as const) child[stream]?.on("end", () => {
258
+ try { request.onStreamEnd?.(stream); } catch { /* passive observation only */ }
259
+ });
249
260
 
250
261
  timers.push(
251
262
  setTimeout(() => controlIfRunning(() => {
package/src/run-herdr.ts CHANGED
@@ -27,7 +27,7 @@ import type { ChildRunResult } from "./run-child.ts";
27
27
  import { DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_TIMEOUT_MS } from "./run-child.ts";
28
28
  import { MAX_OPEN_PANES, markPaneSettled, trackPane, trimOpenPanes, untrackPane } from "./pane-reaper.ts";
29
29
  import { defaultExec, parseReply, type HerdrExec } from "./herdr-cli.ts";
30
- import { readPane, waitForSettled } from "./herdr-poll.ts";
30
+ import { readPane, waitForSettled, seqOf, observeHerdrSession, type PollTarget } from "./herdr-poll.ts";
31
31
  import { stageSystemPrompt } from "./herdr-stage.ts";
32
32
  import { uniqueAgentName } from "./herdr-name.ts";
33
33
 
@@ -90,6 +90,8 @@ export interface HerdrRunRequest {
90
90
  * Exceptions are swallowed; a renderer must not be able to break a governed run.
91
91
  */
92
92
  onSnapshot?: (lines: string[]) => void;
93
+ /** Available pane snapshots only, not a structured session transcript. Never awaited. */
94
+ onObservation?: (bytes: Uint8Array) => void;
93
95
  /** How many pane lines to report per poll. Defaults to `DEFAULT_SNAPSHOT_LINES`. */
94
96
  snapshotLines?: number;
95
97
  /**
@@ -103,6 +105,9 @@ export interface HerdrRunRequest {
103
105
  onRunning?: (paneId: string, agentName: string) => void;
104
106
  /** Security hook for attaching a writer lease to the tab. Unlike display callbacks, errors fail the run. */
105
107
  onTab?: (tabId: string) => void;
108
+ /** Optional observation, unlike the required lease hook above. */
109
+ onNativeTab?: (tabId: string) => void;
110
+ onSessionReference?: PollTarget["onSessionReference"];
106
111
  /** Close even a settled pane before releasing a writer lease; no post-lease prompt may remain live. */
107
112
  closeOnSettle?: boolean;
108
113
  /** Poll cadence override. Exists so tests do not wait `POLL_INTERVAL_MS` per state transition. */
@@ -179,6 +184,7 @@ export async function runHerdrPane(request: HerdrRunRequest): Promise<ChildRunRe
179
184
  const rootPane = (created.result?.root_pane ?? {}) as { pane_id?: string; tab_id?: string };
180
185
  const paneId = rootPane.pane_id;
181
186
  const tabId = rootPane.tab_id;
187
+ if (tabId) { try { request.onNativeTab?.(tabId); } catch { /* observation only */ } }
182
188
  // **Tracked BEFORE the pane-id check, not after.** A tab can exist from the moment this reply is parsed,
183
189
  // so registering later leaves a window — one herdr round-trip wide — in which a killed process orphans a
184
190
  // tab nothing would reap. The normal path had no such window and the error path did, which is backwards:
@@ -300,6 +306,7 @@ export async function runHerdrPane(request: HerdrRunRequest): Promise<ChildRunRe
300
306
 
301
307
  // The state counter BEFORE prompting is what makes the wait correct — see the R-33 note below.
302
308
  const before = seqOf(started.result);
309
+ observeHerdrSession(started.result?.agent, paneId, request.onSessionReference);
303
310
 
304
311
  const prompted = parseReply(await exec(["agent", "prompt", agentName, request.prompt]));
305
312
  if (prompted.error) return { ...empty, spawnError: `herdr agent prompt failed: ${prompted.error}` };
@@ -311,11 +318,12 @@ export async function runHerdrPane(request: HerdrRunRequest): Promise<ChildRunRe
311
318
  }
312
319
  }
313
320
 
314
- const settled = await waitForSettled(exec, { ...request, name: agentName }, before, deadline, maxOutputBytes);
321
+ const settled = await waitForSettled(exec, { ...request, name: agentName, nativePaneId: paneId }, before, deadline, maxOutputBytes);
315
322
  if (settled.aborted || settled.timedOut) {
316
323
  // Still read: a timed-out child usually produced something, and a partial answer labelled partial is
317
324
  // more useful than none. R-03's rule — a missing result must never look like an empty one.
318
325
  const partial = await readPane(exec, agentName, maxOutputBytes);
326
+ if (!partial.readFailed) { try { request.onObservation?.(Buffer.from(partial.text)); } catch { /* observation only */ } }
319
327
  return { ...empty, ...settled, text: partial.readFailed ? "" : partial.text, truncated: partial.truncated };
320
328
  }
321
329
  if (settled.spawnError) return { ...empty, spawnError: settled.spawnError };
@@ -333,6 +341,7 @@ export async function runHerdrPane(request: HerdrRunRequest): Promise<ChildRunRe
333
341
  `The work may have been done — check pane ${paneId} if it is still open.`,
334
342
  };
335
343
  }
344
+ try { request.onObservation?.(Buffer.from(out.text)); } catch { /* observation only */ }
336
345
  settledCleanly = true;
337
346
  // The trim may now reclaim this pane if the cap is exceeded. Until this point it must not: closing the tab
338
347
  // kills the child, and the child was still working.
@@ -387,8 +396,3 @@ async function startAgent(
387
396
  await new Promise((r) => setTimeout(r, PANE_READY_POLL_MS));
388
397
  }
389
398
  }
390
-
391
- function seqOf(result: Record<string, unknown> | undefined): number {
392
- const agent = (result?.agent ?? {}) as { state_change_seq?: number };
393
- return typeof agent.state_change_seq === "number" ? agent.state_change_seq : -1;
394
- }
@@ -0,0 +1,109 @@
1
+ import { createHash } from "node:crypto";
2
+ const SHA = /^[a-f0-9]{64}$/;
3
+ const text = (x: unknown): x is string => typeof x === "string" && x.length > 0 && x.length <= 512 && !/[\u0000-\u001f\u007f]/.test(x);
4
+ const closed = (value: object, names: string[]) => {
5
+ if (!value || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) return false;
6
+ const fields = Object.getOwnPropertyDescriptors(value);
7
+ return Reflect.ownKeys(fields).length === names.length && Reflect.ownKeys(fields).every(key => typeof key === "string" && names.includes(key) && fields[key].enumerable && Object.hasOwn(fields[key], "value"));
8
+ };
9
+ const hash = (value: Record<string, unknown>) => createHash("sha256").update(JSON.stringify(Object.keys(value).sort().map(k => [k, value[k]]))).digest("hex");
10
+ export interface AdoptionInput {
11
+ hypothesisDigest: string; experimentDigest: string; candidateDigest: string; scopeDigest: string; assessmentPolicyDigest: string;
12
+ rollbackCandidateDigest: string; activationBoundary: "next-orders"; expiresAt: number;
13
+ }
14
+ export interface AdoptionBinding extends AdoptionInput { version: "adoption-binding-v1"; id: string }
15
+ export interface AdoptionAuthority { id: string; adoptions: readonly string[]; rollbacks: readonly string[] }
16
+ export interface AdoptionFacts { experimentDigest: string; candidateDigest: string; scopeDigest: string; assessmentPolicyDigest: string; eligible: boolean }
17
+ export function buildAdoptionBinding(input: AdoptionInput): AdoptionBinding {
18
+ if (!input || !closed(input, ["hypothesisDigest", "experimentDigest", "candidateDigest", "scopeDigest", "assessmentPolicyDigest", "rollbackCandidateDigest", "activationBoundary", "expiresAt"])
19
+ || ![input.hypothesisDigest,input.experimentDigest,input.candidateDigest,input.scopeDigest,input.assessmentPolicyDigest,input.rollbackCandidateDigest].every(s => typeof s === "string" && SHA.test(s))
20
+ || input.activationBoundary !== "next-orders" || !Number.isSafeInteger(input.expiresAt) || input.expiresAt < 0) throw new Error("invalid scoped adoption binding");
21
+ const body = { version: "adoption-binding-v1" as const, ...input };
22
+ return Object.freeze({ ...body, id: hash(body) });
23
+ }
24
+ function bindingValid(binding: AdoptionBinding) {
25
+ const { version, id, ...input } = binding;
26
+ if (version !== "adoption-binding-v1" || buildAdoptionBinding(input).id !== id) throw new Error("adoption binding changed");
27
+ }
28
+ function authorityValid(authority: AdoptionAuthority | null): authority is AdoptionAuthority {
29
+ return !!authority && text(authority.id) && Array.isArray(authority.adoptions) && Array.isArray(authority.rollbacks)
30
+ && authority.adoptions.length <= 256 && authority.rollbacks.length <= 256 && [...authority.adoptions, ...authority.rollbacks].every(id => typeof id === "string" && SHA.test(id));
31
+ }
32
+ /** Independent host declarations and independently verified eligible facts are required again at use. */
33
+ export function authorizeAdoption(binding: AdoptionBinding, authority: AdoptionAuthority | null, facts: AdoptionFacts, now: number) {
34
+ bindingValid(binding);
35
+ if (!authorityValid(authority) || !authority.adoptions.includes(binding.id)) throw new Error("independent adoption authority required");
36
+ if (!Number.isSafeInteger(now) || now < 0 || now >= binding.expiresAt || !facts || !closed(facts, ["experimentDigest", "candidateDigest", "scopeDigest", "assessmentPolicyDigest", "eligible"]) || facts.eligible !== true
37
+ || facts.experimentDigest !== binding.experimentDigest || facts.candidateDigest !== binding.candidateDigest
38
+ || facts.scopeDigest !== binding.scopeDigest || facts.assessmentPolicyDigest !== binding.assessmentPolicyDigest) throw new Error("adoption expired, ineligible or outside verified scope");
39
+ const body = { version: "adoption-receipt-v1" as const, bindingId: binding.id, authorityId: authority.id, hypothesisDigest: binding.hypothesisDigest,
40
+ experimentDigest: binding.experimentDigest, candidateDigest: binding.candidateDigest, scopeDigest: binding.scopeDigest,
41
+ assessmentPolicyDigest: binding.assessmentPolicyDigest, rollbackCandidateDigest: binding.rollbackCandidateDigest,
42
+ activationBoundary: "next-orders" as const, grantExpansion: false as const, expiresAt: binding.expiresAt };
43
+ return Object.freeze({ ...body, id: hash(body) });
44
+ }
45
+ export type AdoptionReceipt = ReturnType<typeof authorizeAdoption>;
46
+ export function validateAdoptionReceipt(receipt: AdoptionReceipt, binding: AdoptionBinding, authority: AdoptionAuthority | null, facts: AdoptionFacts, now: number): void {
47
+ if (authorizeAdoption(binding, authority, facts, now).id !== receipt.id) throw new Error("adoption receipt does not match current independent authority/facts");
48
+ receiptValid(receipt);
49
+ }
50
+ function receiptValid(receipt: AdoptionReceipt) {
51
+ const { id, ...body } = receipt;
52
+ if (!closed(receipt, ["version", "bindingId", "authorityId", "hypothesisDigest", "experimentDigest", "candidateDigest", "scopeDigest", "assessmentPolicyDigest", "rollbackCandidateDigest", "activationBoundary", "grantExpansion", "expiresAt", "id"])
53
+ || body.version !== "adoption-receipt-v1" || !text(body.authorityId) || body.activationBoundary !== "next-orders" || body.grantExpansion !== false || hash(body) !== id) throw new Error("adoption receipt changed");
54
+ const binding = buildAdoptionBinding({ hypothesisDigest: body.hypothesisDigest, experimentDigest: body.experimentDigest, candidateDigest: body.candidateDigest,
55
+ scopeDigest: body.scopeDigest, assessmentPolicyDigest: body.assessmentPolicyDigest, rollbackCandidateDigest: body.rollbackCandidateDigest, activationBoundary: body.activationBoundary, expiresAt: body.expiresAt });
56
+ if (binding.id !== body.bindingId) throw new Error("adoption receipt binding mismatch");
57
+ }
58
+ export interface ProductionObservation {
59
+ id: string; adoptionId: string; candidateDigest: string; scopeDigest: string;
60
+ originalRequirementDigest: string; currentRequirementDigest: string;
61
+ /** Only an independent host may supply confirmed outcomes; observer/model claims are not confirmation. */
62
+ outcome: "confirmed-defect" | "success" | "unknown";
63
+ acceptanceDigest: string | null; acceptedArtifactDigest: string | null; observedArtifactDigest: string;
64
+ evidence: string[];
65
+ }
66
+ export function classifyProductionObservation(adoption: AdoptionReceipt, input: ProductionObservation) {
67
+ receiptValid(adoption);
68
+ if (!input || !text(input.id) || input.evidence.length > 256 || input.evidence.some(h => !SHA.test(h))
69
+ || ![input.originalRequirementDigest,input.currentRequirementDigest,input.candidateDigest,input.scopeDigest,input.observedArtifactDigest].every(h => typeof h === "string" && SHA.test(h))
70
+ || ![input.acceptanceDigest,input.acceptedArtifactDigest].every(h => h === null || (typeof h === "string" && SHA.test(h)))
71
+ || (input.acceptanceDigest === null) !== (input.acceptedArtifactDigest === null)
72
+ || !["confirmed-defect", "success", "unknown"].includes(input.outcome)) throw new Error("invalid production observation");
73
+ const state = input.adoptionId !== adoption.id || input.candidateDigest !== adoption.candidateDigest || input.scopeDigest !== adoption.scopeDigest ? "out-of-scope"
74
+ : input.originalRequirementDigest !== input.currentRequirementDigest ? "changed-requirements"
75
+ : input.acceptedArtifactDigest !== null && input.acceptedArtifactDigest !== input.observedArtifactDigest ? "changed-artifact"
76
+ : input.outcome === "confirmed-defect" && input.evidence.length ? input.acceptanceDigest === null ? "caught-defect" : "escape" : input.outcome === "success" ? "success" : "unknown";
77
+ return Object.freeze({ state, adoptionId: adoption.id, hypothesisDigest: adoption.hypothesisDigest, experimentDigest: adoption.experimentDigest, observationId: input.id, acceptanceDigest: input.acceptanceDigest, acceptedArtifactDigest: input.acceptedArtifactDigest, observedArtifactDigest: input.observedArtifactDigest, automaticRollback: false });
78
+ }
79
+ export interface RollbackRequest { version: "rollback-request-v1"; id: string; adoptionId: string; scopeDigest: string; restoreCandidateDigest: string; reason: "operator-request" | "confirmed-escape"; evidence: readonly string[]; expiresAt: number }
80
+ export function buildRollbackRequest(adoption: AdoptionReceipt, reason: RollbackRequest["reason"], evidence: string[], expiresAt = adoption.expiresAt): RollbackRequest {
81
+ receiptValid(adoption);
82
+ if (!Number.isSafeInteger(expiresAt) || expiresAt < 0 || !["operator-request", "confirmed-escape"].includes(reason) || !evidence.length || evidence.length > 256 || evidence.some(h => !SHA.test(h))) throw new Error("invalid rollback request");
83
+ const body = { version: "rollback-request-v1" as const, adoptionId: adoption.id, scopeDigest: adoption.scopeDigest, restoreCandidateDigest: adoption.rollbackCandidateDigest, reason, expiresAt, evidence: Object.freeze([...new Set(evidence)].sort()) };
84
+ return Object.freeze({ ...body, id: hash(body) });
85
+ }
86
+ /** This validates a recorded choice, not an automatic rollback action or a caller-created authority flag. */
87
+ export function authorizeRollback(adoption: AdoptionReceipt, request: RollbackRequest, authority: AdoptionAuthority | null, now: number,
88
+ current: { adoptionId: string; candidateDigest: string; scopeDigest: string }) {
89
+ receiptValid(adoption); const { id, ...body } = request;
90
+ if (!authorityValid(authority) || authority.id !== adoption.authorityId || !authority.rollbacks.includes(id)) throw new Error("independent rollback authority required");
91
+ if (hash(body) !== id || request.version !== "rollback-request-v1" || request.adoptionId !== adoption.id || request.scopeDigest !== adoption.scopeDigest
92
+ || request.restoreCandidateDigest !== adoption.rollbackCandidateDigest || !Number.isSafeInteger(now) || now < 0 || !Number.isSafeInteger(request.expiresAt) || now >= request.expiresAt
93
+ || !current || current.adoptionId !== adoption.id || current.candidateDigest !== adoption.candidateDigest || current.scopeDigest !== adoption.scopeDigest) throw new Error("rollback binding changed or expired");
94
+ return Object.freeze({ requestId: id, adoptionId: adoption.id, restoreCandidateDigest: request.restoreCandidateDigest, scopeDigest: adoption.scopeDigest, grantExpansion: false, application: "not-performed" });
95
+ }
96
+ export interface LeanOutcome {
97
+ population: string; complete: boolean; accepted: boolean | null; repairAttempts: number | null;
98
+ escape: "confirmed" | "none-observed" | "unknown"; requirementsChanged: boolean; decisionWaitMs: number | null; questions: number | null;
99
+ }
100
+ export function summarizeLeanOutcomes(population: string, rows: readonly LeanOutcome[]) {
101
+ if (!text(population) || rows.length > 10000 || rows.some(r => r.population !== population || typeof r.complete !== "boolean" || typeof r.requirementsChanged !== "boolean"
102
+ || ![true,false,null].includes(r.accepted) || !["confirmed","none-observed","unknown"].includes(r.escape)
103
+ || [r.repairAttempts,r.decisionWaitMs,r.questions].some(n => n !== null && (!Number.isSafeInteger(n) || n < 0)))) throw new Error("invalid or incomparable lean population");
104
+ const complete = rows.filter(r => r.complete && r.accepted !== null && r.repairAttempts !== null);
105
+ const observed = (field: "decisionWaitMs" | "questions" | "repairAttempts") => ({ known: rows.reduce((n, r) => n + (r[field] ?? 0), 0), observed: rows.filter(r => r[field] !== null).length, total: rows.length });
106
+ return { population, firstPassAccepted: complete.filter(r => r.accepted && r.repairAttempts === 0).length, firstPassDenominator: complete.length,
107
+ incomplete: rows.length - complete.length, confirmedEscapes: rows.filter(r => r.escape === "confirmed" && !r.requirementsChanged).length,
108
+ changedRequirements: rows.filter(r => r.requirementsChanged).length, recovery: observed("repairAttempts"), decisionWait: observed("decisionWaitMs"), questions: observed("questions"), universalScore: null };
109
+ }
@@ -0,0 +1,113 @@
1
+ import { lstat, realpath, stat } from "node:fs/promises";
2
+ import type { FileHandle } from "node:fs/promises";
3
+ import type { BigIntStats } from "node:fs";
4
+ import { constants } from "node:os";
5
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
6
+ import { WorkLedgerWriteError } from "./work-ledger-types.ts";
7
+
8
+ /** Only actual errno-class failures cross the content-free filesystem error boundary. */
9
+ export function workFsError(error: unknown): error is NodeJS.ErrnoException {
10
+ return error instanceof Error && "code" in error && typeof error.code === "string" && Object.hasOwn(constants.errno, error.code);
11
+ }
12
+ export const validWorkPath = (path: string) => path.length > 0 && isAbsolute(path) && !path.includes("\0");
13
+ // Node encodes string filesystem paths as UTF-8: lone UTF-16 surrogates become U+FFFD.
14
+ // Compare that exact spelling even for absent leaves/ancestors, before mkdir or lock acquisition.
15
+ // This preserves valid Unicode verbatim; it is not Unicode normalization or case folding.
16
+ const filesystemPath = (path: string) => Buffer.from(resolve(path), "utf8").toString("utf8");
17
+ const invalid = (): never => { throw new WorkLedgerWriteError("WORK_DESTINATION_INVALID"); };
18
+ const alias = (): never => { throw new WorkLedgerWriteError("WORK_DESTINATION_ALIAS"); };
19
+ const same = (a: BigIntStats | null, b: BigIntStats | null) => a !== null && b !== null && a.dev === b.dev && a.ino === b.ino;
20
+ function ancestors(path: string): string[] {
21
+ const result: string[] = [];
22
+ for (let p = dirname(path); ; p = dirname(p)) { result.push(p); if (dirname(p) === p) return result; }
23
+ }
24
+ interface PathFact {
25
+ path: string;
26
+ canonical: string | null;
27
+ entry: BigIntStats | null;
28
+ target: BigIntStats | null;
29
+ invalid: boolean;
30
+ io: boolean;
31
+ }
32
+ /** Internal descriptor/topology facts, never a public result, ambient grant lookup or authority input. */
33
+ export interface WorkDestination {
34
+ readonly requestedPath: string;
35
+ readonly grantLedgerPath: string | null;
36
+ readonly path: string;
37
+ readonly existing: BigIntStats | null;
38
+ readonly protectedInodes: readonly BigIntStats[];
39
+ }
40
+
41
+ /** Read-only, complete preflight. A protected filename reserves its entire directory namespace too.
42
+ * Compare lexical paths, canonical paths and exact dev/ino identities; aliases outrank failed facts,
43
+ * which outrank unsafe topology. This is cooperative misrouting protection, NOT host containment. */
44
+ export async function workDestination(requestedPath: string, grantLedgerPath: string | null): Promise<WorkDestination> {
45
+ if (!validWorkPath(requestedPath) || (grantLedgerPath !== null && !validWorkPath(grantLedgerPath))) invalid();
46
+ const path = filesystemPath(requestedPath), leaves = [path, path + ".lock"];
47
+ const protectedPaths = grantLedgerPath === null ? [] : [filesystemPath(grantLedgerPath), filesystemPath(grantLedgerPath) + ".lock"];
48
+ const workPaths = new Set(leaves.flatMap(p => [p, ...ancestors(p)]));
49
+ if (protectedPaths.some(p => workPaths.has(p))) alias(); // Proven lexical collision needs no I/O.
50
+ const facts = new Map<string, Promise<PathFact>>();
51
+ function probe(path: string): Promise<PathFact> {
52
+ const prior = facts.get(path); if (prior) return prior;
53
+ const pending = inspect(path); facts.set(path, pending); return pending;
54
+ }
55
+ async function inspect(path: string): Promise<PathFact> {
56
+ const fact: PathFact = { path, canonical: null, entry: null, target: null, invalid: false, io: false };
57
+ try { fact.entry = await lstat(path, { bigint: true }); }
58
+ catch (error) {
59
+ if (!workFsError(error)) throw error;
60
+ if (error.code !== "ENOENT") { fact.invalid = ["ENOTDIR", "ELOOP"].includes(error.code!); fact.io = !fact.invalid; return fact; }
61
+ }
62
+ if (fact.entry) {
63
+ try {
64
+ fact.canonical = await realpath(path);
65
+ fact.target = fact.entry.isSymbolicLink() ? await stat(path, { bigint: true }) : fact.entry;
66
+ } catch (error) {
67
+ if (!workFsError(error)) throw error;
68
+ fact.invalid = ["ENOENT", "ENOTDIR", "ELOOP"].includes(error.code!); fact.io = !fact.invalid;
69
+ }
70
+ } else {
71
+ const parentPath = dirname(path);
72
+ if (parentPath === path) { fact.invalid = true; return fact; }
73
+ const parent = await probe(parentPath);
74
+ fact.invalid = parent.invalid || (parent.target !== null && !parent.target.isDirectory()); fact.io = parent.io;
75
+ if (parent.canonical) fact.canonical = join(parent.canonical, basename(path));
76
+ }
77
+ return fact;
78
+ }
79
+ const protectedFacts = await Promise.all(protectedPaths.map(probe));
80
+ const initial = await Promise.all([...workPaths].map(probe));
81
+ // A symlink parent can jump INSIDE a reserved directory. Include every canonical ancestor,
82
+ // not just lexical ancestors whose realpath might skip over the protected directory itself.
83
+ for (const fact of initial) if (fact.canonical) for (const p of [fact.canonical, ...ancestors(fact.canonical)]) workPaths.add(p);
84
+ const workFacts = await Promise.all([...workPaths].map(probe));
85
+ for (const w of workFacts) for (const p of protectedFacts) {
86
+ if (w.path === p.path || (w.canonical !== null && w.canonical === p.canonical) || same(w.target, p.target)) alias();
87
+ }
88
+ const all = [...workFacts, ...protectedFacts];
89
+ if (all.some(f => f.io)) throw new WorkLedgerWriteError("WORK_LEDGER_WRITE_FAILED");
90
+ if (all.some(f => f.invalid)) invalid();
91
+ for (const leaf of leaves) {
92
+ const f = await probe(leaf);
93
+ if (f.entry && (!f.entry.isFile() || f.entry.nlink !== 1n)) invalid();
94
+ }
95
+ for (const p of [...ancestors(path), ...ancestors((await probe(path)).canonical!)]) {
96
+ const f = await probe(p);
97
+ if (f.target && !f.target.isDirectory()) invalid();
98
+ }
99
+ const file = await probe(path);
100
+ return { requestedPath, grantLedgerPath, path: file.canonical!, existing: file.target,
101
+ protectedInodes: protectedFacts.flatMap(f => f.target ? [f.target] : []) };
102
+ }
103
+
104
+ /** Repeat classification after mkdir/acquisition/open. Never write through a freshly resolved handle. */
105
+ export async function recheckWorkDestination(original: WorkDestination, handle?: FileHandle): Promise<void> {
106
+ const current = await workDestination(original.requestedPath, original.grantLedgerPath);
107
+ if (handle) {
108
+ const actual = await handle.stat({ bigint: true });
109
+ if (current.protectedInodes.some(p => same(actual, p))) alias();
110
+ if (!actual.isFile() || actual.nlink !== 1n || !same(actual, current.existing)) invalid();
111
+ }
112
+ if (original.path !== current.path || (original.existing && !same(original.existing, current.existing))) invalid();
113
+ }