@brainervirus/workit-core 8.24.0 → 8.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -166,6 +166,12 @@ const workspaceConfigSchema = z
166
166
  * name or per host family; read leniently (workspaceTierModel).
167
167
  */
168
168
  fanout: z.object({ tierModels: z.unknown().optional() }).passthrough().optional(),
169
+ /**
170
+ * Model and effort per role and host (`models.roles`, core/role-models.ts);
171
+ * read leniently there. The same key at the top of workspaces.json is the
172
+ * global default.
173
+ */
174
+ models: z.object({ roles: z.unknown().optional() }).passthrough().optional(),
169
175
  /** Autonomy grants (D15): only ever set here, by the user. */
170
176
  autonomy: z
171
177
  .object(
@@ -219,7 +225,10 @@ const workspaceConfigSchema = z
219
225
  }
220
226
  });
221
227
  const workspacesFileSchema = z
222
- .object({ workspaces: z.array(workspaceConfigSchema).optional() })
228
+ .object({
229
+ workspaces: z.array(workspaceConfigSchema).optional(),
230
+ models: z.object({ roles: z.unknown().optional() }).passthrough().optional(),
231
+ })
223
232
  .passthrough()
224
233
  .superRefine((file, context) => {
225
234
  const names = new Set<string>();
@@ -788,7 +797,52 @@ export const workspaceDelegation = (cwd: string, dir?: string): DelegationMode =
788
797
  return isDelegation(source?.delegation) ? source.delegation : "inline-first";
789
798
  };
790
799
 
791
- const hostFamily = (host: string): string => host.replace(/_(?:cli|desktop)$/u, "");
800
+ /**
801
+ * Where `models.roles` and `fanout.tierModels` can come from for cwd, nearest
802
+ * first: the selected workspace, each enclosing one (most specific first),
803
+ * then the global default at the top of workspaces.json. Read leniently: an
804
+ * unreadable file reads as no sources.
805
+ */
806
+ export const modelSources = (
807
+ cwd: string,
808
+ dir?: string,
809
+ ): Array<{ name: string; global: boolean; models: unknown; tierModels: unknown }> => {
810
+ const out: Array<{ name: string; global: boolean; models: unknown; tierModels: unknown }> = [];
811
+ try {
812
+ const matched = matchRuntimeWorkspace(cwd, dir);
813
+ if (matched) {
814
+ const enclosing = matched.matches
815
+ .filter((entry) => entry !== matched.selected)
816
+ .toSorted((a, b) => globSpecificity(b.glob) - globSpecificity(a.glob));
817
+ for (const entry of [matched.selected, ...enclosing])
818
+ out.push({
819
+ name: entry.name,
820
+ global: false,
821
+ models: entry.models,
822
+ tierModels: (entry.fanout as { tierModels?: unknown } | undefined)?.tierModels,
823
+ });
824
+ }
825
+ } catch {
826
+ // An unreadable or invalid file: no workspace sources.
827
+ }
828
+ try {
829
+ const document: unknown = JSON.parse(
830
+ readFileSync(path.join(dir ?? configDir(), "workspaces.json"), "utf8"),
831
+ );
832
+ if (document && typeof document === "object" && !Array.isArray(document))
833
+ out.push({
834
+ name: "global",
835
+ global: true,
836
+ models: (document as Record<string, unknown>).models,
837
+ tierModels: undefined,
838
+ });
839
+ } catch {
840
+ // No global default.
841
+ }
842
+ return out;
843
+ };
844
+
845
+ export const hostFamily = (host: string): string => host.replace(/_(?:cli|desktop)$/u, "");
792
846
 
793
847
  const TIER_KEYS = new Set(["mundane", "standard", "hard"]);
794
848
  const TIER_HOSTS = new Set([
@@ -841,39 +895,6 @@ export const workspaceTierModelIssues = (cwd: string, dir?: string): string[] =>
841
895
  return issues;
842
896
  };
843
897
 
844
- /**
845
- * The model `fanout.tierModels` names for a tier on a host, or null (the
846
- * fanout skill's TIER guidance applies). A tier maps to one model name for
847
- * every host, or to `{ <host>: <model> }` keyed by host family (claude_code,
848
- * opencode, codex, cursor, pi) with an optional `default`. Inherited like
849
- * `trailers`, per tier; anything malformed reads as unset.
850
- */
851
- export const workspaceTierModel = (
852
- cwd: string,
853
- tier: string,
854
- host: string | null,
855
- dir?: string,
856
- ): { model: string; workspace: string } | null => {
857
- const pick = (entry: RuntimeEntry): string | null => {
858
- const fanout = entry.fanout as { tierModels?: unknown } | undefined;
859
- const models = fanout?.tierModels;
860
- if (!models || typeof models !== "object" || !Object.hasOwn(models, tier)) return null;
861
- const value = (models as Record<string, unknown>)[tier];
862
- if (typeof value === "string") return value.trim() || null;
863
- if (!value || typeof value !== "object") return null;
864
- const byHost = value as Record<string, unknown>;
865
- const family = host ? hostFamily(host) : null;
866
- const named =
867
- (family && Object.hasOwn(byHost, family) ? byHost[family] : undefined) ??
868
- (host && Object.hasOwn(byHost, host) ? byHost[host] : undefined) ??
869
- (Object.hasOwn(byHost, "default") ? byHost.default : undefined);
870
- return typeof named === "string" && named.trim() ? named.trim() : null;
871
- };
872
- const found = inheritedSetting(cwd, dir, (entry) => pick(entry) !== null);
873
- const model = found?.source ? pick(found.source) : null;
874
- return model && found?.source ? { model, workspace: found.source.name } : null;
875
- };
876
-
877
898
  const resolveRuntimeWorkspaceCandidate = (
878
899
  cwd: string,
879
900
  kind: RuntimeWorkspacePolicy,
@@ -13,14 +13,14 @@
13
13
  // workers export nothing (the host derives the id);
14
14
  // Cursor, CI and plain terminals export `<lead>-w-<slice>`. Once
15
15
  // `workit fanout worktree create` made the slice's worktree, WORKTREE and the
16
- // RULES name it: every git and check command runs as `workit --cwd <worktree> …`.
16
+ // RULES name it: every git and check command runs as `workit <verb> --cwd <worktree> …`.
17
17
  // The lead must have a WORKIT_SESSION_ID: worker ids derive from it. A
18
18
  // worker id longer than the ledger's 128 characters hashes its slice part. The fan-in line follows the plan's `fanIn`:
19
19
  // one PR per slice forbids every merge; integration mode has the worker merge
20
20
  // the integration tip before reporting, and nothing else. It only reads, apart
21
21
  // from hiding `.workit-scratch/` in the repository's info/exclude.
22
22
  import { createHash } from "node:crypto";
23
- import { workspaceTierModel } from "./core/workspaces";
23
+ import { resolveRoleModel } from "./core/role-models";
24
24
  import { activeStanding, readLedger, type LedgerActor } from "./ledger";
25
25
  import { branchRef, fanoutFail, planLeads, type FanoutFile, type FanoutResult } from "./fanout";
26
26
  import {
@@ -44,8 +44,12 @@ export type BriefOutcome = {
44
44
  tier: string;
45
45
  /** Issues the slice's PR links (the brief's ISSUE line). */
46
46
  issues: string[];
47
- /** The model `fanout.tierModels` names for the tier on the lead's host, else null. */
47
+ /** The model `models.roles` (or `fanout.tierModels`) names for the tier on the lead's host, else null. */
48
48
  model: string | null;
49
+ /** Its effort, when one is configured. */
50
+ effort: string | null;
51
+ /** The model and effort the lead spawns this slice's non-author verifier with, else null. */
52
+ verifier: { model: string; effort: string | null } | null;
49
53
  /** Absolute when the slice's worktree exists, else relative to the worker's worktree root. */
50
54
  scratch: string;
51
55
  /** The id a worker exports, or null when its host issues or derives it. */
@@ -96,8 +100,9 @@ const identityLines = (identity: BriefIdentity, session: string): string[] => {
96
100
 
97
101
  /**
98
102
  * RULES. With the slice's worktree made (`worktree`), every git and check
99
- * command names it (`workit --cwd <worktree> …`, one verb per call) and it is
100
- * already on the branch; Workit's hooks keep an implementer inside it (#310).
103
+ * command names it (`workit <verb> --cwd <worktree> …`, verb first so host
104
+ * allow rules match, one verb per call) and it is already on the branch;
105
+ * Workit's hooks keep an implementer inside it (#310).
101
106
  */
102
107
  const rules = (
103
108
  mode: BriefMode,
@@ -105,17 +110,18 @@ const rules = (
105
110
  base: string,
106
111
  worktree: string | null,
107
112
  ): string[] => {
108
- const workit = worktree ? `workit --cwd ${worktree}` : "workit";
113
+ const workit = (verb: string) =>
114
+ worktree ? `workit ${verb} --cwd ${worktree}` : `workit ${verb}`;
109
115
  let first = ` 1. First command: \`workit git branch ${branch} --base ${base}\` (your worktree may start on another name).`;
110
116
  if (worktree)
111
- first = ` 1. Work only in WORKTREE, already on ${branch}${mode === "resume" ? " at its head" : ""}: edit files under it and run each git and check command against it, one verb per call (\`${workit} git commit …\`, \`${workit} check <name>\`), never \`cd … &&\` chains or \`$VAR\` paths. An implementer's change outside it is denied.`;
117
+ first = ` 1. Work only in WORKTREE, already on ${branch}${mode === "resume" ? " at its head" : ""}: edit files under it and run each git and check command against it, one verb per call (\`${workit("git")} commit …\`, \`${workit("check")} <name>\`), never \`cd … &&\` chains or \`$VAR\` paths. An implementer's change outside it is denied.`;
112
118
  else if (mode === "resume")
113
119
  first = ` 1. First command: \`git switch ${branch}\`, then continue from its head.`;
114
120
  return [
115
121
  "RULES:",
116
122
  first,
117
123
  ' 2. Decide ambiguities yourself: `workit ledger ruling "<what>" --why "<why>" --cost-if-wrong "<cost>"`; stop only for an irreversible, security-sensitive or out-of-worktree action.',
118
- ` 3. Commit with \`${workit} git commit -m <msg> -- <paths in SCOPE>\`; push only if this brief says so.`,
124
+ ` 3. Commit with \`${workit("git")} commit -m <msg> -- <paths in SCOPE>\`; push only if this brief says so.`,
119
125
  " 4. Never record a verdict on your own work.",
120
126
  " 5. Run tests, lint, typecheck and builds only through `workit check` (a named check, or `workit check --heavy -- <cmd>`): one machine-wide lease queues every agent, so never a hand-made flock; `workit lock status` shows who holds it.",
121
127
  " 6. A failed review gets one fix round and one recheck. Still failing: stop and report the open finding, never a third round (the lead spawns a decider). A product choice is the user's: report it, do not decide it.",
@@ -183,7 +189,8 @@ export function renderBrief(
183
189
  what: String(row.what),
184
190
  }));
185
191
  const identity = identityOf(input.actor.host);
186
- const model = workspaceTierModel(cwd, slice.tier, input.actor.host);
192
+ const model = resolveRoleModel(cwd, { role: "implementer", tier: slice.tier }, input.actor.host);
193
+ const verifier = resolveRoleModel(cwd, { role: "verifier" }, input.actor.host);
187
194
  const minted = workerSession(sessionSafe(input.actor.session), slice.id, input.attempt);
188
195
  const session = identity === "declared" ? minted : null;
189
196
  const fanIn =
@@ -220,7 +227,14 @@ export function renderBrief(
220
227
  ...(slice.reason ? [`REASON: ${slice.reason}`] : []),
221
228
  `TIER: ${slice.tier}`,
222
229
  ...(model
223
- ? [` model: ${model.model} (fanout.tierModels in workspace ${model.workspace})`]
230
+ ? [
231
+ ` model: ${model.model}${model.effort ? ` effort: ${model.effort}` : ""} (${model.source})`,
232
+ ]
233
+ : []),
234
+ ...(verifier
235
+ ? [
236
+ ` verifier: model ${verifier.model}${verifier.effort ? ` effort ${verifier.effort}` : ""} (${verifier.source})`,
237
+ ]
224
238
  : []),
225
239
  `TIMEBOX: ${slice.timebox ?? DEFAULT_TIMEBOX}; past it without a new commit you will be replaced`,
226
240
  scratchLine,
@@ -249,6 +263,8 @@ export function renderBrief(
249
263
  tier: slice.tier,
250
264
  issues: slice.issues,
251
265
  model: model?.model ?? null,
266
+ effort: model?.effort ?? null,
267
+ verifier: verifier ? { model: verifier.model, effort: verifier.effort } : null,
252
268
  scratch,
253
269
  session,
254
270
  standing,
@@ -18,7 +18,7 @@
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 { workspaceTierModel } from "./core/workspaces";
21
+ import { resolveRoleModel } from "./core/role-models";
22
22
  import { actorFromEnv, checkVerdicts, readLedger, type ReadRow, type VerdictBasis } from "./ledger";
23
23
  import { sliceLandings } from "./fanout-check";
24
24
  import { forgeBreaker, inSliceRun } from "./fanout-landed";
@@ -75,7 +75,7 @@ export type StatusOutcome = {
75
75
  landingOrder: string[];
76
76
  /** Not started; every dependency landed (or, stacked, its parent has an accepted verdict). */
77
77
  spawnable: string[];
78
- /** The model `fanout.tierModels` names for each spawnable slice's TIER on this host. */
78
+ /** The model `models.roles` (or `fanout.tierModels`) names for each spawnable slice's TIER on this host. */
79
79
  spawnModels: Record<string, string>;
80
80
  stuck: string[];
81
81
  next: string;
@@ -288,7 +288,7 @@ export function fanoutStatus(
288
288
  const spawnModels: Record<string, string> = {};
289
289
  for (const id of spawnable) {
290
290
  const tier = plan.slices.find((slice) => slice.id === id)?.tier;
291
- const model = tier ? workspaceTierModel(cwd, tier, host) : null;
291
+ const model = tier ? resolveRoleModel(cwd, { role: "implementer", tier }, host) : null;
292
292
  if (model) spawnModels[id] = model.model;
293
293
  }
294
294
  const spawnList = spawnable
@@ -480,7 +480,7 @@ export function releaseSliceWorktree(
480
480
  return fanoutFail(
481
481
  "blocked",
482
482
  `this command runs inside ${entry.path}`,
483
- `run it from the main checkout: workit --cwd <main checkout> fanout worktree release ${slice.id}`,
483
+ `run it from the main checkout: workit fanout --cwd <main checkout> worktree release ${slice.id}`,
484
484
  );
485
485
  if (entry.branch !== null && entry.branch !== `refs/heads/${slice.branch}`)
486
486
  return fanoutFail(
@@ -35,12 +35,13 @@ import { spawnSync } from "node:child_process";
35
35
  import { currentBranch, fetchRefs, GIT_TIMEOUTS, remoteRefTip, resolveRef } from "../git/rev";
36
36
  import {
37
37
  appendObserved,
38
- checkVerdicts,
39
38
  tamperedUnblock,
40
39
  readLedger,
40
+ type CarryNote,
41
41
  type LedgerActor,
42
42
  type VerdictCheck,
43
43
  } from "../ledger";
44
+ import { checkVerdictsCovered, type StackCover } from "../verdict-cover";
44
45
  import type { LaneNeed, ReviewLane } from "../core/policy/lane";
45
46
  import { laneLabel, prLaneGate } from "../core/policy/pr-lane";
46
47
  import { buildStatusDoc, selectPr, type PrStatusDoc } from "./report";
@@ -379,6 +380,10 @@ export type MergeOutcome = {
379
380
  lane: ReviewLane;
380
381
  need: LaneNeed;
381
382
  verification: VerificationMode;
383
+ /** The stack verdict above this PR that covers it (#334), when it has none of its own. */
384
+ cover?: StackCover | null;
385
+ /** How a verdict on an older head carried to this one over a follow-up change (#334). */
386
+ carry?: CarryNote | null;
382
387
  };
383
388
  /** Set when `--unverified` bypassed the verdict: the reason and its ledger row. */
384
389
  unverified: { reason: string; recorded: string } | null;
@@ -662,7 +667,8 @@ export async function mergePullRequest(
662
667
  }
663
668
  const ledger = readLedger(cwd, { integrity: true });
664
669
  if (!ledger.ok) return ledger;
665
- const check = checkVerdicts(cwd, branch, ledger.value.rows);
670
+ // A lower PR of a stack may be covered by the verdict at its top (#334).
671
+ const check = checkVerdictsCovered(cwd, branch, ledger.value.rows);
666
672
  const gate = prLaneGate(cwd, check, { branch, base: doc.base, head });
667
673
  const summary = {
668
674
  head: check.head,
@@ -681,6 +687,9 @@ export async function mergePullRequest(
681
687
  lane: gate.lane.lane,
682
688
  need: gate.need,
683
689
  verification: gate.verification,
690
+ ...(covered && gate.review === "verified"
691
+ ? { cover: check.cover, carry: check.cover ? null : check.carry }
692
+ : {}),
684
693
  };
685
694
  let unverified: MergeOutcome["unverified"] = null;
686
695
  // The bypass covers a missing verdict, never a rejection.
@@ -0,0 +1,49 @@
1
+ // The default base for patch-ids, author trailers and review lanes. Its own
2
+ // module so the lane (core/policy/lane.ts) and the ledger can both read it
3
+ // without importing each other.
4
+ import { vcsConfig } from "../core/vcs-config";
5
+ import { originHeadRef, resolveRef } from "./rev";
6
+
7
+ const safeRef = (value: string): boolean => value.length > 0 && !value.startsWith("-");
8
+
9
+ /**
10
+ * The PR target of the release track the checkout is on, when the workspace
11
+ * configures tracks (as `fanout plan` and `stack plan` pick their trunk); a
12
+ * plain workspace default does not override origin's default branch.
13
+ */
14
+ const trackTrunk = (cwd: string): string | null => {
15
+ try {
16
+ const resolved = vcsConfig("resolve", cwd);
17
+ if (resolved.ok === false || !resolved.releaseTrack || resolved.releaseTrack.blocking)
18
+ return null;
19
+ const target = String(resolved.defaultTargetBranch ?? "").trim();
20
+ return target && safeRef(target) ? target : null;
21
+ } catch {
22
+ return null;
23
+ }
24
+ };
25
+
26
+ /**
27
+ * The default base for patch-ids and author trailers: the release track's PR
28
+ * target (origin's copy, else the local one) when tracks are configured, else
29
+ * origin's HEAD branch, else the first of origin/main, origin/master, main,
30
+ * master, origin/develop, develop that exists.
31
+ */
32
+ export function defaultBase(cwd: string): string | null {
33
+ const trunk = trackTrunk(cwd);
34
+ if (trunk)
35
+ for (const candidate of [`origin/${trunk}`, trunk])
36
+ if (resolveRef(cwd, candidate)) return candidate;
37
+ const originHead = originHeadRef(cwd);
38
+ if (originHead && resolveRef(cwd, originHead)) return originHead;
39
+ for (const candidate of [
40
+ "origin/main",
41
+ "origin/master",
42
+ "main",
43
+ "master",
44
+ "origin/develop",
45
+ "develop",
46
+ ])
47
+ if (resolveRef(cwd, candidate)) return candidate;
48
+ return null;
49
+ }
package/src/git/rev.ts CHANGED
@@ -515,6 +515,117 @@ export function resolveRef(cwd: string, ref: string): string | null {
515
515
  return line(git(cwd, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]));
516
516
  }
517
517
 
518
+ /** The branch origin's HEAD names (`origin/main`), or null. */
519
+ export function originHeadRef(cwd: string): string | null {
520
+ return line(git(cwd, ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"]));
521
+ }
522
+
523
+ /** The top of the working tree, or null outside one. */
524
+ export function worktreeTop(cwd: string): string | null {
525
+ return line(git(cwd, ["rev-parse", "--show-toplevel"]));
526
+ }
527
+
528
+ /** Whether git tracks `file` (repo-relative) in the index of the checkout at `top`. */
529
+ export function isTracked(top: string, file: string): boolean {
530
+ return git(top, ["ls-files", "--error-unmatch", "--", file]).ok;
531
+ }
532
+
533
+ /**
534
+ * The files whose own change differs between two versions of a branch: the
535
+ * judged `from` (forked from `fromFork`) and the current `to` (forked from
536
+ * `toFork`), compared by exact blob and mode, never by diff text (#334). A
537
+ * file the branch touches on one side only differs. A file it touches on
538
+ * both sides is unchanged only when the base left it alone between the forks
539
+ * and the branch's resulting blob is identical, so a hunk moved within a
540
+ * file, a re-indent or a base edit in a file the branch touches always
541
+ * counts. Null when git cannot read either side: the caller fails closed.
542
+ */
543
+ export function ownChangeDrift(
544
+ cwd: string,
545
+ range: { from: string; to: string; fromFork: string; toFork: string },
546
+ ): string[] | null {
547
+ const before = diffRaw(cwd, range.fromFork, range.from);
548
+ const after = diffRaw(cwd, range.toFork, range.to);
549
+ if (!before || !after) return null;
550
+ const judged = new Map(before.map((entry) => [entry.path, entry]));
551
+ const current = new Map(after.map((entry) => [entry.path, entry]));
552
+ const same = (a: RawEntry | undefined, b: RawEntry | undefined): boolean =>
553
+ a !== undefined &&
554
+ b !== undefined &&
555
+ a.oldMode === b.oldMode &&
556
+ a.oldBlob === b.oldBlob &&
557
+ a.newMode === b.newMode &&
558
+ a.newBlob === b.newBlob;
559
+ return [...new Set([...judged.keys(), ...current.keys()])]
560
+ .filter((file) => !same(judged.get(file), current.get(file)))
561
+ .toSorted();
562
+ }
563
+
564
+ /**
565
+ * Whether the branch's own files are identical at `from` (on `fromBase`) and
566
+ * `to` (on `toBase`): ownChangeDrift from each head's fork is empty. The
567
+ * exactness behind every patch-equal carry; false when git cannot tell.
568
+ */
569
+ export function sameOwnChange(
570
+ cwd: string,
571
+ range: { from: string; to: string; fromBase: string; toBase: string },
572
+ ): boolean {
573
+ const fromFork = mergeBase(cwd, range.fromBase, range.from);
574
+ const toFork = mergeBase(cwd, range.toBase, range.to);
575
+ if (!fromFork || !toFork) return false;
576
+ return ownChangeDrift(cwd, { from: range.from, to: range.to, fromFork, toFork })?.length === 0;
577
+ }
578
+
579
+ /**
580
+ * The verbatim patch-ids of the non-merge commits of `fork..head` (empty
581
+ * commits have none) and how many merge commits the range holds: unlike
582
+ * `--stable`, whitespace counts, so a re-indent is another patch. Diff
583
+ * options are pinned as in patchId. Null when git fails, or has no
584
+ * `patch-id --verbatim`: the caller fails closed.
585
+ */
586
+ export function commitPatchIds(
587
+ cwd: string,
588
+ fork: string,
589
+ head: string,
590
+ ): { patchIds: string[]; merges: number } | null {
591
+ if (!safeArg(fork) || !safeArg(head)) return null;
592
+ const range = `${fork}..${head}`;
593
+ const merges = line(git(cwd, ["rev-list", "--count", "--merges", range]));
594
+ const log = git(
595
+ cwd,
596
+ [
597
+ "log",
598
+ "--no-merges",
599
+ "-p",
600
+ "-U3",
601
+ "--diff-algorithm=myers",
602
+ "--indent-heuristic",
603
+ "--no-color",
604
+ "--no-ext-diff",
605
+ "--no-textconv",
606
+ "--no-renames",
607
+ "--full-index",
608
+ "--src-prefix=a/",
609
+ "--dst-prefix=b/",
610
+ "--format=commit %H",
611
+ range,
612
+ "--",
613
+ ],
614
+ { timeoutMs: GIT_TIMEOUTS.worktree },
615
+ );
616
+ if (merges === null || !log.ok) return null;
617
+ if (!log.stdout.trim()) return { patchIds: [], merges: Number(merges) };
618
+ const ids = git(cwd, ["patch-id", "--verbatim"], { input: log.stdout });
619
+ if (!ids.ok) return null;
620
+ return {
621
+ patchIds: ids.stdout
622
+ .split("\n")
623
+ .map((entry) => entry.trim().split(/\s+/u)[0])
624
+ .filter(Boolean),
625
+ merges: Number(merges),
626
+ };
627
+ }
628
+
518
629
  /** Pinned diff options for the review-lane reads: user config never changes them. */
519
630
  const LANE_DIFF = [
520
631
  "-c",
@@ -1,16 +1,32 @@
1
- // `cd <dir> && workit <args>` → `workit --cwd <dir> <args>` (issue #388).
2
- // On Claude Code the compound does not match a `Bash(workit:*)` allow rule
1
+ // `cd <dir> && workit <args>` → `workit <verb> --cwd <dir> <rest>` (issue #388).
2
+ // On Claude Code the compound does not match a `Bash(workit …)` allow rule
3
3
  // (every subcommand must match, and a `cd` outside the working dirs breaks
4
4
  // it), so the auto-mode classifier judged it. The rewrite is exact and
5
5
  // conservative: one literal directory, one workit call, and only read-only
6
6
  // filters after it. Anything else is left alone. Pure string work.
7
- import { segmentsOf } from "./shell-words";
7
+ //
8
+ // The verb goes first (issue #297): Workit's enumerated host rules, such as
9
+ // `Bash(workit git *)`, cannot match a variable directory before the verb.
10
+ // `--cwd` and `--json` mean the same anywhere before `--` (main.ts
11
+ // parseGlobals), so moving them never changes what runs.
12
+ import { WORKIT_ALLOWED_VERBS } from "./delivery-verbs";
13
+ import { segmentsOf, type Segment } from "./shell-words";
8
14
 
9
15
  // A directory spelled as one literal word: bare, '…' or "…" with no expansion.
10
- const COMPOUND =
11
- /^\s*cd\s+([A-Za-z0-9_./@%+:,=-]+|'[^'$`\\\n]*'|"[^"$`\\!\n]*")\s*&&\s*(workit(?:\s[\s\S]*)?)$/u;
16
+ const DIR = String.raw`[A-Za-z0-9_./@%+:,=-]+|'[^'$${"`"}\\\n]*'|"[^"$${"`"}\\!\n]*"`;
17
+ const COMPOUND = new RegExp(String.raw`^\s*cd\s+(${DIR})\s*&&\s*(workit(?:\s[\s\S]*)?)$`, "u");
18
+ // `workit` then `--json` / `--cwd <dir>` globals, then the verb.
19
+ const GLOBALS_FIRST = new RegExp(
20
+ String.raw`^(\s*workit)((?:\s+(?:--json|--cwd(?:=|\s+)(?:${DIR})))+)\s+([a-z][a-z-]*)(?=\s|$)`,
21
+ "u",
22
+ );
23
+ const VERB_FIRST = /^((?:--json\s+)*)([a-z][a-z-]*)(?=\s|$)/u;
24
+ const VERBS = new Set<string>(WORKIT_ALLOWED_VERBS);
12
25
  const FILTERS = new Set(["tail", "head", "grep"]);
13
26
 
27
+ const clean = (s: Segment): boolean =>
28
+ !s.unanalyzable && !s.group && !s.subs?.length && !s.stdin?.length && !s.redirects.length;
29
+
14
30
  // A filter that reads only stdin, so moving the cwd cannot change what it
15
31
  // reads: flags and counts for tail/head, one pattern for grep (no -r/-R/-f).
16
32
  const stdinOnly = (words: readonly string[]): boolean => {
@@ -22,8 +38,9 @@ const stdinOnly = (words: readonly string[]): boolean => {
22
38
  };
23
39
 
24
40
  /**
25
- * The command rewritten to `workit --cwd <dir> …`, or null when it is not
26
- * exactly `cd <literal dir> && workit … [2>&1] [| tail|head|grep …]*`.
41
+ * The command rewritten to `workit <verb> --cwd <dir> …` (`workit --cwd <dir>
42
+ * …` for a verb no host rule lists), or null when it is not exactly
43
+ * `cd <literal dir> && workit … [2>&1] [| tail|head|grep …]*`.
27
44
  */
28
45
  export const cwdRewrite = (command: string): string | null => {
29
46
  const match = COMPOUND.exec(command);
@@ -36,10 +53,27 @@ export const cwdRewrite = (command: string): string | null => {
36
53
  const [head, ...filters] = segments;
37
54
  if (!head || head.words[0] !== "workit") return null;
38
55
  if (head.words.some((w) => w === "--cwd" || w.startsWith("--cwd="))) return null;
39
- const clean = (s: (typeof segments)[number]) =>
40
- !s.unanalyzable && !s.group && !s.subs?.length && !s.stdin?.length && !s.redirects.length;
41
56
  if (!segments.every(clean)) return null;
42
57
  if (!segments.slice(0, -1).every((s) => s.pipe)) return null;
43
58
  if (!filters.every((s) => FILTERS.has(s.words[0] ?? "") && stdinOnly(s.words))) return null;
44
- return `workit --cwd ${dir} ${rest.slice("workit".length).trimStart()}`.trimEnd();
59
+ const args = rest.slice("workit".length).trimStart();
60
+ const verb = VERB_FIRST.exec(args);
61
+ if (!verb?.[2] || !VERBS.has(verb[2])) return `workit --cwd ${dir} ${args}`.trimEnd();
62
+ const json = verb[1] ? " --json" : "";
63
+ return `workit ${verb[2]} --cwd ${dir}${json}${args.slice(verb[0].length)}`.trimEnd();
64
+ };
65
+
66
+ /**
67
+ * `workit --cwd <dir> <verb> …` (with any `--json` there) rewritten with the
68
+ * verb first, for a verb the host rules list; null otherwise. Only the words
69
+ * before the first verb move, and only in a command that is that one call
70
+ * (no `;`, `&&`, `|`, newline, substitution or redirect).
71
+ */
72
+ export const verbFirst = (command: string): string | null => {
73
+ const match = GLOBALS_FIRST.exec(command);
74
+ const [whole, lead, globals, verb] = match ?? [];
75
+ if (!whole || !verb || !VERBS.has(verb) || !globals?.includes("--cwd")) return null;
76
+ const segments = segmentsOf(command);
77
+ if (segments.length !== 1 || !segments.every(clean)) return null;
78
+ return `${lead} ${verb}${globals}${command.slice(whole.length)}`;
45
79
  };