cohorte 2.7.0 → 2.9.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 +118 -0
- package/README.md +15 -10
- package/bin/cli.js +15 -2
- package/core/agents/review.md +4 -2
- package/core/commands/cohorte-brainstorm.md +5 -1
- package/core/commands/cohorte-build.md +3 -1
- package/core/commands/cohorte-doctor.md +5 -3
- package/core/commands/cohorte-fix.md +6 -5
- package/core/commands/cohorte-fleet.md +104 -0
- package/core/commands/cohorte-intake.md +92 -0
- package/core/commands/cohorte-patch.md +6 -1
- package/core/commands/cohorte-retro.md +85 -0
- package/core/commands/cohorte-review.md +85 -23
- package/core/commands/cohorte-ship.md +2 -1
- package/core/commands/cohorte-spec.md +1 -1
- package/core/hooks/gate.py +10 -4
- package/core/workflows/audit.js +14 -2
- package/core/workflows/loop.js +641 -0
- package/core/workflows/refactor.js +21 -8
- package/core/workflows/review.js +92 -12
- package/dashboard/dist/assets/{index-D1rsbLat.js → index-vtFc6Gyc.js} +12 -12
- package/dashboard/dist/index.html +1 -1
- package/dashboard/server/doctor.js +8 -2
- package/dashboard/server/index.js +6 -1
- package/dashboard/server/kanban.js +15 -4
- package/dashboard/server/metrics.js +8 -1
- package/dashboard/server/runtime.js +20 -1
- package/install.ps1 +16 -333
- package/install.sh +27 -297
- package/package.json +1 -1
- package/profile/SCHEMA.md +45 -14
- package/profile/cohorte.config.template.yaml +1 -1
- package/scripts/kanban-move.sh +15 -5
- package/scripts/new-feature.sh.template +8 -1
- package/scripts/preflight.sh +10 -2
- package/scripts/remove-feature.sh.template +3 -1
- package/scripts/test-dashboard.mjs +53 -5
- package/scripts/test-gate.mjs +6 -0
- package/scripts/test-workflows.mjs +415 -6
- package/scripts/validate-core.mjs +68 -34
|
@@ -30,18 +30,19 @@ export const meta = {
|
|
|
30
30
|
const MIN_ITEMS = 5
|
|
31
31
|
|
|
32
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.
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
33
|
+
// JSON-ENCODED STRING instead of a real object gets that string back here. Parse it
|
|
34
|
+
// back into the object it was meant to be. A bare slug is shorthand for the DOMAIN in
|
|
35
|
+
// this script — mapping it to {feature, target} (review.js's keys, once copy-pasted
|
|
36
|
+
// here) left ARGS.domains undefined, which fell through to 'all': the shorthand
|
|
37
|
+
// "backend" dispatched code-editing implementers on EVERY big domain, not the one
|
|
38
|
+
// the caller named.
|
|
38
39
|
const ARGS = (() => {
|
|
39
40
|
if (typeof args === 'string') {
|
|
40
41
|
const t = args.trim()
|
|
41
42
|
if (t.startsWith('{')) {
|
|
42
43
|
try { const o = JSON.parse(t); if (o && typeof o === 'object' && !Array.isArray(o)) return o } catch {}
|
|
43
44
|
}
|
|
44
|
-
return {
|
|
45
|
+
return { domains: [t] }
|
|
45
46
|
}
|
|
46
47
|
return args && typeof args === 'object' ? args : {}
|
|
47
48
|
})()
|
|
@@ -187,19 +188,31 @@ const verifyDomain = async (d, implHandoff) => {
|
|
|
187
188
|
{ model: 'haiku', label: `verify:${d.key}`, phase: 'Verify', schema: VERIFY, effort: 'low' },
|
|
188
189
|
)
|
|
189
190
|
// One bounded retry: re-dispatch the implementer on what verification rejected.
|
|
191
|
+
// The re-verify covers ONLY the retried items, so round 1's cleared list is carried
|
|
192
|
+
// forward — overwriting it un-ticked every item the first pass verified, and the
|
|
193
|
+
// next /cohorte-refactor re-dispatched finished work.
|
|
190
194
|
if (v && (v.remaining.length || !v.gatesGreen) && byKey[d.key]) {
|
|
191
|
-
const
|
|
195
|
+
const cleared1 = v.cleared || []
|
|
196
|
+
// Never retry items round 1 already verified cleared: on a gates-red round with
|
|
197
|
+
// nothing remaining, retrying ALL items put the same lines in both `cleared` and
|
|
198
|
+
// `remaining` when the re-verifier died — ticked off the backlog AND reported open.
|
|
199
|
+
const retryItems = v.remaining.length ? v.remaining : d.items.filter(i => !cleared1.includes(i))
|
|
192
200
|
log(`${d.key}: ${v.remaining.length} item(s) remaining${v.gatesGreen ? '' : ' + red gates'} — one retry round`)
|
|
193
201
|
await agent(
|
|
194
202
|
implementPrompt({ key: d.key, items: retryItems }) + (v.failures ? `\nGate failures to clear too:\n${v.failures}` : ''),
|
|
195
203
|
{ agentType: byKey[d.key].agent, label: `retry:${d.key}`, phase: 'Refactor' },
|
|
196
204
|
)
|
|
197
|
-
|
|
205
|
+
const v2 = await agent(
|
|
198
206
|
`Re-verify domain ${d.key} after a retry round — same procedure as before (gates redirected to ` +
|
|
199
207
|
`specs/reports/refactor-verify.${d.key}.txt, per-item file:line check, verbatim cleared/remaining lines).\n` +
|
|
200
208
|
'Items:\n' + retryItems.join('\n'),
|
|
201
209
|
{ model: 'haiku', label: `reverify:${d.key}`, phase: 'Verify', schema: VERIFY, effort: 'low' },
|
|
202
210
|
)
|
|
211
|
+
// A dead re-verifier loses only the RETRY round's claim — round 1's verified
|
|
212
|
+
// clears stay cleared; the retried items stay open (unverified ≠ cleared).
|
|
213
|
+
v = v2
|
|
214
|
+
? { ...v2, cleared: [...new Set(cleared1.concat(v2.cleared || []))] }
|
|
215
|
+
: { cleared: cleared1, remaining: retryItems, gatesGreen: false, failures: 'verifier died on the retry round' }
|
|
203
216
|
}
|
|
204
217
|
return { key: d.key, ...(v || { cleared: [], remaining: d.items, gatesGreen: false, failures: 'verifier died' }) }
|
|
205
218
|
}
|
package/core/workflows/review.js
CHANGED
|
@@ -42,6 +42,17 @@ const ARGS = (() => {
|
|
|
42
42
|
}
|
|
43
43
|
return args && typeof args === 'object' ? args : {}
|
|
44
44
|
})()
|
|
45
|
+
// Approximate cost of this run from the runtime's own output-token counter — the one
|
|
46
|
+
// figure the conversational path cannot record (a lead cannot read a subagent's token
|
|
47
|
+
// count; SCHEMA.md §Measuring cost). Sampled at start, delta stamped into the metrics line.
|
|
48
|
+
const spentNow = () => {
|
|
49
|
+
try {
|
|
50
|
+
const v = (budget && typeof budget.spent === 'function') ? budget.spent() : 0
|
|
51
|
+
return Number.isFinite(v) ? v : 0 // a NaN here would poison the metrics JSON downstream
|
|
52
|
+
} catch { return 0 }
|
|
53
|
+
}
|
|
54
|
+
const spentStart = spentNow()
|
|
55
|
+
|
|
45
56
|
const isSlug = s => typeof s === 'string' && /^[A-Za-z0-9._-]+$/.test(s)
|
|
46
57
|
const feature = ARGS.feature
|
|
47
58
|
if (!feature) throw new Error('cohorte-review needs args = {feature: "<feature_id>"}')
|
|
@@ -174,7 +185,7 @@ const profile = unwrapProfile(await agent(
|
|
|
174
185
|
{ agentType: 'profile-reader', label: 'profile', schema: PROFILE, effort: 'low' },
|
|
175
186
|
))
|
|
176
187
|
if (!profile || profile.error) {
|
|
177
|
-
return { verdict: 'ABORTED', reason: `profile unreadable: ${(profile && profile.error) || 'profile-reader returned nothing'}` }
|
|
188
|
+
return { verdict: 'ABORTED', aborted: 'profile', reason: `profile unreadable: ${(profile && profile.error) || 'profile-reader returned nothing'}` }
|
|
178
189
|
}
|
|
179
190
|
const cmds = profile.commands || {}
|
|
180
191
|
const base = (profile.vcs && profile.vcs.default_branch) || 'main'
|
|
@@ -183,7 +194,7 @@ const surfaces = Array.isArray(profile.surfaces) ? profile.surfaces : []
|
|
|
183
194
|
// guard compares against `surfaces` — an empty list makes them all vacuously
|
|
184
195
|
// pass. Fail loudly here instead of finishing with nothing done.
|
|
185
196
|
if (!surfaces.length) {
|
|
186
|
-
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 /cohorte-doctor' }
|
|
197
|
+
return { verdict: 'ABORTED', aborted: 'profile', 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 /cohorte-doctor' }
|
|
187
198
|
}
|
|
188
199
|
const quiet = (q, full) => (q && !String(q).startsWith('<') ? q : full ? `${full} 2>&1 | tail -40` : '')
|
|
189
200
|
const checks = [cmds.typecheck, quiet(cmds.lint_quiet, cmds.lint), quiet(cmds.test_quiet, cmds.test)]
|
|
@@ -197,12 +208,16 @@ const pre = await agent(
|
|
|
197
208
|
checks.map(c => JSON.stringify(c)).join(' ') + '\n' +
|
|
198
209
|
'(<core> = .claude if .claude/pipeline/scripts/preflight.sh exists, else ~/.claude — probe with test -x. ' +
|
|
199
210
|
'Script absent on both: run the quoted commands yourself, each appended to the same report file, stopping at the first failure.) ' +
|
|
200
|
-
'Return pass=true only on a fully green run. On failure set pass=false
|
|
211
|
+
'Return pass=true only on a fully green run. On failure set pass=false, put the raw last 40 lines of the report ' +
|
|
212
|
+
'in `tail` — verbatim, no summarizing — and in the same Bash call write the degraded machine verdict the ' +
|
|
213
|
+
`conversational /cohorte-review §0 writes, so an automated driver gets a diagnosis rather than silence: ` +
|
|
214
|
+
`printf '{"id":"${feature}","phase":"review","ts":"%s","aborted":"preflight","verdict":"BLOCK","blocking":null}' ` +
|
|
215
|
+
`"$(date -u +%Y-%m-%dT%H:%M:%SZ)" > specs/reports/${feature}.verdict.json`,
|
|
201
216
|
{ model: 'haiku', label: 'preflight', schema: PREFLIGHT, effort: 'low' },
|
|
202
217
|
)
|
|
203
218
|
if (!pre || !pre.pass) {
|
|
204
219
|
return {
|
|
205
|
-
verdict: 'ABORTED',
|
|
220
|
+
verdict: 'ABORTED', aborted: 'preflight',
|
|
206
221
|
reason: 'preflight red — fix the mechanical failures (or run /cohorte-fix) before any review; no reviewer was spawned',
|
|
207
222
|
failures: (pre && pre.tail) || 'preflight agent returned nothing',
|
|
208
223
|
}
|
|
@@ -224,13 +239,34 @@ const staged = await agent(
|
|
|
224
239
|
// feature nobody had looked at. Distinguish them.
|
|
225
240
|
if (!staged) {
|
|
226
241
|
return {
|
|
227
|
-
verdict: 'ABORTED',
|
|
242
|
+
verdict: 'ABORTED', aborted: 'stage-diff',
|
|
228
243
|
reason: 'the diff-staging agent died — no reviewer was spawned and nothing was reviewed',
|
|
229
244
|
next: `re-run the review workflow, or /cohorte-review ${feature} conversationally`,
|
|
230
245
|
}
|
|
231
246
|
}
|
|
232
247
|
const touched = staged.surfaces || []
|
|
233
|
-
if (!touched.length)
|
|
248
|
+
if (!touched.length) {
|
|
249
|
+
// Nothing was reviewed, so nothing was certified: no DoD tick, no freshness stamp.
|
|
250
|
+
// `next` must say so — a driver relaying a bare "/cohorte-ship" here would point at
|
|
251
|
+
// a gate that (rightly) refuses. And verdict.json is written on EVERY run
|
|
252
|
+
// (cohorte-review.md §3) — leaving last round's REVISE on disk here would hand any
|
|
253
|
+
// driver reading the file a stale verdict.
|
|
254
|
+
await agent(
|
|
255
|
+
`Write EXACTLY this to specs/reports/${feature}.verdict.json (overwrite), substituting <ISO now> ` +
|
|
256
|
+
'with `date -u +%Y-%m-%dT%H:%M:%SZ`, then return the single word done:\n' +
|
|
257
|
+
JSON.stringify({
|
|
258
|
+
id: feature, phase: 'review', ts: '<ISO now>', verdict: 'SHIP', findings: 0, blocking: 0,
|
|
259
|
+
security: 0, deferred: 0, unreviewed: [], severity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 },
|
|
260
|
+
surfaces: {}, blocking_items: [], fingerprint: '',
|
|
261
|
+
}),
|
|
262
|
+
{ model: 'haiku', label: 'stage-verdict', effort: 'low' },
|
|
263
|
+
)
|
|
264
|
+
return {
|
|
265
|
+
verdict: 'SHIP', reason: `no diff against ${base} — nothing to review`,
|
|
266
|
+
findings: 0, blocking: 0, blockingItems: [], deferred: 0, unreviewedSurfaces: [],
|
|
267
|
+
next: `nothing to ship: the diff against ${base} is empty, so no review ran and no freshness stamp was written — check the branch/base (was the feature actually built here?)`,
|
|
268
|
+
}
|
|
269
|
+
}
|
|
234
270
|
log(`Touched surfaces: ${touched.map(s => s.key).join(', ')}`)
|
|
235
271
|
|
|
236
272
|
// ── Phases 3+4 — review each surface, cross-check its hard findings ─────────
|
|
@@ -287,6 +323,17 @@ const refuted = results.flatMap(r => r.refuted.map(f => ({ ...f, surface: r.key
|
|
|
287
323
|
const deferredAll = results.flatMap(r => (r.deferred || []).map(f => ({ ...f, surface: r.key })))
|
|
288
324
|
const counts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 }
|
|
289
325
|
for (const f of kept) counts[f.severity] = (counts[f.severity] || 0) + 1
|
|
326
|
+
// blocking = CRITICAL + security findings, each counted once — the conversational
|
|
327
|
+
// /cohorte-review §3's contract restated as a number, so blocking == 0 ⟺ verdict SHIP.
|
|
328
|
+
// blocking_items carry the finding's IDENTITY, not its wording: surface | file without
|
|
329
|
+
// `:line` (a fix that inserts lines shifts every line below it — a line-bearing identity
|
|
330
|
+
// would change every pass and drift detection would never fire) | the problem's first 8
|
|
331
|
+
// words, lowercased, runs of non-alphanumerics collapsed. Sorted, so two rounds with the
|
|
332
|
+
// same findings compare equal however the reviewers ordered them.
|
|
333
|
+
const blockingFindings = kept.filter(f => f.severity === 'CRITICAL' || f.kind === 'security')
|
|
334
|
+
const normProblem = p => String(p).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().split(' ').slice(0, 8).join(' ')
|
|
335
|
+
const blockingItems = [...new Set(blockingFindings.map(f =>
|
|
336
|
+
`${f.surface}|${String(f.file).replace(/:\d+$/, '')}|${normProblem(f.problem)}`))].sort()
|
|
290
337
|
// Verdict from the findings that SURVIVED the cross-check (a refuted CRITICAL
|
|
291
338
|
// must not force a fix loop): security ⇒ BLOCK, CRITICAL ⇒ REVISE, else SHIP —
|
|
292
339
|
// but never SHIP while a surface went unreviewed (absence of evidence, not
|
|
@@ -318,15 +365,46 @@ const reportBody = [
|
|
|
318
365
|
...(refuted.length ? ['', '## Refuted by cross-check (no action needed)', '',
|
|
319
366
|
refuted.map(f => `- ${f.file}:${f.line} · ${f.problem} — refuted: ${f.reason}`).join('\n')] : []),
|
|
320
367
|
].join('\n')
|
|
368
|
+
// The machine-readable verdict the conversational /cohorte-review §3 guarantees — the
|
|
369
|
+
// ONLY contract between the pipeline and an automated driver (the loop workflow), which
|
|
370
|
+
// parses no prose. Composed HERE so the staging agent substitutes two tokens and can
|
|
371
|
+
// invent nothing; the sha256 fingerprint is computed in its Bash (scripts have no crypto).
|
|
372
|
+
const verdictJson = JSON.stringify({
|
|
373
|
+
id: feature, phase: 'review', ts: '<ISO now>', verdict,
|
|
374
|
+
findings: kept.length, blocking: blockingFindings.length,
|
|
375
|
+
security: kept.filter(f => f.kind === 'security').length,
|
|
376
|
+
deferred: deferredAll.length, unreviewed,
|
|
377
|
+
severity: counts,
|
|
378
|
+
surfaces: Object.fromEntries(results.map(r => [r.key, {
|
|
379
|
+
verdict: r.report.verdict, findings: r.kept.length,
|
|
380
|
+
blocking: r.kept.filter(f => f.severity === 'CRITICAL' || f.kind === 'security').length,
|
|
381
|
+
}])),
|
|
382
|
+
blocking_items: blockingItems, fingerprint: '<FP>',
|
|
383
|
+
})
|
|
321
384
|
const staging = await agent(
|
|
322
385
|
`Stage a cohorte review report and its metrics, mechanically:\n` +
|
|
323
386
|
`1. Write EXACTLY this content to specs/reports/${feature}.md (overwrite):\n<<<REPORT\n${reportBody}\nREPORT\n` +
|
|
324
|
-
`2.
|
|
325
|
-
`
|
|
387
|
+
`2. Write EXACTLY this to specs/reports/${feature}.verdict.json (overwrite), substituting <ISO now> with ` +
|
|
388
|
+
'`date -u +%Y-%m-%dT%H:%M:%SZ` and <FP> with the fingerprint — computed in Bash, never by hand: ' +
|
|
389
|
+
(blockingItems.length
|
|
390
|
+
// POSIX-escape embedded quotes ('\'') rather than stripping them: a stripped quote
|
|
391
|
+
// makes the fingerprint disagree with the blocking_items in the same file, and with
|
|
392
|
+
// a conversational re-run computing it per the §3 contract.
|
|
393
|
+
? `printf '%s\\n' ${blockingItems.map(i => `'${i.replace(/'/g, "'\\''")}'`).join(' ')} | LC_ALL=C sort | sha256sum | cut -c1-16 ` +
|
|
394
|
+
'(`shasum -a 256` then first 16 hex chars where there is no sha256sum):\n'
|
|
395
|
+
: 'the blocking list is empty, so <FP> is the empty string "":\n') +
|
|
396
|
+
`${verdictJson}\n` +
|
|
397
|
+
// Per-surface verdicts (not the merged one stamped on every row — one BLOCK used to
|
|
398
|
+
// mark ALL surfaces failed on the dashboard), and dead reviewers logged as "dead"
|
|
399
|
+
// per SCHEMA.md §Dead agents — an incomplete batch is the batch worth recording.
|
|
400
|
+
`3. Append one line to $(dirname "$(git rev-parse --git-common-dir)")/.claude/pipeline-metrics.jsonl: ` +
|
|
401
|
+
`{"ts":"<ISO now>","feature":"${feature}","phase":"review","seconds":0,"tokens":${Math.max(0, spentNow() - spentStart)},"surfaces":{${
|
|
402
|
+
results.map(r => `"${r.key}":"${r.report.verdict}:${r.kept.length}"`)
|
|
403
|
+
.concat(unreviewed.map(k => `"${k}":"dead"`)).join(',')}}}\n` +
|
|
326
404
|
// Deferred findings must land in the backlog on EVERY verdict — parked only on a
|
|
327
405
|
// SHIP is parked nowhere the rest of the time, which is the leak this closes.
|
|
328
406
|
(deferredAll.length
|
|
329
|
-
? `
|
|
407
|
+
? `4. Route the deferred findings to specs/refactor-backlog.md (create it if absent): for each line below, ` +
|
|
330
408
|
`append it under the \`## <domain>\` heading named in its prefix (create that heading if absent) — with \`>>\`, ` +
|
|
331
409
|
`never by rewriting the file, and skip any whose file path + first words already appear there (grep -F first, ` +
|
|
332
410
|
`they may be left from a prior round or an /cohorte-audit):\n` +
|
|
@@ -339,10 +417,10 @@ const staging = await agent(
|
|
|
339
417
|
// HIGH/MEDIUM ones — certifying those for /cohorte-ship would ship known defects. A dead
|
|
340
418
|
// reviewer already forced the verdict off SHIP, so `clean` covers that too.
|
|
341
419
|
(clean
|
|
342
|
-
? `
|
|
420
|
+
? `5. Stamp the freshness gate in specs/${feature}.md's front-matter, exactly as the conversational /cohorte-review §3 does ` +
|
|
343
421
|
`(so /cohorte-ship can prove the reviewed code is what ships): BASE=$(git merge-base ${base} HEAD); set reviewed_base: $BASE and ` +
|
|
344
|
-
`reviewed_digest: $(git diff $BASE -- . ':(exclude)specs/' | sha256sum | cut -c1-16). ` +
|
|
345
|
-
'
|
|
422
|
+
`reviewed_digest: $(git diff $BASE -- . ':(exclude)specs/' | sha256sum | cut -c1-16) — shasum -a 256, first 16 hex, where sha256sum is absent (macOS). ` +
|
|
423
|
+
'6. Tick the spec DoD boxes this run verified (spec conformance + copy language — review SHIP; tests/lint/typecheck — green preflight); leave the rest unticked.\n'
|
|
346
424
|
: '') +
|
|
347
425
|
'Return the single word: done.',
|
|
348
426
|
{ model: 'haiku', label: 'stage-report', effort: 'low' },
|
|
@@ -357,6 +435,8 @@ const staged_ok = staging != null && /done/i.test(String(staging))
|
|
|
357
435
|
return {
|
|
358
436
|
verdict,
|
|
359
437
|
counts,
|
|
438
|
+
blocking: blockingFindings.length, // CRITICAL + security, each once — 0 ⟺ SHIP; what a driver reduces on
|
|
439
|
+
blockingItems, // the sorted identity list behind verdict.json's fingerprint
|
|
360
440
|
deferred: deferredAll.length, // parked in the backlog for /cohorte-refactor — never blocking
|
|
361
441
|
refutedByCrossCheck: refuted.length,
|
|
362
442
|
reportStaged: staged_ok,
|