@brainervirus/workit-core 8.28.1 → 8.29.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/src/check-config.ts +20 -1
- package/src/core/heavy.ts +5 -5
- package/src/fanout-worktree.ts +60 -3
- package/src/hooks/handle.ts +13 -5
- package/src/hooks/raw-git.ts +22 -4
- package/src/host-session.ts +25 -10
package/package.json
CHANGED
package/src/check-config.ts
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
// self-ignoring `.workit/`, never at the repo root. A legacy root
|
|
11
11
|
// `workit.checks.json` is still read when `.workit/checks.json` is absent,
|
|
12
12
|
// with a note that it moved.
|
|
13
|
+
// In a linked worktree (a fanout slice's, an agent's) that has neither
|
|
14
|
+
// file, the main checkout's is read: `.workit/` is git-ignored, so a new
|
|
15
|
+
// worktree never has one, and it shares the main checkout's checks
|
|
16
|
+
// (#426). Configured commands still run from the worktree's own top.
|
|
13
17
|
// 2. Built-in ecosystem defaults detected from the repo: package.json scripts
|
|
14
18
|
// `test`, `lint`, `typecheck`, `check` (`<pm> run <script>`, pm from the
|
|
15
19
|
// lockfile); go.mod → `go test ./...`; Cargo.toml → `cargo test`;
|
|
@@ -216,10 +220,25 @@ function detectedChecks(root: string): NamedCheck[] {
|
|
|
216
220
|
|
|
217
221
|
const GATES = ["testing", "verification"] as const;
|
|
218
222
|
|
|
223
|
+
/**
|
|
224
|
+
* The checks config file for `root`: its own, else (in a linked worktree
|
|
225
|
+
* without one) the main checkout's, else the path a new config belongs at.
|
|
226
|
+
*/
|
|
227
|
+
function configFileFor(root: string): string {
|
|
228
|
+
const own = checksConfigFile(root);
|
|
229
|
+
if (fs.existsSync(own)) return own;
|
|
230
|
+
const common = git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
231
|
+
if (!common || path.basename(common) !== ".git") return own;
|
|
232
|
+
const main = path.dirname(common);
|
|
233
|
+
if (path.resolve(main) === path.resolve(root)) return own;
|
|
234
|
+
const shared = checksConfigFile(main);
|
|
235
|
+
return fs.existsSync(shared) ? shared : own;
|
|
236
|
+
}
|
|
237
|
+
|
|
219
238
|
/** The named checks for the repository containing `cwd` (see the header). */
|
|
220
239
|
export function loadCheckConfig(cwd: string): CheckConfig {
|
|
221
240
|
const root = checkRoot(cwd);
|
|
222
|
-
const file =
|
|
241
|
+
const file = configFileFor(root);
|
|
223
242
|
const legacy = path.basename(file) === LEGACY_CHECKS_FILE;
|
|
224
243
|
const label = legacy ? LEGACY_CHECKS_FILE : CHECKS_FILE;
|
|
225
244
|
const note = legacy
|
package/src/core/heavy.ts
CHANGED
|
@@ -446,8 +446,8 @@ export type HeavyPlan =
|
|
|
446
446
|
| { kind: "rewrite"; command: string; reason: string }
|
|
447
447
|
/** A chain with heavy parts: run each part alone (`unblock` is the first heavy one). */
|
|
448
448
|
| { kind: "split"; reason: string; unblock: string | null }
|
|
449
|
-
/** A host rule names it, or wrapping would raise it: it runs as typed. */
|
|
450
|
-
| { kind: "passthrough"; note:
|
|
449
|
+
/** A host rule names it, or wrapping would raise it: it runs as typed, saying nothing. */
|
|
450
|
+
| { kind: "passthrough"; note: "" };
|
|
451
451
|
|
|
452
452
|
export type PlanOptions = HeavyOptions & {
|
|
453
453
|
/** The host's permission rules; null where the host has none to read (Pi). */
|
|
@@ -476,7 +476,7 @@ export function heavyPlan(command: string, options: PlanOptions): HeavyPlan {
|
|
|
476
476
|
if (!lines) return { kind: "none" };
|
|
477
477
|
const heavy = heavyLines(lines, options);
|
|
478
478
|
if (!heavy.length) return { kind: "none" };
|
|
479
|
-
const simple = simpleCommand(command, dialect);
|
|
479
|
+
const simple = simpleCommand(command, dialect, 0, true);
|
|
480
480
|
const single = simple && !WORKIT.test(baseName(simple[0])) && heavyLines([simple], options)[0];
|
|
481
481
|
const judged = single
|
|
482
482
|
? [{ words: lines.flat(), text: command }]
|
|
@@ -490,7 +490,7 @@ export function heavyPlan(command: string, options: PlanOptions): HeavyPlan {
|
|
|
490
490
|
if (!verdict.ok)
|
|
491
491
|
return {
|
|
492
492
|
kind: "passthrough",
|
|
493
|
-
note:
|
|
493
|
+
note: "",
|
|
494
494
|
};
|
|
495
495
|
}
|
|
496
496
|
if (single) {
|
|
@@ -502,7 +502,7 @@ export function heavyPlan(command: string, options: PlanOptions): HeavyPlan {
|
|
|
502
502
|
const parts = heavy.map((match) => leaseCommand(match.line));
|
|
503
503
|
return {
|
|
504
504
|
kind: "split",
|
|
505
|
-
reason: `heavy_command: \`${command.trim()}\` chains heavy work (${heavy.map((match) => match.reason).join("; ")}) with other commands, and a chain cannot run under the machine-wide heavy-check lease. Run
|
|
505
|
+
reason: `heavy_command: \`${command.trim()}\` chains heavy work (${heavy.map((match) => match.reason).join("; ")}) with other commands, and a chain cannot run under the machine-wide heavy-check lease. Run the heavy part alone, now: \`${parts[0]}\`; then run the other parts as their own commands${parts.length > 1 ? ", each heavy one the same way" : ""}.`,
|
|
506
506
|
unblock: parts[0] ?? null,
|
|
507
507
|
};
|
|
508
508
|
}
|
package/src/fanout-worktree.ts
CHANGED
|
@@ -255,9 +255,56 @@ export type CreateOutcome = {
|
|
|
255
255
|
created: boolean;
|
|
256
256
|
/** node_modules linked from the main checkout (worktree-deps.ts); null when none were. */
|
|
257
257
|
dependencies: LinkedDependencies | null;
|
|
258
|
+
/** The install command to run in the worktree when its lockfile differs from the main checkout's. */
|
|
259
|
+
install: string | null;
|
|
258
260
|
notes: string[];
|
|
259
261
|
};
|
|
260
262
|
|
|
263
|
+
/** Lockfiles in the order a package manager is picked, with its frozen install for a directory. */
|
|
264
|
+
const LOCKFILES: ReadonlyArray<[string, (dir: string) => string]> = [
|
|
265
|
+
["bun.lock", (dir) => `bun install --frozen-lockfile --cwd ${dir}`],
|
|
266
|
+
["bun.lockb", (dir) => `bun install --frozen-lockfile --cwd ${dir}`],
|
|
267
|
+
["pnpm-lock.yaml", (dir) => `pnpm install --frozen-lockfile --dir ${dir}`],
|
|
268
|
+
["yarn.lock", (dir) => `yarn install --frozen-lockfile --cwd ${dir}`],
|
|
269
|
+
["package-lock.json", (dir) => `npm ci --prefix ${dir}`],
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
const readBytes = (file: string): Buffer | null => {
|
|
273
|
+
try {
|
|
274
|
+
return fs.readFileSync(file);
|
|
275
|
+
} catch {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Link the main checkout's node_modules into `worktree` only when every
|
|
282
|
+
* lockfile is byte-identical in both: a stale main checkout would otherwise
|
|
283
|
+
* hand the worktree the wrong dependency versions (#426). When one differs,
|
|
284
|
+
* nothing is linked and `install` is the command the worker runs instead.
|
|
285
|
+
*/
|
|
286
|
+
function worktreeDependencies(
|
|
287
|
+
main: string,
|
|
288
|
+
worktree: string,
|
|
289
|
+
): { linked: LinkedDependencies | null; install: string | null; notes: string[] } {
|
|
290
|
+
for (const [name, install] of LOCKFILES) {
|
|
291
|
+
const ours = readBytes(path.join(main, name));
|
|
292
|
+
const theirs = readBytes(path.join(worktree, name));
|
|
293
|
+
if (ours === null && theirs === null) continue;
|
|
294
|
+
if (ours !== null && theirs !== null && ours.equals(theirs)) continue;
|
|
295
|
+
if (fs.existsSync(path.join(worktree, "node_modules"))) break;
|
|
296
|
+
const command = install(worktree);
|
|
297
|
+
return {
|
|
298
|
+
linked: null,
|
|
299
|
+
install: command,
|
|
300
|
+
notes: [
|
|
301
|
+
`${name} differs from the main checkout's (${main}), so its node_modules was not linked; install dependencies first: ${command}`,
|
|
302
|
+
],
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
return { ...linkWorktreeDependencies(main, worktree), install: null };
|
|
306
|
+
}
|
|
307
|
+
|
|
261
308
|
function ensureScratch(worktree: string): string {
|
|
262
309
|
const scratch = path.join(worktree, SCRATCH_DIR);
|
|
263
310
|
fs.mkdirSync(scratch, { recursive: true });
|
|
@@ -303,7 +350,7 @@ export function createSliceWorktree(
|
|
|
303
350
|
`remove it yourself (git worktree remove ${target}) once you know what it holds`,
|
|
304
351
|
);
|
|
305
352
|
excludeScratch(cwd);
|
|
306
|
-
const deps =
|
|
353
|
+
const deps = worktreeDependencies(mainCheckout(cwd), target);
|
|
307
354
|
return {
|
|
308
355
|
ok: true,
|
|
309
356
|
data: {
|
|
@@ -313,6 +360,7 @@ export function createSliceWorktree(
|
|
|
313
360
|
mode: resolveRef(cwd, localRef) ? "resume" : "new",
|
|
314
361
|
created: false,
|
|
315
362
|
dependencies: deps.linked,
|
|
363
|
+
install: deps.install,
|
|
316
364
|
notes: [
|
|
317
365
|
deps.linked
|
|
318
366
|
? "the worktree already exists; only its missing dependencies were linked"
|
|
@@ -392,7 +440,7 @@ export function createSliceWorktree(
|
|
|
392
440
|
}
|
|
393
441
|
excludeScratch(cwd);
|
|
394
442
|
const scratch = ensureScratch(target);
|
|
395
|
-
const deps =
|
|
443
|
+
const deps = worktreeDependencies(mainCheckout(cwd), target);
|
|
396
444
|
notes.push(...deps.notes);
|
|
397
445
|
const head = gitRun(target, ["rev-parse", "HEAD"]).stdout.trim();
|
|
398
446
|
const baseSha = mode === "new" ? head : null;
|
|
@@ -415,7 +463,16 @@ export function createSliceWorktree(
|
|
|
415
463
|
});
|
|
416
464
|
return {
|
|
417
465
|
ok: true,
|
|
418
|
-
data: {
|
|
466
|
+
data: {
|
|
467
|
+
...base,
|
|
468
|
+
scratch,
|
|
469
|
+
head,
|
|
470
|
+
mode,
|
|
471
|
+
created: true,
|
|
472
|
+
dependencies: deps.linked,
|
|
473
|
+
install: deps.install,
|
|
474
|
+
notes,
|
|
475
|
+
},
|
|
419
476
|
};
|
|
420
477
|
}
|
|
421
478
|
|
package/src/hooks/handle.ts
CHANGED
|
@@ -127,18 +127,26 @@ const subagentStartText = (
|
|
|
127
127
|
const readOnly = `Workit observed ${who} as read-only/agent-guided.`;
|
|
128
128
|
if (role !== "judge" || !event.agentId) return readOnly;
|
|
129
129
|
const own = subagentSession(session, event.agentId);
|
|
130
|
+
const cwd = input.cwd || process.cwd();
|
|
131
|
+
const judge = event.agentType.endsWith("reviewer") ? "reviewer" : "verifier";
|
|
132
|
+
const pick = judge === "verifier" ? ", --kind live when you drove the real surface" : "";
|
|
133
|
+
const branch = judgeBranch(cwd);
|
|
134
|
+
// Codex runs a subagent's shell with its own CODEX_THREAD_ID: the CLI already
|
|
135
|
+
// acts as that session, so the line names no --session and no --token (a
|
|
136
|
+
// token on a command line lands in the rollout, #427) and nothing is issued.
|
|
137
|
+
if (descriptor.host === "codex_cli" || descriptor.host === "codex_desktop")
|
|
138
|
+
return `${readOnly} Its shell already acts as its own Workit session, so the ledger tells its verdicts apart from the author's. Record the verdict with exactly this command (pick the result${pick}, name the candidate branch if it differs, say what you ran): \`${verdictCommand({ branch, judge })}\`. Add no session or token option to it, and never set WORKIT_SESSION_ID by hand.`;
|
|
130
139
|
// `ledger verdict` accepts `<lead>:<agent id>` only when this hook issued it,
|
|
131
|
-
// and only with the token it hands to this subagent alone.
|
|
140
|
+
// and only with the token it hands to this subagent alone. Claude Code
|
|
141
|
+
// subagents share the lead's environment, so no per-agent env var or file
|
|
142
|
+
// can carry it: the token stays on the line (docs/agents/hosts.md, #427).
|
|
132
143
|
// (An isolated judge's text is rebuilt with cwd "": the hook's own cwd then.)
|
|
133
144
|
// Issued once: a replayed SubagentStart for the same id gets no token.
|
|
134
|
-
const cwd = input.cwd || process.cwd();
|
|
135
145
|
const token = session ? recordIssuedSession(cwd, session, own) : null;
|
|
136
146
|
// Already issued: without its token the id is refused (unbound_session).
|
|
137
147
|
if (session && !token)
|
|
138
148
|
return `${readOnly} ${own} was already issued to an earlier subagent, so no token is handed out again and a verdict under it would be refused (unbound_session). Record no verdict as ${own}: spawn a fresh verifier or reviewer, which gets its own session and token.`;
|
|
139
|
-
const
|
|
140
|
-
const line = verdictCommand({ branch: judgeBranch(cwd), judge, session: own, token });
|
|
141
|
-
const pick = judge === "verifier" ? ", --kind live when you drove the real surface" : "";
|
|
149
|
+
const line = verdictCommand({ branch, judge, session: own, token });
|
|
142
150
|
return `${readOnly} Its own Workit session is ${own}, so the ledger tells its verdicts apart from the author's. Record the verdict with exactly this command (pick the result${pick}, name the candidate branch if it differs, say what you ran): \`${line}\`.${token ? " The token is this subagent's alone: never write it into a report, a file or a commit. --token needs a workit CLI at least as new as this plugin; if the CLI answers that --token is an unknown option, update the workit CLI (workit upgrade --cli) and record again." : ""}`;
|
|
143
151
|
};
|
|
144
152
|
|
package/src/hooks/raw-git.ts
CHANGED
|
@@ -727,15 +727,19 @@ export function workitCheckTail(
|
|
|
727
727
|
: { argv: tail, cwd };
|
|
728
728
|
}
|
|
729
729
|
|
|
730
|
+
const OUTPUT_FILTERS = new Set(["tail", "head", "grep"]);
|
|
731
|
+
|
|
730
732
|
/**
|
|
731
733
|
* The one command `command` runs after its wrappers (env prefixes, `timeout`,
|
|
732
734
|
* `nice`, `flock`, `bash -c '<one command>'`), or null when it is a chain,
|
|
733
|
-
* pipe, redirect, substitution, group or heredoc anywhere
|
|
735
|
+
* pipe, redirect, substitution, group or heredoc anywhere (`shaped` allows
|
|
736
|
+
* redirects and one trailing `| tail|head|grep` filter).
|
|
734
737
|
*/
|
|
735
738
|
export function simpleCommand(
|
|
736
739
|
command: string,
|
|
737
740
|
dialect: ShellDialect = "posix",
|
|
738
741
|
depth = 0,
|
|
742
|
+
shaped = false,
|
|
739
743
|
): string[] | null {
|
|
740
744
|
let segments: Segment[];
|
|
741
745
|
try {
|
|
@@ -743,13 +747,27 @@ export function simpleCommand(
|
|
|
743
747
|
} catch {
|
|
744
748
|
return null;
|
|
745
749
|
}
|
|
746
|
-
|
|
750
|
+
// `shaped`: output shaping around the one command is not a chain: any
|
|
751
|
+
// redirects, and at most one trailing `| tail|head|grep …` filter.
|
|
752
|
+
const filter = shaped && segments.length === 2 ? segments[1] : null;
|
|
753
|
+
if (filter) {
|
|
754
|
+
if (
|
|
755
|
+
!segments[0].pipe ||
|
|
756
|
+
!OUTPUT_FILTERS.has(commandName(filter.words[0])) ||
|
|
757
|
+
filter.group ||
|
|
758
|
+
filter.pipe ||
|
|
759
|
+
filter.unanalyzable ||
|
|
760
|
+
filter.subs?.length ||
|
|
761
|
+
filter.stdin?.length
|
|
762
|
+
)
|
|
763
|
+
return null;
|
|
764
|
+
} else if (segments.length !== 1) return null;
|
|
747
765
|
const [segment] = segments;
|
|
748
766
|
if (
|
|
749
767
|
segment.group ||
|
|
750
|
-
segment.pipe ||
|
|
768
|
+
(segment.pipe && !filter) ||
|
|
751
769
|
segment.unanalyzable ||
|
|
752
|
-
segment.redirects.length ||
|
|
770
|
+
(segment.redirects.length && !shaped) ||
|
|
753
771
|
segment.subs?.length ||
|
|
754
772
|
segment.stdin?.length
|
|
755
773
|
)
|
package/src/host-session.ts
CHANGED
|
@@ -381,6 +381,20 @@ export type SessionClaimRefusal = { error: string; unblock: string };
|
|
|
381
381
|
* Anything else is a forged id: a verdict recorded under it would make author
|
|
382
382
|
* is not verifier a naming convention.
|
|
383
383
|
*/
|
|
384
|
+
const isOwnShellClaim = (real: RealSession, claimed: string): boolean =>
|
|
385
|
+
(claimed === real.session && !(real.kind === "declared" && claimed.includes(":"))) ||
|
|
386
|
+
(real.kind === "derived" && claimed.endsWith(`:${real.session}`));
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* `claimed` is the shell's own derived session (OPENCODE_SESSION_ID,
|
|
390
|
+
* PI_SESSION_ID, CODEX_THREAD_ID), alone or as `<lead>:<it>`: a `--session`
|
|
391
|
+
* naming it adds nothing, so the CLI acts as the shell's session instead.
|
|
392
|
+
*/
|
|
393
|
+
export const namesOwnDerivedSession = (env: NodeJS.ProcessEnv, claimed: string): boolean => {
|
|
394
|
+
const real = realHostSession(env);
|
|
395
|
+
return real.kind === "derived" && isOwnShellClaim(real, claimed);
|
|
396
|
+
};
|
|
397
|
+
|
|
384
398
|
export function sessionClaimRefusal(
|
|
385
399
|
env: NodeJS.ProcessEnv,
|
|
386
400
|
claimed: string,
|
|
@@ -397,16 +411,17 @@ export function sessionClaimRefusal(
|
|
|
397
411
|
unblock: `record the verdict from the ${real.host} session's own shell, where ${HOST_SET_VARIABLE[host]} is set`,
|
|
398
412
|
};
|
|
399
413
|
if (real.kind === "none" || !real.session) return null;
|
|
400
|
-
const ownShell =
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
414
|
+
const ownShell = isOwnShellClaim(real, claimed);
|
|
415
|
+
if (ownShell) {
|
|
416
|
+
// A derived session (the host's own shell variable) cannot be replaced by
|
|
417
|
+
// a flag, so naming it is a no-op, not an error: a printed line that
|
|
418
|
+
// carries it still records. Declared sessions keep refusing the flag.
|
|
419
|
+
if (!options.flag || real.kind === "derived") return null;
|
|
420
|
+
return {
|
|
421
|
+
error: `session_flag: --session ${claimed} names this shell's own ${real.host} session; on a host only an issued subagent id is passed as --session`,
|
|
422
|
+
unblock: "drop --session: the shell's own session is already the acting one",
|
|
423
|
+
};
|
|
424
|
+
}
|
|
410
425
|
const issuedClaim = claimed === real.session || claimed.startsWith(`${real.session}:`);
|
|
411
426
|
if (issuedClaim) {
|
|
412
427
|
const binding = issuedBinding(cwd, claimed);
|