cohorte 1.3.4 → 1.5.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 (43) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +36 -16
  3. package/bin/cli.js +34 -4
  4. package/core/agents/profile-reader.md +22 -0
  5. package/core/commands/build.md +4 -4
  6. package/core/commands/doctor.md +10 -8
  7. package/core/commands/fix.md +5 -5
  8. package/core/commands/loop.md +61 -0
  9. package/core/commands/review.md +38 -5
  10. package/core/hooks/gate.py +4 -4
  11. package/core/templates/spec.template.md +1 -1
  12. package/core/templates/steps/init-pipeline/02-interview-gaps.md +1 -1
  13. package/core/templates/steps/init-pipeline/04-write-render.md +8 -4
  14. package/core/workflows/audit.js +68 -4
  15. package/core/workflows/refactor.js +69 -4
  16. package/core/workflows/review.js +73 -4
  17. package/dashboard/dist/assets/index-8owBnqyv.js +43 -0
  18. package/dashboard/dist/assets/{index-AFQnlfjO.css → index-dkO8UUVl.css} +1 -1
  19. package/dashboard/dist/index.html +2 -2
  20. package/dashboard/server/doctor.js +15 -5
  21. package/dashboard/server/index.js +7 -0
  22. package/dashboard/server/metrics.js +6 -5
  23. package/dashboard/server/usage.js +61 -0
  24. package/install.ps1 +4 -1
  25. package/install.sh +5 -2
  26. package/package.json +1 -1
  27. package/profile/PIPELINE.template.md +3 -3
  28. package/profile/SCHEMA.md +21 -44
  29. package/scripts/loop.sh +189 -0
  30. package/scripts/metrics/collect.mjs +504 -0
  31. package/scripts/metrics/prices.json +39 -0
  32. package/scripts/preflight.sh +2 -2
  33. package/scripts/telemetry-send.sh +5 -2
  34. package/scripts/test-dashboard.mjs +29 -3
  35. package/scripts/test-gate.mjs +1 -2
  36. package/scripts/test-metrics.mjs +144 -0
  37. package/scripts/test-workflows.mjs +56 -178
  38. package/scripts/validate-core.mjs +10 -9
  39. package/core/agents/smoke.md +0 -63
  40. package/core/commands/cycle.md +0 -61
  41. package/core/commands/smoke.md +0 -55
  42. package/core/workflows/cycle.js +0 -513
  43. package/dashboard/dist/assets/index-DLBzciIC.js +0 -43
@@ -20,7 +20,7 @@ Ask ONLY what you couldn't confidently detect. Batch related questions. Cover:
20
20
  Recommended option: dot/failures-only reporter (`--reporter=dot` vitest/playwright, `--silent`
21
21
  jest, `-q` pytest, `--quiet` eslint/ruff — whatever the detected runner supports). These land in
22
22
  `test_quiet_cmd`/`lint_quiet_cmd` + `commands.test_quiet`/`lint_quiet` and are what agents and the
23
- `/review`·`/smoke` pre-flight actually run (SCHEMA.md §Output discipline). If the human declines or
23
+ `/review` pre-flight actually run (SCHEMA.md §Output discipline). If the human declines or
24
24
  the runner has no such flag, leave `""` — consumers then fall back to `<cmd> 2>&1 | tail -40`.
25
25
  - **Contract** — mechanism (`shared-types-zod` / `openapi` / `protobuf` / `json-schema` / `none`) and
26
26
  where feature contracts are authored. If `none`, surfaces sync by the spec prose alone.
@@ -15,7 +15,7 @@
15
15
  (`<SURFACE_EXTRA_NEVER>`, `<SURFACE_DESIGN_INPUT>`, `<SURFACE_TDD_STEP1>` — fill design-related ones
16
16
  only when `uses_design`).
17
17
  Leave the fixed agents as-is (generic, shipped by the installer): `review.md`, `release.md`,
18
- `smoke.md`, `profile-reader.md`.
18
+ `profile-reader.md`.
19
19
  4. **Generate `.claude/gate-config.json`** from the `gate` block — copy all five keys verbatim:
