cohorte 1.3.3 → 1.3.4
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 +90 -0
- package/README.md +4 -4
- package/bin/cli.js +22 -4
- package/core/agents/implementer.template.md +10 -5
- package/core/commands/cycle.md +15 -8
- package/core/commands/doctor.md +3 -1
- package/core/hooks/gate.py +21 -6
- package/core/templates/agent-handoff.md +7 -2
- package/core/templates/review-feedback.md +7 -4
- package/core/templates/spec.template.md +5 -2
- package/core/templates/steps/init-pipeline/04-write-render.md +2 -1
- package/core/workflows/audit.js +20 -3
- package/core/workflows/cycle.js +146 -40
- package/core/workflows/refactor.js +16 -5
- package/core/workflows/review.js +59 -7
- package/dashboard/README.md +22 -5
- package/dashboard/dist/assets/index-AFQnlfjO.css +1 -0
- package/dashboard/dist/assets/{index-BxgA_mz1.js → index-DLBzciIC.js} +12 -11
- package/dashboard/dist/index.html +2 -2
- package/dashboard/server/doctor.js +60 -19
- package/dashboard/server/fleet.js +19 -5
- package/dashboard/server/index.js +79 -7
- package/dashboard/server/metrics.js +15 -4
- package/dashboard/server/versions.js +28 -6
- package/dashboard/server/yaml.js +4 -1
- package/install.ps1 +4 -0
- package/install.sh +19 -1
- package/package.json +5 -2
- package/profile/SCHEMA.md +28 -9
- package/scripts/kanban-move.sh +34 -20
- package/scripts/new-feature.sh.template +3 -1
- package/scripts/preflight.sh +16 -3
- package/scripts/remove-feature.sh.template +2 -1
- package/scripts/telemetry-send.sh +15 -1
- package/scripts/test-dashboard.mjs +356 -0
- package/scripts/test-gate.mjs +273 -0
- package/scripts/test-workflows.mjs +443 -0
- package/scripts/validate-core.mjs +49 -0
- package/dashboard/dist/assets/index-Cj0SpgEY.css +0 -1
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Behavioural tests for core/workflows/*.js.
|
|
3
|
+
//
|
|
4
|
+
// The workflow runtime hands a script an async function body with agent() /
|
|
5
|
+
// parallel() / pipeline() / phase() / log() / args / budget injected. Nothing in
|
|
6
|
+
// a script touches the filesystem, so the whole orchestration is testable by
|
|
7
|
+
// injecting stub agents and asserting the returned verdict object.
|
|
8
|
+
//
|
|
9
|
+
// This exists because of one specific failure mode: agent() resolves to `null`
|
|
10
|
+
// when a subagent dies, and a dead reviewer produces zero findings — which is
|
|
11
|
+
// byte-identical to a clean surface. Both review.js and cycle.js scored that as
|
|
12
|
+
// SHIP, and cycle.js went on to tick the DoD and stamp the freshness gate over
|
|
13
|
+
// code no reviewer had read. A unit test is the only thing that catches it: the
|
|
14
|
+
// structural checks in validate-core.mjs cannot see verdict logic.
|
|
15
|
+
//
|
|
16
|
+
// node scripts/test-workflows.mjs
|
|
17
|
+
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
23
|
+
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
24
|
+
|
|
25
|
+
let failures = 0;
|
|
26
|
+
const check = (name, cond, detail = "") => {
|
|
27
|
+
if (cond) console.log(` ✓ ${name}`);
|
|
28
|
+
else { failures++; console.error(` ✗ ${name}${detail ? ` — ${detail}` : ""}`); }
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const PROFILE = {
|
|
32
|
+
name: "testproj",
|
|
33
|
+
vcs: { default_branch: "main" },
|
|
34
|
+
contract: { enabled: false, path: "packages/shared/src", ext: "ts", mechanism: "none" },
|
|
35
|
+
commands: { typecheck: "tsc --noEmit", lint_quiet: "lint -q", test_quiet: "test --dot" },
|
|
36
|
+
surfaces: [
|
|
37
|
+
{ key: "backend", path: "apps/api", agent: "backend", uses_design: false },
|
|
38
|
+
{ key: "frontend", path: "apps/web", agent: "frontend", uses_design: true },
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const TOUCHED = [
|
|
43
|
+
{ key: "backend", diff: "specs/reports/f.backend.diff", files: ["apps/api/a.ts"] },
|
|
44
|
+
{ key: "frontend", diff: "specs/reports/f.frontend.diff", files: ["apps/web/b.tsx"] },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const finding = (over = {}) => ({
|
|
48
|
+
severity: "HIGH", file: "apps/api/a.ts", line: 3, kind: "quality",
|
|
49
|
+
problem: "p", fix: "f", ...over,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Run one workflow script with a `reply(prompt, opts) => value` stub in place of
|
|
53
|
+
// every agent call. Returns { result, calls }.
|
|
54
|
+
async function run(script, reply, args = { feature: "feat-x" }) {
|
|
55
|
+
const text = readFileSync(join(root, "core/workflows", script), "utf8")
|
|
56
|
+
.replace(/^export const meta/m, "const meta");
|
|
57
|
+
const calls = [];
|
|
58
|
+
const agent = async (prompt, opts = {}) => {
|
|
59
|
+
calls.push(opts.label || "(unlabelled)");
|
|
60
|
+
return reply(prompt, opts, calls);
|
|
61
|
+
};
|
|
62
|
+
// Mirrors the runtime's contract: a thunk that throws resolves to null, the
|
|
63
|
+
// call itself never rejects.
|
|
64
|
+
const parallel = thunks =>
|
|
65
|
+
Promise.all(thunks.map(t => Promise.resolve().then(t).catch(() => null)));
|
|
66
|
+
// Each item runs through every stage independently; a throwing stage drops
|
|
67
|
+
// that item to null and skips its remaining stages.
|
|
68
|
+
const pipeline = (items, ...stages) =>
|
|
69
|
+
Promise.all(items.map(async (item, i) => {
|
|
70
|
+
let v = item;
|
|
71
|
+
for (const s of stages) {
|
|
72
|
+
try { v = await s(v, item, i); } catch { return null; }
|
|
73
|
+
}
|
|
74
|
+
return v;
|
|
75
|
+
}));
|
|
76
|
+
const fn = new AsyncFunction(
|
|
77
|
+
"agent", "parallel", "pipeline", "phase", "log", "args", "budget", "workflow", text);
|
|
78
|
+
const result = await fn(
|
|
79
|
+
agent, parallel, pipeline, () => {}, () => {}, args,
|
|
80
|
+
{ total: null, spent: () => 0, remaining: () => Infinity }, async () => {});
|
|
81
|
+
return { result, calls };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A reply table keyed by label prefix; the first matching prefix wins.
|
|
85
|
+
const replier = table => (prompt, opts) => {
|
|
86
|
+
const label = opts.label || "";
|
|
87
|
+
for (const [prefix, value] of table) {
|
|
88
|
+
if (label === prefix || label.startsWith(prefix)) {
|
|
89
|
+
return typeof value === "function" ? value(label) : value;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return "ok";
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const BASE_REVIEW = [
|
|
96
|
+
["profile", PROFILE],
|
|
97
|
+
["preflight", { pass: true }],
|
|
98
|
+
["stage-diff", { surfaces: TOUCHED }],
|
|
99
|
+
["stage-report", "done"],
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
const BASE_CYCLE = [
|
|
103
|
+
["profile", PROFILE],
|
|
104
|
+
["ready", { frozen: true, gaps: [], designLinks: "none" }],
|
|
105
|
+
["preflight", { pass: true }],
|
|
106
|
+
["stage-diff", { surfaces: TOUCHED }],
|
|
107
|
+
["build:", "handoff ok"],
|
|
108
|
+
["fix:", "handoff ok"],
|
|
109
|
+
["close", "done"],
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
// ── review.js ────────────────────────────────────────────────────────────────
|
|
113
|
+
console.log("review.js");
|
|
114
|
+
{
|
|
115
|
+
const { result } = await run("review.js", replier([
|
|
116
|
+
["review:", { verdict: "SHIP", findings: [] }], ...BASE_REVIEW,
|
|
117
|
+
]));
|
|
118
|
+
check("clean run ⇒ SHIP", result.verdict === "SHIP", `got ${result.verdict}`);
|
|
119
|
+
check("clean run ⇒ no unreviewed surfaces", (result.unreviewedSurfaces || []).length === 0);
|
|
120
|
+
check("clean run ⇒ next is /ship", String(result.next).startsWith("/ship"), result.next);
|
|
121
|
+
}
|
|
122
|
+
{
|
|
123
|
+
// THE regression: every reviewer dies ⇒ zero findings ⇒ must NOT read as SHIP.
|
|
124
|
+
const { result } = await run("review.js", replier([
|
|
125
|
+
["review:", null], ...BASE_REVIEW,
|
|
126
|
+
]));
|
|
127
|
+
check("all reviewers dead ⇒ not SHIP", result.verdict !== "SHIP", `got ${result.verdict}`);
|
|
128
|
+
check("all reviewers dead ⇒ both surfaces reported unreviewed",
|
|
129
|
+
(result.unreviewedSurfaces || []).join(",") === "backend,frontend",
|
|
130
|
+
JSON.stringify(result.unreviewedSurfaces));
|
|
131
|
+
check("all reviewers dead ⇒ next says re-run",
|
|
132
|
+
/re-run the review/.test(result.next), result.next);
|
|
133
|
+
}
|
|
134
|
+
{
|
|
135
|
+
// One dead reviewer must not be masked by the other surface coming back clean.
|
|
136
|
+
const { result } = await run("review.js", replier([
|
|
137
|
+
["review:backend", null],
|
|
138
|
+
["review:", { verdict: "SHIP", findings: [] }],
|
|
139
|
+
...BASE_REVIEW,
|
|
140
|
+
]));
|
|
141
|
+
check("one reviewer dead ⇒ not SHIP", result.verdict !== "SHIP", `got ${result.verdict}`);
|
|
142
|
+
check("one reviewer dead ⇒ names only that surface",
|
|
143
|
+
(result.unreviewedSurfaces || []).join(",") === "backend", JSON.stringify(result.unreviewedSurfaces));
|
|
144
|
+
}
|
|
145
|
+
{
|
|
146
|
+
// A SHIP carrying HIGH findings is a real verdict, but it is not "go ship it":
|
|
147
|
+
// the conversational /review routes any surviving HIGH to /fix.
|
|
148
|
+
const { result } = await run("review.js", replier([
|
|
149
|
+
["review:", { verdict: "SHIP", findings: [finding()] }], ...BASE_REVIEW,
|
|
150
|
+
]));
|
|
151
|
+
check("SHIP + HIGH findings ⇒ verdict still SHIP", result.verdict === "SHIP");
|
|
152
|
+
check("SHIP + HIGH findings ⇒ next routes to /fix, not /ship",
|
|
153
|
+
String(result.next).startsWith("/fix"), result.next);
|
|
154
|
+
}
|
|
155
|
+
{
|
|
156
|
+
const { result } = await run("review.js", replier([
|
|
157
|
+
["review:", { verdict: "SHIP", findings: [finding({ severity: "LOW" })] }], ...BASE_REVIEW,
|
|
158
|
+
]));
|
|
159
|
+
check("SHIP + only LOW ⇒ next is /ship", String(result.next).startsWith("/ship"), result.next);
|
|
160
|
+
}
|
|
161
|
+
{
|
|
162
|
+
const { result } = await run("review.js", replier([
|
|
163
|
+
["preflight", { pass: false, tail: "boom" }], ...BASE_REVIEW,
|
|
164
|
+
]));
|
|
165
|
+
check("red preflight ⇒ ABORTED", result.verdict === "ABORTED", `got ${result.verdict}`);
|
|
166
|
+
}
|
|
167
|
+
{
|
|
168
|
+
const { calls } = await run("review.js", replier([
|
|
169
|
+
["preflight", { pass: false, tail: "boom" }], ...BASE_REVIEW,
|
|
170
|
+
]));
|
|
171
|
+
check("red preflight ⇒ zero reviewers spawned",
|
|
172
|
+
!calls.some(c => c.startsWith("review:")), calls.join(","));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── cycle.js ─────────────────────────────────────────────────────────────────
|
|
176
|
+
console.log("cycle.js");
|
|
177
|
+
{
|
|
178
|
+
const { result } = await run("cycle.js", replier([
|
|
179
|
+
["review:", { verdict: "SHIP", findings: [] }], ...BASE_CYCLE,
|
|
180
|
+
]));
|
|
181
|
+
check("clean run, smoke off ⇒ SHIP-READY", result.outcome === "SHIP-READY", `got ${result.outcome}`);
|
|
182
|
+
check("smoke off ⇒ smoke: SKIPPED", result.smoke === "SKIPPED", result.smoke);
|
|
183
|
+
check("smoke off ⇒ next warns nobody ran the code",
|
|
184
|
+
/\/smoke/.test(result.next), result.next);
|
|
185
|
+
check("clean run ⇒ no questions", (result.questions || []).length === 0, JSON.stringify(result.questions));
|
|
186
|
+
}
|
|
187
|
+
{
|
|
188
|
+
const { result } = await run("cycle.js", replier([
|
|
189
|
+
["smoke", { pass: true, failures: [] }],
|
|
190
|
+
["review:", { verdict: "SHIP", findings: [] }], ...BASE_CYCLE,
|
|
191
|
+
]), { feature: "feat-x", smoke: true });
|
|
192
|
+
check("clean run, smoke on ⇒ SHIP-READY", result.outcome === "SHIP-READY", `got ${result.outcome}`);
|
|
193
|
+
check("smoke on ⇒ smoke: PASS", result.smoke === "PASS", result.smoke);
|
|
194
|
+
check("smoke on + clean ⇒ next is a straight /ship",
|
|
195
|
+
/straight shot/.test(result.next), result.next);
|
|
196
|
+
}
|
|
197
|
+
{
|
|
198
|
+
// THE regression, cycle-side: dead reviewers used to exit SHIP-READY, which
|
|
199
|
+
// ticks the DoD and stamps the freshness gate.
|
|
200
|
+
const { result } = await run("cycle.js", replier([
|
|
201
|
+
["review:", null], ...BASE_CYCLE,
|
|
202
|
+
]), { feature: "feat-x", maxRounds: 2 });
|
|
203
|
+
check("all reviewers dead ⇒ not SHIP-READY", result.outcome !== "SHIP-READY", `got ${result.outcome}`);
|
|
204
|
+
check("all reviewers dead ⇒ verdict not SHIP", result.verdict !== "SHIP", result.verdict);
|
|
205
|
+
check("all reviewers dead ⇒ surfaces reported",
|
|
206
|
+
(result.unreviewedSurfaces || []).length === 2, JSON.stringify(result.unreviewedSurfaces));
|
|
207
|
+
check("all reviewers dead ⇒ a question names them",
|
|
208
|
+
(result.questions || []).some(q => /not reviewed/i.test(q)), JSON.stringify(result.questions));
|
|
209
|
+
}
|
|
210
|
+
{
|
|
211
|
+
// …and it must retry the review round rather than dispatching an empty fix round.
|
|
212
|
+
const { calls } = await run("cycle.js", replier([
|
|
213
|
+
["review:", null], ...BASE_CYCLE,
|
|
214
|
+
]), { feature: "feat-x", maxRounds: 3 });
|
|
215
|
+
check("dead reviewers ⇒ review retried across rounds",
|
|
216
|
+
calls.filter(c => c.startsWith("review:")).length > 2,
|
|
217
|
+
`review calls: ${calls.filter(c => c.startsWith("review:")).length}`);
|
|
218
|
+
check("dead reviewers ⇒ no empty fix round dispatched",
|
|
219
|
+
!calls.some(c => c.startsWith("fix:")), calls.join(","));
|
|
220
|
+
}
|
|
221
|
+
{
|
|
222
|
+
const { result } = await run("cycle.js", replier([
|
|
223
|
+
["ready", { frozen: false, gaps: ["status is draft"], designLinks: "none" }], ...BASE_CYCLE,
|
|
224
|
+
]));
|
|
225
|
+
check("unfrozen spec ⇒ NOT-READY", result.outcome === "NOT-READY", `got ${result.outcome}`);
|
|
226
|
+
check("unfrozen spec ⇒ the gap is in questions",
|
|
227
|
+
(result.questions || []).some(q => /draft/.test(q)), JSON.stringify(result.questions));
|
|
228
|
+
}
|
|
229
|
+
{
|
|
230
|
+
const { result } = await run("cycle.js", replier([
|
|
231
|
+
["smoke", { pass: false, failures: ["❌ POST /x · expected 201 got 500 · apps/api/a.ts"] }],
|
|
232
|
+
["review:", { verdict: "SHIP", findings: [] }], ...BASE_CYCLE,
|
|
233
|
+
]), { feature: "feat-x", smoke: true, maxRounds: 1 });
|
|
234
|
+
check("smoke on + FAIL ⇒ not SHIP-READY", result.outcome !== "SHIP-READY", `got ${result.outcome}`);
|
|
235
|
+
check("smoke on + FAIL ⇒ smoke: FAIL", result.smoke === "FAIL", result.smoke);
|
|
236
|
+
}
|
|
237
|
+
{
|
|
238
|
+
// A finding in round 1 that the fix clears must let round 2 exit clean.
|
|
239
|
+
let round = 0;
|
|
240
|
+
const { result } = await run("cycle.js", (prompt, opts) => {
|
|
241
|
+
const l = opts.label || "";
|
|
242
|
+
if (l.startsWith("review:")) {
|
|
243
|
+
round++;
|
|
244
|
+
return round <= 2 ? { verdict: "REVISE", findings: [finding({ severity: "CRITICAL" })] }
|
|
245
|
+
: { verdict: "SHIP", findings: [] };
|
|
246
|
+
}
|
|
247
|
+
if (l.startsWith("verify:")) return { refuted: false, reason: "holds" };
|
|
248
|
+
return replier(BASE_CYCLE)(prompt, opts);
|
|
249
|
+
}, { feature: "feat-x", maxRounds: 4 });
|
|
250
|
+
check("findings then clean ⇒ SHIP-READY", result.outcome === "SHIP-READY", `got ${result.outcome}`);
|
|
251
|
+
check("findings then clean ⇒ took >1 round", result.rounds > 1, `rounds ${result.rounds}`);
|
|
252
|
+
}
|
|
253
|
+
{
|
|
254
|
+
// A refuted CRITICAL must not force a fix round.
|
|
255
|
+
const { result, calls } = await run("cycle.js", (prompt, opts) => {
|
|
256
|
+
const l = opts.label || "";
|
|
257
|
+
if (l.startsWith("review:")) return { verdict: "REVISE", findings: [finding({ severity: "CRITICAL" })] };
|
|
258
|
+
if (l.startsWith("verify:")) return { refuted: true, reason: "guarded upstream" };
|
|
259
|
+
return replier(BASE_CYCLE)(prompt, opts);
|
|
260
|
+
}, { feature: "feat-x", maxRounds: 2 });
|
|
261
|
+
check("cross-check refutes the only CRITICAL ⇒ SHIP-READY",
|
|
262
|
+
result.outcome === "SHIP-READY", `got ${result.outcome}`);
|
|
263
|
+
check("refuted finding ⇒ no fix round", !calls.some(c => c.startsWith("fix:")), calls.join(","));
|
|
264
|
+
}
|
|
265
|
+
{
|
|
266
|
+
const { result } = await run("cycle.js", replier([
|
|
267
|
+
["build:", null], ["review:", { verdict: "SHIP", findings: [] }], ...BASE_CYCLE,
|
|
268
|
+
]));
|
|
269
|
+
check("dead implementers ⇒ a question names them",
|
|
270
|
+
(result.questions || []).some(q => /implementer\(s\) died/.test(q)), JSON.stringify(result.questions));
|
|
271
|
+
}
|
|
272
|
+
{
|
|
273
|
+
const { result } = await run("cycle.js", replier([
|
|
274
|
+
["profile", { error: "PIPELINE.md not found" }], ...BASE_CYCLE,
|
|
275
|
+
]));
|
|
276
|
+
check("unreadable profile ⇒ ABORTED", result.outcome === "ABORTED", `got ${result.outcome}`);
|
|
277
|
+
}
|
|
278
|
+
{
|
|
279
|
+
// A DEAD contract agent must not be reported as a successful re-authoring, and
|
|
280
|
+
// must not hand every surface a "the contract was RE-AUTHORED, realign" item
|
|
281
|
+
// pointing at a file nobody touched.
|
|
282
|
+
const CONTRACT_PROFILE = {
|
|
283
|
+
...PROFILE,
|
|
284
|
+
contract: { enabled: true, path: "packages/shared/src", ext: "ts", mechanism: "shared-types-zod", index: "" },
|
|
285
|
+
};
|
|
286
|
+
const contractFinding = finding({ severity: "CRITICAL", file: "packages/shared/src/feat-x.ts" });
|
|
287
|
+
const { result, calls } = await run("cycle.js", (prompt, opts) => {
|
|
288
|
+
const l = opts.label || "";
|
|
289
|
+
if (l === "profile") return CONTRACT_PROFILE;
|
|
290
|
+
if (l === "contract-fix") return null; // the agent dies
|
|
291
|
+
if (l.startsWith("review:")) return { verdict: "REVISE", findings: [contractFinding] };
|
|
292
|
+
if (l.startsWith("verify:")) return { refuted: false, reason: "holds" };
|
|
293
|
+
return replier(BASE_CYCLE)(prompt, opts);
|
|
294
|
+
// maxRounds ≥ 2: the loop breaks at the cap BEFORE the fix block, so a
|
|
295
|
+
// 1-round run never reaches the contract path at all (a vacuous test).
|
|
296
|
+
}, { feature: "feat-x", maxRounds: 2 });
|
|
297
|
+
check("dead contract agent ⇒ no fabricated contractChanges entry",
|
|
298
|
+
(result.contractChanges || []).length === 0, JSON.stringify(result.contractChanges));
|
|
299
|
+
check("dead contract agent ⇒ a question says the contract is UNCHANGED",
|
|
300
|
+
(result.questions || []).some(q => /contract agent died/.test(q)), JSON.stringify(result.questions));
|
|
301
|
+
check("dead contract agent ⇒ no surface told to realign against it",
|
|
302
|
+
!calls.some(c => c.startsWith("fix:")), calls.join(","));
|
|
303
|
+
}
|
|
304
|
+
{
|
|
305
|
+
// Preflight red with no owning surface used to spin the loop doing nothing
|
|
306
|
+
// until the round cap, then report a stale verdict.
|
|
307
|
+
const { result, calls } = await run("cycle.js", replier([
|
|
308
|
+
["preflight", { pass: false, tail: "error in vendor/thing.go: boom" }],
|
|
309
|
+
["build:", null], // no implementer survives
|
|
310
|
+
...BASE_CYCLE,
|
|
311
|
+
]), { feature: "feat-x", maxRounds: 5 });
|
|
312
|
+
check("red preflight with no owning surface ⇒ stops instead of spinning",
|
|
313
|
+
result.rounds === 1, `burned ${result.rounds} round(s)`);
|
|
314
|
+
check("…and dispatches no fix agent", !calls.some(c => c.startsWith("fix:")), calls.join(","));
|
|
315
|
+
check("…and the question carries the failure tail",
|
|
316
|
+
(result.questions || []).some(q => /no surface owns the failure/.test(q)),
|
|
317
|
+
JSON.stringify(result.questions));
|
|
318
|
+
}
|
|
319
|
+
{
|
|
320
|
+
// A finding under no surface path has no owner: it keeps the loop from exiting
|
|
321
|
+
// clean while nobody is ever dispatched to fix it. Reachable because `touched`
|
|
322
|
+
// is agent-supplied — the stage-diff agent can name a key the profile lacks.
|
|
323
|
+
const orphan = finding({ severity: "CRITICAL", file: "tools/thing.sh", line: 9 });
|
|
324
|
+
const { result } = await run("cycle.js", (prompt, opts) => {
|
|
325
|
+
const l = opts.label || "";
|
|
326
|
+
if (l === "stage-diff") return { surfaces: [{ key: "tools", diff: "d", files: ["tools/thing.sh"] }] };
|
|
327
|
+
if (l.startsWith("review:")) return { verdict: "REVISE", findings: [orphan] };
|
|
328
|
+
if (l.startsWith("verify:")) return { refuted: false, reason: "holds" };
|
|
329
|
+
return replier(BASE_CYCLE)(prompt, opts);
|
|
330
|
+
}, { feature: "feat-x", maxRounds: 3 });
|
|
331
|
+
check("a finding owned by no surface names the file, not just 'run /fix manually'",
|
|
332
|
+
(result.questions || []).some(q => /never dispatched/.test(q) && /tools\/thing\.sh:9/.test(q)),
|
|
333
|
+
JSON.stringify(result.questions));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── the dead-agent family, swept across every terminal/staging agent ─────────
|
|
337
|
+
// `agent()` returns null when a subagent dies. Any call whose result is turned
|
|
338
|
+
// into a CLAIM (a verdict, a path, "it is on disk") must distinguish "died" from
|
|
339
|
+
// "succeeded with nothing to say". This block is the sweep.
|
|
340
|
+
console.log("dead-agent sweep");
|
|
341
|
+
{
|
|
342
|
+
const { result } = await run("review.js", replier([
|
|
343
|
+
["stage-diff", null], ...BASE_REVIEW,
|
|
344
|
+
]));
|
|
345
|
+
check("review: dead diff-stager ⇒ ABORTED, not 'SHIP — nothing to review'",
|
|
346
|
+
result.verdict === "ABORTED", `got ${result.verdict}: ${result.reason}`);
|
|
347
|
+
}
|
|
348
|
+
{
|
|
349
|
+
const { result } = await run("review.js", replier([
|
|
350
|
+
["stage-report", null],
|
|
351
|
+
["review:", { verdict: "SHIP", findings: [] }], ...BASE_REVIEW,
|
|
352
|
+
]));
|
|
353
|
+
check("review: dead report-stager ⇒ reportStaged false", result.reportStaged === false);
|
|
354
|
+
check("review: dead report-stager ⇒ report path not claimed",
|
|
355
|
+
!/^specs\//.test(String(result.report)), result.report);
|
|
356
|
+
check("review: dead report-stager ⇒ next says nothing was written",
|
|
357
|
+
/NEVER written/.test(result.next), result.next);
|
|
358
|
+
}
|
|
359
|
+
{
|
|
360
|
+
const { result } = await run("cycle.js", replier([
|
|
361
|
+
["stage-diff", null], ["review:", { verdict: "SHIP", findings: [] }], ...BASE_CYCLE,
|
|
362
|
+
]));
|
|
363
|
+
check("cycle: dead diff-stager ⇒ diagnosed as such, not 'wrong branch'",
|
|
364
|
+
(result.questions || []).some(q => /diff-staging agent died/.test(q)),
|
|
365
|
+
JSON.stringify(result.questions));
|
|
366
|
+
}
|
|
367
|
+
{
|
|
368
|
+
const { result } = await run("cycle.js", replier([
|
|
369
|
+
["close", null], ["review:", { verdict: "SHIP", findings: [] }], ...BASE_CYCLE,
|
|
370
|
+
]));
|
|
371
|
+
check("cycle: dead close agent ⇒ NOT SHIP-READY",
|
|
372
|
+
result.outcome !== "SHIP-READY", `got ${result.outcome}`);
|
|
373
|
+
check("cycle: dead close agent ⇒ a question says nothing was written",
|
|
374
|
+
(result.questions || []).some(q => /NEVER written/.test(q)), JSON.stringify(result.questions));
|
|
375
|
+
check("cycle: dead close agent ⇒ report path not claimed",
|
|
376
|
+
!/^specs\//.test(String(result.report)), result.report);
|
|
377
|
+
}
|
|
378
|
+
{
|
|
379
|
+
const { result } = await run("audit.js", replier([
|
|
380
|
+
["profile", PROFILE], ["gates", { failures: [] }],
|
|
381
|
+
["audit:backend", null],
|
|
382
|
+
["audit:", { items: [] }], ["write-backlog", "done"],
|
|
383
|
+
]), {});
|
|
384
|
+
check("audit: dead auditor ⇒ the domain is listed as NOT audited",
|
|
385
|
+
(result.notAudited || []).join(",") === "backend", JSON.stringify(result.notAudited));
|
|
386
|
+
check("audit: dead auditor ⇒ next tells you to re-audit it",
|
|
387
|
+
/re-audit backend/.test(result.next), result.next);
|
|
388
|
+
}
|
|
389
|
+
{
|
|
390
|
+
const { result } = await run("audit.js", replier([
|
|
391
|
+
["profile", PROFILE], ["gates", { failures: [] }],
|
|
392
|
+
["audit:", { items: [] }], ["write-backlog", null],
|
|
393
|
+
]), {});
|
|
394
|
+
check("audit: dead backlog writer ⇒ path not claimed",
|
|
395
|
+
!/^specs\//.test(String(result.backlog)), result.backlog);
|
|
396
|
+
}
|
|
397
|
+
{
|
|
398
|
+
const { result } = await run("refactor.js", replier([
|
|
399
|
+
["profile", PROFILE], ["read-backlog", null],
|
|
400
|
+
]), { domains: "all" });
|
|
401
|
+
check("refactor: dead backlog reader ⇒ says it died, not 'no open items'",
|
|
402
|
+
/agent died/.test(String(result.error)), result.error);
|
|
403
|
+
}
|
|
404
|
+
{
|
|
405
|
+
const items = ["- [ ] a", "- [ ] b", "- [ ] c", "- [ ] d", "- [ ] e"];
|
|
406
|
+
const { result } = await run("refactor.js", replier([
|
|
407
|
+
["profile", PROFILE],
|
|
408
|
+
["read-backlog", { domains: [{ key: "backend", items }] }],
|
|
409
|
+
["verify:", { cleared: items, remaining: [], gatesGreen: true }],
|
|
410
|
+
["reverify:", { cleared: items, remaining: [], gatesGreen: true }],
|
|
411
|
+
["tick-backlog", null],
|
|
412
|
+
["refactor:", "handoff"],
|
|
413
|
+
]), { domains: "all" });
|
|
414
|
+
check("refactor: dead ticker ⇒ backlogTicked false", result.backlogTicked === false);
|
|
415
|
+
check("refactor: dead ticker ⇒ next warns the backlog still shows them open",
|
|
416
|
+
/NOT ticked/.test(result.next), result.next);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ── audit.js / refactor.js — smoke-level: they must return, not throw ────────
|
|
420
|
+
console.log("audit.js / refactor.js");
|
|
421
|
+
{
|
|
422
|
+
const { result } = await run("audit.js", replier([
|
|
423
|
+
["profile", PROFILE],
|
|
424
|
+
["gates", { failures: [] }],
|
|
425
|
+
["audit:", { items: [{ severity: "HIGH", file: "apps/api/a.ts", line: 1, kind: "tdd", fix: "add a test" }] }],
|
|
426
|
+
["write-backlog", "done"],
|
|
427
|
+
]), {});
|
|
428
|
+
check("audit returns a backlog path", result.backlog === "specs/refactor-backlog.md", JSON.stringify(result));
|
|
429
|
+
check("audit counts every domain (surfaces + shared)",
|
|
430
|
+
Object.keys(result.domains || {}).join(",") === "backend,frontend,shared", JSON.stringify(result.domains));
|
|
431
|
+
}
|
|
432
|
+
{
|
|
433
|
+
const { result } = await run("refactor.js", replier([
|
|
434
|
+
["profile", PROFILE],
|
|
435
|
+
["read-backlog", { domains: [{ key: "backend", items: ["- [ ] a", "- [ ] b"] }] }],
|
|
436
|
+
]), { domains: "all" });
|
|
437
|
+
check("refactor skips a domain below the item threshold",
|
|
438
|
+
result.skipped && result.skipped.backend === 2, JSON.stringify(result));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
console.log("");
|
|
442
|
+
if (failures) { console.error(`test-workflows: ${failures} failure(s)`); process.exit(1); }
|
|
443
|
+
console.log("test-workflows: OK");
|
|
@@ -168,6 +168,11 @@ else for (const f of readdirSync(workflowsDir)) {
|
|
|
168
168
|
fail(path, "phase 0 must read the profile via the profile-reader agent");
|
|
169
169
|
if (/\bDate\.now\(\)|\bMath\.random\(\)|new Date\(\)/.test(text))
|
|
170
170
|
fail(path, "Date.now()/Math.random()/new Date() are unavailable in workflow scripts");
|
|
171
|
+
// Prompts hand agents literal `<core>/…` paths; an agent can only resolve that
|
|
172
|
+
// token if the same script also spells out what <core> means. A bare token +
|
|
173
|
+
// `|| true` = the command fails silently and the ping/metrics never happen.
|
|
174
|
+
if (text.includes("<core>/") && !/<core> = /.test(text))
|
|
175
|
+
fail(path, "uses <core>/ paths in prompts without defining `<core> = …` anywhere");
|
|
171
176
|
try {
|
|
172
177
|
new AsyncFunction("agent", "parallel", "pipeline", "phase", "log", "args",
|
|
173
178
|
"budget", "workflow", text.replace(/^export const meta/m, "const meta"));
|
|
@@ -182,6 +187,50 @@ if (!installSh.includes("core/workflows"))
|
|
|
182
187
|
if (!installPs1.includes("core\\workflows"))
|
|
183
188
|
fail("install.ps1", "does not copy core\\workflows (Copy-Core)");
|
|
184
189
|
|
|
190
|
+
// A new workflow script must also be KNOWN to the things that check for it, or it
|
|
191
|
+
// ships and nothing notices when an installer stops copying it. cycle.js shipped in
|
|
192
|
+
// 1.3.0 while three call sites still said "review/audit/refactor".
|
|
193
|
+
const workflowNames = existsSync(workflowsDir)
|
|
194
|
+
? readdirSync(workflowsDir).filter((f) => f.endsWith(".js"))
|
|
195
|
+
: [];
|
|
196
|
+
const ci = existsSync(join(root, ".github/workflows/ci.yml")) ? read(".github/workflows/ci.yml") : "";
|
|
197
|
+
const dashDoctor = read("dashboard/server/doctor.js");
|
|
198
|
+
for (const f of workflowNames) {
|
|
199
|
+
if (!ci.includes(`workflows/${f}`))
|
|
200
|
+
fail(".github/workflows/ci.yml", `install dry-run never asserts .claude/workflows/${f}`);
|
|
201
|
+
if (!dashDoctor.includes(`'${f}'`))
|
|
202
|
+
fail("dashboard/server/doctor.js", `checkWorkflows() does not list ${f}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── dashboard: the metrics phase list is duplicated server/client ────────────
|
|
206
|
+
// A phase present in one and not the other parses fine and renders in no column —
|
|
207
|
+
// silently invisible data, which is how the cycle batch went unnoticed.
|
|
208
|
+
const phaseList = (text, file) => {
|
|
209
|
+
const m = text.match(/const PHASES = \[([^\]]*)\]/);
|
|
210
|
+
if (!m) { fail(file, "no `const PHASES = [...]` found"); return null; }
|
|
211
|
+
return m[1].split(",").map((s) => s.trim().replace(/^['"]|['"]$/g, "")).filter(Boolean);
|
|
212
|
+
};
|
|
213
|
+
const serverPhases = phaseList(read("dashboard/server/metrics.js"), "dashboard/server/metrics.js");
|
|
214
|
+
const clientPhases = phaseList(read("dashboard/app/src/components/MetricsPanel.jsx"),
|
|
215
|
+
"dashboard/app/src/components/MetricsPanel.jsx");
|
|
216
|
+
if (serverPhases && clientPhases && serverPhases.join("|") !== clientPhases.join("|"))
|
|
217
|
+
fail("dashboard/app/src/components/MetricsPanel.jsx",
|
|
218
|
+
`PHASES drifted from dashboard/server/metrics.js ([${clientPhases}] vs [${serverPhases}])`);
|
|
219
|
+
|
|
220
|
+
// ── packaging: no build artifacts in the published tarball ──────────────────
|
|
221
|
+
// `.npmignore` is INERT under an explicit package.json `files` allowlist, so its
|
|
222
|
+
// `__pycache__/` rule never fired — a maintainer who had compiled gate.py shipped
|
|
223
|
+
// their machine's bytecode cache. The negations in `files` are what actually work.
|
|
224
|
+
const pkg = JSON.parse(read("package.json"));
|
|
225
|
+
for (const negation of ["!core/hooks/__pycache__", "!**/*.pyc"])
|
|
226
|
+
if (!(pkg.files || []).includes(negation))
|
|
227
|
+
fail("package.json", `\`files\` must carry the ${negation} negation (.npmignore cannot do this)`);
|
|
228
|
+
for (const [name, src] of Object.entries(installers))
|
|
229
|
+
if (!src.includes("__pycache__"))
|
|
230
|
+
fail(name, "never scrubs hooks/__pycache__ — cp -R would carry it into the user's .claude");
|
|
231
|
+
if (!read("bin/cli.js").includes("__pycache__"))
|
|
232
|
+
fail("bin/cli.js", "copyCore() never excludes __pycache__ from the hooks copy");
|
|
233
|
+
|
|
185
234
|
// ── report ──────────────────────────────────────────────────────────────────
|
|
186
235
|
if (errors.length) {
|
|
187
236
|
console.error(`validate-core: ${errors.length} error(s)\n`);
|
|
@@ -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)}.topbar-right{display:flex;align-items:center;gap:12px}.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-template-columns:repeat(4,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-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{margin-top:3px}@media(max-width:640px){.board{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-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}.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}
|