approval-md 0.3.0 → 0.4.1

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 (270) hide show
  1. package/README.md +83 -914
  2. package/SPEC.md +101 -13
  3. package/cli.js +134 -7
  4. package/dist/src/adapters/smtp.d.ts +18 -0
  5. package/dist/src/adapters/smtp.js +24 -3
  6. package/dist/src/adapters/smtp.js.map +1 -1
  7. package/dist/src/channels/contract.d.ts +72 -0
  8. package/dist/src/channels/contract.js +161 -1
  9. package/dist/src/channels/contract.js.map +1 -1
  10. package/dist/src/channels/relay-server.d.ts +90 -0
  11. package/dist/src/channels/relay-server.js +295 -0
  12. package/dist/src/channels/relay-server.js.map +1 -0
  13. package/dist/src/channels/relay.d.ts +273 -0
  14. package/dist/src/channels/relay.js +564 -0
  15. package/dist/src/channels/relay.js.map +1 -0
  16. package/dist/src/channels/tagging.d.ts +25 -0
  17. package/dist/src/channels/tagging.js +63 -6
  18. package/dist/src/channels/tagging.js.map +1 -1
  19. package/dist/src/channels/telegram-minimal.d.ts +251 -0
  20. package/dist/src/channels/telegram-minimal.js +618 -0
  21. package/dist/src/channels/telegram-minimal.js.map +1 -0
  22. package/dist/src/channels/telegram-webhook.d.ts +273 -0
  23. package/dist/src/channels/telegram-webhook.js +536 -0
  24. package/dist/src/channels/telegram-webhook.js.map +1 -0
  25. package/dist/src/channels/telegram.d.ts +725 -20
  26. package/dist/src/channels/telegram.js +1332 -123
  27. package/dist/src/channels/telegram.js.map +1 -1
  28. package/dist/src/cli/amend.js +358 -5
  29. package/dist/src/cli/amend.js.map +1 -1
  30. package/dist/src/cli/attest.js +43 -6
  31. package/dist/src/cli/attest.js.map +1 -1
  32. package/dist/src/cli/audit-card.d.ts +1 -1
  33. package/dist/src/cli/audit-card.js +40 -1
  34. package/dist/src/cli/audit-card.js.map +1 -1
  35. package/dist/src/cli/audit.d.ts +7 -5
  36. package/dist/src/cli/audit.js +22 -10
  37. package/dist/src/cli/audit.js.map +1 -1
  38. package/dist/src/cli/channel-relay.d.ts +108 -0
  39. package/dist/src/cli/channel-relay.js +334 -0
  40. package/dist/src/cli/channel-relay.js.map +1 -0
  41. package/dist/src/cli/channel-telegram-webhook.d.ts +275 -0
  42. package/dist/src/cli/channel-telegram-webhook.js +716 -0
  43. package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
  44. package/dist/src/cli/channel-telegram.d.ts +347 -28
  45. package/dist/src/cli/channel-telegram.js +648 -56
  46. package/dist/src/cli/channel-telegram.js.map +1 -1
  47. package/dist/src/cli/channel.d.ts +1 -1
  48. package/dist/src/cli/channel.js +5 -1
  49. package/dist/src/cli/channel.js.map +1 -1
  50. package/dist/src/cli/daemon.js +37 -5
  51. package/dist/src/cli/daemon.js.map +1 -1
  52. package/dist/src/cli/doctor.d.ts +63 -0
  53. package/dist/src/cli/doctor.js +856 -111
  54. package/dist/src/cli/doctor.js.map +1 -1
  55. package/dist/src/cli/execute.d.ts +29 -1
  56. package/dist/src/cli/execute.js +387 -21
  57. package/dist/src/cli/execute.js.map +1 -1
  58. package/dist/src/cli/exit-codes.d.ts +12 -0
  59. package/dist/src/cli/exit-codes.js +13 -0
  60. package/dist/src/cli/exit-codes.js.map +1 -1
  61. package/dist/src/cli/gate.d.ts +2 -0
  62. package/dist/src/cli/gate.js +233 -2
  63. package/dist/src/cli/gate.js.map +1 -1
  64. package/dist/src/cli/help.d.ts +10 -1
  65. package/dist/src/cli/help.js +283 -54
  66. package/dist/src/cli/help.js.map +1 -1
  67. package/dist/src/cli/hook.d.ts +485 -6
  68. package/dist/src/cli/hook.js +2370 -70
  69. package/dist/src/cli/hook.js.map +1 -1
  70. package/dist/src/cli/init.js +16 -7
  71. package/dist/src/cli/init.js.map +1 -1
  72. package/dist/src/cli/instructions.js +24 -3
  73. package/dist/src/cli/instructions.js.map +1 -1
  74. package/dist/src/cli/log-anchor.d.ts +55 -0
  75. package/dist/src/cli/log-anchor.js +199 -60
  76. package/dist/src/cli/log-anchor.js.map +1 -1
  77. package/dist/src/cli/log-unlock.d.ts +23 -0
  78. package/dist/src/cli/log-unlock.js +109 -0
  79. package/dist/src/cli/log-unlock.js.map +1 -0
  80. package/dist/src/cli/main.d.ts +0 -12
  81. package/dist/src/cli/main.js +72 -4
  82. package/dist/src/cli/main.js.map +1 -1
  83. package/dist/src/cli/muse.d.ts +2 -0
  84. package/dist/src/cli/muse.js +62 -0
  85. package/dist/src/cli/muse.js.map +1 -0
  86. package/dist/src/cli/payload.js +79 -1
  87. package/dist/src/cli/payload.js.map +1 -1
  88. package/dist/src/cli/scaffold.js +6 -0
  89. package/dist/src/cli/scaffold.js.map +1 -1
  90. package/dist/src/cli/serve.d.ts +53 -0
  91. package/dist/src/cli/serve.js +270 -0
  92. package/dist/src/cli/serve.js.map +1 -0
  93. package/dist/src/cli/up.js +26 -2
  94. package/dist/src/cli/up.js.map +1 -1
  95. package/dist/src/cli/verb-registry.d.ts +2 -0
  96. package/dist/src/cli/verb-registry.js +338 -39
  97. package/dist/src/cli/verb-registry.js.map +1 -1
  98. package/dist/src/cli/wordmark.d.ts +2 -2
  99. package/dist/src/cli/wordmark.js +2 -2
  100. package/dist/src/core/attest-bootstrap.d.ts +95 -0
  101. package/dist/src/core/attest-bootstrap.js +135 -0
  102. package/dist/src/core/attest-bootstrap.js.map +1 -0
  103. package/dist/src/core/attest.d.ts +20 -3
  104. package/dist/src/core/attest.js +25 -0
  105. package/dist/src/core/attest.js.map +1 -1
  106. package/dist/src/core/audit.d.ts +153 -12
  107. package/dist/src/core/audit.js +415 -20
  108. package/dist/src/core/audit.js.map +1 -1
  109. package/dist/src/core/channel-lease.d.ts +322 -0
  110. package/dist/src/core/channel-lease.js +663 -0
  111. package/dist/src/core/channel-lease.js.map +1 -0
  112. package/dist/src/core/command-class.d.ts +51 -0
  113. package/dist/src/core/command-class.js +766 -8
  114. package/dist/src/core/command-class.js.map +1 -1
  115. package/dist/src/core/daemon-host.d.ts +178 -0
  116. package/dist/src/core/daemon-host.js +228 -0
  117. package/dist/src/core/daemon-host.js.map +1 -0
  118. package/dist/src/core/daemon-identity.d.ts +238 -0
  119. package/dist/src/core/daemon-identity.js +252 -0
  120. package/dist/src/core/daemon-identity.js.map +1 -0
  121. package/dist/src/core/gate.d.ts +364 -6
  122. package/dist/src/core/gate.js +802 -27
  123. package/dist/src/core/gate.js.map +1 -1
  124. package/dist/src/core/harness-version.d.ts +128 -4
  125. package/dist/src/core/harness-version.js +179 -12
  126. package/dist/src/core/harness-version.js.map +1 -1
  127. package/dist/src/core/harness-wait.d.ts +82 -3
  128. package/dist/src/core/harness-wait.js +89 -3
  129. package/dist/src/core/harness-wait.js.map +1 -1
  130. package/dist/src/core/log-lock.d.ts +435 -0
  131. package/dist/src/core/log-lock.js +1093 -0
  132. package/dist/src/core/log-lock.js.map +1 -0
  133. package/dist/src/core/log-subscribe.d.ts +21 -0
  134. package/dist/src/core/log-subscribe.js +16 -6
  135. package/dist/src/core/log-subscribe.js.map +1 -1
  136. package/dist/src/core/log-write-layer.d.ts +55 -0
  137. package/dist/src/core/log-write-layer.js +91 -0
  138. package/dist/src/core/log-write-layer.js.map +1 -0
  139. package/dist/src/core/log.d.ts +130 -22
  140. package/dist/src/core/log.js +473 -50
  141. package/dist/src/core/log.js.map +1 -1
  142. package/dist/src/core/payload-census.d.ts +51 -0
  143. package/dist/src/core/payload-census.js +125 -2
  144. package/dist/src/core/payload-census.js.map +1 -1
  145. package/dist/src/core/payload-store.d.ts +31 -0
  146. package/dist/src/core/payload-store.js +113 -8
  147. package/dist/src/core/payload-store.js.map +1 -1
  148. package/dist/src/core/payload.d.ts +18 -3
  149. package/dist/src/core/payload.js +20 -4
  150. package/dist/src/core/payload.js.map +1 -1
  151. package/dist/src/core/policy-diff.d.ts +10 -0
  152. package/dist/src/core/policy-diff.js +10 -2
  153. package/dist/src/core/policy-diff.js.map +1 -1
  154. package/dist/src/core/policy-explain.d.ts +7 -1
  155. package/dist/src/core/policy-explain.js +16 -1
  156. package/dist/src/core/policy-explain.js.map +1 -1
  157. package/dist/src/core/policy-load.d.ts +24 -0
  158. package/dist/src/core/policy-load.js.map +1 -1
  159. package/dist/src/core/policy-match.d.ts +38 -0
  160. package/dist/src/core/policy-match.js +35 -0
  161. package/dist/src/core/policy-match.js.map +1 -1
  162. package/dist/src/core/policy-proposal.d.ts +40 -0
  163. package/dist/src/core/policy-proposal.js +37 -16
  164. package/dist/src/core/policy-proposal.js.map +1 -1
  165. package/dist/src/core/prompt-layout.d.ts +186 -1
  166. package/dist/src/core/prompt-layout.js +345 -3
  167. package/dist/src/core/prompt-layout.js.map +1 -1
  168. package/dist/src/core/read-scope.d.ts +10 -0
  169. package/dist/src/core/read-scope.js +4 -0
  170. package/dist/src/core/read-scope.js.map +1 -1
  171. package/dist/src/core/run-payload.d.ts +160 -0
  172. package/dist/src/core/run-payload.js +304 -0
  173. package/dist/src/core/run-payload.js.map +1 -0
  174. package/dist/src/core/sender-identity.d.ts +59 -11
  175. package/dist/src/core/sender-identity.js +79 -11
  176. package/dist/src/core/sender-identity.js.map +1 -1
  177. package/dist/src/core/state.d.ts +52 -1
  178. package/dist/src/core/state.js +52 -6
  179. package/dist/src/core/state.js.map +1 -1
  180. package/dist/src/core/telegram-config.d.ts +79 -0
  181. package/dist/src/core/telegram-config.js +108 -0
  182. package/dist/src/core/telegram-config.js.map +1 -1
  183. package/dist/src/core/validate.d.ts +20 -6
  184. package/dist/src/core/validate.js +0 -0
  185. package/dist/src/core/validate.js.map +1 -1
  186. package/dist/src/core/verify.d.ts +23 -1
  187. package/dist/src/core/verify.js +20 -2
  188. package/dist/src/core/verify.js.map +1 -1
  189. package/dist/src/daemon/audit.d.ts +80 -5
  190. package/dist/src/daemon/audit.js +126 -6
  191. package/dist/src/daemon/audit.js.map +1 -1
  192. package/dist/src/daemon/daemon.d.ts +174 -2
  193. package/dist/src/daemon/daemon.js +298 -7
  194. package/dist/src/daemon/daemon.js.map +1 -1
  195. package/dist/src/daemon/git-evidence.d.ts +2 -2
  196. package/dist/src/daemon/git-evidence.js +1 -1
  197. package/dist/src/daemon/projection.d.ts +13 -2
  198. package/dist/src/daemon/projection.js +39 -6
  199. package/dist/src/daemon/projection.js.map +1 -1
  200. package/dist/src/mcp/server.d.ts +39 -3
  201. package/dist/src/mcp/server.js +22 -3
  202. package/dist/src/mcp/server.js.map +1 -1
  203. package/dist/src/muse/server.d.ts +16 -0
  204. package/dist/src/muse/server.js +238 -0
  205. package/dist/src/muse/server.js.map +1 -0
  206. package/dist/src/serve/archive.d.ts +166 -0
  207. package/dist/src/serve/archive.js +363 -0
  208. package/dist/src/serve/archive.js.map +1 -0
  209. package/dist/src/serve/arguments.d.ts +93 -0
  210. package/dist/src/serve/arguments.js +228 -0
  211. package/dist/src/serve/arguments.js.map +1 -0
  212. package/dist/src/serve/credentials.d.ts +91 -0
  213. package/dist/src/serve/credentials.js +143 -0
  214. package/dist/src/serve/credentials.js.map +1 -0
  215. package/dist/src/serve/follow.d.ts +86 -0
  216. package/dist/src/serve/follow.js +101 -0
  217. package/dist/src/serve/follow.js.map +1 -0
  218. package/dist/src/serve/hook-thread.d.ts +134 -0
  219. package/dist/src/serve/hook-thread.js +333 -0
  220. package/dist/src/serve/hook-thread.js.map +1 -0
  221. package/dist/src/serve/hook-worker.d.ts +134 -0
  222. package/dist/src/serve/hook-worker.js +173 -0
  223. package/dist/src/serve/hook-worker.js.map +1 -0
  224. package/dist/src/serve/server.d.ts +412 -0
  225. package/dist/src/serve/server.js +1251 -0
  226. package/dist/src/serve/server.js.map +1 -0
  227. package/docs/README-extended.md +768 -0
  228. package/docs/cli-reference.md +2139 -67
  229. package/package.json +4 -2
  230. package/schema/event.schema.json +413 -30
  231. package/schema/fixtures/event/invalid/audit-lock-reclaimed-age-without-created.json +19 -0
  232. package/schema/fixtures/event/invalid/audit-lock-reclaimed-agent-actor.json +14 -0
  233. package/schema/fixtures/event/invalid/audit-lock-reclaimed-lockfile-path.json +14 -0
  234. package/schema/fixtures/event/invalid/audit-lock-reclaimed-operator-cleared-system-actor.json +14 -0
  235. package/schema/fixtures/event/invalid/audit-lock-reclaimed-unknown-reason.json +14 -0
  236. package/schema/fixtures/event/invalid/audit-lock-reclaimed-without-age.json +13 -0
  237. package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +6 -1
  238. package/schema/fixtures/event/invalid/audit-reviewed-bad-payload-hash.json +21 -0
  239. package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +5 -0
  240. package/schema/fixtures/event/invalid/audit-reviewed-no-subject-hash.json +19 -0
  241. package/schema/fixtures/event/invalid/audit-reviewed-no-subject-seq.json +19 -0
  242. package/schema/fixtures/event/invalid/audit-reviewed-no-verdict-source.json +19 -0
  243. package/schema/fixtures/event/invalid/audit-reviewed-no-verdict.json +19 -0
  244. package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +6 -1
  245. package/schema/fixtures/event/invalid/audit-sampled-no-policy-hash.json +14 -0
  246. package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
  247. package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
  248. package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
  249. package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
  250. package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
  251. package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
  252. package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
  253. package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
  254. package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
  255. package/schema/fixtures/event/valid/audit-lock-reclaimed-implausible-created.json +18 -0
  256. package/schema/fixtures/event/valid/audit-lock-reclaimed-legacy.json +14 -0
  257. package/schema/fixtures/event/valid/audit-lock-reclaimed-unlock.json +19 -0
  258. package/schema/fixtures/event/valid/audit-lock-reclaimed.json +19 -0
  259. package/schema/fixtures/event/valid/audit-reviewed-reaction.json +5 -0
  260. package/schema/fixtures/event/valid/audit-reviewed.json +6 -1
  261. package/schema/fixtures/event/valid/audit-sampled.json +2 -1
  262. package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
  263. package/schema/fixtures/event/valid/daemon-identity.json +16 -0
  264. package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
  265. package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
  266. package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
  267. package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
  268. package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
  269. package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
  270. package/schema/policy.schema.json +102 -6
