@vibe-commit/capture 0.8.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 (101) hide show
  1. package/README.md +124 -0
  2. package/dist/agents/claude_code.js +13 -0
  3. package/dist/agents/claude_code.js.map +1 -1
  4. package/dist/agents/codex_cli.js +11 -0
  5. package/dist/agents/codex_cli.js.map +1 -1
  6. package/dist/agents/cursor.js +7 -1
  7. package/dist/agents/cursor.js.map +1 -1
  8. package/dist/agents/registry.d.ts +7 -0
  9. package/dist/agents/registry.js +9 -0
  10. package/dist/agents/registry.js.map +1 -1
  11. package/dist/agents/types.d.ts +32 -0
  12. package/dist/agents/types.js.map +1 -1
  13. package/dist/bounded_id.d.ts +29 -0
  14. package/dist/bounded_id.js +42 -0
  15. package/dist/bounded_id.js.map +1 -0
  16. package/dist/codex_folders.d.ts +340 -0
  17. package/dist/codex_folders.js +1448 -0
  18. package/dist/codex_folders.js.map +1 -0
  19. package/dist/commands/backfill.d.ts +29 -7
  20. package/dist/commands/backfill.js +387 -109
  21. package/dist/commands/backfill.js.map +1 -1
  22. package/dist/commands/connect.d.ts +1 -1
  23. package/dist/commands/connect.js +1 -1
  24. package/dist/commands/connect.js.map +1 -1
  25. package/dist/commands/connect_picker.js +48 -7
  26. package/dist/commands/connect_picker.js.map +1 -1
  27. package/dist/commands/finalize.d.ts +67 -0
  28. package/dist/commands/finalize.js +450 -114
  29. package/dist/commands/finalize.js.map +1 -1
  30. package/dist/commands/scan.d.ts +28 -0
  31. package/dist/commands/scan.js +11 -0
  32. package/dist/commands/scan.js.map +1 -1
  33. package/dist/commands/status_machine.js +47 -10
  34. package/dist/commands/status_machine.js.map +1 -1
  35. package/dist/consent.d.ts +22 -0
  36. package/dist/consent.js +38 -1
  37. package/dist/consent.js.map +1 -1
  38. package/dist/copy/strings.d.ts +43 -1
  39. package/dist/copy/strings.js +48 -4
  40. package/dist/copy/strings.js.map +1 -1
  41. package/dist/folder_grants.d.ts +44 -0
  42. package/dist/folder_grants.js +101 -0
  43. package/dist/folder_grants.js.map +1 -0
  44. package/dist/git.d.ts +26 -4
  45. package/dist/git.js +82 -11
  46. package/dist/git.js.map +1 -1
  47. package/dist/hooks/entry.d.ts +30 -50
  48. package/dist/hooks/entry.js +593 -559
  49. package/dist/hooks/entry.js.map +1 -1
  50. package/dist/hooks/fire_lock.d.ts +31 -0
  51. package/dist/hooks/fire_lock.js +120 -12
  52. package/dist/hooks/fire_lock.js.map +1 -1
  53. package/dist/machine_state.d.ts +44 -1
  54. package/dist/machine_state.js +75 -12
  55. package/dist/machine_state.js.map +1 -1
  56. package/dist/oauth/discovery.js +2 -1
  57. package/dist/oauth/discovery.js.map +1 -1
  58. package/dist/oauth/pkce.js +2 -1
  59. package/dist/oauth/pkce.js.map +1 -1
  60. package/dist/paths.d.ts +34 -1
  61. package/dist/paths.js +62 -12
  62. package/dist/paths.js.map +1 -1
  63. package/dist/physical_path.d.ts +46 -0
  64. package/dist/physical_path.js +151 -0
  65. package/dist/physical_path.js.map +1 -0
  66. package/dist/picker.d.ts +52 -3
  67. package/dist/picker.js +129 -5
  68. package/dist/picker.js.map +1 -1
  69. package/dist/picker_checklist.d.ts +9 -1
  70. package/dist/picker_checklist.js +41 -2
  71. package/dist/picker_checklist.js.map +1 -1
  72. package/dist/policy.d.ts +9 -1
  73. package/dist/policy.js +11 -2
  74. package/dist/policy.js.map +1 -1
  75. package/dist/post.d.ts +14 -0
  76. package/dist/post.js +32 -10
  77. package/dist/post.js.map +1 -1
  78. package/dist/project.d.ts +89 -0
  79. package/dist/project.js +123 -0
  80. package/dist/project.js.map +1 -1
  81. package/dist/redact.d.ts +77 -2
  82. package/dist/redact.js +713 -47
  83. package/dist/redact.js.map +1 -1
  84. package/dist/session_targets.d.ts +713 -0
  85. package/dist/session_targets.js +2602 -0
  86. package/dist/session_targets.js.map +1 -0
  87. package/dist/spool.d.ts +12 -0
  88. package/dist/spool.js +29 -1
  89. package/dist/spool.js.map +1 -1
  90. package/dist/spool_union.d.ts +51 -0
  91. package/dist/spool_union.js +57 -0
  92. package/dist/spool_union.js.map +1 -0
  93. package/dist/state.d.ts +5 -0
  94. package/dist/state.js +19 -1
  95. package/dist/state.js.map +1 -1
  96. package/dist/text_tail.d.ts +8 -0
  97. package/dist/text_tail.js +19 -0
  98. package/dist/text_tail.js.map +1 -0
  99. package/dist/version.d.ts +1 -1
  100. package/dist/version.js +1 -1
  101. 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
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();
@@ -468,202 +478,92 @@ async function hookBody(ctx, budgetMs, hookStartedAt) {
468
478
  budgetLeftMs: () => budgetMs - (Date.now() - hookStartedAt),
469
479
  });
