approval-md 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (416) hide show
  1. package/README.md +629 -559
  2. package/SPEC.md +99 -24
  3. package/dist/src/adapters/agentmail.d.ts +426 -0
  4. package/dist/src/adapters/agentmail.js +2 -2
  5. package/dist/src/adapters/conformance.d.ts +149 -0
  6. package/dist/src/adapters/contract.d.ts +628 -0
  7. package/dist/src/adapters/contract.js +110 -16
  8. package/dist/src/adapters/contract.js.map +1 -1
  9. package/dist/src/adapters/email.d.ts +324 -0
  10. package/dist/src/adapters/env-passphrase.d.ts +93 -0
  11. package/dist/src/adapters/public.d.ts +11 -0
  12. package/dist/src/adapters/public.js +11 -0
  13. package/dist/src/adapters/public.js.map +1 -0
  14. package/dist/src/adapters/registry.d.ts +59 -0
  15. package/dist/src/adapters/registry.js +2 -1
  16. package/dist/src/adapters/registry.js.map +1 -1
  17. package/dist/src/adapters/smtp.d.ts +213 -0
  18. package/dist/src/adapters/vault-provider.d.ts +114 -0
  19. package/dist/src/adapters/vault-provider.js +3 -3
  20. package/dist/src/adapters/zzz.d.ts +66 -0
  21. package/dist/src/adapters/zzz.js +299 -0
  22. package/dist/src/adapters/zzz.js.map +1 -0
  23. package/dist/src/channels/batch.d.ts +109 -0
  24. package/dist/src/channels/cli.d.ts +193 -0
  25. package/dist/src/channels/conformance.d.ts +92 -0
  26. package/dist/src/channels/contract.d.ts +656 -0
  27. package/dist/src/channels/contract.js +200 -7
  28. package/dist/src/channels/contract.js.map +1 -1
  29. package/dist/src/channels/payload-view.d.ts +35 -0
  30. package/dist/src/channels/render-queue.d.ts +149 -0
  31. package/dist/src/channels/tagging.d.ts +196 -0
  32. package/dist/src/channels/telegram.d.ts +1944 -0
  33. package/dist/src/channels/telegram.js +218 -23
  34. package/dist/src/channels/telegram.js.map +1 -1
  35. package/dist/src/channels/web.d.ts +350 -0
  36. package/dist/src/channels/web.js +17 -0
  37. package/dist/src/channels/web.js.map +1 -1
  38. package/dist/src/cli/adapter.d.ts +90 -0
  39. package/dist/src/cli/adapter.js +25 -15
  40. package/dist/src/cli/adapter.js.map +1 -1
  41. package/dist/src/cli/amend.d.ts +59 -0
  42. package/dist/src/cli/amend.js +214 -30
  43. package/dist/src/cli/amend.js.map +1 -1
  44. package/dist/src/cli/args.d.ts +43 -0
  45. package/dist/src/cli/attest.d.ts +50 -0
  46. package/dist/src/cli/attest.js +134 -7
  47. package/dist/src/cli/attest.js.map +1 -1
  48. package/dist/src/cli/audit-card.d.ts +62 -0
  49. package/dist/src/cli/audit.d.ts +59 -0
  50. package/dist/src/cli/channel-telegram.d.ts +879 -0
  51. package/dist/src/cli/channel-telegram.js +311 -13
  52. package/dist/src/cli/channel-telegram.js.map +1 -1
  53. package/dist/src/cli/channel-web.d.ts +131 -0
  54. package/dist/src/cli/channel.d.ts +80 -0
  55. package/dist/src/cli/channel.js +9 -0
  56. package/dist/src/cli/channel.js.map +1 -1
  57. package/dist/src/cli/checkpoint-tap.d.ts +169 -0
  58. package/dist/src/cli/codex-bridge.d.ts +819 -0
  59. package/dist/src/cli/codex-bridge.js +1607 -0
  60. package/dist/src/cli/codex-bridge.js.map +1 -0
  61. package/dist/src/cli/codex.d.ts +2 -0
  62. package/dist/src/cli/codex.js +469 -0
  63. package/dist/src/cli/codex.js.map +1 -0
  64. package/dist/src/cli/coverage.d.ts +61 -0
  65. package/dist/src/cli/daemon.d.ts +120 -0
  66. package/dist/src/cli/daemon.js +4 -1
  67. package/dist/src/cli/daemon.js.map +1 -1
  68. package/dist/src/cli/doctor.d.ts +129 -0
  69. package/dist/src/cli/doctor.js +586 -17
  70. package/dist/src/cli/doctor.js.map +1 -1
  71. package/dist/src/cli/env.d.ts +65 -0
  72. package/dist/src/cli/execute.d.ts +202 -0
  73. package/dist/src/cli/execute.js +25 -2
  74. package/dist/src/cli/execute.js.map +1 -1
  75. package/dist/src/cli/exit-codes.d.ts +73 -0
  76. package/dist/src/cli/feedback.d.ts +60 -0
  77. package/dist/src/cli/gate-window.d.ts +40 -0
  78. package/dist/src/cli/gate.d.ts +68 -0
  79. package/dist/src/cli/git-scope.d.ts +190 -0
  80. package/dist/src/cli/gloss-attach.d.ts +85 -0
  81. package/dist/src/cli/gloss-codex-child.d.ts +9 -0
  82. package/dist/src/cli/gloss-codex.d.ts +24 -0
  83. package/dist/src/cli/gloss-options.d.ts +42 -0
  84. package/dist/src/cli/gloss.d.ts +265 -0
  85. package/dist/src/cli/help.d.ts +107 -0
  86. package/dist/src/cli/help.js +320 -93
  87. package/dist/src/cli/help.js.map +1 -1
  88. package/dist/src/cli/hook-codex.d.ts +126 -0
  89. package/dist/src/cli/hook-codex.js +226 -0
  90. package/dist/src/cli/hook-codex.js.map +1 -0
  91. package/dist/src/cli/hook.d.ts +787 -0
  92. package/dist/src/cli/hook.js +1235 -181
  93. package/dist/src/cli/hook.js.map +1 -1
  94. package/dist/src/cli/import.d.ts +35 -0
  95. package/dist/src/cli/import.js +1 -1
  96. package/dist/src/cli/import.js.map +1 -1
  97. package/dist/src/cli/init.d.ts +84 -0
  98. package/dist/src/cli/init.js +2 -2
  99. package/dist/src/cli/init.js.map +1 -1
  100. package/dist/src/cli/instructions.d.ts +23 -0
  101. package/dist/src/cli/journal.d.ts +41 -0
  102. package/dist/src/cli/log-advance.d.ts +287 -0
  103. package/dist/src/cli/log-advance.js +102 -11
  104. package/dist/src/cli/log-advance.js.map +1 -1
  105. package/dist/src/cli/log-anchor.d.ts +176 -0
  106. package/dist/src/cli/log-checkpoint.d.ts +22 -0
  107. package/dist/src/cli/log-sync.d.ts +243 -0
  108. package/dist/src/cli/log-verbs.d.ts +16 -0
  109. package/dist/src/cli/log-verbs.js +7 -1
  110. package/dist/src/cli/log-verbs.js.map +1 -1
  111. package/dist/src/cli/long-help.d.ts +70 -0
  112. package/dist/src/cli/main.d.ts +77 -0
  113. package/dist/src/cli/main.js +159 -7
  114. package/dist/src/cli/main.js.map +1 -1
  115. package/dist/src/cli/mcp.d.ts +52 -0
  116. package/dist/src/cli/paths.d.ts +56 -0
  117. package/dist/src/cli/payload.d.ts +58 -0
  118. package/dist/src/cli/policy-apply.d.ts +195 -0
  119. package/dist/src/cli/policy-apply.js +573 -0
  120. package/dist/src/cli/policy-apply.js.map +1 -0
  121. package/dist/src/cli/policy.d.ts +43 -0
  122. package/dist/src/cli/policy.js +14 -1
  123. package/dist/src/cli/policy.js.map +1 -1
  124. package/dist/src/cli/preflight.d.ts +501 -0
  125. package/dist/src/cli/preflight.js +689 -45
  126. package/dist/src/cli/preflight.js.map +1 -1
  127. package/dist/src/cli/progress.d.ts +78 -0
  128. package/dist/src/cli/prompt.d.ts +209 -0
  129. package/dist/src/cli/quickstart.d.ts +46 -0
  130. package/dist/src/cli/quickstart.js +297 -0
  131. package/dist/src/cli/quickstart.js.map +1 -0
  132. package/dist/src/cli/records.d.ts +34 -0
  133. package/dist/src/cli/render.d.ts +22 -0
  134. package/dist/src/cli/sandbox.d.ts +51 -0
  135. package/dist/src/cli/sandbox.js +17 -1
  136. package/dist/src/cli/sandbox.js.map +1 -1
  137. package/dist/src/cli/scaffold.d.ts +79 -0
  138. package/dist/src/cli/scaffold.js +1 -1
  139. package/dist/src/cli/setup-adapter.d.ts +137 -0
  140. package/dist/src/cli/setup-adapter.js +38 -4
  141. package/dist/src/cli/setup-adapter.js.map +1 -1
  142. package/dist/src/cli/setup-channel.d.ts +126 -0
  143. package/dist/src/cli/setup-channel.js +28 -1
  144. package/dist/src/cli/setup-channel.js.map +1 -1
  145. package/dist/src/cli/setup-checkpoint.d.ts +57 -0
  146. package/dist/src/cli/setup-common.d.ts +277 -0
  147. package/dist/src/cli/setup-common.js +3 -2
  148. package/dist/src/cli/setup-common.js.map +1 -1
  149. package/dist/src/cli/setup-flow.d.ts +287 -0
  150. package/dist/src/cli/setup-service.d.ts +96 -0
  151. package/dist/src/cli/setup.d.ts +204 -0
  152. package/dist/src/cli/setup.js +94 -2
  153. package/dist/src/cli/setup.js.map +1 -1
  154. package/dist/src/cli/style.d.ts +320 -0
  155. package/dist/src/cli/token.d.ts +39 -0
  156. package/dist/src/cli/up.d.ts +155 -0
  157. package/dist/src/cli/up.js +119 -53
  158. package/dist/src/cli/up.js.map +1 -1
  159. package/dist/src/cli/usage.d.ts +37 -0
  160. package/dist/src/cli/values.d.ts +40 -0
  161. package/dist/src/cli/values.js +3 -4
  162. package/dist/src/cli/values.js.map +1 -1
  163. package/dist/src/cli/vault.d.ts +59 -0
  164. package/dist/src/cli/vault.js +2 -2
  165. package/dist/src/cli/vault.js.map +1 -1
  166. package/dist/src/cli/verb-registry.d.ts +76 -0
  167. package/dist/src/cli/verb-registry.js +344 -11
  168. package/dist/src/cli/verb-registry.js.map +1 -1
  169. package/dist/src/cli/wordmark.d.ts +31 -0
  170. package/dist/src/cli/wordmark.js +2 -2
  171. package/dist/src/codex/broker.d.ts +229 -0
  172. package/dist/src/codex/broker.js +548 -0
  173. package/dist/src/codex/broker.js.map +1 -0
  174. package/dist/src/codex/doctor.d.ts +13 -0
  175. package/dist/src/codex/doctor.js +41 -0
  176. package/dist/src/codex/doctor.js.map +1 -0
  177. package/dist/src/codex/manifest.d.ts +49 -0
  178. package/dist/src/codex/manifest.js +103 -0
  179. package/dist/src/codex/manifest.js.map +1 -0
  180. package/dist/src/codex/runner.d.ts +178 -0
  181. package/dist/src/codex/runner.js +231 -0
  182. package/dist/src/codex/runner.js.map +1 -0
  183. package/dist/src/codex/serve.d.ts +56 -0
  184. package/dist/src/codex/serve.js +98 -0
  185. package/dist/src/codex/serve.js.map +1 -0
  186. package/dist/src/codex/templates.d.ts +41 -0
  187. package/dist/src/codex/templates.js +319 -0
  188. package/dist/src/codex/templates.js.map +1 -0
  189. package/dist/src/codex/trust.d.ts +19 -0
  190. package/dist/src/codex/trust.js +183 -0
  191. package/dist/src/codex/trust.js.map +1 -0
  192. package/dist/src/codex/workspace-commit.d.ts +219 -0
  193. package/dist/src/codex/workspace-commit.js +549 -0
  194. package/dist/src/codex/workspace-commit.js.map +1 -0
  195. package/dist/src/codex/workspace-plan.d.ts +131 -0
  196. package/dist/src/codex/workspace-plan.js +561 -0
  197. package/dist/src/codex/workspace-plan.js.map +1 -0
  198. package/dist/src/core/actor.d.ts +2 -0
  199. package/dist/src/core/actor.js +5 -0
  200. package/dist/src/core/actor.js.map +1 -0
  201. package/dist/src/core/advance-cycle.d.ts +221 -0
  202. package/dist/src/core/advance-cycle.js +66 -2
  203. package/dist/src/core/advance-cycle.js.map +1 -1
  204. package/dist/src/core/agents-md.d.ts +278 -0
  205. package/dist/src/core/agents-md.js +33 -31
  206. package/dist/src/core/agents-md.js.map +1 -1
  207. package/dist/src/core/apply-patch.d.ts +49 -0
  208. package/dist/src/core/apply-patch.js +266 -0
  209. package/dist/src/core/apply-patch.js.map +1 -0
  210. package/dist/src/core/attest.d.ts +635 -0
  211. package/dist/src/core/attest.js +326 -4
  212. package/dist/src/core/attest.js.map +1 -1
  213. package/dist/src/core/audit.d.ts +510 -0
  214. package/dist/src/core/audit.js +13 -0
  215. package/dist/src/core/audit.js.map +1 -1
  216. package/dist/src/core/budgets.d.ts +238 -0
  217. package/dist/src/core/channel-owner.d.ts +213 -0
  218. package/dist/src/core/channel-owner.js +358 -0
  219. package/dist/src/core/channel-owner.js.map +1 -0
  220. package/dist/src/core/checkpoint.d.ts +500 -0
  221. package/dist/src/core/child-env.d.ts +88 -0
  222. package/dist/src/core/clock.d.ts +52 -0
  223. package/dist/src/core/command-class.d.ts +697 -0
  224. package/dist/src/core/command-class.js +713 -25
  225. package/dist/src/core/command-class.js.map +1 -1
  226. package/dist/src/core/commit-guard.d.ts +272 -0
  227. package/dist/src/core/commit-guard.js +424 -0
  228. package/dist/src/core/commit-guard.js.map +1 -0
  229. package/dist/src/core/coverage-sources/adapter.d.ts +40 -0
  230. package/dist/src/core/coverage-sources/gh.d.ts +48 -0
  231. package/dist/src/core/coverage-sources/git.d.ts +101 -0
  232. package/dist/src/core/coverage.d.ts +217 -0
  233. package/dist/src/core/credential-spec.d.ts +72 -0
  234. package/dist/src/core/daemon-actor.d.ts +45 -0
  235. package/dist/src/core/daemon-actor.js +54 -0
  236. package/dist/src/core/daemon-actor.js.map +1 -0
  237. package/dist/src/core/dark-session.d.ts +432 -0
  238. package/dist/src/core/dark-session.js +266 -82
  239. package/dist/src/core/dark-session.js.map +1 -1
  240. package/dist/src/core/decision-refusal.d.ts +206 -0
  241. package/dist/src/core/decision-refusal.js +24 -2
  242. package/dist/src/core/decision-refusal.js.map +1 -1
  243. package/dist/src/core/env-file.d.ts +455 -0
  244. package/dist/src/core/env-file.js +60 -1
  245. package/dist/src/core/env-file.js.map +1 -1
  246. package/dist/src/core/execute.d.ts +871 -0
  247. package/dist/src/core/execute.js +59 -8
  248. package/dist/src/core/execute.js.map +1 -1
  249. package/dist/src/core/frontmatter.d.ts +78 -0
  250. package/dist/src/core/gate-window.d.ts +312 -0
  251. package/dist/src/core/gate.d.ts +1449 -0
  252. package/dist/src/core/gate.js +149 -14
  253. package/dist/src/core/gate.js.map +1 -1
  254. package/dist/src/core/gesture-refusal.d.ts +166 -0
  255. package/dist/src/core/gesture-refusal.js +188 -0
  256. package/dist/src/core/gesture-refusal.js.map +1 -0
  257. package/dist/src/core/git-run.d.ts +73 -0
  258. package/dist/src/core/harness-version.d.ts +157 -0
  259. package/dist/src/core/harness-version.js +4 -1
  260. package/dist/src/core/harness-version.js.map +1 -1
  261. package/dist/src/core/harness-wait.d.ts +55 -0
  262. package/dist/src/core/head-retry.d.ts +107 -0
  263. package/dist/src/core/instance.d.ts +310 -0
  264. package/dist/src/core/instance.js +113 -0
  265. package/dist/src/core/instance.js.map +1 -1
  266. package/dist/src/core/intake-limits.d.ts +247 -0
  267. package/dist/src/core/jcs.d.ts +52 -0
  268. package/dist/src/core/journal.d.ts +144 -0
  269. package/dist/src/core/live-draw.d.ts +436 -0
  270. package/dist/src/core/log-reconcile.d.ts +89 -0
  271. package/dist/src/core/log-subscribe.d.ts +36 -0
  272. package/dist/src/core/log-subscribe.js +162 -0
  273. package/dist/src/core/log-subscribe.js.map +1 -0
  274. package/dist/src/core/log.d.ts +316 -0
  275. package/dist/src/core/log.js.map +1 -1
  276. package/dist/src/core/loop.d.ts +274 -0
  277. package/dist/src/core/loop.js +11 -0
  278. package/dist/src/core/loop.js.map +1 -1
  279. package/dist/src/core/md-fence.d.ts +41 -0
  280. package/dist/src/core/money.d.ts +147 -0
  281. package/dist/src/core/payload-census.d.ts +74 -0
  282. package/dist/src/core/payload-store.d.ts +175 -0
  283. package/dist/src/core/payload.d.ts +71 -0
  284. package/dist/src/core/policy-diff.d.ts +292 -0
  285. package/dist/src/core/policy-diff.js +27 -4
  286. package/dist/src/core/policy-diff.js.map +1 -1
  287. package/dist/src/core/policy-expectations.d.ts +199 -0
  288. package/dist/src/core/policy-explain.d.ts +160 -0
  289. package/dist/src/core/policy-explain.js +63 -3
  290. package/dist/src/core/policy-explain.js.map +1 -1
  291. package/dist/src/core/policy-load.d.ts +567 -0
  292. package/dist/src/core/policy-load.js +36 -6
  293. package/dist/src/core/policy-load.js.map +1 -1
  294. package/dist/src/core/policy-match.d.ts +324 -0
  295. package/dist/src/core/policy-match.js +72 -9
  296. package/dist/src/core/policy-match.js.map +1 -1
  297. package/dist/src/core/policy-proposal.d.ts +317 -0
  298. package/dist/src/core/policy-proposal.js +102 -2
  299. package/dist/src/core/policy-proposal.js.map +1 -1
  300. package/dist/src/core/prompt-layout.d.ts +221 -0
  301. package/dist/src/core/protected-path-guard.d.ts +566 -0
  302. package/dist/src/core/protected-path-guard.js +848 -55
  303. package/dist/src/core/protected-path-guard.js.map +1 -1
  304. package/dist/src/core/question-preempted.d.ts +141 -0
  305. package/dist/src/core/question-preempted.js +152 -0
  306. package/dist/src/core/question-preempted.js.map +1 -0
  307. package/dist/src/core/read-scope.d.ts +172 -0
  308. package/dist/src/core/read-scope.js +252 -0
  309. package/dist/src/core/read-scope.js.map +1 -0
  310. package/dist/src/core/registration.d.ts +25 -0
  311. package/dist/src/core/reindex.d.ts +99 -0
  312. package/dist/src/core/sampler.d.ts +313 -0
  313. package/dist/src/core/sandbox.d.ts +371 -0
  314. package/dist/src/core/sandbox.js +190 -1
  315. package/dist/src/core/sandbox.js.map +1 -1
  316. package/dist/src/core/seal.d.ts +165 -0
  317. package/dist/src/core/sender-identity.d.ts +476 -0
  318. package/dist/src/core/sender-identity.js +572 -0
  319. package/dist/src/core/sender-identity.js.map +1 -0
  320. package/dist/src/core/shlex.d.ts +102 -0
  321. package/dist/src/core/shlex.js +159 -0
  322. package/dist/src/core/shlex.js.map +1 -0
  323. package/dist/src/core/state.d.ts +505 -0
  324. package/dist/src/core/task-file.d.ts +185 -0
  325. package/dist/src/core/telegram-config.d.ts +93 -0
  326. package/dist/src/core/token.d.ts +409 -0
  327. package/dist/src/core/token.js +21 -38
  328. package/dist/src/core/token.js.map +1 -1
  329. package/dist/src/core/validate.d.ts +138 -0
  330. package/dist/src/core/values.d.ts +147 -0
  331. package/dist/src/core/values.js +36 -1
  332. package/dist/src/core/values.js.map +1 -1
  333. package/dist/src/core/vault.d.ts +291 -0
  334. package/dist/src/core/verified-snapshot.d.ts +204 -0
  335. package/dist/src/core/verify.d.ts +336 -0
  336. package/dist/src/core/version.d.ts +8 -0
  337. package/dist/src/core/wysiwys.d.ts +370 -0
  338. package/dist/src/daemon/advance-child.d.ts +39 -0
  339. package/dist/src/daemon/advance.d.ts +476 -0
  340. package/dist/src/daemon/advance.js +25 -4
  341. package/dist/src/daemon/advance.js.map +1 -1
  342. package/dist/src/daemon/audit.d.ts +87 -0
  343. package/dist/src/daemon/daemon.d.ts +1180 -0
  344. package/dist/src/daemon/daemon.js +9 -0
  345. package/dist/src/daemon/daemon.js.map +1 -1
  346. package/dist/src/daemon/dark-session.d.ts +64 -0
  347. package/dist/src/daemon/draw-child.d.ts +36 -0
  348. package/dist/src/daemon/draw.d.ts +154 -0
  349. package/dist/src/daemon/git-evidence.d.ts +173 -0
  350. package/dist/src/daemon/git-evidence.js +1 -1
  351. package/dist/src/daemon/projection.d.ts +180 -0
  352. package/dist/src/daemon/prune.d.ts +207 -0
  353. package/dist/src/mcp/http.d.ts +113 -0
  354. package/dist/src/mcp/server.d.ts +265 -0
  355. package/dist/src/mcp/server.js +17 -1
  356. package/dist/src/mcp/server.js.map +1 -1
  357. package/docs/adapter-api.md +106 -0
  358. package/docs/cli-reference.md +1316 -63
  359. package/docs/codex-enforced-session.md +103 -0
  360. package/docs/codex-workspace-broker.md +118 -0
  361. package/package.json +14 -2
  362. package/schema/codex-instance.schema.json +82 -0
  363. package/schema/event.schema.json +539 -9
  364. package/schema/fixtures/codex-instance/invalid/unpinned-codex-version.json +40 -0
  365. package/schema/fixtures/codex-instance/valid/canonical.json +40 -0
  366. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
  367. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
  368. package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
  369. package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
  370. package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
  371. package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
  372. package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
  373. package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
  374. package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
  375. package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
  376. package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
  377. package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
  378. package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
  379. package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
  380. package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
  381. package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
  382. package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
  383. package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
  384. package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
  385. package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
  386. package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
  387. package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
  388. package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
  389. package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
  390. package/schema/fixtures/policy/valid/canonical.json +1 -1
  391. package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
  392. package/schema/fixtures/policy-md/valid/canonical.md +1 -1
  393. package/schema/fixtures/policy-md/valid/with-values.md +5 -7
  394. package/schema/fixtures/values/invalid/class-shaped.json +1 -1
  395. package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
  396. package/schema/fixtures/values/invalid/non-string-item.json +1 -1
  397. package/schema/fixtures/values/invalid/over-cap.json +1 -1
  398. package/schema/fixtures/values/invalid/unknown-key.json +1 -1
  399. package/schema/fixtures/values/invalid/version-float.json +1 -0
  400. package/schema/fixtures/values/invalid/version-integer.json +1 -0
  401. package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
  402. package/schema/fixtures/values/valid/empty-lists.json +2 -3
  403. package/schema/fixtures/values/valid/full.json +5 -7
  404. package/schema/fixtures/values/valid/minimal.json +1 -1
  405. package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
  406. package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
  407. package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
  408. package/schema/fixtures/values-md/invalid/version-1.md +69 -0
  409. package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
  410. package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
  411. package/schema/fixtures/values-md/valid/absent.md +1 -1
  412. package/schema/fixtures/values-md/valid/with-values.md +5 -7
  413. package/schema/policy.schema.json +75 -3
  414. package/schema/values.schema.json +7 -11
  415. package/templates/codex/README.md +9 -0
  416. package/schema/fixtures/values/invalid/version-string.json +0 -1
