catalyst-os 2.0.3 → 3.0.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.
@@ -86,6 +86,27 @@ function copyFile(src, dest) {
86
86
  return false;
87
87
  }
88
88
 
89
+ /**
90
+ * Ensure the consuming project's .gitignore protects local secrets.
91
+ * If `.catalyst/` (or the secrets file) is already ignored, do nothing.
92
+ */
93
+ function ensureGitignore(cwd) {
94
+ const guard = '.catalyst/secrets.local.yaml';
95
+ const gitignorePath = path.join(cwd, '.gitignore');
96
+ let content = '';
97
+ if (fs.existsSync(gitignorePath)) {
98
+ content = fs.readFileSync(gitignorePath, 'utf8');
99
+ const lines = content.split(/\r?\n/).map((l) => l.trim());
100
+ const covered = lines.some(
101
+ (l) => l === guard || l === '/.catalyst' || l === '.catalyst' || l === '.catalyst/'
102
+ );
103
+ if (covered) return;
104
+ }
105
+ const block = `${content && !content.endsWith('\n') ? '\n' : ''}\n# Catalyst OS local secrets (never commit)\n${guard}\n`;
106
+ fs.appendFileSync(gitignorePath, block);
107
+ console.log(` ${green}✓${reset} Added ${guard} to .gitignore`);
108
+ }
109
+
89
110
  /**
90
111
  * Install catalyst-os to current directory
91
112
  */
@@ -142,6 +163,14 @@ function install() {
142
163
  }
143
164
  }
144
165
 
166
+ // Install voice runtime (zero-dependency local meeting daemon for /meet-spec)
167
+ const voiceSrc = path.join(src, '.catalyst', 'voice');
168
+ const voiceDest = path.join(catalystDir, 'voice');
169
+ if (fs.existsSync(voiceSrc)) {
170
+ copyDir(voiceSrc, voiceDest);
171
+ console.log(` ${green}✓${reset} Installed .catalyst/voice/`);
172
+ }
173
+
145
174
  // Create specs directory if it doesn't exist
146
175
  const specsDir = path.join(catalystDir, 'specs');