470
480
  }
471
- // --- CONSENT GATE. Nothing above this line has opened the transcript. ---
472
- // The null arm is not new behaviour — `isCaptureAllowed(home, null, …)` is already
473
- // false, so a `cwd` outside a work tree already stopped here. It is stated
474
- // separately because `CR-017d` makes this value half the delivery key as well
475
- // as the consent key, and a key the compiler cannot prove is present is one
476
- // some later edit supplies a placeholder for.
477
- //
478
- // ⛔ THE GATE CHECKS `keys.consent` — THE GIT COMMON DIR (`D184 §1`).
479
- //
480
- // It is shared by a main clone and every worktree linked to it, which is the
481
- // whole point. Consent was keyed on `--show-toplevel`, that differs per
482
- // worktree, and absence means no — so an agent session inside a `git worktree`
483
- // captured NOTHING, silently, under the house convention (`D19`) of one
484
- // worktree per builder.
481
+ // --- WHAT DOES THIS SESSION BELONG TO? (any-folder capture, R1 · R2 · R5) ---
485
482
  //
486
- // ⛔⛔ AND THIS IS ONE OF FOUR SITES, ALL OF WHICH NOW AGREE. `D184 §9`: this
487
- // READ gate, `post_commit.ts` (the COMMIT gate), `commands/connect.ts`
488
- // (`grantProject`, the WRITE side and the ONLY writer of the allow list) and
489
- // `commands/status.ts` (`revokeProject`, the REVOKE side). If any one of them
490
- // keyed on the toplevel while the others used the common dir, the allow list
491
- // would never match: the null arm below returns silently BY DESIGN, so every
492
- // session in every repository would capture nothing, with no notice — strictly
493
- // 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).
494
489
  //
495
- // ⚠ AND IT WOULD FAIL GREEN. The suite's fixtures hand-write `projects.json`,
496
- // deliberately, so every cell agrees with this gate whichever key both use —
497
- // the asymmetry is only visible END TO END, through the real `connect` path
498
- // and a real session in a real worktree. `test/consent-key-end-to-end.test.ts`
499
- // is that cell, and under a mutation that grants the toplevel here **135
500
- // 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.
501
492
  //
502
- // ⛔ THAT CELL ASSERTS CONTENT, NOT ONLY ADMISSION, and the distinction is
503
- // `D184 §3`: a session admitted by this gate whose confinement root set has
504
- // collapsed delivers a payload of redaction markers. The request arrives, the
505
- // byte accounting is intact, and the content is gone. Anything asserting only
506
- // that a worktree session was admitted passes on an empty payload.
507
- //
508
- // ⚠ `resolveProjectKeys` is called once, not twice: the redaction root set
509
- // needs the common dir too, and a second resolution could disagree with the
510
- // one consent was checked against. It is stricter than `resolveProjectKey` in
511
- // one way — the common dir must also resolve and hold a `HEAD` — which fails
512
- // closed, silently, exactly as this gate already does.
513
- //
514
- // ⛔ THE GATE IS NOW `isCaptureAllowed` (0.7.0): the common dir OR the repo's slug in `grants.json`, an exclusion
515
- // winning over both. The slug is a MEMOIZED THUNK so a machine with no `grants.json` pays no spawn for it, and so
516
- // `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`.
517
495
  const keys = resolveProjectKeys(input.cwd);
518
496
  const slugOf = keys === null ? null : onceRepoSlug(keys.worktree);
519
- if (keys === null || slugOf === null || !isCaptureAllowed(ctx.home, keys, slugOf)) {
520
- // State 9 (0.7.0, spec review R3-14: "when a hook denies because grants.json is corrupt, have it emit one
521
- // marker-gated systemMessage naming the file and `vibecommit status`"). An unreadable `grants.json` is the one
522
- // denial a hook can say out loud, once per machine and day — and it must also come from here, because the
523
- // plugin channel and Cursor never register `SessionStart`.
524
- // Only for a repo this client WOULD have captured (a remote, so not `local:`), never for a folder that is
525
- // simply not a work tree.
526
- if (keys !== null && slugOf !== null && grantsUnreadable(ctx.home) && !isLocalSlug(slugOf())) {
527
- return notice("grants_unreadable");
528
- }
529
- // Unconsented is not "not connected": say nothing at all. A machine with no
530
- // credential says state 1 below; a repo the user declined stays silent,
531
- // because nagging about a decision the user already made is the tool noise
532
- // DX3's one-line rule exists to avoid.
497
+ if (!hasAnyConsent(ctx.home))
533
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");
534
509
  }
535
- // The THREE roles that never move (`D184 §1`) — the session-state bucket
536
- // (`D58`/`CR-017d`), the `resolveRepoSlug` input, and a directory to run git
537
- // in — under the name every site below already uses for them. Renaming them
538
- // would be churn across a file this change has no other reason to touch, and
539
- // `report.ts:343` is the standing proof that the NAME is not what tells you
540
- // which role a site wants.
541
- const projectKey = keys.worktree;
542
- // ⛔ CURSOR'S FIRST-`Stop` INSTALL (0.7.0, Open Question 1). Claude Code's and Codex's `SessionStart` was measured
543
- // to fire before the first prompt; Cursor's `sessionStart` was only READ out of the shipped bundle (STATIC, NOT
544
- // OBSERVED — `cursor-agent` is not logged in here) and has no field a user sees. So the same marker-gated,
545
- // plain-write-only install also runs on a Cursor `stop`, and its one notice rides this hook's payload below.
546
- // The record makes it once per clone either way, so a `sessionStart` that does fire first loses nothing.
547
- 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
548
515
  ? installOnce({ env: ctx.env, home: ctx.home, selfPath: ctx.selfPath ?? "" }, keys)
