cohorte 1.3.2 → 1.3.4

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 (39) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/README.md +4 -4
  3. package/bin/cli.js +22 -4
  4. package/core/agents/implementer.template.md +10 -5
  5. package/core/commands/cycle.md +15 -8
  6. package/core/commands/doctor.md +3 -1
  7. package/core/hooks/gate.py +57 -26
  8. package/core/templates/agent-handoff.md +7 -2
  9. package/core/templates/review-feedback.md +7 -4
  10. package/core/templates/spec.template.md +5 -2
  11. package/core/templates/steps/init-pipeline/04-write-render.md +8 -3
  12. package/core/workflows/audit.js +20 -3
  13. package/core/workflows/cycle.js +157 -41
  14. package/core/workflows/refactor.js +16 -5
  15. package/core/workflows/review.js +59 -7
  16. package/dashboard/README.md +22 -5
  17. package/dashboard/dist/assets/index-AFQnlfjO.css +1 -0
  18. package/dashboard/dist/assets/{index-BxgA_mz1.js → index-DLBzciIC.js} +12 -11
  19. package/dashboard/dist/index.html +2 -2
  20. package/dashboard/server/doctor.js +60 -19
  21. package/dashboard/server/fleet.js +19 -5
  22. package/dashboard/server/index.js +79 -7
  23. package/dashboard/server/metrics.js +15 -4
  24. package/dashboard/server/versions.js +28 -6
  25. package/dashboard/server/yaml.js +4 -1
  26. package/install.ps1 +4 -0
  27. package/install.sh +19 -1
  28. package/package.json +5 -2
  29. package/profile/SCHEMA.md +28 -9
  30. package/scripts/kanban-move.sh +34 -20
  31. package/scripts/new-feature.sh.template +3 -1
  32. package/scripts/preflight.sh +16 -3
  33. package/scripts/remove-feature.sh.template +2 -1
  34. package/scripts/telemetry-send.sh +15 -1
  35. package/scripts/test-dashboard.mjs +356 -0
  36. package/scripts/test-gate.mjs +273 -0
  37. package/scripts/test-workflows.mjs +443 -0
  38. package/scripts/validate-core.mjs +49 -0
  39. package/dashboard/dist/assets/index-Cj0SpgEY.css +0 -1
@@ -7,8 +7,8 @@
7
7
  <link rel="icon" type="image/png" sizes="16x16" href="./favicon-16.png" />
8
8
  <link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon-180.png" />
9
9
  <title>cohorte · dashboard</title>
10
- <script type="module" crossorigin src="./assets/index-BxgA_mz1.js"></script>
11
- <link rel="stylesheet" crossorigin href="./assets/index-Cj0SpgEY.css">
10
+ <script type="module" crossorigin src="./assets/index-DLBzciIC.js"></script>
11
+ <link rel="stylesheet" crossorigin href="./assets/index-AFQnlfjO.css">
12
12
  </head>
13
13
  <body>
14
14
  <div id="root"></div>
