@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "8.24.0",
3
+ "version": "8.26.0",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -25,8 +25,8 @@ units of ~100+ lines each. Coupled work stays with one owner. Workit never spawn
25
25
  Never hand-edit 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. Model: the brief's `model:` line, else TIER
29
- (`references/orchestration.md`). Claude Code: the `implementer` agent, never
28
+ each one finishes. Model: the brief's `model:` and `effort:` lines (`workit models show`),
29
+ else TIER (`references/orchestration.md`). Claude Code: the `implementer` agent, never
30
30
  with `isolation: "worktree"`; Workit's hooks confine it to its WORKTREE.
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
@@ -40,12 +40,12 @@ units of ~100+ lines each. Coupled work stays with one owner. Workit never spawn
40
40
  chain resumes. A fix round and recheck that both fail: a decider, not a
41
41
  third round; product choices go to the user (`references/orchestration.md`).
42
42
  7. **Verify by the workspace `verification` setting** (`workit grant show`).
43
- A report is a pointer, never evidence. `proportional` (default): each slice's lane (`workit pr status`) decides; none needs no verdict, self the author's `--self`, focused (cheap model: Claude Code `model: sonnet`) and full a verifier after the last push. `self`: a verifier that wrote none
43
+ A report is a pointer, never evidence. `proportional` (default): each slice's lane (`workit pr status`) decides; none needs no verdict, self the author's `--self`, focused and full a verifier (Claude Code: the `model` and `effort` of `workit models show --role verifier`) after the last push; one verdict at a stack's top covers its unchanged lower PRs, and a stale one with `reverify: focused` re-checks only the new diff, reusing green CI. `self`: a verifier that wrote none
44
44
  of the slices, or you if you wrote none. `independent` or high risk: a
45
45
  separate verifier session, never yours (doctrine: the ledger only refuses
46
46
  authors). One verifier may take a batch of slices, one verdict per branch
47
47
  (`workit ledger verdict <result> --branch <b> --how "<evidence>"`), in a
48
- session never chosen by the author. A review panel on separate models only at high risk.
48
+ session never chosen by the author. Spawn it with the brief's `verifier:` model (`workit models show --role verifier`). A review panel on separate models only at high risk.
49
49
  8. **Fan in** with `workit fanout check`: out-of-scope files (any file outside
50
50
  it stops the fan-in) and `git merge-tree` conflicts with trunk and between
51
51
  siblings. Fix what it names until it exits 0; a merged slice reads as
@@ -25,7 +25,7 @@ REPORT: branch, head SHA, files changed, each VERIFY command with its exit code,
25
25
  each ACCEPTANCE line met / not met, rulings you made (`workit ledger ruling`),
26
26
  anything out of scope as a follow-up, not a diff.
27
27
  STANDING: <the standing orders, verbatim: `workit ledger standing add` records each one>
28
- WORKTREE: <the path `workit fanout worktree create <slice>` made; the worker runs `workit --cwd <it> …` there>
28
+ WORKTREE: <the path `workit fanout worktree create <slice>` made; the worker runs `workit <verb> --cwd <it> …` there>
29
29
  <identity line, per the lead's host: see "Identity and git rules per host" below>
