@brainervirus/workit-core 8.27.0 → 8.28.1
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/src/core/branch.ts +12 -0
- package/src/core/operation-input.ts +128 -8
- package/src/core/role-models.ts +27 -0
- package/src/hooks/agent-model.ts +40 -0
- package/src/hooks/handle.ts +4 -1
- package/src/hooks/hosts/claude-code.ts +16 -1
- package/src/hooks/protocol.ts +8 -0
package/package.json
CHANGED
package/src/core/branch.ts
CHANGED
|
@@ -318,6 +318,16 @@ export const validateCommitMessagePolicy = (
|
|
|
318
318
|
};
|
|
319
319
|
};
|
|
320
320
|
|
|
321
|
+
/**
|
|
322
|
+
* A TypeError or ReferenceError while resolving a policy is a bug in this
|
|
323
|
+
* runtime (or a host too old for it), not a config problem the user can
|
|
324
|
+
* repair: it propagates so the hook's failure decision applies instead of a
|
|
325
|
+
* misleading "repair or select a valid workspace policy".
|
|
326
|
+
*/
|
|
327
|
+
const rethrowRuntimeBug = (error: unknown): void => {
|
|
328
|
+
if (error instanceof TypeError || error instanceof ReferenceError) throw error;
|
|
329
|
+
};
|
|
330
|
+
|
|
321
331
|
export const validateBranchNameFor = (
|
|
322
332
|
workspaceRoot: string,
|
|
323
333
|
name: string,
|
|
@@ -326,6 +336,7 @@ export const validateBranchNameFor = (
|
|
|
326
336
|
const resolved = branchPolicySnapshotFor(workspaceRoot);
|
|
327
337
|
return validateBranchNamePolicy(name, resolved.namePolicy, resolved.source);
|
|
328
338
|
} catch (error) {
|
|
339
|
+
rethrowRuntimeBug(error);
|
|
329
340
|
const correction = "repair or select a valid workspace policy before creating a branch";
|
|
330
341
|
return {
|
|
331
342
|
ok: false,
|
|
@@ -347,6 +358,7 @@ export const validateCommitMessageFor = (
|
|
|
347
358
|
const resolved = commitPolicySnapshotFor(workspaceRoot);
|
|
348
359
|
return validateCommitMessagePolicy(message, resolved.policy, resolved.source);
|
|
349
360
|
} catch (error) {
|
|
361
|
+
rethrowRuntimeBug(error);
|
|
350
362
|
const correction = "repair or select a valid workspace policy before committing";
|
|
351
363
|
return {
|
|
352
364
|
ok: false,
|
|
@@ -184,9 +184,11 @@ export function flatOperationJsonSchema(
|
|
|
184
184
|
// Reads advertise only what reads take: no revisions, and task reads only their filters.
|
|
185
185
|
const readField = (name: string) =>
|
|
186
186
|
name === "taskId" || (family === "task" ? READ_TASK_FIELDS.has(name) : family === "policy");
|
|
187
|
+
// Only advertise what some listed action takes (derived from the contract).
|
|
188
|
+
const taken = (name: string) =>
|
|
189
|
+
family === "policy" || actions.some((action) => actionKeys(family, action)?.accepted.has(name));
|
|
187
190
|
for (const [name, field] of Object.entries({ ...COMMON, ...spec.fields }))
|
|
188
|
-
if (options.readOnly ? readField(name) :
|
|
189
|
-
properties[name] = property(field);
|
|
191
|
+
if (options.readOnly ? readField(name) : taken(name)) properties[name] = property(field);
|
|
190
192
|
return {
|
|
191
193
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
192
194
|
type: "object",
|
|
@@ -482,6 +484,129 @@ export function normalizeOperationInput(
|
|
|
482
484
|
return request;
|
|
483
485
|
}
|
|
484
486
|
|
|
487
|
+
// ---------------------------------------------------------------------------
|
|
488
|
+
// per-action keys, derived from the contract
|
|
489
|
+
|
|
490
|
+
type ZodLike = {
|
|
491
|
+
_zod: { def: { type: string; shape?: Record<string, ZodLike>; innerType?: ZodLike } };
|
|
492
|
+
};
|
|
493
|
+
const defOf = (node: ZodLike) => node._zod.def;
|
|
494
|
+
const unwrap = (node: ZodLike): ZodLike => {
|
|
495
|
+
let inner = node;
|
|
496
|
+
while (["optional", "nullable"].includes(defOf(inner).type) && defOf(inner).innerType)
|
|
497
|
+
inner = defOf(inner).innerType!;
|
|
498
|
+
return inner;
|
|
499
|
+
};
|
|
500
|
+
/** Contract keys the flat form renames. */
|
|
501
|
+
const FLAT_NAME: Record<string, string> = {
|
|
502
|
+
authorityRefs: "refs",
|
|
503
|
+
contentRefs: "refs",
|
|
504
|
+
statedChoice: "choice",
|
|
505
|
+
reviewContext: "reviewSession",
|
|
506
|
+
};
|
|
507
|
+
const NOT_FLAT = new Set(["schemaVersion", "action", "workspaceId"]);
|
|
508
|
+
|
|
509
|
+
export type ActionKeys = { accepted: Set<string>; required: string[] };
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* What one action of a family takes, read from the advertised contract:
|
|
513
|
+
* every top-level key, the keys of its nested objects (flat form, renamed per
|
|
514
|
+
* FLAT_NAME), and the required scalars. Lists, scopes and nullables are
|
|
515
|
+
* defaulted by normalization, so they are accepted but never required.
|
|
516
|
+
*/
|
|
517
|
+
export function actionKeys(family: OperationFamily, action: string): ActionKeys | null {
|
|
518
|
+
const options = (advertisedOperationSchemas[family] as unknown as { options: ZodLike[] }).options;
|
|
519
|
+
const option = options.find((item) => {
|
|
520
|
+
const literal = defOf(item).shape?.action as unknown as { _zod: { def: { values: string[] } } };
|
|
521
|
+
return literal._zod.def.values.includes(action);
|
|
522
|
+
});
|
|
523
|
+
if (!option) return null;
|
|
524
|
+
const out: ActionKeys = { accepted: new Set(), required: [] };
|
|
525
|
+
const walk = (shape: Record<string, ZodLike>, nested: boolean): void => {
|
|
526
|
+
for (const [key, node] of Object.entries(shape)) {
|
|
527
|
+
if (NOT_FLAT.has(key) || (nested && key === "taskId")) continue;
|
|
528
|
+
const inner = unwrap(node);
|
|
529
|
+
const type = defOf(inner).type;
|
|
530
|
+
const needed = !["optional", "nullable"].includes(defOf(node).type);
|
|
531
|
+
if (key === "scope")
|
|
532
|
+
for (const name of ["scope", "paths", "exclusions"]) out.accepted.add(name);
|
|
533
|
+
else if (type === "object" && key !== "bundle" && !(key in FLAT_NAME)) {
|
|
534
|
+
out.accepted.add(key);
|
|
535
|
+
walk(defOf(inner).shape!, true);
|
|
536
|
+
} else {
|
|
537
|
+
const name = FLAT_NAME[key] ?? key;
|
|
538
|
+
out.accepted.add(name);
|
|
539
|
+
if (needed && type !== "array" && type !== "record" && !out.required.includes(name))
|
|
540
|
+
out.required.push(name);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
walk(defOf(option).shape!, false);
|
|
545
|
+
return out;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** A minimal valid flat payload for one action: required scalars with placeholders. */
|
|
549
|
+
export function exampleOperation(family: OperationFamily, action: string): Record<string, unknown> {
|
|
550
|
+
const keys = actionKeys(family, action);
|
|
551
|
+
const example: Record<string, unknown> = { action };
|
|
552
|
+
for (const name of keys?.required ?? [])
|
|
553
|
+
example[name] = FLAT[family].fields[name]?.enum?.[0] ?? `<${name}>`;
|
|
554
|
+
if (family === "policy" && !keys?.required.length && action !== "explain")
|
|
555
|
+
example.riskTier = "normal";
|
|
556
|
+
return example;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const oneOf = (values: readonly string[]): string => values.join(" | ");
|
|
560
|
+
|
|
561
|
+
/** Every invalid_input the hosts return: the exact fix for this family and action. */
|
|
562
|
+
function invalidOperation(
|
|
563
|
+
family: OperationFamily,
|
|
564
|
+
input: unknown,
|
|
565
|
+
error: ZodErrorLike,
|
|
566
|
+
): Result<never> {
|
|
567
|
+
const request = isObject(input) ? input : {};
|
|
568
|
+
const action = typeof request.action === "string" ? request.action : "";
|
|
569
|
+
const actions = OPERATION_ACTIONS[family];
|
|
570
|
+
if (!actions.includes(action))
|
|
571
|
+
return failure("invalid_input", `${family}: action must be one of ${oneOf(actions)}`, {
|
|
572
|
+
fields: [{ path: "action", reason: `got ${JSON.stringify(request.action ?? null)}` }],
|
|
573
|
+
});
|
|
574
|
+
const own = actionKeys(family, action)!;
|
|
575
|
+
const fields: { path: string; reason: string }[] = [];
|
|
576
|
+
const sentence: string[] = [];
|
|
577
|
+
for (const issue of error.issues) {
|
|
578
|
+
if (issue.code === "unrecognized_keys") {
|
|
579
|
+
for (const key of issue.keys) {
|
|
580
|
+
const home = actions.find(
|
|
581
|
+
(other) => other !== action && actionKeys(family, other)!.accepted.has(key),
|
|
582
|
+
);
|
|
583
|
+
const reason = home
|
|
584
|
+
? `${key} belongs to action: ${home}`
|
|
585
|
+
: `${key} is not a field of ${family} ${action}; it takes ${[...own.accepted].join(", ")}`;
|
|
586
|
+
fields.push({ path: key, reason });
|
|
587
|
+
sentence.push(reason);
|
|
588
|
+
}
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
const last = String(issue.path.at(-1) ?? "");
|
|
592
|
+
const name = Object.entries(FLAT_NAME).find(([contract]) => contract === last)?.[1] ?? last;
|
|
593
|
+
const missing = issue.code === "invalid_type" && /received undefined/.test(issue.message);
|
|
594
|
+
// A nested container (binding, intent, ...) is absent because its flat fields are.
|
|
595
|
+
const absent = own.required.filter((key) => !(key in request));
|
|
596
|
+
const needs = missing && issue.path.length === 1 && absent.length ? absent.join(", ") : name;
|
|
597
|
+
const reason = missing ? `${family} ${action} requires ${needs}` : `${name}: ${issue.message}`;
|
|
598
|
+
fields.push({ path: issue.path.join("."), reason });
|
|
599
|
+
sentence.push(reason);
|
|
600
|
+
}
|
|
601
|
+
const example = JSON.stringify(exampleOperation(family, action));
|
|
602
|
+
return failure("invalid_input", `${[...new Set(sentence)].join("; ")}. Example: ${example}`, {
|
|
603
|
+
fields,
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
type ZodErrorLike = {
|
|
607
|
+
issues: { code: string; keys: string[]; path: PropertyKey[]; message: string }[];
|
|
608
|
+
};
|
|
609
|
+
|
|
485
610
|
/**
|
|
486
611
|
* Host-side validation before the engine runs: normalize, then parse against
|
|
487
612
|
* the advertised contract (taskId optional: the engine resolves the branch's
|
|
@@ -497,10 +622,5 @@ export function parseAdvertisedOperation(
|
|
|
497
622
|
return failure("unsupported_version", "unsupported schema version", { operation: family });
|
|
498
623
|
const parsed = advertisedOperationSchemas[family].safeParse(request);
|
|
499
624
|
if (parsed.success) return success(null, null, request);
|
|
500
|
-
return
|
|
501
|
-
fields: parsed.error.issues.map((issue) => ({
|
|
502
|
-
path: issue.code === "unrecognized_keys" ? issue.keys.join(".") : issue.path.join("."),
|
|
503
|
-
reason: issue.message,
|
|
504
|
-
})),
|
|
505
|
-
});
|
|
625
|
+
return invalidOperation(family, request, parsed.error as unknown as ZodErrorLike);
|
|
506
626
|
}
|
package/src/core/role-models.ts
CHANGED
|
@@ -207,3 +207,30 @@ export const roleModelIssues = (cwd: string, dir?: string): string[] => {
|
|
|
207
207
|
}
|
|
208
208
|
return issues;
|
|
209
209
|
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The role a Claude Code subagent type runs as: the Workit agents by their
|
|
213
|
+
* namespaced name, the built-in `Explore`. Plan, general-purpose and every
|
|
214
|
+
* other type map to none (their model is not Workit's to choose). An
|
|
215
|
+
* implementer spawn names its brief tier on a `TIER: <tier>` line in its
|
|
216
|
+
* prompt; without one it is `standard`.
|
|
217
|
+
*/
|
|
218
|
+
export const roleForAgentType = (agentType: unknown, prompt?: unknown): RoleSelector | null => {
|
|
219
|
+
switch (agentType) {
|
|
220
|
+
case "workit:implementer": {
|
|
221
|
+
const tier =
|
|
222
|
+
typeof prompt === "string"
|
|
223
|
+
? /^[ \t]*TIER:[ \t]*(mundane|standard|hard)\b/im.exec(prompt)?.[1]
|
|
224
|
+
: undefined;
|
|
225
|
+
return { role: "implementer", tier: (tier?.toLowerCase() as ModelTier) ?? "standard" };
|
|
226
|
+
}
|
|
227
|
+
case "workit:verifier":
|
|
228
|
+
return { role: "verifier" };
|
|
229
|
+
case "workit:reviewer":
|
|
230
|
+
return { role: "reviewer" };
|
|
231
|
+
case "Explore":
|
|
232
|
+
return { role: "explore" };
|
|
233
|
+
default:
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Role models on a Claude Code subagent spawn (#420): a spawn that names no
|
|
2
|
+
// `model` gets its role's model (and effort) from the role config, through
|
|
3
|
+
// PreToolUse `updatedInput` on the Agent tool. An explicit `model` always
|
|
4
|
+
// wins and a spawn is never denied over its model: a type with no role, or a
|
|
5
|
+
// role with nothing usable configured, runs as the caller wrote it.
|
|
6
|
+
import { resolveRoleModel, roleForAgentType } from "../core/role-models";
|
|
7
|
+
import type { HookDecision } from "./protocol";
|
|
8
|
+
|
|
9
|
+
/** The Agent tool's `model` and `effort` enums (claude 2.1.309); anything else fails its schema. */
|
|
10
|
+
const MODELS = new Set(["sonnet", "opus", "haiku", "fable"]);
|
|
11
|
+
const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
|
12
|
+
|
|
13
|
+
const given = (value: unknown): boolean => typeof value === "string" && value.trim() !== "";
|
|
14
|
+
|
|
15
|
+
export const agentModelDecision = (
|
|
16
|
+
cwd: string,
|
|
17
|
+
tool: Record<string, unknown>,
|
|
18
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
19
|
+
): HookDecision => {
|
|
20
|
+
// The host fixes the model itself: nothing to fill, and `model` is not in its schema.
|
|
21
|
+
if (given(tool.model) || env.CLAUDE_CODE_SUBAGENT_MODEL_FORCE) return { kind: "none" };
|
|
22
|
+
const selector = roleForAgentType(tool.subagent_type, tool.prompt);
|
|
23
|
+
if (!selector) return { kind: "none" };
|
|
24
|
+
const resolved = resolveRoleModel(cwd, selector, "claude_code");
|
|
25
|
+
if (!resolved) return { kind: "none" };
|
|
26
|
+
const model = resolved.model.toLowerCase();
|
|
27
|
+
const effort = resolved.effort?.toLowerCase() ?? null;
|
|
28
|
+
const input: Record<string, unknown> = { ...tool };
|
|
29
|
+
if (MODELS.has(model)) input.model = model;
|
|
30
|
+
if (effort && EFFORTS.has(effort) && !given(tool.effort)) input.effort = effort;
|
|
31
|
+
if (input.model === undefined && input.effort === tool.effort) return { kind: "none" };
|
|
32
|
+
const label = selector.tier ? `${selector.role} ${selector.tier}` : selector.role;
|
|
33
|
+
return {
|
|
34
|
+
kind: "update-input",
|
|
35
|
+
input,
|
|
36
|
+
text: `Workit set ${String(input.model ?? "no model")}${
|
|
37
|
+
input.effort !== tool.effort ? ` (effort ${String(input.effort)})` : ""
|
|
38
|
+
} on this ${String(tool.subagent_type)} spawn: role ${label}, ${resolved.source}. Name a model to choose another.`,
|
|
39
|
+
};
|
|
40
|
+
};
|
package/src/hooks/handle.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { HostDescriptor, Support } from "./descriptor";
|
|
|
9
9
|
import { branchGuidance } from "../core/branch";
|
|
10
10
|
import { workspaceTrailers } from "../core/workspaces";
|
|
11
11
|
import { judgeBranch, recordIssuedSession, verdictCommand } from "../host-session";
|
|
12
|
+
import { agentModelDecision } from "./agent-model";
|
|
12
13
|
import { cwdRewrite, verbFirst } from "./cwd-normalize";
|
|
13
14
|
import { workitBypassFlag } from "./delivery-verbs";
|
|
14
15
|
import { heavyDenial, heavyGate, rewriteNote, splitDenial } from "./heavy-gate";
|
|
@@ -159,7 +160,7 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
159
160
|
return decision;
|
|
160
161
|
const delta = standingDelta(input);
|
|
161
162
|
if (!delta) return decision;
|
|
162
|
-
return decision.kind === "rewrite"
|
|
163
|
+
return decision.kind === "rewrite" || decision.kind === "update-input"
|
|
163
164
|
? { ...decision, text: [decision.text, delta].filter(Boolean).join("\n") }
|
|
164
165
|
: withContextLine(decision, delta);
|
|
165
166
|
}
|
|
@@ -307,6 +308,8 @@ function decide(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
307
308
|
case "tool.pre":
|
|
308
309
|
// Host permission policy owns other tools; a skill load is recorded.
|
|
309
310
|
if (event.skill) recordSkillLoad(input, event.skill, "tool");
|
|
311
|
+
if (event.agentInput && input.host === "claude_code")
|
|
312
|
+
return agentModelDecision(input.cwd, event.agentInput);
|
|
310
313
|
return NONE;
|
|
311
314
|
case "stop":
|
|
312
315
|
return usable(descriptor.events.stop.support) ? stopDecision(input, descriptor) : NONE;
|
|
@@ -186,6 +186,17 @@ const eventOf = (name: ClaudeHookEvent, value: Record<string, unknown>): Parsed
|
|
|
186
186
|
skill: isRecord(value.tool_input) ? optionalText(value.tool_input.skill) : null,
|
|
187
187
|
},
|
|
188
188
|
};
|
|
189
|
+
// `Task` is the Agent tool's legacy name.
|
|
190
|
+
if (value.tool_name === "Agent" || value.tool_name === "Task")
|
|
191
|
+
return {
|
|
192
|
+
ok: true,
|
|
193
|
+
event: {
|
|
194
|
+
kind: "tool.pre",
|
|
195
|
+
tool: value.tool_name,
|
|
196
|
+
toolUseId,
|
|
197
|
+
...(isRecord(value.tool_input) ? { agentInput: value.tool_input } : {}),
|
|
198
|
+
},
|
|
199
|
+
};
|
|
189
200
|
if (!SHELL_TOOLS.has(value.tool_name))
|
|
190
201
|
return { ok: true, event: { kind: "tool.pre", tool: value.tool_name, toolUseId } };
|
|
191
202
|
const command = toolCommand(value);
|
|
@@ -276,7 +287,11 @@ const CONTEXT_EVENTS = new Set([
|
|
|
276
287
|
* yielded as the input to run).
|
|
277
288
|
*/
|
|
278
289
|
const render = (decision: HookDecision, native: string | null) => {
|
|
279
|
-
if (
|
|
290
|
+
if (
|
|
291
|
+
(decision.kind === "rewrite" || decision.kind === "update-input") &&
|
|
292
|
+
native === "PreToolUse" &&
|
|
293
|
+
decision.input
|
|
294
|
+
)
|
|
280
295
|
return {
|
|
281
296
|
json: {
|
|
282
297
|
hookSpecificOutput: {
|
package/src/hooks/protocol.ts
CHANGED
|
@@ -37,6 +37,8 @@ export type HookEvent =
|
|
|
37
37
|
toolUseId: string | null;
|
|
38
38
|
/** The skill a skill-loading tool loads (Claude Code's Skill tool). */
|
|
39
39
|
skill?: string | null;
|
|
40
|
+
/** A subagent spawn's whole tool input (Claude Code's Agent tool), where a default is filled in. */
|
|
41
|
+
agentInput?: Record<string, unknown>;
|
|
40
42
|
}
|
|
41
43
|
/** A file-writing tool (Edit, Write, apply_patch…): the before-write gate (S17). */
|
|
42
44
|
| {
|
|
@@ -105,6 +107,12 @@ export type HookDecision =
|
|
|
105
107
|
* host takes the whole input back; `text` is model-visible context.
|
|
106
108
|
*/
|
|
107
109
|
| { kind: "rewrite"; command: string; input: Record<string, unknown> | null; text: string | null }
|
|
110
|
+
/**
|
|
111
|
+
* tool.pre: run the tool with `input` (the host's whole tool input, with
|
|
112
|
+
* defaults filled in) instead; `text` is model-visible context. Never a
|
|
113
|
+
* permission decision.
|
|
114
|
+
*/
|
|
115
|
+
| { kind: "update-input"; input: Record<string, unknown>; text: string | null }
|
|
108
116
|
/** Stop/SubagentStop: keep working. */
|
|
109
117
|
| { kind: "continue"; reason: string }
|
|
110
118
|
| { kind: "notice"; userMessage: string };
|