@@ -60,6 +60,7 @@ import { accessSync, constants, existsSync, mkdtempSync, readFileSync, rmSync, s
60
60
  import { tmpdir } from "node:os";
61
61
  import { basename, dirname, isAbsolute, join, relative, resolve as resolvePathSegments, sep } from "node:path";
62
62
  import { HUMAN_ACTOR_ENV, appendAttestation, attestedPolicyPayloadHash, checkAttestation, policyBytesHash, resolveHumanActor, } from "../core/attest.js";
63
+ import { settleTerminationGuards } from "../core/log-lock.js";
63
64
  import { compareChains } from "../core/log-reconcile.js";
64
65
  import { diffPolicies, renderDiff, SPEC_NAMESPACES } from "../core/policy-diff.js";
65
66
  import { checkPolicyExpectations, describeFailure, describePinChange, diffPinSources, expectationsFor, DOGFOOD_SUITE_BUILT, DOGFOOD_SUITE_SOURCE, EXPECTATIONS_MODULE, } from "../core/policy-expectations.js";
@@ -117,8 +118,14 @@ const DEFAULT_ATTESTATION_WAIT_MS = 15 * 60 * 1000;
117
118
  const DEFAULT_ATTESTATION_INTERVAL_MS = 2000;
118
119
  /** An agent identity, the one `--as` form that routes to the channel path. */
119
120
  const AGENT_ACTOR = /^agent:.+/u;
120
- /** Synchronous sleep with no dependency and no busy-spin (as `cli/execute.ts`). */
121
+ /**
122
+ * Synchronous sleep with no dependency and no busy-spin (as `cli/execute.ts`).
123
+ * The poll follows this verb's own append, so the log lock's signal guard is
124
+ * settled first: Ctrl-C or a supervisor's SIGTERM during the wait ends the
125
+ * process at once (APRV-479, S2).
126
+ */
121
127
  function sleepSync(ms) {
128
+ settleTerminationGuards();
122
129
  if (ms <= 0)
123
130
  return;
124
131
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
@@ -1100,6 +1107,14 @@ export function commandPolicyAmend(argv, streams, cwd) {
1100
1107
  : EXIT_IO;
1101
1108
  return refuse(streams, json, read.code, read.message, exitCode);
1102
1109
  }
1110
+ /**
1111
+ * The amendment branch's name, `--branch` or the seq-derived default.
1112
+ *
1113
+ * Declared HERE rather than beside the flow selection below (APRV-420): the
1114
+ * no-op path needs it too, because a re-run's whole job is to find the branch
1115
+ * a previous run left standing.
1116
+ */
1117
+ const branchName = (seq) => branchFlag ?? `policy-amend-${seq}`;
1103
1118
  const status = checkAttestation(read.records, policyPath);
1104
1119
  const attested = status.status === "attested"
1105
1120
  ? { sha256: status.sha256, seq: status.seq }
@@ -1109,7 +1124,27 @@ export function commandPolicyAmend(argv, streams, cwd) {
1109
1124
  // (a) Nothing to amend. A no-op ceremony is a SUCCESS, not an error: an
1110
1125
  // operator (or a script) that runs `amend` on an already-attested policy has
1111
1126
  // established exactly what they wanted to establish.
1127
+ //
1128
+ // APRV-420, one exception: an amendment branch already standing on origin.
1129
+ // The live policy does match its attestation, and saying only that leaves the
1130
+ // pull request carrying it open, unmergeable and unarmed, which is the state a
1131
+ // records advance landing first puts it in. So the re-run repairs it, or names
1132
+ // exactly what to run — never "nothing to amend" over a dirty amendment.
1112
1133
  if (status.status === "attested") {
1134
+ const noopRoot = repoRoot(dirname(policyPath));
1135
+ const repair = wantCommit && noopRoot !== null && hasOrigin(noopRoot)
1136
+ ? repairAmendBranch({
1137
+ root: noopRoot,
1138
+ policyPath,
1139
+ logPath,
1140
+ payloadFile,
1141
+ seq: status.seq,
1142
+ branch: branchName(String(status.seq)),
1143
+ // `--dry-run` and `--no-publish` mean the same thing here as
1144
+ // everywhere else in this verb: compute it, print it, run nothing.
1145
+ act: !dryRun && !noPublish,
1146
+ })
1147
+ : null;
1113
1148
  if (json) {
1114
1149
  emitReport(streams, {
1115
1150
  policyPath,
@@ -1123,12 +1158,24 @@ export function commandPolicyAmend(argv, streams, cwd) {
1123
1158
  noop: true,
1124
1159
  dryRun,
1125
1160
  aborted: false,
1161
+ repair,
1126
1162
  });
1127
1163
  }
1128
- else {
1164
+ else if (repair === null) {
1129
1165
  streams.out(`nothing to amend: ${relPath(policyPath, cwd)} already matches its attestation at seq ${status.seq} (sha256 ${shortHash(liveSha256)})\n`);
1130
1166
  }
1131
- return EXIT_OK;
1167
+ else {
1168
+ const headline = repair.state === "failed" ? st.glyph("fail") : st.glyph(repair.state === "owed" ? "point" : "ok");
1169
+ streams.out(`${headline} ${repair.branch}: ${repair.message}\n`);
1170
+ for (const step of repair.commands) {
1171
+ streams.err(` ${step.ok === null ? st.glyph("point") : step.ok ? st.glyph("ok") : st.glyph("fail")} ${step.command}\n`);
1172
+ }
1173
+ }
1174
+ // A repair that BROKE keeps the I/O exit, which is the split the rest of
1175
+ // this verb uses: the exit code speaks to scripts, the text to people.
1176
+ // `owed` is not a failure — it is `--dry-run` or `--no-publish` answering
1177
+ // the question they were asked — and `landed` and `repaired` are successes.
1178
+ return repair !== null && repair.state === "failed" ? EXIT_IO : EXIT_OK;
1132
1179
  }
1133
1180
  // (b) The baseline, and only a verifiable one. See the module header.
1134
1181
  progress.phase("recovering the attested baseline and diffing it against the live policy");
@@ -1169,7 +1216,6 @@ export function commandPolicyAmend(argv, streams, cwd) {
1169
1216
  // that cannot reach GitHub answers `unknown`, which used to mean "commit in
1170
1217
  // place"; an operator who typed `--pr` has said what they want either way.
1171
1218
  const useBranch = branchFlag !== null || wantPr || (!forceDirect && onProtectedDefault);
1172
- const branchName = (seq) => branchFlag ?? `policy-amend-${seq}`;
1173
1219
  // The direct flow's push is about to hit a protected branch. Say so before
1174
1220
  // the human types it, rather than after GitHub says it.
1175
1221
  const pushWarning = !useBranch && onProtectedDefault
@@ -1680,6 +1726,14 @@ export function commandPolicyAmend(argv, streams, cwd) {
1680
1726
  const why = commandOutputLines(merge.stderr, merge.stdout)[0];
1681
1727
  note(`${prLabel(publishing.prUrl)} is open — merge it with a MERGE COMMIT when CI is green. auto-merge was not armed${why === undefined ? "" : `: ${why}`}`);
1682
1728
  };
1729
+ /**
1730
+ * Who publishes the log for this amendment (APRV-420).
1731
+ *
1732
+ * Settled at commit-assembly time against the base the commit is parented
1733
+ * on, and reported, so the pull request's file list is never a surprise. The
1734
+ * default is the behaviour that predates the task: this commit carries it.
1735
+ */
1736
+ let carriage = { carry: true, reason: null, publisher: null };
1683
1737
  if (commitPlan !== null && commitBase !== null) {
1684
1738
  // APRV-203. The commit is ASSEMBLED, never checked out: a scratch index is
1685
1739
  // filled from the remote's tree, the two ceremony files are laid over it
@@ -1689,6 +1743,10 @@ export function commandPolicyAmend(argv, streams, cwd) {
1689
1743
  const message = `Policy: ${summary} (attested seq ${seq})`;
1690
1744
  /** `origin/main abc123`, or `HEAD abc123` where there is no remote. */
1691
1745
  const baseLabel = `${commitBase.remote === null ? "HEAD" : `${commitBase.remote}/${commitBase.branch}`} ${commitBase.sha.slice(0, 12)}`;
1746
+ // APRV-420: who publishes the log. Decided here, from the base this commit
1747
+ // is about to be parented on, so the answer is about the tree the commit
1748
+ // will actually sit against.
1749
+ carriage = decideLogCarriage(commitPlan.root, commitBase, logPath);
1692
1750
  progress.phase(`building the amendment commit on ${baseLabel} (nothing is checked out)`);
1693
1751
  const built = commitOnBase(commitPlan.root, {
1694
1752
  base: commitBase.sha,
@@ -1697,7 +1755,11 @@ export function commandPolicyAmend(argv, streams, cwd) {
1697
1755
  // keeps this ceremony from reverting a pins edit somebody else landed.
1698
1756
  paths: [
1699
1757
  commitPlan.policyArg,
1700
- commitPlan.logArg,
1758
+ // APRV-420: the log joins the commit only when this amendment is the
1759
+ // thing publishing it. When a records advance is, two commits carrying
1760
+ // the same appended records is a conflict on `events.jsonl` whichever
1761
+ // lands first, and the repair is a hand merge of the log.
1762
+ ...(carriage.carry ? [commitPlan.logArg] : []),
1701
1763
  ...(pinsChange === null ? [] : [pinsChange.arg]),
1702
1764
  // APRV-356: the attested policy text, so the commit carrying the
1703
1765
  // binding carries the bytes it binds. Without it the in-force policy is
@@ -1708,6 +1770,8 @@ export function commandPolicyAmend(argv, streams, cwd) {
1708
1770
  message,
1709
1771
  });
1710
1772
  progress.done();
1773
+ if (carriage.reason !== null)
1774
+ note(`${st.glyph("ok")} ${carriage.reason}`);
1711
1775
  if (!built.ok) {
1712
1776
  return gitFailed(built.step, built.quote.join(" | "), `the attestation was appended at seq ${seq}, but ${built.message}; the checkout is untouched, so run the printed commands by hand`, commands);
1713
1777
  }
@@ -2321,6 +2385,292 @@ function readLogText(path) {
2321
2385
  return "";
2322
2386
  }
2323
2387
  }
2388
+ /**
2389
+ * The open records-advance branch on `origin`, or `null`.
2390
+ *
2391
+ * `git ls-remote` rather than `gh`, deliberately: this is a question about refs
2392
+ * and it must answer the same way on a box with no GitHub CLI, no token and no
2393
+ * network beyond the remote itself. `approval log advance` names its branch
2394
+ * `records-log-<YYYY-MM-DD>` and falls back to `records-log-<date>-<n>`
2395
+ * (`cli/log-advance.ts`), and merged branches are deleted by the reconcile
2396
+ * script, so a branch matching that pattern on origin is a live advance.
2397
+ */
2398
+ function recordsPublisher(root) {
2399
+ const listed = git(["ls-remote", "--heads", "origin", "records-log-*"], root);
2400
+ if (!listed.ok)
2401
+ return null;
2402
+ for (const line of listed.stdout.split("\n")) {
2403
+ const [sha, ref] = line.trim().split(/\s+/u);
2404
+ if (ref === undefined || sha === undefined)
2405
+ continue;
2406
+ const name = ref.replace(/^refs\/heads\//u, "");
2407
+ if (name.startsWith("records-log-"))
2408
+ return { name, sha };
2409
+ }
2410
+ return null;
2411
+ }
2412
+ /**
2413
+ * Whether the records branch at `sha` already carries every record of the
2414
+ * working log, this attestation included.
2415
+ *
2416
+ * A branch pushed BEFORE the attestation was appended does not, and that is
2417
+ * the ordinary order (the cadence advance ran, then the human signed), so
2418
+ * saying "this attestation included" of it would be a claim the committed
2419
+ * log contradicts. What publishes the attestation then is the NEXT advance,
2420
+ * and the pull request's protected-path guard holds the policy change until
2421
+ * a records branch or main carries the record. `null` when the branch's log
2422
+ * could not be fetched or read, which the caller reports as unknown rather
2423
+ * than as either answer.
2424
+ */
2425
+ function publisherCarriesLog(root, sha, logArg, logPath) {
2426
+ const fetched = git(["fetch", "--quiet", "origin", sha], root);
2427
+ if (!fetched.ok)
2428
+ return null;
2429
+ const branchLog = showBlob(root, sha, logArg);
2430
+ if (branchLog === null)
2431
+ return null;
2432
+ const compared = compareChains({ label: "the working log", text: readLogText(logPath) }, { label: `records branch ${sha.slice(0, 12)}:${logArg}`, text: branchLog.toString("utf8") });
2433
+ if (!compared.ok)
2434
+ return null;
2435
+ return compared.drift.relation === "equal" || compared.drift.relation === "behind";
2436
+ }
2437
+ /**
2438
+ * Decide who publishes the log for this amendment. See the block above.
2439
+ *
2440
+ * Fails toward CARRYING it: every answer this function cannot establish (a log
2441
+ * that does not verify, a blob it could not read, a remote it could not list)
2442
+ * leaves the amendment holding the log, which is the behaviour that predates
2443
+ * this task. A dirty pull request is a nuisance; an attestation that reaches
2444
+ * `main` in nobody's commit is a policy in force that the committed log does
2445
+ * not record, and between those two there is no contest.
2446
+ */
2447
+ function decideLogCarriage(root, base, logPath) {
2448
+ const logArg = repoPath(root, logPath);
2449
+ const baseLabel = base.remote === null ? base.branch : `${base.remote}/${base.branch}`;
2450
+ const baseLog = showBlob(root, base.sha, logArg);
2451
+ const compared = compareChains({ label: "the working log", text: readLogText(logPath) }, { label: `${baseLabel}:${logArg}`, text: baseLog === null ? "" : baseLog.toString("utf8") });
2452
+ if (compared.ok && (compared.drift.relation === "equal" || compared.drift.relation === "behind")) {
2453
+ return {
2454
+ carry: false,
2455
+ reason: `${baseLabel} already carries every record this attestation added, so this amendment does not touch the log`,
2456
+ publisher: null,
2457
+ };
2458
+ }
2459
+ const publisher = recordsPublisher(root);
2460
+ if (publisher !== null) {
2461
+ const carries = publisherCarriesLog(root, publisher.sha, logArg, logPath);
2462
+ return {
2463
+ carry: false,
2464
+ reason: carries === true
2465
+ ? `the records advance on ${publisher.name} publishes the whole log, this attestation included, so this amendment carries only the policy and cannot conflict with it`
2466
+ : `a records advance is live on ${publisher.name}${carries === null ? " (its log could not be read from here)" : ", pushed before this attestation was appended"}, so this amendment carries only the policy and cannot conflict with it; the attestation record lands with the next advance, and the pull request's protected-path check holds the policy change until a records branch or main carries it`,
2467
+ publisher: publisher.name,
2468
+ };
2469
+ }
2470
+ return { carry: true, reason: null, publisher: null };
2471
+ }
2472
+ /**
2473
+ * Repair the amendment pull request a previous run left standing (APRV-420).
2474
+ *
2475
+ * The second run of a ceremony that already attested reaches "nothing to
2476
+ * amend", which is true and useless: the live policy does match its
2477
+ * attestation, and the pull request carrying that attestation to the trunk is
2478
+ * open, unmergeable and unarmed. On 2026-09-21 the repair was a hand merge of
2479
+ * `events.jsonl` in a throwaway worktree, which is the work every idiom here
2480
+ * exists to remove.
2481
+ *
2482
+ * So the re-run rebuilds. The trunk's log is a superset of the amendment's by
2483
+ * construction — every record the amendment appended reaches the trunk through
2484
+ * a records advance as well — so a commit built fresh on the current trunk,
2485
+ * carrying only what the trunk still LACKS, is the merge without a merge.
2486
+ *
2487
+ * Two things this does not do. It does not check anything out: the rebuild is
2488
+ * `commitOnBase`'s scratch index, exactly as the first run's was, so a live
2489
+ * appender never has the log moved underneath it. And it does not touch a
2490
+ * branch it cannot account for: the remote tip must be a single commit on top
2491
+ * of an ancestor of the base, which is the shape this ceremony creates and
2492
+ * nothing else. Anything else is reported with its commands rather than
2493
+ * force-pushed over.
2494
+ *
2495
+ * Returns `null` when there is nothing standing, which leaves the caller's
2496
+ * "nothing to amend" exactly as it was.
2497
+ */
2498
+ function repairAmendBranch(input) {
2499
+ const { root, policyPath, logPath, payloadFile, seq, branch, act } = input;
2500
+ const listed = git(["ls-remote", "--heads", "origin", branch], root);
2501
+ const tipLine = listed.ok
2502
+ ? listed.stdout.split("\n").find((line) => line.trim().length > 0)
2503
+ : undefined;
2504
+ if (tipLine === undefined)
2505
+ return null;
2506
+ const commands = [];
2507
+ const stop = (state, message, extra = []) => {
2508
+ for (const command of extra)
2509
+ commands.push({ command, ok: null });
2510
+ return {
2511
+ branch,
2512
+ state,
2513
+ base: null,
2514
+ carried: [],
2515
+ commit: null,
2516
+ pushed: false,
2517
+ autoMerge: "not-attempted",
2518
+ commands,
2519
+ message,
2520
+ };
2521
+ };
2522
+ const probe = probeProtection(root);
2523
+ const baseBranch = probe.defaultBranch ?? probe.currentBranch;
2524
+ if (baseBranch === null) {
2525
+ return stop("failed", `${branch} is on origin and no branch could be resolved to rebuild it on (${probe.reason})`);
2526
+ }
2527
+ commands.push({ command: `git fetch origin ${baseBranch}`, ok: null });
2528
+ const fetched = fetchBase(root, "origin", baseBranch);
2529
+ if (!fetched.ok) {
2530
+ commands[commands.length - 1] = { command: `git fetch origin ${baseBranch}`, ok: false };
2531
+ return stop("failed", `${branch} is on origin and ${fetched.message}`);
2532
+ }
2533
+ commands[commands.length - 1] = { command: `git fetch origin ${baseBranch}`, ok: true };
2534
+ const base = { remote: "origin", branch: baseBranch, sha: fetched.sha };
2535
+ const baseLabel = `origin/${baseBranch}`;
2536
+ // Fetched rather than read off `ls-remote`, because the count below needs the
2537
+ // tip to be an OBJECT in this repository: a commit somebody else pushed to
2538
+ // the branch has never been near this checkout, and `rev-list` over a sha it
2539
+ // does not have answers nothing at all.
2540
+ commands.push({ command: `git fetch origin ${branch}`, ok: null });
2541
+ const fetchedTip = fetchBase(root, "origin", branch);
2542
+ if (!fetchedTip.ok) {
2543
+ commands[commands.length - 1] = { command: `git fetch origin ${branch}`, ok: false };
2544
+ return stop("failed", `${branch} is on origin and ${fetchedTip.message}`);
2545
+ }
2546
+ commands[commands.length - 1] = { command: `git fetch origin ${branch}`, ok: true };
2547
+ const tipSha = fetchedTip.sha;
2548
+ // What the base still lacks. Each answer is read from the base's own tree,
2549
+ // so a record or a payload a records advance already landed is not carried
2550
+ // twice, which is the whole cause of the conflict this repairs.
2551
+ const policyArg = repoPath(root, policyPath);
2552
+ const payloadArg = repoPath(root, payloadFile);
2553
+ const carried = [];
2554
+ const reasons = [];
2555
+ const basePolicy = showBlob(root, base.sha, policyArg);
2556
+ const livePolicy = ((() => {
2557
+ try {
2558
+ return readFileSync(policyPath);
2559
+ }
2560
+ catch {
2561
+ return null;
2562
+ }
2563
+ })());
2564
+ if (livePolicy === null) {
2565
+ return stop("failed", `${policyPath} could not be read, so the rebuild has no policy bytes to carry`);
2566
+ }
2567
+ if (basePolicy === null || !basePolicy.equals(livePolicy)) {
2568
+ carried.push(policyArg);
2569
+ reasons.push(`the policy bytes (${baseLabel} does not carry them)`);
2570
+ }
2571
+ if (showBlob(root, base.sha, payloadArg) === null) {
2572
+ carried.push(payloadArg);
2573
+ reasons.push("the attested policy text");
2574
+ }
2575
+ const carriage = decideLogCarriage(root, base, logPath);
2576
+ if (carriage.carry) {
2577
+ carried.push(repoPath(root, logPath));
2578
+ reasons.push("the log, which nothing else is publishing");
2579
+ }
2580
+ else {
2581
+ reasons.push(`NOT the log: ${carriage.reason ?? "it is published elsewhere"}`);
2582
+ }
2583
+ const pins = pinsChangeIn(root, policyPath, base.sha);
2584
+ if (pins !== null) {
2585
+ carried.push(pins.arg);
2586
+ reasons.push(pins.arg);
2587
+ }
2588
+ if (carried.length === 0) {
2589
+ return stop("landed", `the amendment at seq ${seq} is already on ${baseLabel} in full: the policy, its attested text and its records are all there, so ${branch} has nothing left to carry. Close the pull request`, [`gh pr close ${branch} --delete-branch`]);
2590
+ }
2591
+ // Only now that a rebuild is actually owed does the branch's SHAPE matter,
2592
+ // and it has to be the one shape this ceremony makes: one commit, on a parent
2593
+ // the base already contains. `rev-list --count base..tip` counts the commits
2594
+ // the tip has that the base does not, so anything above one is work this verb
2595
+ // did not put there and a force-push would lose it. Zero is not checked here,
2596
+ // because a branch the base already contains has nothing left to carry and
2597
+ // left through the `landed` exit above.
2598
+ const countRun = git(["rev-list", "--count", `${base.sha}..${tipSha}`], root);
2599
+ const count = countRun.ok ? Number(countRun.stdout.trim()) : Number.NaN;
2600
+ if (!Number.isInteger(count) || count > 1) {
2601
+ return stop("failed", `${branch} on origin carries ${Number.isInteger(count) ? String(count) : "an unknown number of"} commits that ${baseLabel} does not. The amendment ceremony builds exactly one, so this branch holds work this verb did not put there and it will not be rebuilt over. Rebase or merge it by hand`, [`git log --oneline ${base.sha.slice(0, 12)}..${tipSha.slice(0, 12)}`]);
2602
+ }
2603
+ const subject = git(["log", "-1", "--format=%s", tipSha], root);
2604
+ const message = subject.ok && subject.stdout.trim().length > 0
2605
+ ? subject.stdout.trim()
2606
+ : `Policy: amend ${policyArg} (attested seq ${seq})`;
2607
+ const rebuild = `git push origin +<rebuilt>:refs/heads/${branch}`;
2608
+ const built = commitOnBase(root, { base: base.sha, paths: carried, message });
2609
+ if (!built.ok) {
2610
+ commands.push({ command: rebuild, ok: null });
2611
+ return stop("failed", `${branch} could not be rebuilt on ${baseLabel}: ${built.message}`);
2612
+ }
2613
+ if (built.unchanged) {
2614
+ return stop("landed", `the amendment at seq ${seq} is already on ${baseLabel}: a commit carrying ${carried.join(", ")} over ${baseLabel} is the same tree. Close the pull request`, [`gh pr close ${branch} --delete-branch`]);
2615
+ }
2616
+ const commitSha = built.sha;
2617
+ const carriedNote = `rebuilt ${commitSha.slice(0, 12)} on ${baseLabel} ${base.sha.slice(0, 12)} carrying ${reasons.join(", ")}`;
2618
+ const push = `git push origin +${commitSha.slice(0, 12)}:refs/heads/${branch}`;
2619
+ if (!act) {
2620
+ commands.push({ command: push, ok: null });
2621
+ commands.push({ command: `gh pr merge ${branch} --merge --auto`, ok: null });
2622
+ return {
2623
+ branch,
2624
+ state: "owed",
2625
+ base: base.sha,
2626
+ carried,
2627
+ commit: commitSha,
2628
+ pushed: false,
2629
+ autoMerge: "not-attempted",
2630
+ commands,
2631
+ message: `${carriedNote}; nothing was pushed, so the pull request is unchanged. Run the commands above to finish it`,
2632
+ };
2633
+ }
2634
+ // Force, and only here: the branch carries exactly the one commit this
2635
+ // ceremony put on it (checked above), and the whole repair is replacing that
2636
+ // commit with the same amendment parented on a base that has moved.
2637
+ const pushed = git(["push", "origin", `+${commitSha}:refs/heads/${branch}`], root);
2638
+ commands.push({ command: push, ok: pushed.ok });
2639
+ if (!pushed.ok) {
2640
+ commands.push({ command: `gh pr merge ${branch} --merge --auto`, ok: null });
2641
+ return {
2642
+ branch,
2643
+ state: "failed",
2644
+ base: base.sha,
2645
+ carried,
2646
+ commit: commitSha,
2647
+ pushed: false,
2648
+ autoMerge: "not-attempted",
2649
+ commands,
2650
+ message: `${carriedNote}, and \`${push}\` was REJECTED: ${pushFailureText(pushed)}. The commit exists locally; push it and re-arm the merge`,
2651
+ };
2652
+ }
2653
+ let autoMerge = "not-attempted";
2654
+ if (ghAvailable(root)) {
2655
+ const armed = gh(["pr", "merge", branch, "--merge", "--auto"], root);
2656
+ commands.push({ command: `gh pr merge ${branch} --merge --auto`, ok: armed.ok });
2657
+ autoMerge = armed.ok ? "armed" : "refused";
2658
+ }
2659
+ else {
2660
+ commands.push({ command: `gh pr merge ${branch} --merge --auto`, ok: null });
2661
+ }
2662
+ return {
2663
+ branch,
2664
+ state: "repaired",
2665
+ base: base.sha,
2666
+ carried,
2667
+ commit: commitSha,
2668
+ pushed: true,
2669
+ autoMerge,
2670
+ commands,
2671
+ message: `${carriedNote}, and force-updated ${branch} on origin, so the pull request is mergeable again${autoMerge === "armed" ? " and armed" : ""}`,
2672
+ };
2673
+ }
2324
2674
  function loadSummary(load) {
2325
2675
  return load.ok
2326
2676
  ? { ok: true, code: null, message: null }
@@ -2350,6 +2700,9 @@ function emitReport(streams, report) {
2350
2700
  // APRV-109, additive and always present: `null` says the attestation was
2351
2701
  // performed at this terminal, an object says it was collected as a tap.
2352
2702
  proposal: report.proposal ?? null,
2703
+ // APRV-420, additive and always present: `null` says this run had an
2704
+ // amendment to make, or found no standing branch to repair.
2705
+ repair: report.repair ?? null,
2353
2706
  })}\n`);
2354
2707
  }
2355
2708
  //# sourceMappingURL=amend.js.map