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.
@@ -1,29 +1,23 @@
1
1
  #!/usr/bin/env node
2
- // Tests for the dashboard's server modules (dashboard/server/*.js).
2
+ // Tests for the shared readers in lib/.
3
3
  //
4
- // These are shipped runtime code with real logic and zero coverage until now:
5
- // a hand-rolled YAML parser that every /cohorte-doctor check is derived from, a metrics
6
- // aggregator, the JS port of /cohorte-doctor, an Obsidian board parser, the fleet
7
- // registry, and an HTTP layer whose guards are the dashboard's only defence
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-dashboard.mjs
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, "dashboard/server/yaml.js"));
22
- const { metrics } = require(join(root, "dashboard/server/metrics.js"));
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(), "dash-")); tmps.push(d); return d; };
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, "dashboard/server/runtime.js"));
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-dashboard: ${failures} failure(s)`); process.exit(1); }
526
- console.log("test-dashboard: OK");
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 dashDoctor = read("dashboard/server/doctor.js");
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 (!dashDoctor.includes(`'${f}'`))
281
- fail("dashboard/server/doctor.js", `checkWorkflows() does not list ${f}`);
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
@@ -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
- ```
@@ -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}}