tickmarkr 1.48.1 → 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 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`, or
55
- `pi`), authenticated through its own login. tickmarkr never handles vendor API keys itself.
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 30s.
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.
@@ -64,10 +64,15 @@ export const kimi = {
64
64
  : { ...h, authed: false, note: "no valid ~/.kimi-code/credentials/kimi-code.json (kimi login to fix)" };
65
65
  },
66
66
  channels: (cfg) => channelsFromConfig("kimi", cfg),
67
- // KIMI-02 headless. -p prompt mode + -y yolo + explicit model. kimi 0.27.0 rejects -p combined
68
- // with -y at parse time tickmarkr still emits both per harness contract; interactive uses -y.
69
- headlessCommand: (promptFile, model) => `kimi -p "$(cat ${shq(promptFile)})" -y --model ${shq(model)} --output-format text`,
70
- interactiveCommand: (promptFile, model) => `kimi -y --model ${shq(model)} "$(cat ${shq(promptFile)})"`,
67
+ // KIMI-02 headless. -p prompt mode + explicit model, NO permission flag: kimi 0.26.0 rejects
68
+ // -p combined with -y/--auto at argument parse time ("Cannot combine --prompt with --yolo",
69
+ // OBS-67 doctor probes all failed on it), and prompt mode is already non-interactive with
70
+ // tool actions auto-approved (live-verified 2026-07-17: unattended file write succeeded).
71
+ headlessCommand: (promptFile, model) => `kimi -p "$(cat ${shq(promptFile)})" --model ${shq(model)} --output-format text`,
72
+ // kimi 0.26.0 has NO TUI argv-seeding surface: a positional prompt parses as a subcommand
73
+ // ("unknown command '…'", live-verified 2026-07-17, OBS-67) and -p is non-interactive-only.
74
+ // null → the daemon's print fallback (types.ts:101) keeps kimi workers visible without a TUI.
75
+ interactiveCommand: () => null,
71
76
  invoke(task, _cwd, a, ctx) {
72
77
  return { command: this.headlessCommand(ctx.promptFile, a.model) };
73
78
  },
@@ -1,5 +1,19 @@
1
1
  import type { WorkerAdapter } from "../../adapters/types.js";
2
- export declare function fleet(argv: string[], cwd?: string, adapters?: WorkerAdapter[]): Promise<string | {
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
- async function ask(rl, prompt) {
60
- return (await rl.question(prompt)).trim();
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
- export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters()) {
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 interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
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 rl = createInterface({ input: process.stdin, output: process.stdout });
175
+ const term = openTerm(input, output);
91
176
  try {
92
- // screen 0sensor freshness
177
+ // step 1/4probe data
93
178
  const ageLine = formatAge(doctorAgeMs(cwd));
94
- let step = await ask(rl, `tickmarkr fleet scope your fleet\n probe data: ${ageLine} (.tickmarkr/doctor.json)\n [Enter] use it [r] refresh via doctor [q] quit\n> `);
95
- if (/^q$/i.test(step))
96
- return "fleet: quit without writing";
97
- if (/^r$/i.test(step)) {
98
- return "fleet: run `tickmarkr doctor` to refresh probe data, then re-run `tickmarkr fleet` (doctor is the sensor; fleet never re-probes)";
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
- // screen 1adapter membership (deny adapters)
190
+ // step 2/4agent CLIs (space toggles adapter deny)
101
191
  const installed = adapters.filter((a) => health[a.id]?.installed);
102
- const adapterLines = installed.map((a, i) => {
192
+ const adapterRows = () => installed.map((a) => {
103
193
  const h = health[a.id];
104
194
  const on = !isDeniedAdapter(a.id, editable);
105
- const mark = on ? "x" : " ";
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
- step = await ask(rl, `screen 1/4 agent CLIs [number]=toggle [Enter]=next\n${adapterLines.join("\n")}\n> `);
110
- if (/^q$/i.test(step))
111
- return "fleet: quit without writing";
112
- if (step && step !== "") {
113
- const n = Number.parseInt(step, 10);
114
- if (Number.isInteger(n) && n >= 1 && n <= installed.length) {
115
- const id = installed[n - 1].id;
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
- // screen 2 — models per enabled adapter
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 classified = editable.tiers[a.id] ?? {};
128
- const models = Object.keys(classified);
129
- const unclassified = fleetUnclassifiedModels(cfg, health, adapters).filter((u) => u.adapter === a.id);
130
- const rows = [];
131
- let row = 1;
132
- const indexFor = new Map();
133
- for (const m of models) {
134
- const tier = classified[m];
135
- const denied = isDeniedModel(a.id, m, editable) ? "deny" : "allow";
136
- const mark = denied === "deny" ? " " : "x";
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 target = indexFor.get(numRaw);
156
- if (target.kind !== "unclassified") {
157
- return { out: "fleet: tier reassignment on classified models is not supported in v1 — edit config directly", code: 1 };
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
- const note = await ask(rl, "benchmark provenance note (required): ");
160
- if (!note.trim())
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
- // screen 3per-shape routing
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
- for (let si = 0; si < SHAPES.length; si++) {
184
- const shape = SHAPES[si];
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.provenance}\n → ${r.assignment.adapter}:${r.assignment.model} (${r.assignment.channel}, ${r.assignment.tier})`;
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 autoNote = autoPrefer?.[shape] && !overlayShapes.has(shape)
195
- ? `\n auto-prefer active — explicit prefer overrides it`
196
- : "";
197
- step = await ask(rl, `screen 3/4 shape routing · ${shape} (${si + 1}/${SHAPES.length})\n now: ${now}\n [1] auto within tier [2] pin channel [3] edit tier [4] edit prefer [Enter]=keep${autoNote}\n> `);
198
- if (/^q$/i.test(step))
199
- return "fleet: quit without writing";
200
- if (!step)
201
- continue;
202
- if (step === "1") {
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 (step === "2") {
208
- const pin = await ask(rl, "pin adapter:model> ");
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 (step === "3") {
215
- const tier = await ask(rl, `tier (${TIERS.join("|")})> `);
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 (step === "4") {
223
- const pref = await ask(rl, "prefer (comma-separated adapters or adapter:model)> ");
224
- const prefer = pref.split(",").map((s) => s.trim()).filter(Boolean);
225
- editable.map[shape] = { ...entry, prefer };
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
- process.stdout.write(`\n${diff}\n`);
236
- const confirm = await ask(rl, "write overlay? [y/N] ");
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
- rl.close();
373
+ term.close();
248
374
  }
249
375
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.48.1",
3
+ "version": "1.49.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",