@brainervirus/workit-core 8.25.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/orchestration.md +26 -17
- package/skills/workit-implement/SKILL.md +1 -1
- package/skills/workit-review/SKILL.md +2 -1
- package/skills/workit-ship/SKILL.md +1 -1
- package/src/core/role-models.ts +209 -0
- package/src/core/workspaces.ts +56 -35
- package/src/fanout-brief.ts +18 -4
- package/src/fanout-status.ts +3 -3
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
|
|
@@ -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,7 +9,8 @@ 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.
|
|
@@ -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
|
|
@@ -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
|
+
};
|
package/src/core/workspaces.ts
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
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,
|
package/src/fanout-brief.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
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 {
|
|
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. */
|
|
@@ -185,7 +189,8 @@ export function renderBrief(
|
|
|
185
189
|
what: String(row.what),
|
|
186
190
|
}));
|
|
187
191
|
const identity = identityOf(input.actor.host);
|
|
188
|
-
const model =
|
|
192
|
+
const model = resolveRoleModel(cwd, { role: "implementer", tier: slice.tier }, input.actor.host);
|
|
193
|
+
const verifier = resolveRoleModel(cwd, { role: "verifier" }, input.actor.host);
|
|
189
194
|
const minted = workerSession(sessionSafe(input.actor.session), slice.id, input.attempt);
|
|
190
195
|
const session = identity === "declared" ? minted : null;
|
|
191
196
|
const fanIn =
|
|
@@ -222,7 +227,14 @@ export function renderBrief(
|
|
|
222
227
|
...(slice.reason ? [`REASON: ${slice.reason}`] : []),
|
|
223
228
|
`TIER: ${slice.tier}`,
|
|
224
229
|
...(model
|
|
225
|
-
? [
|
|
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
|
+
]
|
|
226
238
|
: []),
|
|
227
239
|
`TIMEBOX: ${slice.timebox ?? DEFAULT_TIMEBOX}; past it without a new commit you will be replaced`,
|
|
228
240
|
scratchLine,
|
|
@@ -251,6 +263,8 @@ export function renderBrief(
|
|
|
251
263
|
tier: slice.tier,
|
|
252
264
|
issues: slice.issues,
|
|
253
265
|
model: model?.model ?? null,
|
|
266
|
+
effort: model?.effort ?? null,
|
|
267
|
+
verifier: verifier ? { model: verifier.model, effort: verifier.effort } : null,
|
|
254
268
|
scratch,
|
|
255
269
|
session,
|
|
256
270
|
standing,
|
package/src/fanout-status.ts
CHANGED
|
@@ -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 {
|
|
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 ?
|
|
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
|