@@ -101,7 +101,7 @@ function checkAgents(profile, projectRoot) {
101
101
 
102
102
  function checkGate(profile, projectRoot) {
103
103
  const gate = profile && profile.gate;
104
- if (!gate || (!gate.deny && !gate.ask && !gate.ask_on_default_branch)) {
104
+ if (!gate || (!gate.deny && !gate.ask && !gate.ask_on_default_branch && !gate.preflight)) {
105
105
  return mk('gate', 'Gate config', 'skip', 'no gate block in the profile');
106
106
  }
107
107
  const cfg = readJson(path.join(projectRoot, '.claude', 'gate-config.json'));
@@ -114,6 +114,15 @@ function checkGate(profile, projectRoot) {
114
114
  if (!sameSet(cfg.ask, gate.ask)) drifted.push('ask');
115
115
  if (!sameSet(cfg.ask_on_default_branch, gate.ask_on_default_branch)) drifted.push('ask_on_default_branch');
116
116
  if ((cfg.default_branch || 'main') !== (gate.default_branch || 'main')) drifted.push('default_branch');
117
+ // The phase gate (1.3.0) lives in the same file: a profile that enables
118
+ // gate.preflight with a pre-1.3.0 gate-config.json silently never fires it.
119
+ const wantPf = gate.preflight || {};
120
+ const havePf = (cfg.preflight && typeof cfg.preflight === 'object') ? cfg.preflight : {};
121
+ if (!!wantPf.enabled !== !!havePf.enabled
122
+ || !sameSet(wantPf.agents || ['review', 'smoke'], havePf.agents || ['review', 'smoke'])
123
+ || Number(wantPf.max_age_minutes || 30) !== Number(havePf.max_age_minutes || 30)) {
124
+ drifted.push('preflight');
125
+ }
117
126
  if (drifted.length) {
118
127
  return mk('gate', 'Gate config', 'warn',
119
128
  `gate-config.json drifted from PIPELINE.md gate block (${drifted.join(', ')})`,
@@ -125,36 +134,67 @@ function checkGate(profile, projectRoot) {
125
134
  (branchGated ? `, ${branchGated} gated on ${gate.default_branch || 'main'}` : '') + ')');
126
135
  }
127
136
 
128
- function checkHooks(projectRoot, globalDir, installMode) {
129
- const settingsPath = installMode === 'global'
130
- ? path.join(globalDir, 'settings.json')
131
- : path.join(projectRoot, '.claude', 'settings.json');
137
+ function gateRegs(settingsPath) {
132
138
  const data = readJson(settingsPath);
133
139
  const pre = data && data.hooks && Array.isArray(data.hooks.PreToolUse) ? data.hooks.PreToolUse : [];
134
- const regs = pre.filter(e => (e.hooks || []).some(
135
- h => typeof h.command === 'string' && h.command.trim().endsWith('gate.py')));
140
+ // Trailing-quote tolerant, like the installers since 1.3.2: the Windows form is
141
+ // `py "C:\…\gate.py"` a bare .endsWith() reports every healthy Windows install
142
+ // as "not registered".
143
+ return pre.filter(e => (e.hooks || []).some(
144
+ h => typeof h.command === 'string' && h.command.trim().replace(/"+$/, '').endsWith('gate.py')));
145
+ }
146
+
147
+ function checkHooks(projectRoot, globalDir, installMode) {
148
+ // A registration in EITHER scope serves the project: bundled repos get it from
149
+ // /init-pipeline in project settings, but on a machine with the global core the
150
+ // hook usually lives (correctly, exactly once) in global settings — warning
151
+ // there would prescribe a re-registration that double-prompts.
152
+ const scopes = [
153
+ { label: 'project', path: path.join(projectRoot, '.claude', 'settings.json') },
154
+ { label: 'global', path: path.join(globalDir, 'settings.json') },
155
+ ];
156
+ if (installMode === 'global') scopes.reverse();
157
+ const found = scopes.map(s => ({ ...s, regs: gateRegs(s.path) })).filter(s => s.regs.length);
136
158
 
137
- if (regs.length === 0) {
138
- return mk('hooks', 'Gate hook', 'warn', `gate.py not registered in ${installMode} settings.json`,
159
+ if (!found.length) {
160
+ return mk('hooks', 'Gate hook', 'warn', 'gate.py not registered in project or global settings.json',
139
161
  installMode === 'global'
140
162
  ? 'npx cohorte install --global (re-registers the hook)'
141
163
  : '/init-pipeline (register the PreToolUse gate hook)');
142
164
  }
143
- if (regs.length > 1) {
144
- return mk('hooks', 'Gate hook', 'warn', `gate.py registered ${regs.length}× — it will double-prompt`,
145
- 'remove the duplicate PreToolUse entry in settings.json');
165
+ const total = found.reduce((n, s) => n + s.regs.length, 0);
166
+ if (total > 1) {
167
+ return mk('hooks', 'Gate hook', 'warn',
168
+ `gate.py registered ${total}× (${found.map(s => `${s.regs.length} in ${s.label}`).join(', ')}) — it will double-prompt`,
169
+ 'keep exactly one PreToolUse entry (drop the project-level one when the global core is installed)');
146
170
  }
147
- return mk('hooks', 'Gate hook', 'ok', `registered once (${installMode} settings.json)`);
171
+ const matcher = String(found[0].regs[0].matcher || '');
172
+ if (!/\bTask\b/.test(matcher) || !/\bBash\b/.test(matcher)) {
173
+ return mk('hooks', 'Gate hook', 'warn',
174
+ `registered with matcher "${matcher}" — it must cover both Bash (command gating) and Task (preflight phase gate)`,
175
+ 'npx cohorte@latest update --global (reconciles the matcher to Bash|Task)');
176
+ }
177
+ return mk('hooks', 'Gate hook', 'ok', `registered once, matcher ${matcher} (${found[0].label} settings.json)`);
148
178
  }
149
179
 
150
- function checkRetrieval(profile) {
180
+ function checkRetrieval(profile, projectRoot) {
151
181
  const provider = profile && profile.retrieval && profile.retrieval.provider;
152
182
  if (!provider || provider === 'none' || String(provider).startsWith('<')) {
153
183
  return mk('retrieval', 'Code retrieval', 'skip', 'provider: none');
154
184
  }
155
- // Connectivity (server actually connects) needs a live session note it, don't fake green.
185
+ // The profile alone isn't proof the provider was ever wired: /init-pipeline
186
+ // registers it at project scope in .mcp.json. Verify the entry exists on disk;
187
+ // live connectivity still needs a session — note it, don't fake green.
188
+ const mcp = readJson(path.join(projectRoot, '.mcp.json'));
189
+ const servers = (mcp && mcp.mcpServers) || {};
190
+ const wired = Object.keys(servers).some(k => k.toLowerCase().includes(String(provider).toLowerCase()));
191
+ if (!wired) {
192
+ return mk('retrieval', 'Code retrieval', 'warn',
193
+ `profile says provider: ${provider} but .mcp.json has no matching server entry`,
194
+ '/init-pipeline or /update-pipeline (re-wire the retrieval provider)');
195
+ }
156
196
  return mk('retrieval', 'Code retrieval', 'ok',
157
- `provider: ${provider} — connectivity not checked here (run /doctor in-session)`);
197
+ `provider: ${provider} — registered in .mcp.json (connectivity needs /doctor in-session)`);
158
198
  }
159
199
 
160
200
  function checkDesign(profile, projectRoot) {
@@ -186,7 +226,7 @@ function checkIsolation(profile, projectRoot) {
186
226
  return mk('isolation', 'Isolation', 'ok', 'feature scripts rendered (worktree state not checked here)');
187
227
  }
188
228
 
189
- // Workflow variants (review/audit/refactor as deterministic multi-agent runs) are opt-in;
229
+ // Workflow variants (cycle/review/audit/refactor as deterministic multi-agent runs) are opt-in;
190
230
  // the conversational commands stay the default path, so nothing here is ever 'bad'.
191
231
  // Whether the session has workflows ENABLED needs a live Claude session — /doctor
192
232
  // in-session checks that; here we only check what's on disk.
@@ -230,11 +270,12 @@ function scanSpecs(projectRoot) {
230
270
  const fm = txt.match(/^---\r?\n([\s\S]*?)\r?\n---/);
231
271
  const body = fm ? fm[1] : '';
232
272
  const get = k => { const m = body.match(new RegExp(`^${k}:\\s*(.*)$`, 'm')); return m ? m[1].trim() : null; };
273
+ const status = get('status');
233
274
  specs.push({
234
275
  file: f,
235
276
  id: get('feature_id') || f.replace(/\.md$/, ''),
236
277
  title: get('title'),
237
- status: get('status') ? get('status').split('#')[0].trim() : null,
278
+ status: status ? status.split('#')[0].trim() : null,
238
279
  branch: get('branch'),
239
280
  });
240
281
  }
@@ -270,7 +311,7 @@ async function state({ projectRoot, globalDir, cliVersion }) {
270
311
  checkAgents(profile, projectRoot),
271
312
  checkGate(profile, projectRoot),
272
313
  checkHooks(projectRoot, globalDir, v.installMode),
273
- checkRetrieval(profile),
314
+ checkRetrieval(profile, projectRoot),
274
315
  checkDesign(profile, projectRoot),
275
316
  checkIsolation(profile, projectRoot),
276
317
  checkWorkflows(projectRoot, globalDir, v.installMode),
@@ -42,9 +42,16 @@ function write(globalDir, projects) {
42
42
 
43
43
  // Add the launch project on first use so the fleet is never empty.
44
44
  function ensureSeed(globalDir, projectRoot) {
45
+ if (!projectRoot) return;
46
+ // Normalize like add(), or launching from `.` vs an absolute path (or a
47
+ // differently-cased Windows path) seeds the same project twice.
48
+ let abs;
49
+ try { abs = normalizeDir(projectRoot); } catch { abs = path.resolve(projectRoot); }
45
50
  const projects = read(globalDir);
46
- if (projectRoot && !projects.includes(projectRoot)) {
47
- projects.push(projectRoot);
51
+ const known = projects.some(p => p === abs
52
+ || (process.platform === 'win32' && p.toLowerCase() === abs.toLowerCase()));
53
+ if (!known) {
54
+ projects.push(abs);
48
55
  write(globalDir, projects);
49
56
  }
50
57
  }
@@ -59,7 +66,10 @@ function add(globalDir, dir) {
59
66
  }
60
67
 
61
68
  function remove(globalDir, dir) {
62
- const abs = path.resolve(dir);
69
+ // normalizeDir like add() — a bare path.resolve leaves `~/…` unexpanded, the
70
+ // filter matches nothing, and the endpoint reports `removed` for a no-op.
71
+ let abs;
72
+ try { abs = normalizeDir(dir); } catch { abs = path.resolve(dir); }
63
73
  const projects = read(globalDir).filter(p => p !== abs);
64
74
  write(globalDir, projects);
65
75
  }
@@ -76,7 +86,7 @@ async function summarize(projectRoot, globalDir, cliVersion) {
76
86
  exists: true,
77
87
  name: (s.profile && s.profile.name) || path.basename(projectRoot),
78
88
  hasProfile: !!s.profile,
79
- surfaces: s.profile ? s.profile.surfaces.length : 0,
89
+ surfaces: s.profile ? ((s.profile.surfaces || []).length) : 0,
80
90
  specs: s.specs.length,
81
91
  versions: {
82
92
  installMode: s.versions.installMode,
@@ -87,7 +97,11 @@ async function summarize(projectRoot, globalDir, cliVersion) {
87
97
  summary: s.summary,
88
98
  };
89
99
  } catch (e) {
90
- return { path: projectRoot, exists: true, error: String((e && e.message) || e) };
100
+ return {
101
+ path: projectRoot, exists: true,
102
+ name: path.basename(projectRoot), // the card header must still identify the project
103
+ error: String((e && e.message) || e),
104
+ };
91
105
  }
92
106
  }
93
107
 
@@ -22,6 +22,12 @@ const MIME = {
22
22
  '.png': 'image/png',
23
23
  '.ico': 'image/x-icon',
24
24
  '.woff2': 'font/woff2',
25
+ '.woff': 'font/woff',
26
+ '.webmanifest': 'application/manifest+json; charset=utf-8',
27
+ '.txt': 'text/plain; charset=utf-8',
28
+ '.jpg': 'image/jpeg',
29
+ '.jpeg': 'image/jpeg',
30
+ '.webp': 'image/webp',
25
31
  '.map': 'application/json; charset=utf-8',
26
32
  };
27
33
 
@@ -60,14 +66,25 @@ which proxies <code>/api</code> here.</p></body>`;
60
66
 
61
67
  // Serve a static file from distDir; SPA-fallback to index.html for unknown routes.
62
68
  function serveStatic(req, res, distDir) {
63
- let rel = decodeURIComponent(req.url.split('?')[0]);
69
+ let rel;
70
+ // A malformed escape (`/%`) makes decodeURIComponent throw — a 400 is the honest
71
+ // answer, not the caller's 500.
72
+ try { rel = decodeURIComponent(req.url.split('?')[0]); }
73
+ catch { res.writeHead(400); return res.end('bad request'); }
64
74
  if (rel === '/' || rel === '') rel = '/index.html';
65
- // Contain the path inside distDir (no traversal).
75
+ // Contain the path inside distDir (no traversal). Compare with the separator
76
+ // appended: a bare startsWith would also accept a sibling `…/dist-something`.
66
77
  const abs = path.join(distDir, path.normalize(rel));
67
- if (!abs.startsWith(distDir)) { res.writeHead(403); return res.end('forbidden'); }
78
+ if (abs !== distDir && !abs.startsWith(distDir + path.sep)) {
79
+ res.writeHead(403); return res.end('forbidden');
80
+ }
68
81
 
69
82
  fs.readFile(abs, (err, buf) => {
70
83
  if (err) {
84
+ // A missing file WITH an extension is a real 404 (e.g. a stale cached
85
+ // /assets/index-<oldhash>.js after an update) — serving index.html there
86
+ // hands a module script text/html and it dies on an opaque MIME error.
87
+ if (path.extname(abs)) { res.writeHead(404); return res.end('not found'); }
71
88
  // SPA fallback: hand index.html to the client router.
72
89
  const index = path.join(distDir, 'index.html');
73
90
  return fs.readFile(index, (e2, html) => {
@@ -94,10 +111,17 @@ function runAction(req, res, body, { pkgRoot }) {
94
111
  if (action !== 'install' && action !== 'update') {
95
112
  return sendJson(res, 400, { error: "action must be 'install' or 'update'" });
96
113
  }
114
+ // A project-scoped install writes <target>/.claude, and cli.js mkdir -p's the
115
+ // target — so an unchecked path silently creates a pipeline tree in a directory
116
+ // that does not exist (a typo in the fleet registry lands a phantom project on
117
+ // disk). The other two runners already validate; this one never did.
118
+ if (scope !== 'global' && project && !fs.existsSync(path.resolve(project))) {
119
+ return sendJson(res, 400, { error: `project path not found: ${project}` });
120
+ }
97
121
 
98
122
  const args = [path.join(pkgRoot, 'bin', 'cli.js'), action];
99
123
  if (scope === 'global') args.push('--global');
100
- else if (project) args.push(project);
124
+ else if (project) args.push(path.resolve(project));
101
125
 
102
126
  res.writeHead(200, STREAM_HEADERS);
103
127
  res.write(`$ node cli.js ${args.slice(1).join(' ')}\n\n`);
@@ -129,7 +153,13 @@ function runClaude(req, res, body) {
129
153
  res.write(`$ claude -p "${command}" (cwd: ${project})\n\n`);
130
154
 
131
155
  const args = ['-p', command, '--permission-mode', 'bypassPermissions', '--dangerously-skip-permissions', '--verbose'];
132
- const child = spawn('claude', args, { cwd: project, env: process.env });
156
+ // shell on Windows: `claude` is a .cmd shim, which Node refuses to spawn
157
+ // shell-less. Build ONE static string (no args array — that combination is
158
+ // DEP0190-deprecated): `command` is regex-whitelisted above and nothing else
159
+ // is request-supplied, so the shell adds no injection surface.
160
+ const child = process.platform === 'win32'
161
+ ? spawn(`claude ${args.map(a => (a.startsWith('/') ? `"${a}"` : a)).join(' ')}`, { cwd: project, env: process.env, shell: true })
162
+ : spawn('claude', args, { cwd: project, env: process.env });
133
163
  child.stdout.on('data', d => res.write(d));
134
164
  child.stderr.on('data', d => res.write(d));
135
165
  child.on('close', code => { res.write(`\n__EXIT__ ${code == null ? 1 : code}\n`); res.end(); });
@@ -144,12 +174,24 @@ function runClaude(req, res, body) {
144
174
  // optionally specs/) to .claude.bak-<ts>, remove it, then reinstall a fresh BUNDLED core (or,
145
175
  // for global-mode projects, leave the shared ~/.claude core untouched). Never touches ~/.claude.
146
176
  // Streams progress; ends with __EXIT__ <code>. The profile is regenerated by /init-pipeline after.
147
- function runReset(req, res, body, { pkgRoot }) {
177
+ function runReset(req, res, body, { pkgRoot, globalDir }) {
148
178
  const project = body.project ? path.resolve(body.project) : null;
149
179
  const purgeSpecs = !!body.purgeSpecs;
150
180
  if (!project || !fs.existsSync(project)) {
151
181
  return sendJson(res, 400, { error: 'project path not found' });
152
182
  }
183
+ // The whole promise of this endpoint — echoed in the modal's copy — is that the
184
+ // shared global core is never touched. Nothing enforced it: reset moves
185
+ // <project>/.claude, so a project of `~` (or wherever CLAUDE_CONFIG_DIR's parent
186
+ // is) would move the global core itself into a backup dir, silently breaking
187
+ // every repo on the machine. Refuse that path outright.
188
+ const same = (a, b) => path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase();
189
+ if (same(path.join(project, '.claude'), globalDir)) {
190
+ return sendJson(res, 400, {
191
+ error: `refusing to reset ${project}: its .claude IS the shared global core (${globalDir}). ` +
192
+ 'Reset only ever touches a project\'s own pipeline footprint.',
193
+ });
194
+ }
153
195
 
154
196
  res.writeHead(200, STREAM_HEADERS);
155
197
  const log = s => res.write(s + '\n');
@@ -205,6 +247,35 @@ function runReset(req, res, body, { pkgRoot }) {
205
247
 
206
248
  const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1']);
207
249
 
250
+ // Browser-facing guard for the API. Loopback binding is NOT a security boundary
251
+ // against a browser: any web page the user visits can fire form/fetch requests at
252
+ // 127.0.0.1 (CSRF), and DNS rebinding can even make the responses readable. Two
253
+ // checks close both without needing a token round-trip:
254
+ // - Host must be a loopback origin (kills rebinding — an attacker-controlled
255
+ // domain resolving to 127.0.0.1 still sends its own Host header). Skipped
256
+ // when the user explicitly bound a non-loopback host (they were warned).
257
+ // - State-changing methods must carry content-type: application/json. A
258
+ // cross-origin fetch with that header triggers a CORS preflight, which this
259
+ // server never answers — so a browser can't deliver it cross-origin; forms
260
+ // can only send urlencoded/multipart/text.
261
+ function guardBrowser(req, res, bindHost) {
262
+ if (LOOPBACK.has(bindHost)) {
263
+ const host = String(req.headers.host || '').replace(/:\d+$/, '').replace(/^\[|\]$/g, '');
264
+ if (!LOOPBACK.has(host)) {
265
+ sendJson(res, 403, { error: `forbidden host header: ${req.headers.host || '(none)'}` });
266
+ return false;
267
+ }
268
+ }
269
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
270
+ const ct = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
271
+ if (ct !== 'application/json') {
272
+ sendJson(res, 403, { error: 'state-changing requests require content-type: application/json' });
273
+ return false;
274
+ }
275
+ }
276
+ return true;
277
+ }
278
+
208
279
  // Best-effort browser open (opt-in via --open). Never throws; failure is silent.
209
280
  function openBrowserAt(url) {
210
281
  const cmd = process.platform === 'darwin' ? 'open'
@@ -223,6 +294,7 @@ function start({ projectRoot, globalDir, port, host, openBrowser, pkgRoot, versi
223
294
  const server = http.createServer(async (req, res) => {
224
295
  const url = req.url.split('?')[0];
225
296
  try {
297
+ if (url.startsWith('/api/') && !guardBrowser(req, res, bindHost)) return;
226
298
  if (url === '/api/versions') {
227
299
  return sendJson(res, 200, await versions({ projectRoot, globalDir, cliVersion: version }));
228
300
  }
@@ -268,7 +340,7 @@ function start({ projectRoot, globalDir, port, host, openBrowser, pkgRoot, versi
268
340
  if (url === '/api/action' && req.method === 'POST') {
269
341
  let body;
270
342
  try { body = await readBody(req); } catch (e) { return sendJson(res, 400, { error: e.message }); }
271
- if (body.action === 'reset') return runReset(req, res, body, { pkgRoot });
343
+ if (body.action === 'reset') return runReset(req, res, body, { pkgRoot, globalDir });
272
344
  if (body.action === 'claude') return runClaude(req, res, body);
273
345
  return runAction(req, res, body, { pkgRoot });
274
346
  }
@@ -11,7 +11,10 @@
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
13
 
14
- const PHASES = ['build', 'review', 'fix', 'smoke'];
14
+ // `cycle` is the workflow variant's own batch (cycle.js §Close). Without it in this
15
+ // list its per-surface results parse fine but render in no column — the surface table
16
+ // showed rows with every cell empty.
17
+ const PHASES = ['build', 'review', 'fix', 'smoke', 'cycle'];
15
18
 
16
19
  // Parse the raw JSONL into normalized batches ({ts, feature, phase, seconds, surfaces}),
17
20
  // skipping malformed lines. Legacy per-surface lines are folded into their batch.
@@ -25,7 +28,12 @@ function parseBatches(raw) {
25
28
  if (!e || typeof e !== 'object' || !e.feature || !e.phase) continue;
26
29
  const seconds = Number(e.seconds) || 0;
27
30
  if (e.surfaces && typeof e.surfaces === 'object') {
28
- batches.push({ ts: e.ts || '', feature: String(e.feature), phase: String(e.phase), seconds, surfaces: e.surfaces });
31
+ // `rounds` / `smoke` are run-level facts the cycle workflow reports alongside
32
+ // (never inside) `surfaces` — carry them through for the aggregate.
33
+ batches.push({
34
+ ts: e.ts || '', feature: String(e.feature), phase: String(e.phase), seconds,
35
+ surfaces: e.surfaces, rounds: e.rounds, smoke: e.smoke,
36
+ });
29
37
  } else if (e.surface) {
30
38
  const key = `${e.ts}|${e.feature}|${e.phase}`;
31
39
  let b = legacy.get(key);
@@ -43,10 +51,11 @@ function parseBatches(raw) {
43
51
  }
44
52
 
45
53
  // A surface result string counts as a failure when it is "error" or a BLOCK/REVISE verdict
46
- // (verdict lines look like "REVISE:2"), or a non-ok/pass word.
54
+ // (verdict lines look like "REVISE:2"), or a non-ok/pass word. "skipped" is neutral —
55
+ // the cycle workflow reports smoke:SKIPPED when the human opted out, not a failure.
47
56
  function isFailure(result) {
48
57
  const head = String(result).split(':')[0].trim().toLowerCase();
49
- return head !== '' && head !== 'ok' && head !== 'ship' && head !== 'pass';
58
+ return head !== '' && head !== 'ok' && head !== 'ship' && head !== 'pass' && head !== 'skipped';
50
59
  }
51
60
 
52
61
  // Aggregate batches per feature (newest feature first).
@@ -65,6 +74,8 @@ function aggregate(batches) {
65
74
  };
66
75
  byFeature.set(b.feature, f);
67
76
  }
77
+ // cycle.js reports its round count outside `surfaces` (that map is for surfaces).
78
+ if (b.phase === 'cycle' && Number(b.rounds) > 0) f.cycleRounds = Number(b.rounds);
68
79
  if (b.ts && (!f.firstTs || b.ts < f.firstTs)) f.firstTs = b.ts;
69
80
  if (b.ts && b.ts > f.lastTs) f.lastTs = b.ts;
70
81
  f.totalSeconds += b.seconds;
@@ -32,6 +32,10 @@ function pointerAt(projectRoot) {
32
32
  // briefly so the dashboard's poll doesn't hammer the network. null only if both fail.
33
33
  let _cache = { value: null, at: 0 };
34
34
  const CACHE_MS = 5 * 60 * 1000;
35
+ // Failures are cached too, briefly. Without this an offline machine paid the FULL
36
+ // 5s fetch timeout + 8s `npm view` timeout on every call — and /api/fleet calls
37
+ // this once per tracked project, so the dashboard's 15s poll never finished.
38
+ const FAIL_CACHE_MS = 60 * 1000;
35
39
 
36
40
  async function fetchRegistry() {
37
41
  const ctrl = new AbortController();
@@ -51,10 +55,16 @@ async function fetchRegistry() {
51
55
  }
52
56
  }
53
57
 
58
+ // Fallback only — spawnSync blocks the single-threaded server for up to its
59
+ // timeout, so it must stay behind latestNpm()'s cache + in-flight dedupe (at most
60
+ // one call a minute, and only when the registry fetch already failed).
54
61
  function npmView() {
55
62
  try {
56
- const r = spawnSync('npm', ['view', 'cohorte', 'version'],
57
- { encoding: 'utf8', timeout: 8000, shell: process.platform === 'win32' });
63
+ // One static string on Windows (npm is npm.cmd — needs a shell; the
64
+ // shell+args-array combination is DEP0190-deprecated). Nothing dynamic.
65
+ const r = process.platform === 'win32'
66
+ ? spawnSync('npm view cohorte version', { encoding: 'utf8', timeout: 8000, shell: true })
67
+ : spawnSync('npm', ['view', 'cohorte', 'version'], { encoding: 'utf8', timeout: 8000 });
58
68
  if (r.status === 0 && r.stdout) {
59
69
  const v = r.stdout.trim();
60
70
  return /^\d+\.\d+\.\d+/.test(v) ? v : null;
@@ -63,11 +73,23 @@ function npmView() {
63
73
  return null;
64
74
  }
65
75
 
76
+ let _inflight = null;
66
77
  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;
78
+ const age = Date.now() - _cache.at;
79
+ if (_cache.at && age < (_cache.value ? CACHE_MS : FAIL_CACHE_MS)) return _cache.value;
80
+ // /api/fleet resolves N projects concurrently — without this, N lookups race and
81
+ // each pays the full timeout instead of sharing one.
82
+ if (_inflight) return _inflight;
83
+ _inflight = (async () => {
84
+ try {
85
+ const v = (await fetchRegistry()) || npmView();
86
+ _cache = { value: v || null, at: Date.now() };
87
+ return v || null;
88
+ } finally {
89
+ _inflight = null;
90
+ }
91
+ })();
92
+ return _inflight;
71
93
  }
72
94
 
73
95
  // Naive semver compare — returns -1|0|1 (a<b|a==b|a>b). Ignores pre-release tags.
@@ -62,10 +62,13 @@ function scalar(raw) {
62
62
  return v;
63
63
  }
64
64
 
65
+ // Split "key: rest" on the FIRST colon. A colon inside the value (a URL, a
66
+ // chained shell command) is therefore kept in `rest`, which is what we want;
67
+ // callers are responsible for not feeding this a flow scalar (`[a, b]` /
68
+ // `{a: 1}`) — the block parser checks the first character before calling.
65
69
  function keyValue(text) {
66
70
  const idx = text.indexOf(':');
67
71
  if (idx === -1) return null;
68
- // Guard against flow-map values being mistaken for a key split.
69
72
  return { key: text.slice(0, idx).trim(), rest: text.slice(idx + 1).trim() };
70
73
  }
71
74
 
package/install.ps1 CHANGED
@@ -137,6 +137,10 @@ try {
137
137
  Copy-Tree (Join-Path $src 'core\hooks') (Join-Path $dest 'hooks')
138
138
  Copy-Tree (Join-Path $src 'core\templates') (Join-Path $dest 'templates')
139
139
  Copy-Tree (Join-Path $src 'core\workflows') (Join-Path $dest 'workflows')
140
+ # A Python bytecode cache appears in a source checkout the moment anyone compiles
141
+ # or imports gate.py (CI does) and Copy-Item carries it along — machine- and
142
+ # interpreter-specific, and copy-over would never delete it later.
143
+ Remove-Item -LiteralPath (Join-Path $dest 'hooks\__pycache__') -Recurse -Force -ErrorAction SilentlyContinue
140
144
  # 0.1.19 renamed questionnaire-domain-brief.md -> research-brief.md; drop the stale copy.
141
145
  Remove-Item -LiteralPath (Join-Path $dest 'templates\questionnaire-domain-brief.md') -Force -ErrorAction SilentlyContinue
142
146
  New-Item -ItemType Directory -Force -Path (Join-Path $dest 'pipeline\scripts') | Out-Null
package/install.sh CHANGED
@@ -32,8 +32,22 @@ while [ $# -gt 0 ]; do
32
32
  case "$1" in
33
33
  --update) mode="update"; shift ;;
34
34
  --global) scope="global"; shift ;;
35
+ -h|--help)
36
+ cat <<'USAGE'
37
+ install.sh — install the cohorte pipeline core.
38
+
39
+ sh install.sh [target_dir] per-project: bundle the core into <target>/.claude
40
+ sh install.sh --global one shared core in ~/.claude (recommended)
41
+ sh install.sh --update [target] refresh the core in place, keep every generated file
42
+ sh install.sh --update --global
43
+
44
+ Honours $CLAUDE_CONFIG_DIR for the global destination and $PIPELINE_REPO for the
45
+ source when piped through curl. The npm CLI (`npx cohorte install`) does the same
46
+ thing and is the documented route; this script exists for Node-less environments.
47
+ USAGE
48
+ exit 0 ;;
35
49
  --) shift; break ;;
36
- -*) echo "error: unknown flag: $1" >&2; exit 2 ;;
50
+ -*) echo "error: unknown flag: $1 (try --help)" >&2; exit 2 ;;
37
51
  *) positional="$1"; shift ;;
38
52
  esac
39
53
  done
@@ -79,6 +93,10 @@ copy_core() {
79
93
  cp -R "$src/core/hooks" "$dest/"
80
94
  cp -R "$src/core/templates" "$dest/"
81
95
  cp -R "$src/core/workflows" "$dest/"
96
+ # A Python bytecode cache appears in a source checkout the moment anyone compiles
97
+ # or imports gate.py (CI does) and `cp -R` carries it along — machine- and
98
+ # interpreter-specific, and copy-over would never delete it later.
99
+ rm -rf "$dest/hooks/__pycache__"
82
100
  # 0.1.19 renamed questionnaire-domain-brief.md → research-brief.md; drop the stale copy.
83
101
  rm -f "$dest/templates/questionnaire-domain-brief.md"
84
102
  mkdir -p "$dest/pipeline/scripts"
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "cohorte",
3
- "version": "1.3.2",
3
+ "version": "1.3.4",
4
4
  "description": "Portable, stack-agnostic multi-agent development pipeline for Claude Code — install the core, run /init-pipeline, and it adapts to your project's stack.",
5
5
  "bin": {
6
6
  "cohorte": "bin/cli.js"
7
7
  },
8
8
  "scripts": {
9
- "build:dashboard": "npm --prefix dashboard/app ci && npm --prefix dashboard/app run build"
9
+ "build:dashboard": "npm --prefix dashboard/app ci && npm --prefix dashboard/app run build",
10
+ "prepack": "npm run build:dashboard"
10
11
  },
11
12
  "files": [
12
13
  "bin",
@@ -15,6 +16,8 @@
15
16
  "scripts",
16
17
  "!scripts/new-feature.sh",
17
18
  "!scripts/remove-feature.sh",
19
+ "!core/hooks/__pycache__",
20
+ "!**/*.pyc",
18
21
  "dashboard/server",
19
22
  "dashboard/dist",
20
23
  "dashboard/README.md",