@brainervirus/workit-core 8.22.1 → 8.22.2

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.22.1",
3
+ "version": "8.22.2",
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": [
@@ -408,6 +408,6 @@ export const cursorAdapter: HostAdapter = {
408
408
  // only), so the agent is told the id its `workit` calls should act as.
409
409
  addendum: (input) =>
410
410
  input.session.id
411
- ? `<workit-session>Cursor puts no session id in the shell: prefix workit commands that record authorship with WORKIT_SESSION_ID=${input.session.id} (e.g. \`WORKIT_SESSION_ID=${input.session.id} workit git commit -m …\`). A verifier uses its own id, never this one.</workit-session>`
411
+ ? `<workit-session>Cursor puts no session id in the shell: start workit commands that record authorship with the global flag \`--session ${input.session.id}\` (e.g. \`workit --session ${input.session.id} git commit -m …\`; a leading env assignment can miss the \`Shell(workit)\` allowlist). A verifier uses its own id, never this one.</workit-session>`
412
412
  : null,
413
413
  };
@@ -1623,10 +1623,10 @@ const nudgeFor = (action: RawAction, host: HookInput["host"], session: string):
1623
1623
  switch (action.kind) {
1624
1624
  case "commit": {
1625
1625
  // Cursor's shell carries no conversation id: name it explicitly.
1626
- const prefix = host === "cursor" && session ? `WORKIT_SESSION_ID=${session} ` : "";
1626
+ const workit = host === "cursor" && session ? `workit --session ${session}` : "workit";
1627
1627
  return action.amend
1628
- ? `workit: raw ${quote("git commit --amend")} is recorded for this session; for new commits prefer ${quote(`${prefix}workit git commit -m '<msg>'`)} (adds the Workit-Session trailer and checks branch and commit policy).`
1629
- : `workit: prefer ${quote(`${prefix}workit git commit -m '<msg>' [--all | -- <paths>]`)}: it adds the Workit-Session trailer and checks branch and commit policy.`;
1628
+ ? `workit: raw ${quote("git commit --amend")} is recorded for this session; for new commits prefer ${quote(`${workit} git commit -m '<msg>'`)} (adds the Workit-Session trailer and checks branch and commit policy).`
1629
+ : `workit: prefer ${quote(`${workit} git commit -m '<msg>' [--all | -- <paths>]`)}: it adds the Workit-Session trailer and checks branch and commit policy.`;
1630
1630
  }
1631
1631
  case "push":
1632
1632
  return action.blindForce
@@ -42,9 +42,19 @@ export const sessionMarkerFile = (
42
42
  const list = (value: unknown): string[] =>
43
43
  Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
44
44
 
45
- export const readSessionMarker = (file: string): SessionMarker => {
45
+ const EMPTY: SessionMarker = { nudged: [], loaded: [], raw: [], stops: [], judges: [] };
46
+
47
+ const readRaw = (file: string): string => {
48
+ try {
49
+ return fs.readFileSync(file, "utf8");
50
+ } catch {
51
+ return "";
52
+ }
53
+ };
54
+
55
+ const parseMarker = (text: string): SessionMarker => {
46
56
  try {
47
- const value = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
57
+ const value = JSON.parse(text) as Record<string, unknown>;
48
58
  return {
49
59
  nudged: list(value.nudged),
50
60
  loaded: list(value.loaded),
@@ -53,14 +63,51 @@ export const readSessionMarker = (file: string): SessionMarker => {
53
63
  judges: list(value.judges),
54
64
  };
55
65
  } catch {
56
- return { nudged: [], loaded: [], raw: [], stops: [], judges: [] };
66
+ return { ...EMPTY };
67
+ }
68
+ };
69
+
70
+ export const readSessionMarker = (file: string): SessionMarker => parseMarker(readRaw(file));
71
+
72
+ /** Write through a temp file in the same directory and rename: a reader never sees half a marker. */
73
+ const writeAtomic = (file: string, marker: SessionMarker): void => {
74
+ fs.mkdirSync(path.dirname(file), { recursive: true });
75
+ const temp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
76
+ try {
77
+ fs.writeFileSync(temp, JSON.stringify(marker));
78
+ fs.renameSync(temp, file);
79
+ } catch (error) {
80
+ fs.rmSync(temp, { force: true });
81
+ throw error;
57
82
  }
58
83
  };
59
84
 
60
85
  export const writeSessionMarker = (file: string, marker: SessionMarker): void => {
61
86
  try {
62
- fs.mkdirSync(path.dirname(file), { recursive: true });
63
- fs.writeFileSync(file, JSON.stringify(marker));
87
+ writeAtomic(file, marker);
88
+ } catch {
89
+ // Fail open: without a marker a later nudge repeats.
90
+ }
91
+ };
92
+
93
+ /**
94
+ * Read-modify-write: `change` maps the marker as read to the marker to keep.
95
+ * Another hook's rename between the read and ours is detected (the file's
96
+ * text changed) and `change` runs once more on the fresh marker, so an entry
97
+ * of a concurrent hook is not lost. Fails open like the rest.
98
+ */
99
+ export const updateSessionMarker = (
100
+ file: string,
101
+ change: (marker: SessionMarker) => SessionMarker,
102
+ ): void => {
103
+ try {
104
+ for (let attempt = 0; attempt < 2; attempt++) {
105
+ const before = readRaw(file);
106
+ const next = change(parseMarker(before));
107
+ if (attempt === 0 && readRaw(file) !== before) continue;
108
+ writeAtomic(file, next);
109
+ return;
110
+ }
64
111
  } catch {
65
112
  // Fail open: without a marker a later nudge repeats.
66
113
  }
@@ -71,7 +118,8 @@ export const trackJudge = (input: HookInput, agentId: string | null, running: bo
71
118
  const session = input.session.id;
72
119
  const file = session && agentId ? sessionMarkerFile(input, session) : null;
73
120
  if (!file || !agentId) return;
74
- const marker = readSessionMarker(file);
75
- const rest = marker.judges.filter((id) => id !== agentId);
76
- writeSessionMarker(file, { ...marker, judges: running ? [...rest, agentId] : rest });
121
+ updateSessionMarker(file, (marker) => {
122
+ const rest = marker.judges.filter((id) => id !== agentId);
123
+ return { ...marker, judges: running ? [...rest, agentId] : rest };
124
+ });
77
125
  };
package/src/hooks/stop.ts CHANGED
@@ -14,7 +14,8 @@
14
14
  // green or merged (a PR opens without one): the author's own `--self`
15
15
  // verdict at normal risk under `verification: self`, an accepted
16
16
  // non-author verdict at high risk or under `verification: independent`.
17
- // Not raised while a verifier or reviewer this lead started still runs.
17
+ // Not raised when the task's latest judgment records no behavior
18
+ // change at normal risk (policy/derive.ts asks for none either), nor while a verifier or reviewer this lead started still runs.
18
19
  // Each obligation blocks once per head and session (a marker keyed by kind
19
20
  // and head), not once per turn.
20
21
  // A branch that landed owes nothing: a recorded merge (`workit pr merge`, or
@@ -30,13 +31,14 @@
30
31
  import { spawnSync } from "node:child_process";
31
32
  import { resolveAutonomy } from "../autonomy";
32
33
  import { latestJudgment } from "../core/policy/derive";
34
+ import type { Judgment } from "../core/task-contract";
33
35
  import { TaskStore } from "../core/task-store";
34
36
  import { currentBranch, pushRemoteName } from "../git/rev";
35
37
  import { checkVerdicts, readLedger, type ReadRow } from "../ledger";
36
38
  import type { HostDescriptor } from "./descriptor";
37
39
  import type { HookDecision, HookInput } from "./protocol";
38
40
  import { guardedBranch, inWorkitWorkspace } from "./raw-git";
39
- import { readSessionMarker, sessionMarkerFile, writeSessionMarker } from "./session-marker";
41
+ import { readSessionMarker, sessionMarkerFile, updateSessionMarker } from "./session-marker";
40
42
 
41
43
  const NONE: HookDecision = { kind: "none" };
42
44
 
@@ -235,20 +237,20 @@ const redChecks = (
235
237
  };
236
238
  };
237
239
 
238
- /** Did the open task's latest judgment call this change high risk? */
239
- const highRisk = (cwd: string): boolean => {
240
+ /** The open task's latest judgment (the one policy/derive.ts reads), or null when none is recorded. */
241
+ const taskJudgment = (cwd: string): Judgment | null => {
240
242
  try {
241
243
  const store = new TaskStore(cwd);
242
244
  const listed = store.listTaskIndex();
243
245
  const key = store.currentKey();
244
- if (!listed.ok || !key.ok) return false;
246
+ if (!listed.ok || !key.ok) return null;
245
247
  const entry = listed.data
246
248
  .filter((item) => item.key === key.data.key && item.status !== "closed")
247
249
  .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0];
248
250
  const task = entry ? store.readTask(entry.id) : null;
249
- return task?.ok === true && latestJudgment(task.data)?.riskTier === "high";
251
+ return task?.ok === true ? latestJudgment(task.data) : null;
250
252
  } catch {
251
- return false;
253
+ return null;
252
254
  }
253
255
  };
254
256
 
@@ -266,7 +268,12 @@ const unverified = (
266
268
  ): StopObligation | null => {
267
269
  const check = checkVerdicts(cwd, branch, all);
268
270
  if (check.accepted.accepted || check.accepted.reasons.includes("failing_verdict")) return null;
269
- const independent = verification === "independent" || highRisk(cwd);
271
+ const judgment = taskJudgment(cwd);
272
+ const high = judgment?.riskTier === "high";
273
+ // Like policy/derive.ts: a verdict is asked for a behavior change or high
274
+ // risk. A recorded judgment with neither (docs only) owes none.
275
+ if (judgment && !judgment.behaviorChange && !high) return null;
276
+ const independent = verification === "independent" || high;
270
277
  if (!independent && check.review === "self-reviewed") return null;
271
278
  const has = check.review === "self-reviewed" ? "only a self verdict" : "no current verdict";
272
279
  const verifier = `${descriptor.subagents.agentPrefix}verifier`;
@@ -353,7 +360,7 @@ export function stopDecision(input: HookInput, descriptor: HostDescriptor): Hook
353
360
  const key = `${left.kind}:${left.head ?? ""}`;
354
361
  if (file && marker) {
355
362
  if (marker.stops.includes(key)) return NONE;
356
- writeSessionMarker(file, { ...marker, stops: [...marker.stops, key] });
363
+ updateSessionMarker(file, (fresh) => ({ ...fresh, stops: [...fresh.stops, key] }));
357
364
  }
358
365
  return {
359
366
  kind: "continue",