cohorte 1.3.2 → 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 +119 -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 +57 -26
- 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 +8 -3
- package/core/workflows/audit.js +20 -3
- package/core/workflows/cycle.js +157 -41
- 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
package/core/workflows/cycle.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// cohorte — the FULL dev cycle as one deterministic workflow (opt-in):
|
|
2
|
-
// contract → build → [preflight →
|
|
2
|
+
// contract → build → [preflight → review(+cross-check) (∥ smoke if opted in) → fix]* → done.
|
|
3
3
|
//
|
|
4
|
-
// Invoke with args = {feature: "<feature_id>", maxRounds?:
|
|
5
|
-
// checkout (main checkout on the feature branch, or its worktree).
|
|
4
|
+
// Invoke with args = {feature: "<feature_id>", maxRounds?: 5, smoke?: true} in the
|
|
5
|
+
// feature's checkout (main checkout on the feature branch, or its worktree).
|
|
6
|
+
// Smoke is OFF by default — running the app every round is the human's call
|
|
7
|
+
// (/cycle <id> smoke, or standalone /smoke before /ship).
|
|
6
8
|
//
|
|
7
9
|
// The contract with the human: a workflow can NEVER ask a question mid-run, so
|
|
8
10
|
// everything decisional is moved to the edges —
|
|
@@ -24,8 +26,9 @@
|
|
|
24
26
|
// keeps its human confirmation. A SHIP exit ticks the DoD + stamps the
|
|
25
27
|
// freshness gate, so `/ship <id>` right after is a straight shot.
|
|
26
28
|
//
|
|
27
|
-
// Loop economics: smoke
|
|
28
|
-
// neither edits); fix rounds re-dispatch only the surfaces
|
|
29
|
+
// Loop economics: when smoke is opted in it runs CONCURRENTLY with review each
|
|
30
|
+
// round (both observe, neither edits); fix rounds re-dispatch only the surfaces
|
|
31
|
+
// owning findings;
|
|
29
32
|
// the loop is bounded by maxRounds AND by the session token budget if one is
|
|
30
33
|
// set. Disk state stays pipeline-coherent: reports staged to specs/reports/,
|
|
31
34
|
// unresolved findings appended to the spec's ## Remediation — a conversational
|
|
@@ -33,14 +36,14 @@
|
|
|
33
36
|
|
|
34
37
|
export const meta = {
|
|
35
38
|
name: 'cohorte-cycle',
|
|
36
|
-
description: 'Full cohorte dev cycle: contract, parallel build, then bounded
|
|
37
|
-
whenToUse: 'Only when the human explicitly asks to run the full dev-cycle workflow on a FROZEN spec. args = {feature: "<feature_id>", maxRounds?:
|
|
39
|
+
description: 'Full cohorte dev cycle: contract, parallel build, then bounded review→fix rounds (smoke opt-in via args.smoke); deferred questions in the output, never mid-run',
|
|
40
|
+
whenToUse: 'Only when the human explicitly asks to run the full dev-cycle workflow on a FROZEN spec. args = {feature: "<feature_id>", maxRounds?: 5, smoke?: true}.',
|
|
38
41
|
phases: [
|
|
39
42
|
{ title: 'Profile', detail: 'PIPELINE.md → JSON via profile-reader', model: 'haiku' },
|
|
40
43
|
{ title: 'Ready', detail: 'spec frozen + self-sufficient, or abort with the gaps', model: 'haiku' },
|
|
41
44
|
{ title: 'Contract', detail: 'author the frozen contract from spec §5' },
|
|
42
45
|
{ title: 'Build', detail: 'one implementer per surface, parallel' },
|
|
43
|
-
{ title: 'Verify', detail: 'per round: preflight →
|
|
46
|
+
{ title: 'Verify', detail: 'per round: preflight → review + cross-check (∥ smoke if opted in)' },
|
|
44
47
|
{ title: 'Fix', detail: 'per round: re-dispatch only the surfaces with findings' },
|
|
45
48
|
{ title: 'Close', detail: 'reports, Remediation/DoD, freshness stamp, metrics', model: 'haiku' },
|
|
46
49
|
],
|
|
@@ -48,8 +51,12 @@ export const meta = {
|
|
|
48
51
|
|
|
49
52
|
const feature = typeof args === 'string' ? args.trim() : args && args.feature
|
|
50
53
|
if (!feature) throw new Error('cohorte-cycle needs args = {feature: "<feature_id>"}')
|
|
51
|
-
// Runaway protection, not a target — the loop's real exit is 0 findings + PASS.
|
|
54
|
+
// Runaway protection, not a target — the loop's real exit is 0 findings (+ PASS if smoking).
|
|
52
55
|
const MAX_ROUNDS = Math.max(1, (args && args.maxRounds) || 5)
|
|
56
|
+
// Smoke is the human's call: booting infra every round is expensive, and lib-only
|
|
57
|
+
// projects have nothing to smoke. Off ⇒ the cycle verifies by review alone, the
|
|
58
|
+
// runtime-flow DoD boxes stay unticked, and /smoke remains available standalone.
|
|
59
|
+
const SMOKE_ON = !!(args && args.smoke)
|
|
53
60
|
|
|
54
61
|
const questions = [] // every deferred human decision ends up here — emitted at the END
|
|
55
62
|
const contractChanges = [] // contract re-authorings the loop performed (info, not questions)
|
|
@@ -184,7 +191,9 @@ const buildPrompt = s =>
|
|
|
184
191
|
'your surface): none'
|
|
185
192
|
const handoffs = await parallel(surfaces.map(s => () =>
|
|
186
193
|
agent(buildPrompt(s), { agentType: s.agent, label: `build:${s.key}`, phase: 'Build' })
|
|
187
|
-
|
|
194
|
+
// agent() resolves to null (never throws) when a subagent dies — wrapping
|
|
195
|
+
// unconditionally would hide every death from the `dead` check below.
|
|
196
|
+
.then(h => (h == null ? null : { key: s.key, handoff: h }))))
|
|
188
197
|
const built = handoffs.filter(Boolean)
|
|
189
198
|
const dead = surfaces.filter(s => !built.some(b => b.key === s.key)).map(s => s.key)
|
|
190
199
|
if (dead.length) questions.push(`implementer(s) died during build: ${dead.join(', ')} — inspect and re-run /build if their surface matters`)
|
|
@@ -207,8 +216,12 @@ const runPreflight = () => agent(
|
|
|
207
216
|
|
|
208
217
|
let verdict = null
|
|
209
218
|
let smokePass = false
|
|
219
|
+
let smokeFails = [] // last round's smoke failures — Close reports the real count
|
|
210
220
|
let open = [] // findings still open, each {severity,file,line,kind,problem,fix,src}
|
|
221
|
+
let unreviewed = [] // surfaces whose reviewer died this round — they carry NO verdict
|
|
211
222
|
let rounds = 0
|
|
223
|
+
let fixRounds = 0 // fix dispatches performed — the close step's `fix` usage ping needs it
|
|
224
|
+
let preflightRed = false // did the LAST round end on a red preflight (open/verdict then stale)?
|
|
212
225
|
|
|
213
226
|
// ── Phases 4/5 — bounded verify → fix rounds ────────────────────────────────
|
|
214
227
|
while (rounds < MAX_ROUNDS && (!budget.total || budget.remaining() > 30000)) {
|
|
@@ -218,13 +231,25 @@ while (rounds < MAX_ROUNDS && (!budget.total || budget.remaining() > 30000)) {
|
|
|
218
231
|
|
|
219
232
|
// 4a. preflight — mechanical red short-circuits straight to a fix round
|
|
220
233
|
const pre = await runPreflight()
|
|
221
|
-
|
|
234
|
+
preflightRed = !pre || !pre.pass
|
|
235
|
+
if (preflightRed) {
|
|
222
236
|
const tail = (pre && pre.tail) || ''
|
|
223
237
|
const hit = new Set(surfaces.filter(s => tail.includes(String(s.path))).map(s => s.key))
|
|
224
|
-
const targets = hit.size ? [...hit] : built.map(b => b.key)
|
|
238
|
+
const targets = (hit.size ? [...hit] : built.map(b => b.key)).filter(k => byKey[k])
|
|
239
|
+
// No target = nobody to dispatch, so the next round finds the same red gates
|
|
240
|
+
// and spins again: the loop would burn every remaining round doing literally
|
|
241
|
+
// nothing, then report a stale verdict. Stop and say why instead.
|
|
242
|
+
if (!targets.length) {
|
|
243
|
+
questions.push(
|
|
244
|
+
'the mechanical gates are RED but no surface owns the failure (nothing in the tail matches a ' +
|
|
245
|
+
`surface path, and no implementer survived the build) — fix it by hand, then rerun /cycle ${feature}. ` +
|
|
246
|
+
`Failure tail:\n${tail.slice(-1500)}`)
|
|
247
|
+
break
|
|
248
|
+
}
|
|
225
249
|
log(`Preflight red — mechanical fix round on: ${targets.join(', ')}`)
|
|
226
250
|
phase('Fix')
|
|
227
|
-
|
|
251
|
+
fixRounds++
|
|
252
|
+
await parallel(targets.map(k => () => agent(
|
|
228
253
|
`Fix loop for feature \`${feature}\` on your surface (**${k}**). Read \`PIPELINE.md\` first. ` +
|
|
229
254
|
`Contract: \`${contractFile || 'spec §5'}\` (read-only). Touch only \`${byKey[k].path}\`. ` +
|
|
230
255
|
'The mechanical gates (typecheck/lint/tests) are RED. Raw failure tail below — fix exactly ' +
|
|
@@ -243,12 +268,15 @@ while (rounds < MAX_ROUNDS && (!budget.total || budget.remaining() > 30000)) {
|
|
|
243
268
|
'4. Return the touched surfaces (empty array if no diff).',
|
|
244
269
|
{ model: 'haiku', label: 'stage-diff', phase: 'Verify', schema: STAGE, effort: 'low' },
|
|
245
270
|
)
|
|
246
|
-
|
|
271
|
+
// "The staging agent died" and "there is genuinely no diff" are different facts;
|
|
272
|
+
// conflating them diagnosed a dead agent as a wrong branch.
|
|
273
|
+
if (!staged) { questions.push('the diff-staging agent died — nothing could be reviewed this round; rerun the cycle'); break }
|
|
274
|
+
const touched = staged.surfaces || []
|
|
247
275
|
if (!touched.length) { questions.push(`no diff against ${base} after build — wrong branch/checkout?`); break }
|
|
248
276
|
|
|
249
|
-
// 4c.
|
|
277
|
+
// 4c. review(+cross-check), ∥ smoke only when the human opted in — both observe, neither edits
|
|
250
278
|
const [smoke, reviewed] = await parallel([
|
|
251
|
-
() => agent(
|
|
279
|
+
() => !SMOKE_ON ? Promise.resolve(null) : agent(
|
|
252
280
|
'Smoke-test one feature, per your agent instructions (bring it up, exercise the contract + §8 UI ' +
|
|
253
281
|
'flows, stage the full SMOKE REPORT, tear down; return the capped shape — pass + max 10 one-line ' +
|
|
254
282
|
`failures with a file hint when you have one). — Variable slots: feature ${feature} · spec: ` +
|
|
@@ -281,20 +309,40 @@ while (rounds < MAX_ROUNDS && (!budget.total || budget.remaining() > 30000)) {
|
|
|
281
309
|
])
|
|
282
310
|
|
|
283
311
|
smokePass = !!(smoke && smoke.pass)
|
|
284
|
-
|
|
285
|
-
|
|
312
|
+
smokeFails = (smoke && smoke.failures) || []
|
|
313
|
+
const reviewedOk = (reviewed || []).filter(Boolean)
|
|
314
|
+
// A reviewer that DIED returns null, and a dead reviewer yields zero findings —
|
|
315
|
+
// byte-identical to a clean surface. Unchecked, "every reviewer crashed" scores
|
|
316
|
+
// SHIP and this loop exits SHIP-READY, ticking the DoD and stamping the freshness
|
|
317
|
+
// gate over code nobody read. Track the surfaces that carry no verdict.
|
|
318
|
+
unreviewed = touched.filter(s => !reviewedOk.some(r => r.key === s.key)).map(s => s.key)
|
|
319
|
+
open = reviewedOk.flatMap(r => r.kept.map(f => ({ ...f, src: r.key })))
|
|
286
320
|
verdict = open.some(f => f.kind === 'security') ? 'BLOCK'
|
|
287
|
-
: open.
|
|
288
|
-
log(`Round ${rounds}: review ${verdict} (${open.length} finding(s)
|
|
321
|
+
: (open.length || unreviewed.length) ? 'REVISE' : 'SHIP'
|
|
322
|
+
log(`Round ${rounds}: review ${verdict} (${open.length} finding(s)` +
|
|
323
|
+
`${unreviewed.length ? `, ${unreviewed.length} surface(s) UNREVIEWED: ${unreviewed.join(', ')}` : ''}) · ` +
|
|
324
|
+
`smoke ${!SMOKE_ON ? 'SKIPPED' : smokePass ? 'PASS' : `FAIL:${smokeFails.length}`}`)
|
|
289
325
|
|
|
290
|
-
|
|
326
|
+
// The loop's contract is ZERO open findings on FULLY reviewed surfaces + a smoke
|
|
327
|
+
// PASS when smoking — a SHIP verdict alone (which older revisions granted despite
|
|
328
|
+
// HIGH/MEDIUM leftovers) is not enough. Smoke off ⇒ review alone decides, at the
|
|
329
|
+
// human's explicit risk.
|
|
330
|
+
if (!open.length && !unreviewed.length && (smokePass || !SMOKE_ON)) break
|
|
291
331
|
if (rounds >= MAX_ROUNDS) break
|
|
332
|
+
// Nothing to fix, but a reviewer died: spend the next round re-reviewing rather
|
|
333
|
+
// than dispatching a fix round with no items (which would fall through to the
|
|
334
|
+
// "findings map to no surface" abort and end the run on a misleading question).
|
|
335
|
+
if (!open.length && !smokeFails.length && unreviewed.length) {
|
|
336
|
+
log(`No findings, but ${unreviewed.join(', ')} went unreviewed — retrying the review round`)
|
|
337
|
+
continue
|
|
338
|
+
}
|
|
292
339
|
|
|
293
340
|
// 5. fix round. Contract-impacting findings stay INSIDE the loop: a
|
|
294
341
|
// lead-equivalent agent re-authors spec §5 + the contract file (implementers
|
|
295
342
|
// still never touch it — same division of labor as conversational /fix §1),
|
|
296
343
|
// and the surfaces it names re-dispatch against the updated shapes.
|
|
297
344
|
phase('Fix')
|
|
345
|
+
fixRounds++
|
|
298
346
|
const contractFindings = contractFile
|
|
299
347
|
? open.filter(f => String(f.file).startsWith(String(contract.path))) : []
|
|
300
348
|
let rippleSurfaces = []
|
|
@@ -309,10 +357,21 @@ while (rounds < MAX_ROUNDS && (!budget.total || budget.remaining() > 30000)) {
|
|
|
309
357
|
`(surfaces: ${surfaces.map(s => s.key).join(', ')}; when in doubt list them all).`,
|
|
310
358
|
{ label: 'contract-fix', phase: 'Fix' },
|
|
311
359
|
)
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
360
|
+
// A DEAD contract agent (agent() ⇒ null) must not be reported as a successful
|
|
361
|
+
// re-authoring: doing so both fabricates a `contractChanges` entry and hands
|
|
362
|
+
// every consuming surface a CRITICAL "the contract was RE-AUTHORED — realign"
|
|
363
|
+
// item pointing at a file nobody touched. Same failure shape as a dead
|
|
364
|
+
// reviewer scoring SHIP.
|
|
365
|
+
if (cf == null) {
|
|
366
|
+
questions.push(
|
|
367
|
+
`the contract needed a change (${contractFindings.length} finding(s) under ${contract.path}) but the ` +
|
|
368
|
+
`contract agent died — the contract is UNCHANGED; re-run /fix ${feature} or author it yourself`)
|
|
369
|
+
} else {
|
|
370
|
+
const [what, keys] = String(cf).split('||').map(x => x && x.trim())
|
|
371
|
+
contractChanges.push(what || 'contract re-authored (agent gave no summary)')
|
|
372
|
+
rippleSurfaces = (keys ? keys.split(',').map(k => k.trim()) : surfaces.map(s => s.key))
|
|
373
|
+
.filter(k => byKey[k])
|
|
374
|
+
}
|
|
316
375
|
}
|
|
317
376
|
|
|
318
377
|
// Group the remaining findings by owning surface; smoke failures ride with
|
|
@@ -322,9 +381,22 @@ while (rounds < MAX_ROUNDS && (!budget.total || budget.remaining() > 30000)) {
|
|
|
322
381
|
(perSurface[k] = perSurface[k] || []).push(
|
|
323
382
|
`- [ ] CRITICAL · ${contractFile} · spec-violation · the contract was RE-AUTHORED this round (${contractChanges[contractChanges.length - 1]}) — re-read it and realign your surface's implementation + tests`)
|
|
324
383
|
}
|
|
384
|
+
// A finding whose file sits under no surface path AND whose reporting surface
|
|
385
|
+
// is not a live key has no owner: it stays in `open` (so the loop can never
|
|
386
|
+
// exit clean) while nobody is ever dispatched to fix it — a guaranteed burn to
|
|
387
|
+
// the round cap. Surface them instead of dropping them silently.
|
|
388
|
+
const orphaned = []
|
|
325
389
|
for (const f of open.filter(f => !contractFindings.includes(f))) {
|
|
326
390
|
const k = surfaceOf(f.file) || f.src
|
|
327
391
|
if (byKey[k]) (perSurface[k] = perSurface[k] || []).push(itemLine(f))
|
|
392
|
+
else orphaned.push(f)
|
|
393
|
+
}
|
|
394
|
+
if (orphaned.length) {
|
|
395
|
+
questions.push(
|
|
396
|
+
`${orphaned.length} finding(s) map to no surface and were never dispatched — fix them by hand ` +
|
|
397
|
+
`or give their tree a surface in PIPELINE.md: ` +
|
|
398
|
+
orphaned.slice(0, 5).map(f => `${f.file}:${f.line}`).join(', ') +
|
|
399
|
+
(orphaned.length > 5 ? `, +${orphaned.length - 5} more` : ''))
|
|
328
400
|
}
|
|
329
401
|
const fallbackKey = Object.keys(perSurface).sort((a, b) => perSurface[b].length - perSurface[a].length)[0]
|
|
330
402
|
|| (built[0] && built[0].key)
|
|
@@ -343,55 +415,99 @@ while (rounds < MAX_ROUNDS && (!budget.total || budget.remaining() > 30000)) {
|
|
|
343
415
|
{ agentType: byKey[k].agent, label: `fix:${k}`, phase: 'Fix' })))
|
|
344
416
|
}
|
|
345
417
|
|
|
346
|
-
|
|
418
|
+
const smokeOk = !SMOKE_ON || smokePass
|
|
419
|
+
const smokeLabel = !SMOKE_ON ? 'SKIPPED' : smokePass ? 'PASS' : 'FAIL'
|
|
420
|
+
if (preflightRed) {
|
|
421
|
+
questions.push('the last round ended on a RED preflight — the reported verdict/findings are from the previous round and may already be fixed; rerun /review after the mechanical fixes land')
|
|
422
|
+
}
|
|
423
|
+
// Only meaningful when the last round actually reviewed: after a preflight-red
|
|
424
|
+
// round `unreviewed` still holds the PREVIOUS round's value, and preflightRed's
|
|
425
|
+
// own question already says the reported state is one round stale.
|
|
426
|
+
if (unreviewed.length && !preflightRed) {
|
|
427
|
+
questions.push(`no reviewer completed on: ${unreviewed.join(', ')} — those surfaces are NOT reviewed (the verdict covers the others only); rerun /review ${feature}`)
|
|
428
|
+
}
|
|
429
|
+
if (rounds >= MAX_ROUNDS && !(verdict === 'SHIP' && smokeOk)) {
|
|
347
430
|
questions.push(`round cap (${MAX_ROUNDS}) reached with ${open.length} finding(s) open — rerun the cycle (maxRounds higher) or continue with /fix ${feature} + /review`)
|
|
348
431
|
}
|
|
349
|
-
if (budget.total && budget.remaining() <= 30000 && !(verdict === 'SHIP' &&
|
|
432
|
+
if (budget.total && budget.remaining() <= 30000 && !(verdict === 'SHIP' && smokeOk)) {
|
|
350
433
|
questions.push('token budget nearly spent — cycle stopped early; rerun the cycle or continue conversationally')
|
|
351
434
|
}
|
|
352
435
|
|
|
353
436
|
// ── Phase 6 — close: reports, spec bookkeeping, freshness stamp, metrics ────
|
|
354
437
|
phase('Close')
|
|
355
|
-
const success = verdict === 'SHIP' &&
|
|
438
|
+
const success = verdict === 'SHIP' && smokeOk && !unreviewed.length
|
|
356
439
|
const findingLine = f => `- **[${f.severity}]** \`${f.file}:${f.line}\` · ${f.kind} · ${f.problem} → **Fix:** ${f.fix}`
|
|
357
440
|
const reportBody = [
|
|
358
441
|
'# REVIEW REPORT', `feature_id: ${feature} · merged by cohorte-cycle workflow (round ${rounds})`, '',
|
|
359
|
-
`Verdict: ${verdict || 'NOT-REACHED'} · smoke: ${
|
|
442
|
+
`Verdict: ${verdict || 'NOT-REACHED'} · smoke: ${smokeLabel}`, '', '## Findings', '',
|
|
360
443
|
open.length ? open.map(findingLine).join('\n') : 'None.',
|
|
444
|
+
...(unreviewed.length ? ['', '## NOT reviewed (reviewer died — no verdict on these)', '',
|
|
445
|
+
unreviewed.map(k => `- \`${k}\` — rerun /review ${feature}`).join('\n')] : []),
|
|
361
446
|
].join('\n')
|
|
362
|
-
|
|
447
|
+
// Per-surface results for the metrics line. `surfaces` means SURFACES: putting the
|
|
448
|
+
// run summary (rounds/verdict/smoke) in there made the dashboard render them as
|
|
449
|
+
// three phantom surface rows and score `rounds:"1"` as a failed surface.
|
|
450
|
+
const surfaceResults = built.map(b => `"${b.key}":"${
|
|
451
|
+
unreviewed.includes(b.key) ? 'error' : `${verdict || 'none'}:${open.filter(f => f.src === b.key).length}`}"`).join(',')
|
|
452
|
+
const closed = await agent(
|
|
363
453
|
`Close a cohorte cycle run for feature ${feature}, mechanically:\n` +
|
|
364
454
|
`1. Write EXACTLY this to specs/reports/${feature}.md (overwrite):\n<<<REPORT\n${reportBody}\nREPORT\n` +
|
|
365
455
|
(success
|
|
366
456
|
? `2. In specs/${feature}.md tick the DoD boxes the cycle verified (spec conformance + copy language — ` +
|
|
367
|
-
'review SHIP; tests/lint/typecheck — green preflight;
|
|
457
|
+
'review SHIP; tests/lint/typecheck — green preflight; ' +
|
|
458
|
+
(SMOKE_ON ? 'runtime flows — smoke PASS' : 'runtime flows — LEAVE UNTICKED, smoke was skipped this run') +
|
|
459
|
+
'); leave anything ' +
|
|
368
460
|
'unverified unticked. 3. Stamp the freshness gate in the spec front-matter, exactly as /review §3 ' +
|
|
369
461
|
`does: BASE=$(git merge-base ${base} HEAD); reviewed_base: $BASE; reviewed_digest: ` +
|
|
370
462
|
`$(git diff $BASE -- . ':(exclude)specs/' | sha256sum | cut -c1-16).\n` :
|
|
371
463
|
`2. Append the open findings to specs/${feature}.md \`## Remediation\` under a subheading ` +
|
|
372
464
|
`\`### cohorte-cycle round ${rounds}\`, one \`- [ ]\` line each (so a conversational /fix picks them ` +
|
|
373
465
|
'up):\n' + (open.map(itemLine).join('\n') || '(none — see questions in the workflow result)') + '\n' +
|
|
374
|
-
`Set the front-matter status: in-review.\n`) +
|
|
466
|
+
`3. Set the front-matter status: in-review.\n`) +
|
|
375
467
|
`4. Append ONE metrics line to $(dirname "$(git rev-parse --git-common-dir)")/.claude/pipeline-metrics.jsonl: ` +
|
|
376
|
-
`{"ts":"<ISO now>","feature":"${feature}","phase":"cycle","seconds":0,"
|
|
377
|
-
'5. Chain the opt-in usage pings (all funnel phases this run executed, 0 seconds each
|
|
378
|
-
|
|
379
|
-
|
|
468
|
+
`{"ts":"<ISO now>","feature":"${feature}","phase":"cycle","seconds":0,"rounds":${rounds},"smoke":"${smokeLabel}","surfaces":{${surfaceResults}}}\n` +
|
|
469
|
+
'5. Chain the opt-in usage pings (all funnel phases this run executed, 0 seconds each; ' +
|
|
470
|
+
'<core> = .claude if .claude/pipeline/scripts/telemetry-send.sh exists, else ~/.claude — script on neither ⇒ skip the pings): ' +
|
|
471
|
+
// One result per DECLARED surface, not per survivor: `built` holds only the
|
|
472
|
+
// implementers that returned, so mapping over it reported 2-of-3 as "ok,ok" and
|
|
473
|
+
// the dead one vanished from the funnel entirely.
|
|
474
|
+
`<core>/pipeline/scripts/telemetry-send.sh build "${feature}" 0 "${
|
|
475
|
+
surfaces.map(s => (built.some(b => b.key === s.key) ? 'ok' : 'error')).join(',') || 'error'}" || true; ` +
|
|
476
|
+
(SMOKE_ON ? `<core>/pipeline/scripts/telemetry-send.sh smoke "${feature}" 0 "${smokePass ? 'PASS' : 'FAIL:' + smokeFails.length}" || true; ` : '') +
|
|
477
|
+
(fixRounds ? `<core>/pipeline/scripts/telemetry-send.sh fix "${feature}" 0 "rounds:${fixRounds}" || true; ` : '') +
|
|
380
478
|
`<core>/pipeline/scripts/telemetry-send.sh review "${feature}" 0 "${verdict || 'none'}:${open.length}" || true\n` +
|
|
381
479
|
'Return the single word: done.',
|
|
382
480
|
{ model: 'haiku', label: 'close', effort: 'low' },
|
|
383
481
|
)
|
|
384
482
|
|
|
483
|
+
// The close agent is what actually writes the report, ticks the DoD, stamps the
|
|
484
|
+
// freshness gate and appends the metrics. If it died, NONE of that is on disk —
|
|
485
|
+
// and "SHIP-READY · /ship is a straight shot" would be a claim about a stamp that
|
|
486
|
+
// was never written (/ship's freshness gate skips silently when the fields are
|
|
487
|
+
// absent, so the human would ship on it).
|
|
488
|
+
const closeOk = closed != null && /done/i.test(String(closed))
|
|
489
|
+
if (!closeOk) {
|
|
490
|
+
questions.push(
|
|
491
|
+
'the close agent died — the report, DoD ticks, freshness stamp and metrics were NEVER written. ' +
|
|
492
|
+
'The verdict above is real, but nothing landed on disk: rerun the cycle, or run /review ' +
|
|
493
|
+
`${feature} to re-stamp before /ship.`)
|
|
494
|
+
}
|
|
495
|
+
|
|
385
496
|
return {
|
|
386
|
-
outcome: success ? 'SHIP-READY' : 'STOPPED',
|
|
497
|
+
outcome: success && closeOk ? 'SHIP-READY' : 'STOPPED',
|
|
387
498
|
rounds,
|
|
388
499
|
verdict: verdict || 'not reached',
|
|
389
|
-
smoke:
|
|
500
|
+
smoke: smokeLabel,
|
|
390
501
|
contractChanges, // re-authorings the loop performed itself — review them in the diff
|
|
502
|
+
unreviewedSurfaces: unreviewed, // reviewers that died — these carry NO verdict
|
|
391
503
|
openFindings: open.slice(0, 10).map(f => `[${f.severity}] ${f.file}:${f.line} — ${f.problem}`),
|
|
392
504
|
questions, // everything deferred to you — empty when /brainstorm + /spec did their job
|
|
393
|
-
report: `specs/reports/${feature}.md
|
|
394
|
-
next:
|
|
395
|
-
?
|
|
505
|
+
report: closeOk ? `specs/reports/${feature}.md` : '(NOT written — the close agent died)',
|
|
506
|
+
next: !closeOk
|
|
507
|
+
? `nothing was written to disk (close agent died) — rerun the cycle, or /review ${feature} to re-stamp`
|
|
508
|
+
: success
|
|
509
|
+
? (SMOKE_ON
|
|
510
|
+
? `/ship ${feature} — DoD ticked + freshness stamped, ship is a straight shot (human confirm stays)`
|
|
511
|
+
: `/ship ${feature} — or /smoke ${feature} first: the cycle skipped it (opt-in), nobody has RUN this code; the runtime DoD boxes are unticked`)
|
|
396
512
|
: `answer the questions, then rerun the cycle — or continue with /fix ${feature} + /review (Remediation is up to date)`,
|
|
397
513
|
}
|
|
@@ -87,7 +87,10 @@ const backlog = await agent(
|
|
|
87
87
|
`Requested domains: ${wanted === 'all' ? 'all' : wanted.join(', ')} — return only those (all ⇒ every domain with open items).`,
|
|
88
88
|
{ model: 'haiku', label: 'read-backlog', schema: OPEN, effort: 'low' },
|
|
89
89
|
)
|
|
90
|
-
|
|
90
|
+
// A dead reader is not "the backlog is empty" — reporting it as such sends the
|
|
91
|
+
// human to re-run /audit on a backlog that is already there.
|
|
92
|
+
if (!backlog) return { error: 'the backlog-reading agent died — nothing was read; re-run the refactor workflow' }
|
|
93
|
+
const open = (backlog.domains || []).filter(d => d.items.length)
|
|
91
94
|
if (!open.length) return { error: 'no open backlog items for the requested domains — run /audit (or the audit workflow) first' }
|
|
92
95
|
|
|
93
96
|
const big = open.filter(d => d.items.length >= MIN_ITEMS)
|
|
@@ -167,12 +170,16 @@ results.push(...restResults.filter(Boolean))
|
|
|
167
170
|
// ── Phase 5 — tick the cleared items ─────────────────────────────────────────
|
|
168
171
|
phase('Tick')
|
|
169
172
|
const clearedAll = results.flatMap(r => r.cleared)
|
|
173
|
+
let tickedOk = true
|
|
170
174
|
if (clearedAll.length) {
|
|
171
|
-
await agent(
|
|
175
|
+
const ticked = await agent(
|
|
172
176
|
'In specs/refactor-backlog.md flip EXACTLY these open `- [ ]` item lines to `- [x]` (match verbatim, ' +
|
|
173
177
|
'leave every other line untouched), then return the single word done:\n' + clearedAll.join('\n'),
|
|
174
178
|
{ model: 'haiku', label: 'tick-backlog', effort: 'low' },
|
|
175
179
|
)
|
|
180
|
+
// Reporting items as cleared while the backlog still shows them open means the
|
|
181
|
+
// next /refactor re-dispatches work that is already done.
|
|
182
|
+
tickedOk = ticked != null && /done/i.test(String(ticked))
|
|
176
183
|
}
|
|
177
184
|
|
|
178
185
|
return {
|
|
@@ -181,7 +188,11 @@ return {
|
|
|
181
188
|
}])),
|
|
182
189
|
skippedSmall: Object.fromEntries(small.map(d => [d.key, d.items.length])),
|
|
183
190
|
stillOpen: results.flatMap(r => r.remaining.map(line => `[${r.key}] ${line}`)).slice(0, 15),
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
191
|
+
backlogTicked: tickedOk,
|
|
192
|
+
next: !tickedOk
|
|
193
|
+
? `${clearedAll.length} item(s) were cleared in code but NOT ticked off specs/refactor-backlog.md ` +
|
|
194
|
+
'(the ticking agent died) — tick them by hand, or the next /refactor re-dispatches finished work'
|
|
195
|
+
: results.some(r => r.remaining.length || !r.gatesGreen)
|
|
196
|
+
? 'items remain — finish them with the conversational /refactor <domain>'
|
|
197
|
+
: 'all dispatched domains clean — optionally close with one final /audit',
|
|
187
198
|
}
|
package/core/workflows/review.js
CHANGED
|
@@ -132,7 +132,17 @@ const staged = await agent(
|
|
|
132
132
|
'4. Return the touched surfaces with their staged diff path and changed-file list. No changed paths at all ⇒ return an empty surfaces array.',
|
|
133
133
|
{ model: 'haiku', label: 'stage-diff', schema: STAGE, effort: 'low' },
|
|
134
134
|
)
|
|
135
|
-
|
|
135
|
+
// A DEAD staging agent returns null, which is not the same fact as "the diff is
|
|
136
|
+
// empty" — conflating them handed back verdict SHIP ("nothing to review") for a
|
|
137
|
+
// feature nobody had looked at. Distinguish them.
|
|
138
|
+
if (!staged) {
|
|
139
|
+
return {
|
|
140
|
+
verdict: 'ABORTED',
|
|
141
|
+
reason: 'the diff-staging agent died — no reviewer was spawned and nothing was reviewed',
|
|
142
|
+
next: `re-run the review workflow, or /review ${feature} conversationally`,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const touched = staged.surfaces || []
|
|
136
146
|
if (!touched.length) return { verdict: 'SHIP', reason: `no diff against ${base} — nothing to review`, findings: 0 }
|
|
137
147
|
log(`Touched surfaces: ${touched.map(s => s.key).join(', ')}`)
|
|
138
148
|
|
|
@@ -172,14 +182,27 @@ const reviewed = await pipeline(
|
|
|
172
182
|
)
|
|
173
183
|
|
|
174
184
|
const results = reviewed.filter(Boolean)
|
|
185
|
+
// A reviewer that DIED returns null — and a dead reviewer produces zero findings,
|
|
186
|
+
// which is byte-identical to a clean surface. Left unchecked, "every reviewer
|
|
187
|
+
// crashed" scores SHIP: the strongest possible verdict from the weakest possible
|
|
188
|
+
// evidence. Name the unreviewed surfaces and refuse to certify them.
|
|
189
|
+
const unreviewed = touched.filter(s => !results.some(r => r.key === s.key)).map(s => s.key)
|
|
190
|
+
if (unreviewed.length) log(`Reviewer died on: ${unreviewed.join(', ')} — those surfaces are NOT reviewed`)
|
|
175
191
|
const kept = results.flatMap(r => r.kept.map(f => ({ ...f, surface: r.key })))
|
|
176
192
|
const refuted = results.flatMap(r => r.refuted.map(f => ({ ...f, surface: r.key })))
|
|
177
193
|
const counts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 }
|
|
178
194
|
for (const f of kept) counts[f.severity] = (counts[f.severity] || 0) + 1
|
|
179
195
|
// Verdict from the findings that SURVIVED the cross-check (a refuted CRITICAL
|
|
180
|
-
// must not force a fix loop): security ⇒ BLOCK, CRITICAL ⇒ REVISE, else SHIP
|
|
196
|
+
// must not force a fix loop): security ⇒ BLOCK, CRITICAL ⇒ REVISE, else SHIP —
|
|
197
|
+
// but never SHIP while a surface went unreviewed (absence of evidence, not
|
|
198
|
+
// evidence of absence).
|
|
181
199
|
const verdict = kept.some(f => f.kind === 'security') ? 'BLOCK'
|
|
182
|
-
: kept.some(f => f.severity === 'CRITICAL') ? 'REVISE'
|
|
200
|
+
: kept.some(f => f.severity === 'CRITICAL') ? 'REVISE'
|
|
201
|
+
: unreviewed.length ? 'REVISE' : 'SHIP'
|
|
202
|
+
// Only a SHIP whose leftovers are all LOW is a clean bill of health. The
|
|
203
|
+
// conversational /review routes any surviving CRITICAL/HIGH/security to /fix, so
|
|
204
|
+
// this path must not answer "/ship" on a SHIP that still carries HIGH findings.
|
|
205
|
+
const clean = verdict === 'SHIP' && kept.every(f => f.severity === 'LOW')
|
|
183
206
|
|
|
184
207
|
// ── Phase 5 — stage the merged report; only the verdict leaves the workflow ──
|
|
185
208
|
phase('Merge')
|
|
@@ -191,25 +214,54 @@ const reportBody = [
|
|
|
191
214
|
...['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'].map(s => `| ${s} | ${counts[s] || 0} |`), '',
|
|
192
215
|
`Verdict: ${verdict}`, '', '## Findings', '',
|
|
193
216
|
kept.length ? kept.map(findingLine).join('\n') : 'None.',
|
|
217
|
+
...(unreviewed.length ? ['', '## NOT reviewed (reviewer died — no verdict on these)', '',
|
|
218
|
+
unreviewed.map(k => `- \`${k}\` — re-run /review ${feature} (or the review workflow)`).join('\n')] : []),
|
|
194
219
|
...(refuted.length ? ['', '## Refuted by cross-check (no action needed)', '',
|
|
195
220
|
refuted.map(f => `- ${f.file}:${f.line} · ${f.problem} — refuted: ${f.reason}`).join('\n')] : []),
|
|
196
221
|
].join('\n')
|
|
197
|
-
await agent(
|
|
222
|
+
const staging = await agent(
|
|
198
223
|
`Stage a cohorte review report and its metrics, mechanically:\n` +
|
|
199
224
|
`1. Write EXACTLY this content to specs/reports/${feature}.md (overwrite):\n<<<REPORT\n${reportBody}\nREPORT\n` +
|
|
200
225
|
`2. Append one line to $(dirname "$(git rev-parse --git-common-dir)")/.claude/pipeline-metrics.jsonl: ` +
|
|
201
226
|
`{"ts":"<ISO now>","feature":"${feature}","phase":"review","seconds":0,"surfaces":{${results.map(r => `"${r.key}":"${verdict}:${r.kept.length}"`).join(',')}}}\n` +
|
|
202
|
-
`3. Chain the opt-in usage ping: <core>/pipeline/scripts/telemetry-send.sh review "${feature}" 0 "${verdict}:${kept.length}" || true
|
|
227
|
+
`3. Chain the opt-in usage ping: <core>/pipeline/scripts/telemetry-send.sh review "${feature}" 0 "${verdict}:${kept.length}" || true ` +
|
|
228
|
+
'(<core> = .claude if .claude/pipeline/scripts/telemetry-send.sh exists, else ~/.claude; script on neither ⇒ skip the ping).\n' +
|
|
229
|
+
// Stamp + tick only when nothing above LOW survived: the conversational /review
|
|
230
|
+
// keeps the stamp only for LOW findings, and a SHIP verdict here can still carry
|
|
231
|
+
// HIGH/MEDIUM ones — certifying those for /ship would ship known defects. A dead
|
|
232
|
+
// reviewer already forced the verdict off SHIP, so `clean` covers that too.
|
|
233
|
+
(clean
|
|
234
|
+
? `4. Stamp the freshness gate in specs/${feature}.md's front-matter, exactly as the conversational /review §3 does ` +
|
|
235
|
+
`(so /ship can prove the reviewed code is what ships): BASE=$(git merge-base ${base} HEAD); set reviewed_base: $BASE and ` +
|
|
236
|
+
`reviewed_digest: $(git diff $BASE -- . ':(exclude)specs/' | sha256sum | cut -c1-16). ` +
|
|
237
|
+
'5. Tick the spec DoD boxes this run verified (spec conformance + copy language — review SHIP; tests/lint/typecheck — green preflight); leave the rest unticked.\n'
|
|
238
|
+
: '') +
|
|
203
239
|
'Return the single word: done.',
|
|
204
240
|
{ model: 'haiku', label: 'stage-report', effort: 'low' },
|
|
205
241
|
)
|
|
206
242
|
|
|
243
|
+
// The staging agent writes the report, the metrics line, and (when clean) the
|
|
244
|
+
// freshness stamp + DoD ticks. If it died, none of that is on disk — returning
|
|
245
|
+
// `report: <path>` and "/ship" would point the human at a file that does not
|
|
246
|
+
// exist and certify a stamp that was never written.
|
|
247
|
+
const staged_ok = staging != null && /done/i.test(String(staging))
|
|
248
|
+
|
|
207
249
|
return {
|
|
208
250
|
verdict,
|
|
209
251
|
counts,
|
|
210
252
|
refutedByCrossCheck: refuted.length,
|
|
253
|
+
reportStaged: staged_ok,
|
|
254
|
+
unreviewedSurfaces: unreviewed, // reviewers that died — these carry NO verdict
|
|
211
255
|
criticals: kept.filter(f => f.severity === 'CRITICAL' || f.kind === 'security')
|
|
212
256
|
.map(f => `[${f.surface}] ${f.file}:${f.line} — ${f.problem}`),
|
|
213
|
-
report: `specs/reports/${feature}.md
|
|
214
|
-
next:
|
|
257
|
+
report: staged_ok ? `specs/reports/${feature}.md` : '(NOT written — the staging agent died)',
|
|
258
|
+
next: !staged_ok
|
|
259
|
+
? `the report/metrics/freshness stamp were NEVER written (staging agent died) — the verdict above is real, but nothing is on disk: re-run the review workflow, or /review ${feature}`
|
|
260
|
+
: unreviewed.length
|
|
261
|
+
? `re-run the review — no reviewer completed on: ${unreviewed.join(', ')}`
|
|
262
|
+
: clean
|
|
263
|
+
? `/ship ${feature} (DoD ticked + freshness stamped)`
|
|
264
|
+
: verdict === 'SHIP'
|
|
265
|
+
? `/fix ${feature} — SHIP verdict, but ${kept.length} finding(s) above LOW survived; park them in specs/refactor-backlog.md instead if you deliberately defer them`
|
|
266
|
+
: `/fix ${feature}`,
|
|
215
267
|
}
|
package/dashboard/README.md
CHANGED
|
@@ -28,23 +28,40 @@ dashboard/
|
|
|
28
28
|
| `yaml.js` | minimal block-YAML subset parser (for the `pipeline-profile` block + the config) |
|
|
29
29
|
| `fleet.js` | tracked-project registry (`~/.claude/cohorte-dashboard.json`) + folder browse |
|
|
30
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 |
|
|
31
32
|
|
|
32
33
|
## API
|
|
33
34
|
|
|
34
35
|
Read: `GET /api/versions`, `/api/state?project=`, `/api/fleet`, `/api/browse?dir=`,
|
|
35
|
-
`/api/kanban?project=`. Mutate: `POST /api/projects` (add) ·
|
|
36
|
+
`/api/kanban?project=`, `/api/metrics?project=`. Mutate: `POST /api/projects` (add) ·
|
|
37
|
+
`DELETE /api/projects` (remove);
|
|
36
38
|
`POST /api/action` — `{action:'install'|'update', scope, project}` (spawns the CLI),
|
|
37
39
|
`{action:'reset', project, purgeSpecs}` (backup+wipe+reinstall), or
|
|
38
|
-
`{action:'claude', command:'/init-pipeline'|'/update-pipeline', project}` (headless
|
|
40
|
+
`{action:'claude', command:'/init-pipeline'|'/update-pipeline'|'/audit', project}` (headless
|
|
41
|
+
`claude -p`).
|
|
39
42
|
Action responses stream chunked plain text ending in `__EXIT__ <code>`; the client reads the
|
|
40
43
|
`ReadableStream` (`app/src/api.js` `streamAction`).
|
|
41
44
|
|
|
42
45
|
## Security
|
|
43
46
|
|
|
44
47
|
Binds `127.0.0.1` by default — the action endpoints **execute code**. `--host=ADDR` opts into
|
|
45
|
-
exposing it (prints a warning).
|
|
46
|
-
|
|
47
|
-
|
|
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.
|
|
48
65
|
|
|
49
66
|
## Dev loop
|
|
50
67
|
|
|
@@ -0,0 +1 @@
|
|
|
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-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-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}
|