@brainervirus/workit-core 8.32.0 → 8.33.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.33.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),
@@ -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
 
@@ -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
+ };