@brainervirus/workit-core 6.0.1 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -53,3 +53,38 @@ export const commandText = (value: unknown): string | null => {
53
53
  const joined = argv.map(quoteArg).join(" ");
54
54
  return nonEmpty(joined) ? joined : null;
55
55
  };
56
+
57
+ const WRITE_TOOLS = new Set([
58
+ "write",
59
+ "edit",
60
+ "multiedit",
61
+ "notebookedit",
62
+ "applypatch",
63
+ "patch",
64
+ "delete",
65
+ "remove",
66
+ "rename",
67
+ "mkdir",
68
+ "mv",
69
+ "cp",
70
+ "touch",
71
+ "strreplace",
72
+ "searchreplace",
73
+ "editfile",
74
+ "writefile",
75
+ ]);
76
+ /** A host tool that writes files (Claude Edit/Write, Cursor Write/Delete, apply_patch…). */
77
+ export const isWriteTool = (name: unknown): boolean =>
78
+ typeof name === "string" && WRITE_TOOLS.has(name.toLowerCase().replace(/[_-]/g, ""));
79
+
80
+ const PATH_KEYS = ["file_path", "filePath", "notebook_path", "path", "target_file", "file"];
81
+ /** The files a write tool names: its path fields, or the files of an apply_patch body. */
82
+ export const writePaths = (input: unknown): string[] => {
83
+ if (!isRecord(input)) return [];
84
+ const paths = PATH_KEYS.map((key) => input[key]).filter(nonEmpty);
85
+ for (const value of Object.values(input))
86
+ if (typeof value === "string")
87
+ for (const match of value.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm))
88
+ paths.push(match[1].trim());
89
+ return [...new Set(paths)];
90
+ };
@@ -13,6 +13,7 @@ export const OPENCODE_DESCRIPTOR: HostDescriptor = {
13
13
  "context.turn": { support: "native", native: 'session.hook("context")' },
14
14
  "shell.pre": { support: "native", native: 'permission.hook("evaluate")' },
15
15
  "tool.pre": { support: "native", native: 'tool.hook("execute.before")' },
16
+ "write.pre": { support: "native", native: 'permission.hook("evaluate")' },
16
17
  "shell.post": { support: "native", native: 'tool.hook("execute.after")' },
17
18
  "subagent.start": { support: "native", native: 'tool.hook("execute.before") subagent' },
18
19
  "subagent.stop": { support: "native", native: 'tool.hook("execute.after") subagent' },
@@ -12,6 +12,7 @@ export const PI_DESCRIPTOR: HostDescriptor = {
12
12
  "context.turn": { support: "native", native: "before_agent_start" },
13
13
  "shell.pre": { support: "native", native: "tool_call" },
14
14
  "tool.pre": { support: "native", native: "tool_call" },
15
+ "write.pre": { support: "native", native: "tool_call" },
15
16
  "shell.post": { support: "native", native: "tool_result" },
16
17
  // Workers are supervised stock-Pi processes, not host subagents.
17
18
  "subagent.start": { support: "none", native: null },
@@ -17,6 +17,8 @@ export {
17
17
  type SessionHandle,
18
18
  } from "./context";
19
19
  export { shellPolicy } from "./policy";
20
+ export { shellWrites, writeGate } from "./write-gate";
21
+ export { isWriteTool, writePaths } from "./hosts/fields";
20
22
  export { dispatchHook, failureDecision, handleHook, type HookDeps } from "./handle";
21
23
  export { HOOK_ADAPTERS, runHookProcess } from "./run";
22
24
  export { CLAUDE_CODE_DESCRIPTOR, claudeCodeAdapter } from "./hosts/claude-code";
@@ -20,6 +20,8 @@ export type HookEvent =
20
20
  | { kind: "shell.pre"; command: string; toolUseId: string | null }
21
21
  /** A pre-tool gate for a non-shell tool. Host permission policy owns these. */
22
22
  | { kind: "tool.pre"; tool: string; toolUseId: string | null }
23
+ /** A file-writing tool (Edit, Write, apply_patch…): the before-write gate (S17). */
24
+ | { kind: "write.pre"; tool: string; paths: string[]; toolUseId: string | null }
23
25
  | {
24
26
  kind: "shell.post";
25
27
  command: string;
@@ -46,6 +48,7 @@ export const HOOK_EVENT_KINDS = [
46
48
  "context.turn",
47
49
  "shell.pre",
48
50
  "tool.pre",
51
+ "write.pre",
49
52
  "shell.post",
50
53
  "subagent.start",
51
54
  "subagent.stop",
@@ -0,0 +1,303 @@
1
+ // The before-write gate (S17). With no writer lease, a branch task's unmet
2
+ // before-write requirements (an open product choice, a missing plan) deny
3
+ // working-tree edits where the host has a pre-write hook, naming the exact
4
+ // unblock. History moves (commit, merge, rebase, stash pop) are not edits.
5
+ // Reads only the task index, one task snapshot and the ledger; never
6
+ // migrates, never captures a candidate, and fails open on any state error.
7
+ import { realpathSync } from "node:fs";
8
+ import path from "node:path";
9
+ import { fileSignature, TaskStore } from "../core/task-store";
10
+ import { latestJudgment, writeBlockers, type WriteBlocker } from "../core/policy/derive";
11
+ import { applicableDecision } from "../core/task-evaluation";
12
+ import { ledgerPath } from "../ledger";
13
+ import type { HookDecision } from "./protocol";
14
+
15
+ const NONE: HookDecision = { kind: "none" };
16
+
17
+ const CASE_INSENSITIVE = process.platform === "darwin" || process.platform === "win32";
18
+
19
+ /**
20
+ * The real path of `file`: the nearest existing ancestor resolved through
21
+ * symlinks (macOS /var → /private/var), then the not-yet-existing rest.
22
+ */
23
+ const canonical = (file: string): string => {
24
+ let rest = "";
25
+ for (let dir = path.resolve(file); ; dir = path.dirname(dir)) {
26
+ try {
27
+ return path.join(realpathSync(dir), rest);
28
+ } catch {
29
+ if (path.dirname(dir) === dir) return path.resolve(file);
30
+ rest = rest ? path.join(path.basename(dir), rest) : path.basename(dir);
31
+ }
32
+ }
33
+ };
34
+
35
+ /**
36
+ * Writes the gate never blocks: files outside the checkout (e.g. /tmp),
37
+ * Markdown (*.md, *.mdx), the top-level `docs/` tree, any `plans/` directory,
38
+ * and the plan files the task's judgment cites.
39
+ */
40
+ const exempt = (cwd: string, root: string, file: string, plans: ReadonlySet<string>): boolean => {
41
+ const fold = (value: string) => (CASE_INSENSITIVE ? value.toLowerCase() : value);
42
+ const relative = path.relative(fold(canonical(root)), fold(canonical(path.resolve(cwd, file))));
43
+ if (relative.startsWith("..") || path.isAbsolute(relative)) return true;
44
+ const posix = relative.split(path.sep).join("/");
45
+ const segments = posix.split("/");
46
+ return (
47
+ /\.mdx?$/i.test(posix) ||
48
+ segments[0] === "docs" ||
49
+ segments.includes("plans") ||
50
+ [...plans].some((plan) => fold(plan) === posix)
51
+ );
52
+ };
53
+
54
+ type Segment = { words: string[]; redirects: string[] };
55
+
56
+ /** A small shell tokenizer: quoted words, operators, redirects and heredoc bodies. */
57
+ const segmentsOf = (command: string): Segment[] => {
58
+ const segments: Segment[] = [];
59
+ let current: Segment = { words: [], redirects: [] };
60
+ let word = "";
61
+ let inWord = false;
62
+ let pending: "redirect" | "input" | "heredoc" | null = null;
63
+ const heredocs: string[] = [];
64
+ const flushWord = () => {
65
+ if (!inWord) return;
66
+ if (pending === "redirect") {
67
+ if (!word.startsWith("&")) current.redirects.push(word);
68
+ } else if (pending === "heredoc") heredocs.push(word.replace(/^-/, ""));
69
+ else if (pending !== "input") current.words.push(word);
70
+ pending = null;
71
+ word = "";
72
+ inWord = false;
73
+ };
74
+ const flushSegment = () => {
75
+ flushWord();
76
+ if (current.words.length || current.redirects.length) segments.push(current);
77
+ current = { words: [], redirects: [] };
78
+ };
79
+ for (let index = 0; index < command.length; index++) {
80
+ const char = command[index];
81
+ if (char === "'" || char === '"') {
82
+ const end = command.indexOf(char, index + 1);
83
+ word += command.slice(index + 1, end < 0 ? undefined : end);
84
+ inWord = true;
85
+ index = end < 0 ? command.length : end;
86
+ } else if (char === "\\" && index + 1 < command.length) {
87
+ word += command[++index];
88
+ inWord = true;
89
+ } else if (char === "\n") {
90
+ flushSegment();
91
+ // Skip heredoc bodies up to each delimiter line.
92
+ while (heredocs.length) {
93
+ const delimiter = heredocs.shift()!;
94
+ const lines = command.slice(index + 1).split("\n");
95
+ const at = lines.findIndex((line) => line.trim() === delimiter);
96
+ const skipped = (at < 0 ? lines : lines.slice(0, at + 1)).join("\n").length + 1;
97
+ index += skipped;
98
+ }
99
+ } else if (/\s/.test(char)) flushWord();
100
+ else if (";&|()".includes(char)) {
101
+ if (char === "&" && command[index + 1] === ">") continue;
102
+ flushSegment();
103
+ } else if (char === ">") {
104
+ // `2>`, `&>`: the fd digit is not a word.
105
+ if (inWord && /^\d$/.test(word)) {
106
+ word = "";
107
+ inWord = false;
108
+ } else flushWord();
109
+ if (command[index + 1] === ">") index++;
110
+ if (command[index + 1] === "&") {
111
+ index++;
112
+ word = "&";
113
+ inWord = true;
114
+ }
115
+ pending = "redirect";
116
+ } else if (char === "<") {
117
+ flushWord();
118
+ if (command[index + 1] === "<") {
119
+ index++;
120
+ if (command[index + 1] === "<") index++;
121
+ pending = "heredoc";
122
+ } else pending = "input";
123
+ } else {
124
+ word += char;
125
+ inWord = true;
126
+ }
127
+ }
128
+ flushSegment();
129
+ return segments;
130
+ };
131
+
132
+ const PREFIXES = new Set(["sudo", "command", "env", "nohup", "time", "exec", "xargs"]);
133
+ const operands = (words: string[]) => words.filter((item) => !item.startsWith("-"));
134
+
135
+ /** The files one simple command edits: [] none, null unknown. */
136
+ const editsOf = (input: string[]): string[] | null => {
137
+ let words = input;
138
+ while (words.length && (PREFIXES.has(words[0]) || /^\w+=/.test(words[0]))) words = words.slice(1);
139
+ const [verb, ...args] = words;
140
+ const name = (verb?.split(/[\\/]/).at(-1) ?? "").toLowerCase();
141
+ if (!name || name === "workit" || (name === "npx" && args.some((item) => /workit/.test(item))))
142
+ return [];
143
+ switch (name) {
144
+ case "touch":
145
+ case "mkdir":
146
+ case "rm":
147
+ case "rmdir":
148
+ case "tee":
149
+ case "mv":
150
+ case "truncate":
151
+ return operands(args);
152
+ case "cp":
153
+ case "ln":
154
+ case "install": {
155
+ const target = args.indexOf("-t");
156
+ if (target >= 0) return args[target + 1] ? [args[target + 1]] : null;
157
+ const last = operands(args).at(-1);
158
+ return last ? [last] : null;
159
+ }
160
+ case "dd":
161
+ return args.filter((item) => item.startsWith("of=")).map((item) => item.slice(3));
162
+ case "sed":
163
+ case "perl": {
164
+ if (!args.some((item) => /^-[a-zA-Z]*i|^--in-place/.test(item))) return [];
165
+ const scripted = args.some((item) => item === "-e" || item === "-f");
166
+ const files: string[] = [];
167
+ for (let index = 0; index < args.length; index++) {
168
+ if (args[index] === "-e" || args[index] === "-f") index++;
169
+ else if (!args[index].startsWith("-")) files.push(args[index]);
170
+ }
171
+ return scripted ? files : files.slice(1);
172
+ }
173
+ case "patch":
174
+ return null;
175
+ // PowerShell (Claude Code's PowerShell tool): the path parameter, else the first operand.
176
+ case "set-content":
177
+ case "add-content":
178
+ case "out-file":
179
+ case "new-item":
180
+ case "ni":
181
+ case "remove-item":
182
+ case "del":
183
+ case "copy-item":
184
+ case "move-item": {
185
+ const flag = args.findIndex((item) =>
186
+ /^-(?:path|filepath|literalpath|destination)$/i.test(item),
187
+ );
188
+ if (flag >= 0) return args[flag + 1] ? [args[flag + 1]] : null;
189
+ const files = operands(args);
190
+ if (name === "move-item") return files.length ? files : null;
191
+ const file = name === "copy-item" ? files.at(-1) : files[0];
192
+ return file ? [file] : null;
193
+ }
194
+ case "git": {
195
+ const sub = operands(args)[0];
196
+ if (sub === "apply") return null;
197
+ if (sub === "restore" || sub === "mv" || sub === "rm") return operands(args).slice(1);
198
+ if (sub === "checkout" && args.includes("--")) return args.slice(args.indexOf("--") + 1);
199
+ // commit, merge, rebase, stash, cherry-pick… move history, not edits.
200
+ return [];
201
+ }
202
+ default:
203
+ return [];
204
+ }
205
+ };
206
+
207
+ /**
208
+ * Whether a shell command recognizably edits files (redirects, tee, touch,
209
+ * mkdir, cp/mv/rm, `sed -i`/`perl -i`, dd, and working-tree git: apply,
210
+ * restore, checkout --, mv, rm). `targets` lists the files when they are all
211
+ * known, else null. Interpreters and formatters are not detected; Workit's
212
+ * own commands never count.
213
+ */
214
+ export const shellWrites = (command: string): { writes: boolean; targets: string[] | null } => {
215
+ const targets: string[] = [];
216
+ let unknown = false;
217
+ for (const segment of segmentsOf(command)) {
218
+ targets.push(
219
+ ...segment.redirects.filter((item) => !/^\/dev\/(?:null|stdout|stderr|tty)$/.test(item)),
220
+ );
221
+ const edits = editsOf(segment.words);
222
+ if (edits === null) unknown = true;
223
+ else targets.push(...edits);
224
+ }
225
+ if (!unknown && targets.length === 0) return { writes: false, targets: null };
226
+ return { writes: true, targets: unknown ? null : targets };
227
+ };
228
+
229
+ type Gate = { blockers: WriteBlocker[]; root: string; plans: Set<string> };
230
+ type Cached = Gate & { key: string; planKey: string };
231
+ const cache = new Map<string, Cached>();
232
+
233
+ const planKeyOf = (root: string, plans: Iterable<string>) =>
234
+ [...plans].map((file) => fileSignature(path.resolve(root, file)) ?? "-").join("\0");
235
+
236
+ /** Unmet, unwaived before-write requirements of the checkout branch's open task. */
237
+ const gateFor = (cwd: string): Gate | null => {
238
+ const store = new TaskStore(cwd);
239
+ const listed = store.listTaskIndex();
240
+ if (!listed.ok) return null;
241
+ const key = store.currentKey();
242
+ if (!key.ok) return null;
243
+ const entry = listed.data
244
+ .filter((item) => item.key === key.data.key && item.status !== "closed")
245
+ .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0];
246
+ if (!entry) return { blockers: [], root: store.root, plans: new Set() };
247
+ const ledger = ledgerPath(store.root);
248
+ const cacheKey = `${entry.id}\0${entry.revision}\0${(ledger.ok && fileSignature(ledger.value)) || "-"}`;
249
+ const hit = cache.get(store.root);
250
+ // A cited plan created or edited since changes the answer (long-lived hosts).
251
+ if (hit && hit.key === cacheKey && hit.planKey === planKeyOf(store.root, hit.plans)) return hit;
252
+ const task = store.readTask(entry.id);
253
+ if (!task.ok) return null;
254
+ const workspace = store.readWorkspace();
255
+ const plans = new Set(
256
+ (latestJudgment(task.data)?.refs ?? []).flatMap((ref) =>
257
+ ref.kind === "file" ? [ref.path] : [],
258
+ ),
259
+ );
260
+ // An approved limitation clears a waivable requirement, as at close.
261
+ const blockers = writeBlockers(task.data, store.root).filter((blocker) => {
262
+ const requirement = task.data.policy?.requirements.find(
263
+ (item) => item.ruleId === blocker.ruleId && item.before === "write",
264
+ );
265
+ return !(
266
+ requirement?.acceptanceAllowed &&
267
+ workspace.ok &&
268
+ workspace.data &&
269
+ applicableDecision(task.data, workspace.data, requirement, store.root).length > 0
270
+ );
271
+ });
272
+ const gate: Cached = {
273
+ blockers,
274
+ root: store.root,
275
+ plans,
276
+ key: cacheKey,
277
+ planKey: planKeyOf(store.root, plans),
278
+ };
279
+ cache.set(store.root, gate);
280
+ if (cache.size > 32) cache.delete(cache.keys().next().value!);
281
+ return gate;
282
+ };
283
+
284
+ /**
285
+ * Deny a working-tree edit while the branch task has unmet before-write
286
+ * requirements. `paths` are the files the tool writes, or null when unknown
287
+ * (shell); an edit touching only exempt files is always allowed.
288
+ */
289
+ export function writeGate(cwd: string, paths: readonly string[] | null): HookDecision {
290
+ try {
291
+ const gate = gateFor(cwd);
292
+ if (!gate || gate.blockers.length === 0) return NONE;
293
+ if (paths && paths.every((file) => exempt(cwd, gate.root, file, gate.plans))) return NONE;
294
+ const reasons = gate.blockers.map((item) => `${item.ruleId}: ${item.reason}`).join("; ");
295
+ return {
296
+ kind: "deny",
297
+ reason: `workit before-write gate: ${reasons}. Markdown, docs/, plans/ and files outside the checkout stay writable.`,
298
+ unblock: gate.blockers[0].reason,
299
+ };
300
+ } catch {
301
+ return NONE;
302
+ }
303
+ }
package/src/ledger.ts CHANGED
@@ -56,6 +56,15 @@ export const VERDICT_RESULTS = [
56
56
  export type VerdictResult = (typeof VERDICT_RESULTS)[number];
57
57
  const PASSING: ReadonlySet<string> = new Set(["verified", "tests-verified", "type-check-only"]);
58
58
  const FAILING: ReadonlySet<string> = new Set(["failed", "blocked"]);
59
+ /** Results that prove a behavior change; `type-check-only` never does (S17). */
60
+ export const STRONG_RESULTS: ReadonlySet<string> = new Set(["verified", "tests-verified"]);
61
+
62
+ /**
63
+ * How the branch's current code was reviewed: `verified` only with an
64
+ * accepted independent verdict; `self-reviewed` when the only current strong
65
+ * verdict is the author's own (`--self`), never shown as verified.
66
+ */
67
+ export type ReviewLabel = "verified" | "self-reviewed" | "unreviewed";
59
68
  export const VERDICT_KINDS = ["unit", "live", "perf", "review"] as const;
60
69
  export type VerdictKind = (typeof VERDICT_KINDS)[number];
61
70
  export const VERDICT_SURFACES = ["ui", "cli", "api"] as const;
@@ -762,6 +771,9 @@ export type VerdictCheck = {
762
771
  current: { basis: VerdictBasis; verdict: ReadRow | null };
763
772
  /** What merge gates read: an accepted verdict and no current independent failure. */
764
773
  accepted: { accepted: boolean; verdict: ReadRow | null; reasons: RejectReason[] };
774
+ review: ReviewLabel;
775
+ /** The newest current, strong, self verdict when `review` is self-reviewed. */
776
+ selfVerdict: ReadRow | null;
765
777
  verdicts: VerdictEntry[];
766
778
  authors: string[];
767
779
  };
@@ -942,9 +954,17 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
942
954
  ? (["failing_verdict"] as RejectReason[])
943
955
  : (newestCurrent?.reasons ?? (["no_verdict"] as RejectReason[])),
944
956
  };
957
+ const selfEntry = failing
958
+ ? undefined
959
+ : verdicts.findLast(
960
+ (entry) =>
961
+ entry.current && !entry.independent && STRONG_RESULTS.has(String(entry.verdict.result)),
962
+ );
945
963
  return {
946
964
  branch,
947
965
  head,
966
+ review: accepted.accepted ? "verified" : selfEntry ? "self-reviewed" : "unreviewed",
967
+ selfVerdict: accepted.accepted ? null : (selfEntry?.verdict ?? null),
948
968
  base: newestCurrent?.verdict.base ?? fallbackBase,
949
969
  patchId: keyFor(newestCurrent?.verdict.base ?? fallbackBase).patchId,
950
970
  current: { basis: newestCurrent?.basis ?? "none", verdict: newestCurrent?.verdict ?? null },