approval-md 0.4.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 (107) hide show
  1. package/SPEC.md +3 -0
  2. package/cli.js +72 -4
  3. package/dist/src/channels/contract.d.ts +53 -0
  4. package/dist/src/channels/contract.js +56 -0
  5. package/dist/src/channels/contract.js.map +1 -1
  6. package/dist/src/channels/tagging.d.ts +25 -0
  7. package/dist/src/channels/tagging.js +57 -5
  8. package/dist/src/channels/tagging.js.map +1 -1
  9. package/dist/src/channels/telegram-minimal.d.ts +251 -0
  10. package/dist/src/channels/telegram-minimal.js +618 -0
  11. package/dist/src/channels/telegram-minimal.js.map +1 -0
  12. package/dist/src/channels/telegram.d.ts +454 -19
  13. package/dist/src/channels/telegram.js +931 -80
  14. package/dist/src/channels/telegram.js.map +1 -1
  15. package/dist/src/cli/amend.js +8 -1
  16. package/dist/src/cli/amend.js.map +1 -1
  17. package/dist/src/cli/audit-card.d.ts +1 -1
  18. package/dist/src/cli/audit-card.js +40 -1
  19. package/dist/src/cli/audit-card.js.map +1 -1
  20. package/dist/src/cli/audit.d.ts +7 -5
  21. package/dist/src/cli/audit.js +22 -10
  22. package/dist/src/cli/audit.js.map +1 -1
  23. package/dist/src/cli/channel-telegram-webhook.d.ts +2 -0
  24. package/dist/src/cli/channel-telegram-webhook.js +7 -0
  25. package/dist/src/cli/channel-telegram-webhook.js.map +1 -1
  26. package/dist/src/cli/channel-telegram.d.ts +75 -2
  27. package/dist/src/cli/channel-telegram.js +170 -15
  28. package/dist/src/cli/channel-telegram.js.map +1 -1
  29. package/dist/src/cli/execute.d.ts +12 -0
  30. package/dist/src/cli/execute.js +46 -1
  31. package/dist/src/cli/execute.js.map +1 -1
  32. package/dist/src/cli/help.d.ts +1 -0
  33. package/dist/src/cli/help.js +39 -18
  34. package/dist/src/cli/help.js.map +1 -1
  35. package/dist/src/cli/hook.d.ts +114 -3
  36. package/dist/src/cli/hook.js +468 -50
  37. package/dist/src/cli/hook.js.map +1 -1
  38. package/dist/src/cli/log-unlock.d.ts +23 -0
  39. package/dist/src/cli/log-unlock.js +109 -0
  40. package/dist/src/cli/log-unlock.js.map +1 -0
  41. package/dist/src/cli/main.js +23 -6
  42. package/dist/src/cli/main.js.map +1 -1
  43. package/dist/src/cli/up.js +5 -0
  44. package/dist/src/cli/up.js.map +1 -1
  45. package/dist/src/cli/verb-registry.js +1 -0
  46. package/dist/src/cli/verb-registry.js.map +1 -1
  47. package/dist/src/cli/wordmark.d.ts +2 -2
  48. package/dist/src/cli/wordmark.js +2 -2
  49. package/dist/src/core/audit.d.ts +98 -9
  50. package/dist/src/core/audit.js +363 -15
  51. package/dist/src/core/audit.js.map +1 -1
  52. package/dist/src/core/command-class.js +6 -0
  53. package/dist/src/core/command-class.js.map +1 -1
  54. package/dist/src/core/gate.d.ts +58 -1
  55. package/dist/src/core/gate.js +30 -3
  56. package/dist/src/core/gate.js.map +1 -1
  57. package/dist/src/core/log-lock.d.ts +435 -0
  58. package/dist/src/core/log-lock.js +1093 -0
  59. package/dist/src/core/log-lock.js.map +1 -0
  60. package/dist/src/core/log.d.ts +82 -3
  61. package/dist/src/core/log.js +285 -40
  62. package/dist/src/core/log.js.map +1 -1
  63. package/dist/src/core/policy-proposal.d.ts +17 -0
  64. package/dist/src/core/policy-proposal.js +29 -14
  65. package/dist/src/core/policy-proposal.js.map +1 -1
  66. package/dist/src/core/prompt-layout.d.ts +186 -1
  67. package/dist/src/core/prompt-layout.js +345 -3
  68. package/dist/src/core/prompt-layout.js.map +1 -1
  69. package/dist/src/core/sender-identity.d.ts +24 -1
  70. package/dist/src/core/sender-identity.js +31 -0
  71. package/dist/src/core/sender-identity.js.map +1 -1
  72. package/dist/src/core/validate.d.ts +20 -6
  73. package/dist/src/core/validate.js +0 -0
  74. package/dist/src/core/validate.js.map +1 -1
  75. package/dist/src/daemon/git-evidence.d.ts +2 -2
  76. package/dist/src/daemon/git-evidence.js +1 -1
  77. package/dist/src/serve/archive.d.ts +8 -0
  78. package/dist/src/serve/archive.js +11 -2
  79. package/dist/src/serve/archive.js.map +1 -1
  80. package/docs/README-extended.md +2 -0
  81. package/docs/cli-reference.md +507 -25
  82. package/package.json +2 -2
  83. package/schema/event.schema.json +354 -28
  84. package/schema/fixtures/event/invalid/audit-lock-reclaimed-age-without-created.json +19 -0
  85. package/schema/fixtures/event/invalid/audit-lock-reclaimed-agent-actor.json +14 -0
  86. package/schema/fixtures/event/invalid/audit-lock-reclaimed-lockfile-path.json +14 -0
  87. package/schema/fixtures/event/invalid/audit-lock-reclaimed-operator-cleared-system-actor.json +14 -0
  88. package/schema/fixtures/event/invalid/audit-lock-reclaimed-unknown-reason.json +14 -0
  89. package/schema/fixtures/event/invalid/audit-lock-reclaimed-without-age.json +13 -0
  90. package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +6 -1
  91. package/schema/fixtures/event/invalid/audit-reviewed-bad-payload-hash.json +21 -0
  92. package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +5 -0
  93. package/schema/fixtures/event/invalid/audit-reviewed-no-subject-hash.json +19 -0
  94. package/schema/fixtures/event/invalid/audit-reviewed-no-subject-seq.json +19 -0
  95. package/schema/fixtures/event/invalid/audit-reviewed-no-verdict-source.json +19 -0
  96. package/schema/fixtures/event/invalid/audit-reviewed-no-verdict.json +19 -0
  97. package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +6 -1
  98. package/schema/fixtures/event/invalid/audit-sampled-no-policy-hash.json +14 -0
  99. package/schema/fixtures/event/valid/audit-lock-reclaimed-implausible-created.json +18 -0
  100. package/schema/fixtures/event/valid/audit-lock-reclaimed-legacy.json +14 -0
  101. package/schema/fixtures/event/valid/audit-lock-reclaimed-unlock.json +19 -0
  102. package/schema/fixtures/event/valid/audit-lock-reclaimed.json +19 -0
  103. package/schema/fixtures/event/valid/audit-reviewed-reaction.json +5 -0
  104. package/schema/fixtures/event/valid/audit-reviewed.json +6 -1
  105. package/schema/fixtures/event/valid/audit-sampled.json +2 -1
  106. package/schema/fixtures/event/valid/daemon-identity.json +2 -1
  107. package/schema/policy.schema.json +52 -0
