@brainervirus/workit-core 8.13.0 → 8.14.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.13.0",
3
+ "version": "8.14.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": [
@@ -29,9 +29,9 @@ for (const pkg of [
29
29
  ]) {
30
30
  const file = resolve(root, `packages/${pkg}/package.json`);
31
31
  const data = JSON.parse(readFileSync(file, "utf8"));
32
- // devDependencies too: the Claude Code plugin bundles core and the CLI into
33
- // dist/, so they are build-time only, but a published manifest must still
34
- // never carry workspace:*.
32
+ // devDependencies too: every adapter bundles core (and the MCP server) into
33
+ // dist/, so they are build-time only (N19) and npm never installs them, but
34
+ // a published manifest must still never carry workspace:*.
35
35
  const groups = [data.dependencies, data.devDependencies].filter(Boolean);
36
36
  if (groups.length === 0) continue;
37
37
  for (const deps of groups)
@@ -25,9 +25,9 @@ ceremony; do it yourself. Workit never spawns agents; you do.
25
25
  it; change the plan or the standing orders and render again.
26
26
  4. **Spawn in a rolling window**, in the background, each in its own worktree:
27
27
  keep 4-6 in flight and refill from `spawnable` in `workit fanout status` as
28
- each one finishes. Pick the model from TIER (`references/orchestration.md`).
29
- Claude Code: the `implementer` agent; elsewhere first `workit fanout
30
- worktree create <slice>`.
28
+ each one finishes. Model: the brief's `model:` line, else TIER
29
+ (`references/orchestration.md`). Claude Code: the `implementer` agent;
30
+ elsewhere first `workit fanout worktree create <slice>`.
31
31
  5. **Judge liveness by side effects only:** `workit fanout status` (head age,
32
32
  PR, CI, verdict, landed; STUCK past the TIMEBOX). Stop a stuck worker and
33
33
  observe that it exited (a timeout is not proof). Removing a worktree drops
@@ -53,7 +53,7 @@ ceremony; do it yourself. Workit never spawns agents; you do.
53
53
  status` order, release the worktrees you made, and `workit ledger standing
54
54
  clear`. Only you touch topology: workers never rebase, retarget or merge,
55
55
  except the integration-tip merge in integration mode. Then ship
56
- (workit-ship).
56
+ (workit-ship) up to `defaultEndpoint` from `workit grant show`.
57
57
 
58
58
  ## Example
59
59
 
@@ -5,8 +5,10 @@ and fans in. Workit records and checks; the host's own subagents run the work.
5
5
 
6
6
  ## TIER to model
7
7
 
8
- TIER is a field of each slice and of its brief. Map it where the host lets you
9
- pick a model per spawn; nothing in the workspace config routes it.
8
+ TIER is a field of each slice and of its brief. When the workspace sets
9
+ `fanout.tierModels`, the brief has a `model:` line under TIER and `workit
10
+ fanout status` names the model next to each spawnable slice: use that model.
11
+ Without one, map TIER where the host lets you pick a model per spawn:
10
12
 
11
13
  | TIER | Claude Code (`model` on the Agent call) | Hosts without a per-spawn model |
12
14
  | --- | --- | --- |
@@ -72,11 +74,12 @@ Which session id a verdict carries, per host:
72
74
 
73
75
  - Claude Code: subagents inherit your `WORKIT_SESSION_ID`, so the
74
76
  SubagentStart hook names each `verifier` and `reviewer` its own session
75
- (`<lead>:<agent id>`), passed exactly as `--session`: the hook records the
76
- ids it issues, and only those are accepted. An `implementer` is issued
77
+ (`<lead>:<agent id>`) and a token only that subagent sees, passed exactly as
78
+ `--session` and `--token`: the hook records the ids it issues, and only
79
+ those, with their token, are accepted. An `implementer` is issued
77
80
  none: it keeps your id in its env for its workit commands and commits.
78
81
  - Codex: the `workit-verifier` and `workit-reviewer` agents get the same
79
- issued `--session` from SubagentStart; any other verifier passes
82
+ issued `--session` and `--token` from SubagentStart; any other verifier passes
80
83
  `--as verifier`.
81
84
  - OpenCode: a child session's shell already has
82
85
  `WORKIT_SESSION_ID=<lead>:<child>`; Pi: a supervised worker's shell has
@@ -8,7 +8,7 @@ description: Independent review of a diff, branch or PR - intent fidelity and st
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
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);
11
+ Codex, the `reviewer` or `verifier` agent (`--session` and `--token` from SubagentStart);
12
12
  OpenCode, a child session; Pi, a supervised worker. Never hand-mint one such
13
13
  as `<lead>-v1`: it is refused as `forged_session`.
14
14
  An author's `--self` verdict reads self-reviewed and never counts as
@@ -1,4 +1,5 @@
1
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
3
  import path from "node:path";
3
4
  import { changelogUnreleasedStats } from "./changelog";
4
5
  import { assetRoot } from "./package-root";
@@ -75,6 +76,41 @@ export const hygieneFiles = (
75
76
  return { state, openSource };
76
77
  };
77
78
 
79
+ export type ProjectTarget = { ok: true; root: string } | { ok: false; refused: string };
80
+
81
+ /**
82
+ * Where project files (gitignore, hygiene) belong: the git toplevel of `dir`.
83
+ * Outside a repository there is no project to scaffold, and a folder holding
84
+ * several projects must never receive them, so the answer is a refusal naming
85
+ * the way out: the child repositories found one level down, or `cd <repo>`.
86
+ */
87
+ export const projectTarget = (dir: string): ProjectTarget => {
88
+ const start = path.resolve(dir);
89
+ const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: start, encoding: "utf8" });
90
+ const root = top.status === 0 ? (top.stdout ?? "").trim() : "";
91
+ if (root) return { ok: true, root: path.resolve(root) };
92
+ let children: string[] = [];
93
+ try {
94
+ children = readdirSync(start, { withFileTypes: true })
95
+ .filter((entry) => entry.isDirectory() && existsSync(path.join(start, entry.name, ".git")))
96
+ .map((entry) => path.join(start, entry.name))
97
+ .toSorted();
98
+ } catch {
99
+ children = [];
100
+ }
101
+ const where =
102
+ children.length > 0
103
+ ? `run it inside one of its repositories: ${children
104
+ .slice(0, 5)
105
+ .map((child) => `cd ${child}`)
106
+ .join(", ")}${children.length > 5 ? ` (+${children.length - 5} more)` : ""}`
107
+ : "cd <repo> (or git init here) and run workit init again";
108
+ return {
109
+ ok: false,
110
+ refused: `${start} is not a git repository, so no project files were added there; ${where}`,
111
+ };
112
+ };
113
+
78
114
  // Pure planner: which hygiene files would be created and with what content,
79
115
  // without touching the filesystem. Drives the wizard preview (WZ-08) and the
80
116
  // apply path; ensureHygieneFiles is just "plan then write".
@@ -0,0 +1,333 @@
1
+ // Live-session markers (#274): which agent session last wrote in a checkout,
2
+ // so another session's write gate can refuse to edit a checkout a live agent
3
+ // owns. One marker per checkout (a worktree's top level: worktrees of one
4
+ // repository are distinct checkouts) in the per-user state dir, so nothing is
5
+ // written into a repository Workit does not manage:
6
+ // `<state>/live-sessions/<hash of the checkout>.json`, holding the owner's
7
+ // session, lead, host, process id, machine and heartbeat. The hooks each host
8
+ // already runs write it on every allowed edit or mutating command.
9
+ //
10
+ // A marker is live while its heartbeat is younger than LIVE_SESSION_TTL_MS
11
+ // and, on the same machine, its process still runs. The owning session, its
12
+ // subagents (`<lead>:<agent>`, `<lead>-w-<slice>`, a parent session) and any
13
+ // session in the same host process are never refused. Every state error reads
14
+ // as "no marker": the guard fails open.
15
+ import { spawnSync } from "node:child_process";
16
+ import { createHash } from "node:crypto";
17
+ import fs from "node:fs";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+ import { resolveStore } from "../store/paths";
21
+ import { resolveStateDir } from "./logger";
22
+ import { processStartOf } from "./store-lock";
23
+
24
+ export const LIVE_SESSION_TTL_MS = 15 * 60_000;
25
+ /** Rewrite a live marker of one's own at most this often. */
26
+ const HEARTBEAT_EVERY_MS = 30_000;
27
+
28
+ export type LiveOwner = {
29
+ host: string;
30
+ session: string;
31
+ /** Lead ids this session counts as (its own lead, its parent's). */
32
+ leads: string[];
33
+ /**
34
+ * The host process (null when it cannot be told), read lazily: off Linux
35
+ * it costs `ps` calls, so only a write or a foreign marker asks for it.
36
+ */
37
+ pid: () => number | null;
38
+ };
39
+
40
+ export type LiveMarker = {
41
+ version: 1;
42
+ session: string;
43
+ leads: string[];
44
+ host: string;
45
+ pid: number | null;
46
+ /** The pid's start time, so a reused pid does not keep the marker alive. */
47
+ pidStart: string | null;
48
+ machine: string;
49
+ checkout: string;
50
+ heartbeatAt: string;
51
+ };
52
+
53
+ export type LiveDeps = {
54
+ now?: Date;
55
+ alive?: (pid: number) => boolean;
56
+ machine?: string;
57
+ ttlMs?: number;
58
+ };
59
+
60
+ /**
61
+ * What reads the process table; tests swap it to count `ps` calls or to act
62
+ * as another platform.
63
+ */
64
+ export const liveProbe = {
65
+ platform: process.platform as string,
66
+ run: (args: string[]): string =>
67
+ spawnSync("ps", args, { encoding: "utf8", timeout: 2_000 }).stdout ?? "",
68
+ start: (pid: number): string | null => processStartOf(pid),
69
+ };
70
+
71
+ /** A session's lead: the part before `:` (an issued subagent id), minus a fanout `-w-<slice>` tail. */
72
+ export const leadOf = (session: string): string =>
73
+ session.split(":")[0].replace(/-w-.*$/su, "") || session;
74
+
75
+ const familyOf = (host: string): string => host.replace(/_(?:cli|desktop)$/u, "");
76
+
77
+ const processAlive = (pid: number): boolean => {
78
+ try {
79
+ process.kill(pid, 0);
80
+ return true;
81
+ } catch (error) {
82
+ return (error as NodeJS.ErrnoException).code === "EPERM";
83
+ }
84
+ };
85
+
86
+ /** Parent pid and command line of `pid`: /proc on Linux, `ps` on other POSIX systems. */
87
+ const processInfo = (pid: number): { ppid: number; command: string } | null => {
88
+ try {
89
+ if (liveProbe.platform === "linux") {
90
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
91
+ const ppid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
92
+ const command = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").replaceAll("\0", " ");
93
+ return Number.isInteger(ppid) ? { ppid, command: command.trim() } : null;
94
+ }
95
+ if (liveProbe.platform === "win32") return null;
96
+ const match = /^\s*(\d+)\s+(.*)$/su.exec(
97
+ liveProbe.run(["-o", "ppid=,command=", "-p", String(pid)]),
98
+ );
99
+ return match ? { ppid: Number(match[1]), command: match[2].trim() } : null;
100
+ } catch {
101
+ return null;
102
+ }
103
+ };
104
+
105
+ /** A process that only launches the hook and exits with it: a shell, npx, the Cursor launcher. */
106
+ const WRAPPER =
107
+ /^(?:\S*\/)?(?:sh|bash|dash|zsh|ash|ksh|fish|env|timeout|npx|npm|bunx|cmd\.exe|pwsh|powershell)(?:\s|$)|launch\.mjs|npx-cli\.js/u;
108
+
109
+ const PIDS = new Map<string, number | null>();
110
+
111
+ /**
112
+ * The long-lived host process behind this hook: the plugin's own process for
113
+ * an in-process host (OpenCode, Pi); for a hook process (Claude Code, Codex,
114
+ * Cursor), the nearest ancestor that is not a launcher wrapper. Null when the
115
+ * ancestry cannot be read (Windows). Cached per process.
116
+ */
117
+ export const hostPid = (transport: string): number | null => {
118
+ if (transport !== "hook-process") return process.pid;
119
+ const key = `${liveProbe.platform}\0${process.ppid}`;
120
+ if (PIDS.has(key)) return PIDS.get(key) ?? null;
121
+ // Windows: no ancestry to walk, so no owner pid at all.
122
+ if (liveProbe.platform === "win32") {
123
+ PIDS.set(key, null);
124
+ return null;
125
+ }
126
+ let pid = process.ppid;
127
+ for (let hop = 0; hop < 4; hop++) {
128
+ const info = processInfo(pid);
129
+ if (!info || !WRAPPER.test(info.command) || info.ppid <= 1) break;
130
+ pid = info.ppid;
131
+ }
132
+ PIDS.set(key, pid);
133
+ return pid;
134
+ };
135
+
136
+ const sessionSafe = (value: string): string => value.replace(/[^A-Za-z0-9_.:@/+-]/gu, "-");
137
+
138
+ /** The owner a hook acts for, or null without a session id (never refused, never recorded). */
139
+ export const liveOwner = (
140
+ input: { host: string; session: { id: string; parentId: string | null } },
141
+ transport: string,
142
+ env: NodeJS.ProcessEnv = process.env,
143
+ ): LiveOwner | null => {
144
+ const session = input.session.id?.trim();
145
+ if (!session) return null;
146
+ const leads = new Set([leadOf(sessionSafe(session))]);
147
+ if (input.session.parentId) leads.add(leadOf(sessionSafe(input.session.parentId)));
148
+ // A Pi worker's hooks see its own Pi session; its workit id names the lead.
149
+ if (env.WORKIT_PI_WORKER_SESSION && env.WORKIT_SESSION_ID)
150
+ leads.add(leadOf(sessionSafe(env.WORKIT_SESSION_ID)));
151
+ return {
152
+ host: input.host,
153
+ session: sessionSafe(session),
154
+ leads: [...leads],
155
+ pid: () => hostPid(transport),
156
+ };
157
+ };
158
+
159
+ /** The checkout (worktree top level) holding `target`; null outside git. */
160
+ const checkoutOf = (target: string): string | null => {
161
+ let dir = path.resolve(target);
162
+ // A path that does not exist yet: its nearest existing ancestor.
163
+ while (!fs.existsSync(dir) && path.dirname(dir) !== dir) dir = path.dirname(dir);
164
+ if (fs.existsSync(dir) && !fs.statSync(dir).isDirectory()) dir = path.dirname(dir);
165
+ // Inside a `.git` directory: the checkout it belongs to.
166
+ const parts = dir.split(path.sep);
167
+ const dotGit = parts.lastIndexOf(".git");
168
+ if (dotGit > 0) dir = parts.slice(0, dotGit).join(path.sep) || path.sep;
169
+ const location = resolveStore(dir);
170
+ if (location instanceof Error || !location.shared || !location.top) return null;
171
+ let top = location.top;
172
+ try {
173
+ top = fs.realpathSync.native(top);
174
+ } catch {
175
+ // keep git's spelling
176
+ }
177
+ return top;
178
+ };
179
+
180
+ /** The marker file of a checkout. */
181
+ export const markerFile = (top: string): string =>
182
+ path.join(
183
+ resolveStateDir(),
184
+ "live-sessions",
185
+ `${createHash("sha256").update(checkoutKey(top)).digest("hex").slice(0, 32)}.json`,
186
+ );
187
+
188
+ /**
189
+ * One spelling per checkout, however the path was written: resolved through
190
+ * symlinks and Windows 8.3 short names (`RUNNER~1`), native separators, and
191
+ * on Windows a lower-cased path (drive letter included).
192
+ */
193
+ export const checkoutKey = (top: string, platform: string = process.platform): string => {
194
+ let key = path.resolve(top);
195
+ try {
196
+ key = fs.realpathSync.native(key);
197
+ } catch {
198
+ // a path that does not exist keeps its resolved spelling
199
+ }
200
+ return platform === "win32" ? key.replaceAll("/", "\\").toLowerCase() : key;
201
+ };
202
+
203
+ const readMarker = (file: string): LiveMarker | null => {
204
+ try {
205
+ const value = JSON.parse(fs.readFileSync(file, "utf8")) as Partial<LiveMarker>;
206
+ if (
207
+ value?.version !== 1 ||
208
+ typeof value.session !== "string" ||
209
+ typeof value.host !== "string" ||
210
+ typeof value.heartbeatAt !== "string" ||
211
+ Number.isNaN(Date.parse(value.heartbeatAt))
212
+ )
213
+ return null;
214
+ return {
215
+ version: 1,
216
+ session: value.session,
217
+ leads: Array.isArray(value.leads)
218
+ ? value.leads.filter((item) => typeof item === "string")
219
+ : [],
220
+ host: value.host,
221
+ pid: typeof value.pid === "number" ? value.pid : null,
222
+ pidStart: typeof value.pidStart === "string" ? value.pidStart : null,
223
+ machine: typeof value.machine === "string" ? value.machine : "",
224
+ checkout: typeof value.checkout === "string" ? value.checkout : "",
225
+ heartbeatAt: value.heartbeatAt,
226
+ };
227
+ } catch {
228
+ return null;
229
+ }
230
+ };
231
+
232
+ const ageOf = (marker: LiveMarker, now: Date): number =>
233
+ now.getTime() - Date.parse(marker.heartbeatAt);
234
+
235
+ /**
236
+ * Whether the marker's owner still counts: a fresh heartbeat and, on this
237
+ * machine, a running process that is still the one recorded (same start time).
238
+ */
239
+ const isLive = (marker: LiveMarker, deps: LiveDeps): boolean => {
240
+ const age = ageOf(marker, deps.now ?? new Date());
241
+ if (age > (deps.ttlMs ?? LIVE_SESSION_TTL_MS)) return false;
242
+ if (marker.machine !== (deps.machine ?? os.hostname()) || marker.pid === null) return true;
243
+ if (!(deps.alive ?? processAlive)(marker.pid)) return false;
244
+ if (marker.pidStart === null) return true;
245
+ const start = liveProbe.start(marker.pid);
246
+ return start === null || start === marker.pidStart;
247
+ };
248
+
249
+ /** The same session, one of its subagents or fanout workers, or its lead: ids alone, no I/O. */
250
+ const sameLead = (marker: LiveMarker, owner: LiveOwner): boolean =>
251
+ marker.session === owner.session ||
252
+ owner.leads.some((lead) => lead === marker.session || marker.leads.includes(lead));
253
+
254
+ /**
255
+ * The same session, one of its subagents or its lead, or another session of
256
+ * the same host process. Where a pid is unknown (Windows), any session of the
257
+ * same host family counts as the owner: the guard cannot tell a subagent or a
258
+ * cleared session from another window there, and never denies on a guess.
259
+ */
260
+ export const sameOwner = (marker: LiveMarker, owner: LiveOwner, machine: string): boolean => {
261
+ if (sameLead(marker, owner)) return true;
262
+ if (marker.machine !== machine) return false;
263
+ const pid = owner.pid();
264
+ // Without a pid and its start time the process cannot be told apart.
265
+ if (pid === null || marker.pid === null || marker.pidStart === null)
266
+ return familyOf(marker.host) === familyOf(owner.host);
267
+ return marker.pid === pid;
268
+ };
269
+
270
+ export type LiveConflict = { marker: LiveMarker; file: string; ageMs: number; checkout: string };
271
+
272
+ /** The live marker of another session in `target`'s checkout, or null (also on any error). */
273
+ export const liveConflict = (
274
+ target: string,
275
+ owner: LiveOwner,
276
+ deps: LiveDeps = {},
277
+ ): LiveConflict | null => {
278
+ try {
279
+ const checkout = checkoutOf(target);
280
+ if (!checkout) return null;
281
+ const file = markerFile(checkout);
282
+ const marker = readMarker(file);
283
+ // Ids first: the owner's own marker never costs a process-table read.
284
+ if (!marker || sameLead(marker, owner) || !isLive(marker, deps)) return null;
285
+ if (sameOwner(marker, owner, deps.machine ?? os.hostname())) return null;
286
+ return { marker, file, ageMs: ageOf(marker, deps.now ?? new Date()), checkout };
287
+ } catch {
288
+ return null;
289
+ }
290
+ };
291
+
292
+ /**
293
+ * Record `owner` as the live session of `target`'s checkout (a fresh
294
+ * heartbeat), unless another live session owns it; one's own marker is
295
+ * rewritten at most every 30 s. Two sessions claiming a free checkout at
296
+ * the same instant race: each writes a temp file and renames it, the last
297
+ * rename wins, and the loser is refused from its next write on. Never throws.
298
+ */
299
+ export const claimLiveSession = (target: string, owner: LiveOwner, deps: LiveDeps = {}): void => {
300
+ try {
301
+ const checkout = checkoutOf(target);
302
+ if (!checkout) return;
303
+ const file = markerFile(checkout);
304
+ const now = deps.now ?? new Date();
305
+ const machine = deps.machine ?? os.hostname();
306
+ const current = readMarker(file);
307
+ if (current) {
308
+ const age = ageOf(current, now);
309
+ // One's own fresh marker: nothing to read or write.
310
+ if (sameLead(current, owner) && age >= 0 && age < HEARTBEAT_EVERY_MS) return;
311
+ if (!sameLead(current, owner) && isLive(current, deps) && !sameOwner(current, owner, machine))
312
+ return;
313
+ }
314
+ const pid = owner.pid();
315
+ const marker: LiveMarker = {
316
+ version: 1,
317
+ session: owner.session,
318
+ leads: owner.leads,
319
+ host: owner.host,
320
+ pid,
321
+ pidStart: pid === null ? null : liveProbe.start(pid),
322
+ machine,
323
+ checkout,
324
+ heartbeatAt: now.toISOString(),
325
+ };
326
+ fs.mkdirSync(path.dirname(file), { recursive: true });
327
+ const temp = `${file}.${process.pid}.tmp`;
328
+ fs.writeFileSync(temp, JSON.stringify(marker));
329
+ fs.renameSync(temp, file);
330
+ } catch {
331
+ // Fail open: without a marker nobody is refused.
332
+ }
333
+ };
@@ -44,8 +44,16 @@ export const RECLAIM_GUARD_TTL_MS = 30_000;
44
44
  * wait blocks the calling thread, so in-process hosts (OpenCode, MCP, Pi) keep
45
45
  * the short default; the CLI raises it for its own process.
46
46
  */
47
- let defaultLockTimeoutMs = 250;
47
+ export const IN_PROCESS_LOCK_TIMEOUT_MS = 250;
48
+ let defaultLockTimeoutMs = IN_PROCESS_LOCK_TIMEOUT_MS;
48
49
  export const defaultLockTimeout = (): number => defaultLockTimeoutMs;
50
+ /**
51
+ * The wait for a lock that a CLI process may hold longer (the ledger's
52
+ * network-filesystem lock): `cliMs` once the process raised the default
53
+ * (the CLI does), else the in-process cap.
54
+ */
55
+ export const hostAwareLockTimeout = (cliMs: number): number =>
56
+ defaultLockTimeoutMs > IN_PROCESS_LOCK_TIMEOUT_MS ? cliMs : IN_PROCESS_LOCK_TIMEOUT_MS;
49
57
  export const setDefaultLockTimeout = (ms: number): void => {
50
58
  defaultLockTimeoutMs = ms;
51
59
  };
@@ -149,6 +149,16 @@ const workspaceConfigSchema = z
149
149
  * authorship lives in the ledger only (`commit.recorded`).
150
150
  */
151
151
  trailers: z.boolean().optional(),
152
+ /**
153
+ * Whether the live-session guard denies edits in a checkout another live
154
+ * agent session owns (default true; core/live-session.ts).
155
+ */
156
+ liveSessionGuard: z.boolean().optional(),
157
+ /**
158
+ * Fanout settings. `tierModels` names the model per slice TIER, as one
159
+ * name or per host family; read leniently (workspaceTierModel).
160
+ */
161
+ fanout: z.object({ tierModels: z.unknown().optional() }).passthrough().optional(),
152
162
  /** Autonomy grants (D15): only ever set here, by the user. */
153
163
  autonomy: z
154
164
  .object(
@@ -695,35 +705,145 @@ export const resolveRuntimeReleaseTracks = (
695
705
  : null;
696
706
  };
697
707
 
708
+ type RuntimeEntry = Record<string, unknown> & { name: string };
709
+
698
710
  /**
699
- * Whether commits in cwd's workspace carry the `Workit-Session:` trailer.
700
- * The selected workspace's own `trailers` wins; unset, it inherits from the
701
- * nearest enclosing workspace that also matches cwd (a repo workspace nested
702
- * in `work`), and defaults to true. Read leniently: no workspace, or an
703
- * unreadable workspaces.json, keeps the trailer. `from` names the entry the
704
- * value came from (null for the default).
711
+ * The workspace selected for cwd and the entry a setting comes from: the
712
+ * selected workspace when `has(entry)`, else the nearest enclosing workspace
713
+ * that also matches cwd (a repo workspace nested in `work`). Read leniently:
714
+ * no workspace, or an unreadable workspaces.json, reads as null.
705
715
  */
706
- export const workspaceTrailers = (
716
+ const inheritedSetting = (
707
717
  cwd: string,
708
- dir?: string,
709
- ): { enabled: boolean; workspace: string | null; from: string | null } => {
718
+ dir: string | undefined,
719
+ has: (entry: RuntimeEntry) => boolean,
720
+ ): { workspace: string; source: RuntimeEntry | null } | null => {
710
721
  let matched: ReturnType<typeof matchRuntimeWorkspace>;
711
722
  try {
712
723
  matched = matchRuntimeWorkspace(cwd, dir);
713
724
  } catch {
714
- return { enabled: true, workspace: null, from: null };
725
+ return null;
715
726
  }
716
- if (!matched) return { enabled: true, workspace: null, from: null };
727
+ if (!matched) return null;
717
728
  const { selected, matches } = matched;
718
729
  const enclosing = matches
719
730
  .filter((entry) => entry !== selected)
720
731
  .toSorted((a, b) => globSpecificity(b.glob) - globSpecificity(a.glob));
721
- const source = [selected, ...enclosing].find((entry) => typeof entry.trailers === "boolean");
732
+ return { workspace: selected.name, source: [selected, ...enclosing].find(has) ?? null };
733
+ };
734
+
735
+ /**
736
+ * Whether commits in cwd's workspace carry the `Workit-Session:` trailer.
737
+ * The selected workspace's own `trailers` wins; unset, it inherits from the
738
+ * nearest enclosing workspace that also matches cwd, and defaults to true.
739
+ * No workspace, or an unreadable workspaces.json, keeps the trailer. `from`
740
+ * names the entry the value came from (null for the default).
741
+ */
742
+ export const workspaceTrailers = (
743
+ cwd: string,
744
+ dir?: string,
745
+ ): { enabled: boolean; workspace: string | null; from: string | null } => {
746
+ const found = inheritedSetting(cwd, dir, (entry) => typeof entry.trailers === "boolean");
722
747
  return {
723
- enabled: source?.trailers !== false,
724
- workspace: selected.name,
725
- from: source?.name ?? null,
748
+ enabled: found?.source?.trailers !== false,
749
+ workspace: found?.workspace ?? null,
750
+ from: found?.source?.name ?? null,
751
+ };
752
+ };
753
+
754
+ /**
755
+ * Whether the live-session guard runs for cwd's workspace (`liveSessionGuard`,
756
+ * default true; inherited like `trailers`). An unreadable workspaces.json
757
+ * keeps it on: the guard itself fails open on any state error.
758
+ */
759
+ export const workspaceLiveSessionGuard = (cwd: string, dir?: string): boolean =>
760
+ inheritedSetting(cwd, dir, (entry) => typeof entry.liveSessionGuard === "boolean")?.source
761
+ ?.liveSessionGuard !== false;
762
+
763
+ const hostFamily = (host: string): string => host.replace(/_(?:cli|desktop)$/u, "");
764
+
765
+ const TIER_KEYS = new Set(["mundane", "standard", "hard"]);
766
+ const TIER_HOSTS = new Set([
767
+ "claude_code",
768
+ "opencode",
769
+ "codex",
770
+ "codex_cli",
771
+ "codex_desktop",
772
+ "cursor",
773
+ "pi",
774
+ "default",
775
+ ]);
776
+
777
+ /**
778
+ * What `fanout.tierModels` in cwd's workspaces holds that Workit ignores: an
779
+ * unknown tier or host key, or a value that is not a model name. Read
780
+ * leniently (each one is skipped, never an error); `workit grant show`
781
+ * reports them.
782
+ */
783
+ export const workspaceTierModelIssues = (cwd: string, dir?: string): string[] => {
784
+ let matched: ReturnType<typeof matchRuntimeWorkspace>;
785
+ try {
786
+ matched = matchRuntimeWorkspace(cwd, dir);
787
+ } catch {
788
+ return [];
789
+ }
790
+ const issues: string[] = [];
791
+ for (const entry of matched?.matches ?? []) {
792
+ const models = (entry.fanout as { tierModels?: unknown } | undefined)?.tierModels;
793
+ if (models === undefined) continue;
794
+ const where = `workspace ${entry.name} fanout.tierModels`;
795
+ if (!models || typeof models !== "object" || Array.isArray(models)) {
796
+ issues.push(`${where} is not an object; ignored`);
797
+ continue;
798
+ }
799
+ for (const [tier, value] of Object.entries(models)) {
800
+ if (!TIER_KEYS.has(tier)) issues.push(`${where}.${tier}: unknown tier; ignored`);
801
+ else if (typeof value === "string") {
802
+ if (!value.trim()) issues.push(`${where}.${tier}: empty model name; ignored`);
803
+ } else if (!value || typeof value !== "object" || Array.isArray(value))
804
+ issues.push(`${where}.${tier}: not a model name or a per-host object; ignored`);
805
+ else
806
+ for (const [host, model] of Object.entries(value)) {
807
+ if (!TIER_HOSTS.has(host)) issues.push(`${where}.${tier}.${host}: unknown host; ignored`);
808
+ else if (typeof model !== "string" || !model.trim())
809
+ issues.push(`${where}.${tier}.${host}: not a model name; ignored`);
810
+ }
811
+ }
812
+ }
813
+ return issues;
814
+ };
815
+
816
+ /**
817
+ * The model `fanout.tierModels` names for a tier on a host, or null (the
818
+ * fanout skill's TIER guidance applies). A tier maps to one model name for
819
+ * every host, or to `{ <host>: <model> }` keyed by host family (claude_code,
820
+ * opencode, codex, cursor, pi) with an optional `default`. Inherited like
821
+ * `trailers`, per tier; anything malformed reads as unset.
822
+ */
823
+ export const workspaceTierModel = (
824
+ cwd: string,
825
+ tier: string,
826
+ host: string | null,
827
+ dir?: string,
828
+ ): { model: string; workspace: string } | null => {
829
+ const pick = (entry: RuntimeEntry): string | null => {
830
+ const fanout = entry.fanout as { tierModels?: unknown } | undefined;
831
+ const models = fanout?.tierModels;
832
+ if (!models || typeof models !== "object" || !Object.hasOwn(models, tier)) return null;
833
+ const value = (models as Record<string, unknown>)[tier];
834
+ if (typeof value === "string") return value.trim() || null;
835
+ if (!value || typeof value !== "object") return null;
836
+ const byHost = value as Record<string, unknown>;
837
+ const family = host ? hostFamily(host) : null;
838
+ const named =
839
+ (family && Object.hasOwn(byHost, family) ? byHost[family] : undefined) ??
840
+ (host && Object.hasOwn(byHost, host) ? byHost[host] : undefined) ??
841
+ (Object.hasOwn(byHost, "default") ? byHost.default : undefined);
842
+ return typeof named === "string" && named.trim() ? named.trim() : null;
726
843
  };
844
+ const found = inheritedSetting(cwd, dir, (entry) => pick(entry) !== null);
845
+ const model = found?.source ? pick(found.source) : null;
846
+ return model && found?.source ? { model, workspace: found.source.name } : null;
727
847
  };
728
848
 
729
849
  const resolveRuntimeWorkspaceCandidate = (