@videlic/connect 0.1.2 → 0.1.7

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.
package/hook.mjs CHANGED
@@ -2,27 +2,55 @@
2
2
  // Managed by @videlic/connect — do not edit by hand.
3
3
  //
4
4
  // Runs as a Claude Code Stop hook (`node videlic-hook.mjs`). Reads the stop
5
- // event JSON on stdin, resolves the transcript, attaches git metadata, and
6
- // POSTs to Videlic's /v1/ingest. Cross-platform: pure Node, no bash/jq/curl.
5
+ // event JSON on stdin, resolves the transcript, attaches git metadata, asks
6
+ // `gh` which pull request the branch is on, and POSTs to Videlic's
7
+ // /v1/ingest. Cross-platform: pure Node, no bash/jq/curl.
7
8
  //
8
9
  // Resilient, like the original shell hook:
9
10
  // • skips sessions that predate install (consent guard),
10
11
  // • tail-truncates transcripts over 8 MB so edge gateways don't 413,
11
12
  // • retries 5xx up to 3× with backoff; gives up on 4xx,
12
- // • logs HTTP code + body to ~/.claude/videlic-hook.log on failure.
13
+ // • logs HTTP code + body to ~/.claude/videlic-hook.log on failure,
14
+ // • never lets a `gh` that is missing, signed out or offline cost the
15
+ // session: the body still goes, carrying `pr: null`.
16
+ //
17
+ // One line goes back to the terminal on every stop (`systemMessage`): the
18
+ // link to what was captured, or the one honest sentence about why we could
19
+ // not name the pull request.
13
20
 
14
21
  import { homedir } from "node:os";
15
22
  import { join } from "node:path";
23
+ import { createHash } from "node:crypto";
16
24
  import {
25
+ accessSync,
26
+ constants,
17
27
  existsSync,
18
28
  readFileSync,
29
+ readdirSync,
30
+ mkdirSync,
19
31
  statSync,
20
32
  openSync,
21
33
  readSync,
22
34
  closeSync,
23
35
  appendFileSync,
36
+ writeFileSync,
37
+ unlinkSync,
24
38
  } from "node:fs";
25
- import { execFileSync } from "node:child_process";
39
+ import { execFileSync, spawn } from "node:child_process";
40
+
41
+ /**
42
+ * The version of the hook that COMPOSED this body — not what was installed.
43
+ * It lives here, in the file that does the composing, because that is the
44
+ * only claim it can honestly make: `install.mjs` copies this file verbatim
45
+ * (`copyFileSync`), so a machine can be running a hook months older than the
46
+ * package that put it there, and a version read from `package.json` at
47
+ * install time would describe the installer instead of the sender.
48
+ *
49
+ * `hook.test.mjs` holds it equal to `package.json`, so publishing a changed
50
+ * hook under an unchanged version fails the test rather than the fleet
51
+ * measurement.
52
+ */
53
+ const CLIENT_VERSION = "0.1.7";
26
54
 
27
55
  const claudeDir = join(homedir(), ".claude");
28
56
  const logPath = join(claudeDir, "videlic-hook.log");
