@brainervirus/workit-core 8.35.2 → 8.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/workit-implement/SKILL.md +1 -1
- package/skills/workit-ship/SKILL.md +1 -1
- package/src/fanout-brief.ts +16 -2
- package/src/fanout-status.ts +8 -3
- package/src/hooks/budget-signal.ts +284 -0
- package/src/hooks/handle.ts +33 -9
- package/src/hooks/hosts/claude-code.ts +6 -5
package/package.json
CHANGED
|
@@ -23,7 +23,7 @@ description: Build a requested change in small verified steps - follow local pat
|
|
|
23
23
|
(`workit check test`, plus `lint` and `typecheck` when the repo has them),
|
|
24
24
|
not after every edit. Heavy work runs under one machine-wide lease: an ad-hoc
|
|
25
25
|
suite is `workit check --heavy -- <cmd>`, never a hand-made flock; a busy
|
|
26
|
-
lease waits and names its holder (`workit lock status`).
|
|
26
|
+
lease waits and names its holder (`workit lock status`). A tool warning is a finding: fix it in this PR in the right layer, or record the false positive with evidence; pre-existing ones get their own PR.
|
|
27
27
|
5. Prove the feature on its real surface with the project's `verify-<app>`
|
|
28
28
|
skill; none yet? Write one (workit-verify-app). Tests show branch behavior,
|
|
29
29
|
not that the feature works. The report carries what you observed: a UI
|
|
@@ -49,7 +49,7 @@ the same check. Fix round and recheck both failed: a decider (workit-fanout).
|
|
|
49
49
|
5. **CI.** Local gates mirror CI: push and continue; `workit ci wait` once on
|
|
50
50
|
the final head, in the background where the host allows. Red: read `logTail`, classify. Flake or infra: one
|
|
51
51
|
`workit ci rerun --failed --reason flake|infra` per head. Real: reproduce
|
|
52
|
-
with `workit check`, fix the root cause, batch every fix in one push.
|
|
52
|
+
with `workit check`, fix the root cause, batch every fix in one push. A tool warning is a finding: fix it in this PR in the right layer, or record the false positive with evidence; pre-existing ones get their own PR.
|
|
53
53
|
6. **Verified.** The lane in `workit pr status` decides: none, no verdict; self, your `--self` verdict; focused or full (or `verification: self|independent`), one non-author verifier per PR after the last push, never per push (workit-review), on Claude Code with the `model` and `effort` of `workit models show --role verifier`. A docs-only push (or test-only under a unit verdict) keeps the verdict (`carried`); `reverify: focused` re-checks only the new diff, reusing green CI; one verdict at a stack's top covers its unchanged lower PRs. Land only when
|
|
54
54
|
granted: `workit stack land` or `workit pr merge`; `--unverified --reason` if user-asked.
|
|
55
55
|
7. **Observe it landed:** `workit verify-delivery pr` or `merge`. After a failed
|
package/src/fanout-brief.ts
CHANGED
|
@@ -64,6 +64,15 @@ export type BriefOutcome = {
|
|
|
64
64
|
|
|
65
65
|
export const DEFAULT_TIMEBOX = "30 minutes";
|
|
66
66
|
|
|
67
|
+
/** The TIMEBOX line's tool-call budget when the slice names none (hooks/budget-signal.ts). */
|
|
68
|
+
export const DEFAULT_TOOL_CALLS = 120;
|
|
69
|
+
|
|
70
|
+
/** The slice's TIMEBOX text plus a tool-call budget unless it already names one. */
|
|
71
|
+
const timeboxText = (timebox: string | null): string => {
|
|
72
|
+
const text = timebox ?? DEFAULT_TIMEBOX;
|
|
73
|
+
return /\b\d+\s+tool calls\b/u.test(text) ? text : `${text}, ${DEFAULT_TOOL_CALLS} tool calls`;
|
|
74
|
+
};
|
|
75
|
+
|
|
67
76
|
const REPORT = [
|
|
68
77
|
"REPORT: branch, head SHA, files changed, each VERIFY command with its exit code,",
|
|
69
78
|
" each ACCEPTANCE line met / not met, rulings you made (`workit ledger ruling`),",
|
|
@@ -151,7 +160,12 @@ function workerSession(lead: string, slice: string, attempt: number | null): str
|
|
|
151
160
|
export function renderBrief(
|
|
152
161
|
cwd: string,
|
|
153
162
|
plan: FanoutFile,
|
|
154
|
-
input: {
|
|
163
|
+
input: {
|
|
164
|
+
slice: string;
|
|
165
|
+
mode: BriefMode | null;
|
|
166
|
+
attempt: number | null;
|
|
167
|
+
actor: LedgerActor;
|
|
168
|
+
},
|
|
155
169
|
): FanoutResult<BriefOutcome> {
|
|
156
170
|
const found = findSlice(plan, input.slice);
|
|
157
171
|
if (!found.ok) return found;
|
|
@@ -250,7 +264,7 @@ export function renderBrief(
|
|
|
250
264
|
` verifier: model ${verifier.model}${verifier.effort ? ` effort ${verifier.effort}` : ""} (${verifier.source})`,
|
|
251
265
|
]
|
|
252
266
|
: []),
|
|
253
|
-
`TIMEBOX: ${slice.timebox
|
|
267
|
+
`TIMEBOX: ${timeboxText(slice.timebox)}; past it without a new commit you will be replaced`,
|
|
254
268
|
scratchLine,
|
|
255
269
|
`FORBIDDEN: ${slice.forbidden.join("; ")}`,
|
|
256
270
|
...fanIn,
|
package/src/fanout-status.ts
CHANGED
|
@@ -92,10 +92,15 @@ export type StatusOptions = {
|
|
|
92
92
|
host?: string | null;
|
|
93
93
|
};
|
|
94
94
|
|
|
95
|
-
/**
|
|
95
|
+
/**
|
|
96
|
+
* A TIMEBOX such as `45 minutes`, `2h` or `1h30m`, in ms; null without a unit
|
|
97
|
+
* or otherwise. A tool-call budget beside it (`45 minutes, 80 tool calls`) is
|
|
98
|
+
* not time: only the rest is parsed, so `80 tool calls` alone is null.
|
|
99
|
+
*/
|
|
96
100
|
export function timeboxMs(text: string | null): number | null {
|
|
97
|
-
|
|
98
|
-
|
|
101
|
+
const time = text?.replace(/[\s,;]*\b\d+\s+tool calls\b[\s,;]*/gu, " ").trim();
|
|
102
|
+
if (!time || /^\d+(?:\.\d+)?$/u.test(time)) return null;
|
|
103
|
+
const ms = parseDuration(time);
|
|
99
104
|
return ms && ms > 0 ? ms : null;
|
|
100
105
|
}
|
|
101
106
|
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// The worker tool-call budget (retro P9, ruling mvrhg6lc). A brief's TIMEBOX
|
|
2
|
+
// names `N tool calls`; the spawn that carries it records N for the next
|
|
3
|
+
// subagent of that type (`<store>/budget/spawns-<lead>.jsonl`), SubagentStart
|
|
4
|
+
// binds it to the agent (`<store>/budget/<agent>.json`: limit and the last
|
|
5
|
+
// multiple told), and every pre-tool hook of that agent appends one byte to
|
|
6
|
+
// `<store>/budget/<agent>.calls`. At N calls (and 2N, 3N…) one line rides the
|
|
7
|
+
// standing-order channel; the call is never denied. An agent without a
|
|
8
|
+
// budget file is never counted.
|
|
9
|
+
// fs, path and crypto only: the Codex launcher imports this before the
|
|
10
|
+
// runtime, and the Claude Code launcher (bin/workit-hook.mjs) keeps a copy of
|
|
11
|
+
// spawnBudget, noteSpawnBudget and tickBudget that
|
|
12
|
+
// test/workit-core/hooks/budget-signal.test.ts holds equal.
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { gitStoreDir } from "./standing-signal";
|
|
17
|
+
import type { HookInput } from "./protocol";
|
|
18
|
+
|
|
19
|
+
const hash = (text: string): string => createHash("sha256").update(text).digest("hex").slice(0, 32);
|
|
20
|
+
|
|
21
|
+
const budgetDir = (store: string): string => path.join(store, "budget");
|
|
22
|
+
|
|
23
|
+
/** The same key as the standing seen file: one per host session or subagent. */
|
|
24
|
+
const agentBase = (store: string, session: string, agentId: string | null): string =>
|
|
25
|
+
path.join(budgetDir(store), hash(`${session}\0${agentId ?? ""}`));
|
|
26
|
+
|
|
27
|
+
const spawnsFile = (store: string, session: string): string =>
|
|
28
|
+
path.join(budgetDir(store), `spawns-${hash(session)}.jsonl`);
|
|
29
|
+
|
|
30
|
+
/** `N tool calls` on a brief's TIMEBOX line, else null. */
|
|
31
|
+
export const spawnBudget = (prompt: string): number | null => {
|
|
32
|
+
const found = /^TIMEBOX:[^\n]*?\b(\d+)\s+tool calls\b/mu.exec(prompt);
|
|
33
|
+
const n = found ? Number(found[1]) : NaN;
|
|
34
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A lead's spawn that will run: queue its budget, or an explicit none, for
|
|
39
|
+
* the next subagent of `type`. Every spawn of a type queues one entry, so the
|
|
40
|
+
* FIFO stays aligned with the SubagentStart order. Call it only once the
|
|
41
|
+
* spawn is allowed: a denied spawn must leave no entry.
|
|
42
|
+
*/
|
|
43
|
+
export const noteSpawnBudget = (
|
|
44
|
+
cwd: string,
|
|
45
|
+
session: string,
|
|
46
|
+
type: string | null,
|
|
47
|
+
prompt: string,
|
|
48
|
+
): void => {
|
|
49
|
+
try {
|
|
50
|
+
const store = session ? gitStoreDir(cwd) : null;
|
|
51
|
+
if (!store) return;
|
|
52
|
+
fs.mkdirSync(budgetDir(store), { recursive: true });
|
|
53
|
+
fs.appendFileSync(
|
|
54
|
+
spawnsFile(store, session),
|
|
55
|
+
`${JSON.stringify({ type: type ?? "", n: spawnBudget(prompt) })}\n`,
|
|
56
|
+
);
|
|
57
|
+
} catch {
|
|
58
|
+
// Without the note the worker runs unbudgeted.
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type Budget = { limit: number; told: number };
|
|
63
|
+
|
|
64
|
+
/** Small files written whole; a torn read fails open as "no budget". */
|
|
65
|
+
const readBudget = (file: string): Budget | null => {
|
|
66
|
+
try {
|
|
67
|
+
const { limit, told } = JSON.parse(fs.readFileSync(file, "utf8")) as Partial<Budget>;
|
|
68
|
+
return typeof limit === "number" && limit > 0 ? { limit, told: Number(told) || 0 } : null;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* SubagentStart: take the head entry queued for this agent's type and bind
|
|
76
|
+
* its budget to the agent. Returns the limit, or null when the entry says
|
|
77
|
+
* none or nothing was queued.
|
|
78
|
+
*/
|
|
79
|
+
export const claimSpawnBudget = (
|
|
80
|
+
cwd: string,
|
|
81
|
+
session: string,
|
|
82
|
+
agentId: string,
|
|
83
|
+
agentType: string | null,
|
|
84
|
+
): number | null => {
|
|
85
|
+
try {
|
|
86
|
+
const store = session ? gitStoreDir(cwd) : null;
|
|
87
|
+
if (!store) return null;
|
|
88
|
+
const file = spawnsFile(store, session);
|
|
89
|
+
const entries = fs
|
|
90
|
+
.readFileSync(file, "utf8")
|
|
91
|
+
.split("\n")
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.map((line) => JSON.parse(line) as { type?: unknown; n?: unknown });
|
|
94
|
+
const at = entries.findIndex((entry) => entry.type === (agentType ?? ""));
|
|
95
|
+
if (at < 0) return null;
|
|
96
|
+
const [{ n }] = entries.splice(at, 1);
|
|
97
|
+
fs.writeFileSync(file, entries.map((entry) => `${JSON.stringify(entry)}\n`).join(""));
|
|
98
|
+
if (typeof n !== "number" || n <= 0) return null;
|
|
99
|
+
startBudget(store, session, agentId, n);
|
|
100
|
+
return n;
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Starts a budget; first ages out files of agents that never stopped, older than 7 days. */
|
|
107
|
+
const startBudget = (store: string, session: string, agentId: string | null, n: number): void => {
|
|
108
|
+
const dir = budgetDir(store);
|
|
109
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
110
|
+
const cutoff = Date.now() - 6048e5;
|
|
111
|
+
for (const name of fs.readdirSync(dir))
|
|
112
|
+
try {
|
|
113
|
+
if (fs.statSync(path.join(dir, name)).mtimeMs < cutoff) fs.rmSync(path.join(dir, name));
|
|
114
|
+
} catch {
|
|
115
|
+
// Another hook removed it first.
|
|
116
|
+
}
|
|
117
|
+
const base = agentBase(store, session, agentId);
|
|
118
|
+
fs.writeFileSync(`${base}.json`, JSON.stringify({ limit: n, told: 0 }));
|
|
119
|
+
fs.rmSync(`${base}.calls`, { force: true });
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A host that launches the worker itself (Pi) binds the budget its prompt
|
|
124
|
+
* names straight to the worker's session. Returns the limit, else null.
|
|
125
|
+
*/
|
|
126
|
+
export const bindBudget = (
|
|
127
|
+
cwd: string,
|
|
128
|
+
session: string,
|
|
129
|
+
agentId: string | null,
|
|
130
|
+
prompt: string,
|
|
131
|
+
): number | null => {
|
|
132
|
+
try {
|
|
133
|
+
const n = spawnBudget(prompt);
|
|
134
|
+
const store = n && session ? gitStoreDir(cwd) : null;
|
|
135
|
+
if (!n || !store) return null;
|
|
136
|
+
startBudget(store, session, agentId, n);
|
|
137
|
+
return n;
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Count one tool call of a budgeted agent. True when the count reached the
|
|
145
|
+
* next untold multiple of the budget, so the runtime must load to tell it.
|
|
146
|
+
*/
|
|
147
|
+
export const tickBudget = (cwd: string, session: string, agentId: string | null): boolean => {
|
|
148
|
+
try {
|
|
149
|
+
const store = session ? gitStoreDir(cwd) : null;
|
|
150
|
+
if (!store) return false;
|
|
151
|
+
const base = agentBase(store, session, agentId);
|
|
152
|
+
const budget = readBudget(`${base}.json`);
|
|
153
|
+
if (!budget) return false;
|
|
154
|
+
fs.appendFileSync(`${base}.calls`, ".");
|
|
155
|
+
return fs.statSync(`${base}.calls`).size >= budget.limit * (budget.told + 1);
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const lineFor = (calls: number): string =>
|
|
162
|
+
`Tool-call budget reached (${calls}): commit your work, write your report, stop.`;
|
|
163
|
+
|
|
164
|
+
/** The agent's base path, its budget, and how many multiples of it its calls reached. */
|
|
165
|
+
const reachedOf = (cwd: string, session: string, agentId: string | null) => {
|
|
166
|
+
const store = session ? gitStoreDir(cwd) : null;
|
|
167
|
+
const base = store && agentBase(store, session, agentId);
|
|
168
|
+
const budget = base && readBudget(`${base}.json`);
|
|
169
|
+
if (!budget) return null;
|
|
170
|
+
return { base, budget, reached: Math.floor(fs.statSync(`${base}.calls`).size / budget.limit) };
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** SubagentStop: the agent's budget and counter go. */
|
|
174
|
+
export const dropBudget = (cwd: string, session: string, agentId: string | null): void => {
|
|
175
|
+
const store = session ? gitStoreDir(cwd) : null;
|
|
176
|
+
if (store)
|
|
177
|
+
for (const ext of [".json", ".calls"])
|
|
178
|
+
fs.rmSync(agentBase(store, session, agentId) + ext, { force: true });
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** The one line for a budget reached and not yet told (marked told), else null. */
|
|
182
|
+
export const budgetLine = (cwd: string, session: string, agentId: string | null): string | null => {
|
|
183
|
+
try {
|
|
184
|
+
const at = reachedOf(cwd, session, agentId);
|
|
185
|
+
if (!at || at.reached <= at.budget.told) return null;
|
|
186
|
+
fs.writeFileSync(`${at.base}.json`, JSON.stringify({ ...at.budget, told: at.reached }));
|
|
187
|
+
return lineFor(at.budget.limit * at.reached);
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* For a host that rebuilds its context on every model call (OpenCode): the
|
|
195
|
+
* line while the budget stands reached, else null. Never marks it told.
|
|
196
|
+
*/
|
|
197
|
+
export const budgetStanding = (
|
|
198
|
+
cwd: string,
|
|
199
|
+
session: string,
|
|
200
|
+
agentId: string | null,
|
|
201
|
+
): string | null => {
|
|
202
|
+
try {
|
|
203
|
+
const at = reachedOf(cwd, session, agentId);
|
|
204
|
+
return at && at.reached > 0 ? lineFor(at.budget.limit * at.reached) : null;
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/** Codex tools Workit may deny; any other PreToolUse is never denied there. */
|
|
211
|
+
const CODEX_GATED = new Set(["bash", "unifiedexec", "shell", "applypatch", "edit", "write"]);
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* One native Codex PreToolUse payload: a lead's spawn (a tool input naming an
|
|
215
|
+
* agent type, or carrying a brief) queues its budget or an explicit none, and
|
|
216
|
+
* a budgeted subagent's call is counted. A spawn is a tool Workit never denies
|
|
217
|
+
* on Codex, so queueing here cannot outlive a denied call. True when the
|
|
218
|
+
* count reached the budget, so the runtime tells it.
|
|
219
|
+
*/
|
|
220
|
+
export const observeHookBudget = (payload: string): boolean => {
|
|
221
|
+
try {
|
|
222
|
+
const value = JSON.parse(payload) as Record<string, unknown>;
|
|
223
|
+
if (value.hook_event_name !== "PreToolUse" || typeof value.session_id !== "string")
|
|
224
|
+
return false;
|
|
225
|
+
const cwd = typeof value.cwd === "string" ? value.cwd : ".";
|
|
226
|
+
const agentId =
|
|
227
|
+
typeof value.agent_id === "string" && value.agent_id.trim() ? value.agent_id : null;
|
|
228
|
+
if (agentId) return tickBudget(cwd, value.session_id, agentId);
|
|
229
|
+
const tool = typeof value.tool_name === "string" ? value.tool_name : "";
|
|
230
|
+
const spawn = value.tool_input;
|
|
231
|
+
if (
|
|
232
|
+
CODEX_GATED.has(tool.toLowerCase().replace(/[_-]/g, "")) ||
|
|
233
|
+
!spawn ||
|
|
234
|
+
typeof spawn !== "object"
|
|
235
|
+
)
|
|
236
|
+
return false;
|
|
237
|
+
const fields = spawn as Record<string, unknown>;
|
|
238
|
+
const prompt = Object.values(fields).find(
|
|
239
|
+
(field): field is string => typeof field === "string" && field.includes("TIMEBOX:"),
|
|
240
|
+
);
|
|
241
|
+
const type = [fields.agent_type, fields.subagent_type].find(
|
|
242
|
+
(field): field is string => typeof field === "string",
|
|
243
|
+
);
|
|
244
|
+
if (prompt !== undefined || type !== undefined)
|
|
245
|
+
noteSpawnBudget(cwd, value.session_id, type ?? null, prompt ?? "");
|
|
246
|
+
return false;
|
|
247
|
+
} catch {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/** What handleHook does with a budget, for the hosts whose entry passes it (HookDeps.budget). */
|
|
253
|
+
export type BudgetHooks = {
|
|
254
|
+
/** A pre-tool answer that lets the call run: the line once reached. */
|
|
255
|
+
line: (input: HookInput) => string | null;
|
|
256
|
+
/** SubagentStart: bind the queued budget. */
|
|
257
|
+
start: (input: HookInput, agentId: string, agentType: string) => void;
|
|
258
|
+
/** A lead's spawn the gate let through (Claude Code: untyped is general-purpose). */
|
|
259
|
+
spawn: (input: HookInput, spawn: Record<string, unknown>) => void;
|
|
260
|
+
/** SubagentStop: drop the agent's files. */
|
|
261
|
+
stop: (input: HookInput, agentId: string) => void;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
export const budgetHooks: BudgetHooks = {
|
|
265
|
+
line: (input) => budgetLine(input.cwd, input.session.id ?? "", input.session.agentId),
|
|
266
|
+
start: (input, agentId, agentType) =>
|
|
267
|
+
void claimSpawnBudget(input.cwd, input.session.id ?? "", agentId, agentType),
|
|
268
|
+
spawn: (input, spawn) => {
|
|
269
|
+
if (!input.session.agentId)
|
|
270
|
+
noteSpawnBudget(
|
|
271
|
+
input.cwd,
|
|
272
|
+
input.session.id ?? "",
|
|
273
|
+
String(spawn.subagent_type || "general-purpose"),
|
|
274
|
+
String(spawn.prompt ?? ""),
|
|
275
|
+
);
|
|
276
|
+
},
|
|
277
|
+
stop: (input, agentId) => {
|
|
278
|
+
try {
|
|
279
|
+
dropBudget(input.cwd, input.session.id ?? "", agentId);
|
|
280
|
+
} catch {
|
|
281
|
+
// Aged out by the next startBudget.
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
};
|
package/src/hooks/handle.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
} from "./skill-nudge";
|
|
31
31
|
import { spawnDecision } from "./spawn-gate";
|
|
32
32
|
import { seedStanding, standingDelta } from "./standing";
|
|
33
|
+
import type { BudgetHooks } from "./budget-signal";
|
|
33
34
|
import { modelSwitchedOnDisk, trackJudge } from "./session-marker";
|
|
34
35
|
import { invariantBootstrap } from "../core/methods";
|
|
35
36
|
import { stopDecision } from "./stop";
|
|
@@ -39,7 +40,16 @@ import { confineImplementer, confineImplementerShell } from "./worktree-confine"
|
|
|
39
40
|
import { agentSteering, workitRole } from "./steering";
|
|
40
41
|
import type { HookDecision, HookEventKind, HookInput, HostAdapter, RenderedHook } from "./protocol";
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
/**
|
|
44
|
+
* `budget`: the worker tool-call budget (budget-signal.ts budgetHooks), given
|
|
45
|
+
* only by the hosts that count calls in their launcher (Claude Code, Codex),
|
|
46
|
+
* so other hook bundles do not carry it.
|
|
47
|
+
*/
|
|
48
|
+
export type HookDeps = {
|
|
49
|
+
descriptor: HostDescriptor;
|
|
50
|
+
addendum: string | null;
|
|
51
|
+
budget?: BudgetHooks;
|
|
52
|
+
};
|
|
43
53
|
|
|
44
54
|
const usable = (value: Support) => value === "native" || value === "partial";
|
|
45
55
|
const NONE: HookDecision = { kind: "none" };
|
|
@@ -125,9 +135,11 @@ const subagentStartText = (
|
|
|
125
135
|
const PRE_TOOL = new Set<HookEventKind>(["shell.pre", "write.pre", "tool.pre"]);
|
|
126
136
|
|
|
127
137
|
/**
|
|
128
|
-
* A running worker's standing-order delta
|
|
129
|
-
* the
|
|
130
|
-
*
|
|
138
|
+
* A running worker's standing-order delta, and its tool-call budget line once
|
|
139
|
+
* the launcher counted it reached (budget-signal.ts), ride any pre-tool answer
|
|
140
|
+
* that lets the call run. In-process hosts deliver them on their own channel
|
|
141
|
+
* (OpenCode's system context, Pi's tool result), so they are never spent here
|
|
142
|
+
* for them.
|
|
131
143
|
*/
|
|
132
144
|
export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
133
145
|
const decision = decide(input, deps);
|
|
@@ -138,7 +150,8 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
138
150
|
decision.kind === "ask"
|
|
139
151
|
)
|
|
140
152
|
return decision;
|
|
141
|
-
const delta =
|
|
153
|
+
const delta =
|
|
154
|
+
[standingDelta(input), deps.budget?.line(input) ?? null].filter(Boolean).join("\n") || null;
|
|
142
155
|
if (!delta) return decision;
|
|
143
156
|
return decision.kind === "rewrite" || decision.kind === "update-input"
|
|
144
157
|
? { ...decision, text: [decision.text, delta].filter(Boolean).join("\n") }
|
|
@@ -285,7 +298,11 @@ function decide(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
285
298
|
case "subagent.start":
|
|
286
299
|
if (workitRole(descriptor, event.agentType) === "judge")
|
|
287
300
|
trackJudge(input, event.agentId, true);
|
|
288
|
-
seedStanding({
|
|
301
|
+
seedStanding({
|
|
302
|
+
...input,
|
|
303
|
+
session: { ...input.session, agentId: event.agentId },
|
|
304
|
+
});
|
|
305
|
+
deps.budget?.start(input, event.agentId, event.agentType);
|
|
289
306
|
return {
|
|
290
307
|
kind: "context",
|
|
291
308
|
text: subagentStartText(input, descriptor, event),
|
|
@@ -299,8 +316,13 @@ function decide(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
299
316
|
case "tool.pre":
|
|
300
317
|
// Host permission policy owns other tools; a skill load is recorded.
|
|
301
318
|
if (event.skill) recordSkillLoad(input, event.skill, "tool");
|
|
302
|
-
if (event.agentInput && input.host === "claude_code")
|
|
303
|
-
|
|
319
|
+
if (event.agentInput && input.host === "claude_code") {
|
|
320
|
+
const spawn = spawnDecision(descriptor, input.cwd, event.agentInput);
|
|
321
|
+
// A spawn the gate lets through queues its tool-call budget, or
|
|
322
|
+
// none; the launcher does so for one its fast path allows.
|
|
323
|
+
if (spawn.kind !== "deny") deps.budget?.spawn(input, event.agentInput);
|
|
324
|
+
return spawn;
|
|
325
|
+
}
|
|
304
326
|
return NONE;
|
|
305
327
|
case "stop":
|
|
306
328
|
return usable(descriptor.events.stop.support) ? stopDecision(input, descriptor) : NONE;
|
|
@@ -308,6 +330,7 @@ function decide(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
308
330
|
// arrive with the CLI-observed evidence model.
|
|
309
331
|
case "subagent.stop":
|
|
310
332
|
trackJudge(input, event.agentId, false);
|
|
333
|
+
if (event.agentId) deps.budget?.stop(input, event.agentId);
|
|
311
334
|
return NONE;
|
|
312
335
|
case "prompt.submit":
|
|
313
336
|
return NONE;
|
|
@@ -335,7 +358,7 @@ export const failureDecision = (
|
|
|
335
358
|
|
|
336
359
|
/** Parse, handle, and render one native payload. `error` is set on any failure. */
|
|
337
360
|
export const dispatchHook = (
|
|
338
|
-
adapter: HostAdapter,
|
|
361
|
+
adapter: HostAdapter & { budget?: BudgetHooks },
|
|
339
362
|
raw: unknown,
|
|
340
363
|
env: NodeJS.ProcessEnv = process.env,
|
|
341
364
|
): RenderedHook & { error: string | null } => {
|
|
@@ -353,6 +376,7 @@ export const dispatchHook = (
|
|
|
353
376
|
const decision = handleHook(parsed.input, {
|
|
354
377
|
descriptor,
|
|
355
378
|
addendum: adapter.addendum?.(parsed.input) ?? null,
|
|
379
|
+
budget: adapter.budget,
|
|
356
380
|
});
|
|
357
381
|
return { ...adapter.render(decision, parsed.native), error: null };
|
|
358
382
|
} catch (error) {
|
|
@@ -44,11 +44,12 @@ export const CLAUDE_CODE_DESCRIPTOR: HostDescriptor = {
|
|
|
44
44
|
// SubagentStart output is additionalContext only: it cannot block or bind.
|
|
45
45
|
"subagent.start": { support: "native", native: "SubagentStart" },
|
|
46
46
|
"prompt.submit": { support: "native", native: "UserPromptSubmit" },
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
47
|
+
// SubagentStop is never blocked: the launcher answers it itself, dropping
|
|
48
|
+
// the stopped agent's tool-call budget files (hooks/budget-signal.ts).
|
|
49
|
+
// Claude Code has PreCompact, but the plugin does not register it: events
|
|
50
|
+
// list what workit registers (docs/agents/hosts.md, Host parity);
|
|
51
|
+
// compaction restore runs on SessionStart source=compact.
|
|
52
|
+
"subagent.stop": { support: "native", native: "SubagentStop" },
|
|
52
53
|
"compact.pre": { support: "none", native: null },
|
|
53
54
|
// `{"decision":"block","reason"}` continues the turn; the input carries
|
|
54
55
|
// `stop_hook_active` and `last_assistant_message` (claude 2.1.295).
|