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.
- package/CHANGELOG.md +72 -0
- package/README.md +41 -46
- package/bin/cli.js +8 -41
- package/bin/report.js +2 -2
- package/core/commands/cohorte-brainstorm.md +5 -1
- package/core/commands/cohorte-fleet.md +103 -0
- package/core/commands/cohorte-intake.md +92 -0
- package/core/commands/cohorte-patch.md +6 -1
- package/core/commands/cohorte-retro.md +85 -0
- package/core/commands/cohorte-review.md +53 -1
- package/core/commands/cohorte-ship.md +2 -2
- package/core/hooks/gate.py +1 -1
- package/core/workflows/loop.js +27 -3
- package/core/workflows/review.js +13 -2
- package/{dashboard/server → lib}/doctor.js +3 -2
- package/{dashboard/server → lib}/runtime.js +1 -1
- package/{dashboard/server → lib}/versions.js +2 -2
- package/package.json +3 -8
- package/profile/SCHEMA.md +16 -8
- package/scripts/{test-dashboard.mjs → test-lib.mjs} +12 -222
- package/scripts/test-workflows.mjs +31 -2
- package/scripts/validate-core.mjs +7 -21
- package/dashboard/README.md +0 -71
- package/dashboard/dist/apple-touch-icon-180.png +0 -0
- package/dashboard/dist/assets/index-BZ_LQlEj.css +0 -1
- package/dashboard/dist/assets/index-DO3_nq2Q.js +0 -43
- package/dashboard/dist/favicon-16.png +0 -0
- package/dashboard/dist/favicon-32.png +0 -0
- package/dashboard/dist/favicon-48.png +0 -0
- package/dashboard/dist/icon-192.png +0 -0
- package/dashboard/dist/icon-512.png +0 -0
- package/dashboard/dist/index.html +0 -16
- package/dashboard/server/fleet.js +0 -133
- package/dashboard/server/index.js +0 -408
- package/dashboard/server/kanban.js +0 -169
- package/dashboard/server/metrics.js +0 -113
- package/dashboard/server/usage.js +0 -61
- /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
|