@@ -85,6 +85,8 @@ import { harnessProvenance, isHarnessKind, } from "../core/harness-version.js";
85
85
  import { abandonedAfterMs, HARNESS_CAP_MARGIN_MS, harnessCapFitsMargin, harnessCappedTtlMs, HOOK_DEFAULT_WAIT, HOOK_RETRY_GRACE_MS, } from "../core/harness-wait.js";
86
86
  import { harnessLoopFloor, isLoopEscalated, isSideEffectingClass, loopClearance, UNKNOWN_SESSION, } from "../core/loop.js";
87
87
  import { drawSocketPathFor, drawSocketUsable } from "../core/live-draw.js";
88
+ import { appendLockHeld, DEFAULT_LOCK_RETRY_MS, DEFAULT_LOCK_TIMEOUT_MS, } from "../core/log.js";
89
+ import { settleTerminationGuards } from "../core/log-lock.js";
88
90
  import { payloadHash } from "../core/payload.js";
89
91
  import { classifyApplyPatch, parseApplyPatch } from "../core/apply-patch.js";
90
92
  import { loadPolicy, parseDuration } from "../core/policy-load.js";
@@ -988,6 +990,86 @@ export function harnessBlockDirective(code, detail, harness) {
988
990
  const exitCode = deny({ out: (text) => out.push(text), err: () => undefined }, code, detail, harness);
989
991
  return { stdout: out.join(""), exitCode };
990
992
  }
993
+ /**
994
+ * Raised on `globalThis` while the runtime's own Hermes signal guards are
995
+ * registered (APRV-466). The `approval` bin reads the same registry symbol and
996
+ * steps aside while it is `true`, so the bin and the runtime never both print.
997
+ * An explicit flag rather than a listener count: an unrelated listener that
998
+ * does not exit must not switch the bin's guard off.
999
+ */
1000
+ export const HERMES_SIGNAL_OWNER = Symbol.for("approval-md.hermes-signal-owner");
1001
+ /**
1002
+ * The block directive a Hermes hook prints when a signal arrives before it
1003
+ * reached a verdict (APRV-445, APRV-466), as bytes.
1004
+ *
1005
+ * Two places print it: `hermesFailClosed`'s guard below, and the `approval` bin
1006
+ * (`cli.js`), which has to answer a signal that lands while this module is
1007
+ * still loading and so cannot import it. The bin spells the same bytes by hand,
1008
+ * and `tests/hermes-bin-signal-guard.test.ts` pins the two equal, so the
1009
+ * early-signal answer is one code with one spelling (SPEC.md §11.1 invariant
1010
+ * 6). The wait's own handler words its message differently ("while waiting for
1011
+ * a decision") under the same code.
1012
+ *
1013
+ * On the CLI the runtime's guard prints it for a signal held through the
1014
+ * synchronous stretch before the first spend (the stdin read, the
1015
+ * classification), at the pause `BEFORE_SPEND` (APRV-473); the bin prints it
1016
+ * for a signal during the load (docs/hermes-hook.md, "Two layers against a
1017
+ * signal").
1018
+ */
1019
+ export function hermesInterruptedDirective(signal) {
1020
+ return harnessBlockDirective("hook-interrupted", `the hook received ${signal} before it reached a verdict; nothing authorizes this call`, "hermes").stdout;
1021
+ }
1022
+ /**
1023
+ * The block directive a harness wait answers when a SIGTERM or SIGINT ends it
1024
+ * (APRV-473 for Hermes, APRV-475 for every harness), as bytes and exit code.
1025
+ *
1026
+ * It is the harness's ORDINARY deny, built by {@link harnessBlockDirective}
1027
+ * and so by {@link deny}, under the `hook-interrupted` code: the nested
1028
+ * `hookSpecificOutput` deny at exit 0 for Claude Code, Codex and Muse,
1029
+ * `{permission:"deny"}` at exit 0 for Cursor, `{decision:"deny"}` at exit 2
1030
+ * for Grok, `{action:"block"}` at exit 2 for Hermes. One construction site per
1031
+ * harness, so the answer to an interruption is spelled exactly as every other
1032
+ * refusal that harness receives from this hook, and a harness that blocks on
1033
+ * those blocks on this. `tests/harness-wait-interrupt.test.ts` pins the bytes
1034
+ * per harness.
1035
+ */
1036
+ export function interruptedWaitDirective(signal, harness) {
1037
+ return harnessBlockDirective("hook-interrupted", `the hook received ${signal} while waiting for a decision; nothing authorizes this call`, harness);
1038
+ }
1039
+ /**
1040
+ * The exit code an interrupted wait leaves with when stdout would not take its
1041
+ * directive whole (APRV-475): a closed pipe, or a short write.
1042
+ *
1043
+ * 2, because with an empty or torn stdout the exit code is the whole verdict,
1044
+ * and 2 is the one code these harnesses read as a block on its own: Claude Code
1045
+ * documents it as a blocking error, Grok as its deny, Hermes blocks on it
1046
+ * unconditionally, Muse's live probe blocked on it with an empty stdout, and a
1047
+ * Cursor entry with `failClosed` blocks on any non-zero exit. Exiting 0 there
1048
+ * would be the one wrong answer on the harnesses whose deny is exit 0 with a
1049
+ * body: an exit 0 with no body is no verdict, and those harnesses run the call.
1050
+ */
1051
+ const INTERRUPTED_BARE_EXIT = 2;
1052
+ /**
1053
+ * Answer an interrupted wait (APRV-475), and return the code to exit with.
1054
+ *
1055
+ * Writes {@link interruptedWaitDirective} synchronously to fd 1, because the
1056
+ * `process.exit` that follows does not wait for a stream to drain. Returns the
1057
+ * directive's own exit code, or {@link INTERRUPTED_BARE_EXIT} when the write
1058
+ * threw or came up short. Called by the wait's signal handler only, BEFORE its
1059
+ * withdrawal, so the block is on stdout however long the withdrawal waits for
1060
+ * the log's lock.
1061
+ */
1062
+ function writeInterruptedWait(signal, harness) {
1063
+ const directive = interruptedWaitDirective(signal, harness);
1064
+ let whole = false;
1065
+ try {
1066
+ whole = writeSync(1, directive.stdout) === Buffer.byteLength(directive.stdout);
1067
+ }
1068
+ catch {
1069
+ // stdout is gone; the exit code is the whole verdict.
1070
+ }
1071
+ return whole ? directive.exitCode : INTERRUPTED_BARE_EXIT;
1072
+ }
991
1073
  /** The machine-readable code a Hermes `execute_code` call is refused with. */