@@ -15,33 +15,73 @@
15
15
  *
16
16
  * ## What it is allowed to do
17
17
  *
18
- * Read git, and at most two writes: a `--ff-only` merge, and `npm run build`.
18
+ * Read git, and at most four writes: a `--ff-only` merge, `npm run build`,
19
+ * when the merge refused over an untracked file under `backlog/tasks/`
20
+ * that main already contains, clearing that file out of the way (APRV-300, and
21
+ * the rules it obeys are in {@link reconcileUntrackedTaskFiles}), and the one
22
+ * `approval log sync` performs on its behalf (APRV-346, below).
19
23
  *
20
24
  * It never resets, never stashes, never checks anything out, and never touches
21
- * the working log. That list is not conservatism for its own sake — it is fork 2
22
- * of 2026-08-20 (APRV-104's notes, and the reason `approval log sync` exists at
23
- * all): a working `events.jsonl` rewound through git underneath a live appender
24
- * is two chains where there was one. `--ff-only` cannot rewind a file that
25
- * upstream did not change, and when upstream DID change it while the working
26
- * copy is dirty, this module refuses and names `approval log sync`, which is the
27
- * verb that knows how to do it safely.
25
+ * the working log ITSELF. That list is not conservatism for its own sake — it is
26
+ * fork 2 of 2026-08-20 (APRV-104's notes, and the reason `approval log sync`
27
+ * exists at all): a working `events.jsonl` rewound through git underneath a live
28
+ * appender is two chains where there was one. `--ff-only` cannot rewind a file
29
+ * that upstream did not change, and when upstream DID change it while the
30
+ * working copy is dirty, the file is moved by `cli/log-sync.ts` and by nothing
31
+ * here.
32
+ *
33
+ * ## The routine collision, and the one that is not (APRV-346)
34
+ *
35
+ * Every records advance moves `origin/main`'s `.approval/log/events.jsonl`
36
+ * while the hook keeps appending locally, so "upstream changed the log and so
37
+ * did this working copy" is the NORMAL state of the primary checkout after a
38
+ * merge rather than an edge case. Refusing it sent the operator to `approval log
39
+ * sync` and then back to `approval up` every single time, which is a two-step
40
+ * ritual for a state the preflight can already tell apart from a fork.
41
+ *
42
+ * So the collision is now a question rather than a verdict: is the working log a
43
+ * byte-for-byte EXTENSION of the committed one (main's records 1..N unchanged,
44
+ * local N+1.. following) or are they two chains that share a prefix and then
45
+ * differ? {@link planLogSync} asks `core/log-reconcile.ts` — the same comparison
46
+ * `log sync` and doctor's `log-drift` ask — and answers a plan or `null`:
47
+ *
48
+ * - a plan, and {@link runPreflight} calls `logSync` itself. Not a
49
+ * reimplementation of it: the APRV-215 ceremony (one hold of the append lock,
50
+ * snapshot, baseline, fast-forward, reconcile, rebuild the projections,
51
+ * post-verify) stays the single implementation, and this module supplies a
52
+ * caller rather than a copy;
53
+ * - `null`, and the old `up-preflight-log-diverged` refusal stands unchanged.
54
+ * A fork, a log that does not verify, a log at some path other than the
55
+ * repository's own, or any OTHER upstream-touched path locally modified all
56
+ * answer `null`. Every one of those is a judgment, and this module makes none.
28
57
  *
