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