@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.
- package/package.json +1 -1
- package/scripts/install-cursor-plugin.sh +12 -9
- package/skills/workit-implement/SKILL.md +3 -3
- package/skills/workit-ship/SKILL.md +14 -14
- package/src/core/policy/derive.ts +1 -1
- package/src/core/policy/judgment.ts +54 -0
- package/src/core/store-lock.ts +36 -2
- package/src/core/task-engine.ts +77 -2
- package/src/core/task-store.ts +8 -3
- package/src/forge/github.ts +143 -0
- package/src/forge/gitlab.ts +167 -0
- package/src/forge/pr-edit.ts +291 -0
- package/src/forge/pr-ops.ts +103 -17
- package/src/forge/types.ts +32 -0
- package/src/hooks/handle.ts +33 -7
- package/src/hooks/hosts/claude-code.ts +12 -2
- package/src/hooks/hosts/codex.ts +33 -8
- package/src/hooks/hosts/cursor.ts +14 -2
- package/src/hooks/index.ts +1 -0
- package/src/hooks/protocol.ts +8 -1
- package/src/hooks/raw-git.ts +612 -0
- package/src/hooks/shell-words.ts +128 -0
- package/src/hooks/write-gate.ts +1 -78
- package/src/host-session.ts +53 -0
- package/src/ledger.ts +438 -60
|
@@ -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
|
+
}
|