29
58
  * ## Refusals, not repairs
30
59
  *
31
- * Three codes, evaluated in this order, each firing for exactly one condition:
60
+ * Four codes, each firing for exactly one condition. The first three are
61
+ * evaluated in this order, before anything is written:
32
62
  *
33
63
  * - `up-preflight-behind-ahead` — `origin/<branch>..HEAD` is non-empty. Local
34
64
  * commits exist that the remote does not have. A fast-forward is not the
35
65
  * operation for that state, and guessing which side to keep is a decision.
36
66
  * - `up-preflight-log-diverged` — the upstream range changes the working log or
37
- * the queue projection, and the working copy has uncommitted changes to them.
38
- * This is the case the human could not judge by eye, and it is `approval log
39
- * sync`'s whole subject.
67
+ * the queue projection, the working copy has uncommitted changes to them, AND
68
+ * the two chains are not in a prefix relationship (or cannot be compared at
69
+ * all). This is the case the human could not judge by eye, and it is `approval
70
+ * log sync`'s whole subject. The routine case — a working log that merely
71
+ * extends the committed one — is reconciled rather than refused (APRV-346).
40
72
  * - `up-preflight-dirty-protected` — some OTHER path the upstream range changes
41
73
  * is locally modified, so `git merge --ff-only` would refuse to overwrite it.
