@brainervirus/workit-core 8.9.1 → 8.10.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.9.1",
3
+ "version": "8.10.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": [
@@ -25,7 +25,7 @@ REPORT: branch, head SHA, files changed, each VERIFY command with its exit code,
25
25
  each ACCEPTANCE line met / not met, rulings you made (`workit ledger ruling`),
26
26
  anything out of scope as a follow-up, not a diff.
27
27
  STANDING: <the standing orders, verbatim: `workit ledger standing add` records each one>
28
- export WORKIT_SESSION_ID=<lead>-w<n> (set by the lead; a verifier brief gets <lead>-v<n>)
28
+ export WORKIT_SESSION_ID=<lead>-w<n> (set by the lead for a worker's commits; a verifier brief sets none: the host derives it)
29
29
  ```
30
30
 
31
31
  ## Worked example
@@ -72,12 +72,22 @@ Which session id a verdict carries, per host:
72
72
 
73
73
  - Claude Code: subagents inherit your `WORKIT_SESSION_ID`, so the
74
74
  SubagentStart hook names each `verifier` and `reviewer` its own session
75
- (`<lead>:<agent id>`), passed as `--session`; that one wins over any
76
- `<lead>-v<n>` in a brief. An `implementer` uses the session its brief
77
- exports for its workit commands.
78
- - Other hosts: the session the brief sets wins: `export
79
- WORKIT_SESSION_ID=<lead>-w-<slice>` from `workit fanout brief` for a
80
- worker, `<lead>-v<n>` (or `--as verifier`) for a verifier you start.
75
+ (`<lead>:<agent id>`), passed exactly as `--session`: the hook records the
76
+ ids it issues, and only those are accepted. An `implementer` uses the
77
+ session its brief exports for its workit commands.
78
+ - Codex: the `workit-verifier` and `workit-reviewer` agents get the same
79
+ issued `--session` from SubagentStart; any other verifier passes
80
+ `--as verifier`.
81
+ - OpenCode: a child session's shell already has
82
+ `WORKIT_SESSION_ID=<lead>:<child>`; Pi: a supervised worker's shell has
83
+ `<lead>:<worker session>`. Set nothing for a verifier there.
84
+ - Cursor (no session id in the shell): the brief sets the verifier's
85
+ `WORKIT_SESSION_ID=<lead>:verifier-<random hex>`, a fresh suffix per
86
+ verifier. Cursor cannot check it: it is declared, not derived.
87
+ - A worker's commits use `export WORKIT_SESSION_ID=<lead>-w-<slice>` from
88
+ `workit fanout brief`. A verdict under an id you made up (`<lead>-v1`,
89
+ `<lead>:v1`) is refused as `forged_session` on Claude Code, Codex,
90
+ OpenCode and Pi.
81
91
 
82
92
  A batch verifier takes several slices on one surface in one session: for
83
93
  each branch it checks out the head, runs that slice's VERIFY, and records its
@@ -33,8 +33,9 @@ description: Build a requested change in small verified steps - follow local pat
33
33
  `workit ledger verdict tests-verified --self --how "<what you ran>"`; it
34
34
  reads self-reviewed, never verified. High risk, a workspace with
35
35
  `verification: independent` (`workit grant show`) or a verified merge needs
36
- a fresh verifier with its own session (`WORKIT_SESSION_ID=<yours>-v1`;
37
- Claude Code: the `verifier` agent). Before saying done, reconcile
36
+ a fresh verifier with its own host-derived session (Claude Code and Codex:
37
+ the `verifier` agent; OpenCode: a child session; Pi: a supervised worker),
38
+ never a hand-minted id. Before saying done, reconcile
38
39
  every named deliverable against the target checkout and observe it (for a push:
39
40
  `workit verify-delivery push`).
40
41
 
@@ -7,8 +7,10 @@ description: Independent review of a diff, branch or PR - intent fidelity and st
7
7
 
8
8
  Review the candidate, never the author's summary of it. A session that wrote a
9
9
  commit on the branch cannot record a passing verdict: if that is you, hand the
10
- review to a fresh agent with its own session (`WORKIT_SESSION_ID=<lead>-v<n>`;
11
- Claude Code: the `reviewer` or `verifier` agent).
10
+ review to a fresh agent with its own host-derived session: Claude Code and
11
+ Codex, the `reviewer` or `verifier` agent (`--session` from SubagentStart);
12
+ OpenCode, a child session; Pi, a supervised worker. Never hand-mint one such
13
+ as `<lead>-v1`: it is refused as `forged_session`.
12
14
  An author's `--self` verdict reads self-reviewed and never counts as
13
15
  independent; `type-check-only` never proves a behavior change.
14
16
 
@@ -115,7 +115,8 @@ Autonomy contract:
115
115
  satisfies a gate; an ad-hoc \`workit check -- <cmd>\` never does.
116
116
  - Author is not verifier: your own verdict is \`--self\`, shown as
117
117
  self-reviewed, never verified. High risk, \`verification: independent\` and
118
- merge need a verifier with its own session (\`WORKIT_SESSION_ID=<lead>-v<n>\`).
118
+ merge need a verifier with its own host-derived session (a subagent's
119
+ \`<lead>:<child>\`, set by the host); never hand-mint one such as \`<lead>-v1\`.
119
120
  - A local-commit endpoint does not imply PR readiness, and a local commit alone
120
121
  is not evidence of a requested remote push. Before reporting delivery,
121
122
  reconcile every requested item and observe it (\`workit verify-delivery\`).
@@ -6,6 +6,7 @@ import {
6
6
  turnContextText,
7
7
  } from "./context";
8
8
  import type { HostDescriptor, Support } from "./descriptor";
9
+ import { recordIssuedSession } from "../host-session";
9
10
  import { shellPolicy } from "./policy";
10
11
  import {
11
12
  callKey,
@@ -100,6 +101,9 @@ const subagentStartText = (
100
101
  const readOnly = `Workit observed ${who} as read-only/agent-guided.`;
101
102
  if (role !== "judge" || !event.agentId) return readOnly;
102
103
  const own = subagentSession(session, event.agentId);
104
+ // `ledger verdict` accepts `<lead>:<agent id>` only when this hook issued it.
105
+ // (An isolated judge's text is rebuilt with cwd "": the hook's own cwd then.)
106
+ if (session) recordIssuedSession(input.cwd || process.cwd(), session, own);
103
107
  return `${readOnly} Its own Workit session is ${own}: record verdicts with \`workit ledger verdict <result> --session ${own} ...\` so the ledger tells it apart from the author.`;
104
108
  };
105
109
 
@@ -362,5 +362,5 @@ export const codexAdapter: HostAdapter = {
362
362
  },
363
363
  render,
364
364
  addendum: (input) =>
365
- `<workit-codex-mutations>Codex MCP is read-only: unattested callers cannot mutate. Run workit verbs with the workit CLI on the shell (node_modules/.bin/workit, or npx -y @brainervirus/workit-cli): check, git branch|commit|push, pr, ci, stack, ledger, handoff; task-family mutations take --json${input.session.id ? ` --actor ${input.session.id}` : ""}. workit acts as this thread (CODEX_THREAD_ID${input.session.id ? `=${input.session.id}` : ""}) unless WORKIT_SESSION_ID is set. A verifier or reviewer records workit ledger verdict under its own session, never the author's: a workit-verifier or workit-reviewer agent uses the --session its SubagentStart context names; any other uses one the lead assigns (WORKIT_HOST=codex_cli WORKIT_SESSION_ID=<lead>-v<n>). Merge and release need a workspace grant (workit grant show); a question answer is not host permission.</workit-codex-mutations>`,
365
+ `<workit-codex-mutations>Codex MCP is read-only: unattested callers cannot mutate. Run workit verbs with the workit CLI on the shell (node_modules/.bin/workit, or npx -y @brainervirus/workit-cli): check, git branch|commit|push, pr, ci, stack, ledger, handoff; task-family mutations take --json${input.session.id ? ` --actor ${input.session.id}` : ""}. workit acts as this thread (CODEX_THREAD_ID${input.session.id ? `=${input.session.id}` : ""}) unless WORKIT_SESSION_ID is set. A verifier or reviewer records workit ledger verdict under its own session, never the author's: a workit-verifier or workit-reviewer agent uses the --session its SubagentStart context names; any other passes --as verifier (an id derived from its own thread). A hand-minted id such as <lead>-v1 is refused as forged_session. Merge and release need a workspace grant (workit grant show); a question answer is not host permission.</workit-codex-mutations>`,
366
366
  };
