cohorte 2.4.0 → 2.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,43 @@ short, user-facing, most recent first. One `## <version> — <YYYY-MM-DD>` secti
7
7
  > They are history and are deliberately not rewritten — every command gained a `cohorte-` prefix
8
8
  > in 2.0.0.
9
9
 
10
+ ## 2.5.0 — 2026-08-14
11
+
12
+ - **Everything the pipeline knew about your repo, it would only say to a coding agent.**
13
+ `/cohorte-doctor` reads the health of an install and `specs/*.md` holds the board, but both
14
+ answers cost a session: you opened Claude Code, ran a command, and read prose. There was no
15
+ way to ask "is this repo's pipeline sound?" from a shell script, a CI job, or anything that
16
+ isn't an LLM.
17
+
18
+ Two read-only commands now answer without an agent in the loop:
19
+
20
+ ```sh
21
+ cohorte specs # the board: id · status · branch · title, from specs/*.md frontmatter
22
+ cohorte doctor # the /cohorte-doctor checks — exits 1 when any check is bad
23
+ ```
24
+
25
+ `doctor` **exits 1 on any bad check**, so it drops into CI as-is. Both reuse the dashboard's
26
+ own readers, so the board, the dashboard and the CLI can never give three answers about one
27
+ repo. `--porcelain` gives one record per line with `U+001F` between fields — a spec title
28
+ with a space in it never misaligns a column — and `--json` gives the native document.
29
+
30
+ - **The pipeline, in Francois.** A third shape, `--panel` (on `specs`, `doctor` and `metrics`),
31
+ emits the payload a [Francois](https://github.com/antoine-gmnz/francois) extension panel
32
+ validates against. It exists for
33
+ [**francois-plugin-cohorte**](https://github.com/TheBidouilleAgency/francois-plugin-cohorte):
34
+ a manifest-only extension — no code, no binary — that renders the 30-day cost, the doctor
35
+ report and the spec board as three panels beside your sessions, on any project with a
36
+ `PIPELINE.md`.
37
+
38
+ ```sh
39
+ npm i -g cohorte # a Francois extension may only spawn a
40
+ francois ext install TheBidouilleAgency/cohorte # bare binary on PATH — never npx, never a shell
41
+ ```
42
+
43
+ This is the only Francois-aware surface in the package, and it is one flag wide. Nothing in
44
+ the pipeline itself changed: no new agent, no new gate, no template to re-render — this
45
+ release needs no `/cohorte-update-pipeline`.
46
+
10
47
  ## 2.4.0 — 2026-08-13
11
48
 
12
49
  - **The pipeline froze *what* to build, never *how much*.** A spec pins the contract and the
package/README.md CHANGED
@@ -234,6 +234,33 @@ source lives in `dashboard/app/`, built to `dashboard/dist/` at publish time). T
234
234
  are reimplemented in JS, so the dashboard needs no Claude session to compute state. See
235
235
  [`dashboard/README.md`](dashboard/README.md) for the architecture.
236
236
 
237
+ ## Reading a project without an agent
238
+
239
+ The two read-only halves of the pipeline, in the shell:
240
+
241
+ ```sh
242
+ npx cohorte specs # the board: id · status · branch · title, from specs/*.md
243
+ npx cohorte doctor # the /cohorte-doctor checks — exits 1 when any check is bad
244
+ npx cohorte metrics --days=30 # cost + runtime per command, from Claude Code's transcripts
245
+ ```
246
+
247
+ `doctor`'s exit code makes it a CI step as-is. Add `--porcelain` for one record per line with
248
+ `U+001F` between fields (a spec title with a space in it never misaligns a column), or `--json`
249
+ for the native document. Both reuse the dashboard's own readers, so the board and the CLI can
250
+ never give two answers about one repo.
251
+
252
+ `--panel` — on `specs`, `doctor` and `metrics` — emits the payload shape a
253
+ [Francois](https://github.com/antoine-gmnz/francois) extension panel expects. It is the one
254
+ Francois-aware surface in the package, and it exists for
255
+ [**francois-plugin-cohorte**](https://github.com/TheBidouilleAgency/francois-plugin-cohorte): a
256
+ manifest-only extension that renders the spec board, the doctor report and the 30-day cost as
257
+ three panels beside your sessions.
258
+
259
+ ```sh
260
+ npm i -g cohorte # a Francois extension may only spawn a bare
261
+ francois ext install TheBidouilleAgency/cohorte # binary on PATH — never npx, never a shell
262
+ ```
263
+
237
264
  ## Releasing (maintainers)
238
265
 
239
266
  Versions are tracked with npm semver — the published package is the release artifact.
package/bin/cli.js CHANGED
@@ -37,6 +37,8 @@ Usage:
37
37
  cohorte update [target] [--global] [--runtime=a,b | --all-runtimes]
38
38
  cohorte dashboard [target] [--port=N] [--host=ADDR] [--open]
39
39
  cohorte metrics [target] [--days=N] [--since=ISO] [--runs] [--json]
40
+ cohorte specs [target] [--porcelain | --json | --panel]
41
+ cohorte doctor [target] [--porcelain | --json | --panel]
40
42
  cohorte version
41
43
 
42
44
  Commands:
@@ -56,8 +58,18 @@ Commands:
56
58
  ~/.claude/projects — nothing to enable, and it covers runs that
57
59
  already happened. Worktree-aware, so a feature adds up. --json for
58
60
  the raw rollup, --runs to include every individual invocation.
61
+ specs The spec board of <target>: id, status, branch, title, read from the
62
+ frontmatter of specs/*.md. --porcelain for one record per line with
63
+ U+001F between fields, --json for the list.
64
+ doctor The same checks /cohorte-doctor runs (core, pointer, profile, agents,
65
+ hook, gate, retrieval, design, isolation, specs), without a coding
66
+ agent in the loop. Exits 1 when any check is bad, 0 otherwise, so it
67
+ drops into CI. --porcelain / --json as above.
59
68
  version Print the installed CLI version.
60
69
 
70
+ --panel on specs, doctor and metrics emits the payload a Francois extension
71
+ panel expects (github.com/TheBidouilleAgency/francois-plugin-cohorte).
72
+
61
73
  Runtimes (--runtime=): ${adapter.listRuntimes().join(', ')}
62
74
  The pipeline's doctrine is one set of source prompts; the installer renders them into
63
75
  whatever each coding agent reads (markdown + frontmatter, plain markdown, or TOML) and
@@ -86,13 +98,19 @@ const metricsFlags = [];
86
98
  const isMetricsFlag = (a) =>
87
99
  a === '--json' || a === '--runs' || a.startsWith('--days=') || a.startsWith('--since=');
88
100
 
101
+ // How `specs`, `doctor` and `metrics` render. One variable, not one flag per command,
102
+ // so `--porcelain` never means two different things depending on where it sits.
103
+ let format = 'human';
104
+
89
105
  // Which coding agents to install for. Empty ⇒ resolved later (detect, then ask on a TTY,
90
106
  // then fall back to claude — the only behaviour that existed before 2.2.0).
91
107
  let wantRuntimes = [];
92
108
 
93
109
  for (const a of args) {
94
- if (a === 'install' || a === 'update' || a === 'dashboard' || a === 'metrics') mode = a;
95
- else if (isMetricsFlag(a)) metricsFlags.push(a);
110
+ if (a === 'install' || a === 'update' || a === 'dashboard' || a === 'metrics'
111
+ || a === 'specs' || a === 'doctor') mode = a;
112
+ else if (a === '--porcelain' || a === '--panel') format = a.slice(2);
113
+ else if (isMetricsFlag(a)) { if (a === '--json') format = 'json'; metricsFlags.push(a); }
96
114
  else if (a === '--all-runtimes') wantRuntimes = adapter.listRuntimes();
97
115
  else if (a === '--runtimes' || a === '--runtime') {
98
116
  console.error('error: --runtime needs a value, e.g. --runtime=codex,cursor'); process.exit(2);
@@ -136,10 +154,45 @@ if (mode === 'dashboard') {
136
154
  return;
137
155
  }
138
156
 
157
+ // --- specs / doctor: read-only reports on <target> ---------------------------
158
+ // Both reuse the dashboard's own readers, so the board and the CLI can never drift
159
+ // into two answers about the same repo. Nothing here writes or spawns anything.
160
+ if (mode === 'specs' || mode === 'doctor') {
161
+ const report = require('./report.js');
162
+ const { state, scanSpecs } = require('../dashboard/server/doctor.js');
163
+ const globalDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
164
+
165
+ if (mode === 'specs') {
166
+ const records = report.specRecords(target, scanSpecs);
167
+ if (format === 'json') console.log(JSON.stringify({ project: target, specs: records }, null, 2));
168
+ else if (format === 'porcelain') { if (records.length) console.log(report.specsPorcelain(records)); }
169
+ else if (format === 'panel') console.log(report.specsPanel(records));
170
+ else console.log(report.specsHuman(records));
171
+ process.exit(0);
172
+ }
173
+
174
+ // `state` is async (it probes versions), so this branch owns the whole tail.
175
+ state({ projectRoot: target, globalDir, cliVersion: VERSION }).then((s) => {
176
+ const records = report.checkRecords(s);
177
+ if (format === 'json') console.log(JSON.stringify(s, null, 2));
178
+ else if (format === 'porcelain') { if (records.length) console.log(report.doctorPorcelain(records)); }
179
+ else if (format === 'panel') console.log(report.doctorPanel(records));
180
+ else console.log(report.doctorHuman(s, records));
181
+ // A bad check is a failure the shell can branch on; warn and skip are not.
182
+ process.exit(s.summary.bad > 0 && format !== 'panel' ? 1 : 0);
183
+ }).catch((e) => {
184
+ console.error(`error: ${e && e.message ? e.message : e}`);
185
+ process.exit(1);
186
+ });
187
+ return;
188
+ }
189
+
139
190
  // --- metrics: cost + runtime per command ------------------------------------
140
191
  // The collector is ESM and this CLI is CommonJS, so it runs as a child process rather
141
192
  // than being required. stdio is inherited so --json stays pipeable.
142
193
  if (mode === 'metrics') {
194
+ // `--panel` is not in isMetricsFlag (it is not metrics-specific), so forward it here.
195
+ if (format === 'panel') metricsFlags.push('--panel');
143
196
  const script = path.join(pkgRoot, 'scripts', 'metrics', 'collect.mjs');
144
197
  if (!fs.existsSync(script)) {
145
198
  console.error(`error: metrics collector not found at ${script}`);
package/bin/report.js ADDED
@@ -0,0 +1,141 @@
1
+ 'use strict';
2
+ // Machine-readable reports for `cohorte specs` and `cohorte doctor`.
3
+ //
4
+ // Both commands read what the dashboard already computes (dashboard/server/doctor.js:
5
+ // `scanSpecs` and `state`) and render it four ways:
6
+ //
7
+ // default a human table on a terminal
8
+ // --porcelain one record per line, fields separated by U+001F (the ASCII unit
9
+ // separator) — stable, greppable, and immune to a spec title that
10
+ // contains a space, a tab or a pipe
11
+ // --json the native document (the full doctor state, the spec list)
12
+ // --panel the payload shape a Francois extension panel validates against
13
+ // (`{rows:[{key,value,tone}]}` / `{rows:[{id,cells,tone}]}`). This is
14
+ // the ONLY Francois-aware surface in cohorte; everything else here is
15
+ // generic. See github.com/TheBidouilleAgency/francois-plugin-cohorte.
16
+ //
17
+ // Dependency-free, and it never writes anything.
18
+
19
+ const US = String.fromCharCode(0x1f); // ASCII unit separator (U+001F)
20
+
21
+ // A spec's status → a display tone. The tones are the ones a status column can carry
22
+ // anywhere (terminal colour, panel row tone); `busy` marks the two statuses that mean
23
+ // "a command is mid-flight on this spec".
24
+ const STATUS_TONE = {
25
+ draft: 'neutral',
26
+ frozen: 'neutral',
27
+ 'in-progress': 'busy',
28
+ 'in-review': 'busy',
29
+ shipped: 'ok',
30
+ blocked: 'error',
31
+ };
32
+
33
+ // doctor's four check states → the same tone vocabulary. `skip` is not a failure:
34
+ // a check that does not apply to this project (no design system, no worktrees) reads
35
+ // neutral, never warn.
36
+ const CHECK_TONE = { ok: 'ok', warn: 'warn', bad: 'error', skip: 'neutral' };
37
+
38
+ // Collapse anything that would break the line format or a single-line cell. A spec
39
+ // title is free text written by a human in YAML frontmatter — it can contain a newline
40
+ // continuation, and it must never split one record into two. A stray separator inside a
41
+ // field goes the same way, and so does the padding a doctor `fix` carries for terminal
42
+ // alignment — in a one-line panel cell that padding reads as a hole.
43
+ function flat(value) {
44
+ return String(value == null ? '' : value)
45
+ .replace(new RegExp(`[\\s${US}]+`, 'g'), ' ')
46
+ .trim();
47
+ }
48
+
49
+ function specRecords(projectRoot, scanSpecs) {
50
+ return scanSpecs(projectRoot).map((s) => ({
51
+ id: s.id,
52
+ title: s.title || '',
53
+ status: s.status || 'unknown',
54
+ branch: s.branch || '',
55
+ file: s.file,
56
+ tone: STATUS_TONE[s.status] || 'warn', // an unknown status IS the warning
57
+ }));
58
+ }
59
+
60
+ function specsPorcelain(records) {
61
+ return records
62
+ .map((r) => [r.id, r.title, r.status, r.branch, r.tone].map(flat).join(US))
63
+ .join('\n');
64
+ }
65
+
66
+ function specsPanel(records) {
67
+ return JSON.stringify({
68
+ rows: records.map((r) => ({
69
+ id: flat(r.id),
70
+ tone: r.tone,
71
+ cells: { id: flat(r.id), title: flat(r.title), status: flat(r.status), branch: flat(r.branch) },
72
+ })),
73
+ });
74
+ }
75
+
76
+ function specsHuman(records) {
77
+ if (!records.length) return 'no specs in ./specs — /cohorte-spec writes the first one';
78
+ const w = (k) => Math.max(...records.map((r) => flat(r[k]).length), k.length);
79
+ const [wi, ws, wb] = [w('id'), w('status'), w('branch')];
80
+ const head = `${'id'.padEnd(wi)} ${'status'.padEnd(ws)} ${'branch'.padEnd(wb)} title`;
81
+ const rows = records.map(
82
+ (r) => `${flat(r.id).padEnd(wi)} ${flat(r.status).padEnd(ws)} ${flat(r.branch).padEnd(wb)} ${flat(r.title)}`,
83
+ );
84
+ return [head, '-'.repeat(head.length), ...rows].join('\n');
85
+ }
86
+
87
+ function checkRecords(state) {
88
+ return state.checks.map((c) => ({
89
+ id: c.id,
90
+ label: c.label,
91
+ status: c.status,
92
+ detail: c.detail,
93
+ fix: c.fix || '',
94
+ tone: CHECK_TONE[c.status] || 'neutral',
95
+ }));
96
+ }
97
+
98
+ function doctorPorcelain(records) {
99
+ return records
100
+ .map((r) => [r.id, r.label, r.status, r.detail, r.fix].map(flat).join(US))
101
+ .join('\n');
102
+ }
103
+
104
+ // key-value, one row per check: the check's label keyed against what it found. The fix
105
+ // rides in the value for a failing check — a panel row has nowhere else to put it, and
106
+ // a health report that says "broken" without saying "run this" is half a report.
107
+ function doctorPanel(records) {
108
+ return JSON.stringify({
109
+ rows: records.map((r) => ({
110
+ key: flat(r.label),
111
+ value: flat(r.fix && r.tone !== 'ok' ? `${r.detail} → ${r.fix}` : r.detail),
112
+ tone: r.tone,
113
+ })),
114
+ });
115
+ }
116
+
117
+ function doctorHuman(state, records) {
118
+ const mark = { ok: 'ok ', warn: 'warn', bad: 'BAD ', skip: 'skip' };
119
+ const lines = [`cohorte doctor — ${state.project}`, ''];
120
+ for (const r of records) {
121
+ lines.push(`${mark[r.status] || '? '} ${r.label} — ${flat(r.detail)}`);
122
+ if (r.fix && r.status !== 'ok') lines.push(` fix: ${flat(r.fix)}`);
123
+ }
124
+ const s = state.summary;
125
+ lines.push('', `${s.ok} ok · ${s.warn} warn · ${s.bad} bad · ${s.skip} skipped`);
126
+ return lines.join('\n');
127
+ }
128
+
129
+ module.exports = {
130
+ US,
131
+ STATUS_TONE,
132
+ CHECK_TONE,
133
+ specRecords,
134
+ specsPorcelain,
135
+ specsPanel,
136
+ specsHuman,
137
+ checkRecords,
138
+ doctorPorcelain,
139
+ doctorPanel,
140
+ doctorHuman,
141
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cohorte",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "Portable, stack-agnostic multi-agent development pipeline for Claude Code, Codex CLI, Cursor, Gemini CLI and OpenCode — install the core, run /cohorte-init-pipeline, and it adapts to your project's stack.",
5
5
  "bin": {
6
6
  "cohorte": "bin/cli.js"
@@ -481,6 +481,22 @@ function main(argv) {
481
481
  agents: rows.reduce((n, r) => n + r.agents.total, 0),
482
482
  };
483
483
 
484
+ // The headline figures as a Francois extension `stat-row` payload — four tiles, no
485
+ // table. Deliberately ahead of the --json branch: --panel is a shape, not a filter,
486
+ // and passing both should never print two documents.
487
+ // See github.com/TheBidouilleAgency/francois-plugin-cohorte.
488
+ if (flag('panel')) {
489
+ const window = opt('days') ? `last ${opt('days')} days` : since ? 'since ' + new Date(since).toISOString().slice(0, 10) : 'all time';
490
+ const top = rows[0];
491
+ process.stdout.write(JSON.stringify({ tiles: [
492
+ { label: 'Cost', value: fmtUsd(totals.cost), sublabel: window },
493
+ { label: 'Runs', value: String(totals.runs), sublabel: `${totals.sessions} sessions` },
494
+ { label: 'Subagents', value: String(totals.agents) },
495
+ ...(top ? [{ label: 'Priciest', value: top.command, sublabel: fmtUsd(top.cost.total) }] : []),
496
+ ] }) + '\n');
497
+ return 0;
498
+ }
499
+
484
500
  if (flag('json')) {
485
501
  const out = { generatedAt: new Date().toISOString(), projectRoot: root,
486
502
  checkouts: [...checkouts], pricesUpdated: PRICES.updated, totals, commands: rows };