@hizliemre/horse-code 0.3.0 → 0.4.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 (32) hide show
  1. package/dist/{app-5FXHE7GX.js → app-KR7TAHUD.js} +82 -40
  2. package/dist/{chunk-LNW557IO.js → chunk-372X5HHU.js} +2 -2
  3. package/dist/{chunk-XEGQT5EN.js → chunk-4M6LXNG2.js} +1 -1
  4. package/dist/{chunk-6OSEQOYY.js → chunk-6S4WWQMN.js} +2 -2
  5. package/dist/chunk-ACTVFJRW.js +989 -0
  6. package/dist/{chunk-LLL7QWXB.js → chunk-BFIZMM4G.js} +6 -6
  7. package/dist/chunk-CYLPQWIF.js +214 -0
  8. package/dist/{chunk-AE36LLL2.js → chunk-JLWQCA7B.js} +2 -209
  9. package/dist/{chunk-XYZVZPAY.js → chunk-KXBYRU4W.js} +66 -31
  10. package/dist/chunk-MZM24M5M.js +251 -0
  11. package/dist/chunk-NBTH2VVI.js +1945 -0
  12. package/dist/chunk-PIG54WFU.js +2873 -0
  13. package/dist/{run-P6ZYL5JL.js → chunk-QJYVZPLG.js} +133 -389
  14. package/dist/{chunk-YPZP7LYL.js → chunk-UANNVVIU.js} +1 -1
  15. package/dist/{chunk-UGESK765.js → chunk-UTHLEW5V.js} +1 -1
  16. package/dist/{chunk-KAGKX2YT.js → chunk-ZPJP2VH5.js} +10 -1
  17. package/dist/cli.js +262 -69
  18. package/dist/{fix-ONLA45HD.js → fix-SSDUVV4T.js} +11 -8
  19. package/dist/{ongoing-WHYXPW24.js → ongoing-6NUSPSCV.js} +3 -2
  20. package/dist/{project-graph-5HNPRFQG.js → project-graph-OGIM2B33.js} +1 -1
  21. package/dist/research-MHBNZ6SA.js +111 -0
  22. package/dist/run-V5ZLZ3LS.js +274 -0
  23. package/dist/{save-skills-ZW5GY6KV.js → save-skills-NPKTYNAF.js} +2 -2
  24. package/dist/{trace-X6TU3AG6.js → trace-UVMZZRA5.js} +1 -1
  25. package/dist/{trace-adopt-URECQWJV.js → trace-adopt-7HWELJFE.js} +1 -1
  26. package/dist/{trace-run-7U4WJZ3V.js → trace-run-OBOD552Q.js} +9 -4
  27. package/dist/{triage-FCYHD2AQ.js → triage-IFCVL5MA.js} +7 -6
  28. package/dist/{verify-LC57A6H2.js → verify-2DTBG6RG.js} +26 -20
  29. package/package.json +1 -1
  30. package/dist/chunk-MRZVA5JB.js +0 -163
  31. package/dist/chunk-UEWVVN5L.js +0 -5691
  32. package/dist/{chunk-EAF22QIG.js → chunk-JR2JLRE3.js} +3 -3