@@ -7,6 +7,10 @@
7
7
  // - Pi: PI_SESSION_ID (the bash tool exposes the Pi session id);
8
8
  // - Codex CLI and Desktop: CODEX_THREAD_ID (the thread id hooks get as session_id).
9
9
  // Cursor puts no conversation id in its shell; its hook names the id to use.
10
+ import { createHash } from "node:crypto";
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { resolveStore } from "./store/paths";
10
14
 
11
15
  export type HostSession = { host: string | null; session: string | null };
12
16
 
@@ -51,3 +55,188 @@ export function hostSessionFromEnv(env: NodeJS.ProcessEnv): HostSession {
51
55
  }
52
56
  return inner ? { host: inner.host, session: inner.session } : { host: declared, session: null };
53
57
  }
58
+
59
+ /**
60
+ * Where this shell's session comes from:
61
+ * - `derived`: the host's own shell variable (OPENCODE_SESSION_ID,
62
+ * PI_SESSION_ID, CODEX_THREAD_ID), which a caller cannot replace by
63
+ * exporting WORKIT_SESSION_ID;
64
+ * - `declared`: a host that puts no id in the shell but names itself
65
+ * (WORKIT_HOST, e.g. Claude Code's SessionStart export) declares it in
66
+ * WORKIT_SESSION_ID;
67
+ * - `none`: no host at all (CI, a plain terminal, Cursor's prefixed
68
+ * commands): whatever session the caller names is only declared.
69
+ */
70
+ export type RealSession = HostSession & { kind: "derived" | "declared" | "none" };
71
+
72
+ export function realHostSession(env: NodeJS.ProcessEnv): RealSession {
73
+ for (const [key, host] of HOST_SESSION_VARS) {
74
+ const session = env[key]?.trim();
75
+ if (session) return { host: host(env), session, kind: "derived" };
76
+ }
77
+ const declared = hostSessionFromEnv(env);
78
+ const hosted = declared.host !== null && declared.host !== "cli";
79
+ return { ...declared, kind: hosted ? "declared" : "none" };
80
+ }
81
+
82
+ // Ids a SubagentStart hook gave a lead's subagents (`<lead>:<agent id>`): one
83
+ // empty file per id under `<store>/hooks/issued/<lead hash>/`, so concurrent
84
+ // hooks never lose each other's write, bounded per lead.
85
+ const ISSUED_PER_LEAD = 64;
86
+
87
+ /**
88
+ * Written into each issued-id file. A hook bundle that contains this string
89
+ * records issued ids; `workit doctor` looks for it in the installed Claude
90
+ * Code and Codex hooks to catch a plugin older than the CLI.
91
+ */
92
+ export const ISSUED_SESSIONS_MARKER = "workit-issued-sessions/1";
93
+
94
+ const hash = (value: string): string =>
95
+ createHash("sha256").update(value).digest("hex").slice(0, 32);
96
+
97
+ const issuedRoot = (cwd: string): string | null => {
98
+ const location = resolveStore(cwd);
99
+ return location instanceof Error ? null : path.join(location.dir, "hooks", "issued");
100
+ };
101
+
102
+ const issuedDir = (cwd: string, lead: string): string | null => {
103
+ const root = issuedRoot(cwd);
104
+ return root === null ? null : path.join(root, hash(lead));
105
+ };
106
+
107
+ /** No hook in this workspace has ever recorded an issued id (an older plugin hook, most likely). */
108
+ const noIssuedRecord = (cwd: string | undefined): boolean => {
109
+ if (cwd === undefined) return false;
110
+ try {
111
+ const root = issuedRoot(cwd);
112
+ return root !== null && !fs.existsSync(root);
113
+ } catch {
114
+ return false;
115
+ }
116
+ };
117
+
118
+ /** Per host: how to update the plugin whose SubagentStart hook issues ids. */
119
+ const PLUGIN_UPDATE: Readonly<Record<string, string>> = {
120
+ claude_code: "claude plugin marketplace update workit && claude plugin update workit@workit",
121
+ codex: "codex plugin remove workit@workit && codex plugin add workit@workit",
122
+ };
123
+
124
+ /** Remember that a SubagentStart hook issued `issued` to a subagent of `lead`. Fails open. */
125
+ export function recordIssuedSession(cwd: string, lead: string, issued: string): void {
126
+ try {
127
+ const dir = issuedDir(cwd, lead);
128
+ if (!dir) return;
129
+ fs.mkdirSync(dir, { recursive: true });
130
+ const own = hash(issued);
131
+ fs.writeFileSync(path.join(dir, own), ISSUED_SESSIONS_MARKER);
132
+ const entries = fs.readdirSync(dir);
133
+ if (entries.length <= ISSUED_PER_LEAD) return;
134
+ // Oldest first; the id just issued is never the one pruned.
135
+ const byAge = entries
136
+ .filter((name) => name !== own)
137
+ .map((name) => ({ name, at: fs.statSync(path.join(dir, name)).mtimeMs }))
138
+ .toSorted((a, b) => a.at - b.at);
139
+ for (const { name } of byAge.slice(0, entries.length - ISSUED_PER_LEAD))
140
+ fs.rmSync(path.join(dir, name), { force: true });
141
+ } catch {
142
+ // Fail open: an unrecorded id is refused later, never a broken hook.
143
+ }
144
+ }
145
+
146
+ const wasIssued = (cwd: string | undefined, lead: string, claimed: string): boolean => {
147
+ if (cwd === undefined) return false;
148
+ try {
149
+ const dir = issuedDir(cwd, lead);
150
+ return dir !== null && fs.existsSync(path.join(dir, hash(claimed)));
151
+ } catch {
152
+ return false;
153
+ }
154
+ };
155
+
156
+ const leadOf = (session: string): string => session.slice(0, session.lastIndexOf(":"));
157
+
158
+ /**
159
+ * Hosts whose child sessions run their own shell with their own host session
160
+ * id: a child proves itself from its own shell, so a lead never mints a child id.
161
+ */
162
+ const OWN_CHILD_SHELL = new Set(["opencode", "pi"]);
163
+
164
+ const SESSION_UNSAFE = /[^A-Za-z0-9_.:@/+-]/g;
165
+
166
+ /**
167
+ * The host-derived identity of a child session: `<lead>:<child>`, the shape
168
+ * Claude Code's SubagentStart hook gives a subagent. The child's own id is
169
+ * kept whole (it is what the host vouches for); the lead part is trimmed to
170
+ * fit the ledger's 128-character session grammar.
171
+ */
172
+ export function childSessionId(lead: string, child: string): string {
173
+ const leaf = child.replace(SESSION_UNSAFE, "-").slice(-120);
174
+ const prefix = lead.replace(SESSION_UNSAFE, "-").slice(0, 127 - leaf.length);
175
+ return prefix ? `${prefix}:${leaf}` : leaf;
176
+ }
177
+
178
+ export type SessionClaimRefusal = { error: string; unblock: string };
179
+
180
+ /**
181
+ * Whether `claimed` may act as this shell's session (`cwd` locates the
182
+ * workspace's record of hook-issued ids):
183
+ * - no host session (CI, a plain terminal): any id; `--session X` and
184
+ * WORKIT_SESSION_ID=X are the same declaration;
185
+ * - the shell's own session (a declared `<lead>:<x>` on Claude Code only
186
+ * when its SubagentStart hook issued it);
187
+ * - on a host that sets the id, `<lead>:<this session>`: a child naming its
188
+ * own host session;
189
+ * - `<this session>:<agent id>` only when this lead's SubagentStart hook
190
+ * issued it (Claude Code, Codex).
191
+ * Anything else is a forged id: a verdict recorded under it would make author
192
+ * is not verifier a naming convention.
193
+ */
194
+ export function sessionClaimRefusal(
195
+ env: NodeJS.ProcessEnv,
196
+ claimed: string,
197
+ cwd?: string,
198
+ ): SessionClaimRefusal | null {
199
+ const real = realHostSession(env);
200
+ const host = family(real.host);
201
+ // A host that always sets its own variable, named without it: the variable
202
+ // was removed (`env -u OPENCODE_SESSION_ID`), so nothing vouches for the id.
203
+ if (real.kind !== "derived" && host !== null && HOST_SET_VARIABLE[host])
204
+ return {
205
+ error: `forged_session: WORKIT_HOST names ${real.host}, but its own ${HOST_SET_VARIABLE[host]} is not in this shell, so ${claimed} cannot be checked`,
206
+ unblock: `record the verdict from the ${real.host} session's own shell, where ${HOST_SET_VARIABLE[host]} is set`,
207
+ };
208
+ if (real.kind === "none" || !real.session) return null;
209
+ let issuedClaim = false;
210
+ if (claimed === real.session) {
211
+ const declaredChild =
212
+ real.kind === "declared" && host === "claude_code" && claimed.includes(":");
213
+ if (!declaredChild || wasIssued(cwd, leadOf(claimed), claimed)) return null;
214
+ issuedClaim = true;
215
+ } else if (real.kind === "derived" && claimed.endsWith(`:${real.session}`)) return null;
216
+ else if (claimed.startsWith(`${real.session}:`)) {
217
+ if (wasIssued(cwd, real.session, claimed)) return null;
218
+ issuedClaim = true;
219
+ }
220
+ const error = `forged_session: ${claimed} is neither this shell's session (${real.session}) nor an id its host issued to a child of it`;
221
+ // A well-formed subagent id, but no hook here ever recorded one: the hook
222
+ // most likely predates this CLI (CLI and plugin update separately).
223
+ if (issuedClaim && noIssuedRecord(cwd) && host !== null && PLUGIN_UPDATE[host])
224
+ return {
225
+ error: `${error}; no SubagentStart hook in this workspace has recorded an issued id`,
226
+ unblock: `the workit plugin hook is probably older than this CLI and does not record the ids it issues: update the plugin (${PLUGIN_UPDATE[host]}), then relaunch the verifier so its SubagentStart runs the new hook (workit doctor checks this as hook_issued_sessions)`,
227
+ };
228
+ const ownChildShell = host !== null && OWN_CHILD_SHELL.has(host);
229
+ return {
230
+ error,
231
+ unblock: ownChildShell
232
+ ? `record the verdict from the verifier's own ${host === "pi" ? "Pi worker" : "OpenCode child session"} shell, where WORKIT_SESSION_ID is already set; never set WORKIT_SESSION_ID or --session by hand`
233
+ : "never name a session yourself: spawn a verifier or reviewer subagent and pass exactly the --session its SubagentStart context names, or pass --as verifier",
234
+ };
235
+ }
236
+
237
+ /** Hosts that always put their own session variable in the agent's shell. */
238
+ const HOST_SET_VARIABLE: Readonly<Record<string, string>> = {
239
+ opencode: "OPENCODE_SESSION_ID",
240
+ pi: "PI_SESSION_ID",
241
+ codex: "CODEX_THREAD_ID",
242
+ };
package/src/ledger.ts CHANGED
@@ -1140,10 +1140,31 @@ export function authorSessions(
1140
1140
  return sessions;
1141
1141
  }