147
176
  if (!fs.existsSync(specsDir)) {
@@ -149,6 +178,23 @@ function install() {
149
178
  console.log(` ${green}✓${reset} Created .catalyst/specs/`);
150
179
  }
151
180
 
181
+ // Create a local secrets file (git-ignored) if it doesn't exist yet.
182
+ // Optional — only needed for voice meetings (/meet-spec).
183
+ const secretsPath = path.join(catalystDir, 'secrets.local.yaml');
184
+ if (!fs.existsSync(secretsPath)) {
185
+ fs.writeFileSync(
186
+ secretsPath,
187
+ '# Catalyst OS — Local Secrets (git-ignored, never commit)\n' +
188
+ '# Optional. Only needed for /meet-spec voice meetings.\n' +
189
+ '# Get a key at https://elevenlabs.io (you pay for your own usage).\n' +
190
+ 'elevenlabs_api_key: ""\n'
191
+ );
192
+ console.log(` ${green}✓${reset} Created .catalyst/secrets.local.yaml`);
193
+ }
194
+
195
+ // Safety belt: ensure the local secrets file can never be committed.
196
+ ensureGitignore(cwd);
197
+
152
198
  // Install AGENTS.md
153
199
  const agentsMdSrc = path.join(src, 'AGENTS.md');
154
200
  const agentsMdDest = path.join(cwd, 'AGENTS.md');
@@ -33,3 +33,19 @@ git:
33
33
  red_phase: "test({scope}): write failing tests for {spec}"
34
34
  green_phase: "feat({scope}): implement {spec}"
35
35
 
36
+ # Voice meetings (/meet-spec) — optional.
37
+ # See .catalyst/voice/README.md for setup.
38
+ voice:
39
+ enabled: false # set true to enable /meet-spec
40
+ language: "en" # meeting/conversation language (e.g. "tr"); artifacts stay English
41
+
42
+ # mode: diy = browser speech + local `claude -p` brain. FREE, no API key, needs Chrome.
43
+ # bundled = ElevenLabs Conversational AI. Nicer voice, per-minute cost (BYOK).
44
+ mode: diy
45
+
46
+ # --- bundled mode only (ignored in diy) ---
47
+ # The ElevenLabs API key does NOT go here — it lives in .catalyst/secrets.local.yaml.
48
+ provider: elevenlabs
49
+ voice_id: "" # optional ElevenLabs voice id
50
+ agent_id: "" # ElevenLabs Conversational AI agent id
51
+
@@ -46,7 +46,7 @@ files:
46
46
  owner: arbiter
47
47
  purpose: Test results, quality checks
48
48
  created: audit-spec
49
- updated_by: [arbiter, enforcer, sentinel, inquisitor, watcher]
49
+ updated_by: [arbiter, enforcer, sentinel, inquisitor, watcher, curator]
50
50
 
51
51
  assets/:
52
52
  owner: any
@@ -0,0 +1,78 @@
1
+ # Catalyst Voice Runtime
2
+
3
+ Local browser meeting room for `/meet-spec`. **Zero npm dependencies** — Node stdlib
4
+ only. Requires **Node >= 18** and the `claude` CLI on your PATH.
5
+
6
+ Two modes, set by `voice.mode` in `.catalyst/main/project-config.yaml`:
7
+
8
+ | | **diy** (default) | **bundled** |
9
+ |---|---|---|
10
+ | Speech | Browser Web Speech API | ElevenLabs Conversational AI |
11
+ | Brain | Local `claude -p` | ElevenLabs agent (Claude LLM) |
12
+ | Voice quality | Robotic (browser TTS) | Natural |
13
+ | Cost | **Free** (uses your Claude) | ~$0.10–$1/min (ElevenLabs, BYOK) |
14
+ | Setup | None — just Chrome | API key + agent (see below) |
15
+
16
+ Both modes give the agent full read access to the codebase, docs, and web search, and
17
+ both write the same `meeting.md` artifact. **Artifacts are always in English**, whatever
18
+ language the meeting is spoken in (set `voice.language`, e.g. `"tr"`).
19
+
20
+ ## DIY mode (default — nothing to set up)
21
+
22
+ ```yaml
23
+ voice:
24
+ enabled: true
25
+ language: "tr" # or "en", etc.
26
+ mode: diy
27
+ ```
28
+
29
+ Open the room (via `/meet-spec`), click **Start meeting**, and talk. Chrome transcribes
30
+ your speech, `claude -p` answers from the real repo, and the browser speaks the reply.
31
+ You can also **type** in the text box at any time (great when speech is misheard). Needs
32
+ Chrome/Chromium for speech; in other browsers, the text box still works.
33
+
34
+ ## Bundled mode (optional — nicer voice, paid)
35
+
36
+ 1. **Get an ElevenLabs API key** at https://elevenlabs.io. Conversational AI is billed
37
+ **per minute** (~1,000 credits/min on credit plans), so prefer a plan with included
38
+ agent-minutes. Paste the key into `.catalyst/secrets.local.yaml`:
39
+ ```yaml
40
+ elevenlabs_api_key: "your-key-here"
41
+ ```
42
+ 2. **Create a Conversational AI agent** (dashboard): LLM = Claude; a spec-shaping
43
+ interviewer system prompt; a **client tool** named exactly **`investigate_codebase`**
44
+ with one **string** parameter **`query`**. For non-English, enable the **language
45
+ override** (Security/Overrides → Language) and use a multilingual TTS model
46
+ (`eleven_flash_v2_5`).
47
+ 3. **Point Catalyst at it**:
48
+ ```yaml
49
+ voice:
50
+ enabled: true
51
+ language: "tr"
52
+ mode: bundled
53
+ agent_id: "your-agent-id"
54
+ ```
55
+ The key is read server-side by `meet-server.js` and never reaches the browser, argv,
56
+ or env. Signed URLs are minted server-side; client tools run in the browser so they
57
+ reach `localhost` — nothing is exposed to the internet.
58
+
59
+ ## How it works
60
+
61
+ ```
62
+ diy: Browser (Web Speech) ──text──► meet-server /turn ──► claude -p (repo) ──► reply ──► browser TTS
63
+ bundled: Browser (ElevenLabs SDK) ◄─voice─► ElevenLabs agent
64
+ └─ investigate_codebase ─► meet-server /tool ─► claude -p (repo)
65
+ ```
66
+
67
+ Clicking **End meeting** sends the transcript to the daemon, which folds it into
68
+ `.catalyst/meetings/YYYY-MM-DD-{slug}/meeting.md` via one `claude -p` pass, then shuts down.
69
+
70
+ ## Run it manually
71
+
72
+ ```bash
73
+ PORT=4399 \
74
+ MEETING_DIR=".catalyst/meetings/2026-01-01-test" \
75
+ MEETING_TOPIC="Test meeting" \
76
+ node .catalyst/voice/meet-server.js
77
+ # then open http://localhost:4399
78
+ ```
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Meeting artifact generation for /meet-spec.
3
+ * Writes meeting.md (canonical, for /catalyze-spec) and meeting.html (rendered, for humans).
4
+ */
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ function esc(s) {
9
+ return String(s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
10
+ }
11
+
12
+ // One-pass transcript summarizer (bundled mode, or when no live notes were produced).
13
+ const SUMMARY_PROMPT = (topic, transcript) =>
14
+ `You are summarizing a spec-shaping meeting. Convert the transcript below into clean ` +
15
+ `Markdown that another command (/catalyze-spec) will use as the feature request.\n\n` +
16
+ `Write the notes in ENGLISH regardless of the language spoken in the meeting.\n\n` +
17
+ `Output EXACTLY these sections, nothing before or after:\n\n` +
18
+ `# Meeting: ${topic}\n\n## Summary\n(one paragraph)\n\n## Decisions\n(bullet list of ` +
19
+ `decisions reached; "None recorded." if none)\n\n## Action Items\n(bullet list of ` +
20
+ `requirements / things to build)\n\n## Open Questions\n(bullet list of unresolved ` +
21
+ `questions)\n\n## Transcript\n(the full transcript, lightly cleaned)\n\n` +
22
+ `--- TRANSCRIPT ---\n${transcript}`;
23
+
24
+ // notesHtml is the browser-rendered notes (DIY); falls back to <pre> if absent.
25
+ function writeArtifacts({ projectRoot, meetingDir, topic, notesMd, notesHtml, transcriptText, findings }) {
26
+ const dir = path.join(projectRoot, meetingDir);
27
+ fs.mkdirSync(dir, { recursive: true });
28
+
29
+ const research = findings && findings.length
30
+ ? '\n\n## Research Findings\n\n' + findings.map((f) => `### ${f.query}\n\n${f.result}`).join('\n\n')
31
+ : '';
32
+ const notesBody = notesMd.startsWith('#') ? notesMd : `# Meeting: ${topic}\n\n${notesMd}`;
33
+ const md = `${notesBody}\n\n## Transcript\n\n${transcriptText}\n${research}`;
34
+ fs.writeFileSync(path.join(dir, 'meeting.md'), md);
35
+
36
+ const notesSection = notesHtml || `<pre>${esc(notesMd)}</pre>`;
37
+ const transcriptHtml = transcriptText.split('\n').map((l) => `<p>${esc(l)}</p>`).join('\n');
38
+ const html = `<!doctype html>
39
+ <html lang="en"><head><meta charset="utf-8" />
40
+ <title>Meeting: ${esc(topic)}</title>
41
+ <style>
42
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;max-width:820px;
43
+ margin:40px auto;padding:0 20px;line-height:1.6;color:#1a1a24}
44
+ h1{font-size:24px} h2{font-size:18px;margin-top:28px;border-bottom:1px solid #eee;padding-bottom:4px}
45
+ .notes{background:#faf9ff;border:1px solid #ece8ff;border-radius:10px;padding:18px 22px}
46
+ .transcript p{margin:2px 0;color:#444;font-size:14px}
47
+ code{background:#f3f1fa;padding:2px 5px;border-radius:4px}
48
+ </style></head>
49
+ <body>
50
+ <h1>Meeting: ${esc(topic)}</h1>
51
+ <section class="notes">${notesSection}</section>
52
+ <h2>Transcript</h2>
53
+ <div class="transcript">${transcriptHtml}</div>
54
+ </body></html>
55
+ `;
56
+ fs.writeFileSync(path.join(dir, 'meeting.html'), html);
57
+
58
+ return { mdPath: path.join(meetingDir, 'meeting.md'), htmlPath: path.join(meetingDir, 'meeting.html') };
59
+ }
60
+
61
+ module.exports = { writeArtifacts, SUMMARY_PROMPT };
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Local voice-meeting daemon for /meet-spec.
4
+ *
5
+ * Zero npm dependencies: Node stdlib + global fetch (Node >= 18) + child_process.
6
+ *
7
+ * Two modes (project-config voice.mode):
8
+ * diy — browser Web Speech (free) + local `claude -p` brain. No ElevenLabs, no cost.
9
+ * bundled — ElevenLabs Conversational AI (better voice, per-minute cost).
10
+ *
11
+ * Responsibilities:
12
+ * GET / serve the meeting room page (diy or bundled)
13
+ * GET /config non-secret config for the browser (mode, agentId, language, topic)
14
+ * GET /signed-url bundled: mint an ElevenLabs signed URL (key stays server-side)
15
+ * POST /turn diy: one conversational turn via headless `claude -p`
16
+ * POST /tool bundled: live codebase investigation via headless `claude -p`
17
+ * POST /end fold transcript (+ findings) into meeting.md, then shut down
18
+ *
19
+ * The ElevenLabs API key (bundled only) is read from .catalyst/secrets.local.yaml and
20
+ * never leaves this process (not the browser, not argv, not the environment).
21
+ */
22
+
23
+ const http = require('http');
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const { spawn } = require('child_process');
27
+ const { writeArtifacts, SUMMARY_PROMPT } = require('./artifact');
28
+
29
+ const PROJECT_ROOT = process.cwd();
30
+ const PORT = parseInt(process.env.PORT || '4399', 10);
31
+ const MEETING_DIR = process.env.MEETING_DIR || '.catalyst/meetings/untitled';
32
+ const MEETING_TOPIC = process.env.MEETING_TOPIC || 'Untitled meeting';
33
+
34
+ // Read-only tool whitelist for headless claude runs. Non-listed tools are
35
+ // auto-denied in -p mode, so there are no interactive permission hangs.
36
+ const CLAUDE_TOOLS = 'Read,Grep,Glob,Task,WebSearch,WebFetch';
37
+
38
+ // --- Config / secrets (minimal YAML reads, no parser dependency) ---
39
+
40
+ function readScalar(relPath, key) {
41
+ try {
42
+ const txt = fs.readFileSync(path.join(PROJECT_ROOT, relPath), 'utf8');
43
+ const m = txt.match(new RegExp('^\\s*' + key + ':\\s*["\']?([^"\'\\n]*)["\']?', 'm'));
44
+ return m ? m[1].trim() : '';
45
+ } catch {
46
+ return '';
47
+ }
48
+ }
49
+
50
+ const API_KEY = readScalar('.catalyst/secrets.local.yaml', 'elevenlabs_api_key');
51
+ const AGENT_ID = readScalar('.catalyst/main/project-config.yaml', 'agent_id');
52
+ const VOICE_ID = readScalar('.catalyst/main/project-config.yaml', 'voice_id');
53
+ const LANGUAGE = readScalar('.catalyst/main/project-config.yaml', 'language') || 'en';
54
+ // bundled = ElevenLabs Conversational AI; diy = browser speech + local claude -p (free).
55
+ const MODE = readScalar('.catalyst/main/project-config.yaml', 'mode') || 'bundled';
56
+ const HTML_PATH = path.join(__dirname, MODE === 'diy' ? 'meeting-diy.html' : 'meeting.html');
57
+
58
+ // In DIY mode the "agent" is claude -p itself, driven turn by turn.
59
+ const PERSONA =
60
+ `You are Catalyst, a spec-shaping interviewer for this software project. Hold a focused, ` +
61
+ `friendly spoken meeting that turns a rough idea into a clear feature specification. ` +
62
+ `Speak in ${LANGUAGE === 'tr' ? 'Turkish' : "the user's language"}. Ask ONE question at a ` +
63
+ `time and keep each spoken turn short (1-3 sentences). You have full read access to this ` +
64
+ `repository, its documentation, and web search — use them to ground your questions and ` +
65
+ `answers; never guess about the code. Drive toward: the problem, scope, concrete ` +
66
+ `requirements, key decisions, and action items. Be patient: if the user is brief or silent, ` +
67
+ `wait — never nag with "are you there?".\n\n` +
68
+ `OUTPUT FORMAT (strict, every turn): first your spoken words ONLY (no markdown). Then a ` +
69
+ `line containing exactly ===NOTES=== . Then the running meeting notes in Markdown, IN ` +
70
+ `ENGLISH, cumulative (carry forward everything so far and refine it), with these sections:\n` +
71
+ `## Summary\n## Decisions\n## Action Items\n## Open Questions\n` +
72
+ `The spoken part is for text-to-speech; the notes part is never spoken.`;
73
+
74
+ const NOTES_DELIM = '===NOTES===';
75
+
76
+ // Live investigation findings, folded into the artifact at the end.
77
+ const findings = [];
78
+ // DIY conversation session (claude -p --resume keeps context across turns).
79
+ let sessionId = null;
80
+ // Latest running notes (Markdown) maintained across DIY turns.
81
+ let latestNotes = '';
82
+
83
+ // --- Headless claude bridge ---
84
+
85
+ function runClaude(prompt, timeoutMs) {
86
+ return new Promise((resolve) => {
87
+ let child;
88
+ try {
89
+ child = spawn('claude', ['-p', '--allowedTools', CLAUDE_TOOLS], {
90
+ cwd: PROJECT_ROOT,
91
+ });
92
+ } catch (e) {
93
+ return resolve(`(could not start claude: ${e.message})`);
94
+ }
95
+ let out = '';
96
+ let err = '';
97
+ const timer = setTimeout(() => {
98
+ child.kill('SIGTERM');
99
+ resolve(out.trim() || '(investigation timed out)');
100
+ }, timeoutMs);
101
+
102
+ child.stdout.on('data', (d) => (out += d));
103
+ child.stderr.on('data', (d) => (err += d));
104
+ child.on('error', (e) => {
105
+ clearTimeout(timer);
106
+ resolve(`(claude not available: ${e.message})`);
107
+ });
108
+ child.on('close', () => {
109
+ clearTimeout(timer);
110
+ resolve(out.trim() || err.trim() || '(no output)');
111
+ });
112
+
113
+ child.stdin.write(prompt);
114
+ child.stdin.end();
115
+ });
116
+ }
117
+
118
+ // One conversational turn for DIY mode. Uses --resume to keep context (and the
119
+ // codebase warm) across turns; the persona is sent only on the first turn.
120
+ function claudeTurn(userText) {
121
+ return new Promise((resolve) => {
122
+ const args = ['-p', '--output-format', 'json', '--allowedTools', CLAUDE_TOOLS];
123
+ if (sessionId) args.push('--resume', sessionId);
124
+ const prompt = sessionId
125
+ ? userText
126
+ : `${PERSONA}\n\nThe meeting topic is: ${MEETING_TOPIC}\n\nUser: ${userText}`;
127
+ let child;
128
+ try {
129
+ child = spawn('claude', args, { cwd: PROJECT_ROOT });
130
+ } catch (e) {
131
+ return resolve({ reply: `(claude unavailable: ${e.message})`, notes: latestNotes });
132
+ }
133
+ let out = '';
134
+ let err = '';
135
+ const timer = setTimeout(() => {
136
+ child.kill('SIGTERM');
137
+ resolve(splitTurn(out) || { reply: '(timed out)', notes: latestNotes });
138
+ }, 150000);
139
+ child.stdout.on('data', (d) => (out += d));
140
+ child.stderr.on('data', (d) => (err += d));
141
+ child.on('error', (e) => {
142
+ clearTimeout(timer);
143
+ resolve({ reply: `(claude unavailable: ${e.message})`, notes: latestNotes });
144
+ });
145
+ child.on('close', () => {
146
+ clearTimeout(timer);
147
+ let result = out.trim();
148
+ try {
149
+ const j = JSON.parse(out);
150
+ result = (j.result || '').trim();
151
+ if (j.session_id) sessionId = j.session_id;
152
+ } catch {
153
+ /* non-JSON output: fall back to raw text */
154
+ }
155
+ resolve(splitTurn(result) || { reply: err.trim() || '(no response)', notes: latestNotes });
156
+ });
157
+ child.stdin.write(prompt);
158
+ child.stdin.end();
159
+ });
160
+ }
161
+
162
+ // Split a turn's result into spoken reply + running notes. Updates latestNotes.
163
+ // If the delimiter is missing, treat everything as the reply and keep prior notes.
164
+ function splitTurn(result) {
165
+ if (!result) return null;
166
+ const i = result.indexOf(NOTES_DELIM);
167
+ if (i < 0) return { reply: result.trim(), notes: latestNotes };
168
+ const reply = result.slice(0, i).trim();
169
+ const notes = result.slice(i + NOTES_DELIM.length).trim();
170
+ if (notes) latestNotes = notes;
171
+ return { reply: reply || '(no response)', notes: latestNotes };
172
+ }
173
+
174
+ // --- HTTP helpers ---
175
+
176
+ function sendJson(res, code, obj) {
177
+ const body = JSON.stringify(obj);
178
+ res.writeHead(code, { 'Content-Type': 'application/json' });
179
+ res.end(body);
180
+ }
181
+
182
+ function readBody(req) {
183
+ return new Promise((resolve) => {
184
+ let data = '';
185
+ req.on('data', (c) => (data += c));
186
+ req.on('end', () => {
187
+ try {
188
+ resolve(data ? JSON.parse(data) : {});
189
+ } catch {
190
+ resolve({});
191
+ }
192
+ });
193
+ });
194
+ }
195
+
196
+ // --- Server ---
197
+
198
+ const server = http.createServer(async (req, res) => {
199
+ const url = (req.url || '/').split('?')[0];
200
+
201
+ if (req.method === 'GET' && url === '/') {
202
+ fs.readFile(HTML_PATH, (e, buf) => {
203
+ if (e) {
204
+ res.writeHead(500);
205
+ res.end('meeting.html not found');
206
+ } else {
207
+ res.writeHead(200, { 'Content-Type': 'text/html' });
208
+ res.end(buf);
209
+ }
210
+ });
211
+ return;
212
+ }
213
+
214
+ if (req.method === 'GET' && url === '/config') {
215
+ return sendJson(res, 200, {
216
+ mode: MODE,
217
+ agentId: AGENT_ID,
218
+ voiceId: VOICE_ID,
219
+ topic: MEETING_TOPIC,
220
+ language: LANGUAGE,
221
+ });
222
+ }
223
+
224
+ if (req.method === 'GET' && url === '/signed-url') {
225
+ if (!API_KEY || !AGENT_ID) {
226
+ return sendJson(res, 200, { signedUrl: null });
227
+ }
228
+ try {
229
+ const r = await fetch(
230
+ `https://api.elevenlabs.io/v1/convai/conversation/get-signed-url?agent_id=${AGENT_ID}`,
231
+ { headers: { 'xi-api-key': API_KEY } }
232
+ );
233
+ const j = await r.json();
234
+ return sendJson(res, 200, { signedUrl: j.signed_url || null });
235
+ } catch (e) {
236
+ return sendJson(res, 200, { signedUrl: null, error: e.message });
237
+ }
238
+ }
239
+
240
+ if (req.method === 'POST' && url === '/tool') {
241
+ const { query } = await readBody(req);
242
+ if (!query) return sendJson(res, 400, { result: '(empty query)' });
243
+ const prompt =
244
+ `You are assisting a live spec meeting. Investigate this codebase and answer ` +
245
+ `concisely (max ~150 words), citing files where useful. Question: ${query}`;
246
+ const result = await runClaude(prompt, 120000);
247
+ findings.push({ query, result });
248
+ return sendJson(res, 200, { result });
249
+ }
250
+
251
+ if (req.method === 'POST' && url === '/turn') {
252
+ const { text } = await readBody(req);
253
+ if (!text) return sendJson(res, 400, { reply: '', notes: latestNotes });
254
+ const { reply, notes } = await claudeTurn(text);
255
+ return sendJson(res, 200, { reply, notes });
256
+ }
257
+
258
+ if (req.method === 'POST' && url === '/end') {
259
+ const { transcript, notesMd, notesHtml } = await readBody(req);
260
+ const text = (transcript || '').trim() || '(empty transcript)';
261
+ // Prefer the notes maintained live during the meeting; otherwise summarize the
262
+ // transcript in one pass (bundled mode, or if no notes were produced).
263
+ let notes = (notesMd || latestNotes || '').trim();
264
+ if (!notes) notes = await runClaude(SUMMARY_PROMPT(MEETING_TOPIC, text), 180000);
265
+ const { mdPath, htmlPath } = writeArtifacts({
266
+ projectRoot: PROJECT_ROOT,
267
+ meetingDir: MEETING_DIR,
268
+ topic: MEETING_TOPIC,
269
+ notesMd: notes,
270
+ notesHtml,
271
+ transcriptText: text,
272
+ findings,
273
+ });
274
+ sendJson(res, 200, { path: mdPath, htmlPath });
275
+ console.log(`\n ✓ Meeting artifacts written:\n ${mdPath}\n ${htmlPath}`);
276
+ setTimeout(() => server.close(() => process.exit(0)), 500);
277
+ return;
278
+ }
279
+
280
+ res.writeHead(404);
281
+ res.end('not found');
282
+ });
283
+
284
+ server.listen(PORT, () => {
285
+ console.log(`\n Catalyst meeting room: http://localhost:${PORT}`);
286
+ console.log(` Topic: ${MEETING_TOPIC} · Mode: ${MODE} · Language: ${LANGUAGE}`);
287
+ if (MODE === 'diy') {
288
+ console.log(' DIY mode: browser speech + local claude -p (no ElevenLabs, no cost).');
289
+ } else {
290
+ if (!API_KEY) console.log(' ⚠ No elevenlabs_api_key in .catalyst/secrets.local.yaml');
291
+ if (!AGENT_ID) console.log(' ⚠ No voice.agent_id in project-config.yaml');
292
+ }
293
+ });
@@ -0,0 +1,205 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Catalyst Meeting Room (DIY)</title>
7
+ <script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script>
8
+ <style>
9
+ :root { --bg:#0e0e14; --panel:#1a1a24; --accent:#4fd1a5; --text:#e8e8f0; --dim:#8a8a9a; }
10
+ * { box-sizing: border-box; }
11
+ body { margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
12
+ background:var(--bg); color:var(--text); }
13
+ .room { max-width:1080px; margin:0 auto; padding:28px 24px; }
14
+ h1 { font-size:20px; font-weight:600; margin:0 0 4px; }
15
+ .topic { color:var(--dim); margin:0 0 22px; font-size:14px; }
16
+ .split { display:flex; gap:20px; align-items:flex-start; }
17
+ .notes-pane { flex:1; min-width:0; background:var(--panel); border:1px solid #2a2a36;
18
+ border-radius:12px; overflow:hidden; }
19
+ .chat-pane { width:360px; flex:none; }
20
+ @media (max-width:820px){ .split{flex-direction:column;} .chat-pane{width:100%;} }
21
+ .pane-h { padding:12px 16px; font-size:13px; font-weight:600; color:var(--dim);
22
+ border-bottom:1px solid #2a2a36; letter-spacing:.04em; text-transform:uppercase; }
23
+ .notes { padding:6px 20px 18px; max-height:62vh; overflow-y:auto; font-size:14px; line-height:1.55; }
24
+ .notes h1 { font-size:18px; } .notes h2 { font-size:15px; color:var(--accent); margin:18px 0 6px; }
25
+ .notes ul { margin:6px 0; padding-left:20px; } .notes li { margin:3px 0; }
26
+ .notes .empty { color:var(--dim); font-style:italic; }
27
+ .controls { text-align:center; }
28
+ .orb { width:84px; height:84px; border-radius:50%; margin:6px auto 12px;
29
+ background:radial-gradient(circle at 35% 35%, #8fe6c8, var(--accent));
30
+ box-shadow:0 0 0 0 rgba(79,209,165,.5); transition:transform .2s; }
31
+ .orb.listening { animation:pulse 2s infinite; }
32
+ .orb.speaking { animation:pulse .7s infinite; transform:scale(1.08); }
33
+ .orb.thinking { background:radial-gradient(circle at 35% 35%,#ffd27a,#ff9b3d); animation:spin 1.1s linear infinite; }
34
+ @keyframes pulse { 0%{box-shadow:0 0 0 0 rgba(79,209,165,.5);} 70%{box-shadow:0 0 0 18px rgba(79,209,165,0);} 100%{box-shadow:0 0 0 0 rgba(79,209,165,0);} }
35
+ @keyframes spin { to { transform:rotate(360deg);} }
36
+ .status { color:var(--dim); min-height:20px; margin-bottom:14px; font-size:13px; }
37
+ .btns { display:flex; gap:10px; justify-content:center; }
38
+ button { border:none; border-radius:10px; padding:10px 18px; font-size:14px; font-weight:600; cursor:pointer; }
39
+ .start { background:var(--accent); color:#06281e; } .end { background:#2a2a36; color:var(--text); }
40
+ button:disabled { opacity:.4; cursor:not-allowed; }
41
+ .chat { display:flex; gap:8px; margin-top:14px; }
42
+ .chat input { flex:1; min-width:0; background:var(--panel); border:1px solid #2a2a36; color:var(--text);
43
+ border-radius:8px; padding:9px 11px; font-size:14px; }
44
+ .chat input:focus { outline:none; border-color:var(--accent); }
45
+ .chat button { background:#2a2a36; color:var(--text); }
46
+ .log { margin-top:14px; background:var(--panel); border:1px solid #2a2a36; border-radius:10px;
47
+ padding:12px; max-height:34vh; overflow-y:auto; font-size:13px; line-height:1.5; }
48
+ .log .u { color:#7fd1ff; } .log .a { color:#8fe6c8; } .log .t { color:var(--dim); font-style:italic; }
49
+ .hint { color:var(--dim); font-size:12px; margin-top:16px; }
50
+ .done { text-align:center; padding:30px; } .done code { background:var(--panel); padding:4px 8px; border-radius:6px; }
51
+ </style>
52
+ </head>
53
+ <body>
54
+ <div class="room">
55
+ <h1>Catalyst Meeting Room</h1>
56
+ <p class="topic" id="topic">Loading…</p>
57
+ <div class="split">
58
+ <section class="notes-pane">
59
+ <div class="pane-h">Meeting Notes — live</div>
60
+ <div class="notes" id="notes"><p class="empty">Notes will build up here as you talk…</p></div>
61
+ </section>
62
+ <section class="chat-pane">
63
+ <div class="controls">
64
+ <div class="orb" id="orb"></div>
65
+ <div class="status" id="status">Ready when you are.</div>
66
+ <div class="btns">
67
+ <button class="start" id="startBtn">Start</button>
68
+ <button class="end" id="endBtn" disabled>End meeting</button>
69
+ </div>
70
+ </div>
71
+ <div class="chat" id="chatRow" hidden>
72
+ <input id="chatInput" type="text" placeholder="Type instead of speaking…" autocomplete="off" />
73
+ <button id="chatSend">Send</button>
74
+ </div>
75
+ <div class="log" id="log"></div>
76
+ </section>
77
+ </div>
78
+ <p class="hint">Free mode: browser speech + Claude reading the codebase. No ElevenLabs.</p>
79
+ </div>
80
+
81
+ <script>
82
+ const $ = (id) => document.getElementById(id);
83
+ const orb = $('orb'), status = $('status'), log = $('log'), notes = $('notes');
84
+ const startBtn = $('startBtn'), endBtn = $('endBtn');
85
+ const chatRow = $('chatRow'), chatInput = $('chatInput'), chatSend = $('chatSend');
86
+
87
+ const LANG_MAP = { tr: 'tr-TR', en: 'en-US', de: 'de-DE', fr: 'fr-FR', es: 'es-ES' };
88
+ const transcript = [];
89
+ let lang = 'en-US', ended = false, busy = false;
90
+ let recognition = null, wantListen = false;
91
+ let lastNotesMd = '', lastNotesHtml = '';
92
+
93
+ fetch('/config').then(r => r.json()).then(c => {
94
+ $('topic').textContent = c.topic || 'Untitled meeting';
95
+ lang = LANG_MAP[c.language] || c.language || 'en-US';
96
+ });
97
+
98
+ function setStatus(t) { status.textContent = t; }
99
+ function setOrb(cls) { orb.className = 'orb' + (cls ? ' ' + cls : ''); }
100
+ function esc(s){ return String(s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); }
101
+ function addLine(kind, text) {
102
+ const cls = kind === 'user' ? 'u' : kind === 'ai' ? 'a' : 't';
103
+ const who = kind === 'user' ? 'You' : kind === 'ai' ? 'Catalyst' : '·';
104
+ const div = document.createElement('div');
105
+ div.className = cls; div.textContent = `${who}: ${text}`;
106
+ log.appendChild(div); log.scrollTop = log.scrollHeight;
107
+ if (kind === 'user' || kind === 'ai') transcript.push(`${who}: ${text}`);
108
+ }
109
+ function renderNotes(md) {
110
+ if (!md) return;
111
+ lastNotesMd = md;
112
+ lastNotesHtml = (window.marked && marked.parse) ? marked.parse(md) : `<pre>${esc(md)}</pre>`;
113
+ notes.innerHTML = lastNotesHtml;
114
+ }
115
+
116
+ function speak(text) {
117
+ return new Promise((resolve) => {
118
+ if (!('speechSynthesis' in window)) return resolve();
119
+ const u = new SpeechSynthesisUtterance(text);
120
+ u.lang = lang; u.onend = resolve; u.onerror = resolve;
121
+ setOrb('speaking'); setStatus('Catalyst is speaking…');
122
+ speechSynthesis.speak(u);
123
+ });
124
+ }
125
+
126
+ async function handleTurn(text) {
127
+ if (!text || busy || ended) return;
128
+ busy = true; stopListening();
129
+ addLine('user', text);
130
+ setOrb('thinking'); setStatus('Thinking… (reading the codebase)');
131
+ let reply = '(no response)';
132
+ try {
133
+ const r = await fetch('/turn', {
134
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
135
+ body: JSON.stringify({ text }),
136
+ });
137
+ const data = await r.json();
138
+ reply = data.reply || reply;
139
+ renderNotes(data.notes);
140
+ } catch (e) { reply = 'Error: ' + (e?.message || e); }
141
+ addLine('ai', reply);
142
+ await speak(reply);
143
+ busy = false;
144
+ if (!ended) startListening();
145
+ }
146
+
147
+ function buildRecognition() {
148
+ const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
149
+ if (!SR) return null;
150
+ const rec = new SR();
151
+ rec.lang = lang; rec.continuous = false; rec.interimResults = true;
152
+ rec.onresult = (e) => {
153
+ let finalText = '';
154
+ for (let i = e.resultIndex; i < e.results.length; i++) {
155
+ if (e.results[i].isFinal) finalText += e.results[i][0].transcript;
156
+ else setStatus('… ' + e.results[i][0].transcript);
157
+ }
158
+ if (finalText.trim()) handleTurn(finalText.trim());
159
+ };
160
+ rec.onend = () => { if (wantListen && !busy && !ended) { try { rec.start(); } catch {} } };
161
+ rec.onerror = (ev) => { if (ev.error === 'not-allowed') setStatus('Mic blocked — use the text box.'); };
162
+ return rec;
163
+ }
164
+ function startListening() {
165
+ wantListen = true; if (!recognition) return;
166
+ setOrb('listening'); setStatus('Listening…');
167
+ try { recognition.start(); } catch {}
168
+ }
169
+ function stopListening() { wantListen = false; if (recognition) { try { recognition.stop(); } catch {} } }
170
+
171
+ startBtn.onclick = () => {
172
+ startBtn.disabled = true; endBtn.disabled = false; chatRow.hidden = false;
173
+ recognition = buildRecognition();
174
+ if (!recognition) { setStatus('Speech needs Chrome. You can still type below.'); setOrb(''); return; }
175
+ startListening();
176
+ };
177
+
178
+ function sendChat() {
179
+ const t = chatInput.value.trim(); if (!t) return;
180
+ chatInput.value = ''; handleTurn(t);
181
+ }
182
+ chatSend.onclick = sendChat;
183
+ chatInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendChat(); });
184
+
185
+ endBtn.onclick = async () => {
186
+ ended = true; endBtn.disabled = true; stopListening();
187
+ if ('speechSynthesis' in window) speechSynthesis.cancel();
188
+ setOrb('thinking'); setStatus('Wrapping up…');
189
+ try {
190
+ const r = await fetch('/end', {
191
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
192
+ body: JSON.stringify({ transcript: transcript.join('\n'), notesMd: lastNotesMd, notesHtml: lastNotesHtml }),
193
+ });
194
+ const j = await r.json();
195
+ document.querySelector('.room').innerHTML =
196
+ `<div class="done"><h1>Meeting captured ✓</h1>` +
197
+ `<p>Notes saved to:</p><p><code>${j.path}</code><br><code>${j.htmlPath || ''}</code></p>` +
198
+ `<p class="topic">Back in your terminal, shape it with<br>` +
199
+ `<code>/catalyze-spec @${j.path}</code></p>` +
200
+ `<p class="topic">You can close this tab.</p></div>`;
201
+ } catch (e) { setStatus('Failed to save: ' + (e?.message || e)); }
202
+ };
203
+ </script>
204
+ </body>
205
+ </html>
@@ -0,0 +1,198 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Catalyst Meeting Room</title>
7
+ <style>
8
+ :root { --bg:#0e0e14; --panel:#1a1a24; --accent:#9b6dff; --text:#e8e8f0; --dim:#8a8a9a; }
9
+ * { box-sizing: border-box; }
10
+ body { margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
11
+ background:var(--bg); color:var(--text); display:flex; min-height:100vh; }
12
+ .room { margin:auto; width:100%; max-width:680px; padding:32px; }
13
+ h1 { font-size:20px; font-weight:600; margin:0 0 4px; }
14
+ .topic { color:var(--dim); margin:0 0 28px; font-size:14px; }
15
+ .orb { width:120px; height:120px; border-radius:50%; margin:24px auto;
16
+ background:radial-gradient(circle at 35% 35%, #b89bff, var(--accent));
17
+ box-shadow:0 0 0 0 rgba(155,109,255,.5); transition:transform .2s; }
18
+ .orb.listening { animation:pulse 2s infinite; }
19
+ .orb.speaking { animation:pulse .7s infinite; transform:scale(1.08); }
20
+ .orb.thinking { background:radial-gradient(circle at 35% 35%,#ffd27a,#ff9b3d);
21
+ animation:spin 1.1s linear infinite; }
22
+ @keyframes pulse { 0%{box-shadow:0 0 0 0 rgba(155,109,255,.5);} 70%{box-shadow:0 0 0 22px rgba(155,109,255,0);} 100%{box-shadow:0 0 0 0 rgba(155,109,255,0);} }
23
+ @keyframes spin { to { transform:rotate(360deg);} }
24
+ .status { text-align:center; color:var(--dim); min-height:22px; margin:8px 0 24px; font-size:14px; }
25
+ .btns { display:flex; gap:12px; justify-content:center; }
26
+ button { border:none; border-radius:10px; padding:12px 22px; font-size:15px;
27
+ font-weight:600; cursor:pointer; }
28
+ .start { background:var(--accent); color:#fff; }
29
+ .end { background:#2a2a36; color:var(--text); }
30
+ button:disabled { opacity:.4; cursor:not-allowed; }
31
+ .log { margin-top:28px; background:var(--panel); border-radius:12px; padding:16px;
32
+ max-height:34vh; overflow-y:auto; font-size:13px; line-height:1.6; }
33
+ .log .u { color:#7fd1ff; } .log .a { color:#c8b0ff; } .log .t { color:var(--dim); font-style:italic; }
34
+ .done { text-align:center; padding:20px; }
35
+ .done code { background:var(--panel); padding:4px 8px; border-radius:6px; }
36
+ .chat { display:flex; gap:8px; margin-top:16px; }
37
+ .chat input { flex:1; background:var(--panel); border:1px solid #2a2a36; color:var(--text);
38
+ border-radius:8px; padding:10px 12px; font-size:14px; }
39
+ .chat input:focus { outline:none; border-color:var(--accent); }
40
+ .chat button { background:#2a2a36; color:var(--text); }
41
+ </style>
42
+ </head>
43
+ <body>
44
+ <div class="room">
45
+ <h1>Catalyst Meeting Room</h1>
46
+ <p class="topic" id="topic">Loading…</p>
47
+ <div class="orb" id="orb"></div>
48
+ <div class="status" id="status">Ready when you are.</div>
49
+ <div class="btns">
50
+ <button class="start" id="startBtn">Start meeting</button>
51
+ <button class="end" id="endBtn" disabled>End meeting</button>
52
+ </div>
53
+ <div class="chat" id="chatRow" hidden>
54
+ <input id="chatInput" type="text" placeholder="Type a message instead of speaking…" autocomplete="off" />
55
+ <button id="chatSend">Send</button>
56
+ </div>
57
+ <div class="log" id="log" hidden></div>
58
+ </div>
59
+
60
+ <script type="module">
61
+ import { Conversation } from 'https://esm.sh/@elevenlabs/client';
62
+
63
+ const $ = (id) => document.getElementById(id);
64
+ const orb = $('orb'), status = $('status'), log = $('log');
65
+ const startBtn = $('startBtn'), endBtn = $('endBtn');
66
+ const chatRow = $('chatRow'), chatInput = $('chatInput'), chatSend = $('chatSend');
67
+ let conversation = null;
68
+ const transcript = [];
69
+
70
+ fetch('/config').then(r => r.json()).then(c => {
71
+ $('topic').textContent = c.topic || 'Untitled meeting';
72
+ });
73
+
74
+ function setStatus(t) { status.textContent = t; }
75
+ function setOrb(cls) { orb.className = 'orb' + (cls ? ' ' + cls : ''); }
76
+ function addLine(kind, text) {
77
+ log.hidden = false;
78
+ const cls = kind === 'user' ? 'u' : kind === 'ai' ? 'a' : 't';
79
+ const who = kind === 'user' ? 'You' : kind === 'ai' ? 'Catalyst' : '·';
80
+ const div = document.createElement('div');
81
+ div.className = cls;
82
+ div.textContent = `${who}: ${text}`;
83
+ log.appendChild(div);
84
+ log.scrollTop = log.scrollHeight;
85
+ if (kind === 'user' || kind === 'ai') transcript.push(`${who}: ${text}`);
86
+ }
87
+
88
+ // Client tool: live codebase investigation via the local daemon -> claude -p.
89
+ const clientTools = {
90
+ investigate_codebase: async ({ query }) => {
91
+ const prev = orb.className;
92
+ setOrb('thinking');
93
+ setStatus(`🔍 Investigating: ${query}`);
94
+ addLine('tool', `investigating: ${query}`);
95
+ try {
96
+ const r = await fetch('/tool', {
97
+ method: 'POST',
98
+ headers: { 'Content-Type': 'application/json' },
99
+ body: JSON.stringify({ query }),
100
+ });
101
+ const j = await r.json();
102
+ addLine('tool', `findings ready`);
103
+ return j.result || 'No result.';
104
+ } catch (e) {
105
+ return `Investigation failed: ${e.message}`;
106
+ } finally {
107
+ orb.className = prev;
108
+ setStatus('Listening…');
109
+ }
110
+ },
111
+ };
112
+
113
+ startBtn.onclick = async () => {
114
+ startBtn.disabled = true;
115
+ setStatus('Connecting…');
116
+ try {
117
+ const cfg = await (await fetch('/config')).json();
118
+ const signed = await (await fetch('/signed-url')).json();
119
+ const opts = {
120
+ clientTools,
121
+ onConnect: () => { setStatus('Listening…'); setOrb('listening'); endBtn.disabled = false; chatRow.hidden = false; },
122
+ onDisconnect: () => { setOrb(''); },
123
+ onError: (e) => { setStatus('Error: ' + (e?.message || e)); },
124
+ onModeChange: ({ mode }) => {
125
+ setOrb(mode === 'speaking' ? 'speaking' : 'listening');
126
+ setStatus(mode === 'speaking' ? 'Catalyst is speaking…' : 'Listening…');
127
+ },
128
+ onMessage: (payload) => {
129
+ // SDK versions differ: payload may be {message, source} or a bare string.
130
+ const raw = payload && typeof payload === 'object' ? payload.message ?? payload : payload;
131
+ const text = typeof raw === 'string' ? raw : (raw && raw.message) || JSON.stringify(raw);
132
+ const src = payload && payload.source;
133
+ if (text) addLine(src === 'user' ? 'user' : 'ai', text);
134
+ },
135
+ };
136
+ if (signed.signedUrl) opts.signedUrl = signed.signedUrl;
137
+ else opts.agentId = cfg.agentId;
138
+ // Drive the conversation language from project-config (voice.language).
139
+ // Requires the language override to be enabled on the agent; if it isn't,
140
+ // ElevenLabs rejects the override, so we retry once without it.
141
+ if (cfg.language) opts.overrides = { agent: { language: cfg.language } };
142
+ try {
143
+ conversation = await Conversation.startSession(opts);
144
+ } catch (err) {
145
+ if (opts.overrides) {
146
+ console.warn('Language override rejected (enable it on the agent). Retrying without it.', err);
147
+ delete opts.overrides;
148
+ conversation = await Conversation.startSession(opts);
149
+ } else {
150
+ throw err;
151
+ }
152
+ }
153
+ } catch (e) {
154
+ setStatus('Could not start: ' + (e?.message || e));
155
+ startBtn.disabled = false;
156
+ }
157
+ };
158
+
159
+ // Typed messages — useful when speech is misheard. Treated as a user turn.
160
+ function sendChat() {
161
+ const t = chatInput.value.trim();
162
+ if (!t || !conversation) return;
163
+ try {
164
+ conversation.sendUserMessage(t);
165
+ addLine('user', t);
166
+ chatInput.value = '';
167
+ } catch (e) {
168
+ addLine('tool', 'could not send message: ' + (e?.message || e));
169
+ }
170
+ }
171
+ chatSend.onclick = sendChat;
172
+ chatInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendChat(); });
173
+
174
+ endBtn.onclick = async () => {
175
+ endBtn.disabled = true;
176
+ setStatus('Wrapping up…');
177
+ setOrb('thinking');
178
+ try { if (conversation) await conversation.endSession(); } catch {}
179
+ try {
180
+ const r = await fetch('/end', {
181
+ method: 'POST',
182
+ headers: { 'Content-Type': 'application/json' },
183
+ body: JSON.stringify({ transcript: transcript.join('\n') }),
184
+ });
185
+ const j = await r.json();
186
+ document.querySelector('.room').innerHTML =
187
+ `<div class="done"><h1>Meeting captured ✓</h1>` +
188
+ `<p>Artifact written to:</p><p><code>${j.path}</code></p>` +
189
+ `<p class="topic">Back in your terminal, shape it with<br>` +
190
+ `<code>/catalyze-spec @${j.path}</code></p>` +
191
+ `<p class="topic">You can close this tab.</p></div>`;
192
+ } catch (e) {
193
+ setStatus('Failed to save: ' + (e?.message || e));
194
+ }
195
+ };
196
+ </script>
197
+ </body>
198
+ </html>
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "catalyst-os-voice",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "description": "Local voice-meeting runtime for /meet-spec. Zero npm dependencies — Node stdlib only; the browser loads the ElevenLabs SDK from a CDN.",
6
+ "type": "commonjs",
7
+ "main": "meet-server.js",
8
+ "engines": {
9
+ "node": ">=18.0.0"
10
+ }
11
+ }
@@ -6,8 +6,9 @@ description: >
6
6
  - Implementation is complete and needs quality checks
7
7
  - Production readiness needs to be verified
8
8
 
9
- This agent orchestrates Guardians (Enforcer, Sentinel, Inquisitor, Watcher)
10
- to run tests, check code quality, scan for security issues, and verify compliance.
9
+ This agent orchestrates Guardians (Enforcer, Sentinel, Inquisitor, Watcher,
10
+ and Curator for database specs) to run tests, check code quality, scan for
11
+ security issues, verify schema integrity, and verify compliance.
11
12
 
12
13
  DO NOT validate implementations yourself - delegate to Arbiter.
13
14
  model: opus
@@ -56,6 +57,7 @@ Before any action, load `.claude/skills/using-skills/SKILL.md` and check which s
56
57
  4. **Coverage**: Meets coverage thresholds
57
58
  5. **Performance**: No regressions detected
58
59
  6. **Documentation**: Required docs are present
60
+ 7. **Schema Integrity** (database specs only): Live DB matches the spec — delegate to Curator (read-only), never to the Alchemist that built it
59
61
 
60
62
  ## Output
61
63
 
@@ -0,0 +1,75 @@
1
+ ---
2
+ name: curator
3
+ description: >
4
+ PROACTIVELY DELEGATE schema-integrity audits to this agent. MUST BE USED when:
5
+ - Verifying a built implementation matches the actual database schema
6
+ - Confirming columns, constraints, and foreign keys exist in the real DB
7
+ - Tracing that API endpoints persist every field the spec expects
8
+ - Validating SQL functions, sequences, or triggers the feature depends on
9
+
10
+ This is a READ-ONLY Guardian. It audits the database — it never creates or
11
+ modifies schemas, migrations, or data. Building is the Alchemist's job.
12
+
13
+ DO NOT audit database integrity yourself - delegate to Curator.
14
+ model: sonnet
15
+ color: cyan
16
+ skills: secret-scanning
17
+ ---
18
+
19
+ You are the Curator, a schema-integrity auditor who verifies that the
20
+ implementation matches the real database.
21
+
22
+ ## Opening
23
+
24
+ *"Reconciling the schema against reality..."*
25
+
26
+ ## Role
27
+
28
+ You are a **Guardian** in the `/audit-spec` validation phase, orchestrated by the
29
+ Arbiter. You audit — you do not build. The Alchemist designs and migrates the
30
+ schema during `/forge-spec`; you independently verify that what was built is real
31
+ and complete. This separation is the point: the agent that creates a table must
32
+ never be the one that signs off on it.
33
+
34
+ ## Read-Only Discipline — BLOCKING
35
+
36
+ - **NEVER** run migrations, `ALTER`/`CREATE`/`DROP`, or any write/DDL statement.
37
+ - **NEVER** edit migration files, schema definitions, or application code.
38
+ - Use read-only queries only (`SELECT`, `information_schema`, `pg_catalog`, describe).
39
+ - If you discover a defect, you **report** it — you do not fix it. Remediation
40
+ goes back to the Alchemist via `/forge-spec`.
41
+
42
+ ## Audit Checklist (only for specs touching the database)
43
+
44
+ 1. **Tables exist**: Every table the spec/code references exists in the live DB.
45
+ 2. **Columns match**: Real column names and types match what the spec and code
46
+ assume — check against `information_schema.columns`, not code aliases.
47
+ 3. **Constraints & keys**: All foreign keys, unique constraints, NOT NULL, and
48
+ check constraints the spec requires actually exist.
49
+ 4. **End-to-end persistence**: Trace each field the spec expects through the API
50
+ path (API call → DB write → DB read → API response). Flag any dropped field.
51
+ 5. **Functions & procedures**: Any SQL functions/stored procedures the feature
52
+ calls actually exist with the expected signature.
53
+ 6. **DB-level machinery**: Sequences, triggers, and DB-level defaults the feature
54
+ assumes are present.
55
+
56
+ ## Verification Before Reporting
57
+
58
+ Follow `.claude/skills/verification-before-completion/SKILL.md`. Every claim must
59
+ be backed by the actual query output — paste it. "The column exists" is not
60
+ evidence; the `information_schema` row is.
61
+
62
+ ## Output
63
+
64
+ Provide a schema-integrity report to the Arbiter:
65
+
66
+ - Overall status: PASS / FAIL
67
+ - Tables / columns verified (with query evidence)
68
+ - Constraints and foreign keys verified
69
+ - API end-to-end trace result per expected field
70
+ - DB functions / sequences / triggers verified
71
+ - Issues found, by severity, each with a remediation note pointing back to
72
+ `/forge-spec` (Alchemist)
73
+
74
+ Block on any mismatch between what the spec expects and what the database
75
+ actually contains.
@@ -28,6 +28,6 @@ echo "=== FILES CHANGED ===" && git diff --name-only main...HEAD 2>/dev/null ||
28
28
 
29
29
  **Invoke skill:** `spec-validation`
30
30
 
31
- **Orchestrator:** Arbiter (delegates to Enforcer, Sentinel, Inquisitor, Watcher)
31
+ **Orchestrator:** Arbiter (delegates to Enforcer, Sentinel, Inquisitor, Watcher, and Curator for database specs)
32
32
 
33
33
  **Process skills used:** `verification-before-completion`, `agent-delegation`, `test-driven-development`
@@ -0,0 +1,43 @@
1
+ # /meet-spec
2
+
3
+ Hold a **voice meeting** with Catalyst, then turn the notes into a spec.
4
+
5
+ Start a local browser meeting room. Talk through a feature; the meeting agent can
6
+ investigate the codebase, docs, and web live (via `claude -p` → Seer/Scout). When you
7
+ click **End meeting**, a structured, `/catalyze-spec`-ready notes artifact is written.
8
+
9
+ Default mode is **diy** — free, browser speech + local Claude, no API key. Switch
10
+ `voice.mode` to `bundled` for ElevenLabs' nicer (paid) voice.
11
+
12
+ > **Lifecycle position:** **`/meet-spec`** → `/catalyze-spec` → *(optional)* `/challenge-spec` → `/forge-spec` → `/audit-spec` → `/seal-spec`
13
+
14
+ ## Usage
15
+
16
+ ```
17
+ /meet-spec "realtime notifications"
18
+ /meet-spec # will ask for the meeting topic
19
+ ```
20
+
21
+ The output is a meeting artifact, NOT a spec. After the meeting, shape it yourself:
22
+
23
+ ```
24
+ /catalyze-spec @.catalyst/meetings/YYYY-MM-DD-{slug}/meeting.md
25
+ ```
26
+
27
+ ## Pre-computed Context
28
+
29
+ ```bash
30
+ echo "=== NODE VERSION ===" && node --version
31
+ echo "=== CLAUDE CLI? ===" && command -v claude >/dev/null && echo "claude found" || echo "MISSING claude CLI (needed for the brain)"
32
+ echo "=== VOICE CONFIG ===" && sed -n '/^voice:/,/^[^ ]/p' .catalyst/main/project-config.yaml 2>/dev/null || echo "No voice config"
33
+ echo "=== VOICE RUNTIME? ===" && test -f .catalyst/voice/meet-server.js && echo "meet-server.js found" || echo "MISSING .catalyst/voice/ (re-install catalyst-os)"
34
+ echo "=== SECRETS (bundled mode only) ===" && test -f .catalyst/secrets.local.yaml && echo "secrets.local.yaml found" || echo "no secrets.local.yaml (only needed for bundled mode)"
35
+ ```
36
+
37
+ ---
38
+
39
+ **Invoke skill:** `meet-spec`
40
+
41
+ **Orchestrator:** Catalyst (the meeting persona lives in the ElevenLabs agent prompt; codebase investigation delegates to Seer/Scout via headless `claude -p`)
42
+
43
+ **Process skills used:** `brainstorming`, `agent-delegation`
@@ -18,3 +18,19 @@ Commit, archive, and propagate learnings from a **fully built and audited** impl
18
18
  **Invoke skill:** `spec-approval`
19
19
 
20
20
  **Process skills used:** `verification-before-completion`, `agent-delegation`
21
+
22
+ ---
23
+
24
+ ## Execution Mode
25
+
26
+ `/seal-spec` is the user's explicit authorization to run the **full ceremony end-to-end**:
27
+
28
+ > commit → archive → push → **create PR** → self-document
29
+
30
+ Do **not** stop and ask for confirmation between phases. In particular, after `git push` succeeds, immediately run `gh pr create` in the same turn. The only valid reasons to halt are:
31
+
32
+ - A hard gate from Phase 1 fails (TDD missing, validation not passed, handoff missing)
33
+ - A `git` or `gh` command returns an error
34
+ - The working tree contains unexpected uncommitted state
35
+
36
+ If everything is clean, the entire flow runs to completion and reports the PR URL at the end.
@@ -0,0 +1,112 @@
1
+ # Meet Spec
2
+
3
+ > **When to invoke:** When the user wants to shape a spec by holding a voice meeting.
4
+ > **Invoked by:** `/meet-spec` command.
5
+ > **Orchestrator:** Catalyst agent.
6
+
7
+ ## Purpose
8
+
9
+ Run a local, browser-based voice meeting (ElevenLabs) where the user talks through a
10
+ feature. The meeting agent can investigate the codebase live. When the meeting ends,
11
+ write a structured, `/catalyze-spec`-ready notes artifact.
12
+
13
+ **This skill does NOT produce a spec.** It produces a meeting artifact. The user runs
14
+ `/catalyze-spec @<artifact>` afterwards, when they choose to.
15
+
16
+ ## Output Location
17
+
18
+ ```
19
+ .catalyst/meetings/YYYY-MM-DD-{slug}/
20
+ └── meeting.md # Summary + Decisions + Action Items + Open Questions + Research Findings + Transcript
21
+ ```
22
+
23
+ ## Modes
24
+
25
+ `voice.mode` in `project-config.yaml` picks how the meeting runs:
26
+
27
+ - **diy** (default, free) — the browser does speech (Web Speech API) and the brain is a
28
+ local `claude -p`, which reads the codebase/docs and searches the web natively. No
29
+ ElevenLabs, no API key, no per-minute cost. Needs Chrome and the `claude` CLI on PATH.
30
+ - **bundled** — ElevenLabs Conversational AI. Nicer voice, but billed per minute (BYOK).
31
+ Requires `elevenlabs_api_key` in `.catalyst/secrets.local.yaml`, `voice.agent_id` in
32
+ config, and an agent that has a `investigate_codebase` client tool. See the README.
33
+
34
+ `language` sets the spoken meeting language (e.g. `"tr"`); written artifacts stay English.
35
+
36
+ ## Workflow
37
+
38
+ ### Phase 0: Preflight (stop early if not ready)
39
+
40
+ 1. Read `.catalyst/main/project-config.yaml`. If `voice.enabled` is not `true`, stop and
41
+ tell the user how to enable it. Do not proceed.
42
+ 2. Check `node --version` is >= 18 and `.catalyst/voice/meet-server.js` exists; otherwise
43
+ stop and explain (re-install catalyst-os if the runtime is missing).
44
+ 3. **Bundled mode only:** check `.catalyst/secrets.local.yaml` has a non-empty
45
+ `elevenlabs_api_key` and `voice.agent_id` is set. If missing, stop with setup
46
+ instructions — do NOT print or echo the key. In **diy** mode, skip this check entirely.
47
+
48
+ **On any failure: stop gracefully. Voice is optional; never break the rest of Catalyst.**
49
+
50
+ ### Phase 1: Initialize meeting folder
51
+
52
+ 1. Determine the topic from `$ARGUMENTS`; if empty, ask the user one short question.
53
+ 2. Derive a kebab-case `{slug}` from the topic.
54
+ 3. Create `.catalyst/meetings/YYYY-MM-DD-{slug}/` (today's date).
55
+
56
+ ### Phase 2: Launch the meeting room
57
+
58
+ Start the local daemon in the background and open the browser. The server reads config
59
+ (and, in bundled mode, the key from `secrets.local.yaml`) itself — never pass the key on
60
+ the command line or env.
61
+
62
+ ```bash
63
+ PORT=4399 \
64
+ MEETING_DIR=".catalyst/meetings/YYYY-MM-DD-{slug}" \
65
+ MEETING_TOPIC="{topic}" \
66
+ node .catalyst/voice/meet-server.js &
67
+ ```
68
+
69
+ Run it from the project root (so `claude -p` investigations resolve against this repo).
70
+ Then open `http://localhost:4399` (e.g. `open`, `xdg-open`, or `start`). Tell the user:
71
+ "The meeting room is open. Click **Start meeting**, talk it through, then click **End
72
+ meeting** when you're done."
73
+
74
+ ### Phase 3: Wait for the artifact
75
+
76
+ Poll for `.catalyst/meetings/YYYY-MM-DD-{slug}/meeting.md`. It is written when the user
77
+ clicks **End meeting** (the server folds the transcript + live findings into the artifact
78
+ via a single `claude -p` summarization pass). When it appears, the meeting is over — the
79
+ server shuts itself down.
80
+
81
+ ### Phase 4: Output
82
+
83
+ ```
84
+ Meeting captured.
85
+
86
+ Artifact: .catalyst/meetings/YYYY-MM-DD-{slug}/meeting.md
87
+ ├── Summary
88
+ ├── Decisions
89
+ ├── Action Items
90
+ ├── Open Questions
91
+ ├── Research Findings (live codebase investigations from the meeting)
92
+ └── Transcript
93
+
94
+ Next step — shape it into a spec when you're ready:
95
+ /catalyze-spec @.catalyst/meetings/YYYY-MM-DD-{slug}/meeting.md
96
+ ```
97
+
98
+ **Do NOT auto-run `/catalyze-spec`.** The user references the artifact themselves.
99
+
100
+ ## Notes
101
+
102
+ - **Live codebase access:** in **diy** mode every turn is a headless `claude -p` in this
103
+ repo, so the agent reads the codebase/docs and searches the web natively. In **bundled**
104
+ mode the ElevenLabs agent calls the `investigate_codebase` client tool, which the browser
105
+ relays to the daemon, which runs the same `claude -p`. Either way it loads Catalyst's
106
+ agents and can delegate to Seer/Scout.
107
+ - **Typed messages:** the meeting room has a text box; typed messages are sent as user
108
+ turns (handy when speech is misheard) and appear in the transcript.
109
+ - **Latency:** investigations take seconds; the meeting room shows a "thinking" indicator
110
+ while the agent says it is looking into it. This is expected meeting behavior.
111
+ - **Cost:** billed to the user's ElevenLabs account (~$0.10/min bundled). A 30-min meeting
112
+ is ~$3.
@@ -73,13 +73,20 @@ Update spec.md status to COMPLETE.
73
73
 
74
74
  ### Phase 7: Push & Pull Request
75
75
 
76
+ > **This phase is atomic — do NOT stop or ask for confirmation between push and PR creation.**
77
+ > If validation passed in Phase 1 and the commit succeeded in Phase 5, proceed straight through push → PR without pausing.
78
+ > Only halt if a git/gh command returns an error.
79
+
76
80
  1. Read `git.development_branch` from `.catalyst/main/project-config.yaml` (e.g., `development`, `staging`)
77
81
  2. Push the feature branch: `git push -u origin {branch-name}`
78
- 3. Create a PR targeting the development branch:
82
+ 3. **Immediately** create a PR targeting the development branch — do not wait for user input:
79
83
  ```
80
84
  gh pr create --base {development_branch} --title "feat({scope}): {spec title}" --body "..."
81
85
  ```
82
86
  4. Include spec summary, TDD stats, and file change counts in the PR body
87
+ 5. Capture the returned PR URL for the final output
88
+
89
+ **Do not ask** "should I create a PR?" or "ready to push?" — the user already invoked `/seal-spec`, which is the explicit authorization for the full push + PR flow. Only pause if a command fails or the working tree is in an unexpected state.
83
90
 
84
91
  ### Phase 8: Self-Documentation
85
92
 
@@ -56,7 +56,7 @@ If TDD was skipped → REJECT and return to /forge-spec
56
56
  TDD Check (sequential, must pass first)
57
57
  |
58
58
  v
59
- [Enforcer + Sentinel + Inquisitor + Watcher] (all parallel)
59
+ [Enforcer + Sentinel + Inquisitor + Watcher + Curator] (all parallel)
60
60
  |
61
61
  v
62
62
  Arbiter compiles results → validation.md
@@ -98,7 +98,9 @@ Spawn all Guardians in parallel:
98
98
  - Secret scanning
99
99
  - Input validation checks
100
100
 
101
- **Alchemist** (Schema Integrity — only for specs touching database):
101
+ **Curator** (Schema Integrity — only for specs touching database):
102
+ - READ-ONLY Guardian — audits the schema, never creates or modifies it
103
+ - (The Alchemist *builds* the schema during `/forge-spec`; Curator independently *verifies* it here)
102
104
  - Query actual database schema for all tables the spec touches
103
105
  - Verify column names in spec/code match real database columns
104
106
  - Verify all foreign keys and constraints exist in the actual DB
@@ -147,7 +149,7 @@ If all validation passes, create handoff.md with:
147
149
  - Secrets: status
148
150
  - Inputs: status
149
151
 
150
- ### Schema Integrity (Alchemist) — if spec touches database
152
+ ### Schema Integrity (Curator) — if spec touches database
151
153
  - Column names match: status
152
154
  - Constraints verified: status
153
155
  - API end-to-end trace: status
@@ -41,6 +41,7 @@ Skills tell you HOW. User instructions tell you WHAT.
41
41
 
42
42
  | Skill | Path | Load when... |
43
43
  |-------|------|-------------|
44
+ | **meet-spec** | `.claude/skills/meet-spec/SKILL.md` | `/meet-spec` — shaping a spec via a voice meeting (optional) |
44
45
  | **spec-shaping** | `.claude/skills/spec-shaping/SKILL.md` | `/catalyze-spec` — shaping a new specification |
45
46
  | **spec-challenge** | `.claude/skills/spec-challenge/SKILL.md` | `/challenge-spec` — interrogating a shaped spec branch by branch (optional) |
46
47
  | **build-orchestration** | `.claude/skills/build-orchestration/SKILL.md` | `/forge-spec` — implementing a specification |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catalyst-os",
3
- "version": "2.0.3",
3
+ "version": "3.0.0",
4
4
  "scripts": {
5
5
  "postinstall": "node .catalyst/bin/install.js",
6
6
  "validate": "node .catalyst/bin/validate-artifacts.js"
@@ -13,6 +13,7 @@
13
13
  "AGENTS.md",
14
14
  ".claude",
15
15
  ".catalyst/bin",
16
+ ".catalyst/voice",
16
17
  ".catalyst/spec-structure.yaml",
17
18
  ".catalyst/main/project-config.yaml"
18
19
  ],