@@ -36,6 +64,138 @@ const log = (m) => {
36
64
 
37
65
  const MAX_BYTES = 8_000_000;
38
66
 
67
+ /**
68
+ * The server's own shape for a client-reported pull request
69
+ * (`ingest.ts` `clientPrSchema`), mirrored here on purpose.
70
+ *
71
+ * Zod rejects the WHOLE body when one of these is off — a GitHub Enterprise
72
+ * URL, a SHA-256 head, a `state` word we did not expect — and a 400 loses the
73
+ * session, not just the pull request. So everything `gh` answers is measured
74
+ * against the server's limits before it is allowed near the wire, and
75
+ * anything that does not fit becomes `pr: null`: the capture always survives.
76
+ */
77
+ const PR_URL = /^https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/pull\/(\d+)\/?$/;
78
+ const PR_SHA = /^[a-f0-9]{7,64}$/;
79
+ const PR_STATES = new Set(["OPEN", "CLOSED", "MERGED"]);
80
+ const PR_URL_MAX = 500;
81
+ const PR_REPO_MAX = 200;
82
+ const PR_BASE_BRANCH_MAX = 256;
83
+
84
+ /**
85
+ * `gh` has no `baseRepository` field (measured on gh 2.89), so the repository
86
+ * a pull request LIVES in is read off its URL — which is the base repository
87
+ * on a fork pull request too. `headRefName` is here only to check that a pull
88
+ * request found through the session's own `gh pr create` receipt really is
89
+ * this branch's.
90
+ */
91
+ const GH_FIELDS = "number,url,isDraft,state,headRefOid,baseRefName,headRefName,headRepository,headRepositoryOwner";
92
+ /**
93
+ * `gh pr view` measured at 0.53-0.65 s on a warm cache. The ceiling matters
94
+ * because a stop hook HOLDS THE DEVELOPER'S TERMINAL while it runs, and that —
95
+ * not the agent's own patience — is the budget worth defending: a `gh` whose
96
+ * HTTPS call blackholes (captive portal, VPN drop, proxy) would otherwise eat
97
+ * the time the upload needs.
98
+ *
99
+ * The agent's patience was written here as 60 s and that was wrong. Measured
100
+ * on Claude Code 2.1.220, two ways that agree: a hook that sleeps 65 s runs to
101
+ * completion, one that sleeps 620 s is killed at 601 s, and the binary's own
102
+ * constant is 600 000 ms for `Stop`/`SubagentStop` alike. A per-hook `timeout`
103
+ * in `settings.json` overrides it and is in SECONDS — a hook declared
104
+ * `timeout: 5` was killed at 5 s. (`SessionEnd` is the exception and is not
105
+ * ours: 1 500 ms by default, capped at 60 000 ms.)
106
+ *
107
+ * Ten minutes of rope is not permission to take it. The number that bounds
108
+ * this file is the terminal's, and it stays where it is.
109
+ */
110
+ const GH_TIMEOUT_MS = 12_000;
111
+
112
+ /**
113
+ * The environment `gh` is asked in. Three of these repair an answer the hook
114
+ * could not read, and one takes back a question it never meant to ask.
115
+ *
116
+ * `CLICOLOR_FORCE` is the one that bites: on gh 2.89 it beats BOTH `NO_COLOR`
117
+ * and `GH_NO_COLOR`, and `--json` output then arrives as pretty-printed ANSI
118
+ * that `JSON.parse` throws on — measured. A developer with `CLICOLOR_FORCE=1`
119
+ * in their shell profile (a common line for forcing colour through pipes)
120
+ * would silently never have a pull request found, on any stop, forever.
121
+ *
122
+ * `GH_REPO` is deleted, not overridden: it makes gh answer about a repository
123
+ * the session was never in (measured), and the whole point of this call is
124
+ * "the pull request of THIS branch, in THIS clone".
125
+ */
126
+ function ghEnv() {
127
+ const env = {
128
+ ...process.env,
129
+ GH_PAGER: "",
130
+ GH_PROMPT_DISABLED: "1",
131
+ GH_NO_UPDATE_NOTIFIER: "1",
132
+ NO_COLOR: "1",
133
+ GH_NO_COLOR: "1",
134
+ CLICOLOR_FORCE: "0",
135
+ FORCE_COLOR: "0",
136
+ };
137
+ delete env.GH_REPO;
138
+ return env;
139
+ }
140
+
141
+ /**
142
+ * The two provenances this client can stamp (`PR_SOURCES` on the server).
143
+ * Named once so the wire test can hold them against the server's enum
144
+ * instead of against a string typed twice.
145
+ */
146
+ const PR_SOURCE = { view: "gh-pr-view", receipt: "gh-pr-create-output" };
147
+
148
+ /**
149
+ * The two provenances the CATCH-UP can stamp (`CATCH_UP_PR_SOURCES` on the
150
+ * server). A separate map from `PR_SOURCE` on purpose: these describe a
151
+ * question asked on a LATER stop, about a session that already uploaded, and
152
+ * `/v1/ingest` must refuse them exactly as `/v1/catch-up` refuses the other
153
+ * three. The wire test holds both enums apart in both directions.
154
+ */
155
+ const CATCH_UP_SOURCE = { sha: "gh-pr-list-sha", again: "gh-pr-view-again" };
156
+
157
+ /**
158
+ * The absolute path of a tool, resolved from PATH here rather than handed to
159
+ * the OS as a bare name.
160
+ *
161
+ * On Windows libuv looks for the executable in the CHILD's working directory
162
+ * before it scans PATH. This hook runs `gh` with the repository root as that
163
+ * directory, and `git` with whatever directory the agent started it in — so a
164
+ * file named `gh.exe` or `git.exe` committed to a repository would be what
165
+ * runs, at the end of every session, on the machine of everyone who cloned
166
+ * it. Resolving the name ourselves takes that decision away from the
167
+ * directory. Only real executables count: `.cmd` and `.bat` shims cannot be
168
+ * spawned without a shell, and this hook never runs one.
169
+ */
170
+ function resolveTool(name) {
171
+ const isWindows = process.platform === "win32";
172
+ const dirs = (process.env.PATH || "").split(isWindows ? ";" : ":");
173
+ const exts = isWindows ? [".exe", ".com"] : [""];
174
+ for (const raw of dirs) {
175
+ const dir = raw.replace(/^"|"$/g, "").trim();
176
+ if (!dir) continue;
177
+ for (const ext of exts) {
178
+ const candidate = join(dir, `${name}${ext}`);
179
+ try {
180
+ // Executable, not merely present: `execvp` — which is what a bare
181
+ // name used to be resolved by — remembers a file it may not run and
182
+ // keeps scanning. A mode-644 `git` left in an early PATH entry (a zip
183
+ // extraction, a COPY without the bit) must not become a hard stop
184
+ // where the developer's own shell walks straight past it.
185
+ if (statSync(candidate).isFile()) {
186
+ accessSync(candidate, constants.X_OK);
187
+ return candidate;
188
+ }
189
+ } catch {
190
+ /* gone, unreadable, a directory, or not ours to run — keep looking */
191
+ }
192
+ }
193
+ }
194
+ return null;
195
+ }
196
+ /** `terminal` surface profile (spec §4.7): one line, no paths, no quotes, lead capped. */
197
+ const TERMINAL_LEAD_CAP = 100;
198
+
39
199
  function readConfig() {
40
200
  const p = join(claudeDir, "videlic.json");
41
201
  if (!existsSync(p)) return null;
@@ -86,11 +246,17 @@ function readTail(path, maxBytes) {
86
246
  }
87
247
 
88
248
  function gitMeta(cwd) {
89
- const out = { branch: "", commitSha: "", repoUrl: "" };
249
+ const out = { branch: "", commitSha: "", repoUrl: "", root: "" };
90
250
  if (!cwd) return out;
251
+ // No fallback to the bare name: handing "git" to the OS is the Windows
252
+ // cwd-search hazard this resolver exists to close, and a `git` that PATH
253
+ // cannot name is one `execvp` could not have found either. Without it the
254
+ // session still uploads — it simply carries no branch.
255
+ const gitBin = resolveTool("git");
256
+ if (!gitBin) return out;
91
257
  const git = (args) => {
92
258
  try {
93
- return execFileSync("git", ["-C", cwd, ...args], {
259
+ return execFileSync(gitBin, ["-C", cwd, ...args], {
94
260
  stdio: ["ignore", "pipe", "ignore"],
95
261
  })
96
262
  .toString()
@@ -103,10 +269,1188 @@ function gitMeta(cwd) {
103
269
  out.branch = git(["rev-parse", "--abbrev-ref", "HEAD"]);
104
270
  out.commitSha = git(["rev-parse", "HEAD"]);
105
271
  out.repoUrl = git(["config", "--get", "remote.origin.url"]);
272
+ // The working tree's root, not the agent's cwd: a session that ran in a
273
+ // subdirectory is still the same repository, and every later command
274
+ // should be asked from one place.
275
+ out.root = git(["rev-parse", "--show-toplevel"]) || cwd;
106
276
  }
107
277
  return out;
108
278
  }
109
279
 
280
+ /**
281
+ * What went wrong when `gh` did not answer with a pull request. Exit codes
282
+ * alone cannot tell these apart — everything but "not authenticated" (4)
283
+ * exits 1 — so the text `gh` printed decides, and anything unrecognised stays
284
+ * `gh-failed` rather than being read as "there is no pull request".
285
+ */
286
+ function ghFailureKind(err) {
287
+ if (err?.code === "ENOENT") return "gh-missing";
288
+ if (err?.signal || err?.killed) return "timeout";
289
+ const text = `${err?.stderr ?? ""}${err?.stdout ?? ""}${err?.message ?? ""}`;
290
+ if (/no pull requests found/i.test(text)) return "no-pr";
291
+ // Measured on gh 2.89: "none of the git remotes configured for this
292
+ // repository point to a known GitHub host. To tell gh about a new GitHub
293
+ // host, please use `gh auth login`" — the words of the auth test are inside
294
+ // the message for a repository that is simply not on GitHub, and reading it
295
+ // as "signed out" told an authenticated developer to log in on every stop.
296
+ if (/point to a known GitHub host|no git remotes found/i.test(text)) return "not-github";
297
+ if (/not on any branch|could not determine current branch/i.test(text)) return "no-branch";
298
+ if (/not a git repository/i.test(text)) return "no-repo";
299
+ if (err?.status === 4 || /gh auth login|HTTP 401/i.test(text)) return "gh-signed-out";
300
+ if (
301
+ /dial tcp|no such host|connection refused|network is unreachable|i\/o timeout|TLS handshake|context deadline exceeded|EOF$/im.test(
302
+ text,
303
+ )
304
+ ) {
305
+ return "offline";
306
+ }
307
+ return "gh-failed";
308
+ }
309
+
310
+ /**
311
+ * Runs one `gh` subcommand in `dir`, asking for `GH_FIELDS`, and parses its
312
+ * JSON. Never throws.
313
+ *
314
+ * One body for both questions this hook asks. `gh pr view` and `gh pr list`
315
+ * differ only in the words before `--json`, and everything around them —
316
+ * resolving the executable off PATH ourselves, the environment repairs in
317
+ * `ghEnv()`, the timeout, reading stderr so `ghFailureKind` has text to read —
318
+ * is the part that must never drift between the two. A second copy is how
319
+ * `CLICOLOR_FORCE=0` ends up on one spawn and not the other.
320
+ */
321
+ function ghJson(dir, args, timeoutMs = GH_TIMEOUT_MS) {
322
+ const gh = resolveTool("gh");
323
+ if (!gh) return { ok: false, kind: "gh-missing" };
324
+ let raw;
325
+ try {
326
+ raw = execFileSync(gh, [...args, "--json", GH_FIELDS], {
327
+ cwd: dir,
328
+ stdio: ["ignore", "pipe", "pipe"],
329
+ timeout: timeoutMs,
330
+ encoding: "utf8",
331
+ env: ghEnv(),
332
+ });
333
+ } catch (err) {
334
+ return { ok: false, kind: ghFailureKind(err) };
335
+ }
336
+ try {
337
+ return { ok: true, json: JSON.parse(raw) };
338
+ } catch {
339
+ return { ok: false, kind: "gh-failed" };
340
+ }
341
+ }
342
+
343
+ /** `gh pr view` — one pull request, by branch or by number. */
344
+ function ghPrView(dir, args, timeoutMs = GH_TIMEOUT_MS) {
345
+ return ghJson(dir, ["pr", "view", ...args], timeoutMs);
346
+ }
347
+
348
+ /**
349
+ * `gh pr list --search` — every pull request GitHub matches, as an array.
350
+ *
351
+ * Two things measured on gh 2.89 shape this call, and both are written out
352
+ * rather than left to a default:
353
+ *
354
+ * `--state all`, because the default is `open`: without it a pull request
355
+ * merged between two stops reads as "no match", and the ledger entry would
356
+ * wait out its fourteen days instead of closing honestly.
357
+ *
358
+ * `--limit`, so the ceiling is a fact in this file rather than whatever gh
359
+ * ships.
360
+ *
361
+ * And `--repo` is never passed: gh pins the search to the base repository it
362
+ * resolved from the remotes, and overriding that is how a question about THIS
363
+ * clone becomes a question about someone else's.
364
+ *
365
+ * `{ ok: true, list: [] }` — "gh answered, nothing matched" — is a real answer
366
+ * and not a failure; it is what most catch-up asks see.
367
+ */
368
+ function ghPrList(dir, search, limit, timeoutMs = GH_TIMEOUT_MS) {
369
+ const got = ghJson(dir, ["pr", "list", "--state", "all", "--search", search, "--limit", String(limit)], timeoutMs);
370
+ if (!got.ok) return got;
371
+ return { ok: true, list: Array.isArray(got.json) ? got.json : [] };
372
+ }
373
+
374
+ /**
375
+ * `gh pr view --json …` → the body's `pr`, or null when any part of it would
376
+ * not survive the server's schema. Total: every rejection is a `pr: null`,
377
+ * never a thrown hook and never a half-filled object.
378
+ */
379
+ function toWirePr(json) {
380
+ if (!json || typeof json !== "object") return null;
381
+ const url = typeof json.url === "string" ? json.url.trim() : "";
382
+ if (url.length > PR_URL_MAX) return null;
383
+ const m = PR_URL.exec(url);
384
+ if (!m) return null; // not github.com — GitHub Enterprise has no address here yet
385
+ const repo = m[1];
386
+ if (repo.length < 3 || repo.length > PR_REPO_MAX) return null;
387
+ const number = typeof json.number === "number" ? json.number : Number.NaN;
388
+ if (!Number.isInteger(number) || number <= 0 || Number(m[2]) !== number) return null;
389
+ const headSha = typeof json.headRefOid === "string" ? json.headRefOid.trim().toLowerCase() : "";
390
+ if (!PR_SHA.test(headSha)) return null;
391
+ const state = typeof json.state === "string" ? json.state.trim() : "";
392
+ if (!PR_STATES.has(state)) return null;
393
+ if (typeof json.isDraft !== "boolean") return null;
394
+ const pr = {
395
+ number,
396
+ url: url.replace(/\/$/, ""),
397
+ draft: json.isDraft,
398
+ state,
399
+ headSha,
400
+ repo,
401
+ };
402
+ const baseBranch = typeof json.baseRefName === "string" ? json.baseRefName.trim() : "";
403
+ // Optional on the wire: send it only when it is a branch name we actually read.
404
+ if (baseBranch && baseBranch.length <= PR_BASE_BRANCH_MAX) pr.baseBranch = baseBranch;
405
+ return pr;
406
+ }
407
+
408
+ /**
409
+ * The one pull request this session's own transcript proves exists: the
410
+ * receipt Claude Code files for a `gh pr create` that succeeded
411
+ * (`toolUseResult.gitOperation.pr`), or, for clients that file no receipt,
412
+ * the URL such a command printed on stdout.
413
+ *
414
+ * Owner decision D25: a number may come from `gh pr view` or from this
415
+ * session's `gh pr create` — never from a URL someone merely wrote. Measured
416
+ * on 110 local transcripts: 71 name a pull request, 70 of them through a
417
+ * receipt; the single prose-only URL was a pull request in a DIFFERENT
418
+ * repository quoted in a user's message, which is exactly the wrong link this
419
+ * rule refuses to make.
420
+ *
421
+ * `action` must be `created`: the same receipt shape is filed for
422
+ * `gh pr close` and `gh pr edit` (1 and 2 of the 23 in that corpus), and
423
+ * those say nothing about the branch this stop is on.
424
+ */
425
+ function prFromSessionReceipt(sessionData) {
426
+ if (typeof sessionData !== "string" || !sessionData) return null;
427
+ const commands = new Map();
428
+ let fromReceipt = null;
429
+ let fromStdout = null;
430
+ for (const line of sessionData.split("\n")) {
431
+ // Cheap prefilter: JSON.parse over an 8 MB transcript is the expensive
432
+ // part, and only these three kinds of line can carry a receipt.
433
+ if (
434
+ !line ||
435
+ (!line.includes('"gitOperation"') && !line.includes("/pull/") && !line.includes("pr create"))
436
+ ) {
437
+ continue;
438
+ }
439
+ let entry;
440
+ try {
441
+ entry = JSON.parse(line);
442
+ } catch {
443
+ continue; // a tail-truncated transcript starts mid-object
444
+ }
445
+ const receipt = entry?.toolUseResult?.gitOperation?.pr;
446
+ if (receipt && receipt.action === "created") {
447
+ const parsed = parsePrUrl(receipt.url);
448
+ if (parsed && (typeof receipt.number !== "number" || receipt.number === parsed.number)) {
449
+ fromReceipt = parsed;
450
+ }
451
+ }
452
+ const content = entry?.message?.content;
453
+ if (!Array.isArray(content)) continue;
454
+ for (const block of content) {
455
+ if (block?.type === "tool_use" && typeof block.id === "string") {
456
+ const command = typeof block.input?.command === "string" ? block.input.command : "";
457
+ if (command) commands.set(block.id, command);
458
+ }
459
+ if (block?.type === "tool_result" && block.is_error !== true) {
460
+ if (!invokesPrCreate(commands.get(block.tool_use_id) || "")) continue;
461
+ const text =
462
+ typeof block.content === "string"
463
+ ? block.content
464
+ : Array.isArray(block.content)
465
+ ? block.content.map((c) => (typeof c?.text === "string" ? c.text : "")).join("\n")
466
+ : "";
467
+ // A successful `gh pr create` prints ONE address and nothing else.
468
+ // Anything longer is a command that did more than create — a list, a
469
+ // loop that opened several, a script that printed a link it read
470
+ // somewhere — and a number taken from the first URL in such output is
471
+ // exactly the borrowed link this rule exists to refuse.
472
+ const printed = parsePrUrl(text.trim());
473
+ if (printed) fromStdout = printed;
474
+ }
475
+ }
476
+ }
477
+ // The structured receipt is Claude Code's own record of what the command
478
+ // did; the printed line is a reading of what it said. When both exist they
479
+ // are on the SAME transcript entry, so the reading must never be allowed to
480
+ // overwrite the record.
481
+ return fromReceipt ?? fromStdout;
482
+ }
483
+
484
+ /**
485
+ * Whether a Bash command actually RUNS `gh pr create`, rather than merely
486
+ * containing those words.
487
+ *
488
+ * A substring test matched `grep -rn "gh pr create" …`, a `node -e` scan over
489
+ * transcripts, and a heredoc writing this very file — all of which print pull
490
+ * request URLs that belong to someone else's work. So each segment of the
491
+ * command line is checked for `gh` in the position a command name occupies,
492
+ * with `pr create` among its arguments.
493
+ */
494
+ function invokesPrCreate(command, depth = 0) {
495
+ if (!command || depth > 2) return false;
496
+ // `$(…)` and backticks open a new command; a quote does not, or
497
+ // `grep "gh pr create"` would read as an invocation of gh.
498
+ for (const segment of command.split(/\n|&&|\|\||;|\||\$\(|`/)) {
499
+ const tokens = segment.trim().split(/\s+/).filter(Boolean);
500
+ let i = 0;
501
+ // Leading environment assignments and the wrappers that keep a command a
502
+ // command: `GH_TOKEN=… sudo command gh pr create …`.
503
+ while (i < tokens.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]) || /^(sudo|command|env)$/.test(tokens[i]))) i++;
504
+ const name = (tokens[i] ?? "").replace(/^["']|["']$/g, "");
505
+ if (!name) continue;
506
+ // A shell told to run a command line: look inside the line, not at the
507
+ // shell. `grep`, `rg` and `node -e` are not shells, so what they were
508
+ // handed stays a string.
509
+ if (/(^|[\\/])(ba|z|k|da)?sh$/.test(name)) {
510
+ const flag = tokens.indexOf("-c", i) >= 0 ? tokens.indexOf("-c", i) : tokens.findIndex((t, j) => j > i && /^-[a-z]*c$/.test(t));
511
+ if (flag > 0 && invokesPrCreate(tokens.slice(flag + 1).join(" ").replace(/^["']|["']$/g, ""), depth + 1)) return true;
512
+ continue;
513
+ }
514
+ if (!/(^|[\\/])gh(\.exe)?$/.test(name)) continue;
515
+ // `pr` next to `create`, so gh's own global flags (`gh --repo x pr
516
+ // create`) do not hide the subcommand.
517
+ const args = tokens.slice(i + 1).filter((t) => !t.startsWith("-"));
518
+ if (args.some((t, j) => t === "pr" && args[j + 1] === "create")) return true;
519
+ }
520
+ return false;
521
+ }
522
+
523
+ function parsePrUrl(url) {
524
+ if (typeof url !== "string") return null;
525
+ const m = PR_URL.exec(url.trim());
526
+ if (!m) return null;
527
+ return { number: Number(m[2]), repo: m[1] };
528
+ }
529
+
530
+ /**
531
+ * Whether GitHub says this pull request's head IS the branch the stop is on.
532
+ *
533
+ * The local branch NAME, and nothing else. An earlier version also accepted
534
+ * `branch.<name>.merge`, thinking of a branch pushed under another name — but
535
+ * that config holds the branch's UPSTREAM, which for `git checkout -b next
536
+ * origin/parent` is the parent. It would have let a session on a fresh branch
537
+ * be filed under the pull request of the branch it forked from.
538
+ */
539
+ function headIsThisBranch(json, branch) {
540
+ const head = typeof json?.headRefName === "string" ? json.headRefName : "";
541
+ return !!head && head === branch;
542
+ }
543
+
544
+ /** `owner/name` of a git remote, however it is spelled. */
545
+ function repoFromRemote(url) {
546
+ if (typeof url !== "string") return "";
547
+ const m = /[:/]([^/\s:]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(url.trim());
548
+ return m ? `${m[1]}/${m[2]}` : "";
549
+ }
550
+
551
+ /**
552
+ * Where GitHub says this pull request's head lives, compared with the clone
553
+ * this stop happened in: `"yes"`, `"no"`, or `"cannot-tell"`.
554
+ *
555
+ * The branch NAME alone is not enough: a session that opened a pull request
556
+ * in one repository and stopped in another, on a branch of a name both share
557
+ * (`main`, `dev`, `patch-1`), would file its work under the other's number.
558
+ * On a fork pull request the base repository is the parent and the head is
559
+ * the fork — the clone — so this compares the head, not the base.
560
+ *
561
+ * `"cannot-tell"` is a real third answer and is kept apart from `"yes"` on
562
+ * purpose. This clone's own name is read from `remote.origin.url` alone, and a
563
+ * checkout made as `git clone -o upstream …` has no `origin` at all — so the
564
+ * comparison has nothing on OUR side, not merely nothing on GitHub's. A caller
565
+ * that treats that as "yes" is claiming ownership it never established; the
566
+ * catch-up's search path therefore demands the commit proof outright when the
567
+ * answer is `"cannot-tell"`, instead of accepting an abstention from it too.
568
+ */
569
+ function headClone(json, repoUrl) {
570
+ const mine = repoFromRemote(repoUrl);
571
+ const head =
572
+ typeof json?.headRepository?.nameWithOwner === "string"
573
+ ? json.headRepository.nameWithOwner
574
+ : typeof json?.headRepositoryOwner?.login === "string" && typeof json?.headRepository?.name === "string"
575
+ ? `${json.headRepositoryOwner.login}/${json.headRepository.name}`
576
+ : "";
577
+ if (!mine || !head) return "cannot-tell";
578
+ return mine.toLowerCase() === head.toLowerCase() ? "yes" : "no";
579
+ }
580
+
581
+ /**
582
+ * The receipt path's question: is this pull request's head NOT this clone's?
583
+ * Abstains towards accepting, because there the pull request was named by the
584
+ * session's own `gh pr create` and the clone check is corroboration.
585
+ */
586
+ function headIsInThisClone(json, repoUrl) {
587
+ return headClone(json, repoUrl) !== "no";
588
+ }
589
+
590
+ /**
591
+ * Which pull request this session is on, and how we learned it.
592
+ *
593
+ * `gh pr view` first, because it answers about the branch as it is NOW —
594
+ * draft flipped, closed, merged, head moved. Measured on a fork clone with
595
+ * two remotes (`origin` = fork, `upstream` = parent) and no
596
+ * `gh repo set-default`: gh picks the base repository by REMOTE NAME
597
+ * (`upstream` > `github` > `origin`), without prompting and without an error,
598
+ * so a pull request that lives in the fork becomes invisible to the branch
599
+ * query. That measured hole is the one the session's own `gh pr create`
600
+ * receipt closes — and it closes it by asking `gh` again, by number, so
601
+ * `draft`, `state` and `headSha` are still GitHub's answer and never a guess
602
+ * carried over from when the command ran.
603
+ *
604
+ * ONE failure earns that second question: "no pull request for this branch in
605
+ * the repository I picked". Every other answer — gh missing, signed out,
606
+ * unreachable, timed out, unreadable — would fail again the same way, and the
607
+ * stop hook would pay the same wait twice for nothing: two blocking calls of
608
+ * ${GH_TIMEOUT_MS} ms each, in front of an upload the agent gives 60 s to.
609
+ */
610
+ function findPullRequest(dir, branch, repoUrl, readSessionData) {
611
+ if (!dir || !branch || branch === "HEAD") return { pr: null, source: null, gap: "no-branch" };
612
+
613
+ const started = Date.now();
614
+ const viewed = ghPrView(dir, []);
615
+ if (viewed.ok) {
616
+ const pr = toWirePr(viewed.json);
617
+ if (pr) return { pr, source: PR_SOURCE.view, gap: null };
618
+ return { pr: null, source: null, gap: "unusable" };
619
+ }
620
+ if (viewed.kind !== "no-pr") return { pr: null, source: null, gap: viewed.kind };
621
+
622
+ const receipt = prFromSessionReceipt(readSessionData());
623
+ if (!receipt) return { pr: null, source: null, gap: viewed.kind };
624
+
625
+ // The host is pinned to the one the receipt's address has: `--repo
626
+ // owner/name` alone would be resolved against GH_HOST, and a repository of
627
+ // the same name on another host is not this one.
628
+ const left = Math.max(2_000, GH_TIMEOUT_MS - (Date.now() - started));
629
+ const byNumber = ghPrView(dir, [String(receipt.number), "--repo", `github.com/${receipt.repo}`], left);
630
+ if (!byNumber.ok) return { pr: null, source: null, gap: viewed.kind };
631
+ const pr = toWirePr(byNumber.json);
632
+ if (!pr || pr.number !== receipt.number || pr.repo !== receipt.repo) {
633
+ return { pr: null, source: null, gap: viewed.kind };
634
+ }
635
+ // The receipt names a pull request, not a branch: attach it only when
636
+ // GitHub says its head is the branch this stop is on. A session that opened
637
+ // one and moved on would otherwise file its work under the other's number.
638
+ if (!headIsThisBranch(byNumber.json, branch) || !headIsInThisClone(byNumber.json, repoUrl)) {
639
+ return { pr: null, source: null, gap: "receipt-other-branch" };
640
+ }
641
+ return { pr, source: PR_SOURCE.receipt, gap: null };
642
+ }
643
+
644
+ /**
645
+ * Does this pull request ask for a review? The client's mirror of the server's
646
+ * `shouldAnalyzeFromIngest` — OPEN and not a draft, and nothing else.
647
+ *
648
+ * Named, rather than written out at each of the three places that need it,
649
+ * because it is a mirror: an unnamed copy of somebody else's rule is the thing
650
+ * that drifts. The server still asks its own question on every path; this one
651
+ * only decides whether the hook is going to bother asking.
652
+ */
653
+ function asksForReview(pr) {
654
+ return !!pr && pr.draft === false && pr.state === "OPEN";
655
+ }
656
+
657
+ /**
658
+ * Does the pull request whose head is `headSha` actually CONTAIN `sha`?
659
+ *
660
+ * `gh pr list --search` is free text, not a `sha:` qualifier — measured: a
661
+ * search for `ENG-82` returns the pull request whose TITLE says so, and a
662
+ * forty-hex sha quoted in a title or a body matches exactly the same way. So
663
+ * the search is a candidate generator and never a proof, and this is the
664
+ * proof, taken from the clone we are standing in.
665
+ *
666
+ * `git merge-base --is-ancestor A B` — measured in this repository: 0 when A
667
+ * is an ancestor of B (a commit is its own ancestor, so 0 for A === B), 1 when
668
+ * it is not, 128 when either object is missing locally. Only 1 is a refusal.
669
+ * 128 is an ABSTENTION — a fork's head, or a branch this clone never fetched,
670
+ * is not a wrong answer, it is no answer — and the caller keeps whatever the
671
+ * other guards decided rather than treating "I cannot see it" as "it is not
672
+ * there".
673
+ */
674
+ function containsCommit(dir, sha, headSha) {
675
+ const gitBin = resolveTool("git");
676
+ if (!gitBin || !dir || !sha || !headSha) return null;
677
+ try {
678
+ execFileSync(gitBin, ["-C", dir, "merge-base", "--is-ancestor", sha, headSha], {
679
+ stdio: ["ignore", "ignore", "ignore"],
680
+ timeout: 5_000,
681
+ });
682
+ return true;
683
+ } catch (err) {
684
+ return err?.status === 1 ? false : null;
685
+ }
686
+ }
687
+
688
+ /**
689
+ * ─────────────────────────── THE CATCH-UP LEDGER ───────────────────────────
690
+ *
691
+ * A session whose stop found no pull request asking for a review leaves one
692
+ * small file here. On any LATER stop in the same repository, the hook asks gh
693
+ * about one of them, and if gh names a pull request that is provably this
694
+ * session's, tells the server (`POST /v1/catch-up`).
695
+ *
696
+ * That covers the three shapes a stop-time answer cannot: a pull request
697
+ * opened by hand afterwards, a draft marked ready later, and a branch re-used
698
+ * for different work — which must never be mixed in, and is not, because the
699
+ * question asked is "which pull request contains MY commit" rather than "what
700
+ * is on this branch now".
701
+ *
702
+ * NO TIMER AND NO DAEMON. The trigger is a later run of this same hook, which
703
+ * is the honest cost of the design: a session in a repository the developer
704
+ * never opens again is never caught up.
705
+ *
706
+ * ONE FILE PER ENTRY, never a shared list. An enrolment is an exclusive
707
+ * create, an update is a whole small overwrite, a close is an unlink — so
708
+ * there is no read-modify-write for two agents stopping at the same second to
709
+ * lose an update in, and no half-written shared file to parse.
710
+ */
711
+ const LEDGER_DIR = join(claudeDir, "videlic-catchup");
712
+ /**
713
+ * An entry may not outlive the row it names: the server hard-deletes a
714
+ * pending, pull-request-less capture after fourteen days
715
+ * (`ORPHAN_CAPTURE_TTL_DAYS`), and past that the claim has no subject.
716
+ */
717
+ const LEDGER_TTL_MS = 14 * 24 * 60 * 60 * 1000;
718
+ /** Across all repositories. Oldest go first when a new one arrives. */
719
+ const LEDGER_MAX_ENTRIES = 64;
720
+ /** Names read in one pass, however many are on disk — a bound on the work, not on the ledger. */
721
+ const LEDGER_SCAN_CAP = 256;
722
+ /** How long one entry waits between asks. */
723
+ const CATCH_UP_COOLDOWN_MS = 10 * 60 * 1000;
724
+ /** One ceiling for the gh call and the POST together — the tail this pass adds to a stop. */
725
+ const CATCH_UP_BUDGET_MS = 12_000;
726
+ /** Written out so the ceiling is a fact in this file rather than whatever gh's default happens to be. */
727
+ const CATCH_UP_SEARCH_LIMIT = 30;
728
+ /**
729
+ * A session on the trunk has no "this branch's pull request" to wait for. An
730
+ * enrolment filter for cost, not a correctness guard — correctness is the head
731
+ * branch check — and the same two names `findPrOwnerOnBranch` already refuses.
732
+ */
733
+ const TRUNK_BRANCHES = new Set(["main", "master"]);
734
+ /**
735
+ * Server answers that can change by asking again, matched by PREFIX.
736
+ *
737
+ * The prefix is not decoration: `reanalyzeSession` templates two of its reasons
738
+ * with the provider's own message (`download-failed: 502 Bad Gateway`), so an
739
+ * exact-match set could never hold them — and a single Supabase Storage blip,
740
+ * which this codebase has measured at 1 upload in 611, would otherwise close
741
+ * the entry for good. That is the worst shape in the whole pass: the route has
742
+ * already settled the row as failed with the Re-analyze control withheld, so
743
+ * the one mechanism that could still have retried would have just deleted
744
+ * itself.
745
+ *
746
+ * `session-not-found` is here for the same reason. This route fetched that row
747
+ * two calls earlier, so a later "not found" is a swallowed database error, not
748
+ * a deleted session — and if the session really was pruned, the entry simply
749
+ * ages out on the fourteen days it shares with the server's own orphan sweep.
750
+ *
751
+ * Everything else the server says about a claim is final, and the entry closes:
752
+ * it is an answer, not a failure to answer.
753
+ */
754
+ const CATCH_UP_TRANSIENT_REASONS = ["storage-disabled", "pull-request-not-ready", "session-not-found", "download-failed"];
755
+
756
+ function isTransientReason(reason) {
757
+ return CATCH_UP_TRANSIENT_REASONS.some((r) => reason === r || reason.startsWith(`${r}:`));
758
+ }
759
+
760
+ /**
761
+ * This stop's own answer from `gh`, when it means gh cannot answer a SECOND
762
+ * question either.
763
+ *
764
+ * `findPullRequest` already refuses to ask gh twice for exactly this reason —
765
+ * "two blocking calls of ${GH_TIMEOUT_MS} ms each, in front of an upload the
766
+ * agent gives 60 s to" — and the catch-up would have re-introduced that second
767
+ * call through a different function. On a machine where github.com blackholes,
768
+ * every stop would have held the terminal for twice the timeout, every ten
769
+ * minutes, for fourteen days.
770
+ *
771
+ * `no-pr`, `no-branch`, `unusable` and `receipt-other-branch` are NOT here:
772
+ * each is an answer ABOUT THIS BRANCH and says nothing about whether gh can
773
+ * answer a question about a commit.
774
+ */
775
+ const GH_CANNOT_ANSWER = new Set(["gh-missing", "gh-signed-out", "offline", "timeout", "gh-failed", "not-github", "no-repo"]);
776
+
777
+ /**
778
+ * One spelling per repository. The root always comes from
779
+ * `git rev-parse --show-toplevel`, never the stop payload's `cwd`, so both
780
+ * sides of the comparison are answered by the same command — on macOS those
781
+ * two differ (`/var/folders/…` against `/private/var/folders/…`) and a
782
+ * repository would alias itself.
783
+ */
784
+ function normalizeRoot(root) {
785
+ let s = String(root).replace(/\\/g, "/").replace(/\/+$/, "");
786
+ // The two platforms whose filesystems are case-insensitive by default.
787
+ if (process.platform === "win32" || process.platform === "darwin") s = s.toLowerCase();
788
+ return s;
789
+ }
790
+
791
+ /** The repository, as a name: the hash, so the ledger's file names leak no directory layout. */
792
+ function rootHash(root) {
793
+ return createHash("sha256").update(normalizeRoot(root)).digest("hex").slice(0, 12);
794
+ }
795
+
796
+ /**
797
+ * One entry per SESSION, so a second pull-request-less stop of the same
798
+ * session refreshes its own entry instead of enrolling a rival carrying a
799
+ * different commit. Identity is always read from the file's CONTENT, never
800
+ * from its name.
801
+ */
802
+ function ledgerPath(root, sessionPublicId) {
803
+ return join(LEDGER_DIR, `${rootHash(root)}-${sessionPublicId}.json`);
804
+ }
805
+
806
+ /** Best-effort, like `log()`: the ledger is a convenience and never a reason to fail a stop. */
807
+ function ledgerWrite(path, entry) {
808
+ try {
809
+ mkdirSync(LEDGER_DIR, { recursive: true, mode: 0o700 });
810
+ writeFileSync(path, JSON.stringify(entry), { mode: 0o600 });
811
+ return true;
812
+ } catch {
813
+ return false;
814
+ }
815
+ }
816
+
817
+ function ledgerClose(path) {
818
+ try {
819
+ unlinkSync(path);
820
+ } catch {
821
+ /* already gone, or a directory we may not write — either way there is nothing to do */
822
+ }
823
+ }
824
+
825
+ function ledgerRead(path) {
826
+ try {
827
+ const entry = JSON.parse(readFileSync(path, "utf8"));
828
+ // A file we cannot recognise is not an entry. Anything that reached this
829
+ // directory by another route must never become a claim.
830
+ if (entry?.v !== 1 || typeof entry.session !== "string" || typeof entry.branch !== "string") return null;
831
+ if (typeof entry.at !== "number") return null;
832
+ return entry;
833
+ } catch {
834
+ return null;
835
+ }
836
+ }
837
+
838
+ /** Every entry on disk, newest-enrolled last. Never throws. */
839
+ function ledgerList() {
840
+ let names;
841
+ try {
842
+ names = readdirSync(LEDGER_DIR);
843
+ } catch {
844
+ return [];
845
+ }
846
+ const out = [];
847
+ for (const name of names.slice(0, LEDGER_SCAN_CAP)) {
848
+ if (!name.endsWith(".json")) continue;
849
+ const path = join(LEDGER_DIR, name);
850
+ const entry = ledgerRead(path);
851
+ if (entry) {
852
+ out.push({ path, entry, name });
853
+ continue;
854
+ }
855
+ // A file we cannot read is NOT deleted on sight, and that is the whole
856
+ // reason it is not: `writeFileSync` truncates before it writes, so a hook
857
+ // stopping in another repository at that instant reads an empty file — and
858
+ // deleting it there would destroy an enrolment that was about to land, in
859
+ // silence, for the session whose pull request nobody is waiting on any
860
+ // more. It is retired by AGE instead, from the filesystem's own timestamp,
861
+ // which needs no parse and cannot race a write that just happened.
862
+ try {
863
+ if (Date.now() - statSync(path).mtimeMs > LEDGER_TTL_MS) ledgerClose(path);
864
+ } catch {
865
+ /* gone already, or a directory — nothing to retire */
866
+ }
867
+ }
868
+ return out.sort((a, b) => a.entry.at - b.entry.at);
869
+ }
870
+
871
+ /**
872
+ * Should this stop leave an entry behind, and of which kind?
873
+ *
874
+ * THE SIGNAL IS THE SERVER'S OWN SENTENCE, not the hook's guess. Ingest
875
+ * answers `analysis: "none"` with NO reason on exactly one branch — the one
876
+ * where no pull request asked for a review. Every other "none" carries a
877
+ * reason (`auto-review-cap`, `another-session-holds-it`, `already-analyzed`,
878
+ * …), which means a pull request WAS tried and asking again cannot change it.
879
+ *
880
+ * Which kind depends on what this stop's own `gh` found:
881
+ * nothing, or a CLOSED pull request → a COMMIT entry. The branch's work may
882
+ * still get a pull request, and the question to ask about it later is
883
+ * "which one contains this commit".
884
+ * an OPEN one (so, a draft — a ready one would have triggered) → a NUMBER
885
+ * entry. The question is "is that one ready yet", and a number needs no
886
+ * search.
887
+ * a MERGED one → nothing. The work landed; nobody is waiting for a review.
888
+ */
889
+ function catchUpEnrolment({ sent, found, branch, commitSha, now }) {
890
+ if (!sent?.ok || !sent.sessionPublicId) return null;
891
+ if (sent.analysis !== "none" || sent.analysisReason) return null;
892
+ if (!branch || branch === "HEAD" || TRUNK_BRANCHES.has(branch)) return null;
893
+ const pr = found?.pr ?? null;
894
+ if (pr && pr.state === "MERGED") return null;
895
+ // Full shas only: `--search` matches a sha by prefix, and a prefix is a
896
+ // weaker key than the claim it would be used to make.
897
+ const sha = /^[a-f0-9]{40,64}$/.test(commitSha || "") ? commitSha : null;
898
+ const byNumber = pr && pr.state === "OPEN";
899
+ if (!byNumber && !sha) return null;
900
+ return {
901
+ v: 1,
902
+ session: sent.sessionPublicId,
903
+ branch,
904
+ sha,
905
+ pr: byNumber ? pr.number : null,
906
+ prRepo: byNumber ? pr.repo : null,
907
+ at: now,
908
+ // Null, so the very next stop may ask straight away: the cooldown throttles
909
+ // ASKS, not enrolment.
910
+ lastAskAt: null,
911
+ asks: 0,
912
+ };
913
+ }
914
+
915
+ /**
916
+ * Ask gh about one entry. Returns what gh said, in this pass's vocabulary —
917
+ * never a claim, only a candidate and the reasons it survived or did not.
918
+ */
919
+ function catchUpAsk(root, repoUrl, entry, timeoutMs) {
920
+ // A number is an exact question and needs no search.
921
+ if (entry.pr) {
922
+ // The host is pinned to the entry's own, exactly as the receipt path pins
923
+ // it: `--repo owner/name` alone resolves against GH_HOST, and a repository
924
+ // of the same name on another host is not this one.
925
+ const got = ghPrView(root, [String(entry.pr), "--repo", `github.com/${entry.prRepo}`], timeoutMs);
926
+ if (!got.ok) return { kind: "gh", gap: got.kind };
927
+ const pr = toWirePr(got.json);
928
+ if (!pr || pr.number !== entry.pr || pr.repo !== entry.prRepo) return { kind: "unusable" };
929
+ if (!headIsThisBranch(got.json, entry.branch) || !headIsInThisClone(got.json, repoUrl)) {
930
+ return { kind: "other-branch" };
931
+ }
932
+ return { kind: "pr", pr };
933
+ }
934
+
935
+ const got = ghPrList(root, entry.sha, CATCH_UP_SEARCH_LIMIT, timeoutMs);
936
+ if (!got.ok) return { kind: "gh", gap: got.kind };
937
+ if (got.list.length === 0) return { kind: "no-match" };
938
+ // The search finds; the guards prove. Every candidate has to be a pull
939
+ // request we can record, whose head GitHub says is this session's branch, in
940
+ // this clone, and which really contains the commit — the last one because
941
+ // the search is free text and a pull request that merely QUOTES the sha
942
+ // matches it.
943
+ const mine = [];
944
+ for (const json of got.list) {
945
+ const pr = toWirePr(json);
946
+ if (!pr) continue;
947
+ if (!headIsThisBranch(json, entry.branch)) continue;
948
+ const placed = headClone(json, repoUrl);
949
+ if (placed === "no") continue;
950
+ const contained = containsCommit(root, entry.sha, pr.headSha);
951
+ if (contained === false) continue;
952
+ // When the head could not be placed — this clone has no `origin`, or gh
953
+ // answered without a head repository — the commit proof stops being
954
+ // corroboration and becomes the only evidence there is, so an abstention
955
+ // from it is not enough. Otherwise a contributor's fork, on a branch of the
956
+ // same obvious name, reverting a commit of ours, passes every guard at
957
+ // once: the name matches, the clone cannot be compared, and their head
958
+ // object was never fetched here.
959
+ if (placed === "cannot-tell" && contained !== true) continue;
960
+ mine.push(pr);
961
+ }
962
+ if (mine.length === 0) return { kind: "other-branch" };
963
+ // A MERGED candidate that contains the commit means the work already landed,
964
+ // and the same rule `catchUpEnrolment` applies at the stop applies here:
965
+ // nobody is waiting for a review of it. It matters because containment
966
+ // proves ANCESTRY, not authorship — once the commit is in the branch's
967
+ // history every later head on that branch contains it, including the head of
968
+ // a pull request that REVERTS it (git's own revert message quotes the sha in
969
+ // full, so the free-text search returns it too). Without this the session
970
+ // that wrote the code would be reviewed against the diff that undoes it.
971
+ if (mine.some((pr) => pr.state === "MERGED")) return { kind: "settled" };
972
+ const ready = mine.filter(asksForReview);
973
+ // Two open pull requests from one head in one clone (two base branches)
974
+ // leave the claim unprovable. Refuse rather than pick.
975
+ if (ready.length > 1) return { kind: "ambiguous" };
976
+ if (ready.length === 1) return { kind: "pr", pr: ready[0] };
977
+ if (mine.some((pr) => pr.state === "OPEN")) return { kind: "draft" };
978
+ // Every candidate is CLOSED, and none of them landed: the branch's pull
979
+ // requests were abandoned, so a replacement may still come.
980
+ return { kind: "abandoned" };
981
+ }
982
+
983
+ /**
984
+ * Make the claim, ask again later, or stop asking. One place, because the two
985
+ * questions have different shapes and the same three outcomes.
986
+ */
987
+ function catchUpDecide(entry, answer) {
988
+ if (answer.kind === "gh") {
989
+ // This clone will never have a GitHub pull request to name.
990
+ if (answer.gap === "no-repo" || answer.gap === "not-github") return { action: "close", why: answer.gap };
991
+ // gh missing, signed out, offline, timed out: a login and a network can
992
+ // both happen between two stops.
993
+ return { action: "keep", why: answer.gap };
994
+ }
995
+ if (answer.kind === "pr") {
996
+ const pr = answer.pr;
997
+ if (asksForReview(pr)) return { action: "send", pr, why: "ready" };
998
+ if (pr.state === "OPEN") return { action: "keep", why: "draft" };
999
+ if (pr.state === "MERGED") return { action: "close", why: "settled" };
1000
+ // CLOSED: this pull request was abandoned and the branch's work may
1001
+ // continue into another one, so fall back to asking by commit.
1002
+ return entry.sha ? { action: "demote", why: "closed" } : { action: "close", why: "closed" };
1003
+ }
1004
+ if (answer.kind === "ambiguous" || answer.kind === "unusable" || answer.kind === "settled") {
1005
+ return { action: "close", why: answer.kind };
1006
+ }
1007
+ // `no-match`, `other-branch`, `draft`, `abandoned` — nothing to claim yet,
1008
+ // and tomorrow may be different.
1009
+ return { action: "keep", why: answer.kind };
1010
+ }
1011
+
1012
+ /**
1013
+ * ONE attempt, and deliberately not `send()`.
1014
+ *
1015
+ * `send()` retries a 5xx three times with a growing backoff, because the
1016
+ * session it is carrying exists nowhere else. A catch-up carries no bytes at
1017
+ * all — the claim is about work the server already holds — so the retry loop
1018
+ * for old work must never compete with the developer's own capture. The next
1019
+ * stop IS the retry.
1020
+ */
1021
+ async function sendCatchUp(cfg, body, timeoutMs) {
1022
+ const url = `${cfg.apiUrl.replace(/\/+$/, "")}/v1/catch-up`;
1023
+ try {
1024
+ const res = await fetch(url, {
1025
+ method: "POST",
1026
+ headers: { Authorization: `Bearer ${cfg.key}`, "Content-Type": "application/json" },
1027
+ body: JSON.stringify(body),
1028
+ signal: AbortSignal.timeout(timeoutMs),
1029
+ });
1030
+ const text = await res.text().catch(() => "");
1031
+ let parsed = null;
1032
+ try {
1033
+ parsed = JSON.parse(text);
1034
+ } catch {
1035
+ /* the status carries the decision; the body is the explanation */
1036
+ }
1037
+ return { status: res.status, body: parsed, detail: text.slice(0, 300) };
1038
+ } catch (e) {
1039
+ return { status: 0, body: null, detail: String(e?.message ?? e) };
1040
+ }
1041
+ }
1042
+
1043
+ /**
1044
+ * What the server's answer means for the entry.
1045
+ *
1046
+ * Anchored to the one rule `send()` already follows — a 4xx is an answer and a
1047
+ * 5xx is a failure to answer — with the single exception the catch-up adds:
1048
+ * a 202 is an answer too, and the reason inside it says whether asking again
1049
+ * could ever change it.
1050
+ */
1051
+ function catchUpAfterSend(res) {
1052
+ if (res.status === 202) {
1053
+ const reason = res.body?.analysisReason ?? null;
1054
+ if (!reason) return { action: "close", why: "queued" };
1055
+ return isTransientReason(reason) ? { action: "keep", why: reason } : { action: "close", why: reason };
1056
+ }
1057
+ // 400 (we composed a body it will not take), 404 (the row is gone), 409 (the
1058
+ // claim contradicts the server's own record), 413 — none of them changes by
1059
+ // repeating it.
1060
+ if (res.status >= 400 && res.status < 500 && res.status !== 429) {
1061
+ return { action: "close", why: `http-${res.status}` };
1062
+ }
1063
+ // 429, 5xx, and a request that never arrived: not an answer about the claim.
1064
+ return { action: "keep", why: res.status ? `http-${res.status}` : "unreachable" };
1065
+ }
1066
+
1067
+ /**
1068
+ * The whole pass: record this stop, retire what has expired, and spend at most
1069
+ * ONE gh call and ONE request on one older session.
1070
+ *
1071
+ * Deliberately voiceless. The developer's single line (D20) is about the stop
1072
+ * that just happened and carries THIS session's link; a clause about a
1073
+ * different session beside that link is the same contradiction F1 refused when
1074
+ * it declined to print a pull request number the session page could not show.
1075
+ * Every ask writes a line to the hook's log instead, where nothing contradicts
1076
+ * it.
1077
+ */
1078
+ async function catchUp({ cfg, root, repoUrl, sent, branch, commitSha }, found) {
1079
+ // A stop whose OWN capture did not land has a bad token, a bad address or no
1080
+ // network — and a catch-up would spend a gh call and a request to learn the
1081
+ // same thing a second time. The whole pass waits for a stop that worked; the
1082
+ // entries are on disk and lose nothing by it.
1083
+ if (!root || !sent?.ok) return;
1084
+ const started = Date.now();
1085
+ const now = started;
1086
+
1087
+ // This stop first, so a session the server has just linked cannot then be
1088
+ // picked up and asked about as if it were still waiting.
1089
+ if (sent.sessionPublicId) {
1090
+ const mine = ledgerPath(root, sent.sessionPublicId);
1091
+ const entry = catchUpEnrolment({ sent, found, branch, commitSha, now });
1092
+ if (entry) {
1093
+ if (ledgerWrite(mine, entry)) log(`catch-up enrolled ${entry.session} (${entry.pr ? `#${entry.pr}` : "by commit"})`);
1094
+ } else {
1095
+ ledgerClose(mine);
1096
+ }
1097
+ }
1098
+
1099
+ const entries = ledgerList();
1100
+ // Retire from age before anything is asked: an entry past the cap names a
1101
+ // row the server has already deleted.
1102
+ const live = [];
1103
+ for (const it of entries) {
1104
+ if (now - it.entry.at > LEDGER_TTL_MS) {
1105
+ ledgerClose(it.path);
1106
+ log(`catch-up expired ${it.entry.session}`);
1107
+ } else {
1108
+ live.push(it);
1109
+ }
1110
+ }
1111
+ // Oldest first, so one runaway repository cannot starve the rest.
1112
+ while (live.length > LEDGER_MAX_ENTRIES) ledgerClose(live.shift().path);
1113
+
1114
+ const prefix = `${rootHash(root)}-`;
1115
+ const eligible = live.filter(
1116
+ (it) =>
1117
+ it.name.startsWith(prefix) &&
1118
+ it.entry.session !== sent.sessionPublicId &&
1119
+ (it.entry.lastAskAt == null || now - it.entry.lastAskAt >= CATCH_UP_COOLDOWN_MS),
1120
+ );
1121
+ // LEAST RECENTLY ASKED, not oldest enrolled — and the difference is the
1122
+ // whole feature. Enrolment order looks right and starves the ledger: a
1123
+ // branch that never gets a pull request answers `no-match` forever and stays
1124
+ // at the front, so with one ask per stop and a ten-minute cooldown only the
1125
+ // first few entries are ever reached, and the session this change exists for
1126
+ // — the one whose pull request was just opened by hand — waits behind them
1127
+ // until it expires. A never-asked entry sorts first (`?? 0`), so a new
1128
+ // enrolment is always reached on the next stop. Enrolment order keeps the
1129
+ // job it is right for: which entry the cap evicts.
1130
+ eligible.sort((a, b) => (a.entry.lastAskAt ?? 0) - (b.entry.lastAskAt ?? 0) || a.entry.at - b.entry.at);
1131
+ const candidate = eligible[0];
1132
+ if (!candidate) return;
1133
+
1134
+ const { path, entry } = candidate;
1135
+ // This stop already paid to learn that gh cannot answer here. Asking it a
1136
+ // second question would cost the same wait again — and `lastAskAt` is left
1137
+ // alone, so the entry is first in line on a stop where gh works.
1138
+ if (found?.gap && GH_CANNOT_ANSWER.has(found.gap)) {
1139
+ log(`catch-up ${entry.session} not asked (gh ${found.gap} this stop)`);
1140
+ return;
1141
+ }
1142
+ const answer = catchUpAsk(root, repoUrl, entry, Math.max(2_000, CATCH_UP_BUDGET_MS - (Date.now() - started)));
1143
+ // The cost was paid whatever the answer, so the cooldown is spent first.
1144
+ ledgerWrite(path, { ...entry, lastAskAt: Date.now(), asks: (entry.asks ?? 0) + 1 });
1145
+
1146
+ const decision = catchUpDecide(entry, answer);
1147
+ if (decision.action === "close") {
1148
+ log(`catch-up ${entry.session} closed (${decision.why})`);
1149
+ ledgerClose(path);
1150
+ return;
1151
+ }
1152
+ if (decision.action === "demote") {
1153
+ log(`catch-up ${entry.session} demoted to a commit question (${decision.why})`);
1154
+ ledgerWrite(path, { ...entry, pr: null, prRepo: null, lastAskAt: Date.now(), asks: (entry.asks ?? 0) + 1 });
1155
+ return;
1156
+ }
1157
+ if (decision.action === "keep") {
1158
+ log(`catch-up ${entry.session} still waiting (${decision.why})`);
1159
+ return;
1160
+ }
1161
+
1162
+ const body = {
1163
+ sessionPublicId: entry.session,
1164
+ branch: entry.branch,
1165
+ pr: decision.pr,
1166
+ // The provenance names the question that was asked, and the key travels
1167
+ // with it: a commit for the search, nothing for a number asked again.
1168
+ prSource: entry.pr ? CATCH_UP_SOURCE.again : CATCH_UP_SOURCE.sha,
1169
+ ...(entry.pr ? {} : { commitSha: entry.sha }),
1170
+ clientVersion: CLIENT_VERSION,
1171
+ };
1172
+ const res = await sendCatchUp(cfg, body, Math.max(2_000, CATCH_UP_BUDGET_MS - (Date.now() - started)));
1173
+ const after = catchUpAfterSend(res);
1174
+ // The server's own words, not only our reading of them. A 409 says WHICH of
1175
+ // the three ways the claim contradicted the row, a 400 carries the field a
1176
+ // body we composed got wrong, and an unreachable server carries the transport
1177
+ // error — and on the close branch the entry is about to disappear, so this
1178
+ // line is the only place any of it survives.
1179
+ log(
1180
+ `catch-up ${entry.session} -> #${decision.pr.number} ${res.status || "unreachable"} (${after.why})` +
1181
+ (res.detail ? ` ${res.detail}` : ""),
1182
+ );
1183
+ if (after.action === "close") ledgerClose(path);
1184
+ }
1185
+
1186
+ /**
1187
+ * The single line the developer sees in their terminal (owner decision D20).
1188
+ * Either the link to what was captured, or one honest sentence — never
1189
+ * silence, and never a promise about a review that has not been asked for.
1190
+ *
1191
+ * It does NOT name the pull request, even when this hook just found one. The
1192
+ * link goes to the session page, and that page reads the App's `pr_number`
1193
+ * (`sessions/[publicId]/page.tsx`), which F0 deliberately does not write from
1194
+ * a client report — so on the no-App flow this change is built for, a line
1195
+ * saying "on pull request #19" would be followed one click later by a page
1196
+ * saying "Waiting for a pull request". The number goes to the hook's log,
1197
+ * where nothing contradicts it, until F2 makes the page able to show it.
1198
+ */
1199
+ function terminalLine(sent, found) {
1200
+ if (!sent?.ok) {
1201
+ return sent?.status
1202
+ ? `Videlic could not save this session (HTTP ${sent.status})`
1203
+ : "Videlic could not be reached — this session was not sent";
1204
+ }
1205
+ let lead = "Videlic captured this session";
1206
+ if (!found?.pr) {
1207
+ switch (found?.gap) {
1208
+ case "no-pr":
1209
+ // What gh answered, not what the world is: with two remotes gh picks
1210
+ // the base repository by remote NAME (measured), so a pull request
1211
+ // living in the fork is a "no" here while it is plainly open on
1212
+ // GitHub. The sentence says whose answer this is.
1213
+ lead = "Videlic captured this session; gh found no pull request for this branch";
1214
+ break;
1215
+ case "gh-missing":
1216
+ lead = "Videlic captured this session; install gh so it can name your pull request";
1217
+ break;
1218
+ case "gh-signed-out":
1219
+ lead = "Videlic captured this session; run gh auth login so it can name your pull request";
1220
+ break;
1221
+ case "unusable":
1222
+ // gh answered — we could not use the answer (an address that is not
1223
+ // github.com, a field outside what the server accepts). Saying "could
1224
+ // not ask gh" here would blame the tool for our own limit.
1225
+ lead = "Videlic captured this session; the pull request gh named is not one it can record";
1226
+ break;
1227
+ case "receipt-other-branch":
1228
+ lead = "Videlic captured this session; the pull request it opened is not this branch's";
1229
+ break;
1230
+ case "offline":
1231
+ case "timeout":
1232
+ case "gh-failed":
1233
+ lead = "Videlic captured this session; it could not ask gh which pull request this is";
1234
+ break;
1235
+ case "not-github":
1236
+ // Nothing to advise: this product reviews GitHub pull requests, and
1237
+ // the session was captured all the same. The reason is in the log.
1238
+ break;
1239
+ default:
1240
+ break;
1241
+ }
1242
+ }
1243
+ if (lead.length > TERMINAL_LEAD_CAP) lead = `${lead.slice(0, TERMINAL_LEAD_CAP - 1).trimEnd()}…`;
1244
+ return sent.sessionUrl ? `${lead} — ${sent.sessionUrl}` : lead;
1245
+ }
1246
+
1247
+ /** One line of JSON on stdout is how a stop hook speaks to the terminal. */
1248
+ function say(line) {
1249
+ try {
1250
+ // A pipe whose reader is gone reports itself asynchronously, as an
1251
+ // `error` event on the stream — outside this try/catch, outside
1252
+ // `main().catch`, and so as an uncaught exception that ends the hook with
1253
+ // a stack trace and exit code 1 after a session that uploaded perfectly.
1254
+ process.stdout.on("error", () => {});
1255
+ // `suppressOutput` keeps the raw JSON out of the transcript — the message
1256
+ // is shown to the developer, not fed back into the next session we read.
1257
+ process.stdout.write(`${JSON.stringify({ systemMessage: line, suppressOutput: true })}\n`);
1258
+ } catch {
1259
+ /* the line is a courtesy; never fail the stop over it */
1260
+ }
1261
+ }
1262
+
1263
+ async function send(url, key, body) {
1264
+ // The last status the server actually answered with, kept across retries:
1265
+ // "we gave up after three 500s" and "we never reached it" are different
1266
+ // sentences, and only a status tells them apart.
1267
+ let lastStatus = 0;
1268
+ for (let attempt = 1; attempt <= 3; attempt++) {
1269
+ let status = 0;
1270
+ let detail = "";
1271
+ try {
1272
+ const res = await fetch(url, {
1273
+ method: "POST",
1274
+ headers: {
1275
+ Authorization: `Bearer ${key}`,
1276
+ "Content-Type": "application/json",
1277
+ },
1278
+ body: JSON.stringify(body),
1279
+ signal: AbortSignal.timeout(60_000),
1280
+ });
1281
+ status = res.status;
1282
+ lastStatus = status;
1283
+ const text = await res.text().catch(() => "");
1284
+ if (status >= 200 && status < 300) {
1285
+ log(`ingest OK (${status})`);
1286
+ let sessionUrl = "";
1287
+ // What the server did with this upload, in its own words. The id is
1288
+ // the strongest key this hook will ever hold for the session — the
1289
+ // ledger is keyed on it rather than on anything the hook composed —
1290
+ // and `analysis`/`analysisReason` are what decide whether this stop is
1291
+ // worth coming back to at all.
1292
+ let sessionPublicId = "";
1293
+ let analysis = "";
1294
+ let analysisReason = "";
1295
+ // The two the evidence endpoint refuses to work without, and the
1296
+ // reason they are read from the ANSWER rather than from what we sent:
1297
+ // an upload with no `sessionFileId` of its own is given one by the
1298
+ // server (`auto_<hash>`), so the id this session is filed under is the
1299
+ // server's to state. `ingestId` is minted per upload and is how the
1300
+ // server tells a bundle collected for THIS stop from one that arrives
1301
+ // after the next stop already moved the conversation on.
1302
+ let ingestId = "";
1303
+ let sessionFileId = "";
1304
+ try {
1305
+ const parsed = JSON.parse(text);
1306
+ if (typeof parsed?.sessionUrl === "string") sessionUrl = parsed.sessionUrl;
1307
+ if (typeof parsed?.sessionPublicId === "string") sessionPublicId = parsed.sessionPublicId;
1308
+ if (typeof parsed?.analysis === "string") analysis = parsed.analysis;
1309
+ if (typeof parsed?.analysisReason === "string") analysisReason = parsed.analysisReason;
1310
+ if (typeof parsed?.ingestId === "string") ingestId = parsed.ingestId;
1311
+ if (typeof parsed?.sessionFileId === "string") sessionFileId = parsed.sessionFileId;
1312
+ } catch {
1313
+ /* the link is a courtesy; the capture already landed */
1314
+ }
1315
+ return { ok: true, status, sessionUrl, sessionPublicId, analysis, analysisReason, ingestId, sessionFileId };
1316
+ }
1317
+ detail = text.slice(0, 300);
1318
+ } catch (e) {
1319
+ detail = String(e?.message ?? e);
1320
+ }
1321
+ if (status >= 400 && status < 500) {
1322
+ log(`ingest failed ${status}: ${detail}`); // client error — don't retry
1323
+ return { ok: false, status };
1324
+ }
1325
+ log(`ingest attempt ${attempt} -> ${status || "error"} (${detail})`);
1326
+ if (attempt < 3) await new Promise((r) => setTimeout(r, attempt * 2000));
1327
+ }
1328
+ return { ok: false, status: lastStatus };
1329
+ }
1330
+
1331
+ /** The flag that makes this file the collector instead of the hook. */
1332
+ const COLLECT_FLAG = "--collect-evidence";
1333
+
1334
+ /**
1335
+ * Hand the evidence collection to a copy of THIS file, detached.
1336
+ *
1337
+ * Why a second run of the same file and not a second file with its own entry
1338
+ * point: `install.mjs` copies what it is given into `~/.claude`, and a hook
1339
+ * that had to find a sibling script by path would break the moment anybody
1340
+ * moved or renamed one of them. `process.argv[1]` is the file that is running,
1341
+ * which is the only path that is true wherever it was installed.
1342
+ *
1343
+ * Nothing secret goes on the command line — `ps` is world-readable on every
1344
+ * machine this runs on. The child reads `~/.claude/videlic.json` itself, the
1345
+ * same way this process did.
1346
+ *
1347
+ * The transcript's PATH travels, the transcript does not. E2 needs the
1348
+ * session's own test commands — a runner listing a repository's whole test
1349
+ * suite from the root is not the selection of a run that started inside one
1350
+ * package, and the difference was measured at 460 files against 5. A command
1351
+ * LINE may carry a token somebody would not want in `ps`; a path to a file in
1352
+ * the developer's own home directory names nothing that `root` and
1353
+ * `sessionFileId`, already both here, do not.
1354
+ */
1355
+ function spawnCollector(cfg, sent, root, transcript) {
1356
+ if (!root || !sent?.ok) return;
1357
+ // Both are the server's own words about this upload. An older server that
1358
+ // does not echo them yet simply gets no bundle, which is correct: the
1359
+ // endpoint would refuse one it cannot key, and a bundle filed under a guess
1360
+ // is worse than no bundle.
1361
+ if (!sent.ingestId || !sent.sessionFileId) {
1362
+ log("evidence: server did not name this ingest — nothing to collect against");
1363
+ return;
1364
+ }
1365
+ try {
1366
+ const self = process.argv[1];
1367
+ if (!self) return;
1368
+ const argv = [self, COLLECT_FLAG, root, sent.sessionFileId, sent.ingestId];
1369
+ // Optional and last, so a collector started by an older hook — or by a
1370
+ // hook whose stop payload named no transcript — still parses its own
1371
+ // arguments and still sends the tree.
1372
+ if (transcript) argv.push(transcript);
1373
+ const child = spawn(process.execPath, argv, {
1374
+ detached: true,
1375
+ stdio: "ignore",
1376
+ windowsHide: true,
1377
+ });
1378
+ // A spawn failure arrives as an ASYNCHRONOUS `error` event, which the
1379
+ // try/catch around this call cannot see — and an `error` with no listener
1380
+ // is rethrown by Node as an uncaught exception. That would take down the
1381
+ // Stop hook AFTER the capture has landed and the developer's line has been
1382
+ // printed: a stack trace on their terminal, caused entirely by a
1383
+ // nice-to-have. The listener must be attached before `unref`.
1384
+ child.on("error", (e) => log(`evidence: collector failed to start: ${e?.message ?? e}`));
1385
+ child.unref();
1386
+ log(`evidence: collector ${child.pid} detached for ingest ${sent.ingestId}`);
1387
+ } catch (e) {
1388
+ // A collector that could not start costs the review its evidence and the
1389
+ // session nothing at all. That is the right way round, and it is why this
1390
+ // is the last thing main() does.
1391
+ log(`evidence: could not start collector: ${e?.message ?? e}`);
1392
+ }
1393
+ }
1394
+
1395
+ /**
1396
+ * The detached half: collect the bundle and post it.
1397
+ *
1398
+ * It runs after the hook has already returned, so it has no terminal, no
1399
+ * stdin and no deadline it shares with the session. Everything it can do
1400
+ * wrong, it does quietly into the log.
1401
+ */
1402
+ async function collect(root, sessionFileId, ingestId, transcript) {
1403
+ const cfg = readConfig();
1404
+ if (!cfg) return;
1405
+ const started = Date.now();
1406
+ const { collectEvidence, commandsFromTranscript } = await import("./evidence.mjs");
1407
+ // The session's own command lines, read here rather than passed on the
1408
+ // command line. `undefined` when there is no transcript to read, which is
1409
+ // the honest answer: the collector did not ATTEMPT a selection, which is a
1410
+ // different fact from a repository that runs no tests.
1411
+ //
1412
+ // Reading it cannot cost the bundle. A transcript that has been rotated,
1413
+ // truncated or deleted between the hook's read and this one leaves the
1414
+ // sections that do not depend on it exactly as they were.
1415
+ let testCommands;
1416
+ if (transcript) {
1417
+ try {
1418
+ testCommands = commandsFromTranscript(readFileSync(transcript, "utf8"));
1419
+ } catch (e) {
1420
+ log(`evidence: transcript unreadable for the selection (${e?.message ?? e})`);
1421
+ }
1422
+ }
1423
+ const r = collectEvidence({ root, clientVersion: CLIENT_VERSION, testCommands });
1424
+ if (!r.bundle) {
1425
+ log(`evidence: nothing collected (${r.reason})`);
1426
+ return;
1427
+ }
1428
+ const sections = Object.entries(r.bundle.sent)
1429
+ .map(([k, v]) => `${k}=${v === true ? "yes" : v}`)
1430
+ .join(" ");
1431
+ log(`evidence: collected in ${r.spentMs}ms, ${r.bytes} B · ${sections}`);
1432
+ const url = `${cfg.apiUrl.replace(/\/+$/, "")}/v1/evidence`;
1433
+ try {
1434
+ const res = await fetch(url, {
1435
+ method: "POST",
1436
+ headers: { Authorization: `Bearer ${cfg.key}`, "Content-Type": "application/json" },
1437
+ body: JSON.stringify({ sessionFileId, ingestId, bundle: r.bundle }),
1438
+ signal: AbortSignal.timeout(EVIDENCE_POST_TIMEOUT_MS),
1439
+ });
1440
+ const text = await res.text().catch(() => "");
1441
+ // One attempt, no retry. The bundle describes a tree that is already
1442
+ // moving under the developer's hands, and the next stop collects a fresher
1443
+ // one anyway — a retry would spend minutes to deliver something staler
1444
+ // than what is coming.
1445
+ log(`evidence: ${res.status} ${text.slice(0, 200)} (total ${Date.now() - started}ms)`);
1446
+ } catch (e) {
1447
+ log(`evidence: post failed: ${e?.message ?? e}`);
1448
+ }
1449
+ }
1450
+
1451
+ /** The bundle is one request and gzips to well under a megabyte; it does not need the upload's minute. */
1452
+ const EVIDENCE_POST_TIMEOUT_MS = 30_000;
1453
+
110
1454
  async function main() {
111
1455
  const cfg = readConfig();
112
1456
  if (!cfg) process.exit(0); // not configured — nothing to do
@@ -157,48 +1501,130 @@ async function main() {
157
1501
  sessionData = readFileSync(transcript, "utf8");
158
1502
  }
159
1503
 
160
- const { branch, commitSha, repoUrl } = gitMeta(cwd);
1504
+ const { branch, commitSha, repoUrl, root } = gitMeta(cwd);
1505
+ const found = findPullRequest(root, branch, repoUrl, () => sessionData);
1506
+ if (found.pr) {
1507
+ log(`pr ${found.pr.repo}#${found.pr.number} (${found.pr.state}${found.pr.draft ? ", draft" : ""}) via ${found.source}`);
1508
+ } else {
1509
+ log(`pr none (${found.gap})`);
1510
+ }
161
1511
 
1512
+ // Each of these is bounded on the server, and Zod answers a body that
1513
+ // exceeds one with a 400 that loses the WHOLE session — the same trap the
1514
+ // pull request is held to above. A field we cannot send within its bound is
1515
+ // dropped; the capture is worth more than the label.
1516
+ const fits = (value, max) => (typeof value === "string" && value.length > 0 && value.length <= max ? value : null);
162
1517
  const body = { agent: "claude-code", sessionData };
163
- if (sessionId) body.sessionFileId = sessionId;
164
- if (branch) body.branch = branch;
165
- if (commitSha) body.commitSha = commitSha;
166
- if (repoUrl) body.repoUrl = repoUrl;
1518
+ if (fits(sessionId, 100)) body.sessionFileId = sessionId;
1519
+ if (fits(branch, 256)) body.branch = branch;
1520
+ if (/^[a-f0-9]{7,64}$/.test(commitSha)) body.commitSha = commitSha;
1521
+ if (fits(repoUrl, 1000)) body.repoUrl = repoUrl;
167
1522
  if (truncated) body.truncated = true;
1523
+ // Which hook composed this body — never omitted, so a server can measure
1524
+ // the fleet by it and read "absent" as the pre-version client it is.
1525
+ body.clientVersion = CLIENT_VERSION;
1526
+ // Explicitly null, not absent: "we asked and there is none" and "we never
1527
+ // asked" are different facts, and only the first one is a null on the wire.
1528
+ body.pr = found.pr;
1529
+ // `prSource` describes `pr` — the server refuses one without the other.
1530
+ if (found.pr && found.source) body.prSource = found.source;
1531
+
1532
+ // Is a bundle coming? The server reads this to hold the analysis for one
1533
+ // (`startAfter`) rather than judging a session it is about to have evidence
1534
+ // for. It must be answered before the upload, so it is answered by what this
1535
+ // hook is ABLE to do — a git repository we found a root for — and never by
1536
+ // optimism: a `follows` that never arrives costs the review its own budget.
1537
+ body.evidence = root ? "follows" : "none";
168
1538
 
169
1539
  const url = `${cfg.apiUrl.replace(/\/+$/, "")}/v1/ingest`;
170
- for (let attempt = 1; attempt <= 3; attempt++) {
171
- let status = 0;
172
- let detail = "";
173
- try {
174
- const res = await fetch(url, {
175
- method: "POST",
176
- headers: {
177
- Authorization: `Bearer ${cfg.key}`,
178
- "Content-Type": "application/json",
179
- },
180
- body: JSON.stringify(body),
181
- signal: AbortSignal.timeout(60_000),
182
- });
183
- status = res.status;
184
- if (status >= 200 && status < 300) {
185
- log(`ingest OK (${status})`);
186
- return;
187
- }
188
- detail = (await res.text().catch(() => "")).slice(0, 300);
189
- } catch (e) {
190
- detail = String(e?.message ?? e);
191
- }
192
- if (status >= 400 && status < 500) {
193
- log(`ingest failed ${status}: ${detail}`); // client error — don't retry
194
- return;
1540
+ const sent = await send(url, cfg.key, body);
1541
+ // The developer's line goes out BEFORE the catch-up, not after. The pass is
1542
+ // voiceless by design, so nothing on the line depends on it — and putting it
1543
+ // second meant the terminal stayed blank for the whole of the catch-up's
1544
+ // budget on top of the upload's. It also means a hook killed mid-pass has
1545
+ // already said what happened to THIS session.
1546
+ say(terminalLine(sent, found));
1547
+ // The evidence goes to a DETACHED process, and the whole design rests on
1548
+ // that: collection reads every source file in the repository (measured at
1549
+ // ~0.9 s and 4.2 MB on a 24 000-file monorepo, but it is somebody else's
1550
+ // laptop and somebody else's disk), while a stop hook holds the developer's
1551
+ // terminal for as long as it runs. Measured: a child spawned `detached` with
1552
+ // `stdio: "ignore"` and unref'd survives both this process exiting normally
1553
+ // and a SIGKILL of its whole process group — which is what Claude Code does
1554
+ // to a hook that overruns. So the collection cannot cost the session, and
1555
+ // being killed cannot cost the collection.
1556
+ spawnCollector(cfg, sent, root, transcript);
1557
+ // After the upload, because enrolment needs two things only the server's
1558
+ // answer carries — the session's public id and the server's own sentence
1559
+ // about whether anything was even tried — and because a stop whose own
1560
+ // capture failed has a bad token or a bad network, and would spend a gh call
1561
+ // and a request to learn it twice.
1562
+ await catchUp({ cfg, root, repoUrl, sent, branch, commitSha }, found).catch((e) =>
1563
+ log(`catch-up failed: ${e?.message ?? e}`),
1564
+ );
1565
+ }
1566
+
1567
+ // `main()` runs whenever this file is executed, which is the only way the
1568
+ // hook is ever used. Tests import the helpers above with
1569
+ // VIDELIC_HOOK_NO_MAIN=1 set; the default stays "run" on purpose, because a
1570
+ // hook that silently did nothing would be indistinguishable from a working
1571
+ // one until a developer noticed their sessions had stopped arriving.
1572
+ if (process.env.VIDELIC_HOOK_NO_MAIN !== "1") {
1573
+ // The collector branch comes FIRST, and it has to: `main()` awaits stdin
1574
+ // unconditionally, and a detached child is spawned with `stdio: "ignore"` —
1575
+ // it would read "", fail to parse it, and log "unparseable stop payload"
1576
+ // forever while collecting nothing.
1577
+ const flagAt = process.argv.indexOf(COLLECT_FLAG);
1578
+ if (flagAt > 0) {
1579
+ const [root, sessionFileId, ingestId, transcript] = process.argv.slice(flagAt + 1);
1580
+ if (root && sessionFileId && ingestId) {
1581
+ collect(root, sessionFileId, ingestId, transcript).catch((e) => log(`evidence: fatal: ${e?.message ?? e}`));
1582
+ } else {
1583
+ log("evidence: collector started without a root, a session or an ingest");
195
1584
  }
196
- log(`ingest attempt ${attempt} -> ${status || "error"} (${detail})`);
197
- if (attempt < 3) await new Promise((r) => setTimeout(r, attempt * 2000));
1585
+ } else {
1586
+ main().catch((e) => {
1587
+ log(`fatal: ${e?.message ?? e}`);
1588
+ process.exit(0); // never break the user's session on our account
1589
+ });
198
1590
  }
199
1591
  }
200
1592
 
201
- main().catch((e) => {
202
- log(`fatal: ${e?.message ?? e}`);
203
- process.exit(0); // never break the user's session on our account
204
- });
1593
+ export {
1594
+ CLIENT_VERSION,
1595
+ COLLECT_FLAG,
1596
+ collect,
1597
+ spawnCollector,
1598
+ findPullRequest,
1599
+ headIsInThisClone,
1600
+ repoFromRemote,
1601
+ PR_SOURCE,
1602
+ CATCH_UP_SOURCE,
1603
+ GH_FIELDS,
1604
+ GH_TIMEOUT_MS,
1605
+ ghEnv,
1606
+ ghFailureKind,
1607
+ headIsThisBranch,
1608
+ invokesPrCreate,
1609
+ parsePrUrl,
1610
+ prFromSessionReceipt,
1611
+ resolveTool,
1612
+ terminalLine,
1613
+ toWirePr,
1614
+ asksForReview,
1615
+ catchUpAfterSend,
1616
+ catchUpDecide,
1617
+ catchUpEnrolment,
1618
+ containsCommit,
1619
+ headClone,
1620
+ isTransientReason,
1621
+ GH_CANNOT_ANSWER,
1622
+ normalizeRoot,
1623
+ rootHash,
1624
+ LEDGER_DIR,
1625
+ LEDGER_TTL_MS,
1626
+ LEDGER_MAX_ENTRIES,
1627
+ CATCH_UP_COOLDOWN_MS,
1628
+ CATCH_UP_SEARCH_LIMIT,
1629
+ TRUNK_BRANCHES,
1630
+ };