992
1074
  export const HERMES_EXECUTE_CODE_REFUSAL = "hook-hermes-execute-code-unbound";
993
1075
  /** The tool that carries a program and no path, no argv and no directory. */
@@ -2111,10 +2193,221 @@ function commandClassify(argv, streams, cwd) {
2111
2193
  return EXIT_OK;
2112
2194
  }
2113
2195
  function sleepSync(ms) {
2196
+ // A synchronous wait never runs with termination signals held by the log
2197
+ // lock's guard (APRV-479 round 2, RS2: `approval codex bridge` waits for a
2198
+ // human here through `driveSync`).
2199
+ settleTerminationGuards();
2114
2200
  if (ms <= 0)
2115
2201
  return;
2116
2202
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
2117
2203
  }
2204
+ /** The longest delay `setTimeout` honours (2^31 - 1 ms). */
2205
+ const MAX_TIMER_MS = 2_147_483_647;
2206
+ /** Run `steps` to its verdict, sleeping each pause synchronously. */
2207
+ function driveSync(steps) {
2208
+ for (;;) {
2209
+ const next = steps.next(false);
2210
+ if (next.done === true)
2211
+ return next.value;
2212
+ if (next.value > 0)
2213
+ sleepSync(next.value);
2214
+ }
2215
+ }
2216
+ /**
2217
+ * Run `steps` to its verdict, sleeping each pause on the event loop.
2218
+ *
2219
+ * A zero pause is TWO `setImmediate` hops, and the second is what guarantees a
2220
+ * poll phase in between. libuv hands a signal to its JS listeners only when the
2221
+ * poll phase reads the signal pipe. A single immediate scheduled from a
2222
+ * poll-phase callback runs in the same iteration's check phase, before any
2223
+ * further poll, so a signal that arrived during the synchronous stretch would
2224
+ * still be sitting in the pipe when the spend ran. That is the case whenever
2225
+ * the run is still inside the continuation of the bin's `import()` of `dist/`
2226
+ * on a Node whose module load turns the loop (24, and likely 22; APRV-473
2227
+ * refuter, reproduced on 24). A second immediate scheduled from the check phase
2228
+ * runs in the NEXT iteration's check phase, after that iteration's poll, from
2229
+ * whichever phase the first was scheduled.
2230
+ */
2231
+ async function driveYielding(steps) {
2232
+ for (;;) {
2233
+ const next = steps.next(true);
2234
+ if (next.done === true)
2235
+ return next.value;
2236
+ // Clamped to the largest delay a timer holds: past it Node fires the timer
2237
+ // at once, and a pause would become a busy poll. The wait re-reads its
2238
+ // deadline after every pause, so a shorter pause changes nothing it judges.
2239
+ const ms = Math.min(next.value, MAX_TIMER_MS);
2240
+ await new Promise((settle) => {
2241
+ if (ms <= 0)
2242
+ setImmediate(() => setImmediate(settle));
2243
+ else
2244
+ setTimeout(settle, ms);
2245
+ });
2246
+ }
2247
+ }
2248
+ /**
2249
+ * The pause before anything appends `execution.started` or `gate.bypassed`
2250
+ * (APRV-473): a poll phase of the event loop under {@link driveYielding},
2251
+ * nothing under {@link driveSync}.
2252
+ *
2253
+ * Everything between two pauses runs synchronously, so a signal that lands
2254
+ * during the stdin read, the classification, or the poll read that found the
2255
+ * grant is held until the next pause. Without this one, the next point it
2256
+ * could be dispatched is after the spend, and a hook interrupted before its
2257
+ * verdict would record the execution of a call its harness had already given
2258
+ * up on. With it, the listener in force runs first: the wait's handler
2259
+ * withdraws the question and blocks, the early guard (`hermesFailClosed`)
2260
+ * blocks, and either one exits the process before the spend is reached.
2261
+ *
2262
+ * Shared by every harness (APRV-475): it is a yield inside the one gate chain,
2263
+ * so there is no per-harness copy to drift. A signal is HELD only while some
2264
+ * JS listener for it is registered. Outside the wait, the harnesses other than
2265
+ * Hermes register none, so there a signal takes its default disposition the
2266
+ * moment it arrives (the process dies with nothing printed and nothing spent),
2267
+ * and this pause matters for them at the post-wait spend, where the wait's
2268
+ * handler is registered.
2269
+ *
2270
+ * Taken inside {@link spendUnderLock} since APRV-478, immediately before the
2271
+ * spend's one attempt and after any wait for the log's lock, so nothing that can
2272
+ * wait sits between the last pause and the append.
2273
+ */
2274
+ const BEFORE_SPEND = 0;
2275
+ /**
2276
+ * Did this attempt lose a race for the log's lock, writing nothing?
2277
+ *
2278
+ * Two shapes: the spend's own append refused `append-failed` with the writer's
2279
+ * `lock-timeout`, and a budget refusal whose `budget.exceeded` evidence lost the
2280
+ * race (`startHarnessExecution` carries the writer's error on that refusal for
2281
+ * this, APRV-478). In both nothing was appended, so re-running the whole
2282
+ * attempt is safe and gives the record the chance a synchronous lock wait gave
2283
+ * it.
2284
+ */
2285
+ function lockTimedOut(result) {
2286
+ return (!result.ok &&
2287
+ (result.code === "append-failed" || result.code === "budget-exceeded") &&
2288
+ result.append?.code === "lock-timeout");
2289
+ }
2290
+ /**
2291
+ * Run one spend (an append that authorizes a harness call) without ever waiting
2292
+ * for the log's lock synchronously (APRV-478).
2293
+ *
2294
+ * ## The hole
2295
+ *
2296
+ * Every writer waits for `<log>.lock` inside `core/log.ts`, retrying with an
2297
+ * `Atomics.wait` sleep for up to {@link DEFAULT_LOCK_TIMEOUT_MS}. On the gate
2298
+ * path that wait came AFTER the last pause ({@link BEFORE_SPEND}), so a signal
2299
+ * that arrived while another writer (the daemon, routinely) held the lock was
2300
+ * held by the wait's listener through the whole stretch, the spend appended
2301
+ * `execution.started` and the allow went out, and the wait's `finally` then
2302
+ * removed the listener and the held signal was discarded with it: a start
2303
+ * recorded after the harness had abandoned the call, on every harness.
2304
+ *
2305
+ * ## What this does instead
2306
+ *
2307
+ * The wait for the lock becomes yields of the same generator chain, under ONE
2308
+ * deadline for the whole spend (the writer's own `lockTimeoutMs`, default
2309
+ * {@link DEFAULT_LOCK_TIMEOUT_MS}, taken once when the spend begins):
2310
+ *
2311
+ * 1. While the lockfile is present ({@link appendLockHeld}, advisory) and the
2312
+ * deadline has not passed, yield the retry interval (`lockRetryMs`, default
2313
+ * {@link DEFAULT_LOCK_RETRY_MS}).
2314
+ * 2. {@link BEFORE_SPEND}: the zero pause, taken when the spend begins and again
2315
+ * after any wait, so it always comes immediately before the attempt (with at
2316
+ * most the probe in between). This
2317
+ * is the pause the guarantee rests on: two `setImmediate` hops cross a poll
2318
+ * phase from any phase, so a signal that arrived during the wait or the
2319
+ * probe is dispatched here at the latest (a retry timer usually dispatches
2320
+ * it sooner, but a timer scheduled outside a timer callback can fire before
2321
+ * the next poll). The listener in force then blocks, withdraws what it can
2322
+ * and exits, and nothing below runs.
2323
+ * 3. One attempt with `lockTimeoutMs: 0`: the whole spend (a fresh verified read,
2324
+ * every check, compare-and-append against the head that read saw), whose
2325
+ * append tries the lock exactly ONCE and never sleeps. The lock protocol is
2326
+ * `core/log.ts`'s, untouched: atomic create-or-fail, never stolen, the head
2327
+ * compared under it, so this changes when a writer tries and never what a
2328
+ * try may do.
2329
+ *
2330
+ * An attempt that lost the race (another writer took the lock between the probe
2331
+ * and the try, a window as long as the attempt's own read) yields the retry
2332
+ * interval and goes back to 1, until the deadline: the same number of tries
2333
+ * inside the same bound as `acquireLock`'s synchronous retry, each one a whole
2334
+ * fresh attempt. An attempt that loses at or after the deadline is final,
2335
+ * exactly as a writer's own `lock-timeout` is: the caller denies, and nothing
2336
+ * was appended (fail closed, §11.1 invariant 8). See {@link lockTimedOut} for
2337
+ * the two refusals that count as a lost race.
2338
+ *
2339
+ * What is left between the last pause and the append is the spend's own read
2340
+ * of the log (milliseconds on a small log, longer on a large one) and its
2341
+ * single try at the lock, never a wait on another process. A signal there is
2342
+ * answered by the verdict the spend reaches.
2343
+ *
2344
+ * **The trade, on the yielding path only.** The writer's synchronous wait read
2345
+ * the log first and then spun on the lock itself, so it caught a gap of a
2346
+ * millisecond. Here the spend's read sits between the probe that saw the lock
2347
+ * free and the try, so a lock that is only ever free for gaps shorter than that
2348
+ * read (a writer holding it at a duty cycle near 100%) can run the spend out of
2349
+ * its bound where the synchronous wait got through. That ends in the deny
2350
+ * above, the grant left standing for a retry: fail closed. Holding the lock
2351
+ * across a yield to close it is not an option, because a handler that exits at
2352
+ * that yield would leave a lockfile that is never stolen.
2353
+ *
2354
+ * **Under {@link driveSync} nothing changes.** `commandHook` in process and in
2355
+ * the serve worker, and `decideHarnessCall` for the Codex bridge, have no event
2356
+ * loop that a signal could be dispatched on, so a yielded wait buys them
2357
+ * nothing. The driver says so at the first pause (see {@link GateSteps}), and
2358
+ * the spend is then exactly what it was: that zero pause (no pause under this
2359
+ * driver) and one call with the caller's own options, whose writer waits for
2360
+ * the lock synchronously, per head-moved attempt, as it always did.
2361
+ *
2362
+ * `attempt` receives the append options to use (the caller's own, with
2363
+ * `lockTimeoutMs: 0` on the yielding path), and the attempt number (1-based),
2364
+ * for a caller whose first attempt may reuse a read it already holds and whose
2365
+ * later ones may not.
2366
+ */
2367
+ function* spendUnderLock(logPath, append, attempt) {
2368
+ // The shared pause, and the driver's answer to whether it turns the loop.
2369
+ const yielding = (yield BEFORE_SPEND) === true;
2370
+ if (!yielding)
2371
+ return attempt(append ?? {}, 1);
2372
+ const askedBudget = append?.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
2373
+ const budgetMs = Number.isFinite(askedBudget) ? Math.max(0, askedBudget) : DEFAULT_LOCK_TIMEOUT_MS;
2374
+ // Never a zero (or non-finite) retry: under `driveSync` a zero pause is no
2375
+ // pause, and the probe loop would spin.
2376
+ const askedRetry = append?.lockRetryMs ?? DEFAULT_LOCK_RETRY_MS;
2377
+ const retryMs = Number.isFinite(askedRetry) ? Math.max(1, askedRetry) : DEFAULT_LOCK_RETRY_MS;
2378
+ const once = { ...append, lockTimeoutMs: 0 };
2379
+ const deadline = Date.now() + budgetMs;
2380
+ for (let tries = 1;; tries += 1) {
2381
+ let waited = tries > 1;
2382
+ for (;;) {
2383
+ if (!appendLockHeld(logPath))
2384
+ break;
2385
+ const left = deadline - Date.now();
2386
+ if (left <= 0)
2387
+ break;
2388
+ waited = true;
2389
+ yield Math.min(retryMs, left);
2390
+ }
2391
+ // Uncontended first try: the pause above was taken a probe ago, and the
2392
+ // probe is the only thing that ran since. Otherwise the pause again, now
2393
+ // immediately before the attempt.
2394
+ if (waited)
2395
+ yield BEFORE_SPEND;
2396
+ const result = attempt(once, tries);
2397
+ if (!lockTimedOut(result))
2398
+ return result;
2399
+ const left = deadline - Date.now();
2400
+ if (left > 0) {
2401
+ yield Math.min(retryMs, left);
2402
+ continue;
2403
+ }
2404
+ // A copy, as `core/head-retry.ts` makes its own: only the message changes.
2405
+ return {
2406
+ ...result,
2407
+ message: `${result.message ?? "lock-timeout"}; the spend waited for the log's lock on the event loop for its whole ${String(budgetMs)} ms bound, trying every ${String(retryMs)} ms, and another writer held it at each of its ${String(tries)} attempt${tries === 1 ? "" : "s"} (APRV-478). Nothing was appended.`,
2408
+ };
2409
+ }
2410
+ }
2118
2411
  function truncate(text, limit) {
2119
2412
  const collapsed = text.replace(/\s+/gu, " ").trim();
2120
2413
  return collapsed.length <= limit ? collapsed : `${collapsed.slice(0, limit - 1)}…`;
@@ -2742,8 +3035,25 @@ function withdrawAbandoned(run, streams, records, now, keepHash, only = null) {
2742
3035
  * is about (APRV-146). The gate requires it and compares it against what the
2743
3036
  * human answered: the same value keyed the carryover that found these grants, so
2744
3037
  * presenting it states, at the spend, the fact the match was made on.
3038
+ *
3039
+ * Steps since APRV-478: each key is spent through {@link spendUnderLock}, so a
3040
+ * wait for the log's lock is a yield and the shared pause before the spend
3041
+ * ({@link BEFORE_SPEND}) is taken there, immediately before each append.
3042
+ *
3043
+ * **A multi-key spend pauses before every key, deliberately.** A signal that
3044
+ * lands after the first key is spent and before the second is dispatched at the
3045
+ * second key's pause: the call blocks with the first key's `execution.started`
3046
+ * already recorded (before the signal) and the rest unspent. That is the same
3047
+ * outcome as a spend that fails on a later key, above: a recorded start for a
3048
+ * call the harness does not run, a grant that costs one more prompt, and
3049
+ * nothing authorized. The alternative, spending the later keys with no pause,
3050
+ * would make each later key's lock wait synchronous again, and a signal there
3051
+ * would be held through starts recorded AFTER it and answered with an allow.
3052
+ * Recording nothing after the signal and blocking is the stricter of the two.
3053
+ * An atomic spend of every key at once needs a gate operation that does not
3054
+ * exist; this is the residue the SPEC hunk names for a multi-class call.
2745
3055
  */
2746
- function consumeGrants(run, keys, hash,
3056
+ function* consumeGrants(run, keys, hash,
2747
3057
  /**
2748
3058
  * This invocation's own task id (APRV-200). The gate compares it against the
2749
3059
  * task the request record carries and records `grant_origin: "direct"` only
@@ -2751,11 +3061,12 @@ function consumeGrants(run, keys, hash,
2751
3061
  */
2752
3062
  task) {
2753
3063
  for (const key of keys) {
2754
- const spent = consumeHarnessGrant(run.logPath, key, run.actor, {
3064
+ const spent = yield* spendUnderLock(run.logPath, run.options.append, (append) => consumeHarnessGrant(run.logPath, key, run.actor, {
2755
3065
  ...run.options,
3066
+ append,
2756
3067
  presentedPayloadHash: hash,
2757
3068
  spendingTask: task,
2758
- });
3069
+ }));
2759
3070
  if (!spent.ok)
2760
3071
  return { code: spent.code, message: `${key}: ${spent.message}` };
2761
3072
  }
@@ -2914,10 +3225,13 @@ known = null) {
2914
3225
  * declaration would buy no oversight and would double the volume of exactly the
2915
3226
  * traffic this is trying not to drown the log in. The supervised classes are
2916
3227
  * registered already, by the caller, which is what makes them sampleable.
3228
+ *
3229
+ * Steps since APRV-478, for the reason {@link consumeGrants} gives: each class
3230
+ * is recorded through {@link spendUnderLock}, which takes the shared pause.
2917
3231
  */
2918
- function recordUnattended(run, task, classes, hash) {
3232
+ function* recordUnattended(run, task, classes, hash) {
2919
3233
  for (const cls of classes) {
2920
- const started = startHarnessExecution(run.logPath, { task, actionKey: `${task}:${cls}`, cls, payload_hash: hash }, run.actor, run.options);
3234
+ const started = yield* spendUnderLock(run.logPath, run.options.append, (append) => startHarnessExecution(run.logPath, { task, actionKey: `${task}:${cls}`, cls, payload_hash: hash }, run.actor, { ...run.options, append }));
2921
3235
  if (!started.ok)
2922
3236
  return { code: started.code, message: `${cls}: ${started.message}` };
2923
3237
  }
@@ -3045,7 +3359,11 @@ function renderVerdict(streams, adapter, codexCommand, verdict) {
3045
3359
  ? allow(streams, verdict.reason, adapter.kind, codexCommand)
3046
3360
  : deny(streams, verdict.code, verdict.detail, adapter.kind);
3047
3361
  }
3048
- export function gateHarnessCall(streams, run, classes,
3362
+ export function gateHarnessCall(...args) {
3363
+ return driveSync(gateHarnessSteps(...args));
3364
+ }
3365
+ /** {@link gateHarnessCall} as steps, for a driver that yields (APRV-473). */
3366
+ function* gateHarnessSteps(streams, run, classes,
3049
3367
  /**
3050
3368
  * The bytes the grant binds to: `{command, cwd}` for a Bash call, the change
3051
3369
  * itself for a file tool (APRV-124). Whatever this is, it is what reaches the
@@ -3306,8 +3624,9 @@ floor = null) {
3306
3624
  // SPEC.md §6.3), so there is nothing to wait for and nothing to spend.
3307
3625
  // What there is, since APRV-141, is something to charge: the start event
3308
3626
  // is this execution's authorization, and the registration `fresh` just
3309
- // wrote is what makes it a sampleable one.
3310
- const charged = recordUnattended(run, task, classes, hash);
3627
+ // wrote is what makes it a sampleable one. The shared pause before the
3628
+ // spend is taken inside (APRV-473, APRV-478).
3629
+ const charged = yield* recordUnattended(run, task, classes, hash);
3311
3630
  if (charged !== null) {
3312
3631
  return sayDeny(`hook-gate-refused:${charged.code}`, charged.message);
3313
3632
  }
@@ -3315,7 +3634,7 @@ floor = null) {
3315
3634
  }
3316
3635
  // Every gated class carried an unspent grant: a human already answered this
3317
3636
  // exact question about these exact bytes, and nobody is asked again.
3318
- const failed = consumeGrants(run, spendKeys, hash, task);
3637
+ const failed = yield* consumeGrants(run, spendKeys, hash, task);
3319
3638
  if (failed !== null) {
3320
3639
  return sayDeny(`hook-gate-refused:${failed.code}`, failed.message);
3321
3640
  }
@@ -3349,24 +3668,42 @@ floor = null) {
3349
3668
  // `process.exit` is deliberate and immediate: the default disposition for
3350
3669
  // these signals is to die, and a handler that only withdrew would leave the
3351
3670
  // hook wedged in its poll loop with the harness waiting on it.
3671
+ //
3672
+ // APRV-473, APRV-475. On every `approval hook <harness>` CLI route this
3673
+ // handler gets its turn: the wait yields to the event loop between polls
3674
+ // (`driveYielding`), so a signal is dispatched within one pause of its
3675
+ // arrival instead of being held until the wait returns. It exits, so nothing
3676
+ // after it in this function runs: no poll reads the grant a human gives
3677
+ // afterwards, and nothing spends it. The synchronous callers (`commandHook`
3678
+ // in process, the Codex bridge's `decideHarnessCall`, a serve worker thread)
3679
+ // drive these steps with `driveSync`, under which this listener is
3680
+ // registered and removed without the loop ever turning, so it never runs
3681
+ // there and never writes into a stdout that is not a hook's (the bridge's is
3682
+ // a JSON-RPC channel). A serve thread's caller departing is `callerGone`.
3352
3683
  const onSignal = (signal) => {
3353
3684
  leaveWait();
3354
- withdrawPending(run, streams, ownKeys, `the requesting hook process received ${signal} while waiting; the session is ending, so no retry will adopt this request`);
3355
- // APRV-445. On Hermes an exit with nothing on stdout is an ALLOW unless the
3356
- // exit is 2, and a harness tearing down may still read the verdict. So the
3357
- // block directive goes out first, written synchronously because
3358
- // `process.exit` below does not wait for a stream to drain.
3359
- if (run.harness === "hermes") {
3360
- const directive = harnessBlockDirective("hook-interrupted", `the hook received ${signal} while waiting for a decision; nothing authorizes this call`, "hermes");
3361
- try {
3362
- writeSync(1, directive.stdout);
3363
- }
3364
- catch {
3365
- // stdout is gone; the exit code below is the whole verdict.
3366
- }
3367
- process.exit(HERMES_DENY_EXIT);
3685
+ // APRV-445, APRV-475. A harness tearing down may still read the verdict,
3686
+ // and on several harnesses an exit with nothing on stdout is not a block
3687
+ // (Hermes reads it as an ALLOW unless the exit is 2; Claude Code, Codex
3688
+ // and Muse read most such exits as a failed hook and run the call). So the
3689
+ // harness's own block directive goes out FIRST, in its own dialect,
3690
+ // written synchronously, before the withdrawal: the withdrawal is a
3691
+ // compare-and-append that can wait up to the lock timeout behind another
3692
+ // writer, and a harness that escalates SIGTERM to SIGKILL inside that wait
3693
+ // must already be holding the block (APRV-475 refuter). The directive does
3694
+ // not depend on the withdrawal's outcome, and a withdrawal that never lands
3695
+ // leaves the question open as a timed-out one is.
3696
+ const exitCode = writeInterruptedWait(signal, run.harness);
3697
+ try {
3698
+ withdrawPending(run, streams, ownKeys, `the requesting hook process received ${signal} while waiting; the session is ending, so no retry will adopt this request`);
3699
+ }
3700
+ catch (cause) {
3701
+ // The withdrawal failed outright (an I/O fault inside the append). The
3702
+ // block is already on stdout: a block with the question left standing
3703
+ // is the stricter of the two outcomes this handler can still reach.
3704
+ streams.err(`approval: the hook could not withdraw its request after ${signal}: ${cause instanceof Error ? cause.message : String(cause)}\n`);
3368
3705
  }
3369
- process.exit(EXIT_USAGE);
3706
+ process.exit(exitCode);
3370
3707
  };
3371
3708
  const onTerm = () => onSignal("SIGTERM");
3372
3709
  const onInt = () => onSignal("SIGINT");
@@ -3431,7 +3768,7 @@ floor = null) {
3431
3768
  streams.err(`approval: the log's last line was incomplete when this hook read it (another writer mid-append); reading again rather than giving up on ${task}\n`);
3432
3769
  }
3433
3770
  tornReads += 1;
3434
- sleepSync(Math.min(run.intervalMs, Math.max(0, deadline - Date.now())));
3771
+ yield Math.min(run.intervalMs, Math.max(0, deadline - Date.now()));
3435
3772
  continue;
3436
3773
  }
3437
3774
  // AT the deadline (APRV-427 review). A clean read at this tick would
@@ -3536,13 +3873,22 @@ floor = null) {
3536
3873
  if (states.every((state) => state === "granted")) {
3537
3874
  // The grants are spent before the allow is printed, so this exact
3538
3875
  // command cannot ride the same authorization twice.
3876
+ //
3877
+ // APRV-473, APRV-478. `consumeGrants` turns the event loop before it
3878
+ // spends: once per retry while another writer holds the log's lock,
3879
+ // and once more (`BEFORE_SPEND`) immediately before each append. A
3880
+ // signal that arrived while this read ran, or while the spend waited
3881
+ // for the lock, is dispatched there, to the handler above, which
3882
+ // blocks, withdraws (a no-op now the request is decided, refused
3883
+ // `already-decided`) and exits. The grant is then left unspent rather
3884
+ // than recorded as the start of a call the harness gave up on.
3539
3885
  leaveWait();
3540
3886
  // Looked at again now the lock is held: re-taking it may have waited,
3541
3887
  // and a caller that left meanwhile must not have its grant spent on a
3542
3888
  // verdict nobody will receive. The retry is who spends it.
3543
3889
  if (callerGone())
3544
3890
  return sayGone();
3545
- const failed = consumeGrants(run, spendKeys, hash, task);
3891
+ const failed = yield* consumeGrants(run, spendKeys, hash, task);
3546
3892
  if (failed !== null) {
3547
3893
  return sayDeny(`hook-gate-refused:${failed.code}`, failed.message);
3548
3894
  }
@@ -3586,7 +3932,7 @@ floor = null) {
3586
3932
  }
3587
3933
  return keptOpen(`no decision on ${waitKeys.join(", ")} within the hook's ${waitText(run)}.`, stillLagging);
3588
3934
  }
3589
- sleepSync(Math.min(run.intervalMs, Math.max(0, deadline - Date.now())));
3935
+ yield Math.min(run.intervalMs, Math.max(0, deadline - Date.now()));
3590
3936
  }
3591
3937
  }
3592
3938
  catch (cause) {
@@ -4809,7 +5155,7 @@ function bypassBanner(window, classes, seq) {
4809
5155
  * command that ran and left no record is the one state this feature must not be
4810
5156
  * able to reach, so an append failure is a deny.
4811
5157
  */
4812
- function runBypass(streams, input, adapter, cwd, logPath, flags, actor, window,
5158
+ function* runBypass(streams, input, adapter, cwd, logPath, flags, actor, window,
4813
5159
  /**
4814
5160
  * The verified read `window` was derived from (APRV-294), handed on to the
4815
5161
  * append so the same records answer "is a window open" and "which head does
@@ -4869,7 +5215,12 @@ decidedOn) {
4869
5215
  // window path is the one a human comes back to read, so the binary that
4870
5216
  // printed the allow is named on it.
4871
5217
  const provenance = harnessProvenance(adapter.kind, input.harnessVersion);
4872
- const recorded = recordGateBypass(logPath, {
5218
+ // APRV-473 refuter: the bypass record is this path's authorization, exactly
5219
+ // as `execution.started` is the gated path's, so a signal held through the
5220
+ // stdin read is dispatched before it is written (`BEFORE_SPEND`, taken
5221
+ // inside `spendUnderLock`, after any wait for the log's lock, which yields:
5222
+ // APRV-478).
5223
+ const recorded = yield* spendUnderLock(logPath, undefined, (append, round) => recordGateBypass(logPath, {
4873
5224
  tool: input.toolName,
4874
5225
  summary: truncate(described.headline, SUMMARY_LIMIT),
4875
5226
  classes,
@@ -4878,14 +5229,17 @@ decidedOn) {
4878
5229
  ...(input.toolUseId === null ? {} : { toolUseId: input.toolUseId }),
4879
5230
  ...(input.cwd.length === 0 ? {} : { cwd: input.cwd }),
4880
5231
  ...(provenance === null ? {} : { harness: provenance }),
4881
- }, actor, {},
4882
- // APRV-294: the window this verdict was decided under, and the read it was
4883
- // decided on. The append uses both, so a window that ended in between is
4884
- // reported as the thing that happened rather than as "no window is open".
5232
+ }, actor, { append },
5233
+ // APRV-294: the window this verdict was decided under, and the read it
5234
+ // was decided on. The append uses both, so a window that ended in
5235
+ // between is reported as the thing that happened rather than as "no
5236
+ // window is open". The read seeds the FIRST round only: a later round
5237
+ // exists because another writer took the lock, so that read's head is
5238
+ // already known to be stale, and `recordGateBypass` re-reads.
4885
5239
  {
4886
5240
  openedSeq: window.seq,
4887
- ...(decidedOn === null ? {} : { read: decidedOn }),
4888
- });
5241
+ ...(decidedOn === null || round > 1 ? {} : { read: decidedOn }),
5242
+ }));
4889
5243
  if (!recorded.ok) {
4890
5244
  // Invariant 8: the record lands before the allow, so a refusal here is a
4891
5245
  // deny even though a window is open. `append-failed` reaches the caller
@@ -4898,7 +5252,7 @@ decidedOn) {
4898
5252
  const notes = [...described.notes, ...(policyNote === null ? [] : [policyNote])];
4899
5253
  return allow(streams, `gate-open: ${classes.join(", ")} bypassed by the window opened at seq ${String(window.seq)} by ${window.openedBy} (expires ${window.expiresAt}); recorded as gate.bypassed seq ${String(recorded.record.seq)}${notes.length === 0 ? "" : ` (${notes.join("; ")})`}`, adapter.kind, codexCommand);
4900
5254
  }
4901
- function runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
5255
+ function* runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
4902
5256
  // Codex and (APRV-445) Hermes answer a misconfigured entry with a verdict:
4903
5257
  // on both a usage error's empty stdout is a harness that runs the call, and
4904
5258
  // on Hermes a non-zero exit with an empty stdout is an allow outright.
@@ -5199,13 +5553,13 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
5199
5553
  // words. The window suspends the POLICY; it never suspends the log.
5200
5554
  const looked = lookupWindow(logPath);
5201
5555
  if (looked.window !== null) {
5202
- return runBypass(streams, input, adapter, cwd, logPath, parsed.flags, actor, looked.window,
5556
+ return yield* runBypass(streams, input, adapter, cwd, logPath, parsed.flags, actor, looked.window,
5203
5557
  // APRV-294. The records this window was derived from travel with it: the
5204
5558
  // bypass record is appended against the head they ended at, so the
5205
5559
  // verdict and the record are one read of the log.
5206
5560
  looked.records === null ? null : { records: looked.records, head: looked.head });
5207
5561
  }
5208
- return renderVerdict(streams, adapter, codexCommand, decideHarnessCall({
5562
+ return renderVerdict(streams, adapter, codexCommand, yield* decideHarnessSteps({
5209
5563
  streams,
5210
5564
  input,
5211
5565
  adapter,
@@ -5244,6 +5598,10 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
5244
5598
  * progress and withdrawal lines, which are a report rather than a decision.
5245
5599
  */
5246
5600
  export function decideHarnessCall(decide) {
5601
+ return driveSync(decideHarnessSteps(decide));
5602
+ }
5603
+ /** {@link decideHarnessCall} as steps, for a driver that yields (APRV-473). */
5604
+ function* decideHarnessSteps(decide) {
5247
5605
  const { streams, input, adapter, cwd, logPath, root, options, actor, timeoutMs, intervalMs, graceMs, codexCommand, windowRecords, } = decide;
5248
5606
  const harnessCapMs = decide.harnessCapMs ?? null;
5249
5607
  const harnessCapStated = decide.harnessCapStated ?? false;
@@ -5486,8 +5844,10 @@ export function decideHarnessCall(decide) {
5486
5844
  // §6.3), so nothing is requested, decided or granted here. What IS appended
5487
5845
  // since APRV-141 is the execution record itself — the moment the policy
5488
5846
  // authorized this command — because a budget the busiest path does not
5489
- // charge is not a budget. See `recordUnattended`.
5490
- const charged = recordUnattended(run, task, classes, payloadHash(payload));
5847
+ // charge is not a budget. See `recordUnattended`. A held signal is
5848
+ // dispatched first, at the pause it takes before the append (APRV-473,
5849
+ // `BEFORE_SPEND`), and a wait for the log's lock yields (APRV-478).
5850
+ const charged = yield* recordUnattended(run, task, classes, payloadHash(payload));
5491
5851
  if (charged !== null) {
5492
5852
  return {
5493
5853
  permission: "deny",
@@ -5503,14 +5863,14 @@ export function decideHarnessCall(decide) {
5503
5863
  // do not survive a merge. An initialized-but-empty `.approval/log/` counts as
5504
5864
  // reachable — an audit trail that has recorded nothing is an empty log, not a
5505
5865
  // missing one (see `preflightLog`) — and `register` appends the first line.
5506
- return gateHarnessCall(streams, run, classes, payload, headline, task, note, floor);
5866
+ return yield* gateHarnessSteps(streams, run, classes, payload, headline, task, note, floor);
5507
5867
  }
5508
- function commandHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
5868
+ function* commandHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
5509
5869
  if (adapter.kind === "hermes") {
5510
- return hermesFailClosed(argv, streams, cwd, readStdin, adapter, waitSeam);
5870
+ return yield* hermesFailClosed(argv, streams, cwd, readStdin, adapter, waitSeam);
5511
5871
  }
5512
5872
  try {
5513
- return runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam);
5873
+ return yield* runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam);
5514
5874
  }
5515
5875
  catch (cause) {
5516
5876
  // A hook that throws is a hook the harness treats as a non-blocking error,
@@ -5541,7 +5901,7 @@ function commandHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
5541
5901
  * nothing on stdout by design. A throw becomes the same block the other
5542
5902
  * adapters' catch produces.
5543
5903
  */
5544
- function hermesFailClosed(argv, streams, cwd, readStdin, adapter,
5904
+ function* hermesFailClosed(argv, streams, cwd, readStdin, adapter,
5545
5905
  /** APRV-427: serve's wait seam, passed through untouched (null on the CLI). */
5546
5906
  waitSeam) {
5547
5907
  let stdout = "";
@@ -5567,12 +5927,24 @@ waitSeam) {
5567
5927
  // handler is prepended, so inside the wait it answers first and exits. CLI
5568
5928
  // only: under `approval serve` (a wait seam) the process's signals belong to
5569
5929
  // the server.
5930
+ //
5931
+ // A JS listener runs only when the event loop turns (APRV-466). On the CLI
5932
+ // this run is driven by `driveYielding` (APRV-473), so the loop turns at
5933
+ // every pause: between the wait's polls, where the wait's handler answers,
5934
+ // and once before anything appends `execution.started` (`BEFORE_SPEND`),
5935
+ // where whichever of the two is registered answers a signal held through
5936
+ // the synchronous stretch before it (the stdin read, the classification, the
5937
+ // registration). A signal that lands after the last pause is dispatched
5938
+ // once the run has returned, to the `approval` bin's own guard, which exits
5939
+ // with the answer this run gave. While these guards are registered,
5940
+ // HERMES_SIGNAL_OWNER tells the bin's guard to step aside, so the two never
5941
+ // both print.
5570
5942
  const onEarlySignal = (signal) => {
5571
5943
  if (raw !== null && isHermesPostEvent(raw))
5572
5944
  process.exit(EXIT_OK);
5573
5945
  if (stdout.length === 0) {
5574
5946
  try {
5575
- writeSync(1, harnessBlockDirective("hook-interrupted", `the hook received ${signal} before it reached a verdict; nothing authorizes this call`, "hermes").stdout);
5947
+ writeSync(1, hermesInterruptedDirective(signal));
5576
5948
  }
5577
5949
  catch {
5578
5950
  // stdout is gone; the exit code below is the whole verdict.
@@ -5582,23 +5954,27 @@ waitSeam) {
5582
5954
  };
5583
5955
  const onEarlyTerm = () => onEarlySignal("SIGTERM");
5584
5956
  const onEarlyInt = () => onEarlySignal("SIGINT");
5957
+ const owner = globalThis;
5958
+ const ownerBefore = owner[HERMES_SIGNAL_OWNER];
5585
5959
  if (waitSeam === null) {
5586
5960
  process.on("SIGTERM", onEarlyTerm);
5587
5961
  process.on("SIGINT", onEarlyInt);
5962
+ owner[HERMES_SIGNAL_OWNER] = true;
5588
5963
  }
5589
5964
  try {
5590
- return hermesVerdict();
5965
+ return yield* hermesVerdict();
5591
5966
  }
5592
5967
  finally {
5593
5968
  if (waitSeam === null) {
5969
+ owner[HERMES_SIGNAL_OWNER] = ownerBefore;
5594
5970
  process.off("SIGTERM", onEarlyTerm);
5595
5971
  process.off("SIGINT", onEarlyInt);
5596
5972
  }
5597
5973
  }
5598
- function hermesVerdict() {
5974
+ function* hermesVerdict() {
5599
5975
  let code;
5600
5976
  try {
5601
- code = runHarnessHook(argv, tracked, cwd, reading, adapter, waitSeam);
5977
+ code = yield* runHarnessHook(argv, tracked, cwd, reading, adapter, waitSeam);
5602
5978
  }
5603
5979
  catch (cause) {
5604
5980
  // A post-event never blocks: the call already ran (see the post path).
@@ -5657,6 +6033,48 @@ export function commandHook(argv, streams, cwd, readStdin = defaultStdin,
5657
6033
  * the call polls. Every CLI route omits it. See {@link HookWaitSeam}.
5658
6034
  */
5659
6035
  waitSeam = null) {
6036
+ return driveSync(hookSteps(argv, streams, cwd, readStdin, waitSeam));
6037
+ }
6038
+ /**
6039
+ * {@link commandHook}, with its pauses on the event loop (APRV-473).
6040
+ *
6041
+ * Every `approval hook` CLI route, from `main`: Hermes since APRV-473, the
6042
+ * other five harnesses (and `classify`, which never pauses) since APRV-475.
6043
+ * The same steps as the synchronous form; the difference is that a SIGTERM or
6044
+ * SIGINT reaches the listener in force within one pause of arriving, instead
6045
+ * of after the run returns. In the wait that listener withdraws the question
6046
+ * this invocation opened, prints the harness's own `hook-interrupted` block
6047
+ * directive ({@link interruptedWaitDirective}) and exits with that directive's
6048
+ * exit code, so a grant that lands after the harness abandoned the call is
6049
+ * never spent on it. See {@link GateSteps} and {@link BEFORE_SPEND}.
6050
+ */
6051
+ export function commandHookYielding(argv, streams, cwd, readStdin = defaultStdin) {
6052
+ // The loop now turns mid-wait, so an asynchronous stderr failure (EPIPE on a
6053
+ // closed pipe after `announceWait`, where pipes are asynchronous) would
6054
+ // surface as an uncaught exception in the middle of the wait and end it
6055
+ // without withdrawing. Stderr carries progress lines and never the verdict,
6056
+ // so its failure is swallowed (APRV-473 refuter).
6057
+ process.stderr.on("error", () => undefined);
6058
+ return driveYielding(hookSteps(argv, streams, cwd, readStdin, null));
6059
+ }
6060
+ /**
6061
+ * The steps {@link commandHook} and {@link commandHookYielding} drive, for a
6062
+ * test that drives them itself (APRV-475 refuter).
6063
+ *
6064
+ * A test seam and nothing more: it exists so a test can stop at each pause and
6065
+ * look at the log, which is the only deterministic way to pin that the pause
6066
+ * before a spend ({@link BEFORE_SPEND}) sits between the read that found a
6067
+ * grant and the append that spends it, on every harness. No CLI route and no
6068
+ * embedding caller uses it; the one entry for those is the two drivers above.
6069
+ *
6070
+ * Pass `true` to each `next()` to step as {@link commandHookYielding} does
6071
+ * (the spend then waits for a held log lock in yields, APRV-478); `false` or
6072
+ * nothing steps as {@link commandHook} does.
6073
+ */
6074
+ export function commandHookSteps(argv, streams, cwd, readStdin) {
6075
+ return hookSteps(argv, streams, cwd, readStdin, null);
6076
+ }
6077
+ function* hookSteps(argv, streams, cwd, readStdin, waitSeam) {
5660
6078
  const sub = argv[0];
5661
6079
  const rest = argv.slice(1);
5662
6080
  if (sub === undefined) {
@@ -5670,7 +6088,7 @@ waitSeam = null) {
5670
6088
  // boundary and doctor use, so a kind that can be recorded is a kind that can
5671
6089
  // be invoked, and neither can be added without the other.
5672
6090
  if (isHarnessKind(sub)) {
5673
- return commandHarnessHook(rest, streams, cwd, readStdin, HARNESS_ADAPTERS[sub], waitSeam);
6091
+ return yield* commandHarnessHook(rest, streams, cwd, readStdin, HARNESS_ADAPTERS[sub], waitSeam);
5674
6092
  }
5675
6093
  if (sub === "classify")
5676
6094
  return commandClassify(rest, streams, cwd);