@brainervirus/workit-core 8.32.0 → 8.34.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/src/core/operation-input.ts +10 -4
- package/src/hooks/handle.ts +8 -2
- package/src/hooks/hook-runs.ts +96 -0
- package/src/hooks/hosts/codex.ts +1 -0
- package/src/hooks/protocol.ts +2 -0
- package/src/hooks/run.ts +7 -0
- package/src/hooks/session-marker.ts +27 -1
package/package.json
CHANGED
|
@@ -567,10 +567,16 @@ function invalidOperation(
|
|
|
567
567
|
const request = isObject(input) ? input : {};
|
|
568
568
|
const action = typeof request.action === "string" ? request.action : "";
|
|
569
569
|
const actions = OPERATION_ACTIONS[family];
|
|
570
|
-
if (!actions.includes(action))
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
570
|
+
if (!actions.includes(action)) {
|
|
571
|
+
// The family's read (list, else the first action) is the safe corrected call.
|
|
572
|
+
const common = actions.find((name) => READ_ONLY_ACTIONS.has(name)) ?? actions[0];
|
|
573
|
+
const example = JSON.stringify(exampleOperation(family, common));
|
|
574
|
+
return failure(
|
|
575
|
+
"invalid_input",
|
|
576
|
+
`${family}: action must be one of ${oneOf(actions)}. Example: ${example}`,
|
|
577
|
+
{ fields: [{ path: "action", reason: `got ${JSON.stringify(request.action ?? null)}` }] },
|
|
578
|
+
);
|
|
579
|
+
}
|
|
574
580
|
const own = actionKeys(family, action)!;
|
|
575
581
|
const fields: { path: string; reason: string }[] = [];
|
|
576
582
|
const sentence: string[] = [];
|
package/src/hooks/handle.ts
CHANGED
|
@@ -30,7 +30,8 @@ import {
|
|
|
30
30
|
} from "./skill-nudge";
|
|
31
31
|
import { spawnDecision } from "./spawn-gate";
|
|
32
32
|
import { seedStanding, standingDelta } from "./standing";
|
|
33
|
-
import { trackJudge } from "./session-marker";
|
|
33
|
+
import { modelSwitchedOnDisk, trackJudge } from "./session-marker";
|
|
34
|
+
import { invariantBootstrap } from "../core/methods";
|
|
34
35
|
import { stopDecision } from "./stop";
|
|
35
36
|
import { liveOwner } from "../core/live-session";
|
|
36
37
|
import { liveTargets, shellGate, writeGate } from "./write-gate";
|
|
@@ -166,7 +167,12 @@ function decide(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
166
167
|
case "context.turn": {
|
|
167
168
|
if (!usable(descriptor.context.perTurn)) return NONE;
|
|
168
169
|
const current = turnContextText(input, descriptor);
|
|
169
|
-
const
|
|
170
|
+
const turn = resendsOnChange(descriptor) ? changedTurnContext(input, current) : current;
|
|
171
|
+
// A model switch (#435): the compact contract again, once.
|
|
172
|
+
const text =
|
|
173
|
+
input.model && modelSwitchedOnDisk(input)
|
|
174
|
+
? [invariantBootstrap(), turn].filter(Boolean).join("\n")
|
|
175
|
+
: turn;
|
|
170
176
|
return withContextLine(
|
|
171
177
|
text ? { kind: "context", text } : NONE,
|
|
172
178
|
promptNudge(input, event.prompt),
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// A tiny record of which hook events each host ran (#433): without it "zero
|
|
2
|
+
// hook events" cannot tell a host that never fires its hooks from one that
|
|
3
|
+
// fires them silently. One small file per host and native event in the state
|
|
4
|
+
// dir (`hook-runs/<host>__<event>.json`: last seen time and a count), written
|
|
5
|
+
// best effort: one read and one atomic-enough write, no spawn, and a
|
|
6
|
+
// read-only state dir never changes the hook's answer. `workit doctor`
|
|
7
|
+
// (`hook_runs`) reads it. Light imports only: the Codex launcher's quiet path
|
|
8
|
+
// calls it before the runtime loads.
|
|
9
|
+
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { stateDir } from "../core/heavy-words";
|
|
12
|
+
|
|
13
|
+
export const HOOK_RUNS_DIR = "hook-runs";
|
|
14
|
+
|
|
15
|
+
export type HookRun = {
|
|
16
|
+
host: string;
|
|
17
|
+
event: string;
|
|
18
|
+
at: string;
|
|
19
|
+
count: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const safe = (part: string): string => part.replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 64);
|
|
23
|
+
|
|
24
|
+
const runFile = (dir: string, host: string, event: string): string =>
|
|
25
|
+
path.join(dir, HOOK_RUNS_DIR, `${safe(host)}__${safe(event)}.json`);
|
|
26
|
+
|
|
27
|
+
/** Counts one run of the native `event` on `host`; never throws. */
|
|
28
|
+
export const recordHookRun = (
|
|
29
|
+
host: string,
|
|
30
|
+
event: string | null | undefined,
|
|
31
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
32
|
+
now: () => number = Date.now,
|
|
33
|
+
): void => {
|
|
34
|
+
if (!event) return;
|
|
35
|
+
try {
|
|
36
|
+
const dir = stateDir(env);
|
|
37
|
+
const file = runFile(dir, host, event);
|
|
38
|
+
let count = 0;
|
|
39
|
+
try {
|
|
40
|
+
const previous = JSON.parse(readFileSync(file, "utf8")) as {
|
|
41
|
+
count?: unknown;
|
|
42
|
+
};
|
|
43
|
+
if (typeof previous.count === "number" && Number.isFinite(previous.count))
|
|
44
|
+
count = previous.count;
|
|
45
|
+
} catch {
|
|
46
|
+
// First run, or a torn file: start again.
|
|
47
|
+
}
|
|
48
|
+
const row: HookRun = {
|
|
49
|
+
host,
|
|
50
|
+
event,
|
|
51
|
+
at: new Date(now()).toISOString(),
|
|
52
|
+
count: count + 1,
|
|
53
|
+
};
|
|
54
|
+
try {
|
|
55
|
+
writeFileSync(file, JSON.stringify(row));
|
|
56
|
+
} catch {
|
|
57
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
58
|
+
writeFileSync(file, JSON.stringify(row));
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// The hook still answers the host; only the doctor line is lost.
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Every recorded run under `stateDir`; unreadable or malformed files are skipped. */
|
|
66
|
+
export const readHookRuns = (dir: string): HookRun[] => {
|
|
67
|
+
const runs: HookRun[] = [];
|
|
68
|
+
let names: string[] = [];
|
|
69
|
+
try {
|
|
70
|
+
names = readdirSync(path.join(dir, HOOK_RUNS_DIR));
|
|
71
|
+
} catch {
|
|
72
|
+
return runs;
|
|
73
|
+
}
|
|
74
|
+
for (const name of names) {
|
|
75
|
+
try {
|
|
76
|
+
const row = JSON.parse(
|
|
77
|
+
readFileSync(path.join(dir, HOOK_RUNS_DIR, name), "utf8"),
|
|
78
|
+
) as Partial<HookRun>;
|
|
79
|
+
if (
|
|
80
|
+
typeof row.host === "string" &&
|
|
81
|
+
typeof row.event === "string" &&
|
|
82
|
+
typeof row.at === "string" &&
|
|
83
|
+
typeof row.count === "number"
|
|
84
|
+
)
|
|
85
|
+
runs.push({
|
|
86
|
+
host: row.host,
|
|
87
|
+
event: row.event,
|
|
88
|
+
at: row.at,
|
|
89
|
+
count: row.count,
|
|
90
|
+
});
|
|
91
|
+
} catch {
|
|
92
|
+
// Not a run record.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return runs;
|
|
96
|
+
};
|
package/src/hooks/hosts/codex.ts
CHANGED
package/src/hooks/protocol.ts
CHANGED
package/src/hooks/run.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { claudeCodeAdapter } from "./hosts/claude-code";
|
|
12
12
|
import { codexAdapter } from "./hosts/codex";
|
|
13
13
|
import { cursorAdapter } from "./hosts/cursor";
|
|
14
|
+
import { recordHookRun } from "./hook-runs";
|
|
14
15
|
import type { HostAdapter, HostId } from "./protocol";
|
|
15
16
|
|
|
16
17
|
type ProcessHost = Exclude<HostId, "opencode" | "pi">;
|
|
@@ -44,6 +45,12 @@ export async function runHookProcess(
|
|
|
44
45
|
} catch {
|
|
45
46
|
raw = undefined;
|
|
46
47
|
}
|
|
48
|
+
const native = (raw as { hook_event_name?: unknown } | null | undefined)?.hook_event_name;
|
|
49
|
+
recordHookRun(
|
|
50
|
+
typeof host === "string" ? host : adapter.descriptor.host,
|
|
51
|
+
typeof native === "string" ? native : null,
|
|
52
|
+
env,
|
|
53
|
+
);
|
|
47
54
|
const result =
|
|
48
55
|
text === null
|
|
49
56
|
? skippedHook(adapter, undefined, env, budget, "input")
|
|
@@ -17,6 +17,8 @@ export type SessionMarker = {
|
|
|
17
17
|
stops: string[];
|
|
18
18
|
/** Verifier and reviewer subagents of this lead that started and have not stopped. */
|
|
19
19
|
judges: string[];
|
|
20
|
+
/** The last model the session reported (Codex #435): a change re-sends the contract. */
|
|
21
|
+
model: string | null;
|
|
20
22
|
};
|
|
21
23
|
|
|
22
24
|
/**
|
|
@@ -42,7 +44,14 @@ export const sessionMarkerFile = (
|
|
|
42
44
|
const list = (value: unknown): string[] =>
|
|
43
45
|
Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
44
46
|
|
|
45
|
-
const EMPTY: SessionMarker = {
|
|
47
|
+
const EMPTY: SessionMarker = {
|
|
48
|
+
nudged: [],
|
|
49
|
+
loaded: [],
|
|
50
|
+
raw: [],
|
|
51
|
+
stops: [],
|
|
52
|
+
judges: [],
|
|
53
|
+
model: null,
|
|
54
|
+
};
|
|
46
55
|
|
|
47
56
|
const readRaw = (file: string): string => {
|
|
48
57
|
try {
|
|
@@ -61,6 +70,7 @@ const parseMarker = (text: string): SessionMarker => {
|
|
|
61
70
|
raw: list(value.raw),
|
|
62
71
|
stops: list(value.stops),
|
|
63
72
|
judges: list(value.judges),
|
|
73
|
+
model: typeof value.model === "string" ? value.model : null,
|
|
64
74
|
};
|
|
65
75
|
} catch {
|
|
66
76
|
return { ...EMPTY };
|
|
@@ -123,3 +133,19 @@ export const trackJudge = (input: HookInput, agentId: string | null, running: bo
|
|
|
123
133
|
return { ...marker, judges: running ? [...rest, agentId] : rest };
|
|
124
134
|
});
|
|
125
135
|
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Record the session's model and say whether it differs from the last one
|
|
139
|
+
* recorded (the hook is a fresh process each time). The first model is a
|
|
140
|
+
* start, not a switch. Fails open: no store or no model reads as no switch.
|
|
141
|
+
*/
|
|
142
|
+
export const modelSwitchedOnDisk = (input: HookInput): boolean => {
|
|
143
|
+
const session = input.session.id;
|
|
144
|
+
const model = input.model;
|
|
145
|
+
const file = session && model ? sessionMarkerFile(input, session) : null;
|
|
146
|
+
if (!file || !model) return false;
|
|
147
|
+
const previous = readSessionMarker(file).model;
|
|
148
|
+
if (previous === model) return false;
|
|
149
|
+
updateSessionMarker(file, (marker) => ({ ...marker, model }));
|
|
150
|
+
return previous !== null;
|
|
151
|
+
};
|