@vibe-commit/capture 0.7.0 → 0.9.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 (152) hide show
  1. package/README.md +238 -7
  2. package/dist/account_not_here.d.ts +15 -0
  3. package/dist/account_not_here.js +25 -0
  4. package/dist/account_not_here.js.map +1 -0
  5. package/dist/agents/claude_code.js +13 -0
  6. package/dist/agents/claude_code.js.map +1 -1
  7. package/dist/agents/codex_cli.js +11 -0
  8. package/dist/agents/codex_cli.js.map +1 -1
  9. package/dist/agents/cursor.js +7 -1
  10. package/dist/agents/cursor.js.map +1 -1
  11. package/dist/agents/registry.d.ts +7 -0
  12. package/dist/agents/registry.js +9 -0
  13. package/dist/agents/registry.js.map +1 -1
  14. package/dist/agents/types.d.ts +32 -0
  15. package/dist/agents/types.js.map +1 -1
  16. package/dist/bounded_id.d.ts +29 -0
  17. package/dist/bounded_id.js +42 -0
  18. package/dist/bounded_id.js.map +1 -0
  19. package/dist/codex_folders.d.ts +340 -0
  20. package/dist/codex_folders.js +1448 -0
  21. package/dist/codex_folders.js.map +1 -0
  22. package/dist/commands/auth.d.ts +7 -0
  23. package/dist/commands/auth.js +21 -0
  24. package/dist/commands/auth.js.map +1 -1
  25. package/dist/commands/backfill.d.ts +29 -7
  26. package/dist/commands/backfill.js +387 -109
  27. package/dist/commands/backfill.js.map +1 -1
  28. package/dist/commands/browser_credential.d.ts +4 -2
  29. package/dist/commands/browser_credential.js +5 -2
  30. package/dist/commands/browser_credential.js.map +1 -1
  31. package/dist/commands/connect.d.ts +10 -1
  32. package/dist/commands/connect.js +14 -9
  33. package/dist/commands/connect.js.map +1 -1
  34. package/dist/commands/connect_account.d.ts +135 -0
  35. package/dist/commands/connect_account.js +321 -0
  36. package/dist/commands/connect_account.js.map +1 -0
  37. package/dist/commands/connect_picker.js +204 -29
  38. package/dist/commands/connect_picker.js.map +1 -1
  39. package/dist/commands/device_credential.d.ts +3 -1
  40. package/dist/commands/device_credential.js +1 -0
  41. package/dist/commands/device_credential.js.map +1 -1
  42. package/dist/commands/finalize.d.ts +67 -0
  43. package/dist/commands/finalize.js +450 -114
  44. package/dist/commands/finalize.js.map +1 -1
  45. package/dist/commands/finish_mint.d.ts +28 -0
  46. package/dist/commands/finish_mint.js +16 -4
  47. package/dist/commands/finish_mint.js.map +1 -1
  48. package/dist/commands/scan.d.ts +28 -0
  49. package/dist/commands/scan.js +11 -0
  50. package/dist/commands/scan.js.map +1 -1
  51. package/dist/commands/status.d.ts +19 -1
  52. package/dist/commands/status.js +123 -14
  53. package/dist/commands/status.js.map +1 -1
  54. package/dist/commands/status_machine.d.ts +12 -3
  55. package/dist/commands/status_machine.js +111 -13
  56. package/dist/commands/status_machine.js.map +1 -1
  57. package/dist/commands/sync_reason.d.ts +17 -0
  58. package/dist/commands/sync_reason.js +25 -0
  59. package/dist/commands/sync_reason.js.map +1 -0
  60. package/dist/consent.d.ts +131 -3
  61. package/dist/consent.js +248 -14
  62. package/dist/consent.js.map +1 -1
  63. package/dist/copy/strings.d.ts +165 -4
  64. package/dist/copy/strings.js +190 -7
  65. package/dist/copy/strings.js.map +1 -1
  66. package/dist/folder_grants.d.ts +44 -0
  67. package/dist/folder_grants.js +101 -0
  68. package/dist/folder_grants.js.map +1 -0
  69. package/dist/git.d.ts +26 -4
  70. package/dist/git.js +82 -11
  71. package/dist/git.js.map +1 -1
  72. package/dist/grants_sync.d.ts +165 -0
  73. package/dist/grants_sync.js +289 -0
  74. package/dist/grants_sync.js.map +1 -0
  75. package/dist/grants_verb.d.ts +70 -0
  76. package/dist/grants_verb.js +121 -0
  77. package/dist/grants_verb.js.map +1 -0
  78. package/dist/hooks/entry.d.ts +30 -50
  79. package/dist/hooks/entry.js +601 -561
  80. package/dist/hooks/entry.js.map +1 -1
  81. package/dist/hooks/fire_lock.d.ts +31 -0
  82. package/dist/hooks/fire_lock.js +120 -12
  83. package/dist/hooks/fire_lock.js.map +1 -1
  84. package/dist/hooks/session_grants.d.ts +59 -0
  85. package/dist/hooks/session_grants.js +91 -0
  86. package/dist/hooks/session_grants.js.map +1 -0
  87. package/dist/hooks/session_start.d.ts +28 -7
  88. package/dist/hooks/session_start.js +55 -11
  89. package/dist/hooks/session_start.js.map +1 -1
  90. package/dist/index.js +22 -5
  91. package/dist/index.js.map +1 -1
  92. package/dist/machine_state.d.ts +44 -1
  93. package/dist/machine_state.js +75 -12
  94. package/dist/machine_state.js.map +1 -1
  95. package/dist/oauth/discovery.js +2 -1
  96. package/dist/oauth/discovery.js.map +1 -1
  97. package/dist/oauth/pkce.js +2 -1
  98. package/dist/oauth/pkce.js.map +1 -1
  99. package/dist/oauth/retire.d.ts +10 -0
  100. package/dist/oauth/retire.js +13 -0
  101. package/dist/oauth/retire.js.map +1 -1
  102. package/dist/paths.d.ts +54 -1
  103. package/dist/paths.js +88 -12
  104. package/dist/paths.js.map +1 -1
  105. package/dist/pending_revoke.d.ts +117 -0
  106. package/dist/pending_revoke.js +233 -0
  107. package/dist/pending_revoke.js.map +1 -0
  108. package/dist/pending_revoke_file.d.ts +55 -0
  109. package/dist/pending_revoke_file.js +74 -0
  110. package/dist/pending_revoke_file.js.map +1 -0
  111. package/dist/physical_path.d.ts +46 -0
  112. package/dist/physical_path.js +151 -0
  113. package/dist/physical_path.js.map +1 -0
  114. package/dist/picker.d.ts +82 -5
  115. package/dist/picker.js +183 -10
  116. package/dist/picker.js.map +1 -1
  117. package/dist/picker_checklist.d.ts +27 -3
  118. package/dist/picker_checklist.js +81 -6
  119. package/dist/picker_checklist.js.map +1 -1
  120. package/dist/policy.d.ts +24 -1
  121. package/dist/policy.js +30 -2
  122. package/dist/policy.js.map +1 -1
  123. package/dist/post.d.ts +55 -2
  124. package/dist/post.js +72 -22
  125. package/dist/post.js.map +1 -1
  126. package/dist/project.d.ts +89 -0
  127. package/dist/project.js +123 -0
  128. package/dist/project.js.map +1 -1
  129. package/dist/redact.d.ts +77 -2
  130. package/dist/redact.js +713 -47
  131. package/dist/redact.js.map +1 -1
  132. package/dist/session_targets.d.ts +713 -0
  133. package/dist/session_targets.js +2602 -0
  134. package/dist/session_targets.js.map +1 -0
  135. package/dist/spool.d.ts +12 -0
  136. package/dist/spool.js +29 -1
  137. package/dist/spool.js.map +1 -1
  138. package/dist/spool_union.d.ts +51 -0
  139. package/dist/spool_union.js +57 -0
  140. package/dist/spool_union.js.map +1 -0
  141. package/dist/state.d.ts +5 -0
  142. package/dist/state.js +19 -1
  143. package/dist/state.js.map +1 -1
  144. package/dist/system_message.d.ts +25 -2
  145. package/dist/system_message.js +29 -5
  146. package/dist/system_message.js.map +1 -1
  147. package/dist/text_tail.d.ts +8 -0
  148. package/dist/text_tail.js +19 -0
  149. package/dist/text_tail.js.map +1 -0
  150. package/dist/version.d.ts +1 -1
  151. package/dist/version.js +1 -1
  152. package/package.json +3 -3
@@ -33,30 +33,33 @@
33
33
  * own red-proof in `test/credential.test.ts`. Do not read one as evidence for
34
34
  * the other.
35
35
  */
36
- import { closeSync, openSync, readdirSync, readSync, realpathSync, statSync, writeSync, } from "node:fs";
37
- import { basename, isAbsolute, join } from "node:path";
36
+ import { closeSync, existsSync, openSync, readSync, rmSync, statSync, utimesSync, writeSync, } from "node:fs";
37
+ import { basename, dirname } from "node:path";
38
38
  import { zstdCompressSync } from "node:zlib";
39
- import { grantsUnreadable, isCaptureAllowed, isLocalSlug } from "../consent.js";
39
+ import { grantsUnreadable, hasAnyConsent, isCaptureAllowed, isLocalSlug, normalizeSlug } from "../consent.js";
40
40
  import { endingSoon, noteCredentialExpiry } from "../credential_meta.js";
41
41
  import { loadCredential } from "../credential.js";
42
42
  import { EXIT } from "../exit.js";
43
- import { gitProbe, headRef, onceRepoSlug } from "../git.js";
43
+ import { headRef, onceRepoSlug, onceStrictRepoSlug } from "../git.js";
44
44
  import { redactSpan, rulesForTranscript } from "../redact.js";
45
- import { authCommandFor } from "../install.js";
45
+ import { authCommandFor, connectCommandFor } from "../install.js";
46
46
  import { installOnce, sessionStartPayload } from "./session_start.js";
47
47
  import { credentialFingerprint, deliver, ingestUrl } from "../post.js";
48
- import { markSkipped } from "../policy.js";
49
- import { fileState, loadSessionState, saveSessionState, withFileState } from "../state.js";
48
+ import { FOLDER_REPOSITORY_FORBIDDEN, markSkipped } from "../policy.js";
49
+ import { dialectIsolates, evidenceSpecFor, findSubagentTranscripts, isolationOf, listTargetRecords, pastCursorOf, redactionScopeFor, resolveTargets, stopKeyOf, stopTarget, } from "../session_targets.js";
50
+ import { readSpoolUnion } from "../spool_union.js";
51
+ import { fileState, loadSessionState, pruneDeliveredEndHold, saveSessionState, withFileState } from "../state.js";
50
52
  import { SESSION_END_EVENT, settleDelayMs, settledSize, } from "./session_end.js";
51
- import { admitsTranscript, agentForTranscript, canonicalHookEvent, dialectFor, transcriptRoots, } from "../agents/registry.js";
52
- import { claimFiring, fireLockPath } from "./fire_lock.js";
53
- import { announcedSubagentFileKey, isInsideAny, subagentFileKey, subagentsDir, } from "../paths.js";
53
+ import { admitsTranscript, agentForTranscript, canonicalHookEvent, dialectFor, } from "../agents/registry.js";
54
+ import { claimFiring, claimSessionWriter, fireLockPath, SESSION_SCOPE, STALE_MARGIN_MS } from "./fire_lock.js";
55
+ import { announcedSubagentFileKey, subagentFileKey, } from "../paths.js";
54
56
  import { resolveProjectKeys } from "../project.js";
57
+ export { confinementRoots } from "../project.js";
55
58
  import { startSpawnBudget } from "../spawn_budget.js";
56
59
  import { turnStart } from "../pending_bound.js";
57
60
  import { recordInferredSkipped } from "../inferred_tally.js";
58
61
  import { pruneOrphanPending } from "../pending_prune.js";
