tickmarkr 1.48.2 → 1.49.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/README.md +44 -4
- package/dist/cli/commands/fleet.d.ts +15 -1
- package/dist/cli/commands/fleet.js +217 -91
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
tickmarkr is a spec-driven orchestration harness for AI coding agent CLIs. You write a spec with
|
|
13
13
|
acceptance criteria; the engine routes tasks to the best installed agent CLI (claude-code, codex,
|
|
14
|
-
cursor-agent, opencode, grok, pi) by cost and capability, dispatches work in git worktrees for
|
|
14
|
+
cursor-agent, opencode, grok, pi, kimi) by cost and capability, dispatches work in git worktrees for
|
|
15
15
|
change isolation — as interactive TUIs when running under [herdr](https://herdr.dev), headless
|
|
16
16
|
subprocesses otherwise — and independently verifies each committed result by checking for no new
|
|
17
17
|
baseline failures per task, then strictly verifying the integration tip. Green tasks consolidate onto a
|
|
@@ -51,8 +51,8 @@ prefer, every example below works with either.
|
|
|
51
51
|
- **macOS or Linux.** Every command shells out through `bash`, so native Windows is not
|
|
52
52
|
supported — use WSL (untested).
|
|
53
53
|
- Node ≥ 20 and `git` on PATH.
|
|
54
|
-
- At least one agent CLI on PATH (`claude`, `codex`, `cursor-agent`, `opencode`, `grok`,
|
|
55
|
-
`
|
|
54
|
+
- At least one agent CLI on PATH (`claude`, `codex`, `cursor-agent`, `opencode`, `grok`, `pi`,
|
|
55
|
+
or `kimi`), authenticated through its own login. tickmarkr never handles vendor API keys itself.
|
|
56
56
|
|
|
57
57
|
Then verify your fleet:
|
|
58
58
|
|
|
@@ -63,7 +63,11 @@ tickmarkr doctor # probes installed adapters, herdr, auth; prints the capabili
|
|
|
63
63
|
**Expect `doctor` (and `init`, which runs it) to take a while on first run:** auth detection is
|
|
64
64
|
one real, short LLM call per configured model per installed CLI — honest, but not instant. A
|
|
65
65
|
machine with three CLIs and several models each can take a minute or more; each probe is capped
|
|
66
|
-
at
|
|
66
|
+
at 60s.
|
|
67
|
+
|
|
68
|
+
`doctor` also sweeps a catalog of known agent CLIs that have no adapter yet and prints an
|
|
69
|
+
advisory `detected — no adapter; not routable` row for any found on PATH — a freshly installed
|
|
70
|
+
harness is visible the day it lands, and never routed until an adapter ships for it.
|
|
67
71
|
|
|
68
72
|
## Quickstart (5 minutes)
|
|
69
73
|
|
|
@@ -113,6 +117,7 @@ tickmarkr resume <runId> # continue an engagement from the local execution
|
|
|
113
117
|
tickmarkr approve <runId> <taskId> # approve a parked task (--reason to document)
|
|
114
118
|
tickmarkr report <runId> # cost/quality report
|
|
115
119
|
tickmarkr profile # show the learned routing profile
|
|
120
|
+
tickmarkr profile --explain <shape> <channel> # why a channel ranks where it does for a shape
|
|
116
121
|
```
|
|
117
122
|
|
|
118
123
|
Green tasks land on `tickmarkr/<runId>`; merge to your mainline is always your call.
|
|
@@ -136,6 +141,41 @@ away from a failing adapter will never retry it in subsequent `tickmarkr resume`
|
|
|
136
141
|
- The task has burned its full attempt budget without reaching a conclusive result
|
|
137
142
|
- Approving grants a fresh attempt budget, routing around all previously-failed channels and adapters
|
|
138
143
|
|
|
144
|
+
## Choosing your fleet: `tickmarkr fleet`
|
|
145
|
+
|
|
146
|
+
`tickmarkr doctor` is a pure sensor; `tickmarkr fleet` is the actuator. It walks four screens —
|
|
147
|
+
fleet overview, membership (allow/deny), model tiers, and per-shape routing — and ends in a
|
|
148
|
+
unified diff of your config overlay that is written **only on explicit confirm**. Pressing Enter
|
|
149
|
+
through every screen writes nothing. Assigning a tier to a model that has no classification yet
|
|
150
|
+
requires a typed benchmark-provenance note, stored as a config comment beside the assignment.
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
tickmarkr fleet # interactive editor (requires a TTY)
|
|
154
|
+
tickmarkr fleet --print # effective fleet state (repo > global > defaults), non-interactive — good for CI drift checks
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Routing obeys a strict precedence — **pin > prefer > floors > marginal-cost auto**:
|
|
158
|
+
|
|
159
|
+
- **pin** a shape to an exact channel: `map: { plan: { via: claude-code, model: fable } }`
|
|
160
|
+
- **prefer**-rank adapters per shape: `map: { implement: { prefer: [cursor-agent, codex] } }`
|
|
161
|
+
- **floors** set the minimum capability band per shape (`migration: frontier`, `tests: cheap`);
|
|
162
|
+
auto-routing then picks the cheapest authed channel at or above the floor
|
|
163
|
+
- **deny/allow** bench models or whole adapters without touching tiers (see `deny.models` below)
|
|
164
|
+
- **tiers** classify models into bands — only classified, doctor-authed models ever route
|
|
165
|
+
|
|
166
|
+
More levers, all optional and absent-safe (absent config keeps default behavior):
|
|
167
|
+
|
|
168
|
+
- `routing.explore` — fence the exploration budget: `mode: off`, `excludeShapes`,
|
|
169
|
+
`excludeComplexityAtOrAbove`, and a per-channel `cap`; `run --no-explore` disables
|
|
170
|
+
exploration probes for a single run
|
|
171
|
+
- `tiers.<adapter>.windows` — declare context-window sizes per model; doctor grows a window
|
|
172
|
+
column and `plan` warns (advisory, never blocking) when a task's payload estimate exceeds
|
|
173
|
+
the routed model's window
|
|
174
|
+
- `routing.sla` — per-shape latency expectations, surfaced as advisory plan lints against the
|
|
175
|
+
learned performance profile
|
|
176
|
+
- `run --quality` — raise every capability floor one band for that run (it never lowers one),
|
|
177
|
+
disable exploration, and record the original and raised floor in routing provenance
|
|
178
|
+
|
|
139
179
|
## Model scoping and auth detection
|
|
140
180
|
|
|
141
181
|
Each agent CLI exposes a list of available models. tickmarkr's routing works only with **classified** models — those you explicitly enter into the config under `tiers`. The `tickmarkr doctor` command probes these models to detect auth status and records the results, which routing consumes to avoid 401/403 dispatch failures.
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import type { WorkerAdapter } from "../../adapters/types.js";
|
|
2
|
-
export
|
|
2
|
+
export type FleetInput = NodeJS.ReadableStream & {
|
|
3
|
+
isTTY?: boolean;
|
|
4
|
+
setRawMode?: (mode: boolean) => unknown;
|
|
5
|
+
pause: () => unknown;
|
|
6
|
+
resume: () => unknown;
|
|
7
|
+
};
|
|
8
|
+
export type FleetOutput = {
|
|
9
|
+
isTTY?: boolean;
|
|
10
|
+
write: (chunk: string) => unknown;
|
|
11
|
+
};
|
|
12
|
+
export type FleetIO = {
|
|
13
|
+
input?: FleetInput;
|
|
14
|
+
output?: FleetOutput;
|
|
15
|
+
};
|
|
16
|
+
export declare function fleet(argv: string[], cwd?: string, adapters?: WorkerAdapter[], io?: FleetIO): Promise<string | {
|
|
3
17
|
out: string;
|
|
4
18
|
code: number;
|
|
5
19
|
}>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
|
+
import { emitKeypressEvents } from "node:readline";
|
|
3
4
|
import { createInterface } from "node:readline/promises";
|
|
4
5
|
import { parseArgs } from "node:util";
|
|
5
6
|
import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse, readAutoPrefer } from "../../adapters/registry.js";
|
|
@@ -9,6 +10,10 @@ import { SHAPES, TIERS } from "../../graph/schema.js";
|
|
|
9
10
|
import { route } from "../../route/router.js";
|
|
10
11
|
import { loadRoutingProfile } from "../../run/journal.js";
|
|
11
12
|
const NON_TTY_MSG = "tickmarkr fleet: interactive fleet editor requires a TTY — use `tickmarkr fleet --print` for non-interactive output";
|
|
13
|
+
const QUIT = "fleet: quit without writing";
|
|
14
|
+
const BOLD = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
15
|
+
const DIM = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
16
|
+
const POINTER = "❯";
|
|
12
17
|
const isDeniedAdapter = (id, editable) => editable.denyAdapters.includes(id);
|
|
13
18
|
const isDeniedModel = (adapter, model, editable) => editable.denyModels.includes(`${adapter}:${model}`);
|
|
14
19
|
function previewTask(shape) {
|
|
@@ -56,10 +61,88 @@ function proposedOverlayText(initial, editable, repoRoot) {
|
|
|
56
61
|
const merged = fleetRepoOverlayFromDelta(initial, editable, readOverlayFile(repoOverlayPath(repoRoot)));
|
|
57
62
|
return repoOverlayYaml(merged, provenanceMap(editable));
|
|
58
63
|
}
|
|
59
|
-
|
|
60
|
-
|
|
64
|
+
const isAbort = (k) => k.name === "escape" || k.name === "q" || (k.ctrl === true && k.name === "c");
|
|
65
|
+
const isNext = (k) => k.name === "return" || k.name === "enter";
|
|
66
|
+
const isToggle = (k) => k.name === "space";
|
|
67
|
+
function moveCursor(k, cursor, count) {
|
|
68
|
+
if (count === 0)
|
|
69
|
+
return cursor;
|
|
70
|
+
if (k.name === "down" || k.name === "j")
|
|
71
|
+
return Math.min(cursor + 1, count - 1);
|
|
72
|
+
if (k.name === "up" || k.name === "k")
|
|
73
|
+
return Math.max(cursor - 1, 0);
|
|
74
|
+
return cursor;
|
|
61
75
|
}
|
|
62
|
-
|
|
76
|
+
// step title (bold) + one dim key legend + rows; exactly one pointer glyph on the cursor row
|
|
77
|
+
function listFrame(title, legend, rows, cursor) {
|
|
78
|
+
const lines = [BOLD(title), DIM(legend)];
|
|
79
|
+
rows.forEach((r, i) => lines.push(i === cursor ? `${POINTER} ${BOLD(r)}` : ` ${r}`));
|
|
80
|
+
return lines;
|
|
81
|
+
}
|
|
82
|
+
// Keypress terminal over the (injectable) input stream. Keypress events are decoded by
|
|
83
|
+
// node's own emitKeypressEvents ON THE STREAM — no readline interface is held in keypress
|
|
84
|
+
// mode, so the production path and the test seam are the same decoder (OBS-69).
|
|
85
|
+
function openTerm(input, output) {
|
|
86
|
+
emitKeypressEvents(input);
|
|
87
|
+
const queue = [];
|
|
88
|
+
let pending = null;
|
|
89
|
+
const onKeypress = (str, key) => {
|
|
90
|
+
const k = key && (key.name !== undefined || key.sequence !== undefined) ? key : { name: str, sequence: str };
|
|
91
|
+
if (pending) {
|
|
92
|
+
const fn = pending;
|
|
93
|
+
pending = null;
|
|
94
|
+
fn(k);
|
|
95
|
+
}
|
|
96
|
+
else
|
|
97
|
+
queue.push(k);
|
|
98
|
+
};
|
|
99
|
+
const setRaw = (on) => void input.setRawMode?.(on);
|
|
100
|
+
input.on("keypress", onKeypress);
|
|
101
|
+
setRaw(true);
|
|
102
|
+
input.resume();
|
|
103
|
+
let prevLines = 0;
|
|
104
|
+
return {
|
|
105
|
+
key() {
|
|
106
|
+
const buffered = queue.shift();
|
|
107
|
+
if (buffered)
|
|
108
|
+
return Promise.resolve(buffered);
|
|
109
|
+
return new Promise((resolve) => {
|
|
110
|
+
pending = resolve;
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
frame(lines) {
|
|
114
|
+
const erase = prevLines > 0 ? `\x1b[${prevLines}F\x1b[0J` : "";
|
|
115
|
+
output.write(`${erase}${lines.join("\n")}\n`);
|
|
116
|
+
prevLines = lines.length;
|
|
117
|
+
},
|
|
118
|
+
// typed entry leaves keypress mode: raw off, transient line interface, closed after the
|
|
119
|
+
// question; keys the line editor leaked into the decoder are dropped (type-ahead kept)
|
|
120
|
+
async askTyped(prompt) {
|
|
121
|
+
const typedFrom = queue.length;
|
|
122
|
+
setRaw(false);
|
|
123
|
+
const rl = createInterface({
|
|
124
|
+
input: input,
|
|
125
|
+
output: output,
|
|
126
|
+
});
|
|
127
|
+
try {
|
|
128
|
+
return (await rl.question(prompt)).trim();
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
rl.close();
|
|
132
|
+
setRaw(true);
|
|
133
|
+
queue.splice(typedFrom);
|
|
134
|
+
prevLines = 0;
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
// every exit path: raw mode off, zero keypress listeners, stream paused (OBS-70)
|
|
138
|
+
close() {
|
|
139
|
+
input.off("keypress", onKeypress);
|
|
140
|
+
setRaw(false);
|
|
141
|
+
input.pause();
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(), io = {}) {
|
|
63
146
|
const { values } = parseArgs({
|
|
64
147
|
args: argv,
|
|
65
148
|
options: {
|
|
@@ -70,7 +153,9 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters())
|
|
|
70
153
|
});
|
|
71
154
|
const globalDir = values["global-dir"] ?? globalConfigDir();
|
|
72
155
|
const print = values.print ?? false;
|
|
73
|
-
const
|
|
156
|
+
const input = io.input ?? process.stdin;
|
|
157
|
+
const output = io.output ?? process.stdout;
|
|
158
|
+
const interactive = input.isTTY === true && output.isTTY === true;
|
|
74
159
|
if (print)
|
|
75
160
|
return formatFleetPrint(cwd, { globalDir });
|
|
76
161
|
if (!interactive)
|
|
@@ -87,153 +172,194 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters())
|
|
|
87
172
|
const initial = fleetEditableFromConfig(cfg);
|
|
88
173
|
const editable = structuredClone(initial);
|
|
89
174
|
const health = cached;
|
|
90
|
-
const
|
|
175
|
+
const term = openTerm(input, output);
|
|
91
176
|
try {
|
|
92
|
-
//
|
|
177
|
+
// step 1/4 — probe data
|
|
93
178
|
const ageLine = formatAge(doctorAgeMs(cwd));
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
179
|
+
term.frame(listFrame("step 1/4 · probe data", "enter continue · r refresh via doctor · esc/q quit", [`probe data: ${ageLine} (.tickmarkr/doctor.json)`], 0));
|
|
180
|
+
for (;;) {
|
|
181
|
+
const k = await term.key();
|
|
182
|
+
if (isAbort(k))
|
|
183
|
+
return QUIT;
|
|
184
|
+
if (k.name === "r") {
|
|
185
|
+
return "fleet: run `tickmarkr doctor` to refresh probe data, then re-run `tickmarkr fleet` (doctor is the sensor; fleet never re-probes)";
|
|
186
|
+
}
|
|
187
|
+
if (isNext(k))
|
|
188
|
+
break;
|
|
99
189
|
}
|
|
100
|
-
//
|
|
190
|
+
// step 2/4 — agent CLIs (space toggles adapter deny)
|
|
101
191
|
const installed = adapters.filter((a) => health[a.id]?.installed);
|
|
102
|
-
const
|
|
192
|
+
const adapterRows = () => installed.map((a) => {
|
|
103
193
|
const h = health[a.id];
|
|
104
194
|
const on = !isDeniedAdapter(a.id, editable);
|
|
105
|
-
|
|
106
|
-
const auth = h?.authed ? "authed" : "unauthed";
|
|
107
|
-
return ` ${i + 1}. [${mark}] ${a.id} ${h?.version ?? "installed"} ${auth}`;
|
|
195
|
+
return `[${on ? "x" : " "}] ${a.id} ${h?.version ?? "installed"} ${h?.authed ? "authed" : "unauthed"}`;
|
|
108
196
|
});
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
if (
|
|
115
|
-
|
|
197
|
+
const listLegend = "↑↓/jk move · space toggle · enter next · esc/q quit";
|
|
198
|
+
let aCursor = 0;
|
|
199
|
+
term.frame(listFrame("step 2/4 · agent CLIs", listLegend, adapterRows(), aCursor));
|
|
200
|
+
for (;;) {
|
|
201
|
+
const k = await term.key();
|
|
202
|
+
if (isAbort(k))
|
|
203
|
+
return QUIT;
|
|
204
|
+
if (isNext(k))
|
|
205
|
+
break;
|
|
206
|
+
let changed = false;
|
|
207
|
+
const moved = moveCursor(k, aCursor, installed.length);
|
|
208
|
+
if (moved !== aCursor) {
|
|
209
|
+
aCursor = moved;
|
|
210
|
+
changed = true;
|
|
211
|
+
}
|
|
212
|
+
else if (isToggle(k) && installed.length > 0) {
|
|
213
|
+
const id = installed[aCursor].id;
|
|
116
214
|
const idx = editable.denyAdapters.indexOf(id);
|
|
117
215
|
if (idx === -1)
|
|
118
216
|
editable.denyAdapters.push(id);
|
|
119
217
|
else
|
|
120
218
|
editable.denyAdapters.splice(idx, 1);
|
|
121
219
|
editable.denyAdapters.sort();
|
|
220
|
+
changed = true;
|
|
122
221
|
}
|
|
222
|
+
if (changed)
|
|
223
|
+
term.frame(listFrame("step 2/4 · agent CLIs", listLegend, adapterRows(), aCursor));
|
|
123
224
|
}
|
|
124
|
-
//
|
|
225
|
+
// step 3/4 — models per enabled adapter (space toggles deny, t assigns tier from the row)
|
|
125
226
|
const enabledAdapters = installed.filter((a) => !isDeniedAdapter(a.id, editable));
|
|
126
227
|
for (const a of enabledAdapters) {
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
rows.push(` ${row}. [${mark}] ${m} ${tier?.tier ?? "???"} ${denied === "deny" ? "denied" : "allowed"}`);
|
|
138
|
-
indexFor.set(String(row), { kind: "classified", model: m });
|
|
139
|
-
row++;
|
|
140
|
-
}
|
|
141
|
-
for (const u of unclassified) {
|
|
142
|
-
rows.push(` ${row}. ( ) ${u.model} ??? unclassified${u.detectedAt ? ` (${u.detectedAt})` : ""}`);
|
|
143
|
-
indexFor.set(String(row), { kind: "unclassified", model: u.model });
|
|
144
|
-
row++;
|
|
145
|
-
}
|
|
146
|
-
step = await ask(rl, `screen 2/4 — models · ${a.id} [number]=toggle deny [t <n> <tier>]=assign tier [Enter]=next\n${rows.join("\n")}\n> `);
|
|
147
|
-
if (/^q$/i.test(step))
|
|
148
|
-
return "fleet: quit without writing";
|
|
149
|
-
if (step.startsWith("t ")) {
|
|
150
|
-
const [, numRaw, tierRaw] = step.split(/\s+/);
|
|
151
|
-
const tier = tierRaw;
|
|
152
|
-
if (!indexFor.has(numRaw) || !TIERS.includes(tier)) {
|
|
153
|
-
return { out: "fleet: tier assignment requires a valid model number and tier (cheap|mid|frontier)", code: 1 };
|
|
228
|
+
const unclassifiedAll = fleetUnclassifiedModels(cfg, health, adapters).filter((u) => u.adapter === a.id);
|
|
229
|
+
const rowsData = () => [
|
|
230
|
+
...Object.keys(editable.tiers[a.id] ?? {}).map((m) => ({ kind: "classified", model: m })),
|
|
231
|
+
...unclassifiedAll
|
|
232
|
+
.filter((u) => !editable.tiers[a.id]?.[u.model])
|
|
233
|
+
.map((u) => ({ kind: "unclassified", model: u.model, detectedAt: u.detectedAt })),
|
|
234
|
+
];
|
|
235
|
+
const renderRows = (rows) => rows.map((r) => {
|
|
236
|
+
if (r.kind === "unclassified") {
|
|
237
|
+
return `( ) ${r.model} ??? unclassified${r.detectedAt ? ` (${r.detectedAt})` : ""}`;
|
|
154
238
|
}
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
239
|
+
const tier = editable.tiers[a.id]?.[r.model];
|
|
240
|
+
const denied = isDeniedModel(a.id, r.model, editable);
|
|
241
|
+
return `[${denied ? " " : "x"}] ${r.model} ${tier?.tier ?? "???"} ${denied ? "denied" : "allowed"}`;
|
|
242
|
+
});
|
|
243
|
+
const title = `step 3/4 · models · ${a.id}`;
|
|
244
|
+
const modelsLegend = "↑↓/jk move · space toggle · t tier · enter next · esc/q quit";
|
|
245
|
+
let mCursor = 0;
|
|
246
|
+
term.frame(listFrame(title, modelsLegend, renderRows(rowsData()), mCursor));
|
|
247
|
+
for (;;) {
|
|
248
|
+
const k = await term.key();
|
|
249
|
+
if (isAbort(k))
|
|
250
|
+
return QUIT;
|
|
251
|
+
if (isNext(k))
|
|
252
|
+
break;
|
|
253
|
+
const rows = rowsData();
|
|
254
|
+
let changed = false;
|
|
255
|
+
const moved = moveCursor(k, mCursor, rows.length);
|
|
256
|
+
if (moved !== mCursor) {
|
|
257
|
+
mCursor = moved;
|
|
258
|
+
changed = true;
|
|
158
259
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
return { out: "fleet: assigning a tier to an unclassified model requires a typed benchmark-provenance note", code: 1 };
|
|
162
|
-
editable.tiers[a.id] ??= {};
|
|
163
|
-
editable.tiers[a.id][target.model] = { tier, provenance: note.trim() };
|
|
164
|
-
}
|
|
165
|
-
else if (step && /^\d+$/.test(step)) {
|
|
166
|
-
const target = indexFor.get(step);
|
|
167
|
-
if (target?.kind === "classified") {
|
|
168
|
-
const key = `${a.id}:${target.model}`;
|
|
260
|
+
else if (isToggle(k) && rows[mCursor]?.kind === "classified") {
|
|
261
|
+
const key = `${a.id}:${rows[mCursor].model}`;
|
|
169
262
|
const idx = editable.denyModels.indexOf(key);
|
|
170
263
|
if (idx === -1)
|
|
171
264
|
editable.denyModels.push(key);
|
|
172
265
|
else
|
|
173
266
|
editable.denyModels.splice(idx, 1);
|
|
174
267
|
editable.denyModels.sort();
|
|
268
|
+
changed = true;
|
|
175
269
|
}
|
|
270
|
+
else if (k.name === "t" && rows[mCursor]) {
|
|
271
|
+
if (rows[mCursor].kind === "classified") {
|
|
272
|
+
return { out: "fleet: tier reassignment on classified models is not supported in v1 — edit config directly", code: 1 };
|
|
273
|
+
}
|
|
274
|
+
const tier = (await term.askTyped(`tier (${TIERS.join("|")})> `));
|
|
275
|
+
if (!TIERS.includes(tier))
|
|
276
|
+
return { out: `fleet: invalid tier ${tier}`, code: 1 };
|
|
277
|
+
const note = await term.askTyped("benchmark provenance note (required): ");
|
|
278
|
+
if (!note)
|
|
279
|
+
return { out: "fleet: assigning a tier to an unclassified model requires a typed benchmark-provenance note", code: 1 };
|
|
280
|
+
editable.tiers[a.id] ??= {};
|
|
281
|
+
editable.tiers[a.id][rows[mCursor].model] = { tier, provenance: note };
|
|
282
|
+
changed = true;
|
|
283
|
+
}
|
|
284
|
+
if (changed)
|
|
285
|
+
term.frame(listFrame(title, modelsLegend, renderRows(rowsData()), mCursor));
|
|
176
286
|
}
|
|
177
287
|
}
|
|
178
|
-
//
|
|
288
|
+
// step 4/4 — shape routing (typed entry for pin/tier/prefer from the highlighted row)
|
|
179
289
|
const channels = discoverChannels(cfg, adapters, health);
|
|
180
290
|
const profile = loadRoutingProfile(cwd, cfg, { preview: true });
|
|
181
291
|
const autoPrefer = readAutoPrefer(cwd);
|
|
182
292
|
const overlayShapes = overlayPreferShapes(cwd, { globalDir });
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const entry = editable.map[shape] ?? {};
|
|
186
|
-
let now = "";
|
|
293
|
+
const shapeRows = () => SHAPES.map((shape) => {
|
|
294
|
+
let now;
|
|
187
295
|
try {
|
|
188
296
|
const r = route(previewTask(shape), { ...cfg, routing: { ...cfg.routing, map: editable.map, floors: editable.floors } }, channels, profile);
|
|
189
|
-
now = `${r.
|
|
297
|
+
now = `${r.assignment.adapter}:${r.assignment.model} (${r.assignment.channel}, ${r.assignment.tier})`;
|
|
190
298
|
}
|
|
191
299
|
catch (e) {
|
|
192
300
|
now = e.message;
|
|
193
301
|
}
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
302
|
+
const auto = autoPrefer?.[shape] && !overlayShapes.has(shape) ? " (auto-prefer active)" : "";
|
|
303
|
+
return `${shape} → ${now}${auto}`;
|
|
304
|
+
});
|
|
305
|
+
const shapesTitle = "step 4/4 · shape routing";
|
|
306
|
+
const shapesLegend = "↑↓/jk move · a auto · p pin · t tier · f prefer · enter next · esc/q quit";
|
|
307
|
+
let sCursor = 0;
|
|
308
|
+
term.frame(listFrame(shapesTitle, shapesLegend, shapeRows(), sCursor));
|
|
309
|
+
for (;;) {
|
|
310
|
+
const k = await term.key();
|
|
311
|
+
if (isAbort(k))
|
|
312
|
+
return QUIT;
|
|
313
|
+
if (isNext(k))
|
|
314
|
+
break;
|
|
315
|
+
const shape = SHAPES[sCursor];
|
|
316
|
+
const entry = editable.map[shape] ?? {};
|
|
317
|
+
let changed = false;
|
|
318
|
+
const moved = moveCursor(k, sCursor, SHAPES.length);
|
|
319
|
+
if (moved !== sCursor) {
|
|
320
|
+
sCursor = moved;
|
|
321
|
+
changed = true;
|
|
322
|
+
}
|
|
323
|
+
else if (k.name === "a") {
|
|
203
324
|
const next = { ...entry };
|
|
204
325
|
delete next.pin;
|
|
205
326
|
editable.map[shape] = next;
|
|
327
|
+
changed = true;
|
|
206
328
|
}
|
|
207
|
-
else if (
|
|
208
|
-
const pin = await
|
|
329
|
+
else if (k.name === "p") {
|
|
330
|
+
const pin = await term.askTyped("pin adapter:model> ");
|
|
209
331
|
const i = pin.indexOf(":");
|
|
210
332
|
if (i < 1)
|
|
211
333
|
return { out: "fleet: pin must be adapter:model", code: 1 };
|
|
212
334
|
editable.map[shape] = { pin: { via: pin.slice(0, i), model: pin.slice(i + 1) } };
|
|
335
|
+
changed = true;
|
|
213
336
|
}
|
|
214
|
-
else if (
|
|
215
|
-
const tier = await
|
|
337
|
+
else if (k.name === "t") {
|
|
338
|
+
const tier = (await term.askTyped(`tier (${TIERS.join("|")})> `));
|
|
216
339
|
if (!TIERS.includes(tier))
|
|
217
340
|
return { out: `fleet: invalid tier ${tier}`, code: 1 };
|
|
218
341
|
const next = { ...entry, tier };
|
|
219
342
|
delete next.pin;
|
|
220
343
|
editable.map[shape] = next;
|
|
344
|
+
changed = true;
|
|
221
345
|
}
|
|
222
|
-
else if (
|
|
223
|
-
const pref = await
|
|
224
|
-
|
|
225
|
-
|
|
346
|
+
else if (k.name === "f") {
|
|
347
|
+
const pref = await term.askTyped("prefer (comma-separated adapters or adapter:model)> ");
|
|
348
|
+
editable.map[shape] = { ...entry, prefer: pref.split(",").map((s) => s.trim()).filter(Boolean) };
|
|
349
|
+
changed = true;
|
|
226
350
|
}
|
|
351
|
+
if (changed)
|
|
352
|
+
term.frame(listFrame(shapesTitle, shapesLegend, shapeRows(), sCursor));
|
|
227
353
|
}
|
|
228
|
-
// review — unified diff + confirm
|
|
354
|
+
// review — unified diff + typed confirm
|
|
229
355
|
const before = currentRepoOverlayText(cwd);
|
|
230
356
|
const after = proposedOverlayText(initial, editable, cwd);
|
|
231
357
|
if (fleetEditableEquals(initial, editable) || before === after) {
|
|
232
358
|
return "fleet: no overlay changes (empty diff)";
|
|
233
359
|
}
|
|
234
360
|
const diff = unifiedYamlDiff(before, after, repoOverlayPath(cwd));
|
|
235
|
-
|
|
236
|
-
const confirm = await
|
|
361
|
+
output.write(`\n${diff}\n`);
|
|
362
|
+
const confirm = await term.askTyped("write overlay? [y/N] ");
|
|
237
363
|
if (!/^y(?:es)?$/i.test(confirm))
|
|
238
364
|
return "fleet: discarded overlay changes";
|
|
239
365
|
const merged = fleetRepoOverlayFromDelta(initial, editable, readOverlayFile(repoOverlayPath(cwd)));
|
|
@@ -244,6 +370,6 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters())
|
|
|
244
370
|
return `fleet: wrote ${path}`;
|
|
245
371
|
}
|
|
246
372
|
finally {
|
|
247
|
-
|
|
373
|
+
term.close();
|
|
248
374
|
}
|
|
249
375
|
}
|