tickmarkr 1.71.0 → 1.73.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 (53) hide show
  1. package/dist/adapters/codex.js +13 -3
  2. package/dist/cli/commands/approve.js +21 -3
  3. package/dist/cli/commands/fleet.d.ts +2 -0
  4. package/dist/cli/commands/fleet.js +212 -449
  5. package/dist/cli/commands/resume.js +10 -2
  6. package/dist/cli/commands/status.d.ts +2 -0
  7. package/dist/cli/commands/status.js +219 -30
  8. package/dist/cli/commands/ui.d.ts +6 -2
  9. package/dist/cli/commands/ui.js +2 -4
  10. package/dist/config/config.d.ts +4 -0
  11. package/dist/config/config.js +6 -1
  12. package/dist/drivers/herdr.d.ts +6 -0
  13. package/dist/drivers/herdr.js +169 -71
  14. package/dist/drivers/subprocess.d.ts +3 -3
  15. package/dist/drivers/subprocess.js +10 -5
  16. package/dist/gates/acceptance.js +41 -17
  17. package/dist/route/preference.d.ts +2 -1
  18. package/dist/route/preference.js +8 -2
  19. package/dist/run/daemon.d.ts +1 -0
  20. package/dist/run/daemon.js +139 -4
  21. package/dist/run/git.d.ts +2 -0
  22. package/dist/run/git.js +55 -0
  23. package/dist/run/journal.d.ts +6 -1
  24. package/dist/run/journal.js +28 -1
  25. package/dist/tui/ink/components.d.ts +38 -0
  26. package/dist/tui/ink/components.js +42 -0
  27. package/dist/tui/ink/fleet-app.d.ts +113 -0
  28. package/dist/tui/ink/fleet-app.js +678 -0
  29. package/dist/tui/ink/studio-app.d.ts +59 -0
  30. package/dist/tui/ink/studio-app.js +320 -0
  31. package/package.json +4 -1
  32. package/dist/tui/app.d.ts +0 -72
  33. package/dist/tui/app.js +0 -334
  34. package/dist/tui/engine.d.ts +0 -52
  35. package/dist/tui/engine.js +0 -92
  36. package/dist/tui/frame.d.ts +0 -22
  37. package/dist/tui/frame.js +0 -56
  38. package/dist/tui/input.d.ts +0 -41
  39. package/dist/tui/input.js +0 -100
  40. package/dist/tui/views/consult-dossier.d.ts +0 -49
  41. package/dist/tui/views/consult-dossier.js +0 -169
  42. package/dist/tui/views/diff-modal.d.ts +0 -9
  43. package/dist/tui/views/diff-modal.js +0 -19
  44. package/dist/tui/views/fleet-view.d.ts +0 -91
  45. package/dist/tui/views/fleet-view.js +0 -337
  46. package/dist/tui/views/preview-view.d.ts +0 -7
  47. package/dist/tui/views/preview-view.js +0 -80
  48. package/dist/tui/views/profile-view.d.ts +0 -15
  49. package/dist/tui/views/profile-view.js +0 -93
  50. package/dist/tui/views/routing-view.d.ts +0 -48
  51. package/dist/tui/views/routing-view.js +0 -234
  52. package/dist/tui/views/runs-view.d.ts +0 -70
  53. package/dist/tui/views/runs-view.js +0 -387
@@ -1,11 +1,8 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
- import { emitKeypressEvents } from "node:readline";
4
- import { createInterface } from "node:readline/promises";
5
3
  import { parseArgs } from "node:util";
6
4
  import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse, readAutoPrefer } from "../../adapters/registry.js";
7
5
  import { fleetUnclassifiedModels } from "../../adapters/model-lints.js";
8
- import { GLYPHS, bold, legend as legendText, toggleActive, toggleInactive } from "../../brand.js";
9
6
  import { fleetEditableFromConfig, fleetEditableEquals, fleetRepoOverlayFromDelta, formatFleetPrint, globalConfigDir, harvestFleetProvenance, overlayBytesLoadError, overlayPreferShapes, readOverlayFile, repoOverlayPath, repoOverlayYaml, ROUTING_MODES, unifiedYamlDiff, } from "../../config/config.js";
10
7
  import { SHAPES, TIERS } from "../../graph/schema.js";
11
8
  import { candidateRow, costSignal, shapeCandidates } from "./fleet-picker.js";
@@ -14,8 +11,6 @@ import { resolveRunMode } from "../../run/daemon.js";
14
11
  import { loadRoutingProfile } from "../../run/journal.js";
15
12
  const NON_TTY_MSG = "tickmarkr fleet: interactive fleet editor requires a TTY — use `tickmarkr fleet --print` for non-interactive output";
16
13
  const QUIT = "fleet: quit without writing";
17
- const isDeniedAdapter = (id, editable) => editable.denyAdapters.includes(id);
18
- const isDeniedModel = (adapter, model, editable) => editable.denyModels.includes(`${adapter}:${model}`);
19
14
  // v1.60 T3: every preview surface ranks with the SAME exploration setting as the candidate picker
20
15
  // (rankCandidates routes noExplore so repeated calls agree) — a due probe must never make a
