@brainervirus/workit-core 8.4.1 → 8.5.2
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-retro/SKILL.md +2 -2
- package/src/hooks/context.ts +99 -0
- package/src/hooks/descriptor.ts +9 -0
- package/src/hooks/handle.ts +69 -29
- package/src/hooks/hosts/claude-code.ts +31 -12
- package/src/hooks/hosts/codex.ts +50 -21
- package/src/hooks/hosts/cursor.ts +6 -3
- package/src/hooks/hosts/fields.ts +10 -2
- package/src/hooks/hosts/opencode.ts +5 -1
- package/src/hooks/hosts/pi.ts +5 -1
- package/src/hooks/index.ts +1 -0
- package/src/hooks/protocol.ts +19 -3
- package/src/hooks/raw-git.ts +13 -0
- package/src/hooks/skill-nudge.ts +338 -0
- package/src/hooks/write-gate.ts +4 -2
- package/src/ledger.ts +3 -0
package/package.json
CHANGED
|
@@ -15,8 +15,8 @@ proposes and stops. Nothing changes until the user approves.
|
|
|
15
15
|
`retro:` row in `workit ledger list --type decision`), else the last ~10
|
|
16
16
|
sessions. State the window in one line.
|
|
17
17
|
2. **Read through workit, cheapest first.** Every finding cites these:
|
|
18
|
-
- `workit ledger list --last 200`: rulings
|
|
19
|
-
|
|
18
|
+
- `workit ledger list --last 200`: rulings, failed/self verdicts, handoffs,
|
|
19
|
+
check runs, CI reruns, `skill.loaded` rows (skipped skills: routing).
|
|
20
20
|
- `workit pr status --pr <n>` for each recent PR: threads, failing checks.
|
|
21
21
|
- `git log --oneline -50`, plus reverts and fixups after review.
|
|
22
22
|
- `workit knowledge lint`: today's AGENTS.md and need-based files.
|
package/src/hooks/context.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// Session context shared by every host: the contract bootstrap, the current
|
|
2
2
|
// task's compact context, and the one-time offer of unfinished tasks.
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import fs from "node:fs";
|
|
3
5
|
import path from "node:path";
|
|
4
6
|
import { invariantBootstrap } from "../core/methods";
|
|
7
|
+
import { resolveStore } from "../store/paths";
|
|
5
8
|
import { canonicalJson } from "../core/task-contract";
|
|
6
9
|
import { isObservedCheck, type Freshness } from "../core/task-evaluation";
|
|
7
10
|
import { worktreeSignal } from "../git/rev";
|
|
@@ -287,3 +290,99 @@ export const turnContextText = (input: HookInput, descriptor: HostDescriptor): s
|
|
|
287
290
|
return null;
|
|
288
291
|
}
|
|
289
292
|
};
|
|
293
|
+
|
|
294
|
+
// Change-only per-turn context. A hook-process host (Claude Code, Codex)
|
|
295
|
+
// keeps each injected additionalContext in the transcript, so the task
|
|
296
|
+
// context is resent only when it changed since the last injection for the
|
|
297
|
+
// session. Each hook is a fresh process: the last digest lives in the
|
|
298
|
+
// workspace store (`hooks/turn-<session hash>.json`). Every cache failure
|
|
299
|
+
// answers "changed", so context is resent rather than lost.
|
|
300
|
+
|
|
301
|
+
const TURN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
302
|
+
|
|
303
|
+
const digestOf = (text: string): string => createHash("sha256").update(text).digest("hex");
|
|
304
|
+
|
|
305
|
+
/** The session's last-injected digest file, or null when it cannot be told apart or stored. */
|
|
306
|
+
const turnMarker = (input: HookInput): string | null => {
|
|
307
|
+
if (!input.session.id) return null;
|
|
308
|
+
try {
|
|
309
|
+
const location = resolveStore(input.cwd);
|
|
310
|
+
if (location instanceof Error) return null;
|
|
311
|
+
const session = [input.host, input.session.id, input.session.agentId ?? ""].join("\0");
|
|
312
|
+
return path.join(location.dir, "hooks", `turn-${digestOf(session).slice(0, 32)}.json`);
|
|
313
|
+
} catch {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const writeTurnDigest = (file: string, digest: string): void => {
|
|
319
|
+
try {
|
|
320
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
321
|
+
fs.writeFileSync(file, `${JSON.stringify({ digest })}\n`);
|
|
322
|
+
} catch {
|
|
323
|
+
// An unwritable store only means the context is sent every turn.
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const clearTurnDigest = (file: string): void => {
|
|
328
|
+
try {
|
|
329
|
+
fs.rmSync(file, { force: true });
|
|
330
|
+
} catch {
|
|
331
|
+
// A stale digest only withholds one context equal to it.
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
/** Drops digests untouched for a week (sessions that ended). */
|
|
336
|
+
const pruneTurnDigests = (dir: string, now: number): void => {
|
|
337
|
+
try {
|
|
338
|
+
for (const name of fs.readdirSync(dir)) {
|
|
339
|
+
if (!name.startsWith("turn-")) continue;
|
|
340
|
+
const file = path.join(dir, name);
|
|
341
|
+
try {
|
|
342
|
+
if (now - fs.statSync(file).mtimeMs > TURN_TTL_MS) fs.rmSync(file, { force: true });
|
|
343
|
+
} catch {
|
|
344
|
+
// Raced with another session: nothing to prune.
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
} catch {
|
|
348
|
+
// No hooks dir yet.
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* `text` when it differs from the last per-turn context injected for this
|
|
354
|
+
* session (recording it), else null.
|
|
355
|
+
*/
|
|
356
|
+
export const changedTurnContext = (input: HookInput, text: string | null): string | null => {
|
|
357
|
+
const file = turnMarker(input);
|
|
358
|
+
if (!text) {
|
|
359
|
+
// No context now: the next one is a change, even if it repeats an old one.
|
|
360
|
+
if (file) clearTurnDigest(file);
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
if (!file) return text;
|
|
364
|
+
const digest = digestOf(text);
|
|
365
|
+
try {
|
|
366
|
+
const previous = JSON.parse(fs.readFileSync(file, "utf8")) as { digest?: unknown };
|
|
367
|
+
if (previous.digest === digest) return null;
|
|
368
|
+
} catch {
|
|
369
|
+
// Missing or unreadable digest: treat as changed.
|
|
370
|
+
}
|
|
371
|
+
writeTurnDigest(file, digest);
|
|
372
|
+
return text;
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* A session start already injects the task context (inside the contract), so
|
|
377
|
+
* the per-turn digest is seeded with what the next prompt would carry: the
|
|
378
|
+
* first turn after a start, resume or compaction does not resend it. A
|
|
379
|
+
* session without task context clears its digest instead.
|
|
380
|
+
*/
|
|
381
|
+
export const seedTurnContext = (input: HookInput, descriptor: HostDescriptor): void => {
|
|
382
|
+
const file = turnMarker(input);
|
|
383
|
+
if (!file) return;
|
|
384
|
+
pruneTurnDigests(path.dirname(file), Date.now());
|
|
385
|
+
const text = turnContextText(input, descriptor);
|
|
386
|
+
if (text) writeTurnDigest(file, digestOf(text));
|
|
387
|
+
else clearTurnDigest(file);
|
|
388
|
+
};
|
package/src/hooks/descriptor.ts
CHANGED
|
@@ -27,6 +27,12 @@ export type HostDescriptor = {
|
|
|
27
27
|
/** How the session's current task is chosen: by a session bound to the task
|
|
28
28
|
* record, or the workspace's single active task when sessions never bind. */
|
|
29
29
|
task: "session-bound" | "single-active";
|
|
30
|
+
/** `on-change`: the host keeps each injected context in the transcript, so
|
|
31
|
+
* the core resends per-turn context only when it changed; `every-turn`:
|
|
32
|
+
* the core sends it on every turn (an in-process system-prompt hook needs
|
|
33
|
+
* it each time, or the host plugin dedups itself); `per-session`: the
|
|
34
|
+
* host plugin sends it once per session and again after compaction. */
|
|
35
|
+
turnResend: "on-change" | "every-turn" | "per-session";
|
|
30
36
|
};
|
|
31
37
|
subagents: {
|
|
32
38
|
identity: Support;
|
|
@@ -34,6 +40,9 @@ export type HostDescriptor = {
|
|
|
34
40
|
blockStart: Support;
|
|
35
41
|
worktreeIsolation: Support;
|
|
36
42
|
maxConcurrency: number | "undocumented";
|
|
43
|
+
/** How a Workit agent type is spelled: plugin-namespaced (`workit:verifier`)
|
|
44
|
+
* on hosts that namespace plugin agents, else a plain `workit-verifier`. */
|
|
45
|
+
agentPrefix: "workit:" | "workit-";
|
|
37
46
|
};
|
|
38
47
|
provenance: { sessionId: Support; agentIdOnTool: Support; postToolObserve: Support };
|
|
39
48
|
/** Host-native interaction boundaries workit can observe. */
|
package/src/hooks/handle.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
// The one host-hook implementation: every host maps its native events here.
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
changedTurnContext,
|
|
4
|
+
seedTurnContext,
|
|
5
|
+
sessionContextText,
|
|
6
|
+
turnContextText,
|
|
7
|
+
} from "./context";
|
|
3
8
|
import type { HostDescriptor, Support } from "./descriptor";
|
|
4
9
|
import { shellPolicy } from "./policy";
|
|
5
10
|
import {
|
|
@@ -10,6 +15,13 @@ import {
|
|
|
10
15
|
rawGitPre,
|
|
11
16
|
settlePendingCommit,
|
|
12
17
|
} from "./raw-git";
|
|
18
|
+
import {
|
|
19
|
+
promptNudge,
|
|
20
|
+
recordSkillLoad,
|
|
21
|
+
shipNudge,
|
|
22
|
+
skillReadIn,
|
|
23
|
+
withContextLine,
|
|
24
|
+
} from "./skill-nudge";
|
|
13
25
|
import { shellWrites, writeGate } from "./write-gate";
|
|
14
26
|
import type { HookDecision, HookEventKind, HookInput, HostAdapter, RenderedHook } from "./protocol";
|
|
15
27
|
|
|
@@ -18,16 +30,34 @@ export type HookDeps = { descriptor: HostDescriptor; addendum: string | null };
|
|
|
18
30
|
const usable = (value: Support) => value === "native" || value === "partial";
|
|
19
31
|
const NONE: HookDecision = { kind: "none" };
|
|
20
32
|
|
|
33
|
+
const resendsOnChange = (descriptor: HostDescriptor): boolean =>
|
|
34
|
+
descriptor.context.turnResend === "on-change" && usable(descriptor.context.perTurn);
|
|
35
|
+
|
|
21
36
|
/** Sessions already offered unfinished tasks in this process. */
|
|
22
37
|
const offered = new Set<string>();
|
|
23
38
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
39
|
+
const ROLES: Readonly<Record<string, "implementer" | "judge">> = {
|
|
40
|
+
implementer: "implementer",
|
|
41
|
+
verifier: "judge",
|
|
42
|
+
reviewer: "judge",
|
|
43
|
+
};
|
|
28
44
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
45
|
+
/**
|
|
46
|
+
* The Workit agent role an agent type names, spelled the host's way
|
|
47
|
+
* (`descriptor.subagents.agentPrefix`): Claude Code namespaces plugin agents
|
|
48
|
+
* (`workit:implementer`, so a bare `workit-implementer` is someone else's);
|
|
49
|
+
* other hosts name custom agents freely (`workit-implementer`). The
|
|
50
|
+
* implementer is the one subagent that writes; verifier and reviewer are
|
|
51
|
+
* read-only judges that record verdicts as their own session.
|
|
52
|
+
*/
|
|
53
|
+
const workitRole = (
|
|
54
|
+
descriptor: HostDescriptor,
|
|
55
|
+
agentType: string,
|
|
56
|
+
): "implementer" | "judge" | null => {
|
|
57
|
+
const prefix = descriptor.subagents.agentPrefix;
|
|
58
|
+
const name = agentType.startsWith(prefix) ? agentType.slice(prefix.length) : "";
|
|
59
|
+
return Object.hasOwn(ROLES, name) ? ROLES[name] : null;
|
|
60
|
+
};
|
|
31
61
|
|
|
32
62
|
/** `<lead session>:<agent id>`, safe for the ledger's session grammar. */
|
|
33
63
|
const subagentSession = (session: string | null, agentId: string): string =>
|
|
@@ -38,22 +68,24 @@ const subagentSession = (session: string | null, agentId: string): string =>
|
|
|
38
68
|
.slice(0, 128);
|
|
39
69
|
|
|
40
70
|
const subagentStartText = (
|
|
41
|
-
host: HookInput["host"],
|
|
42
71
|
session: string | null,
|
|
43
72
|
descriptor: HostDescriptor,
|
|
44
73
|
event: Extract<HookInput["event"], { kind: "subagent.start" }>,
|
|
45
74
|
): string => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
75
|
+
const role = workitRole(descriptor, event.agentType);
|
|
76
|
+
const who = `${descriptor.label} subagent ${event.agentId} (${event.agentType})`;
|
|
77
|
+
if (role === "implementer") {
|
|
78
|
+
// Only a host that isolates subagents in worktrees has already put it in one.
|
|
79
|
+
const where =
|
|
80
|
+
descriptor.subagents.worktreeIsolation === "native"
|
|
81
|
+
? "working in its own git worktree: it may edit and commit there"
|
|
82
|
+
: "as an implementer: it edits and commits only in its own git worktree (the lead makes one with `workit fanout worktree create <slice>`), never in the lead's checkout";
|
|
83
|
+
return `Workit observed ${who} ${where}, within its brief's scope. Before the first commit, switch to a policy-compliant branch with \`workit git branch <branch> --base <base>\` (e.g. feature/<slug>); branch policy hooks still deny protected or non-compliant branches. Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
|
|
84
|
+
}
|
|
85
|
+
const readOnly = `Workit observed ${who} as read-only/agent-guided.`;
|
|
86
|
+
if (role !== "judge" || !event.agentId) return readOnly;
|
|
87
|
+
const own = subagentSession(session, event.agentId);
|
|
88
|
+
return `${readOnly} Its own Workit session is ${own}: record verdicts with \`workit ledger verdict <result> --session ${own} ...\` so the ledger tells it apart from the author.`;
|
|
57
89
|
};
|
|
58
90
|
|
|
59
91
|
export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
@@ -71,17 +103,22 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
71
103
|
? null
|
|
72
104
|
: `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.`;
|
|
73
105
|
const addendum = [deps.addendum, advisory].filter(Boolean).join("\n") || null;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
};
|
|
106
|
+
const text = sessionContextText(input, descriptor, { offer, addendum });
|
|
107
|
+
if (resendsOnChange(descriptor)) seedTurnContext(input, descriptor);
|
|
108
|
+
return { kind: "context", text };
|
|
78
109
|
}
|
|
79
110
|
case "context.turn": {
|
|
80
111
|
if (!usable(descriptor.context.perTurn)) return NONE;
|
|
81
|
-
const
|
|
82
|
-
|
|
112
|
+
const current = turnContextText(input, descriptor);
|
|
113
|
+
const text = resendsOnChange(descriptor) ? changedTurnContext(input, current) : current;
|
|
114
|
+
return withContextLine(
|
|
115
|
+
text ? { kind: "context", text } : NONE,
|
|
116
|
+
promptNudge(input, event.prompt),
|
|
117
|
+
);
|
|
83
118
|
}
|
|
84
119
|
case "shell.pre": {
|
|
120
|
+
const skillRead = skillReadIn(event.command, event.dialect);
|
|
121
|
+
if (skillRead) recordSkillLoad(input, skillRead, "read");
|
|
85
122
|
const canDeny = usable(descriptor.shellPolicy.deny);
|
|
86
123
|
const policy = canDeny ? shellPolicy(input.cwd, event.command) : NONE;
|
|
87
124
|
if (policy.kind !== "none") return policy;
|
|
@@ -102,7 +139,7 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
102
139
|
event.command,
|
|
103
140
|
postTool ? callKey(event.toolUseId, event.command) : NEXT_COMMAND,
|
|
104
141
|
);
|
|
105
|
-
return raw;
|
|
142
|
+
return withContextLine(raw, shipNudge(input, event.command));
|
|
106
143
|
}
|
|
107
144
|
case "shell.post":
|
|
108
145
|
if (usable(descriptor.events["shell.post"].support))
|
|
@@ -115,7 +152,7 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
115
152
|
case "subagent.start":
|
|
116
153
|
return {
|
|
117
154
|
kind: "context",
|
|
118
|
-
text: subagentStartText(input.
|
|
155
|
+
text: subagentStartText(input.session.id ?? null, descriptor, event),
|
|
119
156
|
};
|
|
120
157
|
case "compact.pre":
|
|
121
158
|
return {
|
|
@@ -123,9 +160,12 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
123
160
|
userMessage:
|
|
124
161
|
"Workit context may be stale after compaction; re-run inspection or resume before acting.",
|
|
125
162
|
};
|
|
126
|
-
// Host permission policy owns other tools; attestation, prompt and stop
|
|
127
|
-
// control arrive with the CLI-observed evidence model.
|
|
128
163
|
case "tool.pre":
|
|
164
|
+
// Host permission policy owns other tools; a skill load is recorded.
|
|
165
|
+
if (event.skill) recordSkillLoad(input, event.skill, "tool");
|
|
166
|
+
return NONE;
|
|
167
|
+
// Attestation, prompt and stop control arrive with the CLI-observed
|
|
168
|
+
// evidence model.
|
|
129
169
|
case "subagent.stop":
|
|
130
170
|
case "prompt.submit":
|
|
131
171
|
case "stop":
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
isWriteTool,
|
|
16
16
|
nonEmpty,
|
|
17
17
|
optionalText,
|
|
18
|
-
|
|
18
|
+
writeTargets,
|
|
19
19
|
} from "./fields";
|
|
20
20
|
|
|
21
21
|
type ClaudeHookEvent =
|
|
@@ -43,11 +43,14 @@ export const CLAUDE_CODE_DESCRIPTOR: HostDescriptor = {
|
|
|
43
43
|
"shell.post": { support: "native", native: "PostToolUse" },
|
|
44
44
|
// SubagentStart output is additionalContext only: it cannot block or bind.
|
|
45
45
|
"subagent.start": { support: "native", native: "SubagentStart" },
|
|
46
|
-
"subagent.stop": { support: "native", native: "SubagentStop" },
|
|
47
46
|
"prompt.submit": { support: "native", native: "UserPromptSubmit" },
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
// Claude Code has SubagentStop, PreCompact and Stop, but the plugin
|
|
48
|
+
// registers none of them: events list what workit registers
|
|
49
|
+
// (docs/agents/hosts.md, Host parity). Compaction restore runs on
|
|
50
|
+
// SessionStart source=compact.
|
|
51
|
+
"subagent.stop": { support: "none", native: null },
|
|
52
|
+
"compact.pre": { support: "none", native: null },
|
|
53
|
+
stop: { support: "none", native: null },
|
|
51
54
|
},
|
|
52
55
|
shellPolicy: { deny: "native", channel: "permissionDecision", failClosed: false },
|
|
53
56
|
context: {
|
|
@@ -55,6 +58,9 @@ export const CLAUDE_CODE_DESCRIPTOR: HostDescriptor = {
|
|
|
55
58
|
perTurn: "native",
|
|
56
59
|
afterCompact: "native",
|
|
57
60
|
task: "session-bound",
|
|
61
|
+
// The plugin's src/hook.ts dedups per-turn context itself (its own cache)
|
|
62
|
+
// until it moves onto the core's on-change resend.
|
|
63
|
+
turnResend: "every-turn",
|
|
58
64
|
},
|
|
59
65
|
subagents: {
|
|
60
66
|
identity: "native",
|
|
@@ -62,6 +68,8 @@ export const CLAUDE_CODE_DESCRIPTOR: HostDescriptor = {
|
|
|
62
68
|
blockStart: "none",
|
|
63
69
|
worktreeIsolation: "native",
|
|
64
70
|
maxConcurrency: "undocumented",
|
|
71
|
+
// Plugin agents are namespaced: `workit:implementer`, never a bare `workit-implementer`.
|
|
72
|
+
agentPrefix: "workit:",
|
|
65
73
|
},
|
|
66
74
|
provenance: { sessionId: "native", agentIdOnTool: "native", postToolObserve: "native" },
|
|
67
75
|
interaction: { questions: "undocumented", writeBoundary: "partial" },
|
|
@@ -79,13 +87,14 @@ export const CLAUDE_CODE_DESCRIPTOR: HostDescriptor = {
|
|
|
79
87
|
},
|
|
80
88
|
{
|
|
81
89
|
name: "native_subagents",
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
90
|
+
// SubagentStop is not registered: identity and guidance come from SubagentStart.
|
|
91
|
+
surface: "SubagentStart",
|
|
92
|
+
refs: ["SubagentStart"],
|
|
93
|
+
requires: ["event:subagent.start"],
|
|
94
|
+
observed: ["subagent.start"],
|
|
86
95
|
assurance: "agent_guided",
|
|
87
96
|
reason: "Claude Code reports stable agent identities, but SubagentStart cannot block or bind",
|
|
88
|
-
unavailableReason: "Claude Code
|
|
97
|
+
unavailableReason: "Claude Code SubagentStart is unavailable",
|
|
89
98
|
},
|
|
90
99
|
{
|
|
91
100
|
name: "fresh-context-review",
|
|
@@ -148,7 +157,7 @@ const eventOf = (name: ClaudeHookEvent, value: Record<string, unknown>): Parsed
|
|
|
148
157
|
? { ok: true, event: { kind: "session.start", source: value.source as SessionSource } }
|
|
149
158
|
: { ok: false, error: "SessionStart source is required" };
|
|
150
159
|
case "UserPromptSubmit":
|
|
151
|
-
return { ok: true, event: { kind: "context.turn" } };
|
|
160
|
+
return { ok: true, event: { kind: "context.turn", prompt: optionalText(value.prompt) } };
|
|
152
161
|
case "PreToolUse": {
|
|
153
162
|
if (!nonEmpty(value.tool_name)) return { ok: false, error: "tool_name is required" };
|
|
154
163
|
if (isWriteTool(value.tool_name))
|
|
@@ -157,8 +166,18 @@ const eventOf = (name: ClaudeHookEvent, value: Record<string, unknown>): Parsed
|
|
|
157
166
|
event: {
|
|
158
167
|
kind: "write.pre",
|
|
159
168
|
tool: value.tool_name,
|
|
160
|
-
paths:
|
|
169
|
+
paths: writeTargets(value.tool_input),
|
|
170
|
+
toolUseId,
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
if (value.tool_name === "Skill")
|
|
174
|
+
return {
|
|
175
|
+
ok: true,
|
|
176
|
+
event: {
|
|
177
|
+
kind: "tool.pre",
|
|
178
|
+
tool: value.tool_name,
|
|
161
179
|
toolUseId,
|
|
180
|
+
skill: isRecord(value.tool_input) ? optionalText(value.tool_input.skill) : null,
|
|
162
181
|
},
|
|
163
182
|
};
|
|
164
183
|
if (!SHELL_TOOLS.has(value.tool_name))
|
package/src/hooks/hosts/codex.ts
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
// Codex CLI and Desktop: command hooks (hooks/hooks.json) mapped onto the protocol.
|
|
2
2
|
import type { HostDescriptor } from "../descriptor";
|
|
3
3
|
import type { HookDecision, HookEvent, HookEventKind, HostAdapter } from "../protocol";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
commandText,
|
|
6
|
+
existingDirectory,
|
|
7
|
+
isRecord,
|
|
8
|
+
isWriteTool,
|
|
9
|
+
nonEmpty,
|
|
10
|
+
optionalText,
|
|
11
|
+
writeTargets,
|
|
12
|
+
} from "./fields";
|
|
5
13
|
|
|
6
14
|
export type CodexHost = "codex_cli" | "codex_desktop";
|
|
7
15
|
export type CodexHookEvent =
|
|
8
16
|
| "SessionStart"
|
|
17
|
+
| "UserPromptSubmit"
|
|
9
18
|
| "PreToolUse"
|
|
10
19
|
| "PostToolUse"
|
|
11
20
|
| "SubagentStart"
|
|
12
21
|
| "SubagentStop";
|
|
13
|
-
|
|
22
|
+
// The session-start.command.input schema in codex-cli 0.160.1 lists fork as a source.
|
|
23
|
+
type SessionSource = "startup" | "resume" | "clear" | "compact" | "fork";
|
|
14
24
|
|
|
15
25
|
export type CodexHookInput = {
|
|
16
26
|
hook_event_name: CodexHookEvent;
|
|
@@ -21,6 +31,8 @@ export type CodexHookInput = {
|
|
|
21
31
|
transcript_path: string | null;
|
|
22
32
|
source?: SessionSource;
|
|
23
33
|
turn_id?: string;
|
|
34
|
+
/** UserPromptSubmit: the prompt about to be sent. */
|
|
35
|
+
prompt?: string | null;
|
|
24
36
|
tool_name?: string;
|
|
25
37
|
tool_input?: unknown;
|
|
26
38
|
/** The shell command as one string; argv arrays are joined. */
|
|
@@ -44,35 +56,43 @@ export function detectCodexSurface(env: NodeJS.ProcessEnv): CodexHost {
|
|
|
44
56
|
: "codex_cli";
|
|
45
57
|
}
|
|
46
58
|
|
|
47
|
-
const undocumented = { support: "undocumented", native: null } as const;
|
|
48
|
-
|
|
49
59
|
export const CODEX_DESCRIPTOR: HostDescriptor = {
|
|
50
60
|
host: "codex_cli",
|
|
51
61
|
label: "Codex",
|
|
52
|
-
|
|
62
|
+
// The hook JSON schemas embedded in the codex-cli 0.160.1 binary
|
|
63
|
+
// (pre-tool-use, user-prompt-submit, subagent-start .command.input/output)
|
|
64
|
+
// and the hooks page: "PreToolUse can intercept Bash, file edits performed
|
|
65
|
+
// through apply_patch, MCP tool calls…"; apply_patch arrives as tool_name
|
|
66
|
+
// "apply_patch" with the patch in tool_input.command (Edit and Write are
|
|
67
|
+
// matcher aliases only).
|
|
68
|
+
verifiedAgainst:
|
|
69
|
+
"codex-cli 0.160.1 (hook JSON schemas in the binary); Codex Desktop 26.901.20858",
|
|
53
70
|
docs: ["https://developers.openai.com/codex/hooks"],
|
|
54
71
|
transport: "hook-process",
|
|
55
72
|
events: {
|
|
56
73
|
"session.start": { support: "native", native: "SessionStart" },
|
|
57
|
-
"context.turn":
|
|
74
|
+
"context.turn": { support: "native", native: "UserPromptSubmit" },
|
|
58
75
|
"shell.pre": { support: "native", native: "PreToolUse" },
|
|
59
76
|
"tool.pre": { support: "native", native: "PreToolUse" },
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
"write.pre": { support: "undocumented", native: null },
|
|
77
|
+
// PreToolUse covers apply_patch; the files come from the patch body.
|
|
78
|
+
"write.pre": { support: "native", native: "PreToolUse" },
|
|
63
79
|
"shell.post": { support: "native", native: "PostToolUse" },
|
|
64
80
|
"subagent.start": { support: "native", native: "SubagentStart" },
|
|
65
81
|
"subagent.stop": { support: "native", native: "SubagentStop" },
|
|
66
|
-
"prompt.submit":
|
|
82
|
+
"prompt.submit": { support: "native", native: "UserPromptSubmit" },
|
|
83
|
+
// Codex has PreCompact and Stop, but workit registers neither yet: events
|
|
84
|
+
// list what workit registers (docs/agents/hosts.md, Host parity).
|
|
67
85
|
"compact.pre": { support: "none", native: null },
|
|
68
|
-
stop:
|
|
86
|
+
stop: { support: "none", native: null },
|
|
69
87
|
},
|
|
70
88
|
shellPolicy: { deny: "native", channel: "permissionDecision", failClosed: false },
|
|
71
89
|
context: {
|
|
72
90
|
sessionStart: "native",
|
|
73
|
-
perTurn: "
|
|
91
|
+
perTurn: "native",
|
|
74
92
|
afterCompact: "native",
|
|
75
93
|
task: "single-active",
|
|
94
|
+
// UserPromptSubmit additionalContext stays in the thread as developer context.
|
|
95
|
+
turnResend: "on-change",
|
|
76
96
|
},
|
|
77
97
|
subagents: {
|
|
78
98
|
identity: "native",
|
|
@@ -80,6 +100,7 @@ export const CODEX_DESCRIPTOR: HostDescriptor = {
|
|
|
80
100
|
blockStart: "none",
|
|
81
101
|
worktreeIsolation: "undocumented",
|
|
82
102
|
maxConcurrency: "undocumented",
|
|
103
|
+
agentPrefix: "workit-",
|
|
83
104
|
},
|
|
84
105
|
provenance: { sessionId: "native", agentIdOnTool: "partial", postToolObserve: "native" },
|
|
85
106
|
interaction: { questions: "none", writeBoundary: "partial" },
|
|
@@ -152,6 +173,7 @@ export const codexDescriptor = (host: CodexHost): HostDescriptor => ({ ...CODEX_
|
|
|
152
173
|
|
|
153
174
|
const EVENTS: Record<CodexHookEvent, HookEventKind> = {
|
|
154
175
|
SessionStart: "session.start",
|
|
176
|
+
UserPromptSubmit: "context.turn",
|
|
155
177
|
PreToolUse: "shell.pre",
|
|
156
178
|
PostToolUse: "shell.post",
|
|
157
179
|
SubagentStart: "subagent.start",
|
|
@@ -159,7 +181,7 @@ const EVENTS: Record<CodexHookEvent, HookEventKind> = {
|
|
|
159
181
|
};
|
|
160
182
|
const SHELL_TOOLS = new Set(["bash", "unified-exec"]);
|
|
161
183
|
const isShellTool = (name: unknown) => SHELL_TOOLS.has(String(name).toLowerCase());
|
|
162
|
-
const SOURCES = new Set<string>(["startup", "resume", "clear", "compact"]);
|
|
184
|
+
const SOURCES = new Set<string>(["startup", "resume", "clear", "compact", "fork"]);
|
|
163
185
|
|
|
164
186
|
/**
|
|
165
187
|
* Read a Codex payload, checking only what each mapping needs (D17): the
|
|
@@ -195,6 +217,7 @@ export const parseCodexHookInput = (value: unknown): CodexParseResult => {
|
|
|
195
217
|
? { source: SOURCES.has(String(value.source)) ? (value.source as SessionSource) : "resume" }
|
|
196
218
|
: {}),
|
|
197
219
|
...(nonEmpty(value.turn_id) ? { turn_id: value.turn_id } : {}),
|
|
220
|
+
...(event === "UserPromptSubmit" ? { prompt: optionalText(value.prompt) } : {}),
|
|
198
221
|
...(nonEmpty(value.tool_name) ? { tool_name: value.tool_name } : {}),
|
|
199
222
|
...(toolEvent ? { tool_input: value.tool_input } : {}),
|
|
200
223
|
...(event === "PostToolUse" && typeof value.tool_response === "string"
|
|
@@ -219,15 +242,21 @@ const protocolEvent = (input: CodexHookInput): HookEvent => {
|
|
|
219
242
|
switch (input.hook_event_name) {
|
|
220
243
|
case "SessionStart":
|
|
221
244
|
return { kind: "session.start", source: input.source! };
|
|
245
|
+
case "UserPromptSubmit":
|
|
246
|
+
return { kind: "context.turn", prompt: input.prompt ?? null };
|
|
222
247
|
case "PreToolUse": {
|
|
223
248
|
const toolUseId = input.tool_use_id ?? null;
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
249
|
+
if (isShellTool(input.tool_name))
|
|
250
|
+
return { kind: "shell.pre", command: input.command ?? "", toolUseId };
|
|
251
|
+
// apply_patch: the files are the patch body's `*** … File:` headers.
|
|
252
|
+
if (isWriteTool(input.tool_name))
|
|
253
|
+
return {
|
|
254
|
+
kind: "write.pre",
|
|
255
|
+
tool: input.tool_name!,
|
|
256
|
+
paths: writeTargets(input.tool_input),
|
|
257
|
+
toolUseId,
|
|
258
|
+
};
|
|
259
|
+
return { kind: "tool.pre", tool: input.tool_name!, toolUseId };
|
|
231
260
|
}
|
|
232
261
|
case "PostToolUse": {
|
|
233
262
|
const toolUseId = input.tool_use_id ?? null;
|
|
@@ -313,5 +342,5 @@ export const codexAdapter: HostAdapter = {
|
|
|
313
342
|
},
|
|
314
343
|
render,
|
|
315
344
|
addendum: (input) =>
|
|
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>)
|
|
345
|
+
`<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 its own session, never the author's: a workit-verifier or workit-reviewer agent uses the --session its SubagentStart context names; any other uses one the lead assigns (WORKIT_HOST=codex_cli WORKIT_SESSION_ID=<lead>-v<n>). Merge and release need a workspace grant (workit grant show); a question answer is not host permission.</workit-codex-mutations>`,
|
|
317
346
|
};
|
|
@@ -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, isWriteTool, nonEmpty,
|
|
5
|
+
import { existingDirectory, isRecord, isWriteTool, nonEmpty, writeTargets } from "./fields";
|
|
6
6
|
|
|
7
7
|
export type CursorHookEvent =
|
|
8
8
|
| "sessionStart"
|
|
@@ -53,7 +53,8 @@ export const CURSOR_DESCRIPTOR: HostDescriptor = {
|
|
|
53
53
|
"prompt.submit": undocumented,
|
|
54
54
|
// preCompact can only show a user message.
|
|
55
55
|
"compact.pre": { support: "partial", native: "preCompact" },
|
|
56
|
-
stop
|
|
56
|
+
// Cursor has a stop hook (followup_message), but workit registers none yet.
|
|
57
|
+
stop: none,
|
|
57
58
|
},
|
|
58
59
|
shellPolicy: { deny: "native", channel: "exit2+json", failClosed: true },
|
|
59
60
|
context: {
|
|
@@ -61,6 +62,7 @@ export const CURSOR_DESCRIPTOR: HostDescriptor = {
|
|
|
61
62
|
perTurn: "none",
|
|
62
63
|
afterCompact: "partial",
|
|
63
64
|
task: "single-active",
|
|
65
|
+
turnResend: "every-turn",
|
|
64
66
|
},
|
|
65
67
|
subagents: {
|
|
66
68
|
identity: "native",
|
|
@@ -68,6 +70,7 @@ export const CURSOR_DESCRIPTOR: HostDescriptor = {
|
|
|
68
70
|
blockStart: "native",
|
|
69
71
|
worktreeIsolation: "undocumented",
|
|
70
72
|
maxConcurrency: "undocumented",
|
|
73
|
+
agentPrefix: "workit-",
|
|
71
74
|
},
|
|
72
75
|
provenance: {
|
|
73
76
|
sessionId: "native",
|
|
@@ -220,7 +223,7 @@ const protocolEvent = (input: CursorHookInput): HookEvent => {
|
|
|
220
223
|
? {
|
|
221
224
|
kind: "write.pre",
|
|
222
225
|
tool: input.tool_name ?? "",
|
|
223
|
-
paths:
|
|
226
|
+
paths: writeTargets(input.tool_input),
|
|
224
227
|
toolUseId: null,
|
|
225
228
|
}
|
|
226
229
|
: { kind: "tool.pre", tool: input.tool_name ?? "", toolUseId: null };
|
|
@@ -78,13 +78,21 @@ export const isWriteTool = (name: unknown): boolean =>
|
|
|
78
78
|
typeof name === "string" && WRITE_TOOLS.has(name.toLowerCase().replace(/[_-]/g, ""));
|
|
79
79
|
|
|
80
80
|
const PATH_KEYS = ["file_path", "filePath", "notebook_path", "path", "target_file", "file"];
|
|
81
|
+
/** apply_patch file headers. Codex trims patch lines, so an indented header still applies. */
|
|
82
|
+
const PATCH_HEADER = /^[ \t]*\*\*\* (?:(?:Add|Update|Delete) File|Move to): (.+)$/gm;
|
|
83
|
+
|
|
81
84
|
/** The files a write tool names: its path fields, or the files of an apply_patch body. */
|
|
82
85
|
export const writePaths = (input: unknown): string[] => {
|
|
83
86
|
if (!isRecord(input)) return [];
|
|
84
87
|
const paths = PATH_KEYS.map((key) => input[key]).filter(nonEmpty);
|
|
85
88
|
for (const value of Object.values(input))
|
|
86
89
|
if (typeof value === "string")
|
|
87
|
-
for (const match of value.matchAll(
|
|
88
|
-
paths.push(match[1].trim());
|
|
90
|
+
for (const match of value.matchAll(PATCH_HEADER)) paths.push(match[1].trim());
|
|
89
91
|
return [...new Set(paths)];
|
|
90
92
|
};
|
|
93
|
+
|
|
94
|
+
/** A write tool's targets for the before-write gate: null (unknown, so gated) when none parse. */
|
|
95
|
+
export const writeTargets = (input: unknown): string[] | null => {
|
|
96
|
+
const paths = writePaths(input);
|
|
97
|
+
return paths.length > 0 ? paths : null;
|
|
98
|
+
};
|
|
@@ -19,7 +19,8 @@ export const OPENCODE_DESCRIPTOR: HostDescriptor = {
|
|
|
19
19
|
"subagent.stop": { support: "native", native: 'tool.hook("execute.after") subagent' },
|
|
20
20
|
"prompt.submit": { support: "undocumented", native: null },
|
|
21
21
|
"compact.pre": { support: "native", native: 'session.hook("compaction")' },
|
|
22
|
-
|
|
22
|
+
// OpenCode reports session.idle, but the plugin registers no stop hook yet.
|
|
23
|
+
stop: { support: "none", native: null },
|
|
23
24
|
},
|
|
24
25
|
shellPolicy: { deny: "native", channel: "effect", failClosed: false },
|
|
25
26
|
context: {
|
|
@@ -27,6 +28,8 @@ export const OPENCODE_DESCRIPTOR: HostDescriptor = {
|
|
|
27
28
|
perTurn: "native",
|
|
28
29
|
afterCompact: "native",
|
|
29
30
|
task: "session-bound",
|
|
31
|
+
// The context hook rebuilds the system prompt on every agent-loop call.
|
|
32
|
+
turnResend: "every-turn",
|
|
30
33
|
},
|
|
31
34
|
subagents: {
|
|
32
35
|
identity: "native",
|
|
@@ -34,6 +37,7 @@ export const OPENCODE_DESCRIPTOR: HostDescriptor = {
|
|
|
34
37
|
blockStart: "native",
|
|
35
38
|
worktreeIsolation: "undocumented",
|
|
36
39
|
maxConcurrency: "undocumented",
|
|
40
|
+
agentPrefix: "workit-",
|
|
37
41
|
},
|
|
38
42
|
provenance: { sessionId: "native", agentIdOnTool: "native", postToolObserve: "native" },
|
|
39
43
|
interaction: { questions: "native", writeBoundary: "partial" },
|
package/src/hooks/hosts/pi.ts
CHANGED
|
@@ -24,9 +24,12 @@ export const PI_DESCRIPTOR: HostDescriptor = {
|
|
|
24
24
|
shellPolicy: { deny: "native", channel: "block", failClosed: false },
|
|
25
25
|
context: {
|
|
26
26
|
sessionStart: "native",
|
|
27
|
-
|
|
27
|
+
// before_agent_start runs every turn, but the extension injects the task
|
|
28
|
+
// context once per session and again after compaction, never on change.
|
|
29
|
+
perTurn: "partial",
|
|
28
30
|
afterCompact: "native",
|
|
29
31
|
task: "session-bound",
|
|
32
|
+
turnResend: "per-session",
|
|
30
33
|
},
|
|
31
34
|
subagents: {
|
|
32
35
|
identity: "none",
|
|
@@ -34,6 +37,7 @@ export const PI_DESCRIPTOR: HostDescriptor = {
|
|
|
34
37
|
blockStart: "none",
|
|
35
38
|
worktreeIsolation: "none",
|
|
36
39
|
maxConcurrency: "undocumented",
|
|
40
|
+
agentPrefix: "workit-",
|
|
37
41
|
},
|
|
38
42
|
provenance: { sessionId: "native", agentIdOnTool: "none", postToolObserve: "native" },
|
|
39
43
|
interaction: { questions: "native", writeBoundary: "native" },
|
package/src/hooks/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ export {
|
|
|
19
19
|
export { shellPolicy } from "./policy";
|
|
20
20
|
export { shellWrites, writeGate } from "./write-gate";
|
|
21
21
|
export { callKey, noteRawCommit, rawGitPost, rawGitPre } from "./raw-git";
|
|
22
|
+
export { promptNudge, recordSkillLoad, shellNudge, skillFileIn } from "./skill-nudge";
|
|
22
23
|
export { isWriteTool, writePaths } from "./hosts/fields";
|
|
23
24
|
export { dispatchHook, failureDecision, handleHook, type HookDeps } from "./handle";
|
|
24
25
|
export { HOOK_ADAPTERS, runHookProcess } from "./run";
|
package/src/hooks/protocol.ts
CHANGED
|
@@ -16,7 +16,11 @@ export type SessionSource = "startup" | "resume" | "clear" | "compact" | "fork";
|
|
|
16
16
|
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
|
+
| {
|
|
20
|
+
kind: "context.turn";
|
|
21
|
+
/** The submitted prompt, on hosts whose per-turn event carries it. */
|
|
22
|
+
prompt?: string | null;
|
|
23
|
+
}
|
|
20
24
|
| {
|
|
21
25
|
kind: "shell.pre";
|
|
22
26
|
command: string;
|
|
@@ -25,9 +29,21 @@ export type HookEvent =
|
|
|
25
29
|
dialect?: "posix" | "powershell";
|
|
26
30
|
}
|
|
27
31
|
/** A pre-tool gate for a non-shell tool. Host permission policy owns these. */
|
|
28
|
-
| {
|
|
32
|
+
| {
|
|
33
|
+
kind: "tool.pre";
|
|
34
|
+
tool: string;
|
|
35
|
+
toolUseId: string | null;
|
|
36
|
+
/** The skill a skill-loading tool loads (Claude Code's Skill tool). */
|
|
37
|
+
skill?: string | null;
|
|
38
|
+
}
|
|
29
39
|
/** A file-writing tool (Edit, Write, apply_patch…): the before-write gate (S17). */
|
|
30
|
-
| {
|
|
40
|
+
| {
|
|
41
|
+
kind: "write.pre";
|
|
42
|
+
tool: string;
|
|
43
|
+
/** The files written; null when they cannot be read (unknown targets, gated). */
|
|
44
|
+
paths: string[] | null;
|
|
45
|
+
toolUseId: string | null;
|
|
46
|
+
}
|
|
31
47
|
| {
|
|
32
48
|
kind: "shell.post";
|
|
33
49
|
command: string;
|
package/src/hooks/raw-git.ts
CHANGED
|
@@ -438,6 +438,19 @@ const actionsIn = (cwd: string, command: string, dialect: ShellDialect): Located
|
|
|
438
438
|
return out;
|
|
439
439
|
};
|
|
440
440
|
|
|
441
|
+
/** Is `dir` in a repository Workit manages? */
|
|
442
|
+
export const inWorkitWorkspace = (dir: string): boolean => {
|
|
443
|
+
const repo = repoAt(dir);
|
|
444
|
+
return repo !== null && isWorkitWorkspace(repo);
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
/** A raw push, PR/MR create or merge in a Workit workspace (help and dry runs excluded). */
|
|
448
|
+
export const rawDelivery = (cwd: string, command: string, dialect: ShellDialect): boolean =>
|
|
449
|
+
mentionsRawTool(command) &&
|
|
450
|
+
actionsIn(cwd, command, dialect).some(
|
|
451
|
+
({ action }) => action.kind !== "commit" && action.kind !== "pr-read",
|
|
452
|
+
);
|
|
453
|
+
|
|
441
454
|
/** Cheap pre-filter: no git/gh/glab word, nothing to do. */
|
|
442
455
|
const mentionsRawTool = (command: string) => /\b(?:git|gh|glab)\b/i.test(command);
|
|
443
456
|
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// Skill routing at the moment it matters. The session-start contract names a
|
|
2
|
+
// trigger for each skill, but in a long or compacted session that table loses
|
|
3
|
+
// weight and agents act without loading the skill. Three deterministic aids,
|
|
4
|
+
// inside a Workit workspace only:
|
|
5
|
+
// - a prompt that asks for a skill's work in so many words gets one
|
|
6
|
+
// advisory line naming the skill;
|
|
7
|
+
// - a delivery command (push, PR create or merge, CI wait) gets one line
|
|
8
|
+
// naming workit-ship;
|
|
9
|
+
// - each skill load is recorded as a `skill.loaded` ledger row, so a nudge
|
|
10
|
+
// stops once the skill is loaded and workit-retro can count skill use.
|
|
11
|
+
// A session is nudged about one skill at most once (a marker per session
|
|
12
|
+
// in the workspace store), never in a subagent, never once it loaded the skill.
|
|
13
|
+
// Every failure answers "no nudge": hooks fail open.
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import {
|
|
18
|
+
skillLoadWording,
|
|
19
|
+
WORKIT_METHOD_SKILLS,
|
|
20
|
+
WORKIT_SKILL_ALIASES,
|
|
21
|
+
type SkillHost,
|
|
22
|
+
type WorkitSkill,
|
|
23
|
+
} from "../core/skill-manifests";
|
|
24
|
+
import { appendHookObserved, readLedger } from "../ledger";
|
|
25
|
+
import { resolveStore } from "../store/paths";
|
|
26
|
+
import type { HookDecision, HookInput, HostId } from "./protocol";
|
|
27
|
+
import { inWorkitWorkspace, rawDelivery, rawGitPre } from "./raw-git";
|
|
28
|
+
import { segmentsOf, type ShellDialect } from "./shell-words";
|
|
29
|
+
|
|
30
|
+
/** Skills the user starts; a nudge says to offer them, never to load them. */
|
|
31
|
+
const USER_INVOKED: ReadonlySet<WorkitSkill> = new Set(["workit-retro", "workit-architecture"]);
|
|
32
|
+
|
|
33
|
+
const SKILL_HOST: Record<HostId, SkillHost> = {
|
|
34
|
+
claude_code: "claude-code",
|
|
35
|
+
opencode: "opencode",
|
|
36
|
+
codex_cli: "codex",
|
|
37
|
+
codex_desktop: "codex",
|
|
38
|
+
cursor: "cursor",
|
|
39
|
+
pi: "pi",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Prompt phrasings that ask for a skill's work. The contract's single-word
|
|
44
|
+
* triggers (plan, build, merge, CI) are for the model's judgment; a hook
|
|
45
|
+
* cannot tell "plan the cache" from "what's in the plan?", so it matches
|
|
46
|
+
* phrases only. Ordered by precedence: a broken thing is debug work before
|
|
47
|
+
* it is a feature or a plan.
|
|
48
|
+
*/
|
|
49
|
+
const PROMPT_INTENTS: ReadonlyArray<readonly [WorkitSkill, RegExp]> = [
|
|
50
|
+
[
|
|
51
|
+
"workit-debug",
|
|
52
|
+
/\b(flaky|regressed|(?:is|introduced) a regression|regression (?:in|after|since|from)|(?:is|are|got|looks) broken|debug (?:this|it|the|why)|fix (?:the|this|a) bug)\b/i,
|
|
53
|
+
],
|
|
54
|
+
[
|
|
55
|
+
"workit-continue",
|
|
56
|
+
/\b(pick up where|where (?:were|did) we|resume (?:the|this|my|our|work|where)|hand ?off)\b/i,
|
|
57
|
+
],
|
|
58
|
+
[
|
|
59
|
+
"workit-review",
|
|
60
|
+
/\b(blast radius|code review|review (?:the|this|my|PR|pr|#\d+|branch|diff|changes))\b/i,
|
|
61
|
+
],
|
|
62
|
+
[
|
|
63
|
+
"workit-ship",
|
|
64
|
+
/\b(ship (?:it|this|the)|babysit|open (?:a|the) (?:PR|pr|MR|mr)(?=\s*$|\s+(?:for|to|on|with|and|against|into|from|after|once)\b|[.!,;])|merge (?:the|this|my) (?:PR|pr|MR|mr|branch)|(?:get|until|make) CI (?:is )?green)\b/i,
|
|
65
|
+
],
|
|
66
|
+
["workit-bdd", /\b(BDD|TDD|acceptance criteria|Given\/When\/Then)(?![\w/])/],
|
|
67
|
+
["workit-test-audit", /\b(test audit|audit the tests|tautolog\w*|weak tests)\b/i],
|
|
68
|
+
["workit-deslop", /\b(deslop|slop|dead code)\b/i],
|
|
69
|
+
[
|
|
70
|
+
"workit-fanout",
|
|
71
|
+
/\b(fan(?: |-)?out|parallelize|parallel agents|agent swarm|swarm of agents)\b/i,
|
|
72
|
+
],
|
|
73
|
+
["workit-verify-app", /\b(verify the app|smoke test|prove it works)\b/i],
|
|
74
|
+
["workit-retro", /\b((?:a|the) retro(?![-\w]|\.\w)|retrospective)/i],
|
|
75
|
+
["workit-architecture", /\b(deepen (?:the )?modules|architecture (?:review|pass|audit))\b/i],
|
|
76
|
+
[
|
|
77
|
+
"workit-shape",
|
|
78
|
+
/\b(brainstorm\w*|grill me|should we|let'?s plan|plan (?:out|how|the|a|for)|(?:write|draft) (?:a|the) spec)\b/i,
|
|
79
|
+
],
|
|
80
|
+
[
|
|
81
|
+
"workit-implement",
|
|
82
|
+
/\b(implement (?:the|this|a|it)|add (?:a|the) feature|build (?:a|an|the) (?:new )?(?:feature|command|verb|page|endpoint|component|cli|tool))\b/i,
|
|
83
|
+
],
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
/** The workit skill a loaded name refers to (`workit:shape`, `workit-shape`, `wk-shape`). */
|
|
87
|
+
export const workitSkillOf = (name: string): WorkitSkill | null => {
|
|
88
|
+
const bare = name.trim().replace(/^\//, "").toLowerCase();
|
|
89
|
+
const alias = (WORKIT_SKILL_ALIASES as Record<string, WorkitSkill>)[bare];
|
|
90
|
+
if (alias) return alias;
|
|
91
|
+
const skill = bare.replace(/^workit:/, "workit-");
|
|
92
|
+
return (WORKIT_METHOD_SKILLS as readonly string[]).includes(skill)
|
|
93
|
+
? (skill as WorkitSkill)
|
|
94
|
+
: null;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** The skill a prompt asks for, by precedence, with the phrase that did. */
|
|
98
|
+
export const promptTrigger = (prompt: string): { skill: WorkitSkill; trigger: string } | null => {
|
|
99
|
+
// Code blocks and quoted output carry words the user did not ask with.
|
|
100
|
+
const text = prompt.replace(/```[\s\S]*?```/g, " ").slice(0, 4000);
|
|
101
|
+
for (const [skill, pattern] of PROMPT_INTENTS) {
|
|
102
|
+
const match = pattern.exec(text);
|
|
103
|
+
if (match) return { skill, trigger: match[1] ?? match[0] };
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** Per-session nudge state: skills nudged or loaded, so each nudges once. */
|
|
109
|
+
type Marker = { nudged: string[]; loaded: string[] };
|
|
110
|
+
|
|
111
|
+
/** The marker in the workspace's store (null outside one: nothing is nudged or recorded there). */
|
|
112
|
+
const markerFile = (input: HookInput, session: string): string | null => {
|
|
113
|
+
const location = resolveStore(input.cwd);
|
|
114
|
+
if (location instanceof Error || !location.shared) return null;
|
|
115
|
+
const name = createHash("sha256").update(`${input.host}\0${session}`).digest("hex").slice(0, 32);
|
|
116
|
+
return path.join(location.dir, "hooks", `skills-${name}.json`);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const readMarker = (file: string): Marker => {
|
|
120
|
+
try {
|
|
121
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8")) as Partial<Marker>;
|
|
122
|
+
return { nudged: value.nudged ?? [], loaded: value.loaded ?? [] };
|
|
123
|
+
} catch {
|
|
124
|
+
return { nudged: [], loaded: [] };
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const writeMarker = (file: string, marker: Marker): void => {
|
|
129
|
+
try {
|
|
130
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
131
|
+
fs.writeFileSync(file, JSON.stringify(marker));
|
|
132
|
+
} catch {
|
|
133
|
+
// Fail open: without a marker a later nudge repeats.
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** Skills this session already loaded, from its `skill.loaded` rows. */
|
|
138
|
+
const loadedSkills = (cwd: string, session: string): Set<string> => {
|
|
139
|
+
const ledger = readLedger(cwd);
|
|
140
|
+
const loaded = new Set<string>();
|
|
141
|
+
if (!ledger.ok) return loaded;
|
|
142
|
+
for (const row of ledger.value.rows)
|
|
143
|
+
if (
|
|
144
|
+
row.type === "skill.loaded" &&
|
|
145
|
+
row.actor.session === session &&
|
|
146
|
+
typeof row.skill === "string"
|
|
147
|
+
)
|
|
148
|
+
loaded.add(row.skill);
|
|
149
|
+
return loaded;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const SKILL_FILE = /(?:^|[\\/])(workit-[a-z-]+)[\\/]SKILL\.md$/;
|
|
153
|
+
|
|
154
|
+
/** The workit skill whose SKILL.md a path names (Pi's read tool). */
|
|
155
|
+
export const skillFileIn = (file: string): string | null => SKILL_FILE.exec(file)?.[1] ?? null;
|
|
156
|
+
|
|
157
|
+
/** Commands that read a file for the agent: a shell-loaded skill is one of these on its SKILL.md. */
|
|
158
|
+
const READERS = new Set([
|
|
159
|
+
"cat",
|
|
160
|
+
"sed",
|
|
161
|
+
"head",
|
|
162
|
+
"tail",
|
|
163
|
+
"less",
|
|
164
|
+
"more",
|
|
165
|
+
"bat",
|
|
166
|
+
"nl",
|
|
167
|
+
"get-content",
|
|
168
|
+
"gc",
|
|
169
|
+
"type",
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
/** The workit skill whose SKILL.md a shell command reads (Codex, Cursor); never git, grep or an editor. */
|
|
173
|
+
export const skillReadIn = (command: string, dialect: ShellDialect = "posix"): string | null => {
|
|
174
|
+
for (const { words } of segmentsOf(command, dialect)) {
|
|
175
|
+
if (!READERS.has((words[0]?.split(/[\\/]/).at(-1) ?? "").toLowerCase())) continue;
|
|
176
|
+
// `sed -i` edits the file; it does not load it.
|
|
177
|
+
if (words.some((word) => /^(?:-i(?:\.\w+)?|--in-place(?:=.*)?)$/.test(word))) continue;
|
|
178
|
+
for (const word of words.slice(1)) {
|
|
179
|
+
const skill = skillFileIn(word);
|
|
180
|
+
if (skill) return skill;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** Record that the session loaded a workit skill (`via`: the tool, a slash command, a file read). */
|
|
187
|
+
export function recordSkillLoad(
|
|
188
|
+
input: HookInput,
|
|
189
|
+
name: string,
|
|
190
|
+
via: "tool" | "slash" | "read",
|
|
191
|
+
): void {
|
|
192
|
+
try {
|
|
193
|
+
const skill = workitSkillOf(name);
|
|
194
|
+
const session = input.session.id;
|
|
195
|
+
if (!skill || !session) return;
|
|
196
|
+
if (!inWorkitWorkspace(input.cwd)) return;
|
|
197
|
+
const file = markerFile(input, session);
|
|
198
|
+
if (!file) return;
|
|
199
|
+
const marker = readMarker(file);
|
|
200
|
+
if (marker.loaded.includes(skill)) return;
|
|
201
|
+
writeMarker(file, { ...marker, loaded: [...marker.loaded, skill] });
|
|
202
|
+
if (loadedSkills(input.cwd, session).has(skill)) return;
|
|
203
|
+
appendHookObserved(input.cwd, {
|
|
204
|
+
type: "skill.loaded",
|
|
205
|
+
actor: { host: input.host, session, agentId: input.session.agentId },
|
|
206
|
+
skill,
|
|
207
|
+
via,
|
|
208
|
+
});
|
|
209
|
+
} catch {
|
|
210
|
+
// Fail open: a lost row only means a later nudge or a retro miscount.
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The nudge for `skill` once per session: null when it was nudged or loaded. */
|
|
215
|
+
const nudgeOnce = (
|
|
216
|
+
input: HookInput,
|
|
217
|
+
session: string,
|
|
218
|
+
skill: WorkitSkill,
|
|
219
|
+
line: string,
|
|
220
|
+
): string | null => {
|
|
221
|
+
const file = markerFile(input, session);
|
|
222
|
+
if (!file) return null;
|
|
223
|
+
const marker = readMarker(file);
|
|
224
|
+
if (marker.nudged.includes(skill) || marker.loaded.includes(skill)) return null;
|
|
225
|
+
if (loadedSkills(input.cwd, session).has(skill)) return null;
|
|
226
|
+
writeMarker(file, { ...marker, nudged: [...marker.nudged, skill] });
|
|
227
|
+
return line;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const nudgeText = (input: HookInput, skill: WorkitSkill, why: string): string =>
|
|
231
|
+
USER_INVOKED.has(skill)
|
|
232
|
+
? `Workit: ${why} looks like ${skill} work, which the user starts; offer it (/${skill.replace(/^workit-/, "wk-")}) rather than loading it.`
|
|
233
|
+
: `Workit: ${why} looks like ${skill} work; if it is, ${skillLoadWording(SKILL_HOST[input.host], skill)} first.`;
|
|
234
|
+
|
|
235
|
+
/** A skill the prompt already loads: `/wk-shape`, `/workit:shape`, Pi's expanded
|
|
236
|
+
* `/skill:` block, or OpenCode's rewritten `/wk-*` command. */
|
|
237
|
+
const promptLoads = (prompt: string): string | null =>
|
|
238
|
+
/^\s*\/(?:skill:)?([\w:-]+)/.exec(prompt)?.[1] ??
|
|
239
|
+
/^\s*<skill name="([\w:-]+)"/.exec(prompt)?.[1] ??
|
|
240
|
+
/^\s*Load the (workit-[a-z-]+) skill with the skill tool\b/.exec(prompt)?.[1] ??
|
|
241
|
+
null;
|
|
242
|
+
|
|
243
|
+
/** A main session's prompt in a Workit workspace: one advisory line naming the skill it asks for. */
|
|
244
|
+
export function promptNudge(input: HookInput, prompt: string | null | undefined): string | null {
|
|
245
|
+
try {
|
|
246
|
+
const session = input.session.id;
|
|
247
|
+
if (!prompt || !session || input.session.agentId) return null;
|
|
248
|
+
const loads = promptLoads(prompt);
|
|
249
|
+
if (loads) {
|
|
250
|
+
// The prompt already loads its skill; record it, never nudge.
|
|
251
|
+
recordSkillLoad(input, loads, "slash");
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
const hit = promptTrigger(prompt);
|
|
255
|
+
if (!hit || !inWorkitWorkspace(input.cwd)) return null;
|
|
256
|
+
return nudgeOnce(
|
|
257
|
+
input,
|
|
258
|
+
session,
|
|
259
|
+
hit.skill,
|
|
260
|
+
nudgeText(input, hit.skill, `this request ("${hit.trigger}")`),
|
|
261
|
+
);
|
|
262
|
+
} catch {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const WORKIT_DELIVERY = /^(?:pr (?:create|merge)|ci wait|git push)\b/;
|
|
268
|
+
const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
|
|
269
|
+
const LAUNCHERS = new Set([
|
|
270
|
+
"npx",
|
|
271
|
+
"bunx",
|
|
272
|
+
"pnpx",
|
|
273
|
+
"-y",
|
|
274
|
+
"--yes",
|
|
275
|
+
"pnpm",
|
|
276
|
+
"dlx",
|
|
277
|
+
"exec",
|
|
278
|
+
"env",
|
|
279
|
+
"sudo",
|
|
280
|
+
"command",
|
|
281
|
+
]);
|
|
282
|
+
|
|
283
|
+
/** Does a segment list run a workit delivery verb (also inside `bash -c '…'`)? */
|
|
284
|
+
const runsWorkitDelivery = (command: string, dialect: ShellDialect, depth = 0): boolean =>
|
|
285
|
+
segmentsOf(command, dialect).some(({ words }) => {
|
|
286
|
+
// The command word, past env assignments and package runners (`npx -y`).
|
|
287
|
+
const at = words.findIndex((word) => !/^\w+=/.test(word) && !LAUNCHERS.has(word));
|
|
288
|
+
if (at >= 0 && /(?:^|[\\/])workit(?:\.exe)?$|^@brainervirus\/workit-cli$/.test(words[at])) {
|
|
289
|
+
const args = words.slice(at + 1);
|
|
290
|
+
if (args.some((arg) => arg === "--help" || arg === "-h" || arg === "--dry-run")) return false;
|
|
291
|
+
return WORKIT_DELIVERY.test(args.join(" "));
|
|
292
|
+
}
|
|
293
|
+
const shell = SHELLS.has(words[at]?.split(/[\\/]/).at(-1) ?? "");
|
|
294
|
+
const script = shell && words[at + 1] === "-c" ? words[at + 2] : undefined;
|
|
295
|
+
return depth < 3 && script !== undefined && runsWorkitDelivery(script, "posix", depth + 1);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Does the command deliver in a Workit workspace: a push, a PR/MR create or
|
|
300
|
+
* merge, or a CI wait (never help or a dry run)?
|
|
301
|
+
*/
|
|
302
|
+
export const isDeliveryCommand = (
|
|
303
|
+
cwd: string,
|
|
304
|
+
command: string,
|
|
305
|
+
dialect: ShellDialect = "posix",
|
|
306
|
+
): boolean =>
|
|
307
|
+
rawDelivery(cwd, command, dialect) ||
|
|
308
|
+
(runsWorkitDelivery(command, dialect) && inWorkitWorkspace(cwd));
|
|
309
|
+
|
|
310
|
+
/** A delivery command in a main session that has not loaded workit-ship. */
|
|
311
|
+
export function shipNudge(input: HookInput, command: string): string | null {
|
|
312
|
+
try {
|
|
313
|
+
const session = input.session.id;
|
|
314
|
+
if (!session || input.session.agentId) return null;
|
|
315
|
+
const dialect = input.event.kind === "shell.pre" ? (input.event.dialect ?? "posix") : "posix";
|
|
316
|
+
if (!isDeliveryCommand(input.cwd, command, dialect)) return null;
|
|
317
|
+
return nudgeOnce(
|
|
318
|
+
input,
|
|
319
|
+
session,
|
|
320
|
+
"workit-ship",
|
|
321
|
+
nudgeText(input, "workit-ship", "this delivery step"),
|
|
322
|
+
);
|
|
323
|
+
} catch {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** `decision` with `line` added: a context grows by a line, `none` becomes one. */
|
|
329
|
+
export const withContextLine = (decision: HookDecision, line: string | null): HookDecision => {
|
|
330
|
+
if (!line) return decision;
|
|
331
|
+
if (decision.kind === "none") return { kind: "context", text: line };
|
|
332
|
+
if (decision.kind === "context") return { kind: "context", text: `${decision.text}\n${line}` };
|
|
333
|
+
return decision;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
/** Hosts whose shell nudge rides on the tool result: the raw-git nudge plus the ship line. */
|
|
337
|
+
export const shellNudge = (input: HookInput, command: string): HookDecision =>
|
|
338
|
+
withContextLine(rawGitPre(input, command), shipNudge(input, command));
|
package/src/hooks/write-gate.ts
CHANGED
|
@@ -207,13 +207,15 @@ const gateFor = (cwd: string): Gate | null => {
|
|
|
207
207
|
/**
|
|
208
208
|
* Deny a working-tree edit while the branch task has unmet before-write
|
|
209
209
|
* requirements. `paths` are the files the tool writes, or null when unknown
|
|
210
|
-
* (shell
|
|
210
|
+
* (a shell write, a patch whose headers cannot be read); unknown or empty
|
|
211
|
+
* targets are gated, and an edit touching only exempt files is always allowed.
|
|
211
212
|
*/
|
|
212
213
|
export function writeGate(cwd: string, paths: readonly string[] | null): HookDecision {
|
|
213
214
|
try {
|
|
214
215
|
const gate = gateFor(cwd);
|
|
215
216
|
if (!gate || gate.blockers.length === 0) return NONE;
|
|
216
|
-
if (paths && paths.every((file) => exempt(cwd, gate.root, file, gate.plans)))
|
|
217
|
+
if (paths?.length && paths.every((file) => exempt(cwd, gate.root, file, gate.plans)))
|
|
218
|
+
return NONE;
|
|
217
219
|
const reasons = gate.blockers.map((item) => `${item.ruleId}: ${item.reason}`).join("; ");
|
|
218
220
|
return {
|
|
219
221
|
kind: "deny",
|
package/src/ledger.ts
CHANGED
|
@@ -1784,6 +1784,9 @@ export function summarizeRow(row: ReadRow): RowSummary {
|
|
|
1784
1784
|
case "standing.cleared":
|
|
1785
1785
|
summary = `cleared ${text("target") || "every standing order"} (fanout ${text("fanout")})`;
|
|
1786
1786
|
break;
|
|
1787
|
+
case "skill.loaded":
|
|
1788
|
+
summary = `${text("skill")} (${text("via") || "tool"})${row.actor.session ? "" : " (no session)"}`;
|
|
1789
|
+
break;
|
|
1787
1790
|
case "commit.recorded":
|
|
1788
1791
|
summary = `${(row.head ?? "?").slice(0, 12)} ${text("subject")}${row.actor.session ? "" : " (no session)"}`;
|
|
1789
1792
|
break;
|