42
74
  * Named separately because the repair is different: look at the edit and
43
75
  * decide, or start on the current build with `--no-preflight`.
44
76
  *
77
+ * The fourth is answered after the merge has already refused, and only for the
78
+ * one path shape where two checkouts routinely author one file (APRV-300):
79
+ *
80
+ * - `up-preflight-task-file-conflict` — an untracked file under
81
+ * `backlog/tasks/` stopped the fast-forward, and it holds lines the incoming
82
+ * copy does not. See {@link reconcileUntrackedTaskFiles} for what happens
83
+ * when it holds none, and why that is a claim rather than an assumption.
84
+ *
45
85
  * `git reset --hard` appears in none of them, and never will: it is the command
46
86
  * that turns "your checkout is confusing" into "your work is gone".
47
87
  *
@@ -67,12 +107,18 @@
67
107
  * operator did not ask for, and says out loud that its answer is only as fresh
68
108
  * as the last fetch.
69
109
  */
70
- import { existsSync, readdirSync, statSync } from "node:fs";
110
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, } from "node:fs";
71
111
  import { spawn, spawnSync } from "node:child_process";
72
- import { dirname, join, resolve as resolvePathSegments } from "node:path";
112
+ import { basename, dirname, join, resolve as resolvePathSegments } from "node:path";
73
113
  import { fileURLToPath } from "node:url";
114
+ import { checkAttestation, policyBytesHash } from "../core/attest.js";
115
+ import { compareChains } from "../core/log-reconcile.js";
116
+ import { POLICY_FILENAMES } from "../core/policy-load.js";
117
+ import { verifyWithRecords } from "../core/verify.js";
74
118
  import { EXIT_IO, EXIT_OK } from "./exit-codes.js";
75
- import { currentBranch, failureText, fetchBase, git, repoPath, repoRoot, } from "./git-scope.js";
119
+ import { currentBranch, failureText, fetchBase, git, repoPath, repoRoot, showBlob, } from "./git-scope.js";
120
+ import { logSync } from "./log-sync.js";
121
+ import { DEFAULT_LOG_PATH } from "./paths.js";
76
122
  import { runbook, style } from "./style.js";
77
123
  // ---------------------------------------------------------------------------
78
124
  // Build freshness (moved here from cli/doctor.ts, APRV-215)
@@ -269,6 +315,7 @@ export const PREFLIGHT_REFUSAL_CODES = [
269
315
  "up-preflight-behind-ahead",
270
316
  "up-preflight-log-diverged",
271
317
  "up-preflight-dirty-protected",
318
+ "up-preflight-task-file-conflict",
272
319
  ];