@@ -0,0 +1,989 @@
1
+ import {
2
+ CODE_TEAM,
3
+ DEFAULT_COUNCIL,
4
+ PLAN_TEAM,
5
+ SPEC_TEAM,
6
+ applySkills
7
+ } from "./chunk-MZM24M5M.js";
8
+ import {
9
+ CLI_KINDS
10
+ } from "./chunk-G45RWL7S.js";
11
+ import {
12
+ defaultGitRunner
13
+ } from "./chunk-LPQU436C.js";
14
+ import {
15
+ briefForPrompt,
16
+ briefPrompt,
17
+ briefStatus,
18
+ gatherBriefInput,
19
+ saveBrief
20
+ } from "./chunk-6S4WWQMN.js";
21
+ import {
22
+ ensureGitignore,
23
+ loadTraceIndex,
24
+ planTraces,
25
+ pruneTraces,
26
+ saveTrace,
27
+ saveTraceIndex,
28
+ traceCoverage,
29
+ tracePrompt,
30
+ traceRootRel,
31
+ traceable
32
+ } from "./chunk-ZPJP2VH5.js";
33
+ import {
34
+ loadGraph
35
+ } from "./chunk-4M6LXNG2.js";
36
+
37
+ // src/agents/cli-models.ts
38
+ var CLAUDE_MODELS = ["fable", "opus", "sonnet", "haiku"];
39
+ var CODEX_MODELS = ["gpt-5.6-terra", "gpt-5.6-sol", "gpt-5.6-luna"];
40
+ var CODEX_DEFAULT = "gpt-5.6-terra";
41
+ var GROK_MODELS = ["grok-4.6", "grok-4.5"];
42
+ function grokEffort(effort) {
43
+ const e = effort.toLowerCase();
44
+ if (e === "xhigh" || e === "high" || e === "medium" || e === "low") return e;
45
+ if (e === "max" || e === "ultra") return "xhigh";
46
+ if (e === "minimal") return "low";
47
+ return void 0;
48
+ }
49
+ var ZAI_MODELS = ["glm-5.3", "glm-5.3-flash"];
50
+ function modelsFor(kind) {
51
+ if (kind === "claude") return CLAUDE_MODELS;
52
+ if (kind === "codex") return CODEX_MODELS;
53
+ if (kind === "grok") return GROK_MODELS;
54
+ return ZAI_MODELS;
55
+ }
56
+ function cliCatalog() {
57
+ return CLI_KINDS.flatMap((k) => [...modelsFor(k)]);
58
+ }
59
+ function cliFor(model) {
60
+ const m = model.toLowerCase().replace(/^no-think\//, "").replace(/^(cc|claude|cx|codex)\//, "");
61
+ if (/^(fable|opus|sonnet|haiku)\b/.test(m) || m.startsWith("claude")) return "claude";
62
+ if (/^(codex|gpt|o[0-9])\b/.test(m)) return "codex";
63
+ if (/^grok(-|$)/.test(m)) return "grok";
64
+ if (/^glm(-|$)/.test(m)) return "zai";
65
+ return void 0;
66
+ }
67
+ function cliInvocation(model) {
68
+ const bare = model.replace(/^no-think\//, "").replace(/^(cc|claude|cx|codex)\//, "");
69
+ const effort = /-(ultra|max|xhigh|high|medium|low|minimal)$/.exec(bare)?.[1];
70
+ const name = effort ? bare.slice(0, -(effort.length + 1)) : bare;
71
+ const resolved = name === "codex" ? CODEX_DEFAULT : name;
72
+ return {
73
+ ...resolved ? { model: resolved } : {},
74
+ ...effort ? { effort } : {}
75
+ };
76
+ }
77
+
78
+ // src/providers/anthropic.ts
79
+ function isAnthropicModel(model) {
80
+ return /(^|\/)(claude|fable|mythos)/i.test(model) || /claude/i.test(model);
81
+ }
82
+
83
+ // src/tui/role-models.ts
84
+ var WEAK_RE = /\b(flash|mini|nano|haiku|lite|small|turbo|fast|\d{1,2}b)\b/i;
85
+ var FLAGSHIP_ROLES = ["judge", "principal-coder"];
86
+ var COUNCIL_ROLES = DEFAULT_COUNCIL.map((c) => c.name);
87
+ var SPEC_LENS_ROLES = SPEC_TEAM.map((c) => c.name);
88
+ var PLAN_LENS_ROLES = PLAN_TEAM.map((c) => c.name);
89
+ var CODE_LENS_ROLES = CODE_TEAM.map((c) => c.name);
90
+ var STRONG_ROLES = [
91
+ "brainstormer",
92
+ "analyst",
93
+ "planner",
94
+ "architect",
95
+ "senior-coder",
96
+ "senior-designer",
97
+ ...COUNCIL_ROLES,
98
+ ...PLAN_LENS_ROLES,
99
+ ...CODE_LENS_ROLES
100
+ ];
101
+ var MID_ROLES = ["coach", "coder", "designer", "code-reviewer", "operational", "memory-keeper", "task-auditor", ...SPEC_LENS_ROLES];
102
+ var FAST_ROLES = ["refiner", "router", "project-manager", "team-lead"];
103
+ var CAPABLE_ROLES = /* @__PURE__ */ new Set([...FLAGSHIP_ROLES, ...STRONG_ROLES, ...MID_ROLES]);
104
+ var ROLE_PROFILES = {
105
+ tracer: "Writes the per-file reference note every other agent reads before changing unfamiliar code \u2014 high volume, but its output is a COMMITTED FILE, not a turn in a conversation: a shallow note is believed by every agent that opens that file, forever, and nothing later corrects it. Give it the MOST capable non-[flagship] model in the catalogue, not merely one that qualifies as [strong]. Volume is not a reason to go cheaper here.",
106
+ refiner: "Classifies intent and rewrites the prompt every turn \u2014 highest call volume, trivial task \u2192 a fast, cheap model.",
107
+ router: "Picks coder-vs-designer for a task \u2014 tiny and frequent \u2192 fast, cheap.",
108
+ "project-manager": "Turns a task list into board items \u2014 light and structured \u2192 fast, cheap.",
109
+ "task-auditor": "The only check on the task breakdown before hours of implementation are spent executing it \u2014 reads the plan against the task list and finds what was dropped. Low volume, and everything downstream depends on it \u2192 a capable model, never the cheapest.",
110
+ "team-lead": "Coordinates implementation waves \u2014 light orchestration \u2192 fast, cheap.",
111
+ coach: "Your main interactive assistant, used constantly all session (highest interaction volume) \u2192 a capable but EFFICIENT model, never the costly flagship.",
112
+ brainstormer: "Turns a raw request into a decided design before the spec: explores the repo, weighs 2-3 approaches, gets the user to choose. Low volume, sets the direction for everything downstream \u2192 a strong reasoning model.",
113
+ analyst: "Authors the spec and constitution \u2192 a strong reasoning model (Opus-tier).",
114
+ planner: "Designs the implementation plan \u2192 a strong reasoning model (Opus-tier).",
115
+ architect: "Diagnoses stuck tasks and produces recovery plans \u2014 serious design work \u2192 a strong model.",
116
+ judge: "Critiques specs/plans and makes the final review call \u2014 low volume, high stakes \u2192 the most capable flagship model.",
117
+ coder: "Writes the bulk of the implementation \u2014 very high work volume \u2192 a good high-throughput coding model (Sonnet-tier), NOT the flagship (wasteful at this volume).",
118
+ "senior-coder": "Reviews and revises above the coder \u2014 must be MORE capable than the coder (Opus-tier).",
119
+ "principal-coder": "Final code decision-maker \u2014 low volume, high stakes \u2192 the flagship is appropriate.",
120
+ designer: "Builds UI \u2014 high volume \u2192 a capable coding/design model, not the flagship.",
121
+ "senior-designer": "Senior UI reviewer \u2014 more capable than the designer.",
122
+ "code-reviewer": "Reviews diffs \u2014 moderate volume \u2192 a solid capable model.",
123
+ "memory-keeper": "Decides what a finished job taught the project and writes it to durable memory \u2014 low volume, but a bad memory poisons every later run \u2192 a capable, efficient model, never the cheapest.",
124
+ operational: "Handles version control: writes conventional commit messages and (later) drives merges/conflicts \u2014 high volume \u2192 a capable, efficient model."
125
+ };
126
+ for (const [stage, lenses, heft] of [
127
+ ["spec", SPEC_TEAM, "a capable, efficient model (a spec is a short business-level doc)"],
128
+ ["plan", PLAN_TEAM, "a strong model (technical design judgment)"],
129
+ ["code", CODE_TEAM, "a strong model (reads real implementations)"]
130
+ ]) {
131
+ for (const l of lenses) ROLE_PROFILES[l.name] = `${stage.toUpperCase()}-review lens \u2014 ${l.perspective}. Low volume, quality-critical \u2192 ${heft}.`;
132
+ }
133
+ for (const c of DEFAULT_COUNCIL) {
134
+ ROLE_PROFILES[c.name] = `Review COUNCIL decider \u2014 ${c.perspective} Casts the binding pass/revise vote on contested work \u2192 a strong model.`;
135
+ }
136
+ var ROLE_ADVICE = ROLE_PROFILES;
137
+ function filterModelsForRole(role, all, exclude = []) {
138
+ const advice = ROLE_ADVICE[role];
139
+ const excluded = new Set(exclude);
140
+ const avail = all.filter((m) => !excluded.has(m));
141
+ if (CAPABLE_ROLES.has(role)) {
142
+ const strong = avail.filter((m) => !WEAK_RE.test(m));
143
+ if (strong.length === 0) return { models: avail.length ? avail : all, note: advice ? `${advice} (No strong models detected \u2014 showing all.)` : void 0 };
144
+ return { models: strong, note: `${advice ?? ""} Showing ${strong.length} of ${avail.length} models (fast/weak models hidden for this role).`.trim() };
145
+ }
146
+ if (FAST_ROLES.includes(role)) {
147
+ const fast = avail.filter((m) => WEAK_RE.test(m));
148
+ if (fast.length === 0) return { models: avail.length ? avail : all, note: advice };
149
+ return { models: fast, note: `${advice ?? ""} Showing ${fast.length} of ${avail.length} fast/cheap models.`.trim() };
150
+ }
151
+ return { models: avail.length ? avail : all };
152
+ }
153
+ function effortFor(role, model) {
154
+ if (!isAnthropicModel(model)) return void 0;
155
+ if (FLAGSHIP_ROLES.includes(role)) return "max";
156
+ if (STRONG_ROLES.includes(role)) return "xhigh";
157
+ if (FAST_ROLES.includes(role)) return "low";
158
+ if (MID_ROLES.includes(role)) return "high";
159
+ return void 0;
160
+ }
161
+ var effortBump = (s) => /-(ultra|max|xhigh)/.test(s) ? 4 : /-high/.test(s) ? 3 : /-medium/.test(s) ? 2 : /-low/.test(s) ? 1 : 0;
162
+ var versionBump = (s, family) => {
163
+ if (family) {
164
+ const m = s.match(new RegExp(`${family}[-_. ]?(\\d+)(?:[-.](\\d+))?`));
165
+ if (m) {
166
+ const major = Number(m[1]);
167
+ const minor = m[2] === void 0 ? 0 : Number(m[2]);
168
+ if (major < 100) return major + (minor < 10 ? minor / 10 : minor / 100);
169
+ }
170
+ }
171
+ const g = s.match(/(\d)[-.](\d)\b/);
172
+ return g ? Number(g[1]) + Number(g[2]) / 10 : 0;
173
+ };
174
+ var KNOWN_FAMILY_RE = /(fable|mythos|opus|sonnet|haiku|claude|codex|gpt-|\bo\d\b|gemini|deepseek|llama|qwen|kimi|glm|mistral|grok|nova|command-r|phi-\d)/i;
175
+ var NON_TEXT_RE = /\b(image|imagen|vision|video|veo|tts|audio|speech|voice|embed|embedding|rerank|ocr|computer-use|realtime|moderation)\b/i;
176
+ function isKnownModel(model) {
177
+ return KNOWN_FAMILY_RE.test(model) && !NON_TEXT_RE.test(model);
178
+ }
179
+ var UNRANKED_SCORE = 50;
180
+ function capabilityScore(model) {
181
+ const s = model.toLowerCase();
182
+ if (WEAK_RE.test(s)) return 20 + effortBump(s);
183
+ if (/fable|mythos/.test(s)) return 100;
184
+ if (/opus/.test(s)) return 88 + versionBump(s, "opus");
185
+ if (/codex|gpt-5|\bo3\b/.test(s)) return 82 + effortBump(s) + versionBump(s, "gpt") / 100;
186
+ if (/sonnet/.test(s)) return 78 + versionBump(s, "sonnet");
187
+ if (/grok/.test(s)) return 78 + versionBump(s, "grok");
188
+ if (/glm/.test(s)) return 78 + versionBump(s, "glm");
189
+ if (/gemini/.test(s) && /pro/.test(s)) return 76 + versionBump(s, "gemini") + effortBump(s);
190
+ if (/gpt-4/.test(s)) return 65;
191
+ if (/deepseek/.test(s)) return 55;
192
+ return UNRANKED_SCORE;
193
+ }
194
+ function mostCapable(models) {
195
+ return [...models].sort((a, b) => capabilityScore(b) - capabilityScore(a))[0] ?? "";
196
+ }
197
+ function modelBand(model) {
198
+ if (WEAK_RE.test(model)) return "fast";
199
+ const s = capabilityScore(model);
200
+ if (s >= 95) return "flagship";
201
+ if (s >= 84) return "strong";
202
+ if (s <= UNRANKED_SCORE) return "fast";
203
+ return "mid";
204
+ }
205
+ function baseModel(model) {
206
+ const segs = model.toLowerCase().split("/");
207
+ let s = segs[segs.length - 1];
208
+ s = s.replace(/-(ultra|max|xhigh|high|medium|low|free|thinking|preview)\b/g, "");
209
+ s = s.replace(/-\d{6,8}\b/g, "");
210
+ return s.replace(/-+$/, "");
211
+ }
212
+ function modelFamily(model) {
213
+ return baseModel(model).replace(/[-.]v?\d+(?:[-.]\d+)*(?=[-.]|$)/g, "").replace(/[-.]{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
214
+ }
215
+ function latestFirst(models) {
216
+ const best = /* @__PURE__ */ new Map();
217
+ for (const m of models) {
218
+ const key = modelFamily(m);
219
+ const cur = best.get(key);
220
+ if (!cur || capabilityScore(m) > capabilityScore(cur)) best.set(key, m);
221
+ }
222
+ const isLatest = (m) => best.get(modelFamily(m)) === m;
223
+ return [...models.filter(isLatest), ...models.filter((m) => !isLatest(m))];
224
+ }
225
+ function versionlessId(model) {
226
+ const cut = model.lastIndexOf("/");
227
+ const prefix = cut >= 0 ? model.slice(0, cut + 1) : "";
228
+ const name = model.slice(cut + 1).toLowerCase().replace(/-\d{6,8}\b/g, "").replace(/[-.]v?\d+(?:[-.]\d+)*(?=[-.]|$)/g, "").replace(/[-.]{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
229
+ return prefix + name;
230
+ }
231
+ var DURABLE_ROLES = ["tracer"];
232
+ function strongestPrimary(chain, pool) {
233
+ const head = chain[0];
234
+ if (!head) return chain;
235
+ let best = head;
236
+ for (const m of pool) {
237
+ if (modelBand(m) === "flagship" || !isKnownModel(m)) continue;
238
+ if (capabilityScore(m) > capabilityScore(best)) best = m;
239
+ }
240
+ if (best === head) return chain;
241
+ const at = chain.indexOf(best);
242
+ if (at > 0) {
243
+ const next = [...chain];
244
+ next[at] = head;
245
+ next[0] = best;
246
+ return next;
247
+ }
248
+ return [best, ...chain.slice(1)];
249
+ }
250
+ function newestPrimary(chain, pool) {
251
+ const head = chain[0];
252
+ if (!head) return chain;
253
+ const key = versionlessId(head);
254
+ let best = head;
255
+ for (const m of pool) {
256
+ if (versionlessId(m) !== key) continue;
257
+ if (capabilityScore(m) > capabilityScore(best)) best = m;
258
+ }
259
+ if (best === head) return chain;
260
+ const at = chain.indexOf(best);
261
+ if (at > 0) {
262
+ const next = [...chain];
263
+ next[at] = head;
264
+ next[0] = best;
265
+ return next;
266
+ }
267
+ return [best, ...chain.slice(1)];
268
+ }
269
+ function dedupBest(models) {
270
+ const best = /* @__PURE__ */ new Map();
271
+ for (const m of models) {
272
+ const key = baseModel(m);
273
+ const cur = best.get(key);
274
+ if (!cur || capabilityScore(m) > capabilityScore(cur)) best.set(key, m);
275
+ }
276
+ return [...best.values()].sort((a, b) => capabilityScore(b) - capabilityScore(a));
277
+ }
278
+ function sourceOf(model) {
279
+ const s = model.toLowerCase().replace(/^no-think\//, "");
280
+ return cliFor(s) ?? s.split("/")[0];
281
+ }
282
+ function interleaveBySource(pool) {
283
+ const bySource = /* @__PURE__ */ new Map();
284
+ for (const m of pool) {
285
+ const s = sourceOf(m);
286
+ const q = bySource.get(s);
287
+ if (q) q.push(m);
288
+ else bySource.set(s, [m]);
289
+ }
290
+ const queues = [...bySource.values()];
291
+ const out = [];
292
+ for (let more = true; more; ) {
293
+ more = false;
294
+ for (const q of queues) {
295
+ const m = q.shift();
296
+ if (m !== void 0) {
297
+ out.push(m);
298
+ more = true;
299
+ }
300
+ }
301
+ }
302
+ return out;
303
+ }
304
+ var BAND_ORDER = { fast: 0, mid: 1, strong: 2, flagship: 3 };
305
+ function bandDistance(primary, candidate) {
306
+ const p = BAND_ORDER[modelBand(primary)];
307
+ const c = BAND_ORDER[modelBand(candidate)];
308
+ return Math.abs(c - p) * 2 + (c < p ? 1 : 0);
309
+ }
310
+ var MAX_BAND_DROP = 1;
311
+ function isPeer(primary, candidate) {
312
+ return BAND_ORDER[modelBand(primary)] - BAND_ORDER[modelBand(candidate)] <= MAX_BAND_DROP;
313
+ }
314
+ function pickFallbacks(primary, pool, n) {
315
+ const chosen = [];
316
+ const usedModels = /* @__PURE__ */ new Set([baseModel(primary)]);
317
+ const usedSources = /* @__PURE__ */ new Set([sourceOf(primary)]);
318
+ const byHeft = pool.map((m, i) => ({ m, i })).sort((a, b) => bandDistance(primary, a.m) - bandDistance(primary, b.m) || a.i - b.i).map((x) => x.m);
319
+ for (const m of byHeft) {
320
+ if (chosen.length >= n) break;
321
+ if (usedModels.has(baseModel(m)) || usedSources.has(sourceOf(m))) continue;
322
+ if (!isPeer(primary, m)) continue;
323
+ chosen.push(m);
324
+ usedModels.add(baseModel(m));
325
+ usedSources.add(sourceOf(m));
326
+ }
327
+ return chosen;
328
+ }
329
+ function bestPerSource(pool, exclude = []) {
330
+ const skip = new Set(exclude.map(baseModel));
331
+ const best = /* @__PURE__ */ new Map();
332
+ for (const m of pool) {
333
+ if (skip.has(baseModel(m))) continue;
334
+ const src = sourceOf(m);
335
+ const held = best.get(src);
336
+ if (!held || capabilityScore(m) > capabilityScore(held)) best.set(src, m);
337
+ }
338
+ return interleaveBySource([...best.values()].sort((a, b) => capabilityScore(b) - capabilityScore(a)));
339
+ }
340
+ var FALLBACK_COUNT = 2;
341
+ function adjustRoleModels(roles, models, unfit) {
342
+ if (models.length === 0) return [];
343
+ const recognised = models.filter(isKnownModel);
344
+ const pick = recognised.length ? recognised : models;
345
+ const capable = dedupBest(pick.filter((m) => !WEAK_RE.test(m)));
346
+ const fast = dedupBest(pick.filter((m) => WEAK_RE.test(m)));
347
+ const capablePool = capable.length ? capable : fast;
348
+ const fastPool = fast.length ? fast : capable;
349
+ const primaryPool = latestFirst(capablePool);
350
+ const primaryFast = latestFirst(fastPool);
351
+ const nonFlagship = primaryPool.filter((m) => modelBand(m) !== "flagship");
352
+ const strongPool = primaryPool.filter((m) => modelBand(m) === "strong");
353
+ const midPool = primaryPool.filter((m) => modelBand(m) === "mid");
354
+ const wanted = new Set(roles);
355
+ const forRole = (role, pool) => {
356
+ if (!unfit) return pool;
357
+ const fit = pool.filter((m) => !unfit(role, m));
358
+ return fit.length ? fit : pool;
359
+ };
360
+ const known = /* @__PURE__ */ new Set([...FLAGSHIP_ROLES, ...STRONG_ROLES, ...MID_ROLES, ...FAST_ROLES]);
361
+ const primary = /* @__PURE__ */ new Map();
362
+ const flagSrc = primaryPool;
363
+ FLAGSHIP_ROLES.filter((r) => wanted.has(r)).forEach((r, i) => {
364
+ const src = forRole(r, flagSrc);
365
+ primary.set(r, src[i % src.length]);
366
+ });
367
+ const strongSrc = bestPerSource(nonFlagship.length ? nonFlagship : primaryPool);
368
+ STRONG_ROLES.filter((r) => wanted.has(r)).concat(roles.filter((r) => !known.has(r))).forEach((r, i) => {
369
+ const src = forRole(r, strongSrc);
370
+ primary.set(r, src[i % src.length]);
371
+ });
372
+ const midSrc = bestPerSource(midPool.length ? midPool : nonFlagship.length ? nonFlagship : primaryPool);
373
+ MID_ROLES.filter((r) => wanted.has(r)).forEach((r, i) => {
374
+ const src = forRole(r, midSrc);
375
+ primary.set(r, src[i % src.length]);
376
+ });
377
+ FAST_ROLES.filter((r) => wanted.has(r)).forEach((r, i) => {
378
+ const src = forRole(r, primaryFast);
379
+ primary.set(r, src[i % src.length]);
380
+ });
381
+ return roles.map((role) => {
382
+ const head = primary.get(role) ?? primaryPool[0];
383
+ const capForFb = MID_ROLES.includes(role) ? capablePool.filter((m) => modelBand(m) !== "flagship") : capablePool;
384
+ const pool = FAST_ROLES.includes(role) ? [...fastPool, ...capForFb] : [...capForFb, ...fastPool];
385
+ return { role, models: newestPrimary([head, ...pickFallbacks(head, forRole(role, pool), FALLBACK_COUNT)], models) };
386
+ });
387
+ }
388
+
389
+ // src/agent/roles.ts
390
+ function isTransientFailure(reason) {
391
+ const r = reason.toLowerCase();
392
+ if (/\b(429|rate.?limit|quota|exhaust|insufficient|billing|credit)\b/.test(r)) return false;
393
+ return /overload|529|50[0234]|timeout|timed out|deadline|econnreset|epipe|socket hang up|stream ended|temporar|unavailable|try again/.test(r);
394
+ }
395
+ function isSourceCapacity(reason) {
396
+ return /capacity is (?:temporarily unavailable|busy)/i.test(reason);
397
+ }
398
+ function sourcePrefix(model) {
399
+ const s = model.replace(/^no-think\//, "");
400
+ const cli = cliFor(s);
401
+ if (cli) return cli;
402
+ const i = s.indexOf("/");
403
+ return i > 0 ? s.slice(0, i) : void 0;
404
+ }
405
+ function weightedCycle(sources, weights) {
406
+ const queues = sources.map((s) => Array(Math.max(1, weights[s] ?? 1)).fill(s));
407
+ const out = [];
408
+ for (let more = true; more; ) {
409
+ more = false;
410
+ for (const q of queues) {
411
+ const m = q.shift();
412
+ if (m !== void 0) {
413
+ out.push(m);
414
+ more = true;
415
+ }
416
+ }
417
+ }
418
+ return out;
419
+ }
420
+ function canonicalSource(name) {
421
+ const s = name.toLowerCase().replace(/^no-think\//, "");
422
+ if (s === "cc" || s === "claude") return "claude";
423
+ if (s === "cx") return "codex";
424
+ return sourcePrefix(s) ?? s;
425
+ }
426
+ function providerOutage(reason) {
427
+ return /no active credentials for provider:?\s*([\w.-]+)/i.exec(reason)?.[1] ?? /provider\s+'?([\w.-]+)'?\s+is not configured/i.exec(reason)?.[1] ?? /all\s+([\w.-]+)\s+accounts have exhausted their quota/i.exec(reason)?.[1] ?? /shared egress ip quota exhausted\s*\(([\w.-]+)\)/i.exec(reason)?.[1] ?? /^\s*(claude|codex|grok|zai)\s+CLI:\s*rejected\b/i.exec(reason)?.[1]?.toLowerCase();
428
+ }
429
+ function quotaResetAt(reason) {
430
+ const iso = /\(resets\s+([0-9T:.\-]+Z)\)/i.exec(reason)?.[1];
431
+ const t = iso ? Date.parse(iso) : NaN;
432
+ return Number.isFinite(t) ? t : void 0;
433
+ }
434
+ var RoleRegistry = class _RoleRegistry {
435
+ // durable behavioral rules → appended to EVERY role's prompt
436
+ constructor(roles, defaultPrompts = {}, skillRegistry) {
437
+ this.roles = roles;
438
+ this.defaultPrompts = defaultPrompts;
439
+ this.skillRegistry = skillRegistry;
440
+ }
441
+ roles;
442
+ defaultPrompts;
443
+ skillRegistry;
444
+ modelOverride;
445
+ roleOverrides = /* @__PURE__ */ new Map();
446
+ // per-role model CHAIN override (highest priority)
447
+ effortOverrides = /* @__PURE__ */ new Map();
448
+ // Models that failed retryably (429/5xx/quota) → skipped in every chain until released. Kept WITH the
449
+ // reason and the time so a coordinator can report them and later re-probe whether the limit has reset.
450
+ quarantine = /* @__PURE__ */ new Map();
451
+ notify;
452
+ // fallback UI note sink (wired once the controller exists)
453
+ onQuarantine;
454
+ /** What each model has actually managed to do in each ROLE — see setFitness. */
455
+ fitness;
456
+ // Models that answered in prose instead of calling the submit tool. Not a transport error, so nothing ever
457
+ // benched them: the chain quietly slid to the fallback on EVERY call, forever, in every role that held them.
458
+ strikes = /* @__PURE__ */ new Map();
459
+ rulesProvider;
460
+ /** Every configured role name — used to validate a role reference produced by a model (memory audiences). */
461
+ names() {
462
+ return [.../* @__PURE__ */ new Set([...Object.keys(this.roles), ...Object.keys(this.defaultPrompts)])];
463
+ }
464
+ /** Wire the fallback-note sink (called after the controller exists). */
465
+ setNotify(fn) {
466
+ this.notify = fn;
467
+ }
468
+ /** Wire the durable-rules source (memory). Rules are appended to every role's system prompt (always honored). */
469
+ setRules(fn) {
470
+ this.rulesProvider = fn;
471
+ }
472
+ /** The rule block to append to a role's prompt — empty when there are no rules. Public so prompt-supplying
473
+ * callers (spec-kit phases build their own prompt) can append it too. */
474
+ ruleSuffix() {
475
+ const rules = this.rulesProvider?.() ?? [];
476
+ return rules.length ? `
477
+
478
+ User rules (ALWAYS honor these):
479
+ ${rules.map((r) => `- ${r}`).join("\n")}` : "";
480
+ }
481
+ /** Live-swap the model used by every role (session-only; clears on undefined/empty). */
482
+ setModelOverride(model) {
483
+ this.modelOverride = model && model.length > 0 ? model : void 0;
484
+ }
485
+ /** Live-swap the model CHAIN of ONE role (session-only; wins over the global override). Clears on empty. */
486
+ setRoleModel(roleName, models) {
487
+ const chain = (typeof models === "string" ? [models] : models ?? []).filter((m) => m.length > 0);
488
+ if (chain.length) this.roleOverrides.set(roleName, chain);
489
+ else this.roleOverrides.delete(roleName);
490
+ }
491
+ /**
492
+ * How hard this role should work, set alongside its chain.
493
+ *
494
+ * An override on the live registry rather than a config re-read, for the same reason `setRoleModel` is one:
495
+ * `/roles adjust` has to take effect in the session that ran it, not only in the next one.
496
+ *
497
+ * `undefined` REMOVES it — a role reassigned from a Claude model to one whose effort cannot be set must
498
+ * stop carrying a level, or the config keeps a number that no longer applies to anything.
499
+ */
500
+ setRoleEffort(roleName, effort) {
501
+ if (effort) this.effortOverrides.set(roleName, effort);
502
+ else this.effortOverrides.delete(roleName);
503
+ }
504
+ /**
505
+ * Wire the record of what each model has actually managed to do in each role.
506
+ *
507
+ * Without it a chain is only a list of names from a catalogue. With it, a model that has twice answered
508
+ * this role in prose instead of doing its work stops being offered to this role — while staying available
509
+ * to every other role, where it may be perfectly good.
510
+ */
511
+ setFitness(f) {
512
+ this.fitness = f;
513
+ }
514
+ /** Wire the quarantine hook: whatever benches a model, every role still holding it must be re-assigned. */
515
+ setOnQuarantine(fn) {
516
+ this.onQuarantine = fn;
517
+ }
518
+ /** Mark a model spent — every chain skips it from now on, until it is released. */
519
+ markExhausted(model, reason = "unavailable", now = Date.now(), until) {
520
+ if (!model || this.isQuarantined(model)) return;
521
+ const ends = until ?? (isTransientFailure(reason) ? now + _RoleRegistry.TRANSIENT_BENCH_MS : void 0);
522
+ this.quarantine.set(model, { at: now, reason, ...ends !== void 0 && { until: ends } });
523
+ this.onQuarantine?.(model, reason, ends);
524
+ }
525
+ /** Every model any role's chain names — the pool this registry can actually reach for. */
526
+ knownModels() {
527
+ return [...new Set(Object.values(this.roles).flatMap((r) => r.models ?? []))];
528
+ }
529
+ /**
530
+ * Benches every model of one provider, for a failure that is about the provider itself.
531
+ *
532
+ * Returns what it took out, so the caller can say so once instead of six times. Falls back to benching the
533
+ * single model when the pool names none of that provider — an unknown provider is still a real failure.
534
+ */
535
+ markProviderExhausted(provider, model, reason, now = Date.now()) {
536
+ const want = canonicalSource(provider);
537
+ const hit = this.knownModels().filter((m) => sourcePrefix(m) === want);
538
+ const until = quotaResetAt(reason);
539
+ for (const m of hit) this.markExhausted(m, reason, now, until);
540
+ if (!hit.length) {
541
+ this.markExhausted(model, reason, now);
542
+ return [model];
543
+ }
544
+ return hit;
545
+ }
546
+ /**
547
+ * How long a BEHAVIOURAL bench lasts before the model is tried again.
548
+ *
549
+ * A model that is out of quota is out until the quota returns, and nothing here can shorten that. A model
550
+ * that answered in prose is a different case entirely: the transport was fine, and the next prompt may not
551
+ * be the one it stumbled on. Benching it for the rest of a multi-hour run costs every role that held it —
552
+ * measured live, one such bench re-assigned SIXTEEN roles away from the best model available.
553
+ */
554
+ static STRUCTURAL_BENCH_MS = 10 * 6e4;
555
+ /**
556
+ * How long a TRANSPORT bench lasts — the busy server, not the spent subscription.
557
+ *
558
+ * The argument above, one door over. A model that answered in prose gets ten minutes because the transport
559
+ * was fine; a model whose transport said "Overloaded" for one second is the same case in its purest form,
560
+ * and it was the one getting benched for the whole session.
561
+ *
562
+ * Measured live, in the middle of a feature run: `cc/claude-opus-5` served five calls in the preceding two
563
+ * minutes (23.8s, 2.9s, 3.1s, 25.3s, 38.7s, all ok), then one 529 in 1.7 seconds — and 18 roles were moved
564
+ * off the best model in the fleet for the rest of the session. A 529 is the textbook transient condition;
565
+ * the API's own guidance for it is to retry with backoff.
566
+ *
567
+ * Two minutes: long enough that a genuinely struggling gateway is not hammered, short enough that a
568
+ * one-second blip costs a couple of turns rather than an afternoon.
569
+ */
570
+ static TRANSIENT_BENCH_MS = 2 * 6e4;
571
+ /**
572
+ * How many structured failures a model gets before it is benched. One miss can be a genuinely hard prompt;
573
+ * a pattern is the model. Low, because every strike costs a full wasted pass in every role that holds it.
574
+ */
575
+ static STRUCTURAL_STRIKES = 2;
576
+ /**
577
+ * Records that a model finished a turn WITHOUT producing the structured result it was asked for (prose
578
+ * instead of a tool call). This is not "unavailable" — the transport was fine — so it never reached the
579
+ * retryable path that benches a model, and the chain slid to the fallback on every single call instead.
580
+ * Returns the strike count; at the threshold the model is quarantined like any other spent one.
581
+ */
582
+ markStructuralFailure(model, reason = "no valid structured result", role) {
583
+ if (!model) return 0;
584
+ const key = role ? `${model}::${role}` : model;
585
+ const n = (this.strikes.get(key) ?? 0) + 1;
586
+ this.strikes.set(key, n);
587
+ if (n < _RoleRegistry.STRUCTURAL_STRIKES) return n;
588
+ if (role) {
589
+ this.fitness?.record?.(role, model, reason);
590
+ const rolesFailed = [...this.strikes.entries()].filter(([k, v]) => k.startsWith(`${model}::`) && v >= _RoleRegistry.STRUCTURAL_STRIKES).length;
591
+ if (rolesFailed >= _RoleRegistry.STRUCTURAL_ROLES_BEFORE_BENCH) {
592
+ this.markExhausted(
593
+ model,
594
+ `${reason} (in ${rolesFailed} roles)`,
595
+ Date.now(),
596
+ Date.now() + _RoleRegistry.STRUCTURAL_BENCH_MS
597
+ );
598
+ }
599
+ return n;
600
+ }
601
+ this.markExhausted(model, reason, Date.now(), Date.now() + _RoleRegistry.STRUCTURAL_BENCH_MS);
602
+ return n;
603
+ }
604
+ /**
605
+ * How many DISTINCT roles must reject a model this way before it is benched outright.
606
+ *
607
+ * Two, because one role can have a prompt that a good model reads badly — and the fitness record already
608
+ * takes it out of that role. A second, unrelated role failing the same way is the first evidence that the
609
+ * model, not the prompt, is the problem.
610
+ */
611
+ static STRUCTURAL_ROLES_BEFORE_BENCH = 2;
612
+ /** Models currently quarantined, with why and when — surfaced to the user and re-probed before an adjust. */
613
+ quarantined() {
614
+ return [...this.quarantine].map(([model, q]) => ({ model, ...q }));
615
+ }
616
+ isQuarantined(model, now = Date.now()) {
617
+ const q = this.quarantine.get(model);
618
+ if (!q) return false;
619
+ if (q.until !== void 0 && now >= q.until) {
620
+ this.quarantine.delete(model);
621
+ this.strikes.clear();
622
+ return false;
623
+ }
624
+ return true;
625
+ }
626
+ /** Put a model back in play (its quota reset, or the user forced it). */
627
+ release(model) {
628
+ this.strikes.delete(model);
629
+ return this.quarantine.delete(model);
630
+ }
631
+ /**
632
+ * Roles whose CURRENT chain still contains `model`. When a model is quarantined these are the roles that
633
+ * would otherwise keep resolving to a dead chain, so they are exactly the ones to re-assign.
634
+ */
635
+ rolesUsing(model) {
636
+ return this.names().filter((r) => this.rawChain(r).includes(model));
637
+ }
638
+ /**
639
+ * The role's chain BEFORE quarantine filtering — what was actually assigned to it.
640
+ *
641
+ * The order is the documented one and nothing precedes it: per-role override, then the session model, then
642
+ * the config. It used to bail on an empty CONFIG chain before either override was consulted, which made a
643
+ * role the config had never heard of impossible to assign — the one case where assigning is the whole
644
+ * point. Measured with `tester`, added in a version the user's config predated: `/roles adjust` set the
645
+ * override, the override was skipped, and the role stayed broken for the rest of the session while the
646
+ * error message recommended running `/roles adjust`.
647
+ */
648
+ rawChain(roleName) {
649
+ const perRole = this.roleOverrides.get(roleName);
650
+ if (perRole && perRole.length) return perRole;
651
+ if (this.modelOverride && roleName !== "refiner") return [this.modelOverride];
652
+ return this.roles[roleName]?.models ?? [];
653
+ }
654
+ /** True when every model assigned to this role is quarantined — the chain has collapsed and needs replacing. */
655
+ chainCollapsed(roleName) {
656
+ const raw = this.rawChain(roleName);
657
+ return raw.length > 0 && raw.every((m) => this.isQuarantined(m));
658
+ }
659
+ /** The full model chain for a role by priority: per-role override → global override (non-refiner) → config. */
660
+ chain(roleName) {
661
+ const base = this.rawChain(roleName);
662
+ if (!base.length) return [];
663
+ const live = base.filter((m) => !this.isQuarantined(m));
664
+ const usable = live.length ? live : base;
665
+ const fit = this.fitness ? usable.filter((m) => !this.fitness.unfit(roleName, m)) : usable;
666
+ return fit.length ? fit : usable;
667
+ }
668
+ /**
669
+ * The role's chain ROTATED by `slot`. Parallel workers share one role — five implementers in a wave are all
670
+ * `coder` — so every one of them resolved to the same chain head and hammered a single subscription until it
671
+ * rate-limited. Rotating gives each worker a different lead model while keeping its FULL fallback set, so
672
+ * spreading the load costs no resilience.
673
+ */
674
+ chainFor(roleName, slot = 0) {
675
+ const c = this.chain(roleName);
676
+ if (c.length < 2) return c;
677
+ const order = [];
678
+ for (const m of c) {
679
+ const s = sourceOf(m);
680
+ if (!order.includes(s)) order.push(s);
681
+ }
682
+ const cycle = weightedCycle(order, this.sourceWeights?.() ?? {});
683
+ if (cycle.length) {
684
+ const want = cycle[(slot % cycle.length + cycle.length) % cycle.length];
685
+ const i = c.findIndex((m) => sourceOf(m) === want);
686
+ if (i > 0) return [c[i], ...c.filter((_, j) => j !== i)];
687
+ if (i === 0) return c;
688
+ }
689
+ const k = (slot % c.length + c.length) % c.length;
690
+ return k === 0 ? c : [...c.slice(k), ...c.slice(0, k)];
691
+ }
692
+ /** How many accounts each source has connected — set at the composition root; equal weights without it. */
693
+ sourceWeights;
694
+ /** Wire the account weights (called once the pool exists). */
695
+ setSourceWeights(fn) {
696
+ this.sourceWeights = fn;
697
+ }
698
+ /** The model a role would use next (chain head), for UI display only. */
699
+ peekModel(roleName) {
700
+ return this.chain(roleName)[0] ?? "";
701
+ }
702
+ /**
703
+ * The chain (primary + fallbacks) and session-fallback hooks for a role, WITHOUT its system prompt —
704
+ * for callers that supply their own prompt (e.g. spec-kit phases). resolve() layers the prompt on top.
705
+ */
706
+ fallbackOpts(roleName) {
707
+ const chain = this.chain(roleName);
708
+ const notify = this.notify;
709
+ const effort = this.effortOverrides.get(roleName) ?? this.roles[roleName]?.effort;
710
+ return {
711
+ // Travels with the chain, not with the prompt: the seven callers that take only the chain are exactly
712
+ // the ones whose work is heaviest (the tester, the analyst, the spec-kit phases).
713
+ ...effort ? { effort } : {},
714
+ /**
715
+ * The name belongs to the CHAIN, not to the prompt.
716
+ *
717
+ * It was put on `resolve()` alone, on the assumption that every caller spreads a resolved role. Seven
718
+ * do not: they take the chain from here and supply their own prompt (the spec-kit phases, the tester,
719
+ * the analyst, the question normalizer). Measured on the first run after the change — 19 tool calls,
720
+ * one of them attributed. Everything the attribution was for happens in those seven.
721
+ */
722
+ role: roleName,
723
+ model: chain[0] ?? "",
724
+ fallbacks: chain.slice(1),
725
+ onExhausted: (m, reason) => {
726
+ const why = reason ?? "unavailable";
727
+ const source = providerOutage(why) ?? (isSourceCapacity(why) ? sourcePrefix(m) : void 0);
728
+ if (source) this.markProviderExhausted(source, m, why);
729
+ else this.markExhausted(m, why);
730
+ },
731
+ onStructuralFailure: (m, reason) => this.markStructuralFailure(m, reason, roleName),
732
+ onFallback: notify ? (from, to, reason) => notify(`\u2935 \`${from}\` \u2192 \`${to}\` \u2014 ${reason}`) : void 0
733
+ };
734
+ }
735
+ /** The skills already attached to a role — what task-level routing must not inline a second time. */
736
+ skillsFor(roleName) {
737
+ return this.roles[roleName]?.skills ?? [];
738
+ }
739
+ resolve(roleName) {
740
+ const role = this.roles[roleName];
741
+ if (!role) throw new Error(`undefined role: ${roleName}`);
742
+ if (!this.rawChain(roleName).length) {
743
+ throw new Error(
744
+ `role '${roleName}' has no model defined \u2014 set one with \`/roles setmodel\`, run \`/roles adjust\`, or choose a session model with \`/model\`.`
745
+ );
746
+ }
747
+ let systemPrompt = role.systemPrompt ?? this.defaultPrompts[roleName];
748
+ if (systemPrompt === void 0) throw new Error(`role '${roleName}' has no systemPrompt`);
749
+ if (this.skillRegistry) {
750
+ try {
751
+ systemPrompt = applySkills(systemPrompt, role.skills ?? [], this.skillRegistry);
752
+ } catch (e) {
753
+ throw new Error(`role '${roleName}' skill error: ${e instanceof Error ? e.message : String(e)}`);
754
+ }
755
+ }
756
+ return { ...this.fallbackOpts(roleName), systemPrompt: systemPrompt + this.ruleSuffix() };
757
+ }
758
+ };
759
+
760
+ // src/engine/trace-run.ts
761
+ async function traceableFiles(cwd, opts) {
762
+ const r = await defaultGitRunner(["ls-files", "--cached", "--others", "--exclude-standard"], cwd);
763
+ return traceable(r.stdout.split("\n").filter(Boolean), opts);
764
+ }
765
+ async function coverageFor(cwd) {
766
+ return traceCoverage(cwd, await traceableFiles(cwd), await loadTraceIndex(cwd));
767
+ }
768
+ var TRACE_CONCURRENCY = 6;
769
+ var INDEX_CHECKPOINT = 25;
770
+ function describePlan(plan, models) {
771
+ if (!plan.jobs.length) {
772
+ return plan.upToDate ? `All ${plan.upToDate} traces are current \u2014 nothing to write, nothing to spend.` : "No files to trace.";
773
+ }
774
+ const kIn = Math.round(plan.estimatedInputTokens / 1e3);
775
+ const kOut = Math.round(plan.estimatedOutputTokens / 1e3);
776
+ const skipped = plan.skipped.length ? `
777
+
778
+ ${plan.skipped.length} file(s) skipped as too large to trace economically (e.g. ${plan.skipped.slice(0, 3).map((s) => `\`${s.file}\``).join(", ")}${plan.skipped.length > 3 ? ", \u2026" : ""}).` : "";
779
+ const cached = plan.upToDate ? `
780
+ ${plan.upToDate} file(s) already have a current trace and will be left alone.` : "";
781
+ const [head, ...rest] = models;
782
+ const chain = `\`${head}\`${rest.length ? `, falling back to ${rest.map((m) => `\`${m}\``).join(" then ")}` : ""}`;
783
+ return `**Tracing ${plan.jobs.length} file(s)** with ${chain}.
784
+
785
+ This is the part of understanding your project that costs tokens \u2014 the graph was free, this is not. Each file is read once and described in ~150 words.
786
+
787
+ Rough cost: **~${kIn}k input + ~${kOut}k output tokens**.${cached}${skipped}`;
788
+ }
789
+ var ChatFailure = class extends Error {
790
+ constructor(message, retryable) {
791
+ super(message);
792
+ this.retryable = retryable;
793
+ }
794
+ retryable;
795
+ };
796
+ var BLIND_BENCH_MS = 60 * 60 * 1e3;
797
+ var SpentSources = class {
798
+ until = /* @__PURE__ */ new Map();
799
+ /** Files a refusal against its subscription. Returns the source, when the message named one. */
800
+ record(reason, now = Date.now()) {
801
+ const source = providerOutage(reason);
802
+ if (!source) return void 0;
803
+ this.until.set(source, quotaResetAt(reason) ?? now + BLIND_BENCH_MS);
804
+ return source;
805
+ }
806
+ /** Whether a source is still standing down. A lapsed bench is forgotten as it is read. */
807
+ spent(source, now = Date.now()) {
808
+ const t = this.until.get(source);
809
+ if (t === void 0) return false;
810
+ if (now >= t) {
811
+ this.until.delete(source);
812
+ return false;
813
+ }
814
+ return true;
815
+ }
816
+ /** The links of a chain still worth trying. A model no CLI serves is nobody's subscription, so it stays. */
817
+ live(chain, now = Date.now()) {
818
+ return chain.filter((m) => {
819
+ const s = cliFor(m);
820
+ return !s || !this.spent(s, now);
821
+ });
822
+ }
823
+ };
824
+ async function traceOne(provider, chain, job, signal, spent, brief) {
825
+ if (!spent.live(chain).length) {
826
+ throw new Error(`every subscription in the chain is out of quota (${chain.join(", ")})`);
827
+ }
828
+ let last;
829
+ for (const model of chain) {
830
+ const source = cliFor(model);
831
+ if (source && spent.spent(source)) continue;
832
+ try {
833
+ return { body: await askOne(provider, model, job, signal, brief), model };
834
+ } catch (e) {
835
+ if (!(e instanceof ChatFailure)) throw e;
836
+ if (!e.retryable) throw e;
837
+ last = e;
838
+ spent.record(e.message);
839
+ }
840
+ }
841
+ throw last ?? new Error("no model could write this trace");
842
+ }
843
+ async function askOne(provider, model, job, signal, brief) {
844
+ const req = {
845
+ model,
846
+ messages: [
847
+ { role: "system", content: "You write terse, factual reference notes about source files. You never speculate." },
848
+ { role: "user", content: tracePrompt(job, brief) }
849
+ ],
850
+ tools: []
851
+ };
852
+ let out = "";
853
+ for await (const ev of provider.chat(req, signal)) {
854
+ if (ev.type === "text-delta") out += ev.text;
855
+ else if (ev.type === "error") throw new ChatFailure(ev.message, ev.retryable === true);
856
+ }
857
+ const body = out.replace(/<\/?think>/gi, "").trim();
858
+ if (!body) throw new ChatFailure("empty response", true);
859
+ return body;
860
+ }
861
+ async function runTraces(opts) {
862
+ const { cwd, plan } = opts;
863
+ const signal = opts.signal ?? new AbortController().signal;
864
+ const index = await loadTraceIndex(cwd);
865
+ const brief = briefForPrompt(cwd);
866
+ const failed = [];
867
+ const spent = new SpentSources();
868
+ let written = 0;
869
+ let done = 0;
870
+ const queue = [...plan.jobs];
871
+ const worker = async () => {
872
+ for (; ; ) {
873
+ const job = queue.shift();
874
+ if (!job || signal.aborted) return;
875
+ let wroteTo;
876
+ let words;
877
+ let error;
878
+ try {
879
+ const { body, model } = await traceOne(opts.provider, opts.models, job, signal, spent, brief);
880
+ const rec = await saveTrace(cwd, job, body, model);
881
+ index.traces[job.file] = rec;
882
+ wroteTo = `${traceRootRel()}/${job.file}.md`;
883
+ words = body.split(/\s+/).filter(Boolean).length;
884
+ written++;
885
+ if (written % INDEX_CHECKPOINT === 0) await saveTraceIndex(cwd, index);
886
+ } catch (e) {
887
+ if (signal.aborted) return;
888
+ error = e instanceof Error ? e.message : String(e);
889
+ failed.push({ file: job.file, error });
890
+ }
891
+ opts.onProgress?.({
892
+ done: ++done,
893
+ total: plan.jobs.length,
894
+ file: job.file,
895
+ ...wroteTo !== void 0 && { wroteTo },
896
+ ...words !== void 0 && { words },
897
+ ...error !== void 0 && { error }
898
+ });
899
+ }
900
+ };
901
+ await Promise.all(Array.from({ length: Math.min(TRACE_CONCURRENCY, queue.length) }, worker));
902
+ const pruned = opts.liveFiles ? await pruneTraces(cwd, opts.liveFiles, index) : [];
903
+ await saveTraceIndex(cwd, index);
904
+ const wroteGitignore = written > 0 && await ensureGitignore(cwd);
905
+ return { written, failed, pruned, upToDate: plan.upToDate, cancelled: signal.aborted, wroteGitignore };
906
+ }
907
+ async function planFor(cwd, files) {
908
+ return planTraces(cwd, files, await loadGraph(cwd), await loadTraceIndex(cwd));
909
+ }
910
+ async function buildBrief(opts) {
911
+ if (!opts.force) {
912
+ const st = await briefStatus(opts.cwd, opts.files);
913
+ if (st.built && !st.stale) {
914
+ return { ok: true, skipped: true, message: `Project brief is current (${st.sources.length} document(s)) \u2014 not rewritten.` };
915
+ }
916
+ }
917
+ const input = await gatherBriefInput(opts.cwd, opts.files);
918
+ if (!input) {
919
+ return { ok: false, message: "No documentation found (README, docs/, specs/) \u2014 traces will describe the code without product context." };
920
+ }
921
+ const signal = opts.signal ?? new AbortController().signal;
922
+ const spent = new SpentSources();
923
+ let body = "";
924
+ let wroteWith = "";
925
+ let last = "";
926
+ for (const model of spent.live(opts.models)) {
927
+ const req = {
928
+ model,
929
+ messages: [
930
+ { role: "system", content: "You write factual project briefings from documentation. You never invent facts the documents do not state." },
931
+ { role: "user", content: briefPrompt(input) }
932
+ ],
933
+ tools: []
934
+ };
935
+ let out = "";
936
+ try {
937
+ for await (const ev of opts.provider.chat(req, signal)) {
938
+ if (ev.type === "text-delta") out += ev.text;
939
+ else if (ev.type === "error") throw new ChatFailure(ev.message, ev.retryable === true);
940
+ }
941
+ } catch (e) {
942
+ if (e instanceof ChatFailure && !e.retryable) return { ok: false, message: `Project brief failed (${e.message}) \u2014 tracing can still run without it.` };
943
+ last = e instanceof Error ? e.message : String(e);
944
+ if (e instanceof ChatFailure) spent.record(e.message);
945
+ continue;
946
+ }
947
+ const text = out.replace(/<\/?think>/gi, "").trim();
948
+ if (text) {
949
+ body = text;
950
+ wroteWith = model;
951
+ break;
952
+ }
953
+ last = "the brief came back empty";
954
+ }
955
+ if (!body) return { ok: false, message: `Project brief failed (${last || "no model answered"}) \u2014 tracing can still run without it.` };
956
+ await saveBrief(opts.cwd, body, { hash: input.hash, sources: input.sources.map((s) => s.file), writtenAt: Date.now(), model: wroteWith });
957
+ return { ok: true, message: `**Project brief** written from ${input.sources.length} document(s): ${input.sources.slice(0, 6).map((s) => `\`${s.file}\``).join(", ")}` };
958
+ }
959
+
960
+ export {
961
+ grokEffort,
962
+ ZAI_MODELS,
963
+ modelsFor,
964
+ cliCatalog,
965
+ cliFor,
966
+ cliInvocation,
967
+ ROLE_PROFILES,
968
+ filterModelsForRole,
969
+ effortFor,
970
+ isKnownModel,
971
+ capabilityScore,
972
+ mostCapable,
973
+ modelBand,
974
+ DURABLE_ROLES,
975
+ strongestPrimary,
976
+ newestPrimary,
977
+ sourceOf,
978
+ isPeer,
979
+ adjustRoleModels,
980
+ RoleRegistry,
981
+ traceableFiles,
982
+ coverageFor,
983
+ TRACE_CONCURRENCY,
984
+ describePlan,
985
+ SpentSources,
986
+ runTraces,
987
+ planFor,
988
+ buildBrief
989
+ };