cohorte 2.9.0 → 2.10.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/CHANGELOG.md +25 -0
- package/README.md +37 -45
- package/bin/cli.js +8 -41
- package/bin/report.js +2 -2
- package/core/commands/cohorte-fleet.md +2 -3
- package/core/commands/cohorte-ship.md +2 -2
- package/core/hooks/gate.py +1 -1
- package/core/workflows/review.js +1 -1
- package/{dashboard/server → lib}/doctor.js +3 -2
- package/{dashboard/server → lib}/runtime.js +1 -1
- package/{dashboard/server → lib}/versions.js +2 -2
- package/package.json +3 -8
- package/profile/SCHEMA.md +5 -5
- package/scripts/{test-dashboard.mjs → test-lib.mjs} +12 -229
- package/scripts/validate-core.mjs +3 -18
- package/dashboard/README.md +0 -71
- package/dashboard/dist/apple-touch-icon-180.png +0 -0
- package/dashboard/dist/assets/index-BZ_LQlEj.css +0 -1
- package/dashboard/dist/assets/index-vtFc6Gyc.js +0 -43
- package/dashboard/dist/favicon-16.png +0 -0
- package/dashboard/dist/favicon-32.png +0 -0
- package/dashboard/dist/favicon-48.png +0 -0
- package/dashboard/dist/icon-192.png +0 -0
- package/dashboard/dist/icon-512.png +0 -0
- package/dashboard/dist/index.html +0 -16
- package/dashboard/server/fleet.js +0 -133
- package/dashboard/server/index.js +0 -408
- package/dashboard/server/kanban.js +0 -169
- package/dashboard/server/metrics.js +0 -120
- package/dashboard/server/usage.js +0 -61
- /package/{dashboard/server → lib}/yaml.js +0 -0
|
@@ -1,29 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Tests for the
|
|
2
|
+
// Tests for the shared readers in lib/.
|
|
3
3
|
//
|
|
4
|
-
// These are shipped runtime code
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// against a web page driving the local agent.
|
|
4
|
+
// These are shipped runtime code behind `cohorte doctor` and `cohorte specs`
|
|
5
|
+
// (and the Francois panels that call them): a hand-rolled YAML parser every
|
|
6
|
+
// check derives from, the JS port of /cohorte-doctor, and the runtime-layout
|
|
7
|
+
// resolver that decides where a given coding agent keeps its core.
|
|
9
8
|
//
|
|
10
|
-
// node scripts/test-
|
|
9
|
+
// node scripts/test-lib.mjs
|
|
11
10
|
|
|
12
11
|
import { createRequire } from "node:module";
|
|
13
12
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
|
|
14
13
|
import { tmpdir } from "node:os";
|
|
15
14
|
import { join } from "node:path";
|
|
16
15
|
import { fileURLToPath } from "node:url";
|
|
17
|
-
import net from "node:net";
|
|
18
16
|
|
|
19
17
|
const require = createRequire(import.meta.url);
|
|
20
18
|
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
21
|
-
const { parse, parseProfileBlock } = require(join(root, "
|
|
22
|
-
const {
|
|
23
|
-
const { usage } = require(join(root, "dashboard/server/usage.js"));
|
|
24
|
-
const { state, scanSpecs } = require(join(root, "dashboard/server/doctor.js"));
|
|
25
|
-
const { kanban } = require(join(root, "dashboard/server/kanban.js"));
|
|
26
|
-
const fleet = require(join(root, "dashboard/server/fleet.js"));
|
|
19
|
+
const { parse, parseProfileBlock } = require(join(root, "lib/yaml.js"));
|
|
20
|
+
const { state, scanSpecs } = require(join(root, "lib/doctor.js"));
|
|
27
21
|
|
|
28
22
|
let failures = 0;
|
|
29
23
|
const check = (name, cond, detail = "") => {
|
|
@@ -34,7 +28,7 @@ const eq = (name, got, want) =>
|
|
|
34
28
|
check(name, JSON.stringify(got) === JSON.stringify(want), `got ${JSON.stringify(got)}`);
|
|
35
29
|
|
|
36
30
|
const tmps = [];
|
|
37
|
-
const scratch = () => { const d = mkdtempSync(join(tmpdir(), "
|
|
31
|
+
const scratch = () => { const d = mkdtempSync(join(tmpdir(), "cohorte-lib-")); tmps.push(d); return d; };
|
|
38
32
|
const write = (p, s) => { mkdirSync(join(p, ".."), { recursive: true }); writeFileSync(p, s); };
|
|
39
33
|
|
|
40
34
|
// ── yaml.js ──────────────────────────────────────────────────────────────────
|
|
@@ -73,59 +67,6 @@ console.log("yaml.js — the profile parser");
|
|
|
73
67
|
eq("…a chained migrate command survives", p.commands.migrate, "cd apps/api && node ace migration:run");
|
|
74
68
|
}
|
|
75
69
|
|
|
76
|
-
// ── metrics.js ───────────────────────────────────────────────────────────────
|
|
77
|
-
console.log("metrics.js — the funnel aggregate");
|
|
78
|
-
{
|
|
79
|
-
const d = scratch();
|
|
80
|
-
const lines = [
|
|
81
|
-
JSON.stringify({ ts: "2026-01-01T00:00:00Z", feature: "f1", phase: "build", seconds: 100, surfaces: { backend: "ok", frontend: "error" } }),
|
|
82
|
-
JSON.stringify({ ts: "2026-01-01T01:00:00Z", feature: "f1", phase: "review", seconds: 50, surfaces: { backend: "REVISE:2" } }),
|
|
83
|
-
JSON.stringify({ ts: "2026-01-01T02:00:00Z", feature: "f1", phase: "fix", seconds: 20, surfaces: { backend: "ok" } }),
|
|
84
|
-
JSON.stringify({ ts: "2026-01-01T03:00:00Z", feature: "f1", phase: "cycle", seconds: 0, rounds: 3, smoke: "SKIPPED", surfaces: { backend: "SHIP:0" } }),
|
|
85
|
-
// legacy: one line PER surface, folded into one batch, wall-clock = max
|
|
86
|
-
JSON.stringify({ ts: "2026-01-02T00:00:00Z", feature: "f2", phase: "build", surface: "backend", seconds: 10, result: "ok" }),
|
|
87
|
-
JSON.stringify({ ts: "2026-01-02T00:00:00Z", feature: "f2", phase: "build", surface: "frontend", seconds: 40, result: "ok" }),
|
|
88
|
-
"not json at all",
|
|
89
|
-
JSON.stringify({ ts: "x", feature: "f3" }), // no phase ⇒ skipped
|
|
90
|
-
JSON.stringify({ ts: "x", feature: "f3", phase: "build" }), // neither surfaces nor surface ⇒ skipped
|
|
91
|
-
];
|
|
92
|
-
mkdirSync(join(d, ".claude"), { recursive: true });
|
|
93
|
-
writeFileSync(join(d, ".claude", "pipeline-metrics.jsonl"), lines.join("\n") + "\n");
|
|
94
|
-
const m = metrics({ projectRoot: d });
|
|
95
|
-
|
|
96
|
-
eq("malformed + incomplete lines are skipped", m.batches, 5);
|
|
97
|
-
const f1 = m.features.find(f => f.feature === "f1");
|
|
98
|
-
const f2 = m.features.find(f => f.feature === "f2");
|
|
99
|
-
eq("per-phase wall-clock", f1.phases.build.seconds, 100);
|
|
100
|
-
eq("fix rounds counted", f1.fixRounds, 1);
|
|
101
|
-
eq("cycle rounds surface outside `surfaces`", f1.cycleRounds, 3);
|
|
102
|
-
eq("legacy lines fold into ONE batch", Object.keys(f2.surfaces).sort(), ["backend", "frontend"]);
|
|
103
|
-
eq("…with wall-clock = the slowest surface", f2.phases.build.seconds, 40);
|
|
104
|
-
eq("`error` counts as a surface failure", f1.surfaces.frontend.failures, 1);
|
|
105
|
-
eq("a REVISE verdict counts as a failure", f1.surfaces.backend.failures, 1);
|
|
106
|
-
check("newest feature first", m.features[0].feature === "f2", m.features[0].feature);
|
|
107
|
-
check("no metrics file ⇒ present:false", metrics({ projectRoot: scratch() }).present === false);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// ── usage.js ─────────────────────────────────────────────────────────────────
|
|
111
|
-
// Wraps the ESM metrics collector for the CJS server. The failure that matters is
|
|
112
|
-
// not a crash: a project with no transcripts must say so, because rendering zeros
|
|
113
|
-
// reads as "this pipeline costs nothing" rather than "nothing was measured".
|
|
114
|
-
console.log("usage.js — the collector bridge");
|
|
115
|
-
{
|
|
116
|
-
const empty = usage({ projectRoot: scratch() });
|
|
117
|
-
check("a project with no transcripts reports present:false", empty.present === false);
|
|
118
|
-
check("…and says why rather than returning silent zeros",
|
|
119
|
-
typeof empty.error === "string" && empty.error.length > 0, JSON.stringify(empty));
|
|
120
|
-
|
|
121
|
-
// Same project twice: the second call must come from cache, or the panel's polling
|
|
122
|
-
// would re-parse tens of MB of transcripts on every refresh.
|
|
123
|
-
const d = scratch();
|
|
124
|
-
const t0 = Date.now(); usage({ projectRoot: d });
|
|
125
|
-
const t1 = Date.now(); usage({ projectRoot: d }); const cached = Date.now() - t1;
|
|
126
|
-
check("a repeated read is served from cache", cached <= Math.max(50, (t1 - t0)), `${cached}ms`);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
70
|
// ── doctor.js ────────────────────────────────────────────────────────────────
|
|
130
71
|
console.log("doctor.js — the /cohorte-doctor port");
|
|
131
72
|
{
|
|
@@ -259,149 +200,6 @@ console.log("doctor.js — the /cohorte-doctor port");
|
|
|
259
200
|
eq("no core ⇒ core bad", by(s.checks, "core").status, "bad");
|
|
260
201
|
}
|
|
261
202
|
|
|
262
|
-
// ── kanban.js ────────────────────────────────────────────────────────────────
|
|
263
|
-
console.log("kanban.js — the Obsidian board");
|
|
264
|
-
{
|
|
265
|
-
const g = scratch(), vault = scratch(), d = scratch();
|
|
266
|
-
writeFileSync(join(d, "PIPELINE.md"), "```yaml pipeline-profile\nname: Proj\n```");
|
|
267
|
-
mkdirSync(join(vault, "Proj"), { recursive: true });
|
|
268
|
-
writeFileSync(join(vault, "Proj", "Tasks.md"), [
|
|
269
|
-
"---", "kanban-plugin: board", "---", "",
|
|
270
|
-
"## Spec", "", "- [ ] Do a thing #feat-a", "\t- a note", "",
|
|
271
|
-
"## Shipped", "", "- [x] Old #feat-z — PR #42", "",
|
|
272
|
-
"%% kanban:settings", "%%", "",
|
|
273
|
-
].join("\n"));
|
|
274
|
-
writeFileSync(join(g, "cohorte.config.yaml"), [
|
|
275
|
-
"kanban:", " enabled: true", " boards:", " Proj:", ' board: "Proj/Tasks.md"',
|
|
276
|
-
"obsidian:", ` vault_path: "${vault.replace(/\\/g, "/")}"`,
|
|
277
|
-
].join("\n"));
|
|
278
|
-
|
|
279
|
-
const k = kanban({ projectRoot: d, globalDir: g });
|
|
280
|
-
check("board resolves for the profile name", k.enabled === true, k.reason);
|
|
281
|
-
eq("columns parsed", k.columns.map(c => c.name), ["Spec", "Shipped"]);
|
|
282
|
-
eq("cards counted", k.total, 2);
|
|
283
|
-
eq("the #tag is extracted", k.columns[0].cards[0].tags, ["feat-a"]);
|
|
284
|
-
eq("the tag is stripped from the display text", k.columns[0].cards[0].text, "Do a thing");
|
|
285
|
-
eq("a checked card is done", k.columns[1].cards[0].done, true);
|
|
286
|
-
eq("a bare #<num> is read as a PR reference", k.columns[1].cards[0].prs.map(p => p.num), ["42"]);
|
|
287
|
-
check("the settings trailer is not parsed as a column",
|
|
288
|
-
!k.columns.some(c => /kanban:settings/.test(c.name)));
|
|
289
|
-
|
|
290
|
-
writeFileSync(join(g, "cohorte.config.yaml"), "kanban:\n enabled: false\n");
|
|
291
|
-
check("kanban disabled ⇒ enabled:false with a reason",
|
|
292
|
-
kanban({ projectRoot: d, globalDir: g }).enabled === false);
|
|
293
|
-
check("no config at all ⇒ enabled:false, never a throw",
|
|
294
|
-
kanban({ projectRoot: d, globalDir: scratch() }).enabled === false);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// ── fleet.js ─────────────────────────────────────────────────────────────────
|
|
298
|
-
console.log("fleet.js — the project registry");
|
|
299
|
-
{
|
|
300
|
-
const g = scratch(), p1 = scratch(), p2 = scratch();
|
|
301
|
-
fleet.ensureSeed(g, p1);
|
|
302
|
-
eq("seed adds the launch project", fleet.read(g), [p1]);
|
|
303
|
-
fleet.ensureSeed(g, p1);
|
|
304
|
-
eq("seeding twice does not duplicate", fleet.read(g).length, 1);
|
|
305
|
-
fleet.add(g, p2);
|
|
306
|
-
eq("add appends", fleet.read(g).length, 2);
|
|
307
|
-
fleet.remove(g, p2);
|
|
308
|
-
eq("remove drops it", fleet.read(g), [p1]);
|
|
309
|
-
|
|
310
|
-
let threw = null;
|
|
311
|
-
try { fleet.add(g, "relative/path"); } catch (e) { threw = e.message; }
|
|
312
|
-
check("a relative path is rejected with a clear message",
|
|
313
|
-
/must be absolute/.test(threw || ""), threw);
|
|
314
|
-
threw = null;
|
|
315
|
-
try { fleet.add(g, join(p1, "nope")); } catch (e) { threw = e.message; }
|
|
316
|
-
check("a non-existent path is rejected", /not found/.test(threw || ""), threw);
|
|
317
|
-
|
|
318
|
-
// legacy registry name is read, then migrated forward on the next write
|
|
319
|
-
const g2 = scratch();
|
|
320
|
-
writeFileSync(join(g2, "thebidouille-dashboard.json"), JSON.stringify({ projects: [p1] }));
|
|
321
|
-
eq("the pre-rename registry is still read", fleet.read(g2), [p1]);
|
|
322
|
-
|
|
323
|
-
const b = fleet.browse(p1);
|
|
324
|
-
check("browse lists a directory", Array.isArray(b.dirs) && b.parent !== null);
|
|
325
|
-
writeFileSync(join(p1, "PIPELINE.md"), "x");
|
|
326
|
-
check("browse flags a pipeline project", fleet.browse(p1).isProject === true);
|
|
327
|
-
const missing = fleet.browse(join(p1, "does-not-exist"));
|
|
328
|
-
check("browse reports an unreadable dir in the body (not a throw)",
|
|
329
|
-
!!missing.error && missing.dirs.length === 0, JSON.stringify(missing));
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// ── index.js — the HTTP guards ───────────────────────────────────────────────
|
|
333
|
-
console.log("index.js — HTTP guards");
|
|
334
|
-
{
|
|
335
|
-
const freePort = await new Promise((res) => {
|
|
336
|
-
const s = net.createServer();
|
|
337
|
-
s.listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => res(port)); });
|
|
338
|
-
});
|
|
339
|
-
const home = scratch(); // stands in for the user's home
|
|
340
|
-
const globalDir = join(home, ".claude"); // …so <home>/.claude IS the global core
|
|
341
|
-
mkdirSync(globalDir, { recursive: true });
|
|
342
|
-
const proj = scratch();
|
|
343
|
-
const start = require(join(root, "dashboard/server/index.js"));
|
|
344
|
-
start({ projectRoot: proj, globalDir, port: freePort, host: "127.0.0.1", openBrowser: false, pkgRoot: root, version: "9.9.9" });
|
|
345
|
-
const base = `http://127.0.0.1:${freePort}`;
|
|
346
|
-
const post = (body, headers = { "content-type": "application/json" }) =>
|
|
347
|
-
fetch(`${base}/api/action`, { method: "POST", headers, body: JSON.stringify(body) });
|
|
348
|
-
|
|
349
|
-
// `Host` is a forbidden header name for fetch/undici — it silently drops it, so
|
|
350
|
-
// a fetch-based assertion here passes against a server with NO guard at all.
|
|
351
|
-
// Speak raw HTTP instead.
|
|
352
|
-
const rawStatus = (host) => new Promise((res, rej) => {
|
|
353
|
-
const s = net.connect(freePort, "127.0.0.1", () => {
|
|
354
|
-
s.write(`GET /api/fleet HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`);
|
|
355
|
-
});
|
|
356
|
-
let buf = "";
|
|
357
|
-
s.on("data", (c) => { buf += c; });
|
|
358
|
-
s.on("end", () => res(Number((buf.match(/^HTTP\/1\.1 (\d+)/) || [])[1])));
|
|
359
|
-
s.on("error", rej);
|
|
360
|
-
});
|
|
361
|
-
eq("a forged Host header is rejected (DNS rebinding)", await rawStatus("evil.example.com"), 403);
|
|
362
|
-
eq("…while a loopback Host with a port passes", await rawStatus(`127.0.0.1:${freePort}`), 200);
|
|
363
|
-
eq("…and a bracketed IPv6 loopback passes", await rawStatus(`[::1]:${freePort}`), 200);
|
|
364
|
-
eq("…and bare 'localhost' passes", await rawStatus("localhost"), 200);
|
|
365
|
-
|
|
366
|
-
const csrf = await fetch(`${base}/api/projects`, {
|
|
367
|
-
method: "POST", headers: { "content-type": "text/plain" }, body: "path=/x",
|
|
368
|
-
});
|
|
369
|
-
eq("a state-changing request without JSON content-type is rejected (CSRF)", csrf.status, 403);
|
|
370
|
-
|
|
371
|
-
eq("a GET on the API still works", (await fetch(`${base}/api/fleet`)).status, 200);
|
|
372
|
-
|
|
373
|
-
const reset = await post({ action: "reset", project: home });
|
|
374
|
-
eq("reset refuses a project whose .claude IS the global core", reset.status, 400);
|
|
375
|
-
check("…and says why", /shared global core/.test((await reset.json()).error));
|
|
376
|
-
|
|
377
|
-
const badPath = await post({ action: "install", project: join(proj, "nope") });
|
|
378
|
-
eq("install refuses a non-existent project path", badPath.status, 400);
|
|
379
|
-
|
|
380
|
-
const badAction = await post({ action: "rm -rf" });
|
|
381
|
-
eq("an unknown action is rejected", badAction.status, 400);
|
|
382
|
-
|
|
383
|
-
const badCmd = await post({ action: "claude", command: "/evil", project: proj });
|
|
384
|
-
eq("a non-whitelisted slash command is rejected", badCmd.status, 400);
|
|
385
|
-
|
|
386
|
-
// Both directions, because testing only the rejection missed a real bug: 2.0.0 prefixed
|
|
387
|
-
// every command, the error message was updated to say `/cohorte-audit`, but the allowlist
|
|
388
|
-
// regex still matched the bare names — so the server accepted the one command that no
|
|
389
|
-
// longer exists and rejected the only one the UI can send. A rejection-only test is blind
|
|
390
|
-
// to an allowlist that drifts away from the client.
|
|
391
|
-
const staleCmd = await post({ action: "claude", command: "/audit", project: proj });
|
|
392
|
-
eq("the pre-2.0.0 unprefixed command is rejected", staleCmd.status, 400);
|
|
393
|
-
|
|
394
|
-
const goodCmd = await post({ action: "claude", command: "/cohorte-audit", project: proj });
|
|
395
|
-
check("a prefixed whitelisted command passes the allowlist",
|
|
396
|
-
goodCmd.status !== 400 || !/unsupported command/.test((await goodCmd.json()).error || ""));
|
|
397
|
-
|
|
398
|
-
eq("a missing hashed asset 404s (never index.html)",
|
|
399
|
-
(await fetch(`${base}/assets/index-DEADBEEF.js`)).status, 404);
|
|
400
|
-
eq("a malformed percent-escape is a 400, not a 500",
|
|
401
|
-
(await fetch(`${base}/%`)).status, 400);
|
|
402
|
-
eq("an unknown API route 404s", (await fetch(`${base}/api/nope`)).status, 404);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
203
|
// ── runtime.js + a non-Claude layout ────────────────────────────────────────
|
|
406
204
|
// Every path-dependent check used to assume `.claude/`. On a repo driven from Cursor that
|
|
407
205
|
// reported a healthy install as three ❌ and a ⚠️ — no core, no rendered agent, artifacts not
|
|
@@ -463,20 +261,6 @@ console.log("doctor.js — a non-Claude runtime layout");
|
|
|
463
261
|
st("workflows") === "skip" && /Cursor/.test(dt("workflows")), dt("workflows"));
|
|
464
262
|
check("nothing is reported broken on a healthy non-Claude install",
|
|
465
263
|
s.summary.bad === 0 && s.summary.warn === 0, JSON.stringify(s.summary));
|
|
466
|
-
|
|
467
|
-
// The metrics sink follows `<state>` too — and workflow-stamped `tokens` aggregate
|
|
468
|
-
// per feature/phase while token-less conversational lines read as 0, not NaN.
|
|
469
|
-
writeFileSync(join(d, ".cohorte", "pipeline-metrics.jsonl"),
|
|
470
|
-
JSON.stringify({ ts: "2026-01-01T00:00:00Z", feature: "f", phase: "build", seconds: 10,
|
|
471
|
-
tokens: 12000, surfaces: { api: "ok" } }) + "\n" +
|
|
472
|
-
JSON.stringify({ ts: "2026-01-01T01:00:00Z", feature: "f", phase: "review", seconds: 5,
|
|
473
|
-
surfaces: { api: "SHIP:0" } }) + "\n");
|
|
474
|
-
const m = metrics({ projectRoot: d, globalDir: g });
|
|
475
|
-
check("metrics are read from the runtime's state dir", m.batches === 2);
|
|
476
|
-
check("workflow tokens aggregate; token-less lines count as 0",
|
|
477
|
-
m.features[0].totalTokens === 12000 && m.features[0].phases.build.tokens === 12000
|
|
478
|
-
&& m.features[0].phases.review.tokens === 0,
|
|
479
|
-
JSON.stringify(m.features[0] && { t: m.features[0].totalTokens, p: m.features[0].phases }));
|
|
480
264
|
}
|
|
481
265
|
|
|
482
266
|
// ── runtime.js — stale absolute registry paths (a cloned/moved bundled core) ─
|
|
@@ -485,7 +269,7 @@ console.log("doctor.js — a non-Claude runtime layout");
|
|
|
485
269
|
// taken verbatim, every check went red on a healthy install.
|
|
486
270
|
console.log("runtime.js — registry paths survive a clone/move");
|
|
487
271
|
{
|
|
488
|
-
const { layouts } = require(join(root, "
|
|
272
|
+
const { layouts } = require(join(root, "lib/runtime.js"));
|
|
489
273
|
const d = scratch();
|
|
490
274
|
const core = join(d, ".claude");
|
|
491
275
|
mkdirSync(join(core, "pipeline"), { recursive: true });
|
|
@@ -522,6 +306,5 @@ console.log("runtime.js — registry paths survive a clone/move");
|
|
|
522
306
|
|
|
523
307
|
for (const d of tmps) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
524
308
|
console.log("");
|
|
525
|
-
if (failures) { console.error(`test-
|
|
526
|
-
console.log("test-
|
|
527
|
-
process.exit(0); // the HTTP server has no handle to close
|
|
309
|
+
if (failures) { console.error(`test-lib: ${failures} failure(s)`); process.exit(1); }
|
|
310
|
+
console.log("test-lib: OK");
|
|
@@ -273,12 +273,12 @@ const workflowNames = existsSync(workflowsDir)
|
|
|
273
273
|
? readdirSync(workflowsDir).filter((f) => f.endsWith(".js"))
|
|
274
274
|
: [];
|
|
275
275
|
const ci = existsSync(join(root, ".github/workflows/ci.yml")) ? read(".github/workflows/ci.yml") : "";
|
|
276
|
-
const
|
|
276
|
+
const libDoctor = read("lib/doctor.js");
|
|
277
277
|
for (const f of workflowNames) {
|
|
278
278
|
if (!ci.includes(`workflows/${f}`))
|
|
279
279
|
fail(".github/workflows/ci.yml", `install dry-run never asserts .claude/workflows/${f}`);
|
|
280
|
-
if (!
|
|
281
|
-
fail("
|
|
280
|
+
if (!libDoctor.includes(`'${f}'`))
|
|
281
|
+
fail("lib/doctor.js", `checkWorkflows() does not list ${f}`);
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
// ── every test suite must run in BOTH workflows ──────────────────────────────
|
|
@@ -298,21 +298,6 @@ for (const f of readdirSync(join(root, "scripts")).filter((f) => /^test-.*\.mjs$
|
|
|
298
298
|
fail(".github/workflows/publish.yml", `never runs scripts/${f} — publish would ship past a failure that gate is meant to catch`);
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
-
// ── dashboard: the metrics phase list is duplicated server/client ────────────
|
|
302
|
-
// A phase present in one and not the other parses fine and renders in no column —
|
|
303
|
-
// silently invisible data, which is how a phase batch once went unnoticed.
|
|
304
|
-
const phaseList = (text, file) => {
|
|
305
|
-
const m = text.match(/const PHASES = \[([^\]]*)\]/);
|
|
306
|
-
if (!m) { fail(file, "no `const PHASES = [...]` found"); return null; }
|
|
307
|
-
return m[1].split(",").map((s) => s.trim().replace(/^['"]|['"]$/g, "")).filter(Boolean);
|
|
308
|
-
};
|
|
309
|
-
const serverPhases = phaseList(read("dashboard/server/metrics.js"), "dashboard/server/metrics.js");
|
|
310
|
-
const clientPhases = phaseList(read("dashboard/app/src/components/MetricsPanel.jsx"),
|
|
311
|
-
"dashboard/app/src/components/MetricsPanel.jsx");
|
|
312
|
-
if (serverPhases && clientPhases && serverPhases.join("|") !== clientPhases.join("|"))
|
|
313
|
-
fail("dashboard/app/src/components/MetricsPanel.jsx",
|
|
314
|
-
`PHASES drifted from dashboard/server/metrics.js ([${clientPhases}] vs [${serverPhases}])`);
|
|
315
|
-
|
|
316
301
|
// ── packaging: no build artifacts in the published tarball ──────────────────
|
|
317
302
|
// `.npmignore` is INERT under an explicit package.json `files` allowlist, so its
|
|
318
303
|
// `__pycache__/` rule never fired — a maintainer who had compiled gate.py shipped
|
package/dashboard/README.md
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
# Dashboard — architecture
|
|
2
|
-
|
|
3
|
-
A local web cockpit for the pipeline, launched with `cohorte dashboard`
|
|
4
|
-
(see the [root README](../README.md#dashboard--a-local-web-cockpit) for user-facing docs).
|
|
5
|
-
|
|
6
|
-
## Two halves: shipped runtime vs dev build
|
|
7
|
-
|
|
8
|
-
```
|
|
9
|
-
dashboard/
|
|
10
|
-
server/ → RUNTIME, dependency-free (node built-ins only). Shipped in the npm package.
|
|
11
|
-
app/ → DEV source: Vite + React. NOT shipped (see ../.npmignore-style dashboard/.npmignore).
|
|
12
|
-
dist/ → app/ built output. Shipped, served by server/. Git-ignored, rebuilt at publish.
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
- **`server/`** is plain node (`http`, `fs`, `child_process`) — no deps, so `cohorte dashboard`
|
|
16
|
-
needs no install. It serves `dist/` as static files + a small JSON/stream API.
|
|
17
|
-
- **`app/`** is a Vite+React app built to `dist/`. `npm run build:dashboard` (root) runs
|
|
18
|
-
`npm --prefix dashboard/app ci && … run build`; CI does this before `npm pack`/`publish`
|
|
19
|
-
(`.github/workflows/publish.yml`), and `dashboard/.npmignore` lets the git-ignored `dist/` ship.
|
|
20
|
-
|
|
21
|
-
## Server modules (`server/`)
|
|
22
|
-
|
|
23
|
-
| File | Responsibility |
|
|
24
|
-
| --- | --- |
|
|
25
|
-
| `index.js` | HTTP server, routing, static serving (SPA fallback), streamed actions, `--host`/bind |
|
|
26
|
-
| `versions.js` | installed core vs npm latest (registry fetch → `npm view` fallback, 5-min cache) |
|
|
27
|
-
| `doctor.js` | the `/cohorte-doctor` checks reimplemented in JS → `/api/state` (profile, agents, gate, hooks, …) |
|
|
28
|
-
| `yaml.js` | minimal block-YAML subset parser (for the `pipeline-profile` block + the config) |
|
|
29
|
-
| `fleet.js` | tracked-project registry (`~/.claude/cohorte-dashboard.json`) + folder browse |
|
|
30
|
-
| `kanban.js` | linked Obsidian board → columns/cards; PR enrichment + ship-date sort via `gh` |
|
|
31
|
-
| `metrics.js` | `.claude/pipeline-metrics.jsonl` → per-feature phase/surface aggregate |
|
|
32
|
-
|
|
33
|
-
## API
|
|
34
|
-
|
|
35
|
-
Read: `GET /api/versions`, `/api/state?project=`, `/api/fleet`, `/api/browse?dir=`,
|
|
36
|
-
`/api/kanban?project=`, `/api/metrics?project=`. Mutate: `POST /api/projects` (add) ·
|
|
37
|
-
`DELETE /api/projects` (remove);
|
|
38
|
-
`POST /api/action` — `{action:'install'|'update', scope, project}` (spawns the CLI),
|
|
39
|
-
`{action:'reset', project, purgeSpecs}` (backup+wipe+reinstall), or
|
|
40
|
-
`{action:'claude', command:'/cohorte-init-pipeline'|'/cohorte-update-pipeline'|'/cohorte-audit', project}` (headless
|
|
41
|
-
`claude -p`).
|
|
42
|
-
Action responses stream chunked plain text ending in `__EXIT__ <code>`; the client reads the
|
|
43
|
-
`ReadableStream` (`app/src/api.js` `streamAction`).
|
|
44
|
-
|
|
45
|
-
## Security
|
|
46
|
-
|
|
47
|
-
Binds `127.0.0.1` by default — the action endpoints **execute code**. `--host=ADDR` opts into
|
|
48
|
-
exposing it (prints a warning).
|
|
49
|
-
|
|
50
|
-
Loopback binding is **not** a boundary against a browser: any page the user visits can fire
|
|
51
|
-
requests at `127.0.0.1`, and DNS rebinding can make the responses readable. `guardBrowser()` in
|
|
52
|
-
`index.js` closes both on every `/api/` route, without a token round-trip:
|
|
53
|
-
|
|
54
|
-
- **Host must be a loopback origin** — kills rebinding (an attacker domain resolving to
|
|
55
|
-
`127.0.0.1` still sends its own `Host`). Skipped when the user bound a non-loopback host.
|
|
56
|
-
- **State-changing methods must send `content-type: application/json`** — that header triggers a
|
|
57
|
-
CORS preflight this server never answers, so a browser cannot deliver it cross-origin; forms
|
|
58
|
-
can only send urlencoded/multipart/text.
|
|
59
|
-
|
|
60
|
-
CORS response headers are never set, so a cross-origin page can fire a GET but cannot read it. If
|
|
61
|
-
a hosted-frontend model is ever added, lock CORS to the exact frontend origin (never `*`).
|
|
62
|
-
|
|
63
|
-
`runReset()` additionally refuses a project whose `.claude` resolves to the shared global core —
|
|
64
|
-
the endpoint's whole promise is that `~/.claude` is never touched, and nothing else enforced it.
|
|
65
|
-
|
|
66
|
-
## Dev loop
|
|
67
|
-
|
|
68
|
-
```sh
|
|
69
|
-
node bin/cli.js dashboard # terminal 1: the node API on :4317
|
|
70
|
-
npm --prefix dashboard/app run dev # terminal 2: Vite on :4318, proxies /api → :4317
|
|
71
|
-
```
|
|
Binary file
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
:root{--bg: #0e0f13;--panel: #16181f;--panel-2: #1c1f28;--border: #262a35;--text: #e6e8ee;--muted: #8b90a0;--accent: #6ea8fe;--ok: #3fb950;--warn: #d9a441;--bad: #f85149;--dev: #a371f7;--mono: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:var(--mono);font-size:14px}.app{min-height:100vh}.topbar{display:flex;align-items:center;justify-content:space-between;padding:12px 20px;border-bottom:1px solid var(--border);position:sticky;top:0;z-index:20;background:#0e0f13e6;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}.brand{font-weight:600;letter-spacing:.2px;display:flex;align-items:center;gap:8px}.dot{width:9px;height:9px;border-radius:50%;background:var(--accent);box-shadow:0 0 10px var(--accent)}.muted{color:var(--muted)}.small{font-size:12px}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-items:start;gap:16px;padding:20px;max-width:1100px;margin:0 auto}@media(max-width:720px){.grid{grid-template-columns:1fr}}.panel{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:16px 18px}.panel h2{margin:0 0 12px;font-size:13px;text-transform:uppercase;letter-spacing:1px;color:var(--muted)}.panel.placeholder{opacity:.7}.panel.error{grid-column:1 / -1;border-color:var(--bad);color:var(--bad)}.panel-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.panel-head h2{margin:0}.badge{font-size:11px;padding:3px 9px;border-radius:999px;border:1px solid var(--border);white-space:nowrap}.badge.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 40%,transparent);background:color-mix(in srgb,var(--ok) 12%,transparent)}.badge.warn{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 40%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)}.badge.bad{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 40%,transparent);background:color-mix(in srgb,var(--bad) 12%,transparent)}.badge.dev{color:var(--dev);border-color:color-mix(in srgb,var(--dev) 40%,transparent);background:color-mix(in srgb,var(--dev) 12%,transparent)}.badge.neutral{color:var(--muted)}.rows{display:flex;flex-direction:column;gap:2px}.row{display:flex;justify-content:space-between;gap:12px;padding:6px 0;border-bottom:1px dashed var(--border)}.row:last-child{border-bottom:none}.row-label{color:var(--muted)}.row-value{text-align:right}.row-value.strong{font-weight:600}.row-value.mono{font-family:var(--mono);font-size:12px}.actions{display:flex;align-items:center;gap:10px;margin-top:16px}.fresh-hint{margin:14px 0 0;padding-top:12px;border-top:1px dashed var(--border)}.fresh-hint strong{color:var(--accent)}button{font-family:var(--mono);font-size:13px;padding:7px 12px;border-radius:8px;border:1px solid var(--border);background:var(--panel-2);color:var(--text);cursor:pointer}button:disabled{opacity:.4;cursor:not-allowed}button.primary{background:color-mix(in srgb,var(--accent) 22%,var(--panel-2));border-color:color-mix(in srgb,var(--accent) 40%,transparent)}button.ghost{background:transparent}button:not(:disabled):hover{border-color:var(--accent)}.span2{grid-column:span 2}@media(max-width:720px){.span2{grid-column:1 / -1}}.summary{display:flex;gap:6px;flex-wrap:wrap}.checks{list-style:none;margin:0;padding:0;display:flex;flex-direction:column}.check{display:flex;gap:10px;padding:9px 0;border-bottom:1px solid var(--border)}.check:last-child{border-bottom:none}.check.skip{opacity:.55}.check-icon{flex:none;width:20px;height:20px;border-radius:6px;display:grid;place-items:center;font-size:12px;font-weight:700;margin-top:1px}.check-icon.ok{color:var(--ok);background:color-mix(in srgb,var(--ok) 15%,transparent)}.check-icon.warn{color:var(--warn);background:color-mix(in srgb,var(--warn) 15%,transparent)}.check-icon.bad{color:var(--bad);background:color-mix(in srgb,var(--bad) 15%,transparent)}.check-icon.skip{color:var(--muted);background:var(--panel-2)}.check-body{flex:1;min-width:0}.check-line{display:flex;gap:10px;justify-content:space-between;flex-wrap:wrap}.check-label{font-weight:600}.check-detail{color:var(--muted);text-align:right}.check-fix{margin-top:4px;font-size:12px;color:var(--muted)}.check-fix code{color:var(--accent);background:var(--panel-2);padding:1px 6px;border-radius:5px}.surfaces{display:flex;flex-direction:column;gap:10px}.surface{border:1px solid var(--border);border-radius:9px;padding:10px 12px;background:var(--panel-2)}.surface-top{display:flex;align-items:center;gap:8px}.surface-key{font-weight:600}.surface-meta{display:flex;justify-content:space-between;gap:10px;margin-top:4px;font-size:12px}.surface-tools{display:flex;flex-wrap:wrap;gap:4px;margin-top:8px}.tool{font-size:11px;color:var(--muted);border:1px solid var(--border);border-radius:5px;padding:1px 6px}.chip{font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--border)}.chip.design{color:var(--dev);border-color:color-mix(in srgb,var(--dev) 40%,transparent)}.chip.model-sonnet{color:var(--accent)}.chip.model-haiku{color:var(--ok)}.chip.model-inherit{color:var(--warn)}.fleet{max-width:1100px;margin:0 auto;padding:20px}.core-banner{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;background:linear-gradient(180deg,var(--panel-2),var(--panel));border:1px solid var(--border);border-radius:12px;padding:14px 18px;margin-bottom:20px}.cb-left{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.cb-title{text-transform:uppercase;letter-spacing:1px;font-size:12px;color:var(--muted)}.cb-version{font-weight:600;font-size:15px}.cb-actions{display:flex;gap:8px}.fleet-head{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:14px;flex-wrap:wrap}.fleet-head h2{margin:0;font-size:14px;display:flex;align-items:center;gap:8px}.add-wrap{flex:1;max-width:560px}.add-form{display:flex;gap:8px}.path-input{flex:1;font-family:var(--mono);font-size:13px;padding:7px 12px;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:8px}.path-input:focus{outline:none;border-color:var(--accent)}.path-input.invalid{border-color:var(--bad)}.add-error{margin-top:6px;font-size:12px;color:var(--bad);word-break:break-word}.fleet-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px}.project-card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:14px 16px;cursor:pointer;transition:border-color .12s,transform .12s}.project-card:hover{border-color:var(--accent);transform:translateY(-1px)}.project-card.gone{opacity:.6;cursor:default}.pc-head{display:flex;align-items:center;justify-content:space-between}.pc-name{font-weight:600}.pc-path{margin:2px 0 10px}.pc-badges{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px}.pc-health{display:flex;align-items:center;gap:5px}.pc-counts{margin-left:auto}.icon-btn{border:none;background:transparent;color:var(--muted);padding:2px 6px;border-radius:6px;font-size:13px}.icon-btn:hover{color:var(--bad);background:var(--panel-2)}.pill{font-size:11px;min-width:20px;text-align:center;padding:1px 7px;border-radius:999px;font-weight:600}.pill.ok{color:var(--ok);background:color-mix(in srgb,var(--ok) 14%,transparent)}.pill.warn{color:var(--warn);background:color-mix(in srgb,var(--warn) 14%,transparent)}.pill.bad{color:var(--bad);background:color-mix(in srgb,var(--bad) 14%,transparent)}.pill.neutral{color:var(--muted);background:var(--panel-2)}.detail-crumb{grid-column:1 / -1;margin-bottom:-4px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}.detail-tools{display:flex;gap:8px;flex-wrap:wrap}.tool-btn{font-size:12px;padding:5px 10px;border:1px solid color-mix(in srgb,var(--accent) 35%,var(--border));color:var(--accent);background:transparent}.tool-btn:hover{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent)}.confirm-text p{margin:0 0 8px;font-size:13px;line-height:1.6}.confirm-text code{background:var(--panel-2);padding:1px 5px;border-radius:4px;color:var(--accent)}.warn-line{color:var(--warn)!important;background:color-mix(in srgb,var(--warn) 10%,transparent);border-radius:8px;padding:8px 10px}.warn-line code{color:var(--text)!important}.danger-ghost{background:transparent;border:1px solid color-mix(in srgb,var(--bad) 40%,var(--border));color:var(--bad);font-size:12px;padding:5px 10px}.danger-ghost:hover{background:color-mix(in srgb,var(--bad) 12%,transparent);border-color:var(--bad)}button.danger{background:color-mix(in srgb,var(--bad) 20%,var(--panel-2));border-color:color-mix(in srgb,var(--bad) 45%,transparent);color:#ffd7d3}button.danger:hover{border-color:var(--bad)}.reset-list{margin:8px 0;padding-left:18px;font-size:13px;line-height:1.7}.reset-list code{color:var(--accent)}.reset-note{font-size:12.5px;color:var(--muted);background:var(--panel-2);border-radius:8px;padding:10px 12px}.reset-note code{color:var(--text)}.reset-check{display:flex;align-items:center;gap:8px;margin:12px 0 0;font-size:13px}.reset-check code{color:var(--accent)}.board-scroll{max-height:500px;overflow:auto}.board{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(140px,1fr);gap:10px}.col{background:var(--bg);border:1px solid var(--border);border-radius:9px;padding:8px;min-width:140px}.col-head{display:flex;justify-content:space-between;font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);padding:2px 4px 8px;position:sticky;top:0;background:var(--bg);z-index:1}.col-shipped{opacity:.85}.col-blocked{border-color:color-mix(in srgb,var(--bad) 40%,var(--border))}.col-other{border-color:color-mix(in srgb,var(--warn) 40%,var(--border))}.spec-card{background:var(--panel-2);border:1px solid var(--border);border-radius:7px;padding:8px 9px;margin-bottom:7px}.spec-card.bad{border-color:color-mix(in srgb,var(--warn) 45%,transparent)}.spec-title{font-size:13px;font-weight:600}.spec-meta,.spec-branch,.spec-loop{margin-top:3px}@media(max-width:640px){.board{grid-auto-flow:row;grid-template-columns:repeat(2,1fr)}}.kanban-board{display:flex;gap:10px}.kanban-col{flex:0 0 210px;background:var(--bg);border:1px solid var(--border);border-radius:9px;padding:8px}.kanban-col.empty{opacity:.5}.kanban-card{background:var(--panel-2);border:1px solid var(--border);border-radius:7px;padding:8px 9px;margin-bottom:7px}.kanban-card.done{opacity:.6}.kanban-card.done .kc-text{text-decoration:line-through}.kc-text{font-size:12.5px;line-height:1.4}.kc-tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px}.kc-tag{font-size:10px;color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,transparent);border-radius:4px;padding:1px 5px}.kc-pr{font-size:10px;border-radius:4px;padding:1px 5px;text-decoration:none;border:1px solid var(--border);color:var(--muted)}a.kc-pr:hover{filter:brightness(1.25)}.kc-pr.state-open{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 40%,transparent);background:color-mix(in srgb,var(--ok) 12%,transparent)}.kc-pr.state-merged{color:var(--dev);border-color:color-mix(in srgb,var(--dev) 40%,transparent);background:color-mix(in srgb,var(--dev) 12%,transparent)}.kc-pr.state-closed{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 40%,transparent);background:color-mix(in srgb,var(--bad) 12%,transparent)}.kc-pr.draft{color:var(--muted);border-color:var(--border);background:var(--panel-2)}.kc-pr.flat{border-style:dashed;cursor:default}.kc-status{font-size:10px;margin-top:5px;color:var(--muted);text-transform:capitalize}.kc-status.state-open{color:var(--ok)}.kc-status.state-merged{color:var(--dev)}.kc-status.state-closed{color:var(--bad)}.metrics-list{display:flex;flex-direction:column;gap:12px}.metric-feature{background:var(--bg);border:1px solid var(--border);border-radius:9px;padding:10px 12px}.mf-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:8px}.mf-name{font-weight:600}.mf-badges{display:flex;gap:6px;flex-wrap:wrap}.phase-bars{display:flex;flex-direction:column;gap:4px}.phase-row{display:grid;grid-template-columns:56px 1fr 90px;align-items:center;gap:10px}.phase-label{color:var(--muted);font-size:12px}.phase-track{height:12px;border-radius:4px;background:var(--panel-2);overflow:hidden}.phase-fill{display:block;height:100%;min-width:2px;border-radius:4px;background:color-mix(in srgb,var(--accent) 65%,var(--panel-2))}.phase-value{text-align:right;white-space:nowrap}.surface-table{width:100%;border-collapse:collapse;margin-top:10px;font-size:12px}.surface-table th{text-align:left;color:var(--muted);font-weight:400;text-transform:uppercase;letter-spacing:.6px;font-size:10px;padding:4px 8px 6px 0;border-bottom:1px dashed var(--border)}.surface-table td{padding:5px 8px 5px 0;border-bottom:1px dashed var(--border)}.surface-table tr:last-child td{border-bottom:none}.st-key{font-weight:600}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;background:#0009;display:grid;place-items:center;z-index:50;padding:20px}.modal{background:var(--panel);border:1px solid var(--border);border-radius:12px;width:min(720px,100%);max-height:80vh;display:flex;flex-direction:column;padding:18px}.modal-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.modal-head h3{margin:0;font-size:15px}.modal-actions{display:flex;gap:8px;margin-top:14px}.cmd{background:#000;color:var(--accent);padding:10px 12px;border-radius:8px;font-size:13px;overflow-x:auto}.run-log{background:#000;color:#d6d9e0;padding:12px;border-radius:8px;font-size:12.5px;line-height:1.5;overflow:auto;white-space:pre-wrap;word-break:break-word;flex:1;min-height:200px;max-height:55vh;margin:0}.picker{max-height:74vh}.picker-path{background:var(--panel-2);border:1px solid var(--border);border-radius:7px;padding:7px 10px;margin-bottom:10px;word-break:break-all}.picker-list{flex:1;overflow:auto;border:1px solid var(--border);border-radius:8px;padding:6px;min-height:220px;max-height:48vh}.picker-row{display:flex;align-items:center;gap:8px;width:100%;text-align:left;background:transparent;border:none;border-radius:6px;padding:7px 9px;color:var(--text);font-size:13px}.picker-row:hover{background:var(--panel-2);border-color:transparent}.picker-row.up{color:var(--muted)}.picker-icon{color:var(--muted);width:14px;flex:none}.picker-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.picker-row .badge.small{font-size:10px;padding:0 6px;margin-left:auto}.picker-empty{padding:12px}.add-form .ghost[type=button]{white-space:nowrap}.usage-list{display:flex;flex-direction:column;gap:2px}.usage-row{display:grid;grid-template-columns:minmax(96px,1.4fr) 44px 62px minmax(90px,1.6fr) 66px 52px 52px 52px;align-items:center;gap:8px;padding:3px 0;font-size:12px}.usage-head{color:var(--muted);font-size:11px;border-bottom:1px solid var(--border);padding-bottom:5px;margin-bottom:3px}.usage-cmd{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}.usage-num,.usage-bar-head{text-align:right;font-variant-numeric:tabular-nums}.usage-bar{position:relative;height:16px;border-radius:4px;background:var(--panel-2);overflow:hidden}.usage-fill{position:absolute;inset:0 auto 0 0;border-radius:4px;background:color-mix(in srgb,var(--accent) 55%,var(--panel-2))}.usage-bar-label{position:relative;display:block;padding-right:6px;text-align:right;line-height:16px;font-variant-numeric:tabular-nums}.usage-chat{color:var(--muted)}.usage-chat .usage-fill{background:var(--panel-2);border:1px solid var(--border)}@media(max-width:720px){.usage-row{grid-template-columns:1fr 40px 58px minmax(70px,1fr)}.usage-row>:nth-child(n+5){display:none}}
|