tickmarkr 1.64.0 → 1.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/claude-code.js +3 -0
- package/dist/adapters/codex.js +3 -0
- package/dist/adapters/cursor-agent.js +2 -0
- package/dist/adapters/grok.js +2 -0
- package/dist/adapters/kimi.js +3 -0
- package/dist/adapters/opencode.js +3 -0
- package/dist/adapters/pi.js +2 -0
- package/dist/adapters/prompt.d.ts +4 -0
- package/dist/adapters/prompt.js +28 -2
- package/dist/adapters/registry.d.ts +3 -0
- package/dist/adapters/registry.js +38 -0
- package/dist/adapters/types.d.ts +4 -0
- package/dist/cli/commands/doctor.js +4 -1
- package/dist/cli/commands/status.js +12 -4
- package/dist/cli/commands/ui.d.ts +5 -0
- package/dist/cli/commands/ui.js +13 -0
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +3 -1
- package/dist/config/config.js +4 -1
- package/dist/run/activity.d.ts +15 -0
- package/dist/run/activity.js +96 -0
- package/dist/run/consult.js +4 -1
- package/dist/run/daemon.js +35 -1
- package/dist/run/stall.d.ts +7 -3
- package/dist/run/stall.js +71 -3
- package/dist/tui/app.d.ts +40 -0
- package/dist/tui/app.js +130 -0
- package/dist/tui/engine.d.ts +52 -0
- package/dist/tui/engine.js +92 -0
- package/dist/tui/frame.d.ts +22 -0
- package/dist/tui/frame.js +56 -0
- package/dist/tui/input.d.ts +41 -0
- package/dist/tui/input.js +100 -0
- package/dist/tui/views/fleet-view.d.ts +69 -0
- package/dist/tui/views/fleet-view.js +243 -0
- package/dist/tui/views/preview-view.d.ts +7 -0
- package/dist/tui/views/preview-view.js +80 -0
- package/dist/tui/views/profile-view.d.ts +15 -0
- package/dist/tui/views/profile-view.js +93 -0
- package/dist/tui/views/routing-view.d.ts +29 -0
- package/dist/tui/views/routing-view.js +98 -0
- package/package.json +1 -1
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { GLYPHS, bold, dim, fail, legend } from "../../brand.js";
|
|
2
|
+
const keyOf = (r) => `${r.adapter}:${r.model}`;
|
|
3
|
+
const median = (xs) => {
|
|
4
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
5
|
+
const m = Math.floor(s.length / 2);
|
|
6
|
+
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
7
|
+
};
|
|
8
|
+
/** Fold one row list into a single digest. Journals are append-only, so input order is
|
|
9
|
+
* chronological and the last row's outcome is the channel's last observed outcome. */
|
|
10
|
+
function foldRows(key, rows) {
|
|
11
|
+
const d = {
|
|
12
|
+
key, tasks: 0, attempts: 0, done: 0, failed: 0, human: 0,
|
|
13
|
+
overruns: 0, quotaFailovers: 0, gateFails: null, medianDurationMs: null, lastOutcome: null,
|
|
14
|
+
};
|
|
15
|
+
for (const r of rows) {
|
|
16
|
+
d.tasks += 1;
|
|
17
|
+
d.attempts += r.attempts;
|
|
18
|
+
if (r.outcome === "done")
|
|
19
|
+
d.done += 1;
|
|
20
|
+
else if (r.outcome === "failed")
|
|
21
|
+
d.failed += 1;
|
|
22
|
+
else
|
|
23
|
+
d.human += 1;
|
|
24
|
+
if (r.overrun === true)
|
|
25
|
+
d.overruns += 1;
|
|
26
|
+
if (r.quotaFailover === true)
|
|
27
|
+
d.quotaFailovers += 1;
|
|
28
|
+
// absent = unobserved: an all-legacy channel keeps gateFails null rather than a fake 0
|
|
29
|
+
if (r.gateFails !== undefined)
|
|
30
|
+
d.gateFails = (d.gateFails ?? 0) + r.gateFails;
|
|
31
|
+
d.lastOutcome = r.outcome;
|
|
32
|
+
}
|
|
33
|
+
if (rows.length)
|
|
34
|
+
d.medianDurationMs = median(rows.map((r) => r.durationMs));
|
|
35
|
+
return d;
|
|
36
|
+
}
|
|
37
|
+
/** Fold journal telemetry rows into a per-channel health digest keyed by channelKey
|
|
38
|
+
* ("adapter:model"). The digest derives from journal telemetry (TelemetryRow, the rows the
|
|
39
|
+
* daemon appends to each run's telemetry.jsonl) rather than any new state file: this module
|
|
40
|
+
* reads nothing and writes nothing — the caller folds rows out of the existing run journals
|
|
41
|
+
* (readAllTelemetry) and the fold is a pure function of them, same telemetry in, same digest
|
|
42
|
+
* out. */
|
|
43
|
+
export function foldChannelHealth(rows) {
|
|
44
|
+
const byChannel = new Map();
|
|
45
|
+
for (const r of rows) {
|
|
46
|
+
const key = keyOf(r);
|
|
47
|
+
const list = byChannel.get(key);
|
|
48
|
+
if (list)
|
|
49
|
+
list.push(r);
|
|
50
|
+
else
|
|
51
|
+
byChannel.set(key, [r]);
|
|
52
|
+
}
|
|
53
|
+
const out = new Map();
|
|
54
|
+
for (const [key, list] of byChannel)
|
|
55
|
+
out.set(key, foldRows(key, list));
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
// fleet.ts formatAge voice — the operator already reads "12m old" on the classic fleet screen.
|
|
59
|
+
const formatCacheAge = (ageMs) => {
|
|
60
|
+
if (ageMs === null)
|
|
61
|
+
return "no probe data";
|
|
62
|
+
const mins = Math.floor(ageMs / 60_000);
|
|
63
|
+
if (mins < 60)
|
|
64
|
+
return `${mins}m old`;
|
|
65
|
+
return `${Math.floor(mins / 60)}h old`;
|
|
66
|
+
};
|
|
67
|
+
const fmtDuration = (ms) => {
|
|
68
|
+
const s = Math.round(ms / 1000);
|
|
69
|
+
return s < 60 ? `${s}s` : `${(s / 60).toFixed(1)}m`;
|
|
70
|
+
};
|
|
71
|
+
const trunc = (s, n) => (s.length <= n ? s : `${s.slice(0, n - 1)}…`);
|
|
72
|
+
const note = (n) => (n ? ` — ${n}` : "");
|
|
73
|
+
const plural = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
74
|
+
/** doctor.ts model-status voice: authed / unauthed with reason+date / unknown. A non-installed
|
|
75
|
+
* adapter is "unknown" whatever stale modelAuth its doctor entry still carries — the roster row
|
|
76
|
+
* and the detail panel must never disagree about whether a channel is usable. */
|
|
77
|
+
function modelAuthText(health, model) {
|
|
78
|
+
const v = health?.installed ? health.modelAuth?.[model] : undefined;
|
|
79
|
+
if (!v)
|
|
80
|
+
return "unknown";
|
|
81
|
+
if (v.authed)
|
|
82
|
+
return "authed";
|
|
83
|
+
return `unauthed: ${trunc(v.reason ?? "probe failed", 40)} (${v.probedAt.slice(0, 10)})`;
|
|
84
|
+
}
|
|
85
|
+
/** A channel is denied by its own routing.deny.models entry OR by its adapter's deny — the roster
|
|
86
|
+
* row and the detail panel resolve deny through this one seam so an adapter-level deny can never
|
|
87
|
+
* read as available on the model row while the panel below it says "denied". Adapter deny wins
|
|
88
|
+
* and carries the adapter's note: it is the broader reason. */
|
|
89
|
+
function denyOf(data, adapter, model) {
|
|
90
|
+
if (data.denyAdapters.includes(adapter))
|
|
91
|
+
return { denied: true, note: data.notes?.denyAdapters?.[adapter] };
|
|
92
|
+
const key = `${adapter}:${model}`;
|
|
93
|
+
if (data.denyModels.includes(key))
|
|
94
|
+
return { denied: true, note: data.notes?.denyModels?.[key] };
|
|
95
|
+
return { denied: false };
|
|
96
|
+
}
|
|
97
|
+
function digestLine(d, noun) {
|
|
98
|
+
if (d.tasks === 0)
|
|
99
|
+
return ` journals: no recent telemetry for this ${noun}`;
|
|
100
|
+
const parts = [
|
|
101
|
+
`${d.tasks} task${d.tasks === 1 ? "" : "s"}`,
|
|
102
|
+
`${d.attempts} attempt${d.attempts === 1 ? "" : "s"}`,
|
|
103
|
+
`${d.done} done`,
|
|
104
|
+
];
|
|
105
|
+
if (d.failed)
|
|
106
|
+
parts.push(`${d.failed} failed`);
|
|
107
|
+
if (d.human)
|
|
108
|
+
parts.push(`${d.human} human`);
|
|
109
|
+
if (d.medianDurationMs !== null)
|
|
110
|
+
parts.push(`median ${fmtDuration(d.medianDurationMs)}`);
|
|
111
|
+
if (d.gateFails !== null)
|
|
112
|
+
parts.push(`${d.gateFails} gate-fails`);
|
|
113
|
+
parts.push(`${d.overruns} overrun${d.overruns === 1 ? "" : "s"}`);
|
|
114
|
+
parts.push(`${d.quotaFailovers} quota failover${d.quotaFailovers === 1 ? "" : "s"}`);
|
|
115
|
+
return ` journals: ${parts.join(" · ")}`;
|
|
116
|
+
}
|
|
117
|
+
export function createFleetView(data) {
|
|
118
|
+
// One expanded bit per adapter, default expanded — the roster shows everything at a glance.
|
|
119
|
+
const expandedMap = new Map((data?.adapters ?? []).map((a) => [a.adapter, true]));
|
|
120
|
+
let cursor = 0;
|
|
121
|
+
const visibleRows = () => {
|
|
122
|
+
const rows = [];
|
|
123
|
+
for (const a of data?.adapters ?? []) {
|
|
124
|
+
rows.push({ kind: "adapter", adapter: a.adapter });
|
|
125
|
+
if (expandedMap.get(a.adapter) !== false) {
|
|
126
|
+
for (const m of a.models)
|
|
127
|
+
rows.push({ kind: "model", adapter: a.adapter, model: m.model });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return rows;
|
|
131
|
+
};
|
|
132
|
+
const view = {
|
|
133
|
+
id: "fleet",
|
|
134
|
+
label: "Fleet",
|
|
135
|
+
key(name) {
|
|
136
|
+
const rows = visibleRows();
|
|
137
|
+
if (!rows.length)
|
|
138
|
+
return;
|
|
139
|
+
if (name === "up")
|
|
140
|
+
cursor = Math.max(cursor - 1, 0);
|
|
141
|
+
else if (name === "down")
|
|
142
|
+
cursor = Math.min(cursor + 1, rows.length - 1);
|
|
143
|
+
else if (name === "left" || name === "right" || name === "space" || name === "return") {
|
|
144
|
+
const row = rows[cursor];
|
|
145
|
+
if (!row)
|
|
146
|
+
return;
|
|
147
|
+
const cur = expandedMap.get(row.adapter) !== false;
|
|
148
|
+
const next = name === "left" ? false : name === "right" ? true : !cur;
|
|
149
|
+
expandedMap.set(row.adapter, next);
|
|
150
|
+
if (row.kind === "model" && !next) {
|
|
151
|
+
// collapsing from a model row parks the cursor on the adapter header row
|
|
152
|
+
cursor = visibleRows().findIndex((r) => r.kind === "adapter" && r.adapter === row.adapter);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const n = visibleRows().length;
|
|
156
|
+
cursor = Math.min(Math.max(cursor, 0), n - 1);
|
|
157
|
+
},
|
|
158
|
+
expanded(adapter) {
|
|
159
|
+
return expandedMap.get(adapter) !== false;
|
|
160
|
+
},
|
|
161
|
+
selectedChannel() {
|
|
162
|
+
const row = visibleRows()[cursor];
|
|
163
|
+
return row?.kind === "model" ? `${row.adapter}:${row.model}` : null;
|
|
164
|
+
},
|
|
165
|
+
render(_props) {
|
|
166
|
+
if (!data) {
|
|
167
|
+
// T2 stub surface — the shell registers the view before any data source is wired.
|
|
168
|
+
return ["Fleet view", "Harness roster and health (read-only in v1.66)."];
|
|
169
|
+
}
|
|
170
|
+
const digest = foldChannelHealth(data.telemetry);
|
|
171
|
+
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`));
|
|
174
|
+
const rows = visibleRows();
|
|
175
|
+
rows.forEach((row, i) => {
|
|
176
|
+
const pointer = i === cursor ? `${GLYPHS.pointer} ` : " ";
|
|
177
|
+
const h = data.health[row.adapter];
|
|
178
|
+
if (row.kind === "adapter") {
|
|
179
|
+
const marker = expandedMap.get(row.adapter) !== false ? "▾" : "▸";
|
|
180
|
+
const state = !h?.installed
|
|
181
|
+
? `${fail(GLYPHS.fail)} not installed`
|
|
182
|
+
: `${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])}`
|
|
186
|
+
: "";
|
|
187
|
+
lines.push(`${pointer}${marker} ${bold(row.adapter)} ${state}${counts}${deny}`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const m = data.adapters.find((a) => a.adapter === row.adapter)?.models.find((mm) => mm.model === row.model);
|
|
191
|
+
if (!m)
|
|
192
|
+
return;
|
|
193
|
+
const key = `${row.adapter}:${row.model}`;
|
|
194
|
+
const auth = modelAuthText(h, row.model);
|
|
195
|
+
const dn = denyOf(data, row.adapter, row.model);
|
|
196
|
+
const deny = dn.denied ? ` ${fail(GLYPHS.fail)} denied${note(dn.note)}` : "";
|
|
197
|
+
const d = digest.get(key);
|
|
198
|
+
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}`);
|
|
200
|
+
});
|
|
201
|
+
lines.push(...detailPanel(data, digest, rows[cursor]));
|
|
202
|
+
return lines;
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
return view;
|
|
206
|
+
}
|
|
207
|
+
const entryOf = (data, adapter) => data.adapters.find((a) => a.adapter === adapter);
|
|
208
|
+
/** The bottom panel: the selected channel's journal-derived health digest (or the adapter fold
|
|
209
|
+
* when the cursor sits on an adapter header row). */
|
|
210
|
+
function detailPanel(data, digest, row) {
|
|
211
|
+
if (!row)
|
|
212
|
+
return [];
|
|
213
|
+
const h = data.health[row.adapter];
|
|
214
|
+
if (row.kind === "model") {
|
|
215
|
+
const key = `${row.adapter}:${row.model}`;
|
|
216
|
+
const m = data.adapters.find((a) => a.adapter === row.adapter)?.models.find((mm) => mm.model === row.model);
|
|
217
|
+
const v = h?.installed ? h.modelAuth?.[row.model] : undefined;
|
|
218
|
+
// an unauthed reading already carries its probe date inside modelAuthText — appending it again
|
|
219
|
+
// printed the same day twice ("… (2026-07-21) (probed 2026-07-21)")
|
|
220
|
+
const probed = v?.authed ? ` (probed ${v.probedAt.slice(0, 10)})` : "";
|
|
221
|
+
const dn = denyOf(data, row.adapter, row.model);
|
|
222
|
+
const d = digest.get(key);
|
|
223
|
+
const lines = [
|
|
224
|
+
"",
|
|
225
|
+
dim(`── ${key} ──`),
|
|
226
|
+
` tier ${m?.tier ?? "???"} · ${m?.channel ?? "?"} · ${modelAuthText(h, row.model)}${probed} · ${dn.denied ? "denied" : "enabled"}${note(dn.note)}`,
|
|
227
|
+
digestLine(d ?? foldRows(key, []), "channel"),
|
|
228
|
+
];
|
|
229
|
+
if (d?.lastOutcome)
|
|
230
|
+
lines.push(` last outcome: ${d.lastOutcome}`);
|
|
231
|
+
return lines;
|
|
232
|
+
}
|
|
233
|
+
const folded = foldRows(row.adapter, data.telemetry.filter((r) => r.adapter === row.adapter));
|
|
234
|
+
const state = !h?.installed ? "not installed" : `${h.version ?? "installed"} · ${h.authed ? "authed" : "unauthed"}`;
|
|
235
|
+
const denied = data.denyAdapters.includes(row.adapter);
|
|
236
|
+
const enabled = `${denied ? "denied" : "enabled"}${denied ? note(data.notes?.denyAdapters?.[row.adapter]) : ""}`;
|
|
237
|
+
return [
|
|
238
|
+
"",
|
|
239
|
+
dim(`── ${row.adapter} (adapter) ──`),
|
|
240
|
+
` ${state} · ${plural(entryOf(data, row.adapter)?.models.length ?? 0, "model")} · ${enabled}`,
|
|
241
|
+
digestLine(folded, "adapter"),
|
|
242
|
+
];
|
|
243
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { allAdapters, discoverChannels, readDoctor } from "../../adapters/registry.js";
|
|
2
|
+
import { costSignal } from "../../cli/commands/fleet-picker.js";
|
|
3
|
+
import { loadGraph } from "../../graph/graph.js";
|
|
4
|
+
import { SHAPES } from "../../graph/schema.js";
|
|
5
|
+
import { route, RoutingError } from "../../route/router.js";
|
|
6
|
+
import { resolveRunMode } from "../../run/daemon.js";
|
|
7
|
+
import { loadRoutingProfile } from "../../run/journal.js";
|
|
8
|
+
// Same exploration setting as the candidate picker (rankCandidates routes noExplore so repeated
|
|
9
|
+
// calls agree): a due probe must never make a preview row disagree with the picker's rank-1 for
|
|
10
|
+
// the same graph and channel set. Mirrors fleet.ts's PREVIEW_EXPLORE.
|
|
11
|
+
const PREVIEW_EXPLORE = { noExplore: true };
|
|
12
|
+
// The no-graph stand-in: one synthetic task per shape so the consequence surface still shows what
|
|
13
|
+
// the router WOULD do with this fleet. Same shape as fleet.ts's previewTask.
|
|
14
|
+
function syntheticTask(shape) {
|
|
15
|
+
return {
|
|
16
|
+
id: `preview-${shape}`,
|
|
17
|
+
title: `${shape} preview`,
|
|
18
|
+
goal: "preview",
|
|
19
|
+
shape,
|
|
20
|
+
complexity: 3,
|
|
21
|
+
acceptance: ["done"],
|
|
22
|
+
deps: [],
|
|
23
|
+
files: [],
|
|
24
|
+
context: [],
|
|
25
|
+
gates: ["build", "test", "lint", "evidence", "scope", "acceptance", "review"],
|
|
26
|
+
humanGate: false,
|
|
27
|
+
status: "pending",
|
|
28
|
+
evidence: { commits: [], artifacts: [], gateResults: [] },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
// The consequence surface: the routing table a plan dry-run would produce, one row per task with
|
|
32
|
+
// the chosen channel, the route()'s why-provenance verbatim, and the picker's cost signal. Every
|
|
33
|
+
// render re-reads graph/config/doctor cache from disk (refresh on demand = repaint); the render
|
|
34
|
+
// path is strictly read-only — doctor cache only, never probeAll, so a preview spends no tokens
|
|
35
|
+
// and invokes no agent. Unroutable rows render the RoutingError in place, like plan's `!!` rows.
|
|
36
|
+
function renderPreview(deps) {
|
|
37
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
38
|
+
const adapters = deps.adapters ?? allAdapters();
|
|
39
|
+
let graph = null;
|
|
40
|
+
try {
|
|
41
|
+
graph = loadGraph(cwd);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
graph = null; // absent or invalid — fall through to the synthetic per-shape set
|
|
45
|
+
}
|
|
46
|
+
const { cfg, mode, source } = resolveRunMode(cwd, { spec: graph?.mode });
|
|
47
|
+
const cached = readDoctor(cwd);
|
|
48
|
+
const channels = discoverChannels(cfg, adapters, cached ?? {});
|
|
49
|
+
const profile = loadRoutingProfile(cwd, cfg, { preview: true });
|
|
50
|
+
const tasks = graph ? graph.tasks : SHAPES.map(syntheticTask);
|
|
51
|
+
const lines = [
|
|
52
|
+
"Preview view — plan dry run (read-only: no writes, no probes, no token spend)",
|
|
53
|
+
graph
|
|
54
|
+
? `source: compiled graph (${tasks.length} task${tasks.length === 1 ? "" : "s"}) · mode: ${mode.mode} (${source}) · explore off (picker parity) · ${channels.length} channels`
|
|
55
|
+
: `source: no compiled graph — synthetic per-shape task set (${tasks.length} shapes) · mode: ${mode.mode} (${source}) · explore off (picker parity) · ${channels.length} channels`,
|
|
56
|
+
];
|
|
57
|
+
if (!cached)
|
|
58
|
+
lines.push("doctor cache missing — rows route against zero channels; run `tickmarkr doctor` (the preview never probes)");
|
|
59
|
+
lines.push("");
|
|
60
|
+
for (const t of tasks) {
|
|
61
|
+
try {
|
|
62
|
+
const r = route(t, cfg, channels, profile, undefined, undefined, PREVIEW_EXPLORE);
|
|
63
|
+
const a = r.assignment;
|
|
64
|
+
lines.push(` ${t.id.padEnd(16)} ${t.shape.padEnd(10)} → ${a.adapter}:${a.model} [${a.channel}/${a.tier}] ${costSignal(a, cfg.pricing)} — ${r.provenance}`);
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
if (!(e instanceof RoutingError))
|
|
68
|
+
throw e;
|
|
69
|
+
lines.push(` ${t.id.padEnd(16)} ${t.shape.padEnd(10)} !! ${e.message}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return lines;
|
|
73
|
+
}
|
|
74
|
+
export function createPreviewView(deps = {}) {
|
|
75
|
+
return {
|
|
76
|
+
id: "preview",
|
|
77
|
+
label: "Preview",
|
|
78
|
+
render: () => renderPreview(deps),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type TickmarkrConfig } from "../../config/config.js";
|
|
2
|
+
import { type RoutingProfile } from "../../route/profile.js";
|
|
3
|
+
import type { View } from "../app.js";
|
|
4
|
+
export type ProfileViewData = {
|
|
5
|
+
cfg: TickmarkrConfig;
|
|
6
|
+
/** the loaded routing profile; undefined ⇒ cold (no telemetry in the window) */
|
|
7
|
+
profile?: RoutingProfile;
|
|
8
|
+
};
|
|
9
|
+
export type ProfileViewDeps = {
|
|
10
|
+
/** injected fixture data — with data present the render path never touches the filesystem */
|
|
11
|
+
data?: ProfileViewData;
|
|
12
|
+
/** where the no-arg studio shell loads config and the profile from (default: process.cwd()) */
|
|
13
|
+
repoRoot?: string;
|
|
14
|
+
};
|
|
15
|
+
export declare function createProfileView(deps?: ProfileViewDeps): View;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { channelKey } from "../../adapters/types.js";
|
|
2
|
+
import { loadConfig } from "../../config/config.js";
|
|
3
|
+
import { AVAIL_WEIGHT, cellsOf, cellSummary, EXPLORE_CAP, HALF_LIFE_RUNS, learnedScore, MIN_SAMPLES, } from "../../route/profile.js";
|
|
4
|
+
import { loadRoutingProfile } from "../../run/journal.js";
|
|
5
|
+
const fmtSigned = (n) => (n >= 0 ? `+${n.toFixed(3)}` : n.toFixed(3));
|
|
6
|
+
// No-arg shell path (app.ts constructs views without deps): the SAME loader the daemon's router
|
|
7
|
+
// calls (daemon.ts:194), with the profile command's inspection-surface preview flag so the tab
|
|
8
|
+
// still renders under routing.learned:off — labeled inert in the header, as `tickmarkr profile`
|
|
9
|
+
// does. Best-effort: a load failure degrades to an explanatory note instead of breaking the studio.
|
|
10
|
+
function loadDefaultData(repoRoot) {
|
|
11
|
+
try {
|
|
12
|
+
const cfg = loadConfig(repoRoot);
|
|
13
|
+
return { cfg, profile: loadRoutingProfile(repoRoot, cfg, { preview: true }) };
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// A configured pin that beat a higher-scored learned channel, or null. route() consults the map
|
|
20
|
+
// pin before any learned sorting, so when another channel's learned score for the shape tops the
|
|
21
|
+
// pin's, the pin is overriding learned evidence — the case this inspector exists to name.
|
|
22
|
+
function pinOverride(profile, cfg, shape, pin, scoreOpts) {
|
|
23
|
+
const pinChKey = channelKey({ adapter: pin.via, model: pin.model });
|
|
24
|
+
const pinChannel = cfg.tiers[pin.via]?.channel ?? "sub";
|
|
25
|
+
const pinScore = learnedScore(profile, shape, pinChKey, pinChannel, scoreOpts);
|
|
26
|
+
let top = null;
|
|
27
|
+
for (const { shape: s, chKey, channel } of cellsOf(profile)) {
|
|
28
|
+
if (s !== shape || (chKey === pinChKey && channel === pinChannel))
|
|
29
|
+
continue;
|
|
30
|
+
const score = learnedScore(profile, shape, chKey, channel, scoreOpts);
|
|
31
|
+
if (score > pinScore && (!top || score > top.score))
|
|
32
|
+
top = { chKey, channel, score };
|
|
33
|
+
}
|
|
34
|
+
if (!top)
|
|
35
|
+
return null;
|
|
36
|
+
return `${shape}: pin ${pinChKey} (score ${fmtSigned(pinScore)}) overrides higher-scored ${top.chKey} (${top.channel}, score ${fmtSigned(top.score)})`;
|
|
37
|
+
}
|
|
38
|
+
export function createProfileView(deps = {}) {
|
|
39
|
+
const data = deps.data ?? loadDefaultData(deps.repoRoot ?? process.cwd());
|
|
40
|
+
return {
|
|
41
|
+
id: "profile",
|
|
42
|
+
label: "Profile",
|
|
43
|
+
render: () => {
|
|
44
|
+
const lines = ["Profile view — learned-routing inspector (read-only)"];
|
|
45
|
+
if (!data) {
|
|
46
|
+
lines.push("profile data unavailable — see `tickmarkr profile` for the line-mode surface");
|
|
47
|
+
return lines;
|
|
48
|
+
}
|
|
49
|
+
const { cfg, profile } = data;
|
|
50
|
+
const halfLife = cfg.routing.learnedTuning?.halfLifeRuns ?? HALF_LIFE_RUNS;
|
|
51
|
+
const availWeight = cfg.routing.learnedTuning?.availWeight ?? AVAIL_WEIGHT;
|
|
52
|
+
// router.ts:29's exploreCap — the exploration fence in effect, not the bare module default
|
|
53
|
+
const cap = cfg.routing.explore?.cap ?? EXPLORE_CAP;
|
|
54
|
+
lines.push(`routing.learned: ${cfg.routing.learned}${cfg.routing.learned === "off" ? " (preview — routing is inert)" : ""}`);
|
|
55
|
+
lines.push(`half-life: ${halfLife} runs availability weight: ${availWeight} exploration cap: ${cap}`);
|
|
56
|
+
if (!profile || profile.cells.size === 0) {
|
|
57
|
+
// ROUTE-07's cold start, explained: an empty profile scores every channel exactly NEUTRAL,
|
|
58
|
+
// so routing is byte-identical static routing until real samples accumulate.
|
|
59
|
+
lines.push("cold start — no learned evidence yet:", ` every channel scores exactly neutral and routing follows the static policy`, ` until a shape×channel cell accumulates ${MIN_SAMPLES} samples.`);
|
|
60
|
+
return lines;
|
|
61
|
+
}
|
|
62
|
+
const scoreOpts = { availWeight: cfg.routing.learnedTuning?.availWeight };
|
|
63
|
+
lines.push(` ${"shape".padEnd(10)} ${"channel".padEnd(28)} ${"class".padEnd(5)} ${"score".padEnd(8)} samples decay explore`);
|
|
64
|
+
for (const { shape, chKey, channel, cell } of cellsOf(profile)) {
|
|
65
|
+
const s = cellSummary(cell);
|
|
66
|
+
const score = learnedScore(profile, shape, chKey, channel, scoreOpts);
|
|
67
|
+
// decayed ⇔ rank-decay folded older runs below full weight (n_eff < n_raw); fresh ⇔ all
|
|
68
|
+
// evidence at weight 1. The two states must never render alike.
|
|
69
|
+
const decay = s.nEff < s.nRaw ? "decayed" : "fresh";
|
|
70
|
+
const remaining = Math.max(0, cap - cell.dispatches);
|
|
71
|
+
const explore = remaining > 0 ? `left ${remaining}` : "spent";
|
|
72
|
+
const disc = s.discounted > 0 ? ` disc=${s.discounted}` : "";
|
|
73
|
+
const cold = s.cold ? ` cold (n<${MIN_SAMPLES})` : "";
|
|
74
|
+
lines.push(` ${shape.padEnd(10)} ${chKey.padEnd(28)} ${channel.padEnd(5)} ${fmtSigned(score).padEnd(8)} ${`n_eff=${s.nEff} n_raw=${s.nRaw}`.padEnd(18)} ${decay.padEnd(9)} ${explore}${disc}${cold}`);
|
|
75
|
+
}
|
|
76
|
+
const overrides = [];
|
|
77
|
+
for (const [shape, entry] of Object.entries(cfg.routing.map).sort(([a], [b]) => a.localeCompare(b))) {
|
|
78
|
+
if (!entry.pin)
|
|
79
|
+
continue;
|
|
80
|
+
const named = pinOverride(profile, cfg, shape, entry.pin, scoreOpts);
|
|
81
|
+
if (named)
|
|
82
|
+
overrides.push(named);
|
|
83
|
+
}
|
|
84
|
+
if (overrides.length) {
|
|
85
|
+
lines.push("pins overriding higher-scored learned channels:", ...overrides.map((l) => ` ${l}`));
|
|
86
|
+
}
|
|
87
|
+
else if (Object.values(cfg.routing.map).some((e) => e?.pin)) {
|
|
88
|
+
lines.push("pins: none overrides a higher-scored learned channel");
|
|
89
|
+
}
|
|
90
|
+
return lines;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type BillingChannel } from "../../adapters/types.js";
|
|
2
|
+
import { type RoutingMode, type TickmarkrConfig } from "../../config/config.js";
|
|
3
|
+
import { type Shape, type Task } from "../../graph/schema.js";
|
|
4
|
+
import type { RoutingProfile } from "../../route/profile.js";
|
|
5
|
+
import type { View } from "../app.js";
|
|
6
|
+
export type RoutingViewData = {
|
|
7
|
+
cfg: TickmarkrConfig;
|
|
8
|
+
channels: BillingChannel[];
|
|
9
|
+
/** active routing mode for the header; absent ⇒ cfg.routing.mode ?? "risk-based" */
|
|
10
|
+
mode?: RoutingMode;
|
|
11
|
+
profile?: RoutingProfile;
|
|
12
|
+
};
|
|
13
|
+
export type RoutingViewDeps = {
|
|
14
|
+
/** injected fixture data — with data present the render path never touches the filesystem */
|
|
15
|
+
data?: RoutingViewData;
|
|
16
|
+
/** where the no-arg studio shell loads config from (default: process.cwd()) */
|
|
17
|
+
repoRoot?: string;
|
|
18
|
+
};
|
|
19
|
+
export type RoutingView = View & {
|
|
20
|
+
/** open the read-only candidate inspector for a shape (default: the selected grid row) */
|
|
21
|
+
openInspector(shape?: Shape): void;
|
|
22
|
+
closeInspector(): void;
|
|
23
|
+
moveCursor(delta: number): void;
|
|
24
|
+
readonly selectedShape: Shape;
|
|
25
|
+
/** the inspector's candidate rows — exactly the picker's candidateRow strings, in rank order */
|
|
26
|
+
inspectorRows(): string[] | null;
|
|
27
|
+
};
|
|
28
|
+
export declare function routingPreviewTask(shape: Shape): Task;
|
|
29
|
+
export declare function createRoutingView(deps?: RoutingViewDeps): RoutingView;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { channelsFromConfig } from "../../adapters/types.js";
|
|
2
|
+
import { candidateRow, shapeCandidates } from "../../cli/commands/fleet-picker.js";
|
|
3
|
+
import { INTEGRITY_FLOOR_SHAPES, loadConfigWithMode, } from "../../config/config.js";
|
|
4
|
+
import { SHAPES } from "../../graph/schema.js";
|
|
5
|
+
// Same preview task fleet.ts's picker ranks (one per shape, complexity 3): the inspector must
|
|
6
|
+
// rank the exact inputs the fleet editor ranks, or the two surfaces could disagree.
|
|
7
|
+
export function routingPreviewTask(shape) {
|
|
8
|
+
return {
|
|
9
|
+
id: "routing-preview",
|
|
10
|
+
title: "routing preview",
|
|
11
|
+
goal: "preview",
|
|
12
|
+
shape,
|
|
13
|
+
complexity: 3,
|
|
14
|
+
acceptance: ["done"],
|
|
15
|
+
deps: [],
|
|
16
|
+
files: [],
|
|
17
|
+
context: [],
|
|
18
|
+
gates: ["build", "test", "lint", "evidence", "scope", "acceptance", "review"],
|
|
19
|
+
humanGate: false,
|
|
20
|
+
status: "pending",
|
|
21
|
+
evidence: { commits: [], artifacts: [], gateResults: [] },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const isIntegrity = (shape) => INTEGRITY_FLOOR_SHAPES.includes(shape);
|
|
25
|
+
// No-arg shell path (app.ts constructs views without deps): load the operator's resolved config
|
|
26
|
+
// and derive channels from the configured tier table. Best-effort — a load failure degrades the
|
|
27
|
+
// view to an explanatory note instead of breaking the studio.
|
|
28
|
+
function loadDefaultData(repoRoot) {
|
|
29
|
+
try {
|
|
30
|
+
const { cfg, mode } = loadConfigWithMode(repoRoot);
|
|
31
|
+
const channels = Object.keys(cfg.tiers).flatMap((id) => channelsFromConfig(id, cfg));
|
|
32
|
+
return { cfg, channels, mode: mode.mode };
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function createRoutingView(deps = {}) {
|
|
39
|
+
const data = deps.data ?? loadDefaultData(deps.repoRoot ?? process.cwd());
|
|
40
|
+
let cursor = 0;
|
|
41
|
+
let inspector = null;
|
|
42
|
+
return {
|
|
43
|
+
id: "routing",
|
|
44
|
+
label: "Routing",
|
|
45
|
+
get selectedShape() {
|
|
46
|
+
return SHAPES[cursor];
|
|
47
|
+
},
|
|
48
|
+
moveCursor(delta) {
|
|
49
|
+
cursor = (cursor + delta + SHAPES.length) % SHAPES.length;
|
|
50
|
+
},
|
|
51
|
+
openInspector(shape) {
|
|
52
|
+
if (!data)
|
|
53
|
+
return;
|
|
54
|
+
const target = shape ?? SHAPES[cursor];
|
|
55
|
+
// THE parity seam: rank through the same shared picker glue the fleet editor uses — no
|
|
56
|
+
// comparator lives here — so rows, cost signals, and provenance match the router's choice.
|
|
57
|
+
const ranked = shapeCandidates(routingPreviewTask(target), data.cfg, data.channels, data.profile);
|
|
58
|
+
inspector = { shape: target, rows: ranked.map((c) => candidateRow(c, data.cfg.pricing)) };
|
|
59
|
+
},
|
|
60
|
+
closeInspector() {
|
|
61
|
+
inspector = null;
|
|
62
|
+
},
|
|
63
|
+
inspectorRows() {
|
|
64
|
+
return inspector ? [...inspector.rows] : null;
|
|
65
|
+
},
|
|
66
|
+
render: () => {
|
|
67
|
+
const lines = ["Routing view — per-shape policy grid (read-only)"];
|
|
68
|
+
if (!data) {
|
|
69
|
+
lines.push("routing data unavailable — see `tickmarkr fleet --print` for the line-mode surface");
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
const { cfg } = data;
|
|
73
|
+
const mode = data.mode ?? cfg.routing.mode ?? "risk-based";
|
|
74
|
+
lines.push(`mode: ${mode}`);
|
|
75
|
+
lines.push(` ${"shape".padEnd(10)}${"floor".padEnd(10)}${"pin".padEnd(20)}prefer`);
|
|
76
|
+
for (const [i, shape] of SHAPES.entries()) {
|
|
77
|
+
const entry = cfg.routing.map[shape];
|
|
78
|
+
const floor = cfg.routing.floors[shape] ?? "—";
|
|
79
|
+
const marker = isIntegrity(shape) ? "*" : "";
|
|
80
|
+
const pin = entry?.pin ? `${entry.pin.via}:${entry.pin.model}` : "—";
|
|
81
|
+
const prefer = entry?.prefer?.length ? entry.prefer.join(", ") : "—";
|
|
82
|
+
const sel = i === cursor ? "❯" : " ";
|
|
83
|
+
lines.push(`${sel} ${shape.padEnd(10)}${`${floor}${marker}`.padEnd(10)}${pin.padEnd(20)}${prefer}`);
|
|
84
|
+
}
|
|
85
|
+
lines.push("* integrity set: never below frontier");
|
|
86
|
+
lines.push(`pick: ${SHAPES[cursor]} — enter opens the candidate inspector (read-only)`);
|
|
87
|
+
if (inspector) {
|
|
88
|
+
lines.push(`─ candidates: ${inspector.shape} — the fleet picker ranking for the same inputs ──`);
|
|
89
|
+
if (inspector.rows.length === 0)
|
|
90
|
+
lines.push(" (no routable candidates)");
|
|
91
|
+
for (const [j, row] of inspector.rows.entries()) {
|
|
92
|
+
lines.push(`${j === 0 ? "❯ " : " "}${row}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return lines;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|