@brainervirus/workit-core 8.13.1 → 8.14.1

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.1",
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
+ };
@@ -33,6 +33,9 @@ const text = z.string().check((ctx) => {
33
33
  ctx.issues.push({ code: "custom", input: ctx.value, message: "invalid Unicode", path: [] });
34
34
  });
35
35
  const id = text.regex(uuidPattern, "expected lowercase UUID");
36
+ /** A full id or an unambiguous lowercase prefix of one (the 8-char short id other surfaces print). */
37
+ export const idRefPattern = /^[0-9a-f][0-9a-f-]{7,35}$/;
38
+ const idRef = text.regex(idRefPattern, "expected a UUID or an id prefix of 8+ characters");
36
39
  const digest = text.regex(digestPattern, "expected lowercase SHA-256 digest");
37
40
  const revision = text.regex(uuidPattern, "expected UUID revision");
38
41
  const validUtc = (value: string): boolean => {
@@ -598,7 +601,7 @@ export const workerReportSchema = z
598
601
  outcome: z.enum(["completed", "failed", "cancelled"]),
599
602
  summary: text,
600
603
  evidenceIds: z.array(id),
601
- findingIds: z.array(id),
604
+ findingIds: z.array(idRef),
602
605
  })
603
606
  .strict();
604
607
  export type WorkerReport = z.infer<typeof workerReportSchema>;
@@ -938,7 +941,7 @@ const findingOperations = {
938
941
  action: z.literal("resolve"),
939
942
  ...taskId,
940
943
  expectedRevision: revision.optional(),
941
- findingId: id,
944
+ findingId: idRef,
942
945
  disposition: z.enum(["open", "fixed", "dismissed", "deferred"]),
943
946
  reason: text,
944
947
  evidenceIds: z.array(id),
@@ -980,7 +983,7 @@ const workerOperations = {
980
983
  ...taskId,
981
984
  expectedRevision: revision.optional(),
982
985
  expectedWorkspaceRevision: revision.optional(),
983
- workerId: id,
986
+ workerId: idRef,
984
987
  report: workerReportSchema,
985
988
  }),
986
989
  cancel: operation({
@@ -988,7 +991,7 @@ const workerOperations = {
988
991
  ...taskId,
989
992
  ...revisions,
990
993
  expectedWorkspaceRevision: revision.optional(),
991
- workerId: id,
994
+ workerId: idRef,
992
995
  reason: text,
993
996
  }),
994
997
  };
@@ -1118,6 +1121,21 @@ export const failure = (
1118
1121
  details: ErrorDetails = {},
1119
1122
  ): Result<never> => ({ ok: false, schemaVersion: 1, code, error, details });
1120
1123
 