1142
1142
 
1143
+ /** Where each host's verifier session comes from, for refusal hints. */
1144
+ const HOST_DERIVED_SESSIONS =
1145
+ "Claude Code, Codex and Cursor: `--session <lead>:<agent id>` from the SubagentStart hook; OpenCode child sessions and Pi workers: WORKIT_SESSION_ID=<lead>:<child> already set in their shell";
1146
+
1147
+ /** Whether `derived` is a host-derived `<lead>:<bare>` id for the bare host session `bare`. */
1148
+ const derivesFrom = (derived: string, bare: string): boolean =>
1149
+ !bare.includes(":") && derived.endsWith(`:${bare}`);
1150
+
1151
+ /**
1152
+ * Whether `session` is one of the authors: the same id, or one is a bare host
1153
+ * session and the other its derived `<lead>:<session>` form (an OpenCode
1154
+ * child that committed as `<lead>:<child>` and judges as `<child>` is still
1155
+ * the author). Two derived ids never match on a shared last segment.
1156
+ */
1157
+ const authoredBy = (authors: Set<string>, session: string): boolean => {
1158
+ if (authors.has(session)) return true;
1159
+ for (const author of authors)
1160
+ if (derivesFrom(author, session) || derivesFrom(session, author)) return true;
1161
+ return false;
1162
+ };
1163
+
1143
1164
  const independenceReasons = (row: ReadRow, authors: Set<string>): RejectReason[] => {
1144
1165
  const reasons: RejectReason[] = [];
1145
1166
  if (!row.actor.session) reasons.push("no_session");
1146
- else if (authors.has(row.actor.session)) reasons.push("author_session");
1167
+ else if (authoredBy(authors, row.actor.session)) reasons.push("author_session");
1147
1168
  if (row.self === true && !reasons.includes("no_session")) reasons.push("self");
1148
1169
  return reasons;
1149
1170
  };
