@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 +1 -1
- package/skills/workit-fanout/SKILL.md +4 -4
- package/skills/workit-fanout/references/orchestration.md +4 -2
- package/src/core/live-session.ts +333 -0
- package/src/core/task-contract.ts +22 -4
- package/src/core/task-engine.ts +38 -3
- package/src/core/task-store.ts +8 -3
- package/src/core/workspaces.ts +135 -15
- package/src/fanout-brief.ts +8 -0
- package/src/fanout-status.ts +18 -2
- package/src/hooks/handle.ts +7 -6
- package/src/hooks/index.ts +2 -1
- package/src/hooks/write-gate.ts +303 -3
package/package.json
CHANGED
|
@@ -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.
|
|
29
|
-
Claude Code: the `implementer` agent;
|
|
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.
|
|
9
|
-
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
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" &&
|
package/src/core/task-engine.ts
CHANGED
|
@@ -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",
|
|
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") {
|
package/src/core/task-store.ts
CHANGED
|
@@ -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" || !
|
|
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
|
-
|
|
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
|
|
package/src/core/workspaces.ts
CHANGED
|
@@ -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
|
-
*
|
|
700
|
-
*
|
|
701
|
-
*
|
|
702
|
-
*
|
|
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
|
-
|
|
716
|
+
const inheritedSetting = (
|
|
707
717
|
cwd: string,
|
|
708
|
-
dir
|
|
709
|
-
|
|
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
|
|
725
|
+
return null;
|
|
715
726
|
}
|
|
716
|
-
if (!matched) return
|
|
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
|
-
|
|
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:
|
|
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 = (
|
package/src/fanout-brief.ts
CHANGED
|
@@ -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,
|
package/src/fanout-status.ts
CHANGED
|
@@ -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 {
|
|
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 ${
|
|
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,
|
package/src/hooks/handle.ts
CHANGED
|
@@ -26,7 +26,8 @@ import {
|
|
|
26
26
|
withContextLine,
|
|
27
27
|
} from "./skill-nudge";
|
|
28
28
|
import { stopDecision } from "./stop";
|
|
29
|
-
import {
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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 {
|
package/src/hooks/index.ts
CHANGED
|
@@ -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";
|
package/src/hooks/write-gate.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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;
|