549
516
  : null;
550
- const load = loadCredential({ env: ctx.env, home: ctx.home });
551
- switch (load.kind) {
552
- case "absent":
553
- return notice("not_connected");
554
- case "wrong-class":
555
- case "insecure-file":
556
- case "unreadable":
557
- // Interactive verbs print four different fixes for these (§13.6). A hook
558
- // has one channel and a one-line budget, so they collapse to the state
559
- // whose fix — `vibecommit connect` — is the same for all four.
560
- return notice("not_connected");
561
- case "ok":
562
- break;
563
- }
564
517
  // --- PATH CONFINEMENT. `/cso` finding 1, HIGH. ---
565
- // `transcript_path` is attacker-influenced in the one way that matters: it
566
- // reaches a read whose result is uploaded. Confining it to the directories the
567
- // agents in the registry actually write transcripts to is what stops this verb
568
- // being a silent arbitrary-file exfiltrator. Silent refusal, per the contract.
569
- //
570
- // ⛔ THE ROOTS COME FROM THE REGISTRY, NEVER FROM `input` (`CR-183`, D164 §5).
571
- // The union is deliberately independent of `ctx.agentId`: the flag comes from
572
- // an install that may have got it wrong, and a wrong SELECTION must not be able
573
- // to widen the boundary — at most it picks a suboptimal redaction profile.
574
- //
575
- // ⛔ AND THE FILE GATE, ADDED HERE BY `CR-193` (D177 §2). The boundary answers
576
- // "may this path be read"; it does not answer "is this a transcript", and
577
- // under Cursor's root those are different questions — `find -type f` there
578
- // returns 113 files and exactly ONE is a transcript. A narrowing over an
579
- // 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.
580
521
  if (!admitsTranscript(ctx.home, ctx.env, input.transcriptPath))
581
522
  return null;
582
523
  const url = ingestUrl(ctx.env);
583
- // `/cso` finding 2: a rejected override is a silent no-send, not a fallback to
584
- // the default. Falling back would send the credential somewhere the operator
585
- // 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.
586
525
  if (url === null)
587
526
  return null;
588
- // CR-016's bootstrap re-sent the whole file from byte 0 every time. CR-018
589
- // replaces it, because `later` (hold the offset) and `never` (advance past the
590
- // failing bytes) are both defined in terms of an offset and mean nothing
591
- // without one. `CR-017d` widened the key that offset is kept under to
592
- // `(repo, session)`; `CR-124` (W6) owns globbing
593
- // `<session-dir>/subagents/agent-*.jsonl` — this sends the `main` file only.
594
- //
595
- // ⚠ The attribution above said `CR-020` until `CR-020d`. It was wrong, and D76
596
- // says so by name: `CR-020`'s finding (D57 §DX7) is SessionEnd timing only and
597
- // says nothing about sub-agent files. Corrected in passing rather than left to
598
- // send the next reader after work that lives in another task.
599
- //
600
- // `SessionEnd` SETTLES FIRST (D57 §DX7). Every other event has a next
601
- // invocation to pick up a lagging write; this one does not, so bytes missed
602
- // here are the final turn, lost permanently.
603
- // ⛔ RESOLVED THROUGH THE DIALECT BEFORE EITHER BRANCH READS IT (`CR-195`,
604
- // D205). `input.eventName` is the agent's own spelling — Cursor sends
605
- // `sessionEnd`, not `SessionEnd` — and both lines below used to compare the
606
- // raw string against this client's vocabulary. That comparison did not fail
607
- // loudly for Cursor; it returned `false`, which cost the final turn every
608
- // session and carved no reserve.
609
- //
610
- // ⚠ Keyed on `ctx.agentId`, deliberately, and this is NOT the `CR-204`
611
- // root-derived selection used for redaction and delegated transcripts. The
612
- // event vocabulary is a property of the process that INVOKED us, and
613
- // `--agent=` is the value WE write into that agent's own hook config — so it
614
- // is the one selector that is not attacker-influenced input. `dialectBudgetMs`
615
- // 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`.
616
529
  const isSessionEnd = event === SESSION_END_EVENT;
617
- // ⛔ ONE FIRING, ONE PROCESS — `CR-195`/U4a. Claimed BEFORE the settle, so a
618
- // duplicate costs nothing rather than spending the budget to discover it is
619
- // redundant.
620
- //
621
- // ⛔ KEYED ON THE CANONICAL EVENT, NOT `input.eventName`. The two racers do
622
- // not necessarily agree on the wire spelling — that is the whole shape of this
623
- // defect: one invocation comes from Cursor's own config and one from Claude's,
624
- // mapped, and they carry different `--agent=` flags. Resolving first is what
625
- // makes both compute the SAME key; keying on the raw string would give them
626
- // two keys and let both run, which is the failure this guards.
530
+ // --- TWO LOCKS, and they are different questions (O5). ---
627
531
  //
628
- // ⚠ The raw name is the fallback for an event we could not resolve. Such an
629
- // 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.
630
541
  const fireKey = {
631
- repoKey: projectKey,
542
+ repoKey: SESSION_SCOPE,
632
543
  sessionId: input.sessionId,
633
544
  event: event ?? input.eventName,
634
545
  };
635
- // ⛔ `claimFiring` RETURNS `null` FOR TWO DIFFERENT REASONS, and conflating
636
- // them would turn this guard into a silent capture outage. One means *another
637
- // process holds this firing* — bow out. The other means *no lock path could be
638
- // built at all*, and treating that as a duplicate would refuse EVERY
639
- // invocation, permanently and silently.
640
- //
641
- // ⚠ It is currently unreachable — `resolveProjectKeys` returns `null` rather
642
- // than an empty toplevel, and the consent gate above has already returned on
643
- // that. It is distinguished anyway because the two errors are not the same
644
- // size: an unlockable firing that runs is one possible duplicate, and an
645
- // unlockable firing that bows out is total silence. So the lock FAILS OPEN.
646
546
  const lockable = fireLockPath(ctx.home, fireKey) !== null;
