@workit-dev/core 0.0.1 → 1.0.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.
Files changed (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -43
  3. package/package.json +56 -6
  4. package/schemas/config.json +74 -0
  5. package/schemas/vcs.json +79 -0
  6. package/schemas/workspaces.json +1029 -0
  7. package/schemas/youtrack.json +84 -0
  8. package/scripts/analyze-release-scope.ts +215 -0
  9. package/scripts/bundled-deps.json +131 -0
  10. package/scripts/fixtures/sample-plan.md +15 -0
  11. package/scripts/fixtures/sample-sdd/progress.md +2 -0
  12. package/scripts/install-codex-plugin.sh +28 -0
  13. package/scripts/install-cursor-plugin.sh +146 -0
  14. package/scripts/install-opencode-plugin.sh +79 -0
  15. package/scripts/install-pi-package.sh +56 -0
  16. package/scripts/lib/config-dir.sh +28 -0
  17. package/scripts/publish-changed-packages.ts +395 -0
  18. package/scripts/rewrite-workspace-deps.ts +53 -0
  19. package/scripts/run-cursor-mcp.sh +6 -0
  20. package/scripts/sync-release-manifests.ts +82 -0
  21. package/scripts/sync-runtime.sh +142 -0
  22. package/scripts/validate-cursor-marketplace.ts +171 -0
  23. package/scripts/verify-bundle-sources.ts +134 -0
  24. package/scripts/write-schemas.ts +16 -0
  25. package/skills/workit-architecture/SKILL.md +72 -0
  26. package/skills/workit-architecture/agents/openai.yaml +3 -0
  27. package/skills/workit-architecture/references/instruction-files.md +52 -0
  28. package/skills/workit-architecture/references/vocabulary.md +53 -0
  29. package/skills/workit-bdd/SKILL.md +53 -0
  30. package/skills/workit-continue/SKILL.md +54 -0
  31. package/skills/workit-debug/SKILL.md +45 -0
  32. package/skills/workit-deslop/SKILL.md +58 -0
  33. package/skills/workit-fanout/SKILL.md +71 -0
  34. package/skills/workit-fanout/references/brief.md +141 -0
  35. package/skills/workit-fanout/references/orchestration.md +169 -0
  36. package/skills/workit-implement/SKILL.md +71 -0
  37. package/skills/workit-retro/SKILL.md +72 -0
  38. package/skills/workit-retro/agents/openai.yaml +3 -0
  39. package/skills/workit-retro/references/sources.md +35 -0
  40. package/skills/workit-retro/references/steering.md +39 -0
  41. package/skills/workit-review/SKILL.md +66 -0
  42. package/skills/workit-review/references/impact.md +24 -0
  43. package/skills/workit-shape/SKILL.md +71 -0
  44. package/skills/workit-shape/references/diagrams.md +17 -0
  45. package/skills/workit-shape/references/knowledge.md +61 -0
  46. package/skills/workit-shape/references/mockups.md +15 -0
  47. package/skills/workit-shape/references/slicing.md +44 -0
  48. package/skills/workit-ship/SKILL.md +71 -0
  49. package/skills/workit-ship/references/changelog.md +44 -0
  50. package/skills/workit-ship/references/pr-body.md +32 -0
  51. package/skills/workit-ship/references/release.md +39 -0
  52. package/skills/workit-test-audit/SKILL.md +54 -0
  53. package/skills/workit-verify-app/SKILL.md +68 -0
  54. package/skills/workit-verify-app/references/template.md +51 -0
  55. package/src/autonomy.ts +910 -0
  56. package/src/check-config.ts +613 -0
  57. package/src/checks.ts +534 -0
  58. package/src/ci-waiter.ts +159 -0
  59. package/src/core/boundary.ts +55 -0
  60. package/src/core/branch-policy.ts +112 -0
  61. package/src/core/branch.ts +1192 -0
  62. package/src/core/changelog-gate.ts +349 -0
  63. package/src/core/changelog-merge.ts +214 -0
  64. package/src/core/changelog.ts +392 -0
  65. package/src/core/cleanup.ts +638 -0
  66. package/src/core/cli-locate.ts +173 -0
  67. package/src/core/commit-flavors.ts +65 -0
  68. package/src/core/config-schemas.ts +653 -0
  69. package/src/core/config.ts +351 -0
  70. package/src/core/context-read.ts +144 -0
  71. package/src/core/docs-gate.ts +69 -0
  72. package/src/core/finding-evidence.ts +89 -0
  73. package/src/core/finding-queue.ts +36 -0
  74. package/src/core/git.ts +73 -0
  75. package/src/core/gitignore.ts +52 -0
  76. package/src/core/heavy-eta.ts +170 -0
  77. package/src/core/heavy-lease.ts +290 -0
  78. package/src/core/heavy-rules.ts +373 -0
  79. package/src/core/heavy-scope.ts +131 -0
  80. package/src/core/heavy-words.ts +83 -0
  81. package/src/core/heavy.ts +535 -0
  82. package/src/core/hygiene.ts +149 -0
  83. package/src/core/init.ts +218 -0
  84. package/src/core/live-session.ts +333 -0
  85. package/src/core/logger.ts +310 -0
  86. package/src/core/methods.ts +185 -0
  87. package/src/core/model-fallback.ts +208 -0
  88. package/src/core/node-resolve.mjs +249 -0
  89. package/src/core/operation-input.ts +632 -0
  90. package/src/core/package-root.ts +28 -0
  91. package/src/core/policy/attributes.ts +97 -0
  92. package/src/core/policy/bookkeeping.ts +119 -0
  93. package/src/core/policy/carry.ts +122 -0
  94. package/src/core/policy/check-evidence.ts +115 -0
  95. package/src/core/policy/current-judgment.ts +20 -0
  96. package/src/core/policy/derive.ts +387 -0
  97. package/src/core/policy/judgment.ts +246 -0
  98. package/src/core/policy/lane.ts +562 -0
  99. package/src/core/policy/pr-lane.ts +32 -0
  100. package/src/core/policy/substance.ts +365 -0
  101. package/src/core/policy-resolver.ts +94 -0
  102. package/src/core/pr-create.ts +448 -0
  103. package/src/core/pr-template.ts +162 -0
  104. package/src/core/release-notes.ts +197 -0
  105. package/src/core/release-tracks.ts +779 -0
  106. package/src/core/remotes.ts +436 -0
  107. package/src/core/repo-context.ts +542 -0
  108. package/src/core/repo-paths.ts +29 -0
  109. package/src/core/resources.ts +1299 -0
  110. package/src/core/role-models.ts +603 -0
  111. package/src/core/route-intent.ts +305 -0
  112. package/src/core/rules.ts +65 -0
  113. package/src/core/runtime-identity.ts +63 -0
  114. package/src/core/safe-write.ts +87 -0
  115. package/src/core/scope-glob.ts +56 -0
  116. package/src/core/scripts.ts +6 -0
  117. package/src/core/skill-manifests.ts +173 -0
  118. package/src/core/store-lock.ts +374 -0
  119. package/src/core/support-matrix.ts +24 -0
  120. package/src/core/task-context.ts +247 -0
  121. package/src/core/task-contract.ts +1279 -0
  122. package/src/core/task-delivery.ts +109 -0
  123. package/src/core/task-engine.ts +2248 -0
  124. package/src/core/task-evaluation.ts +1147 -0
  125. package/src/core/task-store.ts +2649 -0
  126. package/src/core/task-view.ts +180 -0
  127. package/src/core/templates.ts +22 -0
  128. package/src/core/tracker-issues.ts +174 -0
  129. package/src/core/vcs-config.ts +540 -0
  130. package/src/core/workers.ts +62 -0
  131. package/src/core/workit-paths.mjs +242 -0
  132. package/src/core/workspace-branch-policy.ts +123 -0
  133. package/src/core/workspaces.ts +1745 -0
  134. package/src/core/youtrack-backend.ts +288 -0
  135. package/src/core/youtrack-tools.ts +260 -0
  136. package/src/core/youtrack-writes.ts +73 -0
  137. package/src/core/youtrack-ytrack.ts +217 -0
  138. package/src/core/youtrack.ts +454 -0
  139. package/src/core/ytrack-skill.ts +297 -0
  140. package/src/core.ts +171 -0
  141. package/src/deslop/lex.ts +271 -0
  142. package/src/deslop/scan.ts +232 -0
  143. package/src/duration.ts +27 -0
  144. package/src/fanout-brief.ts +486 -0
  145. package/src/fanout-check.ts +455 -0
  146. package/src/fanout-landed.ts +370 -0
  147. package/src/fanout-stack.ts +108 -0
  148. package/src/fanout-status.ts +362 -0
  149. package/src/fanout-worktree.ts +893 -0
  150. package/src/fanout.ts +1473 -0
  151. package/src/forge/exec.ts +385 -0
  152. package/src/forge/github.ts +831 -0
  153. package/src/forge/gitlab.ts +906 -0
  154. package/src/forge/issue-forge.ts +209 -0
  155. package/src/forge/issue-link.ts +468 -0
  156. package/src/forge/issue-ops.ts +314 -0
  157. package/src/forge/merge-gate-base-red.ts +72 -0
  158. package/src/forge/pr-body.ts +100 -0
  159. package/src/forge/pr-edit.ts +292 -0
  160. package/src/forge/pr-ops.ts +1109 -0
  161. package/src/forge/pr-status.ts +10 -0
  162. package/src/forge/redact.ts +195 -0
  163. package/src/forge/report.ts +994 -0
  164. package/src/forge/reruns.ts +132 -0
  165. package/src/forge/resolve.ts +509 -0
  166. package/src/forge/types.ts +242 -0
  167. package/src/forge/verify.ts +398 -0
  168. package/src/git/default-base.ts +130 -0
  169. package/src/git/env-redirect.ts +28 -0
  170. package/src/git/facts.ts +122 -0
  171. package/src/git/ops.ts +1823 -0
  172. package/src/git/refs-read.ts +332 -0
  173. package/src/git/release.ts +1020 -0
  174. package/src/git/rev.ts +1175 -0
  175. package/src/git/session-hook.ts +597 -0
  176. package/src/git/stage.ts +63 -0
  177. package/src/hooks/agent-model.ts +149 -0
  178. package/src/hooks/budget-signal.ts +284 -0
  179. package/src/hooks/context.ts +461 -0
  180. package/src/hooks/cwd-normalize.ts +79 -0
  181. package/src/hooks/deadline.ts +228 -0
  182. package/src/hooks/delivery-verbs.ts +182 -0
  183. package/src/hooks/descriptor.ts +124 -0
  184. package/src/hooks/handle.ts +433 -0
  185. package/src/hooks/heavy-gate.ts +99 -0
  186. package/src/hooks/hook-runs.ts +130 -0
  187. package/src/hooks/hosts/claude-code.ts +380 -0
  188. package/src/hooks/hosts/codex.ts +372 -0
  189. package/src/hooks/hosts/cursor.ts +419 -0
  190. package/src/hooks/hosts/fields.ts +98 -0
  191. package/src/hooks/hosts/opencode.ts +79 -0
  192. package/src/hooks/hosts/pi.ts +78 -0
  193. package/src/hooks/index.ts +60 -0
  194. package/src/hooks/model-limit.ts +378 -0
  195. package/src/hooks/model-switch.ts +35 -0
  196. package/src/hooks/policy.ts +29 -0
  197. package/src/hooks/process.ts +48 -0
  198. package/src/hooks/protocol.ts +135 -0
  199. package/src/hooks/raw-git.ts +2289 -0
  200. package/src/hooks/repeat-failure.ts +115 -0
  201. package/src/hooks/run.ts +54 -0
  202. package/src/hooks/session-marker.ts +151 -0
  203. package/src/hooks/shell-words.ts +532 -0
  204. package/src/hooks/skill-nudge.ts +295 -0
  205. package/src/hooks/spawn-gate.ts +72 -0
  206. package/src/hooks/standing-signal.ts +99 -0
  207. package/src/hooks/standing.ts +225 -0
  208. package/src/hooks/steering.ts +74 -0
  209. package/src/hooks/stop.ts +408 -0
  210. package/src/hooks/worktree-confine.ts +415 -0
  211. package/src/hooks/write-gate.ts +1174 -0
  212. package/src/host-session.ts +459 -0
  213. package/src/knowledge.ts +368 -0
  214. package/src/ledger.ts +2913 -0
  215. package/src/stack.ts +2570 -0
  216. package/src/store/event-log.ts +217 -0
  217. package/src/store/notes.ts +29 -0
  218. package/src/store/patch.ts +135 -0
  219. package/src/store/paths.ts +368 -0
  220. package/src/store/reduce.ts +203 -0
  221. package/src/test-audit-mocks.ts +309 -0
  222. package/src/verdict-cover.ts +151 -0
  223. package/src/worktree-deps.ts +142 -0
  224. package/templates/headers.md +3 -0
  225. package/templates/hygiene/.editorconfig +8 -0
  226. package/templates/hygiene/.gitattributes +3 -0
  227. package/templates/hygiene/CHANGELOG.md +14 -0
  228. package/templates/hygiene/CONTRIBUTING.md +3 -0
  229. package/templates/hygiene/LICENSE +21 -0
  230. package/templates/hygiene/README.md +3 -0
  231. package/templates/issue-update.md +5 -0
  232. package/templates/plan-template.md +26 -0
  233. package/templates/spec-template.md +64 -0
  234. package/templates/workit-contract.md +13 -0
@@ -0,0 +1,603 @@
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. Any value may be an
16
+ // ordered list (`["opus", "openai/gpt-5"]`, or a list per host): the first
17
+ // model whose provider is not exhausted by a usage limit runs
18
+ // (core/model-fallback.ts, #655); `models.limitWindow` sets how long a limit
19
+ // with no reset time lasts (default 5h). Everything is read
20
+ // leniently: a malformed entry is skipped and reported (`roleModelIssues`),
21
+ // never an error. Claude Code has built-in defaults; other hosts inherit
22
+ // their own default model when nothing is configured.
23
+ //
24
+ // The `default` role is a spawn no Workit role claims (Claude Code's
25
+ // general-purpose, OpenCode's general). `models.lead` names the lead's own
26
+ // model, advisory on every host (none lets Workit pick the session model).
27
+ // A model the lead names on a spawn loses to a configured role model unless
28
+ // `models.allowLeadOverride` is true; a built-in default never overrides one.
29
+ import {
30
+ exhaustionFor,
31
+ parseWindow,
32
+ providerOf,
33
+ shortTime,
34
+ type Exhaustion,
35
+ } from "./model-fallback";
36
+ import { hostFamily, modelSources } from "./workspaces";
37
+
38
+ export const MODEL_ROLES = [
39
+ "decider",
40
+ "implementer",
41
+ "verifier",
42
+ "reviewer",
43
+ "explore",
44
+ "babysit",
45
+ "default",
46
+ ] as const;
47
+ export type ModelRole = (typeof MODEL_ROLES)[number];
48
+ export const MODEL_TIERS = ["mundane", "standard", "hard"] as const;
49
+ export type ModelTier = (typeof MODEL_TIERS)[number];
50
+
51
+ export type RoleSelector = { role: ModelRole; tier?: ModelTier };
52
+ export type RoleModel = {
53
+ model: string;
54
+ effort: string | null;
55
+ source: string;
56
+ };
57
+ /** A role model chosen past exhausted providers: `skipped` names each model passed over. */
58
+ export type AvailableRoleModel = RoleModel & {
59
+ skipped: Array<{
60
+ model: string;
61
+ provider: string;
62
+ until: string;
63
+ reason: string;
64
+ }>;
65
+ /** Every candidate is exhausted: the primary runs anyway. */
66
+ allExhausted: boolean;
67
+ };
68
+
69
+ const HOST_KEYS = new Set([
70
+ "claude_code",
71
+ "opencode",
72
+ "codex",
73
+ "codex_cli",
74
+ "codex_desktop",
75
+ "cursor",
76
+ "pi",
77
+ "default",
78
+ ]);
79
+
80
+ const BUILT_IN_CLAUDE_CODE: Record<string, { model: string; effort?: string }> = {
81
+ decider: { model: "fable", effort: "high" },
82
+ "implementer.hard": { model: "opus" },
83
+ "implementer.standard": { model: "sonnet" },
84
+ "implementer.mundane": { model: "haiku" },
85
+ verifier: { model: "sonnet" },
86
+ reviewer: { model: "opus" },
87
+ explore: { model: "haiku" },
88
+ babysit: { model: "haiku" },
89
+ };
90
+
91
+ const isObject = (value: unknown): value is Record<string, unknown> =>
92
+ !!value && typeof value === "object" && !Array.isArray(value);
93
+ const name = (value: unknown): string | null =>
94
+ typeof value === "string" && value.trim() ? value.trim() : null;
95
+
96
+ /** `"model"` or `{ model, effort? }` as a model and effort; null when it names no model. */
97
+ const leaf = (value: unknown): { model: string; effort: string | null } | null => {
98
+ const text = name(value);
99
+ if (text) return { model: text, effort: null };
100
+ if (!isObject(value)) return null;
101
+ const model = name(value.model);
102
+ return model ? { model, effort: name(value.effort) } : null;
103
+ };
104
+
105
+ const isFlat = (value: unknown): boolean =>
106
+ typeof value === "string" || (isObject(value) && Object.hasOwn(value, "model"));
107
+
108
+ type Leaf = { model: string; effort: string | null };
109
+
110
+ /** The ordered models for `host` in a value: a list, a per-host object or a flat value. */
111
+ const forHostList = (value: unknown, host: string | null): Leaf[] => {
112
+ if (Array.isArray(value)) return value.flatMap((item) => forHostList(item, host));
113
+ if (isFlat(value)) {
114
+ const found = leaf(value);
115
+ return found ? [found] : [];
116
+ }
117
+ if (!isObject(value)) return [];
118
+ const family = host ? hostFamily(host) : null;
119
+ for (const key of [family, host, "default"])
120
+ if (key && Object.hasOwn(value, key)) {
121
+ const found = Array.isArray(value[key])
122
+ ? forHostList(value[key], host)
123
+ : [leaf(value[key])].filter((one): one is Leaf => !!one);
124
+ if (found.length) return found;
125
+ }
126
+ return [];
127
+ };
128
+
129
+ const rolesOf = (models: unknown): Record<string, unknown> | null =>
130
+ isObject(models) && isObject(models.roles) ? models.roles : null;
131
+
132
+ /**
133
+ * The model and effort for a role on a host now (the first of its list whose
134
+ * provider is not exhausted), or null (the host's own default applies). Order: the selected workspace, each enclosing workspace, the
135
+ * global default (each: `models.roles`, then `fanout.tierModels` for an
136
+ * implementer tier), then Claude Code's built-in defaults.
137
+ */
138
+ export const resolveRoleModel = (
139
+ cwd: string,
140
+ selector: RoleSelector,
141
+ host: string | null,
142
+ dir?: string,
143
+ ): RoleModel | null => {
144
+ const chosen = firstAvailable(resolveRoleModels(cwd, selector, host, dir), host);
145
+ return chosen ? { model: chosen.model, effort: chosen.effort, source: chosen.source } : null;
146
+ };
147
+
148
+ /** Every model configured for a role on a host, in fallback order (see resolveRoleModel). */
149
+ export const resolveRoleModels = (
150
+ cwd: string,
151
+ selector: RoleSelector,
152
+ host: string | null,
153
+ dir?: string,
154
+ ): RoleModel[] => {
155
+ const { role, tier } = selector;
156
+ for (const source of modelSources(cwd, dir)) {
157
+ const where = source.global ? "the global default" : `workspace ${source.name}`;
158
+ const roles = rolesOf(source.models);
159
+ const configured = roles && Object.hasOwn(roles, role) ? roles[role] : undefined;
160
+ const fromRoles =
161
+ role === "implementer"
162
+ ? tier && isObject(configured) && Object.hasOwn(configured, tier)
163
+ ? forHostList(configured[tier], host)
164
+ : []
165
+ : forHostList(configured, host);
166
+ if (fromRoles.length)
167
+ return fromRoles.map((one) => ({
168
+ ...one,
169
+ source: `models.roles in ${where}`,
170
+ }));
171
+ if (role === "implementer" && tier && isObject(source.tierModels)) {
172
+ const fromTiers = Object.hasOwn(source.tierModels, tier)
173
+ ? forHostList(source.tierModels[tier], host)
174
+ : [];
175
+ if (fromTiers.length)
176
+ return fromTiers.map((one) => ({
177
+ model: one.model,
178
+ effort: null,
179
+ source: `fanout.tierModels in ${where}`,
180
+ }));
181
+ }
182
+ }
183
+ if (host && hostFamily(host) === "claude_code") {
184
+ const builtIn = BUILT_IN_CLAUDE_CODE[tier ? `${role}.${tier}` : role];
185
+ if (builtIn)
186
+ return [
187
+ {
188
+ model: builtIn.model,
189
+ effort: builtIn.effort ?? null,
190
+ source: "built-in",
191
+ },
192
+ ];
193
+ }
194
+ return [];
195
+ };
196
+
197
+ /**
198
+ * The first of `candidates` whose provider is not exhausted at `now`; the
199
+ * primary when every one is (a limit already past its reset never counts).
200
+ */
201
+ export const firstAvailable = (
202
+ candidates: RoleModel[],
203
+ host: string | null,
204
+ now: Date = new Date(),
205
+ ): AvailableRoleModel | null => {
206
+ const [primary] = candidates;
207
+ if (!primary) return null;
208
+ const skipped: AvailableRoleModel["skipped"] = [];
209
+ for (const candidate of candidates) {
210
+ const blocked: (Exhaustion & { provider: string }) | null = exhaustionFor(
211
+ candidate.model,
212
+ host,
213
+ now,
214
+ );
215
+ if (!blocked) return { ...candidate, skipped, allExhausted: false };
216
+ skipped.push({ model: candidate.model, ...blocked });
217
+ }
218
+ return { ...primary, skipped, allExhausted: true };
219
+ };
220
+
221
+ /** The role's model on a host now: past every provider exhausted by a usage limit. */
222
+ export const resolveAvailableRoleModel = (
223
+ cwd: string,
224
+ selector: RoleSelector,
225
+ host: string | null,
226
+ now: Date = new Date(),
227
+ dir?: string,
228
+ ): AvailableRoleModel | null =>
229
+ firstAvailable(resolveRoleModels(cwd, selector, host, dir), host, now);
230
+
231
+ /** Why a role runs on a fallback (or on an exhausted primary), for notes; null when it does not. */
232
+ export const fallbackText = (chosen: AvailableRoleModel): string | null => {
233
+ if (!chosen.skipped.length) return null;
234
+ const passed = chosen.skipped
235
+ .map((one) => `${one.model} (${one.provider} exhausted until ${shortTime(one.until)})`)
236
+ .join(", ");
237
+ return chosen.allExhausted
238
+ ? `every model configured for it is exhausted: ${passed}; it runs on ${chosen.model} anyway. Add a model of another provider to the list in workspaces.json, then run \`workit models show\`.`
239
+ : `fell back past ${passed}.`;
240
+ };
241
+
242
+ /** `models.lead` for a host (nearest workspace, then the global default), or null. */
243
+ export const resolveLeadModel = (
244
+ cwd: string,
245
+ host: string | null,
246
+ dir?: string,
247
+ ): RoleModel | null => resolveLeadModels(cwd, host, dir)[0] ?? null;
248
+
249
+ /** Every `models.lead` model for a host, in fallback order. */
250
+ export const resolveLeadModels = (cwd: string, host: string | null, dir?: string): RoleModel[] => {
251
+ for (const source of modelSources(cwd, dir)) {
252
+ if (!isObject(source.models) || !Object.hasOwn(source.models, "lead")) continue;
253
+ const found = forHostList(source.models.lead, host);
254
+ if (found.length)
255
+ return found.map((one) => ({
256
+ ...one,
257
+ source: `models.lead in ${source.global ? "the global default" : `workspace ${source.name}`}`,
258
+ }));
259
+ }
260
+ return [];
261
+ };
262
+
263
+ const SWITCH_HOW: Record<string, string> = {
264
+ claude_code: "/model",
265
+ opencode: "/models",
266
+ codex: "/model",
267
+ cursor: "the model picker",
268
+ pi: "/model",
269
+ };
270
+
271
+ /**
272
+ * One line telling the lead to switch its own model (no host lets Workit
273
+ * switch a running session's model): when `current` (the session's model, or
274
+ * else the primary `models.lead`) runs on an exhausted provider. Names the
275
+ * first available `models.lead` fallback; null when nothing is exhausted.
276
+ */
277
+ export const leadSwitchNotice = (
278
+ cwd: string,
279
+ host: string,
280
+ current: string | null,
281
+ now: Date = new Date(),
282
+ dir?: string,
283
+ ): string | null => {
284
+ const leads = resolveLeadModels(cwd, host, dir);
285
+ const model = current ?? leads[0]?.model ?? null;
286
+ if (!model) return null;
287
+ const blocked = exhaustionFor(model, host, now);
288
+ if (!blocked) return null;
289
+ const next = leads.find((one) => !exhaustionFor(one.model, host, now));
290
+ const how = SWITCH_HOW[hostFamily(host) ?? ""] ?? "the host's model switch";
291
+ return next
292
+ ? `Workit cannot switch this session's model: ${blocked.provider} is exhausted until ${shortTime(blocked.until)}. Switch the lead to ${next.model} with ${how}${hostFamily(host) === "claude_code" && providerOf(next.model, host) !== "anthropic" ? " (another host: Claude Code runs only Anthropic models)" : ""}.`
293
+ : `Workit cannot switch this session's model: ${blocked.provider} is exhausted until ${shortTime(blocked.until)}, and models.lead names no model of another provider. Add one to models.lead in workspaces.json, then run \`workit models show\`.`;
294
+ };
295
+
296
+ /** `models.allowLeadOverride` (nearest source that sets it): true lets a spawn's own model win. */
297
+ export const leadOverrideAllowed = (cwd: string, dir?: string): boolean => {
298
+ for (const source of modelSources(cwd, dir))
299
+ if (isObject(source.models) && Object.hasOwn(source.models, "allowLeadOverride"))
300
+ return source.models.allowLeadOverride === true;
301
+ return false;
302
+ };
303
+
304
+ /** The hosts `models show` reports enforcement for, in the order it prints them. */
305
+ export const ENFORCEMENT_HOSTS = ["claude_code", "opencode", "codex", "cursor", "pi"] as const;
306
+ export type Enforcement = "enforced" | "advisory";
307
+
308
+ /**
309
+ * Whether Workit applies a role's model on a host itself (`enforced`) or only
310
+ * names it for the lead to pass (`advisory`). Claude Code (a PreToolUse deny
311
+ * naming the exact args) and OpenCode (the plugin's subagent hook) hold every
312
+ * spawn to it; Codex writes it into its verifier, reviewer and implementer agent
313
+ * files, Pi passes it to its reviewer, investigator and implementer workers
314
+ * (the implementer on both only when the three tiers agree); Cursor has no
315
+ * hook that can change a spawn. The lead's own model is advisory everywhere.
316
+ */
317
+ export const roleEnforcement = (
318
+ cwd: string,
319
+ selector: RoleSelector | "lead",
320
+ host: string,
321
+ dir?: string,
322
+ ): Enforcement => {
323
+ if (selector === "lead") return "advisory";
324
+ const family = hostFamily(host);
325
+ if (family === "claude_code" || family === "opencode") return "enforced";
326
+ const agents =
327
+ family === "codex" ? ["verifier", "reviewer"] : family === "pi" ? ["reviewer", "explore"] : [];
328
+ if (agents.includes(selector.role)) return "enforced";
329
+ if (selector.role === "implementer" && (family === "codex" || family === "pi"))
330
+ return uniformImplementerModel(cwd, host, dir) ? "enforced" : "advisory";
331
+ return "advisory";
332
+ };
333
+
334
+ export type SpawnModelChoice = {
335
+ /** The model the spawn should run on, or null to leave its own. */
336
+ model: string | null;
337
+ /** The effort to fill (only when the spawn names none), or null. */
338
+ effort: string | null;
339
+ /** Why Workit changed the spawn, for the lead; null when nothing changed. */
340
+ note: string | null;
341
+ /** Where the chosen model comes from (`role verifier, built-in`), or null when none is chosen. */
342
+ basis: string | null;
343
+ };
344
+
345
+ /** The Claude Code Agent tool's `model` and `effort` enums (claude 2.1.309); anything else fails its schema. */
346
+ const CLAUDE_ALIASES = new Set(["sonnet", "opus", "haiku", "fable"]);
347
+ export const CLAUDE_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
348
+ /** A full Claude model id (`claude-sonnet-5-5`, `anthropic/claude-opus-5`, `us.anthropic.claude-haiku-…`). */
349
+ const FULL_CLAUDE_ID =
350
+ /^(?:[\w.-]+[./])?claude-(?:\d[\d.-]*-)?(sonnet|opus|haiku|fable)(?:[-.@[]|$)/u;
351
+
352
+ /**
353
+ * What the Agent tool's `model` takes for a configured value: an alias as is,
354
+ * a full Claude id as its family alias (the tool takes only aliases), anything
355
+ * else null.
356
+ */
357
+ export const claudeAgentModel = (value: string): string | null => {
358
+ const lower = value.trim().toLowerCase();
359
+ if (CLAUDE_ALIASES.has(lower)) return lower;
360
+ return FULL_CLAUDE_ID.exec(lower)?.[1] ?? null;
361
+ };
362
+
363
+ const roleLabel = (selector: RoleSelector): string =>
364
+ selector.tier ? `${selector.role} ${selector.tier}` : selector.role;
365
+
366
+ /**
367
+ * The model and effort a spawn for `selector` runs on: a spawn that names no
368
+ * model gets the role's; one that names another model keeps it only when the
369
+ * role model is a built-in default or `models.allowLeadOverride` is true.
370
+ * `accept` maps a configured value to what the host's spawn takes (null: it
371
+ * cannot take it, and the spawn runs as written with a note naming the fix).
372
+ */
373
+ export const chooseSpawnModel = (
374
+ cwd: string,
375
+ selector: RoleSelector,
376
+ host: string,
377
+ given: { model?: unknown; effort?: unknown },
378
+ accept: (model: string) => string | null,
379
+ dir?: string,
380
+ ): SpawnModelChoice => {
381
+ const none: SpawnModelChoice = {
382
+ model: null,
383
+ effort: null,
384
+ note: null,
385
+ basis: null,
386
+ };
387
+ const all = resolveRoleModels(cwd, selector, host, dir);
388
+ // A list for every host may name models this host cannot take: those are passed over.
389
+ const takes = all.filter((one) => accept(one.model));
390
+ const resolved = firstAvailable(takes.length ? takes : all, host);
391
+ if (!resolved) return none;
392
+ const givenModel = name(given.model);
393
+ const label = roleLabel(selector);
394
+ const configured = resolved.source !== "built-in";
395
+ if (givenModel && (!configured || leadOverrideAllowed(cwd, dir))) return none;
396
+ const model = accept(resolved.model);
397
+ const effort = resolved.effort && !name(given.effort) ? resolved.effort : null;
398
+ const fallback = fallbackText(resolved);
399
+ const why = fallback ? ` Role ${label} ${fallback}` : "";
400
+ const basis = `role ${label}, ${resolved.source}${fallback ? `; it ${fallback}` : ""}`;
401
+ if (!model)
402
+ return {
403
+ model: null,
404
+ effort,
405
+ basis,
406
+ note: `Workit could not apply ${resolved.model} (role ${label}, ${resolved.source}): this host's spawn does not take that model name, so the spawn runs on ${givenModel ?? "its default model"}.${why} Fix the value in workspaces.json, then run \`workit models show --role ${selector.role}\`.`,
407
+ };
408
+ if (givenModel && accept(givenModel) === model)
409
+ return {
410
+ ...none,
411
+ effort,
412
+ basis,
413
+ note: fallback ? `Workit kept ${model} on this spawn.${why}` : null,
414
+ };
415
+ // The exact spawn to write next time, so the lead's next call needs no rewrite.
416
+ const next = `pass model: ${model}${resolved.effort ? `, effort: ${resolved.effort}` : ""}`;
417
+ return {
418
+ model,
419
+ effort,
420
+ basis,
421
+ note: givenModel
422
+ ? `Workit replaced model ${givenModel} with ${model} on this spawn: role ${label} is set in ${resolved.source}.${why} Next time ${next} on the spawn. To let a spawn's own model win, set "allowLeadOverride": true under models in workspaces.json.`
423
+ : `Workit set ${model}${effort ? ` (effort ${effort})` : ""} on this spawn: role ${label}, ${resolved.source}.${why}${configured ? "" : " Name a model to choose another."}`,
424
+ };
425
+ };
426
+
427
+ /** The model and effort a Claude Code Agent spawn names so the spawn hook leaves it as written. */
428
+ export type ClaudeSpawnArgs = { model: string; effort: string | null };
429
+
430
+ /**
431
+ * The `model` and `effort` a Claude Code Agent spawn for `selector` passes
432
+ * (the hook's own choice for a spawn that names neither), or null when the
433
+ * role has no model the Agent tool takes.
434
+ */
435
+ export const claudeSpawnArgs = (
436
+ cwd: string,
437
+ selector: RoleSelector,
438
+ dir?: string,
439
+ ): ClaudeSpawnArgs | null => {
440
+ const choice = chooseSpawnModel(cwd, selector, "claude_code", {}, claudeAgentModel, dir);
441
+ if (!choice.model) return null;
442
+ const effort = choice.effort?.toLowerCase() ?? null;
443
+ return {
444
+ model: choice.model,
445
+ effort: effort && CLAUDE_EFFORTS.has(effort) ? effort : null,
446
+ };
447
+ };
448
+
449
+ /** Claude Code's spawn for each role: its agent type, or the prompt line that selects it. */
450
+ const CLAUDE_SPAWNS: Array<{ label: string; selector: RoleSelector }> = [
451
+ ...(["hard", "standard", "mundane"] as const).map((tier) => ({
452
+ label: tier === "hard" ? "workit:implementer hard" : tier,
453
+ selector: { role: "implementer" as const, tier },
454
+ })),
455
+ { label: "workit:verifier", selector: { role: "verifier" } },
456
+ { label: "workit:reviewer", selector: { role: "reviewer" } },
457
+ { label: "Explore", selector: { role: "explore" } },
458
+ { label: "ROLE: decider", selector: { role: "decider" } },
459
+ { label: "ROLE: babysit", selector: { role: "babysit" } },
460
+ { label: "general-purpose", selector: { role: "default" } },
461
+ ];
462
+
463
+ /**
464
+ * One line naming the `model`/`effort` of every Claude Code agent type
465
+ * Workit assigns a role (a `ROLE:` prompt line on general-purpose), so the
466
+ * lead's first spawn needs no correction (retro 2026-11-07 R1). Null when no
467
+ * role has a model.
468
+ */
469
+ export const claudeSpawnTable = (cwd: string, dir?: string): string | null => {
470
+ const rows = CLAUDE_SPAWNS.flatMap(({ label, selector }) => {
471
+ const args = claudeSpawnArgs(cwd, selector, dir);
472
+ return args ? [`${label} ${args.model}${args.effort ? `/${args.effort}` : ""}`] : [];
473
+ });
474
+ return rows.length
475
+ ? `Agent spawn model/effort (pass them, workers with run_in_background: true): ${rows.join(", ")}. Implementer prompt: \`workit fanout brief <slice>\` after \`workit fanout worktree create <slice>\`, even for one slice.`
476
+ : null;
477
+ };
478
+
479
+ /**
480
+ * The implementer model when a host's agent serves every fanout tier (a Codex
481
+ * agent file, a Pi worker): set only when all three tiers resolve to the same
482
+ * model and effort, else null (the spawn or session decides per slice).
483
+ */
484
+ export const uniformImplementerModel = (
485
+ cwd: string,
486
+ host: string | null,
487
+ dir?: string,
488
+ ): RoleModel | null => {
489
+ const tiers = MODEL_TIERS.map((tier) =>
490
+ resolveRoleModel(cwd, { role: "implementer", tier }, host, dir),
491
+ );
492
+ const [first] = tiers;
493
+ return first && tiers.every((t) => t?.model === first.model && t.effort === first.effort)
494
+ ? first
495
+ : null;
496
+ };
497
+
498
+ /** Every selector `models show` lists, in role order. */
499
+ export const ROLE_SELECTORS: RoleSelector[] = MODEL_ROLES.flatMap((role): RoleSelector[] =>
500
+ role === "implementer" ? MODEL_TIERS.map((tier) => ({ role, tier })) : [{ role }],
501
+ );
502
+
503
+ const valueIssues = (where: string, value: unknown, out: string[]): void => {
504
+ if (Array.isArray(value)) {
505
+ if (!value.length) out.push(`${where}: an empty list; ignored`);
506
+ value.forEach((item, at) => valueIssues(`${where}[${at}]`, item, out));
507
+ return;
508
+ }
509
+ if (isFlat(value)) {
510
+ if (!leaf(value)) out.push(`${where}.model: not a model name; ignored`);
511
+ if (isObject(value) && value.effort !== undefined && !name(value.effort))
512
+ out.push(`${where}.effort: not an effort name; ignored`);
513
+ return;
514
+ }
515
+ if (!isObject(value)) {
516
+ out.push(`${where}: not a model name or a per-host object; ignored`);
517
+ return;
518
+ }
519
+ for (const [host, entry] of Object.entries(value)) {
520
+ if (!HOST_KEYS.has(host)) out.push(`${where}.${host}: unknown host; ignored`);
521
+ else if (Array.isArray(entry)) valueIssues(`${where}.${host}`, entry, out);
522
+ else {
523
+ if (!leaf(entry)) out.push(`${where}.${host}.model: not a model name; ignored`);
524
+ if (isObject(entry) && entry.effort !== undefined && !name(entry.effort))
525
+ out.push(`${where}.${host}.effort: not an effort name; ignored`);
526
+ }
527
+ }
528
+ };
529
+
530
+ /**
531
+ * What `models.roles` holds that Workit ignores (an unknown role, tier or
532
+ * host, a value that is not a model name or effort), across the global
533
+ * default and every workspace matching cwd. `workit doctor` and `workit grant
534
+ * show` report them.
535
+ */
536
+ export const roleModelIssues = (cwd: string, dir?: string): string[] => {
537
+ const issues: string[] = [];
538
+ for (const source of modelSources(cwd, dir)) {
539
+ if (source.models === undefined) continue;
540
+ const owner = `${source.global ? "global default" : `workspace ${source.name}`} models`;
541
+ const base = `${owner}.roles`;
542
+ if (
543
+ !isObject(source.models) ||
544
+ (source.models.roles !== undefined && !rolesOf(source.models))
545
+ ) {
546
+ issues.push(`${base} is not an object; ignored`);
547
+ continue;
548
+ }
549
+ if (source.models.lead !== undefined) valueIssues(`${owner}.lead`, source.models.lead, issues);
550
+ if (source.models.limitWindow !== undefined && !parseWindow(source.models.limitWindow))
551
+ issues.push(`${owner}.limitWindow: not a duration such as "5h" or "1d 12h"; read as 5h`);
552
+ if (
553
+ source.models.allowLeadOverride !== undefined &&
554
+ typeof source.models.allowLeadOverride !== "boolean"
555
+ )
556
+ issues.push(`${owner}.allowLeadOverride: not true or false; read as false`);
557
+ for (const [role, value] of Object.entries(rolesOf(source.models) ?? {})) {
558
+ const where = `${base}.${role}`;
559
+ if (!(MODEL_ROLES as readonly string[]).includes(role))
560
+ issues.push(`${base}.${role}: unknown role; ignored`);
561
+ else if (role === "implementer") {
562
+ if (!isObject(value)) issues.push(`${where}: not an object keyed by tier; ignored`);
563
+ else
564
+ for (const [tier, entry] of Object.entries(value))
565
+ if (!(MODEL_TIERS as readonly string[]).includes(tier))
566
+ issues.push(`${where}.${tier}: unknown tier; ignored`);
567
+ else valueIssues(`${where}.${tier}`, entry, issues);
568
+ } else valueIssues(where, value, issues);
569
+ }
570
+ }
571
+ return issues;
572
+ };
573
+
574
+ /** A Workit agent by name on any host: `workit:<role>`, `workit-<role>` or the bare role. */
575
+ const AGENT_ROLE = /^(?:workit[:-])?(decider|implementer|verifier|reviewer|explore|babysit)$/iu;
576
+ /** A `ROLE: <role>` line in a spawn prompt, for a host agent Workit does not ship. */
577
+ const ROLE_LINE = /^[ \t]*ROLE:[ \t]*(decider|implementer|verifier|reviewer|explore|babysit)\b/imu;
578
+ const TIER_LINE = /^[ \t]*TIER:[ \t]*(mundane|standard|hard)\b/imu;
579
+ /** A Workit implementer brief: its TIER and WORKTREE lines. */
580
+ const isBrief = (text: string): boolean => TIER_LINE.test(text) && /^WORKTREE:/mu.test(text);
581
+ /** Spawn types with no role of their own: Claude Code's general-purpose (also its default), OpenCode's general. */
582
+ const GENERAL = new Set(["", "general-purpose", "general"]);
583
+
584
+ /**
585
+ * The role a subagent spawn runs as, on any host: a Workit agent by name
586
+ * (`workit:verifier`, `workit-verifier`, `verifier`, Claude Code's
587
+ * `Explore`), else a `ROLE: <role>` line in the prompt, else an implementer
588
+ * brief (TIER and WORKTREE lines) or `default` for a general spawn. A fork
589
+ * (it inherits its parent's model) and any other type map to none. An
590
+ * implementer's tier is its prompt's `TIER: <tier>` line, `standard` without one.
591
+ */
592
+ export const roleForAgentType = (agentType: unknown, prompt?: unknown): RoleSelector | null => {
593
+ const type = typeof agentType === "string" ? agentType.trim() : agentType == null ? "" : null;
594
+ if (type === null || type === "fork") return null;
595
+ const text = typeof prompt === "string" ? prompt : "";
596
+ const general = GENERAL.has(type) ? (isBrief(text) ? "implementer" : "default") : null;
597
+ const role = AGENT_ROLE.exec(type)?.[1] ?? ROLE_LINE.exec(text)?.[1] ?? general;
598
+ if (!role) return null;
599
+ const lower = role.toLowerCase() as ModelRole;
600
+ if (lower !== "implementer") return { role: lower };
601
+ const tier = TIER_LINE.exec(text)?.[1]?.toLowerCase() as ModelTier | undefined;
602
+ return { role: lower, tier: tier ?? "standard" };
603
+ };