273
320
  const ZERO = {
274
321
  behind_by: 0,
@@ -276,6 +323,7 @@ const ZERO = {
276
323
  log_touched: false,
277
324
  dist_stale: false,
278
325
  reexec: false,
326
+ log_synced: false,
279
327
  };
280
328
  /** `git status --porcelain -uno` as a set of repo-relative paths. */
281
329
  function dirtyPaths(root) {
@@ -323,7 +371,68 @@ function counts(root, base) {
323
371
  return { behind, ahead };
324
372
  }
325
373
  function skipped(detail, root) {
326
- return { ok: true, facts: { ...ZERO, action: "skipped" }, detail, warning: null, target: null, root };
374
+ return {
375
+ ok: true,
376
+ facts: { ...ZERO, action: "skipped" },
377
+ detail,
378
+ warning: null,
379
+ target: null,
380
+ root,
381
+ sync: null,
382
+ };
383
+ }
384
+ /**
385
+ * Is this protected-path collision the routine one, and what would clearing it
386
+ * keep? `null` means "not a question this module answers", and the caller
387
+ * refuses (APRV-346).
388
+ *
389
+ * Four conditions, and every one of them is a reason to refuse rather than a
390
+ * degree of confidence:
391
+ *
392
+ * 1. **The log is the repository's own.** `approval log sync` reconciles the log
393
+ * at `.approval/log/events.jsonl` in the checkout it runs in, so a preflight
394
+ * pointed at some other file with `--log` must not hand it a reconcile of a
395
+ * file it was never asked about. That is a mismatch a test fixture can have
396
+ * and the primary checkout cannot, which is exactly when a guard is cheap.
397
+ * 2. **Nothing else is in the merge's way.** `logSync` ends in `git merge
398
+ * --ff-only`, which refuses over any dirty tracked path. A dirty unrelated
399
+ * file is the `up-preflight-dirty-protected` conversation and is not made
400
+ * better by starting a ceremony that will stop half-way.
401
+ * 3. **Both chains can be read.** `compareChains` verifies each side before it
402
+ * compares a single seq, and a side that does not verify is a refusal rather
403
+ * than an answer (SPEC §11.1: enforcement paths read only verified records).
404
+ * 4. **They are in a prefix relationship.** `ahead`, `behind` and `equal` all
405
+ * mean the longer chain contains the other whole, so adopting it extends and
406
+ * rewinds nothing. `diverged` means two appenders built different records on
407
+ * one predecessor, hash chains do not merge, and no verb here will pick.
408
+ *
409
+ * Read-only throughout, so doctor can ask the same question without doctor ever
410
+ * having done anything.
411
+ */
412
+ function planLogSync(root, logPath, target, upstream, dirty, protectedPaths) {
413
+ const logRelative = repoPath(root, logPath);
414
+ if (logRelative !== DEFAULT_LOG_PATH)
415
+ return null;
416
+ const others = [...upstream].filter((path) => dirty.has(path) && !protectedPaths.has(path));
417
+ if (others.length > 0)
418
+ return null;
419
+ let working;
420
+ try {
421
+ const bytes = readIfPresent(logPath);
422
+ working = bytes === null ? "" : bytes.toString("utf8");
423
+ }
424
+ catch {
425
+ return null;
426
+ }
427
+ const committed = showBlob(root, target, logRelative);
428
+ if (committed === null)
429
+ return null;
430
+ const compared = compareChains({ label: `the working log ${logPath}`, text: working }, { label: `the committed log at ${target.slice(0, 12)}`, text: committed.toString("utf8") });
431
+ if (!compared.ok)
432
+ return null;
433
+ if (compared.drift.relation === "diverged")
434
+ return null;
435
+ return { relation: compared.drift.relation, kept: compared.drift.ahead };
327
436
  }
328
437
  /**
329
438
  * The whole judgment, and not one byte of action.
@@ -360,6 +469,7 @@ export function inspectPreflight(input) {
360
469
  warning: fetched.message,
361
470
  target: null,
362
471
  root,
472
+ sync: null,
363
473
  };
364
474
  }
365
475
  base = fetched.sha;
@@ -391,6 +501,7 @@ export function inspectPreflight(input) {
391
501
  dist_stale: stale,
392
502
  action,
393
503
  reexec: false,
504
+ log_synced: false,
394
505
  });
395
506
  // 1. Ahead. Nothing else is worth judging: whatever the upstream range holds,
396
507
  // a fast-forward is not the operation for a checkout carrying commits the
@@ -436,6 +547,7 @@ export function inspectPreflight(input) {
436
547
  root,
437
548
  target: base,
438
549
  warning,
550
+ sync: null,
439
551
  facts: facts(stale ? "rebuild" : "none"),
440
552
  detail: stale
441
553
  ? `up to date with ${remote}/${branch}, and the build is older than the sources`
@@ -449,38 +561,29 @@ export function inspectPreflight(input) {
449
561
  // does this — snapshot, baseline, fast-forward, reconcile, rebuild the
450
562
  // projections — and this module deliberately does not reimplement it.
451
563
  const collidingProtected = [...protectedPaths].filter((path) => upstream.has(path) && dirty.has(path));
452
- if (collidingProtected.length > 0) {
564
+ // ...unless the collision is the routine one, which is the state the primary
565
+ // checkout is in after every records advance (APRV-346). `planLogSync` asks
566
+ // the chains themselves; a plan is a reconcile `runPreflight` will delegate to
567
+ // `approval log sync`, and `null` is the refusal below, unchanged.
568
+ const plan = collidingProtected.length === 0
569
+ ? null
570
+ : planLogSync(root, input.logPath, base, upstream, dirty, protectedPaths);
571
+ if (collidingProtected.length > 0 && plan === null) {
453
572
  return {
454
573
  ok: false,
455
574
  root,
456
575
  facts: facts("refused"),
457
- refusal: {
458
- code: "up-preflight-log-diverged",
459
- headline: `${remote}/${branch} changed ${collidingProtected.join(" and ")} and so did this working copy`,
460
- state: [
461
- `${plural(counted.behind, "commit")} behind ${remote}/${branch}`,
462
- `changed on both sides: ${collidingProtected.join(", ")}`,
463
- "the working log was not read, moved, or rewound",
464
- ],
465
- steps: [
466
- {
467
- command: "approval log sync",
468
- note: "snapshots the working log, fast-forwards, reconciles the chain",
469
- },
470
- { command: "approval up", note: "again, once sync reports clean" },
471
- ],
472
- footer: [
473
- "a fast-forward over a log another process is appending to is how one chain becomes two",
474
- "the ritual and what it refuses: docs/cli-reference.md#log-sync",
475
- ],
476
- next: "approval log sync",
477
- },
576
+ refusal: divergedRefusal(remote, branch, counted.behind, collidingProtected),
478
577
  };
479
578
  }
480
579
  // 3. Any other local modification in the fast-forward's way. `--ff-only` would
481
580
  // refuse rather than clobber it, so the refusal is reported here, where it
482
- // can say which file and what the two ways out are.
483
- const colliding = [...upstream].filter((path) => dirty.has(path)).sort();
581
+ // can say which file and what the two ways out are. The protected pair is
582
+ // exempt when there is a plan: `log sync` is the writer that moves those
583
+ // two, and it has already been asked whether it can.
584
+ const colliding = [...upstream]
585
+ .filter((path) => dirty.has(path) && !(plan !== null && protectedPaths.has(path)))
586
+ .sort();
484
587
  if (colliding.length > 0) {
485
588
  return {
486
589
  ok: false,
@@ -514,8 +617,46 @@ export function inspectPreflight(input) {
514
617
  root,
515
618
  target: base,
516
619
  warning,
620
+ sync: plan,
517
621
  facts: facts(stale ? "fast-forward+rebuild" : "fast-forward"),
518
- detail: `${plural(counted.behind, "commit")} behind ${remote}/${branch}, and the upstream range is safe to fast-forward${stale ? "; the build is older than the sources" : ""}`,
622
+ detail: `${plural(counted.behind, "commit")} behind ${remote}/${branch}, and the upstream range is safe to fast-forward${plan === null ? "" : `, once \`approval log sync\`'s reconcile has kept the ${plural(plan.kept, "local record")}`}${stale ? "; the build is older than the sources" : ""}`,
623
+ };
624
+ }
625
+ /**
626
+ * The `up-preflight-log-diverged` refusal, in one place.
627
+ *
628
+ * Two callers now: the judgment that decides the chains cannot be reconciled
629
+ * without a human, and the one case where `logSync` — asked to reconcile a pair
630
+ * this module had already found reconcilable — refuses anyway. The second is a
631
+ * race (an appender that beat the lock, a fork that landed between the read and
632
+ * the ceremony) or a machine problem, and it says so in `extra` rather than in a
633
+ * code of its own: the repair is the same repair, and a code whose repair is
634
+ * another code's is not a distinct refusal, it is a synonym.
635
+ */
636
+ function divergedRefusal(remote, branch, behind, collidingProtected, extra = []) {
637
+ return {
638
+ code: "up-preflight-log-diverged",
639
+ headline: `${remote}/${branch} changed ${collidingProtected.join(" and ")} and so did this working copy`,
640
+ state: [
641
+ `${plural(behind, "commit")} behind ${remote}/${branch}`,
642
+ `changed on both sides: ${collidingProtected.join(", ")}`,
643
+ ...extra,
644
+ extra.length === 0
645
+ ? "the working log was not read, moved, or rewound"
646
+ : "the reconcile restored the working log exactly as it found it, and nothing was started",
647
+ ],
648
+ steps: [
649
+ {
650
+ command: "approval log sync",
651
+ note: "snapshots the working log, fast-forwards, reconciles the chain",
652
+ },
653
+ { command: "approval up", note: "again, once sync reports clean" },
654
+ ],
655
+ footer: [
656
+ "a fast-forward over a log another process is appending to is how one chain becomes two",
657
+ "the ritual and what it refuses: docs/cli-reference.md#log-sync",
658
+ ],
659
+ next: "approval log sync",
519
660
  };
520
661
  }
521
662
  function plural(count, noun) {
@@ -538,8 +679,65 @@ export function runPreflight(input, spawnBuild = npmBuild) {
538
679
  if (facts.action === "skipped" || facts.action === "fetch-failed") {
539
680
  return { ok: true, facts, detail: report.detail, warning: report.warning };
540
681
  }
682
+ // What the task-file reconciliation below cleared, or `null` when it had
683
+ // nothing to do. It rides out on the warning line so the aside directory is
684
+ // printed where the operator is already looking.
685
+ let cleared = null;
686
+ // The routine protected-path collision, reconciled rather than refused
687
+ // (APRV-346). This runs BEFORE the fast-forward below and does that
688
+ // fast-forward itself: `logSync` holds the append lock for its whole ceremony
689
+ // — snapshot, baseline, `git merge --ff-only`, reconcile, rebuild the
690
+ // projections, post-verify — and this module supplies a caller for it rather
691
+ // than a second copy of any of that. The merge below then finds the checkout
692
+ // already at the target and says so.
693
+ let synced = null;
694
+ if (report.sync !== null && report.root !== null) {
695
+ const remote = input.remote ?? "origin";
696
+ const branch = input.branch ?? currentBranch(report.root) ?? "main";
697
+ const result = logSync({ cwd: report.root, remote, branch });
698
+ if (!result.ok) {
699
+ // Asked for a reconcile this module had already found reconcilable, and
700
+ // refused: an appender that took the lock first, a fork that landed in
701
+ // between, or git itself. Either way nothing starts, and the sync's own
702
+ // code and sentence go into the refusal so the operator is not sent to
703
+ // read a second one.
704
+ return {
705
+ ok: false,
706
+ facts: { ...facts, action: "refused" },
707
+ refusal: divergedRefusal(remote, branch, facts.behind_by, [repoPath(report.root, input.logPath)], [`approval log sync refused at its ${result.step} step (${result.code}): ${result.message}`]),
708
+ };
709
+ }
710
+ synced = {
711
+ commit: result.report.commitAfter,
712
+ kept: report.sync.kept,
713
+ relation: report.sync.relation,
714
+ remote,
715
+ branch,
716
+ };
717
+ }
541
718
  if (facts.behind_by > 0 && report.root !== null && report.target !== null) {
542
- const merged = git(["merge", "--ff-only", report.target], report.root);
719
+ const root = report.root;
720
+ const target = report.target;
721
+ let merged = git(["merge", "--ff-only", target], root);
722
+ if (!merged.ok) {
723
+ const reconciled = reconcileUntrackedTaskFiles(root, target, failureText(merged));
724
+ if (reconciled.kind === "refused") {
725
+ return { ok: false, facts: { ...facts, action: "refused" }, refusal: reconciled.refusal };
726
+ }
727
+ if (reconciled.kind === "failed") {
728
+ return {
729
+ ok: false,
730
+ facts: { ...facts, action: "refused" },
731
+ failed: { step: reconciled.step, message: reconciled.message },
732
+ };
733
+ }
734
+ if (reconciled.kind === "cleared") {
735
+ cleared = reconciled.note;
736
+ // Once. A second failure is a different failure — the merge was asked
737
+ // again only because the thing that stopped it is provably gone.
738
+ merged = git(["merge", "--ff-only", target], root);
739
+ }
740
+ }
543
741
  if (!merged.ok) {
544
742
  return {
545
743
  ok: false,
@@ -605,12 +803,262 @@ export function runPreflight(input, spawnBuild = npmBuild) {
605
803
  const plan = stale && build ? reexecPlan(input.root) : null;
606
804
  return {
607
805
  ok: true,
608
- facts: { ...settled, reexec: plan !== null },
806
+ facts: { ...settled, reexec: plan !== null, log_synced: synced !== null },
609
807
  detail: report.detail,
610
- warning,
808
+ warning: [warning, cleared].filter((part) => part !== null).join("; ") || null,
611
809
  ...(plan === null ? {} : { reexec: plan }),
810
+ ...(synced === null ? {} : { synced }),
612
811
  };
613
812
  }
813
+ // ---------------------------------------------------------------------------
814
+ // Untracked task files in the fast-forward's way (APRV-300)
815
+ // ---------------------------------------------------------------------------
816
+ /**
817
+ * The one directory this reconciliation is allowed to touch.
818
+ *
819
+ * Backlog.md task files are the only path in this repository where the same
820
+ * file is routinely authored in two checkouts at once: a lane files a task on
821
+ * its branch and the primary files it again with `backlog task create`, so the
822
+ * primary holds untracked bytes at a path the incoming commit carries. Nothing
823
+ * else has that shape, and widening the prefix would turn a narrow, provable
824
+ * case into a general licence to move an operator's files.
825
+ */
826
+ const TASK_FILE_PREFIX = "backlog/tasks/";
827
+ /** Git's own sentence when a fast-forward would clobber an untracked file. */
828
+ const UNTRACKED_HEADLINE = "untracked working tree files would be overwritten";
829
+ /**
830
+ * The paths out of that message, or `null` when this was some other failure.
831
+ *
832
+ * {@link failureText} has already joined git's lines with ` | `, so the shape
833
+ * parsed here is `error: The following untracked working tree files would be
834
+ * overwritten by merge: | <path> | <path> | Please move or remove them…`. A
835
+ * path git chose to quote (`core.quotePath`, a name with a control byte or a
836
+ * non-ASCII byte) is answered as `null` rather than unquoted by hand: guessing
837
+ * the spelling of a file about to be moved is the one mistake this whole
838
+ * function exists to avoid, and the existing refusal is a fine answer.
839
+ */
840
+ function untrackedCollisions(message) {
841
+ const parts = message.split(" | ").map((part) => part.trim());
842
+ const start = parts.findIndex((part) => part.includes(UNTRACKED_HEADLINE));
843
+ if (start < 0)
844
+ return null;
845
+ const paths = [];
846
+ for (const part of parts.slice(start + 1)) {
847
+ if (part.length === 0)
848
+ continue;
849
+ if (part.startsWith("Please ") || part === "Aborting")
850
+ break;
851
+ if (part.startsWith('"'))
852
+ return null;
853
+ paths.push(part);
854
+ }
855
+ return paths.length === 0 ? null : paths;
856
+ }
857
+ /**
858
+ * Clear untracked task files out of a fast-forward's way, or refuse saying why.
859
+ *
860
+ * The incident (2026-09-07): a lane filed `backlog/tasks/aprv-299` on its
861
+ * branch and its pull request merged, while the primary checkout held the same
862
+ * path untracked from its own `backlog task create`. `git merge --ff-only`
863
+ * refuses to write over an untracked file, so `approval up` refused, and its
864
+ * next-steps text pointed at `git status`, which cannot say whether the local
865
+ * copy holds anything the incoming one does not. That is the question, and it
866
+ * is answerable, so it is answered here.
867
+ *
868
+ * Three verdicts, in this order, each with a different claim behind it:
869
+ *
870
+ * - **identical bytes** — the local file says nothing the incoming file does
871
+ * not say. Removing it loses nothing, so it is removed;
872
+ * - **every line also in the incoming copy** — the primary's copy is a subset
873
+ * of what main now carries (the ordinary shape: a stub filed by hand, then
874
+ * the lane's copy with a plan and criteria added). Nothing is lost by
875
+ * letting the incoming copy land, but "nothing is lost" is a judgment about
876
+ * an operator's file, so the bytes are moved aside rather than deleted and
877
+ * the destination is printed;
878
+ * - **anything else** — the local copy has lines main lacks. That is a
879
+ * question about which version is wanted, and no verb here will pick.
880
+ *
881
+ * Two passes, and the order is the safety property, exactly as APRV-225's
882
+ * payload reconciliation in `cli/log-sync.ts`: every file is judged before any
883
+ * file is touched, so a refusal over the last one cannot have already removed
884
+ * the first.
885
+ */
886
+ function reconcileUntrackedTaskFiles(root, target, message) {
887
+ const collisions = untrackedCollisions(message);
888
+ if (collisions === null)
889
+ return { kind: "declined" };
890
+ // One path outside `backlog/tasks/` and the whole set is declined. A
891
+ // reconciliation that cleared what it understood and then refused anyway
892
+ // would have moved an operator's files for a merge that was never going to
893
+ // run (AC3).
894
+ if (!collisions.every((path) => path.startsWith(TASK_FILE_PREFIX)))
895
+ return { kind: "declined" };
896
+ const verdicts = [];
897
+ for (const relative of collisions) {
898
+ let local;
899
+ try {
900
+ local = readIfPresent(join(root, relative));
901
+ }
902
+ catch (cause) {
903
+ return {
904
+ kind: "failed",
905
+ step: "reading an untracked task file",
906
+ message: `${relative} stopped the fast-forward and could not be read to judge it: ${detailOf(cause)}. Nothing was moved.`,
907
+ };
908
+ }
909
+ // Gone between the merge's complaint and this read: nothing to clear, and
910
+ // nothing to weigh either. The retry will find out.
911
+ if (local === null)
912
+ continue;
913
+ const incoming = showBlob(root, target, relative);
914
+ if (incoming === null) {
915
+ return {
916
+ kind: "failed",
917
+ step: `git show ${target}:${relative}`,
918
+ message: `the fast-forward stopped on the untracked ${relative} and git could not read the incoming copy to compare it against. Nothing was moved.`,
919
+ };
920
+ }
921
+ if (incoming.equals(local)) {
922
+ verdicts.push({ kind: "remove", relative });
923
+ continue;
924
+ }
925
+ const only = linesOnlyIn(local, incoming);
926
+ verdicts.push(only === 0 ? { kind: "aside", relative } : { kind: "diverged", relative, only });
927
+ }
928
+ const diverged = verdicts.find((verdict) => verdict.kind === "diverged");
929
+ if (diverged !== undefined) {
930
+ const local = join(root, diverged.relative);
931
+ return {
932
+ kind: "refused",
933
+ refusal: {
934
+ code: "up-preflight-task-file-conflict",
935
+ headline: `the untracked ${diverged.relative} has ${plural(diverged.only, "line")} the incoming copy does not`,
936
+ state: [
937
+ `yours: ${local}`,
938
+ `incoming: ${target.slice(0, 12)}:${diverged.relative}`,
939
+ `${plural(diverged.only, "line")} only yours has`,
940
+ "nothing was merged, nothing was moved, and nothing was rebuilt",
941
+ ],
942
+ steps: [
943
+ {
944
+ command: `git show ${target.slice(0, 12)}:${diverged.relative} | diff - ${JSON.stringify(local)}`,
945
+ note: "the two copies, side by side",
946
+ },
947
+ {
948
+ command: `mv ${JSON.stringify(local)} ${JSON.stringify(`${local}.mine`)}`,
949
+ note: "keep yours out of the way, then run approval up again",
950
+ },
951
+ ],
952
+ footer: [
953
+ "an identical copy is removed and a copy main already contains is moved aside; this one is neither",
954
+ "what the preflight will and will not do: docs/cli-reference.md#up",
955
+ ],
956
+ next: `git show ${target.slice(0, 12)}:${diverged.relative}`,
957
+ },
958
+ };
959
+ }
960
+ const removals = verdicts.filter((verdict) => verdict.kind === "remove");
961
+ const asides = verdicts.filter((verdict) => verdict.kind === "aside");
962
+ if (removals.length === 0 && asides.length === 0)
963
+ return { kind: "declined" };
964
+ // The moves go first and the removals second, so a failure part-way has
965
+ // preserved every byte it had a reason to preserve. Either way the message
966
+ // names what already moved: a half-finished clearing an operator cannot see
967
+ // is worse than the collision it was clearing.
968
+ const asideRoot = asidePath(root);
969
+ const moved = [];
970
+ const sofar = () => moved.length === 0 ? "" : ` Already moved aside: ${moved.join(", ")}.`;
971
+ for (const verdict of asides) {
972
+ const from = join(root, verdict.relative);
973
+ const to = join(asideRoot, verdict.relative);
974
+ try {
975
+ mkdirSync(dirname(to), { recursive: true });
976
+ moveFile(from, to);
977
+ }
978
+ catch (cause) {
979
+ return {
980
+ kind: "failed",
981
+ step: "moving an untracked task file aside",
982
+ message: `${verdict.relative} could not be moved to ${to}: ${detailOf(cause)}. Nothing was merged.${sofar()}`,
983
+ };
984
+ }
985
+ moved.push(to);
986
+ }
987
+ for (const verdict of removals) {
988
+ try {
989
+ rmSync(join(root, verdict.relative), { force: true });
990
+ }
991
+ catch (cause) {
992
+ return {
993
+ kind: "failed",
994
+ step: "removing an untracked task file",
995
+ message: `${verdict.relative} is byte-identical to the incoming copy and could not be removed: ${detailOf(cause)}. Nothing was merged.${sofar()}`,
996
+ };
997
+ }
998
+ }
999
+ const said = [];
1000
+ if (removals.length > 0) {
1001
+ said.push(`${plural(removals.length, "untracked task file")} byte-identical to the incoming copy ${removals.length === 1 ? "was" : "were"} removed`);
1002
+ }
1003
+ if (moved.length > 0) {
1004
+ said.push(`${plural(moved.length, "untracked task file")} whose every line the incoming copy already carries ${moved.length === 1 ? "was" : "were"} moved to ${moved.join(", ")}`);
1005
+ }
1006
+ return { kind: "cleared", note: `${said.join("; ")}, and the fast-forward was retried` };
1007
+ }
1008
+ /**
1009
+ * How many lines of `local` do not appear anywhere in `incoming`.
1010
+ *
1011
+ * A set of the incoming lines rather than a diff, and deliberately: the
1012
+ * question is not whether the two files line up, it is whether the local copy
1013
+ * holds any *content* main has not got. A task file reordered by the Backlog.md
1014
+ * CLI, or one whose sections were rewritten in place, is the same information
1015
+ * in a different arrangement, and zero here is exactly the claim that moving
1016
+ * the local copy aside loses nothing.
1017
+ */
1018
+ function linesOnlyIn(local, incoming) {
1019
+ const carried = new Set(incoming.toString("utf8").split("\n"));
1020
+ return local
1021
+ .toString("utf8")
1022
+ .split("\n")
1023
+ .filter((line) => !carried.has(line)).length;
1024
+ }
1025
+ /**
1026
+ * Where a moved-aside file goes: a sibling of the checkout, dated.
1027
+ *
1028
+ * Outside the repository on purpose. Inside it, the file would still be
1029
+ * untracked, `git status` would still show it, and the next fast-forward could
1030
+ * collide with it all over again — which is to say the move would have solved
1031
+ * nothing. A sibling directory is somewhere `ls ..` finds, the date makes two
1032
+ * runs on two days two directories, and nothing here ever removes one: it is
1033
+ * the operator's copy, kept until they say otherwise.
1034
+ */
1035
+ function asidePath(root) {
1036
+ const day = new Date().toISOString().slice(0, 10);
1037
+ return join(dirname(root), `${basename(root)}-preflight-aside-${day}`);
1038
+ }
1039
+ /** `rename`, falling back to copy-and-unlink when the two sit on two devices. */
1040
+ function moveFile(from, to) {
1041
+ try {
1042
+ renameSync(from, to);
1043
+ }
1044
+ catch (cause) {
1045
+ if (cause.code !== "EXDEV")
1046
+ throw cause;
1047
+ copyFileSync(from, to);
1048
+ rmSync(from, { force: true });
1049
+ }
1050
+ }
1051
+ /** The bytes at `path`, or `null` when there is no file there. */
1052
+ function readIfPresent(path) {
1053
+ try {
1054
+ return readFileSync(path);
1055
+ }
1056
+ catch (cause) {
1057
+ if (cause.code === "ENOENT")
1058
+ return null;
1059
+ throw new ScanError(`${path} could not be read: ${detailOf(cause)}`);
1060
+ }
1061
+ }
614
1062
  /**
615
1063
  * The plan to re-exec, or `null` when there is nothing to re-exec into.
616
1064
  *
@@ -683,6 +1131,18 @@ export function describePreflightEvent(event) {
683
1131
  if (event.event === "preflight_warning") {
684
1132
  return { text: `approval: preflight — ${event.message}`, stderr: true };
685
1133
  }
1134
+ if (event.event === "preflight_policy") {
1135
+ return {
1136
+ text: `up: preflight — ${event.detail}${event.fix === null ? "" : `; ${event.fix}`}`,
1137
+ stderr: true,
1138
+ };
1139
+ }
1140
+ if (event.event === "preflight_sync") {
1141
+ return {
1142
+ text: `up: preflight — synced: fast-forwarded to ${event.remote}/${event.branch} ${event.commit.slice(0, 12)}, kept ${String(event.kept)} local records`,
1143
+ stderr: false,
1144
+ };
1145
+ }
686
1146
  const commits = `${String(event.behind_by)} commit${event.behind_by === 1 ? "" : "s"}`;
687
1147
  const did = {
688
1148
  none: "already at the remote tip, on a build no older than the sources",
@@ -702,6 +1162,27 @@ export function describePreflightEvent(event) {
702
1162
  const handover = event.reexec ? ", in a fresh process on the new build" : "";
703
1163
  return { text: `up: preflight — ${did[event.action]}${running}${handover}`, stderr: false };
704
1164
  }
1165
+ /**
1166
+ * The policy file `--policy` names, or the one `--dir` (else `cwd`) holds.
1167
+ *
1168
+ * Discovery, not a load: this answers WHICH FILE, so the preflight can compare
1169
+ * its attested hash against the remote's copy (APRV-342) before either caller
1170
+ * has loaded a policy. The order is `core/policy-load.ts`'s own, so the file
1171
+ * named here is the file the runtime will go on to enforce. `null` when there is
1172
+ * no such file, which is not a finding — `approval doctor`'s `policy` rows are
1173
+ * where an absent policy is somebody's problem.
1174
+ */
1175
+ export function preflightPolicyPath(policyFlag, dirFlag, cwd) {
1176
+ if (policyFlag !== null)
1177
+ return resolvePathSegments(cwd, policyFlag);
1178
+ const dir = dirFlag === null ? cwd : resolvePathSegments(cwd, dirFlag);
1179
+ for (const filename of POLICY_FILENAMES) {
1180
+ const candidate = join(dir, filename);
1181
+ if (existsSync(candidate))
1182
+ return candidate;
1183
+ }
1184
+ return null;
1185
+ }
705
1186
  /**
706
1187
  * Run the preflight, print what it did, and answer whether the caller may start.
707
1188
  *
@@ -733,6 +1214,11 @@ export function startupPreflight(input) {
733
1214
  if (outcome.warning !== null) {
734
1215
  input.emit({ event: "preflight_warning", message: outcome.warning });
735
1216
  }
1217
+ // Before the `preflight` line, because it happened before what that line
1218
+ // reports: the reconcile is what made the fast-forward possible.
1219
+ if (outcome.synced !== undefined) {
1220
+ input.emit({ event: "preflight_sync", ...outcome.synced });
1221
+ }
736
1222
  // Emitted BEFORE the re-exec, and by the parent, because this is the only
737
1223
  // place the whole story is known: the child runs with `--no-preflight` and
738
1224
  // has nothing to say about a fast-forward it did not perform. The commit is
@@ -743,8 +1229,75 @@ export function startupPreflight(input) {
743
1229
  detail: outcome.detail,
744
1230
  ...outcome.facts,
745
1231
  });
1232
+ // APRV-342, and AFTER the fast-forward: a merge that just landed the
1233
+ // amendment is exactly the case where the answer changes, and reporting the
1234
+ // pre-merge one would name an interregnum this process had already left.
1235
+ const interregnum = attestedPolicyLine(input);
1236
+ if (interregnum !== null)
1237
+ input.emit(interregnum);
746
1238
  return { ok: true, reexec: outcome.reexec ?? null };
747
1239
  }
1240
+ /**
1241
+ * The `attested-policy-on-main` line, or `null` when there is nothing to say.
1242
+ *
1243
+ * `null` for a pass, a skip, and for every state the check cannot read: the
1244
+ * preflight's lines report what happened and what an operator has to act on,
1245
+ * and "your policy is where it should be" is neither. It NEVER refuses — see
1246
+ * {@link checkAttestedPolicyOnMain} for why a pending amendment is a normal
1247
+ * state of a repository rather than a fault.
1248
+ *
1249
+ * The log is re-verified here rather than passed in, because the preflight runs
1250
+ * before either caller has opened it. That is one whole-log read at startup, in
1251
+ * a process that is about to read the log on every tick.
1252
+ */
1253
+ function attestedPolicyLine(input) {
1254
+ const policyPath = input.policyPath ?? null;
1255
+ if (policyPath === null)
1256
+ return null;
1257
+ const root = repoRoot(dirname(input.logPath));
1258
+ if (root === null)
1259
+ return null;
1260
+ // The cheap half first, and it answers the common case without opening the
1261
+ // log at all: when the remote's copy of the policy is byte-identical to the
1262
+ // one on disk, "is the attested policy on the remote" has the same answer as
1263
+ // "is the policy on disk attested" — which is doctor's `attestation` row, and
1264
+ // which `up` has no business duplicating on its startup line. The expensive
1265
+ // half below is a whole-log verify, so skipping it whenever the answer is
1266
+ // already settled is the difference between a read per start and a read per
1267
+ // start on a repository mid-amendment.
1268
+ const relative = repoPath(root, policyPath);
1269
+ if (relative.startsWith(".."))
1270
+ return null;
1271
+ const remote = input.remote ?? "origin";
1272
+ const branch = input.branch ?? currentBranch(root) ?? "main";
1273
+ const resolved = git(["rev-parse", "--verify", "--quiet", `refs/remotes/${remote}/${branch}^{commit}`], root);
1274
+ const tip = resolved.stdout.trim();
1275
+ if (!resolved.ok || tip.length === 0)
1276
+ return null;
1277
+ const blob = showBlob(root, tip, relative);
1278
+ let onDisk;
1279
+ try {
1280
+ onDisk = readIfPresent(policyPath);
1281
+ }
1282
+ catch {
1283
+ return null;
1284
+ }
1285
+ if (blob !== null && onDisk !== null && blob.equals(onDisk))
1286
+ return null;
1287
+ const verified = verifyWithRecords(input.logPath);
1288
+ if (verified.result.status !== "clean")
1289
+ return null;
1290
+ const row = checkAttestedPolicyOnMain({
1291
+ policyPath,
1292
+ records: verified.records,
1293
+ root,
1294
+ ...(input.remote === null ? {} : { remote: input.remote }),
1295
+ ...(input.branch === null ? {} : { branch: input.branch }),
1296
+ });
1297
+ if (row.status !== "fail")
1298
+ return null;
1299
+ return { event: "preflight_policy", detail: row.detail, fix: row.fix ?? null };
1300
+ }
748
1301
  /** The short sha this checkout is on, or `null` when git will not say. */
749
1302
  function headCommit(logPath) {
750
1303
  const root = repoRoot(dirname(logPath));
@@ -859,6 +1412,90 @@ function npmBuild(root) {
859
1412
  * never `git`. That constraint predates this row and is the right one — a repair
860
1413
  * line telling an operator to reset a branch would be doctor making a decision.
861
1414
  */
1415
+ /**
1416
+ * `attested-policy-on-main`, doctor's row for the interregnum (APRV-342).
1417
+ *
1418
+ * Between a policy amendment and its pull request merging there is a window
1419
+ * where the attestation is in the log and the amended `APPROVAL.md` is in the
1420
+ * working tree, and `origin/main` carries neither. A fresh checkout of main in
1421
+ * that window has the OLD policy with no attestation covering it, and every
1422
+ * gate operation there refuses `policy-not-attested`.
1423
+ *
1424
+ * Nothing said so. On 2026-09-16 `approval up` ran its preflight in exactly that
1425
+ * state and reported "already at the remote tip"; doctor's `attestation` row
1426
+ * passed, because the LOCAL file is attested and that row asks a different
1427
+ * question. This row asks the missing one: is the policy the log vouches for the
1428
+ * policy `origin/<branch>` carries?
1429
+ *
1430
+ * Read-only, and networkless. The remote tip is read from the last fetch, like
1431
+ * every other answer doctor gives about a remote, and the `policy-amend-<seq>`
1432
+ * branch is looked for among the remote-tracking refs rather than asked of
1433
+ * GitHub: a report that reached the network to be more accurate would be doing
1434
+ * something on its own account.
1435
+ */
1436
+ export function checkAttestedPolicyOnMain(input) {
1437
+ const check = "attested-policy-on-main";
1438
+ const root = input.root;
1439
+ if (root === null) {
1440
+ return {
1441
+ check,
1442
+ status: "skip",
1443
+ detail: `${input.policyPath} is not inside a git repository, so there is no remote copy of it to compare the attestation against`,
1444
+ };
1445
+ }
1446
+ const status = checkAttestation([...input.records], input.policyPath);
1447
+ // Not applicable rather than a pass: with no attestation there is no hash to
1448
+ // compare, and `attestation` is the row that has something to say about that.
1449
+ const attested = status.status === "attested"
1450
+ ? { sha256: status.sha256, seq: status.seq }
1451
+ : status.status === "hash-mismatch"
1452
+ ? { sha256: status.attestedSha256, seq: status.seq }
1453
+ : null;
1454
+ if (attested === null) {
1455
+ return {
1456
+ check,
1457
+ status: "skip",
1458
+ detail: `${input.policyPath} carries no attestation, so there is no attested hash to look for on the remote`,
1459
+ };
1460
+ }
1461
+ const remote = input.remote ?? "origin";
1462
+ const branch = input.branch ?? currentBranch(root) ?? "main";
1463
+ const ref = `refs/remotes/${remote}/${branch}`;
1464
+ const resolved = git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], root);
1465
+ const tip = resolved.stdout.trim();
1466
+ if (!resolved.ok || tip.length === 0) {
1467
+ return {
1468
+ check,
1469
+ status: "skip",
1470
+ detail: `this checkout has no ${remote}/${branch} remote-tracking ref, so there is no remote copy of ${input.policyPath} to compare the attestation against`,
1471
+ };
1472
+ }
1473
+ const relative = repoPath(root, input.policyPath);
1474
+ const blob = relative.startsWith("..") ? null : showBlob(root, tip, relative);
1475
+ const remoteSha256 = blob === null ? null : policyBytesHash(blob);
1476
+ if (remoteSha256 === attested.sha256) {
1477
+ return {
1478
+ check,
1479
+ status: "pass",
1480
+ detail: `${remote}/${branch} carries the policy attested at seq ${String(attested.seq)} (sha256 ${attested.sha256.slice(0, 12)}…)`,
1481
+ };
1482
+ }
1483
+ // The branch the amendment would ride, when this checkout has already seen it
1484
+ // on the remote. Named in the detail rather than in the fix: `approval policy
1485
+ // amend --pr` is the command either way, because it updates an open pull
1486
+ // request rather than opening a second one (APRV-341).
1487
+ const amendBranch = `policy-amend-${String(attested.seq)}`;
1488
+ const pushed = git(["rev-parse", "--verify", "--quiet", `refs/remotes/${remote}/${amendBranch}^{commit}`], root);
1489
+ const carried = pushed.ok && pushed.stdout.trim().length > 0;
1490
+ return {
1491
+ check,
1492
+ status: "fail",
1493
+ detail: `attested at seq ${String(attested.seq)}, not yet on main: ${remote}/${branch} carries ${remoteSha256 === null ? `no ${relative} at all` : `${relative} hashing ${remoteSha256.slice(0, 12)}…`} while the attestation covers ${attested.sha256.slice(0, 12)}…${carried
1494
+ ? `; ${remote} already carries ${amendBranch}, so its pull request is what lands it`
1495
+ : ""}. A fresh checkout of ${branch} refuses every gate operation with policy-not-attested until it merges`,
1496
+ fix: `approval policy amend --pr — commits the policy and its attestation on ${amendBranch}, opens or updates its pull request, and arms the merge`,
1497
+ };
1498
+ }
862
1499
  export function checkMainBehindOrigin(logPath, queuePath, root) {
863
1500
  const report = inspectPreflight({ logPath, queuePath, root, fetch: false });
864
1501
  if (!report.ok) {
@@ -878,10 +1515,17 @@ export function checkMainBehindOrigin(logPath, queuePath, root) {
878
1515
  if (report.facts.behind_by === 0 && !report.facts.dist_stale) {
879
1516
  return { check: "main-behind-origin", status: "pass", detail: `${report.detail}${suffix}` };
880
1517
  }
1518
+ // A plan is worth naming even though it is a pass: the row is where an
1519
+ // operator looks to find out whether starting will be one command or two, and
1520
+ // "your working log extends the committed one" is the answer that used to
1521
+ // arrive as a refusal (APRV-346).
1522
+ const plan = report.sync === null
1523
+ ? ""
1524
+ : `; the working log is a clean extension (${report.sync.relation}, ${plural(report.sync.kept, "local record")}), so up reconciles it rather than refusing`;
881
1525
  return {
882
1526
  check: "main-behind-origin",
883
1527
  status: "pass",
884
- detail: `${report.detail}; upstream ${report.facts.log_touched ? "DOES" : "does not"} touch the working log or queue${suffix}`,
1528
+ detail: `${report.detail}; upstream ${report.facts.log_touched ? "DOES" : "does not"} touch the working log or queue${plan}${suffix}`,
885
1529
  fix: "approval up — fast-forwards and rebuilds when it is safe, and refuses with the next command when it is not",
886
1530
  };
887
1531
  }