647
- firingClaim = claimFiring(ctx.home, fireKey, budgetMs);
648
- if (firingClaim === null && lockable) {
649
- // Another process holds this exact firing. ⛔ Silent and exit 0: there is
650
- // nothing to report, and a `systemMessage` here would put a line in the
651
- // user's turn for a duplicate they did not cause. `finish` releases nothing
652
- // 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.
653
551
  return null;
654
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);
655
563
  const settleFor = isSessionEnd ? settleDelayMs(ctx.env, budgetMs) : 0;
656
- // `CR-182`. Carved OUT of the budget, never added on top — the same shape the
657
- // settle takes, and for the same reason: the watchdog does not move.
658
- //
659
- // ⛔ AND THE PRE-ENTRY COST IS CHARGED HERE (`CR-195/U2`, the G6 defect). The
660
- // send loop measures its own consumption from `settleStart`, one line below;
661
- // the watchdog has been running since `runHook`. The stdin read, the consent
662
- // gate's `git rev-parse` spawn and the credential load all happened in
663
- // between, and `CR-126` measured that interval at p99 392 ms — more than the
664
- // whole 150 ms reserve. Passing it in is what makes the reserve a reserve
665
- // against the deadline that can actually end the process, rather than against
666
- // 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.
667
567
  const settleStart = Date.now();
668
568
  const sendBudget = sendBudgetMs(budgetMs, event, settleStart - hookStartedAt);
669
569
  const eof = settleFor > 0
