@hanzlaa/rcode 3.4.33 → 3.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.
Files changed (80) hide show
  1. package/AGENTS.md +6 -6
  2. package/CONTRIBUTING.md +2 -0
  3. package/LICENSE +21 -0
  4. package/README.md +66 -403
  5. package/cli/doctor.js +87 -1
  6. package/cli/install.js +122 -31
  7. package/cli/lib/schemas.cjs +318 -0
  8. package/cli/postinstall.js +19 -3
  9. package/dist/rcode.js +316 -23
  10. package/package.json +8 -4
  11. package/rihal/agents/rihal-cross-platform-auditor.md +1 -1
  12. package/rihal/agents/rihal-dep-auditor.md +1 -1
  13. package/rihal/agents/rihal-docs-auditor.md +3 -145
  14. package/rihal/agents/rihal-i18n-auditor.md +1 -1
  15. package/rihal/agents/rihal-nyquist-auditor.md +4 -156
  16. package/rihal/agents/rihal-observability-auditor.md +1 -1
  17. package/rihal/bin/rihal-hooks.cjs +394 -4
  18. package/rihal/bin/rihal-tools.cjs +891 -24
  19. package/rihal/commands/create-prd.md +18 -0
  20. package/rihal/commands/execute-milestone.md +18 -0
  21. package/rihal/commands/plan-milestone.md +18 -0
  22. package/rihal/commands/scaffold-milestone.md +18 -0
  23. package/rihal/commands/scaffold-skill.md +18 -0
  24. package/rihal/references/REFERENCES_INDEX.md +49 -7
  25. package/rihal/references/agent-contracts.md +10 -0
  26. package/rihal/references/design-tokens.md +98 -0
  27. package/rihal/references/docs-auditor-playbook.md +148 -0
  28. package/rihal/references/git-preflight.md +117 -0
  29. package/rihal/references/iterative-retrieval.md +85 -0
  30. package/rihal/references/nyquist-auditor-playbook.md +157 -0
  31. package/rihal/references/workstream-flag.md +2 -2
  32. package/rihal/skills/actions/1-analysis/rihal-prfaq/SKILL.md +9 -0
  33. package/rihal/skills/actions/4-implementation/rihal-checkpoint-preview/SKILL.md +9 -0
  34. package/rihal/skills/actions/4-implementation/rihal-ci/SKILL.md +4 -0
  35. package/rihal/skills/actions/4-implementation/rihal-code-review/steps/step-02-review.md +2 -2
  36. package/rihal/skills/actions/4-implementation/rihal-harden/SKILL.md +4 -0
  37. package/rihal/skills/actions/4-implementation/rihal-migrate/SKILL.md +4 -0
  38. package/rihal/skills/agents/haitham-frontend/SKILL.md +2 -0
  39. package/rihal/templates/settings-hooks.json +39 -0
  40. package/rihal/workflows/check-todos.md +4 -0
  41. package/rihal/workflows/code-review-fix.md +4 -3
  42. package/rihal/workflows/code-review.md +1 -1
  43. package/rihal/workflows/debug.md +1 -1
  44. package/rihal/workflows/dev-story.md +4 -0
  45. package/rihal/workflows/diff.md +2 -2
  46. package/rihal/workflows/do.md +16 -8
  47. package/rihal/workflows/docs-update.md +2 -2
  48. package/rihal/workflows/enable-hooks.md +6 -1
  49. package/rihal/workflows/execute-milestone.md +139 -0
  50. package/rihal/workflows/execute-regression-gates.md +1 -1
  51. package/rihal/workflows/execute-sprint.md +54 -2
  52. package/rihal/workflows/execute-verify-phase-goal.md +31 -4
  53. package/rihal/workflows/execute-waves.md +33 -5
  54. package/rihal/workflows/execute.md +40 -6
  55. package/rihal/workflows/help.md +1 -1
  56. package/rihal/workflows/import.md +1 -1
  57. package/rihal/workflows/lens-audit.md +39 -23
  58. package/rihal/workflows/list-workspaces.md +1 -1
  59. package/rihal/workflows/map-codebase.md +4 -4
  60. package/rihal/workflows/new-milestone.md +18 -1
  61. package/rihal/workflows/new-project-research.md +53 -1
  62. package/rihal/workflows/new-workspace.md +1 -1
  63. package/rihal/workflows/plan-milestone.md +105 -0
  64. package/rihal/workflows/plan-research-validation.md +1 -1
  65. package/rihal/workflows/plan-spawn-planner.md +1 -1
  66. package/rihal/workflows/plan.md +31 -3
  67. package/rihal/workflows/plant-seed.md +6 -0
  68. package/rihal/workflows/quick.md +11 -5
  69. package/rihal/workflows/research-phase.md +24 -0
  70. package/rihal/workflows/scaffold-milestone.md +60 -0
  71. package/rihal/workflows/scaffold-skill.md +137 -0
  72. package/rihal/workflows/scan.md +1 -1
  73. package/rihal/workflows/session-report.md +43 -3
  74. package/rihal/workflows/verify-work.md +3 -3
  75. package/server/dashboard.js +52 -5
  76. package/server/lib/html/client.js +723 -11
  77. package/server/lib/html/css.js +2046 -466
  78. package/server/lib/html/shell.js +227 -134
  79. package/server/lib/scanner.js +33 -0
  80. package/server/orchestrator.js +438 -0
