@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/README.md +4 -0
- package/evidence.d.mts +172 -0
- package/evidence.mjs +3005 -0
- package/hook.mjs +1468 -42
- package/install.mjs +6 -0
- package/package.json +10 -2
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,
|
|
6
|
-
//
|
|
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(
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
+
};
|