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.
- package/dist/adapters/codex.js +13 -3
- package/dist/cli/commands/approve.js +21 -3
- package/dist/cli/commands/fleet.d.ts +2 -0
- package/dist/cli/commands/fleet.js +212 -449
- package/dist/cli/commands/resume.js +10 -2
- package/dist/cli/commands/status.d.ts +2 -0
- package/dist/cli/commands/status.js +219 -30
- package/dist/cli/commands/ui.d.ts +6 -2
- package/dist/cli/commands/ui.js +2 -4
- package/dist/config/config.d.ts +4 -0
- package/dist/config/config.js +6 -1
- package/dist/drivers/herdr.d.ts +6 -0
- package/dist/drivers/herdr.js +169 -71
- package/dist/drivers/subprocess.d.ts +3 -3
- package/dist/drivers/subprocess.js +10 -5
- package/dist/gates/acceptance.js +41 -17
- package/dist/route/preference.d.ts +2 -1
- package/dist/route/preference.js +8 -2
- package/dist/run/daemon.d.ts +1 -0
- package/dist/run/daemon.js +139 -4
- package/dist/run/git.d.ts +2 -0
- package/dist/run/git.js +55 -0
- package/dist/run/journal.d.ts +6 -1
- package/dist/run/journal.js +28 -1
- package/dist/tui/ink/components.d.ts +38 -0
- package/dist/tui/ink/components.js +42 -0
- package/dist/tui/ink/fleet-app.d.ts +113 -0
- package/dist/tui/ink/fleet-app.js +678 -0
- package/dist/tui/ink/studio-app.d.ts +59 -0
- package/dist/tui/ink/studio-app.js +320 -0
- package/package.json +4 -1
- package/dist/tui/app.d.ts +0 -72
- package/dist/tui/app.js +0 -334
- package/dist/tui/engine.d.ts +0 -52
- package/dist/tui/engine.js +0 -92
- package/dist/tui/frame.d.ts +0 -22
- package/dist/tui/frame.js +0 -56
- package/dist/tui/input.d.ts +0 -41
- package/dist/tui/input.js +0 -100
- package/dist/tui/views/consult-dossier.d.ts +0 -49
- package/dist/tui/views/consult-dossier.js +0 -169
- package/dist/tui/views/diff-modal.d.ts +0 -9
- package/dist/tui/views/diff-modal.js +0 -19
- package/dist/tui/views/fleet-view.d.ts +0 -91
- package/dist/tui/views/fleet-view.js +0 -337
- package/dist/tui/views/preview-view.d.ts +0 -7
- package/dist/tui/views/preview-view.js +0 -80
- package/dist/tui/views/profile-view.d.ts +0 -15
- package/dist/tui/views/profile-view.js +0 -93
- package/dist/tui/views/routing-view.d.ts +0 -48
- package/dist/tui/views/routing-view.js +0 -234
- package/dist/tui/views/runs-view.d.ts +0 -70
- 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
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
480
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
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
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
const writeMode = modeChanged
|
|
530
|
-
|
|
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,
|
|
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
|
|
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 [
|
|
554
|
-
for (const [
|
|
555
|
-
(tierNotes[
|
|
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, {
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
575
|
-
writeFileSync(path, after);
|
|
576
|
-
return `fleet: wrote ${path}`;
|
|
298
|
+
throw error;
|
|
577
299
|
}
|
|
578
|
-
|
|
579
|
-
|
|
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
|
}
|