@@ -0,0 +1,438 @@
1
+ /**
2
+ * Rihal Local Orchestrator — port 7718
3
+ *
4
+ * Spawns `claude -p` sessions from kanban card clicks.
5
+ * Streams stdout/stderr back to the browser via SSE.
6
+ * Pure Node stdlib — no external dependencies.
7
+ *
8
+ * Endpoints:
9
+ * POST /api/run { storyId, cmd? } → spawn claude session
10
+ * POST /api/stop { storyId } → SIGTERM the process
11
+ * GET /api/status → all session states
12
+ * GET /api/stream/:storyId → SSE log stream
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const { spawn } = require('child_process');
18
+ const http = require('http');
19
+ const path = require('path');
20
+ const fs = require('fs');
21
+ const os = require('os');
22
+ const crypto = require('crypto');
23
+
24
+ const PORT = parseInt(process.env.ORCH_PORT || '7718', 10);
25
+ const PROJECT_ROOT = path.resolve(__dirname, '..');
26
+ const CLAUDE_BIN = process.env.CLAUDE_BIN || 'claude';
27
+ const SESSIONS_DIR = path.join(os.homedir(), '.rihal', 'sessions');
28
+
29
+ // Per-session auth token. Use ORCH_TOKEN if set, else generate one and print
30
+ // it on boot. The dashboard process / user must pass this token on EVERY call
31
+ // (Authorization: Bearer header, or ?token= query param for the SSE endpoint).
32
+ const AUTH_TOKEN = process.env.ORCH_TOKEN || crypto.randomBytes(24).toString('hex');
33
+
34
+ // storyId must be a safe path segment — no separators, no traversal.
35
+ const STORY_ID_RE = /^[A-Za-z0-9._-]+$/;
36
+
37
+ // Ensure sessions directory exists
38
+ try { fs.mkdirSync(SESSIONS_DIR, { recursive: true }); } catch {}
39
+
40
+ // Map<storyId, Session>
41
+ // Session: { pid, proc, status, logs[], fileOps[], toolBuf{}, sseClients: Set, startTime }
42
+ const sessions = new Map();
43
+
44
+ // ── helpers ──────────────────────────────────────────────────────────────────
45
+
46
+ function json(res, code, body) {
47
+ res.writeHead(code, { 'Content-Type': 'application/json' });
48
+ res.end(JSON.stringify(body));
49
+ }
50
+
51
+ // Constant-time token check. Accepts the token via `Authorization: Bearer`
52
+ // header, or a `?token=` query param (the EventSource SSE client cannot set
53
+ // headers). Returns true only on an exact match.
54
+ function authed(req) {
55
+ let presented = null;
56
+ const auth = req.headers && req.headers.authorization;
57
+ if (auth && auth.startsWith('Bearer ')) {
58
+ presented = auth.slice('Bearer '.length);
59
+ } else {
60
+ const qIdx = (req.url || '').indexOf('?');
61
+ if (qIdx !== -1) {
62
+ const params = new URLSearchParams((req.url || '').slice(qIdx + 1));
63
+ presented = params.get('token');
64
+ }
65
+ }
66
+ if (typeof presented !== 'string') return false;
67
+ const a = Buffer.from(presented);
68
+ const b = Buffer.from(AUTH_TOKEN);
69
+ if (a.length !== b.length) return false;
70
+ return crypto.timingSafeEqual(a, b);
71
+ }
72
+
73
+ // Validate a storyId before it touches the filesystem. Charset blocks path
74
+ // separators; the explicit `..` check blocks traversal even though `.` is
75
+ // allowed in the charset.
76
+ function validStoryId(id) {
77
+ return typeof id === 'string'
78
+ && id.length > 0
79
+ && id.length <= 128
80
+ && !id.includes('..')
81
+ && STORY_ID_RE.test(id);
82
+ }
83
+
84
+ function parseBody(req) {
85
+ return new Promise(resolve => {
86
+ let buf = '';
87
+ req.on('data', c => buf += c);
88
+ req.on('end', () => { try { resolve(JSON.parse(buf)); } catch { resolve({}); } });
89
+ });
90
+ }
91
+
92
+ // Push one log line to session buffer + all connected SSE clients
93
+ function broadcast(storyId, line) {
94
+ const s = sessions.get(storyId);
95
+ if (!s || !line) return;
96
+ s.logs.push(line);
97
+ const payload = 'data: ' + JSON.stringify({ line }) + '\n\n';
98
+ for (const client of s.sseClients) {
99
+ try { client.write(payload); } catch { s.sseClients.delete(client); }
100
+ }
101
+ }
102
+
103
+ // Push a raw text chunk to SSE (not buffered — for streaming characters)
104
+ function broadcastChunk(storyId, chunk) {
105
+ const s = sessions.get(storyId);
106
+ if (!s || !chunk) return;
107
+ const payload = 'data: ' + JSON.stringify({ chunk }) + '\n\n';
108
+ for (const client of s.sseClients) {
109
+ try { client.write(payload); } catch { s.sseClients.delete(client); }
110
+ }
111
+ }
112
+
113
+ // Push a file operation event to SSE clients + buffer it
114
+ function broadcastFileOp(storyId, fileOp) {
115
+ const s = sessions.get(storyId);
116
+ if (!s || !fileOp) return;
117
+ s.fileOps.push(fileOp);
118
+ const payload = 'data: ' + JSON.stringify({ fileOp }) + '\n\n';
119
+ for (const client of s.sseClients) {
120
+ try { client.write(payload); } catch { s.sseClients.delete(client); }
121
+ }
122
+ }
123
+
124
+ // Push a status event to all SSE clients for a session
125
+ function broadcastStatus(storyId, status) {
126
+ const s = sessions.get(storyId);
127
+ if (!s) return;
128
+ s.status = status;
129
+ const payload = 'data: ' + JSON.stringify({ status }) + '\n\n';
130
+ for (const client of s.sseClients) {
131
+ try { client.write(payload); } catch { s.sseClients.delete(client); }
132
+ }
133
+ }
134
+
135
+ // Parse one stream-json line → { text?, fileOp? }
136
+ // toolBuf = accumulated partial JSON per content block index
137
+ function parseStreamLine(raw, toolBuf) {
138
+ if (!raw) return {};
139
+ try {
140
+ const p = JSON.parse(raw);
141
+
142
+ // Streaming text delta — send as 'chunk' so browser appends in-place
143
+ if (p.type === 'content_block_delta' && p.delta?.type === 'text_delta') {
144
+ return { chunk: p.delta.text || null };
145
+ }
146
+
147
+ // Tool use start — record tool name, init buffer
148
+ if (p.type === 'content_block_start' && p.content_block?.type === 'tool_use') {
149
+ const name = p.content_block.name || 'tool';
150
+ toolBuf[p.index] = { name, json: '' };
151
+ return { text: '⚙ ' + name };
152
+ }
153
+
154
+ // Tool input JSON accumulation
155
+ if (p.type === 'content_block_delta' && p.delta?.type === 'input_json_delta') {
156
+ if (toolBuf[p.index]) toolBuf[p.index].json += (p.delta.partial_json || '');
157
+ return {};
158
+ }
159
+
160
+ // Tool use complete — try to extract file path
161
+ if (p.type === 'content_block_stop' && toolBuf[p.index]) {
162
+ const { name, json: partial } = toolBuf[p.index];
163
+ delete toolBuf[p.index];
164
+ let fileOp = null;
165
+ try {
166
+ const inp = JSON.parse(partial);
167
+ const filePath = inp.path || inp.file_path || inp.file || inp.filename || null;
168
+ const isWrite = /write|edit|create|str_replace/i.test(name);
169
+ const isRead = /read|view|cat/i.test(name);
170
+ const isBash = /bash|exec|run|shell/i.test(name);
171
+ if (filePath) {
172
+ fileOp = { tool: name, path: filePath, op: isWrite ? 'write' : isRead ? 'read' : 'access' };
173
+ } else if (isBash && inp.command) {
174
+ fileOp = { tool: 'bash', path: null, cmd: String(inp.command).slice(0, 80), op: 'bash' };
175
+ }
176
+ } catch {}
177
+ return { fileOp };
178
+ }
179
+
180
+ // Result summary
181
+ if (p.type === 'result') return { text: '✓ ' + (p.subtype || 'done') };
182
+
183
+ // Legacy format
184
+ if (p.type === 'assistant' && Array.isArray(p.message?.content)) {
185
+ const text = p.message.content.filter(c => c.type === 'text').map(c => c.text).join('');
186
+ return { text: text || null };
187
+ }
188
+
189
+ return {};
190
+ } catch {
191
+ const t = raw.trim();
192
+ return { text: t.startsWith('{') ? null : (t || null) };
193
+ }
194
+ }
195
+
196
+ // Persist completed session to ~/.rihal/sessions/{storyId}-{date}.json
197
+ function persistSession(storyId, exitStatus) {
198
+ const s = sessions.get(storyId);
199
+ if (!s) return;
200
+ try {
201
+ const date = new Date().toISOString().slice(0, 10);
202
+ const file = path.join(SESSIONS_DIR, storyId + '-' + date + '.json');
203
+ // Defense-in-depth: refuse to write outside SESSIONS_DIR.
204
+ if (!path.resolve(file).startsWith(SESSIONS_DIR + path.sep)) return;
205
+ fs.writeFileSync(file, JSON.stringify({
206
+ storyId, status: exitStatus,
207
+ startTime: s.startTime, endTime: new Date().toISOString(),
208
+ logs: s.logs, fileOps: s.fileOps,
209
+ }), 'utf8');
210
+ } catch {}
211
+ }
212
+
213
+ // Load most recent persisted session for a storyId (if any)
214
+ function loadLastSession(storyId) {
215
+ try {
216
+ const files = fs.readdirSync(SESSIONS_DIR)
217
+ .filter(f => f.startsWith(storyId + '-') && f.endsWith('.json'))
218
+ .sort()
219
+ .reverse();
220
+ if (!files.length) return null;
221
+ const file = path.join(SESSIONS_DIR, files[0]);
222
+ // Defense-in-depth: refuse to read outside SESSIONS_DIR.
223
+ if (!path.resolve(file).startsWith(SESSIONS_DIR + path.sep)) return null;
224
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
225
+ } catch { return null; }
226
+ }
227
+
228
+ // Clean sessions older than N days
229
+ function cleanSessions(olderThanDays) {
230
+ const cutoff = Date.now() - olderThanDays * 86400000;
231
+ let removed = 0;
232
+ try {
233
+ for (const f of fs.readdirSync(SESSIONS_DIR)) {
234
+ if (!f.endsWith('.json')) continue;
235
+ const full = path.join(SESSIONS_DIR, f);
236
+ const stat = fs.statSync(full);
237
+ if (stat.mtimeMs < cutoff) { fs.unlinkSync(full); removed++; }
238
+ }
239
+ } catch {}
240
+ return removed;
241
+ }
242
+
243
+ // ── route handlers ────────────────────────────────────────────────────────────
244
+
245
+ function handleStatus(res) {
246
+ const out = {};
247
+ for (const [id, s] of sessions) {
248
+ out[id] = { pid: s.pid, status: s.status, lines: s.logs.length, fileOps: s.fileOps };
249
+ }
250
+ json(res, 200, out);
251
+ }
252
+
253
+ function handleStream(req, res, storyId) {
254
+ if (!validStoryId(storyId)) { res.writeHead(400); res.end('invalid storyId'); return; }
255
+ res.writeHead(200, {
256
+ 'Content-Type': 'text/event-stream',
257
+ 'Cache-Control': 'no-cache',
258
+ 'Connection': 'keep-alive',
259
+ 'X-Accel-Buffering': 'no', // disable nginx/proxy buffering
260
+ });
261
+ // Disable Nagle — flush every write immediately to the browser
262
+ if (res.socket) res.socket.setNoDelay(true);
263
+
264
+ const s = sessions.get(storyId);
265
+ if (!s) {
266
+ // Try to replay last persisted session
267
+ const last = loadLastSession(storyId);
268
+ if (last) {
269
+ for (const line of (last.logs || [])) {
270
+ res.write('data: ' + JSON.stringify({ line }) + '\n\n');
271
+ }
272
+ for (const fileOp of (last.fileOps || [])) {
273
+ res.write('data: ' + JSON.stringify({ fileOp }) + '\n\n');
274
+ }
275
+ res.write('data: ' + JSON.stringify({ status: last.status || 'done' }) + '\n\n');
276
+ } else {
277
+ res.write('data: ' + JSON.stringify({ error: 'no session for ' + storyId }) + '\n\n');
278
+ }
279
+ res.end();
280
+ return;
281
+ }
282
+
283
+ // Replay buffered logs + file ops so late-connecting clients see history
284
+ for (const line of s.logs) {
285
+ res.write('data: ' + JSON.stringify({ line }) + '\n\n');
286
+ }
287
+ for (const fileOp of s.fileOps) {
288
+ res.write('data: ' + JSON.stringify({ fileOp }) + '\n\n');
289
+ }
290
+ res.write('data: ' + JSON.stringify({ status: s.status }) + '\n\n');
291
+
292
+ s.sseClients.add(res);
293
+ req.on('close', () => s.sseClients.delete(res));
294
+ }
295
+
296
+ async function handleRun(req, res) {
297
+ const body = await parseBody(req);
298
+ const storyId = String(body.storyId || '').trim();
299
+ if (!storyId) { json(res, 400, { error: 'missing storyId' }); return; }
300
+ if (!validStoryId(storyId)) { json(res, 400, { error: 'invalid storyId' }); return; }
301
+
302
+ const existing = sessions.get(storyId);
303
+ if (existing?.status === 'running') {
304
+ json(res, 409, { error: 'already running', pid: existing.pid });
305
+ return;
306
+ }
307
+
308
+ // Default command: invoke the rihal-dev-story skill for the given story ID
309
+ const cmd = String(body.cmd || `/rihal-dev-story ${storyId}`);
310
+
311
+ const s = {
312
+ pid: null, proc: null, status: 'starting',
313
+ logs: ['▶ Starting: claude -p "' + cmd + '"'],
314
+ fileOps: [],
315
+ toolBuf: {},
316
+ sseClients: new Set(),
317
+ startTime: new Date().toISOString(),
318
+ };
319
+ sessions.set(storyId, s);
320
+
321
+ const proc = spawn(CLAUDE_BIN, [
322
+ '-p', cmd,
323
+ '--output-format', 'stream-json',
324
+ '--verbose',
325
+ '--dangerously-skip-permissions',
326
+ ], {
327
+ cwd: PROJECT_ROOT,
328
+ env: { ...process.env },
329
+ stdio: ['pipe', 'pipe', 'pipe'],
330
+ });
331
+
332
+ s.proc = proc;
333
+ s.pid = proc.pid;
334
+ s.status = 'running';
335
+
336
+ proc.stdout.on('data', chunk => {
337
+ for (const raw of chunk.toString().split('\n')) {
338
+ const line = raw.trim();
339
+ if (!line) continue;
340
+ const { text, chunk, fileOp } = parseStreamLine(line, s.toolBuf);
341
+ if (chunk) broadcastChunk(storyId, chunk); // streaming text — in-place append
342
+ if (text) broadcast(storyId, text); // event/status line — new row
343
+ if (fileOp) broadcastFileOp(storyId, fileOp);
344
+ }
345
+ });
346
+
347
+ proc.stderr.on('data', chunk => {
348
+ const msg = chunk.toString().trim();
349
+ if (msg) broadcast(storyId, '⚠ ' + msg);
350
+ });
351
+
352
+ proc.on('error', err => {
353
+ broadcast(storyId, '✗ spawn error: ' + err.message);
354
+ broadcastStatus(storyId, 'error');
355
+ });
356
+
357
+ proc.on('exit', code => {
358
+ const final = code === 0 ? 'done' : (code === null ? 'stopped' : 'error');
359
+ broadcast(storyId, final === 'done' ? '✅ Completed' : '✗ Exited with code ' + code);
360
+ broadcastStatus(storyId, final);
361
+ persistSession(storyId, final);
362
+ });
363
+
364
+ json(res, 200, { storyId, pid: proc.pid, status: 'running' });
365
+ }
366
+
367
+ async function handleMessage(req, res) {
368
+ const body = await parseBody(req);
369
+ const storyId = String(body.storyId || '').trim();
370
+ const data = String(body.data || '');
371
+ if (!validStoryId(storyId)) { json(res, 400, { error: 'invalid storyId' }); return; }
372
+ const s = sessions.get(storyId);
373
+ if (!s || s.status !== 'running') { json(res, 404, { error: 'no active session' }); return; }
374
+ try {
375
+ s.proc.stdin.write(data);
376
+ broadcast(storyId, '[input] ' + data.trim());
377
+ json(res, 200, { ok: true });
378
+ } catch (err) {
379
+ json(res, 500, { error: err.message });
380
+ }
381
+ }
382
+
383
+ async function handleCleanSessions(req, res) {
384
+ const body = await parseBody(req);
385
+ const days = parseInt(body.olderThanDays || 7, 10);
386
+ const removed = cleanSessions(days);
387
+ json(res, 200, { removed, sessionsDir: SESSIONS_DIR });
388
+ }
389
+
390
+ async function handleStop(req, res) {
391
+ const body = await parseBody(req);
392
+ const storyId = String(body.storyId || '').trim();
393
+ if (!validStoryId(storyId)) { json(res, 400, { error: 'invalid storyId' }); return; }
394
+ const s = sessions.get(storyId);
395
+ if (!s) { json(res, 404, { error: 'no session' }); return; }
396
+
397
+ try { s.proc?.kill('SIGTERM'); } catch {}
398
+ broadcast(storyId, '■ Stopped by user');
399
+ broadcastStatus(storyId, 'stopped');
400
+ json(res, 200, { storyId, status: 'stopped' });
401
+ }
402
+
403
+ // ── server ────────────────────────────────────────────────────────────────────
404
+
405
+ const server = http.createServer(async (req, res) => {
406
+ const method = req.method || '';
407
+ const url = req.url || '';
408
+ // Every route requires the token — /api/status leaks session detail too.
409
+ if (!authed(req)) { json(res, 401, { error: 'unauthorized' }); return; }
410
+
411
+ if (method === 'GET' && url === '/api/status') { handleStatus(res); return; }
412
+ if (method === 'GET' && url.startsWith('/api/stream/')) {
413
+ const pathOnly = url.indexOf('?') === -1 ? url : url.slice(0, url.indexOf('?'));
414
+ const storyId = decodeURIComponent(pathOnly.slice('/api/stream/'.length));
415
+ handleStream(req, res, storyId);
416
+ return;
417
+ }
418
+ if (method === 'POST' && url === '/api/run') { await handleRun(req, res); return; }
419
+ if (method === 'POST' && url === '/api/stop') { await handleStop(req, res); return; }
420
+ if (method === 'POST' && url === '/api/message') { await handleMessage(req, res); return; }
421
+ if (method === 'POST' && url === '/api/clean-sessions') { await handleCleanSessions(req, res); return; }
422
+
423
+ res.writeHead(404); res.end('Not found');
424
+ });
425
+
426
+ server.listen(PORT, '127.0.0.1', () => {
427
+ console.log('\n🤖 Rihal Orchestrator');
428
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
429
+ console.log(' Port: ' + PORT);
430
+ console.log(' Bind: 127.0.0.1 (loopback only)');
431
+ // The dashboard process / user must pass this token on every API call.
432
+ console.log(' Token: ' + AUTH_TOKEN);
433
+ console.log(' POST /api/run { storyId }');
434
+ console.log(' POST /api/stop { storyId }');
435
+ console.log(' GET /api/status');
436
+ console.log(' GET /api/stream/:storyId (SSE)');
437
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
438
+ });