tickmarkr 1.54.0 → 1.56.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
@@ -143,11 +143,19 @@ away from a failing adapter will never retry it in subsequent `tickmarkr resume`
143
143
 
144
144
  ## Choosing your fleet: `tickmarkr fleet`
145
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.
146
+ `tickmarkr doctor` is a pure sensor; `tickmarkr fleet` is the actuator. It walks six steps
147
+ probe data, agent CLIs, model tiers, routing mode, shape routing with a candidate picker, and
148
+ steering preferences — and ends in a unified diff of your config overlay that is written **only
149
+ on explicit confirm**. Pressing Enter through every step writes nothing. Assigning a tier to a
150
+ model that has no classification yet requires a typed benchmark-provenance note, stored as a
151
+ config comment beside the assignment.
152
+
153
+ The shape routing step (step 5/6) features an **arrow-driven candidate picker** that ranks
154
+ available channels by the production router's own logic, showing economics (tier + channel
155
+ cost signals for API and sub channels) and the router's rationale for each rank. This replaces
156
+ typed pin entry with verifiable suggestions. Step 4/6 shows an estimated spend context line
157
+ across all shapes under each routing mode's resolved floors, also derived from the same preview
158
+ routing machinery.
151
159
 
152
160
  ```bash
153
161
  tickmarkr fleet # interactive editor (requires a TTY)
@@ -173,8 +181,56 @@ More levers, all optional and absent-safe (absent config keeps default behavior)
173
181
  the routed model's window
174
182
  - `routing.sla` — per-shape latency expectations, surfaced as advisory plan lints against the
175
183
  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
184
+ - `run --quality` — compatibility alias for `run --mode partner-led` for that run; it disables
185
+ exploration and records the selected mode in routing provenance
186
+
187
+ ## Steering: routing modes, reviewer preferences, and reruns
188
+
189
+ ### Routing modes
190
+
191
+ `routing.mode` is a preset that compiles into floor assignments at config load time. The router never sees the mode itself; it receives resolved floors only.
192
+
193
+ Three routing modes are available:
194
+
195
+ - **`risk-based`** (default): byte-identical to pre-v1.51 routing. Absent `mode` key resolves as risk-based.
196
+ - **`partner-led`**: resolves every non-overridden shape to a `frontier` floor and disables exploration — use when quality is paramount and cost is secondary.
197
+ - **`staff-led`**: lowers each mode default by one tier (e.g., `implement` and `refactor` become `cheap` instead of `mid`) while keeping the preset floor for the integrity set (`plan`, `spec`, `migration`, `ui`) at `frontier`.
198
+
199
+ Explicit `routing.floors` entries beat mode-preset deltas and are linted during plan if they shadow the mode's delta; an explicit integrity floor below `frontier` is also linted. The mode is compiled once at config load and never consulted during routing.
200
+
201
+ ### Review preferences
202
+
203
+ `review.prefer` is an ordered list of reviewer seats for the cross-vendor code-review gate. Entries are matched by diversity (never the same vendor or model as the original worker), and routing reorders the available channels only — it does not admit unauthed or denied channels.
204
+
205
+ ```yaml
206
+ review:
207
+ prefer: [codex, kimi] # bare adapter: inherits model from the routed channel
208
+ prefer: [codex:gpt-5.6-sol, kimi:kimi-code/k3] # adapter:model explicit
209
+ prefer: [codex, kimi:kimi-code/k3] # mixed: bare and explicit
210
+ ```
211
+
212
+ **Grammar**: review prefer entries may name a bare adapter (inheriting the model from the current channel) or an explicit `adapter:model` pair. Bare adapters rank every diversity-eligible channel for that adapter; explicit pairs rank one diversity-eligible channel.
213
+
214
+ ### Consult preferences
215
+
216
+ `consult.prefer` is a ranked failover list of seats for escalations on deadlock or gate stalls. Unlike review, a consult seat has no channel to inherit a model from, so entries **must be explicit `adapter:model` pairs**.
217
+
218
+ ```yaml
219
+ consult:
220
+ adapter: claude-code
221
+ model: fable
222
+ prefer: [codex:gpt-5.6-sol, kimi:kimi-code/k3] # adapter:model ONLY
223
+ ```
224
+
225
+ The daemon walks the preference list to the first live adapter, then the pinned `consult.adapter:model` pair as the final fallback. Failed or unparseable verdicts fall to the next entry.
226
+
227
+ **Grammar**: consult prefer entries require `adapter:model` form — a bare adapter name is invalid and fails config load. Every entry must declare both the adapter and the model because a consult seat runs independently with no channel context.
228
+
229
+ ### Rerun control: --supersedes
230
+
231
+ `tickmarkr run --supersedes <prior-runId>` marks the current run as a rerun of a prior engagement. The current task graph is used for the rerun; compile it fresh first if the spec changed. The prior runId is recorded in the new journal, and the prior journal records the successor, for audit trails and change attribution.
232
+
233
+ Use this when you modify the spec or worker logic and want to mark an intentional rerun while preserving the relationship in both run journals.
178
234
 
179
235
  ## Model scoping and auth detection
180
236
 
@@ -77,6 +77,19 @@ const MODEL_PROBE_PROMPT = "Reply with exactly OK and nothing else.";
77
77
  const MODEL_PROBE_TIMEOUT_MS = 60000;
78
78
  // Auth-words only match when tied to a failure word; bare "auth"/"OAuth"/"authored" never fail (v1.27 T2).
79
79
  const AUTH_FAILURE_RE = /\b4\d\d\b|\bauth(?:entication|orization)?\s+(?:error|failed|failure|denied)|unauthori[sz]ed|forbidden|access denied|credit(?:s)?\s+(?:exhausted|error|denied)/i;
80
+ const PROBE_REASON_CAP = 240;
81
+ // v1.55 T3: the tail must open at a word boundary — a mid-word slice ("odel 'grok-…'") reads as
82
+ // corruption in operator-facing diagnostics. Input is already space-normalized, so " " is the only
83
+ // boundary. A tail that is one unbroken token keeps its mid-word cut rather than storing nothing.
84
+ function reasonTail(output) {
85
+ if (output.length <= PROBE_REASON_CAP)
86
+ return output;
87
+ const tail = output.slice(-PROBE_REASON_CAP);
88
+ if (output[output.length - PROBE_REASON_CAP - 1] === " ")
89
+ return tail;
90
+ const sp = tail.indexOf(" ");
91
+ return sp === -1 ? tail : tail.slice(sp + 1);
92
+ }
80
93
  function probeFailure(code, stdout, stderr, timedOut, timeoutMs = MODEL_PROBE_TIMEOUT_MS) {
81
94
  // SIGKILL-timeout is not exit-1: report the budget, never the masked kill code (v1.27 T1).
82
95
  if (timedOut)
@@ -85,7 +98,7 @@ function probeFailure(code, stdout, stderr, timedOut, timeoutMs = MODEL_PROBE_TI
85
98
  // OBS-72: TAIL, not head — the error lands at the END of CLI output; a head slice stores only the
86
99
  // startup banner and hid the real "Not inside a trusted directory" failure for a day.
87
100
  return code !== 0 || QUOTA_RE.test(output) || AUTH_FAILURE_RE.test(output)
88
- ? output.slice(-240) || `probe exited ${code}`
101
+ ? reasonTail(output) || `probe exited ${code}`
89
102
  : undefined;
90
103
  }
91
104
  export const pendingAutoPreferKey = Symbol.for("tickmarkr.pendingAutoPrefer");
@@ -0,0 +1,8 @@
1
+ import type { Assignment, BillingChannel } from "../../adapters/types.js";
2
+ import type { TickmarkrConfig } from "../../config/config.js";
3
+ import type { Task } from "../../graph/schema.js";
4
+ import { type RankedCandidate } from "../../route/candidates.js";
5
+ import type { RoutingProfile } from "../../route/profile.js";
6
+ export declare function costSignal(a: Assignment, pricing: Record<string, number>): string;
7
+ export declare function candidateRow(c: RankedCandidate, pricing: Record<string, number>): string;
8
+ export declare function shapeCandidates(task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], profile?: RoutingProfile): RankedCandidate[];
@@ -0,0 +1,25 @@
1
+ import { rankCandidates } from "../../route/candidates.js";
2
+ // v1.56 T2: ranking glue + row presentation for the per-shape candidate picker. Everything here
3
+ // is pure — the picker loop in fleet.ts mutates only in-memory editable state, and disk stays
4
+ // reachable solely through fleet's diff-confirm + reload-guard write path.
5
+ // Channel economics + tier, never invented dollars: a flat-rate sub quota rendered as $0 would be
6
+ // the one dishonest thing this screen could say (v1.56 ruling — cost visible where the choice is made).
7
+ export function costSignal(a, pricing) {
8
+ if (a.channel === "sub")
9
+ return "sub flat-rate quota";
10
+ const perTask = pricing[a.tier];
11
+ return perTask == null ? "api metered" : `api ~$${perTask.toFixed(2)}/task`;
12
+ }
13
+ export function candidateRow(c, pricing) {
14
+ const a = c.assignment;
15
+ const override = c.belowFloor ? " · below floor — operator override" : "";
16
+ return `${a.adapter}:${a.model} ${a.tier} ${costSignal(a, pricing)} — ${c.why}${override}`;
17
+ }
18
+ // Ranking ignores the shape's own map pin so a pinned shape still offers the full candidate
19
+ // order — route() returns a map pin first and fail-louds on its exclusion, which would collapse
20
+ // the picker to a single row. No hidden mutation: fresh cfg objects only.
21
+ export function shapeCandidates(task, cfg, channels, profile) {
22
+ const { pin: _pin, ...entry } = cfg.routing.map[task.shape] ?? {};
23
+ const map = { ...cfg.routing.map, [task.shape]: entry };
24
+ return rankCandidates(task, { ...cfg, routing: { ...cfg.routing, map } }, channels, profile);
25
+ }
@@ -8,6 +8,7 @@ import { fleetUnclassifiedModels } from "../../adapters/model-lints.js";
8
8
  import { GLYPHS, bold, legend as legendText, toggleActive, toggleInactive } from "../../brand.js";
9
9
  import { fleetEditableFromConfig, fleetEditableEquals, fleetRepoOverlayFromDelta, formatFleetPrint, globalConfigDir, overlayBytesLoadError, overlayPreferShapes, readOverlayFile, repoOverlayPath, repoOverlayYaml, ROUTING_MODES, unifiedYamlDiff, } from "../../config/config.js";
10
10
  import { SHAPES, TIERS } from "../../graph/schema.js";
11
+ import { candidateRow, costSignal, shapeCandidates } from "./fleet-picker.js";
11
12
  import { route } from "../../route/router.js";
12
13
  import { resolveRunMode } from "../../run/daemon.js";
13
14
  import { loadRoutingProfile } from "../../run/journal.js";
@@ -308,6 +309,46 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
308
309
  // step 4/6 — routing mode (selection is in-memory; the write happens only through the diff confirm).
309
310
  // Candidate floor tables come from the ONE preset compiler via resolveRunMode — no mode math here.
310
311
  const modeCfgs = Object.fromEntries(ROUTING_MODES.map((m) => [m, m === rm.mode.mode ? rm : resolveRunMode(cwd, { flag: m, globalDir })]));
312
+ // v1.56 T3: both screens preview-route through ONE lens — the same live channel set, learned
313
+ // profile, and per-mode resolved floors — so the mode spend context and the shape rows can
314
+ // never disagree about what the router would do.
315
+ const channels = discoverChannels(cfg, adapters, health);
316
+ const profile = loadRoutingProfile(cwd, cfg, { preview: true });
317
+ const previewCfg = (m) => ({ ...cfg, routing: { ...cfg.routing, map: editable.map, floors: modeCfgs[m].cfg.routing.floors } });
318
+ // Estimated spend context under the highlighted mode: tier mix across all nine preview shapes,
319
+ // then channel economics — dollars only for api-routed shapes (plan.ts's rough per-task
320
+ // pricing table); a sub channel is flat-rate quota and never renders as a dollar amount.
321
+ const modeSpend = (cand) => {
322
+ const tierCount = {};
323
+ let subs = 0;
324
+ let apiN = 0;
325
+ let apiUsd = 0;
326
+ for (const shape of SHAPES) {
327
+ try {
328
+ const a = route(previewTask(shape), previewCfg(cand), channels, profile).assignment;
329
+ tierCount[a.tier] = (tierCount[a.tier] ?? 0) + 1;
330
+ if (a.channel === "sub")
331
+ subs += 1;
332
+ else {
333
+ apiN += 1;
334
+ apiUsd += cfg.pricing[a.tier] ?? 0;
335
+ }
336
+ }
337
+ catch {
338
+ // unroutable under this mode's floors — the shape screen names the error per row
339
+ }
340
+ }
341
+ const mix = [...TIERS].reverse().flatMap((t) => (tierCount[t] ? [`${tierCount[t]} ${t}`] : [])).join(" · ");
342
+ const parts = [];
343
+ if (subs)
344
+ parts.push(`${subs === SHAPES.length ? "all" : subs} sub (flat-rate quota)`);
345
+ if (apiN)
346
+ parts.push(`${apiN} api · est. cost (API shapes only, rough): ~$${apiUsd.toFixed(2)}`);
347
+ const unroutable = SHAPES.length - subs - apiN;
348
+ if (unroutable)
349
+ parts.push(`${unroutable} unroutable`);
350
+ return ` mix: ${mix} — ${parts.join(" · ")}`;
351
+ };
311
352
  const modeRows = () => ROUTING_MODES.map((m) => `${m === rm.mode.mode ? toggleActive() : " "} ${m.padEnd(11)} ${MODE_GLOSS[m]}`);
312
353
  // preview: the highlighted mode's resolved floor table diffed against the current mode
313
354
  const floorPreview = (cand) => {
@@ -325,7 +366,11 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
325
366
  const modeLegend = "↑↓/jk move · enter select · esc/q quit";
326
367
  let modeCursor = ROUTING_MODES.indexOf(rm.mode.mode);
327
368
  let selectedMode = rm.mode.mode;
328
- const modeFrame = () => [...listFrame(modeTitle, modeLegend, modeRows(), modeCursor), ...floorPreview(ROUTING_MODES[modeCursor])];
369
+ const modeFrame = () => [
370
+ ...listFrame(modeTitle, modeLegend, modeRows(), modeCursor),
371
+ modeSpend(ROUTING_MODES[modeCursor]),
372
+ ...floorPreview(ROUTING_MODES[modeCursor]),
373
+ ];
329
374
  term.frame(modeFrame());
330
375
  for (;;) {
331
376
  const k = await term.key();
@@ -341,17 +386,15 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
341
386
  term.frame(modeFrame());
342
387
  }
343
388
  }
344
- // step 5/6 — shape routing (typed entry for pin/tier/prefer from the highlighted row);
389
+ // step 5/6 — shape routing (candidate picker for pin, typed entry for prefer);
345
390
  // previews route under the SELECTED mode's resolved floors, never a floor edit of its own
346
- const channels = discoverChannels(cfg, adapters, health);
347
- const profile = loadRoutingProfile(cwd, cfg, { preview: true });
348
391
  const autoPrefer = readAutoPrefer(cwd);
349
392
  const overlayShapes = overlayPreferShapes(cwd, { globalDir });
350
393
  const shapeRows = () => SHAPES.map((shape) => {
351
394
  let now;
352
395
  try {
353
- const r = route(previewTask(shape), { ...cfg, routing: { ...cfg.routing, map: editable.map, floors: modeCfgs[selectedMode].cfg.routing.floors } }, channels, profile);
354
- now = `${r.assignment.adapter}:${r.assignment.model} (${r.assignment.channel}, ${r.assignment.tier})`;
396
+ const r = route(previewTask(shape), previewCfg(selectedMode), channels, profile);
397
+ now = `${r.assignment.adapter}:${r.assignment.model} (${r.assignment.channel}, ${r.assignment.tier}) ${costSignal(r.assignment, cfg.pricing)}`;
355
398
  }
356
399
  catch (e) {
357
400
  now = e.message;
@@ -385,12 +428,33 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
385
428
  changed = true;
386
429
  }
387
430
  else if (k.name === "p") {
388
- const pin = await term.askTyped("pin adapter:model> ");
389
- const i = pin.indexOf(":");
390
- if (i < 1)
391
- return { out: "fleet: pin must be adapter:model", code: 1 };
392
- editable.map[shape] = { pin: { via: pin.slice(0, i), model: pin.slice(i + 1) } };
393
- changed = true;
431
+ // v1.56 T2: arrow-driven candidate picker replaces the typed pin entry (operator ruling:
432
+ // the suggestion proposes, the user disposes). Every rank is a production route() result
433
+ // (T1 seam); a pick mutates editable only — the write still funnels through the ONE
434
+ // diff-confirm + reload-guard path below. The picker adds no listeners of its own, so
435
+ // every exit path inherits term.close()'s release-and-pause contract in the finally.
436
+ const cand = shapeCandidates(previewTask(shape), previewCfg(selectedMode), channels, profile);
437
+ let pCursor = 0;
438
+ const pickerFrame = () => listFrame(`pick · ${shape}`, "↑↓/jk move · enter pin · esc cancel · q quit", cand.map((c) => candidateRow(c, cfg.pricing)), pCursor);
439
+ term.frame(pickerFrame());
440
+ for (;;) {
441
+ const pk = await term.key();
442
+ if (pk.name === "q" || (pk.ctrl === true && pk.name === "c"))
443
+ return QUIT;
444
+ if (pk.name === "escape")
445
+ break;
446
+ if (isNext(pk) && cand.length > 0) {
447
+ const a = cand[pCursor].assignment;
448
+ editable.map[shape] = { pin: { via: a.adapter, model: a.model } };
449
+ break;
450
+ }
451
+ const pMoved = moveCursor(pk, pCursor, cand.length);
452
+ if (pMoved !== pCursor) {
453
+ pCursor = pMoved;
454
+ term.frame(pickerFrame());
455
+ }
456
+ }
457
+ changed = true; // the picker replaced the frame — every exit redraws the shape screen
394
458
  }
395
459
  else if (k.name === "f") {
396
460
  const pref = await term.askTyped("prefer (comma-separated adapters or adapter:model)> ");
@@ -0,0 +1,11 @@
1
+ import { type Assignment, type BillingChannel } from "../adapters/types.js";
2
+ import type { TickmarkrConfig } from "../config/config.js";
3
+ import type { Task } from "../graph/schema.js";
4
+ import type { RoutingProfile } from "./profile.js";
5
+ import { type RoutingPreferContext } from "./router.js";
6
+ export interface RankedCandidate {
7
+ assignment: Assignment;
8
+ why: string;
9
+ belowFloor: boolean;
10
+ }
11
+ export declare function rankCandidates(task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], profile?: RoutingProfile, preferCtx?: RoutingPreferContext): RankedCandidate[];
@@ -0,0 +1,35 @@
1
+ import { channelKey } from "../adapters/types.js";
2
+ import { route, RoutingError } from "./router.js";
3
+ // v1.56 T1 (scoper ruling 2, RULED FOR FABLE): NO comparator lives here — every rank IS a
4
+ // production route() result. Order derives solely from re-calling route with a growing exclusion
5
+ // set (the OBS-57 exclude seam), exploration off so repeated calls agree. When the eligible pool
6
+ // exhausts (RoutingError), the advisory floor for this shape is dropped ONCE and the remaining
7
+ // live channels rank after, marked belowFloor — mirroring the routes-but-lints semantics a
8
+ // below-floor map pin already has (router.ts pin path).
9
+ export function rankCandidates(task, cfg, channels, profile, preferCtx) {
10
+ const ranked = [];
11
+ const exclude = new Set();
12
+ let effCfg = cfg;
13
+ let belowFloor = false;
14
+ while (exclude.size < channels.length) {
15
+ let r;
16
+ try {
17
+ r = route(task, effCfg, channels, profile, preferCtx, exclude, { noExplore: true });
18
+ }
19
+ catch (e) {
20
+ if (!(e instanceof RoutingError))
21
+ throw e;
22
+ if (belowFloor)
23
+ break; // pool exhausted (or an unresolvable map pin) — ranking is complete
24
+ // eligible pool dry: drop this shape's advisory floor and keep iterating the production
25
+ // router over the leftover live channels. No hidden mutation — fresh cfg objects only.
26
+ belowFloor = true;
27
+ const { [task.shape]: _dropped, ...floors } = cfg.routing.floors;
28
+ effCfg = { ...cfg, routing: { ...cfg.routing, floors } };
29
+ continue;
30
+ }
31
+ ranked.push({ assignment: r.assignment, why: r.provenance, belowFloor });
32
+ exclude.add(channelKey(r.assignment));
33
+ }
34
+ return ranked;
35
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.54.0",
3
+ "version": "1.56.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -50,6 +50,10 @@ Use one of:
50
50
 
51
51
  After sending, **confirm delivery** by reading the target pane and verifying the message landed (input empty, agent status `working`, or notification acknowledged). Never report "briefed" or "relayed" without read-back confirmation.
52
52
 
53
+ ## Dedicated consultant tab rule
54
+
55
+ When spawning consultants (agents gathering synthesis input for decisions like SCOPER analysis or architectural reviews), create them in a DEDICATED tab separate from the ORCHESTRATOR tab. This ensures that when the orchestrator stands down, the consultant panes persist and their assessments remain available for review and reference.
56
+
53
57
  ## Stand-down (mission end and retirement)
54
58
 
55
59
  - **Orchestrator, on terminal state** (green, failed, or parked), after the record commit and operator notification: stop every monitor and background task you started, print one final stand-down line, and leave NOTHING queued in your input box. A finished session with an armed watcher or pre-filled input is a loaded gun — a retired v1.40 orchestrator sat idle with "merge … tag, publish" unsent in its input; one stray Enter would have shipped a duplicate release.