@@ -1687,15 +1708,16 @@ export function recordVerdict(
1687
1708
  head: key.head,
1688
1709
  });
1689
1710
  const authoring = [session, input.derivedFrom].find(
1690
- (candidate): candidate is string => typeof candidate === "string" && authors.has(candidate),
1711
+ (candidate): candidate is string =>
1712
+ typeof candidate === "string" && authoredBy(authors, candidate),
1691
1713
  );
1692
1714
  if (!self && authoring)
1693
1715
  return err(
1694
1716
  "blocked",
1695
1717
  `author_verdict: session ${authoring} authored ${branch}; a verdict must come from a different session`,
1696
1718
  input.derivedFrom === undefined
1697
- ? "have a non-author session record the verdict: spawn a verifier with its own WORKIT_SESSION_ID=<lead>-v<n> (Claude Code: subagents get one from the SubagentStart hook); or, as the author, pass --self (self-reviewed, never accepted)"
1698
- : "run the verifier as a separate session: the lead spawns it with its own WORKIT_SESSION_ID (Claude Code: subagents get one from the SubagentStart hook)",
1719
+ ? `have a non-author session record the verdict: spawn a verifier subagent, which gets its own host-derived session (${HOST_DERIVED_SESSIONS}); or, as the author, pass --self (self-reviewed, never accepted)`
1720
+ : `run the verifier as a separate session: spawn a verifier subagent, which gets its own host-derived session (${HOST_DERIVED_SESSIONS})`,
1699
1721
  );
1700
1722
  const link = checkSupersede(context, "verdict", { self, kind, branch });
1701
1723
  if (!link.ok) return link;