approval-md 0.2.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 (235) hide show
  1. package/README.md +63 -24
  2. package/SPEC.md +57 -11
  3. package/dist/src/channels/contract.d.ts +34 -1
  4. package/dist/src/channels/contract.js +200 -7
  5. package/dist/src/channels/contract.js.map +1 -1
  6. package/dist/src/channels/telegram.d.ts +123 -11
  7. package/dist/src/channels/telegram.js +218 -23
  8. package/dist/src/channels/telegram.js.map +1 -1
  9. package/dist/src/channels/web.d.ts +9 -0
  10. package/dist/src/channels/web.js +17 -0
  11. package/dist/src/channels/web.js.map +1 -1
  12. package/dist/src/cli/amend.js +214 -30
  13. package/dist/src/cli/amend.js.map +1 -1
  14. package/dist/src/cli/attest.d.ts +9 -0
  15. package/dist/src/cli/attest.js +134 -7
  16. package/dist/src/cli/attest.js.map +1 -1
  17. package/dist/src/cli/channel-telegram.d.ts +99 -26
  18. package/dist/src/cli/channel-telegram.js +311 -13
  19. package/dist/src/cli/channel-telegram.js.map +1 -1
  20. package/dist/src/cli/channel.d.ts +9 -0
  21. package/dist/src/cli/channel.js +9 -0
  22. package/dist/src/cli/channel.js.map +1 -1
  23. package/dist/src/cli/codex-bridge.d.ts +819 -0
  24. package/dist/src/cli/codex-bridge.js +1607 -0
  25. package/dist/src/cli/codex-bridge.js.map +1 -0
  26. package/dist/src/cli/codex.d.ts +1 -1
  27. package/dist/src/cli/codex.js +304 -7
  28. package/dist/src/cli/codex.js.map +1 -1
  29. package/dist/src/cli/daemon.js +4 -1
  30. package/dist/src/cli/daemon.js.map +1 -1
  31. package/dist/src/cli/doctor.js +467 -12
  32. package/dist/src/cli/doctor.js.map +1 -1
  33. package/dist/src/cli/execute.js +25 -2
  34. package/dist/src/cli/execute.js.map +1 -1
  35. package/dist/src/cli/help.d.ts +6 -2
  36. package/dist/src/cli/help.js +165 -60
  37. package/dist/src/cli/help.js.map +1 -1
  38. package/dist/src/cli/hook-codex.d.ts +49 -1
  39. package/dist/src/cli/hook-codex.js +60 -1
  40. package/dist/src/cli/hook-codex.js.map +1 -1
  41. package/dist/src/cli/hook.d.ts +459 -3
  42. package/dist/src/cli/hook.js +1062 -114
  43. package/dist/src/cli/hook.js.map +1 -1
  44. package/dist/src/cli/import.js +1 -1
  45. package/dist/src/cli/import.js.map +1 -1
  46. package/dist/src/cli/main.js +5 -3
  47. package/dist/src/cli/main.js.map +1 -1
  48. package/dist/src/cli/policy-apply.d.ts +195 -0
  49. package/dist/src/cli/policy-apply.js +573 -0
  50. package/dist/src/cli/policy-apply.js.map +1 -0
  51. package/dist/src/cli/policy.js +14 -1
  52. package/dist/src/cli/policy.js.map +1 -1
  53. package/dist/src/cli/preflight.d.ts +151 -13
  54. package/dist/src/cli/preflight.js +398 -41
  55. package/dist/src/cli/preflight.js.map +1 -1
  56. package/dist/src/cli/sandbox.js +17 -1
  57. package/dist/src/cli/sandbox.js.map +1 -1
  58. package/dist/src/cli/scaffold.d.ts +1 -1
  59. package/dist/src/cli/scaffold.js +1 -1
  60. package/dist/src/cli/setup-channel.d.ts +9 -0
  61. package/dist/src/cli/setup-channel.js +28 -1
  62. package/dist/src/cli/setup-channel.js.map +1 -1
  63. package/dist/src/cli/setup-common.d.ts +3 -1
  64. package/dist/src/cli/setup-common.js +3 -2
  65. package/dist/src/cli/setup-common.js.map +1 -1
  66. package/dist/src/cli/setup.d.ts +2 -0
  67. package/dist/src/cli/setup.js +94 -2
  68. package/dist/src/cli/setup.js.map +1 -1
  69. package/dist/src/cli/up.js +115 -51
  70. package/dist/src/cli/up.js.map +1 -1
  71. package/dist/src/cli/values.js +3 -4
  72. package/dist/src/cli/values.js.map +1 -1
  73. package/dist/src/cli/verb-registry.js +174 -9
  74. package/dist/src/cli/verb-registry.js.map +1 -1
  75. package/dist/src/cli/wordmark.d.ts +2 -2
  76. package/dist/src/cli/wordmark.js +2 -2
  77. package/dist/src/codex/broker.d.ts +229 -0
  78. package/dist/src/codex/broker.js +548 -0
  79. package/dist/src/codex/broker.js.map +1 -0
  80. package/dist/src/codex/runner.d.ts +178 -0
  81. package/dist/src/codex/runner.js +231 -0
  82. package/dist/src/codex/runner.js.map +1 -0
  83. package/dist/src/codex/serve.d.ts +56 -0
  84. package/dist/src/codex/serve.js +98 -0
  85. package/dist/src/codex/serve.js.map +1 -0
  86. package/dist/src/codex/workspace-commit.d.ts +219 -0
  87. package/dist/src/codex/workspace-commit.js +549 -0
  88. package/dist/src/codex/workspace-commit.js.map +1 -0
  89. package/dist/src/core/advance-cycle.d.ts +51 -0
  90. package/dist/src/core/advance-cycle.js +66 -2
  91. package/dist/src/core/advance-cycle.js.map +1 -1
  92. package/dist/src/core/agents-md.d.ts +20 -18
  93. package/dist/src/core/agents-md.js +33 -31
  94. package/dist/src/core/agents-md.js.map +1 -1
  95. package/dist/src/core/attest.d.ts +215 -0
  96. package/dist/src/core/attest.js +317 -7
  97. package/dist/src/core/attest.js.map +1 -1
  98. package/dist/src/core/audit.d.ts +18 -0
  99. package/dist/src/core/audit.js +13 -0
  100. package/dist/src/core/audit.js.map +1 -1
  101. package/dist/src/core/channel-owner.d.ts +213 -0
  102. package/dist/src/core/channel-owner.js +358 -0
  103. package/dist/src/core/channel-owner.js.map +1 -0
  104. package/dist/src/core/command-class.d.ts +154 -0
  105. package/dist/src/core/command-class.js +673 -20
  106. package/dist/src/core/command-class.js.map +1 -1
  107. package/dist/src/core/commit-guard.d.ts +272 -0
  108. package/dist/src/core/commit-guard.js +424 -0
  109. package/dist/src/core/commit-guard.js.map +1 -0
  110. package/dist/src/core/daemon-actor.d.ts +45 -0
  111. package/dist/src/core/daemon-actor.js +54 -0
  112. package/dist/src/core/daemon-actor.js.map +1 -0
  113. package/dist/src/core/dark-session.d.ts +109 -8
  114. package/dist/src/core/dark-session.js +266 -82
  115. package/dist/src/core/dark-session.js.map +1 -1
  116. package/dist/src/core/decision-refusal.d.ts +23 -2
  117. package/dist/src/core/decision-refusal.js +24 -2
  118. package/dist/src/core/decision-refusal.js.map +1 -1
  119. package/dist/src/core/env-file.d.ts +5 -0
  120. package/dist/src/core/env-file.js +60 -1
  121. package/dist/src/core/env-file.js.map +1 -1
  122. package/dist/src/core/execute.d.ts +15 -2
  123. package/dist/src/core/execute.js +15 -2
  124. package/dist/src/core/execute.js.map +1 -1
  125. package/dist/src/core/gate.d.ts +86 -1
  126. package/dist/src/core/gate.js +81 -1
  127. package/dist/src/core/gate.js.map +1 -1
  128. package/dist/src/core/gesture-refusal.d.ts +166 -0
  129. package/dist/src/core/gesture-refusal.js +188 -0
  130. package/dist/src/core/gesture-refusal.js.map +1 -0
  131. package/dist/src/core/harness-version.d.ts +1 -1
  132. package/dist/src/core/harness-version.js +3 -1
  133. package/dist/src/core/harness-version.js.map +1 -1
  134. package/dist/src/core/instance.d.ts +59 -2
  135. package/dist/src/core/instance.js +113 -0
  136. package/dist/src/core/instance.js.map +1 -1
  137. package/dist/src/core/log.d.ts +39 -1
  138. package/dist/src/core/log.js.map +1 -1
  139. package/dist/src/core/policy-explain.d.ts +10 -0
  140. package/dist/src/core/policy-explain.js +32 -0
  141. package/dist/src/core/policy-explain.js.map +1 -1
  142. package/dist/src/core/policy-load.d.ts +41 -1
  143. package/dist/src/core/policy-load.js +21 -3
  144. package/dist/src/core/policy-load.js.map +1 -1
  145. package/dist/src/core/policy-match.d.ts +43 -0
  146. package/dist/src/core/policy-match.js +52 -0
  147. package/dist/src/core/policy-match.js.map +1 -1
  148. package/dist/src/core/policy-proposal.d.ts +52 -0
  149. package/dist/src/core/policy-proposal.js +102 -2
  150. package/dist/src/core/policy-proposal.js.map +1 -1
  151. package/dist/src/core/protected-path-guard.d.ts +117 -4
  152. package/dist/src/core/protected-path-guard.js +362 -48
  153. package/dist/src/core/protected-path-guard.js.map +1 -1
  154. package/dist/src/core/question-preempted.d.ts +141 -0
  155. package/dist/src/core/question-preempted.js +152 -0
  156. package/dist/src/core/question-preempted.js.map +1 -0
  157. package/dist/src/core/read-scope.d.ts +172 -0
  158. package/dist/src/core/read-scope.js +252 -0
  159. package/dist/src/core/read-scope.js.map +1 -0
  160. package/dist/src/core/sandbox.d.ts +81 -0
  161. package/dist/src/core/sandbox.js +190 -1
  162. package/dist/src/core/sandbox.js.map +1 -1
  163. package/dist/src/core/sender-identity.d.ts +476 -0
  164. package/dist/src/core/sender-identity.js +572 -0
  165. package/dist/src/core/sender-identity.js.map +1 -0
  166. package/dist/src/core/shlex.d.ts +102 -0
  167. package/dist/src/core/shlex.js +159 -0
  168. package/dist/src/core/shlex.js.map +1 -0
  169. package/dist/src/core/values.d.ts +18 -8
  170. package/dist/src/core/values.js +36 -1
  171. package/dist/src/core/values.js.map +1 -1
  172. package/dist/src/daemon/advance.d.ts +10 -0
  173. package/dist/src/daemon/advance.js +25 -4
  174. package/dist/src/daemon/advance.js.map +1 -1
  175. package/dist/src/daemon/daemon.js +9 -0
  176. package/dist/src/daemon/daemon.js.map +1 -1
  177. package/dist/src/daemon/git-evidence.d.ts +2 -2
  178. package/dist/src/daemon/git-evidence.js +1 -1
  179. package/dist/src/mcp/server.js +8 -0
  180. package/dist/src/mcp/server.js.map +1 -1
  181. package/docs/cli-reference.md +932 -32
  182. package/docs/codex-enforced-session.md +75 -2
  183. package/docs/codex-workspace-broker.md +118 -0
  184. package/package.json +3 -1
  185. package/schema/event.schema.json +538 -9
  186. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
  187. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
  188. package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
  189. package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
  190. package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
  191. package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
  192. package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
  193. package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
  194. package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
  195. package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
  196. package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
  197. package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
  198. package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
  199. package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
  200. package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
  201. package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
  202. package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
  203. package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
  204. package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
  205. package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
  206. package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
  207. package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
  208. package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
  209. package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
  210. package/schema/fixtures/policy/valid/canonical.json +1 -1
  211. package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
  212. package/schema/fixtures/policy-md/valid/canonical.md +1 -1
  213. package/schema/fixtures/policy-md/valid/with-values.md +5 -7
  214. package/schema/fixtures/values/invalid/class-shaped.json +1 -1
  215. package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
  216. package/schema/fixtures/values/invalid/non-string-item.json +1 -1
  217. package/schema/fixtures/values/invalid/over-cap.json +1 -1
  218. package/schema/fixtures/values/invalid/unknown-key.json +1 -1
  219. package/schema/fixtures/values/invalid/version-float.json +1 -0
  220. package/schema/fixtures/values/invalid/version-integer.json +1 -0
  221. package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
  222. package/schema/fixtures/values/valid/empty-lists.json +2 -3
  223. package/schema/fixtures/values/valid/full.json +5 -7
  224. package/schema/fixtures/values/valid/minimal.json +1 -1
  225. package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
  226. package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
  227. package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
  228. package/schema/fixtures/values-md/invalid/version-1.md +69 -0
  229. package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
  230. package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
  231. package/schema/fixtures/values-md/valid/absent.md +1 -1
  232. package/schema/fixtures/values-md/valid/with-values.md +5 -7
  233. package/schema/policy.schema.json +54 -2
  234. package/schema/values.schema.json +7 -11
  235. package/schema/fixtures/values/invalid/version-string.json +0 -1
