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/README.md +365 -2
- package/bin/clickroom.js +293 -0
- package/cli/changes.js +148 -0
- package/cli/client.js +86 -0
- package/cli/cloud.js +100 -0
- package/cli/config.js +140 -0
- package/cli/deploy.js +71 -0
- package/cli/detect.js +155 -0
- package/cli/dev.js +279 -0
- package/cli/gaps.js +37 -0
- package/cli/login.js +74 -0
- package/cli/mcp.js +205 -0
- package/cli/note.js +109 -0
- package/cli/process.js +47 -0
- package/cli/push.js +198 -0
- package/cli/service.js +110 -0
- package/cli/share.js +350 -0
- package/cli/vapid.js +10 -0
- package/package.json +38 -4
- package/worker/handoff.js +351 -0
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clickroom",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Share prototype builds for review: pins, region comments, threads, versions, and agent hand-off (MCP). Teammates push builds to their team’s clickroom with `clickroom login`.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"clickroom": "bin/clickroom.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "node bin/clickroom.js dev",
|
|
11
|
+
"share": "node bin/clickroom.js dev --tunnel",
|
|
12
|
+
"demo": "node bin/clickroom.js add demo ./examples/demo && node bin/clickroom.js dev --tunnel",
|
|
13
|
+
"test": "node --test test/*.test.js",
|
|
14
|
+
"screens": "node examples/screens/build.js",
|
|
15
|
+
"demo:seed": "node examples/demo/seed.js",
|
|
16
|
+
"prepublishOnly": "node --test test/detect.test.js test/changes.test.js test/webpush.test.js"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"homepage": "https://clickroom.dev",
|
|
23
|
+
"keywords": [
|
|
24
|
+
"prototype",
|
|
25
|
+
"review",
|
|
26
|
+
"feedback",
|
|
27
|
+
"comments",
|
|
28
|
+
"mcp",
|
|
29
|
+
"claude"
|
|
30
|
+
],
|
|
31
|
+
"files": [
|
|
32
|
+
"bin/",
|
|
33
|
+
"cli/",
|
|
34
|
+
"worker/handoff.js",
|
|
35
|
+
"README.md"
|
|
36
|
+
],
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"wrangler": "^4.148.0"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// Turns review threads into a digest a coding agent can act on.
|
|
2
|
+
|
|
3
|
+
export const isQuestion = (t) => t.kind === 'question';
|
|
4
|
+
export const isTask = (t) => t.kind === 'task';
|
|
5
|
+
|
|
6
|
+
// Where a question stands: waiting for the client until a client has the last word. An assumption
|
|
7
|
+
// the client confirmed with one click is "confirmed"; one they replied to (corrected) is "answered".
|
|
8
|
+
// Once an agent worked the answer into the prototype it's "processed" (status review), and the next
|
|
9
|
+
// push stamps the version it landed in (fixedIn), as for fixes.
|
|
10
|
+
// A draft waits for the team to send it; one the team answered itself counts the team's answer.
|
|
11
|
+
// An answer is final once the team sends it to an agent ("queued"); until then there may be follow-ups.
|
|
12
|
+
export function questionState(t) {
|
|
13
|
+
if (t.status === 'resolved') return 'closed';
|
|
14
|
+
if (t.status === 'review') return 'processed';
|
|
15
|
+
if (t.status === 'queued') return 'queued';
|
|
16
|
+
if (t.status === 'draft') return 'draft';
|
|
17
|
+
const last = [...t.comments].reverse().find((c) => c.kind === 'comment' || c.kind === 'confirm');
|
|
18
|
+
const answerer = t.anchor?.teamOnly ? ['client', 'team'] : ['client'];
|
|
19
|
+
if (!answerer.includes(last?.author?.role) || last === t.comments[0]) return 'waiting';
|
|
20
|
+
return last.kind === 'confirm' ? 'confirmed' : 'answered';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const QUESTION_STATES = {
|
|
24
|
+
question: { draft: 'draft', waiting: 'waiting for client', answered: 'answered', queued: 'sent to agent', processed: 'processed', closed: 'resolved' },
|
|
25
|
+
assumption: { draft: 'draft', waiting: 'to check', answered: 'answered', confirmed: 'confirmed', queued: 'sent to agent', processed: 'processed', closed: 'resolved' },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const typeOf = (t) => (t.anchor?.type === 'assumption' ? 'assumption' : 'question');
|
|
29
|
+
const stateLabel = (t) => QUESTION_STATES[typeOf(t)][questionState(t)];
|
|
30
|
+
const processedIn = (t) => (t.fixedIn ? `in ${t.fixedIn === 'live' ? 'the live dev server' : 'v' + t.fixedIn}` : 'not pushed yet');
|
|
31
|
+
|
|
32
|
+
// kind: "feedback" (comments on the prototype), "question" (questions for the client) or "all".
|
|
33
|
+
// "actionable": what the team queued for agents (and nothing else: the team decides what goes into
|
|
34
|
+
// the next version).
|
|
35
|
+
// "answered" (questions): answers the team sent to an agent (queued), not worked in yet. An answer
|
|
36
|
+
// the team hasn't sent may still get follow-ups, so agents leave it alone.
|
|
37
|
+
export function filterThreads(threads, status = 'actionable', kind = 'feedback') {
|
|
38
|
+
if (kind !== 'all') threads = threads.filter((t) => isQuestion(t) === (kind === 'question'));
|
|
39
|
+
if (status === 'all') return threads;
|
|
40
|
+
if (status === 'answered') return threads.filter((t) => isQuestion(t) && t.status === 'queued');
|
|
41
|
+
if (status === 'actionable') return threads.filter((t) => t.status === 'queued');
|
|
42
|
+
if (status === 'active') return threads.filter((t) => t.status !== 'resolved');
|
|
43
|
+
return threads.filter((t) => t.status === status);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function toJson(project, threads, { status = 'actionable', kind = 'feedback', baseUrl } = {}) {
|
|
47
|
+
const items = filterThreads(threads, status, kind);
|
|
48
|
+
return {
|
|
49
|
+
project: project.name,
|
|
50
|
+
source: project.source || project.target || null,
|
|
51
|
+
filter: status,
|
|
52
|
+
kind,
|
|
53
|
+
generatedAt: new Date().toISOString(),
|
|
54
|
+
total: threads.length,
|
|
55
|
+
count: items.length,
|
|
56
|
+
threads: items.map((t) => (isQuestion(t) ? questionJson(t, project, baseUrl) : {
|
|
57
|
+
seq: t.seq,
|
|
58
|
+
status: t.status,
|
|
59
|
+
page: t.page,
|
|
60
|
+
version: t.version ?? null,
|
|
61
|
+
kind: t.kind,
|
|
62
|
+
title: titleOf(t),
|
|
63
|
+
reviewUrl: baseUrl ? reviewUrl(baseUrl, project.name, t) : undefined,
|
|
64
|
+
element: {
|
|
65
|
+
selector: t.anchor.selector || null,
|
|
66
|
+
tag: t.anchor.tag || null,
|
|
67
|
+
text: t.anchor.text || null,
|
|
68
|
+
html: t.anchor.html || null,
|
|
69
|
+
detachedFallback: { x: t.anchor.docX, y: t.anchor.docY },
|
|
70
|
+
},
|
|
71
|
+
where: whereOf(t.anchor),
|
|
72
|
+
screenshotUrl: t.shot && baseUrl ? shotUrl(baseUrl, project.name, t) : undefined,
|
|
73
|
+
fixedIn: t.fixedIn ?? undefined,
|
|
74
|
+
versions: versionsOf(t),
|
|
75
|
+
region: t.kind === 'region' ? { width: Math.round(t.anchor.w || 0), height: Math.round(t.anchor.h || 0) } : undefined,
|
|
76
|
+
viewport: t.anchor.viewport || null,
|
|
77
|
+
conversation: conversationJson(t),
|
|
78
|
+
})),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function questionJson(t, project, baseUrl) {
|
|
83
|
+
return {
|
|
84
|
+
seq: t.seq,
|
|
85
|
+
kind: t.kind,
|
|
86
|
+
type: typeOf(t),
|
|
87
|
+
key: t.anchor?.key,
|
|
88
|
+
now: t.anchor?.now,
|
|
89
|
+
element: t.anchor?.el ? { selector: t.anchor.el.selector || null, text: t.anchor.el.text || null } : undefined,
|
|
90
|
+
screenshotUrl: t.shot && baseUrl ? shotUrl(baseUrl, project.name, t) : undefined,
|
|
91
|
+
status: t.status,
|
|
92
|
+
state: questionState(t),
|
|
93
|
+
fixedIn: t.fixedIn ?? undefined,
|
|
94
|
+
page: t.page || null,
|
|
95
|
+
title: titleOf(t),
|
|
96
|
+
reviewUrl: baseUrl ? reviewUrl(baseUrl, project.name, t) : undefined,
|
|
97
|
+
conversation: conversationJson(t),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function conversationJson(t) {
|
|
102
|
+
return t.comments.map((c) => ({ kind: c.kind, author: c.author?.name, role: c.author?.role, at: c.createdAt, body: c.body }));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function toMarkdown(project, threads, { status = 'actionable', kind = 'feedback', baseUrl } = {}) {
|
|
106
|
+
if (kind === 'question') return questionsMarkdown(project, threads, { status, baseUrl });
|
|
107
|
+
const items = filterThreads(threads, status, kind);
|
|
108
|
+
const src = project.source || project.target;
|
|
109
|
+
const out = [];
|
|
110
|
+
out.push(`# Prototype feedback: ${project.name}`);
|
|
111
|
+
out.push('');
|
|
112
|
+
out.push(
|
|
113
|
+
`_${src ? `Source: \`${src}\` · ` : ''}${items.length} item${items.length === 1 ? '' : 's'} (filter: ${status}) of ${threads.length} total · generated ${new Date().toISOString()}_`,
|
|
114
|
+
);
|
|
115
|
+
out.push('');
|
|
116
|
+
out.push('> **For agents:** each item is client/team feedback anchored to an element of the running prototype.');
|
|
117
|
+
out.push('> Selector, text and HTML come from the *built* page, so use them to locate the matching source component.');
|
|
118
|
+
out.push(`> When done, mark it fixed with MCP \`clickroom_mark_fixed {"project":"${project.name}","seq":N,"note":"what changed"}\``);
|
|
119
|
+
out.push(`> or CLI \`clickroom resolve ${project.name} N --note "what changed"\`: the reviewer checks it in the next pushed version.`);
|
|
120
|
+
out.push('> Ask the client with `clickroom_reply` if something is unclear. Screenshot links need the team token.');
|
|
121
|
+
out.push('');
|
|
122
|
+
if (!items.length) {
|
|
123
|
+
out.push('_No feedback matches this filter._');
|
|
124
|
+
return out.join('\n') + '\n';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Tasks: free text the team added for the agent, not tied to a page or element.
|
|
128
|
+
const tasks = items.filter(isTask);
|
|
129
|
+
if (tasks.length) {
|
|
130
|
+
out.push('## Tasks');
|
|
131
|
+
out.push('');
|
|
132
|
+
for (const t of tasks) {
|
|
133
|
+
out.push(`### #${t.seq} · Task: ${titleOf(t)} \`${t.status}\``);
|
|
134
|
+
out.push('');
|
|
135
|
+
if (t.status === 'review') out.push(`- **Done:** ${t.fixedIn ? `in ${t.fixedIn === 'live' ? 'the live dev server' : 'v' + t.fixedIn}` : 'not pushed yet'}, waiting for the reviewer`);
|
|
136
|
+
conversationMd(out, t);
|
|
137
|
+
out.push('');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const byPage = new Map();
|
|
141
|
+
for (const t of items) {
|
|
142
|
+
if (isTask(t)) continue;
|
|
143
|
+
if (!byPage.has(t.page)) byPage.set(t.page, []);
|
|
144
|
+
byPage.get(t.page).push(t);
|
|
145
|
+
}
|
|
146
|
+
for (const [page, list] of byPage) {
|
|
147
|
+
out.push(`## Page \`${page}\``);
|
|
148
|
+
out.push('');
|
|
149
|
+
for (const t of list) {
|
|
150
|
+
const a = t.anchor || {};
|
|
151
|
+
out.push(`### #${t.seq} · ${titleOf(t)} \`${t.status}\``);
|
|
152
|
+
out.push('');
|
|
153
|
+
if (baseUrl) out.push(`- **Review link:** ${reviewUrl(baseUrl, project.name, t)}`);
|
|
154
|
+
out.push(
|
|
155
|
+
`- **Annotation:** ${
|
|
156
|
+
t.kind === 'region'
|
|
157
|
+
? `region ${Math.round(a.w || 0)}×${Math.round(a.h || 0)}px over the element`
|
|
158
|
+
: `pin at ${pct(a.xPct)} × ${pct(a.yPct)} of the element`
|
|
159
|
+
}`,
|
|
160
|
+
);
|
|
161
|
+
const where = whereText(a);
|
|
162
|
+
if (where) out.push(`- **Where:** ${where}`);
|
|
163
|
+
if (t.shot && baseUrl) out.push(`- **Screenshot:** ${shotUrl(baseUrl, project.name, t)}`);
|
|
164
|
+
const seen = versionsText(t);
|
|
165
|
+
if (seen) out.push(`- **Versions:** ${seen}`);
|
|
166
|
+
if (t.status === 'review') out.push(`- **Fixed:** ${t.fixedIn ? `in ${t.fixedIn === 'live' ? 'the live dev server' : 'v' + t.fixedIn}` : 'not pushed yet'}, waiting for the reviewer`);
|
|
167
|
+
if (a.selector) out.push(`- **Element:** \`${a.selector}\``);
|
|
168
|
+
if (a.text) out.push(`- **Text:** "${oneLine(a.text, 160)}"`);
|
|
169
|
+
if (a.viewport) out.push(`- **Viewport:** ${a.viewport.w}×${a.viewport.h}`);
|
|
170
|
+
if (t.version) out.push(`- **Version:** ${t.version === 'live' ? 'live dev server' : 'v' + t.version}`);
|
|
171
|
+
if (a.html) {
|
|
172
|
+
out.push('');
|
|
173
|
+
out.push('```html');
|
|
174
|
+
out.push(a.html.replace(/```/g, '```'));
|
|
175
|
+
out.push('```');
|
|
176
|
+
}
|
|
177
|
+
out.push('');
|
|
178
|
+
out.push('**Conversation**');
|
|
179
|
+
out.push('');
|
|
180
|
+
conversationMd(out, t);
|
|
181
|
+
out.push('');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return out.join('\n') + '\n';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Questions the team or an agent asked the client; the replies are the answer.
|
|
188
|
+
function questionsMarkdown(project, threads, { status, baseUrl }) {
|
|
189
|
+
const all = threads.filter(isQuestion);
|
|
190
|
+
const items = filterThreads(threads, status, 'question');
|
|
191
|
+
const n = (type) => items.filter((t) => typeOf(t) === type).length;
|
|
192
|
+
const out = [`# Questions for the client: ${project.name}`, ''];
|
|
193
|
+
out.push(`_${n('question')} questions · ${n('assumption')} assumptions (filter: ${status}) of ${all.length} total · generated ${new Date().toISOString()}_`);
|
|
194
|
+
out.push('');
|
|
195
|
+
out.push('> **For agents:** the client\'s replies are the answer. Follow up with `clickroom_reply`, ask something new with');
|
|
196
|
+
out.push(`> \`clickroom_ask {"project":"${project.name}","question":"..."}\`. Once an answer is worked into the prototype, mark it with`);
|
|
197
|
+
out.push('> `clickroom_mark_fixed` and a note on what changed: the next push records the version it landed in.');
|
|
198
|
+
out.push('');
|
|
199
|
+
if (!items.length) {
|
|
200
|
+
out.push('_No questions match this filter._');
|
|
201
|
+
return out.join('\n') + '\n';
|
|
202
|
+
}
|
|
203
|
+
for (const t of items) {
|
|
204
|
+
out.push(`## #${t.seq} · ${typeOf(t) === 'assumption' ? 'Assumption' : 'Question'}: ${titleOf(t)} \`${stateLabel(t)}\``);
|
|
205
|
+
out.push('');
|
|
206
|
+
if (t.anchor?.now) out.push(`- **Now:** ${indent(t.anchor.now)}`);
|
|
207
|
+
if (t.page) out.push(`- **About page:** \`${t.page}\``);
|
|
208
|
+
const el = t.anchor?.el;
|
|
209
|
+
if (el?.selector) out.push(`- **Element:** \`${el.selector}\`${el.text ? ` "${oneLine(el.text, 120)}"` : ''}`);
|
|
210
|
+
if (t.shot && baseUrl) out.push(`- **Screenshot:** ${shotUrl(baseUrl, project.name, t)}`);
|
|
211
|
+
if (t.anchor?.key) out.push(`- **Key:** \`${t.anchor.key}\``);
|
|
212
|
+
if (t.status === 'review') out.push(`- **Processed:** ${processedIn(t)}`);
|
|
213
|
+
if (t.anchor?.teamOnly) out.push('- **Answered by the team** (the client was not asked)');
|
|
214
|
+
if (baseUrl) out.push(`- **Review link:** ${reviewUrl(baseUrl, project.name, t)}`);
|
|
215
|
+
out.push('');
|
|
216
|
+
conversationMd(out, t);
|
|
217
|
+
out.push('');
|
|
218
|
+
}
|
|
219
|
+
return out.join('\n') + '\n';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function conversationMd(out, t) {
|
|
223
|
+
for (const c of t.comments) {
|
|
224
|
+
const who = `${c.author?.name || 'Someone'} (${c.author?.role || 'client'})`;
|
|
225
|
+
if (c.kind === 'confirm') {
|
|
226
|
+
out.push(`- **${who}** · ${c.createdAt.slice(0, 16).replace('T', ' ')}: ✓ confirmed`);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (c.kind === 'event') out.push(`- _${who} · ${c.createdAt.slice(0, 16).replace('T', ' ')}: ${c.body}_`);
|
|
230
|
+
else out.push(`- **${who}** · ${c.createdAt.slice(0, 16).replace('T', ' ')}: ${indent(c.body)}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Where in the app a comment was made when the URL doesn't say: the selected tab(s), and the
|
|
235
|
+
// dialog, menu… it's in with the clicks that open it.
|
|
236
|
+
function whereOf(a = {}) {
|
|
237
|
+
const view = (a.view || []).map((v) => v.text);
|
|
238
|
+
if (!view.length && !a.layer) return undefined;
|
|
239
|
+
return {
|
|
240
|
+
view: view.length ? view : undefined,
|
|
241
|
+
layer: a.layer ? { kind: a.layer.kind, label: a.layer.label, openedBy: (a.steps || []).map((s) => s.text || s.selector) } : undefined,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function whereText(a = {}) {
|
|
246
|
+
const parts = [];
|
|
247
|
+
for (const v of a.view || []) parts.push(`tab "${v.text}"`);
|
|
248
|
+
if (a.layer) {
|
|
249
|
+
const steps = (a.steps || []).map((s) => `"${s.text || s.selector}"`);
|
|
250
|
+
parts.push(`${a.layer.kind || 'overlay'}${a.layer.label ? ` "${a.layer.label}"` : ''}${steps.length ? ` (opened by clicking ${steps.join(' → ')})` : ''}`);
|
|
251
|
+
}
|
|
252
|
+
return parts.join(' → ');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function titleOf(t) {
|
|
256
|
+
const first = t.comments.find((c) => c.kind === 'comment');
|
|
257
|
+
return oneLine(first?.body || '(no text)', 80);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function shotUrl(baseUrl, name, t) {
|
|
261
|
+
return `${baseUrl.replace(/\/$/, '')}/__ps/api/projects/${encodeURIComponent(name)}/threads/${t.seq}/shot`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Per version whether the comment's element was there: { lastSeen: '5', lost: ['6'] }.
|
|
265
|
+
function versionsOf(t) {
|
|
266
|
+
const entries = Object.entries(t.checks || {});
|
|
267
|
+
if (!entries.length) return undefined;
|
|
268
|
+
const num = (v) => (v === 'live' ? Infinity : Number(v));
|
|
269
|
+
const seen = entries.filter(([, r]) => r !== 'missing').map(([v]) => v).sort((a, b) => num(a) - num(b));
|
|
270
|
+
const lost = entries.filter(([, r]) => r === 'missing').map(([v]) => v).sort((a, b) => num(a) - num(b));
|
|
271
|
+
return { lastSeen: seen[seen.length - 1] || null, lost };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function versionsText(t) {
|
|
275
|
+
const v = versionsOf(t);
|
|
276
|
+
if (!v) return '';
|
|
277
|
+
const name = (x) => (x === 'live' ? 'live' : 'v' + x);
|
|
278
|
+
const parts = [];
|
|
279
|
+
if (v.lastSeen) parts.push(`last seen in ${name(v.lastSeen)}`);
|
|
280
|
+
if (v.lost.length) parts.push(`not found in ${v.lost.map(name).join(', ')} (may already be handled, or the element changed)`);
|
|
281
|
+
return parts.join(' · ');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function reviewUrl(baseUrl, name, t) {
|
|
285
|
+
return `${baseUrl.replace(/\/$/, '')}/s/${encodeURIComponent(name)}#${t.page || '/'}?thread=${t.seq}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function pct(v) {
|
|
289
|
+
return Number.isFinite(v) ? `${Math.round(v * 100)}%` : '?';
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function oneLine(s, max) {
|
|
293
|
+
const flat = String(s).replace(/\s+/g, ' ').trim();
|
|
294
|
+
return flat.length > max ? flat.slice(0, max - 1) + '…' : flat;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function indent(s) {
|
|
298
|
+
return String(s).replace(/\n/g, '\n ');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// The brief an agent works from: what to do, then the queued comments and answers. Built by the
|
|
302
|
+
// review's Copy to Agent (brief.md) and by `clickroom process`. `comments` and `answers` are
|
|
303
|
+
// { count, md } digests (toMarkdown with status actionable / answered).
|
|
304
|
+
export function buildBrief(name, { comments, answers, tasks = 0 }, { root, reviewUrl } = {}) {
|
|
305
|
+
const out = [
|
|
306
|
+
`# Process clickroom feedback: ${name}`,
|
|
307
|
+
'',
|
|
308
|
+
...(root || reviewUrl ? [...(root ? [`- **Project folder:** \`${root}\``] : []), ...(reviewUrl ? [`- **Review:** ${reviewUrl}`] : []), ''] : []),
|
|
309
|
+
`The team queued ${[tasks && plural(tasks, 'task'), plural(comments.count - tasks, 'comment')].filter(Boolean).join(', ')} and ${plural(answers.count, 'answer')} for this version.`,
|
|
310
|
+
'Work each one into the code, then mark it so the review shows where it landed:',
|
|
311
|
+
'',
|
|
312
|
+
`1. **${tasks ? 'Tasks and comments' : 'Comments'}:** change the code, then \`clickroom_mark_fixed\` with a short note on what changed.`,
|
|
313
|
+
'2. **Answers:** change the prototype to follow the answer (a confirmed assumption may need no change), then',
|
|
314
|
+
' `clickroom_mark_fixed` on the question with a note on what changed, or "no change needed".',
|
|
315
|
+
'3. Anything unclear: ask with `clickroom_reply` (one item) or `clickroom_ask` (new question) instead of guessing.',
|
|
316
|
+
`4. When you're done, push a version: \`clickroom push "<one-line summary>"\`. It records the version on every item you marked.`,
|
|
317
|
+
'',
|
|
318
|
+
`No clickroom MCP tools? Use the CLI: \`clickroom resolve ${name} <n> --note "what changed"\` marks an item.`,
|
|
319
|
+
'',
|
|
320
|
+
'**Only these items:** leave everything else in the prototype as it is.',
|
|
321
|
+
'',
|
|
322
|
+
];
|
|
323
|
+
if (comments.count) out.push('---', '', demote(comments.md));
|
|
324
|
+
if (answers.count) out.push('---', '', demote(answers.md));
|
|
325
|
+
return out.join('\n').trimEnd() + '\n';
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// The digests start at "# …"; nest them under the brief's title.
|
|
329
|
+
const demote = (md) => md.replace(/^(#+) /gm, '#$1 ').trimEnd() + '\n';
|
|
330
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
331
|
+
|
|
332
|
+
// "Find gaps with Claude": what an agent reads in the prototype to draft questions and assumptions
|
|
333
|
+
// for the client. Run in the background by the project's clickroom terminal (read-only, see
|
|
334
|
+
// cli/gaps.js), or copied from the empty Clarify tab into Claude Code.
|
|
335
|
+
export function gapsPrompt(project) {
|
|
336
|
+
return [
|
|
337
|
+
`Look for gaps in the clickroom prototype "${project}": what an agent can't know and would otherwise guess.`,
|
|
338
|
+
'',
|
|
339
|
+
`1. Read the prototype's source in this folder (pages, copy, flows), and the questions already asked: clickroom_get_questions {"project":"${project}","status":"all"}. Don't ask anything twice.`,
|
|
340
|
+
'2. Find what only the client can answer: content that is missing or placeholder, business rules (prices, limits, who can do what), the why and for whom behind a screen, and decisions the prototype makes on their behalf.',
|
|
341
|
+
`3. For each one, call clickroom_ask {"project":"${project}", ...} once:`,
|
|
342
|
+
' - type "question" for something open, "assumption" for a choice the prototype already made that the client should confirm',
|
|
343
|
+
' - start the text with a short topic and a colon ("Pricing: Do prices include VAT?"), and keep it to one sentence a client can answer quickly',
|
|
344
|
+
' - "now": what the prototype does until it is answered',
|
|
345
|
+
' - "page" (and "selector" when it is about one element) so the team sees where it is',
|
|
346
|
+
' - a stable "key" (e.g. "pricing-vat") so running this again rewords instead of adding twice',
|
|
347
|
+
'4. Ask 8 to 12 of the most important ones, most important first. They start as drafts that only the team sees.',
|
|
348
|
+
'',
|
|
349
|
+
'Only read and ask: do not change any files.',
|
|
350
|
+
].join('\n');
|
|
351
|
+
}
|