cohorte 2.8.0 → 2.10.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 (38) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +41 -46
  3. package/bin/cli.js +8 -41
  4. package/bin/report.js +2 -2
  5. package/core/commands/cohorte-brainstorm.md +5 -1
  6. package/core/commands/cohorte-fleet.md +103 -0
  7. package/core/commands/cohorte-intake.md +92 -0
  8. package/core/commands/cohorte-patch.md +6 -1
  9. package/core/commands/cohorte-retro.md +85 -0
  10. package/core/commands/cohorte-review.md +53 -1
  11. package/core/commands/cohorte-ship.md +2 -2
  12. package/core/hooks/gate.py +1 -1
  13. package/core/workflows/loop.js +27 -3
  14. package/core/workflows/review.js +13 -2
  15. package/{dashboard/server → lib}/doctor.js +3 -2
  16. package/{dashboard/server → lib}/runtime.js +1 -1
  17. package/{dashboard/server → lib}/versions.js +2 -2
  18. package/package.json +3 -8
  19. package/profile/SCHEMA.md +16 -8
  20. package/scripts/{test-dashboard.mjs → test-lib.mjs} +12 -222
  21. package/scripts/test-workflows.mjs +31 -2
  22. package/scripts/validate-core.mjs +7 -21
  23. package/dashboard/README.md +0 -71
  24. package/dashboard/dist/apple-touch-icon-180.png +0 -0
  25. package/dashboard/dist/assets/index-BZ_LQlEj.css +0 -1
  26. package/dashboard/dist/assets/index-DO3_nq2Q.js +0 -43
  27. package/dashboard/dist/favicon-16.png +0 -0
  28. package/dashboard/dist/favicon-32.png +0 -0
  29. package/dashboard/dist/favicon-48.png +0 -0
  30. package/dashboard/dist/icon-192.png +0 -0
  31. package/dashboard/dist/icon-512.png +0 -0
  32. package/dashboard/dist/index.html +0 -16
  33. package/dashboard/server/fleet.js +0 -133
  34. package/dashboard/server/index.js +0 -408
  35. package/dashboard/server/kanban.js +0 -169
  36. package/dashboard/server/metrics.js +0 -113
  37. package/dashboard/server/usage.js +0 -61
  38. /package/{dashboard/server → lib}/yaml.js +0 -0
