clickroom 0.0.0-stage → 0.2.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.
package/cli/gaps.js ADDED
@@ -0,0 +1,37 @@
1
+ // "Find gaps with Claude" (Clarify): the project's clickroom terminal runs Claude Code headless in
2
+ // the project folder. It may only read files and ask through clickroom's MCP server, so each gap it
3
+ // finds lands in Clarify as a draft, live, while nothing in the folder changes.
4
+ import path from 'node:path';
5
+ import { spawn, spawnSync } from 'node:child_process';
6
+ import { ROOT } from './config.js';
7
+ import { call } from './client.js';
8
+ import { gapsPrompt } from '../worker/handoff.js';
9
+
10
+ const TOOLS = ['Read', 'Glob', 'Grep', 'mcp__clickroom__clickroom_ask', 'mcp__clickroom__clickroom_get_questions'];
11
+ const NEVER = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', 'WebFetch', 'WebSearch'];
12
+ const LIMIT = 10 * 60 * 1000; // a run that hangs is stopped
13
+
14
+ export const hasClaude = () => spawnSync(process.platform === 'win32' ? 'where' : 'which', ['claude'], { stdio: 'ignore' }).status === 0;
15
+
16
+ const questionCount = async (name) =>
17
+ (await call('GET', `/__ps/api/projects/${encodeURIComponent(name)}/threads`)).threads.filter((t) => t.kind === 'question').length;
18
+
19
+ // Resolves to the number of questions it added (drafts), or throws.
20
+ export async function findGaps(name, root) {
21
+ if (!hasClaude()) throw new Error('Claude Code (`claude`) is not installed on the computer running `clickroom` for this project');
22
+ const before = await questionCount(name);
23
+ const mcp = JSON.stringify({ mcpServers: { clickroom: { command: process.execPath, args: [path.join(ROOT, 'bin', 'clickroom.js'), 'mcp'] } } });
24
+ const args = ['-p', gapsPrompt(name), '--mcp-config', mcp, '--strict-mcp-config', '--tools', 'Read,Glob,Grep', '--allowedTools', TOOLS.join(','), '--disallowedTools', NEVER.join(','), '--no-session-persistence'];
25
+ await new Promise((resolve, reject) => {
26
+ const child = spawn('claude', args, { cwd: root, stdio: ['ignore', 'ignore', 'pipe'] });
27
+ let err = '';
28
+ child.stderr.on('data', (d) => (err = (err + d).slice(-2000)));
29
+ const timer = setTimeout(() => child.kill('SIGTERM'), LIMIT);
30
+ child.on('error', reject);
31
+ child.on('exit', (code) => {
32
+ clearTimeout(timer);
33
+ code === 0 ? resolve() : reject(new Error(err.trim().split('\n').pop() || `claude exited with ${code}`));
34
+ });
35
+ });
36
+ return Math.max(0, (await questionCount(name)) - before);
37
+ }
package/cli/login.js ADDED
@@ -0,0 +1,74 @@
1
+ import os from 'node:os';
2
+ import { spawn } from 'node:child_process';
3
+ import { credentials, saveCredentials, adminToken } from './config.js';
4
+ import { bold, dim } from './dev.js';
5
+
6
+ export const DEFAULT_URL = 'https://clickroom.dev';
7
+
8
+ // `clickroom login [workspace|url]`: connects this computer to a clickroom workspace as you
9
+ // ("acme" means https://acme.clickroom.dev). It opens a page in your browser (where you're signed
10
+ // in from your invite) to approve this computer, then keeps a key of its own in
11
+ // ~/.clickroom/credentials.json.
12
+ export async function login(arg) {
13
+ if (arg && /^[a-z][a-z0-9-]{1,19}$/.test(arg)) arg = `https://${arg}.${new URL(DEFAULT_URL).hostname}`;
14
+ const url = (arg || process.env.CLICKROOM_URL || credentials()?.url || DEFAULT_URL).replace(/\/$/, '');
15
+ if (!/^https?:\/\//.test(url)) throw new Error('usage: clickroom login <workspace> (or a URL: clickroom login https://acme.clickroom.dev)');
16
+ if (adminToken() && !process.env.CLICKROOM_TOKEN) console.log(dim(' This computer has the admin key (.dev.vars), which the clickroom command uses first.'));
17
+ const res = await fetch(`${url}/__ps/api/cli-login`, {
18
+ method: 'POST',
19
+ headers: { 'content-type': 'application/json' },
20
+ body: JSON.stringify({ label: `${os.userInfo().username} on ${os.hostname().replace(/\.local$/, '')}` }),
21
+ }).catch(() => null);
22
+ if (!res) throw new Error(`Can't reach ${url}.`);
23
+ const start = await res.json().catch(() => ({}));
24
+ if (!res.ok) throw new Error(start.error || `${url} answered ${res.status}`);
25
+
26
+ console.log(`\n Approve this computer in your browser:\n ${bold(start.url)}\n`);
27
+ console.log(` Check that the page shows ${bold(start.code)}.`);
28
+ console.log(dim(' Not signed in there? Sign in with the email address you were invited with.\n'));
29
+ openUrl(start.url);
30
+
31
+ const until = Date.now() + start.expiresIn * 1000;
32
+ while (Date.now() < until) {
33
+ await new Promise((r) => setTimeout(r, 2000));
34
+ const r = await fetch(`${url}/__ps/api/cli-login/poll`, {
35
+ method: 'POST',
36
+ headers: { 'content-type': 'application/json' },
37
+ body: JSON.stringify({ poll: start.poll }),
38
+ }).catch(() => null);
39
+ if (!r?.ok) continue;
40
+ const d = await r.json();
41
+ if (d.status === 'expired') break;
42
+ if (d.status === 'approved') {
43
+ saveCredentials({ url, token: d.token, name: d.me.name, email: d.me.email || null, at: new Date().toISOString() });
44
+ console.log(`${bold('✔')} Signed in to ${url} as ${bold(d.me.name)}${d.me.email ? ` (${d.me.email})` : ''}.`);
45
+ console.log(dim(' Run `clickroom` in a project to share it.'));
46
+ return;
47
+ }
48
+ }
49
+ throw new Error('The approval link expired. Run `clickroom login` again.');
50
+ }
51
+
52
+ export async function logout() {
53
+ const c = credentials();
54
+ if (!c) return console.log('Not signed in.');
55
+ await fetch(`${c.url}/__ps/api/cli-login/logout`, { method: 'POST', headers: { authorization: `Bearer ${c.token}` } }).catch(() => {});
56
+ saveCredentials(null);
57
+ console.log(`${bold('✔')} Signed out of ${c.url}.`);
58
+ }
59
+
60
+ export async function whoami() {
61
+ const c = credentials();
62
+ if (!c) return console.log(adminToken() ? 'The owner (admin key in .dev.vars).' : 'Not signed in. Run `clickroom login`.');
63
+ const res = await fetch(`${c.url}/__ps/api/me`, { headers: { authorization: `Bearer ${c.token}` } }).catch(() => null);
64
+ if (res?.status === 401) return console.log(`Your sign-in to ${c.url} has ended. Run \`clickroom login\` again.`);
65
+ const me = res?.ok ? await res.json() : c;
66
+ console.log(`${me.name}${me.email ? ` (${me.email})` : ''} on ${c.url}`);
67
+ }
68
+
69
+ function openUrl(url) {
70
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
71
+ try {
72
+ spawn(cmd, [url], { stdio: 'ignore', detached: true }).on('error', () => {}).unref();
73
+ } catch {}
74
+ }
package/cli/mcp.js ADDED
@@ -0,0 +1,205 @@
1
+ import readline from 'node:readline';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { call, callBinary, callUpload } from './client.js';
5
+
6
+ // Minimal MCP server over stdio (newline-delimited JSON-RPC 2.0).
7
+ // Every tool proxies to the running clickroom worker so actions broadcast live to reviewers.
8
+
9
+ const AGENT = () => ({ name: process.env.CLICKROOM_AGENT_NAME || 'Claude', role: 'agent', color: '#7c3aed' });
10
+ const enc = encodeURIComponent;
11
+
12
+ const TOOLS = [
13
+ {
14
+ name: 'clickroom_list_projects',
15
+ description: 'List prototypes under review with feedback counts per status (open, queued, in_progress, review, resolved).',
16
+ inputSchema: { type: 'object', properties: {} },
17
+ run: async () => {
18
+ const { projects } = await call('GET', '/__ps/api/projects');
19
+ if (!projects.length) return 'No projects registered.';
20
+ return projects
21
+ .map((p) => `${p.name} (${p.kind}, source: ${p.source || p.target}): open ${p.counts.open}, queued ${p.counts.queued}, in_progress ${p.counts.in_progress}, review ${p.counts.review ?? 0}, resolved ${p.counts.resolved}, open questions ${p.counts.questions ?? 0}`)
22
+ .join('\n');
23
+ },
24
+ },
25
+ {
26
+ name: 'clickroom_get_feedback',
27
+ description:
28
+ 'Get client/team feedback for a prototype as an actionable Markdown digest: element selectors, text, HTML snippets and full conversations. Default status "actionable" = only the threads the team queued for agents; work on nothing else.',
29
+ inputSchema: {
30
+ type: 'object',
31
+ properties: {
32
+ project: { type: 'string' },
33
+ status: { type: 'string', enum: ['actionable', 'open', 'queued', 'in_progress', 'review', 'resolved', 'active', 'all'], default: 'actionable' },
34
+ format: { type: 'string', enum: ['markdown', 'json'], default: 'markdown' },
35
+ },
36
+ required: ['project'],
37
+ },
38
+ run: async ({ project, status = 'actionable', format = 'markdown' }) => {
39
+ const ext = format === 'json' ? 'json' : 'md';
40
+ const data = await call('GET', `/__ps/api/projects/${enc(project)}/feedback.${ext}?status=${enc(status)}`);
41
+ return typeof data === 'string' ? data : JSON.stringify(data, null, 2);
42
+ },
43
+ },
44
+ {
45
+ name: 'clickroom_ask',
46
+ description:
47
+ 'Ask the client a question (e.g. a business rule, missing content, which option they prefer), or list an assumption you made for them to confirm or correct. It starts as a draft that only the team sees; the team sends it to the client, edits, answers or removes it. It shows up in the review under Questions, with `now` saying what the prototype does until it is answered. Point it at the spot it is about with `page` + `selector` (and a `screenshot` if you have one) so it is easier to answer; the team\'s browser adds a screenshot of that spot when there is none.',
48
+ inputSchema: {
49
+ type: 'object',
50
+ properties: {
51
+ project: { type: 'string' },
52
+ question: { type: 'string' },
53
+ type: { type: 'string', enum: ['question', 'assumption'], default: 'question', description: 'assumption: something you decided that the client should confirm or correct' },
54
+ now: { type: 'string', description: 'What the prototype does until this is answered' },
55
+ key: { type: 'string', description: 'Stable id; asking again with the same key rewords the item instead of adding it twice' },
56
+ page: { type: 'string', description: 'Prototype path it is about, e.g. /pricing' },
57
+ selector: { type: 'string', description: 'CSS selector of the element it is about, on that page' },
58
+ screenshot: { type: 'string', description: 'Absolute path of a PNG/JPEG/WebP screenshot of what it is about' },
59
+ },
60
+ required: ['project', 'question'],
61
+ },
62
+ run: async ({ project, question, type, now, key, page, selector, screenshot }) => {
63
+ const base = `/__ps/api/projects/${enc(project)}/threads`;
64
+ const el = selector ? { selector } : undefined;
65
+ const t = await call('POST', base, { kind: 'question', body: question, type, now, key, page, el, author: AGENT() });
66
+ if (screenshot) {
67
+ const mime = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' }[path.extname(screenshot).toLowerCase()];
68
+ if (!mime) throw new Error('screenshot must be a .png, .jpg or .webp file');
69
+ await callUpload(`${base}/${t.seq}/shot`, fs.readFileSync(screenshot), mime);
70
+ }
71
+ if (t.createdAt !== t.updatedAt) return `Updated #${t.seq}. Check for answers with clickroom_get_questions.`;
72
+ return t.status === 'draft'
73
+ ? `Saved #${t.seq} as a draft: the team reviews it and sends it to the client (or answers it). Check for answers with clickroom_get_questions.`
74
+ : `Asked #${t.seq}. Check for answers with clickroom_get_questions.`;
75
+ },
76
+ },
77
+ {
78
+ name: 'clickroom_get_questions',
79
+ description:
80
+ 'Get the questions asked to the client with their full conversations; the client\'s replies are the answer. Default status "open" (sent to the client); "draft" lists the ones the team has not sent yet. Each question says whether it is waiting for the client, answered, or sent to agent; "answered" lists the answers the team sent to an agent to work in.',
81
+ inputSchema: {
82
+ type: 'object',
83
+ properties: { project: { type: 'string' }, status: { type: 'string', enum: ['open', 'draft', 'answered', 'resolved', 'all'], default: 'open' } },
84
+ required: ['project'],
85
+ },
86
+ run: ({ project, status = 'open' }) => call('GET', `/__ps/api/projects/${enc(project)}/feedback.md?kind=question&status=${enc(status)}`),
87
+ },
88
+ {
89
+ name: 'clickroom_get_thread',
90
+ description: 'Get one feedback thread (by its #number) with its anchor, full conversation and, when there is one, a screenshot of the spot as the reviewer saw it.',
91
+ inputSchema: { type: 'object', properties: { project: { type: 'string' }, seq: { type: 'integer' } }, required: ['project', 'seq'] },
92
+ run: async ({ project, seq }) => {
93
+ const path = `/__ps/api/projects/${enc(project)}/threads/${Number(seq)}`;
94
+ const t = await call('GET', path);
95
+ const content = [{ type: 'text', text: JSON.stringify(t, null, 2) }];
96
+ const shot = t.shot ? await callBinary(`${path}/shot`) : null;
97
+ if (shot) content.push({ type: 'image', data: shot.base64, mimeType: shot.mime });
98
+ return content;
99
+ },
100
+ },
101
+ {
102
+ name: 'clickroom_claim',
103
+ description: 'Optional: post a short note on a thread before working on it (its status stays as it is; mark it with clickroom_mark_fixed when done).',
104
+ inputSchema: {
105
+ type: 'object',
106
+ properties: { project: { type: 'string' }, seq: { type: 'integer' }, note: { type: 'string' } },
107
+ required: ['project', 'seq'],
108
+ },
109
+ run: async ({ project, seq, note }) => {
110
+ const t = await call('PATCH', `/__ps/api/projects/${enc(project)}/threads/${Number(seq)}`, { status: 'in_progress', note, author: AGENT() });
111
+ return note ? `Noted on #${t.seq}.` : `#${t.seq}: nothing to note. Mark it with clickroom_mark_fixed when it's done.`;
112
+ },
113
+ },
114
+ {
115
+ name: 'clickroom_reply',
116
+ description: 'Reply in a feedback thread, e.g. to ask the client a clarifying question. Shows up live in the review.',
117
+ inputSchema: {
118
+ type: 'object',
119
+ properties: { project: { type: 'string' }, seq: { type: 'integer' }, body: { type: 'string' } },
120
+ required: ['project', 'seq', 'body'],
121
+ },
122
+ run: async ({ project, seq, body }) => {
123
+ const t = await call('POST', `/__ps/api/projects/${enc(project)}/threads/${Number(seq)}/comments`, { body, author: AGENT() });
124
+ return `Replied on #${t.seq}.`;
125
+ },
126
+ },
127
+ {
128
+ name: 'clickroom_mark_fixed',
129
+ description:
130
+ 'Mark a thread fixed after making the change, or an answered question as worked into the prototype. Include a note describing what changed. The next pushed version records where it landed; for comments the reviewer then checks it and resolves or reopens it.',
131
+ inputSchema: {
132
+ type: 'object',
133
+ properties: { project: { type: 'string' }, seq: { type: 'integer' }, note: { type: 'string' } },
134
+ required: ['project', 'seq'],
135
+ },
136
+ run: ({ project, seq, note }) => markFixed(project, seq, note),
137
+ },
138
+ {
139
+ name: 'clickroom_resolve',
140
+ description:
141
+ 'Same as clickroom_mark_fixed (the reviewer confirms the fix). Pass close: true to resolve outright, only when no check is needed (e.g. a team note, or closing a settled question).',
142
+ inputSchema: {
143
+ type: 'object',
144
+ properties: { project: { type: 'string' }, seq: { type: 'integer' }, note: { type: 'string' }, close: { type: 'boolean' } },
145
+ required: ['project', 'seq'],
146
+ },
147
+ run: async ({ project, seq, note, close }) => {
148
+ if (!close) return markFixed(project, seq, note);
149
+ const t = await call('PATCH', `/__ps/api/projects/${enc(project)}/threads/${Number(seq)}`, { status: 'resolved', note, author: AGENT() });
150
+ return `Resolved #${t.seq}.`;
151
+ },
152
+ },
153
+ ];
154
+
155
+ async function markFixed(project, seq, note) {
156
+ const t = await call('PATCH', `/__ps/api/projects/${enc(project)}/threads/${Number(seq)}`, { status: 'review', note, author: AGENT() });
157
+ if (t.kind === 'question') return `#${t.seq} marked processed. ${t.fixedIn ? `It landed in v${t.fixedIn}.` : 'The next push (`clickroom push`) records the version it lands in.'}`;
158
+ const where = t.fixedIn ? `in ${t.fixedIn === 'live' ? 'the live dev server' : 'v' + t.fixedIn}` : 'once a new version is pushed (`clickroom push`)';
159
+ return `#${t.seq} marked fixed. The reviewer checks it ${where}.`;
160
+ }
161
+
162
+ export function runMcp({ input = process.stdin, output = process.stdout } = {}) {
163
+ const write = (msg) => output.write(JSON.stringify(msg) + '\n');
164
+ const rl = readline.createInterface({ input });
165
+ rl.on('line', async (line) => {
166
+ if (!line.trim()) return;
167
+ let msg;
168
+ try {
169
+ msg = JSON.parse(line);
170
+ } catch {
171
+ return write({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
172
+ }
173
+ const { id, method, params } = msg;
174
+ const reply = (result) => id !== undefined && write({ jsonrpc: '2.0', id, result });
175
+ const fail = (code, message) => id !== undefined && write({ jsonrpc: '2.0', id, error: { code, message } });
176
+ switch (method) {
177
+ case 'initialize':
178
+ return reply({
179
+ protocolVersion: params?.protocolVersion || '2025-06-18',
180
+ capabilities: { tools: {} },
181
+ serverInfo: { name: 'clickroom', version: '0.1.0' },
182
+ instructions:
183
+ 'clickroom collects client feedback on prototypes. Typical loop: clickroom_get_feedback → for each item change the code, then clickroom_mark_fixed with a note (the reviewer confirms it in the next pushed version). Use clickroom_get_thread to see a screenshot of the spot, and clickroom_reply to ask about one comment. For anything you would otherwise guess (content, business rules, choices), ask the client with clickroom_ask and read answers with clickroom_get_questions. Once an answer is worked into the prototype, mark that question with clickroom_mark_fixed. `clickroom process` in the project folder gathers all of this into .clickroom/process.md.',
184
+ });
185
+ case 'ping':
186
+ return reply({});
187
+ case 'tools/list':
188
+ return reply({ tools: TOOLS.map(({ run, ...t }) => t) });
189
+ case 'tools/call': {
190
+ const tool = TOOLS.find((t) => t.name === params?.name);
191
+ if (!tool) return fail(-32602, `Unknown tool: ${params?.name}`);
192
+ try {
193
+ const out = await tool.run(params.arguments || {});
194
+ return reply({ content: Array.isArray(out) ? out : [{ type: 'text', text: out }] });
195
+ } catch (err) {
196
+ return reply({ content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true });
197
+ }
198
+ }
199
+ default:
200
+ if (method?.startsWith('notifications/')) return;
201
+ return fail(-32601, `Method not found: ${method}`);
202
+ }
203
+ });
204
+ return rl;
205
+ }
package/cli/note.js ADDED
@@ -0,0 +1,109 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { call } from './client.js';
3
+ import { changedSource, folderChanges } from './changes.js';
4
+
5
+ // The version note when a push comes without one ("Push v16" on the dashboard, or an empty
6
+ // answer in the terminal): Claude Code on this computer (`claude -p`) describes the source changes
7
+ // since the last push as reviewers will notice them, plus the comments fixed in this version.
8
+ // Without git, Claude or a usable answer: the fixed comments, else the changed file names.
9
+ // CLICKROOM_AUTO_NOTE=off never asks Claude.
10
+
11
+ const PROMPT = [
12
+ 'You write the version note for a clickable prototype that designers, product people and clients review.',
13
+ 'You get the code changes since the previous version, and the review comments this version fixes.',
14
+ 'Describe what changed the way someone clicking through the prototype would notice it.',
15
+ 'Write 1 to 4 items of at most 6 words each, separated by ", " (comma and space), in English. Keep it under 160 characters.',
16
+ 'No file names, code terms, version numbers, quotes or a full stop at the end. Output only the note.',
17
+ ].join(' ');
18
+ const NOTE_MAX = 200;
19
+
20
+ export async function autoNote(name, dir) {
21
+ const fixes = await fixesInNextVersion(name).catch(() => []);
22
+ if (process.env.CLICKROOM_AUTO_NOTE !== 'off') {
23
+ const src = await changedSource(name, dir).catch(() => null);
24
+ if (src) {
25
+ const input = [
26
+ fixes.length ? `Review comments fixed in this version:\n${fixes.map((f) => `- ${f}`).join('\n')}\n` : '',
27
+ `Files changed:\n${src.stat.trim()}\n`,
28
+ `Diff:\n${src.patch}`,
29
+ ].join('\n');
30
+ const note = clean(await askClaude(input));
31
+ if (note) return note;
32
+ }
33
+ }
34
+ return fallbackNote(fixes, await folderChanges(name, dir).catch(() => null));
35
+ }
36
+
37
+ // Fixes waiting for the next version: in review, not yet shipped (see addVersion in room.js).
38
+ async function fixesInNextVersion(name) {
39
+ const { threads } = await call('GET', `/__ps/api/projects/${encodeURIComponent(name)}/threads`);
40
+ return (threads || [])
41
+ .filter((t) => t.status === 'review' && !t.fixedIn)
42
+ .map((t) => oneLine(t.comments?.[0]?.body || `#${t.seq}`, 80));
43
+ }
44
+
45
+ export function fallbackNote(fixes, changes) {
46
+ if (fixes?.length) return cut(`Fixed: ${fixes.join(', ')}`);
47
+ const files = (changes?.paths || []).map((p) => p.split('/').pop());
48
+ if (!files.length) return null;
49
+ const more = (changes.files || files.length) - Math.min(files.length, 3);
50
+ return cut(`Updated ${files.slice(0, 3).join(', ')}${more > 0 ? ` +${more}` : ''}`);
51
+ }
52
+
53
+ function askClaude(input) {
54
+ return new Promise((resolve) => {
55
+ let out = '';
56
+ let child;
57
+ try {
58
+ child = spawn('claude', ['-p', '--model', 'haiku', '--tools', '', '--no-session-persistence', '--setting-sources', '', '--system-prompt', PROMPT], { stdio: ['pipe', 'pipe', 'ignore'] });
59
+ } catch {
60
+ return resolve(null);
61
+ }
62
+ const timer = setTimeout(() => {
63
+ child.kill();
64
+ resolve(null);
65
+ }, 90000);
66
+ child.on('error', () => {
67
+ clearTimeout(timer);
68
+ resolve(null);
69
+ });
70
+ child.stdout.on('data', (d) => (out += d));
71
+ child.on('close', (code) => {
72
+ clearTimeout(timer);
73
+ resolve(code === 0 ? out : null);
74
+ });
75
+ child.stdin.on('error', () => {});
76
+ child.stdin.end(input);
77
+ });
78
+ }
79
+
80
+ // One plain line: no wrapping quotes, bullets or trailing full stop; null when there's nothing.
81
+ export function clean(text) {
82
+ if (!text) return null;
83
+ const items = String(text).trim().replace(/^["'“]+|["'”]+$/g, '').split('\n')
84
+ .map((l) => l.trim().replace(/^[-*•]\s*/, '').replace(/\.$/, '').trim())
85
+ .filter(Boolean);
86
+ const line = oneLine(items.join(', '), 1000);
87
+ return line ? fit(line) : null;
88
+ }
89
+
90
+ // Whole items only, up to NOTE_MAX: a note never ends halfway through a word.
91
+ function fit(line) {
92
+ if (line.length <= NOTE_MAX) return line;
93
+ let out = '';
94
+ for (const item of line.split(/,\s+/)) {
95
+ const next = out ? `${out}, ${item}` : item;
96
+ if (next.length > NOTE_MAX) break;
97
+ out = next;
98
+ }
99
+ return out || cut(line);
100
+ }
101
+
102
+ function oneLine(s, max) {
103
+ const flat = String(s).replace(/\s+/g, ' ').trim();
104
+ return flat.length > max ? flat.slice(0, max - 1) + '…' : flat;
105
+ }
106
+
107
+ function cut(s) {
108
+ return s.length > NOTE_MAX ? s.slice(0, NOTE_MAX - 1) + '…' : s;
109
+ }
package/cli/process.js ADDED
@@ -0,0 +1,47 @@
1
+ // `clickroom process`: gathers what the team queued for an agent (comments, and
2
+ // answers to questions), writes one brief into the project and starts Claude Code on it.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { spawn, spawnSync } from 'node:child_process';
6
+ import { call } from './client.js';
7
+ import { buildBrief } from '../worker/handoff.js';
8
+
9
+ const BRIEF = path.join('.clickroom', 'process.md');
10
+
11
+ export async function gather(name, { comments = true, questions = true } = {}) {
12
+ const base = `/__ps/api/projects/${encodeURIComponent(name)}`;
13
+ const get = async (query) => {
14
+ const [json, md] = await Promise.all([call('GET', `${base}/feedback.json?${query}`), call('GET', `${base}/feedback.md?${query}`)]);
15
+ return { count: json.count, md: json.count ? md : '', tasks: json.threads.filter((t) => t.kind === 'task').length };
16
+ };
17
+ return {
18
+ ...(comments ? await get('status=actionable').then(({ tasks, ...c }) => ({ comments: c, tasks })) : { comments: { count: 0, md: '' } }),
19
+ answers: questions ? await get('kind=question&status=answered') : { count: 0, md: '' },
20
+ };
21
+ }
22
+
23
+ export { buildBrief };
24
+
25
+ export function writeBrief(root, text) {
26
+ const dir = path.join(root, '.clickroom');
27
+ fs.mkdirSync(dir, { recursive: true });
28
+ // Keep the folder out of the project's git without touching its .gitignore.
29
+ const ignore = path.join(dir, '.gitignore');
30
+ if (!fs.existsSync(ignore)) fs.writeFileSync(ignore, '*\n');
31
+ fs.writeFileSync(path.join(root, BRIEF), text);
32
+ return BRIEF;
33
+ }
34
+
35
+ export function hasClaude() {
36
+ return spawnSync(process.platform === 'win32' ? 'where' : 'which', ['claude'], { stdio: 'ignore' }).status === 0;
37
+ }
38
+
39
+ // Interactive Claude Code in the project, so the person watches and approves the changes.
40
+ export function startClaude(root, name) {
41
+ const prompt = `Process the clickroom feedback in ${BRIEF} for project "${name}". Read it first, then work through every item.`;
42
+ return new Promise((resolve, reject) => {
43
+ const child = spawn('claude', [prompt], { cwd: root, stdio: 'inherit' });
44
+ child.on('error', reject);
45
+ child.on('exit', (code) => resolve(code ?? 0));
46
+ });
47
+ }