20
20
  `{"deny": [...], "ask": [...], "ask_on_default_branch": [...], "default_branch": "<vcs.default_branch>",
21
21
  "preflight": {"enabled": <gate.preflight.enabled>, "agents": [...], "max_age_minutes": <n>}}`
@@ -27,7 +27,11 @@
27
27
  variants** and repo-wide `commands.*` equivalents as `Bash(<cmd>:*)` rules, plus read-only git —
28
28
  `Bash(git status:*)`, `Bash(git diff:*)`, `Bash(git log:*)`, `Bash(git rev-parse:*)` — plus the
29
29
  shipped pipeline scripts for BOTH cores (`Bash(.claude/pipeline/scripts/:*)` and
30
- `Bash(~/.claude/pipeline/scripts/:*)` — preflight, kanban-move, telemetry-send), and the
30
+ `Bash(~/.claude/pipeline/scripts/:*)` — preflight, kanban-move, telemetry-send) **plus the
31
+ `bash`-prefixed form the `/loop` driver uses** (`Bash(bash .claude/pipeline/scripts/loop.sh:*)`
32
+ and `Bash(bash ~/.claude/pipeline/scripts/loop.sh:*)`) — those prefix rules match a command
33
+ *starting* with the path, so `bash <path>` needs its own entry or `/loop` stalls on a permission
34
+ prompt at every launch, and the
31
35
  retrieval provider's MCP tools when wired (e.g. `mcp__serena`). Never allowlist anything matching
32
36
  a `gate.ask`/`gate.deny` pattern. Mention the human can widen it later with
33
37
  `/fewer-permission-prompts`) + the hooks, **conditioned on the install mode:**
@@ -86,8 +90,8 @@
86
90
  `build_cmd`s that are non-empty). Derive the setup steps from the detected stack — mirror what a
87
91
  sibling workflow does if one exists. `/ship` watches these checks before the merge.
88
92
  11. **Metrics sink & report buffer:** add `.claude/pipeline-metrics.jsonl` to `.gitignore` — `/build`,
89
- `/review`, `/fix` and `/smoke` append per-dispatch evidence there (SCHEMA §Specialization reads it).
90
- Also add `specs/reports/` — `/review` and `/smoke` stage their last report there so a `/fix` (or
93
+ `/review` and `/fix` append per-dispatch evidence there (SCHEMA §Specialization reads it).
94
+ Also add `specs/reports/` — `/review` stages its last report there so a `/fix` (or
91
95
  `/spec` Mode B) survives a `/clear`; it's a derived buffer, not a versioned artifact.
92
96
  12. **Design system:** if `design.enabled` with a snapshot dir, note that `/align-ds` is active; else the
93
97
  `/align-ds` command will no-op with a clear message.
@@ -21,9 +21,67 @@ export const meta = {
21
21
  ],
22
22
  }
23
23
 
24
- const target = (typeof args === 'string' ? args.trim() : args && args.target) || ''
24
+ // The Workflow runtime hands `args` to a script verbatim, so a caller that passes a
25
+ // JSON-ENCODED STRING instead of a real object gets that string back here. The old
26
+ // `typeof args === 'string' ? args.trim()` then took the whole blob as the value — which
27
+ // is how a report landed on disk named `specs/reports/{"feature": "x"}.md`, and how
28
+ // maxRounds/smoke were silently dropped on the same run. Parse it back into the object
29
+ // it was meant to be; a bare slug stays valid shorthand.
30
+ const ARGS = (() => {
31
+ if (typeof args === 'string') {
32
+ const t = args.trim()
33
+ if (t.startsWith('{')) {
34
+ try { const o = JSON.parse(t); if (o && typeof o === 'object' && !Array.isArray(o)) return o } catch {}
35
+ }
36
+ return { feature: t, target: t }
37
+ }
38
+ return args && typeof args === 'object' ? args : {}
39
+ })()
40
+ const target = ARGS.target || ''
25
41
 