@@ -15,19 +15,45 @@
15
15
  *
16
16
  * ## What it is allowed to do
17
17
  *
18
- * Read git, and at most three writes: a `--ff-only` merge, `npm run build`,
19
- * and, when the merge refused over an untracked file under `backlog/tasks/`
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
20
  * that main already contains, clearing that file out of the way (APRV-300, and
21
- * the rules it obeys are in {@link reconcileUntrackedTaskFiles}).
21
+ * the rules it obeys are in {@link reconcileUntrackedTaskFiles}), and the one
22
+ * `approval log sync` performs on its behalf (APRV-346, below).
22
23
  *
23
24
  * It never resets, never stashes, never checks anything out, and never touches
24
- * the working log. That list is not conservatism for its own sake — it is fork 2
25
- * of 2026-08-20 (APRV-104's notes, and the reason `approval log sync` exists at
26
- * all): a working `events.jsonl` rewound through git underneath a live appender
27
- * is two chains where there was one. `--ff-only` cannot rewind a file that
28
- * upstream did not change, and when upstream DID change it while the working
29
- * copy is dirty, this module refuses and names `approval log sync`, which is the
30
- * 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.
31
57
  *
32
58
  * ## Refusals, not repairs
33
59
  *
@@ -38,9 +64,11 @@
38
64
  * commits exist that the remote does not have. A fast-forward is not the
