@hizliemre/horse-code 0.1.2 → 0.3.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 (38) hide show
  1. package/dist/{app-LAJN3TWC.js → app-5FXHE7GX.js} +354 -350
  2. package/dist/{chunk-2SVAHH5N.js → chunk-63E73TGI.js} +2 -4
  3. package/dist/{chunk-DTWKSZXY.js → chunk-6OSEQOYY.js} +2 -2
  4. package/dist/{chunk-SSDLHWSF.js → chunk-6W4UH2BQ.js} +33 -1
  5. package/dist/{chunk-VPAWRRHL.js → chunk-AE36LLL2.js} +31 -21
  6. package/dist/{chunk-4EWK7HWQ.js → chunk-EAF22QIG.js} +32 -0
  7. package/dist/chunk-G45RWL7S.js +289 -0
  8. package/dist/{chunk-FFYBY2NA.js → chunk-KAGKX2YT.js} +2 -4
  9. package/dist/{chunk-DRZSUQ7Q.js → chunk-LLL7QWXB.js} +18 -7
  10. package/dist/{chunk-WYQBRCKY.js → chunk-LNW557IO.js} +2 -2
  11. package/dist/{chunk-IW2KBAVZ.js → chunk-LPQU436C.js} +12 -1
  12. package/dist/chunk-M2RKCIGV.js +11 -0
  13. package/dist/{chunk-NNTIACT4.js → chunk-MRZVA5JB.js} +4 -4
  14. package/dist/{chunk-RPVAIS3P.js → chunk-UEWVVN5L.js} +1511 -305
  15. package/dist/{chunk-EQX7BQYN.js → chunk-UGESK765.js} +1 -1
  16. package/dist/{chunk-PGOYDOI4.js → chunk-XEGQT5EN.js} +3 -5
  17. package/dist/{chunk-K2VERI5Q.js → chunk-XYZVZPAY.js} +365 -666
  18. package/dist/{chunk-KOWMHL23.js → chunk-YPZP7LYL.js} +2 -2
  19. package/dist/{chunk-FGVJFMK5.js → chunk-ZSQ24YDJ.js} +1 -1
  20. package/dist/cli.js +675 -95
  21. package/dist/{discover-5URG7C4J.js → discover-G2Z6XC3O.js} +9 -3
  22. package/dist/fix-ONLA45HD.js +34 -0
  23. package/dist/git-QZTDZSJY.js +8 -0
  24. package/dist/{ongoing-XP6WXNI7.js → ongoing-WHYXPW24.js} +4 -4
  25. package/dist/{project-graph-IOPCSZUA.js → project-graph-5HNPRFQG.js} +2 -3
  26. package/dist/{run-LQOZ5I7Z.js → run-P6ZYL5JL.js} +2 -3
  27. package/dist/{save-skills-NSLBU33X.js → save-skills-ZW5GY6KV.js} +2 -1
  28. package/dist/{trace-ZMB7LT7W.js → trace-X6TU3AG6.js} +2 -3
  29. package/dist/{trace-adopt-C6TUWFJL.js → trace-adopt-URECQWJV.js} +2 -3
  30. package/dist/{trace-run-F23MFTY4.js → trace-run-7U4WJZ3V.js} +6 -7
  31. package/dist/{triage-ES5OHZOS.js → triage-FCYHD2AQ.js} +8 -9
  32. package/dist/{verify-3R7DGUSI.js → verify-LC57A6H2.js} +19 -19
  33. package/package.json +1 -1
  34. package/dist/chunk-3UYA3KUG.js +0 -212
  35. package/dist/chunk-B67BK5GQ.js +0 -34
  36. package/dist/chunk-O74BDQKS.js +0 -28
  37. package/dist/fix-CMARU6JR.js +0 -34
  38. package/dist/git-VTSZALSR.js +0 -6
@@ -4,45 +4,54 @@ import {
4
4
  import {
5
5
  MAX_PARALLEL_TASKS,
6
6
  MemoryStore,
7
- isAnthropicModel,
8
7
  isUnknownModelError,
9
8
  makeAskUser,
10
9
  restoreOnExit,
11
10
  restoreTerminal,
12
11
  runJob,
13
12
  sttySane
14
- } from "./chunk-K2VERI5Q.js";
15
- import "./chunk-3UYA3KUG.js";
13
+ } from "./chunk-XYZVZPAY.js";
14
+ import "./chunk-M2RKCIGV.js";
16
15
  import {
17
- CODE_TEAM,
18
- DEFAULT_COUNCIL,
19
- PLAN_TEAM,
16
+ DURABLE_ROLES,
20
17
  REQUIRED_ROLES,
18
+ ROLE_PROFILES,
21
19
  RoleFitness,
22
- SPEC_TEAM,
20
+ adjustRoleModels,
23
21
  asChoice,
22
+ capabilityScore,
23
+ cliFor,
24
24
  describeUnfinished,
25
+ effortFor,
26
+ filterModelsForRole,
27
+ isKnownModel,
28
+ modelBand,
29
+ mostCapable,
30
+ newestPrimary,
31
+ sourceOf,
32
+ strongestPrimary,
25
33
  toSlug,
26
34
  unfinishedSessions
27
- } from "./chunk-RPVAIS3P.js";
35
+ } from "./chunk-UEWVVN5L.js";
28
36
  import "./chunk-QF4MP6BS.js";
29
- import "./chunk-EQX7BQYN.js";
30
- import "./chunk-FGVJFMK5.js";
37
+ import "./chunk-UGESK765.js";
38
+ import "./chunk-ZSQ24YDJ.js";
31
39
  import {
32
40
  saveRoleSkills
33
- } from "./chunk-4EWK7HWQ.js";
41
+ } from "./chunk-EAF22QIG.js";
42
+ import "./chunk-G45RWL7S.js";
34
43
  import {
35
44
  objectField,
36
45
  patchConfig
37
46
  } from "./chunk-H2FDGPVW.js";
38
- import "./chunk-NNTIACT4.js";
39
- import "./chunk-IW2KBAVZ.js";
47
+ import "./chunk-MRZVA5JB.js";
48
+ import "./chunk-LPQU436C.js";
40
49
  import {
41
50
  memoryNote,
42
51
  memoryState,
43
52
  walkFiles
44
- } from "./chunk-DRZSUQ7Q.js";
45
- import "./chunk-2SVAHH5N.js";
53
+ } from "./chunk-LLL7QWXB.js";
54
+ import "./chunk-63E73TGI.js";
46
55
  import {
47
56
  estimateFreezeSeconds,
48
57
  fmtDuration,
@@ -50,319 +59,24 @@ import {
50
59
  redactSecrets,
51
60
  relTime,
52
61
  stripThinking,
62
+ telemetry,
53
63
  writeHeapSnapshot
54
- } from "./chunk-VPAWRRHL.js";
55
- import "./chunk-DTWKSZXY.js";
64
+ } from "./chunk-AE36LLL2.js";
65
+ import "./chunk-6OSEQOYY.js";
56
66
  import {
57
67
  traceRootRel
58
- } from "./chunk-FFYBY2NA.js";
68
+ } from "./chunk-KAGKX2YT.js";
59
69
  import {
60
70
  buildProjectGraph,
61
71
  graphStatus
62
- } from "./chunk-PGOYDOI4.js";
63
- import "./chunk-SSDLHWSF.js";
72
+ } from "./chunk-XEGQT5EN.js";
64
73
  import {
65
74
  writeAtomic
66
- } from "./chunk-B67BK5GQ.js";
75
+ } from "./chunk-6W4UH2BQ.js";
67
76
 
68
77
  // src/tui/app.tsx
69
78
  import { render } from "ink";
70
79
 
