@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
package/src/forge/types.ts
CHANGED
|
@@ -140,6 +140,28 @@ export type MergeResult = {
|
|
|
140
140
|
mergeSha: string | null;
|
|
141
141
|
};
|
|
142
142
|
|
|
143
|
+
/** The editable fields of a PR/MR (`workit pr ready|edit`). */
|
|
144
|
+
export type PrMeta = {
|
|
145
|
+
number: number;
|
|
146
|
+
url: string;
|
|
147
|
+
state: PrState;
|
|
148
|
+
draft: boolean;
|
|
149
|
+
title: string;
|
|
150
|
+
base: string;
|
|
151
|
+
headBranch: string;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** What `workit pr edit` changes; unset fields are left alone. */
|
|
155
|
+
export type PrEdit = {
|
|
156
|
+
title?: string;
|
|
157
|
+
body?: string;
|
|
158
|
+
base?: string;
|
|
159
|
+
addLabels: string[];
|
|
160
|
+
removeLabels: string[];
|
|
161
|
+
/** Logins; on GitHub `org/team` requests a team. */
|
|
162
|
+
addReviewers: string[];
|
|
163
|
+
};
|
|
164
|
+
|
|
143
165
|
/** A repository as the forge sees it: id and the repo it was forked from. */
|
|
144
166
|
export type RepoInfo = { id: number | null; parent: string | null };
|
|
145
167
|
|
|
@@ -163,4 +185,14 @@ export interface Forge {
|
|
|
163
185
|
merge(n: number, options: { sha: string; method: MergeMethod }): ForgeResult<MergeResult>;
|
|
164
186
|
/** Retarget a PR/MR to another base branch (S12 stacks). */
|
|
165
187
|
updateBase(n: number, base: string): ForgeResult<void>;
|
|
188
|
+
/** Title, draft flag, base and state of one PR/MR. */
|
|
189
|
+
prMeta(n: number): ForgeResult<PrMeta>;
|
|
190
|
+
/** Mark a draft ready (`draft: false`) or convert it back to a draft. */
|
|
191
|
+
setDraft(n: number, draft: boolean): ForgeResult<void>;
|
|
192
|
+
editPr(n: number, edit: PrEdit): ForgeResult<void>;
|
|
193
|
+
/** Reply to a review thread (GitHub review thread id, GitLab discussion id). */
|
|
194
|
+
replyThread(n: number, thread: string, body: string): ForgeResult<{ url: string | null }>;
|
|
195
|
+
resolveThread(n: number, thread: string): ForgeResult<void>;
|
|
196
|
+
/** One review thread of PR `n` read directly: null when it is not on that PR. */
|
|
197
|
+
threadState(n: number, thread: string): ForgeResult<"open" | "resolved" | null>;
|
|
166
198
|
}
|
package/src/hooks/handle.ts
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
import { sessionContextText, turnContextText } from "./context";
|
|
3
3
|
import type { HostDescriptor, Support } from "./descriptor";
|
|
4
4
|
import { shellPolicy } from "./policy";
|
|
5
|
+
import {
|
|
6
|
+
callKey,
|
|
7
|
+
NEXT_COMMAND,
|
|
8
|
+
noteRawCommit,
|
|
9
|
+
rawGitPost,
|
|
10
|
+
rawGitPre,
|
|
11
|
+
settlePendingCommit,
|
|
12
|
+
} from "./raw-git";
|
|
5
13
|
import { shellWrites, writeGate } from "./write-gate";
|
|
6
14
|
import type { HookDecision, HookEventKind, HookInput, HostAdapter, RenderedHook } from "./protocol";
|
|
7
15
|
|
|
@@ -74,13 +82,32 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
74
82
|
return text ? { kind: "context", text } : NONE;
|
|
75
83
|
}
|
|
76
84
|
case "shell.pre": {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
85
|
+
const canDeny = usable(descriptor.shellPolicy.deny);
|
|
86
|
+
const policy = canDeny ? shellPolicy(input.cwd, event.command) : NONE;
|
|
87
|
+
if (policy.kind !== "none") return policy;
|
|
88
|
+
// Without a post-tool event, the previous raw commit is recorded now.
|
|
89
|
+
const postTool = usable(descriptor.events["shell.post"].support);
|
|
90
|
+
if (!postTool) settlePendingCommit(input);
|
|
91
|
+
const raw = canDeny ? rawGitPre(input, event.command) : NONE;
|
|
92
|
+
if (raw.kind === "deny") return raw;
|
|
93
|
+
const writes = usable(descriptor.events["write.pre"].support)
|
|
94
|
+
? shellWrites(event.command)
|
|
95
|
+
: { writes: false, targets: null };
|
|
96
|
+
const gate = writes.writes ? writeGate(input.cwd, writes.targets) : NONE;
|
|
97
|
+
if (gate.kind === "deny") return gate;
|
|
98
|
+
// The command will run: note HEAD so the post-tool hook (or the next
|
|
99
|
+
// command, without one) can tell whether a raw commit moved it.
|
|
100
|
+
noteRawCommit(
|
|
101
|
+
input,
|
|
102
|
+
event.command,
|
|
103
|
+
postTool ? callKey(event.toolUseId, event.command) : NEXT_COMMAND,
|
|
104
|
+
);
|
|
105
|
+
return raw;
|
|
83
106
|
}
|
|
107
|
+
case "shell.post":
|
|
108
|
+
if (usable(descriptor.events["shell.post"].support))
|
|
109
|
+
rawGitPost(input, event.command, callKey(event.toolUseId, event.command));
|
|
110
|
+
return NONE;
|
|
84
111
|
case "write.pre":
|
|
85
112
|
return usable(descriptor.events["write.pre"].support)
|
|
86
113
|
? writeGate(input.cwd, event.paths)
|
|
@@ -99,7 +126,6 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
99
126
|
// Host permission policy owns other tools; attestation, prompt and stop
|
|
100
127
|
// control arrive with the CLI-observed evidence model.
|
|
101
128
|
case "tool.pre":
|
|
102
|
-
case "shell.post":
|
|
103
129
|
case "subagent.stop":
|
|
104
130
|
case "prompt.submit":
|
|
105
131
|
case "stop":
|
|
@@ -135,6 +135,8 @@ type Parsed = { ok: true; event: HookEvent } | { ok: false; error: string };
|
|
|
135
135
|
|
|
136
136
|
/** Claude's shell tools: Bash everywhere, PowerShell on Windows. */
|
|
137
137
|
const SHELL_TOOLS = new Set(["Bash", "PowerShell"]);
|
|
138
|
+
const dialectOf = (value: Record<string, unknown>) =>
|
|
139
|
+
value.tool_name === "PowerShell" ? ("powershell" as const) : ("posix" as const);
|
|
138
140
|
const toolCommand = (value: Record<string, unknown>): string | null =>
|
|
139
141
|
isRecord(value.tool_input) ? commandText(value.tool_input.command) : null;
|
|
140
142
|
|
|
@@ -163,7 +165,7 @@ const eventOf = (name: ClaudeHookEvent, value: Record<string, unknown>): Parsed
|
|
|
163
165
|
return { ok: true, event: { kind: "tool.pre", tool: value.tool_name, toolUseId } };
|
|
164
166
|
const command = toolCommand(value);
|
|
165
167
|
return command
|
|
166
|
-
? { ok: true, event: { kind: "shell.pre", command, toolUseId } }
|
|
168
|
+
? { ok: true, event: { kind: "shell.pre", command, toolUseId, dialect: dialectOf(value) } }
|
|
167
169
|
: { ok: false, error: `tool_input.command is required for ${value.tool_name}` };
|
|
168
170
|
}
|
|
169
171
|
case "PostToolUse": {
|
|
@@ -179,6 +181,7 @@ const eventOf = (name: ClaudeHookEvent, value: Record<string, unknown>): Parsed
|
|
|
179
181
|
stdout: optionalText(response.stdout) ?? "",
|
|
180
182
|
exitCode: typeof response.exit_code === "number" ? response.exit_code : null,
|
|
181
183
|
toolUseId,
|
|
184
|
+
dialect: dialectOf(value),
|
|
182
185
|
},
|
|
183
186
|
};
|
|
184
187
|
}
|
|
@@ -222,7 +225,14 @@ const eventOf = (name: ClaudeHookEvent, value: Record<string, unknown>): Parsed
|
|
|
222
225
|
}
|
|
223
226
|
};
|
|
224
227
|
|
|
225
|
-
|
|
228
|
+
/** Events whose hookSpecificOutput takes additionalContext (PreToolUse: a raw-git nudge). */
|
|
229
|
+
const CONTEXT_EVENTS = new Set([
|
|
230
|
+
"SessionStart",
|
|
231
|
+
"UserPromptSubmit",
|
|
232
|
+
"SubagentStart",
|
|
233
|
+
"PreToolUse",
|
|
234
|
+
"PostToolUse",
|
|
235
|
+
]);
|
|
226
236
|
|
|
227
237
|
/** Never emits `allow`: that would bypass the user's own permission prompt. */
|
|
228
238
|
const render = (decision: HookDecision, native: string | null) => {
|
package/src/hooks/hosts/codex.ts
CHANGED
|
@@ -4,7 +4,12 @@ import type { HookDecision, HookEvent, HookEventKind, HostAdapter } from "../pro
|
|
|
4
4
|
import { commandText, existingDirectory, isRecord, nonEmpty, optionalText } from "./fields";
|
|
5
5
|
|
|
6
6
|
export type CodexHost = "codex_cli" | "codex_desktop";
|
|
7
|
-
export type CodexHookEvent =
|
|
7
|
+
export type CodexHookEvent =
|
|
8
|
+
| "SessionStart"
|
|
9
|
+
| "PreToolUse"
|
|
10
|
+
| "PostToolUse"
|
|
11
|
+
| "SubagentStart"
|
|
12
|
+
| "SubagentStop";
|
|
8
13
|
type SessionSource = "startup" | "resume" | "clear" | "compact";
|
|
9
14
|
|
|
10
15
|
export type CodexHookInput = {
|
|
@@ -21,6 +26,8 @@ export type CodexHookInput = {
|
|
|
21
26
|
/** The shell command as one string; argv arrays are joined. */
|
|
22
27
|
command?: string;
|
|
23
28
|
tool_use_id?: string;
|
|
29
|
+
/** PostToolUse: the model-facing tool output. */
|
|
30
|
+
tool_response?: string;
|
|
24
31
|
agent_id?: string;
|
|
25
32
|
agent_type?: string;
|
|
26
33
|
agent_transcript_path?: string | null;
|
|
@@ -53,7 +60,7 @@ export const CODEX_DESCRIPTOR: HostDescriptor = {
|
|
|
53
60
|
// Codex PreToolUse intercepts shell calls, not apply_patch edits: the
|
|
54
61
|
// before-write gate stays advisory rather than half-enforced.
|
|
55
62
|
"write.pre": { support: "undocumented", native: null },
|
|
56
|
-
"shell.post":
|
|
63
|
+
"shell.post": { support: "native", native: "PostToolUse" },
|
|
57
64
|
"subagent.start": { support: "native", native: "SubagentStart" },
|
|
58
65
|
"subagent.stop": { support: "native", native: "SubagentStop" },
|
|
59
66
|
"prompt.submit": undocumented,
|
|
@@ -74,7 +81,7 @@ export const CODEX_DESCRIPTOR: HostDescriptor = {
|
|
|
74
81
|
worktreeIsolation: "undocumented",
|
|
75
82
|
maxConcurrency: "undocumented",
|
|
76
83
|
},
|
|
77
|
-
provenance: { sessionId: "native", agentIdOnTool: "partial", postToolObserve: "
|
|
84
|
+
provenance: { sessionId: "native", agentIdOnTool: "partial", postToolObserve: "native" },
|
|
78
85
|
interaction: { questions: "none", writeBoundary: "partial" },
|
|
79
86
|
stopControl: "undocumented",
|
|
80
87
|
shellAvailable: "native",
|
|
@@ -146,6 +153,7 @@ export const codexDescriptor = (host: CodexHost): HostDescriptor => ({ ...CODEX_
|
|
|
146
153
|
const EVENTS: Record<CodexHookEvent, HookEventKind> = {
|
|
147
154
|
SessionStart: "session.start",
|
|
148
155
|
PreToolUse: "shell.pre",
|
|
156
|
+
PostToolUse: "shell.post",
|
|
149
157
|
SubagentStart: "subagent.start",
|
|
150
158
|
SubagentStop: "subagent.stop",
|
|
151
159
|
};
|
|
@@ -166,10 +174,10 @@ export const parseCodexHookInput = (value: unknown): CodexParseResult => {
|
|
|
166
174
|
const event = value.hook_event_name as CodexHookEvent;
|
|
167
175
|
const cwd = existingDirectory(value.cwd);
|
|
168
176
|
if (!cwd) return { ok: false, error: "cwd must be an existing absolute directory" };
|
|
169
|
-
|
|
170
|
-
|
|
177
|
+
const toolEvent = event === "PreToolUse" || event === "PostToolUse";
|
|
178
|
+
if (toolEvent && !nonEmpty(value.tool_name)) return { ok: false, error: "tool_name is required" };
|
|
171
179
|
const command = isRecord(value.tool_input) ? commandText(value.tool_input.command) : null;
|
|
172
|
-
if (
|
|
180
|
+
if (toolEvent && isShellTool(value.tool_name) && !command)
|
|
173
181
|
return { ok: false, error: "tool_input.command is required for shell tools" };
|
|
174
182
|
if (event === "SubagentStart" && !nonEmpty(value.agent_id))
|
|
175
183
|
return { ok: false, error: "agent_id is required" };
|
|
@@ -188,7 +196,10 @@ export const parseCodexHookInput = (value: unknown): CodexParseResult => {
|
|
|
188
196
|
: {}),
|
|
189
197
|
...(nonEmpty(value.turn_id) ? { turn_id: value.turn_id } : {}),
|
|
190
198
|
...(nonEmpty(value.tool_name) ? { tool_name: value.tool_name } : {}),
|
|
191
|
-
...(
|
|
199
|
+
...(toolEvent ? { tool_input: value.tool_input } : {}),
|
|
200
|
+
...(event === "PostToolUse" && typeof value.tool_response === "string"
|
|
201
|
+
? { tool_response: value.tool_response }
|
|
202
|
+
: {}),
|
|
192
203
|
...(command ? { command } : {}),
|
|
193
204
|
...(nonEmpty(value.tool_use_id) ? { tool_use_id: value.tool_use_id } : {}),
|
|
194
205
|
...(nonEmpty(value.agent_id) ? { agent_id: value.agent_id } : {}),
|
|
@@ -218,6 +229,20 @@ const protocolEvent = (input: CodexHookInput): HookEvent => {
|
|
|
218
229
|
}
|
|
219
230
|
: { kind: "tool.pre", tool: input.tool_name!, toolUseId };
|
|
220
231
|
}
|
|
232
|
+
case "PostToolUse": {
|
|
233
|
+
const toolUseId = input.tool_use_id ?? null;
|
|
234
|
+
return isShellTool(input.tool_name)
|
|
235
|
+
? {
|
|
236
|
+
kind: "shell.post",
|
|
237
|
+
command: input.command ?? "",
|
|
238
|
+
stdout: input.tool_response ?? "",
|
|
239
|
+
// Codex reports the model-facing output, not an exit code.
|
|
240
|
+
exitCode: null,
|
|
241
|
+
toolUseId,
|
|
242
|
+
}
|
|
243
|
+
: // A non-shell tool result carries nothing workit observes.
|
|
244
|
+
{ kind: "tool.pre", tool: input.tool_name!, toolUseId };
|
|
245
|
+
}
|
|
221
246
|
case "SubagentStart":
|
|
222
247
|
return {
|
|
223
248
|
kind: "subagent.start",
|
|
@@ -288,5 +313,5 @@ export const codexAdapter: HostAdapter = {
|
|
|
288
313
|
},
|
|
289
314
|
render,
|
|
290
315
|
addendum: (input) =>
|
|
291
|
-
`<workit-codex-mutations>Codex MCP is read-only: unattested callers cannot mutate. Run workit verbs with the workit CLI on the shell (node_modules/.bin/workit, or npx -y @brainervirus/workit-cli): check, git branch|commit|push, pr, ci, stack, ledger, handoff; task-family mutations take --json${input.session.id ? ` --actor ${input.session.id}` : ""}. A verifier or reviewer records workit ledger verdict under a session the lead assigns (WORKIT_SESSION_ID=<lead>-v<n>), never the author's. Merge and release need a workspace grant (workit grant show); a question answer is not host permission.</workit-codex-mutations>`,
|
|
316
|
+
`<workit-codex-mutations>Codex MCP is read-only: unattested callers cannot mutate. Run workit verbs with the workit CLI on the shell (node_modules/.bin/workit, or npx -y @brainervirus/workit-cli): check, git branch|commit|push, pr, ci, stack, ledger, handoff; task-family mutations take --json${input.session.id ? ` --actor ${input.session.id}` : ""}. workit acts as this thread (CODEX_THREAD_ID${input.session.id ? `=${input.session.id}` : ""}) unless WORKIT_SESSION_ID is set. A verifier or reviewer records workit ledger verdict under a session the lead assigns (WORKIT_HOST=codex_cli WORKIT_SESSION_ID=<lead>-v<n>), never the author's. Merge and release need a workspace grant (workit grant show); a question answer is not host permission.</workit-codex-mutations>`,
|
|
292
317
|
};
|
|
@@ -77,8 +77,10 @@ export const CURSOR_DESCRIPTOR: HostDescriptor = {
|
|
|
77
77
|
interaction: { questions: "none", writeBoundary: "partial" },
|
|
78
78
|
stopControl: "undocumented",
|
|
79
79
|
shellAvailable: "native",
|
|
80
|
-
//
|
|
81
|
-
|
|
80
|
+
// Hooks run the plugin's launcher against a local runtime (bundled dist or a
|
|
81
|
+
// global bin); only an install with neither falls back to a pinned,
|
|
82
|
+
// --prefer-offline npx.
|
|
83
|
+
perEventCost: "low",
|
|
82
84
|
capabilities: [
|
|
83
85
|
{
|
|
84
86
|
name: "known_product_writes",
|
|
@@ -278,6 +280,10 @@ const render = (decision: HookDecision, native: string | null) => {
|
|
|
278
280
|
json: cursorDeny(decision.reason),
|
|
279
281
|
exitCode: native !== null && BLOCKING.has(native) ? 2 : 0,
|
|
280
282
|
};
|
|
283
|
+
// beforeShellExecution has no context channel: a raw-git nudge rides on
|
|
284
|
+
// agent_message next to Cursor's required permission answer.
|
|
285
|
+
if (decision.kind === "context" && native === "beforeShellExecution")
|
|
286
|
+
return { json: { permission: "allow", agent_message: decision.text }, exitCode: 0 };
|
|
281
287
|
if (decision.kind === "context")
|
|
282
288
|
return { json: { additional_context: decision.text }, exitCode: 0 };
|
|
283
289
|
if (decision.kind === "notice")
|
|
@@ -319,4 +325,10 @@ export const cursorAdapter: HostAdapter = {
|
|
|
319
325
|
};
|
|
320
326
|
},
|
|
321
327
|
render,
|
|
328
|
+
// Cursor's shell carries no conversation id (sessionStart env reaches hooks
|
|
329
|
+
// only), so the agent is told the id its `workit` calls should act as.
|
|
330
|
+
addendum: (input) =>
|
|
331
|
+
input.session.id
|
|
332
|
+
? `<workit-session>Cursor puts no session id in the shell: prefix workit commands that record authorship with WORKIT_SESSION_ID=${input.session.id} (e.g. \`WORKIT_SESSION_ID=${input.session.id} workit git commit -m …\`). A verifier uses its own id, never this one.</workit-session>`
|
|
333
|
+
: null,
|
|
322
334
|
};
|
package/src/hooks/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ export {
|
|
|
18
18
|
} from "./context";
|
|
19
19
|
export { shellPolicy } from "./policy";
|
|
20
20
|
export { shellWrites, writeGate } from "./write-gate";
|
|
21
|
+
export { callKey, noteRawCommit, rawGitPost, rawGitPre } from "./raw-git";
|
|
21
22
|
export { isWriteTool, writePaths } from "./hosts/fields";
|
|
22
23
|
export { dispatchHook, failureDecision, handleHook, type HookDeps } from "./handle";
|
|
23
24
|
export { HOOK_ADAPTERS, runHookProcess } from "./run";
|
package/src/hooks/protocol.ts
CHANGED
|
@@ -17,7 +17,13 @@ export type HookEvent =
|
|
|
17
17
|
| { kind: "session.start"; source: SessionSource }
|
|
18
18
|
/** Per-turn injection (OpenCode session context, Pi before_agent_start, Claude UserPromptSubmit). */
|
|
19
19
|
| { kind: "context.turn" }
|
|
20
|
-
| {
|
|
20
|
+
| {
|
|
21
|
+
kind: "shell.pre";
|
|
22
|
+
command: string;
|
|
23
|
+
toolUseId: string | null;
|
|
24
|
+
/** The shell that runs `command`; posix when absent. */
|
|
25
|
+
dialect?: "posix" | "powershell";
|
|
26
|
+
}
|
|
21
27
|
/** A pre-tool gate for a non-shell tool. Host permission policy owns these. */
|
|
22
28
|
| { kind: "tool.pre"; tool: string; toolUseId: string | null }
|
|
23
29
|
/** A file-writing tool (Edit, Write, apply_patch…): the before-write gate (S17). */
|
|
@@ -28,6 +34,7 @@ export type HookEvent =
|
|
|
28
34
|
stdout: string;
|
|
29
35
|
exitCode: number | null;
|
|
30
36
|
toolUseId: string | null;
|
|
37
|
+
dialect?: "posix" | "powershell";
|
|
31
38
|
}
|
|
32
39
|
| { kind: "subagent.start"; agentId: string; agentType: string; task: string | null }
|
|
33
40
|
| {
|