21
16
  // step-4/5 row disagree with the picker's rank-1 for the same shape and channel set.
@@ -37,14 +32,6 @@ function previewTask(shape) {
37
32
  evidence: { commits: [], artifacts: [], gateResults: [] },
38
33
  };
39
34
  }
40
- function formatAge(ageMs) {
41
- if (ageMs === null)
42
- return "no probe data";
43
- const mins = Math.floor(ageMs / 60_000);
44
- if (mins < 60)
45
- return `${mins}m old`;
46
- return `${Math.floor(mins / 60)}h old`;
47
- }
48
35
  function currentRepoOverlayText(repoRoot) {
49
36
  const p = repoOverlayPath(repoRoot);
50
37
  return existsSync(p) ? readFileSync(p, "utf8") : "";
@@ -76,90 +63,6 @@ const MODE_GLOSS = {
76
63
  "risk-based": "risk-tiered default floors",
77
64
  "staff-led": "implement/refactor one band down · integrity shapes hold frontier",
78
65
  };
79
- const isAbort = (k) => k.name === "escape" || k.name === "q" || (k.ctrl === true && k.name === "c");
80
- const isNext = (k) => k.name === "return" || k.name === "enter";
81
- const isToggle = (k) => k.name === "space";
82
- function moveCursor(k, cursor, count) {
83
- if (count === 0)
84
- return cursor;
85
- if (k.name === "down" || k.name === "j")
86
- return Math.min(cursor + 1, count - 1);
87
- if (k.name === "up" || k.name === "k")
88
- return Math.max(cursor - 1, 0);
89
- return cursor;
90
- }
91
- // step title (bold) + one dim key legend + rows; exactly one pointer glyph on the cursor row
92
- function listFrame(title, legend, rows, cursor) {
93
- const lines = [bold(title), legendText(legend)];
94
- rows.forEach((r, i) => lines.push(i === cursor ? `${GLYPHS.pointer} ${bold(r)}` : ` ${r}`));
95
- return lines;
96
- }
97
- // Keypress terminal over the (injectable) input stream. Keypress events are decoded by
98
- // node's own emitKeypressEvents ON THE STREAM — no readline interface is held in keypress
99
- // mode, so the production path and the test seam are the same decoder (OBS-69).
100
- function openTerm(input, output) {
101
- emitKeypressEvents(input);
102
- const queue = [];
103
- let pending = null;
104
- const onKeypress = (str, key) => {
105
- const k = key && (key.name !== undefined || key.sequence !== undefined) ? key : { name: str, sequence: str };
106
- if (pending) {
107
- const fn = pending;
108
- pending = null;
109
- fn(k);
110
- }
111
- else
112
- queue.push(k);
113
- };
114
- const setRaw = (on) => void input.setRawMode?.(on);
115
- input.on("keypress", onKeypress);
116
- setRaw(true);
117
- input.resume();
118
- let prevLines = 0;
119
- return {
120
- key() {
121
- const buffered = queue.shift();
122
- if (buffered)
123
- return Promise.resolve(buffered);
124
- return new Promise((resolve) => {
125
- pending = resolve;
126
- });
127
- },
128
- frame(lines) {
129
- const erase = prevLines > 0 ? `\x1b[${prevLines}F\x1b[0J` : "";
130
- output.write(`${erase}${lines.join("\n")}\n`);
131
- prevLines = lines.length;
132
- },
133
- // typed entry leaves keypress mode: raw off, transient line interface, closed after the
134
- // question; keys the line editor leaked into the decoder are dropped (type-ahead kept)
135
- async askTyped(prompt) {
136
- const typedFrom = queue.length;
137
- setRaw(false);
138
- const rl = createInterface({
139
- input: input,
140
- output: output,
141
- });
142
- try {
143
- return (await rl.question(prompt)).trim();
144
- }
145
- finally {
146
- rl.close();
147
- setRaw(true);
148
- // rl.close() pauses the input stream — without a resume the next key() await has
149
- // nothing keeping the event loop alive and the process silently exits 0 (OBS-77)
150
- input.resume();
151
- queue.splice(typedFrom);
152
- prevLines = 0;
153
- }
154
- },
155
- // every exit path: raw mode off, zero keypress listeners, stream paused (OBS-70)
156
- close() {
157
- input.off("keypress", onKeypress);
158
- setRaw(false);
159
- input.pause();
160
- },
161
- };
162
- }
163
66
  export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(), io = {}) {
164
67
  const { values } = parseArgs({
165
68
  args: argv,
@@ -200,346 +103,146 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
200
103
  const initial = fleetEditableFromConfig(cfg, harvested.tiers);
201
104
  const editable = structuredClone(initial);
202
105
  const health = cached;
203
- const term = openTerm(input, output);
204
- try {
205
- // step 1/6 — probe data
206
- const ageLine = formatAge(doctorAgeMs(cwd));
207
- term.frame(listFrame("step 1/6 · probe data", "enter continue · r refresh via doctor · esc/q quit", [`probe data: ${ageLine} (.tickmarkr/doctor.json)`], 0));
208
- for (;;) {
209
- const k = await term.key();
210
- if (isAbort(k))
211
- return QUIT;
212
- if (k.name === "r") {
213
- return "fleet: run `tickmarkr doctor` to refresh probe data, then re-run `tickmarkr fleet` (doctor is the sensor; fleet never re-probes)";
214
- }
215
- if (isNext(k))
216
- break;
217
- }
218
- // step 2/6 — agent CLIs (space toggles adapter deny)
219
- const installed = adapters.filter((a) => health[a.id]?.installed);
220
- const adapterRows = () => installed.map((a) => {
221
- const h = health[a.id];
222
- const on = !isDeniedAdapter(a.id, editable);
223
- return `${on ? toggleActive() : toggleInactive()} ${a.id} ${h?.version ?? "installed"} ${h?.authed ? "authed" : "unauthed"}`;
224
- });
225
- const listLegend = "↑↓/jk move · space toggle · enter next · esc/q quit";
226
- let aCursor = 0;
227
- term.frame(listFrame("step 2/6 · agent CLIs", listLegend, adapterRows(), aCursor));
228
- for (;;) {
229
- const k = await term.key();
230
- if (isAbort(k))
231
- return QUIT;
232
- if (isNext(k))
233
- break;
234
- let changed = false;
235
- const moved = moveCursor(k, aCursor, installed.length);
236
- if (moved !== aCursor) {
237
- aCursor = moved;
238
- changed = true;
239
- }
240
- else if (isToggle(k) && installed.length > 0) {
241
- const id = installed[aCursor].id;
242
- const idx = editable.denyAdapters.indexOf(id);
243
- if (idx === -1)
244
- editable.denyAdapters.push(id);
245
- else
246
- editable.denyAdapters.splice(idx, 1);
247
- editable.denyAdapters.sort();
248
- changed = true;
249
- }
250
- if (changed)
251
- term.frame(listFrame("step 2/6 · agent CLIs", listLegend, adapterRows(), aCursor));
252
- }
253
- // step 3/6 — models per enabled adapter (space toggles deny, t assigns tier from the row)
254
- const enabledAdapters = installed.filter((a) => !isDeniedAdapter(a.id, editable));
255
- for (const a of enabledAdapters) {
256
- const unclassifiedAll = fleetUnclassifiedModels(cfg, health, adapters).filter((u) => u.adapter === a.id);
257
- const rowsData = () => [
258
- ...Object.keys(editable.tiers[a.id] ?? {}).map((m) => ({ kind: "classified", model: m })),
259
- ...unclassifiedAll
260
- .filter((u) => !editable.tiers[a.id]?.[u.model])
261
- .map((u) => ({ kind: "unclassified", model: u.model, detectedAt: u.detectedAt })),
262
- ];
263
- const renderRows = (rows) => rows.map((r) => {
264
- if (r.kind === "unclassified") {
265
- return `( ) ${r.model} ??? unclassified${r.detectedAt ? ` (${r.detectedAt})` : ""}`;
266
- }
267
- const tier = editable.tiers[a.id]?.[r.model];
268
- const denied = isDeniedModel(a.id, r.model, editable);
269
- return `${denied ? toggleInactive() : toggleActive()} ${r.model} ${tier?.tier ?? "???"} ${denied ? "denied" : "allowed"}`;
270
- });
271
- const title = `step 3/6 · models · ${a.id}`;
272
- const modelsLegend = "↑↓/jk move · space toggle · t tier · enter next · esc/q quit";
273
- let mCursor = 0;
274
- term.frame(listFrame(title, modelsLegend, renderRows(rowsData()), mCursor));
275
- for (;;) {
276
- const k = await term.key();
277
- if (isAbort(k))
278
- return QUIT;
279
- if (isNext(k))
280
- break;
281
- const rows = rowsData();
282
- let changed = false;
283
- const moved = moveCursor(k, mCursor, rows.length);
284
- if (moved !== mCursor) {
285
- mCursor = moved;
286
- changed = true;
287
- }
288
- else if (isToggle(k) && rows[mCursor]?.kind === "classified") {
289
- const key = `${a.id}:${rows[mCursor].model}`;
290
- const idx = editable.denyModels.indexOf(key);
291
- if (idx === -1)
292
- editable.denyModels.push(key);
293
- else
294
- editable.denyModels.splice(idx, 1);
295
- editable.denyModels.sort();
296
- changed = true;
297
- }
298
- else if (k.name === "t" && rows[mCursor]) {
299
- if (rows[mCursor].kind === "classified") {
300
- // v1.60 T2: t on a classified row is a step-3 input mistake like any other — the
301
- // notice renders inline and the session (with every in-session edit) stays alive
302
- term.frame([
303
- ...listFrame(title, modelsLegend, renderRows(rows), mCursor),
304
- "fleet: tier reassignment on classified models is not supported in v1 — edit config directly",
305
- ]);
306
- continue;
307
- }
308
- // v1.60 T2: an input mistake re-prompts the same field — a typo must never unwind
309
- // the whole editor and discard every in-session edit before the review screen
310
- let tier = (await term.askTyped(`tier (${TIERS.join("|")})> `));
311
- while (!TIERS.includes(tier)) {
312
- tier = (await term.askTyped(`fleet: invalid tier ${tier || "(empty)"} — tier (${TIERS.join("|")})> `));
313
- }
314
- let note = await term.askTyped("benchmark provenance note (required): ");
315
- while (!note) {
316
- note = await term.askTyped("fleet: a typed benchmark-provenance note is required — benchmark provenance note (required): ");
317
- }
318
- editable.tiers[a.id] ??= {};
319
- // stamp at typing time — the serializer writes provenance verbatim, so harvested notes
320
- // round-trip byte-for-byte instead of accreting a fresh date suffix every write (OBS-88)
321
- const today = new Date().toISOString().slice(0, 10);
322
- editable.tiers[a.id][rows[mCursor].model] = { tier, provenance: `${note} — fleet ${today}` };
323
- changed = true;
324
- }
325
- if (changed)
326
- term.frame(listFrame(title, modelsLegend, renderRows(rowsData()), mCursor));
327
- }
328
- }
329
- // step 4/6 — routing mode (selection is in-memory; the write happens only through the diff confirm).
330
- // Candidate floor tables come from the ONE preset compiler via resolveRunMode — no mode math here.
331
- const modeCfgs = Object.fromEntries(ROUTING_MODES.map((m) => [m, m === rm.mode.mode ? rm : resolveRunMode(cwd, { flag: m, globalDir })]));
332
- // v1.56 T3: both screens preview-route through ONE lens — the same live channel set, learned
333
- // profile, and per-mode resolved floors — so the mode spend context and the shape rows can
334
- // never disagree about what the router would do.
335
- const channels = discoverChannels(cfg, adapters, health);
336
- const profile = loadRoutingProfile(cwd, cfg, { preview: true });
337
- const previewCfg = (m) => ({ ...cfg, routing: { ...cfg.routing, map: editable.map, floors: modeCfgs[m].cfg.routing.floors } });
338
- // Estimated spend context under the highlighted mode: tier mix across all nine preview shapes,
339
- // then channel economics — dollars only for api-routed shapes (plan.ts's rough per-task
340
- // pricing table); a sub channel is flat-rate quota and never renders as a dollar amount.
341
- const modeSpend = (cand) => {
342
- const tierCount = {};
343
- let subs = 0;
344
- let apiN = 0;
345
- let apiUsd = 0;
346
- for (const shape of SHAPES) {
347
- try {
348
- const a = route(previewTask(shape), previewCfg(cand), channels, profile, undefined, undefined, PREVIEW_EXPLORE).assignment;
349
- tierCount[a.tier] = (tierCount[a.tier] ?? 0) + 1;
350
- if (a.channel === "sub")
351
- subs += 1;
352
- else {
353
- apiN += 1;
354
- apiUsd += cfg.pricing[a.tier] ?? 0;
355
- }
356
- }
357
- catch {
358
- // unroutable under this mode's floors — the shape screen names the error per row
359
- }
360
- }
361
- const mix = [...TIERS].reverse().flatMap((t) => (tierCount[t] ? [`${tierCount[t]} ${t}`] : [])).join(" · ");
362
- const parts = [];
363
- if (subs)
364
- parts.push(`${subs === SHAPES.length ? "all" : subs} sub (flat-rate quota)`);
365
- if (apiN)
366
- parts.push(`${apiN} api · est. cost (API shapes only, rough): ~$${apiUsd.toFixed(2)}`);
367
- const unroutable = SHAPES.length - subs - apiN;
368
- if (unroutable)
369
- parts.push(`${unroutable} unroutable`);
370
- return ` mix: ${mix} — ${parts.join(" · ")}`;
371
- };
372
- const modeRows = () => ROUTING_MODES.map((m) => `${m === rm.mode.mode ? toggleActive() : " "} ${m.padEnd(11)} ${MODE_GLOSS[m]}`);
373
- // preview: the highlighted mode's resolved floor table diffed against the current mode
374
- const floorPreview = (cand) => {
375
- if (cand === rm.mode.mode)
376
- return [];
377
- const cur = cfg.routing.floors;
378
- const next = modeCfgs[cand].cfg.routing.floors;
379
- const changed = SHAPES.filter((s) => cur[s] !== next[s]);
380
- return [
381
- ` floors vs ${rm.mode.mode}:`,
382
- ...(changed.length ? changed.map((s) => ` ${s}: ${cur[s]} → ${next[s]}`) : [" (no floor changes)"]),
383
- ];
106
+ const modelGroups = adapters
107
+ .filter((adapter) => health[adapter.id]?.installed)
108
+ .map((adapter) => {
109
+ const unclassified = fleetUnclassifiedModels(cfg, health, adapters).filter((row) => row.adapter === adapter.id);
110
+ return {
111
+ adapter: adapter.id,
112
+ rows: [
113
+ ...Object.entries(editable.tiers[adapter.id] ?? {}).map(([model, value]) => ({ model, tier: value?.tier })),
114
+ ...unclassified
115
+ .filter((row) => !editable.tiers[adapter.id]?.[row.model])
116
+ .map((row) => ({ model: row.model, detectedAt: row.detectedAt })),
117
+ ],
384
118
  };
385
- const modeTitle = "step 4/6 · routing mode";
386
- const modeLegend = "↑↓/jk move · enter select · esc/q quit";
387
- let modeCursor = ROUTING_MODES.indexOf(rm.mode.mode);
388
- let selectedMode = rm.mode.mode;
389
- const modeFrame = () => [
390
- ...listFrame(modeTitle, modeLegend, modeRows(), modeCursor),
391
- modeSpend(ROUTING_MODES[modeCursor]),
392
- ...floorPreview(ROUTING_MODES[modeCursor]),
393
- ];
394
- term.frame(modeFrame());
395
- for (;;) {
396
- const k = await term.key();
397
- if (isAbort(k))
398
- return QUIT;
399
- if (isNext(k)) {
400
- selectedMode = ROUTING_MODES[modeCursor];
401
- break;
402
- }
403
- const moved = moveCursor(k, modeCursor, ROUTING_MODES.length);
404
- if (moved !== modeCursor) {
405
- modeCursor = moved;
406
- term.frame(modeFrame());
407
- }
408
- }
409
- // step 5/6 — shape routing (candidate picker for pin, typed entry for prefer);
410
- // previews route under the SELECTED mode's resolved floors, never a floor edit of its own
411
- const autoPrefer = readAutoPrefer(cwd);
412
- const overlayShapes = overlayPreferShapes(cwd, { globalDir });
413
- const shapeRows = () => SHAPES.map((shape) => {
414
- let now;
119
+ });
120
+ // Steps 4–5 remain surfaces over production routing. Ink owns interaction and rendering;
121
+ // these callbacks retain the existing router, preset compiler, and candidate-ranker seams.
122
+ const modeCfgs = Object.fromEntries(ROUTING_MODES.map((mode) => [
123
+ mode,
124
+ mode === rm.mode.mode ? rm : resolveRunMode(cwd, { flag: mode, globalDir }),
125
+ ]));
126
+ const channels = discoverChannels(cfg, adapters, health);
127
+ const profile = loadRoutingProfile(cwd, cfg, { preview: true });
128
+ const previewCfg = (mode, map) => ({
129
+ ...cfg,
130
+ routing: { ...cfg.routing, map, floors: modeCfgs[mode].cfg.routing.floors },
131
+ });
132
+ const modeSpend = (mode, map) => {
133
+ const tierCount = {};
134
+ let subs = 0;
135
+ let apiN = 0;
136
+ let apiUsd = 0;
137
+ for (const shape of SHAPES) {
415
138
  try {
416
- const r = route(previewTask(shape), previewCfg(selectedMode), channels, profile, undefined, undefined, PREVIEW_EXPLORE);
417
- now = `${r.assignment.adapter}:${r.assignment.model} (${r.assignment.channel}, ${r.assignment.tier}) ${costSignal(r.assignment, cfg.pricing)}`;
418
- }
419
- catch (e) {
420
- now = e.message;
421
- }
422
- const auto = autoPrefer?.[shape] && !overlayShapes.has(shape) ? " (auto-prefer active)" : "";
423
- return `${shape} → ${now}${auto}`;
424
- });
425
- const shapesTitle = "step 5/6 · shape routing";
426
- // v1.52 T5: no map-tier editing action — routing.floors is the only band authority now.
427
- const shapesLegend = "↑↓/jk move · a auto · p pin · f prefer · enter next · esc/q quit";
428
- let sCursor = 0;
429
- term.frame(listFrame(shapesTitle, shapesLegend, shapeRows(), sCursor));
430
- for (;;) {
431
- const k = await term.key();
432
- if (isAbort(k))
433
- return QUIT;
434
- if (isNext(k))
435
- break;
436
- const shape = SHAPES[sCursor];
437
- const entry = editable.map[shape] ?? {};
438
- let changed = false;
439
- const moved = moveCursor(k, sCursor, SHAPES.length);
440
- if (moved !== sCursor) {
441
- sCursor = moved;
442
- changed = true;
443
- }
444
- else if (k.name === "a") {
445
- const next = { ...entry };
446
- delete next.pin;
447
- editable.map[shape] = next;
448
- changed = true;
449
- }
450
- else if (k.name === "p") {
451
- // v1.56 T2: arrow-driven candidate picker replaces the typed pin entry (operator ruling:
452
- // the suggestion proposes, the user disposes). Every rank is a production route() result
453
- // (T1 seam); a pick mutates editable only — the write still funnels through the ONE
454
- // diff-confirm + reload-guard path below. The picker adds no listeners of its own, so
455
- // every exit path inherits term.close()'s release-and-pause contract in the finally.
456
- const cand = shapeCandidates(previewTask(shape), previewCfg(selectedMode), channels, profile);
457
- let pCursor = 0;
458
- const pickerFrame = () => listFrame(`pick · ${shape}`, "↑↓/jk move · enter pin · esc cancel · q quit", cand.map((c) => candidateRow(c, cfg.pricing)), pCursor);
459
- term.frame(pickerFrame());
460
- for (;;) {
461
- const pk = await term.key();
462
- if (pk.name === "q" || (pk.ctrl === true && pk.name === "c"))
463
- return QUIT;
464
- if (pk.name === "escape")
465
- break;
466
- if (isNext(pk) && cand.length > 0) {
467
- const a = cand[pCursor].assignment;
468
- editable.map[shape] = { pin: { via: a.adapter, model: a.model } };
469
- break;
470
- }
471
- const pMoved = moveCursor(pk, pCursor, cand.length);
472
- if (pMoved !== pCursor) {
473
- pCursor = pMoved;
474
- term.frame(pickerFrame());
475
- }
139
+ const assignment = route(previewTask(shape), previewCfg(mode, map), channels, profile, undefined, undefined, PREVIEW_EXPLORE).assignment;
140
+ tierCount[assignment.tier] = (tierCount[assignment.tier] ?? 0) + 1;
141
+ if (assignment.channel === "sub")
142
+ subs += 1;
143
+ else {
144
+ apiN += 1;
145
+ apiUsd += cfg.pricing[assignment.tier] ?? 0;
476
146
  }
477
- changed = true; // the picker replaced the frame — every exit redraws the shape screen
478
147
  }
479
- else if (k.name === "f") {
480
- const pref = await term.askTyped("prefer (comma-separated adapters or adapter:model)> ");
481
- editable.map[shape] = { ...entry, prefer: pref.split(",").map((s) => s.trim()).filter(Boolean) };
482
- changed = true;
148
+ catch {
149
+ // Unroutable under this mode's floors; the aggregate names it below.
483
150
  }
484
- if (changed)
485
- term.frame(listFrame(shapesTitle, shapesLegend, shapeRows(), sCursor));
486
151
  }
487
- // step 6/6 — steering (v1.54 T4): review.prefer + consult.prefer via the same typed-entry
488
- // pattern as the shape prefer action; edits are in-memory only and land on the merged overlay
489
- // below, so they ride the ONE diff-confirm + reload-guard write path (never a second writer)
490
- const STEER_KEYS = ["review", "consult"];
491
- const initialSteering = {
492
- review: cfg.review.prefer?.slice(),
493
- consult: cfg.consult.prefer?.slice(),
494
- };
495
- const steering = structuredClone(initialSteering);
496
- const steerRows = () => STEER_KEYS.map((k) => `${k}.prefer → ${steering[k]?.join(", ") ?? "(none)"}`);
497
- const steerTitle = "step 6/6 · steering";
498
- const steerLegend = "↑↓/jk move · f prefer · enter next · esc/q quit";
499
- let stCursor = 0;
500
- term.frame(listFrame(steerTitle, steerLegend, steerRows(), stCursor));
501
- for (;;) {
502
- const k = await term.key();
503
- if (isAbort(k))
504
- return QUIT;
505
- if (isNext(k))
506
- break;
507
- let changed = false;
508
- const moved = moveCursor(k, stCursor, STEER_KEYS.length);
509
- if (moved !== stCursor) {
510
- stCursor = moved;
511
- changed = true;
512
- }
513
- else if (k.name === "f") {
514
- const which = STEER_KEYS[stCursor];
515
- const grammar = which === "consult" ? "adapter:model seats" : "adapters or adapter:model";
516
- const raw = await term.askTyped(`${which}.prefer (comma-separated ${grammar}, empty clears)> `);
517
- const list = raw.split(",").map((s) => s.trim()).filter(Boolean);
518
- steering[which] = list.length ? list : undefined;
519
- changed = true;
520
- }
521
- if (changed)
522
- term.frame(listFrame(steerTitle, steerLegend, steerRows(), stCursor));
152
+ const mix = [...TIERS].reverse().flatMap((tier) => tierCount[tier] ? [`${tierCount[tier]} ${tier}`] : []).join(" · ");
153
+ const parts = [];
154
+ if (subs)
155
+ parts.push(`${subs === SHAPES.length ? "all" : subs} sub (flat-rate quota)`);
156
+ if (apiN)
157
+ parts.push(`${apiN} api · est. cost (API shapes only, rough): ~$${apiUsd.toFixed(2)}`);
158
+ const unroutable = SHAPES.length - subs - apiN;
159
+ if (unroutable)
160
+ parts.push(`${unroutable} unroutable`);
161
+ return ` mix: ${mix} — ${parts.join(" · ")}`;
162
+ };
163
+ const floorPreview = (mode) => {
164
+ if (mode === rm.mode.mode)
165
+ return [];
166
+ const current = cfg.routing.floors;
167
+ const next = modeCfgs[mode].cfg.routing.floors;
168
+ const changed = SHAPES.filter((shape) => current[shape] !== next[shape]);
169
+ return [
170
+ ` floors vs ${rm.mode.mode}:`,
171
+ ...(changed.length
172
+ ? changed.map((shape) => ` ${shape}: ${current[shape]} → ${next[shape]}`)
173
+ : [" (no floor changes)"]),
174
+ ];
175
+ };
176
+ const autoPrefer = readAutoPrefer(cwd);
177
+ const overlayShapes = overlayPreferShapes(cwd, { globalDir });
178
+ const routedShapeRows = (mode, map) => SHAPES.map((shape) => {
179
+ let now;
180
+ try {
181
+ const routed = route(previewTask(shape), previewCfg(mode, map), channels, profile, undefined, undefined, PREVIEW_EXPLORE);
182
+ const assignment = routed.assignment;
183
+ now = `${assignment.adapter}:${assignment.model} (${assignment.channel}, ${assignment.tier}) ${costSignal(assignment, cfg.pricing)}`;
523
184
  }
524
- // review — unified diff + typed confirm (the ONLY write path; the mode screen funnels through here)
185
+ catch (error) {
186
+ now = error.message;
187
+ }
188
+ const auto = autoPrefer?.[shape] && !overlayShapes.has(shape) ? " (auto-prefer active)" : "";
189
+ return { id: shape, label: `${shape} → ${now}${auto}` };
190
+ });
191
+ const candidatesForShape = (shape, mode, map) => shapeCandidates(previewTask(shape), previewCfg(mode, map), channels, profile).map((candidate) => ({
192
+ id: `${candidate.assignment.adapter}:${candidate.assignment.model}`,
193
+ label: candidateRow(candidate, cfg.pricing),
194
+ pin: { via: candidate.assignment.adapter, model: candidate.assignment.model },
195
+ }));
196
+ const preferUniverse = [
197
+ ...new Set(channels.flatMap((channel) => [
198
+ channel.adapter,
199
+ channel.model,
200
+ `${channel.adapter}:${channel.model}`,
201
+ ])),
202
+ ];
203
+ const seats = [...new Set(channels.map((channel) => `${channel.adapter}:${channel.model}`))];
204
+ const reviewAdapters = [...new Set(channels.map((channel) => channel.adapter))];
205
+ const initialSteering = {
206
+ review: cfg.review.prefer?.slice(),
207
+ consult: cfg.consult.prefer?.slice(),
208
+ };
209
+ const steeringOptionsFor = (which, current) => {
210
+ const discovered = which === "review" ? [...reviewAdapters, ...seats] : seats;
211
+ return [...discovered, ...current.filter((entry) => !discovered.includes(entry))];
212
+ };
213
+ const reviewOverlay = (state) => {
214
+ const staged = structuredClone(initial);
215
+ staged.denyAdapters = state.denyAdapters;
216
+ staged.denyModels = state.denyModels;
217
+ staged.map = state.map;
218
+ const today = new Date().toISOString().slice(0, 10);
219
+ for (const classification of state.classifications) {
220
+ staged.tiers[classification.adapter] ??= {};
221
+ staged.tiers[classification.adapter][classification.model] = {
222
+ tier: classification.tier,
223
+ provenance: `${classification.note} — fleet ${today}`,
224
+ };
225
+ }
226
+ // This callback is the sole candidate-overlay builder. The Ink component renders
227
+ // the diff and asks for confirmation, but owns neither filesystem access nor a writer.
525
228
  const before = currentRepoOverlayText(cwd);
526
- const existing = readOverlayFile(repoOverlayPath(cwd));
527
- const modeChanged = selectedMode !== rm.mode.mode;
528
- // preserve a repo-declared mode line verbatim; write a new one only when the selection changed it
529
- const writeMode = modeChanged ? selectedMode : existing.routing?.mode;
530
- const merged = fleetEditableEquals(initial, editable)
229
+ const path = repoOverlayPath(cwd);
230
+ const existing = readOverlayFile(path);
231
+ const modeChanged = state.selectedMode !== rm.mode.mode;
232
+ const writeMode = modeChanged
233
+ ? state.selectedMode
234
+ : existing.routing?.mode;
235
+ const merged = fleetEditableEquals(initial, staged)
531
236
  ? structuredClone(existing)
532
- : fleetRepoOverlayFromDelta(initial, editable, existing);
533
- // steering lands on the merged object and serializes via repoOverlayYaml's non-fleet
534
- // passthrough; clearing removes the prefer key (and an emptied parent block) from the overlay
237
+ : fleetRepoOverlayFromDelta(initial, staged, existing);
535
238
  let steeringChanged = false;
536
- for (const key of STEER_KEYS) {
537
- if (JSON.stringify(steering[key]) === JSON.stringify(initialSteering[key]))
239
+ for (const key of ["review", "consult"]) {
240
+ if (JSON.stringify(state.steering[key]) === JSON.stringify(initialSteering[key]))
538
241
  continue;
539
242
  steeringChanged = true;
540
243
  const block = { ...merged[key] };
541
- if (steering[key])
542
- block.prefer = steering[key];
244
+ if (state.steering[key])
245
+ block.prefer = state.steering[key];
543
246
  else
544
247
  delete block.prefer;
545
248
  if (Object.keys(block).length)
@@ -547,35 +250,95 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
547
250
  else
548
251
  delete merged[key];
549
252
  }
550
- // OBS-88: session notes (which include the harvested ones attached at load) win per model;
551
- // harvested notes alone cover entries with no editable seat — e.g. a null tombstone's comment
552
253
  const tierNotes = structuredClone(harvested.tiers);
553
- for (const [ad, ms] of Object.entries(provenanceMap(editable))) {
554
- for (const [m, n] of Object.entries(ms))
555
- (tierNotes[ad] ??= {})[m] = n;
254
+ for (const [adapter, models] of Object.entries(provenanceMap(staged))) {
255
+ for (const [model, note] of Object.entries(models)) {
256
+ (tierNotes[adapter] ??= {})[model] = note;
257
+ }
556
258
  }
557
- const after = withModeLine(repoOverlayYaml(merged, tierNotes, { adapters: harvested.denyAdapters, models: harvested.denyModels }), writeMode);
558
- if ((!modeChanged && !steeringChanged && fleetEditableEquals(initial, editable)) || before === after) {
559
- return "fleet: no overlay changes (empty diff)";
259
+ const after = withModeLine(repoOverlayYaml(merged, tierNotes, {
260
+ adapters: harvested.denyAdapters,
261
+ models: harvested.denyModels,
262
+ }), writeMode);
263
+ if ((!modeChanged && !steeringChanged && fleetEditableEquals(initial, staged)) || before === after) {
264
+ return { kind: "empty" };
560
265
  }
561
- const diff = unifiedYamlDiff(before, after, repoOverlayPath(cwd));
562
- output.write(`\n${diff}\n`);
563
- const confirm = await term.askTyped("write overlay? [y/N] ");
564
- if (!/^y(?:es)?$/i.test(confirm))
565
- return "fleet: discarded overlay changes";
566
- const path = repoOverlayPath(cwd);
567
- // v1.52 T2 reload guard: the exact bytes about to land must reload through the production
568
- // loader path, or the write is refused and the existing overlay stays untouched (OBS-75 class:
569
- // fleet must never persist a config that bricks every later command).
570
- const loadError = overlayBytesLoadError(cwd, after, { globalDir });
571
- if (loadError) {
572
- return { out: `fleet: refusing to write ${path} — the config loader rejects the proposed overlay:\n${loadError}`, code: 1 };
266
+ return {
267
+ kind: "diff",
268
+ before,
269
+ after,
270
+ diff: unifiedYamlDiff(before, after, path),
271
+ path,
272
+ };
273
+ };
274
+ const reloadGuard = io.reloadGuard
275
+ ?? ((bytes) => overlayBytesLoadError(cwd, bytes, { globalDir }));
276
+ // Keep Ink out of print, non-TTY, and missing-probe paths: the component runtime belongs
277
+ // exclusively to the interactive editor.
278
+ const initialInput = [];
279
+ const productionInput = input;
280
+ const captureStartupInput = typeof productionInput.ref === "function"
281
+ && typeof productionInput.unref === "function";
282
+ const onStartupInput = (chunk) => {
283
+ initialInput.push(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
284
+ };
285
+ if (captureStartupInput) {
286
+ input.on("data", onStartupInput);
287
+ input.resume();
288
+ }
289
+ let runFleetInkEditor;
290
+ try {
291
+ ({ runFleetInkEditor } = await import("../../tui/ink/fleet-app.js"));
292
+ }
293
+ catch (error) {
294
+ if (captureStartupInput) {
295
+ input.off("data", onStartupInput);
296
+ input.pause();
573
297
  }
574
- mkdirSync(dirname(path), { recursive: true });
575
- writeFileSync(path, after);
576
- return `fleet: wrote ${path}`;
298
+ throw error;
577
299
  }
578
- finally {
579
- term.close();
300
+ if (captureStartupInput) {
301
+ input.off("data", onStartupInput);
302
+ input.pause();
303
+ }
304
+ const result = await runFleetInkEditor({
305
+ ageMs: doctorAgeMs(cwd),
306
+ adapters,
307
+ health,
308
+ initialDenyAdapters: editable.denyAdapters,
309
+ initialDenyModels: editable.denyModels,
310
+ modelGroups,
311
+ initialMode: rm.mode.mode,
312
+ modeOptions: ROUTING_MODES.map((mode) => ({ id: mode, gloss: MODE_GLOSS[mode] })),
313
+ initialMap: editable.map,
314
+ modePreview: (mode, map) => [modeSpend(mode, map), ...floorPreview(mode)],
315
+ shapeRows: routedShapeRows,
316
+ candidatesForShape,
317
+ preferOptionsForShape: (_shape, current) => [
318
+ ...preferUniverse,
319
+ ...current.filter((entry) => !preferUniverse.includes(entry)),
320
+ ],
321
+ initialSteering,
322
+ steeringOptionsFor,
323
+ reviewOverlay,
324
+ reloadGuard,
325
+ initialInput,
326
+ input: input,
327
+ output: output,
328
+ debug: io.debug,
329
+ });
330
+ if (result.kind === "quit")
331
+ return QUIT;
332
+ if (result.kind === "refresh") {
333
+ return "fleet: run `tickmarkr doctor` to refresh probe data, then re-run `tickmarkr fleet` (doctor is the sensor; fleet never re-probes)";
580
334
  }
335
+ if (result.kind === "no-changes")
336
+ return "fleet: no overlay changes (empty diff)";
337
+ if (result.kind === "discard")
338
+ return "fleet: discarded overlay changes";
339
+ // The command remains the single config actuator. Every interactive edit reaches this
340
+ // one write only after the component-rendered diff confirm and the production reload guard.
341
+ mkdirSync(dirname(result.review.path), { recursive: true });
342
+ writeFileSync(result.review.path, result.review.after);
343
+ return `fleet: wrote ${result.review.path}`;
581
344
  }