@brainervirus/workit-core 8.13.1 → 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.1",
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": [
@@ -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
  | --- | --- | --- |
@@ -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
+ };
@@ -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 = (
@@ -17,6 +17,7 @@
17
17
  // the integration tip before reporting, and nothing else. It only reads, apart
18
18
  // from hiding `.workit-scratch/` in the repository's info/exclude.
19
19
  import { createHash } from "node:crypto";
20
+ import { workspaceTierModel } from "./core/workspaces";
20
21
  import { activeStanding, readLedger, type LedgerActor } from "./ledger";
21
22
  import { branchRef, fanoutFail, planLeads, type FanoutFile, type FanoutResult } from "./fanout";
22
23
  import {
@@ -38,6 +39,8 @@ export type BriefOutcome = {
38
39
  branch: string;
39
40
  base: string;
40
41
  tier: string;
42
+ /** The model `fanout.tierModels` names for the tier on the lead's host, else null. */
43
+ model: string | null;
41
44
  /** Absolute when the slice's worktree exists, else relative to the worker's worktree root. */
42
45
  scratch: string;
43
46
  /** The id a worker exports, or null when its host issues or derives it. */
@@ -171,6 +174,7 @@ export function renderBrief(
171
174
  what: String(row.what),
172
175
  }));
173
176
  const identity = identityOf(input.actor.host);
177
+ const model = workspaceTierModel(cwd, slice.tier, input.actor.host);
174
178
  const minted = workerSession(sessionSafe(input.actor.session), slice.id, input.attempt);
175
179
  const session = identity === "declared" ? minted : null;
176
180
  const fanIn =
@@ -200,6 +204,9 @@ export function renderBrief(
200
204
  ...slice.acceptance.map((line) => ` - ${line}`),
201
205
  `VERIFY: ${slice.verify.join("; ")}`,
202
206
  `TIER: ${slice.tier}`,
207
+ ...(model
208
+ ? [` model: ${model.model} (fanout.tierModels in workspace ${model.workspace})`]
209
+ : []),
203
210
  `TIMEBOX: ${slice.timebox ?? DEFAULT_TIMEBOX}; past it without a new commit you will be replaced`,
204
211
  scratchLine,
205
212
  `FORBIDDEN: ${slice.forbidden.join("; ")}`,
@@ -222,6 +229,7 @@ export function renderBrief(
222
229
  branch,
223
230
  base: slice.base,
224
231
  tier: slice.tier,
232
+ model: model?.model ?? null,
225
233
  scratch,
226
234
  session,
227
235
  standing,
@@ -18,7 +18,8 @@
18
18
  import { parseDuration } from "./duration";
19
19
  import { checksState, gatingChecks, missingRequired, type ChecksState } from "./forge/report";
20
20
  import type { ResolvedForge } from "./forge/resolve";
21
- import { checkVerdicts, readLedger, type ReadRow, type VerdictBasis } from "./ledger";
21
+ import { workspaceTierModel } from "./core/workspaces";
22
+ import { actorFromEnv, checkVerdicts, readLedger, type ReadRow, type VerdictBasis } from "./ledger";
22
23
  import { sliceLandings } from "./fanout-check";
23
24
  import { forgeBreaker, inSliceRun } from "./fanout-landed";
24
25
  import {
@@ -74,6 +75,8 @@ export type StatusOutcome = {
74
75
  landingOrder: string[];
75
76
  /** Not started; every dependency landed (or, stacked, its parent has an accepted verdict). */
76
77
  spawnable: string[];
78
+ /** The model `fanout.tierModels` names for each spawnable slice's TIER on this host. */
79
+ spawnModels: Record<string, string>;
77
80
  stuck: string[];
78
81
  next: string;
79
82
  notes: string[];
@@ -85,6 +88,8 @@ export type StatusOptions = {
85
88
  forgeError?: string | null;
86
89
  stuckAfterMs?: number | null;
87
90
  now?: Date;
91
+ /** The lead's host, for `fanout.tierModels`; default: the environment's (WORKIT_HOST…). */
92
+ host?: string | null;
88
93
  };
89
94
 
90
95
  /** A TIMEBOX such as `45 minutes`, `2h` or `1h30m`, in ms; null without a unit or otherwise. */
@@ -279,6 +284,16 @@ export function fanoutStatus(
279
284
  if (row.waitsFor.length && !suggested.includes(id))
280
285
  row.reasons.push(`waits for ${row.waitsFor.join(", ")}`);
281
286
  }
287
+ const host = options.host === undefined ? actorFromEnv(process.env).host : options.host;
288
+ const spawnModels: Record<string, string> = {};
289
+ for (const id of spawnable) {
290
+ const tier = plan.slices.find((slice) => slice.id === id)?.tier;
291
+ const model = tier ? workspaceTierModel(cwd, tier, host) : null;
292
+ if (model) spawnModels[id] = model.model;
293
+ }
294
+ const spawnList = spawnable
295
+ .map((id) => (spawnModels[id] ? `${id} (model ${spawnModels[id]})` : id))
296
+ .join(", ");
282
297
  const stuck = order.filter((id) => out.get(id)?.stuck);
283
298
  const down = breaker.down();
284
299
  if (down)
@@ -300,7 +315,7 @@ export function fanoutStatus(
300
315
  : noPr.length
301
316
  ? `open PRs for ${noPr.join(", ")}: workit git push && workit pr create --fill on each branch`
302
317
  : spawnable.length
303
- ? `spawn ${spawnable.join(", ")} (workit fanout worktree create <slice> on hosts without native worktrees)`
318
+ ? `spawn ${spawnList} (workit fanout worktree create <slice> on hosts without native worktrees)`
304
319
  : order.every((id) => out.get(id)?.landed)
305
320
  ? "every slice has landed"
306
321
  : "wait for workers, then workit fanout status again";
@@ -317,6 +332,7 @@ export function fanoutStatus(
317
332
  slices: order.map((id) => out.get(id) as SliceStatusRow),
318
333
  landingOrder: suggested,
319
334
  spawnable,
335
+ spawnModels,
320
336
  stuck,
321
337
  next,
322
338
  notes,
@@ -26,7 +26,8 @@ import {
26
26
  withContextLine,
27
27
  } from "./skill-nudge";
28
28
  import { stopDecision } from "./stop";
29
- import { shellWrites, writeGate } from "./write-gate";
29
+ import { liveOwner } from "../core/live-session";
30
+ import { shellGate, writeGate } from "./write-gate";
30
31
  import type { HookDecision, HookEventKind, HookInput, HostAdapter, RenderedHook } from "./protocol";
31
32
 
32
33
  export type HookDeps = { descriptor: HostDescriptor; addendum: string | null };
@@ -156,10 +157,10 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
156
157
  if (!postTool) settlePendingCommit(input);
157
158
  const raw = canDeny ? rawGitPre(input, event.command, { nudge: false }) : NONE;
158
159
  if (raw.kind === "deny") return raw;
159
- const writes = usable(descriptor.events["write.pre"].support)
160
- ? shellWrites(event.command)
161
- : { writes: false, targets: null };
162
- const gate = writes.writes ? writeGate(input.cwd, writes.targets) : NONE;
160
+ // The before-write gate and the live-session guard (#274).
161
+ const gate = usable(descriptor.events["write.pre"].support)
162
+ ? shellGate(input.cwd, event.command, liveOwner(input, descriptor.transport), event.dialect)
163
+ : NONE;
163
164
  if (gate.kind === "deny") return gate;
164
165
  // The command will run: note HEAD so the post-tool hook (or the next
165
166
  // command, without one) can tell whether a raw commit moved it.
@@ -182,7 +183,7 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
182
183
  return NONE;
183
184
  case "write.pre":
184
185
  return usable(descriptor.events["write.pre"].support)
185
- ? writeGate(input.cwd, event.paths)
186
+ ? writeGate(input.cwd, event.paths, liveOwner(input, descriptor.transport))
186
187
  : NONE;
187
188
  case "subagent.start":
188
189
  return {
@@ -19,7 +19,8 @@ export {
19
19
  type SessionHandle,
20
20
  } from "./context";
21
21
  export { shellPolicy } from "./policy";
22
- export { shellWrites, writeGate } from "./write-gate";
22
+ export { shellGate, shellWrites, writeGate } from "./write-gate";
23
+ export { liveOwner } from "../core/live-session";
23
24
  export { asksUser, stopDecision, stopObligation, type StopObligation } from "./stop";
24
25
  export { callKey, noteRawCommit, rawGitPost, rawGitPre } from "./raw-git";
25
26
  export { promptNudge, recordSkillLoad, shellNudge, skillFileIn } from "./skill-nudge";
@@ -5,13 +5,28 @@
5
5
  // Reads only the task index, one task snapshot and the ledger; never
6
6
  // migrates, never captures a candidate, and fails open on any state error.
7
7
  import { realpathSync } from "node:fs";
8
+ import os from "node:os";
8
9
  import path from "node:path";
9
10
  import { fileSignature, TaskStore } from "../core/task-store";
10
11
  import { latestJudgment, writeBlockers, type WriteBlocker } from "../core/policy/derive";
11
12
  import { applicableDecision } from "../core/task-evaluation";
12
- import { ledgerPath } from "../ledger";
13
+ import {
14
+ claimLiveSession,
15
+ liveConflict,
16
+ type LiveConflict,
17
+ type LiveDeps,
18
+ type LiveOwner,
19
+ } from "../core/live-session";
20
+ import { workspaceLiveSessionGuard } from "../core/workspaces";
21
+ import { appendHookObserved, ledgerPath } from "../ledger";
13
22
  import type { HookDecision } from "./protocol";
14
- import { segmentsOf } from "./shell-words";
23
+ import {
24
+ inWorkitWorkspace,
25
+ rawInvocations,
26
+ workitInvocations,
27
+ type RawInvocation,
28
+ } from "./raw-git";
29
+ import { segmentsOf, type ShellDialect } from "./shell-words";
15
30
 
16
31
  const NONE: HookDecision = { kind: "none" };
17
32
 
@@ -204,13 +219,298 @@ const gateFor = (cwd: string): Gate | null => {
204
219
  return gate;
205
220
  };
206
221
 
222
+ // --- the live-session guard (#274) ------------------------------------------
223
+
224
+ /** git subcommands that change a checkout's files, index or HEAD. */
225
+ const MUTATING_GIT = new Set([
226
+ "add",
227
+ "am",
228
+ "apply",
229
+ "checkout",
230
+ "cherry-pick",
231
+ "clean",
232
+ "commit",
233
+ "merge",
234
+ "mv",
235
+ "pull",
236
+ "rebase",
237
+ "reset",
238
+ "restore",
239
+ "revert",
240
+ "rm",
241
+ "stash",
242
+ "switch",
243
+ ]);
244
+ /** Subcommands whose `-n` means `--dry-run` (for `commit`, `-n` skips hooks). */
245
+ const DRY_N = new Set(["add", "clean", "rm", "mv"]);
246
+ /** `workit git` verbs that commit or move HEAD in the checkout. */
247
+ const MUTATING_WORKIT_GIT = new Set(["commit", "branch"]);
248
+
249
+ /** A placeholder file name that stands for its directory (an unknown write, a git command). */
250
+ const DIR_TARGET = ".workit-live-target";
251
+
252
+ /** A one-command override: `WORKIT_LIVE_SESSION=override <command>`. */
253
+ const OVERRIDE = "WORKIT_LIVE_SESSION=override";
254
+
255
+ /** Only asks for help or a dry run: changes nothing. */
256
+ const inert = (args: readonly string[], sub: string): boolean =>
257
+ args.some(
258
+ (arg) =>
259
+ arg === "--help" ||
260
+ arg === "-h" ||
261
+ arg === "--dry-run" ||
262
+ (DRY_N.has(sub) && /^-[a-zA-Z]*n[a-zA-Z]*$/.test(arg) && !arg.startsWith("--")),
263
+ );
264
+
265
+ /**
266
+ * The directories mutating git commands in `command` run in (raw-git's
267
+ * analysis: `cd`/`pushd`, groups, `-C`, env and wrapper prefixes, `bash -c`,
268
+ * `$(…)`, `eval`, `xargs`, `find -exec`, PowerShell, `cmd /c`). A command
269
+ * whose directory cannot be known is skipped, never denied.
270
+ */
271
+ const gitMutationDirs = (cwd: string, command: string, dialect: ShellDialect): string[] => {
272
+ let invocations: readonly RawInvocation[];
273
+ try {
274
+ invocations = rawInvocations(command, dialect);
275
+ } catch {
276
+ return [];
277
+ }
278
+ return invocations.flatMap((invocation) => {
279
+ if (invocation.tool !== "git" || invocation.dir === undefined) return [];
280
+ const at = invocation.args.findIndex((arg) => !arg.startsWith("-"));
281
+ const sub = invocation.args[at] ?? "";
282
+ const rest = invocation.args.slice(at + 1);
283
+ if (!MUTATING_GIT.has(sub) || inert(rest, sub)) return [];
284
+ // `git stash list|show` only reads.
285
+ if (sub === "stash" && ["list", "show"].includes(rest[0] ?? "")) return [];
286
+ return [path.resolve(cwd, invocation.dir ?? ".")];
287
+ });
288
+ };
289
+
290
+ const CD = new Set(["cd", "chdir", "set-location", "sl"]);
291
+ const PUSHD = new Set(["pushd", "push-location"]);
292
+ const POPD = new Set(["popd", "pop-location"]);
293
+
294
+ /** A `cd` operand resolved against `dir`; undefined when it cannot be known. */
295
+ const cdInto = (dir: string | undefined, words: string[]): string | undefined => {
296
+ const target = words.slice(1).find((word) => !word.startsWith("-") || word === "-");
297
+ // Only a leading `~` expands; a mid-path `~` is literal (Windows 8.3
298
+ // short names like RUNNER~1).
299
+ if (dir === undefined || target === "-" || (target && /^~|[$`*?]/.test(target))) return undefined;
300
+ return target === undefined ? os.homedir() : path.resolve(dir, target);
301
+ };
302
+
303
+ const quoteWord = (word: string): string =>
304
+ /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`;
305
+
306
+ /**
307
+ * Per top-level segment, the files it writes (redirects, `touch`, `rm`,
308
+ * `sed -i`…) and `workit git commit|branch`, resolved against the
309
+ * directory that segment runs in (`cd`, `pushd`/`popd`, `( … )` groups). A
310
+ * relative target in an unknown directory is skipped.
311
+ */
312
+ const segmentTargets = (cwd: string, command: string, dialect: ShellDialect): string[] => {
313
+ const targets: string[] = [];
314
+ let dir: string | undefined = cwd;
315
+ let stack: Array<string | undefined> = [];
316
+ const groups: Array<{ dir: string | undefined; stack: Array<string | undefined> }> = [];
317
+ const at = (file: string) =>
318
+ path.isAbsolute(file) ? file : dir === undefined ? null : path.resolve(dir, file);
319
+ for (const segment of segmentsOf(command, dialect)) {
320
+ if (segment.group === "open") {
321
+ groups.push({ dir, stack: [...stack] });
322
+ continue;
323
+ }
324
+ if (segment.group === "close") {
325
+ const saved = groups.pop();
326
+ if (saved) ({ dir, stack } = saved);
327
+ continue;
328
+ }
329
+ const lead = segment.words.findIndex((word) => !/^\w+=/.test(word));
330
+ const words = lead < 0 ? [] : segment.words.slice(lead);
331
+ const name = (words[0]?.split(/[\\/]/).at(-1) ?? "").toLowerCase();
332
+ if (CD.has(name)) {
333
+ dir = cdInto(dir, words);
334
+ continue;
335
+ }
336
+ if (PUSHD.has(name)) {
337
+ stack.push(dir);
338
+ dir = cdInto(dir, words);
339
+ continue;
340
+ }
341
+ if (POPD.has(name)) {
342
+ dir = stack.length ? stack.pop() : undefined;
343
+ continue;
344
+ }
345
+ for (const file of segment.redirects)
346
+ if (!/^\/dev\/(?:null|stdout|stderr|tty)$/.test(file)) {
347
+ const resolved = at(file);
348
+ if (resolved) targets.push(resolved);
349
+ }
350
+ const edits = editsOf(segment.words);
351
+ if (edits === null) {
352
+ if (dir !== undefined) targets.push(path.join(dir, DIR_TARGET));
353
+ } else
354
+ for (const file of edits) {
355
+ const resolved = at(file);
356
+ if (resolved) targets.push(resolved);
357
+ }
358
+ if (
359
+ dir !== undefined &&
360
+ workitInvocations(segment.words.map(quoteWord).join(" "), "posix").some(
361
+ (args) =>
362
+ args[0] === "git" && MUTATING_WORKIT_GIT.has(args[1] ?? "") && !inert(args.slice(2), ""),
363
+ )
364
+ )
365
+ targets.push(path.join(dir, DIR_TARGET));
366
+ }
367
+ return targets;
368
+ };
369
+
370
+ /** The command is one simple command led by `WORKIT_LIVE_SESSION=override`. */
371
+ const overridden = (command: string, dialect: ShellDialect): boolean => {
372
+ const segments = segmentsOf(command, dialect);
373
+ if (segments.length !== 1 || segments[0].group || segments[0].subs?.length) return false;
374
+ const words = segments[0].words;
375
+ const lead = words.findIndex((word) => !/^\w+=/.test(word));
376
+ return lead > 0 && words.slice(0, lead).includes(OVERRIDE);
377
+ };
378
+
379
+ const HOST_LABELS: Readonly<Record<string, string>> = {
380
+ claude_code: "Claude Code",
381
+ opencode: "OpenCode",
382
+ codex_cli: "Codex CLI",
383
+ codex_desktop: "Codex Desktop",
384
+ cursor: "Cursor",
385
+ pi: "Pi",
386
+ };
387
+
388
+ const ageText = (ms: number): string => {
389
+ const seconds = Math.max(0, Math.round(ms / 1000));
390
+ return seconds < 120 ? `${seconds}s` : `${Math.round(seconds / 60)} min`;
391
+ };
392
+
393
+ const liveDeny = ({ marker, checkout, ageMs, file }: LiveConflict): HookDecision => {
394
+ const unblock = `Work in your own worktree (\`git worktree add <path> -b <branch>\`, or \`workit fanout worktree create <slice>\`). Only if the user confirms that session is finished: release it with \`rm '${file}'\`, or prefix one command with ${OVERRIDE}.`;
395
+ return {
396
+ kind: "deny",
397
+ reason: `workit live-session guard: ${checkout} belongs to another live agent session, ${HOST_LABELS[marker.host] ?? marker.host} session ${marker.session}${marker.pid === null ? "" : ` (pid ${marker.pid})`}, last active ${ageText(ageMs)} ago. Do not edit a checkout another session is working in; read-only commands stay allowed. ${unblock}`,
398
+ unblock,
399
+ };
400
+ };
401
+
402
+ /** One target per directory. */
403
+ const guarded = (targets: readonly string[]): string[] => {
404
+ const seen = new Set<string>();
405
+ return targets.filter((target) => {
406
+ const dir = path.dirname(target);
407
+ if (seen.has(dir)) return false;
408
+ seen.add(dir);
409
+ return true;
410
+ });
411
+ };
412
+
413
+ /**
414
+ * Deny when a target's checkout belongs to another live session and its
415
+ * workspace keeps `liveSessionGuard` on. Fails open.
416
+ */
417
+ const liveGate = (owner: LiveOwner, targets: readonly string[], deps?: LiveDeps): HookDecision => {
418
+ try {
419
+ for (const target of guarded(targets)) {
420
+ const conflict = liveConflict(target, owner, deps);
421
+ if (conflict && workspaceLiveSessionGuard(conflict.checkout)) return liveDeny(conflict);
422
+ }
423
+ } catch {
424
+ // Fail open.
425
+ }
426
+ return NONE;
427
+ };
428
+
429
+ const claimAll = (owner: LiveOwner, targets: readonly string[], deps?: LiveDeps): void => {
430
+ for (const target of guarded(targets)) claimLiveSession(target, owner, deps);
431
+ };
432
+
433
+ /** The absolute paths an edit touches: its files, or cwd itself when unknown. */
434
+ const liveTargets = (cwd: string, paths: readonly string[] | null): string[] =>
435
+ paths?.length ? paths.map((file) => path.resolve(cwd, file)) : [path.join(cwd, DIR_TARGET)];
436
+
207
437
  /**
208
438
  * Deny a working-tree edit while the branch task has unmet before-write
209
439
  * requirements. `paths` are the files the tool writes, or null when unknown
210
440
  * (a shell write, a patch whose headers cannot be read); unknown or empty
211
441
  * targets are gated, and an edit touching only exempt files is always allowed.
442
+ * With `owner` (the hook's session), the live-session guard runs first, and
443
+ * an allowed edit refreshes the owner's marker in each checkout it touches.
444
+ */
445
+ export function writeGate(
446
+ cwd: string,
447
+ paths: readonly string[] | null,
448
+ owner?: LiveOwner | null,
449
+ deps?: LiveDeps,
450
+ ): HookDecision {
451
+ if (!owner) return beforeWriteGate(cwd, paths);
452
+ const targets = liveTargets(cwd, paths);
453
+ const live = liveGate(owner, targets, deps);
454
+ if (live.kind !== "none") return live;
455
+ const decision = beforeWriteGate(cwd, paths);
456
+ if (decision.kind === "none") claimAll(owner, targets, deps);
457
+ return decision;
458
+ }
459
+
460
+ /**
461
+ * The write gates on one shell command: the live-session guard on its writes
462
+ * and mutating git commands in the directories they run in, then the
463
+ * before-write gate on its writes. A command that is only
464
+ * `WORKIT_LIVE_SESSION=override <command>` skips the guard and leaves a
465
+ * ledger row. An allowed mutation refreshes the owner's marker.
212
466
  */
213
- export function writeGate(cwd: string, paths: readonly string[] | null): HookDecision {
467
+ export function shellGate(
468
+ cwd: string,
469
+ command: string,
470
+ owner: LiveOwner | null,
471
+ dialect: ShellDialect = "posix",
472
+ deps?: LiveDeps,
473
+ ): HookDecision {
474
+ const writes = shellWrites(command);
475
+ let targets: string[] = [];
476
+ if (owner) {
477
+ try {
478
+ targets = [
479
+ ...segmentTargets(cwd, command, dialect),
480
+ ...gitMutationDirs(cwd, command, dialect).map((dir) => path.join(dir, DIR_TARGET)),
481
+ ];
482
+ } catch {
483
+ targets = [];
484
+ }
485
+ }
486
+ if (owner && targets.length) {
487
+ const live = liveGate(owner, targets, deps);
488
+ if (live.kind !== "none") {
489
+ if (!overridden(command, dialect)) return live;
490
+ noteOverride(cwd, owner, command);
491
+ }
492
+ }
493
+ const decision = writes.writes ? beforeWriteGate(cwd, writes.targets) : NONE;
494
+ if (owner && targets.length && decision.kind === "none") claimAll(owner, targets, deps);
495
+ return decision;
496
+ }
497
+
498
+ /** A ledger row for an override that let a command past the guard (managed repositories only). */
499
+ const noteOverride = (cwd: string, owner: LiveOwner, command: string): void => {
500
+ try {
501
+ if (!inWorkitWorkspace(cwd)) return;
502
+ appendHookObserved(cwd, {
503
+ type: "live_session.override",
504
+ actor: { host: owner.host, session: owner.session, agentId: null },
505
+ session: owner.session,
506
+ command: command.slice(0, 200),
507
+ });
508
+ } catch {
509
+ // A note never blocks the command.
510
+ }
511
+ };
512
+
513
+ function beforeWriteGate(cwd: string, paths: readonly string[] | null): HookDecision {
214
514
  try {
215
515
  const gate = gateFor(cwd);
216
516
  if (!gate || gate.blockers.length === 0) return NONE;