cohorte 2.8.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 +72 -0
- package/README.md +41 -46
- package/bin/cli.js +8 -41
- package/bin/report.js +2 -2
- package/core/commands/cohorte-brainstorm.md +5 -1
- package/core/commands/cohorte-fleet.md +103 -0
- package/core/commands/cohorte-intake.md +92 -0
- package/core/commands/cohorte-patch.md +6 -1
- package/core/commands/cohorte-retro.md +85 -0
- package/core/commands/cohorte-review.md +53 -1
- package/core/commands/cohorte-ship.md +2 -2
- package/core/hooks/gate.py +1 -1
- package/core/workflows/loop.js +27 -3
- package/core/workflows/review.js +13 -2
- 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 +16 -8
- package/scripts/{test-dashboard.mjs → test-lib.mjs} +12 -222
- package/scripts/test-workflows.mjs +31 -2
- package/scripts/validate-core.mjs +7 -21
- 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-DO3_nq2Q.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 -113
- 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,13 +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.
|
|
468
|
-
writeFileSync(join(d, ".cohorte", "pipeline-metrics.jsonl"),
|
|
469
|
-
JSON.stringify({ ts: "2026-01-01T00:00:00Z", feature: "f", phase: "build", seconds: 10,
|
|
470
|
-
surfaces: { api: "ok" } }) + "\n");
|
|
471
|
-
check("metrics are read from the runtime's state dir",
|
|
472
|
-
metrics({ projectRoot: d, globalDir: g }).batches === 1);
|
|
473
264
|
}
|
|
474
265
|
|
|
475
266
|
// ── runtime.js — stale absolute registry paths (a cloned/moved bundled core) ─
|
|
@@ -478,7 +269,7 @@ console.log("doctor.js — a non-Claude runtime layout");
|
|
|
478
269
|
// taken verbatim, every check went red on a healthy install.
|
|
479
270
|
console.log("runtime.js — registry paths survive a clone/move");
|
|
480
271
|
{
|
|
481
|
-
const { layouts } = require(join(root, "
|
|
272
|
+
const { layouts } = require(join(root, "lib/runtime.js"));
|
|
482
273
|
const d = scratch();
|
|
483
274
|
const core = join(d, ".claude");
|
|
484
275
|
mkdirSync(join(core, "pipeline"), { recursive: true });
|
|
@@ -515,6 +306,5 @@ console.log("runtime.js — registry paths survive a clone/move");
|
|
|
515
306
|
|
|
516
307
|
for (const d of tmps) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
517
308
|
console.log("");
|
|
518
|
-
if (failures) { console.error(`test-
|
|
519
|
-
console.log("test-
|
|
520
|
-
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");
|
|
@@ -52,7 +52,7 @@ const finding = (over = {}) => ({
|
|
|
52
52
|
// every agent call, and an optional `wf(name, args)` stub in place of nested
|
|
53
53
|
// workflow() calls (loop.js runs the review workflow as a child). Returns
|
|
54
54
|
// { result, calls, prompts } — prompts keyed by label, for byte-identity asserts.
|
|
55
|
-
async function run(script, reply, args = { feature: "feat-x" }, wf) {
|
|
55
|
+
async function run(script, reply, args = { feature: "feat-x" }, wf, budgetStub) {
|
|
56
56
|
const text = readFileSync(join(root, "core/workflows", script), "utf8")
|
|
57
57
|
.replace(/^export const meta/m, "const meta");
|
|
58
58
|
const calls = [];
|
|
@@ -80,11 +80,18 @@ async function run(script, reply, args = { feature: "feat-x" }, wf) {
|
|
|
80
80
|
"agent", "parallel", "pipeline", "phase", "log", "args", "budget", "workflow", text);
|
|
81
81
|
const result = await fn(
|
|
82
82
|
agent, parallel, pipeline, () => {}, () => {}, args,
|
|
83
|
-
{ total: null, spent: () => 0, remaining: () => Infinity },
|
|
83
|
+
budgetStub || { total: null, spent: () => 0, remaining: () => Infinity },
|
|
84
84
|
wf || (async () => {}));
|
|
85
85
|
return { result, calls, prompts };
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// A budget stub whose spent() grows with every agent/workflow call — what the runtime's
|
|
89
|
+
// counter does — so token deltas in the scripts come out non-zero and orderable.
|
|
90
|
+
const tickingBudget = () => {
|
|
91
|
+
let n = 0;
|
|
92
|
+
return { total: null, spent: () => (n += 1000), remaining: () => Infinity };
|
|
93
|
+
};
|
|
94
|
+
|
|
88
95
|
// A reply table keyed by label prefix; the first matching prefix wins.
|
|
89
96
|
const replier = table => (prompt, opts) => {
|
|
90
97
|
const label = opts.label || "";
|
|
@@ -715,6 +722,28 @@ const SHIP_CLEAN = { verdict: "SHIP", blocking: 0, blockingItems: [], unreviewed
|
|
|
715
722
|
check("dead ingest agent ⇒ abort/ingest-died, items never invented",
|
|
716
723
|
result.reason === "ingest-died", result.reason);
|
|
717
724
|
}
|
|
725
|
+
{
|
|
726
|
+
// Token accounting: the run's history and metrics lines carry approximate output
|
|
727
|
+
// tokens from budget.spent() — the figure the conversational path cannot record.
|
|
728
|
+
const wf = async () => reviewOf();
|
|
729
|
+
const { result, prompts } = await run("loop.js", loopReply(loopFacts()),
|
|
730
|
+
{ feature: "feat-x" }, wf, tickingBudget());
|
|
731
|
+
check("loop: history rounds carry a tokens figure",
|
|
732
|
+
result.history.length > 0 && result.history.every(h => typeof h.tokens === "number" && h.tokens > 0),
|
|
733
|
+
JSON.stringify(result.history));
|
|
734
|
+
check("loop: build metrics line carries tokens",
|
|
735
|
+
/"phase":"build".*"tokens":[1-9]/.test(prompts["state:built"] || ""),
|
|
736
|
+
(prompts["state:built"] || "").slice(-200));
|
|
737
|
+
check("loop: the run total is returned", typeof result.tokens === "number" && result.tokens > 0,
|
|
738
|
+
String(result.tokens));
|
|
739
|
+
let stagePrompt = "";
|
|
740
|
+
await run("review.js", (prompt, opts) => {
|
|
741
|
+
if (opts.label === "stage-report") { stagePrompt = prompt; return "done"; }
|
|
742
|
+
return replier([["review:", { verdict: "SHIP", findings: [] }], ...BASE_REVIEW])(prompt, opts);
|
|
743
|
+
}, { feature: "feat-x" }, undefined, tickingBudget());
|
|
744
|
+
check("review: metrics line carries tokens",
|
|
745
|
+
/"phase":"review".*"tokens":[1-9]/.test(stagePrompt), stagePrompt.slice(-200));
|
|
746
|
+
}
|
|
718
747
|
{
|
|
719
748
|
// workflow() unavailable (no runtime / review.js not installed) ⇒ explicit refusal,
|
|
720
749
|
// never a conversational fallback.
|
|
@@ -24,8 +24,9 @@ const frontmatter = (text) => {
|
|
|
24
24
|
// Interactive commands must stay unpinned (they inherit on purpose).
|
|
25
25
|
const PINNED = ["cohorte-build", "cohorte-review", "cohorte-fix", "cohorte-ship",
|
|
26
26
|
"cohorte-audit", "cohorte-refactor", "cohorte-doctor", "cohorte-align-ds",
|
|
27
|
-
"cohorte-update-pipeline"];
|
|
28
|
-
const UNPINNED = ["cohorte-brainstorm", "cohorte-spec", "cohorte-init-pipeline", "cohorte-patch"
|
|
27
|
+
"cohorte-update-pipeline", "cohorte-fleet"];
|
|
28
|
+
const UNPINNED = ["cohorte-brainstorm", "cohorte-spec", "cohorte-init-pipeline", "cohorte-patch",
|
|
29
|
+
"cohorte-intake", "cohorte-retro"];
|
|
29
30
|
|
|
30
31
|
// Every command must carry the `cohorte-` prefix. This replaces the old RESERVED
|
|
31
32
|
// blocklist, which chased collisions one name at a time and always lagged: a command
|
|
@@ -164,7 +165,7 @@ if (/usage ping|telemetry-send|consent/i.test(read(TELEMETRY_SCRUBBER)))
|
|
|
164
165
|
// having opened neither the config nor PIPELINE.md, and a merged feature's card
|
|
165
166
|
// stayed in "Ready to build". `kanban-move.sh auto` moved resolution into the
|
|
166
167
|
// script; this keeps it there. Prose is not a call site — the literal invocation is.
|
|
167
|
-
const KANBAN_STAGES = ["brainstorm", "spec", "build", "review", "fix", "ship", "patch"];
|
|
168
|
+
const KANBAN_STAGES = ["brainstorm", "spec", "build", "review", "fix", "ship", "patch", "intake"];
|
|
168
169
|
for (const c of KANBAN_STAGES) {
|
|
169
170
|
const path = `core/commands/${PREFIX}${c}.md`;
|
|
170
171
|
const text = read(path);
|
|
@@ -272,12 +273,12 @@ const workflowNames = existsSync(workflowsDir)
|
|
|
272
273
|
? readdirSync(workflowsDir).filter((f) => f.endsWith(".js"))
|
|
273
274
|
: [];
|
|
274
275
|
const ci = existsSync(join(root, ".github/workflows/ci.yml")) ? read(".github/workflows/ci.yml") : "";
|
|
275
|
-
const
|
|
276
|
+
const libDoctor = read("lib/doctor.js");
|
|
276
277
|
for (const f of workflowNames) {
|
|
277
278
|
if (!ci.includes(`workflows/${f}`))
|
|
278
279
|
fail(".github/workflows/ci.yml", `install dry-run never asserts .claude/workflows/${f}`);
|
|
279
|
-
if (!
|
|
280
|
-
fail("
|
|
280
|
+
if (!libDoctor.includes(`'${f}'`))
|
|
281
|
+
fail("lib/doctor.js", `checkWorkflows() does not list ${f}`);
|
|
281
282
|
}
|
|
282
283
|
|
|
283
284
|
// ── every test suite must run in BOTH workflows ──────────────────────────────
|
|
@@ -297,21 +298,6 @@ for (const f of readdirSync(join(root, "scripts")).filter((f) => /^test-.*\.mjs$
|
|
|
297
298
|
fail(".github/workflows/publish.yml", `never runs scripts/${f} — publish would ship past a failure that gate is meant to catch`);
|
|
298
299
|
}
|
|
299
300
|
|
|
300
|
-
// ── dashboard: the metrics phase list is duplicated server/client ────────────
|
|
301
|
-
// A phase present in one and not the other parses fine and renders in no column —
|
|
302
|
-
// silently invisible data, which is how a phase batch once went unnoticed.
|
|
303
|
-
const phaseList = (text, file) => {
|
|
304
|
-
const m = text.match(/const PHASES = \[([^\]]*)\]/);
|
|
305
|
-
if (!m) { fail(file, "no `const PHASES = [...]` found"); return null; }
|
|
306
|
-
return m[1].split(",").map((s) => s.trim().replace(/^['"]|['"]$/g, "")).filter(Boolean);
|
|
307
|
-
};
|
|
308
|
-
const serverPhases = phaseList(read("dashboard/server/metrics.js"), "dashboard/server/metrics.js");
|
|
309
|
-
const clientPhases = phaseList(read("dashboard/app/src/components/MetricsPanel.jsx"),
|
|
310
|
-
"dashboard/app/src/components/MetricsPanel.jsx");
|
|
311
|
-
if (serverPhases && clientPhases && serverPhases.join("|") !== clientPhases.join("|"))
|
|
312
|
-
fail("dashboard/app/src/components/MetricsPanel.jsx",
|
|
313
|
-
`PHASES drifted from dashboard/server/metrics.js ([${clientPhases}] vs [${serverPhases}])`);
|
|
314
|
-
|
|
315
301
|
// ── packaging: no build artifacts in the published tarball ──────────────────
|
|
316
302
|
// `.npmignore` is INERT under an explicit package.json `files` allowlist, so its
|
|
317
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}}
|