cohorte 1.3.3 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +133 -0
- package/README.md +4 -7
- package/bin/cli.js +49 -4
- package/core/agents/implementer.template.md +10 -5
- package/core/agents/profile-reader.md +22 -0
- package/core/commands/doctor.md +8 -4
- 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 +88 -7
- package/core/workflows/refactor.js +85 -9
- package/core/workflows/review.js +132 -11
- 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 +68 -20
- package/dashboard/server/fleet.js +19 -5
- package/dashboard/server/index.js +79 -7
- package/dashboard/server/metrics.js +16 -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 +23 -26
- package/scripts/kanban-move.sh +34 -20
- package/scripts/metrics/collect.mjs +495 -0
- package/scripts/metrics/prices.json +39 -0
- 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 +362 -0
- package/scripts/test-gate.mjs +273 -0
- package/scripts/test-metrics.mjs +135 -0
- package/scripts/test-workflows.mjs +321 -0
- package/scripts/validate-core.mjs +51 -1
- package/core/commands/cycle.md +0 -54
- package/core/workflows/cycle.js +0 -407
- package/dashboard/dist/assets/index-Cj0SpgEY.css +0 -1
|
@@ -29,13 +29,72 @@ export const meta = {
|
|
|
29
29
|
// /refactor handles it with less overhead than a workflow run.
|
|
30
30
|
const MIN_ITEMS = 5
|
|
31
31
|
|
|
32
|
+
// The Workflow runtime hands `args` to a script verbatim, so a caller that passes a
|
|
33
|
+
// JSON-ENCODED STRING instead of a real object gets that string back here. The old
|
|
34
|
+
// `typeof args === 'string' ? args.trim()` then took the whole blob as the value — which
|
|
35
|
+
// is how a report landed on disk named `specs/reports/{"feature": "x"}.md`, and how
|
|
36
|
+
// maxRounds/smoke were silently dropped on the same run. Parse it back into the object
|
|
37
|
+
// it was meant to be; a bare slug stays valid shorthand.
|
|
38
|
+
const ARGS = (() => {
|
|
39
|
+
if (typeof args === 'string') {
|
|
40
|
+
const t = args.trim()
|
|
41
|
+
if (t.startsWith('{')) {
|
|
42
|
+
try { const o = JSON.parse(t); if (o && typeof o === 'object' && !Array.isArray(o)) return o } catch {}
|
|
43
|
+
}
|
|
44
|
+
return { feature: t, target: t }
|
|
45
|
+
}
|
|
46
|
+
return args && typeof args === 'object' ? args : {}
|
|
47
|
+
})()
|
|
48
|
+
|
|
32
49
|
const wanted = (() => {
|
|
33
|
-
const d =
|
|
50
|
+
const d = ARGS.domains
|
|
34
51
|
if (!d || d === 'all') return 'all'
|
|
35
52
|
return Array.isArray(d) ? d : [String(d)]
|
|
36
53
|
})()
|
|
37
54
|
|
|
38
|
-
|
|
55
|
+
// The profile-reader returns through a StructuredOutput tool call, and a haiku agent
|
|
56
|
+
// intermittently nests the whole profile as a JSON *string* under a single wrapper field
|
|
57
|
+
// ({"output": "{\"surfaces\": …}"}) instead of putting the profile's keys at the top level.
|
|
58
|
+
// The schema here used to be {type:'object', additionalProperties:true} — no declared
|
|
59
|
+
// properties, no required keys — so that wrapper validated cleanly and every field then read
|
|
60
|
+
// as undefined: `surfaces` fell back to [], parallel([]) dispatched zero agents, the
|
|
61
|
+
// dead-agent guard had no surfaces to find missing, and the run reported a verdict having
|
|
62
|
+
// done nothing. On the surface it is indistinguishable from a clean run with an empty diff.
|
|
63
|
+
// Declaring the shape gives the tool layer something to validate and the agent something to
|
|
64
|
+
// aim at; unwrapProfile() salvages a wrapped return that still gets through; and the
|
|
65
|
+
// zero-surface abort below makes the silent-success path impossible either way.
|
|
66
|
+
// See also the structured-output section of core/agents/profile-reader.md.
|
|
67
|
+
const PROFILE = {
|
|
68
|
+
type: 'object', additionalProperties: true,
|
|
69
|
+
properties: {
|
|
70
|
+
error: { type: 'string', description: 'set ONLY when PIPELINE.md is missing or unparseable' },
|
|
71
|
+
surfaces: {
|
|
72
|
+
type: 'array',
|
|
73
|
+
description: "one entry per surface, at the TOP LEVEL of this object — never a JSON string",
|
|
74
|
+
items: {
|
|
75
|
+
type: 'object', required: ['key'], additionalProperties: true,
|
|
76
|
+
properties: { key: { type: 'string' }, path: { type: 'string' }, agent: { type: 'string' } },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Salvage a profile handed back as JSON text rather than as an object — either the whole
|
|
83
|
+
// return, or nested under a single wrapper field. Anything already shaped like a profile
|
|
84
|
+
// (has `surfaces`, or is the documented `{error}` failure shape) passes through untouched.
|
|
85
|
+
const unwrapProfile = p => {
|
|
86
|
+
if (typeof p === 'string') { try { return JSON.parse(p) } catch { return null } }
|
|
87
|
+
if (!p || typeof p !== 'object') return null
|
|
88
|
+
if (Array.isArray(p.surfaces) || p.error) return p
|
|
89
|
+
for (const v of Object.values(p)) {
|
|
90
|
+
if (typeof v !== 'string') continue
|
|
91
|
+
try {
|
|
92
|
+
const inner = JSON.parse(v)
|
|
93
|
+
if (inner && typeof inner === 'object' && !Array.isArray(inner)) return inner
|
|
94
|
+
} catch {}
|
|
95
|
+
}
|
|
96
|
+
return p
|
|
97
|
+
}
|
|
39
98
|
|
|
40
99
|
const OPEN = {
|
|
41
100
|
type: 'object', required: ['domains'], additionalProperties: false,
|
|
@@ -65,14 +124,20 @@ const VERIFY = {
|
|
|
65
124
|
|
|
66
125
|
// ── Phase 0 — profile ────────────────────────────────────────────────────────
|
|
67
126
|
phase('Profile')
|
|
68
|
-
const profile = await agent(
|
|
127
|
+
const profile = unwrapProfile(await agent(
|
|
69
128
|
'Return this project\'s PIPELINE.md `yaml pipeline-profile` block as JSON, per your instructions.',
|
|
70
129
|
{ agentType: 'profile-reader', label: 'profile', schema: PROFILE, effort: 'low' },
|
|
71
|
-
)
|
|
130
|
+
))
|
|
72
131
|
if (!profile || profile.error) {
|
|
73
132
|
return { error: `profile unreadable: ${(profile && profile.error) || 'profile-reader returned nothing'}` }
|
|
74
133
|
}
|
|
75
134
|
const surfaces = Array.isArray(profile.surfaces) ? profile.surfaces : []
|
|
135
|
+
// A profile with no surfaces cannot do this workflow's work, and every later
|
|
136
|
+
// guard compares against `surfaces` — an empty list makes them all vacuously
|
|
137
|
+
// pass. Fail loudly here instead of finishing with nothing done.
|
|
138
|
+
if (!surfaces.length) {
|
|
139
|
+
return { error: 'profile has no surfaces — nothing would be refactored. the `yaml pipeline-profile` block in PIPELINE.md is empty or unparseable, or the profile-reader mis-returned; run /doctor' }
|
|
140
|
+
}
|
|
76
141
|
const byKey = Object.fromEntries(surfaces.map(s => [s.key, s]))
|
|
77
142
|
const contractPath = (profile.contract && profile.contract.path) || ''
|
|
78
143
|
const base = (profile.vcs && profile.vcs.default_branch) || 'main'
|
|
@@ -87,7 +152,10 @@ const backlog = await agent(
|
|
|
87
152
|
`Requested domains: ${wanted === 'all' ? 'all' : wanted.join(', ')} — return only those (all ⇒ every domain with open items).`,
|
|
88
153
|
{ model: 'haiku', label: 'read-backlog', schema: OPEN, effort: 'low' },
|
|
89
154
|
)
|
|
90
|
-
|
|
155
|
+
// A dead reader is not "the backlog is empty" — reporting it as such sends the
|
|
156
|
+
// human to re-run /audit on a backlog that is already there.
|
|
157
|
+
if (!backlog) return { error: 'the backlog-reading agent died — nothing was read; re-run the refactor workflow' }
|
|
158
|
+
const open = (backlog.domains || []).filter(d => d.items.length)
|
|
91
159
|
if (!open.length) return { error: 'no open backlog items for the requested domains — run /audit (or the audit workflow) first' }
|
|
92
160
|
|
|
93
161
|
const big = open.filter(d => d.items.length >= MIN_ITEMS)
|
|
@@ -167,12 +235,16 @@ results.push(...restResults.filter(Boolean))
|
|
|
167
235
|
// ── Phase 5 — tick the cleared items ─────────────────────────────────────────
|
|
168
236
|
phase('Tick')
|
|
169
237
|
const clearedAll = results.flatMap(r => r.cleared)
|
|
238
|
+
let tickedOk = true
|
|
170
239
|
if (clearedAll.length) {
|
|
171
|
-
await agent(
|
|
240
|
+
const ticked = await agent(
|
|
172
241
|
'In specs/refactor-backlog.md flip EXACTLY these open `- [ ]` item lines to `- [x]` (match verbatim, ' +
|
|
173
242
|
'leave every other line untouched), then return the single word done:\n' + clearedAll.join('\n'),
|
|
174
243
|
{ model: 'haiku', label: 'tick-backlog', effort: 'low' },
|
|
175
244
|
)
|
|
245
|
+
// Reporting items as cleared while the backlog still shows them open means the
|
|
246
|
+
// next /refactor re-dispatches work that is already done.
|
|
247
|
+
tickedOk = ticked != null && /done/i.test(String(ticked))
|
|
176
248
|
}
|
|
177
249
|
|
|
178
250
|
return {
|
|
@@ -181,7 +253,11 @@ return {
|
|
|
181
253
|
}])),
|
|
182
254
|
skippedSmall: Object.fromEntries(small.map(d => [d.key, d.items.length])),
|
|
183
255
|
stillOpen: results.flatMap(r => r.remaining.map(line => `[${r.key}] ${line}`)).slice(0, 15),
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
256
|
+
backlogTicked: tickedOk,
|
|
257
|
+
next: !tickedOk
|
|
258
|
+
? `${clearedAll.length} item(s) were cleared in code but NOT ticked off specs/refactor-backlog.md ` +
|
|
259
|
+
'(the ticking agent died) — tick them by hand, or the next /refactor re-dispatches finished work'
|
|
260
|
+
: results.some(r => r.remaining.length || !r.gatesGreen)
|
|
261
|
+
? 'items remain — finish them with the conversational /refactor <domain>'
|
|
262
|
+
: 'all dispatched domains clean — optionally close with one final /audit',
|
|
187
263
|
}
|
package/core/workflows/review.js
CHANGED
|
@@ -26,10 +26,73 @@ export const meta = {
|
|
|
26
26
|
],
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
// The Workflow runtime hands `args` to a script verbatim, so a caller that passes a
|
|
30
|
+
// JSON-ENCODED STRING instead of a real object gets that string back here. The old
|
|
31
|
+
// `typeof args === 'string' ? args.trim()` then took the whole blob as the value — which
|
|
32
|
+
// is how a report landed on disk named `specs/reports/{"feature": "x"}.md`, and how
|
|
33
|
+
// maxRounds/smoke were silently dropped on the same run. Parse it back into the object
|
|
34
|
+
// it was meant to be; a bare slug stays valid shorthand.
|
|
35
|
+
const ARGS = (() => {
|
|
36
|
+
if (typeof args === 'string') {
|
|
37
|
+
const t = args.trim()
|
|
38
|
+
if (t.startsWith('{')) {
|
|
39
|
+
try { const o = JSON.parse(t); if (o && typeof o === 'object' && !Array.isArray(o)) return o } catch {}
|
|
40
|
+
}
|
|
41
|
+
return { feature: t, target: t }
|
|
42
|
+
}
|
|
43
|
+
return args && typeof args === 'object' ? args : {}
|
|
44
|
+
})()
|
|
45
|
+
const isSlug = s => typeof s === 'string' && /^[A-Za-z0-9._-]+$/.test(s)
|
|
46
|
+
const feature = ARGS.feature
|
|
30
47
|
if (!feature) throw new Error('cohorte-review needs args = {feature: "<feature_id>"}')
|
|
48
|
+
if (!isSlug(feature)) {
|
|
49
|
+
throw new Error(`cohorte-review got a feature id that is not a slug: ${JSON.stringify(feature)}. ` +
|
|
50
|
+
'Pass args as a real object, e.g. {feature: "titlebar-project-switcher"} — not a JSON string.')
|
|
51
|
+
}
|
|
31
52
|
|
|
32
|
-
|
|
53
|
+
// The profile-reader returns through a StructuredOutput tool call, and a haiku agent
|
|
54
|
+
// intermittently nests the whole profile as a JSON *string* under a single wrapper field
|
|
55
|
+
// ({"output": "{\"surfaces\": …}"}) instead of putting the profile's keys at the top level.
|
|
56
|
+
// The schema here used to be {type:'object', additionalProperties:true} — no declared
|
|
57
|
+
// properties, no required keys — so that wrapper validated cleanly and every field then read
|
|
58
|
+
// as undefined: `surfaces` fell back to [], parallel([]) dispatched zero agents, the
|
|
59
|
+
// dead-agent guard had no surfaces to find missing, and the run reported a verdict having
|
|
60
|
+
// done nothing. On the surface it is indistinguishable from a clean run with an empty diff.
|
|
61
|
+
// Declaring the shape gives the tool layer something to validate and the agent something to
|
|
62
|
+
// aim at; unwrapProfile() salvages a wrapped return that still gets through; and the
|
|
63
|
+
// zero-surface abort below makes the silent-success path impossible either way.
|
|
64
|
+
// See also the structured-output section of core/agents/profile-reader.md.
|
|
65
|
+
const PROFILE = {
|
|
66
|
+
type: 'object', additionalProperties: true,
|
|
67
|
+
properties: {
|
|
68
|
+
error: { type: 'string', description: 'set ONLY when PIPELINE.md is missing or unparseable' },
|
|
69
|
+
surfaces: {
|
|
70
|
+
type: 'array',
|
|
71
|
+
description: "one entry per surface, at the TOP LEVEL of this object — never a JSON string",
|
|
72
|
+
items: {
|
|
73
|
+
type: 'object', required: ['key'], additionalProperties: true,
|
|
74
|
+
properties: { key: { type: 'string' }, path: { type: 'string' }, agent: { type: 'string' } },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Salvage a profile handed back as JSON text rather than as an object — either the whole
|
|
81
|
+
// return, or nested under a single wrapper field. Anything already shaped like a profile
|
|
82
|
+
// (has `surfaces`, or is the documented `{error}` failure shape) passes through untouched.
|
|
83
|
+
const unwrapProfile = p => {
|
|
84
|
+
if (typeof p === 'string') { try { return JSON.parse(p) } catch { return null } }
|
|
85
|
+
if (!p || typeof p !== 'object') return null
|
|
86
|
+
if (Array.isArray(p.surfaces) || p.error) return p
|
|
87
|
+
for (const v of Object.values(p)) {
|
|
88
|
+
if (typeof v !== 'string') continue
|
|
89
|
+
try {
|
|
90
|
+
const inner = JSON.parse(v)
|
|
91
|
+
if (inner && typeof inner === 'object' && !Array.isArray(inner)) return inner
|
|
92
|
+
} catch {}
|
|
93
|
+
}
|
|
94
|
+
return p
|
|
95
|
+
}
|
|
33
96
|
|
|
34
97
|
const PREFLIGHT = {
|
|
35
98
|
type: 'object', required: ['pass'], additionalProperties: false,
|
|
@@ -88,16 +151,22 @@ const VERDICT = {
|
|
|
88
151
|
|
|
89
152
|
// ── Phase 0 — profile ────────────────────────────────────────────────────────
|
|
90
153
|
phase('Profile')
|
|
91
|
-
const profile = await agent(
|
|
154
|
+
const profile = unwrapProfile(await agent(
|
|
92
155
|
'Return this project\'s PIPELINE.md `yaml pipeline-profile` block as JSON, per your instructions.',
|
|
93
156
|
{ agentType: 'profile-reader', label: 'profile', schema: PROFILE, effort: 'low' },
|
|
94
|
-
)
|
|
157
|
+
))
|
|
95
158
|
if (!profile || profile.error) {
|
|
96
159
|
return { verdict: 'ABORTED', reason: `profile unreadable: ${(profile && profile.error) || 'profile-reader returned nothing'}` }
|
|
97
160
|
}
|
|
98
161
|
const cmds = profile.commands || {}
|
|
99
162
|
const base = (profile.vcs && profile.vcs.default_branch) || 'main'
|
|
100
163
|
const surfaces = Array.isArray(profile.surfaces) ? profile.surfaces : []
|
|
164
|
+
// A profile with no surfaces cannot do this workflow's work, and every later
|
|
165
|
+
// guard compares against `surfaces` — an empty list makes them all vacuously
|
|
166
|
+
// pass. Fail loudly here instead of finishing with nothing done.
|
|
167
|
+
if (!surfaces.length) {
|
|
168
|
+
return { verdict: 'ABORTED', reason: 'profile has no surfaces — nothing would be reviewed. the `yaml pipeline-profile` block in PIPELINE.md is empty or unparseable, or the profile-reader mis-returned; run /doctor' }
|
|
169
|
+
}
|
|
101
170
|
const quiet = (q, full) => (q && !String(q).startsWith('<') ? q : full ? `${full} 2>&1 | tail -40` : '')
|
|
102
171
|
const checks = [cmds.typecheck, quiet(cmds.lint_quiet, cmds.lint), quiet(cmds.test_quiet, cmds.test)]
|
|
103
172
|
.filter(c => c && !String(c).startsWith('<'))
|
|
@@ -132,7 +201,17 @@ const staged = await agent(
|
|
|
132
201
|
'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
202
|
{ model: 'haiku', label: 'stage-diff', schema: STAGE, effort: 'low' },
|
|
134
203
|
)
|
|
135
|
-
|
|
204
|
+
// A DEAD staging agent returns null, which is not the same fact as "the diff is
|
|
205
|
+
// empty" — conflating them handed back verdict SHIP ("nothing to review") for a
|
|
206
|
+
// feature nobody had looked at. Distinguish them.
|
|
207
|
+
if (!staged) {
|
|
208
|
+
return {
|
|
209
|
+
verdict: 'ABORTED',
|
|
210
|
+
reason: 'the diff-staging agent died — no reviewer was spawned and nothing was reviewed',
|
|
211
|
+
next: `re-run the review workflow, or /review ${feature} conversationally`,
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const touched = staged.surfaces || []
|
|
136
215
|
if (!touched.length) return { verdict: 'SHIP', reason: `no diff against ${base} — nothing to review`, findings: 0 }
|
|
137
216
|
log(`Touched surfaces: ${touched.map(s => s.key).join(', ')}`)
|
|
138
217
|
|
|
@@ -172,14 +251,27 @@ const reviewed = await pipeline(
|
|
|
172
251
|
)
|
|
173
252
|
|
|
174
253
|
const results = reviewed.filter(Boolean)
|
|
254
|
+
// A reviewer that DIED returns null — and a dead reviewer produces zero findings,
|
|
255
|
+
// which is byte-identical to a clean surface. Left unchecked, "every reviewer
|
|
256
|
+
// crashed" scores SHIP: the strongest possible verdict from the weakest possible
|
|
257
|
+
// evidence. Name the unreviewed surfaces and refuse to certify them.
|
|
258
|
+
const unreviewed = touched.filter(s => !results.some(r => r.key === s.key)).map(s => s.key)
|
|
259
|
+
if (unreviewed.length) log(`Reviewer died on: ${unreviewed.join(', ')} — those surfaces are NOT reviewed`)
|
|
175
260
|
const kept = results.flatMap(r => r.kept.map(f => ({ ...f, surface: r.key })))
|
|
176
261
|
const refuted = results.flatMap(r => r.refuted.map(f => ({ ...f, surface: r.key })))
|
|
177
262
|
const counts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 }
|
|
178
263
|
for (const f of kept) counts[f.severity] = (counts[f.severity] || 0) + 1
|
|
179
264
|
// 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
|
|
265
|
+
// must not force a fix loop): security ⇒ BLOCK, CRITICAL ⇒ REVISE, else SHIP —
|
|
266
|
+
// but never SHIP while a surface went unreviewed (absence of evidence, not
|
|
267
|
+
// evidence of absence).
|
|
181
268
|
const verdict = kept.some(f => f.kind === 'security') ? 'BLOCK'
|
|
182
|
-
: kept.some(f => f.severity === 'CRITICAL') ? 'REVISE'
|
|
269
|
+
: kept.some(f => f.severity === 'CRITICAL') ? 'REVISE'
|
|
270
|
+
: unreviewed.length ? 'REVISE' : 'SHIP'
|
|
271
|
+
// Only a SHIP whose leftovers are all LOW is a clean bill of health. The
|
|
272
|
+
// conversational /review routes any surviving CRITICAL/HIGH/security to /fix, so
|
|
273
|
+
// this path must not answer "/ship" on a SHIP that still carries HIGH findings.
|
|
274
|
+
const clean = verdict === 'SHIP' && kept.every(f => f.severity === 'LOW')
|
|
183
275
|
|
|
184
276
|
// ── Phase 5 — stage the merged report; only the verdict leaves the workflow ──
|
|
185
277
|
phase('Merge')
|
|
@@ -191,25 +283,54 @@ const reportBody = [
|
|
|
191
283
|
...['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'].map(s => `| ${s} | ${counts[s] || 0} |`), '',
|
|
192
284
|
`Verdict: ${verdict}`, '', '## Findings', '',
|
|
193
285
|
kept.length ? kept.map(findingLine).join('\n') : 'None.',
|
|
286
|
+
...(unreviewed.length ? ['', '## NOT reviewed (reviewer died — no verdict on these)', '',
|
|
287
|
+
unreviewed.map(k => `- \`${k}\` — re-run /review ${feature} (or the review workflow)`).join('\n')] : []),
|
|
194
288
|
...(refuted.length ? ['', '## Refuted by cross-check (no action needed)', '',
|
|
195
289
|
refuted.map(f => `- ${f.file}:${f.line} · ${f.problem} — refuted: ${f.reason}`).join('\n')] : []),
|
|
196
290
|
].join('\n')
|
|
197
|
-
await agent(
|
|
291
|
+
const staging = await agent(
|
|
198
292
|
`Stage a cohorte review report and its metrics, mechanically:\n` +
|
|
199
293
|
`1. Write EXACTLY this content to specs/reports/${feature}.md (overwrite):\n<<<REPORT\n${reportBody}\nREPORT\n` +
|
|
200
294
|
`2. Append one line to $(dirname "$(git rev-parse --git-common-dir)")/.claude/pipeline-metrics.jsonl: ` +
|
|
201
295
|
`{"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
|
|
296
|
+
`3. Chain the opt-in usage ping: <core>/pipeline/scripts/telemetry-send.sh review "${feature}" 0 "${verdict}:${kept.length}" || true ` +
|
|
297
|
+
'(<core> = .claude if .claude/pipeline/scripts/telemetry-send.sh exists, else ~/.claude; script on neither ⇒ skip the ping).\n' +
|
|
298
|
+
// Stamp + tick only when nothing above LOW survived: the conversational /review
|
|
299
|
+
// keeps the stamp only for LOW findings, and a SHIP verdict here can still carry
|
|
300
|
+
// HIGH/MEDIUM ones — certifying those for /ship would ship known defects. A dead
|
|
301
|
+
// reviewer already forced the verdict off SHIP, so `clean` covers that too.
|
|
302
|
+
(clean
|
|
303
|
+
? `4. Stamp the freshness gate in specs/${feature}.md's front-matter, exactly as the conversational /review §3 does ` +
|
|
304
|
+
`(so /ship can prove the reviewed code is what ships): BASE=$(git merge-base ${base} HEAD); set reviewed_base: $BASE and ` +
|
|
305
|
+
`reviewed_digest: $(git diff $BASE -- . ':(exclude)specs/' | sha256sum | cut -c1-16). ` +
|
|
306
|
+
'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'
|
|
307
|
+
: '') +
|
|
203
308
|
'Return the single word: done.',
|
|
204
309
|
{ model: 'haiku', label: 'stage-report', effort: 'low' },
|
|
205
310
|
)
|
|
206
311
|
|
|
312
|
+
// The staging agent writes the report, the metrics line, and (when clean) the
|
|
313
|
+
// freshness stamp + DoD ticks. If it died, none of that is on disk — returning
|
|
314
|
+
// `report: <path>` and "/ship" would point the human at a file that does not
|
|
315
|
+
// exist and certify a stamp that was never written.
|
|
316
|
+
const staged_ok = staging != null && /done/i.test(String(staging))
|
|
317
|
+
|
|
207
318
|
return {
|
|
208
319
|
verdict,
|
|
209
320
|
counts,
|
|
210
321
|
refutedByCrossCheck: refuted.length,
|
|
322
|
+
reportStaged: staged_ok,
|
|
323
|
+
unreviewedSurfaces: unreviewed, // reviewers that died — these carry NO verdict
|
|
211
324
|
criticals: kept.filter(f => f.severity === 'CRITICAL' || f.kind === 'security')
|
|
212
325
|
.map(f => `[${f.surface}] ${f.file}:${f.line} — ${f.problem}`),
|
|
213
|
-
report: `specs/reports/${feature}.md
|
|
214
|
-
next:
|
|
326
|
+
report: staged_ok ? `specs/reports/${feature}.md` : '(NOT written — the staging agent died)',
|
|
327
|
+
next: !staged_ok
|
|
328
|
+
? `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}`
|
|
329
|
+
: unreviewed.length
|
|
330
|
+
? `re-run the review — no reviewer completed on: ${unreviewed.join(', ')}`
|
|
331
|
+
: clean
|
|
332
|
+
? `/ship ${feature} (DoD ticked + freshness stamped)`
|
|
333
|
+
: verdict === 'SHIP'
|
|
334
|
+
? `/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`
|
|
335
|
+
: `/fix ${feature}`,
|
|
215
336
|
}
|
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}
|