26
- const PROFILE = { type: 'object', additionalProperties: true }
42
+ // The profile-reader returns through a StructuredOutput tool call, and a haiku agent
43
+ // intermittently nests the whole profile as a JSON *string* under a single wrapper field
44
+ // ({"output": "{\"surfaces\": …}"}) instead of putting the profile's keys at the top level.
45
+ // The schema here used to be {type:'object', additionalProperties:true} — no declared
46
+ // properties, no required keys — so that wrapper validated cleanly and every field then read
47
+ // as undefined: `surfaces` fell back to [], parallel([]) dispatched zero agents, the
48
+ // dead-agent guard had no surfaces to find missing, and the run reported a verdict having
49
+ // done nothing. On the surface it is indistinguishable from a clean run with an empty diff.
50
+ // Declaring the shape gives the tool layer something to validate and the agent something to
51
+ // aim at; unwrapProfile() salvages a wrapped return that still gets through; and the
52
+ // zero-surface abort below makes the silent-success path impossible either way.
53
+ // See also the structured-output section of core/agents/profile-reader.md.
54
+ const PROFILE = {
55
+ type: 'object', additionalProperties: true,
56
+ properties: {
57
+ error: { type: 'string', description: 'set ONLY when PIPELINE.md is missing or unparseable' },
58
+ surfaces: {
59
+ type: 'array',
60
+ description: "one entry per surface, at the TOP LEVEL of this object — never a JSON string",
61
+ items: {
62
+ type: 'object', required: ['key'], additionalProperties: true,
63
+ properties: { key: { type: 'string' }, path: { type: 'string' }, agent: { type: 'string' } },
64
+ },
65
+ },
66
+ },
67
+ }
68
+
69
+ // Salvage a profile handed back as JSON text rather than as an object — either the whole
70
+ // return, or nested under a single wrapper field. Anything already shaped like a profile
71
+ // (has `surfaces`, or is the documented `{error}` failure shape) passes through untouched.
72
+ const unwrapProfile = p => {
73
+ if (typeof p === 'string') { try { return JSON.parse(p) } catch { return null } }
74
+ if (!p || typeof p !== 'object') return null
75
+ if (Array.isArray(p.surfaces) || p.error) return p
76
+ for (const v of Object.values(p)) {
77
+ if (typeof v !== 'string') continue
78
+ try {
79
+ const inner = JSON.parse(v)
80
+ if (inner && typeof inner === 'object' && !Array.isArray(inner)) return inner
81
+ } catch {}
82
+ }
83
+ return p
84
+ }
27
85
 
28
86
  const GATES = {
29
87
  type: 'object', required: ['failures'], additionalProperties: false,
@@ -64,15 +122,21 @@ const BACKLOG = {
64
122
 
65
123
  // ── Phase 0 — profile ────────────────────────────────────────────────────────
66
124
  phase('Profile')
67
- const profile = await agent(
125
+ const profile = unwrapProfile(await agent(
68
126
  'Return this project\'s PIPELINE.md `yaml pipeline-profile` block as JSON, per your instructions.',
69
127
  { agentType: 'profile-reader', label: 'profile', schema: PROFILE, effort: 'low' },
70
- )
128
+ ))
71
129
  if (!profile || profile.error) {
72
130
  return { error: `profile unreadable: ${(profile && profile.error) || 'profile-reader returned nothing'}` }
73
131
  }
74
132
  const cmds = profile.commands || {}
75
133
  const surfaces = Array.isArray(profile.surfaces) ? profile.surfaces : []
134
+ // A profile with no surfaces cannot do this workflow's work, and every later
135
+ // guard compares against `surfaces` — an empty list makes them all vacuously
136
+ // pass. Fail loudly here instead of finishing with nothing done.
137
+ if (!surfaces.length) {
138
+ return { error: 'profile has no surfaces — nothing would be audited. the `yaml pipeline-profile` block in PIPELINE.md is empty or unparseable, or the profile-reader mis-returned; run /doctor' }
139
+ }
76
140
  const quiet = (q, full) => (q && !String(q).startsWith('<') ? q : full ? `${full} 2>&1 | tail -40` : '')
77
141
  const scope = target || 'the whole repo'
78
142
 
@@ -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 = args && args.domains
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
- const PROFILE = { type: 'object', additionalProperties: true }
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'
@@ -26,10 +26,73 @@ export const meta = {
26
26
  ],
27
27
  }
28
28
 
29
- const feature = typeof args === 'string' ? args.trim() : args && args.feature
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
+ }
52
+
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
+ }
31
79
 
32
- const PROFILE = { type: 'object', additionalProperties: true }
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('<'))