approval-md 0.0.1 → 0.2.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 (722) hide show
  1. package/LICENSE +176 -0
  2. package/NOTICE +5 -0
  3. package/README.md +940 -4
  4. package/SPEC.md +476 -34
  5. package/cli.js +29 -3
  6. package/dist/src/adapters/agentmail.d.ts +426 -0
  7. package/dist/src/adapters/agentmail.js +1200 -0
  8. package/dist/src/adapters/agentmail.js.map +1 -0
  9. package/dist/src/adapters/conformance.d.ts +149 -0
  10. package/dist/src/adapters/conformance.js +461 -0
  11. package/dist/src/adapters/conformance.js.map +1 -0
  12. package/dist/src/adapters/contract.d.ts +628 -0
  13. package/dist/src/adapters/contract.js +1035 -0
  14. package/dist/src/adapters/contract.js.map +1 -0
  15. package/dist/src/adapters/email.d.ts +324 -0
  16. package/dist/src/adapters/email.js +749 -0
  17. package/dist/src/adapters/email.js.map +1 -0
  18. package/dist/src/adapters/env-passphrase.d.ts +93 -0
  19. package/dist/src/adapters/env-passphrase.js +132 -0
  20. package/dist/src/adapters/env-passphrase.js.map +1 -0
  21. package/dist/src/adapters/public.d.ts +11 -0
  22. package/dist/src/adapters/public.js +11 -0
  23. package/dist/src/adapters/public.js.map +1 -0
  24. package/dist/src/adapters/registry.d.ts +59 -0
  25. package/dist/src/adapters/registry.js +77 -0
  26. package/dist/src/adapters/registry.js.map +1 -0
  27. package/dist/src/adapters/smtp.d.ts +213 -0
  28. package/dist/src/adapters/smtp.js +499 -0
  29. package/dist/src/adapters/smtp.js.map +1 -0
  30. package/dist/src/adapters/vault-provider.d.ts +114 -0
  31. package/dist/src/adapters/vault-provider.js +161 -0
  32. package/dist/src/adapters/vault-provider.js.map +1 -0
  33. package/dist/src/adapters/zzz.d.ts +66 -0
  34. package/dist/src/adapters/zzz.js +299 -0
  35. package/dist/src/adapters/zzz.js.map +1 -0
  36. package/dist/src/channels/batch.d.ts +109 -0
  37. package/dist/src/channels/batch.js +121 -0
  38. package/dist/src/channels/batch.js.map +1 -0
  39. package/dist/src/channels/cli.d.ts +193 -0
  40. package/dist/src/channels/cli.js +468 -0
  41. package/dist/src/channels/cli.js.map +1 -0
  42. package/dist/src/channels/conformance.d.ts +92 -0
  43. package/dist/src/channels/conformance.js +445 -0
  44. package/dist/src/channels/conformance.js.map +1 -0
  45. package/dist/src/channels/contract.d.ts +623 -0
  46. package/dist/src/channels/contract.js +494 -0
  47. package/dist/src/channels/contract.js.map +1 -0
  48. package/dist/src/channels/payload-view.d.ts +35 -0
  49. package/dist/src/channels/payload-view.js +43 -0
  50. package/dist/src/channels/payload-view.js.map +1 -0
  51. package/dist/src/channels/render-queue.d.ts +149 -0
  52. package/dist/src/channels/render-queue.js +564 -0
  53. package/dist/src/channels/render-queue.js.map +1 -0
  54. package/dist/src/channels/tagging.d.ts +196 -0
  55. package/dist/src/channels/tagging.js +723 -0
  56. package/dist/src/channels/tagging.js.map +1 -0
  57. package/dist/src/channels/telegram.d.ts +1832 -0
  58. package/dist/src/channels/telegram.js +3190 -0
  59. package/dist/src/channels/telegram.js.map +1 -0
  60. package/dist/src/channels/web.d.ts +341 -0
  61. package/dist/src/channels/web.js +903 -0
  62. package/dist/src/channels/web.js.map +1 -0
  63. package/dist/src/cli/adapter.d.ts +90 -0
  64. package/dist/src/cli/adapter.js +288 -0
  65. package/dist/src/cli/adapter.js.map +1 -0
  66. package/dist/src/cli/amend.d.ts +59 -0
  67. package/dist/src/cli/amend.js +2171 -0
  68. package/dist/src/cli/amend.js.map +1 -0
  69. package/dist/src/cli/args.d.ts +43 -0
  70. package/dist/src/cli/args.js +86 -0
  71. package/dist/src/cli/args.js.map +1 -0
  72. package/dist/src/cli/attest.d.ts +41 -0
  73. package/dist/src/cli/attest.js +307 -0
  74. package/dist/src/cli/attest.js.map +1 -0
  75. package/dist/src/cli/audit-card.d.ts +62 -0
  76. package/dist/src/cli/audit-card.js +201 -0
  77. package/dist/src/cli/audit-card.js.map +1 -0
  78. package/dist/src/cli/audit.d.ts +59 -0
  79. package/dist/src/cli/audit.js +460 -0
  80. package/dist/src/cli/audit.js.map +1 -0
  81. package/dist/src/cli/channel-telegram.d.ts +806 -0
  82. package/dist/src/cli/channel-telegram.js +2063 -0
  83. package/dist/src/cli/channel-telegram.js.map +1 -0
  84. package/dist/src/cli/channel-web.d.ts +131 -0
  85. package/dist/src/cli/channel-web.js +357 -0
  86. package/dist/src/cli/channel-web.js.map +1 -0
  87. package/dist/src/cli/channel.d.ts +71 -0
  88. package/dist/src/cli/channel.js +438 -0
  89. package/dist/src/cli/channel.js.map +1 -0
  90. package/dist/src/cli/checkpoint-tap.d.ts +169 -0
  91. package/dist/src/cli/checkpoint-tap.js +238 -0
  92. package/dist/src/cli/checkpoint-tap.js.map +1 -0
  93. package/dist/src/cli/codex.d.ts +2 -0
  94. package/dist/src/cli/codex.js +172 -0
  95. package/dist/src/cli/codex.js.map +1 -0
  96. package/dist/src/cli/coverage.d.ts +61 -0
  97. package/dist/src/cli/coverage.js +343 -0
  98. package/dist/src/cli/coverage.js.map +1 -0
  99. package/dist/src/cli/daemon.d.ts +120 -0
  100. package/dist/src/cli/daemon.js +631 -0
  101. package/dist/src/cli/daemon.js.map +1 -0
  102. package/dist/src/cli/doctor.d.ts +129 -0
  103. package/dist/src/cli/doctor.js +2762 -0
  104. package/dist/src/cli/doctor.js.map +1 -0
  105. package/dist/src/cli/env.d.ts +65 -0
  106. package/dist/src/cli/env.js +302 -0
  107. package/dist/src/cli/env.js.map +1 -0
  108. package/dist/src/cli/execute.d.ts +202 -0
  109. package/dist/src/cli/execute.js +1682 -0
  110. package/dist/src/cli/execute.js.map +1 -0
  111. package/dist/src/cli/exit-codes.d.ts +73 -0
  112. package/dist/src/cli/exit-codes.js +82 -0
  113. package/dist/src/cli/exit-codes.js.map +1 -0
  114. package/dist/src/cli/feedback.d.ts +60 -0
  115. package/dist/src/cli/feedback.js +205 -0
  116. package/dist/src/cli/feedback.js.map +1 -0
  117. package/dist/src/cli/gate-window.d.ts +40 -0
  118. package/dist/src/cli/gate-window.js +294 -0
  119. package/dist/src/cli/gate-window.js.map +1 -0
  120. package/dist/src/cli/gate.d.ts +68 -0
  121. package/dist/src/cli/gate.js +557 -0
  122. package/dist/src/cli/gate.js.map +1 -0
  123. package/dist/src/cli/git-scope.d.ts +190 -0
  124. package/dist/src/cli/git-scope.js +295 -0
  125. package/dist/src/cli/git-scope.js.map +1 -0
  126. package/dist/src/cli/gloss-attach.d.ts +85 -0
  127. package/dist/src/cli/gloss-attach.js +107 -0
  128. package/dist/src/cli/gloss-attach.js.map +1 -0
  129. package/dist/src/cli/gloss-codex-child.d.ts +9 -0
  130. package/dist/src/cli/gloss-codex-child.js +149 -0
  131. package/dist/src/cli/gloss-codex-child.js.map +1 -0
  132. package/dist/src/cli/gloss-codex.d.ts +24 -0
  133. package/dist/src/cli/gloss-codex.js +255 -0
  134. package/dist/src/cli/gloss-codex.js.map +1 -0
  135. package/dist/src/cli/gloss-options.d.ts +42 -0
  136. package/dist/src/cli/gloss-options.js +79 -0
  137. package/dist/src/cli/gloss-options.js.map +1 -0
  138. package/dist/src/cli/gloss.d.ts +265 -0
  139. package/dist/src/cli/gloss.js +362 -0
  140. package/dist/src/cli/gloss.js.map +1 -0
  141. package/dist/src/cli/help.d.ts +103 -0
  142. package/dist/src/cli/help.js +2339 -0
  143. package/dist/src/cli/help.js.map +1 -0
  144. package/dist/src/cli/hook-codex.d.ts +78 -0
  145. package/dist/src/cli/hook-codex.js +167 -0
  146. package/dist/src/cli/hook-codex.js.map +1 -0
  147. package/dist/src/cli/hook.d.ts +331 -0
  148. package/dist/src/cli/hook.js +2849 -0
  149. package/dist/src/cli/hook.js.map +1 -0
  150. package/dist/src/cli/import.d.ts +35 -0
  151. package/dist/src/cli/import.js +175 -0
  152. package/dist/src/cli/import.js.map +1 -0
  153. package/dist/src/cli/init.d.ts +84 -0
  154. package/dist/src/cli/init.js +336 -0
  155. package/dist/src/cli/init.js.map +1 -0
  156. package/dist/src/cli/instructions.d.ts +23 -0
  157. package/dist/src/cli/instructions.js +262 -0
  158. package/dist/src/cli/instructions.js.map +1 -0
  159. package/dist/src/cli/journal.d.ts +41 -0
  160. package/dist/src/cli/journal.js +238 -0
  161. package/dist/src/cli/journal.js.map +1 -0
  162. package/dist/src/cli/log-advance.d.ts +287 -0
  163. package/dist/src/cli/log-advance.js +840 -0
  164. package/dist/src/cli/log-advance.js.map +1 -0
  165. package/dist/src/cli/log-anchor.d.ts +176 -0
  166. package/dist/src/cli/log-anchor.js +387 -0
  167. package/dist/src/cli/log-anchor.js.map +1 -0
  168. package/dist/src/cli/log-checkpoint.d.ts +22 -0
  169. package/dist/src/cli/log-checkpoint.js +128 -0
  170. package/dist/src/cli/log-checkpoint.js.map +1 -0
  171. package/dist/src/cli/log-sync.d.ts +243 -0
  172. package/dist/src/cli/log-sync.js +849 -0
  173. package/dist/src/cli/log-sync.js.map +1 -0
  174. package/dist/src/cli/log-verbs.d.ts +16 -0
  175. package/dist/src/cli/log-verbs.js +360 -0
  176. package/dist/src/cli/log-verbs.js.map +1 -0
  177. package/dist/src/cli/long-help.d.ts +70 -0
  178. package/dist/src/cli/long-help.js +148 -0
  179. package/dist/src/cli/long-help.js.map +1 -0
  180. package/dist/src/cli/main.d.ts +77 -0
  181. package/dist/src/cli/main.js +1206 -0
  182. package/dist/src/cli/main.js.map +1 -0
  183. package/dist/src/cli/mcp.d.ts +52 -0
  184. package/dist/src/cli/mcp.js +306 -0
  185. package/dist/src/cli/mcp.js.map +1 -0
  186. package/dist/src/cli/paths.d.ts +56 -0
  187. package/dist/src/cli/paths.js +79 -0
  188. package/dist/src/cli/paths.js.map +1 -0
  189. package/dist/src/cli/payload.d.ts +58 -0
  190. package/dist/src/cli/payload.js +253 -0
  191. package/dist/src/cli/payload.js.map +1 -0
  192. package/dist/src/cli/policy.d.ts +43 -0
  193. package/dist/src/cli/policy.js +229 -0
  194. package/dist/src/cli/policy.js.map +1 -0
  195. package/dist/src/cli/preflight.d.ts +363 -0
  196. package/dist/src/cli/preflight.js +1175 -0
  197. package/dist/src/cli/preflight.js.map +1 -0
  198. package/dist/src/cli/progress.d.ts +78 -0
  199. package/dist/src/cli/progress.js +112 -0
  200. package/dist/src/cli/progress.js.map +1 -0
  201. package/dist/src/cli/prompt.d.ts +209 -0
  202. package/dist/src/cli/prompt.js +312 -0
  203. package/dist/src/cli/prompt.js.map +1 -0
  204. package/dist/src/cli/quickstart.d.ts +46 -0
  205. package/dist/src/cli/quickstart.js +297 -0
  206. package/dist/src/cli/quickstart.js.map +1 -0
  207. package/dist/src/cli/records.d.ts +34 -0
  208. package/dist/src/cli/records.js +66 -0
  209. package/dist/src/cli/records.js.map +1 -0
  210. package/dist/src/cli/render.d.ts +22 -0
  211. package/dist/src/cli/render.js +132 -0
  212. package/dist/src/cli/render.js.map +1 -0
  213. package/dist/src/cli/sandbox.d.ts +51 -0
  214. package/dist/src/cli/sandbox.js +150 -0
  215. package/dist/src/cli/sandbox.js.map +1 -0
  216. package/dist/src/cli/scaffold.d.ts +79 -0
  217. package/dist/src/cli/scaffold.js +137 -0
  218. package/dist/src/cli/scaffold.js.map +1 -0
  219. package/dist/src/cli/setup-adapter.d.ts +137 -0
  220. package/dist/src/cli/setup-adapter.js +509 -0
  221. package/dist/src/cli/setup-adapter.js.map +1 -0
  222. package/dist/src/cli/setup-channel.d.ts +117 -0
  223. package/dist/src/cli/setup-channel.js +635 -0
  224. package/dist/src/cli/setup-channel.js.map +1 -0
  225. package/dist/src/cli/setup-checkpoint.d.ts +57 -0
  226. package/dist/src/cli/setup-checkpoint.js +196 -0
  227. package/dist/src/cli/setup-checkpoint.js.map +1 -0
  228. package/dist/src/cli/setup-common.d.ts +275 -0
  229. package/dist/src/cli/setup-common.js +376 -0
  230. package/dist/src/cli/setup-common.js.map +1 -0
  231. package/dist/src/cli/setup-flow.d.ts +287 -0
  232. package/dist/src/cli/setup-flow.js +476 -0
  233. package/dist/src/cli/setup-flow.js.map +1 -0
  234. package/dist/src/cli/setup-service.d.ts +96 -0
  235. package/dist/src/cli/setup-service.js +308 -0
  236. package/dist/src/cli/setup-service.js.map +1 -0
  237. package/dist/src/cli/setup.d.ts +202 -0
  238. package/dist/src/cli/setup.js +473 -0
  239. package/dist/src/cli/setup.js.map +1 -0
  240. package/dist/src/cli/style.d.ts +320 -0
  241. package/dist/src/cli/style.js +469 -0
  242. package/dist/src/cli/style.js.map +1 -0
  243. package/dist/src/cli/token.d.ts +39 -0
  244. package/dist/src/cli/token.js +274 -0
  245. package/dist/src/cli/token.js.map +1 -0
  246. package/dist/src/cli/up.d.ts +155 -0
  247. package/dist/src/cli/up.js +849 -0
  248. package/dist/src/cli/up.js.map +1 -0
  249. package/dist/src/cli/usage.d.ts +37 -0
  250. package/dist/src/cli/usage.js +91 -0
  251. package/dist/src/cli/usage.js.map +1 -0
  252. package/dist/src/cli/values.d.ts +40 -0
  253. package/dist/src/cli/values.js +189 -0
  254. package/dist/src/cli/values.js.map +1 -0
  255. package/dist/src/cli/vault.d.ts +59 -0
  256. package/dist/src/cli/vault.js +362 -0
  257. package/dist/src/cli/vault.js.map +1 -0
  258. package/dist/src/cli/verb-registry.d.ts +76 -0
  259. package/dist/src/cli/verb-registry.js +2341 -0
  260. package/dist/src/cli/verb-registry.js.map +1 -0
  261. package/dist/src/cli/wordmark.d.ts +31 -0
  262. package/dist/src/cli/wordmark.js +52 -0
  263. package/dist/src/cli/wordmark.js.map +1 -0
  264. package/dist/src/codex/doctor.d.ts +13 -0
  265. package/dist/src/codex/doctor.js +41 -0
  266. package/dist/src/codex/doctor.js.map +1 -0
  267. package/dist/src/codex/manifest.d.ts +49 -0
  268. package/dist/src/codex/manifest.js +103 -0
  269. package/dist/src/codex/manifest.js.map +1 -0
  270. package/dist/src/codex/templates.d.ts +41 -0
  271. package/dist/src/codex/templates.js +319 -0
  272. package/dist/src/codex/templates.js.map +1 -0
  273. package/dist/src/codex/trust.d.ts +19 -0
  274. package/dist/src/codex/trust.js +183 -0
  275. package/dist/src/codex/trust.js.map +1 -0
  276. package/dist/src/codex/workspace-plan.d.ts +131 -0
  277. package/dist/src/codex/workspace-plan.js +561 -0
  278. package/dist/src/codex/workspace-plan.js.map +1 -0
  279. package/dist/src/core/actor.d.ts +2 -0
  280. package/dist/src/core/actor.js +5 -0
  281. package/dist/src/core/actor.js.map +1 -0
  282. package/dist/src/core/advance-cycle.d.ts +170 -0
  283. package/dist/src/core/advance-cycle.js +200 -0
  284. package/dist/src/core/advance-cycle.js.map +1 -0
  285. package/dist/src/core/agents-md.d.ts +276 -0
  286. package/dist/src/core/agents-md.js +747 -0
  287. package/dist/src/core/agents-md.js.map +1 -0
  288. package/dist/src/core/apply-patch.d.ts +49 -0
  289. package/dist/src/core/apply-patch.js +266 -0
  290. package/dist/src/core/apply-patch.js.map +1 -0
  291. package/dist/src/core/attest.d.ts +420 -0
  292. package/dist/src/core/attest.js +589 -0
  293. package/dist/src/core/attest.js.map +1 -0
  294. package/dist/src/core/audit.d.ts +492 -0
  295. package/dist/src/core/audit.js +882 -0
  296. package/dist/src/core/audit.js.map +1 -0
  297. package/dist/src/core/budgets.d.ts +238 -0
  298. package/dist/src/core/budgets.js +449 -0
  299. package/dist/src/core/budgets.js.map +1 -0
  300. package/dist/src/core/checkpoint.d.ts +500 -0
  301. package/dist/src/core/checkpoint.js +738 -0
  302. package/dist/src/core/checkpoint.js.map +1 -0
  303. package/dist/src/core/child-env.d.ts +88 -0
  304. package/dist/src/core/child-env.js +86 -0
  305. package/dist/src/core/child-env.js.map +1 -0
  306. package/dist/src/core/clock.d.ts +52 -0
  307. package/dist/src/core/clock.js +43 -0
  308. package/dist/src/core/clock.js.map +1 -0
  309. package/dist/src/core/command-class.d.ts +543 -0
  310. package/dist/src/core/command-class.js +2356 -0
  311. package/dist/src/core/command-class.js.map +1 -0
  312. package/dist/src/core/coverage-sources/adapter.d.ts +40 -0
  313. package/dist/src/core/coverage-sources/adapter.js +71 -0
  314. package/dist/src/core/coverage-sources/adapter.js.map +1 -0
  315. package/dist/src/core/coverage-sources/gh.d.ts +48 -0
  316. package/dist/src/core/coverage-sources/gh.js +136 -0
  317. package/dist/src/core/coverage-sources/gh.js.map +1 -0
  318. package/dist/src/core/coverage-sources/git.d.ts +101 -0
  319. package/dist/src/core/coverage-sources/git.js +269 -0
  320. package/dist/src/core/coverage-sources/git.js.map +1 -0
  321. package/dist/src/core/coverage.d.ts +217 -0
  322. package/dist/src/core/coverage.js +337 -0
  323. package/dist/src/core/coverage.js.map +1 -0
  324. package/dist/src/core/credential-spec.d.ts +72 -0
  325. package/dist/src/core/credential-spec.js +23 -0
  326. package/dist/src/core/credential-spec.js.map +1 -0
  327. package/dist/src/core/dark-session.d.ts +331 -0
  328. package/dist/src/core/dark-session.js +714 -0
  329. package/dist/src/core/dark-session.js.map +1 -0
  330. package/dist/src/core/decision-refusal.d.ts +185 -0
  331. package/dist/src/core/decision-refusal.js +265 -0
  332. package/dist/src/core/decision-refusal.js.map +1 -0
  333. package/dist/src/core/env-file.d.ts +450 -0
  334. package/dist/src/core/env-file.js +837 -0
  335. package/dist/src/core/env-file.js.map +1 -0
  336. package/dist/src/core/execute.d.ts +858 -0
  337. package/dist/src/core/execute.js +1271 -0
  338. package/dist/src/core/execute.js.map +1 -0
  339. package/dist/src/core/frontmatter.d.ts +78 -0
  340. package/dist/src/core/frontmatter.js +100 -0
  341. package/dist/src/core/frontmatter.js.map +1 -0
  342. package/dist/src/core/gate-window.d.ts +312 -0
  343. package/dist/src/core/gate-window.js +506 -0
  344. package/dist/src/core/gate-window.js.map +1 -0
  345. package/dist/src/core/gate.d.ts +1364 -0
  346. package/dist/src/core/gate.js +3002 -0
  347. package/dist/src/core/gate.js.map +1 -0
  348. package/dist/src/core/git-run.d.ts +73 -0
  349. package/dist/src/core/git-run.js +93 -0
  350. package/dist/src/core/git-run.js.map +1 -0
  351. package/dist/src/core/harness-version.d.ts +157 -0
  352. package/dist/src/core/harness-version.js +211 -0
  353. package/dist/src/core/harness-version.js.map +1 -0
  354. package/dist/src/core/harness-wait.d.ts +55 -0
  355. package/dist/src/core/harness-wait.js +58 -0
  356. package/dist/src/core/harness-wait.js.map +1 -0
  357. package/dist/src/core/head-retry.d.ts +107 -0
  358. package/dist/src/core/head-retry.js +121 -0
  359. package/dist/src/core/head-retry.js.map +1 -0
  360. package/dist/src/core/instance.d.ts +253 -0
  361. package/dist/src/core/instance.js +319 -0
  362. package/dist/src/core/instance.js.map +1 -0
  363. package/dist/src/core/intake-limits.d.ts +247 -0
  364. package/dist/src/core/intake-limits.js +350 -0
  365. package/dist/src/core/intake-limits.js.map +1 -0
  366. package/dist/src/core/jcs.d.ts +52 -0
  367. package/dist/src/core/jcs.js +132 -0
  368. package/dist/src/core/jcs.js.map +1 -0
  369. package/dist/src/core/journal.d.ts +144 -0
  370. package/dist/src/core/journal.js +200 -0
  371. package/dist/src/core/journal.js.map +1 -0
  372. package/dist/src/core/live-draw.d.ts +436 -0
  373. package/dist/src/core/live-draw.js +703 -0
  374. package/dist/src/core/live-draw.js.map +1 -0
  375. package/dist/src/core/log-reconcile.d.ts +89 -0
  376. package/dist/src/core/log-reconcile.js +136 -0
  377. package/dist/src/core/log-reconcile.js.map +1 -0
  378. package/dist/src/core/log-subscribe.d.ts +36 -0
  379. package/dist/src/core/log-subscribe.js +162 -0
  380. package/dist/src/core/log-subscribe.js.map +1 -0
  381. package/dist/src/core/log.d.ts +278 -0
  382. package/dist/src/core/log.js +546 -0
  383. package/dist/src/core/log.js.map +1 -0
  384. package/dist/src/core/loop.d.ts +274 -0
  385. package/dist/src/core/loop.js +487 -0
  386. package/dist/src/core/loop.js.map +1 -0
  387. package/dist/src/core/md-fence.d.ts +41 -0
  388. package/dist/src/core/md-fence.js +74 -0
  389. package/dist/src/core/md-fence.js.map +1 -0
  390. package/dist/src/core/money.d.ts +147 -0
  391. package/dist/src/core/money.js +195 -0
  392. package/dist/src/core/money.js.map +1 -0
  393. package/dist/src/core/payload-census.d.ts +74 -0
  394. package/dist/src/core/payload-census.js +146 -0
  395. package/dist/src/core/payload-census.js.map +1 -0
  396. package/dist/src/core/payload-store.d.ts +175 -0
  397. package/dist/src/core/payload-store.js +340 -0
  398. package/dist/src/core/payload-store.js.map +1 -0
  399. package/dist/src/core/payload.d.ts +71 -0
  400. package/dist/src/core/payload.js +80 -0
  401. package/dist/src/core/payload.js.map +1 -0
  402. package/dist/src/core/policy-diff.d.ts +292 -0
  403. package/dist/src/core/policy-diff.js +588 -0
  404. package/dist/src/core/policy-diff.js.map +1 -0
  405. package/dist/src/core/policy-expectations.d.ts +199 -0
  406. package/dist/src/core/policy-expectations.js +394 -0
  407. package/dist/src/core/policy-expectations.js.map +1 -0
  408. package/dist/src/core/policy-explain.d.ts +150 -0
  409. package/dist/src/core/policy-explain.js +258 -0
  410. package/dist/src/core/policy-explain.js.map +1 -0
  411. package/dist/src/core/policy-load.d.ts +527 -0
  412. package/dist/src/core/policy-load.js +536 -0
  413. package/dist/src/core/policy-load.js.map +1 -0
  414. package/dist/src/core/policy-match.d.ts +281 -0
  415. package/dist/src/core/policy-match.js +478 -0
  416. package/dist/src/core/policy-match.js.map +1 -0
  417. package/dist/src/core/policy-proposal.d.ts +265 -0
  418. package/dist/src/core/policy-proposal.js +458 -0
  419. package/dist/src/core/policy-proposal.js.map +1 -0
  420. package/dist/src/core/prompt-layout.d.ts +221 -0
  421. package/dist/src/core/prompt-layout.js +422 -0
  422. package/dist/src/core/prompt-layout.js.map +1 -0
  423. package/dist/src/core/protected-path-guard.d.ts +453 -0
  424. package/dist/src/core/protected-path-guard.js +1566 -0
  425. package/dist/src/core/protected-path-guard.js.map +1 -0
  426. package/dist/src/core/registration.d.ts +25 -0
  427. package/dist/src/core/registration.js +39 -0
  428. package/dist/src/core/registration.js.map +1 -0
  429. package/dist/src/core/reindex.d.ts +99 -0
  430. package/dist/src/core/reindex.js +336 -0
  431. package/dist/src/core/reindex.js.map +1 -0
  432. package/dist/src/core/sampler.d.ts +313 -0
  433. package/dist/src/core/sampler.js +388 -0
  434. package/dist/src/core/sampler.js.map +1 -0
  435. package/dist/src/core/sandbox.d.ts +290 -0
  436. package/dist/src/core/sandbox.js +424 -0
  437. package/dist/src/core/sandbox.js.map +1 -0
  438. package/dist/src/core/seal.d.ts +165 -0
  439. package/dist/src/core/seal.js +290 -0
  440. package/dist/src/core/seal.js.map +1 -0
  441. package/dist/src/core/state.d.ts +505 -0
  442. package/dist/src/core/state.js +1009 -0
  443. package/dist/src/core/state.js.map +1 -0
  444. package/dist/src/core/task-file.d.ts +185 -0
  445. package/dist/src/core/task-file.js +464 -0
  446. package/dist/src/core/task-file.js.map +1 -0
  447. package/dist/src/core/telegram-config.d.ts +93 -0
  448. package/dist/src/core/telegram-config.js +114 -0
  449. package/dist/src/core/telegram-config.js.map +1 -0
  450. package/dist/src/core/token.d.ts +409 -0
  451. package/dist/src/core/token.js +561 -0
  452. package/dist/src/core/token.js.map +1 -0
  453. package/dist/src/core/validate.d.ts +138 -0
  454. package/dist/src/core/validate.js +0 -0
  455. package/dist/src/core/validate.js.map +1 -0
  456. package/dist/src/core/values.d.ts +137 -0
  457. package/dist/src/core/values.js +153 -0
  458. package/dist/src/core/values.js.map +1 -0
  459. package/dist/src/core/vault.d.ts +291 -0
  460. package/dist/src/core/vault.js +612 -0
  461. package/dist/src/core/vault.js.map +1 -0
  462. package/dist/src/core/verified-snapshot.d.ts +204 -0
  463. package/dist/src/core/verified-snapshot.js +506 -0
  464. package/dist/src/core/verified-snapshot.js.map +1 -0
  465. package/dist/src/core/verify.d.ts +336 -0
  466. package/dist/src/core/verify.js +549 -0
  467. package/dist/src/core/verify.js.map +1 -0
  468. package/dist/src/core/version.d.ts +8 -0
  469. package/dist/src/core/version.js +9 -0
  470. package/dist/src/core/version.js.map +1 -0
  471. package/dist/src/core/wysiwys.d.ts +370 -0
  472. package/dist/src/core/wysiwys.js +728 -0
  473. package/dist/src/core/wysiwys.js.map +1 -0
  474. package/dist/src/daemon/advance-child.d.ts +39 -0
  475. package/dist/src/daemon/advance-child.js +78 -0
  476. package/dist/src/daemon/advance-child.js.map +1 -0
  477. package/dist/src/daemon/advance.d.ts +466 -0
  478. package/dist/src/daemon/advance.js +849 -0
  479. package/dist/src/daemon/advance.js.map +1 -0
  480. package/dist/src/daemon/audit.d.ts +87 -0
  481. package/dist/src/daemon/audit.js +90 -0
  482. package/dist/src/daemon/audit.js.map +1 -0
  483. package/dist/src/daemon/daemon.d.ts +1180 -0
  484. package/dist/src/daemon/daemon.js +1988 -0
  485. package/dist/src/daemon/daemon.js.map +1 -0
  486. package/dist/src/daemon/dark-session.d.ts +64 -0
  487. package/dist/src/daemon/dark-session.js +119 -0
  488. package/dist/src/daemon/dark-session.js.map +1 -0
  489. package/dist/src/daemon/draw-child.d.ts +36 -0
  490. package/dist/src/daemon/draw-child.js +132 -0
  491. package/dist/src/daemon/draw-child.js.map +1 -0
  492. package/dist/src/daemon/draw.d.ts +154 -0
  493. package/dist/src/daemon/draw.js +458 -0
  494. package/dist/src/daemon/draw.js.map +1 -0
  495. package/dist/src/daemon/git-evidence.d.ts +173 -0
  496. package/dist/src/daemon/git-evidence.js +345 -0
  497. package/dist/src/daemon/git-evidence.js.map +1 -0
  498. package/dist/src/daemon/projection.d.ts +180 -0
  499. package/dist/src/daemon/projection.js +233 -0
  500. package/dist/src/daemon/projection.js.map +1 -0
  501. package/dist/src/daemon/prune.d.ts +207 -0
  502. package/dist/src/daemon/prune.js +376 -0
  503. package/dist/src/daemon/prune.js.map +1 -0
  504. package/dist/src/mcp/http.d.ts +113 -0
  505. package/dist/src/mcp/http.js +343 -0
  506. package/dist/src/mcp/http.js.map +1 -0
  507. package/dist/src/mcp/server.d.ts +265 -0
  508. package/dist/src/mcp/server.js +602 -0
  509. package/dist/src/mcp/server.js.map +1 -0
  510. package/docs/adapter-api.md +106 -0
  511. package/docs/cli-reference.md +5716 -0
  512. package/docs/codex-enforced-session.md +30 -0
  513. package/package.json +53 -4
  514. package/schema/.gitkeep +0 -0
  515. package/schema/LICENSE +117 -0
  516. package/schema/codex-instance.schema.json +82 -0
  517. package/schema/envelope.schema.json +137 -0
  518. package/schema/event.schema.json +1811 -0
  519. package/schema/fixtures/codex-instance/invalid/unpinned-codex-version.json +40 -0
  520. package/schema/fixtures/codex-instance/valid/canonical.json +40 -0
  521. package/schema/fixtures/envelope/invalid/action-missing-idempotency-key.json +15 -0
  522. package/schema/fixtures/envelope/invalid/action-unknown-class-format.json +14 -0
  523. package/schema/fixtures/envelope/invalid/confidence-out-of-range.json +11 -0
  524. package/schema/fixtures/envelope/invalid/est-cost-bare-number.json +14 -0
  525. package/schema/fixtures/envelope/invalid/est-cost-noncanonical-string.json +14 -0
  526. package/schema/fixtures/envelope/invalid/malformed-assignee.json +10 -0
  527. package/schema/fixtures/envelope/invalid/malformed-created-by.json +7 -0
  528. package/schema/fixtures/envelope/invalid/malformed-max-latency.json +11 -0
  529. package/schema/fixtures/envelope/invalid/malformed-payload-hash.json +14 -0
  530. package/schema/fixtures/envelope/invalid/max-cost-bare-number.json +11 -0
  531. package/schema/fixtures/envelope/invalid/missing-origin.json +3 -0
  532. package/schema/fixtures/envelope/invalid/negative-est-cost.json +14 -0
  533. package/schema/fixtures/envelope/invalid/unknown-state.json +7 -0
  534. package/schema/fixtures/envelope/invalid/unknown-top-level-field.json +8 -0
  535. package/schema/fixtures/envelope/valid/action-payload-hash.json +17 -0
  536. package/schema/fixtures/envelope/valid/actions-without-budget.json +18 -0
  537. package/schema/fixtures/envelope/valid/canonical.json +25 -0
  538. package/schema/fixtures/envelope/valid/minimal.json +7 -0
  539. package/schema/fixtures/envelope/valid/multi-action-executed.json +30 -0
  540. package/schema/fixtures/envelope/valid/record-write-stage.json +22 -0
  541. package/schema/fixtures/event/invalid/approval-granted-agent-actor.json +15 -0
  542. package/schema/fixtures/event/invalid/approval-granted-empty-batch-delivery-id.json +17 -0
  543. package/schema/fixtures/event/invalid/approval-granted-fifth-reaction.json +16 -0
  544. package/schema/fixtures/event/invalid/approval-granted-missing-actor.json +14 -0
  545. package/schema/fixtures/event/invalid/approval-requested-missing-action-key.json +14 -0
  546. package/schema/fixtures/event/invalid/approval-withdrawn-agent-policy-drift.json +15 -0
  547. package/schema/fixtures/event/invalid/approval-withdrawn-missing-reason.json +15 -0
  548. package/schema/fixtures/event/invalid/approval-withdrawn-system-actor.json +15 -0
  549. package/schema/fixtures/event/invalid/audit-decision-refused-human-actor.json +17 -0
  550. package/schema/fixtures/event/invalid/audit-decision-refused-missing-code.json +16 -0
  551. package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +15 -0
  552. package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +16 -0
  553. package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +15 -0
  554. package/schema/fixtures/event/invalid/bad-actor-prefix.json +15 -0
  555. package/schema/fixtures/event/invalid/est-cost-bare-number.json +17 -0
  556. package/schema/fixtures/event/invalid/execution-completed-fabricated-exit-code.json +16 -0
  557. package/schema/fixtures/event/invalid/execution-completed-provider-ref-empty-id.json +18 -0
  558. package/schema/fixtures/event/invalid/execution-completed-provider-ref-extra-field.json +19 -0
  559. package/schema/fixtures/event/invalid/execution-completed-provider-ref-id-not-string.json +18 -0
  560. package/schema/fixtures/event/invalid/execution-completed-provider-ref-missing-adapter.json +17 -0
  561. package/schema/fixtures/event/invalid/execution-failed-open-reported-by.json +16 -0
  562. package/schema/fixtures/event/invalid/execution-indeterminate-open-reason.json +14 -0
  563. package/schema/fixtures/event/invalid/execution-reconciled-agent-actor.json +17 -0
  564. package/schema/fixtures/event/invalid/execution-started-negative-env-stripped.json +16 -0
  565. package/schema/fixtures/event/invalid/gate-bypassed-missing-opened-seq.json +15 -0
  566. package/schema/fixtures/event/invalid/gate-closed-non-integer-opened-seq.json +13 -0
  567. package/schema/fixtures/event/invalid/gate-opened-agent-actor.json +16 -0
  568. package/schema/fixtures/event/invalid/gate-organ-attested-absolute-path.json +14 -0
  569. package/schema/fixtures/event/invalid/gate-organ-attested-agent-actor.json +14 -0
  570. package/schema/fixtures/event/invalid/gate-organ-attested-missing-organ-path.json +13 -0
  571. package/schema/fixtures/event/invalid/harness-unknown-kind.json +18 -0
  572. package/schema/fixtures/event/invalid/harness-version-multiline.json +16 -0
  573. package/schema/fixtures/event/invalid/log-checkpoint-agent-actor.json +17 -0
  574. package/schema/fixtures/event/invalid/log-checkpoint-missing-signature.json +16 -0
  575. package/schema/fixtures/event/invalid/log-checkpoint-short-signed-hash.json +17 -0
  576. package/schema/fixtures/event/invalid/log-checkpoint-unknown-signature-alg.json +17 -0
  577. package/schema/fixtures/event/invalid/malformed-ts.json +15 -0
  578. package/schema/fixtures/event/invalid/missing-alg.json +14 -0
  579. package/schema/fixtures/event/invalid/missing-hash.json +14 -0
  580. package/schema/fixtures/event/invalid/non-integer-seq.json +15 -0
  581. package/schema/fixtures/event/invalid/payload-pruned-human-actor.json +14 -0
  582. package/schema/fixtures/event/invalid/payload-pruned-missing-hash.json +14 -0
  583. package/schema/fixtures/event/invalid/policy-declined-agent-actor.json +15 -0
  584. package/schema/fixtures/event/invalid/policy-proposed-missing-diff.json +19 -0
  585. package/schema/fixtures/event/invalid/policy-proposed-system-actor.json +26 -0
  586. package/schema/fixtures/event/invalid/short-hash.json +15 -0
  587. package/schema/fixtures/event/invalid/unknown-alg.json +15 -0
  588. package/schema/fixtures/event/invalid/unknown-event-type.json +15 -0
  589. package/schema/fixtures/event/invalid/unknown-top-level-field.json +16 -0
  590. package/schema/fixtures/event/valid/approval-expired.json +15 -0
  591. package/schema/fixtures/event/valid/approval-granted-batch.json +19 -0
  592. package/schema/fixtures/event/valid/approval-granted-reaction.json +16 -0
  593. package/schema/fixtures/event/valid/approval-granted.json +15 -0
  594. package/schema/fixtures/event/valid/approval-rejected.json +15 -0
  595. package/schema/fixtures/event/valid/approval-requested.json +19 -0
  596. package/schema/fixtures/event/valid/approval-revoked.json +15 -0
  597. package/schema/fixtures/event/valid/approval-withdrawn-policy-drift.json +17 -0
  598. package/schema/fixtures/event/valid/approval-withdrawn.json +16 -0
  599. package/schema/fixtures/event/valid/audit-decision-refused.json +20 -0
  600. package/schema/fixtures/event/valid/audit-reviewed-reaction.json +17 -0
  601. package/schema/fixtures/event/valid/audit-reviewed.json +15 -0
  602. package/schema/fixtures/event/valid/audit-sampled.json +14 -0
  603. package/schema/fixtures/event/valid/budget-exceeded.json +21 -0
  604. package/schema/fixtures/event/valid/envelope-drift.json +16 -0
  605. package/schema/fixtures/event/valid/execution-completed-harness-report.json +16 -0
  606. package/schema/fixtures/event/valid/execution-completed-provider-ref.json +18 -0
  607. package/schema/fixtures/event/valid/execution-completed.json +15 -0
  608. package/schema/fixtures/event/valid/execution-failed-harness-report.json +16 -0
  609. package/schema/fixtures/event/valid/execution-failed.json +15 -0
  610. package/schema/fixtures/event/valid/execution-indeterminate.json +15 -0
  611. package/schema/fixtures/event/valid/execution-reconciled.json +17 -0
  612. package/schema/fixtures/event/valid/execution-started-env-stripped.json +17 -0
  613. package/schema/fixtures/event/valid/execution-started.json +14 -0
  614. package/schema/fixtures/event/valid/gate-bypassed-harness-version.json +18 -0
  615. package/schema/fixtures/event/valid/gate-bypassed.json +19 -0
  616. package/schema/fixtures/event/valid/gate-closed.json +14 -0
  617. package/schema/fixtures/event/valid/gate-opened.json +16 -0
  618. package/schema/fixtures/event/valid/gate-organ-attested.json +14 -0
  619. package/schema/fixtures/event/valid/genesis-null-prev.json +14 -0
  620. package/schema/fixtures/event/valid/log-checkpoint.json +17 -0
  621. package/schema/fixtures/event/valid/payload-pruned-orphan.json +13 -0
  622. package/schema/fixtures/event/valid/payload-pruned.json +17 -0
  623. package/schema/fixtures/event/valid/policy-declined.json +16 -0
  624. package/schema/fixtures/event/valid/policy-proposed.json +35 -0
  625. package/schema/fixtures/event/valid/policy-updated.json +14 -0
  626. package/schema/fixtures/event/valid/reconciliation-required.json +18 -0
  627. package/schema/fixtures/event/valid/reconciliation-satisfied.json +17 -0
  628. package/schema/fixtures/event/valid/route-accepted.json +15 -0
  629. package/schema/fixtures/event/valid/route-proposed.json +16 -0
  630. package/schema/fixtures/event/valid/spec-example.json +15 -0
  631. package/schema/fixtures/event/valid/task-registered-harness-version.json +23 -0
  632. package/schema/fixtures/event/valid/task-registered.json +14 -0
  633. package/schema/fixtures/hash/known-answer-pre-121.json +74 -0
  634. package/schema/fixtures/hash/known-answer.json +74 -0
  635. package/schema/fixtures/policy/invalid/bad-approval-ttl.json +7 -0
  636. package/schema/fixtures/policy/invalid/bad-web-port.json +4 -0
  637. package/schema/fixtures/policy/invalid/checkpoint-key-not-base64.json +6 -0
  638. package/schema/fixtures/policy/invalid/class-rule-missing-autonomy.json +9 -0
  639. package/schema/fixtures/policy/invalid/empty-class-key.json +6 -0
  640. package/schema/fixtures/policy/invalid/live-rate-on-human-only.json +7 -0
  641. package/schema/fixtures/policy/invalid/malformed-class-key.json +6 -0
  642. package/schema/fixtures/policy/invalid/missing-version.json +8 -0
  643. package/schema/fixtures/policy/invalid/negative-limit.json +9 -0
  644. package/schema/fixtures/policy/invalid/non-numeric-limit.json +9 -0
  645. package/schema/fixtures/policy/invalid/non-positive-max-pending.json +9 -0
  646. package/schema/fixtures/policy/invalid/on-expiry-grant.json +8 -0
  647. package/schema/fixtures/policy/invalid/payload-retention-bare-number.json +4 -0
  648. package/schema/fixtures/policy/invalid/payload-retention-compound.json +4 -0
  649. package/schema/fixtures/policy/invalid/payload-retention-fractional.json +4 -0
  650. package/schema/fixtures/policy/invalid/payload-retention-zero.json +4 -0
  651. package/schema/fixtures/policy/invalid/protected-paths-escape.json +4 -0
  652. package/schema/fixtures/policy/invalid/protected-paths-glob.json +4 -0
  653. package/schema/fixtures/policy/invalid/retro-rate-on-human-only.json +7 -0
  654. package/schema/fixtures/policy/invalid/retro-rate-on-manual.json +7 -0
  655. package/schema/fixtures/policy/invalid/retro-rate-zero.json +7 -0
  656. package/schema/fixtures/policy/invalid/sample-rate-too-high.json +5 -0
  657. package/schema/fixtures/policy/invalid/sampling-secret-env-empty.json +7 -0
  658. package/schema/fixtures/policy/invalid/sampling-secret-env-not-string.json +6 -0
  659. package/schema/fixtures/policy/invalid/skew-tolerance-compound.json +6 -0
  660. package/schema/fixtures/policy/invalid/unknown-autonomy.json +7 -0
  661. package/schema/fixtures/policy/invalid/unknown-class-rule-key.json +6 -0
  662. package/schema/fixtures/policy/invalid/unknown-top-level-key.json +7 -0
  663. package/schema/fixtures/policy/invalid/vault-passphrase-env-empty.json +6 -0
  664. package/schema/fixtures/policy/invalid/vault-passphrase-literal.json +6 -0
  665. package/schema/fixtures/policy/invalid/version-not-string.json +4 -0
  666. package/schema/fixtures/policy/valid/canonical.json +47 -0
  667. package/schema/fixtures/policy/valid/checkpoint-keys.json +18 -0
  668. package/schema/fixtures/policy/valid/class-approvers-limits.json +25 -0
  669. package/schema/fixtures/policy/valid/class-retro-rate.json +17 -0
  670. package/schema/fixtures/policy/valid/global-budgets.json +19 -0
  671. package/schema/fixtures/policy/valid/human-only.json +9 -0
  672. package/schema/fixtures/policy/valid/minimal.json +6 -0
  673. package/schema/fixtures/policy/valid/protected-paths.json +10 -0
  674. package/schema/fixtures/policy/valid/record-namespace.json +13 -0
  675. package/schema/fixtures/policy/valid/request-volume-limits.json +26 -0
  676. package/schema/fixtures/policy/valid/retention-and-sampling-secret.json +16 -0
  677. package/schema/fixtures/policy/valid/skew-tolerance.json +15 -0
  678. package/schema/fixtures/policy/valid/vault-passphrase-env.json +14 -0
  679. package/schema/fixtures/policy/valid/wildcards.json +15 -0
  680. package/schema/fixtures/policy-md/invalid/alias-bomb.md +15 -0
  681. package/schema/fixtures/policy-md/invalid/no-fence.md +7 -0
  682. package/schema/fixtures/policy-md/invalid/protected-route-not-a-subclass.md +16 -0
  683. package/schema/fixtures/policy-md/invalid/schema-invalid-autonomy.md +16 -0
  684. package/schema/fixtures/policy-md/invalid/schema-invalid-read-proof.md +17 -0
  685. package/schema/fixtures/policy-md/invalid/two-fences.md +19 -0
  686. package/schema/fixtures/policy-md/invalid/unclosed-fence.md +11 -0
  687. package/schema/fixtures/policy-md/invalid/wrong-info-string.md +11 -0
  688. package/schema/fixtures/policy-md/invalid/yaml-syntax-error.md +13 -0
  689. package/schema/fixtures/policy-md/precedence/both/APPROVAL.md +7 -0
  690. package/schema/fixtures/policy-md/precedence/both/APPROVALS.md +7 -0
  691. package/schema/fixtures/policy-md/precedence/fallback-only/APPROVALS.md +7 -0
  692. package/schema/fixtures/policy-md/valid/canonical.md +50 -0
  693. package/schema/fixtures/policy-md/valid/daemon-read-proof.md +18 -0
  694. package/schema/fixtures/policy-md/valid/minimal.md +3 -0
  695. package/schema/fixtures/policy-md/valid/prose-lookalikes.md +54 -0
  696. package/schema/fixtures/policy-md/valid/routed-protected-paths.md +49 -0
  697. package/schema/fixtures/policy-md/valid/with-values.md +79 -0
  698. package/schema/fixtures/sample-record/invalid/bad-date-time.json +4 -0
  699. package/schema/fixtures/sample-record/invalid/missing-required-field.json +3 -0
  700. package/schema/fixtures/sample-record/invalid/unknown-top-level-field.json +5 -0
  701. package/schema/fixtures/sample-record/invalid/wrong-type.json +4 -0
  702. package/schema/fixtures/sample-record/valid/minimal.json +4 -0
  703. package/schema/fixtures/sample-record/valid/with-note.json +5 -0
  704. package/schema/fixtures/values/invalid/class-shaped.json +9 -0
  705. package/schema/fixtures/values/invalid/duplicate-entry.json +4 -0
  706. package/schema/fixtures/values/invalid/non-string-item.json +4 -0
  707. package/schema/fixtures/values/invalid/over-cap.json +26 -0
  708. package/schema/fixtures/values/invalid/unknown-key.json +5 -0
  709. package/schema/fixtures/values/invalid/version-string.json +1 -0
  710. package/schema/fixtures/values/valid/empty-lists.json +7 -0
  711. package/schema/fixtures/values/valid/full.json +20 -0
  712. package/schema/fixtures/values/valid/minimal.json +1 -0
  713. package/schema/fixtures/values-md/invalid/schema-invalid.md +62 -0
  714. package/schema/fixtures/values-md/invalid/two-blocks.md +69 -0
  715. package/schema/fixtures/values-md/invalid/unterminated.md +61 -0
  716. package/schema/fixtures/values-md/invalid/yaml-error.md +63 -0
  717. package/schema/fixtures/values-md/valid/absent.md +50 -0
  718. package/schema/fixtures/values-md/valid/with-values.md +79 -0
  719. package/schema/policy.schema.json +501 -0
  720. package/schema/sample-record.schema.json +26 -0
  721. package/schema/values.schema.json +55 -0
  722. package/templates/codex/README.md +9 -0
