tickmarkr 1.66.0 → 1.68.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.
@@ -0,0 +1,169 @@
1
+ // T2 (v1.68): consult dossier viewer — the Runs cockpit's detail panel.
2
+ // Lists every consult verdict recorded for the selected task (action + reason, dispatch order)
3
+ // and expands one verdict's persisted prompt content on request. Pure render over INJECTED data:
4
+ // this module never touches the filesystem — the caller folds journal events and loads the
5
+ // consults/ artifacts, then hands both in. The prompt-content translator is a hand-written
6
+ // line-oriented pass over a known narrow subset (headings, blockquotes, bullets, fenced blocks),
7
+ // never a markdown-AST dependency. Rendered prompt lines are cached per verdict and invalidated
8
+ // only on selection change — never re-parsed on repaint (opencode's opentui regression lesson).
9
+ // Read-only by construction: there is no edit or delete path for a dossier or a verdict.
10
+ import { GLYPHS, bold, dim, warn } from "../../brand.js";
11
+ /** Placeholder marker surfaced in the Runs detail panel until T2 fills the viewer. */
12
+ export const DOSSIER_PLACEHOLDER = "consult dossier viewer — stub, filled by T2";
13
+ /** Placeholder renderer — returns one dim line. */
14
+ export function renderDossierPlaceholder() {
15
+ return ` ${DOSSIER_PLACEHOLDER}`;
16
+ }
17
+ const ACTIONS = ["retry", "reroute", "decompose", "human"];
18
+ /** Fold one task's consult-verdict journal events into dossier verdicts, in dispatch order.
19
+ * Pure function over injected events. opts.prompts carries the persisted prompt contents in
20
+ * the same dispatch order (the caller reads the consults/ artifacts); a verdict whose artifact
21
+ * was not loaded simply has no prompt. Malformed events are skipped, never fatal. */
22
+ export function foldConsultVerdicts(events, taskId, opts = {}) {
23
+ const verdicts = [];
24
+ for (const e of events) {
25
+ if (e.taskId !== taskId || e.event !== "consult-verdict")
26
+ continue;
27
+ const d = e.data;
28
+ if (typeof d.action !== "string" || !ACTIONS.includes(d.action))
29
+ continue;
30
+ verdicts.push({
31
+ action: d.action,
32
+ ...(typeof d.reason === "string" ? { reason: d.reason } : {}),
33
+ ...(typeof d.notes === "string" ? { notes: d.notes } : {}),
34
+ ...(typeof d.guidance === "string" ? { guidance: d.guidance } : {}),
35
+ });
36
+ }
37
+ if (opts.prompts) {
38
+ for (const [i, prompt] of opts.prompts.entries()) {
39
+ if (verdicts[i])
40
+ verdicts[i].prompt = prompt;
41
+ }
42
+ }
43
+ return verdicts;
44
+ }
45
+ // ── prompt-content translator ────────────────────────────────────────────────
46
+ // Hand-written, line-oriented, pure. Known subset only: ATX headings (# = bold, deeper = dim),
47
+ // `> ` blockquotes (dim `│ ` bar prefix), `- `/`* ` bullets (• marker), ``` fenced blocks
48
+ // (dim, indented), everything else passes through plain. No markdown-AST dependency.
49
+ const HEADING_RE = /^(#{1,6})[ \t]+(.*)$/;
50
+ const QUOTE_RE = /^>[ \t]?(.*)$/;
51
+ const BULLET_RE = /^[-*][ \t]+(.*)$/;
52
+ const FENCE_RE = /^\s*```/;
53
+ /** Translate persisted prompt content into styled terminal lines. Pure. */
54
+ export function translatePromptContent(content) {
55
+ const out = [];
56
+ let fenced = false;
57
+ for (const raw of content.split("\n")) {
58
+ const line = raw.replace(/\s+$/, "");
59
+ if (FENCE_RE.test(line)) {
60
+ fenced = !fenced;
61
+ continue;
62
+ }
63
+ if (fenced) {
64
+ out.push(line.trim() ? dim(` ${line}`) : "");
65
+ continue;
66
+ }
67
+ const heading = HEADING_RE.exec(line);
68
+ if (heading) {
69
+ out.push(heading[1].length === 1 ? bold(heading[2]) : dim(heading[2]));
70
+ continue;
71
+ }
72
+ const quote = QUOTE_RE.exec(line);
73
+ if (quote) {
74
+ out.push(dim(`│ ${quote[1]}`));
75
+ continue;
76
+ }
77
+ const bullet = BULLET_RE.exec(line);
78
+ if (bullet) {
79
+ out.push(`• ${bullet[1]}`);
80
+ continue;
81
+ }
82
+ out.push(line);
83
+ }
84
+ return out;
85
+ }
86
+ /** A verdict's guidance field is "imperative steps for the worker (newline-separated ok)" —
87
+ * already list-shaped, so it renders as a bullet list, never a wall of prose. Pure. */
88
+ export function renderGuidanceSteps(guidance) {
89
+ return guidance
90
+ .split(/\n+/)
91
+ .map((s) => s.trim())
92
+ .filter(Boolean)
93
+ .map((s) => `• ${s}`);
94
+ }
95
+ /** The consult dossier panel. Stateful over injected data: a verdict cursor, at most one
96
+ * expanded dossier, and a per-verdict render cache cleared only when the selection changes. */
97
+ export function createConsultDossierPanel(initial, opts = {}) {
98
+ const translate = opts.translate ?? translatePromptContent;
99
+ let data = initial;
100
+ let cursor = 0;
101
+ let expanded = null;
102
+ // Per-verdict cached render of the expanded dossier block — parsed once, reused on every
103
+ // later expand of the same verdict within the same selection; never re-parsed per repaint.
104
+ const cache = new Map();
105
+ const dossierBlock = (verdict, index) => {
106
+ const hit = cache.get(index);
107
+ if (hit)
108
+ return hit;
109
+ const lines = [];
110
+ lines.push(dim(`── persisted prompt · consult · ${data?.taskId ?? "?"} · verdict ${index + 1} ──`));
111
+ if (verdict.prompt !== undefined)
112
+ lines.push(...translate(verdict.prompt));
113
+ else
114
+ lines.push(dim("(no persisted prompt content recorded for this verdict)"));
115
+ lines.push(`action: ${verdict.action}`);
116
+ const steps = verdict.guidance ? renderGuidanceSteps(verdict.guidance) : [];
117
+ if (steps.length > 0) {
118
+ lines.push("guidance:");
119
+ for (const s of steps)
120
+ lines.push(` ${s}`);
121
+ }
122
+ cache.set(index, lines);
123
+ return lines;
124
+ };
125
+ return {
126
+ get expandedIndex() {
127
+ return expanded;
128
+ },
129
+ select(next) {
130
+ data = next;
131
+ cursor = 0;
132
+ expanded = null;
133
+ cache.clear();
134
+ },
135
+ key(name) {
136
+ if (!data || data.verdicts.length === 0)
137
+ return;
138
+ if (name === "up")
139
+ cursor = Math.max(cursor - 1, 0);
140
+ else if (name === "down")
141
+ cursor = Math.min(cursor + 1, data.verdicts.length - 1);
142
+ else if (name === "enter")
143
+ expanded = expanded === cursor ? null : cursor;
144
+ },
145
+ render() {
146
+ if (!data)
147
+ return [];
148
+ if (data.verdicts.length === 0) {
149
+ return [
150
+ ` no consult verdicts recorded for ${data.taskId} — every attempt finished without needing a consult.`,
151
+ ];
152
+ }
153
+ const lines = [];
154
+ data.verdicts.forEach((verdict, i) => {
155
+ const pointer = i === cursor ? `${GLYPHS.pointer} ` : " ";
156
+ // Consult actions are not pass/fail: human → warn (needs the operator), the rest plain.
157
+ const actionWord = verdict.action === "human" ? warn(verdict.action) : verdict.action;
158
+ const reason = verdict.reason ?? verdict.notes;
159
+ const hint = dim(i === expanded ? "[enter: collapse]" : "[enter: expand]");
160
+ lines.push(` ${pointer}${i + 1}. ${actionWord}${reason ? ` — ${reason}` : ""} ${hint}`);
161
+ if (i === expanded) {
162
+ for (const l of dossierBlock(verdict, i))
163
+ lines.push(` ${l}`);
164
+ }
165
+ });
166
+ return lines;
167
+ },
168
+ };
169
+ }
@@ -0,0 +1,9 @@
1
+ import { type SaveTarget } from "../save.js";
2
+ export type DiffModalProps = {
3
+ target: SaveTarget;
4
+ targetPath: string;
5
+ diff: string;
6
+ liveRun: boolean;
7
+ };
8
+ /** Render the save-confirmation modal: header names the target path, body shows the exact overlay diff. */
9
+ export declare function renderDiffModal(props: DiffModalProps): string[];
@@ -0,0 +1,19 @@
1
+ import { bold, dim, fail, legend } from "../../brand.js";
2
+ import { RELOAD_GUARD_NOTICE } from "../save.js";
3
+ /** Render the save-confirmation modal: header names the target path, body shows the exact overlay diff. */
4
+ export function renderDiffModal(props) {
5
+ const lines = [];
6
+ lines.push(bold(`Save overlay — ${props.targetPath} (${props.target === "repo" ? "repository" : "global"} target)`));
7
+ lines.push(legend("t toggle target · y confirm · n/esc cancel"));
8
+ if (props.liveRun) {
9
+ lines.push(fail(RELOAD_GUARD_NOTICE));
10
+ }
11
+ lines.push(dim("─ diff ─"));
12
+ if (props.diff) {
13
+ lines.push(...props.diff.trimEnd().split("\n"));
14
+ }
15
+ else {
16
+ lines.push(dim("(no overlay changes)"));
17
+ }
18
+ return lines;
19
+ }
@@ -2,6 +2,7 @@ import type { View } from "../app.js";
2
2
  import type { AuthHealth } from "../../adapters/types.js";
3
3
  import type { Tier } from "../../config/config.js";
4
4
  import type { TelemetryRow } from "../../run/journal.js";
5
+ import type { FleetStaging } from "../staging.js";
5
6
  /** One discovered adapter and its classified models (cfg.tiers via channels()). */
6
7
  export type FleetRosterEntry = {
7
8
  adapter: string;
@@ -65,5 +66,26 @@ export type FleetView = View & {
65
66
  expanded(adapter: string): boolean;
66
67
  /** channelKey of the cursor's channel, or null when the cursor sits on an adapter row. */
67
68
  selectedChannel(): string | null;
69
+ /** Stage a deny⇄allow toggle for one adapter into the shared buffer. No-op without staging. */
70
+ toggleDenyAdapter(adapter: string): void;
71
+ /** Stage a deny⇄allow toggle for one channel ("adapter:model"). No-op without staging. */
72
+ toggleDenyChannel(channelKey: string): void;
73
+ /** Stage a tier override for one channel ("adapter:model"). No-op without staging. */
74
+ stageTierOverride(channelKey: string, tier: Tier): void;
68
75
  };
69
- export declare function createFleetView(data?: FleetViewData): FleetView;
76
+ /** A channel that is denied in the staged buffer while a routing.map pin still points at it —
77
+ * the fleet view stages the deny, the routing view stages the pin, and BOTH are read out of the
78
+ * one shared staging model, so either view surfaces the same conflict set. */
79
+ export type StagedConflict = {
80
+ /** channelKey ("adapter:model") that is both denied and pinned in the buffer. */
81
+ channelKey: string;
82
+ /** shapes whose pin the deny would dead-end (deny wins over pin in saved config). */
83
+ shapes: string[];
84
+ };
85
+ /** Detect deny-vs-pin conflicts from the shared staging model. The buffer's CURRENT state holds
86
+ * both views' staged state — the fleet view's deny/allow + tier edits and the routing view's
87
+ * pin/floor edits — so reading it sees the combination before anything is saved. A conflict is
88
+ * a pinned channel denied by its own entry OR by its adapter's deny (the adapter deny reaches
89
+ * every channel under it, same rule as denyOf). */
90
+ export declare function stagedConflicts(staging: FleetStaging): StagedConflict[];
91
+ export declare function createFleetView(data?: FleetViewData, staging?: FleetStaging): FleetView;
@@ -1,4 +1,4 @@
1
- import { GLYPHS, bold, dim, fail, legend } from "../../brand.js";
1
+ import { GLYPHS, bold, dim, fail, legend, warn } from "../../brand.js";
2
2
  const keyOf = (r) => `${r.adapter}:${r.model}`;
3
3
  const median = (xs) => {
4
4
  const s = [...xs].sort((a, b) => a - b);
@@ -55,6 +55,73 @@ export function foldChannelHealth(rows) {
55
55
  out.set(key, foldRows(key, list));
56
56
  return out;
57
57
  }
58
+ /** Detect deny-vs-pin conflicts from the shared staging model. The buffer's CURRENT state holds
59
+ * both views' staged state — the fleet view's deny/allow + tier edits and the routing view's
60
+ * pin/floor edits — so reading it sees the combination before anything is saved. A conflict is
61
+ * a pinned channel denied by its own entry OR by its adapter's deny (the adapter deny reaches
62
+ * every channel under it, same rule as denyOf). */
63
+ export function stagedConflicts(staging) {
64
+ const cur = staging.current;
65
+ const byChannel = new Map();
66
+ for (const [shape, entry] of Object.entries(cur.map)) {
67
+ const pin = entry?.pin;
68
+ if (!pin)
69
+ continue;
70
+ const key = `${pin.via}:${pin.model}`;
71
+ if (cur.denyModels.includes(key) || cur.denyAdapters.includes(pin.via)) {
72
+ byChannel.set(key, [...(byChannel.get(key) ?? []), shape]);
73
+ }
74
+ }
75
+ return [...byChannel.entries()].map(([channelKey, shapes]) => ({ channelKey, shapes }));
76
+ }
77
+ /** Rows whose staged state differs from the loaded (saved) state: adapter deny flips, channel
78
+ * deny flips, and tier overrides. Staged rows render marked so an unsaved edit never reads as
79
+ * saved state. Same JSON leaf-comparison semantics as the staging model's own delta count. */
80
+ function stagedRowSets(staging) {
81
+ const cur = staging.current;
82
+ const saved = staging.loadedState;
83
+ const flipped = (a, b) => new Set([...a.filter((x) => !b.includes(x)), ...b.filter((x) => !a.includes(x))]);
84
+ const channels = flipped(cur.denyModels, saved.denyModels);
85
+ const tierAdapters = new Set([...Object.keys(cur.tiers), ...Object.keys(saved.tiers)]);
86
+ for (const adapter of tierAdapters) {
87
+ const models = new Set([
88
+ ...Object.keys(cur.tiers[adapter] ?? {}),
89
+ ...Object.keys(saved.tiers[adapter] ?? {}),
90
+ ]);
91
+ for (const model of models) {
92
+ if (JSON.stringify(cur.tiers[adapter]?.[model]) !== JSON.stringify(saved.tiers[adapter]?.[model])) {
93
+ channels.add(`${adapter}:${model}`);
94
+ }
95
+ }
96
+ }
97
+ return { adapters: flipped(cur.denyAdapters, saved.denyAdapters), channels };
98
+ }
99
+ /** The roster renders the buffer's CURRENT state when a staging model is attached: deny lists
100
+ * and tier badges come from the buffer, everything else (health, telemetry, notes, row shape)
101
+ * from the injected facts. Without staging the injected facts render as-is (saved state). */
102
+ function overlayStaged(data, staging) {
103
+ if (!staging)
104
+ return data;
105
+ const cur = staging.current;
106
+ return {
107
+ ...data,
108
+ denyAdapters: cur.denyAdapters,
109
+ denyModels: cur.denyModels,
110
+ adapters: data.adapters.map((a) => ({
111
+ ...a,
112
+ models: a.models.map((m) => ({ ...m, tier: cur.tiers[a.adapter]?.[m.model]?.tier ?? m.tier })),
113
+ })),
114
+ };
115
+ }
116
+ /** Sorted in-place toggle so a stage-then-unstage leaves the list byte-identical to loaded. */
117
+ const toggleIn = (list, value) => {
118
+ const i = list.indexOf(value);
119
+ if (i >= 0)
120
+ list.splice(i, 1);
121
+ else
122
+ list.push(value);
123
+ list.sort();
124
+ };
58
125
  // fleet.ts formatAge voice — the operator already reads "12m old" on the classic fleet screen.
59
126
  const formatCacheAge = (ageMs) => {
60
127
  if (ageMs === null)
@@ -114,7 +181,7 @@ function digestLine(d, noun) {
114
181
  parts.push(`${d.quotaFailovers} quota failover${d.quotaFailovers === 1 ? "" : "s"}`);
115
182
  return ` journals: ${parts.join(" · ")}`;
116
183
  }
117
- export function createFleetView(data) {
184
+ export function createFleetView(data, staging) {
118
185
  // One expanded bit per adapter, default expanded — the roster shows everything at a glance.
119
186
  const expandedMap = new Map((data?.adapters ?? []).map((a) => [a.adapter, true]));
120
187
  let cursor = 0;
@@ -162,43 +229,70 @@ export function createFleetView(data) {
162
229
  const row = visibleRows()[cursor];
163
230
  return row?.kind === "model" ? `${row.adapter}:${row.model}` : null;
164
231
  },
232
+ toggleDenyAdapter(adapter) {
233
+ staging?.apply((buffer) => toggleIn(buffer.denyAdapters, adapter));
234
+ },
235
+ toggleDenyChannel(channelKey) {
236
+ staging?.apply((buffer) => toggleIn(buffer.denyModels, channelKey));
237
+ },
238
+ stageTierOverride(channelKey, tier) {
239
+ const split = channelKey.indexOf(":");
240
+ if (split <= 0)
241
+ return;
242
+ const adapter = channelKey.slice(0, split);
243
+ const model = channelKey.slice(split + 1);
244
+ staging?.apply((buffer) => {
245
+ buffer.tiers[adapter] ??= {};
246
+ // spread keeps an existing provenance note riding the staged assignment
247
+ buffer.tiers[adapter][model] = { ...buffer.tiers[adapter][model], tier };
248
+ });
249
+ },
165
250
  render(_props) {
166
251
  if (!data) {
167
252
  // T2 stub surface — the shell registers the view before any data source is wired.
168
253
  return ["Fleet view", "Harness roster and health (read-only in v1.66)."];
169
254
  }
170
- const digest = foldChannelHealth(data.telemetry);
255
+ const eff = overlayStaged(data, staging);
256
+ const staged = staging ? stagedRowSets(staging) : null;
257
+ const conflicts = new Map((staging ? stagedConflicts(staging) : []).map((c) => [c.channelKey, c.shapes]));
258
+ const digest = foldChannelHealth(eff.telemetry);
171
259
  const lines = [bold("Fleet view — harness roster")];
172
- lines.push(legend(`doctor cache: ${formatCacheAge(data.doctorAgeMs)} · ${data.adapters.length} adapters · ` +
173
- `${data.telemetry.length} journal rows · ↑↓ select · → expand · ← collapse · space toggle`));
260
+ lines.push(legend(`doctor cache: ${formatCacheAge(eff.doctorAgeMs)} · ${eff.adapters.length} adapters · ` +
261
+ `${eff.telemetry.length} journal rows · ↑↓ select · → expand · ← collapse · space toggle`));
174
262
  const rows = visibleRows();
175
263
  rows.forEach((row, i) => {
176
264
  const pointer = i === cursor ? `${GLYPHS.pointer} ` : " ";
177
- const h = data.health[row.adapter];
265
+ const h = eff.health[row.adapter];
178
266
  if (row.kind === "adapter") {
179
267
  const marker = expandedMap.get(row.adapter) !== false ? "▾" : "▸";
180
268
  const state = !h?.installed
181
269
  ? `${fail(GLYPHS.fail)} not installed`
182
270
  : `${h.version ?? "installed"} · ${h.authed ? "authed" : "unauthed"}`;
183
- const counts = h?.installed ? ` · ${plural(entryOf(data, row.adapter)?.models.length ?? 0, "model")}` : "";
184
- const deny = data.denyAdapters.includes(row.adapter)
185
- ? ` · ${fail("denied")}${note(data.notes?.denyAdapters?.[row.adapter])}`
271
+ const counts = h?.installed ? ` · ${plural(entryOf(eff, row.adapter)?.models.length ?? 0, "model")}` : "";
272
+ const deny = eff.denyAdapters.includes(row.adapter)
273
+ ? ` · ${fail("denied")}${note(eff.notes?.denyAdapters?.[row.adapter])}`
186
274
  : "";
187
- lines.push(`${pointer}${marker} ${bold(row.adapter)} ${state}${counts}${deny}`);
275
+ const stagedBit = staged?.adapters.has(row.adapter) ? ` · ${warn("● staged")}` : "";
276
+ lines.push(`${pointer}${marker} ${bold(row.adapter)} ${state}${counts}${deny}${stagedBit}`);
188
277
  return;
189
278
  }
190
- const m = data.adapters.find((a) => a.adapter === row.adapter)?.models.find((mm) => mm.model === row.model);
279
+ const m = eff.adapters.find((a) => a.adapter === row.adapter)?.models.find((mm) => mm.model === row.model);
191
280
  if (!m)
192
281
  return;
193
282
  const key = `${row.adapter}:${row.model}`;
194
283
  const auth = modelAuthText(h, row.model);
195
- const dn = denyOf(data, row.adapter, row.model);
284
+ const dn = denyOf(eff, row.adapter, row.model);
196
285
  const deny = dn.denied ? ` ${fail(GLYPHS.fail)} denied${note(dn.note)}` : "";
197
286
  const d = digest.get(key);
198
287
  const healthBit = d && d.tasks > 0 ? ` ${dim(`${d.done}/${d.tasks} done`)}` : "";
199
- lines.push(`${pointer} ${row.model.padEnd(28)} [${m.tier}] ${m.channel} ${auth}${deny}${healthBit}`);
288
+ const stagedBit = staged?.channels.has(key) ? ` ${warn("● staged")}` : "";
289
+ const pinnedFor = conflicts.get(key);
290
+ const conflictBit = pinnedFor
291
+ ? ` ${warn(`${GLYPHS.attention} conflict: pinned for ${pinnedFor.join(", ")}`)}`
292
+ : "";
293
+ lines.push(`${pointer} ${row.model.padEnd(28)} [${m.tier}] ${m.channel} ${auth}${deny}${healthBit}${stagedBit}${conflictBit}`);
200
294
  });
201
- lines.push(...detailPanel(data, digest, rows[cursor]));
295
+ lines.push(...detailPanel(eff, digest, rows[cursor]));
202
296
  return lines;
203
297
  },
204
298
  };
@@ -1,7 +1,8 @@
1
1
  import { type BillingChannel } from "../../adapters/types.js";
2
- import { type RoutingMode, type TickmarkrConfig } from "../../config/config.js";
2
+ import { type RoutingMode, type TickmarkrConfig, type Tier } from "../../config/config.js";
3
3
  import { type Shape, type Task } from "../../graph/schema.js";
4
4
  import type { RoutingProfile } from "../../route/profile.js";
5
+ import { FleetStaging } from "../staging.js";
5
6
  import type { View } from "../app.js";
6
7
  export type RoutingViewData = {
7
8
  cfg: TickmarkrConfig;
@@ -15,14 +16,32 @@ export type RoutingViewDeps = {
15
16
  data?: RoutingViewData;
16
17
  /** where the no-arg studio shell loads config from (default: process.cwd()) */
17
18
  repoRoot?: string;
19
+ /** shared staged-changes buffer; default derives one from data.cfg — edits never leave it */
20
+ staging?: FleetStaging;
18
21
  };
19
22
  export type RoutingView = View & {
20
- /** open the read-only candidate inspector for a shape (default: the selected grid row) */
23
+ /** open the candidate inspector for a shape (default: the selected grid row) */
21
24
  openInspector(shape?: Shape): void;
22
25
  closeInspector(): void;
23
26
  moveCursor(delta: number): void;
27
+ /** move the inspector's candidate highlight (wraps) */
28
+ moveInspectorCursor(delta: number): void;
29
+ /** stage the highlighted inspector candidate as that shape's map pin */
30
+ stageInspectorPin(): void;
31
+ /** stage a floor tier for the selected grid shape */
32
+ stageFloor(tier: Tier): void;
33
+ /** stage a prefer chain for the selected grid shape ([] clears the chain) */
34
+ stagePrefer(chain: string[]): void;
35
+ /** stage a routing mode — compiles into the preset's floor table in the buffer */
36
+ stageMode(mode: RoutingMode): void;
37
+ /** discard every staged edit (delegates to the staging model) */
38
+ revert(): void;
24
39
  readonly selectedShape: Shape;
25
- /** the inspector's candidate rows — exactly the picker's candidateRow strings, in rank order */
40
+ /** mode derived from the buffer: loaded mode, a staged preset, or "custom" — never stored */
41
+ readonly selectedMode: RoutingMode | "custom";
42
+ /** the staging model every change flows through */
43
+ readonly staging: FleetStaging;
44
+ /** the inspector's candidate rows — the picker's candidateRow strings, in rank order */
26
45
  inspectorRows(): string[] | null;
27
46
  };
28
47
  export declare function routingPreviewTask(shape: Shape): Task;