@vibe-commit/capture 0.1.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 (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +66 -0
  3. package/dist/agents/claude_code.d.ts +11 -0
  4. package/dist/agents/claude_code.js +162 -0
  5. package/dist/agents/claude_code.js.map +1 -0
  6. package/dist/agents/codex_cli.d.ts +15 -0
  7. package/dist/agents/codex_cli.js +67 -0
  8. package/dist/agents/codex_cli.js.map +1 -0
  9. package/dist/agents/cursor.d.ts +21 -0
  10. package/dist/agents/cursor.js +89 -0
  11. package/dist/agents/cursor.js.map +1 -0
  12. package/dist/agents/registry.d.ts +184 -0
  13. package/dist/agents/registry.js +212 -0
  14. package/dist/agents/registry.js.map +1 -0
  15. package/dist/agents/types.d.ts +279 -0
  16. package/dist/agents/types.js +67 -0
  17. package/dist/agents/types.js.map +1 -0
  18. package/dist/commands/connect.d.ts +36 -0
  19. package/dist/commands/connect.js +499 -0
  20. package/dist/commands/connect.js.map +1 -0
  21. package/dist/commands/context.d.ts +45 -0
  22. package/dist/commands/context.js +14 -0
  23. package/dist/commands/context.js.map +1 -0
  24. package/dist/commands/report.d.ts +105 -0
  25. package/dist/commands/report.js +430 -0
  26. package/dist/commands/report.js.map +1 -0
  27. package/dist/commands/status.d.ts +29 -0
  28. package/dist/commands/status.js +236 -0
  29. package/dist/commands/status.js.map +1 -0
  30. package/dist/commands/why.d.ts +349 -0
  31. package/dist/commands/why.js +937 -0
  32. package/dist/commands/why.js.map +1 -0
  33. package/dist/consent.d.ts +59 -0
  34. package/dist/consent.js +111 -0
  35. package/dist/consent.js.map +1 -0
  36. package/dist/copy/claims.d.ts +79 -0
  37. package/dist/copy/claims.js +343 -0
  38. package/dist/copy/claims.js.map +1 -0
  39. package/dist/copy/grades.d.ts +117 -0
  40. package/dist/copy/grades.js +96 -0
  41. package/dist/copy/grades.js.map +1 -0
  42. package/dist/copy/index.d.ts +12 -0
  43. package/dist/copy/index.js +13 -0
  44. package/dist/copy/index.js.map +1 -0
  45. package/dist/copy/strings.d.ts +1328 -0
  46. package/dist/copy/strings.js +1243 -0
  47. package/dist/copy/strings.js.map +1 -0
  48. package/dist/copy/time.d.ts +27 -0
  49. package/dist/copy/time.js +43 -0
  50. package/dist/copy/time.js.map +1 -0
  51. package/dist/credential.d.ts +211 -0
  52. package/dist/credential.js +298 -0
  53. package/dist/credential.js.map +1 -0
  54. package/dist/exit.d.ts +59 -0
  55. package/dist/exit.js +60 -0
  56. package/dist/exit.js.map +1 -0
  57. package/dist/git.d.ts +299 -0
  58. package/dist/git.js +653 -0
  59. package/dist/git.js.map +1 -0
  60. package/dist/hooks/entry.d.ts +277 -0
  61. package/dist/hooks/entry.js +1092 -0
  62. package/dist/hooks/entry.js.map +1 -0
  63. package/dist/hooks/post_commit.d.ts +59 -0
  64. package/dist/hooks/post_commit.js +160 -0
  65. package/dist/hooks/post_commit.js.map +1 -0
  66. package/dist/hooks/post_rewrite.d.ts +59 -0
  67. package/dist/hooks/post_rewrite.js +161 -0
  68. package/dist/hooks/post_rewrite.js.map +1 -0
  69. package/dist/hooks/session_end.d.ts +101 -0
  70. package/dist/hooks/session_end.js +120 -0
  71. package/dist/hooks/session_end.js.map +1 -0
  72. package/dist/index.d.ts +34 -0
  73. package/dist/index.js +360 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/install.d.ts +153 -0
  76. package/dist/install.js +284 -0
  77. package/dist/install.js.map +1 -0
  78. package/dist/json.d.ts +100 -0
  79. package/dist/json.js +94 -0
  80. package/dist/json.js.map +1 -0
  81. package/dist/oauth/discovery.d.ts +96 -0
  82. package/dist/oauth/discovery.js +163 -0
  83. package/dist/oauth/discovery.js.map +1 -0
  84. package/dist/oauth/lock.d.ts +100 -0
  85. package/dist/oauth/lock.js +287 -0
  86. package/dist/oauth/lock.js.map +1 -0
  87. package/dist/oauth/loopback.d.ts +85 -0
  88. package/dist/oauth/loopback.js +124 -0
  89. package/dist/oauth/loopback.js.map +1 -0
  90. package/dist/oauth/pkce.d.ts +58 -0
  91. package/dist/oauth/pkce.js +77 -0
  92. package/dist/oauth/pkce.js.map +1 -0
  93. package/dist/oauth/read.d.ts +120 -0
  94. package/dist/oauth/read.js +144 -0
  95. package/dist/oauth/read.js.map +1 -0
  96. package/dist/oauth/session.d.ts +80 -0
  97. package/dist/oauth/session.js +112 -0
  98. package/dist/oauth/session.js.map +1 -0
  99. package/dist/oauth/signin.d.ts +178 -0
  100. package/dist/oauth/signin.js +225 -0
  101. package/dist/oauth/signin.js.map +1 -0
  102. package/dist/oauth/token.d.ts +139 -0
  103. package/dist/oauth/token.js +183 -0
  104. package/dist/oauth/token.js.map +1 -0
  105. package/dist/paths.d.ts +366 -0
  106. package/dist/paths.js +460 -0
  107. package/dist/paths.js.map +1 -0
  108. package/dist/policy.d.ts +195 -0
  109. package/dist/policy.js +244 -0
  110. package/dist/policy.js.map +1 -0
  111. package/dist/post.d.ts +302 -0
  112. package/dist/post.js +384 -0
  113. package/dist/post.js.map +1 -0
  114. package/dist/project.d.ts +99 -0
  115. package/dist/project.js +192 -0
  116. package/dist/project.js.map +1 -0
  117. package/dist/redact.d.ts +175 -0
  118. package/dist/redact.js +700 -0
  119. package/dist/redact.js.map +1 -0
  120. package/dist/runtime.d.ts +30 -0
  121. package/dist/runtime.js +37 -0
  122. package/dist/runtime.js.map +1 -0
  123. package/dist/spawn_budget.d.ts +101 -0
  124. package/dist/spawn_budget.js +124 -0
  125. package/dist/spawn_budget.js.map +1 -0
  126. package/dist/spool.d.ts +390 -0
  127. package/dist/spool.js +556 -0
  128. package/dist/spool.js.map +1 -0
  129. package/dist/state.d.ts +109 -0
  130. package/dist/state.js +217 -0
  131. package/dist/state.js.map +1 -0
  132. package/dist/system_message.d.ts +101 -0
  133. package/dist/system_message.js +141 -0
  134. package/dist/system_message.js.map +1 -0
  135. package/dist/term.d.ts +112 -0
  136. package/dist/term.js +134 -0
  137. package/dist/term.js.map +1 -0
  138. package/dist/version.d.ts +16 -0
  139. package/dist/version.js +17 -0
  140. package/dist/version.js.map +1 -0
  141. package/package.json +50 -0
@@ -0,0 +1,1092 @@
1
+ /**
2
+ * The hook client — and the enforcement of the hook half of the exit contract.
3
+ *
4
+ * DESIGN.md §13.7, restated because every line below exists to hold it:
5
+ *
6
+ * exit ALWAYS 0. Every path: success, 401, 403, network failure,
7
+ * uncaught exception, unsupported Node, signal.
8
+ * stderr ALWAYS empty.
9
+ * wall clock ALWAYS under the hooks.json timeout for that event.
10
+ * stdout At most one well-formed JSON object carrying `systemMessage`;
11
+ * otherwise empty. If JSON construction fails for ANY reason →
12
+ * empty stdout. The fallback is always silence.
13
+ *
14
+ * ⚠ **That stdout line reads "EMPTY on the happy path" in DESIGN.md §13.7, and
15
+ * as of `CR-023d` it is no longer true.** Style guide §10.1's fourth state
16
+ * (`capture confirmed`) fires on a successful send, once per `(repo, day)` — so
17
+ * a populated stdout no longer implies something went wrong. §13.7 and §10.1
18
+ * disagreed on this before this task; the code now follows §10.1, which D65
19
+ * records as the executed ruling. DESIGN.md is orchestrator-owned (D66 §3), so
20
+ * the amendment there is reported, not made here.
21
+ *
22
+ * **A hook that exits non-zero feeds stderr back into the user's agent and
23
+ * derails the task they were actually doing.** That is the whole reason.
24
+ *
25
+ * ## Read this before trusting a green contract test
26
+ *
27
+ * `silenceStderr()` makes the stderr-empty assertion true STRUCTURALLY. That is
28
+ * the correct engineering choice — it is the only thing that holds when a
29
+ * dependency three levels down decides to warn — but it also means a green
30
+ * "stderr is empty" proves the hook never speaks, and proves **nothing** about
31
+ * whether the credential is handled carefully. The credential's own control is
32
+ * separate and lives in `credential.ts` (opaque wrapper + redaction), with its
33
+ * own red-proof in `test/credential.test.ts`. Do not read one as evidence for
34
+ * the other.
35
+ */
36
+ import { closeSync, openSync, readdirSync, readSync, realpathSync, statSync, writeSync, } from "node:fs";
37
+ import { basename, isAbsolute, join } from "node:path";
38
+ import { zstdCompressSync } from "node:zlib";
39
+ import { isProjectAllowed } from "../consent.js";
40
+ import { loadCredential } from "../credential.js";
41
+ import { EXIT } from "../exit.js";
42
+ import { gitProbe, headRef, resolveRepoSlug } from "../git.js";
43
+ import { redactSpan, rulesForTranscript } from "../redact.js";
44
+ import { deliver, ingestUrl } from "../post.js";
45
+ import { markSkipped } from "../policy.js";
46
+ import { fileState, loadSessionState, saveSessionState, withFileState } from "../state.js";
47
+ import { SESSION_END_EVENT, settleDelayMs, settledSize, } from "./session_end.js";
48
+ import { admitsTranscript, agentForTranscript, dialectFor, transcriptRoots, } from "../agents/registry.js";
49
+ import { announcedSubagentFileKey, isInsideAny, subagentFileKey, subagentsDir, } from "../paths.js";
50
+ import { resolveProjectKeys } from "../project.js";
51
+ import { startSpawnBudget } from "../spawn_budget.js";
52
+ import { capSpool, capSuccessors, readRewrites, readSpool } from "../spool.js";
53
+ import { meetsNodeFloor } from "../runtime.js";
54
+ import { renderNotice } from "../system_message.js";
55
+ /**
56
+ * Default wall-clock budget. `CR-028` sets the real per-event `timeout` in
57
+ * `claude-plugin/hooks/hooks.json`; this is the client-side backstop, which must
58
+ * be the SMALLER of the two or the backstop never fires.
59
+ *
60
+ * ⚠ **That relation was prose and was checked by nothing until `CR-183`.** It is
61
+ * now asserted for every event of every dialect in `test/agent-registry.test.ts`,
62
+ * which also pins this constant against the `claude-code` dialect's own entry.
63
+ */
64
+ export const DEFAULT_HOOK_BUDGET_MS = 5_000;
65
+ /** The subset of the network budget one attempt may take. */
66
+ const SEND_BUDGET_FRACTION = 0.6;
67
+ /**
68
+ * A floor under the send timeout, for the case the budget arithmetic cannot
69
+ * cover: `consumedMs` is MEASURED, not planned, so a scheduler stall during the
70
+ * settle can overshoot the budget outright and leave nothing to divide.
71
+ * `settleDelayMs`'s cap bounds the plan; this bounds reality.
72
+ *
73
+ * When it binds, the outer watchdog is already the real limit — it fires at
74
+ * `budgetMs` regardless — so a small positive timeout here costs nothing and a
75
+ * zero or negative one would abort the send before it was attempted.
76
+ */
77
+ const MIN_SEND_TIMEOUT_MS = 50;
78
+ /**
79
+ * What one send attempt may take, after the settle has taken its share.
80
+ *
81
+ * `consumedMs` is 0 on every event but `SessionEnd`, and at 0 this is exactly
82
+ * the expression that was inline here before `CR-020d`
83
+ * (`Math.floor(budgetMs * SEND_BUDGET_FRACTION)`) for any budget above ~84 ms —
84
+ * so `Stop` and `PreCompact` are unchanged in both timing and code path.
85
+ */
86
+ export function sendTimeoutMs(budgetMs, consumedMs) {
87
+ const remaining = Math.max(0, budgetMs - consumedMs);
88
+ return Math.max(MIN_SEND_TIMEOUT_MS, Math.floor(remaining * SEND_BUDGET_FRACTION));
89
+ }
90
+ /** A promise that resolves after `ms`. The one clock the settle path needs. */
91
+ function wait(ms) {
92
+ return new Promise((resolve) => setTimeout(resolve, ms));
93
+ }
94
+ /**
95
+ * Below this much REMAINING budget, stop starting sub-agent files — `CR-124`,
96
+ * D95.
97
+ *
98
+ * This, not a file count, is the real bound. `sendTimeoutMs` gives each attempt
99
+ * 0.6 of what is LEFT, so the remainder decays geometrically (5000 → 2000 → 800
100
+ * → …) and the sum can never reach the budget however many files there are. What
101
+ * a floor adds is refusing to START an attempt so small it can only time out:
102
+ * a 200 ms send against a slow server burns the wall clock and delivers nothing,
103
+ * and the file's offset would have been held anyway.
104
+ *
105
+ * The leftovers are not lost. Their offsets simply do not advance, and the next
106
+ * hook picks them up — except on `SessionEnd`, where there is no next hook and
107
+ * they stamp a gap instead (D95).
108
+ */
109
+ const MIN_SUBAGENT_BUDGET_MS = 400;
110
+ /**
111
+ * A hard backstop on files attempted per invocation.
112
+ *
113
+ * The floor above is the bound that binds when the server is slow; this one
114
+ * bounds the pathological shape the floor cannot see — a directory with
115
+ * hundreds of entries answering instantly, where the loop would spend its time
116
+ * in `stat` rather than in sends. The busiest session measured on this machine
117
+ * has 27 sub-agent files, so eight per invocation clears it in four Stop hooks,
118
+ * and a session that spawned 27 delegated agents will fire far more than four.
119
+ */
120
+ const MAX_SUBAGENT_FILES = 8;
121
+ /**
122
+ * What `stampGaps` gets to keep, carved OUT of the send budget on `SessionEnd`
123
+ * — `CR-182`.
124
+ *
125
+ * ## The defect, and why it is "starved" rather than "never runs"
126
+ *
127
+ * `stampGaps` is called AFTER the send loop, and the watchdog fires at
128
+ * `budgetMs` regardless. The loop's own arithmetic does leave it something: each
129
+ * attempt takes 0.6 of what REMAINS, so the last one starts with at least
130
+ * `MIN_SUBAGENT_BUDGET_MS` and returns with 0.4 of that — 160 ms — still on the
131
+ * clock. But that 160 ms is measured from `startedAt`, which is the settle, and
132
+ * **the watchdog is measured from `runHook`**. Everything before the settle —
133
+ * the stdin read, the consent gate's `git rev-parse`, the credential load — is
134
+ * time the loop never sees and the watchdog already spent. `CR-126` measured a
135
+ * comparable pre-entry interval at p99 392 ms idle and 184 ms under load, so the
136
+ * 160 ms cushion is routinely already gone.
137
+ *
138
+ * MEASURED against the unmodified client (12 delegated files, hung ingest
139
+ * server, `SessionEnd`): idle, every run stamped. Under CPU saturation, a
140
+ * 2200 ms budget produced **one state cell and zero gaps** — the loop's last
141
+ * send overshot and the process was cut off before it could record anything.
142
+ * That is D56 §D8's quietly-short record, which is the one outcome this client
143
+ * may not produce.
144
+ *
145
+ * ## Why 150 ms, and why a fixed number is honest here
146
+ *
147
+ * `stampGaps` is pure synchronous fs — one `loadSessionState` + `saveSessionState`
148
+ * pair per undelivered stream, bounded by `MAX_SUBAGENT_FILES + 1` (9) — with no
149
+ * network, no spawn and no timer in it. So unlike a send, its cost does not
150
+ * depend on anything outside this process, and a fixed reserve is a real bound
151
+ * rather than a guess dressed as one. 150 ms is ~17 ms per stream at the cap on
152
+ * a machine where the whole 9-file loop measures under 5 ms, i.e. an order of
153
+ * magnitude of margin over the effect — the same ratio `SESSION_END_SETTLE_DELAY_MS`
154
+ * is set at, and small enough that it costs the send 90 ms of 2850 on the
155
+ * default budget.
156
+ *
157
+ * ⚠ **A mitigation, not a guarantee.** Enough scheduler pressure defeats any
158
+ * fixed reserve, exactly as it defeats the watchdog itself. What this buys is
159
+ * that the ordinary slow-server case — the one Codex's 3 s cap makes routine —
160
+ * stops eating the gap record.
161
+ *
162
+ * ⛔ Zero on every other event: `stampGaps` runs on `SessionEnd` alone, so
163
+ * reserving elsewhere would shrink `Stop`'s send for a stamp that never happens.
164
+ */
165
+ const GAP_STAMP_RESERVE_MS = 150;
166
+ /**
167
+ * The budget the SENDS may spend — the whole budget, less the stamp's share.
168
+ *
169
+ * A function, and exported, for the reason `sendTimeoutMs` is: the carve-out is
170
+ * the thing this task changes, and a wall-clock test cannot pin it. Every
171
+ * discriminator that distinguishes reserve from no-reserve through a real
172
+ * process turned out to slide with per-send overhead — a 149 ms window that
173
+ * moves by 25 ms for every 10 ms of connect-and-compress cost — so pinning it
174
+ * there would be tuning a constant to one machine. Here it is arithmetic.
175
+ *
176
+ * ⛔ `budgetMs` itself still governs the SETTLE and the WATCHDOG. Only the sends
177
+ * are carved.
178
+ */
179
+ export function sendBudgetMs(budgetMs, eventName) {
180
+ return budgetMs - (eventName === SESSION_END_EVENT ? GAP_STAMP_RESERVE_MS : 0);
181
+ }
182
+ /**
183
+ * Parse hook stdin. Claude Code supplies `{ session_id, transcript_path, cwd,
184
+ * hook_event_name }` on stdin — NOT in the environment.
185
+ *
186
+ * ⚠ **And an `announce` dialect adds `agent_transcript_path` (`CR-196`).** It is
187
+ * OPTIONAL in both directions: a `discover` install never sends it, and reading
188
+ * it here rather than in a Cursor-specific parser is deliberate — stdin has one
189
+ * shape and one parser, and a second one would be a second place for the
190
+ * confinement boundary to be forgotten.
191
+ *
192
+ * Returns null on anything malformed. A hook cannot report a parse error (stderr
193
+ * is empty by contract), so the only honest response is to do nothing. ⚠ A
194
+ * malformed `agent_transcript_path` is NOT malformed input: it is dropped and
195
+ * the main transcript still goes, because losing the whole capture over a
196
+ * delegated extra would be the larger loss.
197
+ */
198
+ export function parseHookInput(raw) {
199
+ let parsed;
200
+ try {
201
+ parsed = JSON.parse(raw);
202
+ }
203
+ catch {
204
+ return null;
205
+ }
206
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
207
+ return null;
208
+ const o = parsed;
209
+ const sessionId = o.session_id;
210
+ const transcriptPath = o.transcript_path;
211
+ const cwd = o.cwd;
212
+ const eventName = o.hook_event_name;
213
+ if (typeof sessionId !== "string" || sessionId === "")
214
+ return null;
215
+ if (typeof transcriptPath !== "string" || transcriptPath === "")
216
+ return null;
217
+ const announced = o.agent_transcript_path;
218
+ return {
219
+ sessionId,
220
+ transcriptPath,
221
+ cwd: typeof cwd === "string" && cwd !== "" ? cwd : process.cwd(),
222
+ eventName: typeof eventName === "string" ? eventName : "",
223
+ ...(typeof announced === "string" && announced !== ""
224
+ ? { agentTranscriptPath: announced }
225
+ : {}),
226
+ };
227
+ }
228
+ /**
229
+ * Replace stderr with a sink for the rest of the process.
230
+ *
231
+ * Node writes uncaught exceptions, unhandled rejections and process warnings to
232
+ * `process.stderr`, so overriding `write` is what makes the contract hold on the
233
+ * paths we do not control. The handlers below stop the non-zero exit; this stops
234
+ * the bytes.
235
+ */
236
+ export function silenceStderr() {
237
+ const sink = (_chunk, encodingOrCallback, callback) => {
238
+ const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
239
+ if (typeof done === "function")
240
+ done();
241
+ return true;
242
+ };
243
+ // The cast is unavoidable: `Writable.write` is overloaded and the sink
244
+ // satisfies every overload's contract without matching any one signature.
245
+ process.stderr.write = sink;
246
+ }
247
+ /**
248
+ * Write the single stdout payload and exit 0.
249
+ *
250
+ * `writeSync(1, …)` rather than `process.stdout.write`: stdout to a pipe is
251
+ * asynchronous, and `process.exit` does not flush it. A `systemMessage` lost to a
252
+ * truncated write is the silent-unconfigured failure DX3 exists to kill.
253
+ */
254
+ function finish(payload) {
255
+ if (payload !== null) {
256
+ try {
257
+ writeSync(1, `${payload}\n`);
258
+ }
259
+ catch {
260
+ // Silence is the fallback, always.
261
+ }
262
+ }
263
+ process.exit(EXIT.ok);
264
+ }
265
+ /**
266
+ * Install every contract guard, then run the body.
267
+ *
268
+ * Guards go in FIRST, before any work, because the paths they cover include
269
+ * "the body threw on its first line".
270
+ */
271
+ export async function runHook(ctx) {
272
+ silenceStderr();
273
+ const budgetMs = hookBudgetMs(ctx.env, ctx.agentId);
274
+ // The watchdog covers the wall-clock clause on paths no `await` can reach: a
275
+ // socket that never settles, a promise that never resolves. `unref` so it
276
+ // never itself keeps the process alive.
277
+ const watchdog = setTimeout(() => finish(null), budgetMs);
278
+ watchdog.unref();
279
+ // ⛔ AND THE HALF THE WATCHDOG CANNOT COVER (`TODOS[87]`). A timer cannot fire
280
+ // while the event loop is BLOCKED, and `execFileSync` blocks it by definition
281
+ // — so a git binary that never returns is an unbounded hang, not a slow hook,
282
+ // and no amount of watchdog fixes it. Armed HERE, from the same `budgetMs`, so
283
+ // the two halves of one bound cannot drift apart.
284
+ startSpawnBudget(budgetMs);
285
+ process.on("uncaughtException", () => finish(null));
286
+ process.on("unhandledRejection", () => finish(null));
287
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
288
+ process.on(signal, () => finish(null));
289
+ }
290
+ let payload = null;
291
+ try {
292
+ payload = await hookBody(ctx, budgetMs);
293
+ }
294
+ catch {
295
+ payload = null;
296
+ }
297
+ finish(payload);
298
+ }
299
+ /**
300
+ * The wall clock this invocation gets.
301
+ *
302
+ * ⛔ **THE TIGHTEST BUDGET THE DIALECT REGISTERS, and the reason is the ORDER
303
+ * things happen in `runHook`** — the watchdog is armed BEFORE stdin is read, so
304
+ * the event name is not known yet and a per-event budget cannot be applied here
305
+ * without re-arming the guard. Taking the minimum is the direction that is safe
306
+ * to be wrong in: it can only finish early, never overrun a registered timeout
307
+ * and let the agent kill the process instead.
308
+ *
309
+ * ⚠ **Claude Code is unchanged, arithmetically** — its three events all carry
310
+ * `DEFAULT_HOOK_BUDGET_MS`, so the minimum IS that constant. What this costs is
311
+ * an agent whose events differ: it runs every event at its tightest one, which
312
+ * under-uses the wide ones. Stated rather than smuggled; widening it means
313
+ * re-arming the watchdog after the parse, which is its own change.
314
+ *
315
+ * `VIBECOMMIT_HOOK_TIMEOUT_MS` still wins outright, as it always has.
316
+ */
317
+ export function hookBudgetMs(env, agentId) {
318
+ const raw = env.VIBECOMMIT_HOOK_TIMEOUT_MS;
319
+ if (raw === undefined)
320
+ return dialectBudgetMs(agentId);
321
+ const parsed = Number(raw);
322
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : dialectBudgetMs(agentId);
323
+ }
324
+ /** The smallest `clientBudgetMs` across a dialect's events. */
325
+ function dialectBudgetMs(agentId) {
326
+ const budgets = Object.values(dialectFor(agentId).events).map((e) => e.clientBudgetMs);
327
+ return Math.min(...budgets);
328
+ }
329
+ /**
330
+ * The hook body. Returns the stdout payload, or null for silence.
331
+ *
332
+ * Ordering is load-bearing in two places:
333
+ * 1. the Node floor is checked FIRST, because everything below it may use
334
+ * syntax or APIs the old runtime lacks;
335
+ * 2. the CONSENT GATE runs BEFORE `transcript_path` is opened (D56 §D19). A
336
+ * gate that runs after the read has already read the thing it was gating.
337
+ */
338
+ async function hookBody(ctx, budgetMs) {
339
+ const raw = await ctx.readStdin();
340
+ const input = parseHookInput(raw);
341
+ if (input === null)
342
+ return null;
343
+ // `repoKey` is passed per call rather than captured, because the three
344
+ // session-keyed states can fire BEFORE a project has been resolved at all —
345
+ // `unsupported_runtime` fires before anything else runs. Only
346
+ // `capture_confirmed` needs one, and only it supplies one.
347
+ const notice = (state, repoKey = null) => renderNotice({
348
+ home: ctx.home,
349
+ sessionId: input.sessionId,
350
+ nodeVersion: ctx.nodeVersion,
351
+ repoKey,
352
+ now: () => new Date(),
353
+ }, state);
354
+ if (!meetsNodeFloor(ctx.nodeVersion))
355
+ return notice("unsupported_runtime");
356
+ // --- CONSENT GATE. Nothing above this line has opened the transcript. ---
357
+ // The null arm is not new behaviour — `isProjectAllowed(home, null)` is already
358
+ // false, so a `cwd` outside a work tree already stopped here. It is stated
359
+ // separately because `CR-017d` makes this value half the delivery key as well
360
+ // as the consent key, and a key the compiler cannot prove is present is one
361
+ // some later edit supplies a placeholder for.
362
+ //
363
+ // ⛔ THE GATE CHECKS `keys.consent` — THE GIT COMMON DIR (`D184 §1`).
364
+ //
365
+ // It is shared by a main clone and every worktree linked to it, which is the
366
+ // whole point. Consent was keyed on `--show-toplevel`, that differs per
367
+ // worktree, and absence means no — so an agent session inside a `git worktree`
368
+ // captured NOTHING, silently, under the house convention (`D19`) of one
369
+ // worktree per builder.
370
+ //
371
+ // ⛔⛔ AND THIS IS ONE OF FOUR SITES, ALL OF WHICH NOW AGREE. `D184 §9`: this
372
+ // READ gate, `post_commit.ts` (the COMMIT gate), `commands/connect.ts`
373
+ // (`grantProject`, the WRITE side and the ONLY writer of the allow list) and
374
+ // `commands/status.ts` (`revokeProject`, the REVOKE side). If any one of them
375
+ // keyed on the toplevel while the others used the common dir, the allow list
376
+ // would never match: the null arm below returns silently BY DESIGN, so every
377
+ // session in every repository would capture nothing, with no notice — strictly
378
+ // worse than the worktree blackout this change exists to fix.
379
+ //
380
+ // ⚠ AND IT WOULD FAIL GREEN. The suite's fixtures hand-write `projects.json`,
381
+ // deliberately, so every cell agrees with this gate whichever key both use —
382
+ // the asymmetry is only visible END TO END, through the real `connect` path
383
+ // and a real session in a real worktree. `test/consent-key-end-to-end.test.ts`
384
+ // is that cell, and under a mutation that grants the toplevel here **135
385
+ // existing cells stay green and only those two red**.
386
+ //
387
+ // ⛔ THAT CELL ASSERTS CONTENT, NOT ONLY ADMISSION, and the distinction is
388
+ // `D184 §3`: a session admitted by this gate whose confinement root set has
389
+ // collapsed delivers a payload of redaction markers. The request arrives, the
390
+ // byte accounting is intact, and the content is gone. Anything asserting only
391
+ // that a worktree session was admitted passes on an empty payload.
392
+ //
393
+ // ⚠ `resolveProjectKeys` is called once, not twice: the redaction root set
394
+ // needs the common dir too, and a second resolution could disagree with the
395
+ // one consent was checked against. It is stricter than `resolveProjectKey` in
396
+ // one way — the common dir must also resolve and hold a `HEAD` — which fails
397
+ // closed, silently, exactly as this gate already does.
398
+ const keys = resolveProjectKeys(input.cwd);
399
+ if (keys === null || !isProjectAllowed(ctx.home, keys.consent)) {
400
+ // Unconsented is not "not connected": say nothing at all. A machine with no
401
+ // credential says state 1 below; a repo the user declined stays silent,
402
+ // because nagging about a decision the user already made is the tool noise
403
+ // DX3's one-line rule exists to avoid.
404
+ return null;
405
+ }
406
+ // The THREE roles that never move (`D184 §1`) — the session-state bucket
407
+ // (`D58`/`CR-017d`), the `resolveRepoSlug` input, and a directory to run git
408
+ // in — under the name every site below already uses for them. Renaming them
409
+ // would be churn across a file this change has no other reason to touch, and
410
+ // `report.ts:343` is the standing proof that the NAME is not what tells you
411
+ // which role a site wants.
412
+ const projectKey = keys.worktree;
413
+ const load = loadCredential({ env: ctx.env, home: ctx.home });
414
+ switch (load.kind) {
415
+ case "absent":
416
+ return notice("not_connected");
417
+ case "wrong-class":
418
+ case "insecure-file":
419
+ case "unreadable":
420
+ // Interactive verbs print four different fixes for these (§13.6). A hook
421
+ // has one channel and a one-line budget, so they collapse to the state
422
+ // whose fix — `vibecommit connect` — is the same for all four.
423
+ return notice("not_connected");
424
+ case "ok":
425
+ break;
426
+ }
427
+ // --- PATH CONFINEMENT. `/cso` finding 1, HIGH. ---
428
+ // `transcript_path` is attacker-influenced in the one way that matters: it
429
+ // reaches a read whose result is uploaded. Confining it to the directories the
430
+ // agents in the registry actually write transcripts to is what stops this verb
431
+ // being a silent arbitrary-file exfiltrator. Silent refusal, per the contract.
432
+ //
433
+ // ⛔ THE ROOTS COME FROM THE REGISTRY, NEVER FROM `input` (`CR-183`, D164 §5).
434
+ // The union is deliberately independent of `ctx.agentId`: the flag comes from
435
+ // an install that may have got it wrong, and a wrong SELECTION must not be able
436
+ // to widen the boundary — at most it picks a suboptimal redaction profile.
437
+ //
438
+ // ⛔ AND THE FILE GATE, ADDED HERE BY `CR-193` (D177 §2). The boundary answers
439
+ // "may this path be read"; it does not answer "is this a transcript", and
440
+ // under Cursor's root those are different questions — `find -type f` there
441
+ // returns 113 files and exactly ONE is a transcript. A narrowing over an
442
+ // already-computed boundary, so it can only refuse. See `admitsTranscript`.
443
+ if (!admitsTranscript(ctx.home, ctx.env, input.transcriptPath))
444
+ return null;
445
+ const url = ingestUrl(ctx.env);
446
+ // `/cso` finding 2: a rejected override is a silent no-send, not a fallback to
447
+ // the default. Falling back would send the credential somewhere the operator
448
+ // did not ask for, which is the opposite of what the check is for.
449
+ if (url === null)
450
+ return null;
451
+ // CR-016's bootstrap re-sent the whole file from byte 0 every time. CR-018
452
+ // replaces it, because `later` (hold the offset) and `never` (advance past the
453
+ // failing bytes) are both defined in terms of an offset and mean nothing
454
+ // without one. `CR-017d` widened the key that offset is kept under to
455
+ // `(repo, session)`; `CR-124` (W6) owns globbing
456
+ // `<session-dir>/subagents/agent-*.jsonl` — this sends the `main` file only.
457
+ //
458
+ // ⚠ The attribution above said `CR-020` until `CR-020d`. It was wrong, and D76
459
+ // says so by name: `CR-020`'s finding (D57 §DX7) is SessionEnd timing only and
460
+ // says nothing about sub-agent files. Corrected in passing rather than left to
461
+ // send the next reader after work that lives in another task.
462
+ //
463
+ // `SessionEnd` SETTLES FIRST (D57 §DX7). Every other event has a next
464
+ // invocation to pick up a lagging write; this one does not, so bytes missed
465
+ // here are the final turn, lost permanently.
466
+ const isSessionEnd = input.eventName === SESSION_END_EVENT;
467
+ const settleFor = isSessionEnd ? settleDelayMs(ctx.env, budgetMs) : 0;
468
+ // `CR-182`. Carved OUT of the budget, never added on top — the same shape the
469
+ // settle takes, and for the same reason: the watchdog does not move.
470
+ const sendBudget = sendBudgetMs(budgetMs, input.eventName);
471
+ const settleStart = Date.now();
472
+ const eof = settleFor > 0
473
+ ? await settledSize(() => fileSize(input.transcriptPath), wait, settleFor)
474
+ : fileSize(input.transcriptPath);
475
+ if (eof === null)
476
+ return null;
477
+ // MEASURED, not assumed to be `settleFor`: the wait can overshoot under load,
478
+ // and the budget cares about the time actually spent.
479
+ const consumedMs = settleFor > 0 ? Date.now() - settleStart : 0;
480
+ // The WIRE identity — `owner/repo` when `origin` is GitHub, `host/owner/repo`
481
+ // for any other host, else `local:<hash>`. Required by the merged server
482
+ // (`CR-019b`), which 400s a delta without it rather than writing a row nobody
483
+ // can read (D57 §OV1).
484
+ //
485
+ // ⛔ RESOLVED EXACTLY ONCE PER HOOK (`TODOS[87]`). It spawns
486
+ // `git remote get-url origin`, and it used to be called here AND once per
487
+ // sub-agent file — up to nine spawns for a value that cannot change within an
488
+ // invocation, since `projectKey` is fixed by the consent gate above. Passing
489
+ // it down rather than re-deriving it is also what keeps the two paths from
490
+ // ever disagreeing about which repository this is.
491
+ const repoSlug = resolveRepoSlug(projectKey);
492
+ // ⛔ THE ONE NEW SPAWN `CR-170` ADDS TO THIS PATH, and it is for REF MOVES,
493
+ // not commits. `post-commit` observes commits; it cannot see a `pull`, a
494
+ // `checkout` or a `reset --hard`, because those move a ref WITHOUT creating a
495
+ // commit and no commit hook fires. A direct look at `HEAD` is the only record.
496
+ //
497
+ // ⚠ It shares `CR-169`'s spawn budget like every other probe, so a hung git
498
+ // here is bounded and cannot defeat the watchdog. Null on an empty repository
499
+ // (`rev-parse HEAD` exits 128 there) — a state, not an error.
500
+ const head = headRef(projectKey);
501
+ // Commits OBSERVED since the last delivery. Capped, with the remainder left in
502
+ // the spool for the next hook rather than dropped.
503
+ const spoolKey = { repoKey: projectKey, sessionId: input.sessionId };
504
+ const spooled = capSpool(readSpool(ctx.home, spoolKey));
505
+ // ⛔ REWRITES ARE THEIR OWN FILE WITH THEIR OWN CAP (`T5`). The units differ —
506
+ // 41 bytes for a sha, 82 for an `ancestor:successor` pair — so one constant
507
+ // sized against the other would make one of the two headers wrong.
508
+ const rewritten = capSuccessors(readRewrites(ctx.home, spoolKey));
509
+ // ⛔ THE REDACTION BOUNDARY, and it is NOT the consent key (`D184 §3`).
510
+ // Resolved here rather than at the gate so the derivation's one spawn is not
511
+ // paid by the invocations that return above without reading a byte.
512
+ const redactionRoots = confinementRoots(keys);
513
+ const delivery = await deliver({
514
+ home: ctx.home,
515
+ env: ctx.env,
516
+ url,
517
+ credential: load.credential,
518
+ // The consent gate's key, reused rather than re-resolved: a second
519
+ // `git rev-parse` could disagree with the one consent was checked against.
520
+ repoKey: projectKey,
521
+ repoSlug,
522
+ sessionId: input.sessionId,
523
+ fileKey: "main",
524
+ // ⛔ THE PATH, so `deliver()` can derive `X-Agent` from the root that
525
+ // contains it (`CR-204`, D177 §7). Deliberately the same expression the
526
+ // `readBody` closure below takes, so the bytes and the producer can never
527
+ // describe two different files.
528
+ transcriptPath: input.transcriptPath,
529
+ // ⛔ `sendBudget`, not `budgetMs`. The main send is the single largest
530
+ // consumer on this path, so a reserve the loop honours and this does not
531
+ // is one the biggest spender walks straight through (`CR-182`).
532
+ timeoutMs: sendTimeoutMs(sendBudget, consumedMs),
533
+ nowMs: Date.now(),
534
+ // ⛔ THE MAIN DELIVERY ONLY. Sub-agent deliveries below leave all three
535
+ // unset: each is its own `capture_id`, and `capture_commits`' PK is
536
+ // `(org_id, capture_id, commit_sha)`, so attaching these to all nine
537
+ // deliveries would admit nine legal, permanent rows for one commit — the
538
+ // objection that killed the polling design (D154).
539
+ head,
540
+ // ⛔ THE WHOLE OBJECT, NOT `spooled.shas` (`T5`, `D190 §1`). The shas and
541
+ // their rungs are only safe as a pair — `parseObservedCommits` drops the
542
+ // ENTIRE BATCH when the two lists differ in length — and `count` is the
543
+ // number of spool LINES consumed, which is what the 2xx truncates by. A
544
+ // held entry is consumed without being sent, so `shas.length` is the wrong
545
+ // number for that and passing the pieces separately is how they diverge.
546
+ commits: spooled,
547
+ rewrites: rewritten.pairs,
548
+ }, eof,
549
+ // ⛔ THE ROOT SET, AND IT IS NOT THE VALUE THE CONSENT GATE APPROVED.
550
+ // Those were one value until `D184`, and this line is where the difference
551
+ // becomes observable: redaction is measured against `[worktree,
552
+ // mainToplevel]`, never against the git common dir. If it followed the
553
+ // consent key there, `isInside` would fail-close every file of an external
554
+ // worktree to "outside the main clone" and hand the server a span of
555
+ // markers — a capture that succeeds, with turn counts that look right and
556
+ // no content in it (`D184 §3`).
557
+ (from, to) => readSpan(input.transcriptPath, from, to, redactionRoots, ctx.home, ctx.env));
558
+ // --- DELEGATED WORK. `CR-124`, D76. ---
559
+ // Claude Code writes sub-agent transcripts to `<session>/subagents/`, which
560
+ // `transcript_path` never names: ~16% of transcript volume, and the work a
561
+ // reviewer most wants attributed. MAIN WENT FIRST, deliberately (D95) — the
562
+ // content most likely to matter is the content most likely to fit.
563
+ const delegated = await deliverSubagents({
564
+ ctx,
565
+ input,
566
+ url,
567
+ credential: load.credential,
568
+ projectKey,
569
+ redactionRoots,
570
+ repoSlug,
571
+ startedAt: settleStart,
572
+ sendBudget,
573
+ });
574
+ // --- THE HONEST HOLE. D95, widened to `main` by `CR-182`. ---
575
+ //
576
+ // ⛔ AFTER EVERY SEND, AND IT CANNOT MOVE. `markSkipped` sets
577
+ // `sentOffset = to`, so stamping first would leave `nextSpan` nothing to
578
+ // return for any stream — it would gap the whole session and send none of it.
579
+ //
580
+ // ⛔ AND IT CANNOT HANG OFF `finish()`, which the signal handlers reach and
581
+ // which must stay minimal.
582
+ //
583
+ // It sits here rather than inside `deliverSubagents` because that function
584
+ // returns early when there are no delegated files at all — and the MAIN
585
+ // transcript's gap must not be conditional on a `subagents/` directory
586
+ // existing. `eof`, not a fresh `statSync`: the gap records what this
587
+ // invocation COMMITTED to sending and could not, which is exactly what
588
+ // `SubagentFile.size` is for the delegated streams.
589
+ if (isSessionEnd) {
590
+ stampGaps(ctx, input, projectKey, [{ fileKey: "main", size: eof }, ...delegated]);
591
+ }
592
+ // The three classes are decided in `policy.ts` and applied in `post.ts`. Two
593
+ // of the four outcomes reach a human.
594
+ //
595
+ // `fatal`: a hook that stays silent about a revoked credential captures
596
+ // nothing forever. `later` and `never` say nothing — a hook has one line per
597
+ // session and spending it on a condition that resolves itself next turn is the
598
+ // tool noise D57 §DX3 exists to avoid.
599
+ //
600
+ // `ok`: the fourth state (`CR-023d`), and the FIRST time this hook has ever
601
+ // spoken on success. Its `(repo, day)` claim is what keeps it to once — no
602
+ // "have I already said this today" check belongs here, because the atomic `wx`
603
+ // claim already is that check, and a second one could disagree with it.
604
+ if (delivery.kind === "attempted") {
605
+ if (delivery.disposition === "fatal")
606
+ return notice("credential_revoked");
607
+ if (delivery.disposition === "ok")
608
+ return notice("capture_confirmed", projectKey);
609
+ }
610
+ return null;
611
+ }
612
+ /**
613
+ * Send whatever delegated transcripts fit in what is left of the budget.
614
+ *
615
+ * ## Why this is bounded and not a loop over the directory
616
+ *
617
+ * `entry.ts` gets ONE wall clock (DESIGN.md §13.7) and the watchdog cuts the
618
+ * process off at `budgetMs` regardless. A real session on this machine has 27
619
+ * sub-agent files; twenty-seven sends cannot each have a share of five seconds.
620
+ * D95 settles the shape: one MEASURED deadline, the running elapsed handed to
621
+ * `sendTimeoutMs` so file N gets the real remainder, and a floor below which no
622
+ * new file is started.
623
+ *
624
+ * Nothing is lost by stopping early. An unattempted file's offset does not
625
+ * advance, so the next hook picks it up — the same property `CR-018`'s failure
626
+ * classes already rely on.
627
+ *
628
+ * **Except on `SessionEnd`, where there is no next hook.** There, an undelivered
629
+ * file STAMPS A GAP (D95): the offset advances and the hole is recorded, because
630
+ * a record that says what it could not send is honest and one that is quietly
631
+ * short is not. That is D56 §D8's own shape, applied to the one event where
632
+ * advance-regardless would otherwise lose the bytes silently. The stamp itself
633
+ * is the CALLER's, not this function's (`CR-182`) — it covers the main
634
+ * transcript too, and must therefore happen whether or not this ran.
635
+ *
636
+ * Serial, never `Promise.all`: each `deliver()` does load-modify-save on one
637
+ * JSON file and claims `session.seq + 1`, so concurrent calls would lose seq
638
+ * updates to each other and the server would read a replay.
639
+ *
640
+ * Returns every delegated file it DISCOVERED, attempted or not — the caller
641
+ * stamps against that set, and an unattempted file is precisely the one most in
642
+ * need of a hole.
643
+ */
644
+ async function deliverSubagents(opts) {
645
+ const { ctx, input, url, credential, projectKey, redactionRoots, repoSlug, startedAt, sendBudget } = opts;
646
+ // ⛔ TWO MECHANISMS, NEVER BOTH AT ONCE — `CR-196`, D177 §9. Each of these
647
+ // returns `[]` unless the CONTAINING ROOT's dialect declares its own
648
+ // mechanism, so the concatenation is a union of two disjoint sets rather than
649
+ // a fallback chain. Written this way so `findSubagentFiles` is byte-unchanged
650
+ // and `CR-204`'s root-derived gating (pinned by `test/wire-agent.test.ts` G4)
651
+ // keeps its meaning.
652
+ const files = [
653
+ ...findSubagentFiles(ctx, input.transcriptPath),
654
+ ...announcedSubagentFiles(ctx, input),
655
+ ];
656
+ if (files.length === 0)
657
+ return files;
658
+ // ⚠ SKIP WHAT HAS NOTHING PENDING, BEFORE THE CAP APPLIES — and this is not an
659
+ // optimisation, it is what stops the tail starving.
660
+ //
661
+ // The order is by mtime, which is STABLE: delivering a file does not touch it.
662
+ // So a cap counting ATTEMPTS re-attempts the same eight oldest files on every
663
+ // invocation, each returning `nothing-to-send`, and files nine and up are
664
+ // never reached at all. Caught by the test that runs two invocations and
665
+ // expects the second to make progress; it did not.
666
+ //
667
+ // Filtering on pending bytes makes the cap count WORK rather than calls, so
668
+ // each invocation starts where the last one stopped.
669
+ const key = { repoKey: projectKey, sessionId: input.sessionId };
670
+ const before = loadSessionState(ctx.home, key);
671
+ const pending = files.filter((f) => fileState(before, f.fileKey).sentOffset < f.size);
672
+ if (pending.length === 0)
673
+ return files;
674
+ for (const file of pending.slice(0, MAX_SUBAGENT_FILES)) {
675
+ const consumedMs = Date.now() - startedAt;
676
+ if (sendBudget - consumedMs < MIN_SUBAGENT_BUDGET_MS)
677
+ break;
678
+ await deliver({
679
+ home: ctx.home,
680
+ env: ctx.env,
681
+ url,
682
+ credential,
683
+ repoKey: projectKey,
684
+ repoSlug,
685
+ sessionId: input.sessionId,
686
+ fileKey: file.fileKey,
687
+ // ⛔ THE DELEGATED FILE'S OWN PATH, not the main transcript's. Each
688
+ // sub-agent stream is its own `capture_id` and its own row; the root
689
+ // that contains IT is what names its producer.
690
+ transcriptPath: file.path,
691
+ // The remainder, MEASURED. This is what keeps N files inside one budget.
692
+ timeoutMs: sendTimeoutMs(sendBudget, consumedMs),
693
+ nowMs: Date.now(),
694
+ }, file.size,
695
+ // The SAME reader as the main transcript, so `CR-024d`'s redaction and
696
+ // `/cso` finding 1's confinement both apply. A delegated agent reading a
697
+ // third party's file is exactly as out-of-tree as the main thread doing it.
698
+ (from, to) => readSpan(file.path, from, to, redactionRoots, ctx.home, ctx.env));
699
+ }
700
+ return files;
701
+ }
702
+ /**
703
+ * On `SessionEnd`, record what could not be sent rather than losing it quietly.
704
+ *
705
+ * Covers both the files never started and the ones whose send was held: after
706
+ * this event there is no invocation to retry either, so both are permanently
707
+ * undelivered and both deserve the same honest hole.
708
+ *
709
+ * ## The MAIN transcript is one of them now — `CR-182`
710
+ *
711
+ * D95 scoped this to `subagents/` and its own note recorded the cost: *"the main
712
+ * transcript's own failed send on `SessionEnd` is equally permanent and stamps
713
+ * nothing."* That note is load-bearing rather than tidy, because the span caps
714
+ * make the main file's remainder something a single `SessionEnd` routinely
715
+ * cannot finish — so the widest, most-read stream was the one going quietly
716
+ * short.
717
+ *
718
+ * ⚠ **WHAT THIS COSTS, stated rather than smuggled.** `stampGaps` decides on
719
+ * `sentOffset < size` and cannot tell WHY a stream is behind, so the main
720
+ * transcript now inherits both cases D99 §1 measured for delegated streams: a
721
+ * `403 capture_not_approved` — which means org-approval-PENDING, i.e. later —
722
+ * and a `401` `fatal`, whose own comment in `post.ts` says the offset does not
723
+ * advance because *"these bytes are fine"*. On `SessionEnd` neither has a next
724
+ * invocation to be right about, so both are written off. Whether a refusal
725
+ * should stamp at all is `TODOS[93]`, still open and still not decided here;
726
+ * what changed is only that it now reaches `main` as well.
727
+ */
728
+ function stampGaps(ctx, input, projectKey, files) {
729
+ const key = { repoKey: projectKey, sessionId: input.sessionId };
730
+ // Re-read per file rather than once: each `deliver()` above saved, so a
731
+ // snapshot taken before the loop would stamp gaps over work that succeeded.
732
+ for (const file of files) {
733
+ const session = loadSessionState(ctx.home, key);
734
+ const current = fileState(session, file.fileKey);
735
+ // The offset is the only thing that decides this, which is why no record of
736
+ // what was attempted is needed: a file the loop never reached and one whose
737
+ // send was held are both simply behind, and both are equally permanent now.
738
+ if (current.sentOffset >= file.size)
739
+ continue;
740
+ saveSessionState(ctx.home, key, withFileState(session, file.fileKey, markSkipped(current, current.sentOffset, file.size)));
741
+ }
742
+ }
743
+ /**
744
+ * Every `agent-*.jsonl` under this session's `subagents/`, oldest first.
745
+ *
746
+ * **`agent-*.jsonl`, never `agent-*`.** The directory carries an
747
+ * `agent-<id>.meta.json` beside every transcript — 636 of each on this machine,
748
+ * exactly 1:1 — so the looser glob would double every upload and send metadata
749
+ * as transcript content.
750
+ *
751
+ * **Every path is confined.** `isInsideAny(transcriptRoots(...))` gates each
752
+ * entry before it is opened, exactly as the main transcript is gated: this adds
753
+ * a new SET of readers to the surface `/cso` finding 1 rated HIGH, and a
754
+ * symlinked entry in `subagents/` must not walk out of the transcript roots.
755
+ *
756
+ * Oldest first, because it cannot starve. Ordering by pending bytes would let a
757
+ * small file lose every round forever; by mtime, every file reaches the front.
758
+ *
759
+ * A missing or unreadable directory is simply "no sub-agents" — silent, per the
760
+ * hook contract.
761
+ */
762
+ function findSubagentFiles(ctx, transcriptPath) {
763
+ // ⛔ `CR-183` — THE GLOB IS THIS DIALECT'S MECHANISM, not the lane's. An agent
764
+ // that ANNOUNCES its delegated transcript on stdin has no `subagents/` to read,
765
+ // and running the glob against its layout would be a directory walk that can
766
+ // only ever find nothing. Nothing parses an announced path yet, so an
767
+ // announcing dialect delegates nothing today — stated, not disguised.
768
+ //
769
+ // ⛔ SELECTED BY THE CONTAINING ROOT, NOT BY `ctx.agentId` (`CR-204`,
770
+ // D177 §7). Sub-agent LAYOUT is a property of the TRANSCRIPT, so the flag is
771
+ // the wrong oracle for it in both directions — and both are live, not
772
+ // theoretical. MEASURED on a no-flag install (`test/wire-agent.test.ts` G4):
773
+ // a Codex-root transcript with a `subagents/` directory beside it had Claude's
774
+ // glob run over it and UPLOADED what it found. The other direction is the one
775
+ // D177 §7 names: a cross-fired hook globbing Claude's `agent-*.jsonl` under a
776
+ // foreign root and silently finding nothing.
777
+ //
778
+ // ⚠ An unattributed path resolves `unknown`, and `dialectFor` falls back to
779
+ // Claude — so it would glob. It cannot arrive here: confinement refuses
780
+ // everything outside the union first, and `subagentsDir` returns null for
781
+ // anything that is not a `.jsonl` anyway.
782
+ if (dialectFor(agentForTranscript(ctx.home, ctx.env, transcriptPath)).delegatedTranscripts !==
783
+ "discover") {
784
+ return [];
785
+ }
786
+ const dir = subagentsDir(transcriptPath);
787
+ if (dir === null)
788
+ return [];
789
+ const roots = transcriptRoots(ctx.home, ctx.env);
790
+ let entries;
791
+ try {
792
+ entries = readdirSync(dir);
793
+ }
794
+ catch {
795
+ return [];
796
+ }
797
+ const found = [];
798
+ for (const entry of entries) {
799
+ const fileKey = subagentFileKey(entry);
800
+ if (fileKey === null)
801
+ continue;
802
+ const path = join(dir, entry);
803
+ if (!isInsideAny(roots, path))
804
+ continue;
805
+ try {
806
+ const stat = statSync(path);
807
+ if (!stat.isFile() || stat.size === 0)
808
+ continue;
809
+ found.push({ path, fileKey: fileKey, size: stat.size, mtimeMs: stat.mtimeMs });
810
+ }
811
+ catch {
812
+ // Vanished between readdir and stat. Not ours to report.
813
+ }
814
+ }
815
+ return found.sort((a, b) => a.mtimeMs - b.mtimeMs);
816
+ }
817
+ /**
818
+ * ⛔ **THE `announce` READER — the first one there has ever been.** `CR-196`,
819
+ * D177 §9.
820
+ *
821
+ * `agents/types.ts` said `announce` *"has no reader yet"* from `CR-183` until
822
+ * this function existed; that sentence is amended by this task rather than left
823
+ * to rot. Until now a dialect declaring `announce` delegated nothing at all, so
824
+ * `codex_cli` and `cursor` both had a discriminator whose only effect was to
825
+ * SUPPRESS Claude's glob.
826
+ *
827
+ * ## ⛔ AN ANNOUNCED PATH IS INPUT, AND IS CONFINED EXACTLY AS THE GLOB'S ARE
828
+ *
829
+ * `findSubagentFiles` gates every entry it finds on `isInsideAny` plus the
830
+ * `.jsonl` family test, and this gates on `admitsTranscript` — the same
831
+ * registry-computed boundary plus the same file gate, in one call. ⚠ **Being
832
+ * HANDED a path is not evidence about where it points.** A hook definition that
833
+ * announced `~/.ssh/id_rsa` would otherwise get it read and uploaded under the
834
+ * user's ingest credential, which is `/cso` finding 1 with the agent's help.
835
+ *
836
+ * ## Selected by the CONTAINING ROOT, like everything else since `CR-204`
837
+ *
838
+ * The main transcript's root names the producer, and the producer's dialect
839
+ * says whether it announces. ⛔ Not `ctx.agentId`: no install passes the flag,
840
+ * so a flag-gated reader would read announced paths for `claude-code` — a
841
+ * dialect that declares `discover` and never announces anything — and ignore
842
+ * them for the two that do.
843
+ *
844
+ * ## ⚠ NOT GATED ON THE EVENT NAME, and that is a decision about `D5`
845
+ *
846
+ * The plan's `D5` — the exact `hook_event_name` strings Cursor sends — is
847
+ * founder-blocked and gates `CR-195`. `parseHookInput` falls back to `""` for a
848
+ * non-string, so an unrecognised spelling is not an error but a value that
849
+ * matches nothing; gating here on a guessed `subagentStop` spelling would make
850
+ * this reader silently inert on the day the guess was wrong. The PRESENCE of
851
+ * the field is the signal, and it is one the client can verify.
852
+ *
853
+ * ## ⚠ `M0.8` IS UNMEASURED, NOT ABSENT
854
+ *
855
+ * Two query forms — a path glob under the root and
856
+ * `find ~/.cursor -type d -name subagents` — return **zero** `subagents`
857
+ * directories on this machine. ⛔ **But no session here has ever spawned a
858
+ * Cursor sub-agent**, so that is *"the producing event has not occurred"*, not
859
+ * *"Cursor does not populate it."* The fixtures below it are therefore REAL IN
860
+ * SHAPE AND SYNTHETIC IN CONTENT, and no cell claims otherwise
861
+ * (`feedback_single_artifact_absence`).
862
+ *
863
+ * Returns at most one file: one `subagentStop`, one announced path. An array
864
+ * rather than a nullable, so the caller's cap, ordering and gap-stamping treat
865
+ * it identically to a globbed one.
866
+ */
867
+ function announcedSubagentFiles(ctx, input) {
868
+ const announced = input.agentTranscriptPath;
869
+ if (announced === undefined)
870
+ return [];
871
+ if (dialectFor(agentForTranscript(ctx.home, ctx.env, input.transcriptPath))
872
+ .delegatedTranscripts !== "announce") {
873
+ return [];
874
+ }
875
+ // ⛔ CONFINEMENT AND THE FILE GATE, over the ANNOUNCED path — not over the
876
+ // main transcript, which was gated separately and says nothing about this one.
877
+ if (!admitsTranscript(ctx.home, ctx.env, announced))
878
+ return [];
879
+ const fileKey = announcedSubagentFileKey(basename(announced));
880
+ if (fileKey === null)
881
+ return [];
882
+ try {
883
+ const stat = statSync(announced);
884
+ if (!stat.isFile() || stat.size === 0)
885
+ return [];
886
+ return [
887
+ { path: announced, fileKey: fileKey, size: stat.size, mtimeMs: stat.mtimeMs },
888
+ ];
889
+ }
890
+ catch {
891
+ // Vanished, or never there. Silent, per the hook contract.
892
+ return [];
893
+ }
894
+ }
895
+ /** Size in bytes, or null if it cannot be read. */
896
+ function fileSize(path) {
897
+ try {
898
+ return statSync(path).size;
899
+ }
900
+ catch {
901
+ return null;
902
+ }
903
+ }
904
+ /** The porcelain record's opening line — `worktree <absolute path>`. */
905
+ const WORKTREE_LINE = "worktree ";
906
+ /**
907
+ * The MAIN work tree of the repository whose common dir this is, or **null**.
908
+ *
909
+ * ## ⛔ `rev-parse --show-toplevel` CANNOT ANSWER THIS, measured
910
+ *
911
+ * `D184 §4` mandates `git -C <common-dir> rev-parse --show-toplevel`. Measured
912
+ * on git 2.39.5 against real repositories, that command exits **128** —
913
+ * *"fatal: this operation must be run in a work tree"* — for **every shape a
914
+ * consent key takes**, because a consent key is always a git DIRECTORY and
915
+ * `--is-inside-work-tree` is `false` inside one:
916
+ *
917
+ * | `-C` | `--show-toplevel` |
918
+ * |---|---|
919
+ * | `<main>/.git` (main clone, and every worktree linked to it) | ⛔ ec 128 |
920
+ * | `<repos>/project.git` (bare) | ⛔ ec 128 |
921
+ *
922
+ * Implemented literally, "a failure means no second root" would fire 100% of
923
+ * the time: the set would be the singleton it is today, forever, and a
924
+ * main-clone file read from a worktree session would come back a marker. The
925
+ * whole change would pass its own tests while doing nothing.
926
+ *
927
+ * ## What answers instead, and why it is not a guess
928
+ *
929
+ * `git worktree list --porcelain` DOES run from a git dir and from a bare
930
+ * directory, and its FIRST record is the main work tree. Bare repositories say
931
+ * so in the record itself — `bare`, with no work tree of their own.
932
+ *
933
+ * ⛔ **The answer is then validated by CONTENT, never trusted by position:**
934
+ * `realpath(<candidate>/.git)` must BE the consented common dir. That check is
935
+ * independently fatal to every wrong answer this could produce — a bare repo
936
+ * (`<repos>/.git` does not exist), a linked worktree returned first by some
937
+ * future git (its `.git` is a FILE, so the realpath is the file, not the common
938
+ * dir), a submodule work tree (same). `D184 §4`'s hazard is what it is for: in a
939
+ * worktree of a bare repo the common dir is `…/repos/project.git`, whose parent
940
+ * is **the folder holding every other repository on the machine**, and a root
941
+ * derived by stripping a suffix would transmit every sibling repo's file
942
+ * contents verbatim.
943
+ *
944
+ * Every failure yields NO SECOND ROOT. Never a fallback, never a parent, never a
945
+ * suffix strip.
946
+ */
947
+ function mainWorkTree(commonDir) {
948
+ const out = gitProbe(commonDir, ["worktree", "list", "--porcelain"]);
949
+ if (out === null)
950
+ return null;
951
+ const record = [];
952
+ for (const line of out.split("\n")) {
953
+ if (line === "")
954
+ break;
955
+ record.push(line);
956
+ }
957
+ // A BARE repository HAS no work tree, and git says so in the record itself.
958
+ if (record.includes("bare"))
959
+ return null;
960
+ const first = record[0];
961
+ if (first === undefined || !first.startsWith(WORKTREE_LINE))
962
+ return null;
963
+ const candidate = first.slice(WORKTREE_LINE.length);
964
+ if (!isAbsolute(candidate))
965
+ return null;
966
+ try {
967
+ return realpathSync(join(candidate, ".git")) === commonDir ? candidate : null;
968
+ }
969
+ catch {
970
+ return null;
971
+ }
972
+ }
973
+ /**
974
+ * ⛔ **The redaction confinement ROOT SET — `D184 §3`.**
975
+ *
976
+ * ```
977
+ * isInsideAny([worktree, mainToplevel], candidate)
978
+ * ```
979
+ *
980
+ * ## Why it is a set, and why the common dir is not in it
981
+ *
982
+ * `D184` moved the consent key to the git common dir so that a session inside a
983
+ * `git worktree` is admitted at all. ⛔ **The confinement root MUST NOT follow
984
+ * it there.** `isInside` realpaths both sides and fail-closes anything it cannot
985
+ * place to *outside* — which here means REDACT — so a root at `<main>/.git`
986
+ * would judge every file of an external worktree to be outside the main clone
987
+ * and replace its `content`, `oldString`, `newString` and `structuredPatch` with
988
+ * markers. **The capture still succeeds. The turn counts still look right. The
989
+ * content is gone**, and a test asserting *"worktree sessions capture now"*
990
+ * passes on an empty payload.
991
+ *
992
+ * Two roots, because both are genuinely the user's own tree: the worktree the
993
+ * session is running in, and the main clone it was cut from — a builder in a
994
+ * worktree reads the main clone's files constantly.
995
+ *
996
+ * ## ⛔ The `paths.ts:487-489` prohibition, answered — `D184 §5`
997
+ *
998
+ * > *"`roots` comes from the REGISTRY. It must never be derived from input."*
999
+ *
1000
+ * **That prohibition is about PROVENANCE, and both members have the right one.**
1001
+ * The named hazard is *anything on hook stdin* — attacker-supplied. Neither of
1002
+ * these is: `keys` can only be produced by `resolveProjectKeys`, which is git's
1003
+ * own answer about the directory the session runs in; `mainToplevel` is git's
1004
+ * answer about the repository **the consent gate already approved**, and it is
1005
+ * content-validated by `mainWorkTree` above. The worktree root has exactly the
1006
+ * same provenance and has been used this way since `CR-024d`.
1007
+ *
1008
+ * ⚠ **Provenance is the whole argument, so this code preserves it:** nothing
1009
+ * here is read from stdin, the derivation is validated by content, and a failure
1010
+ * yields no second root rather than a fallback. An empty set would refuse
1011
+ * everything (`paths.ts:491-492`), which is the correct direction — but this
1012
+ * function cannot return one, because `keys.worktree` is always a member.
1013
+ *
1014
+ * ⚠ The dedup is cosmetic. `isInsideAny` over a repeated root is a union with
1015
+ * itself; it is dropped so that a main-clone session reads as the single root it
1016
+ * has always had, and so a cell asserting "no second root" is asserting
1017
+ * something.
1018
+ */
1019
+ export function confinementRoots(keys) {
1020
+ const main = mainWorkTree(keys.consent);
1021
+ return main === null || main === keys.worktree ? [keys.worktree] : [keys.worktree, main];
1022
+ }
1023
+ /**
1024
+ * Read `[from, to)` of the transcript, REDACT it, and compress it.
1025
+ *
1026
+ * The path is already confined to the Claude Code transcript root by the caller
1027
+ * (`/cso` finding 1) — this function must never be handed one that is not.
1028
+ * Returns null on any failure, because a hook cannot report a read error.
1029
+ *
1030
+ * ## The one chokepoint
1031
+ *
1032
+ * This is the single place transcript bytes become a request body, which is why
1033
+ * `CR-024d`'s redaction goes HERE and nowhere else — the same discipline
1034
+ * `post.ts` applies to `expose()`: one call site, greppable, reviewable. Any
1035
+ * future path that reads transcript bytes for upload must come through here or
1036
+ * it silently bypasses the confidentiality boundary.
1037
+ *
1038
+ * `redactSpan` is length-preserving BY CONTRACT (see `redact.ts`), which the
1039
+ * server's offset accounting depends on — so the compressed body still covers
1040
+ * exactly `[from, to)`.
1041
+ *
1042
+ * ## ⛔ The SCRUBBER is chosen HERE, from the path, and never from `ctx.agentId`
1043
+ *
1044
+ * `CR-185`, D164 §5. This function knows the file it is about to read, so it is
1045
+ * the one place that can answer *which registry root contains it* — and a
1046
+ * caller therefore cannot pick the wrong profile for a file it hands over. The
1047
+ * flag is not consulted: both no-flag install paths resolve to `claude-code`,
1048
+ * and running Claude's key set over a Codex record redacts NOTHING.
1049
+ *
1050
+ * ⚠ **`home` and `env` are here for that lookup and nothing else.** They are
1051
+ * passed rather than read from the process for the reason `paths.ts` states: a
1052
+ * module that reads the real HOME cannot be tested without touching the
1053
+ * developer's own transcripts.
1054
+ *
1055
+ * ## ⛔ `projectRoots` is a SET, and the consent key is not in it
1056
+ *
1057
+ * `D184 §3`. It was one string — the consented toplevel — while the consent key
1058
+ * and the confinement boundary were the same value. They are not any more:
1059
+ * `confinementRoots` above builds `[worktree, mainToplevel]` and the git common
1060
+ * dir the consent gate approved is deliberately absent from it. A single-element
1061
+ * set is the ordinary main-clone case and behaves exactly as the old parameter
1062
+ * did.
1063
+ *
1064
+ * EXPORTED for `connect`'s test capture (`CR-025`), which reads a transcript on
1065
+ * a path the hook never takes. It reuses this rather than opening one itself,
1066
+ * because a second reader would bypass both the confinement and the redaction —
1067
+ * exactly what the chokepoint note above warns about.
1068
+ */
1069
+ export function readSpan(path, from, to, projectRoots, home, env) {
1070
+ let fd;
1071
+ try {
1072
+ fd = openSync(path, "r");
1073
+ const buf = Buffer.alloc(to - from);
1074
+ const read = readSync(fd, buf, 0, buf.length, from);
1075
+ const raw = read === buf.length ? buf : buf.subarray(0, read);
1076
+ return zstdCompressSync(redactSpan(raw, projectRoots, rulesForTranscript(home, env, path)).bytes);
1077
+ }
1078
+ catch {
1079
+ return null;
1080
+ }
1081
+ finally {
1082
+ if (fd !== undefined) {
1083
+ try {
1084
+ closeSync(fd);
1085
+ }
1086
+ catch {
1087
+ // Nothing useful to do, and nothing may be said.
1088
+ }
1089
+ }
1090
+ }
1091
+ }
1092
+ //# sourceMappingURL=entry.js.map