@brainervirus/workit-core 8.1.1 → 8.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,612 @@
1
+ // Raw git/forge commands on an agent's shell (audit B1/B2). Workit's delivery
2
+ // rules (merge grant, verdict gate, protected branches, session trailer) live
3
+ // in its own verbs, so a raw command skips them. The pre-tool hook:
4
+ // - refuses, inside a Workit workspace, the raw forms that bypass a gate:
5
+ // `gh pr merge`, `glab mr merge`, and `git push` to the workspace's push
6
+ // remote onto a protected branch or the default target, naming the
7
+ // workit command;
8
+ // - lets routine raw commands run (`git commit`, a feature-branch push,
9
+ // `gh pr create|view|checks`, `glab mr create|view`) with a short nudge.
10
+ // A raw `git commit` is recorded as the session's `commit.recorded` row so
11
+ // the author's own verdict never reads as independent. The pre-tool hook
12
+ // notes HEAD before the command (a marker in the commit's own repository);
13
+ // the post-tool hook records only a commit that moved HEAD away from it and
14
+ // was made after it, and that no other session claims. Hosts without a
15
+ // post-tool event settle the marker on the session's next shell command.
16
+ //
17
+ // Classification is string parsing only. Git is spawned only to read the new
18
+ // commit after a raw commit and the push remote before a deny; HEAD and the
19
+ // current branch are read from the git dir. Every failure answers "no
20
+ // decision": hooks fail open.
21
+ import { spawnSync } from "node:child_process";
22
+ import { createHash } from "node:crypto";
23
+ import fs from "node:fs";
24
+ import os from "node:os";
25
+ import path from "node:path";
26
+ import { resolveBranchPolicyFor } from "../core/branch";
27
+ import { resolveRuntimeWorkspaceVcs } from "../core/workspaces";
28
+ import { pushRemoteName } from "../git/rev";
29
+ import { appendHookObserved, readLedger } from "../ledger";
30
+ import { resolveStore, resolveTaskKey, type StoreLocation } from "../store/paths";
31
+ import type { HookDecision, HookInput } from "./protocol";
32
+ import { segmentsOf, type ShellDialect } from "./shell-words";
33
+
34
+ const NONE: HookDecision = { kind: "none" };
35
+
36
+ /** One git/gh/glab invocation: its arguments after global options, and the
37
+ * directory it runs in relative to the hook's cwd (`cd x`, `git -C x`).
38
+ * `dir` is undefined when it cannot be known (`cd -`, `cd $X`, an unmatched
39
+ * `popd`): such an invocation never gets a decision. */
40
+ export type RawInvocation = {
41
+ tool: "git" | "gh" | "glab";
42
+ dir: string | null | undefined;
43
+ args: string[];
44
+ };
45
+
46
+ const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
47
+ const PREFIXES = new Set(["sudo", "command", "env", "nohup", "time", "exec", "nice", "&"]);
48
+ const SCRIPT_FLAG = /^-[a-zA-Z]*c[a-zA-Z]*$/;
49
+ const CD = new Set(["cd", "chdir", "set-location", "sl"]);
50
+ const PUSHD = new Set(["pushd", "push-location"]);
51
+ const POPD = new Set(["popd", "pop-location"]);
52
+ /** git global options that take the next word as their value. */
53
+ const GIT_VALUE_OPTIONS = new Set([
54
+ "-c",
55
+ "--git-dir",
56
+ "--work-tree",
57
+ "--namespace",
58
+ "--exec-path",
59
+ "--config-env",
60
+ "--super-prefix",
61
+ ]);
62
+
63
+ type Dir = string | null | undefined;
64
+
65
+ const isAbsolute = (value: string) =>
66
+ path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");
67
+
68
+ const joinDir = (base: Dir, next: string): Dir => {
69
+ if (base === undefined || /[$`*?]/.test(next) || next === "-") return undefined;
70
+ const expanded =
71
+ next === "~" || next.startsWith("~/") ? path.join(os.homedir(), next.slice(1)) : next;
72
+ return base === null || isAbsolute(expanded) ? expanded : path.join(base, expanded);
73
+ };
74
+
75
+ /** The directory operand of `cd`/`Set-Location` (`-Path x`, `-LiteralPath x`). */
76
+ const cdTarget = (words: string[]): string | null => {
77
+ const args = words.slice(1);
78
+ const flag = args.findIndex((word) => /^-(?:literal)?path$/i.test(word));
79
+ if (flag >= 0) return args[flag + 1] ?? null;
80
+ return args.find((word) => word === "-" || !word.startsWith("-")) ?? null;
81
+ };
82
+
83
+ /** Drop env assignments, wrappers (`sudo`, `env -i`, `timeout 30`) and their options. */
84
+ const stripPrefixes = (input: string[]): string[] => {
85
+ let words = input;
86
+ for (;;) {
87
+ const first = words[0];
88
+ if (first === undefined) return words;
89
+ if (/^\w+=/.test(first)) words = words.slice(1);
90
+ else if (PREFIXES.has(first)) {
91
+ words = words.slice(1);
92
+ while (words[0]?.startsWith("-")) words = words.slice(words[0] === "-u" ? 2 : 1);
93
+ } else if (first === "timeout") {
94
+ words = words.slice(1);
95
+ while (words[0]?.startsWith("-")) words = words.slice(1);
96
+ words = words.slice(1);
97
+ } else return words;
98
+ }
99
+ };
100
+
101
+ const commandName = (word: string | undefined) =>
102
+ (word?.split(/[\\/]/).at(-1) ?? "").toLowerCase().replace(/\.exe$/, "");
103
+
104
+ /**
105
+ * Every git/gh/glab invocation in a shell command: `&&`/`;`/`|` chains,
106
+ * `cd`/`pushd`/`popd` before it (scoped to `( … )` groups), `git -C dir`,
107
+ * env prefixes and `bash -c '…'` scripts.
108
+ */
109
+ export function rawInvocations(
110
+ command: string,
111
+ dialect: ShellDialect = "posix",
112
+ depth = 0,
113
+ ): RawInvocation[] {
114
+ const found: RawInvocation[] = [];
115
+ let dir: Dir = null;
116
+ const groups: Array<{ dir: Dir; stack: Dir[] }> = [];
117
+ let stack: Dir[] = [];
118
+ for (const segment of segmentsOf(command, dialect)) {
119
+ if (segment.group === "open") {
120
+ groups.push({ dir, stack: [...stack] });
121
+ continue;
122
+ }
123
+ if (segment.group === "close") {
124
+ const saved = groups.pop();
125
+ if (saved) ({ dir, stack } = saved);
126
+ continue;
127
+ }
128
+ const words = stripPrefixes(segment.words);
129
+ const name = commandName(words[0]);
130
+ if (CD.has(name)) {
131
+ const target = cdTarget(words);
132
+ dir = target === null ? os.homedir() : joinDir(dir, target);
133
+ continue;
134
+ }
135
+ if (PUSHD.has(name)) {
136
+ const target = cdTarget(words);
137
+ stack.push(dir);
138
+ dir = target === null || target === "-" ? undefined : joinDir(dir, target);
139
+ continue;
140
+ }
141
+ if (POPD.has(name)) {
142
+ dir = stack.length ? stack.pop() : undefined;
143
+ continue;
144
+ }
145
+ if (SHELLS.has(name) && depth < 3) {
146
+ const flag = words.findIndex((word, index) => index > 0 && SCRIPT_FLAG.test(word));
147
+ const script = flag > 0 ? words[flag + 1] : undefined;
148
+ if (script)
149
+ for (const inner of rawInvocations(script, "posix", depth + 1))
150
+ found.push({
151
+ ...inner,
152
+ dir:
153
+ inner.dir === null
154
+ ? dir
155
+ : inner.dir === undefined
156
+ ? undefined
157
+ : joinDir(dir, inner.dir),
158
+ });
159
+ continue;
160
+ }
161
+ if (name === "gh" || name === "glab") {
162
+ found.push({ tool: name, dir, args: words.slice(1) });
163
+ continue;
164
+ }
165
+ if (name !== "git") continue;
166
+ let here = dir;
167
+ let index = 1;
168
+ for (; index < words.length; index++) {
169
+ const word = words[index];
170
+ if (word === "-C") here = words[index + 1] ? joinDir(here, words[++index]) : here;
171
+ else if (GIT_VALUE_OPTIONS.has(word)) index++;
172
+ else if (!word.startsWith("-")) break;
173
+ }
174
+ found.push({ tool: "git", dir: here, args: words.slice(index) });
175
+ }
176
+ return found;
177
+ }
178
+
179
+ /** What one raw invocation means for workit. */
180
+ export type RawAction =
181
+ | { kind: "commit"; amend: boolean }
182
+ | {
183
+ kind: "push";
184
+ /** Branches named by the refspecs; `null` stands for the current branch. */
185
+ targets: Array<string | null>;
186
+ /** Forced without a lease (`--force`, `-f`, `+refspec`). */
187
+ blindForce: boolean;
188
+ /** The remote named on the command line; null pushes to the default one. */
189
+ remote: string | null;
190
+ }
191
+ | { kind: "merge"; forge: "gh" | "glab" }
192
+ | { kind: "pr-create"; forge: "gh" | "glab" }
193
+ | { kind: "pr-read"; forge: "gh" | "glab" };
194
+
195
+ /** git push options that take the next word as their value. */
196
+ const PUSH_VALUE_OPTIONS = new Set(["-o", "--push-option", "--receive-pack", "--exec", "--repo"]);
197
+
198
+ const pushAction = (args: string[]): RawAction | null => {
199
+ let force = false;
200
+ let lease = false;
201
+ let wholesale = false;
202
+ let remote: string | null = null;
203
+ const positionals: string[] = [];
204
+ for (let index = 1; index < args.length; index++) {
205
+ const arg = args[index];
206
+ if (arg === "--") {
207
+ positionals.push(...args.slice(index + 1));
208
+ break;
209
+ }
210
+ if (arg === "--dry-run" || /^-[a-zA-Z]*n[a-zA-Z]*$/.test(arg)) return null;
211
+ if (arg === "--repo") remote = args[++index] ?? null;
212
+ else if (arg.startsWith("--repo=")) remote = arg.slice("--repo=".length);
213
+ else if (PUSH_VALUE_OPTIONS.has(arg)) index++;
214
+ else if (arg === "--force" || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(arg)) force = true;
215
+ else if (arg.startsWith("--force-with-lease") || arg === "--force-if-includes") lease = true;
216
+ else if (["--all", "--mirror", "--branches", "--tags"].includes(arg)) wholesale = true;
217
+ else if (!arg.startsWith("-")) positionals.push(arg);
218
+ }
219
+ // `--repo` names the remote; positionals are then all refspecs.
220
+ const specs = remote === null ? positionals.slice(1) : positionals;
221
+ remote ??= positionals[0] ?? null;
222
+ const targets: Array<string | null> = [];
223
+ for (const raw of specs) {
224
+ if (raw.startsWith("+")) force = true;
225
+ const spec = raw.replace(/^\+/, "");
226
+ const colon = spec.indexOf(":");
227
+ const destination = colon >= 0 ? spec.slice(colon + 1) : spec;
228
+ if (!destination) continue;
229
+ if (destination === "HEAD") targets.push(null);
230
+ else if (destination.startsWith("refs/heads/")) targets.push(destination.slice(11));
231
+ else if (!destination.startsWith("refs/")) targets.push(destination);
232
+ }
233
+ // No refspec pushes the current branch (push.default simple/current).
234
+ if (specs.length === 0 && !wholesale) targets.push(null);
235
+ return { kind: "push", targets, blindForce: force && !lease, remote };
236
+ };
237
+
238
+ /** Options whose next word is their value (never an option or subcommand). */
239
+ const VALUE_OPTIONS: Record<RawInvocation["tool"], ReadonlySet<string>> = {
240
+ git: new Set([
241
+ "-m",
242
+ "--message",
243
+ "-F",
244
+ "--file",
245
+ "-C",
246
+ "-c",
247
+ "--reuse-message",
248
+ "--reedit-message",
249
+ "--author",
250
+ "--date",
251
+ "--trailer",
252
+ "-t",
253
+ "--template",
254
+ "--cleanup",
255
+ "--fixup",
256
+ "--squash",
257
+ "-o",
258
+ "--push-option",
259
+ "--repo",
260
+ "--receive-pack",
261
+ "--exec",
262
+ ]),
263
+ gh: new Set(
264
+ ["-b", "--body", "-F", "--body-file", "-t", "--title", "--subject", "-A", "--author-email"]
265
+ .concat(["-B", "--base", "-H", "--head", "-l", "--label", "-a", "--assignee"])
266
+ .concat(["-r", "--reviewer", "-m", "--milestone", "-p", "--project", "-T", "--template"])
267
+ .concat(["--match-head-commit", "-R", "--repo"]),
268
+ ),
269
+ glab: new Set(
270
+ ["-m", "--message", "--squash-message", "--sha", "-t", "--title", "-d", "--description"]
271
+ .concat(["-b", "--target-branch", "-s", "--source-branch", "-l", "--label"])
272
+ .concat(["-a", "--assignee", "--reviewer", "-R", "--repo"]),
273
+ ),
274
+ };
275
+
276
+ /**
277
+ * A help request: `help` as the subcommand (`git help push`, `gh pr help`),
278
+ * or `--help`/`-h` where an option can stand, never as an option's value
279
+ * (`git commit -m help` is a commit).
280
+ */
281
+ const asksHelp = (invocation: RawInvocation): boolean => {
282
+ const { tool, args } = invocation;
283
+ if (args[0] === "help" || (tool !== "git" && args[1] === "help")) return true;
284
+ const values = VALUE_OPTIONS[tool];
285
+ for (let index = 0; index < args.length; index++) {
286
+ const arg = args[index];
287
+ if (arg === "--") return false;
288
+ if (arg === "--help" || arg === "-h") return true;
289
+ if (values.has(arg)) index++;
290
+ }
291
+ return false;
292
+ };
293
+
294
+ /** The workit meaning of one invocation; null for read-only, help or unrelated ones. */
295
+ export function rawAction(invocation: RawInvocation): RawAction | null {
296
+ const args = invocation.args;
297
+ if (asksHelp(invocation)) return null;
298
+ const [first, second] = args;
299
+ if (invocation.tool === "git") {
300
+ if (first === "commit")
301
+ return args.includes("--dry-run")
302
+ ? null
303
+ : { kind: "commit", amend: args.includes("--amend") };
304
+ if (first === "push") return pushAction(args);
305
+ return null;
306
+ }
307
+ const forge = invocation.tool;
308
+ const noun = forge === "gh" ? "pr" : "mr";
309
+ if (first !== noun) return null;
310
+ if (second === "merge") return { kind: "merge", forge };
311
+ if (second === "create") return { kind: "pr-create", forge };
312
+ if (["view", "checks", "status", "ci", "list"].includes(second ?? ""))
313
+ return { kind: "pr-read", forge };
314
+ return null;
315
+ }
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // repository facts (filesystem only)
319
+
320
+ type Repo = { location: StoreLocation; dir: string; branch: string | null };
321
+
322
+ const repoAt = (dir: string): Repo | null => {
323
+ const location = resolveStore(dir);
324
+ if (location instanceof Error || !location.shared) return null;
325
+ return { location, dir, branch: resolveTaskKey(dir, location).branch };
326
+ };
327
+
328
+ /** A repository Workit manages: it has a Workit store, or workspaces.json matches it. */
329
+ const isWorkitWorkspace = (repo: Repo): boolean => {
330
+ if (fs.existsSync(repo.location.dir)) return true;
331
+ try {
332
+ return resolveRuntimeWorkspaceVcs(repo.location.top ?? repo.dir) !== null;
333
+ } catch {
334
+ return false;
335
+ }
336
+ };
337
+
338
+ /** Protected by the branch policy, or the workspace's default target. */
339
+ const guardedBranch = (dir: string, branch: string): boolean => {
340
+ try {
341
+ const policy = resolveBranchPolicyFor(dir);
342
+ return (
343
+ policy.protected.has(branch.toLowerCase()) ||
344
+ policy.defaultTargetBranch.toLowerCase() === branch.toLowerCase()
345
+ );
346
+ } catch {
347
+ return false;
348
+ }
349
+ };
350
+
351
+ /** A push names the remote workit itself pushes to (or none, the default). */
352
+ const toPushRemote = (repo: Repo, remote: string | null): boolean => {
353
+ if (remote === null) return true;
354
+ try {
355
+ // No known push remote: the named one may well be it.
356
+ const pushRemote = pushRemoteName(repo.dir, repo.branch);
357
+ return pushRemote === null || pushRemote === remote;
358
+ } catch {
359
+ return false;
360
+ }
361
+ };
362
+
363
+ /** HEAD's commit read from the git dir (loose or packed ref), or null. */
364
+ const headSha = (repo: Repo): string | null => {
365
+ const git = repo.location.git;
366
+ if (!git || git.reftable) return null;
367
+ try {
368
+ const head = fs.readFileSync(path.join(git.gitDir, "HEAD"), "utf8").trim();
369
+ if (/^[0-9a-f]{40,64}$/.test(head)) return head;
370
+ const ref = /^ref: (refs\/.+)$/.exec(head)?.[1];
371
+ if (!ref) return null;
372
+ const common = path.dirname(repo.location.dir);
373
+ try {
374
+ return fs.readFileSync(path.join(common, ref), "utf8").trim() || null;
375
+ } catch {}
376
+ const packed = fs.readFileSync(path.join(common, "packed-refs"), "utf8");
377
+ return (
378
+ new RegExp(`^([0-9a-f]{40,64}) ${ref.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "m").exec(
379
+ packed,
380
+ )?.[1] ?? null
381
+ );
382
+ } catch {
383
+ return null;
384
+ }
385
+ };
386
+
387
+ // ---------------------------------------------------------------------------
388
+ // pre-tool: refuse or nudge
389
+
390
+ const quote = (text: string) => `\`${text}\``;
391
+
392
+ const refusal = (action: RawAction, branch: string | null): string => {
393
+ if (action.kind === "merge")
394
+ return `workit: raw ${quote(action.forge === "gh" ? "gh pr merge" : "glab mr merge")} bypasses the merge grant and the verdict gate. Run ${quote("workit pr merge")} instead (it checks both; ${quote("workit help pr")}).`;
395
+ return `workit: raw ${quote("git push")}${action.kind === "push" && action.blindForce ? " --force" : ""} onto ${branch}, a protected branch or the default target, bypasses workit's protected-branch check. Push your feature branch with ${quote("workit git push")} (${quote("workit git push --force-with-lease")} to rewrite it) and land it with ${quote("workit pr merge")}.`;
396
+ };
397
+
398
+ const nudgeFor = (action: RawAction, host: HookInput["host"], session: string): string | null => {
399
+ switch (action.kind) {
400
+ case "commit": {
401
+ // Cursor's shell carries no conversation id: name it explicitly.
402
+ const prefix = host === "cursor" && session ? `WORKIT_SESSION_ID=${session} ` : "";
403
+ return action.amend
404
+ ? `workit: raw ${quote("git commit --amend")} is recorded for this session; for new commits prefer ${quote(`${prefix}workit git commit -m '<msg>'`)} (adds the Workit-Session trailer and checks branch and commit policy).`
405
+ : `workit: prefer ${quote(`${prefix}workit git commit -m '<msg>' [--all | -- <paths>]`)}: it adds the Workit-Session trailer and checks branch and commit policy.`;
406
+ }
407
+ case "push":
408
+ return action.blindForce
409
+ ? `workit: ${quote("git push --force")} overwrites without a lease; use ${quote("workit git push --force-with-lease")}.`
410
+ : `workit: prefer ${quote("workit git push")}: it refuses protected branches and verifies the remote tip.`;
411
+ case "pr-create":
412
+ return `workit: prefer ${quote("workit pr create --fill")} (or --title/--body): it records the PR the merge gate checks.`;
413
+ case "pr-read":
414
+ return `workit: ${quote("workit pr status")} shows the PR, its checks and the verdict state; ${quote("workit ci wait")} waits for checks.`;
415
+ case "merge":
416
+ return null;
417
+ }
418
+ };
419
+
420
+ type Located = { action: RawAction; repo: Repo };
421
+
422
+ /** Raw invocations with a meaning, each with its Workit repository. */
423
+ const actionsIn = (cwd: string, command: string, dialect: ShellDialect): Located[] => {
424
+ const out: Located[] = [];
425
+ const repos = new Map<string, Repo | null>();
426
+ for (const invocation of rawInvocations(command, dialect)) {
427
+ if (invocation.dir === undefined) continue;
428
+ const action = rawAction(invocation);
429
+ if (!action) continue;
430
+ const dir = path.resolve(cwd, invocation.dir ?? ".");
431
+ if (!repos.has(dir)) {
432
+ const repo = repoAt(dir);
433
+ repos.set(dir, repo && isWorkitWorkspace(repo) ? repo : null);
434
+ }
435
+ const repo = repos.get(dir);
436
+ if (repo) out.push({ action, repo });
437
+ }
438
+ return out;
439
+ };
440
+
441
+ /** Cheap pre-filter: no git/gh/glab word, nothing to do. */
442
+ const mentionsRawTool = (command: string) => /\b(?:git|gh|glab)\b/i.test(command);
443
+
444
+ const dialectOf = (input: HookInput): ShellDialect =>
445
+ (input.event.kind === "shell.pre" || input.event.kind === "shell.post"
446
+ ? input.event.dialect
447
+ : undefined) ?? "posix";
448
+
449
+ /**
450
+ * The pre-tool decision for a shell command: deny a gate bypass inside a
451
+ * Workit workspace, else a nudge (as context) for routine raw delivery
452
+ * commands, else nothing. Never throws, never writes.
453
+ */
454
+ export function rawGitPre(input: HookInput, command: string): HookDecision {
455
+ try {
456
+ if (!mentionsRawTool(command)) return NONE;
457
+ const nudges: string[] = [];
458
+ for (const { action, repo } of actionsIn(input.cwd, command, dialectOf(input))) {
459
+ if (action.kind === "merge") return deny(refusal(action, null));
460
+ if (action.kind === "push")
461
+ for (const target of action.targets) {
462
+ const branch = target ?? repo.branch;
463
+ if (branch && guardedBranch(repo.dir, branch) && toPushRemote(repo, action.remote))
464
+ return deny(refusal(action, branch));
465
+ }
466
+ const nudge = nudgeFor(action, input.host, input.session.id);
467
+ if (nudge && !nudges.includes(nudge)) nudges.push(nudge);
468
+ }
469
+ return nudges.length ? { kind: "context", text: nudges.join("\n") } : NONE;
470
+ } catch {
471
+ return NONE;
472
+ }
473
+ }
474
+
475
+ const deny = (reason: string): HookDecision => ({ kind: "deny", reason, unblock: null });
476
+
477
+ // ---------------------------------------------------------------------------
478
+ // recording raw commits (B2)
479
+
480
+ /** The marker key for a shell call that has no post-tool event (Cursor). */
481
+ export const NEXT_COMMAND = "next";
482
+ /** A marker older than this is a command that never reported back. */
483
+ const MARKER_TTL_SECONDS = 3_600;
484
+
485
+ type Marker = { head: string | null; at: number };
486
+
487
+ const markerFile = (repo: Repo, session: string, key: string) =>
488
+ path.join(
489
+ repo.location.dir,
490
+ "hooks",
491
+ `commit-${createHash("sha256").update(`${session}\0${key}`).digest("hex").slice(0, 32)}.json`,
492
+ );
493
+
494
+ /** The marker key of one shell call: its tool-use id, else the command. */
495
+ export const callKey = (toolUseId: string | null, command: string): string =>
496
+ toolUseId ?? `cmd:${createHash("sha256").update(command).digest("hex")}`;
497
+
498
+ /**
499
+ * Before a shell call that runs a raw `git commit` in a Workit workspace:
500
+ * note HEAD and the time in that repository's store, under the session and
501
+ * `key`. Never throws.
502
+ */
503
+ export function noteRawCommit(input: HookInput, command: string, key: string): void {
504
+ try {
505
+ if (!input.session.id || !/\bcommit\b/.test(command)) return;
506
+ const at = Math.floor(Date.now() / 1000);
507
+ for (const { action, repo } of actionsIn(input.cwd, command, dialectOf(input))) {
508
+ if (action.kind !== "commit") continue;
509
+ const file = markerFile(repo, input.session.id, key);
510
+ fs.mkdirSync(path.dirname(file), { recursive: true });
511
+ fs.writeFileSync(file, `${JSON.stringify({ head: headSha(repo), at } satisfies Marker)}\n`);
512
+ }
513
+ } catch {
514
+ // Fail open: a missing marker only loses this record.
515
+ }
516
+ }
517
+
518
+ type HeadCommit = { sha: string; committed: number; subject: string; trailer: string | null };
519
+
520
+ const headCommit = (dir: string): HeadCommit | null => {
521
+ const run = spawnSync(
522
+ "git",
523
+ [
524
+ "log",
525
+ "-1",
526
+ "--format=%H%x1f%ct%x1f%s%x1f%(trailers:key=Workit-Session,valueonly,separator=%x2c)",
527
+ ],
528
+ {
529
+ cwd: dir,
530
+ encoding: "utf8",
531
+ timeout: 5_000,
532
+ windowsHide: true,
533
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" },
534
+ },
535
+ );
536
+ const [sha, committed, subject = "", trailer = ""] = (
537
+ run.status === 0 ? run.stdout.trim() : ""
538
+ ).split("\x1f");
539
+ return sha && /^[0-9a-f]{40,64}$/.test(sha)
540
+ ? { sha, committed: Number(committed), subject, trailer: trailer.trim() || null }
541
+ : null;
542
+ };
543
+
544
+ /**
545
+ * Settle one marker: record HEAD for the session when HEAD moved away from
546
+ * the noted commit, the new commit was made at or after the note, and no
547
+ * other session claims it (a ledger row or a Workit-Session trailer).
548
+ */
549
+ const settle = (input: HookInput, repo: Repo, file: string): void => {
550
+ let marker: Marker;
551
+ try {
552
+ marker = JSON.parse(fs.readFileSync(file, "utf8")) as Marker;
553
+ } catch {
554
+ return;
555
+ }
556
+ fs.rmSync(file, { force: true });
557
+ const session = input.session.id;
558
+ const now = Date.now() / 1000;
559
+ if (typeof marker.at !== "number" || now - marker.at > MARKER_TTL_SECONDS) return;
560
+ if (!session || !repo.branch) return;
561
+ const head = headCommit(repo.dir);
562
+ if (!head || head.sha === marker.head || head.committed < marker.at) return;
563
+ if (head.trailer && head.trailer !== session) return;
564
+ const ledger = readLedger(repo.dir);
565
+ if (!ledger.ok) return;
566
+ const claimed = ledger.value.rows.some(
567
+ (row) => row.type === "commit.recorded" && (row.sha === head.sha || row.head === head.sha),
568
+ );
569
+ if (claimed) return;
570
+ appendHookObserved(repo.dir, {
571
+ type: "commit.recorded",
572
+ actor: { host: input.host, session, agentId: input.session.agentId },
573
+ branch: repo.branch,
574
+ head: head.sha,
575
+ sha: head.sha,
576
+ session,
577
+ agentId: input.session.agentId,
578
+ subject: head.subject.slice(0, 200),
579
+ files: [],
580
+ fileCount: null,
581
+ via: "raw_shell",
582
+ });
583
+ };
584
+
585
+ /** Post-tool: a raw `git commit` the pre-tool hook noted is recorded for the session. */
586
+ export function rawGitPost(input: HookInput, command: string, key: string): void {
587
+ try {
588
+ if (!input.session.id || !/\bcommit\b/.test(command)) return;
589
+ for (const { action, repo } of actionsIn(input.cwd, command, dialectOf(input)))
590
+ if (action.kind === "commit") settle(input, repo, markerFile(repo, input.session.id, key));
591
+ } catch {
592
+ // Fail open: a lost record only weakens author detection.
593
+ }
594
+ }
595
+
596
+ /**
597
+ * A host without a post-tool event: the session's previous raw commit in
598
+ * the checkout it works in is settled on its next shell command (one stat
599
+ * when nothing is pending). A commit made in another repository through
600
+ * `cd`/`-C` is settled only when a later command runs there.
601
+ */
602
+ export function settlePendingCommit(input: HookInput): void {
603
+ try {
604
+ if (!input.session.id) return;
605
+ const repo = repoAt(input.cwd);
606
+ if (!repo) return;
607
+ const file = markerFile(repo, input.session.id, NEXT_COMMAND);
608
+ if (fs.existsSync(file)) settle(input, repo, file);
609
+ } catch {
610
+ // Fail open.
611
+ }
612
+ }