@brainervirus/workit-core 2.1.5 → 2.2.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 +2 -1
- package/src/core/doctor.ts +45 -0
- package/src/core/methods.ts +5 -0
- package/src/core/store-lock.ts +301 -0
- package/src/core/task-contract.ts +121 -1
- package/src/core/task-store.ts +395 -77
- package/src/core.ts +1 -1
- package/src/git/rev.ts +736 -0
- package/src/{core/session-context.ts → hooks/context.ts} +95 -8
- package/src/hooks/descriptor.ts +113 -0
- package/src/hooks/handle.ts +111 -0
- package/src/hooks/hosts/claude-code.ts +281 -0
- package/src/hooks/hosts/codex.ts +298 -0
- package/src/hooks/hosts/cursor.ts +321 -0
- package/src/hooks/hosts/fields.ts +55 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +80 -0
- package/src/hooks/index.ts +43 -0
- package/src/hooks/policy.ts +14 -0
- package/src/hooks/protocol.ts +89 -0
- package/src/hooks/run.ts +52 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// OpenCode V2: an in-process plugin; hooks call core/hooks functions directly.
|
|
2
|
+
import type { HostDescriptor } from "../descriptor";
|
|
3
|
+
|
|
4
|
+
export const OPENCODE_DESCRIPTOR: HostDescriptor = {
|
|
5
|
+
host: "opencode",
|
|
6
|
+
label: "OpenCode",
|
|
7
|
+
verifiedAgainst: "@opencode-ai/plugin 1.18.30",
|
|
8
|
+
docs: ["https://opencode.ai/docs/plugins/"],
|
|
9
|
+
transport: "in-process-plugin",
|
|
10
|
+
events: {
|
|
11
|
+
// No separate start event: context is injected on every agent-loop call.
|
|
12
|
+
"session.start": { support: "none", native: null },
|
|
13
|
+
"context.turn": { support: "native", native: 'session.hook("context")' },
|
|
14
|
+
"shell.pre": { support: "native", native: 'permission.hook("evaluate")' },
|
|
15
|
+
"tool.pre": { support: "native", native: 'tool.hook("execute.before")' },
|
|
16
|
+
"shell.post": { support: "native", native: 'tool.hook("execute.after")' },
|
|
17
|
+
"subagent.start": { support: "native", native: 'tool.hook("execute.before") subagent' },
|
|
18
|
+
"subagent.stop": { support: "native", native: 'tool.hook("execute.after") subagent' },
|
|
19
|
+
"prompt.submit": { support: "undocumented", native: null },
|
|
20
|
+
"compact.pre": { support: "native", native: 'session.hook("compaction")' },
|
|
21
|
+
stop: { support: "partial", native: "session.idle" },
|
|
22
|
+
},
|
|
23
|
+
shellPolicy: { deny: "native", channel: "effect", failClosed: false },
|
|
24
|
+
context: {
|
|
25
|
+
sessionStart: "none",
|
|
26
|
+
perTurn: "native",
|
|
27
|
+
afterCompact: "native",
|
|
28
|
+
task: "session-bound",
|
|
29
|
+
},
|
|
30
|
+
subagents: {
|
|
31
|
+
identity: "native",
|
|
32
|
+
parentBinding: "native",
|
|
33
|
+
blockStart: "native",
|
|
34
|
+
worktreeIsolation: "undocumented",
|
|
35
|
+
maxConcurrency: "undocumented",
|
|
36
|
+
},
|
|
37
|
+
provenance: { sessionId: "native", agentIdOnTool: "native", postToolObserve: "native" },
|
|
38
|
+
interaction: { questions: "native", writeBoundary: "partial" },
|
|
39
|
+
stopControl: "partial",
|
|
40
|
+
shellAvailable: "native",
|
|
41
|
+
perEventCost: "low",
|
|
42
|
+
capabilities: [
|
|
43
|
+
{
|
|
44
|
+
name: "interactive_decision",
|
|
45
|
+
surface: "question",
|
|
46
|
+
refs: ["question"],
|
|
47
|
+
requires: ["interaction.questions"],
|
|
48
|
+
assurance: "enforced",
|
|
49
|
+
reason: "native question answers are observed by tool.execute.after and consumed once",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "known_product_writes",
|
|
53
|
+
surface: "edit/shell",
|
|
54
|
+
refs: ["permission.evaluate"],
|
|
55
|
+
requires: [],
|
|
56
|
+
assurance: "unavailable",
|
|
57
|
+
reason:
|
|
58
|
+
"file writes are host-policy; OpenCode native permissions govern them, workit no longer gates write tools",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "direct_child_workers",
|
|
62
|
+
surface: "subagent",
|
|
63
|
+
refs: ["subagent"],
|
|
64
|
+
requires: ["subagents.parentBinding", "subagents.blockStart"],
|
|
65
|
+
assurance: "enforced",
|
|
66
|
+
reason:
|
|
67
|
+
"nested subagent launches are denied and observed child sessions are parent-bound before they may own a worker",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "fresh-context-review",
|
|
71
|
+
surface: "subagent",
|
|
72
|
+
refs: ["subagent"],
|
|
73
|
+
requires: ["event:subagent.start"],
|
|
74
|
+
assurance: "agent_guided",
|
|
75
|
+
reason:
|
|
76
|
+
"independent review runs as a native child session; evidence evaluation enforces creator and duplicate-reviewer exclusion",
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Pi: an in-process extension; its events call core/hooks functions directly.
|
|
2
|
+
import type { HostDescriptor } from "../descriptor";
|
|
3
|
+
|
|
4
|
+
export const PI_DESCRIPTOR: HostDescriptor = {
|
|
5
|
+
host: "pi",
|
|
6
|
+
label: "Pi",
|
|
7
|
+
verifiedAgainst: "@earendil-works/pi-coding-agent 0.85.1",
|
|
8
|
+
docs: ["https://github.com/earendil-works/pi-coding-agent"],
|
|
9
|
+
transport: "in-process-plugin",
|
|
10
|
+
events: {
|
|
11
|
+
"session.start": { support: "native", native: "session_start" },
|
|
12
|
+
"context.turn": { support: "native", native: "before_agent_start" },
|
|
13
|
+
"shell.pre": { support: "native", native: "tool_call" },
|
|
14
|
+
"tool.pre": { support: "native", native: "tool_call" },
|
|
15
|
+
"shell.post": { support: "native", native: "tool_result" },
|
|
16
|
+
// Workers are supervised stock-Pi processes, not host subagents.
|
|
17
|
+
"subagent.start": { support: "none", native: null },
|
|
18
|
+
"subagent.stop": { support: "none", native: null },
|
|
19
|
+
"prompt.submit": { support: "undocumented", native: null },
|
|
20
|
+
"compact.pre": { support: "native", native: "session_before_compact" },
|
|
21
|
+
stop: { support: "none", native: null },
|
|
22
|
+
},
|
|
23
|
+
shellPolicy: { deny: "native", channel: "block", failClosed: false },
|
|
24
|
+
context: {
|
|
25
|
+
sessionStart: "native",
|
|
26
|
+
perTurn: "native",
|
|
27
|
+
afterCompact: "native",
|
|
28
|
+
task: "session-bound",
|
|
29
|
+
},
|
|
30
|
+
subagents: {
|
|
31
|
+
identity: "none",
|
|
32
|
+
parentBinding: "none",
|
|
33
|
+
blockStart: "none",
|
|
34
|
+
worktreeIsolation: "none",
|
|
35
|
+
maxConcurrency: "undocumented",
|
|
36
|
+
},
|
|
37
|
+
provenance: { sessionId: "native", agentIdOnTool: "none", postToolObserve: "native" },
|
|
38
|
+
interaction: { questions: "native", writeBoundary: "native" },
|
|
39
|
+
stopControl: "none",
|
|
40
|
+
shellAvailable: "native",
|
|
41
|
+
perEventCost: "low",
|
|
42
|
+
capabilities: [
|
|
43
|
+
{
|
|
44
|
+
name: "product_write_interception",
|
|
45
|
+
surface: "write/edit tool_call",
|
|
46
|
+
refs: ["tool_call"],
|
|
47
|
+
requires: ["interaction.writeBoundary"],
|
|
48
|
+
assurance: "enforced",
|
|
49
|
+
reason:
|
|
50
|
+
"Pi exposes a before-tool boundary for known built-in write tools; it enforces project trust while file targets stay host-policy.",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "interactive_decision",
|
|
54
|
+
surface: "ui.confirm",
|
|
55
|
+
refs: ["ui.confirm"],
|
|
56
|
+
requires: ["interaction.questions"],
|
|
57
|
+
observed: ["ui"],
|
|
58
|
+
assurance: "enforced",
|
|
59
|
+
reason: "Pi supplies a native confirmation receipt when dialog UI is available.",
|
|
60
|
+
unavailableReason: "Pi is running without dialog UI, so required decisions need user input.",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "arbitrary_shell_write",
|
|
64
|
+
surface: "bash",
|
|
65
|
+
refs: ["tool_call"],
|
|
66
|
+
requires: ["shellAvailable"],
|
|
67
|
+
assurance: "agent_guided",
|
|
68
|
+
reason: "Pi extensions do not sandbox arbitrary shell commands.",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: "fresh-context-review",
|
|
72
|
+
surface: "supervised_worker",
|
|
73
|
+
refs: ["worker"],
|
|
74
|
+
requires: [],
|
|
75
|
+
assurance: "agent_guided",
|
|
76
|
+
reason:
|
|
77
|
+
"independent review runs as a supervised stock-Pi process; evidence evaluation enforces reviewer exclusivity",
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Shared host-hook protocol. Hook bundles import this, not the core barrel.
|
|
2
|
+
export * from "./protocol";
|
|
3
|
+
export {
|
|
4
|
+
capabilitiesFor,
|
|
5
|
+
support,
|
|
6
|
+
type Axis,
|
|
7
|
+
type CapabilityRule,
|
|
8
|
+
type HostDescriptor,
|
|
9
|
+
type Support,
|
|
10
|
+
} from "./descriptor";
|
|
11
|
+
export {
|
|
12
|
+
currentTaskEntry,
|
|
13
|
+
sessionCompactContext,
|
|
14
|
+
sessionContextText,
|
|
15
|
+
turnContextText,
|
|
16
|
+
unfinishedTaskOffer,
|
|
17
|
+
type SessionHandle,
|
|
18
|
+
} from "./context";
|
|
19
|
+
export { shellPolicy } from "./policy";
|
|
20
|
+
export { dispatchHook, failureDecision, handleHook, type HookDeps } from "./handle";
|
|
21
|
+
export { HOOK_ADAPTERS, runHookProcess } from "./run";
|
|
22
|
+
export { CLAUDE_CODE_DESCRIPTOR, claudeCodeAdapter } from "./hosts/claude-code";
|
|
23
|
+
export {
|
|
24
|
+
CODEX_DESCRIPTOR,
|
|
25
|
+
codexAdapter,
|
|
26
|
+
codexDescriptor,
|
|
27
|
+
detectCodexSurface,
|
|
28
|
+
parseCodexHookInput,
|
|
29
|
+
type CodexHookEvent,
|
|
30
|
+
type CodexHookInput,
|
|
31
|
+
type CodexHost,
|
|
32
|
+
} from "./hosts/codex";
|
|
33
|
+
export {
|
|
34
|
+
CURSOR_DESCRIPTOR,
|
|
35
|
+
cursorAdapter,
|
|
36
|
+
cursorDeny,
|
|
37
|
+
cursorWorkspaceRoot,
|
|
38
|
+
parseCursorHookInput,
|
|
39
|
+
type CursorHookEvent,
|
|
40
|
+
type CursorHookInput,
|
|
41
|
+
} from "./hosts/cursor";
|
|
42
|
+
export { OPENCODE_DESCRIPTOR } from "./hosts/opencode";
|
|
43
|
+
export { PI_DESCRIPTOR } from "./hosts/pi";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { shellBranchPolicyViolation } from "../core/route-intent";
|
|
2
|
+
import type { HookDecision } from "./protocol";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Branch policy for one shell command: only direct literal branch creation
|
|
6
|
+
* onto a noncompliant name is denied. Every other command keeps the host's own
|
|
7
|
+
* permission decision. The reason carries the policy's correction.
|
|
8
|
+
*/
|
|
9
|
+
export const shellPolicy = (cwd: string, command: string): HookDecision => {
|
|
10
|
+
const policy = shellBranchPolicyViolation(cwd, command);
|
|
11
|
+
return policy && !policy.ok
|
|
12
|
+
? { kind: "deny", reason: `branch_policy_denied: ${policy.error}`, unblock: null }
|
|
13
|
+
: { kind: "none" };
|
|
14
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// The shared host-hook protocol: every host adapter parses its native payload
|
|
2
|
+
// into a HookInput and renders a HookDecision back. Types only, no I/O.
|
|
3
|
+
import type { HostDescriptor } from "./descriptor";
|
|
4
|
+
|
|
5
|
+
export type HostId = "claude_code" | "opencode" | "codex_cli" | "codex_desktop" | "cursor" | "pi";
|
|
6
|
+
|
|
7
|
+
export type Session = {
|
|
8
|
+
id: string;
|
|
9
|
+
agentId: string | null;
|
|
10
|
+
agentType: string | null;
|
|
11
|
+
parentId: string | null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type SessionSource = "startup" | "resume" | "clear" | "compact" | "fork";
|
|
15
|
+
|
|
16
|
+
export type HookEvent =
|
|
17
|
+
| { kind: "session.start"; source: SessionSource }
|
|
18
|
+
/** Per-turn injection (OpenCode session context, Pi before_agent_start, Claude UserPromptSubmit). */
|
|
19
|
+
| { kind: "context.turn" }
|
|
20
|
+
| { kind: "shell.pre"; command: string; toolUseId: string | null }
|
|
21
|
+
/** A pre-tool gate for a non-shell tool. Host permission policy owns these. */
|
|
22
|
+
| { kind: "tool.pre"; tool: string; toolUseId: string | null }
|
|
23
|
+
| {
|
|
24
|
+
kind: "shell.post";
|
|
25
|
+
command: string;
|
|
26
|
+
stdout: string;
|
|
27
|
+
exitCode: number | null;
|
|
28
|
+
toolUseId: string | null;
|
|
29
|
+
}
|
|
30
|
+
| { kind: "subagent.start"; agentId: string; agentType: string; task: string | null }
|
|
31
|
+
| {
|
|
32
|
+
kind: "subagent.stop";
|
|
33
|
+
agentId: string | null;
|
|
34
|
+
agentType: string | null;
|
|
35
|
+
lastMessage: string | null;
|
|
36
|
+
stopHookActive: boolean;
|
|
37
|
+
}
|
|
38
|
+
| { kind: "prompt.submit"; prompt: string; source: string | null }
|
|
39
|
+
| { kind: "compact.pre"; trigger: "manual" | "auto" }
|
|
40
|
+
| { kind: "stop"; lastMessage: string | null; stopHookActive: boolean };
|
|
41
|
+
|
|
42
|
+
export type HookEventKind = HookEvent["kind"];
|
|
43
|
+
|
|
44
|
+
export const HOOK_EVENT_KINDS = [
|
|
45
|
+
"session.start",
|
|
46
|
+
"context.turn",
|
|
47
|
+
"shell.pre",
|
|
48
|
+
"tool.pre",
|
|
49
|
+
"shell.post",
|
|
50
|
+
"subagent.start",
|
|
51
|
+
"subagent.stop",
|
|
52
|
+
"prompt.submit",
|
|
53
|
+
"compact.pre",
|
|
54
|
+
"stop",
|
|
55
|
+
] as const satisfies readonly HookEventKind[];
|
|
56
|
+
|
|
57
|
+
export type HookInput = {
|
|
58
|
+
host: HostId;
|
|
59
|
+
cwd: string;
|
|
60
|
+
session: Session;
|
|
61
|
+
permissionMode: string | null;
|
|
62
|
+
transcriptPath: string | null;
|
|
63
|
+
event: HookEvent;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type HookDecision =
|
|
67
|
+
| { kind: "none" }
|
|
68
|
+
/** Model-visible additional context. */
|
|
69
|
+
| { kind: "context"; text: string }
|
|
70
|
+
/** The reason text always carries its own correction (principle 3). */
|
|
71
|
+
| { kind: "deny"; reason: string; unblock: string | null }
|
|
72
|
+
/** Stop/SubagentStop: keep working. */
|
|
73
|
+
| { kind: "continue"; reason: string }
|
|
74
|
+
| { kind: "notice"; userMessage: string };
|
|
75
|
+
|
|
76
|
+
/** A hook-process parse: the native event name is kept for rendering. */
|
|
77
|
+
export type ParsedHook =
|
|
78
|
+
| { ok: true; input: HookInput; native: string }
|
|
79
|
+
| { ok: false; error: string; native: string | null; event: HookEventKind | null };
|
|
80
|
+
|
|
81
|
+
export type RenderedHook = { json: Record<string, unknown>; exitCode: number };
|
|
82
|
+
|
|
83
|
+
export type HostAdapter = {
|
|
84
|
+
descriptor: HostDescriptor;
|
|
85
|
+
parse(raw: unknown, env: NodeJS.ProcessEnv): ParsedHook;
|
|
86
|
+
render(decision: HookDecision, native: string | null): RenderedHook;
|
|
87
|
+
/** Host-specific lines appended inside the session-start contract. */
|
|
88
|
+
addendum?(input: HookInput): string | null;
|
|
89
|
+
};
|
package/src/hooks/run.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Hook-process entry: stdin JSON → parse → handle → render → stdout.
|
|
2
|
+
import { dispatchHook } from "./handle";
|
|
3
|
+
import { claudeCodeAdapter } from "./hosts/claude-code";
|
|
4
|
+
import { codexAdapter } from "./hosts/codex";
|
|
5
|
+
import { cursorAdapter } from "./hosts/cursor";
|
|
6
|
+
import type { HostAdapter, HostId } from "./protocol";
|
|
7
|
+
|
|
8
|
+
type ProcessHost = Exclude<HostId, "opencode" | "pi">;
|
|
9
|
+
|
|
10
|
+
export const HOOK_ADAPTERS: Record<ProcessHost, HostAdapter> = {
|
|
11
|
+
claude_code: claudeCodeAdapter,
|
|
12
|
+
codex_cli: codexAdapter,
|
|
13
|
+
codex_desktop: codexAdapter,
|
|
14
|
+
cursor: cursorAdapter,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type Sink = { write(chunk: string): unknown };
|
|
18
|
+
|
|
19
|
+
/** Runs one hook invocation and returns the process exit code. A broken
|
|
20
|
+
* payload never throws: the host's fail policy decides what is written. */
|
|
21
|
+
export async function runHookProcess(
|
|
22
|
+
host: ProcessHost | HostAdapter,
|
|
23
|
+
stdin: AsyncIterable<unknown> | Iterable<unknown>,
|
|
24
|
+
stdout: Sink,
|
|
25
|
+
stderr: Sink = process.stderr,
|
|
26
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
27
|
+
): Promise<number> {
|
|
28
|
+
const adapter = typeof host === "string" ? HOOK_ADAPTERS[host] : host;
|
|
29
|
+
let text = "";
|
|
30
|
+
for await (const chunk of stdin) text += String(chunk);
|
|
31
|
+
let raw: unknown;
|
|
32
|
+
try {
|
|
33
|
+
raw = JSON.parse(text || "{}");
|
|
34
|
+
} catch {
|
|
35
|
+
raw = undefined;
|
|
36
|
+
}
|
|
37
|
+
const result = dispatchHook(adapter, raw, env);
|
|
38
|
+
if (raw === undefined || result.error)
|
|
39
|
+
stderr.write(
|
|
40
|
+
`[workit] hook input rejected: ${raw === undefined ? "invalid JSON hook input" : result.error}\n`,
|
|
41
|
+
);
|
|
42
|
+
stdout.write(`${JSON.stringify(result.json)}\n`);
|
|
43
|
+
return result.exitCode;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (import.meta.main) {
|
|
47
|
+
const host = process.argv[2] as ProcessHost;
|
|
48
|
+
if (!Object.hasOwn(HOOK_ADAPTERS, host)) {
|
|
49
|
+
process.stderr.write(`usage: run.ts <${Object.keys(HOOK_ADAPTERS).join("|")}>\n`);
|
|
50
|
+
process.exitCode = 64;
|
|
51
|
+
} else process.exitCode = await runHookProcess(host, process.stdin, process.stdout);
|
|
52
|
+
}
|