30
30
  ```
31
31
 
@@ -114,7 +114,7 @@ lead's host (`WORKIT_HOST`, or the host's own session variable):
114
114
 
115
115
  Once `workit fanout worktree create <slice>` made the worktree, the brief's
116
116
  WORKTREE line names it and RULES 1 and 3 run against it on every host:
117
- `workit --cwd <worktree> git commit …`, `workit --cwd <worktree> check <name>`,
117
+ `workit git --cwd <worktree> commit …`, `workit check --cwd <worktree> <name>`,
118
118
  one verb per call. The worktree is already on the branch (MODE new or resume),
119
119
  so no branch command comes first. Without one, `MODE: resume` is
120
120
  `git switch <branch>` (the branch exists), then continue from its head. A race
@@ -124,13 +124,13 @@ attempt (`--attempt <n>`) follows its host's row with the branch
124
124
  ## Worker rules (`workit fanout brief` appends them as RULES)
125
125
 
126
126
  1. Work only in WORKTREE, already on the branch, one verb per call against it
127
- (`workit --cwd <worktree> …`); Workit's hooks deny an implementer's change
127
+ (`workit <verb> --cwd <worktree> …`); Workit's hooks deny an implementer's change
128
128
  outside it. With no worktree made yet: create the branch (`MODE: new`) or
129
129
  `git switch` to it (`MODE: resume`), as the table above says.
130
130
  2. Decide ambiguities yourself and record them:
131
131
  `workit ledger ruling "<what>" --why "<why>" --cost-if-wrong "<cost>"`.
132
132
  Stop only for an irreversible action, a security-sensitive one, or a side
133
133
  effect outside the worktree.
134
- 3. Commit with `workit --cwd <worktree> git commit -m "<msg>" -- <paths in SCOPE>`;
134
+ 3. Commit with `workit git --cwd <worktree> commit -m "<msg>" -- <paths in SCOPE>`;
135
135
  push only if the brief says so.
136
136
  4. Never record a verdict on your own work.
@@ -5,22 +5,31 @@ 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. 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:
12
-
13
- | TIER | Claude Code (`model` on the Agent call) | Hosts without a per-spawn model |
14
- | --- | --- | --- |
15
- | scouting (read-only search, not a slice) | `haiku` | host default |
16
- | mundane | `sonnet` | host default |
17
- | standard | omit it (inherits yours) | host default |
18
- | hard | omit it (inherits yours); consider a race | host default |
19
-
20
- OpenCode takes a subagent's model from its agent config: pick a configured
21
- agent whose model fits the TIER, else the default. Codex, Cursor and Pi run
22
- every worker on the host's model, so TIER only sizes the timebox and decides
23
- whether to race.
8
+ TIER is a field of each slice and of its brief. Role models come from the
9
+ workspace (`models.roles`; `fanout.tierModels` still feeds the implementer
10
+ tiers) and, on Claude Code, built-in defaults: the brief has a `model:` line
11
+ (with `effort:` when set) under TIER and a `verifier:` line; `workit fanout
12
+ status` names the model next to each spawnable slice; `workit models show
13
+ [--role r] [--host h] --json` lists every role (decider, implementer tiers,
14
+ verifier, reviewer, explore, babysit). Pass exactly that `model` (and `effort`
15
+ where the host's spawn call takes one) on the spawn call: a subagent that
16
+ passes none inherits your model. Scouting and PR babysitting use the `explore`
17
+ and `babysit` roles. A role with no model (other hosts without config) runs
18
+ on the host's model, and TIER only sizes the timebox and decides whether to
19
+ race. Claude Code's built-in defaults, when nothing is configured:
20
+
21
+ | Role or TIER | `model` on the Agent call |
22
+ | --- | --- |
23
+ | decider | `fable`, effort `high` |
24
+ | hard | `opus` |
25
+ | standard | `sonnet` |
26
+ | mundane | `haiku` |
27
+ | verifier | `sonnet` |
28
+ | reviewer | `opus` |
29
+ | explore, babysit | `haiku` |
30
+
31
+ OpenCode takes a subagent's model from its agent config: pick a
32
+ configured agent whose model fits the TIER, else the default.
24
33
 
25
34
  ## Rolling window
26
35
 
@@ -53,7 +62,7 @@ reversibility (the option cheapest to undo wins a tie), and the exact change
53
62
  list. The fixer applies that list once, then one focused recheck follows.
54
63
 
55
64
  - Hard call (shared contract, security, migration): two deciders on different
56
- models (Claude Code: a different `model` on the Agent call; OpenCode: a
65
+ models (Claude Code: the `decider` role model, the other a different `model` on the Agent call; OpenCode: a
57
66
  child session on another configured agent; Codex, Cursor and Pi: one
58
67
  decider, since the host picks no model). If they disagree, you take the
59
68
  more reversible option.
@@ -40,7 +40,7 @@ description: Build a requested change in small verified steps - follow local pat
40
40
  status`). Lane none: no verdict. Lane self: after `workit check test` passes,
41
41
  record `workit ledger verdict tests-verified --self --how "<what you ran>"`;
42
42
  it reads self-reviewed, never verified, and is enough. Focused, full, high