1124
+ /**
1125
+ * Resolve a full id or an unambiguous prefix against the ids a task holds.
1126
+ * An exact match always wins; several prefix matches fail listing the candidates.
1127
+ */
1128
+ export function resolveIdRef(kind: string, ref: string, ids: readonly string[]): Result<string> {
1129
+ if (ids.includes(ref)) return success(null, null, ref);
1130
+ const matches = ids.filter((candidate) => candidate.startsWith(ref));
1131
+ if (matches.length === 1) return success(null, null, matches[0]);
1132
+ if (matches.length === 0) return failure("not_found", `${kind} not found`);
1133
+ return failure(
1134
+ "invalid_input",
1135
+ `${kind} prefix ${ref} is ambiguous; candidates: ${matches.join(", ")}`,
1136
+ );
1137
+ }
1138
+
1121
1139
  export function parseOperation(family: OperationFamily, input: unknown): Result<OperationRequest> {
1122
1140
  if (
1123
1141
  typeof input === "object" &&
@@ -15,6 +15,7 @@ import {
15
15
  legacyAssessmentSchema,
16
16
  newId,
17
17
  parseOperation,
18
+ resolveIdRef,
18
19
  rewriteRecordRefs,
19
20
  success,
20
21
  type Candidate,
@@ -391,6 +392,8 @@ const TASK_SCOPED_ACTIONS: ReadonlySet<string> = new Set([
391
392
  "state.export",
392
393
  ]);
393
394
  /** Worker actions that name their worker: they resolve to that worker's task. */
395
+ const ONLY_OWN_REPORT = "workers can submit only their own report";
396
+ const NO_HELPER_CONTROL = "helpers cannot cancel or control workers";
394
397
  const WORKER_ADDRESSED_ACTIONS: ReadonlySet<string> = new Set(["worker.cancel", "worker.report"]);
395
398
  /**
396
399
  * The exact CLI command that runs worker `request` (cancel or report, with a
@@ -1524,6 +1527,13 @@ export class WorkitCore {
1524
1527
  return success(changed.data.revision, null, changed.data.findings.at(-1)!);
1525
1528
  }
1526
1529
  if (helper?.ok) return failure("permission_denied", "helpers cannot resolve findings");
1530
+ const findingRef = resolveIdRef(
1531
+ "finding",
1532
+ input.findingId,
1533
+ task.data.findings.map((entry) => entry.id),
1534
+ );
1535
+ if (!findingRef.ok) return findingRef;
1536
+ input.findingId = findingRef.data;
1527
1537
  const finding = task.data.findings.find((entry) => entry.id === input.findingId);
1528
1538
  if (!finding) return failure("not_found", "finding not found");
1529
1539
  if (!input.reason.trim())
@@ -1738,13 +1748,39 @@ export class WorkitCore {
1738
1748
  );
1739
1749
  }
1740
1750
 
1751
+ // A helper may only address itself: resolving against its own id alone keeps
1752
+ // another worker's id out of any ambiguity error.
1753
+ const workerRef = resolveIdRef(
1754
+ "worker",
1755
+ input.workerId,
1756
+ helperId === null ? task.data.workers.map((candidate) => candidate.id) : [helperId],
1757
+ );
1758
+ if (!workerRef.ok)
1759
+ return helperId === null
1760
+ ? workerRef
1761
+ : failure(
1762
+ "permission_denied",
1763
+ input.action === "report" ? ONLY_OWN_REPORT : NO_HELPER_CONTROL,
1764
+ );
1765
+ input.workerId = workerRef.data;
1741
1766
  const entry = task.data.workers.find((candidate) => candidate.id === input.workerId);
1742
1767
  if (!entry) return failure("not_found", "worker not found");
1743
1768
  if (input.action === "report") {
1744
1769
  if (helperId === null || helperId !== input.workerId)
1745
- return failure("permission_denied", "workers can submit only their own report");
1770
+ return failure("permission_denied", ONLY_OWN_REPORT);
1746
1771
  const helper = this.helperEntry(task.data, true, true);
1747
1772
  if (!helper.ok) return helper;
1773
+ const ownFindings = task.data.findings
1774
+ .filter((candidate) => candidate.provenance.workerId === helperId)
1775
+ .map((candidate) => candidate.id);
1776
+ const reported: string[] = [];
1777
+ for (const findingRef of input.report.findingIds) {
1778
+ const found = resolveIdRef("finding", findingRef, ownFindings);
1779
+ if (found.ok) reported.push(found.data);
1780
+ else if (found.code === "not_found") reported.push(findingRef);
1781
+ else return found;
1782
+ }
1783
+ input.report.findingIds = reported;
1748
1784
  const evidence = task.data.evidence.filter((candidate) =>
1749
1785
  input.report.evidenceIds.includes(candidate.id),
1750
1786
  );
@@ -1800,8 +1836,7 @@ export class WorkitCore {
1800
1836
  : failure("recovery_required", "worker disappeared during report");
1801
1837
  }
1802
1838
 
1803
- if (helperId !== null)
1804
- return failure("permission_denied", "helpers cannot cancel or control workers");
1839
+ if (helperId !== null) return failure("permission_denied", NO_HELPER_CONTROL);
1805
1840
  if (task.data.status === "closed")
1806
1841
  return failure("invalid_transition", "closed task cannot cancel workers");
1807
1842
  if (entry.data.state === "stopped") {
@@ -47,6 +47,8 @@ import {
47
47
  success,
48
48
  taskRecordSchema,
49
49
  workspaceRecordSchema,
50
+ idRefPattern,
51
+ resolveIdRef,
50
52
  type Id,
51
53
  type Intent,
52
54
  type Provenance,
@@ -728,20 +730,23 @@ export class TaskStore {
728
730
  * the branch checked out now (#264). Null when no open task has it.
729
731
  */
730
732
  taskForWorker(workerId: unknown): Result<Id | null> {
731
- if (typeof workerId !== "string" || !validId(workerId))
733
+ if (typeof workerId !== "string" || !idRefPattern.test(workerId))
732
734
  return failure(
733
735
  "invalid_input",
734
736
  "needs a worker id: `workit worker cancel|report <workerId>`",
735
737
  );
736
738
  const listed = this.listTaskIndex();
737
739
  if (!listed.ok) return listed;
740
+ const owners = new Map<string, Id>();
738
741
  for (const entry of listed.data) {
739
742
  if (entry.status === "closed") continue;
740
743
  const loaded = this.load(entry.id);
741
744
  if (!loaded.ok) return loaded;
742
- if (loaded.data?.record.workers.some((worker) => worker.id === workerId))
743
- return success(null, null, entry.id);
745
+ for (const worker of loaded.data?.record.workers ?? []) owners.set(worker.id, entry.id);
744
746
  }
747
+ const resolved = resolveIdRef("worker", workerId, [...owners.keys()]);
748
+ if (resolved.ok) return success(null, null, owners.get(resolved.data) ?? null);
749
+ if (resolved.code !== "not_found") return resolved;
745
750
  return success(null, null, null);
746
751
  }
747
752
 
@@ -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;