cohorte 1.3.3 → 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.
- package/CHANGELOG.md +90 -0
- package/README.md +4 -4
- package/bin/cli.js +22 -4
- package/core/agents/implementer.template.md +10 -5
- package/core/commands/cycle.md +15 -8
- package/core/commands/doctor.md +3 -1
- package/core/hooks/gate.py +21 -6
- package/core/templates/agent-handoff.md +7 -2
- package/core/templates/review-feedback.md +7 -4
- package/core/templates/spec.template.md +5 -2
- package/core/templates/steps/init-pipeline/04-write-render.md +2 -1
- package/core/workflows/audit.js +20 -3
- package/core/workflows/cycle.js +146 -40
- package/core/workflows/refactor.js +16 -5
- package/core/workflows/review.js +59 -7
- package/dashboard/README.md +22 -5
- package/dashboard/dist/assets/index-AFQnlfjO.css +1 -0
- package/dashboard/dist/assets/{index-BxgA_mz1.js → index-DLBzciIC.js} +12 -11
- package/dashboard/dist/index.html +2 -2
- package/dashboard/server/doctor.js +60 -19
- package/dashboard/server/fleet.js +19 -5
- package/dashboard/server/index.js +79 -7
- package/dashboard/server/metrics.js +15 -4
- package/dashboard/server/versions.js +28 -6
- package/dashboard/server/yaml.js +4 -1
- package/install.ps1 +4 -0
- package/install.sh +19 -1
- package/package.json +5 -2
- package/profile/SCHEMA.md +28 -9
- package/scripts/kanban-move.sh +34 -20
- package/scripts/new-feature.sh.template +3 -1
- package/scripts/preflight.sh +16 -3
- package/scripts/remove-feature.sh.template +2 -1
- package/scripts/telemetry-send.sh +15 -1
- package/scripts/test-dashboard.mjs +356 -0
- package/scripts/test-gate.mjs +273 -0
- package/scripts/test-workflows.mjs +443 -0
- package/scripts/validate-core.mjs +49 -0
- 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-
|
|
11
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
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
|
|
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
|
-
|
|
135
|
-
|
|
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 (
|
|
138
|
-
return mk('hooks', 'Gate hook', 'warn',
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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} —
|
|
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:
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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.
|
package/dashboard/server/yaml.js
CHANGED
|
@@ -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.
|
|
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",
|