@@ -671,216 +571,487 @@ async function hookBody(ctx, budgetMs, hookStartedAt) {
671
571
  : fileSize(input.transcriptPath);
672
572
  if (eof === null)
673
573
  return null;
674
- // MEASURED, not assumed to be `settleFor`: the wait can overshoot under load,
675
- // 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.
676
575
  const consumedMs = settleFor > 0 ? Date.now() - settleStart : 0;
677
- // The WIRE identity — `owner/repo` when `origin` is GitHub, `host/owner/repo`
678
- // for any other host, else `local:<hash>`. Required by the merged server
679
- // (`CR-019b`), which 400s a delta without it rather than writing a row nobody
680
- // can read (D57 §OV1).
681
- //
682
- // ⛔ RESOLVED EXACTLY ONCE PER HOOK (`TODOS[87]`). It spawns
683
- // `git remote get-url origin`, and it used to be called here AND once per
684
- // sub-agent file — up to nine spawns for a value that cannot change within an
685
- // invocation, since `projectKey` is fixed by the consent gate above. Passing
686
- // it down rather than re-deriving it is also what keeps the two paths from
687
- // ever disagreeing about which repository this is.
688
- const repoSlug = slugOf();
689
- // ⛔ THE ONE NEW SPAWN `CR-170` ADDS TO THIS PATH, and it is for REF MOVES,
690
- // not commits. `post-commit` observes commits; it cannot see a `pull`, a
691
- // `checkout` or a `reset --hard`, because those move a ref WITHOUT creating a
692
- // commit and no commit hook fires. A direct look at `HEAD` is the only record.
693
- //
694
- // ⚠ It shares `CR-169`'s spawn budget like every other probe, so a hung git
695
- // here is bounded and cannot defeat the watchdog. Null on an empty repository
696
- // (`rev-parse HEAD` exits 128 there) — a state, not an error.
697
- const head = headRef(projectKey);
698
- // Commits OBSERVED since the last delivery. Capped, with the remainder left in
699
- // the spool for the next hook rather than dropped.
700
- const spoolKey = { repoKey: projectKey, sessionId: input.sessionId };
701
- // ⛔ PROMOTE FIRST (`CR-195`/D208). A commit made during this session's FIRST
702
- // turn was recorded uncorroborated, because `Stop` fires at the end of a turn
703
- // and no state file existed when `post-commit` ran. By the time this hook is
704
- // executing, that state file is this session's own — which is exactly the
705
- // corroboration rung 1 requires — so the observation becomes deliverable now
706
- // rather than being lost. Before this line, every first-turn commit was
707
- // dropped: MEASURED as commit `f1608bc`, spooled nowhere.
708
- // ⛔ The corroboration is THIS CALL SITE: `spoolKey.sessionId` is
709
- // `input.sessionId`, the session whose hook is executing. See `promotePending`
710
- // for why a state-file test is the wrong one and fails the first turn.
711
- // ⛔ `TODOS[176]` — THE PROMOTION BOUND, applied to EVERY promotion (the first-turn cold case too): a pending line observed
712
- // before the START OF THIS TURN (the first unsent prompt-like record, skipping the previous Stop's trailing records and any
713
- // record without a timestamp) predates the turn and is dropped. `stale-own` = this session's state file exists and is
714
- // 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
715
- // behaviour), first-turn KEEPS. See `pending_bound.ts` for the rule, the clocks and the scan cap.
716
- const pendingMode = pendingModeFor(ctx.home, spoolKey);
717
- const bound = promotionBound(ctx.home, spoolKey, input.transcriptPath, Date.now());
718
- promotePending(ctx.home, spoolKey, "this-session-is-running", bound, pendingMode);
719
- // ⛔ `TODOS[175]` — ORPHAN PENDING FILES. After THIS session's own pending lines were promoted (or dropped by the bound), sweep the
720
- // repo for pending files nobody can claim any more (older than 7 days; see `pending_prune.ts` for the rule, the tombstone race and
721
- // the counts-only tally `status` announces). Stop and SessionEnd ONLY: never `post-commit` (it runs under git, on the commit
722
- // path) and never `status` (readers stay read-only). Cheap (one readdir, at most 50 files) and it never throws.
723
- if (event === "Stop" || isSessionEnd) {
724
- pruneOrphanPending(ctx.home, projectKey, Date.now());
725
- // `TODOS[177]` follow-up (VL, 2026-10-08, item 5): `.inferred.jsonl` gets the same 7-day sweep.
726
- //
727
- // ⛔⛔ THE RETURN VALUE IS NOT DISCARDED (VL, 2026-10-08, VG's silent-loss finding, G1: a mutant that makes this
728
- // call a no-op SURVIVED all 57 tests, because the function was tested in isolation but nothing here checked it
729
- // was actually WIRED). `pairs` is fed to the same durable tally `set_aside` uses, before the sweep's own
730
- // tombstone-delete removes the file `status`'s live gauge would otherwise have read.
731
- const prunedInferred = pruneExpiredInferred(ctx.home, projectKey, Date.now());
732
- if (prunedInferred.pairs > 0)
733
- recordInferredSkipped(ctx.home, projectKey, "pruned", Date.now(), prunedInferred.pairs);
734
- }
735
- const spooled = capSpool(readSpool(ctx.home, spoolKey));
736
- // ⛔ REWRITES ARE THEIR OWN FILE WITH THEIR OWN CAP (`T5`). The units differ —
737
- // 41 bytes for a sha, 82 for an `ancestor:successor` pair — so one constant
738
- // sized against the other would make one of the two headers wrong.
739
- const rewritten = capSuccessors(readRewrites(ctx.home, spoolKey));
740
- // `TODOS[177]` — INFERRED squash pairs: their own file, their own cap, their own header (`x-rewrites-inferred`), never `x-rewrites`.
741
- const inferred = capInferred(readInferred(ctx.home, spoolKey));
742
- // ⛔ THE REDACTION BOUNDARY, and it is NOT the consent key (`D184 §3`).
743
- // Resolved here rather than at the gate so the derivation's one spawn is not
744
- // paid by the invocations that return above without reading a byte.
745
- const redactionRoots = confinementRoots(keys);
746
- const delivery = await deliver({
576
+ const resolution = resolveTargets({
747
577
  home: ctx.home,
748
578
  env: ctx.env,
749
- url,
750
- credential: load.credential,
751
- // The consent gate's key, reused rather than re-resolved: a second
752
- // `git rev-parse` could disagree with the one consent was checked against.
753
- repoKey: projectKey,
754
- repoSlug,
755
579
  sessionId: input.sessionId,
756
- fileKey: "main",
757
- // ⛔ THE PATH, so `deliver()` can derive `X-Agent` from the root that
758
- // contains it (`CR-204`, D177 §7). Deliberately the same expression the
759
- // `readBody` closure below takes, so the bytes and the producer can never
760
- // describe two different files.
761
580
  transcriptPath: input.transcriptPath,
762
- // ⛔ `sendBudget`, not `budgetMs`. The main send is the single largest
763
- // consumer on this path, so a reserve the loop honours and this does not
764
- // is one the biggest spender walks straight through (`CR-182`).
765
- timeoutMs: sendTimeoutMs(sendBudget, consumedMs),
766
- // PR 4a — WHEN THE SENDS MUST STOP: `sendBudget` is measured from `settleStart`, the same origin `consumedMs` uses.
767
- // A span larger than one delta goes out as several only while this lasts; the rest waits for the next event.
768
- deadlineAtMs: settleStart + sendBudget,
581
+ turnEndCwd: input.cwd,
582
+ mode: isSessionEnd ? "end" : "turn",
769
583
  nowMs: Date.now(),
770
- byHook: true,
771
- // ⛔ THE MAIN DELIVERY ONLY. Sub-agent deliveries below leave all three
772
- // unset: each is its own `capture_id`, and `capture_commits`' PK is
773
- // `(org_id, capture_id, commit_sha)`, so attaching these to all nine
774
- // deliveries would admit nine legal, permanent rows for one commit — the
775
- // objection that killed the polling design (D154).
776
- head,
777
- // ⛔ THE WHOLE OBJECT, NOT `spooled.shas` (`T5`, `D190 §1`). The shas and
778
- // their rungs are only safe as a pair — `parseObservedCommits` drops the
779
- // ENTIRE BATCH when the two lists differ in length — and `count` is the
780
- // number of spool LINES consumed, which is what the 2xx truncates by. A
781
- // held entry is consumed without being sent, so `shas.length` is the wrong
782
- // number for that and passing the pieces separately is how they diverge.
783
- commits: spooled,
784
- rewrites: rewritten.pairs,
785
- inferredRewrites: inferred.pairs,
786
- inferredGroups: inferred.groups,
787
- }, eof,
788
- // ⛔ THE ROOT SET, AND IT IS NOT THE VALUE THE CONSENT GATE APPROVED.
789
- // Those were one value until `D184`, and this line is where the difference
790
- // becomes observable: redaction is measured against `[worktree,
791
- // mainToplevel]`, never against the git common dir. If it followed the
792
- // consent key there, `isInside` would fail-close every file of an external
793
- // worktree to "outside the main clone" and hand the server a span of
794
- // markers — a capture that succeeds, with turn counts that look right and
795
- // no content in it (`D184 §3`).
796
- (from, to) => readSpan(input.transcriptPath, from, to, redactionRoots, ctx.home, ctx.env), recordCutter(input.transcriptPath));
797
- // --- DELEGATED WORK. `CR-124`, D76. ---
798
- // Claude Code writes sub-agent transcripts to `<session>/subagents/`, which
799
- // `transcript_path` never names: ~16% of transcript volume, and the work a
800
- // reviewer most wants attributed. MAIN WENT FIRST, deliberately (D95) — the
801
- // content most likely to matter is the content most likely to fit.
802
- const subagents = await deliverSubagents({
803
- ctx,
804
- input,
805
- url,
806
- credential: load.credential,
807
- projectKey,
808
- redactionRoots,
809
- repoSlug,
810
- startedAt: settleStart,
811
- sendBudget,
812
- 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) } } : {}),
813
587
  });
