@rigour-labs/cli 5.3.3 → 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,543 +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`);
67
133
  }
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' });
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
+ }
148
+ }
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');
86
- }
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;
173
+ res.end('Not found');
110
174
  }
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);
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);
304
+ }
305
+ catch (e) {
306
+ sendJson(res, 500, { error: e.message });
307
+ }
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));
297
328
  }
298
329
  }
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
- }
330
+ catch (e) {
331
+ sendJson(res, 500, { error: e.message });
332
+ }
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
+ });
366
+ }
367
+ catch (e) {
368
+ sendJson(res, 500, { error: e.message });
369
+ }
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 || {});
310
378
  }
311
- catch (e) {
312
- res.writeHead(500);
313
- res.end(e.message);
379
+ else {
380
+ sendJson(res, 200, {});
314
381
  }
315
382
  }
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
- }
383
+ catch (e) {
384
+ sendJson(res, 500, { error: e.message });
385
+ }
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);
328
395
  }
329
- catch (e) {
330
- res.writeHead(500);
331
- res.end(e.message);
396
+ else {
397
+ sendJson(res, 200, []);
332
398
  }
333
399
  }
334
- else if (url.pathname === '/api/deep-findings') {
400
+ catch (e) {
401
+ sendJson(res, 500, { error: e.message });
402
+ }
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 });
410
+ }
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 });
467
+ }
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 () => {
335
531
  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));
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;
342
537
  }
343
- else {
344
- res.writeHead(200, { 'Content-Type': 'application/json' });
345
- res.end(JSON.stringify([]));
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);
346
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
+ });
347
557
  }
348
558
  catch (e) {
349
- res.writeHead(500);
350
- res.end(e.message);
351
- }
352
- }
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 }));
559
+ sendJson(res, 500, { error: e.message });
364
560
  }
365
- }
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));
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())) {
571
+ try {
572
+ handoffs.push(JSON.parse(line));
573
+ }
574
+ catch {
575
+ // skip bad lines
576
+ }
577
+ }
373
578
  }
374
- catch (e) {
375
- res.writeHead(500);
376
- res.end(JSON.stringify({ error: e.message }));
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
+ }
595
+ }
377
596
  }
597
+ sendJson(res, 200, {
598
+ handoffs: handoffs.slice(-100).reverse(),
599
+ count: handoffs.length,
600
+ });
378
601
  }
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));
386
- }
387
- catch (e) {
388
- res.writeHead(500);
389
- res.end(JSON.stringify({ error: e.message }));
390
- }
602
+ catch (e) {
603
+ sendJson(res, 500, { error: e.message });
391
604
  }
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
- }
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);
624
+ }
625
+ catch {
626
+ return null;
627
+ }
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,
694
+ });
403
695
  }
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));
412
- }
413
- catch (e) {
414
- res.writeHead(500);
415
- res.end(JSON.stringify({ error: e.message }));
416
- }
696
+ catch (e) {
697
+ sendJson(res, 500, { error: e.message });
417
698
  }
418
- else if (url.pathname === '/api/context-scope') {
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 () => {
419
705
  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));
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);
710
+ }
711
+ else {
712
+ importedCount = await importCursorUsageCsv(body, cwd);
713
+ }
714
+ sendJson(res, 200, { success: true, importedCount });
424
715
  }
425
716
  catch (e) {
426
- res.writeHead(500);
427
- res.end(JSON.stringify({ error: e.message }));
717
+ sendJson(res, 500, { error: e.message });
428
718
  }
429
- }
430
- else if (url.pathname === '/api/checkpoint-metrics') {
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 () => {
431
726
  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));
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 });
437
739
  }
438
740
  catch (e) {
439
741
  res.writeHead(500);
440
- res.end(JSON.stringify({ error: e.message }));
742
+ res.end(e.message);
441
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) {
772
+ setTimeout(async () => {
773
+ console.log(chalk.green(`\n✅ Rigour Studio is live at ${chalk.bold(url)}`));
774
+ try {
775
+ await execa('open', [url]);
442
776
  }
443
- else if (url.pathname === '/api/cursor-api-key/status') {
444
- try {
445
- const { getCursorApiKey } = await import('@rigour-labs/core');
446
- const configured = Boolean(getCursorApiKey());
447
- res.writeHead(200, { 'Content-Type': 'application/json' });
448
- res.end(JSON.stringify({ configured }));
449
- }
450
- catch (e) {
451
- res.writeHead(500);
452
- res.end(JSON.stringify({ error: e.message }));
453
- }
777
+ catch {
778
+ // non-mac or open unavailable
454
779
  }
455
- else if (url.pathname === '/api/cursor-api-key' && req.method === 'POST') {
456
- let body = '';
457
- req.on('data', chunk => body += chunk);
458
- req.on('end', async () => {
459
- try {
460
- const { apiKey } = JSON.parse(body || '{}');
461
- if (!apiKey || typeof apiKey !== 'string' || !apiKey.trim()) {
462
- res.writeHead(400, { 'Content-Type': 'application/json' });
463
- res.end(JSON.stringify({ error: 'Missing apiKey' }));
464
- return;
465
- }
466
- const { updateCursorApiKey } = await import('@rigour-labs/core');
467
- updateCursorApiKey(apiKey.trim());
468
- res.writeHead(200, { 'Content-Type': 'application/json' });
469
- res.end(JSON.stringify({ success: true, configured: true }));
470
- }
471
- catch (e) {
472
- res.writeHead(500);
473
- res.end(JSON.stringify({ error: e.message }));
474
- }
780
+ }, 800);
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
+ },
475
825
  });
476
- }
477
- else if (url.pathname === '/api/import-cursor-usage' && req.method === 'POST') {
478
- let body = '';
479
- req.on('data', chunk => body += chunk);
480
- req.on('end', async () => {
481
- try {
482
- const { importCursorUsageCsv, importCursorUsageJson } = await import('@rigour-labs/core');
483
- let importedCount = 0;
484
- if (body.trim().startsWith('{') || body.trim().startsWith('[')) {
485
- importedCount = await importCursorUsageJson(JSON.parse(body), cwd);
486
- }
487
- else {
488
- importedCount = await importCursorUsageCsv(body, cwd);
489
- }
490
- res.writeHead(200, { 'Content-Type': 'application/json' });
491
- res.end(JSON.stringify({ success: true, importedCount }));
492
- }
493
- catch (e) {
494
- res.writeHead(500);
495
- res.end(JSON.stringify({ error: e.message }));
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();
496
832
  }
497
833
  });
498
- }
499
- else if (url.pathname === '/api/arbitrate' && req.method === 'POST') {
500
- let body = '';
501
- req.on('data', chunk => body += chunk);
502
- req.on('end', async () => {
503
- try {
504
- const decision = JSON.parse(body);
505
- const logEntry = JSON.stringify({
506
- id: randomUUID(),
507
- timestamp: new Date().toISOString(),
508
- tool: 'human_arbitration',
509
- requestId: decision.requestId,
510
- decision: decision.decision,
511
- status: decision.decision === 'approve' ? 'success' : 'error',
512
- arbitrated: true
513
- }) + "\n";
514
- await fs.appendFile(eventsPath, logEntry);
515
- res.writeHead(200);
516
- res.end(JSON.stringify({ success: true }));
517
- }
518
- catch (e) {
519
- res.writeHead(500);
520
- res.end(e.message);
521
- }
834
+ apiServer.listen(apiPort, '127.0.0.1', () => {
835
+ console.log(chalk.gray(`API Streamer active on 127.0.0.1:${apiPort}`));
522
836
  });
837
+ announce(`http://127.0.0.1:${studioPort}`);
838
+ await studioProcess;
839
+ return;
523
840
  }
524
- else {
525
- res.writeHead(404);
526
- res.end();
841
+ catch {
842
+ console.log(chalk.dim('Development mode failed, falling back to standalone...'));
527
843
  }
528
- });
529
- apiServer.listen(apiPort, () => {
530
- console.log(chalk.gray(`API Streamer active on port ${apiPort}`));
531
- });
532
- setTimeout(async () => {
533
- const url = `http://localhost:${studioPort}`;
534
- console.log(chalk.green(`\n✅ Rigour Studio is live at ${chalk.bold(url)}`));
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'}`);
535
855
  try {
536
- await execa('open', [url]);
856
+ if (await handleApiRequest(req, res, url, ctx))
857
+ return;
858
+ await serveStaticFile(localStudioDist, url.pathname, res);
537
859
  }
538
- catch { }
539
- }, 1500);
540
- if (studioProcess) {
541
- await studioProcess;
542
- }
543
- }
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
+ });