cohorte 1.0.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 +264 -0
- package/LICENSE +661 -0
- package/README.md +269 -0
- package/bin/cli.js +339 -0
- package/core/agents/implementer.template.md +74 -0
- package/core/agents/release.md +51 -0
- package/core/agents/review.md +85 -0
- package/core/commands/align-ds.md +32 -0
- package/core/commands/audit.md +31 -0
- package/core/commands/brainstorm.md +48 -0
- package/core/commands/build.md +91 -0
- package/core/commands/doctor.md +50 -0
- package/core/commands/fix.md +62 -0
- package/core/commands/init-pipeline.md +32 -0
- package/core/commands/refactor.md +38 -0
- package/core/commands/review.md +68 -0
- package/core/commands/ship.md +68 -0
- package/core/commands/smoke.md +55 -0
- package/core/commands/spec.md +67 -0
- package/core/commands/update-pipeline.md +96 -0
- package/core/hooks/__pycache__/gate.cpython-312.pyc +0 -0
- package/core/hooks/gate.py +129 -0
- package/core/templates/agent-handoff.md +34 -0
- package/core/templates/brainstorm-return.md +36 -0
- package/core/templates/design-brief.md +35 -0
- package/core/templates/pr-body.md +29 -0
- package/core/templates/review-feedback.md +36 -0
- package/core/templates/spec.template.md +84 -0
- package/core/templates/steps/init-pipeline/01-detect-stack.md +40 -0
- package/core/templates/steps/init-pipeline/02-interview-gaps.md +41 -0
- package/core/templates/steps/init-pipeline/03-draft-profile.md +10 -0
- package/core/templates/steps/init-pipeline/04-write-render.md +88 -0
- package/core/templates/steps/init-pipeline/05-report.md +12 -0
- package/dashboard/README.md +54 -0
- package/dashboard/dist/apple-touch-icon-180.png +0 -0
- package/dashboard/dist/assets/index-CoBuEdy-.js +42 -0
- package/dashboard/dist/assets/index-DN5OGW9g.css +1 -0
- 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 +16 -0
- package/dashboard/server/doctor.js +266 -0
- package/dashboard/server/fleet.js +119 -0
- package/dashboard/server/index.js +306 -0
- package/dashboard/server/kanban.js +158 -0
- package/dashboard/server/versions.js +111 -0
- package/dashboard/server/yaml.js +126 -0
- package/install.ps1 +359 -0
- package/install.sh +301 -0
- package/package.json +40 -0
- package/profile/PIPELINE.template.md +208 -0
- package/profile/SCHEMA.md +303 -0
- package/profile/cohorte.config.template.yaml +43 -0
- package/scripts/new-feature.sh.template +89 -0
- package/scripts/remove-feature.sh.template +53 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Local dashboard HTTP server — dependency-free (node built-ins only).
|
|
3
|
+
// Serves the built React app from dashboard/dist and a small JSON API.
|
|
4
|
+
// Phase 0: GET /api/versions (freshness). Later phases add /api/state, /api/fleet, /api/action.
|
|
5
|
+
|
|
6
|
+
const http = require('http');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { spawn } = require('child_process');
|
|
10
|
+
const { versions } = require('./versions');
|
|
11
|
+
const { state } = require('./doctor');
|
|
12
|
+
const { kanban } = require('./kanban');
|
|
13
|
+
const fleet = require('./fleet');
|
|
14
|
+
|
|
15
|
+
const MIME = {
|
|
16
|
+
'.html': 'text/html; charset=utf-8',
|
|
17
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
18
|
+
'.css': 'text/css; charset=utf-8',
|
|
19
|
+
'.json': 'application/json; charset=utf-8',
|
|
20
|
+
'.svg': 'image/svg+xml',
|
|
21
|
+
'.png': 'image/png',
|
|
22
|
+
'.ico': 'image/x-icon',
|
|
23
|
+
'.woff2': 'font/woff2',
|
|
24
|
+
'.map': 'application/json; charset=utf-8',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function sendJson(res, status, body) {
|
|
28
|
+
const data = JSON.stringify(body);
|
|
29
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
30
|
+
res.end(data);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Read + JSON-parse a request body (POST/DELETE), capped to avoid unbounded buffering.
|
|
34
|
+
function readBody(req) {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
let raw = '';
|
|
37
|
+
req.on('data', chunk => {
|
|
38
|
+
raw += chunk;
|
|
39
|
+
if (raw.length > 1e6) { reject(new Error('body too large')); req.destroy(); }
|
|
40
|
+
});
|
|
41
|
+
req.on('end', () => {
|
|
42
|
+
if (raw.trim() === '') return resolve({});
|
|
43
|
+
try { resolve(JSON.parse(raw)); } catch { reject(new Error('invalid JSON body')); }
|
|
44
|
+
});
|
|
45
|
+
req.on('error', reject);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function notBuiltPage(distDir) {
|
|
50
|
+
return `<!doctype html><meta charset="utf-8"><title>dashboard — not built</title>
|
|
51
|
+
<body style="font:16px/1.6 ui-monospace,monospace;max-width:42rem;margin:4rem auto;padding:0 1rem;color:#ddd;background:#111">
|
|
52
|
+
<h1>Dashboard not built yet</h1>
|
|
53
|
+
<p>No <code>${distDir}</code> found. Build the React app once:</p>
|
|
54
|
+
<pre style="background:#000;padding:1rem;border-radius:8px;overflow:auto">npm --prefix dashboard/app install
|
|
55
|
+
npm --prefix dashboard/app run build</pre>
|
|
56
|
+
<p>Or, for live development, run the Vite dev server (<code>npm --prefix dashboard/app run dev</code>)
|
|
57
|
+
which proxies <code>/api</code> here.</p></body>`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Serve a static file from distDir; SPA-fallback to index.html for unknown routes.
|
|
61
|
+
function serveStatic(req, res, distDir) {
|
|
62
|
+
let rel = decodeURIComponent(req.url.split('?')[0]);
|
|
63
|
+
if (rel === '/' || rel === '') rel = '/index.html';
|
|
64
|
+
// Contain the path inside distDir (no traversal).
|
|
65
|
+
const abs = path.join(distDir, path.normalize(rel));
|
|
66
|
+
if (!abs.startsWith(distDir)) { res.writeHead(403); return res.end('forbidden'); }
|
|
67
|
+
|
|
68
|
+
fs.readFile(abs, (err, buf) => {
|
|
69
|
+
if (err) {
|
|
70
|
+
// SPA fallback: hand index.html to the client router.
|
|
71
|
+
const index = path.join(distDir, 'index.html');
|
|
72
|
+
return fs.readFile(index, (e2, html) => {
|
|
73
|
+
if (e2) { res.writeHead(404, { 'content-type': 'text/html' }); return res.end(notBuiltPage(distDir)); }
|
|
74
|
+
res.writeHead(200, { 'content-type': MIME['.html'] });
|
|
75
|
+
res.end(html);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
res.writeHead(200, { 'content-type': MIME[path.extname(abs)] || 'application/octet-stream' });
|
|
79
|
+
res.end(buf);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const STREAM_HEADERS = {
|
|
84
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
85
|
+
'cache-control': 'no-cache',
|
|
86
|
+
'x-accel-buffering': 'no', // defeat proxy buffering so lines stream live
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Run `cli.js install|update [target] [--global]` and stream its output back as a plain-text
|
|
90
|
+
// chunked response (the client reads it via fetch's ReadableStream). Ends with __EXIT__ <code>.
|
|
91
|
+
function runAction(req, res, body, { pkgRoot }) {
|
|
92
|
+
const { action, scope, project } = body;
|
|
93
|
+
if (action !== 'install' && action !== 'update') {
|
|
94
|
+
return sendJson(res, 400, { error: "action must be 'install' or 'update'" });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const args = [path.join(pkgRoot, 'bin', 'cli.js'), action];
|
|
98
|
+
if (scope === 'global') args.push('--global');
|
|
99
|
+
else if (project) args.push(project);
|
|
100
|
+
|
|
101
|
+
res.writeHead(200, STREAM_HEADERS);
|
|
102
|
+
res.write(`$ node cli.js ${args.slice(1).join(' ')}\n\n`);
|
|
103
|
+
|
|
104
|
+
const child = spawn(process.execPath, args, { cwd: pkgRoot, env: process.env });
|
|
105
|
+
child.stdout.on('data', d => res.write(d));
|
|
106
|
+
child.stderr.on('data', d => res.write(d));
|
|
107
|
+
child.on('close', code => { res.write(`\n__EXIT__ ${code == null ? 1 : code}\n`); res.end(); });
|
|
108
|
+
child.on('error', err => { res.write(`\nspawn error: ${err.message}\n__EXIT__ 1\n`); res.end(); });
|
|
109
|
+
req.on('close', () => { try { child.kill(); } catch { /* already gone */ } });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Run a pipeline slash-command through Claude Code headless (`claude -p`) in the project dir,
|
|
113
|
+
// streaming its output. The command is whitelisted (no arbitrary injection into claude -p) and
|
|
114
|
+
// runs autonomously (--dangerously-skip-permissions), so it never hangs waiting on a prompt.
|
|
115
|
+
function runClaude(req, res, body) {
|
|
116
|
+
const project = body.project ? path.resolve(body.project) : null;
|
|
117
|
+
const command = String(body.command || '');
|
|
118
|
+
if (!/^\/(init-pipeline|update-pipeline)$/.test(command)) {
|
|
119
|
+
return sendJson(res, 400, { error: 'unsupported command (only /init-pipeline or /update-pipeline)' });
|
|
120
|
+
}
|
|
121
|
+
if (!project || !fs.existsSync(project)) {
|
|
122
|
+
return sendJson(res, 400, { error: 'project path not found' });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
res.writeHead(200, STREAM_HEADERS);
|
|
126
|
+
res.write(`$ claude -p "${command}" (cwd: ${project})\n\n`);
|
|
127
|
+
|
|
128
|
+
const args = ['-p', command, '--permission-mode', 'bypassPermissions', '--dangerously-skip-permissions', '--verbose'];
|
|
129
|
+
const child = spawn('claude', args, { cwd: project, env: process.env });
|
|
130
|
+
child.stdout.on('data', d => res.write(d));
|
|
131
|
+
child.stderr.on('data', d => res.write(d));
|
|
132
|
+
child.on('close', code => { res.write(`\n__EXIT__ ${code == null ? 1 : code}\n`); res.end(); });
|
|
133
|
+
child.on('error', err => {
|
|
134
|
+
res.write(`\nspawn error: ${err.message}\n(is the \`claude\` CLI on PATH and authenticated?)\n__EXIT__ 1\n`);
|
|
135
|
+
res.end();
|
|
136
|
+
});
|
|
137
|
+
req.on('close', () => { try { child.kill(); } catch { /* already gone */ } });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Full project reset: back up the project's pipeline footprint (.claude/, PIPELINE.md, and
|
|
141
|
+
// optionally specs/) to .claude.bak-<ts>, remove it, then reinstall a fresh BUNDLED core (or,
|
|
142
|
+
// for global-mode projects, leave the shared ~/.claude core untouched). Never touches ~/.claude.
|
|
143
|
+
// Streams progress; ends with __EXIT__ <code>. The profile is regenerated by /init-pipeline after.
|
|
144
|
+
function runReset(req, res, body, { pkgRoot }) {
|
|
145
|
+
const project = body.project ? path.resolve(body.project) : null;
|
|
146
|
+
const purgeSpecs = !!body.purgeSpecs;
|
|
147
|
+
if (!project || !fs.existsSync(project)) {
|
|
148
|
+
return sendJson(res, 400, { error: 'project path not found' });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
res.writeHead(200, STREAM_HEADERS);
|
|
152
|
+
const log = s => res.write(s + '\n');
|
|
153
|
+
const done = code => { res.write(`\n__EXIT__ ${code == null ? 1 : code}\n`); res.end(); };
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const claudeDir = path.join(project, '.claude');
|
|
157
|
+
const pipelineMd = path.join(project, 'PIPELINE.md');
|
|
158
|
+
const specsDir = path.join(project, 'specs');
|
|
159
|
+
|
|
160
|
+
// Detect the prior install mode before we move anything.
|
|
161
|
+
let priorMode = 'unknown';
|
|
162
|
+
const ptr = path.join(claudeDir, 'pipeline.json');
|
|
163
|
+
if (fs.existsSync(ptr)) { try { priorMode = JSON.parse(fs.readFileSync(ptr, 'utf8')).mode || 'unknown'; } catch { /* keep unknown */ } }
|
|
164
|
+
const hadBundledCore = fs.existsSync(path.join(claudeDir, 'pipeline', 'VERSION'));
|
|
165
|
+
const bundled = priorMode === 'bundled' || (priorMode === 'unknown' && hadBundledCore);
|
|
166
|
+
|
|
167
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
168
|
+
const backup = path.join(project, `.claude.bak-${ts}`);
|
|
169
|
+
fs.mkdirSync(backup, { recursive: true });
|
|
170
|
+
log(`Backing up the project's pipeline footprint → ${path.basename(backup)}/`);
|
|
171
|
+
|
|
172
|
+
let moved = 0;
|
|
173
|
+
if (fs.existsSync(claudeDir)) { fs.renameSync(claudeDir, path.join(backup, '.claude')); log(' · moved .claude/'); moved++; }
|
|
174
|
+
if (fs.existsSync(pipelineMd)) { fs.renameSync(pipelineMd, path.join(backup, 'PIPELINE.md')); log(' · moved PIPELINE.md'); moved++; }
|
|
175
|
+
if (purgeSpecs && fs.existsSync(specsDir)) { fs.renameSync(specsDir, path.join(backup, 'specs')); log(' · moved specs/'); moved++; }
|
|
176
|
+
if (!moved) log(' · nothing to move (no .claude/ or PIPELINE.md found)');
|
|
177
|
+
|
|
178
|
+
log(`\nPrior mode: ${bundled ? 'bundled' : 'global'} — the shared ~/.claude core is never touched.`);
|
|
179
|
+
|
|
180
|
+
if (bundled) {
|
|
181
|
+
log('\nReinstalling a fresh bundled core…\n');
|
|
182
|
+
const args = [path.join(pkgRoot, 'bin', 'cli.js'), 'install', project];
|
|
183
|
+
const child = spawn(process.execPath, args, { cwd: pkgRoot, env: process.env });
|
|
184
|
+
child.stdout.on('data', d => res.write(d));
|
|
185
|
+
child.stderr.on('data', d => res.write(d));
|
|
186
|
+
child.on('close', code => {
|
|
187
|
+
log('\n✔ Reset complete. Now run /init-pipeline in Claude Code to regenerate PIPELINE.md + the surface agents.');
|
|
188
|
+
done(code);
|
|
189
|
+
});
|
|
190
|
+
child.on('error', err => { log(`\nspawn error: ${err.message}`); done(1); });
|
|
191
|
+
req.on('close', () => { try { child.kill(); } catch { /* already gone */ } });
|
|
192
|
+
} else {
|
|
193
|
+
log('\n✔ Reset complete. The shared global core stays installed in ~/.claude.');
|
|
194
|
+
log('Now run /init-pipeline in Claude Code to regenerate this project\'s PIPELINE.md + agents.');
|
|
195
|
+
done(0);
|
|
196
|
+
}
|
|
197
|
+
} catch (e) {
|
|
198
|
+
log(`\nreset error: ${(e && e.message) || e}`);
|
|
199
|
+
done(1);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
204
|
+
|
|
205
|
+
// Best-effort browser open (opt-in via --open). Never throws; failure is silent.
|
|
206
|
+
function openBrowserAt(url) {
|
|
207
|
+
const cmd = process.platform === 'darwin' ? 'open'
|
|
208
|
+
: process.platform === 'win32' ? 'cmd'
|
|
209
|
+
: 'xdg-open';
|
|
210
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
211
|
+
try { spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref(); }
|
|
212
|
+
catch { /* no opener available */ }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function start({ projectRoot, globalDir, port, host, openBrowser, pkgRoot, version }) {
|
|
216
|
+
const distDir = path.join(pkgRoot, 'dashboard', 'dist');
|
|
217
|
+
const bindHost = host || '127.0.0.1';
|
|
218
|
+
try { fleet.ensureSeed(globalDir, projectRoot); } catch { /* registry is best-effort */ }
|
|
219
|
+
|
|
220
|
+
const server = http.createServer(async (req, res) => {
|
|
221
|
+
const url = req.url.split('?')[0];
|
|
222
|
+
try {
|
|
223
|
+
if (url === '/api/versions') {
|
|
224
|
+
return sendJson(res, 200, await versions({ projectRoot, globalDir, cliVersion: version }));
|
|
225
|
+
}
|
|
226
|
+
if (url === '/api/state') {
|
|
227
|
+
// ?project=<abs path> overrides the launch cwd (fleet-ready); default = launch project.
|
|
228
|
+
const q = new URL(req.url, 'http://localhost').searchParams.get('project');
|
|
229
|
+
const root = q ? path.resolve(q) : projectRoot;
|
|
230
|
+
return sendJson(res, 200, await state({ projectRoot: root, globalDir, cliVersion: version }));
|
|
231
|
+
}
|
|
232
|
+
if (url === '/api/fleet') {
|
|
233
|
+
return sendJson(res, 200, { projects: await fleet.list(globalDir, version) });
|
|
234
|
+
}
|
|
235
|
+
if (url === '/api/browse') {
|
|
236
|
+
const dir = new URL(req.url, 'http://localhost').searchParams.get('dir');
|
|
237
|
+
return sendJson(res, 200, fleet.browse(dir));
|
|
238
|
+
}
|
|
239
|
+
if (url === '/api/kanban') {
|
|
240
|
+
const q = new URL(req.url, 'http://localhost').searchParams.get('project');
|
|
241
|
+
const root = q ? path.resolve(q) : projectRoot;
|
|
242
|
+
return sendJson(res, 200, kanban({ projectRoot: root, globalDir }));
|
|
243
|
+
}
|
|
244
|
+
if (url === '/api/projects') {
|
|
245
|
+
const body = await readBody(req);
|
|
246
|
+
if (req.method === 'POST') {
|
|
247
|
+
if (!body.path) return sendJson(res, 400, { error: 'path is required' });
|
|
248
|
+
let abs;
|
|
249
|
+
try { abs = fleet.add(globalDir, body.path); }
|
|
250
|
+
catch (e) { return sendJson(res, 400, { error: String((e && e.message) || e) }); }
|
|
251
|
+
return sendJson(res, 200, { added: abs, projects: await fleet.list(globalDir, version) });
|
|
252
|
+
}
|
|
253
|
+
if (req.method === 'DELETE') {
|
|
254
|
+
if (!body.path) return sendJson(res, 400, { error: 'path is required' });
|
|
255
|
+
fleet.remove(globalDir, body.path);
|
|
256
|
+
return sendJson(res, 200, { removed: body.path, projects: await fleet.list(globalDir, version) });
|
|
257
|
+
}
|
|
258
|
+
return sendJson(res, 405, { error: 'use POST to add, DELETE to remove' });
|
|
259
|
+
}
|
|
260
|
+
if (url === '/api/action' && req.method === 'POST') {
|
|
261
|
+
let body;
|
|
262
|
+
try { body = await readBody(req); } catch (e) { return sendJson(res, 400, { error: e.message }); }
|
|
263
|
+
if (body.action === 'reset') return runReset(req, res, body, { pkgRoot });
|
|
264
|
+
if (body.action === 'claude') return runClaude(req, res, body);
|
|
265
|
+
return runAction(req, res, body, { pkgRoot });
|
|
266
|
+
}
|
|
267
|
+
if (url.startsWith('/api/')) {
|
|
268
|
+
return sendJson(res, 404, { error: `unknown endpoint: ${url}` });
|
|
269
|
+
}
|
|
270
|
+
return serveStatic(req, res, distDir);
|
|
271
|
+
} catch (err) {
|
|
272
|
+
return sendJson(res, 500, { error: String(err && err.message || err) });
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
server.listen(port, bindHost, () => {
|
|
277
|
+
const shownHost = LOOPBACK.has(bindHost) ? 'localhost' : bindHost;
|
|
278
|
+
const url = `http://${shownHost}:${port}`;
|
|
279
|
+
console.log(`\n cohorte dashboard v${version}`);
|
|
280
|
+
console.log(` ┌${'─'.repeat(url.length + 10)}┐`);
|
|
281
|
+
console.log(` │ open ${url} │`);
|
|
282
|
+
console.log(` └${'─'.repeat(url.length + 10)}┘`);
|
|
283
|
+
console.log(` project : ${projectRoot}`);
|
|
284
|
+
console.log(` bind : ${bindHost}:${port}`);
|
|
285
|
+
if (!LOOPBACK.has(bindHost)) {
|
|
286
|
+
console.log(' ⚠ SECURITY: bound to a non-loopback address — the dashboard\'s actions execute');
|
|
287
|
+
console.log(' code (install/update/reset/claude). Anyone who can reach this host+port can');
|
|
288
|
+
console.log(' run them. Only do this on a trusted network.');
|
|
289
|
+
}
|
|
290
|
+
if (!fs.existsSync(path.join(distDir, 'index.html'))) {
|
|
291
|
+
console.log(' note : React app not built yet — run npm --prefix dashboard/app run build');
|
|
292
|
+
}
|
|
293
|
+
console.log(' (Ctrl-C to stop)\n');
|
|
294
|
+
if (openBrowser) openBrowserAt(url);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
server.on('error', (err) => {
|
|
298
|
+
if (err.code === 'EADDRINUSE') {
|
|
299
|
+
console.error(`error: port ${port} is in use — pass another with --port=<N>`);
|
|
300
|
+
process.exit(1);
|
|
301
|
+
}
|
|
302
|
+
throw err;
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
module.exports = start;
|
|
@@ -0,0 +1,158 @@
|
|
|
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 path = require('path');
|
|
8
|
+
const { spawnSync } = require('child_process');
|
|
9
|
+
const { parse, parseProfileBlock } = require('./yaml');
|
|
10
|
+
|
|
11
|
+
// owner/repo from the project's GitHub origin remote (SSH or HTTPS form), or null.
|
|
12
|
+
function githubRepo(projectRoot) {
|
|
13
|
+
try {
|
|
14
|
+
const r = spawnSync('git', ['-C', projectRoot, 'remote', 'get-url', 'origin'],
|
|
15
|
+
{ encoding: 'utf8', timeout: 3000 });
|
|
16
|
+
if (r.status !== 0 || !r.stdout) return null;
|
|
17
|
+
const m = r.stdout.trim().match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/);
|
|
18
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
19
|
+
} catch { return null; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// PR metadata (state, draft, dates) for the repo, keyed by number. Uses the user's authenticated
|
|
23
|
+
// `gh` CLI; cached 60s so the dashboard poll doesn't hammer the API. Empty map if gh is absent/fails.
|
|
24
|
+
let _prCache = { repo: null, at: 0, map: {} };
|
|
25
|
+
function fetchPRs(repo) {
|
|
26
|
+
if (!repo) return {};
|
|
27
|
+
if (_prCache.repo === repo && Date.now() - _prCache.at < 60000) return _prCache.map;
|
|
28
|
+
try {
|
|
29
|
+
const r = spawnSync('gh', ['pr', 'list', '--repo', repo, '--state', 'all', '--limit', '200',
|
|
30
|
+
'--json', 'number,state,isDraft,createdAt,mergedAt,url,headRefName'], { encoding: 'utf8', timeout: 8000 });
|
|
31
|
+
if (r.status !== 0 || !r.stdout) return _prCache.repo === repo ? _prCache.map : {};
|
|
32
|
+
const map = {};
|
|
33
|
+
for (const pr of JSON.parse(r.stdout)) map[String(pr.number)] = pr;
|
|
34
|
+
_prCache = { repo, at: Date.now(), map };
|
|
35
|
+
return map;
|
|
36
|
+
} catch { return {}; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readConfig(globalDir) {
|
|
40
|
+
// cohorte.config.yaml, then the pre-rename legacy names (read-only fallback).
|
|
41
|
+
for (const n of ['cohorte.config.yaml', 'thebidouille.config.yaml']) {
|
|
42
|
+
try { return parse(fs.readFileSync(path.join(globalDir, n), 'utf8')); } catch { /* try next */ }
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The project's PIPELINE.md `name`, or the directory basename as a fallback (a purged project
|
|
48
|
+
// has no profile, but its board is still keyed by the old name — match case-insensitively).
|
|
49
|
+
function projectName(projectRoot) {
|
|
50
|
+
const md = (() => { try { return fs.readFileSync(path.join(projectRoot, 'PIPELINE.md'), 'utf8'); } catch { return null; } })();
|
|
51
|
+
const profile = md ? parseProfileBlock(md) : null;
|
|
52
|
+
return (profile && profile.name) || path.basename(projectRoot);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Resolve the board key for this project: exact name, else case-insensitive basename match.
|
|
56
|
+
function boardEntry(boards, name, projectRoot) {
|
|
57
|
+
if (!boards || typeof boards !== 'object') return null;
|
|
58
|
+
if (boards[name]) return boards[name];
|
|
59
|
+
const base = path.basename(projectRoot).toLowerCase();
|
|
60
|
+
const key = Object.keys(boards).find(k => k.toLowerCase() === name.toLowerCase() || k.toLowerCase() === base);
|
|
61
|
+
return key ? boards[key] : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Parse an Obsidian Kanban markdown file into columns of cards. `repo` (owner/repo) turns
|
|
65
|
+
// bare `#123` PR references into links.
|
|
66
|
+
function parseBoard(md, repo) {
|
|
67
|
+
const cols = [];
|
|
68
|
+
let cur = null;
|
|
69
|
+
for (const raw of md.split(/\r?\n/)) {
|
|
70
|
+
const line = raw.replace(/\s+$/, '');
|
|
71
|
+
if (/^%%/.test(line)) break; // the `%% kanban:settings %%` trailer ends the board
|
|
72
|
+
const h = line.match(/^##\s+(.+?)\s*$/);
|
|
73
|
+
if (h) { cur = { name: h[1], cards: [] }; cols.push(cur); continue; }
|
|
74
|
+
const c = line.match(/^\s*-\s*\[([ xX])\]\s+(.+?)\s*$/);
|
|
75
|
+
if (c && cur) {
|
|
76
|
+
const src = c[2];
|
|
77
|
+
// `#123` = PR reference; `#word` (letter-first) = a feature tag.
|
|
78
|
+
const prs = [...src.matchAll(/#(\d+)\b/g)].map(m => ({
|
|
79
|
+
num: m[1], url: repo ? `https://github.com/${repo}/pull/${m[1]}` : null,
|
|
80
|
+
}));
|
|
81
|
+
const tags = [...src.matchAll(/#([A-Za-zÀ-ɏ][\wÀ-ɏ/-]*)/g)].map(m => m[1]);
|
|
82
|
+
const text = src
|
|
83
|
+
.replace(/#[\wÀ-ɏ/-]+/g, '') // strip tags + PR refs
|
|
84
|
+
.replace(/\bPR\b/g, '') // and the leftover "PR" label
|
|
85
|
+
.replace(/[—–-]\s*$/, '') // trailing dash
|
|
86
|
+
.replace(/\s{2,}/g, ' ').trim();
|
|
87
|
+
cur.cards.push({ text, done: c[1].toLowerCase() === 'x', tags, prs });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return cols;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function kanban({ projectRoot, globalDir }) {
|
|
94
|
+
const cfg = readConfig(globalDir);
|
|
95
|
+
if (!cfg) return { enabled: false, reason: 'no cohorte.config.yaml' };
|
|
96
|
+
const k = cfg.kanban;
|
|
97
|
+
if (!k || k.enabled !== true) return { enabled: false, reason: 'kanban disabled in config' };
|
|
98
|
+
const vault = cfg.obsidian && cfg.obsidian.vault_path;
|
|
99
|
+
if (!vault) return { enabled: false, reason: 'no obsidian.vault_path configured' };
|
|
100
|
+
|
|
101
|
+
const name = projectName(projectRoot);
|
|
102
|
+
const entry = boardEntry(k.boards, name, projectRoot);
|
|
103
|
+
if (!entry || !entry.board) return { enabled: false, reason: `no board linked for "${name}"` };
|
|
104
|
+
|
|
105
|
+
const boardPath = path.join(vault, entry.board);
|
|
106
|
+
let md;
|
|
107
|
+
try { md = fs.readFileSync(boardPath, 'utf8'); }
|
|
108
|
+
catch { return { enabled: false, reason: `board file unreadable: ${entry.board}` }; }
|
|
109
|
+
|
|
110
|
+
const repo = githubRepo(projectRoot);
|
|
111
|
+
const columns = parseBoard(md, repo);
|
|
112
|
+
|
|
113
|
+
// Enrich PR refs with live status (state/draft/dates) from gh, and compute each card's ship date.
|
|
114
|
+
const prMap = fetchPRs(repo);
|
|
115
|
+
const byBranch = {};
|
|
116
|
+
for (const pr of Object.values(prMap)) if (pr.headRefName) byBranch[pr.headRefName] = pr;
|
|
117
|
+
const applyMeta = (pr, meta) => {
|
|
118
|
+
pr.state = meta.state; pr.draft = meta.isDraft; pr.mergedAt = meta.mergedAt; pr.createdAt = meta.createdAt;
|
|
119
|
+
pr.url = pr.url || meta.url; pr.num = pr.num || String(meta.number);
|
|
120
|
+
};
|
|
121
|
+
// A card with a `#<feature_id>` tag but no explicit `#<num>`: infer its PR from the branch
|
|
122
|
+
// `<...>/<feature_id>` (the old cards predate "always write the PR number"). Marked inferred.
|
|
123
|
+
const inferPR = tags => {
|
|
124
|
+
for (const t of tags) {
|
|
125
|
+
const hit = byBranch[`feature/${t}`] || Object.values(prMap).find(p => p.headRefName && p.headRefName.endsWith(`/${t}`));
|
|
126
|
+
if (hit) return { num: String(hit.number), inferred: true };
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
for (const col of columns) {
|
|
132
|
+
for (const card of col.cards) {
|
|
133
|
+
if (card.prs.length === 0) {
|
|
134
|
+
const inf = inferPR(card.tags);
|
|
135
|
+
if (inf) card.prs.push(inf);
|
|
136
|
+
}
|
|
137
|
+
let latest = null;
|
|
138
|
+
for (const pr of card.prs) {
|
|
139
|
+
const meta = prMap[pr.num];
|
|
140
|
+
if (meta) {
|
|
141
|
+
applyMeta(pr, meta);
|
|
142
|
+
const d = meta.mergedAt || meta.createdAt;
|
|
143
|
+
if (d && (!latest || d > latest)) latest = d;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
card.shipDate = latest; // ISO string of the newest merge/creation among the card's PRs
|
|
147
|
+
}
|
|
148
|
+
// Shipped column: most-recently-shipped first (cards without a date sink to the bottom).
|
|
149
|
+
if (/shipped/i.test(col.name)) {
|
|
150
|
+
col.cards.sort((a, b) => (b.shipDate || '').localeCompare(a.shipDate || ''));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const total = columns.reduce((n, c) => n + c.cards.length, 0);
|
|
155
|
+
return { enabled: true, name, boardRel: entry.board, prSource: Object.keys(prMap).length ? 'gh' : 'none', columns, total };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { kanban };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Freshness data: which core is installed where, and how it compares to npm latest.
|
|
3
|
+
// Dependency-free — reads VERSION files + the committed pointer, fetches the npm registry.
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { spawnSync } = require('child_process');
|
|
8
|
+
|
|
9
|
+
function readTrimmed(file) {
|
|
10
|
+
try { return fs.readFileSync(file, 'utf8').trim(); } catch { return null; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function readJson(file) {
|
|
14
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Installed core in a given root (global ~/.claude or a repo's bundled .claude).
|
|
18
|
+
function coreAt(claudeDir) {
|
|
19
|
+
const version = readTrimmed(path.join(claudeDir, 'pipeline', 'VERSION'));
|
|
20
|
+
return { present: version != null, version, dir: claudeDir };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The committed per-repo pointer (bundled mode); names the mode + core_version.
|
|
24
|
+
function pointerAt(projectRoot) {
|
|
25
|
+
const ptr = readJson(path.join(projectRoot, '.claude', 'pipeline.json'));
|
|
26
|
+
if (!ptr || typeof ptr !== 'object') return { present: false };
|
|
27
|
+
return { present: true, mode: ptr.mode || null, core_version: ptr.core_version || null };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Latest published version — registry fetch first, `npm view` as a fallback (it uses the
|
|
31
|
+
// user's configured registry/proxy, which works where a raw fetch may be blocked). Cached
|
|
32
|
+
// briefly so the dashboard's poll doesn't hammer the network. null only if both fail.
|
|
33
|
+
let _cache = { value: null, at: 0 };
|
|
34
|
+
const CACHE_MS = 5 * 60 * 1000;
|
|
35
|
+
|
|
36
|
+
async function fetchRegistry() {
|
|
37
|
+
const ctrl = new AbortController();
|
|
38
|
+
const t = setTimeout(() => ctrl.abort(), 5000);
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch('https://registry.npmjs.org/cohorte/latest', {
|
|
41
|
+
signal: ctrl.signal,
|
|
42
|
+
headers: { accept: 'application/vnd.npm.install-v1+json' },
|
|
43
|
+
});
|
|
44
|
+
if (!res.ok) return null;
|
|
45
|
+
const body = await res.json();
|
|
46
|
+
return typeof body.version === 'string' ? body.version : null;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
} finally {
|
|
50
|
+
clearTimeout(t);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function npmView() {
|
|
55
|
+
try {
|
|
56
|
+
const r = spawnSync('npm', ['view', 'cohorte', 'version'],
|
|
57
|
+
{ encoding: 'utf8', timeout: 8000, shell: process.platform === 'win32' });
|
|
58
|
+
if (r.status === 0 && r.stdout) {
|
|
59
|
+
const v = r.stdout.trim();
|
|
60
|
+
return /^\d+\.\d+\.\d+/.test(v) ? v : null;
|
|
61
|
+
}
|
|
62
|
+
} catch { /* npm absent or slow */ }
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function latestNpm() {
|
|
67
|
+
if (_cache.value && (Date.now() - _cache.at) < CACHE_MS) return _cache.value;
|
|
68
|
+
const v = (await fetchRegistry()) || npmView();
|
|
69
|
+
if (v) _cache = { value: v, at: Date.now() };
|
|
70
|
+
return v;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Naive semver compare — returns -1|0|1 (a<b|a==b|a>b). Ignores pre-release tags.
|
|
74
|
+
function cmpSemver(a, b) {
|
|
75
|
+
if (!a || !b) return null;
|
|
76
|
+
const pa = a.split('.').map(n => parseInt(n, 10) || 0);
|
|
77
|
+
const pb = b.split('.').map(n => parseInt(n, 10) || 0);
|
|
78
|
+
for (let i = 0; i < 3; i++) {
|
|
79
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
|
|
80
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
|
|
81
|
+
}
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function versions({ projectRoot, globalDir, cliVersion }) {
|
|
86
|
+
const global = coreAt(globalDir);
|
|
87
|
+
const bundled = coreAt(path.join(projectRoot, '.claude'));
|
|
88
|
+
const pointer = pointerAt(projectRoot);
|
|
89
|
+
const latest = await latestNpm();
|
|
90
|
+
|
|
91
|
+
// The core that actually serves this project: bundled wins if present, else global.
|
|
92
|
+
const effective = bundled.present ? bundled : global.present ? global : null;
|
|
93
|
+
const installedVersion = effective ? effective.version : null;
|
|
94
|
+
const behind = cmpSemver(installedVersion, latest);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
cli: cliVersion,
|
|
98
|
+
latest,
|
|
99
|
+
global,
|
|
100
|
+
bundled,
|
|
101
|
+
pointer,
|
|
102
|
+
installMode: bundled.present ? 'bundled' : global.present ? 'global' : 'none',
|
|
103
|
+
installedVersion,
|
|
104
|
+
// -1 behind, 0 up-to-date, 1 ahead of registry (dev), null unknown
|
|
105
|
+
freshness: behind,
|
|
106
|
+
// freshness of the GLOBAL core specifically (for the fleet banner, independent of project mode)
|
|
107
|
+
globalFreshness: global.present ? cmpSemver(global.version, latest) : null,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { versions };
|