814
- // --- THE HONEST HOLE. D95, widened to `main` by `CR-182`. ---
815
- //
816
- // ⛔ AFTER EVERY SEND, AND IT CANNOT MOVE. `markSkipped` sets
817
- // `sentOffset = to`, so stamping first would leave `nextSpan` nothing to
818
- // return for any stream — it would gap the whole session and send none of it.
819
- //
820
- // ⛔ AND IT CANNOT HANG OFF `finish()`, which the signal handlers reach and
821
- // which must stay minimal.
822
- //
823
- // It sits here rather than inside `deliverSubagents` because that function
824
- // returns early when there are no delegated files at all — and the MAIN
825
- // transcript's gap must not be conditional on a `subagents/` directory
826
- // existing. `eof`, not a fresh `statSync`: the gap records what this
827
- // invocation COMMITTED to sending and could not, which is exactly what
828
- // `SubagentFile.size` is for the delegated streams.
829
- //
830
- // ⛔ AND `CR-228` (`TODOS[142]`): it writes off only what nothing explains.
831
- // `held` is whether ANY delivery in this invocation ended `later`/`fatal`, or
832
- // was refused by a standing stop — the main one or a delegated one. When it
833
- // did, every stream still behind is behind because of THAT, not because D95's
834
- // budget ran out against a server that was answering.
835
- if (isSessionEnd) {
836
- 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;
837
613
  }
838
- // The three classes are decided in `policy.ts` and applied in `post.ts`. Two
839
- // 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.
840
660
  //
841
- // `fatal`: a hook that stays silent about a revoked credential captures
842
- // nothing forever. `later` and `never` say nothing — a hook has one line per
843
- // session and spending it on a condition that resolves itself next turn is the
844
- // 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.
845
664
  //
