cohorte 1.2.6 → 1.3.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 +50 -0
- package/README.md +44 -9
- package/bin/cli.js +4 -1
- package/core/agents/implementer.template.md +27 -14
- package/core/agents/profile-reader.md +28 -0
- package/core/agents/review.md +7 -3
- package/core/agents/smoke.md +4 -2
- package/core/commands/audit.md +17 -9
- package/core/commands/doctor.md +21 -5
- package/core/commands/refactor.md +8 -3
- package/core/commands/review.md +46 -18
- package/core/commands/smoke.md +23 -9
- package/core/commands/update-pipeline.md +10 -4
- package/core/hooks/gate.py +95 -6
- package/core/templates/agent-handoff.md +2 -1
- package/core/templates/review-feedback.md +4 -1
- package/core/templates/steps/init-pipeline/02-interview-gaps.md +7 -0
- package/core/templates/steps/init-pipeline/04-write-render.md +16 -8
- package/core/workflows/audit.js +144 -0
- package/core/workflows/cycle.js +397 -0
- package/core/workflows/refactor.js +187 -0
- package/core/workflows/review.js +215 -0
- package/dashboard/dist/assets/{index-YvkzH-yF.js → index-BxgA_mz1.js} +10 -10
- package/dashboard/dist/index.html +1 -1
- package/dashboard/server/doctor.js +35 -1
- package/dashboard/server/index.js +4 -2
- package/install.ps1 +4 -1
- package/install.sh +5 -2
- package/package.json +1 -1
- package/profile/PIPELINE.template.md +17 -0
- package/profile/SCHEMA.md +113 -1
- package/scripts/preflight.sh +48 -0
- package/scripts/validate-core.mjs +39 -3
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// cohorte — the FULL dev cycle as one deterministic workflow (opt-in):
|
|
2
|
+
// contract → build → [preflight → smoke ∥ review(+cross-check) → fix]* → done.
|
|
3
|
+
//
|
|
4
|
+
// Invoke with args = {feature: "<feature_id>", maxRounds?: 3} in the feature's
|
|
5
|
+
// checkout (main checkout on the feature branch, or its worktree).
|
|
6
|
+
//
|
|
7
|
+
// The contract with the human: a workflow can NEVER ask a question mid-run, so
|
|
8
|
+
// everything decisional is moved to the edges —
|
|
9
|
+
// · UPSTREAM: the spec must be frozen and self-sufficient (design links in the
|
|
10
|
+
// front-matter, complete §5 contract). A readiness gate checks this FIRST
|
|
11
|
+
// and aborts with the list of gaps as `questions` before spending anything.
|
|
12
|
+
// A well-run /brainstorm + /spec IS the answer sheet — the sharper it is,
|
|
13
|
+
// the further the cycle runs with an empty questions array.
|
|
14
|
+
// · DOWNSTREAM: the loop runs review→fix→review until ZERO open findings and
|
|
15
|
+
// a PASS smoke (bounded by maxRounds + the token budget). Even a finding
|
|
16
|
+
// that implies a CONTRACT change stays inside the loop: a lead-equivalent
|
|
17
|
+
// agent re-authors spec §5 + the contract file (exactly what /fix does
|
|
18
|
+
// conversationally — implementers still never touch it), the affected
|
|
19
|
+
// surfaces re-dispatch, and the loop continues. Only what is genuinely
|
|
20
|
+
// human comes back at the END, in the result's `questions` array (spec
|
|
21
|
+
// ambiguities the readiness gate flagged, a hit round-cap/budget) — ready
|
|
22
|
+
// to feed a follow-up fix/review loop if you decide to keep going.
|
|
23
|
+
// /ship stays out on purpose: it is the outward-facing, irreversible gate and
|
|
24
|
+
// keeps its human confirmation. A SHIP exit ticks the DoD + stamps the
|
|
25
|
+
// freshness gate, so `/ship <id>` right after is a straight shot.
|
|
26
|
+
//
|
|
27
|
+
// Loop economics: smoke and review run CONCURRENTLY each round (both observe,
|
|
28
|
+
// neither edits); fix rounds re-dispatch only the surfaces owning findings;
|
|
29
|
+
// the loop is bounded by maxRounds AND by the session token budget if one is
|
|
30
|
+
// set. Disk state stays pipeline-coherent: reports staged to specs/reports/,
|
|
31
|
+
// unresolved findings appended to the spec's ## Remediation — a conversational
|
|
32
|
+
// /fix can always pick up where the workflow stopped.
|
|
33
|
+
|
|
34
|
+
export const meta = {
|
|
35
|
+
name: 'cohorte-cycle',
|
|
36
|
+
description: 'Full cohorte dev cycle: contract, parallel build, then bounded smoke∥review→fix rounds; deferred questions in the output, never mid-run',
|
|
37
|
+
whenToUse: 'Only when the human explicitly asks to run the full dev-cycle workflow on a FROZEN spec. args = {feature: "<feature_id>", maxRounds?: 3}.',
|
|
38
|
+
phases: [
|
|
39
|
+
{ title: 'Profile', detail: 'PIPELINE.md → JSON via profile-reader', model: 'haiku' },
|
|
40
|
+
{ title: 'Ready', detail: 'spec frozen + self-sufficient, or abort with the gaps', model: 'haiku' },
|
|
41
|
+
{ title: 'Contract', detail: 'author the frozen contract from spec §5' },
|
|
42
|
+
{ title: 'Build', detail: 'one implementer per surface, parallel' },
|
|
43
|
+
{ title: 'Verify', detail: 'per round: preflight → smoke ∥ review + cross-check' },
|
|
44
|
+
{ title: 'Fix', detail: 'per round: re-dispatch only the surfaces with findings' },
|
|
45
|
+
{ title: 'Close', detail: 'reports, Remediation/DoD, freshness stamp, metrics', model: 'haiku' },
|
|
46
|
+
],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const feature = typeof args === 'string' ? args.trim() : args && args.feature
|
|
50
|
+
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.
|
|
52
|
+
const MAX_ROUNDS = Math.max(1, (args && args.maxRounds) || 5)
|
|
53
|
+
|
|
54
|
+
const questions = [] // every deferred human decision ends up here — emitted at the END
|
|
55
|
+
const contractChanges = [] // contract re-authorings the loop performed (info, not questions)
|
|
56
|
+
|
|
57
|
+
const PROFILE = { type: 'object', additionalProperties: true }
|
|
58
|
+
const READY = {
|
|
59
|
+
type: 'object', required: ['frozen', 'gaps', 'designLinks'], additionalProperties: false,
|
|
60
|
+
properties: {
|
|
61
|
+
frozen: { type: 'boolean', description: 'front-matter status is frozen or in-review' },
|
|
62
|
+
gaps: { type: 'array', items: { type: 'string' }, description: 'anything the cycle would have had to ask about' },
|
|
63
|
+
designLinks: { type: 'string', description: 'the design_files links, comma-joined, or "none"' },
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
const PREFLIGHT = {
|
|
67
|
+
type: 'object', required: ['pass'], additionalProperties: false,
|
|
68
|
+
properties: { pass: { type: 'boolean' }, tail: { type: 'string' } },
|
|
69
|
+
}
|
|
70
|
+
const STAGE = {
|
|
71
|
+
type: 'object', required: ['surfaces'], additionalProperties: false,
|
|
72
|
+
properties: {
|
|
73
|
+
surfaces: {
|
|
74
|
+
type: 'array',
|
|
75
|
+
items: {
|
|
76
|
+
type: 'object', required: ['key', 'diff', 'files'], additionalProperties: false,
|
|
77
|
+
properties: {
|
|
78
|
+
key: { type: 'string' }, diff: { type: 'string' },
|
|
79
|
+
files: { type: 'array', items: { type: 'string' } },
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
const FINDING = {
|
|
86
|
+
type: 'object', required: ['severity', 'file', 'line', 'kind', 'problem', 'fix'],
|
|
87
|
+
additionalProperties: false,
|
|
88
|
+
properties: {
|
|
89
|
+
severity: { enum: ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] },
|
|
90
|
+
file: { type: 'string' }, line: { type: 'integer' },
|
|
91
|
+
kind: { enum: ['spec-violation', 'quality', 'security'] },
|
|
92
|
+
problem: { type: 'string' }, fix: { type: 'string' },
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
const REPORT = {
|
|
96
|
+
type: 'object', required: ['verdict', 'findings'], additionalProperties: false,
|
|
97
|
+
properties: {
|
|
98
|
+
verdict: { enum: ['SHIP', 'REVISE', 'BLOCK'] },
|
|
99
|
+
findings: { type: 'array', maxItems: 20, items: FINDING },
|
|
100
|
+
overflow: { type: 'integer' },
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
const VERDICT = {
|
|
104
|
+
type: 'object', required: ['refuted', 'reason'], additionalProperties: false,
|
|
105
|
+
properties: { refuted: { type: 'boolean' }, reason: { type: 'string' } },
|
|
106
|
+
}
|
|
107
|
+
const SMOKE = {
|
|
108
|
+
type: 'object', required: ['pass', 'failures'], additionalProperties: false,
|
|
109
|
+
properties: {
|
|
110
|
+
pass: { type: 'boolean' },
|
|
111
|
+
failures: {
|
|
112
|
+
type: 'array', maxItems: 10,
|
|
113
|
+
items: { type: 'string', description: '❌ <flow/endpoint> · expected <x> got <y> · file hint if known' },
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Phase 0 — profile ────────────────────────────────────────────────────────
|
|
119
|
+
phase('Profile')
|
|
120
|
+
const profile = await agent(
|
|
121
|
+
'Return this project\'s PIPELINE.md `yaml pipeline-profile` block as JSON, per your instructions.',
|
|
122
|
+
{ agentType: 'profile-reader', label: 'profile', schema: PROFILE, effort: 'low' },
|
|
123
|
+
)
|
|
124
|
+
if (!profile || profile.error) {
|
|
125
|
+
return { outcome: 'ABORTED', questions: [`profile unreadable: ${(profile && profile.error) || 'no return'} — run /init-pipeline or /doctor`] }
|
|
126
|
+
}
|
|
127
|
+
const surfaces = Array.isArray(profile.surfaces) ? profile.surfaces : []
|
|
128
|
+
const byKey = Object.fromEntries(surfaces.map(s => [s.key, s]))
|
|
129
|
+
const cmds = profile.commands || {}
|
|
130
|
+
const base = (profile.vcs && profile.vcs.default_branch) || 'main'
|
|
131
|
+
const contract = profile.contract || {}
|
|
132
|
+
const contractFile = contract.enabled ? `${contract.path}/${feature}.${contract.ext || 'ts'}` : ''
|
|
133
|
+
const quiet = (q, full) => (q && !String(q).startsWith('<') ? q : full ? `${full} 2>&1 | tail -40` : '')
|
|
134
|
+
const checks = [cmds.typecheck, quiet(cmds.lint_quiet, cmds.lint), quiet(cmds.test_quiet, cmds.test)]
|
|
135
|
+
.filter(c => c && !String(c).startsWith('<'))
|
|
136
|
+
|
|
137
|
+
// ── Phase 1 — readiness gate: the spec must pre-answer everything ───────────
|
|
138
|
+
phase('Ready')
|
|
139
|
+
const ready = await agent(
|
|
140
|
+
`Readiness check for the cohorte full-cycle workflow on spec specs/${feature}.md — the run can ask ` +
|
|
141
|
+
'NOTHING mid-flight, so list every gap a lead would normally have to ask about. Read the spec ' +
|
|
142
|
+
'(front-matter + §5 contract + per-surface tasks + §9 acceptance) and report:\n' +
|
|
143
|
+
'- frozen: front-matter status is `frozen` or `in-review`\n' +
|
|
144
|
+
'- gaps: e.g. status draft/missing; §5 contract absent or with open placeholders/TODOs; a surface\'s ' +
|
|
145
|
+
'tasks empty while §5 clearly implies work there; `design_files` empty while a uses_design surface ' +
|
|
146
|
+
`has tasks (uses_design surfaces: ${surfaces.filter(s => s.uses_design).map(s => s.key).join(', ') || 'none'}); ` +
|
|
147
|
+
'open `## Remediation` items that imply a contract change\n' +
|
|
148
|
+
'- designLinks: the design_files links comma-joined, or "none"',
|
|
149
|
+
{ model: 'haiku', label: 'ready', schema: READY, effort: 'low' },
|
|
150
|
+
)
|
|
151
|
+
if (!ready || !ready.frozen) {
|
|
152
|
+
return {
|
|
153
|
+
outcome: 'NOT-READY',
|
|
154
|
+
questions: ((ready && ready.gaps) || ['spec unreadable']).concat(
|
|
155
|
+
['freeze the spec first: /spec (the cycle only runs on a frozen, self-sufficient spec)']),
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
questions.push(...(ready.gaps || [])) // non-blocking gaps ride along as deferred questions
|
|
159
|
+
const designLinks = ready.designLinks || 'none'
|
|
160
|
+
|
|
161
|
+
// ── Phase 2 — author the contract (lead-equivalent, the single sync channel) ─
|
|
162
|
+
if (contract.enabled) {
|
|
163
|
+
phase('Contract')
|
|
164
|
+
const c = await agent(
|
|
165
|
+
`Author the frozen contract for cohorte feature ${feature}, acting as the lead (/build §2): from ` +
|
|
166
|
+
`spec specs/${feature}.md §5, write/update ${contractFile} in the profile's mechanism ` +
|
|
167
|
+
`(${contract.mechanism})${contract.index ? `, exported from ${contract.index}` : ''}. ` +
|
|
168
|
+
'Postcondition: the file exists and typechecks. Implementers import it read-only. ' +
|
|
169
|
+
'Return one line: what you wrote, or what blocked you.',
|
|
170
|
+
{ label: 'contract' },
|
|
171
|
+
)
|
|
172
|
+
if (c == null) return { outcome: 'ABORTED', questions: questions.concat(['contract authoring failed — run /build manually']) }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Phase 3 — build: one implementer per surface, parallel ───────────────────
|
|
176
|
+
phase('Build')
|
|
177
|
+
const buildPrompt = s =>
|
|
178
|
+
`Implement the **${s.key}** surface for feature \`${feature}\`. Read \`PIPELINE.md\` first. ` +
|
|
179
|
+
`Spec: \`specs/${feature}.md\`. Contract: \`${contractFile || 'none — spec §5 prose is the contract'}\` ` +
|
|
180
|
+
'(import read-only). Work test-first. Touch only `' + s.path + '`. Need the current state of your ' +
|
|
181
|
+
`tree? Compute it yourself: \`git diff ${base} -- ${s.path}\`. Return the handoff in the format ` +
|
|
182
|
+
`your agent instructions define. Design files: ${s.uses_design ? designLinks : 'none'}. ` +
|
|
183
|
+
'Open Remediation items for YOUR surface (`none` ⇒ first build, implement the spec\'s tasks for ' +
|
|
184
|
+
'your surface): none'
|
|
185
|
+
const handoffs = await parallel(surfaces.map(s => () =>
|
|
186
|
+
agent(buildPrompt(s), { agentType: s.agent, label: `build:${s.key}`, phase: 'Build' })
|
|
187
|
+
.then(h => ({ key: s.key, handoff: h }))))
|
|
188
|
+
const built = handoffs.filter(Boolean)
|
|
189
|
+
const dead = surfaces.filter(s => !built.some(b => b.key === s.key)).map(s => s.key)
|
|
190
|
+
if (dead.length) questions.push(`implementer(s) died during build: ${dead.join(', ')} — inspect and re-run /build if their surface matters`)
|
|
191
|
+
|
|
192
|
+
// ── helpers for the verify/fix rounds ────────────────────────────────────────
|
|
193
|
+
const surfaceOf = file => {
|
|
194
|
+
for (const s of surfaces) if (file && String(file).startsWith(String(s.path))) return s.key
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
const itemLine = f => `- [ ] ${f.severity} · ${f.file}:${f.line} · ${f.kind} · ${f.fix}`
|
|
198
|
+
const runPreflight = () => agent(
|
|
199
|
+
`Run the cohorte deterministic pre-flight for feature ${feature} in ONE Bash call:\n` +
|
|
200
|
+
`<core>/pipeline/scripts/preflight.sh specs/reports/${feature}.preflight.txt ` +
|
|
201
|
+
checks.map(c => JSON.stringify(c)).join(' ') + '\n' +
|
|
202
|
+
'(<core> = .claude if .claude/pipeline/scripts/preflight.sh exists, else ~/.claude; script absent ⇒ ' +
|
|
203
|
+
'run the quoted commands yourself into the same file, stopping at the first failure.) ' +
|
|
204
|
+
'pass=true only on fully green; on failure put the raw last 40 lines in tail, verbatim.',
|
|
205
|
+
{ model: 'haiku', label: 'preflight', phase: 'Verify', schema: PREFLIGHT, effort: 'low' },
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
let verdict = null
|
|
209
|
+
let smokePass = false
|
|
210
|
+
let open = [] // findings still open, each {severity,file,line,kind,problem,fix,src}
|
|
211
|
+
let rounds = 0
|
|
212
|
+
|
|
213
|
+
// ── Phases 4/5 — bounded verify → fix rounds ────────────────────────────────
|
|
214
|
+
while (rounds < MAX_ROUNDS && (!budget.total || budget.remaining() > 30000)) {
|
|
215
|
+
rounds++
|
|
216
|
+
log(`Round ${rounds}/${MAX_ROUNDS}`)
|
|
217
|
+
phase('Verify')
|
|
218
|
+
|
|
219
|
+
// 4a. preflight — mechanical red short-circuits straight to a fix round
|
|
220
|
+
const pre = await runPreflight()
|
|
221
|
+
if (!pre || !pre.pass) {
|
|
222
|
+
const tail = (pre && pre.tail) || ''
|
|
223
|
+
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)
|
|
225
|
+
log(`Preflight red — mechanical fix round on: ${targets.join(', ')}`)
|
|
226
|
+
phase('Fix')
|
|
227
|
+
await parallel(targets.filter(k => byKey[k]).map(k => () => agent(
|
|
228
|
+
`Fix loop for feature \`${feature}\` on your surface (**${k}**). Read \`PIPELINE.md\` first. ` +
|
|
229
|
+
`Contract: \`${contractFile || 'spec §5'}\` (read-only). Touch only \`${byKey[k].path}\`. ` +
|
|
230
|
+
'The mechanical gates (typecheck/lint/tests) are RED. Raw failure tail below — fix exactly ' +
|
|
231
|
+
'what concerns your tree, then rerun your quiet commands until green. Failures:\n' + tail,
|
|
232
|
+
{ agentType: byKey[k].agent, label: `fix:${k}`, phase: 'Fix' })))
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 4b. stage the diff once for the reviewers
|
|
237
|
+
const staged = await agent(
|
|
238
|
+
`Stage review inputs for cohorte feature ${feature}. Diff base: ${base}. Surfaces: ` +
|
|
239
|
+
surfaces.map(s => `${s.key} → ${s.path}`).join(' · ') + '.\n' +
|
|
240
|
+
`1. git diff ${base} --stat > specs/reports/${feature}.stat.txt (never print it). ` +
|
|
241
|
+
'2. Group changed paths by surface prefix (unowned paths = shared remainder → most relevant surface). ' +
|
|
242
|
+
`3. Per touched surface only: git diff ${base} -- <path> [remainder] > specs/reports/${feature}.<key>.diff. ` +
|
|
243
|
+
'4. Return the touched surfaces (empty array if no diff).',
|
|
244
|
+
{ model: 'haiku', label: 'stage-diff', phase: 'Verify', schema: STAGE, effort: 'low' },
|
|
245
|
+
)
|
|
246
|
+
const touched = (staged && staged.surfaces) || []
|
|
247
|
+
if (!touched.length) { questions.push(`no diff against ${base} after build — wrong branch/checkout?`); break }
|
|
248
|
+
|
|
249
|
+
// 4c. smoke ∥ review(+cross-check) — both observe, neither edits: run together
|
|
250
|
+
const [smoke, reviewed] = await parallel([
|
|
251
|
+
() => agent(
|
|
252
|
+
'Smoke-test one feature, per your agent instructions (bring it up, exercise the contract + §8 UI ' +
|
|
253
|
+
'flows, stage the full SMOKE REPORT, tear down; return the capped shape — pass + max 10 one-line ' +
|
|
254
|
+
`failures with a file hint when you have one). — Variable slots: feature ${feature} · spec: ` +
|
|
255
|
+
`specs/${feature}.md · contract: ${contractFile || 'spec §5'} · report: specs/reports/${feature}.smoke.md · ` +
|
|
256
|
+
'checkout: the current working directory (already the feature checkout) · ports/db: the profile/slot defaults',
|
|
257
|
+
{ agentType: 'smoke', label: 'smoke', phase: 'Verify', schema: SMOKE }),
|
|
258
|
+
() => pipeline(
|
|
259
|
+
touched,
|
|
260
|
+
s => agent(
|
|
261
|
+
'Review one feature surface against its frozen spec, per your agent instructions (staged diff ' +
|
|
262
|
+
'FIRST; capped shape — max 20 one-line findings, no code excerpts). — Variable slots: ' +
|
|
263
|
+
`feature ${feature} · scope: the ${s.key} surface only · spec: specs/${feature}.md · ` +
|
|
264
|
+
`staged diff: ${s.diff} · changed files: ${s.files.join(', ')}`,
|
|
265
|
+
{ agentType: 'review', label: `review:${s.key}`, phase: 'Verify', schema: REPORT }),
|
|
266
|
+
async (report, s) => {
|
|
267
|
+
if (!report) return null
|
|
268
|
+
const hard = report.findings.filter(f => f.severity === 'CRITICAL' || f.kind === 'security')
|
|
269
|
+
const rest = report.findings.filter(f => !hard.includes(f))
|
|
270
|
+
if (!hard.length) return { key: s.key, kept: rest }
|
|
271
|
+
const votes = await parallel(hard.map(f => () => agent(
|
|
272
|
+
'Adversarially verify ONE review finding — REFUTE it if you can (code, guard, test, or spec ' +
|
|
273
|
+
'shows it does not hold); uncertain ⇒ refuted=false. — Finding: ' +
|
|
274
|
+
`[${f.severity}/${f.kind}] ${f.file}:${f.line} — ${f.problem} (fix: ${f.fix}). ` +
|
|
275
|
+
`Feature ${feature} · spec: specs/${feature}.md · staged diff: ${s.diff}`,
|
|
276
|
+
{ agentType: 'review', label: `verify:${s.key}`, phase: 'Verify', schema: VERDICT },
|
|
277
|
+
).then(v => ({ f, refuted: !!(v && v.refuted) }))))
|
|
278
|
+
return { key: s.key, kept: rest.concat(votes.filter(Boolean).filter(v => !v.refuted).map(v => v.f)) }
|
|
279
|
+
},
|
|
280
|
+
),
|
|
281
|
+
])
|
|
282
|
+
|
|
283
|
+
smokePass = !!(smoke && smoke.pass)
|
|
284
|
+
const smokeFails = (smoke && smoke.failures) || []
|
|
285
|
+
open = (reviewed || []).filter(Boolean).flatMap(r => r.kept.map(f => ({ ...f, src: r.key })))
|
|
286
|
+
verdict = open.some(f => f.kind === 'security') ? 'BLOCK'
|
|
287
|
+
: open.some(f => f.severity === 'CRITICAL') ? 'REVISE' : 'SHIP'
|
|
288
|
+
log(`Round ${rounds}: review ${verdict} (${open.length} finding(s)) · smoke ${smokePass ? 'PASS' : `FAIL:${smokeFails.length}`}`)
|
|
289
|
+
|
|
290
|
+
if (verdict === 'SHIP' && smokePass) break
|
|
291
|
+
if (rounds >= MAX_ROUNDS) break
|
|
292
|
+
|
|
293
|
+
// 5. fix round. Contract-impacting findings stay INSIDE the loop: a
|
|
294
|
+
// lead-equivalent agent re-authors spec §5 + the contract file (implementers
|
|
295
|
+
// still never touch it — same division of labor as conversational /fix §1),
|
|
296
|
+
// and the surfaces it names re-dispatch against the updated shapes.
|
|
297
|
+
phase('Fix')
|
|
298
|
+
const contractFindings = contractFile
|
|
299
|
+
? open.filter(f => String(f.file).startsWith(String(contract.path))) : []
|
|
300
|
+
let rippleSurfaces = []
|
|
301
|
+
if (contractFindings.length) {
|
|
302
|
+
const cf = await agent(
|
|
303
|
+
`Act as the cohorte lead on a contract change for feature ${feature} (exactly /fix §1's contract ` +
|
|
304
|
+
`check): the findings below show the frozen contract is wrong. Update spec specs/${feature}.md §5 ` +
|
|
305
|
+
`accordingly, then re-author ${contractFile} (mechanism: ${contract.mechanism}` +
|
|
306
|
+
`${contract.index ? `, exported from ${contract.index}` : ''}) until it typechecks. Findings:\n` +
|
|
307
|
+
contractFindings.map(f => `- ${f.file}:${f.line} — ${f.problem} (fix: ${f.fix})`).join('\n') + '\n' +
|
|
308
|
+
'Return ONE line: `<what changed> || <comma-separated surface keys that consume the changed shapes>` ' +
|
|
309
|
+
`(surfaces: ${surfaces.map(s => s.key).join(', ')}; when in doubt list them all).`,
|
|
310
|
+
{ label: 'contract-fix', phase: 'Fix' },
|
|
311
|
+
)
|
|
312
|
+
const [what, keys] = String(cf || '').split('||').map(x => x && x.trim())
|
|
313
|
+
contractChanges.push(what || 'contract re-authored (agent gave no summary)')
|
|
314
|
+
rippleSurfaces = (keys ? keys.split(',').map(k => k.trim()) : surfaces.map(s => s.key))
|
|
315
|
+
.filter(k => byKey[k])
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Group the remaining findings by owning surface; smoke failures ride with
|
|
319
|
+
// the surface their file hint maps to, else the surface with most findings.
|
|
320
|
+
const perSurface = {}
|
|
321
|
+
for (const k of rippleSurfaces) {
|
|
322
|
+
(perSurface[k] = perSurface[k] || []).push(
|
|
323
|
+
`- [ ] 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
|
+
}
|
|
325
|
+
for (const f of open.filter(f => !contractFindings.includes(f))) {
|
|
326
|
+
const k = surfaceOf(f.file) || f.src
|
|
327
|
+
if (byKey[k]) (perSurface[k] = perSurface[k] || []).push(itemLine(f))
|
|
328
|
+
}
|
|
329
|
+
const fallbackKey = Object.keys(perSurface).sort((a, b) => perSurface[b].length - perSurface[a].length)[0]
|
|
330
|
+
|| (built[0] && built[0].key)
|
|
331
|
+
for (const line of smokeFails) {
|
|
332
|
+
const k = surfaceOf((line.match(/[\w./-]+\.\w{1,4}/) || [])[0]) || fallbackKey
|
|
333
|
+
if (byKey[k]) (perSurface[k] = perSurface[k] || []).push(`- [ ] HIGH · runtime · smoke failure · ${line}`)
|
|
334
|
+
}
|
|
335
|
+
if (!Object.keys(perSurface).length) { questions.push('open findings map to no surface — run /fix manually'); break }
|
|
336
|
+
await parallel(Object.entries(perSurface).map(([k, items]) => () => agent(
|
|
337
|
+
`Fix loop for feature \`${feature}\` on your surface (**${k}**). Read \`PIPELINE.md\` first. ` +
|
|
338
|
+
`Contract: \`${contractFile || 'spec §5'}\` (read-only — report mismatches, never edit it). Touch only ` +
|
|
339
|
+
`\`${byKey[k].path}\`. Need your tree's current state? \`git diff ${base} -- ${byKey[k].path}\`. ` +
|
|
340
|
+
'Fix exactly the open items below (self-contained — read only the files they name), then rerun your ' +
|
|
341
|
+
'quiet commands until green. Return the handoff your agent instructions define. Design files: ' +
|
|
342
|
+
`${byKey[k].uses_design ? designLinks : 'none'}. Open items for YOUR surface:\n` + items.join('\n'),
|
|
343
|
+
{ agentType: byKey[k].agent, label: `fix:${k}`, phase: 'Fix' })))
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (rounds >= MAX_ROUNDS && !(verdict === 'SHIP' && smokePass)) {
|
|
347
|
+
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
|
+
}
|
|
349
|
+
if (budget.total && budget.remaining() <= 30000 && !(verdict === 'SHIP' && smokePass)) {
|
|
350
|
+
questions.push('token budget nearly spent — cycle stopped early; rerun the cycle or continue conversationally')
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ── Phase 6 — close: reports, spec bookkeeping, freshness stamp, metrics ────
|
|
354
|
+
phase('Close')
|
|
355
|
+
const success = verdict === 'SHIP' && smokePass
|
|
356
|
+
const findingLine = f => `- **[${f.severity}]** \`${f.file}:${f.line}\` · ${f.kind} · ${f.problem} → **Fix:** ${f.fix}`
|
|
357
|
+
const reportBody = [
|
|
358
|
+
'# REVIEW REPORT', `feature_id: ${feature} · merged by cohorte-cycle workflow (round ${rounds})`, '',
|
|
359
|
+
`Verdict: ${verdict || 'NOT-REACHED'} · smoke: ${smokePass ? 'PASS' : 'FAIL'}`, '', '## Findings', '',
|
|
360
|
+
open.length ? open.map(findingLine).join('\n') : 'None.',
|
|
361
|
+
].join('\n')
|
|
362
|
+
await agent(
|
|
363
|
+
`Close a cohorte cycle run for feature ${feature}, mechanically:\n` +
|
|
364
|
+
`1. Write EXACTLY this to specs/reports/${feature}.md (overwrite):\n<<<REPORT\n${reportBody}\nREPORT\n` +
|
|
365
|
+
(success
|
|
366
|
+
? `2. In specs/${feature}.md tick the DoD boxes the cycle verified (spec conformance + copy language — ` +
|
|
367
|
+
'review SHIP; tests/lint/typecheck — green preflight; runtime flows — smoke PASS); leave anything ' +
|
|
368
|
+
'unverified unticked. 3. Stamp the freshness gate in the spec front-matter, exactly as /review §3 ' +
|
|
369
|
+
`does: BASE=$(git merge-base ${base} HEAD); reviewed_base: $BASE; reviewed_digest: ` +
|
|
370
|
+
`$(git diff $BASE -- . ':(exclude)specs/' | sha256sum | cut -c1-16).\n` :
|
|
371
|
+
`2. Append the open findings to specs/${feature}.md \`## Remediation\` under a subheading ` +
|
|
372
|
+
`\`### cohorte-cycle round ${rounds}\`, one \`- [ ]\` line each (so a conversational /fix picks them ` +
|
|
373
|
+
'up):\n' + (open.map(itemLine).join('\n') || '(none — see questions in the workflow result)') + '\n' +
|
|
374
|
+
`Set the front-matter status: in-review.\n`) +
|
|
375
|
+
`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,"surfaces":{"rounds":"${rounds}","verdict":"${verdict || 'none'}:${open.length}","smoke":"${smokePass ? 'PASS' : 'FAIL'}"}}\n` +
|
|
377
|
+
'5. Chain the opt-in usage pings (all funnel phases this run executed, 0 seconds each): ' +
|
|
378
|
+
`<core>/pipeline/scripts/telemetry-send.sh build "${feature}" 0 "${built.map(() => 'ok').join(',') || 'error'}" || true; ` +
|
|
379
|
+
`<core>/pipeline/scripts/telemetry-send.sh smoke "${feature}" 0 "${smokePass ? 'PASS' : 'FAIL:' + open.filter(f => f.kind === 'runtime').length}" || true; ` +
|
|
380
|
+
`<core>/pipeline/scripts/telemetry-send.sh review "${feature}" 0 "${verdict || 'none'}:${open.length}" || true\n` +
|
|
381
|
+
'Return the single word: done.',
|
|
382
|
+
{ model: 'haiku', label: 'close', effort: 'low' },
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
outcome: success ? 'SHIP-READY' : 'STOPPED',
|
|
387
|
+
rounds,
|
|
388
|
+
verdict: verdict || 'not reached',
|
|
389
|
+
smoke: smokePass ? 'PASS' : 'FAIL',
|
|
390
|
+
contractChanges, // re-authorings the loop performed itself — review them in the diff
|
|
391
|
+
openFindings: open.slice(0, 10).map(f => `[${f.severity}] ${f.file}:${f.line} — ${f.problem}`),
|
|
392
|
+
questions, // everything deferred to you — empty when /brainstorm + /spec did their job
|
|
393
|
+
report: `specs/reports/${feature}.md`,
|
|
394
|
+
next: success
|
|
395
|
+
? `/ship ${feature} — DoD ticked + freshness stamped, ship is a straight shot (human confirm stays)`
|
|
396
|
+
: `answer the questions, then rerun the cycle — or continue with /fix ${feature} + /review (Remediation is up to date)`,
|
|
397
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// cohorte — /refactor as a deterministic workflow (opt-in; the conversational
|
|
2
|
+
// /refactor command remains the default path and the fallback).
|
|
3
|
+
//
|
|
4
|
+
// BIG domains only: a domain with just a handful of open backlog items is
|
|
5
|
+
// cheaper through the conversational /refactor — this script skips it and says
|
|
6
|
+
// so. Invoke with args = {domains: ["backend", …]} or {domains: "all"}.
|
|
7
|
+
//
|
|
8
|
+
// Shape (SCHEMA.md §Workflows): profile via profile-reader (phase 0), the open
|
|
9
|
+
// backlog read once, the `shared` domain (contract package — every slice
|
|
10
|
+
// imports it) refactored FIRST and alone, then the other domains' surface
|
|
11
|
+
// implementers in parallel (their trees are disjoint by construction), each
|
|
12
|
+
// verified per-domain with one bounded retry round, and the backlog ticked.
|
|
13
|
+
|
|
14
|
+
export const meta = {
|
|
15
|
+
name: 'cohorte-refactor',
|
|
16
|
+
description: 'Apply the /audit refactor backlog for big domains: shared first, then parallel surface implementers, per-domain verify + one retry',
|
|
17
|
+
whenToUse: 'Only when the human explicitly asks for the refactor workflow on big domains. args = {domains: ["<surface key>", …] | "all"}.',
|
|
18
|
+
phases: [
|
|
19
|
+
{ title: 'Profile', detail: 'PIPELINE.md → JSON via profile-reader', model: 'haiku' },
|
|
20
|
+
{ title: 'Backlog', detail: 'read the open items per requested domain', model: 'haiku' },
|
|
21
|
+
{ title: 'Shared', detail: 'refactor the contract package first, alone' },
|
|
22
|
+
{ title: 'Refactor', detail: 'one surface implementer per domain, parallel' },
|
|
23
|
+
{ title: 'Verify', detail: 'per-domain gates + item check, one retry round', model: 'haiku' },
|
|
24
|
+
{ title: 'Tick', detail: 'check cleared items off specs/refactor-backlog.md', model: 'haiku' },
|
|
25
|
+
],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A domain below this many open items is not "big" — the conversational
|
|
29
|
+
// /refactor handles it with less overhead than a workflow run.
|
|
30
|
+
const MIN_ITEMS = 5
|
|
31
|
+
|
|
32
|
+
const wanted = (() => {
|
|
33
|
+
const d = args && args.domains
|
|
34
|
+
if (!d || d === 'all') return 'all'
|
|
35
|
+
return Array.isArray(d) ? d : [String(d)]
|
|
36
|
+
})()
|
|
37
|
+
|
|
38
|
+
const PROFILE = { type: 'object', additionalProperties: true }
|
|
39
|
+
|
|
40
|
+
const OPEN = {
|
|
41
|
+
type: 'object', required: ['domains'], additionalProperties: false,
|
|
42
|
+
properties: {
|
|
43
|
+
domains: {
|
|
44
|
+
type: 'array',
|
|
45
|
+
items: {
|
|
46
|
+
type: 'object', required: ['key', 'items'], additionalProperties: false,
|
|
47
|
+
properties: {
|
|
48
|
+
key: { type: 'string' },
|
|
49
|
+
items: { type: 'array', items: { type: 'string', description: 'the open `- [ ] …` line verbatim' } },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const VERIFY = {
|
|
57
|
+
type: 'object', required: ['cleared', 'remaining', 'gatesGreen'], additionalProperties: false,
|
|
58
|
+
properties: {
|
|
59
|
+
cleared: { type: 'array', items: { type: 'string' } },
|
|
60
|
+
remaining: { type: 'array', items: { type: 'string' } },
|
|
61
|
+
gatesGreen: { type: 'boolean' },
|
|
62
|
+
failures: { type: 'string', description: 'one line per gate failure, no output dumps' },
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Phase 0 — profile ────────────────────────────────────────────────────────
|
|
67
|
+
phase('Profile')
|
|
68
|
+
const profile = await agent(
|
|
69
|
+
'Return this project\'s PIPELINE.md `yaml pipeline-profile` block as JSON, per your instructions.',
|
|
70
|
+
{ agentType: 'profile-reader', label: 'profile', schema: PROFILE, effort: 'low' },
|
|
71
|
+
)
|
|
72
|
+
if (!profile || profile.error) {
|
|
73
|
+
return { error: `profile unreadable: ${(profile && profile.error) || 'profile-reader returned nothing'}` }
|
|
74
|
+
}
|
|
75
|
+
const surfaces = Array.isArray(profile.surfaces) ? profile.surfaces : []
|
|
76
|
+
const byKey = Object.fromEntries(surfaces.map(s => [s.key, s]))
|
|
77
|
+
const contractPath = (profile.contract && profile.contract.path) || ''
|
|
78
|
+
const base = (profile.vcs && profile.vcs.default_branch) || 'main'
|
|
79
|
+
const quiet = s => (s.test_quiet_cmd && !String(s.test_quiet_cmd).startsWith('<'))
|
|
80
|
+
? s.test_quiet_cmd : s.test_cmd ? `${s.test_cmd} 2>&1 | tail -40` : ''
|
|
81
|
+
|
|
82
|
+
// ── Phase 1 — read the open backlog ──────────────────────────────────────────
|
|
83
|
+
phase('Backlog')
|
|
84
|
+
const backlog = await agent(
|
|
85
|
+
'Read specs/refactor-backlog.md and return, per `## <domain>` heading, the OPEN `- [ ] …` item lines ' +
|
|
86
|
+
'verbatim (skip checked `- [x]` ones). File missing ⇒ return an empty domains array. ' +
|
|
87
|
+
`Requested domains: ${wanted === 'all' ? 'all' : wanted.join(', ')} — return only those (all ⇒ every domain with open items).`,
|
|
88
|
+
{ model: 'haiku', label: 'read-backlog', schema: OPEN, effort: 'low' },
|
|
89
|
+
)
|
|
90
|
+
const open = ((backlog && backlog.domains) || []).filter(d => d.items.length)
|
|
91
|
+
if (!open.length) return { error: 'no open backlog items for the requested domains — run /audit (or the audit workflow) first' }
|
|
92
|
+
|
|
93
|
+
const big = open.filter(d => d.items.length >= MIN_ITEMS)
|
|
94
|
+
const small = open.filter(d => d.items.length < MIN_ITEMS)
|
|
95
|
+
for (const d of small) log(`Skipping ${d.key} (${d.items.length} open item(s) < ${MIN_ITEMS}) — use the conversational /refactor ${d.key}, it's cheaper`)
|
|
96
|
+
if (!big.length) return { skipped: Object.fromEntries(small.map(d => [d.key, d.items.length])), reason: `every requested domain is below the ${MIN_ITEMS}-item workflow threshold — use /refactor` }
|
|
97
|
+
|
|
98
|
+
const implementPrompt = d =>
|
|
99
|
+
'Refactor pass on your surface (no feature spec). Read PIPELINE.md first. Add the missing tests ' +
|
|
100
|
+
'FIRST (pin current behavior / cover the entry points), watch them pass, THEN refactor to clear each ' +
|
|
101
|
+
'item. Preserve current public behavior unless an item marks it a bug. Migrations stay additive. ' +
|
|
102
|
+
`Need the current state of your tree? Compute it yourself: git diff ${base} -- <your surface path>. ` +
|
|
103
|
+
'Lint + format before handoff; return the handoff in the format your agent instructions define. ' +
|
|
104
|
+
'Backlog items for YOUR surface (self-contained — clear exactly these, reading only the files they name):\n' +
|
|
105
|
+
d.items.join('\n')
|
|
106
|
+
|
|
107
|
+
const verifyDomain = async (d, implHandoff) => {
|
|
108
|
+
if (implHandoff == null) return { key: d.key, cleared: [], remaining: d.items, gatesGreen: false, failures: 'implementer died' }
|
|
109
|
+
const s = byKey[d.key]
|
|
110
|
+
const gateCmds = s ? [quiet(s), s.lint_quiet_cmd || (s.lint_cmd ? `${s.lint_cmd} 2>&1 | tail -40` : ''), s.typecheck_cmd]
|
|
111
|
+
.filter(c => c && !String(c).startsWith('<')) : []
|
|
112
|
+
let v = await agent(
|
|
113
|
+
`Verify a cohorte refactor round for domain ${d.key}. 1. Run these gates, each redirected to ` +
|
|
114
|
+
`specs/reports/refactor-verify.${d.key}.txt (append) — never print their output: ` +
|
|
115
|
+
`${gateCmds.map(c => JSON.stringify(c)).join(' · ') || '(none declared — skip gates, gatesGreen=true)'}. ` +
|
|
116
|
+
'2. For EACH backlog item below, open its file:line and check the prescribed fix actually landed. ' +
|
|
117
|
+
'Return the item lines split into cleared / remaining (verbatim), gatesGreen, and one line per gate failure.\n' +
|
|
118
|
+
'Items:\n' + d.items.join('\n'),
|
|
119
|
+
{ model: 'haiku', label: `verify:${d.key}`, phase: 'Verify', schema: VERIFY, effort: 'low' },
|
|
120
|
+
)
|
|
121
|
+
// One bounded retry: re-dispatch the implementer on what verification rejected.
|
|
122
|
+
if (v && (v.remaining.length || !v.gatesGreen) && byKey[d.key]) {
|
|
123
|
+
const retryItems = v.remaining.length ? v.remaining : d.items
|
|
124
|
+
log(`${d.key}: ${v.remaining.length} item(s) remaining${v.gatesGreen ? '' : ' + red gates'} — one retry round`)
|
|
125
|
+
await agent(
|
|
126
|
+
implementPrompt({ key: d.key, items: retryItems }) + (v.failures ? `\nGate failures to clear too:\n${v.failures}` : ''),
|
|
127
|
+
{ agentType: byKey[d.key].agent, label: `retry:${d.key}`, phase: 'Refactor' },
|
|
128
|
+
)
|
|
129
|
+
v = await agent(
|
|
130
|
+
`Re-verify domain ${d.key} after a retry round — same procedure as before (gates redirected to ` +
|
|
131
|
+
`specs/reports/refactor-verify.${d.key}.txt, per-item file:line check, verbatim cleared/remaining lines).\n` +
|
|
132
|
+
'Items:\n' + retryItems.join('\n'),
|
|
133
|
+
{ model: 'haiku', label: `reverify:${d.key}`, phase: 'Verify', schema: VERIFY, effort: 'low' },
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
return { key: d.key, ...(v || { cleared: [], remaining: d.items, gatesGreen: false, failures: 'verifier died' }) }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Phase 2 — `shared` first, alone (every slice imports the contract pkg) ───
|
|
140
|
+
const results = []
|
|
141
|
+
const shared = big.find(d => d.key === 'shared')
|
|
142
|
+
if (shared) {
|
|
143
|
+
phase('Shared')
|
|
144
|
+
const handoff = await agent(
|
|
145
|
+
`Refactor the shared contract package (${contractPath || 'the tree outside every surface'}) of this ` +
|
|
146
|
+
'cohorte project — normally lead-owned, so: additive changes only, never break a shape a surface ' +
|
|
147
|
+
'imports (grep consumers before changing any export), migrations stay additive, lint + format before ' +
|
|
148
|
+
'handoff. Clear exactly these open backlog items (self-contained; read only the files they name):\n' +
|
|
149
|
+
shared.items.join('\n'),
|
|
150
|
+
{ label: 'refactor:shared', phase: 'Shared' },
|
|
151
|
+
)
|
|
152
|
+
results.push(await verifyDomain(shared, handoff))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Phases 3+4 — the other domains in parallel, each verified as it lands ────
|
|
156
|
+
const rest = big.filter(d => d.key !== 'shared' && byKey[d.key])
|
|
157
|
+
for (const d of big) {
|
|
158
|
+
if (d.key !== 'shared' && !byKey[d.key]) log(`Skipping ${d.key} — no matching surface in PIPELINE.md`)
|
|
159
|
+
}
|
|
160
|
+
const restResults = await pipeline(
|
|
161
|
+
rest,
|
|
162
|
+
d => agent(implementPrompt(d), { agentType: byKey[d.key].agent, label: `refactor:${d.key}`, phase: 'Refactor' }),
|
|
163
|
+
(handoff, d) => verifyDomain(d, handoff),
|
|
164
|
+
)
|
|
165
|
+
results.push(...restResults.filter(Boolean))
|
|
166
|
+
|
|
167
|
+
// ── Phase 5 — tick the cleared items ─────────────────────────────────────────
|
|
168
|
+
phase('Tick')
|
|
169
|
+
const clearedAll = results.flatMap(r => r.cleared)
|
|
170
|
+
if (clearedAll.length) {
|
|
171
|
+
await agent(
|
|
172
|
+
'In specs/refactor-backlog.md flip EXACTLY these open `- [ ]` item lines to `- [x]` (match verbatim, ' +
|
|
173
|
+
'leave every other line untouched), then return the single word done:\n' + clearedAll.join('\n'),
|
|
174
|
+
{ model: 'haiku', label: 'tick-backlog', effort: 'low' },
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
domains: Object.fromEntries(results.map(r => [r.key, {
|
|
180
|
+
cleared: r.cleared.length, remaining: r.remaining.length, gatesGreen: r.gatesGreen,
|
|
181
|
+
}])),
|
|
182
|
+
skippedSmall: Object.fromEntries(small.map(d => [d.key, d.items.length])),
|
|
183
|
+
stillOpen: results.flatMap(r => r.remaining.map(line => `[${r.key}] ${line}`)).slice(0, 15),
|
|
184
|
+
next: results.some(r => r.remaining.length || !r.gatesGreen)
|
|
185
|
+
? 'items remain — finish them with the conversational /refactor <domain>'
|
|
186
|
+
: 'all dispatched domains clean — optionally close with one final /audit',
|
|
187
|
+
}
|