@@ -1,169 +0,0 @@
1
- 'use strict';
2
- // Read a project's linked Obsidian Kanban board (from ~/.claude/cohorte.config.yaml) and
3
- // parse it into columns + cards. The board is a plain markdown file in the user's vault, so this
4
- // stays local + dependency-free. The kanban mirror is Obsidian-only by design.
5
-
6
- const fs = require('fs');
7
- const os = require('os');
8
- const path = require('path');
9
- const { spawnSync } = require('child_process');
10
- const { parse, parseProfileBlock } = require('./yaml');
11
-
12
- // owner/repo from the project's GitHub origin remote (SSH or HTTPS form), or null.
13
- function githubRepo(projectRoot) {
14
- try {
15
- const r = spawnSync('git', ['-C', projectRoot, 'remote', 'get-url', 'origin'],
16
- { encoding: 'utf8', timeout: 3000 });
17
- if (r.status !== 0 || !r.stdout) return null;
18
- const m = r.stdout.trim().match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/);
19
- return m ? `${m[1]}/${m[2]}` : null;
20
- } catch { return null; }
21
- }
22
-
23
- // PR metadata (state, draft, dates) for the repo, keyed by number. Uses the user's authenticated
24
- // `gh` CLI; cached 60s so the dashboard poll doesn't hammer the API. Empty map if gh is absent/fails.
25
- let _prCache = { repo: null, at: 0, map: {} };
26
- function fetchPRs(repo) {
27
- if (!repo) return {};
28
- if (_prCache.repo === repo && Date.now() - _prCache.at < 60000) return _prCache.map;
29
- try {
30
- const r = spawnSync('gh', ['pr', 'list', '--repo', repo, '--state', 'all', '--limit', '200',
31
- '--json', 'number,state,isDraft,createdAt,mergedAt,url,headRefName'], { encoding: 'utf8', timeout: 8000 });
32
- if (r.status !== 0 || !r.stdout) return _prCache.repo === repo ? _prCache.map : {};
33
- const map = {};
34
- for (const pr of JSON.parse(r.stdout)) map[String(pr.number)] = pr;
35
- _prCache = { repo, at: Date.now(), map };
36
- return map;
37
- } catch { return {}; }
38
- }
39
-
40
- function readConfig(globalDir) {
41
- // cohorte.config.yaml under the Claude global dir first, then `~/.cohorte/` — where the
42
- // installer seeds it for every non-Claude runtime (the shipped scripts probe the same two,
43
- // in the same order) — then the pre-rename legacy name (read-only fallback).
44
- const candidates = [
45
- path.join(globalDir, 'cohorte.config.yaml'),
46
- path.join(os.homedir(), '.cohorte', 'cohorte.config.yaml'),
47
- path.join(globalDir, 'thebidouille.config.yaml'),
48
- ];
49
- for (const p of candidates) {
50
- try { return parse(fs.readFileSync(p, 'utf8')); } catch { /* try next */ }
51
- }
52
- return null;
53
- }
54
-
55
- // The project's PIPELINE.md `name`, or the directory basename as a fallback (a purged project
56
- // has no profile, but its board is still keyed by the old name — match case-insensitively).
57
- function projectName(projectRoot) {
58
- const md = (() => { try { return fs.readFileSync(path.join(projectRoot, 'PIPELINE.md'), 'utf8'); } catch { return null; } })();
59
- const profile = md ? parseProfileBlock(md) : null;
60
- return (profile && profile.name) || path.basename(projectRoot);
61
- }
62
-
63
- // Resolve the board key for this project: exact name, else case-insensitive basename match.
64
- function boardEntry(boards, name, projectRoot) {
65
- if (!boards || typeof boards !== 'object') return null;
66
- if (boards[name]) return boards[name];
67
- const base = path.basename(projectRoot).toLowerCase();
68
- const key = Object.keys(boards).find(k => k.toLowerCase() === name.toLowerCase() || k.toLowerCase() === base);
69
- return key ? boards[key] : null;
70
- }
71
-
72
- // Parse an Obsidian Kanban markdown file into columns of cards. `repo` (owner/repo) turns
73
- // bare `#123` PR references into links.
74
- function parseBoard(md, repo) {
75
- const cols = [];
76
- let cur = null;
77
- for (const raw of md.split(/\r?\n/)) {
78
- const line = raw.replace(/\s+$/, '');
79
- if (/^%%/.test(line)) break; // the `%% kanban:settings %%` trailer ends the board
80
- const h = line.match(/^##\s+(.+?)\s*$/);
81
- if (h) { cur = { name: h[1], cards: [] }; cols.push(cur); continue; }
82
- const c = line.match(/^\s*-\s*\[([ xX])\]\s+(.+?)\s*$/);
83
- if (c && cur) {
84
- const src = c[2];
85
- // `#123` = PR reference; `#word` (letter-first) = a feature tag.
86
- const prs = [...src.matchAll(/#(\d+)\b/g)].map(m => ({
87
- num: m[1], url: repo ? `https://github.com/${repo}/pull/${m[1]}` : null,
88
- }));
89
- // A tag is any #-word that is not a bare number (`#123` = PR ref) — feature ids
90
- // may start with a digit (`#2fa-login`), which a letter-first pattern dropped
91
- // from tags while the text-strip below still removed it: the card lost its join key.
92
- const tags = [...src.matchAll(/#(?!\d+\b)([\wÀ-ɏ][\wÀ-ɏ/-]*)/g)].map(m => m[1]);
93
- const text = src
94
- .replace(/#[\wÀ-ɏ/-]+/g, '') // strip tags + PR refs
95
- .replace(/\bPR\b/g, '') // and the leftover "PR" label
96
- .replace(/[—–-]\s*$/, '') // trailing dash
97
- .replace(/\s{2,}/g, ' ').trim();
98
- cur.cards.push({ text, done: c[1].toLowerCase() === 'x', tags, prs });
99
- }
100
- }
101
- return cols;
102
- }
103
-
104
- function kanban({ projectRoot, globalDir }) {
105
- const cfg = readConfig(globalDir);
106
- if (!cfg) return { enabled: false, reason: 'no cohorte.config.yaml' };
107
- const k = cfg.kanban;
108
- if (!k || k.enabled !== true) return { enabled: false, reason: 'kanban disabled in config' };
109
- const vault = cfg.obsidian && cfg.obsidian.vault_path;
110
- if (!vault) return { enabled: false, reason: 'no obsidian.vault_path configured' };
111
-
112
- const name = projectName(projectRoot);
113
- const entry = boardEntry(k.boards, name, projectRoot);
114
- if (!entry || !entry.board) return { enabled: false, reason: `no board linked for "${name}"` };
115
-
116
- const boardPath = path.join(vault, entry.board);
117
- let md;
118
- try { md = fs.readFileSync(boardPath, 'utf8'); }
119
- catch { return { enabled: false, reason: `board file unreadable: ${entry.board}` }; }
120
-
121
- const repo = githubRepo(projectRoot);
122
- const columns = parseBoard(md, repo);
123
-
124
- // Enrich PR refs with live status (state/draft/dates) from gh, and compute each card's ship date.
125
- const prMap = fetchPRs(repo);
126
- const byBranch = {};
127
- for (const pr of Object.values(prMap)) if (pr.headRefName) byBranch[pr.headRefName] = pr;
128
- const applyMeta = (pr, meta) => {
129
- pr.state = meta.state; pr.draft = meta.isDraft; pr.mergedAt = meta.mergedAt; pr.createdAt = meta.createdAt;
130
- pr.url = pr.url || meta.url; pr.num = pr.num || String(meta.number);
131
- };
132
- // A card with a `#<feature_id>` tag but no explicit `#<num>`: infer its PR from the branch
133
- // `<...>/<feature_id>` (the old cards predate "always write the PR number"). Marked inferred.
134
- const inferPR = tags => {
135
- for (const t of tags) {
136
- const hit = byBranch[`feature/${t}`] || Object.values(prMap).find(p => p.headRefName && p.headRefName.endsWith(`/${t}`));
137
- if (hit) return { num: String(hit.number), inferred: true };
138
- }
139
- return null;
140
- };
141
-
142
- for (const col of columns) {
143
- for (const card of col.cards) {
144
- if (card.prs.length === 0) {
145
- const inf = inferPR(card.tags);
146
- if (inf) card.prs.push(inf);
147
- }
148
- let latest = null;
149
- for (const pr of card.prs) {
150
- const meta = prMap[pr.num];
151
- if (meta) {
152
- applyMeta(pr, meta);
153
- const d = meta.mergedAt || meta.createdAt;
154
- if (d && (!latest || d > latest)) latest = d;
155
- }
156
- }
157
- card.shipDate = latest; // ISO string of the newest merge/creation among the card's PRs
158
- }
159
- // Shipped column: most-recently-shipped first (cards without a date sink to the bottom).
160
- if (/shipped/i.test(col.name)) {
161
- col.cards.sort((a, b) => (b.shipDate || '').localeCompare(a.shipDate || ''));
162
- }
163
- }
164
-
165
- const total = columns.reduce((n, c) => n + c.cards.length, 0);
166
- return { enabled: true, name, boardRel: entry.board, prSource: Object.keys(prMap).length ? 'gh' : 'none', columns, total };
167
- }
168
-
169
- module.exports = { kanban };
@@ -1,113 +0,0 @@
1
- 'use strict';
2
- // Read a project's `pipeline-metrics.jsonl` (one line per phase batch, appended by
3
- // /cohorte-build, /cohorte-review and /cohorte-fix) and aggregate it per feature: wall-clock per phase, fix
4
- // rounds, and per-surface results. Dependency-free; a missing file is simply "no data yet".
5
- //
6
- // Two line formats coexist in the file:
7
- // new {"ts","feature","phase","seconds",surfaces:{"<key>":"ok|error"|"<verdict>:<count>"}}
8
- // legacy {"ts","feature","phase","surface","seconds","result"} — one line PER surface;
9
- // legacy lines are grouped by ts+feature+phase into one batch (wall-clock = max).
10
-
11
- const fs = require('fs');
12
- const path = require('path');
13
- const { layouts, stateDirs } = require('./runtime.js');
14
-
15
- // `smoke` and `cycle` are RETIRED phases, kept so metrics files written before their removal
16
- // still render. Without them in this list their per-surface results parse fine but land in no
17
- // column — the surface table showed rows with every cell empty.
18
- const PHASES = ['build', 'review', 'fix', 'smoke', 'cycle'];
19
-
20
- // Parse the raw JSONL into normalized batches ({ts, feature, phase, seconds, surfaces}),
21
- // skipping malformed lines. Legacy per-surface lines are folded into their batch.
22
- function parseBatches(raw) {
23
- const batches = [];
24
- const legacy = new Map(); // "ts|feature|phase" → batch (legacy lines share the key)
25
- for (const line of raw.split(/\r?\n/)) {
26
- if (!line.trim()) continue;
27
- let e;
28
- try { e = JSON.parse(line); } catch { continue; }
29
- if (!e || typeof e !== 'object' || !e.feature || !e.phase) continue;
30
- const seconds = Number(e.seconds) || 0;
31
- if (e.surfaces && typeof e.surfaces === 'object') {
32
- // `rounds` / `smoke` are run-level facts the cycle workflow reports alongside
33
- // (never inside) `surfaces` — carry them through for the aggregate.
34
- batches.push({
35
- ts: e.ts || '', feature: String(e.feature), phase: String(e.phase), seconds,
36
- surfaces: e.surfaces, rounds: e.rounds, smoke: e.smoke,
37
- });
38
- } else if (e.surface) {
39
- const key = `${e.ts}|${e.feature}|${e.phase}`;
40
- let b = legacy.get(key);
41
- if (!b) {
42
- b = { ts: e.ts || '', feature: String(e.feature), phase: String(e.phase), seconds: 0, surfaces: {} };
43
- legacy.set(key, b);
44
- batches.push(b);
45
- }
46
- b.surfaces[String(e.surface)] = e.result == null ? '' : String(e.result);
47
- b.seconds = Math.max(b.seconds, seconds); // batch wall-clock = the slowest surface
48
- }
49
- // lines with neither `surfaces` nor `surface` are malformed → skipped
50
- }
51
- return batches;
52
- }
53
-
54
- // A surface result string counts as a failure when it is "error" or a BLOCK/REVISE verdict
55
- // (verdict lines look like "REVISE:2"), or a non-ok/pass word. "skipped" is neutral —
56
- // the cycle workflow reports smoke:SKIPPED when the human opted out, not a failure.
57
- function isFailure(result) {
58
- const head = String(result).split(':')[0].trim().toLowerCase();
59
- return head !== '' && head !== 'ok' && head !== 'ship' && head !== 'pass' && head !== 'skipped';
60
- }
61
-
62
- // Aggregate batches per feature (newest feature first).
63
- function aggregate(batches) {
64
- const byFeature = new Map();
65
- for (const b of batches) {
66
- let f = byFeature.get(b.feature);
67
- if (!f) {
68
- f = {
69
- feature: b.feature,
70
- firstTs: b.ts, lastTs: b.ts,
71
- totalSeconds: 0,
72
- fixRounds: 0,
73
- phases: {}, // phase → { seconds, rounds }
74
- surfaces: {}, // surface → { phase → latest result }, plus failure count
75
- };
76
- byFeature.set(b.feature, f);
77
- }
78
- // The retired cycle phase reported its round count outside `surfaces` (that map is for
79
- // surfaces); historical files still carry it.
80
- if (b.phase === 'cycle' && Number(b.rounds) > 0) f.cycleRounds = Number(b.rounds);
81
- if (b.ts && (!f.firstTs || b.ts < f.firstTs)) f.firstTs = b.ts;
82
- if (b.ts && b.ts > f.lastTs) f.lastTs = b.ts;
83
- f.totalSeconds += b.seconds;
84
- const ph = f.phases[b.phase] || (f.phases[b.phase] = { seconds: 0, rounds: 0 });
85
- ph.seconds += b.seconds;
86
- ph.rounds += 1;
87
- if (b.phase === 'fix') f.fixRounds += 1;
88
- for (const [key, result] of Object.entries(b.surfaces)) {
89
- const s = f.surfaces[key] || (f.surfaces[key] = { results: {}, failures: 0 });
90
- s.results[b.phase] = result; // batches are appended in order → last write is the latest
91
- if (isFailure(result)) s.failures += 1;
92
- }
93
- }
94
- return [...byFeature.values()].sort((a, b) => (b.lastTs || '').localeCompare(a.lastTs || ''));
95
- }
96
-
97
- function metrics({ projectRoot, globalDir }) {
98
- // The sink lives in `<state>`, which is `.claude` on a Claude install and `.cohorte` on the
99
- // others. A repo driven from both has two, and the phases genuinely split across them — read
100
- // every one rather than silently reporting half the spend.
101
- const dirs = stateDirs(layouts({ projectRoot, globalDir }), projectRoot);
102
- let raw = '';
103
- for (const d of dirs) {
104
- try { raw += fs.readFileSync(path.join(d, 'pipeline-metrics.jsonl'), 'utf8'); }
105
- catch { /* absent in this layout */ }
106
- }
107
- if (!raw) return { present: false, phases: PHASES, features: [], batches: 0 };
108
-
109
- const batches = parseBatches(raw);
110
- return { present: true, phases: PHASES, features: aggregate(batches), batches: batches.length };
111
- }
112
-
113
- module.exports = { metrics };
@@ -1,61 +0,0 @@
1
- 'use strict';
2
- // Serve the metrics collector's rollup (scripts/metrics/collect.mjs) to the dashboard:
3
- // real cost and runtime per command, read from Claude Code's own transcripts.
4
- //
5
- // This is the SECOND metrics source in the cockpit, and the two answer different questions.
6
- // `metrics.js` reads `.claude/pipeline-metrics.jsonl` — written by the model itself, so it
7
- // carries per-surface verdicts (ok / REVISE:2 / error) that only the model knows, but it
8
- // misses any run that ended early and can never report tokens. This one is derived from the
9
- // transcripts, so it is complete and exact on cost and time but knows nothing about verdicts.
10
- // Verdicts from one, money from the other; neither is a replacement for the other.
11
- //
12
- // The collector is ESM and this server is CommonJS, so it runs as a child process — the same
13
- // bridge `bin/cli.js` uses. A spawn is ~1-2s on a large history, which is why the result is
14
- // cached: the panel polls, and re-parsing tens of MB of transcripts on every poll would make
15
- // the whole cockpit feel broken.
16
-
17
- const path = require('path');
18
- const { spawnSync } = require('child_process');
19
-
20
- const COLLECT = path.join(__dirname, '..', '..', 'scripts', 'metrics', 'collect.mjs');
21
-
22
- // Transcripts only grow, and nobody needs sub-minute freshness on a spend figure.
23
- const CACHE_MS = 60_000;
24
- const cache = new Map(); // projectRoot → { at, value }
25
-
26
- function usage({ projectRoot, days = null, force = false }) {
27
- const key = `${projectRoot}|${days || ''}`;
28
- const hit = cache.get(key);
29
- if (!force && hit && Date.now() - hit.at < CACHE_MS) return hit.value;
30
-
31
- const args = [COLLECT, projectRoot, '--json'];
32
- if (days) args.push(`--days=${days}`);
33
-
34
- let value;
35
- try {
36
- const r = spawnSync(process.execPath, args, {
37
- encoding: 'utf8',
38
- // A pathological history must not wedge the cockpit's event loop forever.
39
- timeout: 60_000,
40
- maxBuffer: 64 * 1024 * 1024,
41
- });
42
- if (r.status !== 0 || !r.stdout) {
43
- value = { present: false, error: (r.stderr || '').trim().split('\n').slice(-1)[0] || 'collector failed' };
44
- } else {
45
- const parsed = JSON.parse(r.stdout);
46
- // No transcripts for this project is a normal state (a fresh checkout, or a project
47
- // driven from another machine), not an error — say so rather than rendering zeros
48
- // that look like "this pipeline is free".
49
- value = parsed.totals && parsed.totals.sessions
50
- ? { present: true, ...parsed }
51
- : { present: false, error: 'no Claude Code transcripts found for this project' };
52
- }
53
- } catch (e) {
54
- value = { present: false, error: String((e && e.message) || e) };
55
- }
56
-
57
- cache.set(key, { at: Date.now(), value });
58
- return value;
59
- }
60
-
61
- module.exports = { usage };
File without changes