59
- import { capInferred, capSpool, capSuccessors, pendingModeFor, promotePending, pruneExpiredInferred, readInferred, readPending, readRewrites, readSpool } from "../spool.js";
62
+ import { pendingModeFor, promotePending, pruneExpiredInferred, readPending } from "../spool.js";
60
63
  import { meetsNodeFloor } from "../runtime.js";
61
64
  import { renderNotice } from "../system_message.js";
62
65
  /**
@@ -326,17 +329,18 @@ function finish(payload) {
326
329
  * `hookBody`. Threading a claim out to those paths is not possible — the
327
330
  * watchdog holds no reference to the body's locals.
328
331
  */
329
- let firingClaim = null;
330
- /** Idempotent, and never throws: the hook contract forbids saying anything. */
332
+ const heldClaims = [];
333
+ /** Idempotent, and never throws: the hook contract forbids saying anything. Releases EVERY lock this process holds. */
331
334
  function releaseFiring() {
332
- const claim = firingClaim;
333
- firingClaim = null;
334
- try {
335
- claim?.release();
336
- }
337
- catch {
338
- // A lock we could not release goes stale on its own; the bound is derived
339
- // from the budget precisely so a lost release costs one invocation, once.
335
+ const claims = heldClaims.splice(0, heldClaims.length);
336
+ for (const claim of claims) {
337
+ try {
338
+ claim.release();
339
+ }
340
+ catch {
341
+ // A lock we could not release goes stale on its own; the bound is derived
342
+ // from the budget precisely so a lost release costs one invocation, once.
343
+ }
340
344
  }
341
345
  }
342
346
  /**
@@ -416,8 +420,14 @@ function dialectBudgetMs(agentId) {
416
420
  * Ordering is load-bearing in two places:
417
421
  * 1. the Node floor is checked FIRST, because everything below it may use
418
422
  * syntax or APIs the old runtime lacks;
419
- * 2. the CONSENT GATE runs BEFORE `transcript_path` is opened (D56 §D19). A
420
- * gate that runs after the read has already read the thing it was gating.
423
+ * 2. CONSENT runs BEFORE `transcript_path` is opened (D56 §D19). A gate that
424
+ * runs after the read has already read the thing it was gating. Since any-folder
425
+ * capture the question is no longer "is the one repo the turn ended in granted?"
426
+ * but "has this machine granted anything at all" (`hasAnyConsent`), asked before
427
+ * the read, and then, per target, "is THIS repo (or folder) granted"
428
+ * (`isCaptureAllowed`), asked by the resolver. A turn that ended in a folder that
429
+ * is not a work tree no longer ends the hook; the session's own records may name
430
+ * granted repos, and the resolver decides which.
421
431
  */
422
432
  async function hookBody(ctx, budgetMs, hookStartedAt) {
423
433
  const raw = await ctx.readStdin();
@@ -428,12 +438,16 @@ async function hookBody(ctx, budgetMs, hookStartedAt) {
428
438
  // session-keyed states can fire BEFORE a project has been resolved at all —
429
439
  // `unsupported_runtime` fires before anything else runs. Only
430
440
  // `capture_confirmed` needs one, and only it supplies one.
431
- const notice = (state, repoKey = null, rejected, endsAt, cloneKey) => renderNotice({
441
+ const notice = (state, repoKey = null, rejected, endsAt, cloneKey, account) => renderNotice({
432
442
  home: ctx.home,
433
443
  sessionId: input.sessionId,
434
444
  nodeVersion: ctx.nodeVersion,
435
445
  repoKey,
436
446
  ...(cloneKey === undefined ? {} : { cloneKey }),
447
+ // State 1 names the channel's `connect` (the same rule as state 2's `auth`): a plugin-only machine has no `vibecommit` on PATH.
448
+ ...(state === "not_connected" ? { connectCommand: connectCommandFor(ctx.env, ctx.selfPath ?? "") } : {}),
449
+ // State 10: the epoch keys its once-per-epoch claim; the count is the sentence.
450
+ ...(account === undefined ? {} : { accountEpoch: account.epoch, unnamedOwners: account.owners, unnamedRepos: account.repos }),
437
451
  now: () => new Date(),
438
452
  // `11A`: states 2 and 5 carry a credential. The command is the channel's, and the claim is
439
453
  // keyed by the credential, so replacing it re-arms the message.
@@ -460,204 +474,96 @@ async function hookBody(ctx, budgetMs, hookStartedAt) {
460
474
  selfPath: ctx.selfPath ?? "",
461
475
  cwd: input.cwd,
462
476
  notice,
477
+ // The one bounded GET is limited by what is left of this hook's own wall clock as well as by its 1,000 ms.
478
+ budgetLeftMs: () => budgetMs - (Date.now() - hookStartedAt),
463
479
  });
464
480
  }
465
- // --- CONSENT GATE. Nothing above this line has opened the transcript. ---
466
- // The null arm is not new behaviour — `isCaptureAllowed(home, null, …)` is already
467
- // false, so a `cwd` outside a work tree already stopped here. It is stated
468
- // separately because `CR-017d` makes this value half the delivery key as well
469
- // as the consent key, and a key the compiler cannot prove is present is one
470
- // some later edit supplies a placeholder for.
471
- //
472
- // ⛔ THE GATE CHECKS `keys.consent` — THE GIT COMMON DIR (`D184 §1`).
473
- //
474
- // It is shared by a main clone and every worktree linked to it, which is the
475
- // whole point. Consent was keyed on `--show-toplevel`, that differs per
476
- // worktree, and absence means no — so an agent session inside a `git worktree`
477
- // captured NOTHING, silently, under the house convention (`D19`) of one
478
- // worktree per builder.
481
+ // --- WHAT DOES THIS SESSION BELONG TO? (any-folder capture, R1 · R2 · R5) ---
479
482
  //
480
- // ⛔⛔ AND THIS IS ONE OF FOUR SITES, ALL OF WHICH NOW AGREE. `D184 §9`: this
481
- // READ gate, `post_commit.ts` (the COMMIT gate), `commands/connect.ts`
482
- // (`grantProject`, the WRITE side and the ONLY writer of the allow list) and
483
- // `commands/status.ts` (`revokeProject`, the REVOKE side). If any one of them
484
- // keyed on the toplevel while the others used the common dir, the allow list
485
- // would never match: the null arm below returns silently BY DESIGN, so every
486
- // session in every repository would capture nothing, with no notice — strictly
487
- // worse than the worktree blackout this change exists to fix.
483
+ // This replaces the consent gate that decided ONE target from the cwd the turn ENDED in. That cwd is wrong for most sessions:
484
+ // Claude Code's Bash tool resets the shell to the launch folder, so 93% of turn-ends were in a parent folder that is not a
485
+ // work tree, and the old gate returned null, silently, for every one of them (measured, 154 hooks, 143 in the parent).
486
+ // `session_targets.resolveTargets` now decides, from the turn-end folder AND what the session's own records name, which
487
+ // granted repos (and, only at the end of a repo-free session, which granted folder) this session's bytes go to. The old
488
+ // single-target derivation is DELETED here, not kept beside it (A4: two ways to do one task).
488
489
  //
489
- // ⚠ AND IT WOULD FAIL GREEN. The suite's fixtures hand-write `projects.json`,
490
- // deliberately, so every cell agrees with this gate whichever key both use —
491
- // the asymmetry is only visible END TO END, through the real `connect` path
492
- // and a real session in a real worktree. `test/consent-key-end-to-end.test.ts`
493
- // is that cell, and under a mutation that grants the toplevel here **135
494
- // existing cells stay green and only those two red**.
490
+ // ⛔ NOTHING IS READ FOR A MACHINE THAT HAS GRANTED NOTHING. `hasAnyConsent` is checked before a transcript is opened or a byte
491
+ // of state is written: widening what a hook reads must never widen WHO it reads for.
495
492
  //
496
- // ⛔ THAT CELL ASSERTS CONTENT, NOT ONLY ADMISSION, and the distinction is
497
- // `D184 §3`: a session admitted by this gate whose confinement root set has
498
- // collapsed delivers a payload of redaction markers. The request arrives, the
499
- // byte accounting is intact, and the content is gone. Anything asserting only
500
- // that a worktree session was admitted passes on an empty payload.
501
- //
502
- // ⚠ `resolveProjectKeys` is called once, not twice: the redaction root set
503
- // needs the common dir too, and a second resolution could disagree with the
504
- // one consent was checked against. It is stricter than `resolveProjectKey` in
505
- // one way — the common dir must also resolve and hold a `HEAD` — which fails
506
- // closed, silently, exactly as this gate already does.
507
- //
508
- // ⛔ THE GATE IS NOW `isCaptureAllowed` (0.7.0): the common dir OR the repo's slug in `grants.json`, an exclusion
509
- // winning over both. The slug is a MEMOIZED THUNK so a machine with no `grants.json` pays no spawn for it, and so
510
- // `repoSlug` below is the very resolution the gate used. `keys` is the same result the confinement roots come from.
493
+ // ⛔ The turn-end repo is still resolved ONCE here (`resolveProjectKeys`), for the notices that name it and for Cursor's
494
+ // first-`Stop` install; consent for it is the resolver's call, through the same `isCaptureAllowed`.
511
495
  const keys = resolveProjectKeys(input.cwd);
512
496
  const slugOf = keys === null ? null : onceRepoSlug(keys.worktree);
513
- if (keys === null || slugOf === null || !isCaptureAllowed(ctx.home, keys, slugOf)) {
514
- // State 9 (0.7.0, spec review R3-14: "when a hook denies because grants.json is corrupt, have it emit one
515
- // marker-gated systemMessage naming the file and `vibecommit status`"). An unreadable `grants.json` is the one
516
- // denial a hook can say out loud, once per machine and day — and it must also come from here, because the
517
- // plugin channel and Cursor never register `SessionStart`.
518
- // Only for a repo this client WOULD have captured (a remote, so not `local:`), never for a folder that is
519
- // simply not a work tree.
520
- if (keys !== null && slugOf !== null && grantsUnreadable(ctx.home) && !isLocalSlug(slugOf())) {
521
- return notice("grants_unreadable");
522
- }
523
- // Unconsented is not "not connected": say nothing at all. A machine with no
524
- // credential says state 1 below; a repo the user declined stays silent,
525
- // because nagging about a decision the user already made is the tool noise
526
- // DX3's one-line rule exists to avoid.
497
+ if (!hasAnyConsent(ctx.home))
527
498
  return null;
499
+ // The turn-end repo's OWN consent, decided exactly where the old gate decided it, before anything touches the transcript. It
500
+ // no longer ends the hook (another repo the session named may be granted); it decides the two things that were always tied
501
+ // to it. ⛔ THIS IS THE SAME `isCaptureAllowed` (one admission decision, and a slug thunk that spawns only when asked).
502
+ const turnEndAllowed = keys !== null && slugOf !== null && isCaptureAllowed(ctx.home, keys, slugOf);
503
+ // State 9 (0.7.0, spec review R3-14): an unreadable `grants.json` is the one denial a hook can say out loud, once per machine
504
+ // and day, and it must come from here because the plugin channel and Cursor never register `SessionStart`. Only for a repo
505
+ // this client WOULD have captured (a remote, so not `local:`), never for a folder that is not a work tree. With the file
506
+ // unreadable every slug-bearing repo is denied, so no target could exist and nothing is lost by saying so now.
507
+ if (!turnEndAllowed && keys !== null && slugOf !== null && grantsUnreadable(ctx.home) && !isLocalSlug(slugOf())) {
508
+ return notice("grants_unreadable");
528
509
  }
529
- // The THREE roles that never move (`D184 §1`) — the session-state bucket
530
- // (`D58`/`CR-017d`), the `resolveRepoSlug` input, and a directory to run git
531
- // in — under the name every site below already uses for them. Renaming them
532
- // would be churn across a file this change has no other reason to touch, and
533
- // `report.ts:343` is the standing proof that the NAME is not what tells you
534
- // which role a site wants.
535
- const projectKey = keys.worktree;
536
- // ⛔ CURSOR'S FIRST-`Stop` INSTALL (0.7.0, Open Question 1). Claude Code's and Codex's `SessionStart` was measured
537
- // to fire before the first prompt; Cursor's `sessionStart` was only READ out of the shipped bundle (STATIC, NOT
538
- // OBSERVED — `cursor-agent` is not logged in here) and has no field a user sees. So the same marker-gated,
539
- // plain-write-only install also runs on a Cursor `stop`, and its one notice rides this hook's payload below.
540
- // The record makes it once per clone either way, so a `sessionStart` that does fire first loses nothing.
541
- const firstStop = ctx.agentId === "cursor" && event === "Stop"
510
+ // ⛔ CURSOR'S FIRST-`Stop` INSTALL (0.7.0, Open Question 1). `sessionStart` was only READ out of the shipped bundle (STATIC,
511
+ // NOT OBSERVED) and has no field a user sees, so the same marker-gated, plain-write-only install also runs on a Cursor
512
+ // `stop`, and its one notice rides this hook's payload below. Once per clone either way. Where the gate always put it:
513
+ // before the transcript, the credential and the network.
514
+ const firstStop = ctx.agentId === "cursor" && event === "Stop" && turnEndAllowed && keys !== null
542
515
  ? installOnce({ env: ctx.env, home: ctx.home, selfPath: ctx.selfPath ?? "" }, keys)
543
516
  : null;
544
- const load = loadCredential({ env: ctx.env, home: ctx.home });
545
- switch (load.kind) {
546
- case "absent":
547
- return notice("not_connected");
548
- case "wrong-class":
549
- case "insecure-file":
550
- case "unreadable":
551
- // Interactive verbs print four different fixes for these (§13.6). A hook
552
- // has one channel and a one-line budget, so they collapse to the state
553
- // whose fix — `vibecommit connect` — is the same for all four.
554
- return notice("not_connected");
555
- case "ok":
556
- break;
557
- }
558
517
  // --- PATH CONFINEMENT. `/cso` finding 1, HIGH. ---
559
- // `transcript_path` is attacker-influenced in the one way that matters: it
560
- // reaches a read whose result is uploaded. Confining it to the directories the
561
- // agents in the registry actually write transcripts to is what stops this verb
562
- // being a silent arbitrary-file exfiltrator. Silent refusal, per the contract.
563
- //
564
- // ⛔ THE ROOTS COME FROM THE REGISTRY, NEVER FROM `input` (`CR-183`, D164 §5).
565
- // The union is deliberately independent of `ctx.agentId`: the flag comes from
566
- // an install that may have got it wrong, and a wrong SELECTION must not be able
567
- // to widen the boundary — at most it picks a suboptimal redaction profile.
568
- //
569
- // ⛔ AND THE FILE GATE, ADDED HERE BY `CR-193` (D177 §2). The boundary answers
570
- // "may this path be read"; it does not answer "is this a transcript", and
571
- // under Cursor's root those are different questions — `find -type f` there
572
- // returns 113 files and exactly ONE is a transcript. A narrowing over an
573
- // already-computed boundary, so it can only refuse. See `admitsTranscript`.
518
+ // `transcript_path` is attacker-influenced in the one way that matters: it reaches a read whose result is uploaded.
519
+ // ⛔ THE ROOTS COME FROM THE REGISTRY, NEVER FROM `input` (`CR-183`, D164 §5), independent of `ctx.agentId`, and the file gate
520
+ // (`CR-193`, D177 §2) narrows them. It can only refuse. Silent refusal, per the contract.
574
521
  if (!admitsTranscript(ctx.home, ctx.env, input.transcriptPath))
575
522
  return null;
576
523
  const url = ingestUrl(ctx.env);
577
- // `/cso` finding 2: a rejected override is a silent no-send, not a fallback to
578
- // the default. Falling back would send the credential somewhere the operator
579
- // did not ask for, which is the opposite of what the check is for.
524
+ // `/cso` finding 2: a rejected override is a silent no-send, not a fallback to the default.
580
525
  if (url === null)
581
526
  return null;
582
- // CR-016's bootstrap re-sent the whole file from byte 0 every time. CR-018
583
- // replaces it, because `later` (hold the offset) and `never` (advance past the
584
- // failing bytes) are both defined in terms of an offset and mean nothing
585
- // without one. `CR-017d` widened the key that offset is kept under to
586
- // `(repo, session)`; `CR-124` (W6) owns globbing
587
- // `<session-dir>/subagents/agent-*.jsonl` — this sends the `main` file only.
588
- //
589
- // ⚠ The attribution above said `CR-020` until `CR-020d`. It was wrong, and D76
590
- // says so by name: `CR-020`'s finding (D57 §DX7) is SessionEnd timing only and
591
- // says nothing about sub-agent files. Corrected in passing rather than left to
592
- // send the next reader after work that lives in another task.
593
- //
594
- // `SessionEnd` SETTLES FIRST (D57 §DX7). Every other event has a next
595
- // invocation to pick up a lagging write; this one does not, so bytes missed
596
- // here are the final turn, lost permanently.
597
- // ⛔ RESOLVED THROUGH THE DIALECT BEFORE EITHER BRANCH READS IT (`CR-195`,
598
- // D205). `input.eventName` is the agent's own spelling — Cursor sends
599
- // `sessionEnd`, not `SessionEnd` — and both lines below used to compare the
600
- // raw string against this client's vocabulary. That comparison did not fail
601
- // loudly for Cursor; it returned `false`, which cost the final turn every
602
- // session and carved no reserve.
603
- //
604
- // ⚠ Keyed on `ctx.agentId`, deliberately, and this is NOT the `CR-204`
605
- // root-derived selection used for redaction and delegated transcripts. The
606
- // event vocabulary is a property of the process that INVOKED us, and
607
- // `--agent=` is the value WE write into that agent's own hook config — so it
608
- // is the one selector that is not attacker-influenced input. `dialectBudgetMs`
609
- // already keys the budget off it for the same reason.
527
+ // `SessionEnd` SETTLES FIRST (D57 §DX7): every other event has a next invocation to pick up a lagging write; this one does
528
+ // not. Resolved through the dialect (`CR-195`, D205): Cursor sends `sessionEnd`.
610
529
  const isSessionEnd = event === SESSION_END_EVENT;
611
- // ⛔ ONE FIRING, ONE PROCESS — `CR-195`/U4a. Claimed BEFORE the settle, so a
612
- // duplicate costs nothing rather than spending the budget to discover it is
613
- // redundant.
614
- //
615
- // ⛔ KEYED ON THE CANONICAL EVENT, NOT `input.eventName`. The two racers do
616
- // not necessarily agree on the wire spelling — that is the whole shape of this
617
- // defect: one invocation comes from Cursor's own config and one from Claude's,
618
- // mapped, and they carry different `--agent=` flags. Resolving first is what
619
- // makes both compute the SAME key; keying on the raw string would give them
620
- // two keys and let both run, which is the failure this guards.
530
+ // --- TWO LOCKS, and they are different questions (O5). ---
621
531
  //
622
- // ⚠ The raw name is the fallback for an event we could not resolve. Such an
623
- // invocation does almost nothing, but two of them are still two writers.
532
+ // 1. `claimFiring`: "is this exact firing already running?" ONE FIRING, ONE PROCESS (`CR-195`/U4a): claimed BEFORE the settle
533
+ // so a duplicate costs nothing; keyed on the canonical event; the loser BOWS OUT. It lives beside the turn-end repo's state
534
+ // exactly as before, or beside the turn-end folder when the turn ended outside any work tree. ⚠ `claimFiring` returns
535
+ // `null` for two reasons (held by another, or no lock path at all) and only the first may bow out: an unlockable firing
536
+ // FAILS OPEN, because bowing out of every invocation would be a total silent outage.
537
+ // 2. `claimSessionWriter`: "is another writer touching THIS SESSION right now?": ONE LOCK PER `sessionId`, whatever repo each
538
+ // writer stands in, serialising the target record, every ledger and every spool against Stop, PreCompact, SessionEnd,
539
+ // `finalize` and `backfill`. A different event WAITS inside this hook's own budget and gives up with `null` (its bytes ride
540
+ // the next firing or the idle drain), never holding the watchdog hostage.
624
541
  const fireKey = {
625
- repoKey: projectKey,
542
+ repoKey: SESSION_SCOPE,
626
543
  sessionId: input.sessionId,
627
544
  event: event ?? input.eventName,
628
545
  };
629
- // ⛔ `claimFiring` RETURNS `null` FOR TWO DIFFERENT REASONS, and conflating
630
- // them would turn this guard into a silent capture outage. One means *another
631
- // process holds this firing* — bow out. The other means *no lock path could be
632
- // built at all*, and treating that as a duplicate would refuse EVERY
633
- // invocation, permanently and silently.
634
- //
635
- // ⚠ It is currently unreachable — `resolveProjectKeys` returns `null` rather
636
- // than an empty toplevel, and the consent gate above has already returned on
637
- // that. It is distinguished anyway because the two errors are not the same
638
- // size: an unlockable firing that runs is one possible duplicate, and an
639
- // unlockable firing that bows out is total silence. So the lock FAILS OPEN.
640
546
  const lockable = fireLockPath(ctx.home, fireKey) !== null;
641
- firingClaim = claimFiring(ctx.home, fireKey, budgetMs);
642
- if (firingClaim === null && lockable) {
643
- // Another process holds this exact firing. ⛔ Silent and exit 0: there is
644
- // nothing to report, and a `systemMessage` here would put a line in the
645
- // user's turn for a duplicate they did not cause. `finish` releases nothing
646
- // because we hold nothing.
547
+ const firing = claimFiring(ctx.home, fireKey, budgetMs);
548
+ if (firing === null && lockable) {
549
+ // Another process holds this exact firing. ⛔ Silent and exit 0: a `systemMessage` here would put a line in the user's turn
550
+ // for a duplicate they did not cause. `finish` releases nothing because we hold nothing.
647
551
  return null;
648
552
  }
553
+ if (firing !== null)
554
+ heldClaims.push(firing);
555
+ const writer = await claimSessionWriter(ctx.home, input.sessionId, {
556
+ staleAfterMs: budgetMs + STALE_MARGIN_MS,
557
+ deadlineAtMs: hookStartedAt + Math.floor(budgetMs * WRITER_WAIT_FRACTION),
558
+ wait,
559
+ });
560
+ if (writer === null)
561
+ return null;
562
+ heldClaims.push(writer);
649
563
  const settleFor = isSessionEnd ? settleDelayMs(ctx.env, budgetMs) : 0;
650
- // `CR-182`. Carved OUT of the budget, never added on top — the same shape the
651
- // settle takes, and for the same reason: the watchdog does not move.
652
- //
653
- // ⛔ AND THE PRE-ENTRY COST IS CHARGED HERE (`CR-195/U2`, the G6 defect). The
654
- // send loop measures its own consumption from `settleStart`, one line below;
655
- // the watchdog has been running since `runHook`. The stdin read, the consent
656
- // gate's `git rev-parse` spawn and the credential load all happened in
657
- // between, and `CR-126` measured that interval at p99 392 ms — more than the
658
- // whole 150 ms reserve. Passing it in is what makes the reserve a reserve
659
- // against the deadline that can actually end the process, rather than against
660
- // a clock that starts later than the one holding the gun.
564
+ // `CR-182`. Carved OUT of the budget, never added on top. ⛔ AND THE PRE-ENTRY COST IS CHARGED HERE (`CR-195/U2`, the G6
565
+ // defect): the stdin read, the gate's spawn, the credential load AND NOW the writer-lock wait all happened between the
566
+ // watchdog starting and this line, and the reserve must be measured against the clock that can actually end the process.
661
567
  const settleStart = Date.now();
662
568
  const sendBudget = sendBudgetMs(budgetMs, event, settleStart - hookStartedAt);
663
569
  const eof = settleFor > 0
@@ -665,216 +571,487 @@ async function hookBody(ctx, budgetMs, hookStartedAt) {
665
571
  : fileSize(input.transcriptPath);
666
572
  if (eof === null)
667
573
  return null;
668
- // MEASURED, not assumed to be `settleFor`: the wait can overshoot under load,
669
- // and the budget cares about the time actually spent.
574
+ // MEASURED, not assumed to be `settleFor`: the wait can overshoot under load, and the budget cares about the time spent.
670
575
  const consumedMs = settleFor > 0 ? Date.now() - settleStart : 0;
671
- // The WIRE identity — `owner/repo` when `origin` is GitHub, `host/owner/repo`
672
- // for any other host, else `local:<hash>`. Required by the merged server
673
- // (`CR-019b`), which 400s a delta without it rather than writing a row nobody
674
- // can read (D57 §OV1).
675
- //
676
- // ⛔ RESOLVED EXACTLY ONCE PER HOOK (`TODOS[87]`). It spawns
677
- // `git remote get-url origin`, and it used to be called here AND once per
678
- // sub-agent file — up to nine spawns for a value that cannot change within an
679
- // invocation, since `projectKey` is fixed by the consent gate above. Passing
680
- // it down rather than re-deriving it is also what keeps the two paths from
681
- // ever disagreeing about which repository this is.
682
- const repoSlug = slugOf();
683
- // ⛔ THE ONE NEW SPAWN `CR-170` ADDS TO THIS PATH, and it is for REF MOVES,
684
- // not commits. `post-commit` observes commits; it cannot see a `pull`, a
685
- // `checkout` or a `reset --hard`, because those move a ref WITHOUT creating a
686
- // commit and no commit hook fires. A direct look at `HEAD` is the only record.
687
- //
688
- // ⚠ It shares `CR-169`'s spawn budget like every other probe, so a hung git
689
- // here is bounded and cannot defeat the watchdog. Null on an empty repository
690
- // (`rev-parse HEAD` exits 128 there) — a state, not an error.
691
- const head = headRef(projectKey);
692
- // Commits OBSERVED since the last delivery. Capped, with the remainder left in
693
- // the spool for the next hook rather than dropped.
694
- const spoolKey = { repoKey: projectKey, sessionId: input.sessionId };
695
- // ⛔ PROMOTE FIRST (`CR-195`/D208). A commit made during this session's FIRST
696
- // turn was recorded uncorroborated, because `Stop` fires at the end of a turn
697
- // and no state file existed when `post-commit` ran. By the time this hook is
698
- // executing, that state file is this session's own — which is exactly the
699
- // corroboration rung 1 requires — so the observation becomes deliverable now
700
- // rather than being lost. Before this line, every first-turn commit was
701
- // dropped: MEASURED as commit `f1608bc`, spooled nowhere.
702
- // ⛔ The corroboration is THIS CALL SITE: `spoolKey.sessionId` is
703
- // `input.sessionId`, the session whose hook is executing. See `promotePending`
704
- // for why a state-file test is the wrong one and fails the first turn.
705
- // ⛔ `TODOS[176]` — THE PROMOTION BOUND, applied to EVERY promotion (the first-turn cold case too): a pending line observed
706
- // before the START OF THIS TURN (the first unsent prompt-like record, skipping the previous Stop's trailing records and any
707
- // record without a timestamp) predates the turn and is dropped. `stale-own` = this session's state file exists and is
708
- // not live, i.e. it is being RESUMED; `first-turn` = no state file yet (or a live one — the first Stop promotes before it saves). They differ only when no turn start can be found: stale-own DROPS (0.4.0's
709
- // behaviour), first-turn KEEPS. See `pending_bound.ts` for the rule, the clocks and the scan cap.
710
- const pendingMode = pendingModeFor(ctx.home, spoolKey);
711
- const bound = promotionBound(ctx.home, spoolKey, input.transcriptPath, Date.now());
712
- promotePending(ctx.home, spoolKey, "this-session-is-running", bound, pendingMode);
713
- // ⛔ `TODOS[175]` — ORPHAN PENDING FILES. After THIS session's own pending lines were promoted (or dropped by the bound), sweep the
714
- // repo for pending files nobody can claim any more (older than 7 days; see `pending_prune.ts` for the rule, the tombstone race and
715
- // the counts-only tally `status` announces). Stop and SessionEnd ONLY: never `post-commit` (it runs under git, on the commit
716
- // path) and never `status` (readers stay read-only). Cheap (one readdir, at most 50 files) and it never throws.
717
- if (event === "Stop" || isSessionEnd) {
718
- pruneOrphanPending(ctx.home, projectKey, Date.now());
719
- // `TODOS[177]` follow-up (VL, 2026-10-08, item 5): `.inferred.jsonl` gets the same 7-day sweep.
720
- //
721
- // ⛔⛔ THE RETURN VALUE IS NOT DISCARDED (VL, 2026-10-08, VG's silent-loss finding, G1: a mutant that makes this
722
- // call a no-op SURVIVED all 57 tests, because the function was tested in isolation but nothing here checked it
723
- // was actually WIRED). `pairs` is fed to the same durable tally `set_aside` uses, before the sweep's own
724
- // tombstone-delete removes the file `status`'s live gauge would otherwise have read.
725
- const prunedInferred = pruneExpiredInferred(ctx.home, projectKey, Date.now());
726
- if (prunedInferred.pairs > 0)
727
- recordInferredSkipped(ctx.home, projectKey, "pruned", Date.now(), prunedInferred.pairs);
728
- }
729
- const spooled = capSpool(readSpool(ctx.home, spoolKey));
730
- // ⛔ REWRITES ARE THEIR OWN FILE WITH THEIR OWN CAP (`T5`). The units differ —
731
- // 41 bytes for a sha, 82 for an `ancestor:successor` pair — so one constant
732
- // sized against the other would make one of the two headers wrong.
733
- const rewritten = capSuccessors(readRewrites(ctx.home, spoolKey));
734
- // `TODOS[177]` — INFERRED squash pairs: their own file, their own cap, their own header (`x-rewrites-inferred`), never `x-rewrites`.
735
- const inferred = capInferred(readInferred(ctx.home, spoolKey));
736
- // ⛔ THE REDACTION BOUNDARY, and it is NOT the consent key (`D184 §3`).
737
- // Resolved here rather than at the gate so the derivation's one spawn is not
738
- // paid by the invocations that return above without reading a byte.
739
- const redactionRoots = confinementRoots(keys);
740
- const delivery = await deliver({
576
+ const resolution = resolveTargets({
741
577
  home: ctx.home,
742
578
  env: ctx.env,
743
- url,
744
- credential: load.credential,
745
- // The consent gate's key, reused rather than re-resolved: a second
746
- // `git rev-parse` could disagree with the one consent was checked against.
747
- repoKey: projectKey,
748
- repoSlug,
749
579
  sessionId: input.sessionId,
750
- fileKey: "main",
751
- // ⛔ THE PATH, so `deliver()` can derive `X-Agent` from the root that
752
- // contains it (`CR-204`, D177 §7). Deliberately the same expression the
753
- // `readBody` closure below takes, so the bytes and the producer can never
754
- // describe two different files.
755
580
  transcriptPath: input.transcriptPath,
756
- // ⛔ `sendBudget`, not `budgetMs`. The main send is the single largest
757
- // consumer on this path, so a reserve the loop honours and this does not
758
- // is one the biggest spender walks straight through (`CR-182`).
759
- timeoutMs: sendTimeoutMs(sendBudget, consumedMs),
760
- // PR 4a — WHEN THE SENDS MUST STOP: `sendBudget` is measured from `settleStart`, the same origin `consumedMs` uses.
761
- // A span larger than one delta goes out as several only while this lasts; the rest waits for the next event.
762
- deadlineAtMs: settleStart + sendBudget,
581
+ turnEndCwd: input.cwd,
582
+ mode: isSessionEnd ? "end" : "turn",
763
583
  nowMs: Date.now(),
764
- byHook: true,
765
- // ⛔ THE MAIN DELIVERY ONLY. Sub-agent deliveries below leave all three
766
- // unset: each is its own `capture_id`, and `capture_commits`' PK is
767
- // `(org_id, capture_id, commit_sha)`, so attaching these to all nine
768
- // deliveries would admit nine legal, permanent rows for one commit — the
769
- // objection that killed the polling design (D154).
770
- head,
771
- // ⛔ THE WHOLE OBJECT, NOT `spooled.shas` (`T5`, `D190 §1`). The shas and
772
- // their rungs are only safe as a pair — `parseObservedCommits` drops the
773
- // ENTIRE BATCH when the two lists differ in length — and `count` is the
774
- // number of spool LINES consumed, which is what the 2xx truncates by. A
775
- // held entry is consumed without being sent, so `shas.length` is the wrong
776
- // number for that and passing the pieces separately is how they diverge.
777
- commits: spooled,
778
- rewrites: rewritten.pairs,
779
- inferredRewrites: inferred.pairs,
780
- inferredGroups: inferred.groups,
781
- }, eof,
782
- // ⛔ THE ROOT SET, AND IT IS NOT THE VALUE THE CONSENT GATE APPROVED.
783
- // Those were one value until `D184`, and this line is where the difference
784
- // becomes observable: redaction is measured against `[worktree,
785
- // mainToplevel]`, never against the git common dir. If it followed the
786
- // consent key there, `isInside` would fail-close every file of an external
787
- // worktree to "outside the main clone" and hand the server a span of
788
- // markers — a capture that succeeds, with turn counts that look right and
789
- // no content in it (`D184 §3`).
790
- (from, to) => readSpan(input.transcriptPath, from, to, redactionRoots, ctx.home, ctx.env), recordCutter(input.transcriptPath));
791
- // --- DELEGATED WORK. `CR-124`, D76. ---
792
- // Claude Code writes sub-agent transcripts to `<session>/subagents/`, which
793
- // `transcript_path` never names: ~16% of transcript volume, and the work a
794
- // reviewer most wants attributed. MAIN WENT FIRST, deliberately (D95) — the
795
- // content most likely to matter is the content most likely to fit.
796
- const subagents = await deliverSubagents({
797
- ctx,
798
- input,
799
- url,
800
- credential: load.credential,
801
- projectKey,
802
- redactionRoots,
803
- repoSlug,
804
- startedAt: settleStart,
805
- sendBudget,
806
- byHook: true,
584
+ // ⛔ The RESOLVER gets a STRICT thunk (C17 #13b): `slugOf` above falls back to `local:<hash>` on a timeout, and what the resolver is given
585
+ // becomes the target's sticky identity. The consent gate keeps `slugOf`.
586
+ ...(keys !== null && slugOf !== null ? { known: { keys, slugOf: onceStrictRepoSlug(keys.worktree) } } : {}),
807
587
  });
808
- // --- THE HONEST HOLE. D95, widened to `main` by `CR-182`. ---
809
- //
810
- // ⛔ AFTER EVERY SEND, AND IT CANNOT MOVE. `markSkipped` sets
811
- // `sentOffset = to`, so stamping first would leave `nextSpan` nothing to
812
- // return for any stream — it would gap the whole session and send none of it.
813
- //
814
- // ⛔ AND IT CANNOT HANG OFF `finish()`, which the signal handlers reach and
815
- // which must stay minimal.
816
- //
817
- // It sits here rather than inside `deliverSubagents` because that function
818
- // returns early when there are no delegated files at all — and the MAIN
819
- // transcript's gap must not be conditional on a `subagents/` directory
820
- // existing. `eof`, not a fresh `statSync`: the gap records what this
821
- // invocation COMMITTED to sending and could not, which is exactly what
822
- // `SubagentFile.size` is for the delegated streams.
823
- //
824
- // ⛔ AND `CR-228` (`TODOS[142]`): it writes off only what nothing explains.
825
- // `held` is whether ANY delivery in this invocation ended `later`/`fatal`, or
826
- // was refused by a standing stop — the main one or a delegated one. When it
827
- // did, every stream still behind is behind because of THAT, not because D95's
828
- // budget ran out against a server that was answering.
829
- if (isSessionEnd) {
830
- stampGaps(ctx, input, projectKey, [{ fileKey: "main", size: eof }, ...subagents.files], endedHeld(delivery) || subagents.held, Date.now());
588
+ if (resolution.targets.length === 0) {
589
+ // Nothing granted was named (R8 · D11 · B): the hook stays SILENT, as it always was for an unconsented repo. A repo or
590
+ // folder the session named that nobody decided about is recorded BY NAME for `vibecommit status` to list.
591
+ //
592
+ // But D22 says ANY later firing drains idle sessions, and a session that names nothing granted (common in a parent folder)
593
+ // is exactly the firing that has the whole deadline spare. So: load the credential SILENTLY (no notice, ever: this session
594
+ // has nothing to say), and if it is usable, spend the leftover on others' owed bytes.
595
+ const quiet = loadCredential({ env: ctx.env, home: ctx.home });
596
+ if (quiet.kind === "ok") {
597
+ try {
598
+ await drainIdleSessions({
599
+ ctx,
600
+ url,
601
+ credential: quiet.credential,
602
+ currentSessionId: input.sessionId,
603
+ settleStart,
604
+ sendBudget,
605
+ staleAfterMs: budgetMs + STALE_MARGIN_MS,
606
+ });
607
+ }
608
+ catch {
609
+ // A hook cannot report an error.
610
+ }
611
+ }
612
+ return null;
831
613
  }
832
- // The three classes are decided in `policy.ts` and applied in `post.ts`. Two
833
- // of the four outcomes reach a human.
614
+ const load = loadCredential({ env: ctx.env, home: ctx.home });
615
+ switch (load.kind) {
616
+ case "absent":
617
+ return notice("not_connected");
618
+ case "wrong-class":
619
+ case "insecure-file":
620
+ case "unreadable":
621
+ // Interactive verbs print four different fixes for these (§13.6). A hook has one channel and a one-line budget, so they
622
+ // collapse to the state whose fix, `vibecommit connect`, is the same for all four.
623
+ return notice("not_connected");
624
+ case "ok":
625
+ break;
626
+ }
627
+ // --- ONE SESSION, N TARGETS, ONE DEADLINE (A2). ---
628
+ // Least-recently-sent first, so a target with a 20 MB backlog cannot starve the others forever: it spends the firing, its
629
+ // `lastSentAt` moves, and the NEXT firing serves the one that waited. Each target's own sends still honour the shared
630
+ // deadline (`deadlineAtMs`), so the whole firing stays inside the one budget.
631
+ const ordered = leastRecentlySentFirst(ctx.home, input.sessionId, resolution.targets);
632
+ // ⛔ D95 STAYS, exactly as written, for the one case R14 protects: a session whose turns end in ONE granted work tree and
633
+ // which names nothing else granted. Every other session (several targets, a target found only by evidence, a folder target)
634
+ // has later firings that can finish it, so a shortfall at SessionEnd is OWED there, not written off (R19 · D22).
635
+ const writeOffAtSessionEnd = ordered.length === 1 && ordered[0].kind === "repo" && resolution.turnEndSlug === normalizeSlug(ordered[0].slug);
636
+ const deadline = settleStart + sendBudget;
637
+ const results = [];
638
+ for (const target of ordered) {
639
+ const result = await deliverTarget({
640
+ ctx,
641
+ input,
642
+ url,
643
+ credential: load.credential,
644
+ target,
645
+ eof,
646
+ event,
647
+ isSessionEnd,
648
+ settleStart,
649
+ sendBudget,
650
+ consumedMs: results.length === 0 ? consumedMs : Date.now() - settleStart,
651
+ // A target reached with the deadline already spent sends nothing this firing but is still STAMPED at SessionEnd.
652
+ skipSend: results.length > 0 && Date.now() >= deadline,
653
+ owedAtSessionEnd: !writeOffAtSessionEnd,
654
+ scanned: scannedOffsets(resolution.record.scan),
655
+ record: resolution.record,
656
+ });
657
+ results.push(result);
658
+ }
659
+ // The three classes are decided in `policy.ts` and applied in `post.ts`. Two of the four outcomes reach a human.
834
660
  //
835
- // `fatal`: a hook that stays silent about a revoked credential captures
836
- // nothing forever. `later` and `never` say nothing — a hook has one line per
837
- // session and spending it on a condition that resolves itself next turn is the
838
- // tool noise D57 §DX3 exists to avoid.
661
+ // `fatal`: a hook that stays silent about a revoked credential captures nothing forever. `later` and `never` say nothing: a
662
+ // hook has one line per session and spending it on a condition that resolves itself next turn is the tool noise D57 §DX3
663
+ // exists to avoid. `ok`: the fourth state (`CR-023d`), and its `(repo, day)` claim is what keeps it to once.
839
664
  //
840
- // `ok`: the fourth state (`CR-023d`), and the FIRST time this hook has ever
841
- // spoken on success. Its `(repo, day)` claim is what keeps it to once — no
842
- // "have I already said this today" check belongs here, because the atomic `wx`
843
- // claim already is that check, and a second one could disagree with it.
844
- if (delivery.kind === "attempted") {
845
- if (delivery.disposition === "fatal") {
846
- // ⛔ QUIET DURING ROTATION (`11A`). This hook loaded its credential before an `auth` replaced
847
- // the file; its 401 is about a credential that is already gone. If what is on disk NOW is a
848
- // different credential, there is nothing to tell the user — the stop is keyed to the old
849
- // fingerprint and so does not apply to the new one.
665
+ // With N targets the answer is ONE: any `fatal` wins (it is credential-wide by definition), else the first target that
666
+ // delivered speaks. A per-target stop (C3) is never `fatal` and is never a notice: `status` names it.
667
+ const fatal = results.find((r) => r.stopped === false && r.delivery.kind === "attempted" && r.delivery.disposition === "fatal");
668
+ const payload = (() => {
669
+ if (fatal !== undefined && fatal.delivery.kind === "attempted") {
670
+ // ⛔ QUIET DURING ROTATION (`11A`). This hook loaded its credential before an `auth` replaced the file; its 401 is about a
671
+ // credential that is already gone. If what is on disk NOW is a different credential, there is nothing to tell the user.
850
672
  const current = loadCredential({ env: ctx.env, home: ctx.home });
851
- if (current.kind === "ok" &&
852
- credentialFingerprint(current.credential) !== credentialFingerprint(load.credential)) {
673
+ if (current.kind === "ok" && credentialFingerprint(current.credential) !== credentialFingerprint(load.credential)) {
853
674
  return null;
854
675
  }
855
676
  return notice("credential_revoked", null, load.credential);
856
677
  }
857
- if (delivery.disposition === "ok") {
858
- // `CR-234d` (12A): a 2xx carries where the credential stands (`X-Credential-Expires`). Remember it in
859
- // the SIDECAR — never in `credentials.json`, which a hook must not rewrite (R10) — and, from 14 days
860
- // before the end, say so once per machine per day. It outranks the positive message: that one repeats
861
- // daily, this one is the only way a person learns capture will stop on a date.
862
- if (delivery.credentialExpires != null) {
863
- const meta = noteCredentialExpiry(ctx.home, ctx.env, credentialFingerprint(load.credential), delivery.credentialExpires, new Date());
864
- const endsAt = endingSoon(meta, Date.now());
865
- if (endsAt !== null) {
866
- const ending = notice("credential_ending", null, load.credential, endsAt);
867
- if (ending !== null)
868
- return ending;
678
+ const ok = results.find((r) => r.delivery.kind === "attempted" && r.delivery.disposition === "ok");
679
+ if (ok === undefined || ok.delivery.kind !== "attempted")
680
+ return null;
681
+ // `CR-234d` (12A): a 2xx carries where the credential stands (`X-Credential-Expires`). Remember it in the SIDECAR (never in
682
+ // `credentials.json`, which a hook must not rewrite, R10) and, from 14 days before the end, say so once per machine per day.
683
+ // It outranks the positive message: that one repeats daily, this one is the only way a person learns capture will stop.
684
+ if (ok.delivery.credentialExpires != null) {
685
+ const meta = noteCredentialExpiry(ctx.home, ctx.env, credentialFingerprint(load.credential), ok.delivery.credentialExpires, new Date());
686
+ const endsAt = endingSoon(meta, Date.now());
687
+ if (endsAt !== null) {
688
+ const ending = notice("credential_ending", null, load.credential, endsAt);
689
+ if (ending !== null)
690
+ return ending;
691
+ }
692
+ }
693
+ return ((firstStop?.state == null ? null : notice(firstStop.state, null, undefined, undefined, firstStop.cloneKey)) ??
694
+ notice("capture_confirmed", ok.target.ledgerKey));
695
+ })();
696
+ // The leftover of the one deadline goes to sessions that have had no firing for 10 minutes (R19 · D22): never throws.
697
+ try {
698
+ await drainIdleSessions({
699
+ ctx,
700
+ url,
701
+ credential: load.credential,
702
+ currentSessionId: input.sessionId,
703
+ settleStart,
704
+ sendBudget,
705
+ staleAfterMs: budgetMs + STALE_MARGIN_MS,
706
+ });
707
+ }
708
+ catch {
709
+ // A hook cannot report an error, and a drain that failed leaves every session exactly as it was.
710
+ }
711
+ return payload;
712
+ }
713
+ /**
714
+ * How much of the hook's budget a firing may spend WAITING for another writer of the same session before it gives up. Most of it (0.8): a different event is serialised behind the holder, and the holder's own send can run to its timeout before it lets go, so a shorter
715
+ * window would give up on exactly the overlapping Stop/SessionEnd case (`session-end-gaps` U4a). The
716
+ * rest must still cover the settle, the send and (`SessionEnd`) the gap stamp. A waiter that gives up loses nothing: its bytes
717
+ * ride the next firing, or the idle drain.
718
+ */
719
+ const WRITER_WAIT_FRACTION = 0.8;
720
+ /**
721
+ * Targets in the order they are served: the one that sent LEAST recently first (A2). `lastSentAt` is the latest successful send
722
+ * across the target's ledger's streams, 0 for "never", so a target that has never been served goes first and a target that just
723
+ * spent a firing on a big backlog goes last. Stable, so equal targets keep the resolver's order.
724
+ */
725
+ function leastRecentlySentFirst(home, sessionId, targets) {
726
+ const lastSent = (target) => {
727
+ const state = loadSessionState(home, { repoKey: target.ledgerKey, sessionId });
728
+ return Math.max(0, ...Object.values(state.files).map((file) => file.lastSentAt));
729
+ };
730
+ return targets
731
+ .map((target, index) => ({ target, index, at: lastSent(target) }))
732
+ .sort((a, b) => a.at - b.at || a.index - b.index)
733
+ .map((entry) => entry.target);
734
+ }
735
+ /**
736
+ * Deliver ONE target of ONE session: what `hookBody` used to do inline for its single repo, now per target.
737
+ *
738
+ * promote and prune the target's pending commits, read its spool UNION, deliver `main`, then its sub-agents, then (on
739
+ * `SessionEnd`) stamp the honest hole. The order is the old one and every comment on it still holds: promote first (`CR-195`/
740
+ * D208), main before sub-agents (D95), the stamp AFTER every send because `markSkipped` sets `sentOffset = to`.
741
+ *
742
+ * ⛔ **Per target, never shared:** the ledger (`target.ledgerKey`, one per (slug, session), R5), the slug, the redaction roots
743
+ * and the spool sources. A session that worked in two granted repos sends each its own copy through its own ledger, so neither
744
+ * stream's offset can rewind the other's.
745
+ */
746
+ async function deliverTarget(opts) {
747
+ const { ctx, input, url, credential, target, event, isSessionEnd, settleStart, sendBudget, consumedMs } = opts;
748
+ // ⛔ A FOLDER stream delivers only up to what the resolver has SCANNED (P1, A-T3 requirement b): bytes appended between the scan
749
+ // and this read were never checked for repo evidence, and a repo they name would end the folder rule. A repo target has no such
750
+ // limit: its bytes are its own whatever else the session named.
751
+ // C42: a REPO copy of a dialect that isolates records may deliver past the scan cursor. Every per-record decision there is exact (the record's own
752
+ // cwd, the path rule, the always-withheld types); the two facts the checkpoint cannot hold are learned IN-SPAN, in order (`pastCursorOf`).
753
+ const folderCap = target.kind === "folder" ? (opts.scanned ?? {}) : undefined;
754
+ const eof = folderCap === undefined ? opts.eof : Math.min(opts.eof, folderCap.main ?? 0);
755
+ const baseIsolation = isolationOf(target, opts.record);
756
+ const isolation = opts.record !== undefined && opts.scanned !== undefined && dialectIsolates(ctx.home, ctx.env, input.transcriptPath)
757
+ ? { ...baseIsolation, past: pastCursorOf(baseIsolation, opts.scanned, { deadlineAtMs: settleStart + sendBudget }) }
758
+ : baseIsolation;
759
+ const sessionId = input.sessionId;
760
+ const ledgerKey = target.ledgerKey;
761
+ const sourceKeys = [ledgerKey, ...target.worktrees.filter((worktree) => worktree !== ledgerKey)];
762
+ // ⛔ PROMOTE FIRST (`CR-195`/D208), for every work tree whose spool this target reads. A commit made during this session's
763
+ // FIRST turn was recorded uncorroborated, because `Stop` fires at the end of a turn and no state file existed when
764
+ // `post-commit` ran. The corroboration is THIS CALL SITE: `sessionId` is the session whose hook is executing.
765
+ // ⛔ `TODOS[176]` — THE PROMOTION BOUND applies to every promotion; ⛔ `TODOS[175]` — orphan pending files are swept on Stop and
766
+ // SessionEnd only, never from `post-commit` and never from `status`. The `TODOS[177]` follow-up (VL, 2026-10-08): the sweep's
767
+ // return value is NOT discarded, it feeds the durable `set_aside` tally.
768
+ for (const projectKey of sourceKeys) {
769
+ const spoolKey = { repoKey: projectKey, sessionId: input.sessionId };
770
+ const pendingMode = pendingModeFor(ctx.home, spoolKey);
771
+ const bound = promotionBound(ctx.home, spoolKey, input.transcriptPath, Date.now());
772
+ promotePending(ctx.home, spoolKey, "this-session-is-running", bound, pendingMode);
773
+ if (event === "Stop" || isSessionEnd) {
774
+ pruneOrphanPending(ctx.home, projectKey, Date.now());
775
+ const prunedInferred = pruneExpiredInferred(ctx.home, projectKey, Date.now());
776
+ if (prunedInferred.pairs > 0)
777
+ recordInferredSkipped(ctx.home, projectKey, "pruned", Date.now(), prunedInferred.pairs);
778
+ }
779
+ }
780
+ let delivery = { kind: "nothing-to-send" };
781
+ let subagents = { files: [], held: false, deliveries: [] };
782
+ let stopped = false;
783
+ if (!opts.skipSend) {
784
+ // ⛔ THE UNION, acknowledged per source (O7): commits, rewrites and inferred squashes of every work tree of this repo for this
785
+ // session. A single-worktree target reads exactly the three calls the hook always made.
786
+ const union = readSpoolUnion(ctx.home, sessionId, ledgerKey, target.worktrees);
787
+ // ⛔ THE ONE NEW SPAWN `CR-170` ADDED, for REF MOVES, not commits: a direct look at `HEAD` is the only record of a `pull`,
788
+ // `checkout` or `reset --hard`. Null on an empty repository. A FOLDER has no HEAD: no spawn.
789
+ const head = target.kind === "repo" ? headRef(ledgerKey) : null;
790
+ delivery = await deliver({
791
+ home: ctx.home,
792
+ env: ctx.env,
793
+ url,
794
+ credential,
795
+ repoKey: ledgerKey,
796
+ repoSlug: target.slug,
797
+ sessionId,
798
+ fileKey: "main",
799
+ // ⛔ THE PATH, so `deliver()` can derive `X-Agent` from the root that contains it (`CR-204`, D177 §7).
800
+ transcriptPath: input.transcriptPath,
801
+ // ⛔ `sendBudget`, not `budgetMs`: the main send is the single largest consumer on this path (`CR-182`).
802
+ timeoutMs: sendTimeoutMs(sendBudget, consumedMs),
803
+ // PR 4a: WHEN THE SENDS MUST STOP. Shared by every target of the firing, so the rest waits for the next event.
804
+ deadlineAtMs: settleStart + sendBudget,
805
+ nowMs: Date.now(),
806
+ byHook: true,
807
+ // ⛔ THE MAIN DELIVERY ONLY. Sub-agent deliveries leave all three unset: each is its own `capture_id`, and
808
+ // `capture_commits`' PK is `(org_id, capture_id, commit_sha)`, so attaching these to all of them would admit several
809
+ // legal, permanent rows for one commit (D154).
810
+ head,
811
+ // ⛔ THE WHOLE OBJECT, NOT `spooled.shas` (`T5`, `D190 §1`): shas and rungs are only safe as a pair, and `count` is the
812
+ // number of spool LINES consumed, which is what a 2xx truncates by.
813
+ commits: union.commits,
814
+ rewrites: union.rewrites.pairs,
815
+ inferredRewrites: union.inferred.pairs,
816
+ inferredGroups: union.inferred.groups,
817
+ spoolSources: union.sources,
818
+ }, eof,
819
+ // ⛔ THE ROOT SET, AND IT IS NOT THE VALUE THE CONSENT GATE APPROVED (`D184 §3`): redaction is measured against this
820
+ // target's own roots, never against the git common dir, or `isInside` would fail-close every file of an external worktree.
821
+ (from, to) => readSpan(input.transcriptPath, from, to, target.redactionRoots, ctx.home, ctx.env, isolation), recordCutter(input.transcriptPath));
822
+ // C3: the server refused THIS target for good. It is `never` (the span is advanced past, never held), and it is recorded as
823
+ // a per-target stop so the next firing does not ask again. Never credential-wide, never `fatal`.
824
+ if (delivery.kind === "attempted" && delivery.detail?.code === FOLDER_REPOSITORY_FORBIDDEN) {
825
+ stopTarget(ctx.home, sessionId, target, FOLDER_REPOSITORY_FORBIDDEN, Date.now());
826
+ return { target, delivery, subagents, stopped: true };
827
+ }
828
+ // --- DELEGATED WORK. `CR-124`, D76. ---
829
+ // Claude Code writes sub-agent transcripts to `<session>/subagents/`, which `transcript_path` never names: ~16% of
830
+ // transcript volume. MAIN WENT FIRST, deliberately (D95).
831
+ subagents = await deliverSubagents({
832
+ ctx,
833
+ input,
834
+ url,
835
+ credential,
836
+ projectKey: ledgerKey,
837
+ redactionRoots: target.redactionRoots,
838
+ repoSlug: target.slug,
839
+ startedAt: settleStart,
840
+ sendBudget,
841
+ byHook: true,
842
+ ...(folderCap === undefined ? {} : { sizeCap: folderCap }),
843
+ isolation,
844
+ });
845
+ }
846
+ else {
847
+ subagents = { files: findSubagentFiles(ctx, input.transcriptPath), held: false, deliveries: [] };
848
+ }
849
+ // --- THE HONEST HOLE. D95, widened to `main` by `CR-182`. ---
850
+ // ⛔ AFTER EVERY SEND, AND IT CANNOT MOVE: `markSkipped` sets `sentOffset = to`, so stamping first would leave `nextSpan`
851
+ // nothing to return. ⛔ And `CR-228` (`TODOS[142]`): it writes off only what nothing explains. `held` is whether ANY delivery
852
+ // of this target ended `later`/`fatal`, or was refused by a standing stop. R19 · D22 adds the other case that is HELD, not
853
+ // written off: `owedAtSessionEnd`, a session other than the one-target in-repo case, whose later firings can finish it.
854
+ if (isSessionEnd) {
855
+ stampGaps(ctx, input, ledgerKey, [{ fileKey: "main", size: eof }, ...subagents.files], endedHeld(delivery) || subagents.held || opts.owedAtSessionEnd, Date.now());
856
+ }
857
+ return { target, delivery, subagents, stopped };
858
+ }
859
+ /** The resolver's scanned offset per stream, for the folder cap. */
860
+ function scannedOffsets(scan) {
861
+ return Object.fromEntries(Object.entries(scan).map(([fileKey, cursor]) => [fileKey, cursor.offset]));
862
+ }
863
+ /** A session with no firing of its own for this long is IDLE: another session's firing may finish its owed bytes (R19 · D22). */
864
+ const DRAIN_IDLE_MS = 10 * 60_000;
865
+ /** Session records one firing looks at. Examined ones move to the back of the line (their mtime moves), so none starves. */
866
+ const DRAIN_EXAMINE_MAX = 40;
867
+ /** A record whose transcript is gone and that owes nothing is forgotten after this long. */
868
+ const DRAIN_FORGET_MS = 30 * 24 * 3600_000;
869
+ /** Do not START another session with less than this left of the shared deadline: its first request could not finish. */
870
+ const DRAIN_MIN_LEFT_MS = 250;
871
+ /**
872
+ * An owed target whose transcript is GONE (Claude Code deletes transcripts ~30 days on) can never be sent: its hold becomes the
873
+ * honest gap now (R19 · D22), and the hold is cleared. Whatever was already delivered is untouched.
874
+ */
875
+ function writeOffOwed(home, record, nowMs) {
876
+ for (const target of record.targets) {
877
+ const key = { repoKey: target.ledgerKey, sessionId: record.sessionId };
878
+ let session = loadSessionState(home, key);
879
+ if (session.endHold === null)
880
+ continue;
881
+ for (const [fileKey, eof] of Object.entries(session.endHold.eof)) {
882
+ const current = fileState(session, fileKey);
883
+ if (current.sentOffset < eof)
884
+ session = withFileState(session, fileKey, markSkipped(current, current.sentOffset, eof));
885
+ }
886
+ saveSessionState(home, key, { ...session, endHold: null, liveAt: session.liveAt });
887
+ void nowMs;
888
+ }
889
+ }
890
+ /**
891
+ * ⛔ THE IDLE-SESSION DRAIN (R19 · D22 · A). What finishes a session's unsent bytes after its LAST hook.
892
+ *
893
+ * Each hook run can send a few megabytes, and a session whose last hook ran with bytes still behind (a multi-repo session at
894
+ * `SessionEnd`, a repo found late in a long one) has no next hook of its own. So any later `Stop`, `PreCompact` or `SessionEnd`
895
+ * on this machine, AFTER its own session's targets and inside the SAME one deadline, finishes sessions that have had no firing
896
+ * for 10 minutes: oldest first, each under THAT session's writer lock (never waited for: a busy session is skipped), consent
897
+ * re-checked by the resolver, and its discovery continued (≤8 MiB and ≤8 roots per firing, R16), which can add repo targets.
898
+ *
899
+ * - A session is RETIRED from the drain once its discovery is settled and nothing is behind: it costs a stat and moves to the
900
+ * back of the line. Discovery stopping never stops a repo target delivering owed bytes.
901
+ * - A folder decision is exactly R18's (`unknown` stays unknown here; backfill offers it); only an ALREADY-sticky folder target
902
+ * delivers, and only up to what was scanned.
903
+ * - An owed target whose transcript is gone becomes a gap then (`writeOffOwed`).
904
+ *
905
+ * ⛔ It spends only the LEFTOVER of the one deadline (`settleStart + sendBudget`): the deliveries below honour the same
906
+ * `deadlineAtMs`, and no new session starts with under `DRAIN_MIN_LEFT_MS` left. Never throws: a hook cannot say anything.
907
+ */
908
+ async function drainIdleSessions(opts) {
909
+ const { ctx, url, credential, settleStart, sendBudget } = opts;
910
+ const deadline = settleStart + sendBudget;
911
+ const candidates = listTargetRecords(ctx.home)
912
+ .filter(({ record }) => record.sessionId !== opts.currentSessionId)
913
+ .sort((a, b) => a.mtimeMs - b.mtimeMs)
914
+ .slice(0, DRAIN_EXAMINE_MAX);
915
+ for (const { path, record } of candidates) {
916
+ if (deadline - Date.now() < DRAIN_MIN_LEFT_MS)
917
+ return;
918
+ const now = Date.now();
919
+ if (now - record.firedAt < DRAIN_IDLE_MS)
920
+ continue;
921
+ // ⛔ A record the drain cannot act on (no transcript path, a transcript gone, a path confinement refuses) must still MOVE to the
922
+ // back of the line (C18 #7): the candidates are the oldest records by mtime, and forty of these at the front would otherwise be
923
+ // re-examined first on every firing and starve a live owed session behind them.
924
+ const rotate = () => {
925
+ try {
926
+ utimesSync(path, new Date(), new Date());
927
+ }
928
+ catch {
929
+ // Ordering only.
930
+ }
931
+ };
932
+ if (record.transcriptPath === null) {
933
+ rotate();
934
+ continue;
935
+ }
936
+ const transcript = record.transcriptPath;
937
+ // ⛔ C28: A SESSION OF A DIALECT THAT CANNOT ISOLATE IS NEVER OWED (Cursor; Codex until its own isolation is built). It delivers only
938
+ // the repo each turn ends in, on that turn's own firing; there is no later firing to finish for it and no discovery to continue, so
939
+ // the drain neither delivers nor writes off for it. It still moves to the back of the line, and the record is forgotten on the
940
+ // usual schedule (a session that has not fired for that long is over).
941
+ if (!dialectIsolates(ctx.home, ctx.env, transcript)) {
942
+ if (now - record.firedAt > DRAIN_FORGET_MS) {
943
+ try {
944
+ rmSync(path);
945
+ }
946
+ catch {
947
+ // A record we could not delete is just examined again.
869
948
  }
870
949
  }
871
- return ((firstStop?.state == null
872
- ? null
873
- : notice(firstStop.state, null, undefined, undefined, firstStop.cloneKey)) ??
874
- notice("capture_confirmed", projectKey));
950
+ else {
951
+ rotate();
952
+ }
953
+ continue;
954
+ }
955
+ // ⛔ CONFINEMENT, AGAIN (`/cso` finding 1): this path was read from a state file, not handed over by a gated hook input, and it
956
+ // reaches a read whose result is UPLOADED. A path admitted weeks ago can since have become a symlink out of the agent roots,
957
+ // and the record is only as trustworthy as the state directory. A file that is not there is not read, so it needs no gate
958
+ // (the write-off below touches no bytes).
959
+ if (existsSync(transcript) && !admitsTranscript(ctx.home, ctx.env, transcript)) {
960
+ rotate();
961
+ continue;
962
+ }
963
+ const size = fileSize(transcript);
964
+ if (size === null) {
965
+ // ⛔ THE WRITE-OFF IS A WRITE TO THE SESSION'S LEDGERS, so it takes the session's one writer lock like every other writer
966
+ // (C18 #6), never waiting: a session a delivery holds right now is skipped and examined on a later firing.
967
+ const writeOff = await claimSessionWriter(ctx.home, record.sessionId, { staleAfterMs: opts.staleAfterMs, deadlineAtMs: 0, wait });
968
+ if (writeOff === null) {
969
+ rotate();
970
+ continue;
971
+ }
972
+ try {
973
+ writeOffOwed(ctx.home, record, now);
974
+ }
975
+ finally {
976
+ writeOff.release();
977
+ }
978
+ if (now - record.firedAt > DRAIN_FORGET_MS) {
979
+ try {
980
+ rmSync(path);
981
+ }
982
+ catch {
983
+ // A record we could not delete is just examined again.
984
+ }
985
+ }
986
+ else {
987
+ rotate();
988
+ }
989
+ continue;
990
+ }
991
+ const live = record.targets.filter((target) => record.stopped[stopKeyOf(target)] === undefined);
992
+ const subFiles = findSubagentFiles(ctx, transcript);
993
+ const behind = live.some((target) => {
994
+ const state = loadSessionState(ctx.home, { repoKey: target.ledgerKey, sessionId: record.sessionId });
995
+ return (state.endHold !== null ||
996
+ fileState(state, "main").sentOffset < size ||
997
+ subFiles.some((file) => fileState(state, file.fileKey).sentOffset < file.size));
998
+ });
999
+ if (!behind && record.settled) {
1000
+ // Retired: nothing to send and nothing left to discover. Move it to the back of the line.
1001
+ try {
1002
+ utimesSync(path, new Date(), new Date());
1003
+ }
1004
+ catch {
1005
+ // Ordering only.
1006
+ }
1007
+ continue;
1008
+ }
1009
+ const claim = await claimSessionWriter(ctx.home, record.sessionId, { staleAfterMs: opts.staleAfterMs, deadlineAtMs: 0, wait });
1010
+ // ⛔ C29 (iii): a writer is on it right now: not ours to wait for, but it MOVES to the back of the line like every other record the
1011
+ // drain cannot act on (C18 #7). Left in place, forty busy records at the front would be re-examined first on every firing.
1012
+ if (claim === null) {
1013
+ rotate();
1014
+ continue;
1015
+ }
1016
+ try {
1017
+ const resolution = resolveTargets({
1018
+ home: ctx.home,
1019
+ env: ctx.env,
1020
+ sessionId: record.sessionId,
1021
+ transcriptPath: transcript,
1022
+ turnEndCwd: null,
1023
+ mode: "turn",
1024
+ nowMs: Date.now(),
1025
+ drain: true,
1026
+ });
1027
+ const input = { sessionId: record.sessionId, transcriptPath: transcript, cwd: "", eventName: "Stop" };
1028
+ for (const target of leastRecentlySentFirst(ctx.home, record.sessionId, resolution.targets)) {
1029
+ if (deadline - Date.now() < DRAIN_MIN_LEFT_MS)
1030
+ break;
1031
+ await deliverTarget({
1032
+ ctx,
1033
+ input,
1034
+ url,
1035
+ credential,
1036
+ target,
1037
+ eof: size,
1038
+ event: null,
1039
+ isSessionEnd: false,
1040
+ settleStart,
1041
+ sendBudget,
1042
+ consumedMs: Date.now() - settleStart,
1043
+ skipSend: false,
1044
+ owedAtSessionEnd: false,
1045
+ scanned: scannedOffsets(resolution.record.scan),
1046
+ record: resolution.record,
1047
+ });
1048
+ pruneDeliveredEndHold(ctx.home, { repoKey: target.ledgerKey, sessionId: record.sessionId });
1049
+ }
1050
+ }
1051
+ finally {
1052
+ claim.release();
875
1053
  }
876
1054
  }
877
- return null;
878
1055
  }
879
1056
  /**
880
1057
  * Send whatever delegated transcripts fit in what is left of the budget.
@@ -920,10 +1097,12 @@ export async function deliverSubagents(opts) {
920
1097
  // a fallback chain. Written this way so `findSubagentFiles` is byte-unchanged
921
1098
  // and `CR-204`'s root-derived gating (pinned by `test/wire-agent.test.ts` G4)
922
1099
  // keeps its meaning.
923
- const files = [
1100
+ const discovered = [
924
1101
  ...findSubagentFiles(ctx, input.transcriptPath),
925
1102
  ...announcedSubagentFiles(ctx, input),
926
1103
  ];
1104
+ const cap = opts.sizeCap;
1105
+ const files = cap === undefined ? discovered : discovered.map((f) => ({ ...f, size: Math.min(f.size, cap[f.fileKey] ?? 0) }));
927
1106
  if (files.length === 0)
928
1107
  return { files, held: false, deliveries: [] };
929
1108
  // ⚠ SKIP WHAT HAS NOTHING PENDING, BEFORE THE CAP APPLIES — and this is not an
@@ -948,6 +1127,8 @@ export async function deliverSubagents(opts) {
948
1127
  const consumedMs = Date.now() - startedAt;
949
1128
  if (sendBudget - consumedMs < MIN_SUBAGENT_BUDGET_MS)
950
1129
  break;
1130
+ if (opts.heartbeat !== undefined && !opts.heartbeat())
1131
+ break;
951
1132
  const delivery = await deliver({
952
1133
  home: ctx.home,
953
1134
  env: ctx.env,
@@ -967,11 +1148,12 @@ export async function deliverSubagents(opts) {
967
1148
  deadlineAtMs: startedAt + sendBudget,
968
1149
  nowMs: Date.now(),
969
1150
  byHook,
1151
+ ...(opts.heartbeat === undefined ? {} : { beforeRequest: opts.heartbeat }),
970
1152
  }, file.size,
971
1153
  // The SAME reader as the main transcript, so `CR-024d`'s redaction and
972
1154
  // `/cso` finding 1's confinement both apply. A delegated agent reading a
973
1155
  // third party's file is exactly as out-of-tree as the main thread doing it.
974
- (from, to) => readSpan(file.path, from, to, redactionRoots, ctx.home, ctx.env), recordCutter(file.path));
1156
+ (from, to) => readSpan(file.path, from, to, redactionRoots, ctx.home, ctx.env, opts.isolation ?? { kind: "repo", ownRoots: redactionRoots }, file.fileKey), recordCutter(file.path));
975
1157
  deliveries.push(delivery);
976
1158
  if (endedHeld(delivery))
977
1159
  held = true;
@@ -1107,59 +1289,10 @@ heldThisInvocation, nowMs) {
1107
1289
  * hook contract.
1108
1290
  */
1109
1291
  function findSubagentFiles(ctx, transcriptPath) {
1110
- // ⛔ `CR-183` — THE GLOB IS THIS DIALECT'S MECHANISM, not the lane's. An agent
1111
- // that ANNOUNCES its delegated transcript on stdin has no `subagents/` to read,
1112
- // and running the glob against its layout would be a directory walk that can
1113
- // only ever find nothing. Nothing parses an announced path yet, so an
1114
- // announcing dialect delegates nothing today — stated, not disguised.
1115
- //
1116
- // ⛔ SELECTED BY THE CONTAINING ROOT, NOT BY `ctx.agentId` (`CR-204`,
1117
- // D177 §7). Sub-agent LAYOUT is a property of the TRANSCRIPT, so the flag is
1118
- // the wrong oracle for it in both directions — and both are live, not
1119
- // theoretical. MEASURED on a no-flag install (`test/wire-agent.test.ts` G4):
1120
- // a Codex-root transcript with a `subagents/` directory beside it had Claude's
1121
- // glob run over it and UPLOADED what it found. The other direction is the one
1122
- // D177 §7 names: a cross-fired hook globbing Claude's `agent-*.jsonl` under a
1123
- // foreign root and silently finding nothing.
1124
- //
1125
- // ⚠ An unattributed path resolves `unknown`, and `dialectFor` falls back to
1126
- // Claude — so it would glob. It cannot arrive here: confinement refuses
1127
- // everything outside the union first, and `subagentsDir` returns null for
1128
- // anything that is not a `.jsonl` anyway.
1129
- if (dialectFor(agentForTranscript(ctx.home, ctx.env, transcriptPath)).delegatedTranscripts !==
1130
- "discover") {
1131
- return [];
1132
- }
1133
- const dir = subagentsDir(transcriptPath);
1134
- if (dir === null)
1135
- return [];
1136
- const roots = transcriptRoots(ctx.home, ctx.env);
1137
- let entries;
1138
- try {
1139
- entries = readdirSync(dir);
1140
- }
1141
- catch {
1142
- return [];
1143
- }
1144
- const found = [];
1145
- for (const entry of entries) {
1146
- const fileKey = subagentFileKey(entry);
1147
- if (fileKey === null)
1148
- continue;
1149
- const path = join(dir, entry);
1150
- if (!isInsideAny(roots, path))
1151
- continue;
1152
- try {
1153
- const stat = statSync(path);
1154
- if (!stat.isFile() || stat.size === 0)
1155
- continue;
1156
- found.push({ path, fileKey: fileKey, size: stat.size, mtimeMs: stat.mtimeMs });
1157
- }
1158
- catch {
1159
- // Vanished between readdir and stat. Not ours to report.
1160
- }
1161
- }
1162
- return found.sort((a, b) => a.mtimeMs - b.mtimeMs);
1292
+ // ⛔ THE ONE COPY lives in `session_targets.ts` (`findSubagentTranscripts`), shared with the evidence reader so discovery and
1293
+ // delivery can never disagree about which files are a session's. Every rule above this line (the dialect's mechanism, the root
1294
+ // that selects it, `agent-*.jsonl` and never `agent-*`, confinement of every entry) is enforced there.
1295
+ return findSubagentTranscripts(ctx.home, ctx.env, transcriptPath).map((file) => ({ ...file, fileKey: file.fileKey }));
1163
1296
  }
1164
1297
  /**
1165
1298
  * ⛔ **THE `announce` READER — the first one there has ever been.** `CR-196`,
@@ -1248,124 +1381,11 @@ export function fileSize(path) {
1248
1381
  return null;
1249
1382
  }
1250
1383
  }
1251
- /** The porcelain record's opening line — `worktree <absolute path>`. */
1252
- const WORKTREE_LINE = "worktree ";
1253
- /**
1254
- * The MAIN work tree of the repository whose common dir this is, or **null**.
1255
- *
1256
- * ## ⛔ `rev-parse --show-toplevel` CANNOT ANSWER THIS, measured
1257
- *
1258
- * `D184 §4` mandates `git -C <common-dir> rev-parse --show-toplevel`. Measured
1259
- * on git 2.39.5 against real repositories, that command exits **128** —
1260
- * *"fatal: this operation must be run in a work tree"* — for **every shape a
1261
- * consent key takes**, because a consent key is always a git DIRECTORY and
1262
- * `--is-inside-work-tree` is `false` inside one:
1263
- *
1264
- * | `-C` | `--show-toplevel` |
1265
- * |---|---|
1266
- * | `<main>/.git` (main clone, and every worktree linked to it) | ⛔ ec 128 |
1267
- * | `<repos>/project.git` (bare) | ⛔ ec 128 |
1268
- *
1269
- * Implemented literally, "a failure means no second root" would fire 100% of
1270
- * the time: the set would be the singleton it is today, forever, and a
1271
- * main-clone file read from a worktree session would come back a marker. The
1272
- * whole change would pass its own tests while doing nothing.
1273
- *
1274
- * ## What answers instead, and why it is not a guess
1275
- *
1276
- * `git worktree list --porcelain` DOES run from a git dir and from a bare
1277
- * directory, and its FIRST record is the main work tree. Bare repositories say
1278
- * so in the record itself — `bare`, with no work tree of their own.
1279
- *
1280
- * ⛔ **The answer is then validated by CONTENT, never trusted by position:**
1281
- * `realpath(<candidate>/.git)` must BE the consented common dir. That check is
1282
- * independently fatal to every wrong answer this could produce — a bare repo
1283
- * (`<repos>/.git` does not exist), a linked worktree returned first by some
1284
- * future git (its `.git` is a FILE, so the realpath is the file, not the common
1285
- * dir), a submodule work tree (same). `D184 §4`'s hazard is what it is for: in a
1286
- * worktree of a bare repo the common dir is `…/repos/project.git`, whose parent
1287
- * is **the folder holding every other repository on the machine**, and a root
1288
- * derived by stripping a suffix would transmit every sibling repo's file
1289
- * contents verbatim.
1290
- *
1291
- * Every failure yields NO SECOND ROOT. Never a fallback, never a parent, never a
1292
- * suffix strip.
1293
- */
1294
- function mainWorkTree(commonDir) {
1295
- const out = gitProbe(commonDir, ["worktree", "list", "--porcelain"]);
1296
- if (out === null)
1297
- return null;
1298
- const record = [];
1299
- for (const line of out.split("\n")) {
1300
- if (line === "")
1301
- break;
1302
- record.push(line);
1303
- }
1304
- // A BARE repository HAS no work tree, and git says so in the record itself.
1305
- if (record.includes("bare"))
1306
- return null;
1307
- const first = record[0];
1308
- if (first === undefined || !first.startsWith(WORKTREE_LINE))
1309
- return null;
1310
- const candidate = first.slice(WORKTREE_LINE.length);
1311
- if (!isAbsolute(candidate))
1312
- return null;
1313
- try {
1314
- return realpathSync(join(candidate, ".git")) === commonDir ? candidate : null;
1315
- }
1316
- catch {
1317
- return null;
1318
- }
1319
- }
1320
- /**
1321
- * ⛔ **The redaction confinement ROOT SET — `D184 §3`.**
1322
- *
1323
- * ```
1324
- * isInsideAny([worktree, mainToplevel], candidate)
1325
- * ```
1326
- *
1327
- * ## Why it is a set, and why the common dir is not in it
1328
- *
1329
- * `D184` moved the consent key to the git common dir so that a session inside a
1330
- * `git worktree` is admitted at all. ⛔ **The confinement root MUST NOT follow
1331
- * it there.** `isInside` realpaths both sides and fail-closes anything it cannot
1332
- * place to *outside* — which here means REDACT — so a root at `<main>/.git`
1333
- * would judge every file of an external worktree to be outside the main clone
1334
- * and replace its `content`, `oldString`, `newString` and `structuredPatch` with
1335
- * markers. **The capture still succeeds. The turn counts still look right. The
1336
- * content is gone**, and a test asserting *"worktree sessions capture now"*
1337
- * passes on an empty payload.
1338
- *
1339
- * Two roots, because both are genuinely the user's own tree: the worktree the
1340
- * session is running in, and the main clone it was cut from — a builder in a
1341
- * worktree reads the main clone's files constantly.
1342
- *
1343
- * ## ⛔ The `paths.ts:487-489` prohibition, answered — `D184 §5`
1344
- *
1345
- * > *"`roots` comes from the REGISTRY. It must never be derived from input."*
1346
- *
1347
- * **That prohibition is about PROVENANCE, and both members have the right one.**
1348
- * The named hazard is *anything on hook stdin* — attacker-supplied. Neither of
1349
- * these is: `keys` can only be produced by `resolveProjectKeys`, which is git's
1350
- * own answer about the directory the session runs in; `mainToplevel` is git's
1351
- * answer about the repository **the consent gate already approved**, and it is
1352
- * content-validated by `mainWorkTree` above. The worktree root has exactly the
1353
- * same provenance and has been used this way since `CR-024d`.
1354
- *
1355
- * ⚠ **Provenance is the whole argument, so this code preserves it:** nothing
1356
- * here is read from stdin, the derivation is validated by content, and a failure
1357
- * yields no second root rather than a fallback. An empty set would refuse
1358
- * everything (`paths.ts:491-492`), which is the correct direction — but this
1359
- * function cannot return one, because `keys.worktree` is always a member.
1360
- *
1361
- * ⚠ The dedup is cosmetic. `isInsideAny` over a repeated root is a union with
1362
- * itself; it is dropped so that a main-clone session reads as the single root it
1363
- * has always had, and so a cell asserting "no second root" is asserting
1364
- * something.
1365
- */
1366
- export function confinementRoots(keys) {
1367
- const main = mainWorkTree(keys.consent);
1368
- return main === null || main === keys.worktree ? [keys.worktree] : [keys.worktree, main];
1384
+ /** The stream a transcript path is: `main`, or the `agent-<id>` key of a delegated file under a `subagents/` directory. */
1385
+ function streamKeyOf(path) {
1386
+ if (basename(dirname(path)) !== "subagents")
1387
+ return "main";
1388
+ return subagentFileKey(basename(path)) ?? "main";
1369
1389
  }
1370
1390
  /**
1371
1391
  * Read `[from, to)` of the transcript, REDACT it, and compress it.
@@ -1408,19 +1428,39 @@ export function confinementRoots(keys) {
1408
1428
  * set is the ordinary main-clone case and behaves exactly as the old parameter
1409
1429
  * did.
1410
1430
  *
1431
+ * ## ⛔ `isolation` — what a TARGET's stream may contain beyond the path rule (any-folder capture)
1432
+ *
1433
+ * R6 · R17. A session that worked in N granted repos uploads N copies, so the chokepoint also knows WHOSE copy it is cutting.
1434
+ * For a REPO target, a record whose `cwd` resolves inside a work tree other than the target's is withheld (length-preserving, the
1435
+ * same markers): another repo's commands and output must not reach this repo's readers. For a FOLDER target, anything inside a
1436
+ * git work tree nested in the folder is withheld (exclusion roots). Records run from the launch folder are kept in every copy;
1437
+ * that residual (prompts and prose from the parent folder) is stated in the README and the claims register. Absent: the
1438
+ * structured-path rule alone, exactly as before.
1439
+ *
1411
1440
  * EXPORTED for `connect`'s test capture (`CR-025`), which reads a transcript on
1412
1441
  * a path the hook never takes. It reuses this rather than opening one itself,
1413
1442
  * because a second reader would bypass both the confinement and the redaction —
1414
1443
  * exactly what the chokepoint note above warns about.
1415
1444
  */
1416
- export function readSpan(path, from, to, projectRoots, home, env) {
1445
+ export function readSpan(path, from, to, projectRoots, home, env,
1446
+ /** A-T4: the target's isolation (R17 record isolation for a repo, R6 exclusion roots for a folder). Absent: structured-path redaction only. */
1447
+ isolation,
1448
+ /** The stream this file is. Default: derived from the path (Claude's `subagents/agent-<id>.jsonl`); a Codex delegated file is announced, not found by directory, so its caller says. */
1449
+ streamKey) {
1417
1450
  let fd;
1418
1451
  try {
1419
1452
  fd = openSync(path, "r");
1420
1453
  const buf = Buffer.alloc(to - from);
1421
1454
  const read = readSync(fd, buf, 0, buf.length, from);
1422
1455
  const raw = read === buf.length ? buf : buf.subarray(0, read);
1423
- return zstdCompressSync(redactSpan(raw, projectRoots, rulesForTranscript(home, env, path)).bytes);
1456
+ const spec = isolation === undefined ? undefined : evidenceSpecFor(home, env, path);
1457
+ const fileKey = streamKey ?? streamKeyOf(path);
1458
+ // C42: a read that reaches past the scan cursor first observes whatever lies between the observed bytes and its start, then each record as it is cut.
1459
+ if (spec !== undefined)
1460
+ isolation?.past?.begin(spec, path, fileKey, from, to);
1461
+ const redacted = redactSpan(raw, projectRoots, rulesForTranscript(home, env, path), isolation === undefined || spec === undefined ? undefined : redactionScopeFor(isolation, spec.cwdKeys, { fileKey, trackCalls: spec.trackCalls === true, codex: spec.codexFolders === true }), from);
1462
+ isolation?.past?.end(fileKey, to);
1463
+ return zstdCompressSync(redacted.bytes);
1424
1464
  }
1425
1465
  catch {
1426
1466
  return null;