43
- risk or `verification: self|independent` merges: one fresh verifier per PR after the last push, its own host-derived session (Claude Code: the `verifier` agent, `model: sonnet` when focused; Codex:
43
+ risk or `verification: self|independent` merges: one fresh verifier per PR after the last push, its own host-derived session (Claude Code: the `verifier` agent with the `model` (and `effort`) of `workit models show --role verifier`; Codex:
44
44
  the `verifier` agent; OpenCode: a child session; Pi: a supervised worker),
45
45
  never a hand-minted id. Before saying done, reconcile
46
46
  every named deliverable against the target checkout and observe it (for a push:
@@ -9,13 +9,16 @@ Review the candidate, never the author's summary of it. A session that wrote a
9
9
  commit on the branch cannot record a passing verdict: if that is you, hand the
10
10
  review to a fresh agent with its own host-derived session: Claude Code and
11
11
  Codex, the `reviewer` or `verifier` agent (`--session` and `--token` from SubagentStart);
12
- OpenCode, a child session; Pi, a supervised worker. Never hand-mint one such
12
+ OpenCode, a child session; Pi, a supervised worker. On Claude Code pass the
13
+ `model` and `effort` of `workit models show --role <verifier|reviewer>`. Never hand-mint one such
13
14
  as `<lead>-v1`: it is refused as `forged_session`.
14
15
  An author's `--self` verdict reads self-reviewed and never counts as
15
16
  independent; `type-check-only` never proves a behavior change.
16
17
  Depth follows the review lane in `workit pr status`: focused reads the diff and
17
18
  runs its tests; full also drives the real surface (verify-<app>). Review once
18
19
  per PR after its last push; a second pass on an unchanged head is waste.
20
+ A stale verdict showing `reverify: focused (<files>)` in `workit pr status`: re-check only
21
+ those files since the judged head and reuse green CI there (no fresh install, no full suite).
19
22
 
20
23
  1. Pin the candidate: `git rev-parse HEAD`, the base, `git diff <base>...HEAD`;
21
24
  for a PR, `workit pr status --json` (checks, unresolved threads).
@@ -51,7 +51,7 @@ the same check. Fix round and recheck both failed: a decider (workit-fanout).
51
51
  allows; no sleep loop. Red: read `logTail`, classify. Flake or infra: one
52
52
  `workit ci rerun --failed --reason flake|infra` per head. Real: reproduce
53
53
  with `workit check`, fix the root cause, batch every fix in one push.
54
- 6. **Verified.** The lane in `workit pr status` decides: none, no verdict; self, your `--self` verdict; focused or full (or `verification: self|independent`), one non-author verifier per PR after the last push, never per push (workit-review), on Claude Code `model: sonnet` when focused. Land only when
54
+ 6. **Verified.** The lane in `workit pr status` decides: none, no verdict; self, your `--self` verdict; focused or full (or `verification: self|independent`), one non-author verifier per PR after the last push, never per push (workit-review), on Claude Code with the `model` and `effort` of `workit models show --role verifier`. A docs-only push (or test-only under a unit verdict) keeps the verdict (`carried`); `reverify: focused` re-checks only the new diff, reusing green CI; one verdict at a stack's top covers its unchanged lower PRs. Land only when
55
55
  granted: `workit stack land` or `workit pr merge`; `--unverified --reason` if user-asked.
56
56
  7. **Observe it landed:** `workit verify-delivery pr` or `merge`. After a failed
57
57
  verdict or a check red 3+ times, at any endpoint: offer `/wk-retro` in one
@@ -125,7 +125,7 @@ Autonomy contract:
125
125
  CLI first: if a step has one right answer, use the \`workit\` verb instead of
126
126
  hand-running git, gh or glab (\`workit git|pr|ci|check|help\`;
127
127
  else \`npx -y @brainervirus/workit-cli\`).
128
- Other checkout: \`workit --cwd <dir> <verb>\` (Codex: \`<verb> --cwd <dir>\`); one workit
128
+ Other checkout: \`workit <verb> --cwd <dir> …\` (verb first: host rules match it); one workit
129
129
  verb per shell call, never \`cd <dir> && workit\`. zsh-safe: no \`echo =====\`
130
130
  (\`printf '%s\\n' ---\`), quote globs and URLs, no variable named \`status\` or \`path\`.
131
131
  Add Co-Authored-By via \`workit git commit --trailer "K: v" -- <paths>\`, never
@@ -137,7 +137,7 @@ authorizes an effect. Judge work once with
137
137
  \`workit policy assess --judge risk= behavior= product-choice= plan=\`.
138
138
  Across repositories, bind items to checkout, branch and endpoint, resolve
139
139
  competing targets before a mutation, and reconcile an uncertain
140
- external effect before a retry.
140
+ effect before a retry.
141
141
 
142
142
  Mid-task: never silently drop or resume an objective (workit-continue).
143
143
 
@@ -0,0 +1,74 @@
1
+ // Verdict carry-over across a follow-up change (#334, decision mvk5q3io): a
2
+ // verdict judged head H; the branch is now at H'. The branch's own change
3
+ // since H (what came from the base left out, see lane.ts driftFiles) is
4
+ // classified with the review lane, and the verdict still applies when:
5
+ //
6
+ // lane-none that change is lane `none`: docs, comment or formatting-only
7
+ // edits, generated copies (by the base's .gitattributes), or
8
+ // nothing at all (an empty commit);
9
+ // tests-only the verdict is kind `unit` and the change is lane `self`
10
+ // (small) with every behavior file a test: test files off risk
11
+ // paths, no lockfile, no binary.
12
+ //
13
+ // Everything else is stale, failing closed: a behavior or risk-path change,
14
+ // an opaque file, an unreadable diff, a judged head git no longer has. A
15
+ // stale verdict whose change is lane `self` (small, no risk path, nothing
16
+ // opaque) asks only a focused re-verify of the changed files.
17
+ import { mergeBase, fileAt } from "../../git/rev";
18
+ import { computeLane, driftFiles, kindOf, parseRiskPaths, RISK_PATHS_FILE } from "./lane";
19
+
20
+ export type CarryRule = "lane-none" | "tests-only";
21
+
22
+ export type Drift =
23
+ | { carries: true; rule: CarryRule; since: string; files: string[] }
24
+ | {
25
+ carries: false;
26
+ since: string;
27
+ /** The changed files, or null when they cannot be read. */
28
+ files: string[] | null;
29
+ /** A focused re-verify of `files` is enough (lane self). */
30
+ focused: boolean;
31
+ reason: string;
32
+ };
33
+
34
+ const TEST_ONLY_KINDS: ReadonlySet<string> = new Set(["doc", "cosmetic", "generated", "test"]);
35
+
36
+ /**
37
+ * Whether a `kind` verdict at `from` (keyed against `fromBase`, a commit)
38
+ * still applies at `to` (against `toBase`).
39
+ */
40
+ export function verdictDrift(
41
+ cwd: string,
42
+ input: { from: string; to: string; fromBase: string; toBase: string; kind: string },
43
+ ): Drift {
44
+ const since = input.from;
45
+ const stale = (reason: string, files: string[] | null = null, focused = false): Drift => ({
46
+ carries: false,
47
+ since,
48
+ files,
49
+ focused,
50
+ reason,
51
+ });
52
+ const fromFork = mergeBase(cwd, input.fromBase, input.from);
53
+ const toFork = mergeBase(cwd, input.toBase, input.to);
54
+ const files =
55
+ fromFork && toFork
56
+ ? driftFiles(cwd, { from: input.from, to: input.to, fromFork, toFork })
57
+ : null;
58
+ if (!fromFork || !toFork || !files)
59
+ return stale(`the change since ${since.slice(0, 12)} cannot be read`);
60
+ // Repo risk paths from both base revisions: they only tighten.
61
+ const riskPaths = [fromFork, toFork].flatMap((fork) =>
62
+ parseRiskPaths(fileAt(cwd, fork, RISK_PATHS_FILE) ?? ""),
63
+ );
64
+ const lane = computeLane({ files, riskPaths });
65
+ const paths = files.map((file) => file.path);
66
+ if (lane.lane === "none") return { carries: true, rule: "lane-none", since, files: paths };
67
+ if (
68
+ input.kind === "unit" &&
69
+ lane.lane === "self" &&
70
+ files.every((file) => TEST_ONLY_KINDS.has(kindOf(file)))
71
+ )
72
+ return { carries: true, rule: "tests-only", since, files: paths };
73
+ return stale(lane.reasons[0] ?? `lane ${lane.lane}`, paths, lane.lane === "self");
74
+ }
@@ -25,15 +25,18 @@ import {
25
25
  diffNumstat,
26
26
  diffRaw,
27
27
  fileAt,
28
+ isTracked,
28
29
  mergeBase,
30
+ ownChangeDrift,
29
31
  readBlobs,
30
32
  resolveRef,
31
33
  treeFilesNamed,
32
34
  untrackedFiles,
35
+ worktreeTop,
33
36
  } from "../../git/rev";
34
37
  import type { VerificationMode } from "../../autonomy";
35
- import { git } from "../../git/ops";
36
- import { defaultBase, type VerdictCheck } from "../../ledger";
38
+ import { defaultBase } from "../../git/default-base";
39
+ import type { VerdictCheck } from "../../ledger";
37
40
  import { generatedByAttributes } from "./attributes";
38
41
  import { cosmeticChange, hasOpaqueLineBreak } from "./substance";
39
42
 
@@ -117,14 +120,21 @@ const LOCKFILES = new Set([
117
120
  "composer.lock",
118
121
  ]);
119
122
 
120
- /** Docs are told by extension only: code under `docs/` is code. */
121
- const DOC_EXT = /\.(?:md|mdx|markdown|rst|adoc|txt)$/iu;
123
+ /**
124
+ * Docs are told by prose extension only: code under `docs/` is code. `.txt`
125
+ * is not a doc: requirements.txt, CMakeLists.txt or robots.txt change behavior.
126
+ */
127
+ const DOC_EXT = /\.(?:md|mdx|markdown|rst|adoc)$/iu;
122
128
  const DOC_NAMES = /^(?:license|licence|notice|authors|changelog|changes|contributors)$/iu;
123
129
  /** Markdown that steers agents is behavior, not docs. */
124
130
  const INSTRUCTIONS =
125
131
  /(?:^|\/)(?:AGENTS|CLAUDE|GEMINI|SKILL)\.md$|(?:^|\/)(?:skills|agents|commands|rules|prompts)\/[^/]+/iu;
126
- const TEST_PATH =
127
- /(?:^|\/)(?:test|tests|__tests__|spec|specs|fixtures)\/|\.(?:test|spec)\.[^/]+$/iu;
132
+ /**
133
+ * A test file: under a `test/`, `tests/` or `__tests__/` folder, or named
134
+ * `*.test.*`, `*.spec.*` or `*_test.*`. A `fixtures/` or `spec/` folder can
135
+ * hold runtime data or code, so it is not a test path (it reads as behavior).
136
+ */
137
+ const TEST_PATH = /(?:^|\/)(?:test|tests|__tests__)\/|\.(?:test|spec)\.[^/]+$|_test\.[^/]+$/iu;
128
138
 
129
139
  export type ChangedFile = {
130
140
  path: string;
@@ -156,7 +166,7 @@ export type LaneAssessment = {
156
166
  riskFiles: string[];
157
167
  };
158
168
 
159
- type FileKind = "doc" | "cosmetic" | "generated" | "lockfile" | "test" | "behavior";
169
+ export type FileKind = "doc" | "cosmetic" | "generated" | "lockfile" | "test" | "behavior";
160
170
 
161
171
  const basename = (file: string): string => file.slice(file.lastIndexOf("/") + 1);
162
172
 
@@ -165,7 +175,7 @@ const isDoc = (file: string): boolean =>
165
175
  !INSTRUCTIONS.test(file) && (DOC_EXT.test(file) || DOC_NAMES.test(basename(file)));
166
176
 
167
177
  /** What a file off risk paths is; risk paths are decided before (see computeLane). */
168
- function kindOf(file: ChangedFile): FileKind {
178
+ export function kindOf(file: ChangedFile): FileKind {
169
179
  if (file.binary) return "behavior";
170
180
  if (file.generated) return "generated";
171
181
  if (LOCKFILES.has(basename(file.path))) return "lockfile";
@@ -360,7 +370,13 @@ function generatedAt(cwd: string, fork: string, paths: readonly string[]): Set<s
360
370
  const forkOf = (cwd: string, base: string, head: string | null): string | null =>
361
371
  mergeBase(cwd, base, head ?? "HEAD");
362
372
 
363
- function laneFiles(cwd: string, fork: string, head: string | null): ChangedFile[] | null {
373
+ /** The files of `fork..head`; generated copies are read from `attributesAt` (the base revision). */
374
+ function laneFiles(
375
+ cwd: string,
376
+ fork: string,
377
+ head: string | null,
378
+ attributesAt: string = fork,
379
+ ): ChangedFile[] | null {
364
380
  const stats = diffNumstat(cwd, fork, head);
365
381
  const cosmetic = stats ? cosmeticFiles(cwd, fork, head) : null;
366
382
  if (!stats || !cosmetic) return null;
@@ -374,7 +390,7 @@ function laneFiles(cwd: string, fork: string, head: string | null): ChangedFile[
374
390
  ];
375
391
  const generated = generatedAt(
376
392
  cwd,
377
- fork,
393
+ attributesAt,
378
394
  files.map((entry) => entry.path),
379
395
  );
380
396
  return files.map((entry) => ({
@@ -383,6 +399,41 @@ function laneFiles(cwd: string, fork: string, head: string | null): ChangedFile[
383
399
  }));
384
400
  }
385
401
 
402
+ /**
403
+ * The files whose own change differs between two versions of a branch: the
404
+ * judged `from` (forked from `fromFork`) and the current `to` (forked from
405
+ * `toFork`). Generated copies are read from `toFork` only.
406
+ *
407
+ * - Same fork: the plain `from..to` diff, comments and formatting read as
408
+ * in the lane.
409
+ * - Moved fork (a rebase): each file the branch touches is compared by exact
410
+ * blob (see ownChangeDrift): it counts when the branch's resulting file
411
+ * differs, or when the base changed it too between the forks. Their lines
412
+ * come from `from..to`, which may include base edits (over-counting is the
413
+ * safe direction), and none is cosmetic.
414
+ *
415
+ * Null when git cannot read either side.
416
+ */
417
+ export function driftFiles(
418
+ cwd: string,
419
+ range: { from: string; to: string; fromFork: string; toFork: string },
420
+ ): ChangedFile[] | null {
421
+ if (range.fromFork === range.toFork) return laneFiles(cwd, range.from, range.to, range.toFork);
422
+ const drifted = ownChangeDrift(cwd, range);
423
+ const stats = drifted ? diffNumstat(cwd, range.from, range.to) : null;
424
+ if (!drifted || !stats) return null;
425
+ const byPath = new Map(stats.map((entry) => [entry.path, entry]));
426
+ const generated = generatedAt(cwd, range.toFork, drifted);
427
+ return drifted.map((file) => ({
428
+ path: file,
429
+ added: byPath.get(file)?.added ?? 0,
430
+ deleted: byPath.get(file)?.deleted ?? 0,
431
+ ...(byPath.get(file)?.binary ? { binary: true } : {}),
432
+ cosmetic: false,
433
+ generated: generated.has(file),
434
+ }));
435
+ }
436
+
386
437
  /**
387
438
  * The changed files of `merge-base(base, head)..head`, or with `head` null
388
439
  * of the working tree (tracked changes plus untracked files) since
@@ -455,11 +506,9 @@ export function branchLane(
455
506
  * it is committed there. Null when there is nothing to warn about.
456
507
  */
457
508
  export function untrackedRiskPathsWarning(cwd: string): string | null {
458
- const top = git(cwd, ["rev-parse", "--show-toplevel"]);
459
- const root = top.ok ? top.stdout.trim() : "";
509
+ const root = worktreeTop(cwd);
460
510
  if (!root || !existsSync(path.join(root, RISK_PATHS_FILE))) return null;
461
- const tracked = git(root, ["ls-files", "--error-unmatch", "--", RISK_PATHS_FILE]);
462
- return tracked.ok
511
+ return isTracked(root, RISK_PATHS_FILE)
463
512
  ? null
464
513
  : `${RISK_PATHS_FILE} is not committed: it does not apply until it is committed on the base branch`;
465
514
  }
@@ -0,0 +1,209 @@
1
+ // Role models (#395): the model and effort a role runs on, per host.
2
+ //
3
+ // `models.roles` in workspaces.json (top level = global default, per
4
+ // workspace = override, nested workspaces inherit like `fanout.tierModels`):
5
+ //
6
+ // { "decider": { "claude_code": { "model": "fable", "effort": "high" } },
7
+ // "implementer": { "hard": { "claude_code": "opus" }, "standard": … },
8
+ // "verifier": { "claude_code": "sonnet", "default": "…" } }
9
+ //
10
+ // A role maps to a per-host object (keys: claude_code, opencode, codex,
11
+ // cursor, pi, default) whose values are a model name or `{ model, effort? }`.
12
+ // A bare model name or `{ model, effort? }` applies to every host. The
13
+ // implementer takes one such value per fanout tier. `fanout.tierModels` is
14
+ // read as the implementer tiers (a model name per tier and host, no effort);
15
+ // within one workspace `models.roles` wins over it. Everything is read
16
+ // leniently: a malformed entry is skipped and reported (`roleModelIssues`),
17
+ // never an error. Claude Code has built-in defaults; other hosts inherit
18
+ // their own default model when nothing is configured.
19
+ import { hostFamily, modelSources } from "./workspaces";
20
+
21
+ export const MODEL_ROLES = [
22
+ "decider",
23
+ "implementer",
24
+ "verifier",
25
+ "reviewer",
26
+ "explore",
27
+ "babysit",
28
+ ] as const;
29
+ export type ModelRole = (typeof MODEL_ROLES)[number];
30
+ export const MODEL_TIERS = ["mundane", "standard", "hard"] as const;
31
+ export type ModelTier = (typeof MODEL_TIERS)[number];
32
+
33
+ export type RoleSelector = { role: ModelRole; tier?: ModelTier };
34
+ export type RoleModel = { model: string; effort: string | null; source: string };
35
+
36
+ const HOST_KEYS = new Set([
37
+ "claude_code",
38
+ "opencode",
39
+ "codex",
40
+ "codex_cli",
41
+ "codex_desktop",
42
+ "cursor",
43
+ "pi",
44
+ "default",
45
+ ]);
46
+
47
+ const BUILT_IN_CLAUDE_CODE: Record<string, { model: string; effort?: string }> = {
48
+ decider: { model: "fable", effort: "high" },
49
+ "implementer.hard": { model: "opus" },
50
+ "implementer.standard": { model: "sonnet" },
51
+ "implementer.mundane": { model: "haiku" },
52
+ verifier: { model: "sonnet" },
53
+ reviewer: { model: "opus" },
54
+ explore: { model: "haiku" },
55
+ babysit: { model: "haiku" },
56
+ };
57
+
58
+ const isObject = (value: unknown): value is Record<string, unknown> =>
59
+ !!value && typeof value === "object" && !Array.isArray(value);
60
+ const name = (value: unknown): string | null =>
61
+ typeof value === "string" && value.trim() ? value.trim() : null;
62
+
63
+ /** `"model"` or `{ model, effort? }` as a model and effort; null when it names no model. */
64
+ const leaf = (value: unknown): { model: string; effort: string | null } | null => {
65
+ const text = name(value);
66
+ if (text) return { model: text, effort: null };
67
+ if (!isObject(value)) return null;
68
+ const model = name(value.model);
69
+ return model ? { model, effort: name(value.effort) } : null;
70
+ };
71
+
72
+ const isFlat = (value: unknown): boolean =>
73
+ typeof value === "string" || (isObject(value) && Object.hasOwn(value, "model"));
74
+
75
+ /** The value for `host` in a per-host object (or a flat value for every host). */
76
+ const forHost = (value: unknown, host: string | null) => {
77
+ if (isFlat(value)) return leaf(value);
78
+ if (!isObject(value)) return null;
79
+ const family = host ? hostFamily(host) : null;
80
+ for (const key of [family, host, "default"])
81
+ if (key && Object.hasOwn(value, key)) {
82
+ const found = leaf(value[key]);
83
+ if (found) return found;
84
+ }
85
+ return null;
86
+ };
87
+
88
+ const rolesOf = (models: unknown): Record<string, unknown> | null =>
89
+ isObject(models) && isObject(models.roles) ? models.roles : null;
90
+
91
+ /**
92
+ * The model and effort for a role on a host, or null (the host's own default
93
+ * applies). Order: the selected workspace, each enclosing workspace, the
94
+ * global default (each: `models.roles`, then `fanout.tierModels` for an
95
+ * implementer tier), then Claude Code's built-in defaults.
96
+ */
97
+ export const resolveRoleModel = (
98
+ cwd: string,
99
+ selector: RoleSelector,
100
+ host: string | null,
101
+ dir?: string,
102
+ ): RoleModel | null => {
103
+ const { role, tier } = selector;
104
+ for (const source of modelSources(cwd, dir)) {
105
+ const where = source.global ? "the global default" : `workspace ${source.name}`;
106
+ const roles = rolesOf(source.models);
107
+ const configured = roles && Object.hasOwn(roles, role) ? roles[role] : undefined;
108
+ const fromRoles =
109
+ role === "implementer"
110
+ ? tier && isObject(configured) && Object.hasOwn(configured, tier)
111
+ ? forHost(configured[tier], host)
112
+ : null
113
+ : forHost(configured, host);
114
+ if (fromRoles) return { ...fromRoles, source: `models.roles in ${where}` };
115
+ if (role === "implementer" && tier && isObject(source.tierModels)) {
116
+ const fromTiers = Object.hasOwn(source.tierModels, tier)
117
+ ? forHost(source.tierModels[tier], host)
118
+ : null;
119
+ if (fromTiers)
120
+ return { model: fromTiers.model, effort: null, source: `fanout.tierModels in ${where}` };
121
+ }
122
+ }
123
+ if (host && hostFamily(host) === "claude_code") {
124
+ const builtIn = BUILT_IN_CLAUDE_CODE[tier ? `${role}.${tier}` : role];
125
+ if (builtIn)
126
+ return { model: builtIn.model, effort: builtIn.effort ?? null, source: "built-in" };
127
+ }
128
+ return null;
129
+ };
130
+
131
+ /**
132
+ * The implementer model when a host's agent serves every fanout tier (a Codex
133
+ * agent file, a Pi worker): set only when all three tiers resolve to the same
134
+ * model and effort, else null (the spawn or session decides per slice).
135
+ */
136
+ export const uniformImplementerModel = (
137
+ cwd: string,
138
+ host: string | null,
139
+ dir?: string,
140
+ ): RoleModel | null => {
141
+ const tiers = MODEL_TIERS.map((tier) =>
142
+ resolveRoleModel(cwd, { role: "implementer", tier }, host, dir),
143
+ );
144
+ const [first] = tiers;
145
+ return first && tiers.every((t) => t?.model === first.model && t.effort === first.effort)
146
+ ? first
147
+ : null;
148
+ };
149
+
150
+ /** Every selector `models show` lists, in role order. */
151
+ export const ROLE_SELECTORS: RoleSelector[] = MODEL_ROLES.flatMap((role): RoleSelector[] =>
152
+ role === "implementer" ? MODEL_TIERS.map((tier) => ({ role, tier })) : [{ role }],
153
+ );
154
+
155
+ const valueIssues = (where: string, value: unknown, out: string[]): void => {
156
+ if (isFlat(value)) {
157
+ if (!leaf(value)) out.push(`${where}.model: not a model name; ignored`);
158
+ if (isObject(value) && value.effort !== undefined && !name(value.effort))
159
+ out.push(`${where}.effort: not an effort name; ignored`);
160
+ return;
161
+ }
162
+ if (!isObject(value)) {
163
+ out.push(`${where}: not a model name or a per-host object; ignored`);
164
+ return;
165
+ }
166
+ for (const [host, entry] of Object.entries(value)) {
167
+ if (!HOST_KEYS.has(host)) out.push(`${where}.${host}: unknown host; ignored`);
168
+ else {
169
+ if (!leaf(entry)) out.push(`${where}.${host}.model: not a model name; ignored`);
170
+ if (isObject(entry) && entry.effort !== undefined && !name(entry.effort))
171
+ out.push(`${where}.${host}.effort: not an effort name; ignored`);
172
+ }
173
+ }
174
+ };
175
+
176
+ /**
177
+ * What `models.roles` holds that Workit ignores (an unknown role, tier or
178
+ * host, a value that is not a model name or effort), across the global
179
+ * default and every workspace matching cwd. `workit doctor` and `workit grant
180
+ * show` report them.
181
+ */
182
+ export const roleModelIssues = (cwd: string, dir?: string): string[] => {
183
+ const issues: string[] = [];
184
+ for (const source of modelSources(cwd, dir)) {
185
+ if (source.models === undefined) continue;
186
+ const base = `${source.global ? "global default" : `workspace ${source.name}`} models.roles`;
187
+ if (
188
+ !isObject(source.models) ||
189
+ (source.models.roles !== undefined && !rolesOf(source.models))
190
+ ) {
191
+ issues.push(`${base} is not an object; ignored`);
192
+ continue;
193
+ }
194
+ for (const [role, value] of Object.entries(rolesOf(source.models) ?? {})) {
195
+ const where = `${base}.${role}`;
196
+ if (!(MODEL_ROLES as readonly string[]).includes(role))
197
+ issues.push(`${base}.${role}: unknown role; ignored`);
198
+ else if (role === "implementer") {
199
+ if (!isObject(value)) issues.push(`${where}: not an object keyed by tier; ignored`);
200
+ else
201
+ for (const [tier, entry] of Object.entries(value))
202
+ if (!(MODEL_TIERS as readonly string[]).includes(tier))
203
+ issues.push(`${where}.${tier}: unknown tier; ignored`);
204
+ else valueIssues(`${where}.${tier}`, entry, issues);
205
+ } else valueIssues(where, value, issues);
206
+ }
207
+ }
208
+ return issues;
209
+ };