@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "8.28.1",
3
+ "version": "8.29.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": [
@@ -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 = checksConfigFile(root);
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: string };
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: `workit: \`${command.trim()}\` is heavy but runs outside the heavy-check lease because a host permission rule names it (or \`workit\` is allowlisted); the host decides.`,
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 each heavy part alone as \`workit check --heavy -- <segment>\` (here: ${parts.map((part) => `\`${part}\``).join("; ")}); run the other parts as their own commands.`,
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
  }
@@ -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 = linkWorktreeDependencies(mainCheckout(cwd), target);
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 = linkWorktreeDependencies(mainCheckout(cwd), target);
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: { ...base, scratch, head, mode, created: true, dependencies: deps.linked, notes },
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
 
@@ -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 judge = event.agentType.endsWith("reviewer") ? "reviewer" : "verifier";
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
 
@@ -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
- if (segments.length !== 1) return null;
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
  )
@@ -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
- (claimed === real.session && !(real.kind === "declared" && claimed.includes(":"))) ||
402
- (real.kind === "derived" && claimed.endsWith(`:${real.session}`));
403
- if (ownShell)
404
- return options.flag
405
- ? {
406
- 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`,
407
- unblock: "drop --session: the shell's own session is already the acting one",
408
- }
409
- : null;
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);