@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 +1 -1
- package/skills/workit-fanout/SKILL.md +4 -4
- package/skills/workit-fanout/references/brief.md +4 -4
- package/skills/workit-fanout/references/orchestration.md +26 -17
- package/skills/workit-implement/SKILL.md +1 -1
- package/skills/workit-review/SKILL.md +4 -1
- package/skills/workit-ship/SKILL.md +1 -1
- package/src/core/methods.ts +2 -2
- package/src/core/policy/carry.ts +74 -0
- package/src/core/policy/lane.ts +63 -14
- package/src/core/role-models.ts +209 -0
- package/src/core/workspaces.ts +56 -35
- package/src/fanout-brief.ts +26 -10
- package/src/fanout-status.ts +3 -3
- package/src/fanout-worktree.ts +1 -1
- package/src/forge/pr-ops.ts +11 -2
- package/src/git/default-base.ts +49 -0
- package/src/git/rev.ts +111 -0
- package/src/hooks/cwd-normalize.ts +44 -10
- package/src/hooks/delivery-verbs.ts +129 -0
- package/src/hooks/handle.ts +21 -6
- package/src/hooks/hosts/claude-code.ts +11 -0
- package/src/hooks/index.ts +6 -0
- package/src/hooks/protocol.ts +2 -0
- package/src/hooks/raw-git.ts +34 -8
- package/src/hooks/shell-words.ts +32 -1
- package/src/hooks/worktree-confine.ts +3 -3
- package/src/hooks/write-gate.ts +10 -1
- package/src/ledger.ts +148 -50
- package/src/stack.ts +14 -4
- package/src/verdict-cover.ts +151 -0
package/package.json
CHANGED
|
@@ -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:`
|
|
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
|
|
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>
|
|
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>
|
|
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.
|
|
9
|
-
`fanout.tierModels
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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.
|
|
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:
|
|
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
|
package/src/core/methods.ts
CHANGED
|
@@ -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>
|
|
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
|
-
|
|
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
|
+
}
|
package/src/core/policy/lane.ts
CHANGED
|
@@ -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 {
|
|
36
|
-
import {
|
|
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
|
-
/**
|
|
121
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
};
|