@rigour-labs/cli 5.4.0 → 5.5.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.
@@ -1,573 +1,869 @@
1
1
  import { Command } from 'commander';
2
2
  import path from 'path';
3
+ import os from 'os';
3
4
  import chalk from 'chalk';
4
5
  import { execa } from 'execa';
5
6
  import fs from 'fs-extra';
6
7
  import http from 'http';
7
8
  import { randomUUID } from 'crypto';
8
- export const studioCommand = new Command('studio')
9
- .description('Launch Rigour Studio (Local-First Governance UI)')
10
- .option('-p, --port <number>', 'Port to run the studio on', '3000')
11
- .option('--dev', 'Run in development mode', true)
12
- .action(async (options) => {
13
- const cwd = process.cwd();
14
- const apiPort = parseInt(options.port) + 1;
15
- const eventsPath = path.join(cwd, '.rigour/events.jsonl');
16
- // Calculate the local dist path (where the pre-built Studio UI lives)
17
- // When running from source: cli/dist/commands/studio.js → cli/studio-dist/
18
- // When running via npx: node_modules/@rigour-labs/cli/dist/commands/studio.js → cli/dist/studio-dist/
19
- const __dirname = path.dirname(new URL(import.meta.url).pathname);
20
- const candidates = [
21
- path.join(__dirname, '../studio-dist'), // npm publish: cli/dist/studio-dist/
22
- path.join(__dirname, '../../studio-dist'), // monorepo: cli/studio-dist/
23
- path.join(__dirname, '../../../studio-dist'), // npx: @rigour-labs/cli/studio-dist/
24
- ];
25
- const localStudioDist = candidates.find(p => fs.pathExistsSync(p)) ?? candidates[0];
26
- const workspaceRoot = path.join(__dirname, '../../../../');
27
- console.log(chalk.bold.cyan('\n🛡️ Launching Rigour Studio...'));
28
- console.log(chalk.gray(`Project Root: ${cwd}`));
29
- // Pre-flight check: Is the project initialized?
30
- const configPath = path.join(cwd, 'rigour.yml');
31
- if (!(await fs.pathExists(configPath))) {
32
- console.log(chalk.yellow('\n⚠️ Warning: rigour.yml not found.'));
33
- console.log(chalk.dim('The Studio will be empty until you initialize the project.'));
34
- console.log(chalk.cyan('Suggest: ') + chalk.bold('npx @rigour-labs/cli init') + '\n');
9
+ function sendJson(res, status, body) {
10
+ res.writeHead(status, { 'Content-Type': 'application/json' });
11
+ res.end(JSON.stringify(body));
12
+ }
13
+ async function readJsonIfExists(filePath) {
14
+ if (!(await fs.pathExists(filePath)))
15
+ return null;
16
+ try {
17
+ return await fs.readJson(filePath);
35
18
  }
36
- console.log(chalk.gray(`Shadowing interactions in ${path.join(cwd, '.rigour/events.jsonl')}\n`));
37
- // Check if we are in a monorepo development environment
38
- const isMonorepo = await fs.pathExists(path.join(workspaceRoot, 'packages/rigour-studio'));
39
- if (isMonorepo && options.dev) {
40
- console.log(chalk.yellow('Monorepo detected: Launching Studio in Development Mode...'));
41
- try {
42
- // Start the Studio dev server in the workspace root
43
- const studioProcess = execa('pnpm', ['--filter', '@rigour-labs/studio', 'dev', '--port', options.port], {
44
- stdio: 'inherit',
45
- cwd: workspaceRoot
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ async function mergeMemoryStores(cwd) {
24
+ const sources = [];
25
+ const memories = {};
26
+ const projectPath = path.join(cwd, '.rigour/memory.json');
27
+ const globalPath = path.join(os.homedir(), '.rigour/memory.json');
28
+ for (const [label, filePath] of [
29
+ ['project', projectPath],
30
+ ['global', globalPath],
31
+ ]) {
32
+ const data = await readJsonIfExists(filePath);
33
+ if (!data)
34
+ continue;
35
+ sources.push(label);
36
+ const entries = data.memories && typeof data.memories === 'object' ? data.memories : data;
37
+ for (const [key, value] of Object.entries(entries || {})) {
38
+ const namespaced = memories[key] ? `${label}:${key}` : key;
39
+ memories[namespaced] = {
40
+ ...(typeof value === 'object' && value !== null ? value : { value }),
41
+ source: label,
42
+ };
43
+ }
44
+ }
45
+ return { memories, sources };
46
+ }
47
+ function mapCheckpointMetrics(metrics) {
48
+ return metrics.map((m) => {
49
+ const raw = m.rawStateTokens || 0;
50
+ const packed = m.checkpointTokens || 0;
51
+ const avoided = m.replayTokensAvoided || Math.max(0, raw - packed);
52
+ const compression = packed > 0 ? Math.round(raw / packed) : 0;
53
+ const qualityScore = Math.max(40, Math.min(100, 60 + Math.min(40, compression)));
54
+ const createdAt = m.createdAt ?? Date.now();
55
+ return {
56
+ checkpointId: m.checkpointId,
57
+ agentId: m.agentId,
58
+ taskId: m.taskId,
59
+ timestamp: new Date(createdAt).toISOString(),
60
+ progressPct: Math.min(100, Math.round((avoided / Math.max(raw, 1)) * 100)),
61
+ filesChanged: [],
62
+ summary: `Compressed ${raw.toLocaleString()} → ${packed.toLocaleString()} tokens; avoided ${avoided.toLocaleString()} replay tokens${compression ? ` (${compression}×)` : ''}.`,
63
+ qualityScore,
64
+ warnings: [],
65
+ rawStateTokens: raw,
66
+ checkpointTokens: packed,
67
+ replayTokensAvoided: avoided,
68
+ };
69
+ });
70
+ }
71
+ async function synthesizeAgents(cwd, checkpoints) {
72
+ const sessionPath = path.join(cwd, '.rigour/agent-session.json');
73
+ const session = await readJsonIfExists(sessionPath);
74
+ if (session?.agents?.length) {
75
+ return session;
76
+ }
77
+ const byAgent = new Map();
78
+ for (const cp of checkpoints) {
79
+ const existing = byAgent.get(cp.agentId);
80
+ if (!existing) {
81
+ byAgent.set(cp.agentId, {
82
+ agentId: cp.agentId,
83
+ taskScope: cp.taskId ? [`task:${cp.taskId}`] : [],
84
+ registeredAt: cp.timestamp,
85
+ lastCheckpoint: cp.timestamp,
86
+ status: 'completed',
46
87
  });
47
- await setupApiAndLaunch(apiPort, options.port, eventsPath, cwd, studioProcess);
48
- return;
49
88
  }
50
- catch (e) {
51
- console.log(chalk.dim('Development mode failed, falling back to standalone...'));
89
+ else {
90
+ existing.lastCheckpoint = cp.timestamp;
91
+ if (cp.taskId && !existing.taskScope.includes(`task:${cp.taskId}`)) {
92
+ existing.taskScope.push(`task:${cp.taskId}`);
93
+ }
52
94
  }
53
95
  }
54
- // Standalone Mode: Serve pre-built static files
55
- console.log(chalk.green('Launching Studio in Standalone Mode...'));
56
- if (!(await fs.pathExists(localStudioDist))) {
57
- console.error(chalk.red(`\n❌ Error: Studio UI artifacts not found at ${localStudioDist}`));
58
- console.log(chalk.yellow('If you are a developer, run "pnpm build" in the monorepo root first.\n'));
59
- process.exit(1);
96
+ const agents = [...byAgent.values()];
97
+ return {
98
+ sessionId: agents.length ? 'derived-from-checkpoints' : 'inactive',
99
+ agents,
100
+ status: agents.length ? 'completed' : 'inactive',
101
+ createdAt: agents[0]?.registeredAt || new Date().toISOString(),
102
+ derived: true,
103
+ };
104
+ }
105
+ async function handleApiRequest(req, res, url, ctx) {
106
+ if (!url.pathname.startsWith('/api'))
107
+ return false;
108
+ const requestOrigin = req.headers.origin;
109
+ if (typeof requestOrigin === 'string' && ctx.allowedOrigins.has(requestOrigin)) {
110
+ res.setHeader('Access-Control-Allow-Origin', requestOrigin);
111
+ res.setHeader('Vary', 'Origin');
60
112
  }
61
- const staticServer = http.createServer(async (req, res) => {
62
- const url = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`);
63
- let filePath = path.join(localStudioDist, url.pathname === '/' ? 'index.html' : url.pathname);
64
- try {
65
- if (!(await fs.pathExists(filePath)) || (await fs.stat(filePath)).isDirectory()) {
66
- filePath = path.join(localStudioDist, 'index.html');
113
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS, POST, DELETE');
114
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
115
+ if (req.method === 'OPTIONS') {
116
+ res.writeHead(204);
117
+ res.end();
118
+ return true;
119
+ }
120
+ const { cwd, eventsPath } = ctx;
121
+ if (url.pathname === '/api/events') {
122
+ res.writeHead(200, {
123
+ 'Content-Type': 'text/event-stream',
124
+ 'Cache-Control': 'no-cache',
125
+ Connection: 'keep-alive',
126
+ });
127
+ if (await fs.pathExists(eventsPath)) {
128
+ const content = await fs.readFile(eventsPath, 'utf8');
129
+ const lines = content.split('\n').filter((l) => l.trim());
130
+ // Send recent history only — full 77MB dumps freeze the UI
131
+ for (const line of lines.slice(-200)) {
132
+ res.write(`data: ${line}\n\n`);
133
+ }
134
+ }
135
+ await fs.ensureDir(path.dirname(eventsPath));
136
+ const watcher = fs.watch(path.dirname(eventsPath), async (_eventType, filename) => {
137
+ if (filename === 'events.jsonl') {
138
+ try {
139
+ const content = await fs.readFile(eventsPath, 'utf8');
140
+ const lines = content.split('\n').filter((l) => l.trim());
141
+ const lastLine = lines[lines.length - 1];
142
+ if (lastLine)
143
+ res.write(`data: ${lastLine}\n\n`);
144
+ }
145
+ catch {
146
+ // ignore transient reads
147
+ }
67
148
  }
68
- const content = await fs.readFile(filePath);
69
- const ext = path.extname(filePath);
70
- const contentTypes = {
71
- '.html': 'text/html',
72
- '.js': 'application/javascript',
73
- '.css': 'text/css',
74
- '.json': 'application/json',
75
- '.png': 'image/png',
76
- '.jpg': 'image/jpeg',
77
- '.svg': 'image/svg+xml',
78
- '.ico': 'image/x-icon'
79
- };
80
- res.writeHead(200, { 'Content-Type': contentTypes[ext] || 'application/octet-stream' });
149
+ });
150
+ req.on('close', () => watcher.close());
151
+ return true;
152
+ }
153
+ if (url.pathname === '/api/file') {
154
+ const filePath = url.searchParams.get('path');
155
+ if (!filePath) {
156
+ res.writeHead(400);
157
+ res.end('Missing path');
158
+ return true;
159
+ }
160
+ const absolutePath = path.resolve(cwd, filePath);
161
+ if (!absolutePath.startsWith(cwd)) {
162
+ res.writeHead(403);
163
+ res.end('Forbidden');
164
+ return true;
165
+ }
166
+ try {
167
+ const content = await fs.readFile(absolutePath, 'utf8');
168
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
81
169
  res.end(content);
82
170
  }
83
- catch (e) {
171
+ catch {
84
172
  res.writeHead(404);
85
- res.end('Not Found');
173
+ res.end('Not found');
86
174
  }
87
- });
88
- staticServer.listen(options.port, () => {
89
- setupApiAndLaunch(apiPort, options.port, eventsPath, cwd);
90
- });
91
- });
92
- async function setupApiAndLaunch(apiPort, studioPort, eventsPath, cwd, studioProcess) {
93
- const allowedOrigins = new Set([
94
- `http://localhost:${studioPort}`,
95
- `http://127.0.0.1:${studioPort}`,
96
- ]);
97
- const apiServer = http.createServer(async (req, res) => {
98
- const url = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`);
99
- const requestOrigin = req.headers.origin;
100
- if (typeof requestOrigin === 'string' && allowedOrigins.has(requestOrigin)) {
101
- res.setHeader('Access-Control-Allow-Origin', requestOrigin);
102
- res.setHeader('Vary', 'Origin');
103
- }
104
- res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS, POST');
105
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
106
- if (req.method === 'OPTIONS') {
107
- res.writeHead(204);
108
- res.end();
109
- return;
110
- }
111
- if (url.pathname === '/api/events') {
112
- res.writeHead(200, {
113
- 'Content-Type': 'text/event-stream',
114
- 'Cache-Control': 'no-cache',
115
- 'Connection': 'keep-alive'
116
- });
117
- if (await fs.pathExists(eventsPath)) {
118
- const content = await fs.readFile(eventsPath, 'utf8');
119
- const lines = content.split('\n').filter(l => l.trim());
120
- for (const line of lines) {
121
- res.write(`data: ${line}\n\n`);
175
+ return true;
176
+ }
177
+ if (url.pathname === '/api/info') {
178
+ try {
179
+ const pkgPath = path.join(cwd, 'package.json');
180
+ const pkg = (await fs.pathExists(pkgPath)) ? await fs.readJson(pkgPath) : {};
181
+ const __dirname = path.dirname(new URL(import.meta.url).pathname);
182
+ const cliPkgPath = path.join(__dirname, '../../package.json');
183
+ const mcpPkgCandidates = [
184
+ path.join(__dirname, '../../../rigour-mcp/package.json'),
185
+ path.join(__dirname, '../../../../packages/rigour-mcp/package.json'),
186
+ ];
187
+ const cliPkg = (await fs.pathExists(cliPkgPath)) ? await fs.readJson(cliPkgPath) : {};
188
+ let mcpVersion = '5.5.0';
189
+ for (const candidate of mcpPkgCandidates) {
190
+ if (await fs.pathExists(candidate)) {
191
+ const mcpPkg = await fs.readJson(candidate);
192
+ mcpVersion = mcpPkg.version || mcpVersion;
193
+ break;
122
194
  }
123
195
  }
124
- await fs.ensureDir(path.dirname(eventsPath));
125
- const watcher = fs.watch(path.dirname(eventsPath), async (_eventType, filename) => {
126
- if (filename === 'events.jsonl') {
127
- try {
128
- const content = await fs.readFile(eventsPath, 'utf8');
129
- const lines = content.split('\n').filter(l => l.trim());
130
- const lastLine = lines[lines.length - 1];
131
- if (lastLine) {
132
- res.write(`data: ${lastLine}\n\n`);
133
- }
196
+ // Product version is the MCP/governance release; CLI package may differ.
197
+ const studioVersion = mcpVersion || cliPkg.version || '0.0.0';
198
+ sendJson(res, 200, {
199
+ name: pkg.name || path.basename(cwd),
200
+ projectName: pkg.name || path.basename(cwd),
201
+ path: cwd,
202
+ projectPath: cwd,
203
+ version: pkg.version || '0.0.0',
204
+ projectVersion: pkg.version || '0.0.0',
205
+ studioVersion,
206
+ mcpVersion,
207
+ brainDb: path.join(os.homedir(), '.rigour/rigour.db'),
208
+ });
209
+ }
210
+ catch (e) {
211
+ res.writeHead(500);
212
+ res.end(e.message);
213
+ }
214
+ return true;
215
+ }
216
+ if (url.pathname === '/api/tree') {
217
+ try {
218
+ const getTree = async (dir) => {
219
+ const entries = await fs.readdir(dir, { withFileTypes: true });
220
+ let files = [];
221
+ const exclude = ['node_modules', '.git', '.rigour', '.venv', 'dist', 'build'];
222
+ for (const entry of entries) {
223
+ if (exclude.includes(entry.name) || entry.name.startsWith('.'))
224
+ continue;
225
+ const fullPath = path.join(dir, entry.name);
226
+ if (entry.isDirectory()) {
227
+ files = [...files, ...(await getTree(fullPath))];
134
228
  }
135
- catch {
136
- // ignore transient file read failures during writes
229
+ else {
230
+ files.push(path.relative(cwd, fullPath));
137
231
  }
138
232
  }
139
- });
140
- req.on('close', () => watcher.close());
233
+ return files;
234
+ };
235
+ sendJson(res, 200, await getTree(cwd));
141
236
  }
142
- else if (url.pathname === '/api/file') {
143
- const filePath = url.searchParams.get('path');
144
- if (!filePath) {
145
- res.writeHead(400);
146
- res.end('Missing path');
147
- return;
148
- }
149
- const absolutePath = path.resolve(cwd, filePath);
150
- if (!absolutePath.startsWith(cwd)) {
151
- res.writeHead(403);
152
- res.end('Forbidden');
153
- return;
154
- }
155
- try {
156
- const content = await fs.readFile(absolutePath, 'utf8');
157
- res.writeHead(200, { 'Content-Type': 'text/plain' });
158
- res.end(content);
237
+ catch (e) {
238
+ res.writeHead(500);
239
+ res.end(e.message);
240
+ }
241
+ return true;
242
+ }
243
+ if (url.pathname === '/api/config') {
244
+ try {
245
+ const configPath = path.join(cwd, 'rigour.yml');
246
+ if (await fs.pathExists(configPath)) {
247
+ res.writeHead(200, { 'Content-Type': 'text/yaml' });
248
+ res.end(await fs.readFile(configPath, 'utf8'));
159
249
  }
160
- catch {
250
+ else {
161
251
  res.writeHead(404);
162
252
  res.end('Not found');
163
253
  }
164
254
  }
165
- else if (url.pathname === '/api/info') {
166
- try {
167
- const pkgPath = path.join(cwd, 'package.json');
168
- const pkg = await fs.pathExists(pkgPath) ? await fs.readJson(pkgPath) : {};
169
- res.writeHead(200, { 'Content-Type': 'application/json' });
170
- res.end(JSON.stringify({
171
- name: pkg.name || path.basename(cwd),
172
- path: cwd,
173
- version: pkg.version || '0.0.0'
174
- }));
175
- }
176
- catch (e) {
177
- res.writeHead(500);
178
- res.end(e.message);
179
- }
255
+ catch (e) {
256
+ res.writeHead(500);
257
+ res.end(e.message);
180
258
  }
181
- else if (url.pathname === '/api/tree') {
182
- try {
183
- const getTree = async (dir) => {
184
- const entries = await fs.readdir(dir, { withFileTypes: true });
185
- let files = [];
186
- const exclude = ['node_modules', '.git', '.rigour', '.venv', 'dist', 'build'];
187
- for (const entry of entries) {
188
- if (exclude.includes(entry.name) || entry.name.startsWith('.'))
189
- continue;
190
- const fullPath = path.join(dir, entry.name);
191
- if (entry.isDirectory()) {
192
- files = [...files, ...(await getTree(fullPath))];
193
- }
194
- else {
195
- files.push(path.relative(cwd, fullPath));
196
- }
197
- }
198
- return files;
199
- };
200
- res.writeHead(200, { 'Content-Type': 'application/json' });
201
- res.end(JSON.stringify(await getTree(cwd)));
202
- }
203
- catch (e) {
204
- res.writeHead(500);
205
- res.end(e.message);
206
- }
259
+ return true;
260
+ }
261
+ if (url.pathname === '/api/memory') {
262
+ try {
263
+ sendJson(res, 200, await mergeMemoryStores(cwd));
207
264
  }
208
- else if (url.pathname === '/api/config') {
209
- try {
210
- const configPath = path.join(cwd, 'rigour.yml');
211
- if (await fs.pathExists(configPath)) {
212
- res.writeHead(200, { 'Content-Type': 'text/plain' });
213
- res.end(await fs.readFile(configPath, 'utf8'));
214
- }
215
- else {
216
- res.writeHead(404);
217
- res.end('Not found');
218
- }
219
- }
220
- catch (e) {
221
- res.writeHead(500);
222
- res.end(e.message);
223
- }
265
+ catch (e) {
266
+ sendJson(res, 500, { error: e.message });
224
267
  }
225
- else if (url.pathname === '/api/memory') {
226
- try {
227
- const memoryPath = path.join(cwd, '.rigour/memory.json');
228
- if (await fs.pathExists(memoryPath)) {
229
- res.writeHead(200, { 'Content-Type': 'application/json' });
230
- res.end(await fs.readFile(memoryPath, 'utf8'));
231
- }
232
- else {
233
- res.end(JSON.stringify({}));
234
- }
268
+ return true;
269
+ }
270
+ if (url.pathname === '/api/index-stats') {
271
+ try {
272
+ const indexPath = path.join(cwd, '.rigour/patterns.json');
273
+ if (await fs.pathExists(indexPath)) {
274
+ sendJson(res, 200, await fs.readJson(indexPath));
235
275
  }
236
- catch (e) {
237
- res.writeHead(500);
238
- res.end(e.message);
276
+ else {
277
+ sendJson(res, 200, { patterns: [], stats: { totalPatterns: 0, totalFiles: 0, byType: {} } });
239
278
  }
240
279
  }
241
- else if (url.pathname === '/api/index-stats') {
242
- try {
243
- const indexPath = path.join(cwd, '.rigour/patterns.json');
244
- if (await fs.pathExists(indexPath)) {
245
- res.writeHead(200, { 'Content-Type': 'application/json' });
246
- res.end(JSON.stringify(await fs.readJson(indexPath)));
247
- }
248
- else {
249
- res.end(JSON.stringify({ patterns: [], stats: { totalPatterns: 0, totalFiles: 0, byType: {} } }));
250
- }
251
- }
252
- catch (e) {
253
- res.writeHead(500);
254
- res.end(e.message);
255
- }
280
+ catch (e) {
281
+ sendJson(res, 500, { error: e.message });
256
282
  }
257
- else if (url.pathname === '/api/index-search') {
258
- const query = url.searchParams.get('q');
259
- if (!query) {
260
- res.writeHead(400);
261
- res.end('Missing query');
262
- return;
263
- }
264
- try {
265
- const { generateEmbedding, semanticSearch } = await import('@rigour-labs/core/pattern-index');
266
- const indexPath = path.join(cwd, '.rigour/patterns.json');
267
- const indexData = await fs.readJson(indexPath);
268
- const queryVector = await generateEmbedding(query);
269
- const similarities = semanticSearch(queryVector, indexData.patterns);
270
- const results = indexData.patterns.map((p, i) => ({ ...p, similarity: similarities[i] }))
271
- .filter((p) => p.similarity > 0.3)
272
- .sort((a, b) => b.similarity - a.similarity)
273
- .slice(0, 20);
274
- res.writeHead(200, { 'Content-Type': 'application/json' });
275
- res.end(JSON.stringify(results));
276
- }
277
- catch (e) {
278
- res.writeHead(500);
279
- res.end(e.message);
280
- }
283
+ return true;
284
+ }
285
+ if (url.pathname === '/api/index-search') {
286
+ const query = url.searchParams.get('q');
287
+ if (!query) {
288
+ res.writeHead(400);
289
+ res.end('Missing query');
290
+ return true;
281
291
  }
282
- else if (url.pathname === '/api/agents') {
283
- try {
284
- const sessionPath = path.join(cwd, '.rigour/agent-session.json');
285
- if (await fs.pathExists(sessionPath)) {
286
- res.writeHead(200, { 'Content-Type': 'application/json' });
287
- res.end(await fs.readFile(sessionPath, 'utf-8'));
288
- }
289
- else {
290
- res.writeHead(200, { 'Content-Type': 'application/json' });
291
- res.end(JSON.stringify({ agents: [], status: 'inactive' }));
292
- }
293
- }
294
- catch (e) {
295
- res.writeHead(500);
296
- res.end(e.message);
297
- }
292
+ try {
293
+ const { generateEmbedding, semanticSearch } = await import('@rigour-labs/core/pattern-index');
294
+ const indexPath = path.join(cwd, '.rigour/patterns.json');
295
+ const indexData = await fs.readJson(indexPath);
296
+ const queryVector = await generateEmbedding(query);
297
+ const similarities = semanticSearch(queryVector, indexData.patterns);
298
+ const results = indexData.patterns
299
+ .map((p, i) => ({ ...p, similarity: similarities[i] }))
300
+ .filter((p) => p.similarity > 0.3)
301
+ .sort((a, b) => b.similarity - a.similarity)
302
+ .slice(0, 20);
303
+ sendJson(res, 200, results);
298
304
  }
299
- else if (url.pathname === '/api/checkpoints') {
300
- try {
301
- const checkpointPath = path.join(cwd, '.rigour/checkpoint-session.json');
302
- if (await fs.pathExists(checkpointPath)) {
303
- res.writeHead(200, { 'Content-Type': 'application/json' });
304
- res.end(await fs.readFile(checkpointPath, 'utf-8'));
305
- }
306
- else {
307
- res.writeHead(200, { 'Content-Type': 'application/json' });
308
- res.end(JSON.stringify({ checkpoints: [], status: 'inactive' }));
309
- }
310
- }
311
- catch (e) {
312
- res.writeHead(500);
313
- res.end(e.message);
314
- }
305
+ catch (e) {
306
+ sendJson(res, 500, { error: e.message });
315
307
  }
316
- else if (url.pathname === '/api/report-stats') {
317
- try {
318
- const reportPath = path.join(cwd, 'rigour-report.json');
319
- if (await fs.pathExists(reportPath)) {
320
- const report = await fs.readJson(reportPath);
321
- res.writeHead(200, { 'Content-Type': 'application/json' });
322
- res.end(JSON.stringify(report.stats || {}));
323
- }
324
- else {
325
- res.writeHead(200, { 'Content-Type': 'application/json' });
326
- res.end(JSON.stringify({}));
327
- }
328
- }
329
- catch (e) {
330
- res.writeHead(500);
331
- res.end(e.message);
308
+ return true;
309
+ }
310
+ if (url.pathname === '/api/checkpoints' || url.pathname === '/api/agents') {
311
+ try {
312
+ const { getCheckpointMetrics, } = await import('@rigour-labs/core');
313
+ const metrics = await getCheckpointMetrics(undefined, cwd);
314
+ const mapped = mapCheckpointMetrics(metrics);
315
+ const sessionFile = await readJsonIfExists(path.join(cwd, '.rigour/checkpoint-session.json'));
316
+ const sessionCheckpoints = Array.isArray(sessionFile?.checkpoints) ? sessionFile.checkpoints : [];
317
+ const checkpoints = sessionCheckpoints.length > 0 ? sessionCheckpoints : mapped;
318
+ if (url.pathname === '/api/checkpoints') {
319
+ sendJson(res, 200, {
320
+ checkpoints,
321
+ status: checkpoints.length ? 'active' : 'inactive',
322
+ source: sessionCheckpoints.length ? 'session' : 'brain-metrics',
323
+ metricsCount: metrics.length,
324
+ });
325
+ }
326
+ else {
327
+ sendJson(res, 200, await synthesizeAgents(cwd, checkpoints));
332
328
  }
333
329
  }
334
- else if (url.pathname === '/api/deep-findings') {
335
- try {
336
- const reportPath = path.join(cwd, 'rigour-report.json');
337
- if (await fs.pathExists(reportPath)) {
338
- const report = await fs.readJson(reportPath);
339
- const findings = (report.failures || []).filter((f) => f.provenance === 'deep-analysis' || f.source === 'llm' || f.source === 'hybrid');
340
- res.writeHead(200, { 'Content-Type': 'application/json' });
341
- res.end(JSON.stringify(findings));
342
- }
343
- else {
344
- res.writeHead(200, { 'Content-Type': 'application/json' });
345
- res.end(JSON.stringify([]));
346
- }
347
- }
348
- catch (e) {
349
- res.writeHead(500);
350
- res.end(e.message);
351
- }
330
+ catch (e) {
331
+ sendJson(res, 500, { error: e.message });
352
332
  }
353
- else if (url.pathname === '/api/drift') {
354
- try {
355
- const { generateTemporalDriftReport } = await import('@rigour-labs/core');
356
- const report = generateTemporalDriftReport(cwd);
357
- res.writeHead(200, { 'Content-Type': 'application/json' });
358
- res.end(JSON.stringify(report || { totalScans: 0 }));
359
- }
360
- catch (e) {
361
- // SQLite not available or no data
362
- res.writeHead(200, { 'Content-Type': 'application/json' });
363
- res.end(JSON.stringify({ totalScans: 0 }));
364
- }
333
+ return true;
334
+ }
335
+ if (url.pathname === '/api/overview') {
336
+ try {
337
+ const { getTaskContextStats, getTaskCostStats, getCacheStats, getCheckpointSummary, getCheckpointMetrics, } = await import('@rigour-labs/core');
338
+ const [context, cost, cache, checkpointSummary, metrics, memory, indexStats] = await Promise.all([
339
+ getTaskContextStats(undefined, cwd),
340
+ getTaskCostStats(undefined, cwd),
341
+ getCacheStats(cwd),
342
+ getCheckpointSummary(undefined, cwd),
343
+ getCheckpointMetrics(undefined, cwd),
344
+ mergeMemoryStores(cwd),
345
+ readJsonIfExists(path.join(cwd, '.rigour/patterns.json')),
346
+ ]);
347
+ let recentEvents = 0;
348
+ if (await fs.pathExists(eventsPath)) {
349
+ const content = await fs.readFile(eventsPath, 'utf8');
350
+ recentEvents = content.split('\n').filter((l) => l.trim()).length;
351
+ }
352
+ sendJson(res, 200, {
353
+ context,
354
+ cost,
355
+ cache,
356
+ checkpointSummary,
357
+ checkpointCount: metrics.length,
358
+ memoryCount: Object.keys(memory.memories).length,
359
+ memorySources: memory.sources,
360
+ patternCount: indexStats?.stats?.totalPatterns ?? indexStats?.patterns?.length ?? 0,
361
+ patternFiles: indexStats?.stats?.totalFiles ?? 0,
362
+ eventCount: recentEvents,
363
+ projectPath: cwd,
364
+ brainDb: path.join(os.homedir(), '.rigour/rigour.db'),
365
+ });
365
366
  }
366
- else if (url.pathname === '/api/context-stats') {
367
- try {
368
- const { getTaskContextStats } = await import('@rigour-labs/core');
369
- const taskId = url.searchParams.get('taskId') || undefined;
370
- const stats = await getTaskContextStats(taskId, cwd);
371
- res.writeHead(200, { 'Content-Type': 'application/json' });
372
- res.end(JSON.stringify(stats));
373
- }
374
- catch (e) {
375
- res.writeHead(500);
376
- res.end(JSON.stringify({ error: e.message }));
377
- }
367
+ catch (e) {
368
+ sendJson(res, 500, { error: e.message });
378
369
  }
379
- else if (url.pathname === '/api/task-cost') {
380
- try {
381
- const { getTaskCostStats } = await import('@rigour-labs/core');
382
- const taskId = url.searchParams.get('taskId') || undefined;
383
- const costStats = await getTaskCostStats(taskId, cwd);
384
- res.writeHead(200, { 'Content-Type': 'application/json' });
385
- res.end(JSON.stringify(costStats));
370
+ return true;
371
+ }
372
+ if (url.pathname === '/api/report-stats') {
373
+ try {
374
+ const reportPath = path.join(cwd, 'rigour-report.json');
375
+ if (await fs.pathExists(reportPath)) {
376
+ const report = await fs.readJson(reportPath);
377
+ sendJson(res, 200, report.stats || {});
386
378
  }
387
- catch (e) {
388
- res.writeHead(500);
389
- res.end(JSON.stringify({ error: e.message }));
379
+ else {
380
+ sendJson(res, 200, {});
390
381
  }
391
382
  }
392
- else if (url.pathname === '/api/cache-stats') {
393
- try {
394
- const { getCacheStats } = await import('@rigour-labs/core');
395
- const stats = await getCacheStats(cwd);
396
- res.writeHead(200, { 'Content-Type': 'application/json' });
397
- res.end(JSON.stringify(stats));
398
- }
399
- catch (e) {
400
- res.writeHead(500);
401
- res.end(JSON.stringify({ error: e.message }));
402
- }
383
+ catch (e) {
384
+ sendJson(res, 500, { error: e.message });
403
385
  }
404
- else if (url.pathname === '/api/context-explain') {
405
- try {
406
- const { explainContext } = await import('@rigour-labs/core');
407
- const target = url.searchParams.get('target') || 'all';
408
- const taskId = url.searchParams.get('taskId') || undefined;
409
- const explanation = await explainContext(target, taskId, cwd);
410
- res.writeHead(200, { 'Content-Type': 'application/json' });
411
- res.end(JSON.stringify(explanation));
386
+ return true;
387
+ }
388
+ if (url.pathname === '/api/deep-findings') {
389
+ try {
390
+ const reportPath = path.join(cwd, 'rigour-report.json');
391
+ if (await fs.pathExists(reportPath)) {
392
+ const report = await fs.readJson(reportPath);
393
+ const findings = (report.failures || []).filter((f) => f.provenance === 'deep-analysis' || f.source === 'llm' || f.source === 'hybrid');
394
+ sendJson(res, 200, findings);
412
395
  }
413
- catch (e) {
414
- res.writeHead(500);
415
- res.end(JSON.stringify({ error: e.message }));
396
+ else {
397
+ sendJson(res, 200, []);
416
398
  }
417
399
  }
418
- else if (url.pathname === '/api/context-scope') {
419
- try {
420
- const { getContextScopeSummary } = await import('@rigour-labs/core');
421
- const summary = await getContextScopeSummary(cwd);
422
- res.writeHead(200, { 'Content-Type': 'application/json' });
423
- res.end(JSON.stringify(summary));
424
- }
425
- catch (e) {
426
- res.writeHead(500);
427
- res.end(JSON.stringify({ error: e.message }));
428
- }
400
+ catch (e) {
401
+ sendJson(res, 500, { error: e.message });
429
402
  }
430
- else if (url.pathname === '/api/checkpoint-metrics') {
431
- try {
432
- const { getCheckpointSummary } = await import('@rigour-labs/core');
433
- const taskId = url.searchParams.get('taskId') || undefined;
434
- const summary = await getCheckpointSummary(taskId, cwd);
435
- res.writeHead(200, { 'Content-Type': 'application/json' });
436
- res.end(JSON.stringify(summary));
437
- }
438
- catch (e) {
439
- res.writeHead(500);
440
- res.end(JSON.stringify({ error: e.message }));
441
- }
403
+ return true;
404
+ }
405
+ if (url.pathname === '/api/drift') {
406
+ try {
407
+ const { generateTemporalDriftReport } = await import('@rigour-labs/core');
408
+ const report = generateTemporalDriftReport(cwd);
409
+ sendJson(res, 200, report || { totalScans: 0 });
442
410
  }
443
- else if (url.pathname === '/api/cursor-api-key/status') {
444
- try {
445
- const { getCursorApiKey, countCursorAdminImportedEvents } = await import('@rigour-labs/core');
446
- const configured = Boolean(getCursorApiKey());
447
- const importedCount = await countCursorAdminImportedEvents(cwd);
448
- res.writeHead(200, { 'Content-Type': 'application/json' });
449
- res.end(JSON.stringify({ configured, importedCount }));
450
- }
451
- catch (e) {
452
- res.writeHead(500);
453
- res.end(JSON.stringify({ error: e.message }));
454
- }
411
+ catch {
412
+ sendJson(res, 200, { totalScans: 0 });
413
+ }
414
+ return true;
415
+ }
416
+ if (url.pathname === '/api/context-stats') {
417
+ try {
418
+ const { getTaskContextStats } = await import('@rigour-labs/core');
419
+ const taskId = url.searchParams.get('taskId') || undefined;
420
+ sendJson(res, 200, await getTaskContextStats(taskId, cwd));
421
+ }
422
+ catch (e) {
423
+ sendJson(res, 500, { error: e.message });
424
+ }
425
+ return true;
426
+ }
427
+ if (url.pathname === '/api/task-cost') {
428
+ try {
429
+ const { getTaskCostStats } = await import('@rigour-labs/core');
430
+ const taskId = url.searchParams.get('taskId') || undefined;
431
+ sendJson(res, 200, await getTaskCostStats(taskId, cwd));
432
+ }
433
+ catch (e) {
434
+ sendJson(res, 500, { error: e.message });
435
+ }
436
+ return true;
437
+ }
438
+ if (url.pathname === '/api/cache-stats') {
439
+ try {
440
+ const { getCacheStats } = await import('@rigour-labs/core');
441
+ sendJson(res, 200, await getCacheStats(cwd));
442
+ }
443
+ catch (e) {
444
+ sendJson(res, 500, { error: e.message });
445
+ }
446
+ return true;
447
+ }
448
+ if (url.pathname === '/api/context-explain') {
449
+ try {
450
+ const { explainContext } = await import('@rigour-labs/core');
451
+ const target = url.searchParams.get('target') || 'all';
452
+ const taskId = url.searchParams.get('taskId') || undefined;
453
+ sendJson(res, 200, await explainContext(target, taskId, cwd));
454
+ }
455
+ catch (e) {
456
+ sendJson(res, 500, { error: e.message });
457
+ }
458
+ return true;
459
+ }
460
+ if (url.pathname === '/api/context-scope') {
461
+ try {
462
+ const { getContextScopeSummary } = await import('@rigour-labs/core');
463
+ sendJson(res, 200, await getContextScopeSummary(cwd));
464
+ }
465
+ catch (e) {
466
+ sendJson(res, 500, { error: e.message });
455
467
  }
456
- else if (url.pathname === '/api/cursor-sync' && req.method === 'POST') {
468
+ return true;
469
+ }
470
+ if (url.pathname === '/api/checkpoint-metrics') {
471
+ try {
472
+ const { getCheckpointSummary } = await import('@rigour-labs/core');
473
+ const taskId = url.searchParams.get('taskId') || undefined;
474
+ sendJson(res, 200, await getCheckpointSummary(taskId, cwd));
475
+ }
476
+ catch (e) {
477
+ sendJson(res, 500, { error: e.message });
478
+ }
479
+ return true;
480
+ }
481
+ if (url.pathname === '/api/cursor-api-key/status') {
482
+ try {
483
+ const { getCursorApiKey, getCursorApiKeyHint, countCursorAdminImportedEvents, } = await import('@rigour-labs/core');
484
+ const key = getCursorApiKey();
485
+ const fromEnv = Boolean(process.env.RIGOUR_CURSOR_API_KEY?.trim() || process.env.CURSOR_ADMIN_API_KEY?.trim());
486
+ sendJson(res, 200, {
487
+ configured: Boolean(key),
488
+ hint: getCursorApiKeyHint(),
489
+ source: key ? (fromEnv ? 'env' : 'file') : 'none',
490
+ importedCount: await countCursorAdminImportedEvents(cwd),
491
+ });
492
+ }
493
+ catch (e) {
494
+ sendJson(res, 500, { error: e.message });
495
+ }
496
+ return true;
497
+ }
498
+ if (url.pathname === '/api/cursor-sync' && req.method === 'POST') {
499
+ try {
500
+ const { syncCursorUsageFromAdminApi } = await import('@rigour-labs/core');
501
+ const result = await syncCursorUsageFromAdminApi(cwd);
502
+ sendJson(res, 200, { success: true, ...result });
503
+ }
504
+ catch (e) {
505
+ sendJson(res, 502, { success: false, error: e.message || 'Cursor usage sync failed' });
506
+ }
507
+ return true;
508
+ }
509
+ if (url.pathname === '/api/cursor-api-key' && req.method === 'DELETE') {
510
+ try {
511
+ const { removeCursorApiKey, getCursorApiKey, getCursorApiKeyHint, } = await import('@rigour-labs/core');
512
+ removeCursorApiKey();
513
+ const key = getCursorApiKey();
514
+ const fromEnv = Boolean(process.env.RIGOUR_CURSOR_API_KEY?.trim() || process.env.CURSOR_ADMIN_API_KEY?.trim());
515
+ sendJson(res, 200, {
516
+ success: true,
517
+ configured: Boolean(key),
518
+ hint: getCursorApiKeyHint(),
519
+ source: key ? (fromEnv ? 'env' : 'file') : 'none',
520
+ });
521
+ }
522
+ catch (e) {
523
+ sendJson(res, 500, { error: e.message });
524
+ }
525
+ return true;
526
+ }
527
+ if (url.pathname === '/api/cursor-api-key' && req.method === 'POST') {
528
+ let body = '';
529
+ req.on('data', (chunk) => (body += chunk));
530
+ req.on('end', async () => {
457
531
  try {
458
- const { syncCursorUsageFromAdminApi } = await import('@rigour-labs/core');
459
- const result = await syncCursorUsageFromAdminApi(cwd);
460
- res.writeHead(200, { 'Content-Type': 'application/json' });
461
- res.end(JSON.stringify({ success: true, ...result }));
532
+ const parsed = JSON.parse(body || '{}');
533
+ const apiKey = typeof parsed.apiKey === 'string' ? parsed.apiKey.trim() : '';
534
+ if (!apiKey || apiKey.length > 512) {
535
+ sendJson(res, 400, { error: 'Missing or invalid apiKey' });
536
+ return;
537
+ }
538
+ const { updateCursorApiKey, syncCursorUsageFromAdminApi, getCursorApiKeyHint, } = await import('@rigour-labs/core');
539
+ updateCursorApiKey(apiKey);
540
+ let syncResult = { importedCount: 0, totalEvents: 0 };
541
+ let syncError;
542
+ try {
543
+ syncResult = await syncCursorUsageFromAdminApi(cwd);
544
+ }
545
+ catch (syncErr) {
546
+ syncError = syncErr?.message || 'Initial Cursor sync failed';
547
+ }
548
+ sendJson(res, 200, {
549
+ success: true,
550
+ configured: true,
551
+ hint: getCursorApiKeyHint(),
552
+ source: 'file',
553
+ importedCount: syncResult.importedCount,
554
+ totalEvents: syncResult.totalEvents,
555
+ syncError,
556
+ });
462
557
  }
463
558
  catch (e) {
464
- res.writeHead(502, { 'Content-Type': 'application/json' });
465
- res.end(JSON.stringify({
466
- success: false,
467
- error: e.message || 'Cursor usage sync failed',
468
- }));
559
+ sendJson(res, 500, { error: e.message });
469
560
  }
470
- }
471
- else if (url.pathname === '/api/cursor-api-key' && req.method === 'POST') {
472
- let body = '';
473
- req.on('data', chunk => body += chunk);
474
- req.on('end', async () => {
475
- try {
476
- const { apiKey } = JSON.parse(body || '{}');
477
- if (!apiKey || typeof apiKey !== 'string' || !apiKey.trim()) {
478
- res.writeHead(400, { 'Content-Type': 'application/json' });
479
- res.end(JSON.stringify({ error: 'Missing apiKey' }));
480
- return;
481
- }
482
- const { updateCursorApiKey, syncCursorUsageFromAdminApi } = await import('@rigour-labs/core');
483
- updateCursorApiKey(apiKey.trim());
484
- let syncResult = { importedCount: 0, totalEvents: 0 };
485
- let syncError;
561
+ });
562
+ return true;
563
+ }
564
+ if (url.pathname === '/api/handoffs') {
565
+ try {
566
+ const handoffPath = path.join(cwd, '.rigour/handoffs.jsonl');
567
+ const handoffs = [];
568
+ if (await fs.pathExists(handoffPath)) {
569
+ const content = await fs.readFile(handoffPath, 'utf8');
570
+ for (const line of content.split('\n').filter((l) => l.trim())) {
486
571
  try {
487
- syncResult = await syncCursorUsageFromAdminApi(cwd);
572
+ handoffs.push(JSON.parse(line));
488
573
  }
489
- catch (syncErr) {
490
- syncError = syncErr?.message || 'Initial Cursor sync failed';
574
+ catch {
575
+ // skip bad lines
491
576
  }
492
- res.writeHead(200, { 'Content-Type': 'application/json' });
493
- res.end(JSON.stringify({
494
- success: true,
495
- configured: true,
496
- importedCount: syncResult.importedCount,
497
- totalEvents: syncResult.totalEvents,
498
- syncError,
499
- }));
500
577
  }
501
- catch (e) {
502
- res.writeHead(500);
503
- res.end(JSON.stringify({ error: e.message }));
578
+ }
579
+ if (await fs.pathExists(eventsPath)) {
580
+ const content = await fs.readFile(eventsPath, 'utf8');
581
+ for (const line of content.split('\n').filter((l) => l.trim()).slice(-500)) {
582
+ try {
583
+ const ev = JSON.parse(line);
584
+ if (ev.type === 'handoff_accepted' && ev.handoffId) {
585
+ const target = handoffs.find((h) => h.handoffId === ev.handoffId);
586
+ if (target) {
587
+ target.status = 'accepted';
588
+ target.acceptedAt = ev.timestamp || ev.ts;
589
+ }
590
+ }
591
+ }
592
+ catch {
593
+ // skip
594
+ }
504
595
  }
596
+ }
597
+ sendJson(res, 200, {
598
+ handoffs: handoffs.slice(-100).reverse(),
599
+ count: handoffs.length,
505
600
  });
506
601
  }
507
- else if (url.pathname === '/api/import-cursor-usage' && req.method === 'POST') {
508
- let body = '';
509
- req.on('data', chunk => body += chunk);
510
- req.on('end', async () => {
511
- try {
512
- const { importCursorUsageCsv, importCursorUsageJson } = await import('@rigour-labs/core');
513
- let importedCount = 0;
514
- if (body.trim().startsWith('{') || body.trim().startsWith('[')) {
515
- importedCount = await importCursorUsageJson(JSON.parse(body), cwd);
602
+ catch (e) {
603
+ sendJson(res, 500, { error: e.message });
604
+ }
605
+ return true;
606
+ }
607
+ if (url.pathname === '/api/enforcement') {
608
+ try {
609
+ const { getCheckpointMetrics, } = await import('@rigour-labs/core');
610
+ const metrics = await getCheckpointMetrics(undefined, cwd);
611
+ const mapped = mapCheckpointMetrics(metrics);
612
+ const agentsSession = await synthesizeAgents(cwd, mapped);
613
+ const memory = await mergeMemoryStores(cwd);
614
+ let events = [];
615
+ if (await fs.pathExists(eventsPath)) {
616
+ const content = await fs.readFile(eventsPath, 'utf8');
617
+ events = content
618
+ .split('\n')
619
+ .filter((l) => l.trim())
620
+ .slice(-400)
621
+ .map((l) => {
622
+ try {
623
+ return JSON.parse(l);
516
624
  }
517
- else {
518
- importedCount = await importCursorUsageCsv(body, cwd);
625
+ catch {
626
+ return null;
519
627
  }
520
- res.writeHead(200, { 'Content-Type': 'application/json' });
521
- res.end(JSON.stringify({ success: true, importedCount }));
522
- }
523
- catch (e) {
524
- res.writeHead(500);
525
- res.end(JSON.stringify({ error: e.message }));
526
- }
628
+ })
629
+ .filter(Boolean);
630
+ }
631
+ const handoffPath = path.join(cwd, '.rigour/handoffs.jsonl');
632
+ let handoffCount = 0;
633
+ let acceptedHandoffs = 0;
634
+ if (await fs.pathExists(handoffPath)) {
635
+ const content = await fs.readFile(handoffPath, 'utf8');
636
+ const lines = content.split('\n').filter((l) => l.trim());
637
+ handoffCount = lines.length;
638
+ acceptedHandoffs = events.filter((e) => e.type === 'handoff_accepted').length;
639
+ }
640
+ const typeCount = (types) => events.filter((e) => types.includes(e.type) || types.includes(e.tool)).length;
641
+ const registerCount = Math.max(agentsSession.agents?.length || 0, typeCount(['agent_registered', 'rigour_agent_register']));
642
+ const scopeCount = typeCount(['context_scoped', 'rigour_context_scope', 'scope_resolved']);
643
+ const gateCount = typeCount([
644
+ 'gate_failed',
645
+ 'gate_passed',
646
+ 'hook_blocked',
647
+ 'interception_requested',
648
+ 'rigour_check',
649
+ ]);
650
+ const gateBlocked = typeCount(['gate_failed', 'hook_blocked', 'interception_requested']);
651
+ const checkpointCount = Math.max(mapped.length, typeCount(['checkpoint_recorded', 'rigour_checkpoint']));
652
+ const memoryCount = Object.keys(memory.memories || {}).length;
653
+ const stage = (id, label, count, status, detail) => ({ id, label, count, status, detail });
654
+ const stages = [
655
+ stage('register', 'Register', registerCount, registerCount > 0 ? 'pass' : 'idle', registerCount ? `${registerCount} agent scope(s)` : 'Awaiting rigour_agent_register'),
656
+ stage('scope', 'Context scope', scopeCount, scopeCount > 0 ? 'pass' : registerCount > 0 ? 'warn' : 'idle', scopeCount ? `${scopeCount} scope event(s)` : 'Call rigour_context_scope / recall'),
657
+ stage('gates', 'Gates', gateCount, gateBlocked > 0 ? 'block' : gateCount > 0 ? 'pass' : 'idle', gateBlocked > 0
658
+ ? `${gateBlocked} block/intercept event(s)`
659
+ : gateCount
660
+ ? 'Gates exercised'
661
+ : 'Hooks & quality gates idle'),
662
+ stage('checkpoint', 'Checkpoint', checkpointCount, checkpointCount > 0 ? 'pass' : 'idle', checkpointCount ? `${checkpointCount} checkpoint(s)` : 'Awaiting rigour_checkpoint'),
663
+ stage('handoff', 'Handoff', handoffCount, handoffCount > 0 ? (acceptedHandoffs > 0 ? 'pass' : 'warn') : 'idle', handoffCount
664
+ ? `${acceptedHandoffs}/${handoffCount} accepted`
665
+ : 'Awaiting rigour_handoff'),
666
+ stage('memory', 'Memory', memoryCount, memoryCount > 0 ? 'pass' : 'idle', memoryCount ? `${memoryCount} stable memor(ies)` : 'Awaiting rigour_remember'),
667
+ ];
668
+ const timeline = events
669
+ .filter((e) => [
670
+ 'agent_registered',
671
+ 'checkpoint_recorded',
672
+ 'handoff_initiated',
673
+ 'handoff_accepted',
674
+ 'gate_failed',
675
+ 'gate_passed',
676
+ 'hook_blocked',
677
+ 'interception_requested',
678
+ 'memory_stored',
679
+ ].includes(e.type))
680
+ .slice(-40)
681
+ .reverse()
682
+ .map((e) => ({
683
+ type: e.type,
684
+ timestamp: e.timestamp || e.ts || null,
685
+ agentId: e.agentId || e.fromAgentId || null,
686
+ summary: e.summary || e.taskDescription || e.tool || e.type,
687
+ }));
688
+ sendJson(res, 200, {
689
+ stages,
690
+ timeline,
691
+ derived: Boolean(agentsSession.derived),
692
+ agentCount: agentsSession.agents?.length || 0,
693
+ sessionStatus: agentsSession.status,
527
694
  });
528
695
  }
529
- else if (url.pathname === '/api/arbitrate' && req.method === 'POST') {
530
- let body = '';
531
- req.on('data', chunk => body += chunk);
532
- req.on('end', async () => {
533
- try {
534
- const decision = JSON.parse(body);
535
- const logEntry = JSON.stringify({
536
- id: randomUUID(),
537
- timestamp: new Date().toISOString(),
538
- tool: 'human_arbitration',
539
- requestId: decision.requestId,
540
- decision: decision.decision,
541
- status: decision.decision === 'approve' ? 'success' : 'error',
542
- arbitrated: true
543
- }) + "\n";
544
- await fs.appendFile(eventsPath, logEntry);
545
- res.writeHead(200);
546
- res.end(JSON.stringify({ success: true }));
696
+ catch (e) {
697
+ sendJson(res, 500, { error: e.message });
698
+ }
699
+ return true;
700
+ }
701
+ if (url.pathname === '/api/import-cursor-usage' && req.method === 'POST') {
702
+ let body = '';
703
+ req.on('data', (chunk) => (body += chunk));
704
+ req.on('end', async () => {
705
+ try {
706
+ const { importCursorUsageCsv, importCursorUsageJson } = await import('@rigour-labs/core');
707
+ let importedCount = 0;
708
+ if (body.trim().startsWith('{') || body.trim().startsWith('[')) {
709
+ importedCount = await importCursorUsageJson(JSON.parse(body), cwd);
547
710
  }
548
- catch (e) {
549
- res.writeHead(500);
550
- res.end(e.message);
711
+ else {
712
+ importedCount = await importCursorUsageCsv(body, cwd);
551
713
  }
552
- });
553
- }
554
- else {
555
- res.writeHead(404);
556
- res.end();
557
- }
558
- });
559
- apiServer.listen(apiPort, () => {
560
- console.log(chalk.gray(`API Streamer active on port ${apiPort}`));
561
- });
714
+ sendJson(res, 200, { success: true, importedCount });
715
+ }
716
+ catch (e) {
717
+ sendJson(res, 500, { error: e.message });
718
+ }
719
+ });
720
+ return true;
721
+ }
722
+ if (url.pathname === '/api/arbitrate' && req.method === 'POST') {
723
+ let body = '';
724
+ req.on('data', (chunk) => (body += chunk));
725
+ req.on('end', async () => {
726
+ try {
727
+ const decision = JSON.parse(body);
728
+ const logEntry = JSON.stringify({
729
+ id: randomUUID(),
730
+ timestamp: new Date().toISOString(),
731
+ tool: 'human_arbitration',
732
+ requestId: decision.requestId,
733
+ decision: decision.decision,
734
+ status: decision.decision === 'approve' ? 'success' : 'error',
735
+ arbitrated: true,
736
+ }) + '\n';
737
+ await fs.appendFile(eventsPath, logEntry);
738
+ sendJson(res, 200, { success: true });
739
+ }
740
+ catch (e) {
741
+ res.writeHead(500);
742
+ res.end(e.message);
743
+ }
744
+ });
745
+ return true;
746
+ }
747
+ res.writeHead(404);
748
+ res.end();
749
+ return true;
750
+ }
751
+ async function serveStaticFile(studioDist, pathname, res) {
752
+ let filePath = path.join(studioDist, pathname === '/' ? 'index.html' : pathname);
753
+ if (!(await fs.pathExists(filePath)) || (await fs.stat(filePath)).isDirectory()) {
754
+ filePath = path.join(studioDist, 'index.html');
755
+ }
756
+ const content = await fs.readFile(filePath);
757
+ const ext = path.extname(filePath);
758
+ const contentTypes = {
759
+ '.html': 'text/html',
760
+ '.js': 'application/javascript',
761
+ '.css': 'text/css',
762
+ '.json': 'application/json',
763
+ '.png': 'image/png',
764
+ '.jpg': 'image/jpeg',
765
+ '.svg': 'image/svg+xml',
766
+ '.ico': 'image/x-icon',
767
+ };
768
+ res.writeHead(200, { 'Content-Type': contentTypes[ext] || 'application/octet-stream' });
769
+ res.end(content);
770
+ }
771
+ function announce(url) {
562
772
  setTimeout(async () => {
563
- const url = `http://localhost:${studioPort}`;
564
773
  console.log(chalk.green(`\n✅ Rigour Studio is live at ${chalk.bold(url)}`));
565
774
  try {
566
775
  await execa('open', [url]);
567
776
  }
568
- catch { }
569
- }, 1500);
570
- if (studioProcess) {
571
- await studioProcess;
572
- }
777
+ catch {
778
+ // non-mac or open unavailable
779
+ }
780
+ }, 800);
573
781
  }
782
+ export const studioCommand = new Command('studio')
783
+ .description('Launch Rigour Studio (Local-First Governance UI)')
784
+ .option('-p, --port <number>', 'Port to run the studio on', '3000')
785
+ .option('--dev', 'Opt-in: run Vite against monorepo studio source (developers only)', false)
786
+ .action(async (options) => {
787
+ const cwd = process.cwd();
788
+ const studioPort = String(options.port);
789
+ const apiPort = parseInt(studioPort, 10) + 1;
790
+ const eventsPath = path.join(cwd, '.rigour/events.jsonl');
791
+ const __dirname = path.dirname(new URL(import.meta.url).pathname);
792
+ const candidates = [
793
+ path.join(__dirname, '../studio-dist'),
794
+ path.join(__dirname, '../../studio-dist'),
795
+ path.join(__dirname, '../../../studio-dist'),
796
+ ];
797
+ const localStudioDist = candidates.find((p) => fs.pathExistsSync(p)) ?? candidates[0];
798
+ const workspaceRoot = path.join(__dirname, '../../../../');
799
+ const allowedOrigins = new Set([
800
+ `http://localhost:${studioPort}`,
801
+ `http://127.0.0.1:${studioPort}`,
802
+ ]);
803
+ const ctx = { cwd, eventsPath, allowedOrigins };
804
+ console.log(chalk.bold.cyan('\n🛡️ Launching Rigour Studio...'));
805
+ console.log(chalk.gray(`Project Root: ${cwd}`));
806
+ const configPath = path.join(cwd, 'rigour.yml');
807
+ if (!(await fs.pathExists(configPath))) {
808
+ console.log(chalk.yellow('\n⚠️ Warning: rigour.yml not found.'));
809
+ console.log(chalk.dim('The Studio will be empty until you initialize the project.'));
810
+ console.log(chalk.cyan('Suggest: ') + chalk.bold('npx @rigour-labs/cli init') + '\n');
811
+ }
812
+ console.log(chalk.gray(`Shadowing interactions in ${eventsPath}\n`));
813
+ const isMonorepo = await fs.pathExists(path.join(workspaceRoot, 'packages/rigour-studio'));
814
+ if (isMonorepo && options.dev) {
815
+ console.log(chalk.yellow('Monorepo detected: Launching Studio in Development Mode...'));
816
+ console.log(chalk.gray(`Vite :${studioPort} → API :${apiPort} (same-origin via proxy)`));
817
+ try {
818
+ const studioProcess = execa('pnpm', ['--filter', '@rigour-labs/studio', 'dev', '--port', studioPort], {
819
+ stdio: 'inherit',
820
+ cwd: workspaceRoot,
821
+ env: {
822
+ ...process.env,
823
+ RIGOUR_API_PORT: String(apiPort),
824
+ },
825
+ });
826
+ const apiServer = http.createServer(async (req, res) => {
827
+ const url = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`);
828
+ const handled = await handleApiRequest(req, res, url, ctx);
829
+ if (!handled) {
830
+ res.writeHead(404);
831
+ res.end();
832
+ }
833
+ });
834
+ apiServer.listen(apiPort, '127.0.0.1', () => {
835
+ console.log(chalk.gray(`API Streamer active on 127.0.0.1:${apiPort}`));
836
+ });
837
+ announce(`http://127.0.0.1:${studioPort}`);
838
+ await studioProcess;
839
+ return;
840
+ }
841
+ catch {
842
+ console.log(chalk.dim('Development mode failed, falling back to standalone...'));
843
+ }
844
+ }
845
+ console.log(chalk.green('Launching Studio in Standalone Mode (same-origin API)...'));
846
+ if (!(await fs.pathExists(localStudioDist))) {
847
+ console.error(chalk.red(`\n❌ Error: Studio UI artifacts not found at ${localStudioDist}`));
848
+ console.log(chalk.yellow('If you are a developer, run "pnpm build" in the monorepo root first.\n'));
849
+ process.exit(1);
850
+ }
851
+ // Critical UX fix: serve UI + /api on ONE port so fetch('/api/...') works.
852
+ // Bind loopback only — Studio can accept optional vendor secrets locally.
853
+ const server = http.createServer(async (req, res) => {
854
+ const url = new URL(req.url || '', `http://${req.headers.host || '127.0.0.1'}`);
855
+ try {
856
+ if (await handleApiRequest(req, res, url, ctx))
857
+ return;
858
+ await serveStaticFile(localStudioDist, url.pathname, res);
859
+ }
860
+ catch (e) {
861
+ res.writeHead(500);
862
+ res.end(e.message || 'Internal error');
863
+ }
864
+ });
865
+ server.listen(parseInt(studioPort, 10), '127.0.0.1', () => {
866
+ console.log(chalk.gray(`Studio + API on 127.0.0.1:${studioPort}`));
867
+ announce(`http://127.0.0.1:${studioPort}`);
868
+ });
869
+ });