@brainervirus/workit-core 6.0.1 → 7.0.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/skills/workit-deslop/SKILL.md +1 -1
- package/skills/workit-implement/SKILL.md +9 -4
- package/skills/workit-review/SKILL.md +2 -0
- package/skills/workit-shape/SKILL.md +1 -1
- package/skills/workit-ship/SKILL.md +1 -1
- package/src/autonomy.ts +24 -0
- package/src/core/methods.ts +7 -5
- package/src/core/operation-input.ts +506 -0
- package/src/core/policy/derive.ts +338 -0
- package/src/core/policy/judgment.ts +192 -0
- package/src/core/policy-resolver.ts +32 -427
- package/src/core/task-contract.ts +40 -229
- package/src/core/task-engine.ts +51 -58
- package/src/core/task-evaluation.ts +35 -1
- package/src/core/workspaces.ts +2 -0
- package/src/core.ts +8 -6
- package/src/hooks/handle.ts +19 -4
- package/src/hooks/hosts/claude-code.ts +20 -1
- package/src/hooks/hosts/codex.ts +3 -0
- package/src/hooks/hosts/cursor.ts +11 -2
- package/src/hooks/hosts/fields.ts +35 -0
- package/src/hooks/hosts/opencode.ts +1 -0
- package/src/hooks/hosts/pi.ts +1 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/protocol.ts +3 -0
- package/src/hooks/write-gate.ts +303 -0
- package/src/ledger.ts +20 -0
package/src/hooks/handle.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { sessionContextText, turnContextText } from "./context";
|
|
3
3
|
import type { HostDescriptor, Support } from "./descriptor";
|
|
4
4
|
import { shellPolicy } from "./policy";
|
|
5
|
+
import { shellWrites, writeGate } from "./write-gate";
|
|
5
6
|
import type { HookDecision, HookEventKind, HookInput, HostAdapter, RenderedHook } from "./protocol";
|
|
6
7
|
|
|
7
8
|
export type HookDeps = { descriptor: HostDescriptor; addendum: string | null };
|
|
@@ -58,9 +59,13 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
58
59
|
const key = input.session.id ? `${input.host}\0${input.session.id}` : null;
|
|
59
60
|
const offer = event.source === "startup" && (key === null || !offered.has(key));
|
|
60
61
|
if (offer && key !== null) offered.add(key);
|
|
62
|
+
const advisory = usable(descriptor.events["write.pre"].support)
|
|
63
|
+
? null
|
|
64
|
+
: `Workit cannot gate file writes on ${descriptor.label} (no pre-write hook): before-write requirements (an open product choice, a missing plan) are advisory here, so settle them before editing.`;
|
|
65
|
+
const addendum = [deps.addendum, advisory].filter(Boolean).join("\n") || null;
|
|
61
66
|
return {
|
|
62
67
|
kind: "context",
|
|
63
|
-
text: sessionContextText(input, descriptor, { offer, addendum
|
|
68
|
+
text: sessionContextText(input, descriptor, { offer, addendum }),
|
|
64
69
|
};
|
|
65
70
|
}
|
|
66
71
|
case "context.turn": {
|
|
@@ -68,8 +73,18 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
68
73
|
const text = turnContextText(input, descriptor);
|
|
69
74
|
return text ? { kind: "context", text } : NONE;
|
|
70
75
|
}
|
|
71
|
-
case "shell.pre":
|
|
72
|
-
|
|
76
|
+
case "shell.pre": {
|
|
77
|
+
const policy = usable(descriptor.shellPolicy.deny)
|
|
78
|
+
? shellPolicy(input.cwd, event.command)
|
|
79
|
+
: NONE;
|
|
80
|
+
if (policy.kind !== "none" || !usable(descriptor.events["write.pre"].support)) return policy;
|
|
81
|
+
const writes = shellWrites(event.command);
|
|
82
|
+
return writes.writes ? writeGate(input.cwd, writes.targets) : NONE;
|
|
83
|
+
}
|
|
84
|
+
case "write.pre":
|
|
85
|
+
return usable(descriptor.events["write.pre"].support)
|
|
86
|
+
? writeGate(input.cwd, event.paths)
|
|
87
|
+
: NONE;
|
|
73
88
|
case "subagent.start":
|
|
74
89
|
return {
|
|
75
90
|
kind: "context",
|
|
@@ -102,7 +117,7 @@ export const failureDecision = (
|
|
|
102
117
|
event: HookEventKind | null,
|
|
103
118
|
error: string,
|
|
104
119
|
): HookDecision => {
|
|
105
|
-
if (event === "shell.pre" || event === "tool.pre")
|
|
120
|
+
if (event === "shell.pre" || event === "tool.pre" || event === "write.pre")
|
|
106
121
|
return descriptor.shellPolicy.failClosed
|
|
107
122
|
? { kind: "deny", reason: error, unblock: null }
|
|
108
123
|
: NONE;
|
|
@@ -8,7 +8,15 @@ import type {
|
|
|
8
8
|
HostAdapter,
|
|
9
9
|
SessionSource,
|
|
10
10
|
} from "../protocol";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
commandText,
|
|
13
|
+
existingDirectory,
|
|
14
|
+
isRecord,
|
|
15
|
+
isWriteTool,
|
|
16
|
+
nonEmpty,
|
|
17
|
+
optionalText,
|
|
18
|
+
writePaths,
|
|
19
|
+
} from "./fields";
|
|
12
20
|
|
|
13
21
|
type ClaudeHookEvent =
|
|
14
22
|
| "SessionStart"
|
|
@@ -31,6 +39,7 @@ export const CLAUDE_CODE_DESCRIPTOR: HostDescriptor = {
|
|
|
31
39
|
"context.turn": { support: "native", native: "UserPromptSubmit" },
|
|
32
40
|
"shell.pre": { support: "native", native: "PreToolUse" },
|
|
33
41
|
"tool.pre": { support: "native", native: "PreToolUse" },
|
|
42
|
+
"write.pre": { support: "native", native: "PreToolUse" },
|
|
34
43
|
"shell.post": { support: "native", native: "PostToolUse" },
|
|
35
44
|
// SubagentStart output is additionalContext only: it cannot block or bind.
|
|
36
45
|
"subagent.start": { support: "native", native: "SubagentStart" },
|
|
@@ -140,6 +149,16 @@ const eventOf = (name: ClaudeHookEvent, value: Record<string, unknown>): Parsed
|
|
|
140
149
|
return { ok: true, event: { kind: "context.turn" } };
|
|
141
150
|
case "PreToolUse": {
|
|
142
151
|
if (!nonEmpty(value.tool_name)) return { ok: false, error: "tool_name is required" };
|
|
152
|
+
if (isWriteTool(value.tool_name))
|
|
153
|
+
return {
|
|
154
|
+
ok: true,
|
|
155
|
+
event: {
|
|
156
|
+
kind: "write.pre",
|
|
157
|
+
tool: value.tool_name,
|
|
158
|
+
paths: writePaths(value.tool_input),
|
|
159
|
+
toolUseId,
|
|
160
|
+
},
|
|
161
|
+
};
|
|
143
162
|
if (!SHELL_TOOLS.has(value.tool_name))
|
|
144
163
|
return { ok: true, event: { kind: "tool.pre", tool: value.tool_name, toolUseId } };
|
|
145
164
|
const command = toolCommand(value);
|
package/src/hooks/hosts/codex.ts
CHANGED
|
@@ -50,6 +50,9 @@ export const CODEX_DESCRIPTOR: HostDescriptor = {
|
|
|
50
50
|
"context.turn": undocumented,
|
|
51
51
|
"shell.pre": { support: "native", native: "PreToolUse" },
|
|
52
52
|
"tool.pre": { support: "native", native: "PreToolUse" },
|
|
53
|
+
// Codex PreToolUse intercepts shell calls, not apply_patch edits: the
|
|
54
|
+
// before-write gate stays advisory rather than half-enforced.
|
|
55
|
+
"write.pre": { support: "undocumented", native: null },
|
|
53
56
|
"shell.post": undocumented,
|
|
54
57
|
"subagent.start": { support: "native", native: "SubagentStart" },
|
|
55
58
|
"subagent.stop": { support: "native", native: "SubagentStop" },
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import type { HostDescriptor } from "../descriptor";
|
|
4
4
|
import type { HookDecision, HookEvent, HookEventKind, HostAdapter } from "../protocol";
|
|
5
|
-
import { existingDirectory, isRecord, nonEmpty } from "./fields";
|
|
5
|
+
import { existingDirectory, isRecord, isWriteTool, nonEmpty, writePaths } from "./fields";
|
|
6
6
|
|
|
7
7
|
export type CursorHookEvent =
|
|
8
8
|
| "sessionStart"
|
|
@@ -44,6 +44,8 @@ export const CURSOR_DESCRIPTOR: HostDescriptor = {
|
|
|
44
44
|
"context.turn": none,
|
|
45
45
|
"shell.pre": { support: "native", native: "beforeShellExecution" },
|
|
46
46
|
"tool.pre": { support: "native", native: "preToolUse" },
|
|
47
|
+
// preToolUse write tools; the file path is read from tool_input when present.
|
|
48
|
+
"write.pre": { support: "partial", native: "preToolUse" },
|
|
47
49
|
"shell.post": undocumented,
|
|
48
50
|
"subagent.start": { support: "native", native: "subagentStart" },
|
|
49
51
|
// subagentStop carries no stable child identity.
|
|
@@ -212,7 +214,14 @@ const protocolEvent = (input: CursorHookInput): HookEvent => {
|
|
|
212
214
|
// which branch name it targets.
|
|
213
215
|
return { kind: "shell.pre", command: input.command ?? "", toolUseId: null };
|
|
214
216
|
case "preToolUse":
|
|
215
|
-
return
|
|
217
|
+
return isWriteTool(input.tool_name)
|
|
218
|
+
? {
|
|
219
|
+
kind: "write.pre",
|
|
220
|
+
tool: input.tool_name ?? "",
|
|
221
|
+
paths: writePaths(input.tool_input),
|
|
222
|
+
toolUseId: null,
|
|
223
|
+
}
|
|
224
|
+
: { kind: "tool.pre", tool: input.tool_name ?? "", toolUseId: null };
|
|
216
225
|
case "subagentStart":
|
|
217
226
|
return {
|
|
218
227
|
kind: "subagent.start",
|
|
@@ -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' },
|
package/src/hooks/hosts/pi.ts
CHANGED
|
@@ -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 },
|
package/src/hooks/index.ts
CHANGED
|
@@ -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";
|
package/src/hooks/protocol.ts
CHANGED
|
@@ -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 },
|