cohorte 2.9.0 → 2.10.1
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 +33 -0
- package/README.md +37 -45
- package/bin/cli.js +15 -43
- package/bin/report.js +2 -2
- package/core/adapter/render.js +33 -7
- package/core/commands/cohorte-doctor.md +28 -4
- package/core/commands/cohorte-fleet.md +2 -3
- package/core/commands/cohorte-ship.md +2 -2
- package/core/commands/cohorte-update-pipeline.md +34 -3
- package/core/hooks/gate.py +13 -5
- package/core/runtimes/codex.json +5 -3
- package/core/templates/steps/init-pipeline/04-write-render.md +32 -2
- package/core/workflows/review.js +1 -1
- package/{dashboard/server → lib}/doctor.js +48 -17
- package/{dashboard/server → lib}/runtime.js +6 -1
- package/{dashboard/server → lib}/versions.js +2 -2
- package/package.json +3 -8
- package/profile/PIPELINE.template.md +10 -2
- package/profile/SCHEMA.md +43 -6
- package/scripts/test-adapter.mjs +70 -1
- package/scripts/test-gate.mjs +15 -0
- package/scripts/{test-dashboard.mjs → test-lib.mjs} +58 -229
- package/scripts/validate-core.mjs +9 -19
- 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
package/scripts/test-gate.mjs
CHANGED
|
@@ -395,6 +395,21 @@ console.log("gate.py — runtime dialects");
|
|
|
395
395
|
cx.json?.hookSpecificOutput?.permissionDecision === "deny");
|
|
396
396
|
check("…and the reason says why it was refused rather than queried",
|
|
397
397
|
/no confirmation tier/.test(cx.json?.hookSpecificOutput?.permissionDecisionReason || ""));
|
|
398
|
+
for (const tool_name of ['spawn_agent', 'Agent']) {
|
|
399
|
+
const dispatch = raw({ tool_name, tool_input: { agent_type: 'review' }, cwd: d }, '--runtime', 'codex');
|
|
400
|
+
check(`codex: ${tool_name} with agent_type is denied without a preflight stamp`,
|
|
401
|
+
dispatch.json?.hookSpecificOutput?.permissionDecision === 'deny'
|
|
402
|
+
&& /preflight/i.test(dispatch.json?.hookSpecificOutput?.permissionDecisionReason || ''));
|
|
403
|
+
check(`codex: ${tool_name} for an ungated implementer passes`,
|
|
404
|
+
raw({ tool_name, tool_input: { agent_type: 'backend' }, cwd: d }, '--runtime', 'codex').json === null);
|
|
405
|
+
}
|
|
406
|
+
const nativeCwd = spawnSync(python, [GATE, '--runtime', 'codex'], {
|
|
407
|
+
input: JSON.stringify({ tool_name: 'spawn_agent', tool_input: { agent_type: 'review' }, cwd: d }),
|
|
408
|
+
cwd: root, encoding: 'utf8',
|
|
409
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: '', COHORTE_PROJECT_DIR: '' },
|
|
410
|
+
});
|
|
411
|
+
check('codex: payload cwd locates project config without Claude environment variables',
|
|
412
|
+
JSON.parse(nativeCwd.stdout || '{}').hookSpecificOutput?.permissionDecision === 'deny');
|
|
398
413
|
|
|
399
414
|
// Cursor sends the command at the top level and names no tool.
|
|
400
415
|
const cu = raw({ hook_event_name: "beforeShellExecution", command: "git push", cwd: d },
|
|
@@ -1,29 +1,24 @@
|
|
|
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
|
|
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"));
|
|
21
|
+
const adapter = require(join(root, 'core/adapter/render.js'));
|
|
27
22
|
|
|
28
23
|
let failures = 0;
|
|
29
24
|
const check = (name, cond, detail = "") => {
|
|
@@ -34,8 +29,13 @@ const eq = (name, got, want) =>
|
|
|
34
29
|
check(name, JSON.stringify(got) === JSON.stringify(want), `got ${JSON.stringify(got)}`);
|
|
35
30
|
|
|
36
31
|
const tmps = [];
|
|
37
|
-
const scratch = () => { const d = mkdtempSync(join(tmpdir(), "
|
|
32
|
+
const scratch = () => { const d = mkdtempSync(join(tmpdir(), "cohorte-lib-")); tmps.push(d); return d; };
|
|
38
33
|
const write = (p, s) => { mkdirSync(join(p, ".."), { recursive: true }); writeFileSync(p, s); };
|
|
34
|
+
// Runtime discovery must not pick up Cohorte installations from the developer's real home.
|
|
35
|
+
const os = require('node:os');
|
|
36
|
+
const originalHomedir = os.homedir;
|
|
37
|
+
os.homedir = () => isolatedHome;
|
|
38
|
+
const isolatedHome = scratch();
|
|
39
39
|
|
|
40
40
|
// ── yaml.js ──────────────────────────────────────────────────────────────────
|
|
41
41
|
console.log("yaml.js — the profile parser");
|
|
@@ -61,7 +61,7 @@ console.log("yaml.js — the profile parser");
|
|
|
61
61
|
|
|
62
62
|
// The real thing: the shipped template must round-trip.
|
|
63
63
|
const tpl = readFileSync(join(root, "profile/PIPELINE.template.md"), "utf8");
|
|
64
|
-
const p = parseProfileBlock(tpl);
|
|
64
|
+
const p = parseProfileBlock(adapter.adaptInstructions(tpl, adapter.loadRuntime('claude')));
|
|
65
65
|
check("the shipped PIPELINE.template.md parses", !!p);
|
|
66
66
|
eq("…surfaces are a list of 2", (p.surfaces || []).length, 2);
|
|
67
67
|
eq("…surface tools survive as an array", p.surfaces[0].tools.length, 7);
|
|
@@ -73,59 +73,6 @@ console.log("yaml.js — the profile parser");
|
|
|
73
73
|
eq("…a chained migrate command survives", p.commands.migrate, "cd apps/api && node ace migration:run");
|
|
74
74
|
}
|
|
75
75
|
|
|
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
76
|
// ── doctor.js ────────────────────────────────────────────────────────────────
|
|
130
77
|
console.log("doctor.js — the /cohorte-doctor port");
|
|
131
78
|
{
|
|
@@ -259,149 +206,6 @@ console.log("doctor.js — the /cohorte-doctor port");
|
|
|
259
206
|
eq("no core ⇒ core bad", by(s.checks, "core").status, "bad");
|
|
260
207
|
}
|
|
261
208
|
|
|
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
209
|
// ── runtime.js + a non-Claude layout ────────────────────────────────────────
|
|
406
210
|
// Every path-dependent check used to assume `.claude/`. On a repo driven from Cursor that
|
|
407
211
|
// reported a healthy install as three ❌ and a ⚠️ — no core, no rendered agent, artifacts not
|
|
@@ -463,20 +267,46 @@ console.log("doctor.js — a non-Claude runtime layout");
|
|
|
463
267
|
st("workflows") === "skip" && /Cursor/.test(dt("workflows")), dt("workflows"));
|
|
464
268
|
check("nothing is reported broken on a healthy non-Claude install",
|
|
465
269
|
s.summary.bad === 0 && s.summary.warn === 0, JSON.stringify(s.summary));
|
|
270
|
+
}
|
|
466
271
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
272
|
+
// Codex's global core supplies fixed agents; each consuming repo owns its surfaces.
|
|
273
|
+
{
|
|
274
|
+
const d = scratch();
|
|
275
|
+
const g = join(d, 'global');
|
|
276
|
+
write(join(g, 'pipeline/VERSION'), '9.9.9\n');
|
|
277
|
+
write(join(g, 'pipeline/runtimes.json'), JSON.stringify({ codex: {
|
|
278
|
+
label: 'Codex', scope: 'global', capabilities: { subagents: true, hooks: true, workflows: false },
|
|
279
|
+
paths: { core: g, agents: join(g, 'agents'), hooks_config: join(g, 'hooks.json'), state: '.cohorte' },
|
|
280
|
+
} }));
|
|
281
|
+
// Legacy registry intentionally has no surface_agents or agent_ext fields.
|
|
282
|
+
write(join(g, 'agents/unrelated.toml'), 'name = "unrelated"\n');
|
|
283
|
+
const hook = matcher => write(join(g, 'hooks.json'), JSON.stringify({ hooks: {
|
|
284
|
+
PreToolUse: [{ matcher, hooks: [{ command: `python3 ${g}/hooks/gate.py --runtime codex` }] }],
|
|
285
|
+
} }));
|
|
286
|
+
hook('Bash|shell');
|
|
287
|
+
const a = join(d, 'project-a'), b = join(d, 'project-b');
|
|
288
|
+
for (const repo of [a, b]) {
|
|
289
|
+
write(join(repo, 'PIPELINE.md'), '```yaml pipeline-profile\nname: example\nsurfaces:\n - key: api\n agent: api\nretrieval:\n provider: serena\n```\n');
|
|
290
|
+
write(join(repo, '.codex/agents/api.toml'), 'name = "api"\ndescription = "API"\ndeveloper_instructions = "Implement"\n');
|
|
291
|
+
write(join(repo, '.codex/config.toml'), '[mcp_servers.serena]\ncommand = "serena"\n');
|
|
292
|
+
}
|
|
293
|
+
const inspect = repo => state({ projectRoot: repo, globalDir: g, cliVersion: '9.9.9' });
|
|
294
|
+
const status = (s, id) => s.checks.find(c => c.id === id)?.status;
|
|
295
|
+
let s = await inspect(a);
|
|
296
|
+
check('Codex doctor accepts project TOML agents with a legacy global registry', status(s, 'agents') === 'ok');
|
|
297
|
+
check('Codex doctor rejects a shell-only preflight hook', status(s, 'hooks') === 'warn');
|
|
298
|
+
check('Codex doctor finds native project MCP configuration', status(s, 'retrieval') === 'ok');
|
|
299
|
+
hook('Bash|spawn_agent');
|
|
300
|
+
s = await inspect(b);
|
|
301
|
+
check('a second project resolves its own agents without a launcher', status(s, 'agents') === 'ok');
|
|
302
|
+
check('Codex doctor accepts the native dispatch matcher', status(s, 'hooks') === 'ok');
|
|
303
|
+
write(join(b, '.codex/agents/stale.toml'), 'name = "stale"\n');
|
|
304
|
+
s = await inspect(b);
|
|
305
|
+
check('Codex doctor detects local orphans even with a global core', status(s, 'agents') === 'warn');
|
|
306
|
+
write(join(a, '.codex/config.toml'), '# [mcp_servers.serena]\n');
|
|
307
|
+
write(join(a, '.mcp.json'), JSON.stringify({ mcpServers: { serena: { command: 'serena' } } }));
|
|
308
|
+
s = await inspect(a);
|
|
309
|
+
check('a comment or Claude MCP config does not count as Codex registration', status(s, 'retrieval') === 'warn');
|
|
480
310
|
}
|
|
481
311
|
|
|
482
312
|
// ── runtime.js — stale absolute registry paths (a cloned/moved bundled core) ─
|
|
@@ -485,7 +315,7 @@ console.log("doctor.js — a non-Claude runtime layout");
|
|
|
485
315
|
// taken verbatim, every check went red on a healthy install.
|
|
486
316
|
console.log("runtime.js — registry paths survive a clone/move");
|
|
487
317
|
{
|
|
488
|
-
const { layouts } = require(join(root, "
|
|
318
|
+
const { layouts } = require(join(root, "lib/runtime.js"));
|
|
489
319
|
const d = scratch();
|
|
490
320
|
const core = join(d, ".claude");
|
|
491
321
|
mkdirSync(join(core, "pipeline"), { recursive: true });
|
|
@@ -522,6 +352,5 @@ console.log("runtime.js — registry paths survive a clone/move");
|
|
|
522
352
|
|
|
523
353
|
for (const d of tmps) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
524
354
|
console.log("");
|
|
525
|
-
if (failures) { console.error(`test-
|
|
526
|
-
console.log("test-
|
|
527
|
-
process.exit(0); // the HTTP server has no handle to close
|
|
355
|
+
if (failures) { console.error(`test-lib: ${failures} failure(s)`); process.exit(1); }
|
|
356
|
+
console.log("test-lib: OK");
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
const { applyConditionals, loadRuntime } = createRequire(import.meta.url)('../core/adapter/render.js');
|
|
8
10
|
|
|
9
11
|
// fileURLToPath, not .pathname — on Windows the latter yields "/C:/…", which
|
|
10
12
|
// join() then resolves against the cwd drive ("C:\C:\…") and every read ENOENTs.
|
|
@@ -121,11 +123,14 @@ for (const p of ["profile/PIPELINE.template.md", "profile/SCHEMA.md",
|
|
|
121
123
|
"profile/cohorte.config.template.yaml"])
|
|
122
124
|
if (!existsSync(join(root, p))) fail(p, "missing");
|
|
123
125
|
|
|
124
|
-
const tpl = read("profile/PIPELINE.template.md");
|
|
126
|
+
const tpl = applyConditionals(read("profile/PIPELINE.template.md"), loadRuntime('claude'));
|
|
125
127
|
if (/^\s*model:\s*inherit\b/m.test(tpl))
|
|
126
128
|
fail("profile/PIPELINE.template.md",
|
|
127
129
|
"a surfaces[] example pins `model: inherit` — examples must default to sonnet " +
|
|
128
130
|
"(inherit bills at the lead session's model)");
|
|
131
|
+
const codexTpl = applyConditionals(read('profile/PIPELINE.template.md'), loadRuntime('codex'));
|
|
132
|
+
if (/^\s*model:\s*(sonnet|haiku|opus)\b/m.test(codexTpl))
|
|
133
|
+
fail('profile/PIPELINE.template.md', 'Codex examples must not pin Anthropic model aliases');
|
|
129
134
|
|
|
130
135
|
// ── init-pipeline router steps ──────────────────────────────────────────────
|
|
131
136
|
const steps = join(root, "core/templates/steps/init-pipeline");
|
|
@@ -273,12 +278,12 @@ const workflowNames = existsSync(workflowsDir)
|
|
|
273
278
|
? readdirSync(workflowsDir).filter((f) => f.endsWith(".js"))
|
|
274
279
|
: [];
|
|
275
280
|
const ci = existsSync(join(root, ".github/workflows/ci.yml")) ? read(".github/workflows/ci.yml") : "";
|
|
276
|
-
const
|
|
281
|
+
const libDoctor = read("lib/doctor.js");
|
|
277
282
|
for (const f of workflowNames) {
|
|
278
283
|
if (!ci.includes(`workflows/${f}`))
|
|
279
284
|
fail(".github/workflows/ci.yml", `install dry-run never asserts .claude/workflows/${f}`);
|
|
280
|
-
if (!
|
|
281
|
-
fail("
|
|
285
|
+
if (!libDoctor.includes(`'${f}'`))
|
|
286
|
+
fail("lib/doctor.js", `checkWorkflows() does not list ${f}`);
|
|
282
287
|
}
|
|
283
288
|
|
|
284
289
|
// ── every test suite must run in BOTH workflows ──────────────────────────────
|
|
@@ -298,21 +303,6 @@ for (const f of readdirSync(join(root, "scripts")).filter((f) => /^test-.*\.mjs$
|
|
|
298
303
|
fail(".github/workflows/publish.yml", `never runs scripts/${f} — publish would ship past a failure that gate is meant to catch`);
|
|
299
304
|
}
|
|
300
305
|
|
|
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
306
|
// ── packaging: no build artifacts in the published tarball ──────────────────
|
|
317
307
|
// `.npmignore` is INERT under an explicit package.json `files` allowlist, so its
|
|
318
308
|
// `__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}}
|