39
65
  * operation for that state, and guessing which side to keep is a decision.
40
66
  * - `up-preflight-log-diverged` — the upstream range changes the working log or
41
- * the queue projection, and the working copy has uncommitted changes to them.
42
- * This is the case the human could not judge by eye, and it is `approval log
43
- * 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).
44
72
  * - `up-preflight-dirty-protected` — some OTHER path the upstream range changes
45
73
  * is locally modified, so `git merge --ff-only` would refuse to overwrite it.
46
74
  * Named separately because the repair is different: look at the edit and
@@ -83,8 +111,14 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameS
83
111
  import { spawn, spawnSync } from "node:child_process";
84
112
  import { basename, dirname, join, resolve as resolvePathSegments } from "node:path";
85
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";
86
118
  import { EXIT_IO, EXIT_OK } from "./exit-codes.js";
87
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";
88
122
  import { runbook, style } from "./style.js";
89
123
  // ---------------------------------------------------------------------------
90
124
  // Build freshness (moved here from cli/doctor.ts, APRV-215)
@@ -289,6 +323,7 @@ const ZERO = {
289
323
  log_touched: false,
290
324
  dist_stale: false,
291
325
  reexec: false,
326
+ log_synced: false,
292
327
  };
293
328
  /** `git status --porcelain -uno` as a set of repo-relative paths. */
294
329
  function dirtyPaths(root) {
@@ -336,7 +371,68 @@ function counts(root, base) {
336
371
  return { behind, ahead };
337
372
  }
338
373
  function skipped(detail, root) {
339
- 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 };
340
436
  }
341
437
  /**
342
438
  * The whole judgment, and not one byte of action.
@@ -373,6 +469,7 @@ export function inspectPreflight(input) {
373
469
  warning: fetched.message,
374
470
  target: null,
375
471
  root,
472
+ sync: null,
376
473
  };
377
474
  }
378
475
  base = fetched.sha;
@@ -404,6 +501,7 @@ export function inspectPreflight(input) {
404
501
  dist_stale: stale,
405
502
  action,
406
503
  reexec: false,
504
+ log_synced: false,
407
505
  });
408
506
  // 1. Ahead. Nothing else is worth judging: whatever the upstream range holds,
409
507
  // a fast-forward is not the operation for a checkout carrying commits the
@@ -449,6 +547,7 @@ export function inspectPreflight(input) {
449
547
  root,
450
548
  target: base,
451
549
  warning,
550
+ sync: null,
452
551
  facts: facts(stale ? "rebuild" : "none"),
453
552
  detail: stale
454
553
  ? `up to date with ${remote}/${branch}, and the build is older than the sources`
@@ -462,38 +561,29 @@ export function inspectPreflight(input) {
462
561
  // does this — snapshot, baseline, fast-forward, reconcile, rebuild the
463
562
  // projections — and this module deliberately does not reimplement it.
464
563
  const collidingProtected = [...protectedPaths].filter((path) => upstream.has(path) && dirty.has(path));
465
- 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) {
466
572
  return {
467
573
  ok: false,
468
574
  root,
469
575
  facts: facts("refused"),
470
- refusal: {
471
- code: "up-preflight-log-diverged",
472
- headline: `${remote}/${branch} changed ${collidingProtected.join(" and ")} and so did this working copy`,
473
- state: [
474
- `${plural(counted.behind, "commit")} behind ${remote}/${branch}`,
475
- `changed on both sides: ${collidingProtected.join(", ")}`,
476
- "the working log was not read, moved, or rewound",
477
- ],
478
- steps: [
479
- {
480
- command: "approval log sync",
481
- note: "snapshots the working log, fast-forwards, reconciles the chain",
482
- },
483
- { command: "approval up", note: "again, once sync reports clean" },
484
- ],
485
- footer: [
486
- "a fast-forward over a log another process is appending to is how one chain becomes two",
487
- "the ritual and what it refuses: docs/cli-reference.md#log-sync",
488
- ],
489
- next: "approval log sync",
490
- },
576
+ refusal: divergedRefusal(remote, branch, counted.behind, collidingProtected),
491
577
  };
492
578
  }
493
579
  // 3. Any other local modification in the fast-forward's way. `--ff-only` would
494
580
  // refuse rather than clobber it, so the refusal is reported here, where it
495
- // can say which file and what the two ways out are.
496
- 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();
497
587
  if (colliding.length > 0) {
498
588
  return {
499
589
  ok: false,
@@ -527,8 +617,46 @@ export function inspectPreflight(input) {
527
617
  root,
528
618
  target: base,
529
619
  warning,
620
+ sync: plan,
530
621
  facts: facts(stale ? "fast-forward+rebuild" : "fast-forward"),
531
- 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",
532
660
  };
533
661
  }
534
662
  function plural(count, noun) {
@@ -555,6 +683,38 @@ export function runPreflight(input, spawnBuild = npmBuild) {
555
683
  // nothing to do. It rides out on the warning line so the aside directory is
556
684
  // printed where the operator is already looking.
557
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
+ }
558
718
  if (facts.behind_by > 0 && report.root !== null && report.target !== null) {
559
719
  const root = report.root;
560
720
  const target = report.target;
@@ -643,10 +803,11 @@ export function runPreflight(input, spawnBuild = npmBuild) {
643
803
  const plan = stale && build ? reexecPlan(input.root) : null;
644
804
  return {
645
805
  ok: true,
646
- facts: { ...settled, reexec: plan !== null },
806
+ facts: { ...settled, reexec: plan !== null, log_synced: synced !== null },
647
807
  detail: report.detail,
648
808
  warning: [warning, cleared].filter((part) => part !== null).join("; ") || null,
649
809
  ...(plan === null ? {} : { reexec: plan }),
810
+ ...(synced === null ? {} : { synced }),
650
811
  };
651
812
  }
652
813
  // ---------------------------------------------------------------------------
@@ -970,6 +1131,18 @@ export function describePreflightEvent(event) {
970
1131
  if (event.event === "preflight_warning") {
971
1132
  return { text: `approval: preflight — ${event.message}`, stderr: true };
972
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
+ }
973
1146
  const commits = `${String(event.behind_by)} commit${event.behind_by === 1 ? "" : "s"}`;
974
1147
  const did = {
975
1148
  none: "already at the remote tip, on a build no older than the sources",
@@ -989,6 +1162,27 @@ export function describePreflightEvent(event) {
989
1162
  const handover = event.reexec ? ", in a fresh process on the new build" : "";
990
1163
  return { text: `up: preflight — ${did[event.action]}${running}${handover}`, stderr: false };
991
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
+ }
992
1186
  /**
993
1187
  * Run the preflight, print what it did, and answer whether the caller may start.
994
1188
  *
@@ -1020,6 +1214,11 @@ export function startupPreflight(input) {
1020
1214
  if (outcome.warning !== null) {
1021
1215
  input.emit({ event: "preflight_warning", message: outcome.warning });
1022
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
+ }
1023
1222
  // Emitted BEFORE the re-exec, and by the parent, because this is the only
1024
1223
  // place the whole story is known: the child runs with `--no-preflight` and
1025
1224
  // has nothing to say about a fast-forward it did not perform. The commit is
@@ -1030,8 +1229,75 @@ export function startupPreflight(input) {
1030
1229
  detail: outcome.detail,
1031
1230
  ...outcome.facts,
1032
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);
1033
1238
  return { ok: true, reexec: outcome.reexec ?? null };
1034
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
+ }
1035
1301
  /** The short sha this checkout is on, or `null` when git will not say. */
1036
1302
  function headCommit(logPath) {
1037
1303
  const root = repoRoot(dirname(logPath));
@@ -1146,6 +1412,90 @@ function npmBuild(root) {
1146
1412
  * never `git`. That constraint predates this row and is the right one — a repair
1147
1413
  * line telling an operator to reset a branch would be doctor making a decision.
1148
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
+ }
1149
1499
  export function checkMainBehindOrigin(logPath, queuePath, root) {
1150
1500
  const report = inspectPreflight({ logPath, queuePath, root, fetch: false });
1151
1501
  if (!report.ok) {
@@ -1165,10 +1515,17 @@ export function checkMainBehindOrigin(logPath, queuePath, root) {
1165
1515
  if (report.facts.behind_by === 0 && !report.facts.dist_stale) {
1166
1516
  return { check: "main-behind-origin", status: "pass", detail: `${report.detail}${suffix}` };
1167
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`;
1168
1525
  return {
1169
1526
  check: "main-behind-origin",
1170
1527
  status: "pass",
1171
- 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}`,
1172
1529
  fix: "approval up — fast-forwards and rebuilds when it is safe, and refuses with the next command when it is not",
1173
1530
  };
1174
1531
  }