@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "8.32.0",
3
+ "version": "8.34.0",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -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
- return failure("invalid_input", `${family}: action must be one of ${oneOf(actions)}`, {
572
- fields: [{ path: "action", reason: `got ${JSON.stringify(request.action ?? null)}` }],
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[] = [];
@@ -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 text = resendsOnChange(descriptor) ? changedTurnContext(input, current) : current;
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
+ };
@@ -361,6 +361,7 @@ export const codexAdapter: HostAdapter = {
361
361
  },
362
362
  permissionMode: input.permission_mode,
363
363
  transcriptPath: input.transcript_path,
364
+ model: input.model,
364
365
  event: protocolEvent(input),
365
366
  },
366
367
  };
@@ -90,6 +90,8 @@ export type HookInput = {
90
90
  session: Session;
91
91
  permissionMode: string | null;
92
92
  transcriptPath: string | null;
93
+ /** The host's current model, where every hook input carries it (Codex). */
94
+ model?: string | null;
93
95
  event: HookEvent;
94
96
  };
95
97
 
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 = { nudged: [], loaded: [], raw: [], stops: [], judges: [] };
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
+ };