846
- // `ok`: the fourth state (`CR-023d`), and the FIRST time this hook has ever
847
- // spoken on success. Its `(repo, day)` claim is what keeps it to once — no
848
- // "have I already said this today" check belongs here, because the atomic `wx`
849
- // claim already is that check, and a second one could disagree with it.
850
- if (delivery.kind === "attempted") {
851
- if (delivery.disposition === "fatal") {
852
- // ⛔ QUIET DURING ROTATION (`11A`). This hook loaded its credential before an `auth` replaced
853
- // the file; its 401 is about a credential that is already gone. If what is on disk NOW is a
854
- // different credential, there is nothing to tell the user — the stop is keyed to the old
855
- // 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.
856
672
  const current = loadCredential({ env: ctx.env, home: ctx.home });
857
- if (current.kind === "ok" &&
858
- credentialFingerprint(current.credential) !== credentialFingerprint(load.credential)) {
673
+ if (current.kind === "ok" && credentialFingerprint(current.credential) !== credentialFingerprint(load.credential)) {
859
674
  return null;
860
675
  }
861
676
  return notice("credential_revoked", null, load.credential);
862
677
  }
863
- if (delivery.disposition === "ok") {
864
- // `CR-234d` (12A): a 2xx carries where the credential stands (`X-Credential-Expires`). Remember it in
865
- // the SIDECAR — never in `credentials.json`, which a hook must not rewrite (R10) — and, from 14 days
866
- // before the end, say so once per machine per day. It outranks the positive message: that one repeats
867
- // daily, this one is the only way a person learns capture will stop on a date.
868
- if (delivery.credentialExpires != null) {
869
- const meta = noteCredentialExpiry(ctx.home, ctx.env, credentialFingerprint(load.credential), delivery.credentialExpires, new Date());
870
- const endsAt = endingSoon(meta, Date.now());
871
- if (endsAt !== null) {
872
- const ending = notice("credential_ending", null, load.credential, endsAt);
873
- if (ending !== null)
874
- 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.
875
948
  }
876
949
  }
877
- return ((firstStop?.state == null
878
- ? null
879
- : notice(firstStop.state, null, undefined, undefined, firstStop.cloneKey)) ??
880
- 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();
881
1053
  }
882
1054
  }
883
- return null;
884
1055
  }
885
1056
  /**
886
1057
  * Send whatever delegated transcripts fit in what is left of the budget.
@@ -926,10 +1097,12 @@ export async function deliverSubagents(opts) {
926
1097
  // a fallback chain. Written this way so `findSubagentFiles` is byte-unchanged
927
1098
  // and `CR-204`'s root-derived gating (pinned by `test/wire-agent.test.ts` G4)
928
1099
  // keeps its meaning.
929
- const files = [
1100
+ const discovered = [
930
1101
  ...findSubagentFiles(ctx, input.transcriptPath),
931
1102
  ...announcedSubagentFiles(ctx, input),
932
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) }));
933
1106
  if (files.length === 0)
934
1107
  return { files, held: false, deliveries: [] };
935
1108
  // ⚠ SKIP WHAT HAS NOTHING PENDING, BEFORE THE CAP APPLIES — and this is not an
@@ -954,6 +1127,8 @@ export async function deliverSubagents(opts) {
954
1127
  const consumedMs = Date.now() - startedAt;
955
1128
  if (sendBudget - consumedMs < MIN_SUBAGENT_BUDGET_MS)
956
1129
  break;
1130
+ if (opts.heartbeat !== undefined && !opts.heartbeat())
1131
+ break;
957
1132
  const delivery = await deliver({
958
1133
  home: ctx.home,
959
1134
  env: ctx.env,
@@ -973,11 +1148,12 @@ export async function deliverSubagents(opts) {
973
1148
  deadlineAtMs: startedAt + sendBudget,
974
1149
  nowMs: Date.now(),
975
1150
  byHook,
1151
+ ...(opts.heartbeat === undefined ? {} : { beforeRequest: opts.heartbeat }),
976
1152
  }, file.size,
977
1153
  // The SAME reader as the main transcript, so `CR-024d`'s redaction and
978
1154
  // `/cso` finding 1's confinement both apply. A delegated agent reading a
979
1155
  // third party's file is exactly as out-of-tree as the main thread doing it.
980
- (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));
981
1157
  deliveries.push(delivery);
982
1158
  if (endedHeld(delivery))
983
1159
  held = true;
@@ -1113,59 +1289,10 @@ heldThisInvocation, nowMs) {
1113
1289
  * hook contract.
1114
1290
  */
1115
1291
  function findSubagentFiles(ctx, transcriptPath) {
1116
- // ⛔ `CR-183` — THE GLOB IS THIS DIALECT'S MECHANISM, not the lane's. An agent
1117
- // that ANNOUNCES its delegated transcript on stdin has no `subagents/` to read,
1118
- // and running the glob against its layout would be a directory walk that can
1119
- // only ever find nothing. Nothing parses an announced path yet, so an
1120
- // announcing dialect delegates nothing today — stated, not disguised.
1121
- //
1122
- // ⛔ SELECTED BY THE CONTAINING ROOT, NOT BY `ctx.agentId` (`CR-204`,
1123
- // D177 §7). Sub-agent LAYOUT is a property of the TRANSCRIPT, so the flag is
1124
- // the wrong oracle for it in both directions — and both are live, not
1125
- // theoretical. MEASURED on a no-flag install (`test/wire-agent.test.ts` G4):
1126
- // a Codex-root transcript with a `subagents/` directory beside it had Claude's
1127
- // glob run over it and UPLOADED what it found. The other direction is the one
1128
- // D177 §7 names: a cross-fired hook globbing Claude's `agent-*.jsonl` under a
1129
- // foreign root and silently finding nothing.
1130
- //
1131
- // ⚠ An unattributed path resolves `unknown`, and `dialectFor` falls back to
1132
- // Claude — so it would glob. It cannot arrive here: confinement refuses
1133
- // everything outside the union first, and `subagentsDir` returns null for
1134
- // anything that is not a `.jsonl` anyway.
1135
- if (dialectFor(agentForTranscript(ctx.home, ctx.env, transcriptPath)).delegatedTranscripts !==
1136
- "discover") {
1137
- return [];
1138
- }
1139
- const dir = subagentsDir(transcriptPath);
1140
- if (dir === null)
1141
- return [];
1142
- const roots = transcriptRoots(ctx.home, ctx.env);
1143
- let entries;
1144
- try {
1145
- entries = readdirSync(dir);
1146
- }
1147
- catch {
1148
- return [];
1149
- }
1150
- const found = [];
1151
- for (const entry of entries) {
1152
- const fileKey = subagentFileKey(entry);
1153
- if (fileKey === null)
1154
- continue;
1155
- const path = join(dir, entry);
1156
- if (!isInsideAny(roots, path))
1157
- continue;
1158
- try {
1159
- const stat = statSync(path);
1160
- if (!stat.isFile() || stat.size === 0)
1161
- continue;
1162
- found.push({ path, fileKey: fileKey, size: stat.size, mtimeMs: stat.mtimeMs });
1163
- }
1164
- catch {
1165
- // Vanished between readdir and stat. Not ours to report.
1166
- }
1167
- }
1168
- 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 }));
1169
1296
  }