71
- // src/tui/role-models.ts
72
- var WEAK_RE = /\b(flash|mini|nano|haiku|lite|small|turbo|fast|\d{1,2}b)\b/i;
73
- var FLAGSHIP_ROLES = ["judge", "principal-coder"];
74
- var COUNCIL_ROLES = DEFAULT_COUNCIL.map((c) => c.name);
75
- var SPEC_LENS_ROLES = SPEC_TEAM.map((c) => c.name);
76
- var PLAN_LENS_ROLES = PLAN_TEAM.map((c) => c.name);
77
- var CODE_LENS_ROLES = CODE_TEAM.map((c) => c.name);
78
- var STRONG_ROLES = [
79
- "brainstormer",
80
- "analyst",
81
- "planner",
82
- "architect",
83
- "senior-coder",
84
- "senior-designer",
85
- ...COUNCIL_ROLES,
86
- ...PLAN_LENS_ROLES,
87
- ...CODE_LENS_ROLES
88
- ];
89
- var MID_ROLES = ["coach", "coder", "designer", "code-reviewer", "operational", "memory-keeper", "task-auditor", ...SPEC_LENS_ROLES];
90
- var FAST_ROLES = ["refiner", "router", "project-manager", "team-lead"];
91
- var CAPABLE_ROLES = /* @__PURE__ */ new Set([...FLAGSHIP_ROLES, ...STRONG_ROLES, ...MID_ROLES]);
92
- var ROLE_PROFILES = {
93
- 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.",
94
- refiner: "Classifies intent and rewrites the prompt every turn \u2014 highest call volume, trivial task \u2192 a fast, cheap model.",
95
- router: "Picks coder-vs-designer for a task \u2014 tiny and frequent \u2192 fast, cheap.",
96
- "project-manager": "Turns a task list into board items \u2014 light and structured \u2192 fast, cheap.",
97
- "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.",
98
- "team-lead": "Coordinates implementation waves \u2014 light orchestration \u2192 fast, cheap.",
99
- coach: "Your main interactive assistant, used constantly all session (highest interaction volume) \u2192 a capable but EFFICIENT model, never the costly flagship.",
100
- 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.",
101
- analyst: "Authors the spec and constitution \u2192 a strong reasoning model (Opus-tier).",
102
- planner: "Designs the implementation plan \u2192 a strong reasoning model (Opus-tier).",
103
- architect: "Diagnoses stuck tasks and produces recovery plans \u2014 serious design work \u2192 a strong model.",
104
- judge: "Critiques specs/plans and makes the final review call \u2014 low volume, high stakes \u2192 the most capable flagship model.",
105
- 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).",
106
- "senior-coder": "Reviews and revises above the coder \u2014 must be MORE capable than the coder (Opus-tier).",
107
- "principal-coder": "Final code decision-maker \u2014 low volume, high stakes \u2192 the flagship is appropriate.",
108
- designer: "Builds UI \u2014 high volume \u2192 a capable coding/design model, not the flagship.",
109
- "senior-designer": "Senior UI reviewer \u2014 more capable than the designer.",
110
- "code-reviewer": "Reviews diffs \u2014 moderate volume \u2192 a solid capable model.",
111
- "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.",
112
- operational: "Handles version control: writes conventional commit messages and (later) drives merges/conflicts \u2014 high volume \u2192 a capable, efficient model."
113
- };
114
- for (const [stage, lenses, heft] of [
115
- ["spec", SPEC_TEAM, "a capable, efficient model (a spec is a short business-level doc)"],
116
- ["plan", PLAN_TEAM, "a strong model (technical design judgment)"],
117
- ["code", CODE_TEAM, "a strong model (reads real implementations)"]
118
- ]) {
119
- for (const l of lenses) ROLE_PROFILES[l.name] = `${stage.toUpperCase()}-review lens \u2014 ${l.perspective}. Low volume, quality-critical \u2192 ${heft}.`;
120
- }
121
- for (const c of DEFAULT_COUNCIL) {
122
- ROLE_PROFILES[c.name] = `Review COUNCIL decider \u2014 ${c.perspective} Casts the binding pass/revise vote on contested work \u2192 a strong model.`;
123
- }
124
- var ROLE_ADVICE = ROLE_PROFILES;
125
- function filterModelsForRole(role, all, exclude = []) {
126
- const advice = ROLE_ADVICE[role];
127
- const excluded = new Set(exclude);
128
- const avail = all.filter((m) => !excluded.has(m));
129
- if (CAPABLE_ROLES.has(role)) {
130
- const strong = avail.filter((m) => !WEAK_RE.test(m));
131
- if (strong.length === 0) return { models: avail.length ? avail : all, note: advice ? `${advice} (No strong models detected \u2014 showing all.)` : void 0 };
132
- return { models: strong, note: `${advice ?? ""} Showing ${strong.length} of ${avail.length} models (fast/weak models hidden for this role).`.trim() };
133
- }
134
- if (FAST_ROLES.includes(role)) {
135
- const fast = avail.filter((m) => WEAK_RE.test(m));
136
- if (fast.length === 0) return { models: avail.length ? avail : all, note: advice };
137
- return { models: fast, note: `${advice ?? ""} Showing ${fast.length} of ${avail.length} fast/cheap models.`.trim() };
138
- }
139
- return { models: avail.length ? avail : all };
140
- }
141
- function effortFor(role, model) {
142
- if (!isAnthropicModel(model)) return void 0;
143
- if (FLAGSHIP_ROLES.includes(role)) return "max";
144
- if (STRONG_ROLES.includes(role)) return "xhigh";
145
- if (FAST_ROLES.includes(role)) return "low";
146
- if (MID_ROLES.includes(role)) return "high";
147
- return void 0;
148
- }
149
- var effortBump = (s) => /-(ultra|max|xhigh)/.test(s) ? 4 : /-high/.test(s) ? 3 : /-medium/.test(s) ? 2 : /-low/.test(s) ? 1 : 0;
150
- var versionBump = (s, family) => {
151
- if (family) {
152
- const m = s.match(new RegExp(`${family}[-_. ]?(\\d+)(?:[-.](\\d+))?`));
153
- if (m) {
154
- const major = Number(m[1]);
155
- const minor = m[2] === void 0 ? 0 : Number(m[2]);
156
- if (major < 100) return major + (minor < 10 ? minor / 10 : minor / 100);
157
- }
158
- }
159
- const g = s.match(/(\d)[-.](\d)\b/);
160
- return g ? Number(g[1]) + Number(g[2]) / 10 : 0;
161
- };
162
- 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;
163
- var NON_TEXT_RE = /\b(image|imagen|vision|video|veo|tts|audio|speech|voice|embed|embedding|rerank|ocr|computer-use|realtime|moderation)\b/i;
164
- function isKnownModel(model) {
165
- return KNOWN_FAMILY_RE.test(model) && !NON_TEXT_RE.test(model);
166
- }
167
- var UNRANKED_SCORE = 50;
168
- function capabilityScore(model) {
169
- const s = model.toLowerCase();
170
- if (WEAK_RE.test(s)) return 20 + effortBump(s);
171
- if (/fable|mythos/.test(s)) return 100;
172
- if (/opus/.test(s)) return 88 + versionBump(s, "opus");
173
- if (/codex|gpt-5|\bo3\b/.test(s)) return 82 + effortBump(s) + versionBump(s, "gpt") / 100;
174
- if (/sonnet/.test(s)) return 78 + versionBump(s, "sonnet");
175
- if (/gemini/.test(s) && /pro/.test(s)) return 76 + versionBump(s, "gemini") + effortBump(s);
176
- if (/gpt-4/.test(s)) return 65;
177
- if (/deepseek/.test(s)) return 55;
178
- return UNRANKED_SCORE;
179
- }
180
- function mostCapable(models) {
181
- return [...models].sort((a, b) => capabilityScore(b) - capabilityScore(a))[0] ?? "";
182
- }
183
- function modelBand(model) {
184
- if (WEAK_RE.test(model)) return "fast";
185
- const s = capabilityScore(model);
186
- if (s >= 95) return "flagship";
187
- if (s >= 84) return "strong";
188
- if (s <= UNRANKED_SCORE) return "fast";
189
- return "mid";
190
- }
191
- function baseModel(model) {
192
- const segs = model.toLowerCase().split("/");
193
- let s = segs[segs.length - 1];
194
- s = s.replace(/-(ultra|max|xhigh|high|medium|low|free|thinking|preview)\b/g, "");
195
- s = s.replace(/-\d{6,8}\b/g, "");
196
- return s.replace(/-+$/, "");
197
- }
198
- function modelFamily(model) {
199
- return baseModel(model).replace(/[-.]v?\d+(?:[-.]\d+)*(?=[-.]|$)/g, "").replace(/[-.]{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
200
- }
201
- function latestFirst(models) {
202
- const best = /* @__PURE__ */ new Map();
203
- for (const m of models) {
204
- const key = modelFamily(m);
205
- const cur = best.get(key);
206
- if (!cur || capabilityScore(m) > capabilityScore(cur)) best.set(key, m);
207
- }
208
- const isLatest = (m) => best.get(modelFamily(m)) === m;
209
- return [...models.filter(isLatest), ...models.filter((m) => !isLatest(m))];
210
- }
211
- function versionlessId(model) {
212
- const cut = model.lastIndexOf("/");
213
- const prefix = cut >= 0 ? model.slice(0, cut + 1) : "";
214
- const name = model.slice(cut + 1).toLowerCase().replace(/-\d{6,8}\b/g, "").replace(/[-.]v?\d+(?:[-.]\d+)*(?=[-.]|$)/g, "").replace(/[-.]{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
215
- return prefix + name;
216
- }
217
- var DURABLE_ROLES = ["tracer"];
218
- function strongestPrimary(chain, pool) {
219
- const head = chain[0];
220
- if (!head) return chain;
221
- let best = head;
222
- for (const m of pool) {
223
- if (modelBand(m) === "flagship" || !isKnownModel(m)) continue;
224
- if (capabilityScore(m) > capabilityScore(best)) best = m;
225
- }
226
- if (best === head) return chain;
227
- const at = chain.indexOf(best);
228
- if (at > 0) {
229
- const next = [...chain];
230
- next[at] = head;
231
- next[0] = best;
232
- return next;
233
- }
234
- return [best, ...chain.slice(1)];
235
- }
236
- function newestPrimary(chain, pool) {
237
- const head = chain[0];
238
- if (!head) return chain;
239
- const key = versionlessId(head);
240
- let best = head;
241
- for (const m of pool) {
242
- if (versionlessId(m) !== key) continue;
243
- if (capabilityScore(m) > capabilityScore(best)) best = m;
244
- }
245
- if (best === head) return chain;
246
- const at = chain.indexOf(best);
247
- if (at > 0) {
248
- const next = [...chain];
249
- next[at] = head;
250
- next[0] = best;
251
- return next;
252
- }
253
- return [best, ...chain.slice(1)];
254
- }
255
- function dedupBest(models) {
256
- const best = /* @__PURE__ */ new Map();
257
- for (const m of models) {
258
- const key = baseModel(m);
259
- const cur = best.get(key);
260
- if (!cur || capabilityScore(m) > capabilityScore(cur)) best.set(key, m);
261
- }
262
- return [...best.values()].sort((a, b) => capabilityScore(b) - capabilityScore(a));
263
- }
264
- function sourceOf(model) {
265
- const s = model.toLowerCase().replace(/^no-think\//, "");
266
- const p = s.split("/")[0];
267
- return p === "cc" ? "claude" : p === "cx" ? "codex" : p;
268
- }
269
- function interleaveBySource(pool) {
270
- const bySource = /* @__PURE__ */ new Map();
271
- for (const m of pool) {
272
- const s = sourceOf(m);
273
- const q = bySource.get(s);
274
- if (q) q.push(m);
275
- else bySource.set(s, [m]);
276
- }
277
- const queues = [...bySource.values()];
278
- const out = [];
279
- for (let more = true; more; ) {
280
- more = false;
281
- for (const q of queues) {
282
- const m = q.shift();
283
- if (m !== void 0) {
284
- out.push(m);
285
- more = true;
286
- }
287
- }
288
- }
289
- return out;
290
- }
291
- var BAND_ORDER = { fast: 0, mid: 1, strong: 2, flagship: 3 };
292
- function bandDistance(primary, candidate) {
293
- const p = BAND_ORDER[modelBand(primary)];
294
- const c = BAND_ORDER[modelBand(candidate)];
295
- return Math.abs(c - p) * 2 + (c < p ? 1 : 0);
296
- }
297
- function pickFallbacks(primary, pool, n) {
298
- const chosen = [];
299
- const usedModels = /* @__PURE__ */ new Set([baseModel(primary)]);
300
- const usedSources = /* @__PURE__ */ new Set([sourceOf(primary)]);
301
- 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);
302
- for (const m of byHeft) {
303
- if (chosen.length >= n) break;
304
- if (usedModels.has(baseModel(m)) || usedSources.has(sourceOf(m))) continue;
305
- chosen.push(m);
306
- usedModels.add(baseModel(m));
307
- usedSources.add(sourceOf(m));
308
- }
309
- for (const m of byHeft) {
310
- if (chosen.length >= n) break;
311
- if (usedModels.has(baseModel(m))) continue;
312
- chosen.push(m);
313
- usedModels.add(baseModel(m));
314
- }
315
- return chosen;
316
- }
317
- var FALLBACK_COUNT = 2;
318
- function adjustRoleModels(roles, models, unfit) {
319
- if (models.length === 0) return [];
320
- const recognised = models.filter(isKnownModel);
321
- const pick = recognised.length ? recognised : models;
322
- const capable = dedupBest(pick.filter((m) => !WEAK_RE.test(m)));
323
- const fast = dedupBest(pick.filter((m) => WEAK_RE.test(m)));
324
- const capablePool = capable.length ? capable : fast;
325
- const fastPool = fast.length ? fast : capable;
326
- const primaryPool = latestFirst(capablePool);
327
- const primaryFast = latestFirst(fastPool);
328
- const nonFlagship = primaryPool.filter((m) => modelBand(m) !== "flagship");
329
- const strongPool = primaryPool.filter((m) => modelBand(m) === "strong");
330
- const midPool = primaryPool.filter((m) => modelBand(m) === "mid");
331
- const wanted = new Set(roles);
332
- const forRole = (role, pool) => {
333
- if (!unfit) return pool;
334
- const fit = pool.filter((m) => !unfit(role, m));
335
- return fit.length ? fit : pool;
336
- };
337
- const known = /* @__PURE__ */ new Set([...FLAGSHIP_ROLES, ...STRONG_ROLES, ...MID_ROLES, ...FAST_ROLES]);
338
- const primary = /* @__PURE__ */ new Map();
339
- const flagSrc = primaryPool;
340
- FLAGSHIP_ROLES.filter((r) => wanted.has(r)).forEach((r, i) => {
341
- const src = forRole(r, flagSrc);
342
- primary.set(r, src[i % src.length]);
343
- });
344
- const strongSrc = interleaveBySource(strongPool.length ? strongPool : nonFlagship.length ? nonFlagship : primaryPool);
345
- STRONG_ROLES.filter((r) => wanted.has(r)).concat(roles.filter((r) => !known.has(r))).forEach((r, i) => {
346
- const src = forRole(r, strongSrc);
347
- primary.set(r, src[i % src.length]);
348
- });
349
- const midSrc = interleaveBySource(midPool.length ? midPool : nonFlagship.length ? nonFlagship : primaryPool);
350
- MID_ROLES.filter((r) => wanted.has(r)).forEach((r, i) => {
351
- const src = forRole(r, midSrc);
352
- primary.set(r, src[i % src.length]);
353
- });
354
- FAST_ROLES.filter((r) => wanted.has(r)).forEach((r, i) => {
355
- const src = forRole(r, primaryFast);
356
- primary.set(r, src[i % src.length]);
357
- });
358
- return roles.map((role) => {
359
- const head = primary.get(role) ?? primaryPool[0];
360
- const capForFb = MID_ROLES.includes(role) ? capablePool.filter((m) => modelBand(m) !== "flagship") : capablePool;
361
- const pool = FAST_ROLES.includes(role) ? [...fastPool, ...capForFb] : [...capForFb, ...fastPool];
362
- return { role, models: newestPrimary([head, ...pickFallbacks(head, forRole(role, pool), FALLBACK_COUNT)], models) };
363
- });
364
- }
365
-
366
80
  // src/engine/role-tuner.ts
367
81
  function describeCatalog(models) {
368
82
  const bySource = /* @__PURE__ */ new Map();
@@ -644,7 +358,7 @@ async function connectAllMcp(specs) {
644
358
 
645
359
  // src/tui/app.tsx
646
360
  import { homedir as homedir2 } from "os";
647
- import { join as join6 } from "path";
361
+ import { join as join7 } from "path";
648
362
  import { basename } from "path";
649
363
 
650
364
  // src/tui/labels.ts
@@ -2025,6 +1739,7 @@ var COMMANDS = [
2025
1739
  { name: "/memories", desc: "List remembered facts (/forget N to remove)" },
2026
1740
  { name: "/forget", desc: "Forget a remembered fact (/forget N)" },
2027
1741
  { name: "/mcp", desc: "Connected MCP servers (/mcp add <url|command> installs one and verifies it)" },
1742
+ { name: "/models", desc: "Models each connected subscription serves, and which ones a role is running" },
2028
1743
  { name: "/sources", desc: "Show your connected model sources (/sources refresh re-detects)" },
2029
1744
  { name: "/migrate", desc: "Bring a project from Claude Code / Codex / Cursor into horse-code (rules, memory, skills)" },
2030
1745
  { name: "/continue-from-claude", desc: "Continue work started in a Claude Code worktree (/continue-from-claude <name>) \u2014 its branch becomes the base" },
@@ -2036,6 +1751,7 @@ var COMMANDS = [
2036
1751
  { name: "/monitor", desc: "Where the run's time is going (/monitor enable shows the panel, disable hides it, log shows the file, heap writes a snapshot)" },
2037
1752
  { name: "/watch", desc: "Watch any command \u2014 each line it prints becomes an event (/watch <cmd>, /watch stop N)" },
2038
1753
  { name: "/paste", desc: "Put the clipboard's image into the input (same as Ctrl+V, for terminals that swallow it)" },
1754
+ { name: "/start-smoke-test", desc: "Walk the finished feature's manual verification one step at a time, and record what you saw into its guide" },
2039
1755
  { name: "/help", desc: "List the available commands" },
2040
1756
  { name: "/clear", desc: "Clear the conversation" },
2041
1757
  { name: "/exit", desc: "Quit horse-code" }
@@ -2245,7 +1961,7 @@ function rankFiles(files, query, limit = 8) {
2245
1961
  }
2246
1962
 
2247
1963
  // src/tui/components.tsx
2248
- import { join as join2 } from "path";
1964
+ import { join as join3 } from "path";
2249
1965
  import { homedir } from "os";
2250
1966
 
2251
1967
  // src/tui/paste.ts
@@ -2576,7 +2292,130 @@ function shortName(command) {
2576
2292
  return first.split("/").pop()?.slice(0, 24) || "watch";
2577
2293
  }
2578
2294
 
2295
+ // src/engine/smoke-test.ts
2296
+ import { existsSync, readFileSync as readFileSync2, readdirSync } from "fs";
2297
+ import { join as join2 } from "path";
2298
+ function splitInstructions(paragraph) {
2299
+ return paragraph.split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter((s) => s.length > 12);
2300
+ }
2301
+ function parseGuide(path, markdown) {
2302
+ const lines = markdown.split("\n");
2303
+ const title = lines.find((l) => l.startsWith("# "))?.slice(2).trim() ?? "Manual verification";
2304
+ const preconditions = [];
2305
+ const scenarios = [];
2306
+ const steps = [];
2307
+ let section = "other";
2308
+ let scenario = "";
2309
+ let buffer = [];
2310
+ const flush = () => {
2311
+ if (!scenario) {
2312
+ buffer = [];
2313
+ return;
2314
+ }
2315
+ const text = buffer.join(" ").trim();
2316
+ for (const instruction of splitInstructions(text)) {
2317
+ steps.push({ n: steps.length + 1, scenario, scenarioIndex: Math.max(0, scenarios.length - 1), instruction });
2318
+ }
2319
+ buffer = [];
2320
+ };
2321
+ for (const line of lines) {
2322
+ const h2 = /^##\s+(?!#)(.*)$/.exec(line);
2323
+ const h3 = /^###\s+(.*)$/.exec(line);
2324
+ if (h2) {
2325
+ flush();
2326
+ scenario = "";
2327
+ section = /ön koşul|precondition/i.test(h2[1]) ? "pre" : /senaryo|scenario/i.test(h2[1]) ? "scenario" : "other";
2328
+ continue;
2329
+ }
2330
+ if (h3) {
2331
+ flush();
2332
+ if (section === "scenario") {
2333
+ scenario = h3[1].trim();
2334
+ scenarios.push(scenario);
2335
+ }
2336
+ continue;
2337
+ }
2338
+ const text = line.trim();
2339
+ if (!text) {
2340
+ flush();
2341
+ continue;
2342
+ }
2343
+ if (section === "pre") {
2344
+ const item = /^\d+\.\s+(.*)$/.exec(text)?.[1] ?? /^[-*]\s+(.*)$/.exec(text)?.[1];
2345
+ if (item) preconditions.push(item);
2346
+ continue;
2347
+ }
2348
+ if (section === "scenario" && scenario) buffer.push(text);
2349
+ }
2350
+ flush();
2351
+ return { path, title, preconditions, steps, scenarios };
2352
+ }
2353
+ function renderStep(step, total) {
2354
+ return `**Ad\u0131m ${step.n}/${total}** \u2014 _${step.scenario}_
2355
+
2356
+ ${step.instruction}`;
2357
+ }
2358
+ function recordOutcome(markdown, scenarioIndex, status, evidence) {
2359
+ const want = scenarioIndex + 1;
2360
+ return markdown.split("\n").map((line) => {
2361
+ if (!line.startsWith("|")) return line;
2362
+ const cells = line.split("|");
2363
+ if (cells.length < 4) return line;
2364
+ if (Number(/^\s*(\d+)/.exec(cells[1] ?? "")?.[1]) !== want) return line;
2365
+ cells[2] = ` ${status} `;
2366
+ cells[3] = ` ${evidence.replace(/\|/g, "\\|").replace(/\s+/g, " ").trim()} `;
2367
+ return cells.join("|");
2368
+ }).join("\n");
2369
+ }
2370
+ function scenarioOutcome(results) {
2371
+ const status = results.some((r) => r.status === "ba\u015Far\u0131s\u0131z") ? "ba\u015Far\u0131s\u0131z" : results.every((r) => r.status === "\xE7al\u0131\u015Ft\u0131r\u0131lmad\u0131") ? "\xE7al\u0131\u015Ft\u0131r\u0131lmad\u0131" : "ge\xE7ti";
2372
+ const evidence = results.filter((r) => r.evidence.trim()).map((r) => `${r.step.n}. ${r.evidence.trim()}`).join(" ");
2373
+ return { status, evidence: evidence || "g\xF6zlem kaydedilmedi" };
2374
+ }
2375
+ function findGuide(projectRoot) {
2376
+ const roots = [projectRoot, ...worktreeRoots(projectRoot)];
2377
+ let best;
2378
+ for (const root of roots) {
2379
+ let names = [];
2380
+ try {
2381
+ names = readdirSync(join2(root, "specs"));
2382
+ } catch {
2383
+ continue;
2384
+ }
2385
+ for (const slug of names) {
2386
+ if (!/^\d{3}-/.test(slug)) continue;
2387
+ const path = join2(root, "specs", slug, "quickstart.md");
2388
+ if (!existsSync(path)) continue;
2389
+ if (!best || slug > best.slug) best = { root, slug, path };
2390
+ }
2391
+ }
2392
+ return best;
2393
+ }
2394
+ function worktreeRoots(projectRoot) {
2395
+ const dir = join2(projectRoot, ".horsecode", "worktrees");
2396
+ try {
2397
+ return readdirSync(dir).map((s) => join2(dir, s, "base")).filter((p) => existsSync(p));
2398
+ } catch {
2399
+ return [];
2400
+ }
2401
+ }
2402
+ function progressLine(done, total) {
2403
+ const width = 24;
2404
+ const filled = total ? Math.round(done / total * width) : 0;
2405
+ return `${"\u2588".repeat(filled)}${"\u2591".repeat(width - filled)} ${done}/${total}`;
2406
+ }
2407
+ function loadGuide(projectRoot) {
2408
+ const found = findGuide(projectRoot);
2409
+ if (!found) {
2410
+ return { error: "No `specs/NNN-\u2026/quickstart.md` in this project or any of its session worktrees \u2014 a feature run writes one when it finishes." };
2411
+ }
2412
+ const guide = parseGuide(found.path, readFileSync2(found.path, "utf8"));
2413
+ if (!guide.steps.length) return { error: `${found.path} has no scenarios to walk \u2014 it may not be a verification guide.` };
2414
+ return { ...guide, slug: found.slug };
2415
+ }
2416
+
2579
2417
  // src/tui/components.tsx
2418
+ import { readFileSync as readFileSync3, writeFileSync } from "fs";
2580
2419
  import { Fragment, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
2581
2420
  function parsePending(raw) {
2582
2421
  const t = raw.replace(/^\s+/, "");
@@ -3330,7 +3169,7 @@ function ViewportLines({ lines, height }) {
3330
3169
  lines.map((line, i) => /* @__PURE__ */ jsx4(Text4, { children: line.length === 0 ? " " : line.map((s, j) => /* @__PURE__ */ jsx4(Text4, { color: s.color, backgroundColor: s.backgroundColor, bold: s.bold, italic: s.italic, dimColor: s.dim, children: s.text }, j)) }, i))
3331
3170
  ] });
3332
3171
  }
3333
- function App({ controller, fullscreen = false, model, coachModel, refinerModel, listModels, setModel, setRoleModel, listRoles, adjustRoles, listSessions, resumeSession, listPins, addPin, removePin, listMemories, addMemory, removeMemory, listMcp, sourcesInfo, refreshSources, listSkills, updateSkills, addSkill, graphStatus: graphStatus2, buildGraph, planTraces, runTraces, cleanWorktrees, migrate, continueFromClaude, addMcp, answerByTheWay, telemetryPath, parallel, setParallel, permMode, setPermMode, cancelJob, onExit }) {
3172
+ function App({ controller, fullscreen = false, model, coachModel, refinerModel, listModels, setModel, setRoleModel, listRoles, adjustRoles, modelsPanel, listSessions, resumeSession, listPins, addPin, removePin, listMemories, addMemory, removeMemory, listMcp, sourcesInfo, refreshSources, listSkills, updateSkills, addSkill, graphStatus: graphStatus2, buildGraph, planTraces, runTraces, cleanWorktrees, migrate, continueFromClaude, addMcp, answerByTheWay, telemetryPath, parallel, setParallel, permMode, setPermMode, cancelJob, onExit }) {
3334
3173
  const [state, setState] = useState3(controller.getState());
3335
3174
  useEffect3(() => controller.subscribe(() => setState(controller.getState())), [controller]);
3336
3175
  const { stdout } = useStdout();
@@ -3543,10 +3382,10 @@ _Type \`/resume N\` to continue one._`);
3543
3382
  }
3544
3383
  try {
3545
3384
  const { writeFile: writeFile3, mkdir: mkdir3 } = await import("fs/promises");
3546
- const dir = join2(homedir(), ".horsecode", "pastes");
3385
+ const dir = join3(homedir(), ".horsecode", "pastes");
3547
3386
  await mkdir3(dir, { recursive: true });
3548
3387
  const id = ++imageIdRef.current;
3549
- const file = join2(dir, `paste-${process.pid}-${id}.png`);
3388
+ const file = join3(dir, `paste-${process.pid}-${id}.png`);
3550
3389
  await writeFile3(file, Buffer.from(uri.slice(uri.indexOf(",") + 1), "base64"));
3551
3390
  imageMapRef.current.set(id, file);
3552
3391
  const tok = imageToken(id);
@@ -3786,6 +3625,14 @@ ${rows.join("\n")}
3786
3625
 
3787
3626
  _\`/mcp add <url|command>\` to install another._`);
3788
3627
  };
3628
+ const doModels = () => {
3629
+ if (!modelsPanel) {
3630
+ controller.note("Models are not available.");
3631
+ return;
3632
+ }
3633
+ const inUse = [...new Set((listRoles?.() ?? []).flatMap((r) => r.models))];
3634
+ controller.note(modelsPanel(inUse));
3635
+ };
3789
3636
  const doSources = (arg) => {
3790
3637
  const info = sourcesInfo?.();
3791
3638
  if (arg.trim().toLowerCase() === "refresh") {
@@ -3849,6 +3696,56 @@ _\`/sources refresh\` to re-probe your connected subscriptions._`);
3849
3696
  }
3850
3697
  );
3851
3698
  };
3699
+ const doSmokeTest = async () => {
3700
+ const guide = loadGuide(process.cwd());
3701
+ if ("error" in guide) {
3702
+ controller.note(guide.error);
3703
+ return;
3704
+ }
3705
+ controller.note(
3706
+ `\u{1F9EA} **${guide.title}**
3707
+
3708
+ \`${guide.slug}\` \xB7 ${guide.steps.length} ad\u0131m \xB7 ${guide.scenarios.length} senaryo`
3709
+ );
3710
+ if (guide.preconditions.length) {
3711
+ controller.note(`**Ba\u015Flamadan \xF6nce**
3712
+ ${guide.preconditions.map((p) => `- ${p}`).join("\n")}`);
3713
+ }
3714
+ controller.note(
3715
+ "Her ad\u0131mda ne g\xF6rd\xFC\u011F\xFCn\xFC yap\u0131\u015Ft\u0131r \u2014 API yan\u0131t\u0131, DB sat\u0131r\u0131, log kayd\u0131. horse-code veritaban\u0131na ve Loki'ye kendisi bakmaz; kan\u0131t senin g\xF6zlemin olarak kaydedilir."
3716
+ );
3717
+ if (await controller.ask("Ortam haz\u0131r m\u0131?", { options: ["Ba\u015Fla", "\u0130ptal"] }) !== "Ba\u015Fla") {
3718
+ controller.note("B\u0131rak\u0131ld\u0131 \u2014 rehberde hi\xE7bir \u015Fey de\u011Fi\u015Fmedi.");
3719
+ return;
3720
+ }
3721
+ let markdown = readFileSync3(guide.path, "utf8");
3722
+ let batch = [];
3723
+ const flush = (index) => {
3724
+ if (!batch.length) return;
3725
+ const { status, evidence } = scenarioOutcome(batch);
3726
+ markdown = recordOutcome(markdown, index, status, evidence);
3727
+ writeFileSync(guide.path, markdown);
3728
+ controller.note(`\u{1F4DD} _${guide.scenarios[index]}_ \u2192 **${status}**`);
3729
+ batch = [];
3730
+ };
3731
+ for (const [i, step] of guide.steps.entries()) {
3732
+ const previous = guide.steps[i - 1];
3733
+ if (previous && previous.scenarioIndex !== step.scenarioIndex) flush(previous.scenarioIndex);
3734
+ controller.note(renderStep(step, guide.steps.length));
3735
+ const answer = await controller.ask("Sonu\xE7?", { options: ["ge\xE7ti", "ba\u015Far\u0131s\u0131z", "atla", "durdur"] });
3736
+ if (answer === "durdur") {
3737
+ flush(step.scenarioIndex);
3738
+ controller.note(`Durduruldu \u2014 ${step.n - 1}/${guide.steps.length} ad\u0131m kaydedildi.`);
3739
+ return;
3740
+ }
3741
+ const status = answer === "ge\xE7ti" ? "ge\xE7ti" : answer === "ba\u015Far\u0131s\u0131z" ? "ba\u015Far\u0131s\u0131z" : "\xE7al\u0131\u015Ft\u0131r\u0131lmad\u0131";
3742
+ const evidence = status === "\xE7al\u0131\u015Ft\u0131r\u0131lmad\u0131" ? "" : await controller.ask("Ne g\xF6zlemledin? (API yan\u0131t\u0131 / DB sat\u0131r\u0131 / log)");
3743
+ batch.push({ step, status, evidence });
3744
+ controller.note(progressLine(step.n, guide.steps.length));
3745
+ }
3746
+ flush(guide.steps[guide.steps.length - 1].scenarioIndex);
3747
+ controller.note(`\u2705 Bitti \u2014 ${guide.path} g\xFCncellendi.`);
3748
+ };
3852
3749
  const doGraph = (arg) => {
3853
3750
  if (!graphStatus2 || !buildGraph) {
3854
3751
  controller.note("The project graph is not available.");
@@ -4012,8 +3909,10 @@ _\`/skills add <github-url>\` to install one \xB7 \`/skills update\` re-installs
4012
3909
  else if (c.name === "/remember") doRemember("");
4013
3910
  else if (c.name === "/forget") doForget("");
4014
3911
  else if (c.name === "/mcp") doMcp("");
3912
+ else if (c.name === "/models") doModels();
4015
3913
  else if (c.name === "/sources") doSources("");
4016
3914
  else if (c.name === "/skills") doSkills("");
3915
+ else if (c.name === "/start-smoke-test") void doSmokeTest();
4017
3916
  else if (c.name === "/graph") doGraph("");
4018
3917
  else if (c.name === "/clean-worktrees") doCleanWorktrees("");
4019
3918
  else if (c.name === "/paste") pasteImage();
@@ -4303,9 +4202,9 @@ _\`/skills add <github-url>\` to install one \xB7 \`/skills update\` re-installs
4303
4202
  const cw = Math.max(1, size.cols - 4);
4304
4203
  const inputH = draft.split("\n").reduce((n, l) => n + Math.max(1, Math.ceil((l.length + 3) / cw)), 0);
4305
4204
  const running = mode === "running";
4306
- const progressLine = running && !state.pending;
4205
+ const progressLine2 = running && !state.pending;
4307
4206
  const doneLine = !!state.meta && !state.meta.running && !state.pending;
4308
- const showStatus = progressLine || !!state.pending || doneLine;
4207
+ const showStatus = progressLine2 || !!state.pending || doneLine;
4309
4208
  const choiceOptionsForFit = state.pending?.options ?? [];
4310
4209
  const pendingBodyMax = state.pending ? Math.max(
4311
4210
  1,
@@ -4316,7 +4215,7 @@ _\`/skills add <github-url>\` to install one \xB7 \`/skills update\` re-installs
4316
4215
  flattenMarkdown(parsePending(state.pending.question).body, pendingBodyWidth(size.cols)).length,
4317
4216
  pendingBodyMax
4318
4217
  ) : 0;
4319
- const statusH = (progressLine || doneLine ? 1 : 0) + pendingLines;
4218
+ const statusH = (progressLine2 || doneLine ? 1 : 0) + pendingLines;
4320
4219
  const inputMarginTop = showStatus ? 0 : 1;
4321
4220
  const choiceOptions = state.pending?.options ?? [];
4322
4221
  const choiceActive = choiceOptions.length > 0 && !choiceDismissed;
@@ -4363,7 +4262,7 @@ _\`/skills add <github-url>\` to install one \xB7 \`/skills update\` re-installs
4363
4262
  /* @__PURE__ */ jsx4(Box4, { paddingLeft: CHAT_INDENT, children: /* @__PURE__ */ jsx4(ViewportLines, { lines: windowed, height: viewportH }) }),
4364
4263
  /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: clamped > 0 ? ` \u2193 ${clamped} more \xB7 \u2193/PgDn to jump to bottom` : " " }),
4365
4264
  showStatus ? /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
4366
- progressLine ? /* @__PURE__ */ jsx4(Box4, { paddingLeft: 2, children: /* @__PURE__ */ jsx4(ProgressView, { phase: state.phase, detail: state.detail, refinerModel: refinerModel?.(), meta: state.meta, cols: size.cols, live: state.liveActivity }) }) : null,
4265
+ progressLine2 ? /* @__PURE__ */ jsx4(Box4, { paddingLeft: 2, children: /* @__PURE__ */ jsx4(ProgressView, { phase: state.phase, detail: state.detail, refinerModel: refinerModel?.(), meta: state.meta, cols: size.cols, live: state.liveActivity }) }) : null,
4367
4266
  doneLine ? /* @__PURE__ */ jsx4(Box4, { paddingLeft: 2, children: /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: `${donePhrase(state.phase)} for ${fmtDuration(state.meta?.durationMs ?? 0)}${state.meta && state.meta.calls > 0 ? ` \xB7 \u2191${fmtTokens(state.meta.promptTokens)} \u2193${fmtTokens(state.meta.completionTokens)} \xB7 ${state.meta.calls} call${state.meta.calls === 1 ? "" : "s"}` : ""}` }) }) : null,
4368
4267
  state.pending ? /* @__PURE__ */ jsx4(PendingQuestion, { text: state.pending.question, cols: size.cols, maxLines: pendingBodyMax }) : null
4369
4268
  ] }) : null,
@@ -4556,6 +4455,15 @@ _\`/skills add <github-url>\` to install one \xB7 \`/skills update\` re-installs
4556
4455
  doMcp(trimmed.slice("/mcp".length).trim());
4557
4456
  return;
4558
4457
  }
4458
+ if (cmd === "/start-smoke-test") {
4459
+ setScroll(0);
4460
+ setDraft("");
4461
+ setDraftCursor(0);
4462
+ usedPastes();
4463
+ controller.echoCommand(trimmed);
4464
+ void doSmokeTest();
4465
+ return;
4466
+ }
4559
4467
  if (cmd.startsWith("/graph ")) {
4560
4468
  setScroll(0);
4561
4469
  setDraft("");
@@ -4667,7 +4575,7 @@ async function saveMode(home, mode) {
4667
4575
  // src/session/store.ts
4668
4576
  import { mkdir, readFile as readFile2, readdir } from "fs/promises";
4669
4577
  import { createHash } from "crypto";
4670
- import { join as join3 } from "path";
4578
+ import { join as join4 } from "path";
4671
4579
  var SessionStore = class {
4672
4580
  activeId;
4673
4581
  dir;
@@ -4675,7 +4583,7 @@ var SessionStore = class {
4675
4583
  constructor(opts) {
4676
4584
  this.now = opts.now ?? (() => Date.now());
4677
4585
  const hash = createHash("sha256").update(opts.cwd).digest("hex").slice(0, 16);
4678
- this.dir = join3(opts.home, ".horsecode", "projects", hash, "sessions");
4586
+ this.dir = join4(opts.home, ".horsecode", "projects", hash, "sessions");
4679
4587
  this.activeId = `s${this.now()}`;
4680
4588
  }
4681
4589
  /** The id this store currently writes to (a fresh one, or a resumed session's id after setActive). */
@@ -4687,7 +4595,7 @@ var SessionStore = class {
4687
4595
  this.activeId = id;
4688
4596
  }
4689
4597
  file(id) {
4690
- return join3(this.dir, `${id}.json`);
4598
+ return join4(this.dir, `${id}.json`);
4691
4599
  }
4692
4600
  /** Overwrite the active session with the current messages (no-op on an empty transcript). */
4693
4601
  async save(messages) {
@@ -4714,7 +4622,7 @@ var SessionStore = class {
4714
4622
  for (const n of names) {
4715
4623
  if (!n.endsWith(".json")) continue;
4716
4624
  try {
4717
- const d = JSON.parse(await readFile2(join3(this.dir, n), "utf8"));
4625
+ const d = JSON.parse(await readFile2(join4(this.dir, n), "utf8"));
4718
4626
  out.push({ id: d.id, title: d.title, updatedAt: d.updatedAt, count: d.count });
4719
4627
  } catch {
4720
4628
  }
@@ -4740,7 +4648,7 @@ function titleOf(messages) {
4740
4648
  // src/session/pins.ts
4741
4649
  import { mkdir as mkdir2, readFile as readFile3 } from "fs/promises";
4742
4650
  import { createHash as createHash2 } from "crypto";
4743
- import { join as join4, dirname } from "path";
4651
+ import { join as join5, dirname } from "path";
4744
4652
  var MAX_PINS = 20;
4745
4653
  var MAX_PIN_CHARS = 500;
4746
4654
  var PinStore = class {
@@ -4748,7 +4656,7 @@ var PinStore = class {
4748
4656
  cache;
4749
4657
  constructor(opts) {
4750
4658
  const hash = createHash2("sha256").update(opts.cwd).digest("hex").slice(0, 16);
4751
- this.file = join4(opts.home, ".horsecode", "projects", hash, "pins.json");
4659
+ this.file = join5(opts.home, ".horsecode", "projects", hash, "pins.json");
4752
4660
  }
4753
4661
  /** Load pins from disk (memoized). Missing/corrupt → empty. */
4754
4662
  async load() {
@@ -4798,6 +4706,8 @@ var ModelHealth = class {
4798
4706
  fitness;
4799
4707
  listModels;
4800
4708
  probe;
4709
+ /** Routability, not recovery — see ModelHealthOpts.routable. */
4710
+ routable;
4801
4711
  note;
4802
4712
  now;
4803
4713
  /** Serializes healing: a whole review team failing at once must not trigger 14 concurrent re-assignments. */
@@ -4873,6 +4783,7 @@ var ModelHealth = class {
4873
4783
  this.port = opts.port;
4874
4784
  this.listModels = opts.listModels;
4875
4785
  this.probe = opts.probe;
4786
+ this.routable = opts.routable;
4876
4787
  this.note = opts.note ?? (() => {
4877
4788
  });
4878
4789
  this.now = opts.now ?? (() => Date.now());
@@ -4893,7 +4804,38 @@ var ModelHealth = class {
4893
4804
  mark(model, reason) {
4894
4805
  for (const r of this.port.registries()) r.markExhausted(model, reason, this.now());
4895
4806
  }
4896
- /** Models that may be assigned right now: the catalog minus everything quarantined. */
4807
+ /**
4808
+ * Models that may be assigned right now: the catalog, minus everything quarantined, minus what no role has
4809
+ * ever been configured with.
4810
+ *
4811
+ * The catalog is what the gateway LISTS, which is not what it will route to. Measured across a whole run:
4812
+ * `opencode-go/muse-spark-1.2-contributor-xhigh` is in the catalog of 726 and answers every request with
4813
+ * "not available in the active live catalog". It was in nobody's chain — `/roles adjust` had already
4814
+ * dropped it — and re-assignment handed it out anyway, to twenty-three parallel slots at once. It took the
4815
+ * ladders of twenty-one tasks and, through their dependencies, ninety-seven more.
4816
+ *
4817
+ * The configured set is the curated one: every model in it was chosen for a role and has been reached at
4818
+ * least once. Preferring it costs nothing, because the wider catalog is still there when the curated pool
4819
+ * is spent — which is the only situation that ever needed the breadth.
4820
+ *
4821
+ * That fallback is the door the herd came back through, so nothing walks through it unproven. Measured on
4822
+ * the run after the preference was added: the curated pool emptied for about a minute, the catalog came
4823
+ * back whole, and `muse-spark-1.2-contributor-xhigh` — ranked top by name alone — was handed to every role
4824
+ * being re-assigned at the same instant. 204 calls left before the first answer could bench it. Its eight
4825
+ * siblings each cost exactly ONE call, which is the bench working; the difference is only that they were
4826
+ * not the head of the ranking.
4827
+ *
4828
+ * A probe is the cheapest thing that can tell "listed" from "routable", it runs only on this rare path, and
4829
+ * `poolWithConfigured` already proves the same pattern at startup. It must be the ROUTABILITY probe (see
4830
+ * `routable`): asking the recovery probe instead rejects everything the moment the fleet is busy, which is
4831
+ * exactly when this path runs.
4832
+ *
4833
+ * And a probe that clears nothing does not get to strand every role. Measured live: "probed 78 catalog
4834
+ * model(s) and dropped 78", then `No healthy model left to reassign code-data-integrity` — the lens kept
4835
+ * its spent chain and returned UNVERIFIED, blocking the review. Handing back the unproven catalog risks
4836
+ * one wasted call per model, which the bench ends and the ladder no longer charges for; handing back
4837
+ * nothing leaves a role with no way to run at all. The first is recoverable, so it wins.
4838
+ */
4897
4839
  async healthyModels() {
4898
4840
  const dead = new Set(this.quarantined().map((q) => q.model));
4899
4841
  let all;
@@ -4902,7 +4844,46 @@ var ModelHealth = class {
4902
4844
  } catch {
4903
4845
  return [];
4904
4846
  }
4905
- return all.filter((m) => !dead.has(m));
4847
+ const live = all.filter((m) => !dead.has(m));
4848
+ const configured = new Set(this.port.registries().flatMap((r) => r.knownModels()));
4849
+ const curated = live.filter((m) => configured.has(m) || cliFor(m) !== void 0);
4850
+ telemetry().event("decision.pool", {
4851
+ "hc.decision": "pool",
4852
+ "hc.pool.catalog": all.length,
4853
+ "hc.pool.live": live.length,
4854
+ "hc.pool.configured": configured.size,
4855
+ "hc.pool.curated": curated.length,
4856
+ "hc.pool.source": curated.length ? "curated" : "fallback"
4857
+ });
4858
+ if (curated.length) return curated;
4859
+ const routable = this.routable;
4860
+ if (!routable || !live.length) {
4861
+ telemetry().event("decision.pool", {
4862
+ "hc.decision": "pool",
4863
+ "hc.pool.source": "unscreened",
4864
+ "hc.pool.live": live.length,
4865
+ "hc.pool.reason": routable ? "no live models" : "no routability probe"
4866
+ });
4867
+ return live;
4868
+ }
4869
+ const checked = await Promise.all(live.map(async (m) => ({ m, ok: await routable(m).catch(() => false) })));
4870
+ const usable = checked.filter((c) => c.ok).map((c) => c.m);
4871
+ if (!usable.length) {
4872
+ this.note(`\u{1F50E} The configured models are all spent, and none of the ${checked.length} catalog model(s) answered a probe \u2014 offering them anyway rather than leaving roles with nothing.`);
4873
+ return live;
4874
+ }
4875
+ const refused = checked.length - usable.length;
4876
+ telemetry().event("decision.pool", {
4877
+ "hc.decision": "pool",
4878
+ "hc.pool.source": "probed",
4879
+ "hc.pool.probed": checked.length,
4880
+ "hc.pool.refused": refused,
4881
+ "hc.pool.usable": usable.length
4882
+ });
4883
+ if (refused) {
4884
+ this.note(`\u{1F50E} The configured models are all spent \u2014 probed ${checked.length} catalog model(s) and dropped ${refused} the gateway would not route to.`);
4885
+ }
4886
+ return usable;
4906
4887
  }
4907
4888
  /**
4908
4889
  * Re-probes the quarantine and releases whatever answers again. A 429 is a temporary limit, not a permanent
@@ -4970,6 +4951,12 @@ var ModelHealth = class {
4970
4951
  const reg = this.port.registryFor(role);
4971
4952
  const before = [...reg.rawChain(role)];
4972
4953
  reg.setRoleModel(role, chain);
4954
+ telemetry().event("decision.rechain", {
4955
+ "hc.decision": "rechain",
4956
+ "hc.role": role,
4957
+ "hc.chain": chain.join(","),
4958
+ "hc.chain.before": before.join(",")
4959
+ });
4973
4960
  out.push({ role, chain, before });
4974
4961
  }
4975
4962
  return out;
@@ -5204,7 +5191,7 @@ First explain your key choices in a few short sentences, including which skills
5204
5191
  // src/engine/scan-repo.ts
5205
5192
  import { spawn as spawn2 } from "child_process";
5206
5193
  import { readFile as readFile4 } from "fs/promises";
5207
- import { join as join5 } from "path";
5194
+ import { join as join6 } from "path";
5208
5195
  var MANIFESTS = ["package.json", "pyproject.toml", "requirements.txt", "go.mod", "Cargo.toml", "Gemfile", "pom.xml", "build.gradle", "composer.json"];
5209
5196
  var MAX_FILES = 2e4;
5210
5197
  function gitFiles(cwd) {
@@ -5224,7 +5211,7 @@ async function scanRepo(cwd) {
5224
5211
  for (const name of MANIFESTS) {
5225
5212
  if (!files.includes(name)) continue;
5226
5213
  try {
5227
- manifests[name] = await readFile4(join5(cwd, name), "utf8");
5214
+ manifests[name] = await readFile4(join6(cwd, name), "utf8");
5228
5215
  } catch {
5229
5216
  }
5230
5217
  }
@@ -5373,6 +5360,12 @@ function startupSummary(f) {
5373
5360
  `- Rules: ${f.rules} active${f.rules ? " (`/memories` to read them)" : ""}`,
5374
5361
  `- Memory: ${plural(m.total, "entry", "entries")}${kinds ? ` \u2014 ${kinds}` : ""}`,
5375
5362
  `- Skills: ${plural(f.skills, "available", "available")}`,
5363
+ /**
5364
+ * The one line about what the work will be CHARGED to, so it sits with the rest of what the session has
5365
+ * to work with. It was previously printed with `console.log` before this panel rendered — which the TUI
5366
+ * then cleared, so a person with two working logins was shown nothing at all.
5367
+ */
5368
+ ...f.accounts ? [`- Accounts: ${f.accounts}`] : [],
5376
5369
  /**
5377
5370
  * "traces" is named precisely, because the word is taken.
5378
5371
  *
@@ -5395,7 +5388,7 @@ function startupSummary(f) {
5395
5388
 
5396
5389
  // src/tui/app.tsx
5397
5390
  import { execFileSync } from "child_process";
5398
- import { existsSync } from "fs";
5391
+ import { existsSync as existsSync2 } from "fs";
5399
5392
  import { jsx as jsx5 } from "react/jsx-runtime";
5400
5393
  async function runTui(opts) {
5401
5394
  const controller = new TuiController();
@@ -5445,7 +5438,7 @@ async function runTuiRepl(opts) {
5445
5438
  const chain = regFor(r).chain(r);
5446
5439
  return { name: r, model: chain[0] ?? "", models: chain, council: reviewNames.has(r), decider: councilNames.includes(r) };
5447
5440
  });
5448
- const fitness = deps0.fitness ?? new RoleFitness(join6(homedir2(), ".horsecode", "model-fitness.json"));
5441
+ const fitness = deps0.fitness ?? new RoleFitness(join7(homedir2(), ".horsecode", "model-fitness.json"));
5449
5442
  for (const r of [deps0.roleRegistry, deps0.teamRegistries.spec, deps0.teamRegistries.plan, deps0.teamRegistries.code, deps0.councilRegistry]) {
5450
5443
  r.setFitness(fitness);
5451
5444
  }
@@ -5460,6 +5453,12 @@ async function runTuiRepl(opts) {
5460
5453
  // STRICTER than source discovery, which counts 429 as "routed": a rate-limited model is exactly what was
5461
5454
  // quarantined, so only a real answer may release it.
5462
5455
  ...opts.probeModel ? { probe: opts.probeModel } : {},
5456
+ /**
5457
+ * …and the 429-tolerant one for the other question. `healthyModels` asks whether the gateway will ROUTE
5458
+ * to a model, not whether a benched one has recovered, and it asks precisely when the fleet is busiest.
5459
+ * Given the strict probe it dropped all 78 catalog models at once — see ModelHealthOpts.routable.
5460
+ */
5461
+ ...opts.routableModel ? { routable: opts.routableModel } : {},
5463
5462
  note: (m) => controller.note(m)
5464
5463
  });
5465
5464
  health.watch();
@@ -5570,7 +5569,7 @@ Question: ${question}` }
5570
5569
  };
5571
5570
  const addMcp = async (input) => {
5572
5571
  const { parseCommand, parseConfigBlock, extractFromPage, verify } = await import("./install-O34KMWJB.js");
5573
- const { saveMcpServer } = await import("./save-skills-NSLBU33X.js");
5572
+ const { saveMcpServer } = await import("./save-skills-ZW5GY6KV.js");
5574
5573
  let cand = parseCommand(input) ?? parseConfigBlock(input);
5575
5574
  if (!cand) {
5576
5575
  const url = input.trim();
@@ -5619,7 +5618,7 @@ _Restart to connect it._`;
5619
5618
  const baseRef = { current: opts.jobBase.fromBranch };
5620
5619
  const continueFromClaude = async (arg) => {
5621
5620
  const { adoptClaudeWorktree, describeAdoption, AdoptError, listClaudeWorktrees } = await import("./worktree-F7TWLWLN.js");
5622
- const { defaultGitRunner } = await import("./git-VTSZALSR.js");
5621
+ const { defaultGitRunner } = await import("./git-QZTDZSJY.js");
5623
5622
  const git = defaultGitRunner;
5624
5623
  try {
5625
5624
  const w = await adoptClaudeWorktree(git, process.cwd(), arg, baseRef.current);
@@ -5642,7 +5641,7 @@ _This project has no worktrees under \`.claude/worktrees\`._`}`);
5642
5641
  const migrate = async () => {
5643
5642
  if (!opts.memStore) return "Migration needs the memory store, which is not available in this session.";
5644
5643
  const progressLines = /* @__PURE__ */ new Map();
5645
- const { runMigration, describeResult } = await import("./run-LQOZ5I7Z.js");
5644
+ const { runMigration, describeResult } = await import("./run-P6ZYL5JL.js");
5646
5645
  const r = await runMigration({
5647
5646
  cwd: process.cwd(),
5648
5647
  home: homedir2(),
@@ -5868,9 +5867,13 @@ _${saved ? `Saved to your config \u2014 future sessions start with these. ` : ""
5868
5867
  rules: rulesFromMemory().length,
5869
5868
  memory: { total: all.length, rules: kindOf("rule"), lessons: kindOf("lesson"), facts: kindOf("fact") },
5870
5869
  skills: opts.listSkills?.().length ?? 0,
5871
- constitution: existsSync(join6(process.cwd(), ".specify", "memory", "constitution.md")),
5870
+ constitution: existsSync2(join7(process.cwd(), ".specify", "memory", "constitution.md")),
5872
5871
  graph: { built: false, nodes: 0 },
5873
5872
  traceRoot: traceRootRel(),
5873
+ ...(() => {
5874
+ const a = opts.accountsNote?.();
5875
+ return a ? { accounts: a } : {};
5876
+ })(),
5874
5877
  ...unfinished.length ? { unfinished } : {},
5875
5878
  ...startupExtra
5876
5879
  };
@@ -5899,12 +5902,12 @@ _${saved ? `Saved to your config \u2014 future sessions start with these. ` : ""
5899
5902
  paintSummary();
5900
5903
  void (async () => {
5901
5904
  try {
5902
- const { defaultGitRunner } = await import("./git-VTSZALSR.js");
5905
+ const { defaultGitRunner } = await import("./git-QZTDZSJY.js");
5903
5906
  const { recordedMainBranch, detectMainBranch } = await import("./main-branch-KGWUINYQ.js");
5904
5907
  const cwd = process.cwd();
5905
5908
  const main = await recordedMainBranch(cwd) ?? await detectMainBranch(cwd, defaultGitRunner);
5906
5909
  if (!main) return;
5907
- const { ongoingWork, chooseOngoing } = await import("./ongoing-XP6WXNI7.js");
5910
+ const { ongoingWork, chooseOngoing } = await import("./ongoing-WHYXPW24.js");
5908
5911
  const open = await ongoingWork(defaultGitRunner, cwd, main);
5909
5912
  if (!open.length) return;
5910
5913
  const picked = await chooseOngoing(deps, read, open[0]?.language, open);
@@ -5923,11 +5926,11 @@ _${saved ? `Saved to your config \u2014 future sessions start with these. ` : ""
5923
5926
  })();
5924
5927
  void (async () => {
5925
5928
  try {
5926
- const { loadTraceIndex } = await import("./trace-ZMB7LT7W.js");
5929
+ const { loadTraceIndex } = await import("./trace-X6TU3AG6.js");
5927
5930
  let index = await loadTraceIndex(process.cwd());
5928
5931
  startupExtra.traces = Object.keys(index.traces).length;
5929
5932
  paintSummary();
5930
- const { graphStatus: graphStatus2 } = await import("./project-graph-IOPCSZUA.js");
5933
+ const { graphStatus: graphStatus2 } = await import("./project-graph-5HNPRFQG.js");
5931
5934
  const g = await graphStatus2(process.cwd());
5932
5935
  startupExtra.graph = { built: g.built, nodes: g.nodes, stale: g.stale };
5933
5936
  if (!g.built || g.stale) {
@@ -5936,18 +5939,18 @@ _${saved ? `Saved to your config \u2014 future sessions start with these. ` : ""
5936
5939
  const after = await graphStatus2(process.cwd());
5937
5940
  startupExtra.graph = { built: after.built, nodes: after.nodes, stale: after.stale };
5938
5941
  }
5939
- const g0 = await import("./project-graph-IOPCSZUA.js").then((m) => m.loadGraphSync(process.cwd()));
5942
+ const g0 = await import("./project-graph-5HNPRFQG.js").then((m) => m.loadGraphSync(process.cwd()));
5940
5943
  if (g0) {
5941
5944
  const files = /* @__PURE__ */ new Set();
5942
5945
  for (const n of g0.nodes) if (n.source_file) files.add(n.source_file);
5943
- const { adoptDocs, indexAdoption, describeAdoption } = await import("./trace-adopt-C6TUWFJL.js");
5946
+ const { adoptDocs, indexAdoption, describeAdoption } = await import("./trace-adopt-URECQWJV.js");
5944
5947
  const adoption = await adoptDocs(process.cwd(), files);
5945
5948
  const { readFile: readFile5 } = await import("fs/promises");
5946
5949
  const r = await indexAdoption(
5947
5950
  process.cwd(),
5948
5951
  index,
5949
5952
  adoption,
5950
- async (f) => readFile5(join6(process.cwd(), f), "utf8").catch(() => void 0)
5953
+ async (f) => readFile5(join7(process.cwd(), f), "utf8").catch(() => void 0)
5951
5954
  );
5952
5955
  if (r.added) {
5953
5956
  index = r.index;
@@ -5956,8 +5959,8 @@ _${saved ? `Saved to your config \u2014 future sessions start with these. ` : ""
5956
5959
  }
5957
5960
  startupExtra.traces = Object.keys(index.traces).length;
5958
5961
  paintSummary();
5959
- const { traceableFiles } = await import("./trace-run-F23MFTY4.js");
5960
- const { traceCoverage } = await import("./trace-ZMB7LT7W.js");
5962
+ const { traceableFiles } = await import("./trace-run-7U4WJZ3V.js");
5963
+ const { traceCoverage } = await import("./trace-X6TU3AG6.js");
5961
5964
  startupExtra.coverage = await traceCoverage(process.cwd(), await traceableFiles(process.cwd()), index);
5962
5965
  paintSummary();
5963
5966
  } catch {
@@ -6073,6 +6076,7 @@ _${saved ? `Saved to your config \u2014 future sessions start with these. ` : ""
6073
6076
  setRoleModel: applyChainPersisted,
6074
6077
  listRoles,
6075
6078
  adjustRoles,
6079
+ modelsPanel: opts.modelsPanel,
6076
6080
  listSkills: opts.listSkills,
6077
6081
  updateSkills: opts.updateSkills,
6078
6082
  addSkill: opts.addSkill,