@@ -0,0 +1,2762 @@
1
+ /**
2
+ * `approval doctor` — environment sanity in one verb (APRV-31).
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * During the live policy-amendment ceremony the operator lost time twice to
7
+ * questions this command answers in a second. First they drove a **stale
8
+ * checkout**: `dist/` was older than the source tree, so verbs that existed in
9
+ * `src/` were simply absent from the built CLI and every invocation looked like
10
+ * a version confusion rather than a missing `npm run build`. Then they reached
11
+ * for what turned out to be an **unbuilt placeholder binary** — a `cli.js`
12
+ * loader with no `dist/` behind it, which fails with one line about a missing
13
+ * file and says nothing about which of the several checkouts on the machine is
14
+ * the real one. Only on the third try did they find the working install.
15
+ *
16
+ * Neither failure was a bug in the runtime. Both were facts about the
17
+ * environment that nothing was in a position to state out loud. `doctor` is
18
+ * that statement: eleven checks, in the order in which their failures cascade,
19
+ * each with a concrete repair.
20
+ *
21
+ * ## Every fix begins with a command (APRV-75)
22
+ *
23
+ * A `fix` string starts with something the operator can paste, and the prose
24
+ * comes after it. The reason is the reading order of a failed run: an operator
25
+ * scanning a wall of `fix:` lines is looking for the next thing to type, and a
26
+ * line that opens with "check that…" makes them read a sentence to discover
27
+ * there is nothing to type at all. {@link FIX_COMMAND_PREFIXES} is the pinned
28
+ * allowlist, and `tests/cli-doctor.test.ts` drives every failing verdict this
29
+ * command can produce and asserts the shape (never the wording).
30
+ *
31
+ * ## What it will not do
32
+ *
33
+ * **It appends nothing.** Not an event, not a marker, not a "doctor ran"
34
+ * breadcrumb. An operator reaching for a diagnostic while the log is in a state
35
+ * they do not understand must not have that state changed by looking at it; the
36
+ * test suite byte-compares the log across a run.
37
+ *
38
+ * **It sends no message.** The Telegram check calls `getMe` and only `getMe` —
39
+ * a pure identity read. It never calls `sendMessage` (a diagnostic that pings a
40
+ * human's phone is a diagnostic nobody runs twice) and it never calls
41
+ * `getUpdates`, because a running `channel telegram listen` owns that offset
42
+ * and a stray poll would consume an update the listener would then never see.
43
+ *
44
+ * **It repairs nothing.** Every failure yields a `fix` string the human runs
45
+ * themselves. A doctor that rebuilt, re-attested, or truncated on its own would
46
+ * be making exactly the decisions this project exists to keep human.
47
+ *
48
+ * ## Exit codes
49
+ *
50
+ * 0 when every check passed or skipped, 1 when any failed. {@link EXIT_IO} is
51
+ * reserved for doctor's own inability to look — the installation root cannot be
52
+ * stat'd for a reason other than "not there". An unreadable *log* or an
53
+ * unreadable *policy* is not that: those are environment facts, which is
54
+ * precisely what this command reports, so they are check failures (exit 1).
55
+ */
56
+ import { createServer } from "node:net";
57
+ import { closeSync, existsSync, openSync, readFileSync, readdirSync, statSync, unlinkSync, } from "node:fs";
58
+ import { basename, dirname, isAbsolute, join, resolve as resolvePathSegments } from "node:path";
59
+ import { WEB_DEFAULT_PORT } from "../channels/web.js";
60
+ import { TELEGRAM_DEFAULT_API_BASE, telegramChatEnvFor, telegramTokenEnvFor, } from "../channels/telegram.js";
61
+ import { HUMAN_ACTOR_ENV, checkAttestation, findOrganAttestation, latestOrganAttestation, policyBytesHash, resolveHumanActor, } from "../core/attest.js";
62
+ import { isGateOrganPath } from "../core/command-class.js";
63
+ import { VALUES_INFO_STRING, loadValues } from "../core/values.js";
64
+ import { KEYSTORE_DEFERRED, NON_RESOLVING_RUNNER, envFilePathFor, resolveEnvironment, } from "../core/env-file.js";
65
+ import { readTaskFile } from "../core/frontmatter.js";
66
+ import { instanceFindings, instanceHomeFor, instanceIdFor } from "../core/instance.js";
67
+ import { checkLogAnchor } from "./log-anchor.js";
68
+ import { checkLogCheckpoints, checkpointPolicyOf } from "../core/checkpoint.js";
69
+ import { payloadStoreCensus } from "../core/payload-census.js";
70
+ import { payloadStoreDirFor } from "../core/payload-store.js";
71
+ import { DEFAULT_TASKS_DIR, latestRegistration } from "../core/registration.js";
72
+ import { POLICY_FILENAMES, loadPolicy } from "../core/policy-load.js";
73
+ import { openObligations } from "../core/audit.js";
74
+ import { classSampling, resolveSampler } from "../core/sampler.js";
75
+ import { checkVault, passphraseEnvFor, passphraseFrom, vaultExists, vaultPathFor, } from "../core/vault.js";
76
+ import { admitSnapshot, logBytes, snapshotPathFor, snapshotSummary, } from "../core/verified-snapshot.js";
77
+ import { askDaemonSampling, dialDrawSocket, drawSocketPathFor, liveClassesOf, } from "../core/live-draw.js";
78
+ import { keyStoreDirFor } from "../core/seal.js";
79
+ import { verifyWithRecords } from "../core/verify.js";
80
+ import { boolFlag, parseFlags, stringFlag } from "./args.js";
81
+ import { policyWebPort } from "./channel-web.js";
82
+ import { EXIT_INTEGRITY, EXIT_IO, EXIT_OK, EXIT_USAGE } from "./exit-codes.js";
83
+ import { RESOLVE_DANGLING_COMMAND, lastAdvance, proveDanglingAdvances, } from "../core/advance-cycle.js";
84
+ import { DEFAULT_DARK_WINDOW_MS, reportDarkSessions, } from "../core/dark-session.js";
85
+ import { HARNESS_BINARY, HARNESS_KINDS, installedHarnessVersion, isHarnessKind, readHarnessProvenance, } from "../core/harness-version.js";
86
+ import { git, repoPath, repoRoot } from "./git-scope.js";
87
+ import { ScanError, checkBuildFreshness, checkMainBehindOrigin, installationRoot, } from "./preflight.js";
88
+ import { publishedState } from "./log-advance.js";
89
+ import { DOCTOR_HELP } from "./help.js";
90
+ import { DEFAULT_LOG_PATH, resolvePath } from "./paths.js";
91
+ import { DEFAULT_QUEUE_PATH } from "./render.js";
92
+ import { style } from "./style.js";
93
+ import { usageErrorText } from "./usage.js";
94
+ const FLAGS = {
95
+ "--log": "string",
96
+ "--policy": "string",
97
+ "--dir": "string",
98
+ "--api-base": "string",
99
+ // Where the task files live, for the envelope-integrity check (APRV-63).
100
+ // Defaults to <--dir>/backlog/tasks, the same default the daemon uses.
101
+ "--tasks": "string",
102
+ // Test-only (documented as such in --help): retarget the build-freshness
103
+ // check at a fixture tree. It moves no other check, and a wrong value can
104
+ // only make check 1 wrong — never the log, the policy, or the network.
105
+ "--root": "string",
106
+ // APRV-102. The brief's `--verbose`: never abbreviate a detail, whatever the
107
+ // terminal is doing. See `renderDoctorHuman` for why the default is not the
108
+ // aggressive truncation the brief first proposed.
109
+ "--verbose": "boolean",
110
+ "--json": "boolean",
111
+ "--help": "boolean",
112
+ "-h": "boolean",
113
+ };
114
+ /** How long the Telegram identity probe waits before calling it a network failure. */
115
+ const PROBE_TIMEOUT_MS = 10_000;
116
+ /**
117
+ * Every `fix` string begins with one of these, and the prose follows (APRV-75).
118
+ *
119
+ * A closed, small list rather than "looks like a command": the point is that a
120
+ * reader can paste the head of the line, and a `fix` that opened with a verb
121
+ * nobody has installed would be no better than a sentence. `approval ` is by far
122
+ * the commonest — most repairs in this runtime are another verb of this CLI —
123
+ * and the shell builtins here are the ones an actual repair needs: a variable to
124
+ * export, a mode to set, an ignore line to append, a directory to move aside.
125
+ *
126
+ * Note what is NOT here: no `rm`, no `sudo`, no `git commit`. Doctor repairs
127
+ * nothing, and a fix line that told an operator to delete or to commit would be
128
+ * making the decision this project exists to keep human.
129
+ */
130
+ export const FIX_COMMAND_PREFIXES = [
131
+ "approval ",
132
+ "chmod ",
133
+ "echo ",
134
+ "export ",
135
+ "mv ",
136
+ "node ",
137
+ "npm ",
138
+ ];
139
+ function detailOf(cause) {
140
+ return cause instanceof Error ? cause.message : String(cause);
141
+ }
142
+ /**
143
+ * Fold a multi-line message onto one line.
144
+ *
145
+ * The human renderer is one line per check plus one indented `fix:`, and a
146
+ * message that arrived with an embedded newline (the `.approval/env` mode
147
+ * refusal carries its `chmod` on a second line) would silently break that shape
148
+ * for every reader and every test that counts lines.
149
+ */
150
+ function oneLine(text) {
151
+ return text.replace(/\s*\n\s*/gu, " ").trim();
152
+ }
153
+ function absolute(value, cwd) {
154
+ return isAbsolute(value) ? value : resolvePathSegments(cwd, value);
155
+ }
156
+ function usageError(streams, json, message) {
157
+ if (json)
158
+ streams.err(`${JSON.stringify({ error: { code: "usage", message } })}\n`);
159
+ else
160
+ streams.err(usageErrorText(message, DOCTOR_HELP));
161
+ return EXIT_USAGE;
162
+ }
163
+ function ioError(streams, json, message) {
164
+ if (json)
165
+ streams.err(`${JSON.stringify({ error: { code: "io", message } })}\n`);
166
+ else
167
+ streams.err(`approval: ${message}\n`);
168
+ return EXIT_IO;
169
+ }
170
+ // ---------------------------------------------------------------------------
171
+ // 1. build freshness
172
+ // ---------------------------------------------------------------------------
173
+ //
174
+ // `installationRoot`, `ScanError`, `newestMtime` and `checkBuildFreshness`
175
+ // moved to `cli/preflight.ts` (APRV-215), where the startup preflight needs the
176
+ // same answer before it decides whether to rebuild. They are imported back
177
+ // above and their behaviour is unchanged; this note is here so the numbered
178
+ // walk through doctor's checks still has a first step to stand on.
179
+ // ---------------------------------------------------------------------------
180
+ // 2. identity
181
+ // ---------------------------------------------------------------------------
182
+ /**
183
+ * Is a human identity declared in the environment?
184
+ *
185
+ * Environment only — deliberately no `--as`. `doctor` reports what the *next*
186
+ * command will find, and a `--as` typed here would answer for this invocation
187
+ * and nothing else, which is the opposite of useful.
188
+ */
189
+ function checkIdentity() {
190
+ const actor = resolveHumanActor();
191
+ if (actor !== null) {
192
+ return {
193
+ check: "identity",
194
+ status: "pass",
195
+ detail: `${HUMAN_ACTOR_ENV}=${actor} (config-declared: the trust boundary is this machine, not cryptography)`,
196
+ };
197
+ }
198
+ const raw = process.env[HUMAN_ACTOR_ENV];
199
+ return {
200
+ check: "identity",
201
+ status: "fail",
202
+ detail: raw === undefined || raw.length === 0
203
+ ? `${HUMAN_ACTOR_ENV} is unset: the human-only verbs (grant, reject, revoke, policy attest) will refuse`
204
+ : `${HUMAN_ACTOR_ENV}=${JSON.stringify(raw)} does not match human:<id>, so it is ignored rather than guessed at`,
205
+ // `approval setup identity` lands in APRV-74, in parallel with this task;
206
+ // the manual alternative is kept after it deliberately, because a fix that
207
+ // named only a verb would be useless to anyone on an older build.
208
+ fix: `approval setup identity — or set it yourself: export ${HUMAN_ACTOR_ENV}=human:<id>, or pass --as human:<id> to each human-only verb`,
209
+ };
210
+ }
211
+ // ---------------------------------------------------------------------------
212
+ // 3. attestation
213
+ // ---------------------------------------------------------------------------
214
+ /**
215
+ * The policy file this runtime would enforce.
216
+ *
217
+ * `--policy` wins outright; otherwise discovery walks {@link POLICY_FILENAMES}
218
+ * in `dir` exactly as `loadPolicy` and `policy attest` do, so doctor never
219
+ * judges a different file from the one the gate reads. When nothing is found,
220
+ * the first candidate name is returned anyway: `checkAttestation` will report
221
+ * it `unreadable`, which is the honest answer ("there is no policy here"), and
222
+ * the message names the path that is missing.
223
+ */
224
+ function resolvePolicyPath(policyFlag, dir, cwd) {
225
+ if (policyFlag !== null)
226
+ return absolute(policyFlag, cwd);
227
+ for (const filename of POLICY_FILENAMES) {
228
+ const candidate = join(dir, filename);
229
+ try {
230
+ statSync(candidate);
231
+ return candidate;
232
+ }
233
+ catch {
234
+ continue;
235
+ }
236
+ }
237
+ return join(dir, POLICY_FILENAMES[0] ?? "APPROVAL.md");
238
+ }
239
+ /**
240
+ * Do the live policy bytes match the latest attestation in the log?
241
+ *
242
+ * This is the check that decides whether the gate will do anything at all: an
243
+ * unattested policy makes every gated operation refuse, and the refusal is
244
+ * easily misread as "the policy says no" rather than "the policy is unverified".
245
+ */
246
+ function checkAttestationHealth(records, policyPath) {
247
+ const status = checkAttestation(records, policyPath);
248
+ switch (status.status) {
249
+ case "attested":
250
+ return {
251
+ check: "attestation",
252
+ status: "pass",
253
+ detail: `${policyPath} is attested at seq ${status.seq} (sha256 ${status.sha256.slice(0, 12)}…)`,
254
+ };
255
+ case "not-attested":
256
+ return {
257
+ check: "attestation",
258
+ status: "fail",
259
+ detail: `${policyPath} has never been attested; every gated operation will refuse with policy-not-attested`,
260
+ fix: "approval policy attest --as human:<id> — after reading the file",
261
+ };
262
+ case "hash-mismatch":
263
+ return {
264
+ check: "attestation",
265
+ status: "fail",
266
+ detail: `${policyPath} has changed since it was attested at seq ${status.seq} (attested ${status.attestedSha256.slice(0, 12)}…, live ${status.liveSha256.slice(0, 12)}…); an edited policy is inoperative until a human re-attests it`,
267
+ fix: `approval policy attest --as human:<id> — after reviewing the diff (\`git diff -- ${policyPath}\`); re-attesting is what makes the new bytes operative`,
268
+ };
269
+ case "unreadable":
270
+ return {
271
+ check: "attestation",
272
+ status: "fail",
273
+ detail: `${status.message}; an unverifiable policy is treated as unattested`,
274
+ fix: `approval init — to scaffold a policy file here (or point --policy / --dir at the one you have), then \`approval policy attest --as human:<id>\``,
275
+ };
276
+ }
277
+ }
278
+ // ---------------------------------------------------------------------------
279
+ // 4. log
280
+ // ---------------------------------------------------------------------------
281
+ /** The chain verdict, in doctor's vocabulary. Reads; never writes. */
282
+ function checkLog(logPath, result) {
283
+ switch (result.status) {
284
+ case "clean":
285
+ return {
286
+ check: "log",
287
+ status: "pass",
288
+ detail: result.head === null
289
+ ? `${logPath} is empty (an audit trail that has recorded nothing is clean, not missing)`
290
+ : `${logPath} verifies: ${result.records} record(s), head seq ${result.head.seq} ${result.head.hash.slice(0, 12)}…`,
291
+ };
292
+ case "torn-tail":
293
+ return {
294
+ check: "log",
295
+ status: "fail",
296
+ detail: `${logPath} ends with an unterminated final line — the signature of a crashed write, not of tampering; records 1..${result.intactThroughSeq} verify clean`,
297
+ fix: "approval log verify — the full report; nothing here truncates the torn line, because that is a human decision",
298
+ };
299
+ case "corrupt":
300
+ return {
301
+ check: "log",
302
+ status: "fail",
303
+ detail: `${logPath} does not verify (${result.reason}${result.firstBadSeq === null ? "" : ` at seq ${result.firstBadSeq}`}): ${result.message}`,
304
+ fix: "approval log verify — and authorize nothing from this log until a human has accounted for the break",
305
+ };
306
+ }
307
+ }
308
+ // ---------------------------------------------------------------------------
309
+ // 5. telegram
310
+ // ---------------------------------------------------------------------------
311
+ /** Replace the bot token wherever it appears. Nothing leaves this file with it. */
312
+ function redact(text, token) {
313
+ return token.length === 0 ? text : text.split(token).join("<token redacted>");
314
+ }
315
+ /**
316
+ * Is the configured bot token live?
317
+ *
318
+ * `getMe` and nothing else. Not `sendMessage`: a diagnostic that buzzes a
319
+ * human's phone gets run once and then avoided. Not `getUpdates`: that call
320
+ * advances an offset a running `approval channel telegram listen` owns, and a
321
+ * decision tap consumed here would never reach the listener that was waiting
322
+ * for it. `getMe` mutates nothing and acknowledges nothing.
323
+ *
324
+ * Absent configuration is a `skip`, not a failure. Telegram is optional; a
325
+ * runtime driven entirely by `channel cli` is perfectly healthy without it.
326
+ *
327
+ * WHICH variables carry the configuration is the policy's to say (SPEC.md §5.1
328
+ * `channels.telegram.token_env` / `chat_id_env`, amended §5.2 by APRV-72), so
329
+ * the already-computed policy load comes in and every message here names the
330
+ * variable this operator's policy actually asked for. A policy that failed to
331
+ * load names nothing and the reference defaults apply: doctor telling an
332
+ * operator to set a variable their policy never mentions is the failure mode
333
+ * this parameter removes.
334
+ */
335
+ async function checkTelegram(apiBase, load) {
336
+ const tokenEnv = telegramTokenEnvFor(load);
337
+ const chatEnv = telegramChatEnvFor(load);
338
+ const token = process.env[tokenEnv] ?? "";
339
+ const chat = process.env[chatEnv] ?? "";
340
+ if (token.length === 0 || chat.length === 0) {
341
+ const missing = [
342
+ token.length === 0 ? tokenEnv : null,
343
+ chat.length === 0 ? chatEnv : null,
344
+ ].filter((name) => name !== null);
345
+ return {
346
+ check: "telegram",
347
+ status: "skip",
348
+ // A skip carries no `fix` — there is nothing wrong to repair — but a
349
+ // reader who WANTED Telegram still needs the path out, so the detail ends
350
+ // with it. (`approval setup channel telegram` is the verb; APRV-79 renamed it.)
351
+ detail: `${missing.join(" and ")} ${missing.length === 1 ? "is" : "are"} unset: the Telegram channel is not configured, which is a legitimate configuration and not a fault; run \`approval setup channel telegram\` to configure it`,
352
+ };
353
+ }
354
+ const controller = new AbortController();
355
+ const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
356
+ const base = apiBase.replace(/\/+$/u, "");
357
+ try {
358
+ const response = await fetch(`${base}/bot${token}/getMe`, {
359
+ method: "POST",
360
+ headers: { "content-type": "application/json" },
361
+ body: "{}",
362
+ signal: controller.signal,
363
+ });
364
+ const raw = await response.text();
365
+ let envelope = {};
366
+ try {
367
+ const parsed = JSON.parse(raw);
368
+ if (typeof parsed === "object" && parsed !== null) {
369
+ envelope = parsed;
370
+ }
371
+ }
372
+ catch {
373
+ /* handled below: a non-JSON body is not an ok envelope */
374
+ }
375
+ if (!response.ok || envelope["ok"] !== true) {
376
+ const description = redact(String(envelope["description"] ?? "no description"), token);
377
+ return {
378
+ check: "telegram",
379
+ status: "fail",
380
+ detail: `getMe on ${base} was refused: HTTP ${response.status} (${description})`,
381
+ fix: response.status === 401 || /unauthorized/iu.test(description)
382
+ ? `approval setup channel telegram — the bot token is not valid; re-copy it from @BotFather into ${tokenEnv}`
383
+ : `approval channel telegram health — the offline configuration report; then check ${tokenEnv} and that ${base} is the right Bot API base`,
384
+ };
385
+ }
386
+ const result = (envelope["result"] ?? {});
387
+ const username = typeof result["username"] === "string" ? `@${result["username"]}` : "unnamed";
388
+ const id = result["id"] === undefined ? "unknown id" : `id ${String(result["id"])}`;
389
+ return {
390
+ check: "telegram",
391
+ status: "pass",
392
+ detail: `token valid: ${username} (${id}) via ${base}, chat ${chat}; no message was sent and no update was consumed`,
393
+ };
394
+ }
395
+ catch (cause) {
396
+ return {
397
+ check: "telegram",
398
+ status: "fail",
399
+ detail: `getMe on ${base} failed: ${redact(detailOf(cause), token)}`,
400
+ fix: `approval channel telegram health — the offline configuration report; then check network reachability of ${base} (and ${tokenEnv} if it is a TLS or auth failure)`,
401
+ };
402
+ }
403
+ finally {
404
+ clearTimeout(timer);
405
+ }
406
+ }
407
+ // ---------------------------------------------------------------------------
408
+ // 6. web port
409
+ // ---------------------------------------------------------------------------
410
+ /**
411
+ * Can the web channel's port be bound on loopback?
412
+ *
413
+ * A **held** port is a `pass`, not a failure, and the detail says why: the most
414
+ * likely holder is this runtime's own `approval channel web`, and a doctor that
415
+ * cried "broken" at a working channel would train operators to ignore it. Only
416
+ * a bind error that means the configuration itself is wrong — `EACCES`, i.e. a
417
+ * privileged port the runtime may not have — is a failure.
418
+ *
419
+ * The probe binds 127.0.0.1 only, never `0.0.0.0`: the web channel is
420
+ * loopback-only, so testing a wider bind would answer a question nobody asked
421
+ * and would briefly open a port to the network.
422
+ */
423
+ async function checkWebPort(port) {
424
+ return await new Promise((resolve) => {
425
+ const server = createServer();
426
+ const settle = (check) => {
427
+ server.removeAllListeners();
428
+ server.close(() => resolve(check));
429
+ };
430
+ server.once("error", (cause) => {
431
+ server.removeAllListeners();
432
+ if (cause.code === "EADDRINUSE") {
433
+ resolve({
434
+ check: "web-port",
435
+ status: "pass",
436
+ detail: `127.0.0.1:${port} is already held — most likely this runtime's own \`approval channel web\`; nothing here connected to it`,
437
+ });
438
+ return;
439
+ }
440
+ if (cause.code === "EACCES") {
441
+ resolve({
442
+ check: "web-port",
443
+ status: "fail",
444
+ detail: `127.0.0.1:${port} cannot be bound: EACCES (a privileged port this process may not open)`,
445
+ fix: "approval policy attest --as human:<id> — after setting channels.web.port in APPROVAL.md to a port above 1023 (an edited policy is inoperative until it is re-attested)",
446
+ });
447
+ return;
448
+ }
449
+ resolve({
450
+ check: "web-port",
451
+ status: "fail",
452
+ detail: `127.0.0.1:${port} cannot be bound: ${detailOf(cause)}`,
453
+ fix: "approval policy attest --as human:<id> — after setting a usable channels.web.port in APPROVAL.md (an edited policy is inoperative until it is re-attested)",
454
+ });
455
+ });
456
+ server.once("listening", () => {
457
+ settle({
458
+ check: "web-port",
459
+ status: "pass",
460
+ detail: `127.0.0.1:${port} is free (bound and released; nothing was left listening)`,
461
+ });
462
+ });
463
+ server.listen(port, "127.0.0.1");
464
+ });
465
+ }
466
+ // ---------------------------------------------------------------------------
467
+ // 7. payload store
468
+ // ---------------------------------------------------------------------------
469
+ /** The sentence every verdict of this check carries. */
470
+ const PAYLOAD_STORE_WARNING = "the store holds the bytes approvals bind to, keyed by their hash, and it is the one cache that CANNOT be rebuilt from the log: the log records the binding, never the material, so payloads deleted from here are gone and their manual requests render payload-unavailable";
471
+ /**
472
+ * Can the payload store be written?
473
+ *
474
+ * A store that does not exist yet is a `pass`: the directory is created by the
475
+ * first request that carries `--payload`, and a repo that has not made one is
476
+ * not broken. What is worth failing on is an existing directory this process
477
+ * cannot write, because the failure surfaces at exactly the wrong moment: a
478
+ * request already accepted by the gate refuses `payload-store-failed` mid
479
+ * ceremony, and the operator reads it as the runtime refusing rather than as a
480
+ * permission bit.
481
+ *
482
+ * The probe is a real create-and-remove in the store directory, not a `statSync`
483
+ * mode test: mode bits do not answer the question on a read-only mount, under an
484
+ * ACL, or in a container whose uid mapping differs from the one that made the
485
+ * directory. Nothing is left behind, and no payload file is read, written or
486
+ * verified here.
487
+ */
488
+ function checkPayloadStore(logPath, records) {
489
+ const storeDir = payloadStoreDirFor(logPath);
490
+ let stats;
491
+ try {
492
+ stats = statSync(storeDir);
493
+ }
494
+ catch (cause) {
495
+ if (cause.code === "ENOENT") {
496
+ return {
497
+ check: "payload-store",
498
+ status: "pass",
499
+ detail: `${storeDir} is not created until the first request --payload; ${PAYLOAD_STORE_WARNING}`,
500
+ };
501
+ }
502
+ return {
503
+ check: "payload-store",
504
+ status: "fail",
505
+ detail: `${storeDir} could not be stat'd: ${detailOf(cause)}; ${PAYLOAD_STORE_WARNING}`,
506
+ fix: `chmod u+rwx ${storeDir} — make it readable and writable by the user running approval, and check its ownership`,
507
+ };
508
+ }
509
+ if (!stats.isDirectory()) {
510
+ return {
511
+ check: "payload-store",
512
+ status: "fail",
513
+ detail: `${storeDir} exists and is not a directory, so no payload can be stored beside the log; ${PAYLOAD_STORE_WARNING}`,
514
+ fix: `mv ${storeDir} ${storeDir}.aside — move whatever occupies the store's path out of the way (do not delete it until you know what it is), then re-run the request`,
515
+ };
516
+ }
517
+ const probe = join(storeDir, `.doctor-write-probe-${String(process.pid)}`);
518
+ try {
519
+ const handle = openSync(probe, "wx");
520
+ closeSync(handle);
521
+ }
522
+ catch (cause) {
523
+ return {
524
+ check: "payload-store",
525
+ status: "fail",
526
+ detail: `${storeDir} exists but is not writable (${detailOf(cause)}): a request carrying --payload will refuse payload-store-failed; ${PAYLOAD_STORE_WARNING}`,
527
+ fix: `chmod u+w ${storeDir} — make it writable by the user running approval, and check its ownership`,
528
+ };
529
+ }
530
+ finally {
531
+ try {
532
+ unlinkSync(probe);
533
+ }
534
+ catch {
535
+ // The probe may never have been created; nothing to clean up.
536
+ }
537
+ }
538
+ let files = 0;
539
+ try {
540
+ for (const entry of readdirSync(storeDir, { withFileTypes: true })) {
541
+ if (entry.isFile() && entry.name.endsWith(".json") && !entry.name.startsWith(".")) {
542
+ files += 1;
543
+ }
544
+ }
545
+ }
546
+ catch {
547
+ files = 0;
548
+ }
549
+ // What the log says about the store, beside what the store holds (APRV-41).
550
+ // `pruned` is retention doing its job and leaving the evidence of the deletion
551
+ // behind; `orphans` are files no record binds; `awaiting removal` are files the
552
+ // log already says are gone, which the next daemon tick unlinks without
553
+ // appending a second event.
554
+ const census = payloadStoreCensus(records, storeDir);
555
+ const residue = census.awaitingRemoval === 0
556
+ ? ""
557
+ : `, ${census.awaitingRemoval} already recorded as pruned and awaiting removal by the daemon`;
558
+ return {
559
+ check: "payload-store",
560
+ status: "pass",
561
+ detail: `${storeDir} is writable and holds ${files} payload file(s), ${census.pruned} pruned by the log, ${census.orphans} bound to no record${residue}; ${PAYLOAD_STORE_WARNING}`,
562
+ };
563
+ }
564
+ // ---------------------------------------------------------------------------
565
+ // 7b. the verified-head snapshot (APRV-188)
566
+ // ---------------------------------------------------------------------------
567
+ /**
568
+ * Is the daemon's verified-head snapshot present, and does it still cover the
569
+ * log a hook would read?
570
+ *
571
+ * The snapshot is what lets a hook process re-prove a digest instead of
572
+ * re-walking the chain, so its absence is a latency fact and never a
573
+ * correctness one. That is why nothing here is a `fail` on the snapshot's own
574
+ * account: every reader re-proves it, an unusable one is ignored, and a hook
575
+ * behind an unusable snapshot behaves exactly as it did before APRV-188. The
576
+ * row exists so an operator can see whether the acceleration is actually in
577
+ * force, and so a snapshot that is somehow unreadable (a bad mode, a foreign
578
+ * owner) is visible rather than silent.
579
+ *
580
+ * The one `fail` is a snapshot a reader would REFUSE for a reason the operator
581
+ * should act on: permissions or ownership. A stale one is a `pass` that says so
582
+ * — it endorses a shorter prefix, and the hook walks the tail.
583
+ */
584
+ function checkVerifiedSnapshot(logPath) {
585
+ const path = snapshotPathFor(logPath);
586
+ const read = snapshotSummary(logPath);
587
+ if (!read.ok) {
588
+ if (read.reason === "absent") {
589
+ return {
590
+ check: "verified-snapshot",
591
+ status: "skip",
592
+ detail: `no snapshot at ${path}; every hook invocation verifies the log from genesis. It is published by \`approval daemon run\`, so this is expected when the daemon has never run here.`,
593
+ };
594
+ }
595
+ if (read.reason === "foreign-owner" || read.reason === "loose-permissions") {
596
+ return {
597
+ check: "verified-snapshot",
598
+ status: "fail",
599
+ detail: `${path} would be refused by every reader: ${read.detail}. Hooks fall back to a full chain walk, which is correct but slower.`,
600
+ fix: `rm ${path} — remove it and let \`approval daemon run\` republish it as this user at mode 0600`,
601
+ };
602
+ }
603
+ return {
604
+ check: "verified-snapshot",
605
+ status: "pass",
606
+ detail: `${path} is present but not usable (${read.reason}: ${read.detail}); hooks verify the log from genesis, which is the behaviour without a snapshot at all.`,
607
+ };
608
+ }
609
+ const raw = logBytes(logPath);
610
+ if (raw === null) {
611
+ return {
612
+ check: "verified-snapshot",
613
+ status: "pass",
614
+ detail: `${path} endorses ${String(read.snapshot.lines)} record(s), and the log could not be read here to check it against.`,
615
+ };
616
+ }
617
+ const admitted = admitSnapshot(logPath, raw, read.snapshot, undefined);
618
+ if (!admitted.ok) {
619
+ return {
620
+ check: "verified-snapshot",
621
+ status: "pass",
622
+ detail: `${path} no longer applies to the log (${admitted.reason}: ${admitted.detail}); hooks verify from genesis until the daemon republishes it.`,
623
+ };
624
+ }
625
+ const behind = raw.length - read.snapshot.byte_length;
626
+ const currency = behind === 0
627
+ ? "the whole log"
628
+ : `all but the last ${String(behind)} byte(s), which a hook walks itself`;
629
+ return {
630
+ check: "verified-snapshot",
631
+ status: "pass",
632
+ detail: `${path} endorses ${currency}: ${String(read.snapshot.lines)} record(s) through seq ${String(read.snapshot.head.seq)}, published ${read.snapshot.verified_at}. A hook re-proves the digest rather than re-walking the chain.`,
633
+ };
634
+ }
635
+ // ---------------------------------------------------------------------------
636
+ // 7d. the live draw (APRV-208)
637
+ // ---------------------------------------------------------------------------
638
+ /**
639
+ * Is a daemon answering live draws for this log?
640
+ *
641
+ * The row exists because the difference it reports is invisible everywhere else
642
+ * and expensive: with nothing answering, a class an operator declared
643
+ * `supervised-live` at 0.1 is gated at 100% — safely, silently, and for as long
644
+ * as nobody notices, which is the state APRV-184 found this repository in for a
645
+ * fortnight. "Every policy edit asks for a tap" and "one in ten policy edits
646
+ * asks for a tap" look identical from inside the policy file.
647
+ *
648
+ * `skip` when the policy declares no `supervised-live` class: there is nothing
649
+ * to draw, and a row announcing a missing socket for a feature nobody uses is
650
+ * noise. Otherwise `pass` with the socket, or `fail` — this row's one `fail` —
651
+ * when a live class is declared and no usable socket is there, because that IS
652
+ * the operator's control not being in force.
653
+ *
654
+ * ## Why it connects (APRV-282)
655
+ *
656
+ * It used to `stat` the socket and stop there, and on 2026-09-05 that read a
657
+ * socket file left behind by a daemon that had exited as a healthy gate: the
658
+ * row was green while every tap on the operator's phone sat unconsumed. A
659
+ * socket file is created by a bind and removed by an orderly shutdown, so the
660
+ * one state its presence cannot report is the one that matters — a process that
661
+ * died. PRESENCE PROVES NOTHING. So the row opens a connection and closes it
662
+ * again, which is the first thing an asker does and the first thing that fails.
663
+ *
664
+ * It still asks the daemon NOTHING: it sends no question, waits for no answer,
665
+ * and hangs up the moment the connection is accepted. What it reports is what
666
+ * an asker would conclude before it had said a word.
667
+ */
668
+ async function checkLiveDraw(logPath, load) {
669
+ const path = drawSocketPathFor(logPath);
670
+ // The same helper the daemon's server asks, so this row and the process that
671
+ // serves draws can never disagree about whether the file declares one.
672
+ const liveClasses = load.ok ? liveClassesOf(load.policy) : [];
673
+ if (liveClasses.length === 0) {
674
+ return {
675
+ check: "live-draw",
676
+ status: "skip",
677
+ detail: `this policy declares no supervised-live class, so no draw is ever made and ${path} is not needed.`,
678
+ };
679
+ }
680
+ const declared = liveClasses.join(", ");
681
+ let stats;
682
+ try {
683
+ stats = statSync(path);
684
+ }
685
+ catch {
686
+ return {
687
+ check: "live-draw",
688
+ status: "fail",
689
+ detail: `no draw socket at ${path}, so every action of ${declared} gates to a human instead of being sampled: a gate process holds no sampling secret, and there is no daemon to ask.`,
690
+ fix: 'eval "$(approval env)" && approval up — start the ambient runtime in a shell where the sampling secret resolves, so it can answer draws',
691
+ };
692
+ }
693
+ const euid = typeof process.geteuid === "function" ? process.geteuid() : null;
694
+ if (!stats.isSocket() || euid === null || stats.uid !== euid || (stats.mode & 0o077) !== 0) {
695
+ return {
696
+ check: "live-draw",
697
+ status: "fail",
698
+ detail: `${path} exists but every asker would refuse it (owner uid ${String(stats.uid)}, mode ${(stats.mode & 0o777).toString(8)}), so ${declared} gates to a human on every action.`,
699
+ fix: "stop the daemon, remove the socket, and start it again as the user who owns this approval home",
700
+ };
701
+ }
702
+ // The question a `stat` cannot answer: is anything on the other end?
703
+ const dialled = await dialDrawSocket(path, null);
704
+ if (!dialled.ok) {
705
+ return {
706
+ check: "live-draw",
707
+ status: "fail",
708
+ detail: oneLine(`${path} is on disk and refuses connections (${dialled.detail}). That is what a daemon killed rather than stopped leaves behind: the file was last written ${stats.mtime.toISOString()} and nothing has served it since. Every action of ${declared} gates to a human at 100% while this stands, and the file's presence says otherwise.`),
709
+ fix: "approval up — start the ambient runtime again, in a shell where the sampling secret resolves; it clears the stale socket and binds a new one",
710
+ };
711
+ }
712
+ return {
713
+ check: "live-draw",
714
+ status: "pass",
715
+ detail: `${path} is owner-only and answered a connection, so a gate process with no sampling secret can have its draw answered and ${declared} is sampled at its declared rate rather than gated at 100%. The connection was opened and closed with no question asked.`,
716
+ };
717
+ }
718
+ // ---------------------------------------------------------------------------
719
+ // 7d. the values block (APRV-238)
720
+ // ---------------------------------------------------------------------------
721
+ /**
722
+ * Whether the optional values block of `APPROVAL.md` can be read.
723
+ *
724
+ * The row exists because nothing else would ever report a broken one. A values
725
+ * block is guidance and not policy (SPEC.md §5.3, §11.1 invariant 10), so a
726
+ * malformed one changes nothing about what the policy says and deliberately
727
+ * does not appear in `approval policy check`, whose answer is the enforcement
728
+ * trace. Left there, a typo in the block would silently mean the operator's
729
+ * stated values reach no agent while every gate keeps working perfectly.
730
+ *
731
+ * Absence is a `pass` and says so in the words SPEC.md §5.3 fixes: a file with
732
+ * no block is an operator who declared no values, which is a state and not a
733
+ * fault. The only `fail` is a block that is present and unreadable, and its fix
734
+ * names the code rather than proposing a repair, because what the block should
735
+ * say is the human's to write.
736
+ */
737
+ function checkValuesBlock(policyPath, policyFlagged, dir) {
738
+ const result = loadValues(policyFlagged ? { file: policyPath } : { dir });
739
+ if (!result.ok) {
740
+ if (result.code === "file-missing") {
741
+ return {
742
+ check: "values-block",
743
+ status: "skip",
744
+ detail: oneLine(`${result.message}, so there is no values block to read. The policy file's own absence is reported by the attestation row above.`),
745
+ };
746
+ }
747
+ return {
748
+ check: "values-block",
749
+ status: "fail",
750
+ detail: oneLine(`a \`\`\`${VALUES_INFO_STRING} block is present and could not be read (${result.code}): ${result.message}. Nothing about the policy changed — guidance is not enforcement — but the operator's stated values reach no agent until this parses.`),
751
+ fix: `approval values --json — prints the same failure with its code (${result.code}); fix the block in ${result.source?.filename ?? "the policy file"} and re-attest, since the attestation digests the whole file`,
752
+ };
753
+ }
754
+ if (!result.present) {
755
+ return {
756
+ check: "values-block",
757
+ status: "pass",
758
+ detail: `${result.source.filename}: no approval-values block; the operator has declared no values here. That is a declaration rather than a gap, and \`approval values\` says so in those words.`,
759
+ };
760
+ }
761
+ const declared = ["love", "like", "dislike", "wants"].filter((key) => result.values[key] !== undefined);
762
+ const responds = result.values.responds === undefined ? "" : ", responds";
763
+ return {
764
+ check: "values-block",
765
+ status: "pass",
766
+ detail: `${result.source.filename}: the values block parses and validates (version ${String(result.values.version)}; ${declared.length === 0 ? "no list" : declared.join(", ")}${responds}). It is guidance, so nothing here is enforced; read it with \`approval values\`.`,
767
+ };
768
+ }
769
+ // ---------------------------------------------------------------------------
770
+ // 7c. the prefix proof long-lived readers run (APRV-217)
771
+ // ---------------------------------------------------------------------------
772
+ /**
773
+ * Which prefix proof this policy configures for its long-lived readers.
774
+ *
775
+ * A configuration row, and only that. It reads the POLICY and never a running
776
+ * daemon: the mode a process is actually using is on that process's own
777
+ * `started` line, a daemon may have been launched with a flag that beat the
778
+ * policy, and a doctor that reported a live process's memory would be reporting
779
+ * something it cannot verify. Nothing here is ever a `fail` — both modes are
780
+ * correct, they differ in what a repeat read re-proves and how often — and a
781
+ * policy that declares no `daemon` block skips the row rather than announcing a
782
+ * default nobody wrote.
783
+ */
784
+ function checkReadProof(policyLoad) {
785
+ if (!policyLoad.ok) {
786
+ return {
787
+ check: "read-proof",
788
+ status: "skip",
789
+ detail: `the policy did not load (${policyLoad.code}), so no daemon read proof is configured; every reader proves the whole prefix on every read, which is the strict default. The policy failure itself is reported by \`approval policy check\`.`,
790
+ };
791
+ }
792
+ const configured = policyLoad.daemon;
793
+ if (!configured.declared) {
794
+ return {
795
+ check: "read-proof",
796
+ status: "skip",
797
+ detail: `${policyLoad.source.filename} declares no \`daemon\` block, so long-lived readers re-hash the whole verified prefix on every read (read_proof: full). That is the default and the strictest setting; \`daemon.read_proof: incremental\` trades it for a cadence-bounded proof of the appended bytes.`,
798
+ };
799
+ }
800
+ if (configured.readProof === "full") {
801
+ return {
802
+ check: "read-proof",
803
+ status: "pass",
804
+ detail: `${policyLoad.source.filename} sets daemon.read_proof: full — every cached read re-hashes the whole verified prefix and compares the digest. One-shot processes and \`approval log verify\` do that regardless.`,
805
+ };
806
+ }
807
+ return {
808
+ check: "read-proof",
809
+ status: "pass",
810
+ detail: `${policyLoad.source.filename} sets daemon.read_proof: incremental — a long-lived reader hashes only the appended bytes, re-proving the whole prefix at least every ${String(configured.fullReproofEvery)} read(s) or ${String(configured.fullReproofAfterMs)} ms, whichever comes first, and after every append it makes. The Claude Code hook, \`approval log verify\` and \`approval doctor\` prove in full whatever this says.`,
811
+ };
812
+ }
813
+ // ---------------------------------------------------------------------------
814
+ // 8. audit sampling
815
+ // ---------------------------------------------------------------------------
816
+ /**
817
+ * Surface the sampler's state, because sampling fails open by design.
818
+ *
819
+ * SPEC.md §5.2: an unconfigured sampler is disabled with a machine-readable
820
+ * reason rather than escalating everything (the only remaining seed would be
821
+ * agent-authored event content, which §5.2 forbids). The human ruling that
822
+ * accepted the fail-open pairs it with this check: doctor states the disabled
823
+ * state and its reason prominently, so unconfigured-in-production cannot
824
+ * persist unnoticed.
825
+ *
826
+ * Verdict mapping: a sampler the operator plainly chose not to have (no rate,
827
+ * or rate 0) is `skip`, stated in full. A sampler that is half-configured or
828
+ * unreadable (rate set but the secret unnamed or unset, an invalid rate, an
829
+ * unloadable policy) is `fail` with a fix: someone intended sampling and is not
830
+ * getting it.
831
+ */
832
+ /**
833
+ * The per-class half of the sampling report (amended SPEC.md §5.2, APRV-183).
834
+ *
835
+ * A rate that can differ per class makes "sampling is on" an incomplete answer:
836
+ * an operator needs to know which classes sample, at which rate, and which
837
+ * sample at nothing and why. Appended to the existing `audit-sampling` detail
838
+ * rather than added as a check of its own, because it is the same fact about the
839
+ * same control and a second check would let one of them go stale.
840
+ */
841
+ function classDetail(load, sampler) {
842
+ const entries = classSampling(load, sampler);
843
+ if (entries.length === 0)
844
+ return "";
845
+ const rendered = entries.map((entry) => entry.enabled
846
+ ? `${entry.pattern} ${String(entry.rate)} (${entry.source})`
847
+ : `${entry.pattern} none (${entry.reason ?? "rate-absent"})`);
848
+ return `; supervised classes: ${rendered.join(", ")}`;
849
+ }
850
+ /**
851
+ * The half of the sampling report that this shell cannot answer (APRV-271).
852
+ *
853
+ * `secret-unset` means "the variable the policy names is not in MY
854
+ * environment", and doctor's environment is almost never the one that matters.
855
+ * The secret lives in the single terminal the operator ran `eval "$(approval
856
+ * env)"` in and started the daemon from, and `core/child-env.ts` strips
857
+ * `APPROVAL_*` from every child, so a doctor run from an agent session, a
858
+ * different tab, or a hook could not see it even on a machine where sampling
859
+ * has been running for a fortnight. That is what this row reported as a fault
860
+ * on 2026-09-05, in red, beside a daemon banner confirming the secret in use.
861
+ *
862
+ * So the row asks the daemon, over the APRV-208 socket, and reports the answer
863
+ * WITH ITS SOURCE. Three things bound what that is allowed to do:
864
+ *
865
+ * - **Only this branch.** `secret-unset` is the one disabled reason that is a
866
+ * fact about a process environment. `rate-absent`, `rate-invalid`,
867
+ * `rate-zero`, `secret-env-unnamed` and `policy-unreadable` are facts about
868
+ * the policy FILE, which doctor is reading for itself, and no daemon's answer
869
+ * may soften one of those.
870
+ * - **Only an owner-only socket.** `askDaemonSampling` refuses a socket that is
871
+ * not owned by this euid or is reachable by group or other, and refuses an
872
+ * answer naming a pid that is gone.
873
+ * - **Nothing is authorized either way.** The answer moves a diagnostic row and
874
+ * the exit code of a verb that appends nothing, sends nothing and repairs
875
+ * nothing. It reaches no gate, no budget and no log, so SPEC.md §11.1's rule
876
+ * that self-reported fields never reduce scrutiny is not in play: there is no
877
+ * scrutiny here to reduce, only a report to get right.
878
+ */
879
+ async function samplingFromDaemon(logPath, sampler) {
880
+ if (sampler.enabled || sampler.reason !== "secret-unset")
881
+ return null;
882
+ const probe = await askDaemonSampling(logPath);
883
+ const variable = sampler.secretEnv ?? "the sampling secret's variable";
884
+ if (!probe.ok) {
885
+ return {
886
+ check: "audit-sampling",
887
+ status: "fail",
888
+ detail: oneLine(`disabled (${sampler.reason}): ${sampler.message} No daemon answered on ${probe.socket} (${probe.reason}), so this is what THIS shell can see and the daemon's shell is what decides: a daemon started where ${variable} resolves is sampling whatever this row says.`),
889
+ fix: `approval setup sampling — or set it yourself: export ${variable} with the operator-held sampling secret in the environment that runs the daemon`,
890
+ };
891
+ }
892
+ const report = probe.answer.sampling;
893
+ const where = `the running daemon (pid ${String(probe.answer.daemon_pid)}, ${probe.socket})`;
894
+ if (!report.enabled) {
895
+ return {
896
+ check: "audit-sampling",
897
+ status: "fail",
898
+ detail: oneLine(`disabled (${report.reason ?? "unstated"}) per ${where}, which is the process that decides: ${variable} is unset in this shell too, and the daemon reports its own sampler off. Nothing is sampled.`),
899
+ fix: `approval setup sampling — or set it yourself: export ${variable} with the operator-held sampling secret in the environment that runs the daemon, then restart it`,
900
+ };
901
+ }
902
+ const rate = report.rate === null
903
+ ? "no global fallback rate: only classes declaring their own retro_rate are sampled"
904
+ : `fallback rate ${String(report.rate)} from audit.supervised_sample_rate`;
905
+ return {
906
+ check: "audit-sampling",
907
+ status: "pass",
908
+ detail: oneLine(`enabled per ${where}; ${variable} is not exported in THIS shell, and the daemon's is the environment that decides. The value itself is never printed and never logged; ${rate}.`),
909
+ };
910
+ }
911
+ /**
912
+ * The per-class half of the report, said in DECLARED terms (APRV-271).
913
+ *
914
+ * {@link classDetail} renders what the LOCAL sampler puts in force, which on
915
+ * this branch is nothing: the local reading is `secret-unset`, so every class
916
+ * would come out as "none (secret-unset)" beside a sentence saying sampling is
917
+ * enabled. The entries still carry each rule's own declared `retro_rate`, which
918
+ * is a fact about the policy file and true whoever is holding the secret, so
919
+ * they are rendered as declarations and a rule with no rate of its own is named
920
+ * as taking the daemon's fallback.
921
+ */
922
+ function declaredClassDetail(load, sampler) {
923
+ const entries = classSampling(load, sampler);
924
+ if (entries.length === 0)
925
+ return "";
926
+ const rendered = entries.map((entry) => entry.rate === null
927
+ ? `${entry.pattern} at the daemon's fallback rate`
928
+ : `${entry.pattern} ${String(entry.rate)} (class)`);
929
+ return `; supervised classes, as the policy declares them: ${rendered.join(", ")}`;
930
+ }
931
+ function checkSamplingLocally(load) {
932
+ const sampler = resolveSampler(load);
933
+ if (sampler.enabled) {
934
+ const fallback = sampler.rate === null
935
+ ? `no global fallback rate (${sampler.fallbackReason ?? "rate-absent"}): only classes declaring their own retro_rate are sampled`
936
+ : `fallback rate ${String(sampler.rate)} from audit.supervised_sample_rate`;
937
+ return {
938
+ check: "audit-sampling",
939
+ status: "pass",
940
+ detail: `enabled at rate ${String(sampler.rate)}; secret read from $${sampler.secretEnv} (the value itself is never printed and never logged); ${fallback}${classDetail(load, sampler)}`,
941
+ };
942
+ }
943
+ const deliberate = sampler.reason === "rate-absent" || sampler.reason === "rate-zero";
944
+ if (deliberate) {
945
+ return {
946
+ check: "audit-sampling",
947
+ status: "skip",
948
+ detail: `disabled (${sampler.reason}): ${sampler.message}${classDetail(load, sampler)}`,
949
+ };
950
+ }
951
+ return {
952
+ check: "audit-sampling",
953
+ status: "fail",
954
+ detail: `disabled (${sampler.reason}): ${sampler.message}${classDetail(load, sampler)}`,
955
+ fix: sampler.reason === "secret-unset" && sampler.secretEnv !== null
956
+ ? `approval setup sampling — or set it yourself: export ${sampler.secretEnv} with the operator-held sampling secret in the environment that runs the daemon`
957
+ : "approval policy attest --as human:<id> — after setting audit.supervised_sample_rate and audit.sampling_secret_env in the policy; then export the named variable where the daemon runs",
958
+ };
959
+ }
960
+ /**
961
+ * The row, from this shell first and from the daemon only where this shell
962
+ * cannot be right (APRV-271).
963
+ *
964
+ * The local reading is computed regardless, so a daemon that answers nothing
965
+ * leaves the row saying exactly what it said before, plus who could have
966
+ * answered. There is no path on which the probe makes the report weaker.
967
+ */
968
+ async function checkSampling(load, logPath) {
969
+ const sampler = resolveSampler(load);
970
+ const delegated = await samplingFromDaemon(logPath, sampler);
971
+ if (delegated === null)
972
+ return checkSamplingLocally(load);
973
+ // The per-class breakdown is a fact about the policy file, so it belongs on
974
+ // the row whichever process answered the environment half. It is said in
975
+ // declared terms only where the daemon says sampling is on: everywhere else
976
+ // the local reading IS what is in force, and the existing wording holds.
977
+ const classes = delegated.status === "pass"
978
+ ? declaredClassDetail(load, sampler)
979
+ : classDetail(load, sampler);
980
+ return { ...delegated, detail: `${delegated.detail}${classes}` };
981
+ }
982
+ // ---------------------------------------------------------------------------
983
+ // 12. reconciliation obligations (amended SPEC.md §5.2 — APRV-127)
984
+ // ---------------------------------------------------------------------------
985
+ /**
986
+ * Is any retrospective denial still unreconciled?
987
+ *
988
+ * A denial cannot undo the action it denies. What it does is open an obligation,
989
+ * and the obligation is worth nothing unless somebody is told about it — so
990
+ * doctor FAILS while one is open, in the same voice it uses for a half-configured
991
+ * sampler. This is the "loud" half of the design: a human said an action should
992
+ * not have happened, and until a person records what was done about it, the
993
+ * system has not responded to that at all.
994
+ *
995
+ * **It repairs nothing.** Satisfaction is human-only, in code and in the event
996
+ * schema; a doctor that could close an obligation would be the runtime closing
997
+ * its own homework. The `fix` is the command a person runs after they have
998
+ * actually done the thing.
999
+ */
1000
+ function checkReconciliation(records) {
1001
+ const open = openObligations(records);
1002
+ if (open.length === 0) {
1003
+ return {
1004
+ check: "reconciliation",
1005
+ status: "pass",
1006
+ detail: "no retrospective denial is waiting to be reconciled",
1007
+ };
1008
+ }
1009
+ const first = open[0];
1010
+ return {
1011
+ check: "reconciliation",
1012
+ status: "fail",
1013
+ detail: `${String(open.length)} unreconciled retrospective denial(s): ${open
1014
+ .map((item) => `seq ${String(item.seq)} ${item.actionKey} (${item.obligation})`)
1015
+ .join(", ")}. A denial cannot undo what already ran, so what it leaves is this obligation, and it stays open until a PERSON records what was done.`,
1016
+ fix: `approval audit obligations — then, once you have done it: approval audit reconcile ${String(first.seq)} --note "<what you did>"${first.obligation === "gated-revert" ? " --revert <action-key>" : ""}`,
1017
+ };
1018
+ }
1019
+ // ---------------------------------------------------------------------------
1020
+ // 9. envelope integrity
1021
+ // ---------------------------------------------------------------------------
1022
+ /**
1023
+ * Which task files have lost the envelope the log says they had? (APRV-63)
1024
+ *
1025
+ * The failure this reports was observed live in APRV-60: a task-file rewrite by
1026
+ * a tool that did not know the `approval:` key dropped it. Nothing was corrupt,
1027
+ * nothing refused, and the loss was invisible until someone looked — which is
1028
+ * precisely the shape of question doctor exists to answer out loud.
1029
+ *
1030
+ * Log-derived in both directions. A file is only interesting when the *log*
1031
+ * holds a `task.registered` for its id; the file's own claims are read for one
1032
+ * thing, whether an `approval:` key is present, and trusted for nothing else. A
1033
+ * file with no frontmatter at all leaves no id, so its Backlog.md file name is
1034
+ * matched case-insensitively against registered ids — a way of asking the log a
1035
+ * question, never a way of deciding the answer.
1036
+ *
1037
+ * **It repairs nothing**, in the strong sense doctor means it: the registration
1038
+ * in the log holds every action the envelope declared, so a writer could re-emit
1039
+ * one, and doing so would make a projection into a source. The fix is a human
1040
+ * restoring the block by hand.
1041
+ */
1042
+ function checkEnvelopeIntegrity(tasksDir, records) {
1043
+ let entries;
1044
+ try {
1045
+ entries = readdirSync(tasksDir, { withFileTypes: true });
1046
+ }
1047
+ catch (cause) {
1048
+ if (cause.code === "ENOENT") {
1049
+ return {
1050
+ check: "envelope-integrity",
1051
+ status: "skip",
1052
+ detail: `no task folder at ${tasksDir}, so no task file can be compared against the log (pass --tasks <dir> if your task files live elsewhere)`,
1053
+ };
1054
+ }
1055
+ return {
1056
+ check: "envelope-integrity",
1057
+ status: "fail",
1058
+ detail: `${tasksDir} could not be listed: ${detailOf(cause)}; whether any task lost its envelope is unknown`,
1059
+ fix: `chmod u+rx ${tasksDir} — make it readable by the user running approval, or point --tasks at the task folder`,
1060
+ };
1061
+ }
1062
+ const files = entries
1063
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
1064
+ .map((entry) => entry.name)
1065
+ .sort();
1066
+ const lost = [];
1067
+ for (const name of files) {
1068
+ const read = readTaskFile(join(tasksDir, name));
1069
+ // Unreadable or unparseable frontmatter is the daemon's warning to raise,
1070
+ // not this check's verdict: the question here is only "is the envelope
1071
+ // gone", and a file nobody can parse has not answered it.
1072
+ if (read.ok && read.data["approval"] !== undefined)
1073
+ continue;
1074
+ if (!read.ok && read.code !== "no-frontmatter")
1075
+ continue;
1076
+ const declared = read.ok ? read.data["id"] : undefined;
1077
+ const hasId = typeof declared === "string" && declared.length > 0;
1078
+ const id = hasId ? declared : taskIdFromFileName(name);
1079
+ if (id === null)
1080
+ continue;
1081
+ const registration = latestRegistration(records, id, !hasId);
1082
+ if (registration === null)
1083
+ continue;
1084
+ lost.push(`${String(registration.task)} (${name}, registered at seq ${String(registration.seq)})`);
1085
+ }
1086
+ if (lost.length === 0) {
1087
+ return {
1088
+ check: "envelope-integrity",
1089
+ status: "pass",
1090
+ detail: `${String(files.length)} task file(s) in ${tasksDir}; every task the log has registered still carries its approval: envelope`,
1091
+ };
1092
+ }
1093
+ return {
1094
+ check: "envelope-integrity",
1095
+ status: "fail",
1096
+ detail: `${String(lost.length)} task(s) have log history and no envelope in their file: ${lost.join("; ")}. The log still holds every action they declared; the file does not.`,
1097
+ fix: "approval log tail — it shows the actions each registration declared. The envelope was removed by an external rewrite; restore it from the log by hand — see docs/dogfood-cutover.md (\"If an envelope goes missing\") and the APRV-60 record. Nothing here rewrites a task file: re-emitting the envelope from the log would turn a projection into a source.",
1098
+ };
1099
+ }
1100
+ // ---------------------------------------------------------------------------
1101
+ // 10. vault
1102
+ // ---------------------------------------------------------------------------
1103
+ /** The exact line doctor tells an operator to add for the vault. */
1104
+ const VAULT_IGNORE_LINE = ".approval/vault.enc";
1105
+ /** The same, for the environment source map (APRV-75). */
1106
+ const ENV_IGNORE_LINE = ".approval/env";
1107
+ /**
1108
+ * Patterns in a `.gitignore` that cover one path under `.approval/`.
1109
+ *
1110
+ * A deliberately small, literal set rather than a gitignore engine. The two
1111
+ * error directions are not symmetric: a false PASS says a file holding
1112
+ * credentials is safe from a commit when it is not, and a false FAIL costs an
1113
+ * operator one glance at a fix line they can ignore. So only forms whose
1114
+ * meaning is unambiguous are accepted, and anything cleverer (a negation, a
1115
+ * nested `.gitignore`, a `core.excludesFile`) reads as "not covered here".
1116
+ *
1117
+ * Generalised from the vault's fixed list (APRV-68) when the env file arrived
1118
+ * (APRV-75), because the two questions are the same question: the bare basename
1119
+ * is included in both cases because a `.gitignore` pattern with no slash matches
1120
+ * at every level, so a line `vault.enc` — or `env` — does cover the file.
1121
+ *
1122
+ * `kind` was added for the sealed-token key store (APRV-285), which is a
1123
+ * DIRECTORY rather than a file. The trailing-slash forms are accepted only for
1124
+ * that kind, and deliberately: `keys/` covers everything under `.approval/keys`,
1125
+ * while a line `env/` would match a directory and not the file `.approval/env`,
1126
+ * so accepting it there would be exactly the false PASS this list is shaped to
1127
+ * avoid.
1128
+ */
1129
+ function ignorePatternsFor(relative, kind = "file") {
1130
+ const base = relative.slice(relative.lastIndexOf("/") + 1);
1131
+ return [
1132
+ relative,
1133
+ `/${relative}`,
1134
+ ".approval/",
1135
+ "/.approval/",
1136
+ ".approval",
1137
+ "/.approval",
1138
+ ".approval/*",
1139
+ "/.approval/*",
1140
+ base,
1141
+ ...(relative.endsWith(".enc") ? ["*.enc"] : []),
1142
+ ...(kind === "dir" ? [`${relative}/`, `/${relative}/`, `${base}/`] : []),
1143
+ ];
1144
+ }
1145
+ /**
1146
+ * Is `relative` covered by the project's `.gitignore`?
1147
+ *
1148
+ * `not-a-repo` when `dir` holds no `.git` entry (a directory in a normal clone,
1149
+ * a file in a worktree or submodule): outside a repository there is nothing to
1150
+ * accidentally commit the file to, and failing a check about a risk that does
1151
+ * not exist trains people to ignore the check.
1152
+ */
1153
+ function ignoreVerdict(dir, relative, kind = "file") {
1154
+ try {
1155
+ statSync(join(dir, ".git"));
1156
+ }
1157
+ catch {
1158
+ return "not-a-repo";
1159
+ }
1160
+ let text;
1161
+ try {
1162
+ text = readFileSync(join(dir, ".gitignore"), "utf8");
1163
+ }
1164
+ catch {
1165
+ return "not-ignored";
1166
+ }
1167
+ const patterns = ignorePatternsFor(relative, kind);
1168
+ for (const raw of text.split(/\r\n|\n|\r/u)) {
1169
+ const line = raw.trim();
1170
+ if (line.length === 0 || line.startsWith("#"))
1171
+ continue;
1172
+ if (patterns.includes(line))
1173
+ return "ignored";
1174
+ }
1175
+ return "not-ignored";
1176
+ }
1177
+ /**
1178
+ * Can the credential vault be opened, and is it kept out of the repository?
1179
+ *
1180
+ * Three verdicts, in an order chosen for what stays wrong the longest:
1181
+ *
1182
+ * 1. **Not gitignored** is reported FIRST, ahead of any passphrase problem. A
1183
+ * vault that is one `git add -A` from being published is the worse fault, it
1184
+ * is silent, and it remains true after every other problem here is fixed. An
1185
+ * encrypted file in a public repository is not a catastrophe, but it is a
1186
+ * permanent offline-attack target against one human-chosen passphrase, and
1187
+ * history is not something a later commit removes.
1188
+ * 2. **No passphrase, or it does not decrypt.** Both fail: the credentials are
1189
+ * unreachable, so every adapter that needs one refuses at execution time,
1190
+ * and that refusal reads as "the adapter is broken" rather than "this
1191
+ * machine cannot open the vault". The fix names the variable.
1192
+ * 3. **Absent vault** is a SKIP with the consequence stated. Nobody has created
1193
+ * one, which is a legitimate configuration — the same reading the Telegram
1194
+ * check gives an unconfigured channel.
1195
+ *
1196
+ * The detail names the credential COUNT and never a name, never a value, and
1197
+ * the passphrase is read but never printed (SPEC.md §11.1 invariant 3).
1198
+ */
1199
+ function checkVaultHealth(logPath, dir, load) {
1200
+ const vaultPath = vaultPathFor(logPath);
1201
+ const passphraseEnv = passphraseEnvFor(load);
1202
+ if (!vaultExists(vaultPath)) {
1203
+ return {
1204
+ check: "vault",
1205
+ status: "skip",
1206
+ detail: `no credential vault at ${vaultPath}; adapters that need a credential will refuse credential-unavailable until one exists, which is a legitimate configuration for a runtime driven by \`approval run\` and the CLI channel. The passphrase would be read from $${passphraseEnv} (\`approval vault set <name>\` creates the file)`,
1207
+ };
1208
+ }
1209
+ const ignored = ignoreVerdict(dir, VAULT_IGNORE_LINE);
1210
+ if (ignored === "not-ignored") {
1211
+ return {
1212
+ check: "vault",
1213
+ status: "fail",
1214
+ detail: `${vaultPath} exists and is NOT gitignored in ${dir}: one \`git add -A\` publishes an encrypted credential file, and a commit is not something a later commit removes. The contents stay encrypted, but a published vault is a permanent offline-attack target against one human-chosen passphrase`,
1215
+ fix: `echo '${VAULT_IGNORE_LINE}' >> ${join(dir, ".gitignore")} — and if the file has already been committed, treat every credential in it as disclosed and rotate`,
1216
+ };
1217
+ }
1218
+ const passphrase = passphraseFrom(passphraseEnv);
1219
+ if (passphrase === null) {
1220
+ return {
1221
+ check: "vault",
1222
+ status: "fail",
1223
+ detail: `${vaultPath} exists and $${passphraseEnv} is unset or empty in this process, so no credential can be read: every adapter that needs one will refuse credential-unavailable, which reads like a broken adapter rather than an unopened vault`,
1224
+ fix: `approval setup vault — or set it yourself: export ${passphraseEnv} with the vault passphrase in the environment that runs the adapters (the policy names the variable, never the value; there is no --passphrase flag)`,
1225
+ };
1226
+ }
1227
+ const opened = checkVault(vaultPath, passphrase);
1228
+ if (!opened.ok) {
1229
+ return {
1230
+ check: "vault",
1231
+ status: "fail",
1232
+ detail: `${vaultPath} did not open (${opened.code}): ${opened.message}`,
1233
+ fix: opened.code === "vault-unreadable"
1234
+ ? `approval env --check — confirm value-free where $${passphraseEnv} is coming from, and that it is the passphrase this vault was created with; then check the file's provenance. A wrong passphrase and an altered file are ONE verdict on purpose, because distinguishing them would confirm a guessed passphrase against a file someone had modified`
1235
+ : `mv ${vaultPath} ${vaultPath}.unreadable — set it aside and inspect it by hand (do NOT delete it: it may be the only copy of a credential). It is not a vault this build can read, and nothing here rewrites it`,
1236
+ };
1237
+ }
1238
+ return {
1239
+ check: "vault",
1240
+ status: "pass",
1241
+ detail: `${vaultPath} opens with the passphrase in $${passphraseEnv} and holds ${String(opened.count)} credential(s)${ignored === "not-a-repo" ? "; no git repository at " + dir + ", so there is nothing here to commit it to" : ", and it is gitignored"}. No credential name or value is printed by this check`,
1242
+ };
1243
+ }
1244
+ // ---------------------------------------------------------------------------
1245
+ // 11. environment (APRV-75)
1246
+ // ---------------------------------------------------------------------------
1247
+ /**
1248
+ * WHY DOCTOR DOES NOT RESOLVE KEYSTORE SOURCES.
1249
+ *
1250
+ * `resolveEnvironment` is called here exactly as `approval env --check` calls it
1251
+ * — same function, same policy load, same file path — so doctor and env cannot
1252
+ * disagree about what the environment IS. The one difference is this runner,
1253
+ * and it is a difference about what doctor is allowed to DO.
1254
+ *
1255
+ * `security find-generic-password -w` is not a read. On macOS it can raise a GUI
1256
+ * prompt: an item whose ACL does not already trust the calling binary produces
1257
+ * the "wants to access key … in your keychain" dialog, and a locked keychain
1258
+ * produces an unlock dialog. Both BLOCK the process until a human answers, and
1259
+ * both ask a human for a password. `secret-tool lookup` has the same shape
1260
+ * against a locked keyring. Neither is acceptable from a diagnostic: doctor is
1261
+ * the command an operator runs when something is already wrong, frequently over
1262
+ * ssh or from a CI job where no one will ever see the dialog, and a `doctor`
1263
+ * that hangs forever is worse than no doctor. It is also the wrong thing to
1264
+ * TEACH: a command that pops a keychain prompt trains people to click through
1265
+ * keychain prompts.
1266
+ *
1267
+ * So a keystore-backed variable is reported as DECLARED, never resolved, and the
1268
+ * report says which scheme and which service or label — facts `.approval/env`
1269
+ * already carries in the open. `approval env --check`, which the human runs
1270
+ * deliberately and watches, is the command that resolves them.
1271
+ *
1272
+ * This is not a general exception to doctor probing: the Telegram check does
1273
+ * make a network call. The line is that a probe may cost time and packets, and
1274
+ * may not block on a human or ask anyone for a password.
1275
+ *
1276
+ * The runner itself is `core/env-file.ts`'s {@link NON_RESOLVING_RUNNER} since
1277
+ * APRV-178, because `approval up`'s cross-instance report needs the same
1278
+ * refusal and two copies would be two sets of words for one rule.
1279
+ */
1280
+ /** Was this variable left unresolved by {@link NON_RESOLVING_RUNNER}? */
1281
+ function isDeferred(variable) {
1282
+ return (variable.refusal !== undefined &&
1283
+ variable.refusal.code === "helper-failed" &&
1284
+ variable.refusal.message.startsWith(KEYSTORE_DEFERRED));
1285
+ }
1286
+ /**
1287
+ * The `approval setup <thing>` that knows a given variable, or `null`.
1288
+ *
1289
+ * Derived here from the names doctor already resolves rather than read off the
1290
+ * resolution, so that a fix line in this file is a fix line this file can be
1291
+ * read to verify. A name the policy invented under the `_env` convention maps to
1292
+ * nothing, and its repair is the generic one.
1293
+ */
1294
+ function setupThingFor(name, load) {
1295
+ if (name === HUMAN_ACTOR_ENV)
1296
+ return "identity";
1297
+ // Two words, because the verb is two words: SPEC.md §4 gives channels and
1298
+ // adapters separate setup nouns, and a fix line that printed the old
1299
+ // one-word spelling would be a command that exits 2 (APRV-79).
1300
+ if (name === telegramTokenEnvFor(load) || name === telegramChatEnvFor(load)) {
1301
+ return "channel telegram";
1302
+ }
1303
+ if (name === passphraseEnvFor(load))
1304
+ return "vault";
1305
+ if (name === resolveSampler(load).secretEnv)
1306
+ return "sampling";
1307
+ return null;
1308
+ }
1309
+ /**
1310
+ * One variable, in words, with NO VALUE ON ANY PATH.
1311
+ *
1312
+ * `ResolvedVariable.value` is never read by this check — not to length-check it,
1313
+ * not to redact it. The fields consulted are `status`, `source`, `plaintext` and
1314
+ * `refusal`, and `source` is a scheme and a service label, which is what
1315
+ * `.approval/env` carries in the open (SPEC.md §11.1 invariant 3).
1316
+ */
1317
+ function describeVariable(variable) {
1318
+ switch (variable.status) {
1319
+ case "set-in-environment":
1320
+ return "set in the environment";
1321
+ case "resolved-from-keychain":
1322
+ case "resolved-from-secret-service":
1323
+ return `resolved from ${variable.source}`;
1324
+ case "resolved-literal":
1325
+ return variable.plaintext
1326
+ ? `declared in ${ENV_IGNORE_LINE} as a PLAINTEXT literal`
1327
+ : `declared in ${ENV_IGNORE_LINE} as a literal`;
1328
+ case "unset":
1329
+ if (isDeferred(variable)) {
1330
+ return `declared in ${ENV_IGNORE_LINE} as ${variable.source} (${KEYSTORE_DEFERRED}; \`approval env --check\` resolves it)`;
1331
+ }
1332
+ if (variable.refusal !== undefined) {
1333
+ return `unresolved — ${variable.refusal.code}: ${variable.refusal.message}`;
1334
+ }
1335
+ if (variable.source.startsWith("env:")) {
1336
+ return `declared in ${ENV_IGNORE_LINE} as env: (inherited), and not set in this shell`;
1337
+ }
1338
+ return "unset";
1339
+ }
1340
+ }
1341
+ /**
1342
+ * Are the variables the policy NAMES actually going to be there? (APRV-75)
1343
+ *
1344
+ * The gap this closes: every check above reports on ONE variable at the moment
1345
+ * it needs it (identity, the sampling secret, the vault passphrase, the Telegram
1346
+ * pair), each in its own message, and nothing states the environment as a whole
1347
+ * or mentions `.approval/env` — the file SPEC.md §5.2 made the written-down
1348
+ * place for where those values come from. An operator whose file has the wrong
1349
+ * mode learns it one refusal at a time.
1350
+ *
1351
+ * ## The verdict rule, and why unset is a SKIP
1352
+ *
1353
+ * - **PASS** when every policy-named variable is set in this environment,
1354
+ * resolved, or declared against a keystore (see {@link NON_RESOLVING_RUNNER}:
1355
+ * declared-and-deferred counts as configured, because the operator wrote the
1356
+ * line and only `approval env --check` may run the lookup).
1357
+ * - **FAIL** for something that is WRONG: a mode other than 0600, an unreadable
1358
+ * or unparseable file, a secret-bearing variable sitting in the working tree
1359
+ * as a plaintext literal, an env file a `git add -A` would commit, or a
1360
+ * variable whose declared source refused for a real reason (a missing helper
1361
+ * binary, an item that is not there, a policy `_env` key that is not a usable
1362
+ * variable name).
1363
+ * - **SKIP**, naming the variables, when the only thing true is that some are
1364
+ * unset. Unset is a STATE, exactly as an absent vault and an unconfigured
1365
+ * Telegram are states, and each of those is a skip already. It is a real
1366
+ * consideration that doctor runs in THIS shell and an unset variable here
1367
+ * means the verbs run from here will refuse — but doctor is also run from a
1368
+ * shell that never intends to grant anything, and a machine with no Telegram
1369
+ * and no vault would then be permanently "unhealthy" for declining features it
1370
+ * was never asked to have. The state is stated loudly instead, with the verb
1371
+ * that gives the full table. The checks that DO fail on a specific unset
1372
+ * variable are the ones that know it is needed: `identity` fails because
1373
+ * human-only verbs refuse without it, `vault` fails only once a vault exists,
1374
+ * and `audit-sampling` fails only once a rate has been configured.
1375
+ */
1376
+ function checkEnvironment(logPath, dir, load) {
1377
+ const envPath = envFilePathFor(logPath);
1378
+ const resolved = resolveEnvironment(load, envPath, NON_RESOLVING_RUNNER, process.env);
1379
+ if (!resolved.ok) {
1380
+ return {
1381
+ check: "environment",
1382
+ status: "fail",
1383
+ detail: `${resolved.path}: ${resolved.code}: ${oneLine(resolved.message)}`,
1384
+ fix: resolved.code === "env-file-mode"
1385
+ ? `chmod 600 ${resolved.path} — the file may carry a plaintext secret, so it is read only at mode 0600`
1386
+ : `approval env --check — the value-free report on this file; fix the line it names (nothing here rewrites it)`,
1387
+ };
1388
+ }
1389
+ const variables = resolved.variables;
1390
+ const table = variables
1391
+ .map((variable) => `${variable.name} ${describeVariable(variable)}`)
1392
+ .join("; ");
1393
+ const head = resolved.present
1394
+ ? `${resolved.path} (mode 0600, and no verb loads it implicitly: \`eval "$(approval env)"\` is how a human puts these in a shell)`
1395
+ : `${resolved.path} is absent, so every variable below is inherited from this shell or unset`;
1396
+ const preamble = `${head}. ${table}`;
1397
+ // Ordered by what stays wrong the longest, the same reading the vault check
1398
+ // uses: a file one `git add -A` from publication is the fault that survives
1399
+ // fixing everything else here.
1400
+ if (resolved.present && ignoreVerdict(dir, ENV_IGNORE_LINE) === "not-ignored") {
1401
+ return {
1402
+ check: "environment",
1403
+ status: "fail",
1404
+ detail: `${preamble}. The file is NOT gitignored in ${dir}: one \`git add -A\` commits it, and it is the file whose whole purpose is to say where credentials come from — a plaintext literal in it would be published outright, and even a keychain: line publishes the service names`,
1405
+ fix: `echo '${ENV_IGNORE_LINE}' >> ${join(dir, ".gitignore")} — and if the file has already been committed, treat anything literal in it as disclosed and rotate`,
1406
+ };
1407
+ }
1408
+ // A plaintext secret is REPORTED, never failed (APRV-76 review). SPEC §5.2
1409
+ // permits the literal form and `approval setup` itself writes one, behind a
1410
+ // typed "yes", on a machine with no keystore; a verdict that called setup's
1411
+ // own documented fallback wrong would have two verbs disagreeing about the
1412
+ // same line. So the state is a skip: prominent, named, with the upgrade in
1413
+ // the detail, and never a pass with a fix (passing checks carry none).
1414
+ const plaintext = variables.filter((variable) => variable.plaintext);
1415
+ if (plaintext.length > 0) {
1416
+ const thing = setupThingFor(plaintext[0]?.name ?? "", load);
1417
+ const upgrade = thing === null
1418
+ ? `move each one to \`<NAME>=keychain:<service>\` (macOS) or \`<NAME>=secret-service:<label>\` (Linux) in ${resolved.path}`
1419
+ : `run \`approval setup ${thing}\` on a machine with a keystore, or edit ${resolved.path} to \`<NAME>=keychain:<service>\` (macOS) / \`<NAME>=secret-service:<label>\` (Linux)`;
1420
+ return {
1421
+ check: "environment",
1422
+ status: "skip",
1423
+ detail: `${preamble}. ${plaintext.map((variable) => variable.name).join(", ")} ${plaintext.length === 1 ? "is a secret written" : "are secrets written"} literally into ${resolved.path}: permitted, and reported every time because the value sits in the working tree where a backup, an editor swap file or a stray \`git add -f\` reaches it; to stop seeing this, ${upgrade}`,
1424
+ };
1425
+ }
1426
+ const broken = variables.filter((variable) => variable.refusal !== undefined && !isDeferred(variable));
1427
+ const first = broken[0];
1428
+ if (first !== undefined) {
1429
+ const thing = setupThingFor(first.name, load);
1430
+ return {
1431
+ check: "environment",
1432
+ status: "fail",
1433
+ detail: `${preamble}. ${broken.map((variable) => variable.name).join(", ")} ${broken.length === 1 ? "declares a source that did not resolve" : "declare sources that did not resolve"}: a line was written for ${broken.length === 1 ? "it" : "them"}, so this is a configuration that is not working rather than one nobody made`,
1434
+ fix: first.refusal?.code === "invalid-variable-name"
1435
+ ? `approval policy attest --as human:<id> — after fixing the _env key in APPROVAL.md: ${JSON.stringify(first.name)} is not a usable shell variable name, so no export line is ever emitted for it`
1436
+ : thing === null
1437
+ ? `approval env --check — the full value-free report, with the helper's own reason for each variable`
1438
+ : `approval setup ${thing} — re-store the item the file names; \`approval env --check\` shows the helper's own reason`,
1439
+ };
1440
+ }
1441
+ const unset = variables.filter((variable) => variable.status === "unset" && !isDeferred(variable));
1442
+ if (unset.length > 0) {
1443
+ return {
1444
+ check: "environment",
1445
+ status: "skip",
1446
+ detail: `${preamble}. ${unset.map((variable) => variable.name).join(", ")} ${unset.length === 1 ? "is" : "are"} unset in this shell, which is a state and not a fault — but the verbs run from THIS shell will refuse anything that needs ${unset.length === 1 ? "it" : "them"}; run \`approval env --check\` for the full table, and \`eval "$(approval env)"\` once you have written the file`,
1447
+ };
1448
+ }
1449
+ return {
1450
+ check: "environment",
1451
+ status: "pass",
1452
+ detail: `${preamble}. Every variable your policy names is available to the verbs run from this shell. No value is printed by this check on any path`,
1453
+ };
1454
+ }
1455
+ /**
1456
+ * Whose credentials is this instance actually using? (APRV-178)
1457
+ *
1458
+ * The row this check exists to print did not exist on the morning a demo gate
1459
+ * in another directory stored its bot token under the same fixed keystore name
1460
+ * the production gate used, read the production token back, and put two long
1461
+ * pollers on one bot until a human's approval tap was delivered to the listener
1462
+ * that had not asked the question. Nothing on the machine could be asked "are
1463
+ * two instances sharing a credential"; the answer was assembled by hand,
1464
+ * afterwards.
1465
+ *
1466
+ * It resolves nothing (`core/instance.ts` calls the {@link NON_RESOLVING_RUNNER}
1467
+ * for exactly the reason the environment check does), reads no value, and
1468
+ * prints none: a scope suffix, an item name and a variable name are all the
1469
+ * evidence it needs, and all three are already in `.approval/env` in the open.
1470
+ *
1471
+ * ## The verdict rule
1472
+ *
1473
+ * - **FAIL** for an item whose scope suffix belongs to ANOTHER instance. That
1474
+ * is two gates on one credential, and it is wrong rather than a state.
1475
+ * - **SKIP**, named and loud, for the unscoped legacy item and for a value that
1476
+ * came from the ambient environment while the file names something else. Both
1477
+ * are what a correct pre-APRV-178 machine looks like, and the primary gate on
1478
+ * this project's own machine is fed exactly that way on purpose. A red row for
1479
+ * every existing installation is a red row people learn to skip past.
1480
+ * - **PASS** when every line names this instance's own item.
1481
+ */
1482
+ function checkKeychainScope(logPath, load) {
1483
+ const findings = instanceFindings(logPath, load);
1484
+ const id = instanceIdFor(logPath);
1485
+ const head = `${instanceHomeFor(logPath)} is instance ${id}; its keystore items are named \`<secret>-${id}\``;
1486
+ const foreign = findings.filter((finding) => finding.kind === "foreign-instance");
1487
+ if (foreign.length > 0) {
1488
+ return {
1489
+ check: "keychain-scope",
1490
+ status: "fail",
1491
+ detail: `${head}. ${foreign.map((finding) => finding.detail).join("; ")}. Two instances resolving one item share a bot: both long-poll it, their getUpdates offsets acknowledge each other's messages, and an approval tap is answered by whichever listener asked first`,
1492
+ fix: `approval setup channel telegram — store this instance's own token under its own item; \`approval env --check\` shows which name each variable resolves through`,
1493
+ };
1494
+ }
1495
+ const shared = findings.filter((finding) => finding.kind === "legacy-shared");
1496
+ const bleed = findings.filter((finding) => finding.kind === "ambient-bleed");
1497
+ if (shared.length > 0 || bleed.length > 0) {
1498
+ return {
1499
+ check: "keychain-scope",
1500
+ status: "skip",
1501
+ detail: `${head}. ${[...shared, ...bleed].map((finding) => finding.detail).join("; ")}. Neither is broken here and both are how one instance becomes two instances' problem: re-run \`approval setup channel telegram\` to move onto this instance's own item, and \`unset\` an inherited variable before \`eval "$(approval env)"\` if the exported value is another gate's`,
1502
+ };
1503
+ }
1504
+ return {
1505
+ check: "keychain-scope",
1506
+ status: "pass",
1507
+ detail: `${head}, and every source ${envFilePathFor(logPath)} names is this instance's own. No value is read or printed by this check on any path`,
1508
+ };
1509
+ }
1510
+ // ---------------------------------------------------------------------------
1511
+ // 12. log-drift (APRV-125)
1512
+ // ---------------------------------------------------------------------------
1513
+ /**
1514
+ * How the working log stands against the committed one.
1515
+ *
1516
+ * This is the doctor mitigation named in APRV-104's fork-2 notes: the fork that
1517
+ * incident produced was invisible until something tried to append onto it, and
1518
+ * the instrument a person reaches for first is `approval doctor`.
1519
+ *
1520
+ * Since APRV-219 the row IS `approval log verify --anchor`'s check
1521
+ * (`cli/log-anchor.ts`), rather than a second comparison written beside it. Two
1522
+ * implementations were two chances to disagree about whether a repository has
1523
+ * forked, and that is the one question where disagreement is intolerable — and
1524
+ * the disagreement duly arrived: APRV-210 recorded this row printing "this log
1525
+ * has never been committed" in a checkout where `git show HEAD:<log>` printed
1526
+ * the log, because it built its blob spec from an unresolved path. The anchor
1527
+ * check resolves that path through `git-scope.repoPath`, realpath on both
1528
+ * sides, and looks at every rev a committed copy may live at rather than only
1529
+ * `HEAD`.
1530
+ *
1531
+ * Reads only. It never fetches, never pulls and never writes: the committed
1532
+ * side comes out of git's object store with `git show`.
1533
+ */
1534
+ function checkLogDrift(logPath, records) {
1535
+ const outcome = checkLogAnchor({ logPath, records });
1536
+ switch (outcome.status) {
1537
+ // A SKIP carries no `fix` — the rule every non-git fixture in
1538
+ // `tests/cli-doctor.test.ts` pins. A check that could not look has nothing
1539
+ // to prescribe, so what a reader might still want to run is said in the
1540
+ // detail. A pass that owes records keeps its `fix`, as this row always has.
1541
+ // The reason is carried through whole, `oneLine`d rather than trimmed. When
1542
+ // no rev resolved it now names the git command each candidate ran and what
1543
+ // git answered, and that is the half of the sentence a person acts on: the
1544
+ // row that misread a twelve-megabyte committed log said only which revs it
1545
+ // had tried, which is equally true of a repository that has genuinely never
1546
+ // committed one.
1547
+ case "skip":
1548
+ return {
1549
+ check: "log-drift",
1550
+ status: "skip",
1551
+ detail: oneLine(`${outcome.reason}. \`approval log advance --dry-run\` shows what a first advance would carry`),
1552
+ };
1553
+ case "pass":
1554
+ return {
1555
+ check: "log-drift",
1556
+ status: "pass",
1557
+ detail: outcome.ahead === 0
1558
+ ? outcome.detail
1559
+ : `${outcome.detail} — the ordinary state of a checkout that has been recording decisions`,
1560
+ ...(outcome.ahead === 0
1561
+ ? {}
1562
+ : { fix: "approval log advance — commit those records onto a records branch" }),
1563
+ };
1564
+ case "behind":
1565
+ return {
1566
+ check: "log-drift",
1567
+ status: "pass",
1568
+ detail: `${outcome.detail} — the committed copy carries records this working file does not`,
1569
+ fix: "approval log sync — fast-forward, then reconcile the chain",
1570
+ };
1571
+ case "diverged":
1572
+ return {
1573
+ check: "log-drift",
1574
+ status: "fail",
1575
+ detail: `${oneLine(outcome.message)} Hash chains do not merge and nothing in this runtime will re-chain them: which of these is the log is a human decision`,
1576
+ fix: "approval log verify --anchor — then `git log -- .approval/log/events.jsonl` for who committed the other chain",
1577
+ };
1578
+ }
1579
+ }
1580
+ // ---------------------------------------------------------------------------
1581
+ // 25. checkpoint (APRV-257)
1582
+ // ---------------------------------------------------------------------------
1583
+ /**
1584
+ * The second witness, as a row: how many checkpoints verify, how old the newest
1585
+ * one is against the cadence, and how many keys the policy declares.
1586
+ *
1587
+ * The row IS `core/checkpoint.ts`'s check, exactly as `log-drift` IS the anchor
1588
+ * check — the argument APRV-219 made and APRV-210 proved the hard way. Two
1589
+ * implementations of "does this log's own signature contradict it" would be two
1590
+ * chances to disagree about the one question where disagreement is intolerable.
1591
+ *
1592
+ * Three verdicts and no fourth:
1593
+ *
1594
+ * - **skip** when the policy declares no readable key. Nothing was verified,
1595
+ * and a check that could not look must never report a pass. A skip carries no
1596
+ * `fix` — the rule every non-git fixture in `tests/cli-doctor.test.ts` pins —
1597
+ * so what to run is said in the detail.
1598
+ * - **fail** on any refusal. A checkpoint whose signature does not verify, or
1599
+ * whose named hash is not the hash at that seq, is a human's key vouching for
1600
+ * a chain this file does not carry. That is the finding this whole mechanism
1601
+ * exists to produce, and doctor exits 1 on it.
1602
+ * - **pass** otherwise, INCLUDING when a checkpoint is due. The cadence carries
1603
+ * a `fix` rather than a status: a person who has not signed recently is not
1604
+ * evidence of tampering, and a doctor that went red because somebody was on
1605
+ * holiday is a doctor whose red people stop reading.
1606
+ */
1607
+ function checkCheckpoints(records, policy) {
1608
+ const configured = checkpointPolicyOf(policy);
1609
+ const outcome = checkLogCheckpoints({
1610
+ records,
1611
+ publicKeys: configured.publicKeys,
1612
+ checkpointEveryMs: configured.checkpointEveryMs,
1613
+ keysUnavailable: configured.unloadable,
1614
+ });
1615
+ if (outcome.status === "skip") {
1616
+ return {
1617
+ check: "checkpoint",
1618
+ status: "skip",
1619
+ detail: `${outcome.reason}. \`approval setup checkpoint\` mints a key and prints the audit.checkpoint_keys block to add`,
1620
+ };
1621
+ }
1622
+ if (outcome.status === "refused") {
1623
+ return {
1624
+ check: "checkpoint",
1625
+ status: "fail",
1626
+ detail: `${oneLine(outcome.message)} A key no agent process holds signed a head this chain does not carry: the chain was rewritten after the checkpoint was taken`,
1627
+ fix: "approval log verify --checkpoints — then `git log -- .approval/log/events.jsonl` for who wrote the other chain",
1628
+ };
1629
+ }
1630
+ const newest = outcome.checkpoints[outcome.checkpoints.length - 1];
1631
+ const detail = `${outcome.detail}, ${String(configured.publicKeys.length)} key(s) declared` +
1632
+ (newest === undefined ? "" : ` (newest at seq ${String(newest.at)}, ${newest.ts})`) +
1633
+ (outcome.unchecked === 0
1634
+ ? ""
1635
+ : `; ${String(outcome.unchecked)} signed a seq below this range`);
1636
+ return {
1637
+ check: "checkpoint",
1638
+ status: "pass",
1639
+ detail: outcome.warning === null ? detail : `${detail} — ${oneLine(outcome.warning)}`,
1640
+ ...(outcome.warning === null
1641
+ ? {}
1642
+ : {
1643
+ fix: "approval log checkpoint --as human:<id> — or answer the CHECKPOINT DUE prompt on your channel",
1644
+ }),
1645
+ };
1646
+ }
1647
+ // ---------------------------------------------------------------------------
1648
+ // 13. log-advance-cadence (APRV-204)
1649
+ // ---------------------------------------------------------------------------
1650
+ /**
1651
+ * How far the log has run ahead of any records branch, and how the daemon's
1652
+ * last advance ended.
1653
+ *
1654
+ * This is the status surface the cadence needed and `approval daemon` did not
1655
+ * have. There is no `approval daemon status` subcommand and no status file: the
1656
+ * daemon reports live on its own event stream, which is gone the moment nobody
1657
+ * is tailing it, and a status file would be a second copy of facts the log
1658
+ * already carries. So the answer is read from the log itself (the advance
1659
+ * cycles the daemon registers under `daemon-advance-*`) plus git's local refs,
1660
+ * which is why it can be answered by a DIFFERENT process from the one that made
1661
+ * the attempt, and why an operator gets the same answer whether or not a daemon
1662
+ * is running at all.
1663
+ *
1664
+ * Reads only, and never fetches: the same rule `log-drift` holds itself to.
1665
+ * Advisory rather than failing — records waiting to be published is the normal
1666
+ * state of a checkout that has been recording decisions, and only the reader
1667
+ * knows how long is too long.
1668
+ */
1669
+ function checkAdvanceCadence(logPath, records) {
1670
+ const check = "log-advance-cadence";
1671
+ const root = repoRoot(dirname(logPath));
1672
+ if (root === null) {
1673
+ return {
1674
+ check,
1675
+ status: "skip",
1676
+ detail: `${logPath} is not inside a git repository, so there is no records branch for anything to be waiting for`,
1677
+ };
1678
+ }
1679
+ const today = new Date().toISOString();
1680
+ const state = publishedState(root, logPath, records, { remote: "origin", base: null }, today);
1681
+ const last = lastAdvance(records);
1682
+ // The reason, when the log carries one (APRV-211). A failed advance used to
1683
+ // reach this row as the bare word `failed`, which told an operator that
1684
+ // something had gone wrong and nothing about what: the daemon knew, said it
1685
+ // once on an event stream nobody was tailing, and recorded `exit_code: 1`.
1686
+ // The verb's own code and message now travel onto `execution.failed`, so this
1687
+ // row says them. A cycle recorded before the field existed still reads `null`
1688
+ // and still prints the bare outcome; the shape is not assumed away.
1689
+ const why = last === null || last.code === null
1690
+ ? ""
1691
+ : ` (${last.code}${last.message === null ? "" : `: ${last.message}`})`;
1692
+ const attempt = last === null
1693
+ ? "no daemon advance cycle is in this log yet (the cadence is opt-in: `approval daemon run --advance`)"
1694
+ : `the last daemon advance (through seq ${String(last.toSeq)}, ${last.ts}) ended ${last.outcome}${why}`;
1695
+ // Which ref the count came from (APRV-210). A row that says "9,875 records
1696
+ // are not yet on a records branch" is unreadable without it: a rev that
1697
+ // resolved to nothing and a rev that carried nothing produce the same number
1698
+ // and are completely different facts, and this row reported the first as the
1699
+ // second on a log whose first 8,379 records had been merged to the trunk an
1700
+ // hour earlier.
1701
+ const from = state.publishedRev === null
1702
+ ? `no rev this checkout can see carries a copy of this chain (tried ${state.revs.join(", ")})`
1703
+ : `read from ${state.publishedRev}`;
1704
+ // APRV-264. The advance cycles nobody closed, and what this checkout can
1705
+ // prove about each. They belong on THIS row rather than only in `status`'s
1706
+ // dangling list, because their effect is on the cadence: while one stands the
1707
+ // daemon authorizes no further advance, so a row reporting how far behind the
1708
+ // records branch is without saying that the thing that publishes it is
1709
+ // blocked reports the symptom and hides the cause. Provable ones are named as
1710
+ // the daemon's to close on its next tick; the rest are a person's, with the
1711
+ // one command that takes them all.
1712
+ const open = proveDanglingAdvances(records, state);
1713
+ const outstanding = open.filter((entry) => entry.provenBy === null);
1714
+ const provable = open.filter((entry) => entry.provenBy !== null);
1715
+ const blocked = open.length === 0
1716
+ ? ""
1717
+ : ` ${String(open.length)} advance execution(s) are open and no further advance is authorized while they stand: ${open
1718
+ .map((entry) => `${entry.actionKey} (${entry.provenBy === null
1719
+ ? "nothing in this checkout carries the seq it named"
1720
+ : `proved by ${entry.provenBy}`})`)
1721
+ .join(", ")}.${provable.length === 0
1722
+ ? ""
1723
+ : ` A running daemon closes ${String(provable.length)} of them on its next tick.`}`;
1724
+ const sweepFix = outstanding.length === 0
1725
+ ? null
1726
+ : `${RESOLVE_DANGLING_COMMAND} — close the advance executions this checkout can prove and list the ${String(outstanding.length)} it cannot`;
1727
+ if (state.pending === 0) {
1728
+ return {
1729
+ check,
1730
+ status: "pass",
1731
+ detail: `every record through seq ${String(state.publishedSeq)} is on a records branch or the trunk (${from}); ${attempt}${blocked}`,
1732
+ ...(sweepFix === null ? {} : { fix: sweepFix }),
1733
+ };
1734
+ }
1735
+ return {
1736
+ check,
1737
+ status: "pass",
1738
+ detail: `${String(state.pending)} record(s) are not yet on a records branch (${String(state.substantive)} of them are not the daemon's own advance bookkeeping); published through seq ${String(state.publishedSeq)} (${from}), working head seq ${String(state.workingSeq)}. ${attempt}${blocked}`,
1739
+ fix: sweepFix ??
1740
+ "approval log advance --pr — publish them now, or run the daemon with --advance",
1741
+ };
1742
+ }
1743
+ // ---------------------------------------------------------------------------
1744
+ // harness hook outcome reporting (APRV-145)
1745
+ // ---------------------------------------------------------------------------
1746
+ /** Where Claude Code keeps the hook registration a human commits. */
1747
+ const CLAUDE_SETTINGS = join(".claude", "settings.json");
1748
+ /** Does any `hooks.<event>` entry run this CLI's harness hook? */
1749
+ function registersApprovalHook(hooks, event) {
1750
+ if (typeof hooks !== "object" || hooks === null || Array.isArray(hooks))
1751
+ return false;
1752
+ const matchers = hooks[event];
1753
+ if (!Array.isArray(matchers))
1754
+ return false;
1755
+ for (const matcher of matchers) {
1756
+ if (typeof matcher !== "object" || matcher === null)
1757
+ continue;
1758
+ const entries = matcher["hooks"];
1759
+ if (!Array.isArray(entries))
1760
+ continue;
1761
+ for (const entry of entries) {
1762
+ if (typeof entry !== "object" || entry === null)
1763
+ continue;
1764
+ const command = entry["command"];
1765
+ if (typeof command === "string" && /\bapproval hook\b/u.test(command))
1766
+ return true;
1767
+ }
1768
+ }
1769
+ return false;
1770
+ }
1771
+ /**
1772
+ * Is the harness registered for the event that reports outcomes (APRV-145)?
1773
+ *
1774
+ * The configuration this exists to name is the one in which loop escalation
1775
+ * cannot accrue AT ALL: the pre-execution hook registered and the post-execution
1776
+ * one not, so every tool call opens a delegated `execution.started` that nothing
1777
+ * ever closes, the harness streaks of amended SPEC.md §10.2 hold at zero, and
1778
+ * the guard reads as passing because there is nothing for it to see. That is a
1779
+ * silent control, which is worse than an absent one.
1780
+ *
1781
+ * Doctor READS this file and never writes it. `.claude/settings.json` is
1782
+ * `policy.core` in this taxonomy — a file that configures the gate is part of
1783
+ * the gate — so the repair is a line for a human to commit, printed by
1784
+ * `approval instructions hook`.
1785
+ */
1786
+ function checkHarnessOutcomes(dir) {
1787
+ const check = "harness-hook-outcomes";
1788
+ const path = join(dir, CLAUDE_SETTINGS);
1789
+ if (!existsSync(path)) {
1790
+ return {
1791
+ check,
1792
+ status: "skip",
1793
+ detail: `no ${CLAUDE_SETTINGS} in ${dir}: this checkout does not run a Claude Code harness hook`,
1794
+ };
1795
+ }
1796
+ let parsed;
1797
+ try {
1798
+ parsed = JSON.parse(readFileSync(path, "utf8"));
1799
+ }
1800
+ catch (cause) {
1801
+ return {
1802
+ check,
1803
+ status: "skip",
1804
+ detail: `${path} is not readable as JSON (${detailOf(cause)}), so which hooks it registers cannot be established here`,
1805
+ };
1806
+ }
1807
+ const hooks = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
1808
+ ? parsed["hooks"]
1809
+ : null;
1810
+ const pre = registersApprovalHook(hooks, "PreToolUse");
1811
+ const post = registersApprovalHook(hooks, "PostToolUse") ||
1812
+ registersApprovalHook(hooks, "PostToolUseFailure");
1813
+ if (!pre && !post) {
1814
+ return {
1815
+ check,
1816
+ status: "skip",
1817
+ detail: `${path} registers no \`approval hook\` entry, so this checkout is not gated by the harness hook at all`,
1818
+ };
1819
+ }
1820
+ if (!post) {
1821
+ return {
1822
+ check,
1823
+ status: "fail",
1824
+ detail: `${path} registers \`approval hook\` for PreToolUse and not for PostToolUse, so no tool call ever reports an outcome: every harness execution.started stays delegated, and the loop escalation of SPEC.md §10.2 cannot accrue on this path`,
1825
+ fix: "approval hook claude-code --help — prints the PostToolUse entry to add, which a human commits (.claude/settings.json is policy.core)",
1826
+ };
1827
+ }
1828
+ return {
1829
+ check,
1830
+ status: "pass",
1831
+ detail: `${path} registers \`approval hook\` for the ${pre ? "pre-execution and " : ""}post-execution event, so tool call outcomes reach the log and loop escalation can accrue`,
1832
+ };
1833
+ }
1834
+ // ---------------------------------------------------------------------------
1835
+ // harness hook wiring in THIS worktree (APRV-151)
1836
+ // ---------------------------------------------------------------------------
1837
+ /** The tool names a protected-path write can arrive as. */
1838
+ const GATED_TOOLS = ["Edit", "Write", "Bash"];
1839
+ /** The `matcher` strings of every `approval hook` entry registered for `event`. */
1840
+ function approvalHookMatchers(hooks, event) {
1841
+ if (typeof hooks !== "object" || hooks === null || Array.isArray(hooks))
1842
+ return [];
1843
+ const matchers = hooks[event];
1844
+ if (!Array.isArray(matchers))
1845
+ return [];
1846
+ const found = [];
1847
+ for (const matcher of matchers) {
1848
+ if (typeof matcher !== "object" || matcher === null)
1849
+ continue;
1850
+ const entries = matcher["hooks"];
1851
+ if (!Array.isArray(entries))
1852
+ continue;
1853
+ for (const entry of entries) {
1854
+ if (typeof entry !== "object" || entry === null)
1855
+ continue;
1856
+ const command = entry["command"];
1857
+ if (typeof command !== "string" || !/\bapproval hook\b/u.test(command))
1858
+ continue;
1859
+ const pattern = matcher["matcher"];
1860
+ found.push(typeof pattern === "string" ? pattern : "");
1861
+ }
1862
+ }
1863
+ return found;
1864
+ }
1865
+ /**
1866
+ * Does the settings file THIS worktree carries register the pre-execution hook
1867
+ * for the tools a protected-path write arrives through? (APRV-151.)
1868
+ *
1869
+ * The incidents this row exists for are two file-tool Edits to protected paths
1870
+ * that applied in spawned-agent worktrees with no prompt, no denial, and no
1871
+ * refused-request record — the hook never ran, and nothing anywhere said so.
1872
+ * A session cannot be asked whether it is hooked (a party under oversight does
1873
+ * not report its own oversight, SPEC.md §11), so this row reports only the one
1874
+ * thing a process CAN establish about itself from disk: whether the settings
1875
+ * file in this checkout carries the entry at all.
1876
+ *
1877
+ * Read the `pass` wording carefully, because the limit is the point. The entry
1878
+ * being on disk is NOT proof the session loaded it: `.claude/settings.json` is
1879
+ * git-tracked here, so every worktree has an identical copy, and both bypasses
1880
+ * happened in worktrees whose copy was present and correct. What actually
1881
+ * differs between a gated and an ungated session is whether the harness
1882
+ * resolved and trusted this file when the session started, which is state this
1883
+ * runtime cannot see. That is exactly why the deterministic backstop is
1884
+ * CI-side, over the committed log, in `core/protected-path-guard.ts`: it does
1885
+ * not trust session wiring, and this row does not claim to establish it.
1886
+ *
1887
+ * Advisory, so it never fails the run. Doctor reads and never writes; the file
1888
+ * is `policy.edit` and its repair is a line for a human to commit.
1889
+ */
1890
+ function checkHarnessWiring(dir) {
1891
+ const check = "harness-hook-wiring";
1892
+ const root = repoRoot(dir);
1893
+ const where = root === null ? dir : root;
1894
+ const path = join(where, CLAUDE_SETTINGS);
1895
+ const scope = root === null
1896
+ ? `${dir} (git could not say what checkout this is)`
1897
+ : root === dir
1898
+ ? root
1899
+ : `${root}, the checkout root above ${dir}`;
1900
+ if (!existsSync(path)) {
1901
+ return {
1902
+ check,
1903
+ status: "skip",
1904
+ // No `fix`, deliberately: a checkout that is not a Claude Code checkout
1905
+ // at all owes no repair, exactly as `harness-hook-outcomes` treats the
1906
+ // same absence. The two branches below DO carry one, because there the
1907
+ // harness is present and the entry is what is missing.
1908
+ detail: `NOT WIRED: ${scope} carries no ${CLAUDE_SETTINGS}, so nothing in this worktree registers the pre-execution hook and a protected-path Edit here would apply unclassified. A session started elsewhere may still be hooked; this row can only see this checkout.`,
1909
+ };
1910
+ }
1911
+ let parsed;
1912
+ try {
1913
+ parsed = JSON.parse(readFileSync(path, "utf8"));
1914
+ }
1915
+ catch (cause) {
1916
+ return {
1917
+ check,
1918
+ status: "skip",
1919
+ detail: `UNDETERMINABLE: ${path} exists and is not readable as JSON (${detailOf(cause)}), so whether this checkout registers the pre-execution hook cannot be established here.`,
1920
+ };
1921
+ }
1922
+ const hooks = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
1923
+ ? parsed["hooks"]
1924
+ : null;
1925
+ const matchers = approvalHookMatchers(hooks, "PreToolUse");
1926
+ if (matchers.length === 0) {
1927
+ return {
1928
+ check,
1929
+ status: "skip",
1930
+ detail: `NOT WIRED: ${path} registers no \`approval hook\` entry for PreToolUse, so a protected-path Edit, Write or Bash call in this checkout reaches the file system unclassified.`,
1931
+ fix: "approval instructions hook — prints the PreToolUse entry a human commits",
1932
+ };
1933
+ }
1934
+ const covered = GATED_TOOLS.filter((tool) => matchers.some((pattern) => pattern.length === 0 || pattern.split("|").includes(tool)));
1935
+ const missing = GATED_TOOLS.filter((tool) => !covered.includes(tool));
1936
+ if (missing.length > 0) {
1937
+ return {
1938
+ check,
1939
+ status: "skip",
1940
+ detail: `NOT WIRED for every tool: ${path} registers \`approval hook\` for PreToolUse with matcher ${JSON.stringify(matchers.join(", "))}, which does not cover ${missing.join(", ")}. A protected-path write arriving through ${missing[0]} is never classified.`,
1941
+ fix: "approval instructions hook — prints the PreToolUse entry a human commits",
1942
+ };
1943
+ }
1944
+ return {
1945
+ check,
1946
+ status: "pass",
1947
+ detail: `WIRED on disk: ${path} registers \`approval hook\` for PreToolUse over ${GATED_TOOLS.join(", ")}. This is the file being present, NOT proof this session loaded it — the APRV-151 bypasses happened in worktrees carrying exactly this entry. The check that does not trust session wiring is the CI-side grant cross-check over the committed log, which asks whether the CHANGE was granted rather than whether the path ever was (APRV-202).`,
1948
+ };
1949
+ }
1950
+ /** The project-local Codex hook files doctor can observe without asking Codex. */
1951
+ const CODEX_HOOKS = join(".codex", "hooks.json");
1952
+ const CODEX_CONFIG = join(".codex", "config.toml");
1953
+ const CODEX_MATCHER = "Bash|apply_patch";
1954
+ const CODEX_HOOK_TIMEOUT_SECONDS = 600;
1955
+ function isDirectCodexHookCommand(command) {
1956
+ const match = command.match(/^(?:"([^"$`\\\r\n;&|<>]+)"|'([^'\\\r\n;&|<>]+)'|([^\s"'$`\\\r\n;&|<>]+)) hook codex --dir (?:"([^"$`\\\r\n;&|<>]+)"|'([^'\\\r\n;&|<>]+)'|([^\s"'$`\\\r\n;&|<>]+)) --as agent:codex --timeout 9m$/u);
1957
+ if (match === null)
1958
+ return false;
1959
+ const executable = match[1] ?? match[2] ?? match[3] ?? "";
1960
+ const primaryDir = match[4] ?? match[5] ?? match[6] ?? "";
1961
+ return isAbsolute(executable) && basename(executable) === "approval" && isAbsolute(primaryDir);
1962
+ }
1963
+ function codexEventConfigured(hooks, event) {
1964
+ if (typeof hooks !== "object" || hooks === null || Array.isArray(hooks))
1965
+ return false;
1966
+ const groups = hooks[event];
1967
+ if (!Array.isArray(groups))
1968
+ return false;
1969
+ return groups.some((group) => {
1970
+ if (typeof group !== "object" || group === null || Array.isArray(group))
1971
+ return false;
1972
+ const fields = group;
1973
+ if (fields["matcher"] !== CODEX_MATCHER || !Array.isArray(fields["hooks"]))
1974
+ return false;
1975
+ return fields["hooks"].some((handler) => {
1976
+ if (typeof handler !== "object" || handler === null || Array.isArray(handler))
1977
+ return false;
1978
+ const entry = handler;
1979
+ return (entry["type"] === "command" &&
1980
+ typeof entry["command"] === "string" &&
1981
+ isDirectCodexHookCommand(entry["command"]) &&
1982
+ entry["timeout"] === CODEX_HOOK_TIMEOUT_SECONDS &&
1983
+ (entry["async"] === undefined || entry["async"] === false));
1984
+ });
1985
+ });
1986
+ }
1987
+ /**
1988
+ * Report only project configuration visible on disk.
1989
+ *
1990
+ * Codex owns hook trust and runtime loading. Neither is inferable from a file,
1991
+ * and no historical log record proves what the current desktop session loaded.
1992
+ */
1993
+ export function checkCodexHookWiring(dir) {
1994
+ const check = "codex-hook-wiring";
1995
+ const root = repoRoot(dir);
1996
+ const where = root ?? dir;
1997
+ const hooksPath = join(where, CODEX_HOOKS);
1998
+ const configPath = join(where, CODEX_CONFIG);
1999
+ const hasHooks = existsSync(hooksPath);
2000
+ const hasConfig = existsSync(configPath);
2001
+ if (!hasHooks && !hasConfig) {
2002
+ return {
2003
+ check,
2004
+ status: "skip",
2005
+ detail: `NOT CONFIGURED on disk: ${where} carries neither ${CODEX_HOOKS} nor ${CODEX_CONFIG}. Codex hook trust and observed execution are separate and remain unknown.`,
2006
+ };
2007
+ }
2008
+ if (!hasHooks) {
2009
+ return {
2010
+ check,
2011
+ status: "skip",
2012
+ detail: `${configPath} exists. Doctor does not interpret inline TOML hook tables, so Codex hook configuration, trust and observed execution are undetermined.`,
2013
+ fix: "approval hook codex --help — compare the documented PreToolUse and PostToolUse entries with .codex/config.toml",
2014
+ };
2015
+ }
2016
+ let parsed;
2017
+ try {
2018
+ parsed = JSON.parse(readFileSync(hooksPath, "utf8"));
2019
+ }
2020
+ catch (cause) {
2021
+ return {
2022
+ check,
2023
+ status: "fail",
2024
+ detail: `${hooksPath} cannot be read as JSON (${oneLine(detailOf(cause))}); configured wiring cannot be established, and trust or execution cannot be inferred.`,
2025
+ fix: "approval hook codex --help — compare and repair the project-local hook JSON after human review",
2026
+ };
2027
+ }
2028
+ const hooks = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
2029
+ ? parsed["hooks"]
2030
+ : null;
2031
+ const pre = codexEventConfigured(hooks, "PreToolUse");
2032
+ const post = codexEventConfigured(hooks, "PostToolUse");
2033
+ if (!pre || !post) {
2034
+ const missing = [!pre ? "PreToolUse" : null, !post ? "PostToolUse" : null]
2035
+ .filter((value) => value !== null)
2036
+ .join(" and ");
2037
+ return {
2038
+ check,
2039
+ status: "skip",
2040
+ detail: `${hooksPath} is present but does not match the expected APRV-313 profile for ${missing}: synchronous command hooks with matcher ${JSON.stringify(CODEX_MATCHER)}, command \`approval hook codex\`, and timeout ${String(CODEX_HOOK_TIMEOUT_SECONDS)} seconds. Other Codex hook configurations may be valid; this integration's coverage is undetermined. File presence proves neither trust nor observed execution.`,
2041
+ fix: "approval hook codex --help — install the documented pair only after human review",
2042
+ };
2043
+ }
2044
+ if (hasConfig) {
2045
+ return {
2046
+ check,
2047
+ status: "skip",
2048
+ detail: `CONFIGURED in ${hooksPath}: the required PreToolUse and PostToolUse entries are present. ${configPath} also exists and Codex merges hook sources; doctor does not interpret its TOML tables, so the effective configuration is not fully established. Trust and observed execution remain unknown.`,
2049
+ fix: "approval hook codex --help — compare .codex/config.toml with the reviewed hooks.json and keep one representation per layer",
2050
+ };
2051
+ }
2052
+ return {
2053
+ check,
2054
+ status: "pass",
2055
+ detail: `CONFIGURED on disk: ${hooksPath} carries synchronous PreToolUse and PostToolUse \`approval hook codex\` entries for ${CODEX_MATCHER}, each with a ${String(CODEX_HOOK_TIMEOUT_SECONDS)} second outer timeout. This does not establish Codex trust, loading, or observed execution; inspect and trust the exact hook with \`/hooks\`, then run the bounded smoke test.`,
2056
+ };
2057
+ }
2058
+ // ---------------------------------------------------------------------------
2059
+ // harness version provenance (APRV-227)
2060
+ // ---------------------------------------------------------------------------
2061
+ /** The Cursor counterpart of {@link CLAUDE_SETTINGS}. */
2062
+ const CURSOR_HOOKS = join(".cursor", "hooks.json");
2063
+ /** Where a harness hook registration can be written, one file per harness. */
2064
+ const HARNESS_SETTINGS = [CLAUDE_SETTINGS, CURSOR_HOOKS, CODEX_HOOKS];
2065
+ /** `approval hook <kind>` inside a command string, whichever file shape holds it. */
2066
+ const HOOK_COMMAND = /\bapproval["']?\s+hook\s+(claude-code|cursor|codex)\b/u;
2067
+ /**
2068
+ * Every harness this checkout registers an `approval hook` command for.
2069
+ *
2070
+ * Shape-agnostic on purpose: `.claude/settings.json` nests the command under
2071
+ * `hooks.PreToolUse[].hooks[].command` and `.cursor/hooks.json` under
2072
+ * `hooks.preToolUse[].command`, and a third harness would nest it somewhere
2073
+ * else again. What all of them have in common is a STRING somewhere in the
2074
+ * document that invokes this CLI, so the document is parsed as JSON (a file
2075
+ * that is not JSON registers nothing this can read) and its string leaves are
2076
+ * searched. The row this feeds can only SKIP when the answer is empty, so a
2077
+ * miss costs a skip and never a false red.
2078
+ */
2079
+ export function registeredHarnesses(dir) {
2080
+ const found = new Set();
2081
+ for (const relative of HARNESS_SETTINGS) {
2082
+ const path = join(dir, relative);
2083
+ if (!existsSync(path))
2084
+ continue;
2085
+ let parsed;
2086
+ try {
2087
+ parsed = JSON.parse(readFileSync(path, "utf8"));
2088
+ }
2089
+ catch {
2090
+ continue;
2091
+ }
2092
+ const stack = [parsed];
2093
+ while (stack.length > 0) {
2094
+ const node = stack.pop();
2095
+ if (typeof node === "string") {
2096
+ const match = HOOK_COMMAND.exec(node);
2097
+ if (match !== null &&
2098
+ isHarnessKind(match[1]) &&
2099
+ (match[1] !== "codex" || isDirectCodexHookCommand(node))) {
2100
+ found.add(match[1]);
2101
+ }
2102
+ continue;
2103
+ }
2104
+ if (Array.isArray(node)) {
2105
+ stack.push(...node);
2106
+ continue;
2107
+ }
2108
+ if (typeof node === "object" && node !== null) {
2109
+ stack.push(...Object.values(node));
2110
+ }
2111
+ }
2112
+ }
2113
+ return HARNESS_KINDS.filter((kind) => found.has(kind));
2114
+ }
2115
+ /**
2116
+ * The last version each harness recorded, from the records the hook writes.
2117
+ *
2118
+ * Latest wins: the log is append-only and ordered, so the newest record naming
2119
+ * a harness is the newest statement about that binary. Only `task.registered`
2120
+ * and `gate.bypassed` are consulted, because those are the two the hook stamps
2121
+ * (APRV-227); the pair appearing on any other event type was not written by the
2122
+ * surface this row reports on and is ignored rather than trusted.
2123
+ */
2124
+ function recordedHarnessVersions(records) {
2125
+ const latest = new Map();
2126
+ for (const record of records) {
2127
+ if (record.event !== "task.registered" && record.event !== "gate.bypassed")
2128
+ continue;
2129
+ const provenance = readHarnessProvenance(record.payload);
2130
+ if (provenance === null)
2131
+ continue;
2132
+ latest.set(provenance.harness, {
2133
+ version: provenance.harness_version,
2134
+ seq: record.seq,
2135
+ });
2136
+ }
2137
+ return latest;
2138
+ }
2139
+ /**
2140
+ * Has the harness binary changed under the hook since the log last saw it?
2141
+ *
2142
+ * ## What this row is for
2143
+ *
2144
+ * A harness upgrade swaps the binary that hosts the PreToolUse hook, and it
2145
+ * happens on a human's own machine, unattended, at whatever hour an updater
2146
+ * runs. A release can change the hook envelope semantics; the gate then answers
2147
+ * a protocol nobody is speaking any more and the tool calls go through
2148
+ * unclassified. Nothing in the log would say so, because the thing that changed
2149
+ * is outside the log entirely.
2150
+ *
2151
+ * So this row compares the two facts it can actually establish: what
2152
+ * `<binary> --version` says now, and what the last hook-written record says the
2153
+ * binary was. A difference is not evidence of a fault, since most upgrades are
2154
+ * fine. It is evidence that the gate has not been exercised since the binary
2155
+ * changed, and the remedy is to exercise it. The self-test in
2156
+ * `docs/claude-code-hook.md` does that and costs nobody a prompt.
2157
+ *
2158
+ * ## Why it fails rather than warns
2159
+ *
2160
+ * The reason `dark-sessions` fails. A row in the pass column would be saying
2161
+ * "the gate may or may not still fire and I am content", and the whole content
2162
+ * of an unverified change is that nobody has checked. It clears the moment one
2163
+ * record is written under the new binary, which is a cheap and bounded remedy,
2164
+ * and that is what makes a red row here honest rather than nagging.
2165
+ *
2166
+ * ## What it will not claim
2167
+ *
2168
+ * A recorded version is SELF-REPORTED (SPEC.md §11.1 invariant 4), so this row
2169
+ * is careful about the direction it can move. A match ADDS nothing: not proof
2170
+ * the hook fired, not proof the harness is honest, and no substitute for
2171
+ * `harness-hook-wiring` or the CI-side guard. A mismatch is the only thing it
2172
+ * asserts, and all it asserts about one is that a human should run the
2173
+ * self-test. Nothing anywhere reads the field as an input to a verdict, a
2174
+ * floor, a budget, a streak or a sampling draw.
2175
+ *
2176
+ * Three skips, each with its reason in the detail: no harness hook registered
2177
+ * in this checkout; no hook-written record naming that harness yet (a fresh log
2178
+ * has nothing to compare against, and inventing a baseline would be inventing
2179
+ * the fact); and no such binary on PATH, since doctor may be running somewhere
2180
+ * the harness is not installed, which is a state and not a fault.
2181
+ */
2182
+ function checkHarnessVersion(dir, records) {
2183
+ const check = "harness-version-unverified";
2184
+ const root = repoRoot(dir);
2185
+ const where = root === null ? dir : root;
2186
+ const kinds = registeredHarnesses(where);
2187
+ if (kinds.length === 0) {
2188
+ return {
2189
+ check,
2190
+ status: "skip",
2191
+ detail: `${where} registers no \`approval hook\` command in ${HARNESS_SETTINGS.join(" or ")}, so no harness hosts the hook here and there is no installed version for the log to be behind`,
2192
+ };
2193
+ }
2194
+ const recorded = recordedHarnessVersions(records);
2195
+ const mismatched = [];
2196
+ const matched = [];
2197
+ const unknown = [];
2198
+ for (const kind of kinds) {
2199
+ const last = recorded.get(kind);
2200
+ if (last === undefined) {
2201
+ unknown.push(`${kind}: no hook-written task.registered or gate.bypassed names a version yet, so there is no baseline to compare against`);
2202
+ continue;
2203
+ }
2204
+ const installed = installedHarnessVersion(kind);
2205
+ if (installed === null) {
2206
+ unknown.push(`${kind}: \`${HARNESS_BINARY[kind]} --version\` gave no usable answer here (not on PATH, a non-zero exit, or output this runtime will not record), so what is installed cannot be established; the log last saw ${JSON.stringify(last.version)} at seq ${String(last.seq)}`);
2207
+ continue;
2208
+ }
2209
+ if (installed === last.version) {
2210
+ matched.push(`${kind} ${JSON.stringify(installed)} matches the version on the hook record at seq ${String(last.seq)}`);
2211
+ continue;
2212
+ }
2213
+ mismatched.push(`${kind} is installed at ${JSON.stringify(installed)} and the last hook-written record (seq ${String(last.seq)}) was issued by ${JSON.stringify(last.version)}`);
2214
+ }
2215
+ if (mismatched.length > 0) {
2216
+ const first = kinds[0];
2217
+ return {
2218
+ check,
2219
+ status: "fail",
2220
+ detail: `the harness binary changed and the gate has not been exercised since: ${mismatched.join("; ")}. A release can change the hook envelope semantics, so until one record is written under the new binary nothing here shows the hook still fires. The recorded version is self-reported and reduces nothing: a match would not have proved the hook fired either, and what a mismatch says is that nobody has looked.`,
2221
+ fix: `approval hook ${first} --dir ${where} < one PreToolUse event for a supervised-class command — the self-test in docs/${first === "cursor" ? "cursor" : "claude-code"}-hook.md. It prompts nobody and writes one task.registered carrying the installed version.`,
2222
+ };
2223
+ }
2224
+ if (matched.length > 0) {
2225
+ return {
2226
+ check,
2227
+ status: "pass",
2228
+ detail: `${matched.join("; ")}${unknown.length === 0 ? "" : `; ${unknown.join("; ")}`}. A match is not proof the hook fired; it is the absence of the one thing this row can see, an unverified change of the binary hosting it.`,
2229
+ };
2230
+ }
2231
+ return {
2232
+ check,
2233
+ status: "skip",
2234
+ detail: `${where} registers ${kinds.join(", ")} and no comparison could be made: ${unknown.join("; ")}`,
2235
+ };
2236
+ }
2237
+ // ---------------------------------------------------------------------------
2238
+ // dark sessions (APRV-192)
2239
+ // ---------------------------------------------------------------------------
2240
+ /**
2241
+ * Does the git activity in this checkout have log records beside it?
2242
+ *
2243
+ * The detective complement to `harness-hook-wiring` above. That row reports
2244
+ * whether the settings file is on disk and says plainly that this is not proof
2245
+ * a session loaded it; this row asks the question that does not depend on
2246
+ * session wiring at all — git shows commits and worktrees, and the log either
2247
+ * carries records beside them or it does not.
2248
+ *
2249
+ * Reads only. Doctor never appends, so a dark subject found HERE is reported
2250
+ * and not recorded: the record is the daemon's, written by the sweep it runs on
2251
+ * its own cadence (`approval daemon run --dark-sessions`). Two processes
2252
+ * appending the same observation would be two writers to one fact, and doctor
2253
+ * is a reader.
2254
+ *
2255
+ * This row DOES fail the run, which is where it parts company with
2256
+ * `harness-hook-wiring` above. That row reports a configuration, and a
2257
+ * configuration this runtime cannot verify from disk is not a health verdict.
2258
+ * This one reports an EVENT: work was done in this repository and the log was
2259
+ * not told. "Never silently tolerate it" is the whole of APRV-192, and a row
2260
+ * that reported a dark session in the pass column would be tolerating it
2261
+ * quietly in the one place an operator goes to ask whether anything is wrong.
2262
+ *
2263
+ * An `undetermined` subject is a skip, not a fail, for the reason the daemon
2264
+ * appends nothing for one: what the detector could not see is a gap in the
2265
+ * instrument, and a red row for it would train an operator to ignore red rows.
2266
+ * The gap is named in the detail, never folded into a pass.
2267
+ */
2268
+ function checkDarkSessions(logPath, dir, policyPath, records, verified) {
2269
+ const check = "dark-sessions";
2270
+ const root = repoRoot(dir);
2271
+ if (root === null) {
2272
+ return {
2273
+ check,
2274
+ status: "skip",
2275
+ detail: `${dir} is not inside a git repository, so there is no git activity for the log to owe records against`,
2276
+ };
2277
+ }
2278
+ // `reportDarkSessions`, never `sweepDarkSessions`: the read-only half of the
2279
+ // same code, so doctor and the daemon reach identical verdicts and only the
2280
+ // daemon writes them down.
2281
+ const { report } = reportDarkSessions({
2282
+ logPath,
2283
+ root,
2284
+ policy: { file: policyPath },
2285
+ windowMs: DEFAULT_DARK_WINDOW_MS,
2286
+ records: verified ? records : null,
2287
+ ...(verified ? {} : { logDetail: "the chain did not verify; see the log check above" }),
2288
+ });
2289
+ const dark = report.findings.filter((finding) => finding.verdict === "dark");
2290
+ const undetermined = report.findings.filter((finding) => finding.verdict === "undetermined");
2291
+ const watched = report.findings.length;
2292
+ if (dark.length > 0) {
2293
+ return {
2294
+ check,
2295
+ status: "fail",
2296
+ detail: `${String(dark.length)} of ${String(watched)} checkout(s) show git activity the log carries no record of: ${dark
2297
+ .map((finding) => `${finding.subject} [${finding.code ?? "?"}]`)
2298
+ .join(", ")}. ${dark[0].detail}`,
2299
+ fix: "approval doctor --dir <that checkout> — its harness-hook-wiring row, then `approval instructions hook`",
2300
+ };
2301
+ }
2302
+ if (undetermined.length > 0) {
2303
+ return {
2304
+ check,
2305
+ status: "skip",
2306
+ detail: `UNDETERMINED for ${String(undetermined.length)} of ${String(watched)} checkout(s): ${undetermined
2307
+ .map((finding) => `${finding.subject} [${finding.code ?? "?"}]`)
2308
+ .join(", ")}. ${undetermined[0].detail}`,
2309
+ };
2310
+ }
2311
+ return {
2312
+ check,
2313
+ status: "pass",
2314
+ detail: `${String(watched)} checkout(s) swept over the last ${String(Math.round(DEFAULT_DARK_WINDOW_MS / 3_600_000))}h and every one of them either produced no git activity or has records beside it. ${report.coverage}`,
2315
+ };
2316
+ }
2317
+ // ---------------------------------------------------------------------------
2318
+ // 27. gate-organs (APRV-272)
2319
+ // ---------------------------------------------------------------------------
2320
+ /**
2321
+ * Where the gate's organs live, as repository-relative prefixes.
2322
+ *
2323
+ * The enumeration is deliberately narrow and one level deep. `core/command-class.ts`
2324
+ * decides what IS an organ (and every path listed here is put to it before it
2325
+ * is reported); this list only says where to look, so a directory nobody uses
2326
+ * costs nothing and a file nobody named is not invented.
2327
+ */
2328
+ const ORGAN_SEARCH = [
2329
+ { dir: ".claude", prefix: "settings" },
2330
+ { dir: ".cursor", prefix: "hooks.json" },
2331
+ { dir: join(".cursor", "hooks") },
2332
+ { dir: join(".cursor", "agents") },
2333
+ ];
2334
+ /** The organ files this checkout actually carries, repository-relative, sorted. */
2335
+ function listGateOrgans(root) {
2336
+ const found = [];
2337
+ for (const entry of ORGAN_SEARCH) {
2338
+ let names;
2339
+ try {
2340
+ names = readdirSync(join(root, entry.dir));
2341
+ }
2342
+ catch {
2343
+ continue;
2344
+ }
2345
+ for (const name of names.sort()) {
2346
+ if (entry.prefix !== undefined && !name.startsWith(entry.prefix))
2347
+ continue;
2348
+ const relative = `${entry.dir.split(/[/\\]+/u).join("/")}/${name}`;
2349
+ let isFile;
2350
+ try {
2351
+ isFile = statSync(join(root, relative)).isFile();
2352
+ }
2353
+ catch {
2354
+ continue;
2355
+ }
2356
+ // The classifier has the last word on what an organ is, so a file that
2357
+ // merely sits in one of these directories is not reported as one.
2358
+ if (isFile && isGateOrganPath(relative))
2359
+ found.push(relative);
2360
+ }
2361
+ }
2362
+ return found;
2363
+ }
2364
+ /**
2365
+ * Which gate organs in this checkout carry no attestation of their CURRENT
2366
+ * bytes (APRV-272)?
2367
+ *
2368
+ * **This row never moves the exit code, by design.** It reports a fact about
2369
+ * files a human edits by hand, and the enforcement for that fact lives in the
2370
+ * CI-side protected-path guard, which fails the pull request. Doctor's job here
2371
+ * is to make the state visible BEFORE a pull request fails on it: a human who
2372
+ * has just hand-edited the settings file should be told they owe an
2373
+ * attestation while they are still at the terminal, not by a red check twenty
2374
+ * minutes later. A failing row would also be wrong on its own terms — an
2375
+ * unattested organ breaks nothing on this machine, unlike an unattested policy,
2376
+ * which makes every gated operation refuse.
2377
+ *
2378
+ * A checkout with no organ files at all is a skip: there is no harness
2379
+ * configuration here, which is a state and not a fault, exactly as
2380
+ * `harness-hook-wiring` treats the same absence.
2381
+ */
2382
+ function checkGateOrgans(dir, records) {
2383
+ const check = "gate-organs";
2384
+ const root = repoRoot(dir) ?? dir;
2385
+ const organs = listGateOrgans(root);
2386
+ if (organs.length === 0) {
2387
+ return {
2388
+ check,
2389
+ status: "skip",
2390
+ detail: `${root} carries no gate organ files (${ORGAN_SEARCH.map((entry) => entry.dir).join(", ")}), so there is nothing here for a human to have attested`,
2391
+ };
2392
+ }
2393
+ const unattested = [];
2394
+ const unreadable = [];
2395
+ let attested = 0;
2396
+ for (const organ of organs) {
2397
+ let sha256;
2398
+ try {
2399
+ sha256 = policyBytesHash(readFileSync(join(root, organ)));
2400
+ }
2401
+ catch (cause) {
2402
+ unreadable.push(`${organ} (${detailOf(cause)})`);
2403
+ continue;
2404
+ }
2405
+ if (findOrganAttestation(records, organ, sha256) !== null) {
2406
+ attested += 1;
2407
+ continue;
2408
+ }
2409
+ const previous = latestOrganAttestation(records, organ);
2410
+ unattested.push(previous === null
2411
+ ? `${organ} (never attested, live ${sha256.slice(0, 12)}…)`
2412
+ : `${organ} (edited since seq ${previous.record.seq}: attested ${previous.sha256.slice(0, 12)}…, live ${sha256.slice(0, 12)}…)`);
2413
+ }
2414
+ if (unattested.length === 0 && unreadable.length === 0) {
2415
+ return {
2416
+ check,
2417
+ status: "pass",
2418
+ detail: `${String(attested)} gate organ file(s) carry an attestation of their current bytes: ${organs.join(", ")}`,
2419
+ };
2420
+ }
2421
+ const parts = [];
2422
+ if (unattested.length > 0)
2423
+ parts.push(`NOT ATTESTED: ${unattested.join("; ")}`);
2424
+ if (unreadable.length > 0)
2425
+ parts.push(`unreadable: ${unreadable.join("; ")}`);
2426
+ return {
2427
+ check,
2428
+ // Never a fail: see the note above. The exit code belongs to the guard.
2429
+ status: "skip",
2430
+ detail: `${parts.join(". ")}. A gate organ is policy.core, so no grant for a hand edit to one can exist and the protected-path guard accepts only an attestation of these exact bytes; a pull request carrying this change will fail until one is in the committed log`,
2431
+ fix: `approval policy attest --organ ${(unattested[0] ?? "<path>").split(" ")[0] ?? "<path>"} --as human:<id> — after reading the file`,
2432
+ };
2433
+ }
2434
+ // ---------------------------------------------------------------------------
2435
+ // 28. sealed-keys (APRV-285)
2436
+ // ---------------------------------------------------------------------------
2437
+ /** The exact line doctor tells an operator to add for the sealed-token key store. */
2438
+ const KEYS_IGNORE_LINE = ".approval/keys/";
2439
+ /** The same path without the trailing slash, which is how git spells a path. */
2440
+ const KEYS_IGNORE_PATH = ".approval/keys";
2441
+ /** The private key files this key store actually holds, sorted, names only. */
2442
+ function listPrivateKeys(keyDir) {
2443
+ try {
2444
+ return readdirSync(keyDir)
2445
+ .filter((name) => name.endsWith(".key"))
2446
+ .sort();
2447
+ }
2448
+ catch {
2449
+ return [];
2450
+ }
2451
+ }
2452
+ /**
2453
+ * The paths git TRACKS under `keyDir`, repo-relative, or `[]` when git cannot say.
2454
+ *
2455
+ * Tracking is asked of git rather than inferred from the working tree, because
2456
+ * the two can disagree in the direction that matters: a key consumed and
2457
+ * unlinked is gone from disk and still in the index, and it is the index that
2458
+ * becomes a commit.
2459
+ */
2460
+ function trackedPrivateKeys(root, keyDir) {
2461
+ const relative = repoPath(root, keyDir);
2462
+ // A key store outside this repository cannot be committed to it.
2463
+ if (relative.startsWith("../") || isAbsolute(relative))
2464
+ return [];
2465
+ const listed = git(["ls-files", "-z", "--", relative], root);
2466
+ if (!listed.ok)
2467
+ return [];
2468
+ return listed.stdout.split("\0").filter((entry) => entry.length > 0);
2469
+ }
2470
+ /**
2471
+ * Is a sealed-delivery private key one `git add` away from publication?
2472
+ *
2473
+ * The key store holds the X25519 private halves of sealed token delivery
2474
+ * (`core/seal.ts`): one per request, written 0600 in a 0700 directory, unlinked
2475
+ * at consume, expiry or revocation. The log — which IS shared, and which this
2476
+ * project commits on purpose — carries only the ciphertext. A private key in
2477
+ * that same history hands every reader of it the ability to open that action's
2478
+ * `token_sealed` for as long as the token is unspent and inside its TTL, so the
2479
+ * whole design of sealed delivery rests on the key never being committed.
2480
+ *
2481
+ * Nothing enforced that. `.approval/payloads/` is deliberately TRACKED (evidence
2482
+ * belongs in the history), so `.approval/` is a directory an operator adds from
2483
+ * during a records or ceremony commit, and a key store with no ignore line is
2484
+ * swept in by the same `git add` that carries the payloads.
2485
+ *
2486
+ * Two questions, in the order of what stays wrong the longest, the same reading
2487
+ * the vault and environment rows use:
2488
+ *
2489
+ * 1. **A tracked key** is the fault that has already happened, and a commit is
2490
+ * not something a later commit removes. Asked of git, so a key that was
2491
+ * consumed and unlinked but is still in the index is still reported.
2492
+ * 2. **An unignored key store** is the fault about to happen. A key present with
2493
+ * no ignore line covering it FAILS; an empty or absent store is a SKIP that
2494
+ * still names the line in its detail and carries no `fix`, because nothing on
2495
+ * this machine is wrong yet and a non-failing row that hands an operator
2496
+ * something to type is a row they learn to scroll past.
2497
+ *
2498
+ * Outside a git repository the row skips: there is nothing here to commit a key
2499
+ * to, and failing a check about a risk that does not exist trains people to
2500
+ * ignore the check.
2501
+ *
2502
+ * Neither fix line deletes anything and neither commits anything. The repair for
2503
+ * a key already in the index is named in prose and left to the human, exactly as
2504
+ * {@link FIX_COMMAND_PREFIXES} requires.
2505
+ */
2506
+ function checkSealedKeys(logPath, dir) {
2507
+ const check = "sealed-keys";
2508
+ const keyDir = keyStoreDirFor(logPath);
2509
+ const ignored = ignoreVerdict(dir, KEYS_IGNORE_PATH, "dir");
2510
+ if (ignored === "not-a-repo") {
2511
+ return {
2512
+ check,
2513
+ status: "skip",
2514
+ detail: `no git repository at ${dir}, so there is nothing to commit a sealed-delivery private key to. ${keyDir} is where they would live, 0600 in a 0700 directory, and the log carries only the ciphertext they open`,
2515
+ };
2516
+ }
2517
+ const root = repoRoot(dir);
2518
+ const tracked = root === null ? [] : trackedPrivateKeys(root, keyDir);
2519
+ if (tracked.length > 0) {
2520
+ return {
2521
+ check,
2522
+ status: "fail",
2523
+ detail: `${String(tracked.length)} sealed-delivery private key(s) are TRACKED by git: ${tracked.join(", ")}. The log is committed and carries the ciphertext, so a committed key opens that action's token_sealed for anyone holding the history, for as long as the token is unspent and inside its TTL — and a commit is not something a later commit removes`,
2524
+ fix: `approval init — it writes '${KEYS_IGNORE_LINE}' into .gitignore so the next key is not swept in; a key already in the index has to be untracked by hand (\`git rm --cached\` on the paths above), and every action whose token is still unspent inside its TTL treated as disclosed and revoked`,
2525
+ };
2526
+ }
2527
+ const present = listPrivateKeys(keyDir);
2528
+ if (ignored === "not-ignored") {
2529
+ if (present.length > 0) {
2530
+ return {
2531
+ check,
2532
+ status: "fail",
2533
+ detail: `${String(present.length)} sealed-delivery private key(s) in ${keyDir} are NOT gitignored in ${dir}: one \`git add .approval/\` — the command a records or ceremony commit uses, because \`.approval/payloads/\` is deliberately tracked — publishes a live key beside the ciphertext it opens`,
2534
+ fix: `echo '${KEYS_IGNORE_LINE}' >> ${join(dir, ".gitignore")} — the line \`approval init\` writes; and treat every action whose token is still unspent inside its TTL as disclosed`,
2535
+ };
2536
+ }
2537
+ // A SKIP WITH NO FIX, because nothing here is wrong yet. Doctor's older
2538
+ // rule is that a non-failing row carries no `fix` line, and an operator
2539
+ // scanning a wall of them for the next thing to type must not be handed one
2540
+ // for a risk that does not exist on this machine today. The line is named in
2541
+ // the detail instead, where a reader who wants it can still find it.
2542
+ return {
2543
+ check,
2544
+ status: "skip",
2545
+ detail: `no key is in ${keyDir}, so nothing is exposed here today, and no \`${KEYS_IGNORE_LINE}\` line covers it in ${dir}. \`approval init\` writes that line whether or not a policy has opted into \`token_delivery: sealed\`, because the entry an operator needs is the one already there on the day they turn the knob`,
2546
+ };
2547
+ }
2548
+ return {
2549
+ check,
2550
+ status: "pass",
2551
+ detail: `${keyDir} is covered by ${KEYS_IGNORE_LINE} in ${dir} and git tracks nothing under it${present.length === 0 ? " (no key is stored there right now)" : `; ${String(present.length)} key(s) are stored there`}. The private halves stay on this machine and the log carries only the ciphertext they open`,
2552
+ };
2553
+ }
2554
+ /** The Backlog.md board key a task file's name begins with (`task-3 - Slug.md`). */
2555
+ function taskIdFromFileName(name) {
2556
+ const match = /^([A-Za-z][A-Za-z0-9_]*-\d+)/u.exec(name);
2557
+ return match?.[1] ?? null;
2558
+ }
2559
+ // ---------------------------------------------------------------------------
2560
+ // Rendering
2561
+ // ---------------------------------------------------------------------------
2562
+ /** The status column, as a glyph role the shared style already knows how to paint. */
2563
+ const GLYPH_OF = {
2564
+ pass: "ok",
2565
+ fail: "fail",
2566
+ skip: "skip",
2567
+ };
2568
+ /**
2569
+ * The human report: one aligned row per check, fixes indented under their row.
2570
+ *
2571
+ * The line contract is load-bearing and older than the table (APRV-91 #9): a
2572
+ * check occupies exactly one line, and a `fix` exactly one indented line under
2573
+ * it, so an operator scanning a failed run counts rows rather than paragraphs.
2574
+ * What the table changed is alignment and colour, never that arithmetic.
2575
+ *
2576
+ * A detail is abbreviated only when a TERMINAL WIDTH IS KNOWN and the row would
2577
+ * not fit it, and `--verbose` (APRV-102) turns even that off. The brief asked
2578
+ * for truncation outright; this is the narrowed version of it, for two reasons.
2579
+ * A pipe has no width, so piped output — which every other suite pins, and
2580
+ * which is what a bug report contains — is never abbreviated at all. And a
2581
+ * `fix:` line is never touched on any path: repair instructions cut off
2582
+ * mid-command are worse than a wide line, which is what the truncation was
2583
+ * supposed to prevent.
2584
+ */
2585
+ export function renderDoctorHuman(checks, st = style(), options = {}) {
2586
+ const labelWidth = Math.max(0, ...checks.map((entry) => entry.check.length));
2587
+ // glyph (1) + space + label + gap (2), the columns the detail starts after.
2588
+ const room = options.verbose === true || options.width === null || options.width === undefined
2589
+ ? null
2590
+ : Math.max(20, options.width - labelWidth - 4);
2591
+ const fit = (detail) => room === null || detail.length <= room ? detail : `${detail.slice(0, room - 1)}…`;
2592
+ const rows = checks.map((entry) => ({
2593
+ left: entry.check,
2594
+ right: fit(entry.detail),
2595
+ glyph: GLYPH_OF[entry.status],
2596
+ ...(entry.fix === undefined ? {} : { under: [`fix: ${entry.fix}`] }),
2597
+ }));
2598
+ const count = (status) => checks.filter((entry) => entry.status === status).length;
2599
+ const failed = count("fail");
2600
+ // Each count wears its own role, so the summary is scannable at the same
2601
+ // glance as the glyph column above it and says the same thing.
2602
+ const summary = [
2603
+ st.ok(`${count("pass")} ok`),
2604
+ st.warn(`${count("skip")} not applicable`),
2605
+ failed === 0 ? st.muted("0 failed") : st.fail(`${failed} failed`),
2606
+ ].join(" · ");
2607
+ return `${st.table(rows)}\n${summary}\n`;
2608
+ }
2609
+ // ---------------------------------------------------------------------------
2610
+ // The verb
2611
+ // ---------------------------------------------------------------------------
2612
+ /**
2613
+ * `approval doctor …` — run every check in order and report.
2614
+ *
2615
+ * Returns a number for the paths that are decided before any I/O (help, usage),
2616
+ * and a promise otherwise, because two checks are asynchronous. `main`
2617
+ * dispatches both shapes, as it already does for `channel`.
2618
+ */
2619
+ export function commandDoctor(argv, streams, cwd) {
2620
+ const json = argv.includes("--json");
2621
+ const parsed = parseFlags(argv, FLAGS);
2622
+ if (!parsed.ok)
2623
+ return usageError(streams, json, parsed.message);
2624
+ if (boolFlag(parsed.flags, "--help") || boolFlag(parsed.flags, "-h")) {
2625
+ streams.out(`${DOCTOR_HELP}\n`);
2626
+ return EXIT_OK;
2627
+ }
2628
+ const extra = parsed.positionals[0];
2629
+ if (extra !== undefined) {
2630
+ return usageError(streams, json, `unexpected argument ${JSON.stringify(extra)}`);
2631
+ }
2632
+ const rootFlag = stringFlag(parsed.flags, "--root");
2633
+ const root = rootFlag === null ? installationRoot() : absolute(rootFlag, cwd);
2634
+ let build;
2635
+ try {
2636
+ build = checkBuildFreshness(root);
2637
+ }
2638
+ catch (cause) {
2639
+ // Doctor could not look — the one thing that is not a report about the
2640
+ // environment but a failure of the instrument. Exit 4.
2641
+ if (cause instanceof ScanError) {
2642
+ return ioError(streams, json, `doctor could not inspect ${root}: ${cause.message}`);
2643
+ }
2644
+ throw cause;
2645
+ }
2646
+ const dirFlag = stringFlag(parsed.flags, "--dir");
2647
+ const dir = dirFlag === null ? cwd : absolute(dirFlag, cwd);
2648
+ const policyFlag = stringFlag(parsed.flags, "--policy");
2649
+ const policyPath = resolvePolicyPath(policyFlag, dir, cwd);
2650
+ const logPath = resolvePath(stringFlag(parsed.flags, "--log"), DEFAULT_LOG_PATH, cwd);
2651
+ // The second path the startup preflight must not let a fast-forward clobber.
2652
+ // Spelled from `--dir` rather than from a flag of its own: doctor has no
2653
+ // `--out`, and inventing one for a single row would be a new surface.
2654
+ const queuePath = join(dir, DEFAULT_QUEUE_PATH);
2655
+ // ONE walk of the log for both the attestation check and the log check: two
2656
+ // walks could disagree, and doctor is the last place a reader wants to be
2657
+ // told two different things about one file.
2658
+ const verified = verifyWithRecords(logPath);
2659
+ const policyLoad = loadPolicy(policyFlag === null ? { dir } : { file: policyPath });
2660
+ const port = policyWebPort(policyLoad);
2661
+ const apiBase = stringFlag(parsed.flags, "--api-base") ?? TELEGRAM_DEFAULT_API_BASE;
2662
+ const tasksFlag = stringFlag(parsed.flags, "--tasks");
2663
+ const tasksDir = tasksFlag === null ? join(dir, DEFAULT_TASKS_DIR) : absolute(tasksFlag, cwd);
2664
+ return (async () => {
2665
+ const checks = [
2666
+ build,
2667
+ checkIdentity(),
2668
+ checkAttestationHealth(verified.records, policyPath),
2669
+ checkLog(logPath, verified.result),
2670
+ await checkTelegram(apiBase, policyLoad),
2671
+ await checkWebPort(port ?? WEB_DEFAULT_PORT),
2672
+ checkPayloadStore(logPath, verified.records),
2673
+ // APRV-271: asks the running daemon for the one half of this answer that
2674
+ // doctor's own environment cannot hold.
2675
+ await checkSampling(policyLoad, logPath),
2676
+ checkEnvelopeIntegrity(tasksDir, verified.records),
2677
+ // APRV-68: appended rather than inserted, for the same reason the
2678
+ // envelope check was — a reader's position-based expectations still hold.
2679
+ checkVaultHealth(logPath, dir, policyLoad),
2680
+ // APRV-75: appended, for the third time and the same reason — the check
2681
+ // list is a frozen shape that grows only at the end.
2682
+ checkEnvironment(logPath, dir, policyLoad),
2683
+ // APRV-125: appended, fourth time, same reason. The fork this reports is
2684
+ // the one APRV-104 could only find by hand.
2685
+ checkLogDrift(logPath, verified.records),
2686
+ // APRV-127: appended, fifth time, same reason.
2687
+ checkReconciliation(verified.records),
2688
+ // APRV-145: appended, sixth time, same reason.
2689
+ checkHarnessOutcomes(dir),
2690
+ // APRV-151: appended, seventh time, same reason.
2691
+ checkHarnessWiring(dir),
2692
+ // APRV-178: appended, eighth time, same reason. The sharing this reports
2693
+ // is what put a demo gate on the production bot.
2694
+ checkKeychainScope(logPath, policyLoad),
2695
+ // APRV-204: appended, ninth time, same reason. The cadence advance needed
2696
+ // a status surface that outlives the daemon's own event stream.
2697
+ checkAdvanceCadence(logPath, verified.records),
2698
+ // APRV-192: appended, tenth time, same reason. The detective complement
2699
+ // to harness-hook-wiring above — that row asks this checkout's settings
2700
+ // file, this one asks git and the log and never asks a session anything.
2701
+ checkDarkSessions(logPath, dir, policyPath, verified.records, verified.result.status === "clean"),
2702
+ // APRV-188: appended, eleventh time, same reason.
2703
+ checkVerifiedSnapshot(logPath),
2704
+ // APRV-217: appended, twelfth time, same reason. A configuration row: it
2705
+ // reads the policy, never a running daemon's memory.
2706
+ checkReadProof(policyLoad),
2707
+ // APRV-215: appended, thirteenth time, same reason. The report half of
2708
+ // `approval up`'s startup preflight, and the only row that reads the
2709
+ // remote-tracking refs. It fetches NOTHING: a report that reached the
2710
+ // network to be more accurate would be acting on its own account, so the
2711
+ // answer is as fresh as the operator's last fetch and says so.
2712
+ checkMainBehindOrigin(logPath, queuePath, root),
2713
+ // APRV-227: appended, fourteenth time, same reason. The only row that
2714
+ // asks a question about a binary OUTSIDE this repository, and it asks it
2715
+ // the one way a log can: what the last record said the harness was,
2716
+ // against what `<binary> --version` says it is now.
2717
+ checkHarnessVersion(dir, verified.records),
2718
+ // APRV-208: appended, fourteenth time, same reason. The one row that says
2719
+ // whether supervised-live is actually live on this machine.
2720
+ // APRV-282: it connects now, because a socket file outlives the process
2721
+ // that bound it and a `stat` reads the leftovers as a healthy gate.
2722
+ await checkLiveDraw(logPath, policyLoad),
2723
+ // APRV-238: appended, fifteenth time, same reason. The one surface
2724
+ // besides `approval values` that would notice a broken values block:
2725
+ // `policy check` deliberately says nothing about it, because guidance has
2726
+ // no place in an enforcement trace.
2727
+ checkValuesBlock(policyPath, policyFlag !== null, dir),
2728
+ // APRV-257: appended, sixteenth time, same reason. The status surface the
2729
+ // second witness needed. It runs the SAME check `approval log verify
2730
+ // --checkpoints` and the daemon's full re-proof run, over the same single
2731
+ // walk of the log every other row here reads, so three instruments cannot
2732
+ // disagree about one file.
2733
+ checkCheckpoints(verified.records, policyFlag === null ? { dir } : { file: policyPath }),
2734
+ // APRV-272: appended, seventeenth time, same reason. Informational and
2735
+ // never a fail: the enforcement for an unattested organ is the CI-side
2736
+ // protected-path guard, and this row exists so a hand edit is visible at
2737
+ // the terminal before a pull request fails on it.
2738
+ checkGateOrgans(dir, verified.records),
2739
+ // APRV-285: appended, eighteenth time, same reason. The sibling of the
2740
+ // vault and environment rows for the one file under `.approval/` that is
2741
+ // a raw private key: `.approval/payloads/` is tracked on purpose, so
2742
+ // `.approval/` is a directory people `git add` from, and the key store had
2743
+ // nothing telling them it must not come along.
2744
+ checkSealedKeys(logPath, dir),
2745
+ // APRV-313: appended, nineteenth time, same reason. Configuration on
2746
+ // disk is distinct from Codex trust, loading and observed execution.
2747
+ checkCodexHookWiring(dir),
2748
+ ];
2749
+ const ok = checks.every((entry) => entry.status !== "fail");
2750
+ if (json)
2751
+ streams.out(`${JSON.stringify({ ok, checks })}\n`);
2752
+ else {
2753
+ streams.out(renderDoctorHuman(checks, style({ json }), {
2754
+ verbose: boolFlag(parsed.flags, "--verbose"),
2755
+ // `undefined` in a pipe, which is exactly when nothing is abbreviated.
2756
+ width: process.stdout.columns ?? null,
2757
+ }));
2758
+ }
2759
+ return ok ? EXIT_OK : EXIT_INTEGRITY;
2760
+ })();
2761
+ }
2762
+ //# sourceMappingURL=doctor.js.map