1170
1297
  /**
1171
1298
  * ⛔ **THE `announce` READER — the first one there has ever been.** `CR-196`,
@@ -1254,124 +1381,11 @@ export function fileSize(path) {
1254
1381
  return null;
1255
1382
  }
1256
1383
  }
1257
- /** The porcelain record's opening line — `worktree <absolute path>`. */
1258
- const WORKTREE_LINE = "worktree ";
1259
- /**
1260
- * The MAIN work tree of the repository whose common dir this is, or **null**.
1261
- *
1262
- * ## ⛔ `rev-parse --show-toplevel` CANNOT ANSWER THIS, measured
1263
- *
1264
- * `D184 §4` mandates `git -C <common-dir> rev-parse --show-toplevel`. Measured
1265
- * on git 2.39.5 against real repositories, that command exits **128** —
1266
- * *"fatal: this operation must be run in a work tree"* — for **every shape a
1267
- * consent key takes**, because a consent key is always a git DIRECTORY and
1268
- * `--is-inside-work-tree` is `false` inside one:
1269
- *
1270
- * | `-C` | `--show-toplevel` |
1271
- * |---|---|
1272
- * | `<main>/.git` (main clone, and every worktree linked to it) | ⛔ ec 128 |
1273
- * | `<repos>/project.git` (bare) | ⛔ ec 128 |
1274
- *
1275
- * Implemented literally, "a failure means no second root" would fire 100% of
1276
- * the time: the set would be the singleton it is today, forever, and a
1277
- * main-clone file read from a worktree session would come back a marker. The
1278
- * whole change would pass its own tests while doing nothing.
1279
- *
1280
- * ## What answers instead, and why it is not a guess
1281
- *
1282
- * `git worktree list --porcelain` DOES run from a git dir and from a bare
1283
- * directory, and its FIRST record is the main work tree. Bare repositories say
1284
- * so in the record itself — `bare`, with no work tree of their own.
1285
- *
1286
- * ⛔ **The answer is then validated by CONTENT, never trusted by position:**
1287
- * `realpath(<candidate>/.git)` must BE the consented common dir. That check is
1288
- * independently fatal to every wrong answer this could produce — a bare repo
1289
- * (`<repos>/.git` does not exist), a linked worktree returned first by some
1290
- * future git (its `.git` is a FILE, so the realpath is the file, not the common
1291
- * dir), a submodule work tree (same). `D184 §4`'s hazard is what it is for: in a
1292
- * worktree of a bare repo the common dir is `…/repos/project.git`, whose parent
1293
- * is **the folder holding every other repository on the machine**, and a root
1294
- * derived by stripping a suffix would transmit every sibling repo's file
1295
- * contents verbatim.
1296
- *
1297
- * Every failure yields NO SECOND ROOT. Never a fallback, never a parent, never a
1298
- * suffix strip.
1299
- */
1300
- function mainWorkTree(commonDir) {
1301
- const out = gitProbe(commonDir, ["worktree", "list", "--porcelain"]);
1302
- if (out === null)
1303
- return null;
1304
- const record = [];
1305
- for (const line of out.split("\n")) {
1306
- if (line === "")
1307
- break;
1308
- record.push(line);
1309
- }
1310
- // A BARE repository HAS no work tree, and git says so in the record itself.
1311
- if (record.includes("bare"))
1312
- return null;
1313
- const first = record[0];
1314
- if (first === undefined || !first.startsWith(WORKTREE_LINE))
1315
- return null;
1316
- const candidate = first.slice(WORKTREE_LINE.length);
1317
- if (!isAbsolute(candidate))
1318
- return null;
1319
- try {
1320
- return realpathSync(join(candidate, ".git")) === commonDir ? candidate : null;
1321
- }
1322
- catch {
1323
- return null;
1324
- }
1325
- }
1326
- /**
1327
- * ⛔ **The redaction confinement ROOT SET — `D184 §3`.**
1328
- *
1329
- * ```
1330
- * isInsideAny([worktree, mainToplevel], candidate)
1331
- * ```
1332
- *
1333
- * ## Why it is a set, and why the common dir is not in it
1334
- *
1335
- * `D184` moved the consent key to the git common dir so that a session inside a
1336
- * `git worktree` is admitted at all. ⛔ **The confinement root MUST NOT follow
1337
- * it there.** `isInside` realpaths both sides and fail-closes anything it cannot
1338
- * place to *outside* — which here means REDACT — so a root at `<main>/.git`
1339
- * would judge every file of an external worktree to be outside the main clone
1340
- * and replace its `content`, `oldString`, `newString` and `structuredPatch` with
1341
- * markers. **The capture still succeeds. The turn counts still look right. The
1342
- * content is gone**, and a test asserting *"worktree sessions capture now"*
1343
- * passes on an empty payload.
1344
- *
1345
- * Two roots, because both are genuinely the user's own tree: the worktree the
1346
- * session is running in, and the main clone it was cut from — a builder in a
1347
- * worktree reads the main clone's files constantly.
1348
- *
1349
- * ## ⛔ The `paths.ts:487-489` prohibition, answered — `D184 §5`
1350
- *
1351
- * > *"`roots` comes from the REGISTRY. It must never be derived from input."*
1352
- *
1353
- * **That prohibition is about PROVENANCE, and both members have the right one.**
1354
- * The named hazard is *anything on hook stdin* — attacker-supplied. Neither of
1355
- * these is: `keys` can only be produced by `resolveProjectKeys`, which is git's
1356
- * own answer about the directory the session runs in; `mainToplevel` is git's
1357
- * answer about the repository **the consent gate already approved**, and it is
1358
- * content-validated by `mainWorkTree` above. The worktree root has exactly the
1359
- * same provenance and has been used this way since `CR-024d`.
1360
- *
1361
- * ⚠ **Provenance is the whole argument, so this code preserves it:** nothing
1362
- * here is read from stdin, the derivation is validated by content, and a failure
1363
- * yields no second root rather than a fallback. An empty set would refuse
1364
- * everything (`paths.ts:491-492`), which is the correct direction — but this
1365
- * function cannot return one, because `keys.worktree` is always a member.
1366
- *
1367
- * ⚠ The dedup is cosmetic. `isInsideAny` over a repeated root is a union with
1368
- * itself; it is dropped so that a main-clone session reads as the single root it
1369
- * has always had, and so a cell asserting "no second root" is asserting
1370
- * something.
1371
- */
1372
- export function confinementRoots(keys) {
1373
- const main = mainWorkTree(keys.consent);
1374
- 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";
1375
1389
  }
1376
1390
  /**
1377
1391
  * Read `[from, to)` of the transcript, REDACT it, and compress it.
@@ -1414,19 +1428,39 @@ export function confinementRoots(keys) {
1414
1428
  * set is the ordinary main-clone case and behaves exactly as the old parameter
1415
1429
  * did.
1416
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
+ *
1417
1440
  * EXPORTED for `connect`'s test capture (`CR-025`), which reads a transcript on
1418
1441
  * a path the hook never takes. It reuses this rather than opening one itself,
1419
1442
  * because a second reader would bypass both the confinement and the redaction —
1420
1443
  * exactly what the chokepoint note above warns about.
1421
1444
  */
1422
- 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) {
1423
1450
  let fd;
1424
1451
  try {
1425
1452
  fd = openSync(path, "r");
1426
1453
  const buf = Buffer.alloc(to - from);
1427
1454
  const read = readSync(fd, buf, 0, buf.length, from);
1428
1455
  const raw = read === buf.length ? buf : buf.subarray(0, read);
1429
- 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);
1430
1464
  }
1431
1465
  catch {
1432
1466
  return null;