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.
Files changed (40) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +15 -10
  3. package/bin/cli.js +15 -2
  4. package/core/agents/review.md +4 -2
  5. package/core/commands/cohorte-brainstorm.md +5 -1
  6. package/core/commands/cohorte-build.md +3 -1
  7. package/core/commands/cohorte-doctor.md +5 -3
  8. package/core/commands/cohorte-fix.md +6 -5
  9. package/core/commands/cohorte-fleet.md +104 -0
  10. package/core/commands/cohorte-intake.md +92 -0
  11. package/core/commands/cohorte-patch.md +6 -1
  12. package/core/commands/cohorte-retro.md +85 -0
  13. package/core/commands/cohorte-review.md +85 -23
  14. package/core/commands/cohorte-ship.md +2 -1
  15. package/core/commands/cohorte-spec.md +1 -1
  16. package/core/hooks/gate.py +10 -4
  17. package/core/workflows/audit.js +14 -2
  18. package/core/workflows/loop.js +641 -0
  19. package/core/workflows/refactor.js +21 -8
  20. package/core/workflows/review.js +92 -12
  21. package/dashboard/dist/assets/{index-D1rsbLat.js → index-vtFc6Gyc.js} +12 -12
  22. package/dashboard/dist/index.html +1 -1
  23. package/dashboard/server/doctor.js +8 -2
  24. package/dashboard/server/index.js +6 -1
  25. package/dashboard/server/kanban.js +15 -4
  26. package/dashboard/server/metrics.js +8 -1
  27. package/dashboard/server/runtime.js +20 -1
  28. package/install.ps1 +16 -333
  29. package/install.sh +27 -297
  30. package/package.json +1 -1
  31. package/profile/SCHEMA.md +45 -14
  32. package/profile/cohorte.config.template.yaml +1 -1
  33. package/scripts/kanban-move.sh +15 -5
  34. package/scripts/new-feature.sh.template +8 -1
  35. package/scripts/preflight.sh +10 -2
  36. package/scripts/remove-feature.sh.template +3 -1
  37. package/scripts/test-dashboard.mjs +53 -5
  38. package/scripts/test-gate.mjs +6 -0
  39. package/scripts/test-workflows.mjs +415 -6
  40. 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. 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.
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 { feature: t, target: t }
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 retryItems = v.remaining.length ? v.remaining : d.items
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
- v = await agent(
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
  }
@@ -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 and put the raw last 40 lines of the report in `tail` — verbatim, no summarizing.',
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) return { verdict: 'SHIP', reason: `no diff against ${base} — nothing to review`, findings: 0 }
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. Append one line to $(dirname "$(git rev-parse --git-common-dir)")/.claude/pipeline-metrics.jsonl: ` +
325
- `{"ts":"<ISO now>","feature":"${feature}","phase":"review","seconds":0,"surfaces":{${results.map(r => `"${r.key}":"${verdict}:${r.kept.length}"`).join(',')}}}\n` +
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
- ? `3. Route the deferred findings to specs/refactor-backlog.md (create it if absent): for each line below, ` +
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
- ? `4. Stamp the freshness gate in specs/${feature}.md's front-matter, exactly as the conversational /cohorte-review §3 does ` +
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
- '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'
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,