@lovinka/vitrinka 1.1.0 → 1.4.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 +20 -0
- package/dist/cli.js +493 -10
- package/dist/cli.js.map +1 -1
- package/dist/lib/token.js +26 -0
- package/dist/lib/token.js.map +1 -1
- package/dist/mcp.js +411 -16
- package/dist/mcp.js.map +1 -1
- package/package.json +7 -5
- package/skills/artifact/SKILL.md +82 -0
- package/skills/brainstorming/SKILL.md +107 -0
- package/skills/screenshot/SKILL.md +70 -0
- package/skills/screenshot/gallery.mjs +6 -0
- package/skills/vitrinka/cli.ts +39 -0
- package/skills/vitrinka/listen/SKILL.md +193 -0
- package/skills/vitrinka/tests/_helpers.ts +108 -0
- package/skills/vitrinka/tests/derive.test.ts +118 -0
- package/skills/vitrinka/tests/dx.test.ts +264 -0
- package/skills/vitrinka/tests/embed.test.ts +96 -0
- package/skills/vitrinka/tests/index.test.ts +250 -0
- package/skills/vitrinka/tests/install-name.test.ts +111 -0
- package/skills/vitrinka/tests/manifest.test.ts +98 -0
- package/skills/vitrinka/tests/operator.test.ts +119 -0
- package/skills/vitrinka/tests/push.test.ts +115 -0
- package/skills/vitrinka/tests/scaffold.test.ts +173 -0
- package/skills/vitrinka/tests/shim.test.ts +28 -0
- package/skills/vitrinka/tests/snap.test.ts +175 -0
- package/skills/vitrinka/tests/watch.test.ts +204 -0
package/dist/mcp.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// Register (user-level MCP config):
|
|
20
20
|
// claude mcp add --scope user vitrinka -- node <repo>/mcp/server.ts
|
|
21
21
|
import { createInterface } from 'node:readline';
|
|
22
|
-
import { readToken } from "./lib/token.js";
|
|
22
|
+
import { readToken, tokenPath } from "./lib/token.js";
|
|
23
23
|
import { readOperator, actorHeaderValue } from "./lib/operator.js";
|
|
24
24
|
import { resolveBaseUrl } from "./lib/base-url.js";
|
|
25
25
|
import { makeApi } from "./lib/http.js";
|
|
@@ -33,7 +33,7 @@ const TOKEN = readToken();
|
|
|
33
33
|
// this side uses the MCP semantics (treatEmptyAsUnset=false).
|
|
34
34
|
const OPERATOR = actorHeaderValue(readOperator(false));
|
|
35
35
|
const PROTOCOL_VERSION = '2025-06-18';
|
|
36
|
-
const SERVER_INFO = { name: 'vitrinka', version: '1.
|
|
36
|
+
const SERVER_INFO = { name: 'vitrinka', version: '1.4.0' };
|
|
37
37
|
// ---------- HTTP helper ----------
|
|
38
38
|
const rawApi = makeApi({ baseUrl: BASE_URL, token: TOKEN, operator: OPERATOR });
|
|
39
39
|
// Thin shim so the existing call sites keep their (method, path, body, opts)
|
|
@@ -81,6 +81,45 @@ const TOOLS = {
|
|
|
81
81
|
},
|
|
82
82
|
handler: (a) => api('GET', `/api/v1/sets/${seg(a.project)}/${seg(a.branch)}/${seg(a.selector)}`),
|
|
83
83
|
},
|
|
84
|
+
search_shots: {
|
|
85
|
+
description: 'Search the cross-set screens library (shot index): every ingested screenshot, filterable by '
|
|
86
|
+
+ 'project / journey / surface / branch / kind, free text over route·note·title·label (FTS prefix match), '
|
|
87
|
+
+ 'and a time range. Default mode returns the NEWEST shot per screen (history = older occurrences, '
|
|
88
|
+
+ 'fetch them via the returned screenKey at GET /api/v1/library/screen); all=true returns every occurrence. '
|
|
89
|
+
+ 'Use this to pull screens onto boards, e.g. "all marketplace_pro screens" or the "inquiry" journey.',
|
|
90
|
+
inputSchema: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
properties: {
|
|
93
|
+
project: selectorProps.project,
|
|
94
|
+
journey: { type: 'string', description: 'Journey tag, e.g. "inquiry" (case-insensitive; from manifest per-shot journey or the set journey title)' },
|
|
95
|
+
surface: { type: 'string', description: 'Capture surface, e.g. "ios", "web"' },
|
|
96
|
+
branch: { ...selectorProps.branch, description: 'Branch slug filter' },
|
|
97
|
+
kind: { type: 'string', description: 'Set kind filter (default sets are "screenshots")' },
|
|
98
|
+
q: { type: 'string', description: 'Free text over route, note, title, label, journey' },
|
|
99
|
+
since: { type: 'string', description: 'Inclusive RFC 3339 lower bound on capture ts' },
|
|
100
|
+
until: { type: 'string', description: 'Exclusive RFC 3339 upper bound on capture ts' },
|
|
101
|
+
all: { type: 'boolean', description: 'true = every occurrence flat; default false = newest per screen' },
|
|
102
|
+
limit: { type: 'number', description: 'Max results (default 200, cap 1000)' },
|
|
103
|
+
offset: { type: 'number', description: 'Pagination offset' },
|
|
104
|
+
},
|
|
105
|
+
additionalProperties: false,
|
|
106
|
+
},
|
|
107
|
+
handler: (a) => {
|
|
108
|
+
const p = new URLSearchParams();
|
|
109
|
+
for (const k of ['project', 'journey', 'surface', 'branch', 'kind', 'q', 'since', 'until']) {
|
|
110
|
+
if (a[k])
|
|
111
|
+
p.set(k, String(a[k]));
|
|
112
|
+
}
|
|
113
|
+
if (a.all)
|
|
114
|
+
p.set('all', '1');
|
|
115
|
+
if (a.limit)
|
|
116
|
+
p.set('limit', String(a.limit));
|
|
117
|
+
if (a.offset)
|
|
118
|
+
p.set('offset', String(a.offset));
|
|
119
|
+
const qs = p.toString();
|
|
120
|
+
return api('GET', '/api/v1/library' + (qs ? '?' + qs : ''));
|
|
121
|
+
},
|
|
122
|
+
},
|
|
84
123
|
rename_set: {
|
|
85
124
|
description: 'Set (or change) the custom URL-addressable name of a set — e.g. name fixit/main v2 "payout-flow" so https://…/fixit/main/payout-flow resolves. Pass an empty name to clear it. Requires VITRINKA_TOKEN.',
|
|
86
125
|
inputSchema: {
|
|
@@ -115,6 +154,47 @@ const TOOLS = {
|
|
|
115
154
|
return api('PATCH', `/api/v1/sets/${seg(a.project)}/${seg(a.branch)}/${seg(a.selector)}`, patch);
|
|
116
155
|
},
|
|
117
156
|
},
|
|
157
|
+
list_components: {
|
|
158
|
+
description: 'List a project\'s indexed UI components — compact rows (library, name, kind, one-line '
|
|
159
|
+
+ 'summary). LOAD THIS BEFORE MOCKING a project\'s UI so artifact mockups use its real component '
|
|
160
|
+
+ 'vocabulary (names, variants, tokens) instead of inventing one. Monorepos have several libraries '
|
|
161
|
+
+ '(e.g. "expo" vs "web") — filter with library. Empty = the project has not run '
|
|
162
|
+
+ '`vitrinka index-components` yet.',
|
|
163
|
+
inputSchema: {
|
|
164
|
+
type: 'object',
|
|
165
|
+
properties: {
|
|
166
|
+
project: { type: 'string', description: 'Project slug, e.g. "fixit"' },
|
|
167
|
+
library: { type: 'string', description: 'One UI kit only, e.g. "web"' },
|
|
168
|
+
query: { type: 'string', description: 'Substring filter over name + summary' },
|
|
169
|
+
},
|
|
170
|
+
required: ['project'],
|
|
171
|
+
additionalProperties: false,
|
|
172
|
+
},
|
|
173
|
+
handler: (a) => {
|
|
174
|
+
const q = new URLSearchParams();
|
|
175
|
+
if (a.library)
|
|
176
|
+
q.set('library', String(a.library));
|
|
177
|
+
if (a.query)
|
|
178
|
+
q.set('q', String(a.query));
|
|
179
|
+
const qs = q.toString();
|
|
180
|
+
return api('GET', `/api/v1/projects/${seg(a.project)}/components${qs ? '?' + qs : ''}`);
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
get_component: {
|
|
184
|
+
description: 'One indexed component in full: props (names/types), variant vocabulary, summary, source '
|
|
185
|
+
+ 'path. Use after list_components when a mockup needs the exact prop shape.',
|
|
186
|
+
inputSchema: {
|
|
187
|
+
type: 'object',
|
|
188
|
+
properties: {
|
|
189
|
+
project: { type: 'string' },
|
|
190
|
+
library: { type: 'string' },
|
|
191
|
+
name: { type: 'string' },
|
|
192
|
+
},
|
|
193
|
+
required: ['project', 'library', 'name'],
|
|
194
|
+
additionalProperties: false,
|
|
195
|
+
},
|
|
196
|
+
handler: (a) => api('GET', `/api/v1/projects/${seg(a.project)}/components?library=${seg(a.library)}&name=${seg(a.name)}`),
|
|
197
|
+
},
|
|
118
198
|
// ---- annotation-board work queue (design 2026-07-04) ----
|
|
119
199
|
//
|
|
120
200
|
// The agent loop: list_work → set_status working → fix the app →
|
|
@@ -122,21 +202,27 @@ const TOOLS = {
|
|
|
122
202
|
// reply → set_status in_review. Only the USER resolves (accept in the
|
|
123
203
|
// board UI); never set status "resolved" yourself.
|
|
124
204
|
list_boards: {
|
|
125
|
-
description: 'List the annotation boards (Freeform-style canvases over screenshot sets). Each has a slug, title and URL.',
|
|
126
|
-
inputSchema: {
|
|
127
|
-
|
|
205
|
+
description: 'List the annotation boards (Freeform-style canvases over screenshot sets). Each has a slug, title, owning project and URL. Optionally filter to one project.',
|
|
206
|
+
inputSchema: {
|
|
207
|
+
type: 'object',
|
|
208
|
+
properties: { project: { type: 'string', description: 'Only boards of this project (e.g. "fixit"); omit for all' } },
|
|
209
|
+
additionalProperties: false,
|
|
210
|
+
},
|
|
211
|
+
handler: (a) => api('GET', `/api/v1/boards${a.project ? `?project=${seg(a.project)}` : ''}`),
|
|
128
212
|
},
|
|
129
213
|
ask_board: {
|
|
130
|
-
description: 'Ask the operator a first-class multiple-choice question on a board — rendered as one-click pills, optionally anchored to a card. The answer returns on the work wire as a {type:"choice"} item (wait_for_work)
|
|
214
|
+
description: 'Ask the operator a first-class multiple-choice question on a board — rendered as one-click pills, optionally anchored to a card. The answer is STAGED on the board until the operator presses "Send to Claude", then returns on the work wire as a {type:"choice"} item (wait_for_work) — so answers may arrive in a batch, not per click. You can also poll GET /api/v1/boards/{slug}/questions to see staged/answered state without holding the wire. This is how you let the user "pick B" in one click instead of prose. An "Other / Něco jiného" free-text escape is always added by the engine, so pass only the real options — never hard-block off-vocabulary answers. Stamps the operator as X-Board-Actor.',
|
|
131
215
|
inputSchema: {
|
|
132
216
|
type: 'object',
|
|
133
217
|
properties: {
|
|
134
218
|
board: { type: 'string', description: 'Board slug (from list_boards)' },
|
|
135
219
|
prompt: { type: 'string', description: 'The question' },
|
|
136
220
|
options: {
|
|
137
|
-
type: 'array',
|
|
138
|
-
|
|
221
|
+
type: 'array',
|
|
222
|
+
items: { anyOf: [{ type: 'string' }, { type: 'object', properties: { label: { type: 'string' }, hint: { type: 'string' }, recommended: { type: 'boolean' } }, required: ['label'], additionalProperties: false }] },
|
|
223
|
+
description: '1-12 answer options — strings, or {label, hint?, recommended?} to carry tradeoffs (hint shows on hover, recommended gets a hairline mark). The "Other" escape is appended automatically.',
|
|
139
224
|
},
|
|
225
|
+
multiSelect: { type: 'boolean', description: 'Allow picking several options; the choice item then carries options: [labels] (default false)' },
|
|
140
226
|
cardId: { type: 'number', description: 'Optional card id to anchor the question to (omit for a board-level question)' },
|
|
141
227
|
},
|
|
142
228
|
required: ['board', 'prompt', 'options'],
|
|
@@ -144,22 +230,277 @@ const TOOLS = {
|
|
|
144
230
|
},
|
|
145
231
|
handler: (a) => api('POST', `/api/v1/boards/${seg(a.board)}/questions`, {
|
|
146
232
|
prompt: String(a.prompt),
|
|
147
|
-
options: Array.isArray(a.options) ? a.options
|
|
233
|
+
options: Array.isArray(a.options) ? a.options : [],
|
|
234
|
+
...(a.multiSelect ? { multiSelect: true } : {}),
|
|
148
235
|
...(a.cardId !== undefined ? { cardId: Number(a.cardId) } : {}),
|
|
149
236
|
}),
|
|
150
237
|
},
|
|
238
|
+
compose_board: {
|
|
239
|
+
description: 'Place a BATCH of AI-authored content on a board in one call — text blocks, notes, '
|
|
240
|
+
+ 'shapes and questions, wired together. This is how you present ideas, decision maps and '
|
|
241
|
+
+ 'brainstorm notes visually. Batch-or-bust: compose the whole thought in ONE call (never one '
|
|
242
|
+
+ 'card per call); the server owns geometry (layout modes, no coordinates) and everything appears '
|
|
243
|
+
+ 'live at once. Cards land on the toggleable "✦ eve" layer. Give cards a "ref" so edges/questions '
|
|
244
|
+
+ 'in the same call can reference them. All-or-nothing: on 400 the error carries {code,index} '
|
|
245
|
+
+ 'pointing at the bad item and nothing was placed. DEEP COMPOSITION: frames NEST — a cluster/'
|
|
246
|
+
+ 'section may list other frames in "children" (max depth 5), and any frame may carry '
|
|
247
|
+
+ 'payload.layout {mode, gap, cols, padding} to OWN its inner flow forever (the server reflows '
|
|
248
|
+
+ 'it recursively on every change — one call renders a whole laid-out tree). TEMPLATES: pass '
|
|
249
|
+
+ '{template: "name", params: {slot: "value"}} to instantiate a registry template (save_template '
|
|
250
|
+
+ 'creates them; get_templates lists them) — its cards/edges/questions land first, then any '
|
|
251
|
+
+ 'inline ones; {{slot}} placeholders substitute from params.',
|
|
252
|
+
inputSchema: {
|
|
253
|
+
type: 'object',
|
|
254
|
+
properties: {
|
|
255
|
+
board: { type: 'string', description: 'Board slug (from list_boards)' },
|
|
256
|
+
layout: { type: 'string', enum: ['column', 'row', 'grid'], description: 'How the batch flows: column (default — reading order), row, or grid (3 per row)' },
|
|
257
|
+
anchorCardId: { type: 'number', description: 'Place the batch beside this existing card (omit for free space beside the board)' },
|
|
258
|
+
section: { type: 'string', description: 'Journey section name (see scrape_board sections[]): the batch lands INSIDE that frame, below its existing content, and the frame grows to fit. Mutually exclusive with anchorCardId and journey.' },
|
|
259
|
+
journey: { type: 'string', description: 'Pass-chain key ("checkout"): target a journey\'s ITERATION chain instead of a named section. Alone = the batch lands in the chain\'s LATEST pass (creates pass 1 if none — a plain section whose title matches is adopted as pass 1). This is THE way to present round 2/3 of a journey: one call opens the next pass and fills it.' },
|
|
260
|
+
pass: { anyOf: [{ type: 'string', enum: ['next'] }, { type: 'number' }], description: 'With journey: "next" CREATES the next pass section (auto-named "Checkout — pass N", placed beside pass N−1, layout inherited) and fills it in this same call; a number targets that existing pass. Omit = latest.' },
|
|
261
|
+
template: { type: 'string', description: 'Registry template name (save_template / get_templates): its cards/edges/questions are instantiated FIRST, then any inline ones — one call renders the whole skeleton' },
|
|
262
|
+
params: { type: 'object', description: 'Template {{slot}} substitutions, e.g. {version: "2.4"}; unfilled slots render literally' },
|
|
263
|
+
cards: {
|
|
264
|
+
type: 'array',
|
|
265
|
+
description: 'Up to 60 cards. kind "text": payload {md, role: idea|note|pro|con|risk|heading|caption} — markdown (#/##/### **bold** `code` - lists). kind "note": payload {text}. kind "shape": payload {shape: rect|ellipse, color}. kind "viz": payload {viz, data, title?} — data-only visualizations: table {cols,rows}, matrix {x:[lo,hi],y:[lo,hi],points:[{label,x,y}] with x/y in 0..1}, flow {steps:[label]}, bars {items:[{label,value}]}, timeline {items:[{label,when}]}, line {series:[{label,points:[numbers]}], x?:[labels]} — a chart, stat {items:[{label,value,delta?,unit?}]} — KPI stat boxes. kind "cluster": payload {title} — a frame; list member refs in "children" and they are laid out inside it. kind "section": payload {title} — a NAMED JOURNEY FRAME ("Onboarding", "Checkout"): always visible (not on the ✦ eve layer), auto-indexed (pill navigation + scrape sections[]), membership is spatial — cards inside it belong to it; "children" lays the listed refs out inside. Also use a section to present the NEXT ITERATION of a journey ("Checkout — iteration 2") as its own frame beside the original — never mix a new take into the old section. UI CORE ELEMENTS (data-only, ~100-300 tokens each): kind "step" {n?, title, note?, status: todo|pass|fail, cardId?} — a numbered journey step; cardId references an EXISTING shot card for a live thumbnail (never re-upload a screen you can reference). kind "callout" {tone: info|warn|success|decision, md}. kind "checklist" {title?, items:[{text, done?}]} — interactive, users tick items live (state persists on the card). kind "compare" {a:{cardId}, b:{cardId}, mode: wipe|ghost} — two existing cards\' faces behind a wipe/ghost slider (iteration pixel-diff). Kanban: a cluster with payload {title, role:"kanban"} renders as a status column. Any card may carry payload.lane (e.g. "iphone") — the arrange/auto-layout \'lanes\' mode groups rows by it. kind "wireframe" {title?, device: phone|tablet|web, fidelity?: lo|tokens, nodes:[{t, label?, …}]} — a ~100-250-token LO-FI UI MOCKUP (the cheap alternative to an HTML artifact; use it to SKETCH proposed screens): nodes render top-to-bottom inside a device frame; t: nav {label}, tabbar {label: "home · search · profile"}, heading {label}, text {label? — omit for gray placeholder lines}, button {label, primary?}, input {label = placeholder}, img {h: s|m|l — crossed placeholder box}, list {n: rows}, listitem {label?}, divider, chiprow {label: "a, b, c"}, modal {label}; unknown t renders as a labeled dashed box. fidelity defaults "lo" (sketchy gray — the brainstorming register); "tokens" tints primaries. kind "doc" {title?, nodes:[{t, …}]} — a declarative node DOCUMENT in ONE card (~300 tokens for a dense dashboard): containers t: row|col {nodes:[…]} nest freely; content leaves t: heading|text {md}, stat|bars|table|line|timeline|flow|matrix (same data shape as the viz kinds, inline), checklist {items:[{text,done?}]} (static), img, divider; inner nodes are pixels (not annotatable) — use real cards when structure matters. FRAME NESTING: cluster/section children may include other frames (depth ≤5), and any frame payload may carry layout {mode: column|row|grid|flow|lanes|masonry, gap: S|M|L|px, cols, padding} — the server then owns that frame\'s inner flow recursively, forever. Optional w/h override the per-kind default footprint.',
|
|
266
|
+
items: {
|
|
267
|
+
type: 'object',
|
|
268
|
+
properties: {
|
|
269
|
+
ref: { type: 'string', description: 'Batch-local handle for edges/questions/children (e.g. "idea1")' },
|
|
270
|
+
children: { type: 'array', items: { type: 'string' }, description: 'cluster/section only: refs of batch cards to lay out inside this frame — INCLUDING other frames (nesting, max depth 5)' },
|
|
271
|
+
kind: { type: 'string', description: 'text|note|shape|viz|cluster|section|step|callout|checklist|compare|wireframe|doc — OPEN vocabulary: the server validates; an unknown kind 400s with the full allowed list, so new kinds work without a schema update' },
|
|
272
|
+
payload: { type: 'object', description: 'Kind-specific payload (see cards description)' },
|
|
273
|
+
w: { type: 'number' }, h: { type: 'number' },
|
|
274
|
+
},
|
|
275
|
+
required: ['kind', 'payload'],
|
|
276
|
+
additionalProperties: false,
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
edges: {
|
|
280
|
+
type: 'array',
|
|
281
|
+
description: 'Up to 40 wires between cards. from/to: a ref string from this batch, or an existing card id (number).',
|
|
282
|
+
items: {
|
|
283
|
+
type: 'object',
|
|
284
|
+
properties: {
|
|
285
|
+
from: { description: 'Card ref (string) or existing card id (number)' },
|
|
286
|
+
to: { description: 'Card ref (string) or existing card id (number)' },
|
|
287
|
+
label: { type: 'string' },
|
|
288
|
+
},
|
|
289
|
+
required: ['from', 'to'],
|
|
290
|
+
additionalProperties: false,
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
questions: {
|
|
294
|
+
type: 'array',
|
|
295
|
+
description: 'Up to 12 questions, optionally anchored to a batch card (cardRef) or existing card (cardId). Same staged-answer mechanics as ask_board.',
|
|
296
|
+
items: {
|
|
297
|
+
type: 'object',
|
|
298
|
+
properties: {
|
|
299
|
+
prompt: { type: 'string' },
|
|
300
|
+
options: { type: 'array', items: { anyOf: [{ type: 'string' }, { type: 'object', properties: { label: { type: 'string' }, hint: { type: 'string' }, recommended: { type: 'boolean' } }, required: ['label'], additionalProperties: false }] }, description: '1-12 options — strings or {label, hint?, recommended?} (the "Other" escape is appended automatically)' },
|
|
301
|
+
multiSelect: { type: 'boolean' },
|
|
302
|
+
cardRef: { type: 'string' },
|
|
303
|
+
cardId: { type: 'number' },
|
|
304
|
+
},
|
|
305
|
+
required: ['prompt', 'options'],
|
|
306
|
+
additionalProperties: false,
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
required: ['board'],
|
|
311
|
+
additionalProperties: false,
|
|
312
|
+
},
|
|
313
|
+
handler: (a) => api('POST', `/api/v1/boards/${seg(a.board)}/compose`, {
|
|
314
|
+
...(a.layout ? { layout: String(a.layout) } : {}),
|
|
315
|
+
...(a.section ? { section: String(a.section) } : {}),
|
|
316
|
+
...(a.journey ? { journey: String(a.journey) } : {}),
|
|
317
|
+
...(a.pass !== undefined ? { pass: a.pass } : {}),
|
|
318
|
+
...(a.anchorCardId !== undefined ? { anchor: { cardId: Number(a.anchorCardId) } } : {}),
|
|
319
|
+
...(a.template ? { template: String(a.template) } : {}),
|
|
320
|
+
...(a.params ? { params: a.params } : {}),
|
|
321
|
+
cards: Array.isArray(a.cards) ? a.cards : [],
|
|
322
|
+
edges: Array.isArray(a.edges) ? a.edges : [],
|
|
323
|
+
questions: Array.isArray(a.questions) ? a.questions : [],
|
|
324
|
+
}),
|
|
325
|
+
},
|
|
326
|
+
save_template: {
|
|
327
|
+
description: 'Save a REUSABLE board template into the registry (upsert by name). Two modes: '
|
|
328
|
+
+ 'pass body {cards, edges?, questions?} (compose-shaped, {{slot}} placeholders in any string '
|
|
329
|
+
+ 'payload field become instantiation params) — or pass board + section to EXTRACT a live '
|
|
330
|
+
+ 'section\'s whole subtree (nested frames, layouts, children) as the skeleton. Instantiate '
|
|
331
|
+
+ 'later with compose_board {template, params}; get_templates lists the registry. Blob-backed '
|
|
332
|
+
+ 'cards (shots/media/artifacts) are skipped on extraction — reference them at instantiation '
|
|
333
|
+
+ 'time instead.',
|
|
334
|
+
inputSchema: {
|
|
335
|
+
type: 'object',
|
|
336
|
+
properties: {
|
|
337
|
+
name: { type: 'string', description: 'Registry key — a slug (a-z, 0-9, dashes, ≤64); saving again overwrites' },
|
|
338
|
+
title: { type: 'string', description: 'Human title shown in listings' },
|
|
339
|
+
body: { type: 'object', description: 'Compose-shaped subtree {cards, edges?, questions?} with {{slot}} placeholders. Mutually exclusive with board+section.' },
|
|
340
|
+
board: { type: 'string', description: 'With section: extract from this board (slug)' },
|
|
341
|
+
section: { type: 'string', description: 'With board: the journey section name whose subtree becomes the template' },
|
|
342
|
+
},
|
|
343
|
+
required: ['name'],
|
|
344
|
+
additionalProperties: false,
|
|
345
|
+
},
|
|
346
|
+
handler: (a) => api('POST', '/api/v1/templates', {
|
|
347
|
+
name: String(a.name),
|
|
348
|
+
...(a.title ? { title: String(a.title) } : {}),
|
|
349
|
+
...(a.body ? { body: a.body } : {}),
|
|
350
|
+
...(a.board ? { board: String(a.board) } : {}),
|
|
351
|
+
...(a.section ? { section: String(a.section) } : {}),
|
|
352
|
+
}),
|
|
353
|
+
},
|
|
354
|
+
arrange: {
|
|
355
|
+
description: 'Auto-align board cards server-side — the LAYOUT ENGINE: geometry is computed in Go, '
|
|
356
|
+
+ 'costs you zero tokens, snaps to the board lattice and lands as ONE atomic layout event that '
|
|
357
|
+
+ 'returns every card\'s final bounds. Never do pixel math yourself — express intent with the '
|
|
358
|
+
+ 'knobs (gap/padding "S"|"M"|"L" or px, cols, sizing uniform). Default scope is the AI '
|
|
359
|
+
+ 'expression layer; scope "all" tidies everything; scope "section:<Name>" reflows one journey '
|
|
360
|
+
+ 'section INSIDE its frame (which grows to fit); ids picks specific cards. Modes: column, row, '
|
|
361
|
+
+ 'grid (cols per row), flow (row-wrap at the section\'s inner width), lanes (payload.lane rows — '
|
|
362
|
+
+ 'journey steps × variants), masonry (column-balanced mixed heights), timeline (creation order), '
|
|
363
|
+
+ 'timeaxis (one row positioned PROPORTIONALLY by real creation time — activity history with time ticks), '
|
|
364
|
+
+ 'compare (sections: ["A","B"] side-by-side, members row-aligned pairwise — iterations), '
|
|
365
|
+
+ 'align-left, align-top, distribute-h, distribute-v. AUTO-LAYOUT: pass save:true with a section '
|
|
366
|
+
+ 'scope to PERSIST the layout onto the section — the server then re-flows it automatically on '
|
|
367
|
+
+ 'every change (one arrange call, tidy forever; prefer this over repeated arrange calls).',
|
|
368
|
+
inputSchema: {
|
|
369
|
+
type: 'object',
|
|
370
|
+
properties: {
|
|
371
|
+
board: { type: 'string', description: 'Board slug' },
|
|
372
|
+
mode: { type: 'string', description: 'column|row|grid|flow|lanes|masonry|timeline|timeaxis|compare|align-left|align-top|distribute-h|distribute-v — open vocabulary, server-validated (unknown mode 400s with the list)' },
|
|
373
|
+
save: { type: 'boolean', description: 'With a section scope: persist this layout onto the section (payload.layout) so the server auto-reflows it on every future change' },
|
|
374
|
+
sections: { type: 'array', items: { type: 'string' }, description: 'mode "compare" only: exactly two section names to align side-by-side' },
|
|
375
|
+
scope: { type: 'string', description: 'Which cards move: "ai" (default — the expression layer), "all", or "section:<Name>" (that journey\'s members, arranged inside the frame)' },
|
|
376
|
+
ids: { type: 'array', items: { type: 'number' }, description: 'Explicit card ids; overrides scope' },
|
|
377
|
+
gap: { anyOf: [{ type: 'string', enum: ['S', 'M', 'L'] }, { type: 'number' }], description: 'Spacing between cards: S (12), M (24, default), L (48) or explicit px 0-400' },
|
|
378
|
+
padding: { anyOf: [{ type: 'string', enum: ['S', 'M', 'L'] }, { type: 'number' }], description: 'Frame inner padding when scoped to a section (default 24)' },
|
|
379
|
+
cols: { type: 'number', description: 'grid mode: cards per row (default 3)' },
|
|
380
|
+
sizing: { type: 'string', enum: ['natural', 'uniform'], description: 'uniform = every card takes the selection\'s max footprint (even rows); default natural' },
|
|
381
|
+
},
|
|
382
|
+
required: ['board', 'mode'],
|
|
383
|
+
additionalProperties: false,
|
|
384
|
+
},
|
|
385
|
+
handler: (a) => api('POST', `/api/v1/boards/${seg(a.board)}/arrange`, {
|
|
386
|
+
mode: String(a.mode),
|
|
387
|
+
...(a.scope ? { scope: String(a.scope) } : {}),
|
|
388
|
+
...(Array.isArray(a.ids) && a.ids.length ? { ids: a.ids.map(Number) } : {}),
|
|
389
|
+
...(a.gap !== undefined ? { gap: a.gap } : {}),
|
|
390
|
+
...(a.padding !== undefined ? { padding: a.padding } : {}),
|
|
391
|
+
...(a.cols !== undefined ? { cols: Number(a.cols) } : {}),
|
|
392
|
+
...(a.sizing ? { sizing: String(a.sizing) } : {}),
|
|
393
|
+
...(a.save ? { save: true } : {}),
|
|
394
|
+
...(Array.isArray(a.sections) && a.sections.length ? { sections: a.sections.map(String) } : {}),
|
|
395
|
+
}),
|
|
396
|
+
},
|
|
397
|
+
update_cards: {
|
|
398
|
+
description: 'Batch-edit YOUR OWN board layer — patch payload/geometry or remove cards you '
|
|
399
|
+
+ 'composed. Restricted to AI-layer cards (payload.layer "ai") plus section frames (shared '
|
|
400
|
+
+ 'journey structure — rename/resize/remove is allowed; removing a frame never deletes its '
|
|
401
|
+
+ 'members): the user\'s shots and notes are untouchable, a non-AI target 403s with '
|
|
402
|
+
+ '{code:"not_ai_layer"}. Use it to refine a brainstorm in place (reword a text card, retitle '
|
|
403
|
+
+ 'a cluster or section, drop a dead idea) instead of composing duplicates. Removals are SOFT '
|
|
404
|
+
+ '— trashed, never fully deleted: restore lifts your trashed cards back (GET '
|
|
405
|
+
+ '/api/v1/boards/{slug}/trash lists them; the response of a remove is your undo handle).',
|
|
406
|
+
inputSchema: {
|
|
407
|
+
type: 'object',
|
|
408
|
+
properties: {
|
|
409
|
+
board: { type: 'string', description: 'Board slug' },
|
|
410
|
+
patch: {
|
|
411
|
+
type: 'array',
|
|
412
|
+
items: {
|
|
413
|
+
type: 'object',
|
|
414
|
+
properties: {
|
|
415
|
+
id: { type: 'number' },
|
|
416
|
+
payload: { type: 'object', description: 'Replacement payload (the "layer" marker is preserved automatically)' },
|
|
417
|
+
x: { type: 'number' }, y: { type: 'number' }, w: { type: 'number' }, h: { type: 'number' },
|
|
418
|
+
},
|
|
419
|
+
required: ['id'],
|
|
420
|
+
additionalProperties: false,
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
remove: { type: 'array', items: { type: 'number' }, description: 'Card ids to TRASH (AI layer only; soft delete — restorable)' },
|
|
424
|
+
restore: { type: 'array', items: { type: 'number' }, description: 'Trashed card ids to lift back onto the board (AI layer only)' },
|
|
425
|
+
},
|
|
426
|
+
required: ['board'],
|
|
427
|
+
additionalProperties: false,
|
|
428
|
+
},
|
|
429
|
+
handler: (a) => api('POST', `/api/v1/boards/${seg(a.board)}/update-cards`, {
|
|
430
|
+
patch: Array.isArray(a.patch) ? a.patch : [],
|
|
431
|
+
remove: Array.isArray(a.remove) ? a.remove : [],
|
|
432
|
+
...(Array.isArray(a.restore) && a.restore.length ? { restore: a.restore.map(Number) } : {}),
|
|
433
|
+
}),
|
|
434
|
+
},
|
|
151
435
|
scrape_board: {
|
|
152
|
-
description: 'Read a WHOLE board as one structured testing digest — every media image (URL), note text and annotation — with the connect-tool edges walked into ordered journey chains ("flow": [[A,B,C]])
|
|
436
|
+
description: 'Read a WHOLE board as one structured testing digest — every media image (URL), note text and annotation — with the connect-tool edges walked into ordered journey chains ("flow": [[A,B,C]]) and the journey sections summarized in "sections": [{name, cards, bounds, order, journey?, pass?}] plus "journeys": [{journey, passes, latest}] (the pass-chain orientation map). Pass section to ISOLATE the digest to one journey ("Checkout") — the scoped read for section-focused work. Each card carries its containing "section" name. Images are absolute URLs (no auth on the mesh) — fetch them to SEE each step.',
|
|
437
|
+
inputSchema: {
|
|
438
|
+
type: 'object',
|
|
439
|
+
properties: {
|
|
440
|
+
slug: { type: 'string', description: 'Board slug (from list_boards)' },
|
|
441
|
+
section: { type: 'string', description: 'Journey section name — digest only that frame\'s members (404 if no such section)' },
|
|
442
|
+
diff: { type: 'array', items: { type: 'string' }, description: 'Exactly two section names — the ITERATION DIFF: members matched pairwise (same file, then reading order) as {pairs:[{a,b}]} plus unpaired leftovers. Mutually exclusive with section.' },
|
|
443
|
+
},
|
|
444
|
+
required: ['slug'],
|
|
445
|
+
additionalProperties: false,
|
|
446
|
+
},
|
|
447
|
+
handler: (a) => {
|
|
448
|
+
const q = new URLSearchParams();
|
|
449
|
+
if (a.section)
|
|
450
|
+
q.set('section', String(a.section));
|
|
451
|
+
if (Array.isArray(a.diff) && a.diff.length === 2)
|
|
452
|
+
q.set('diff', a.diff.map(String).join(','));
|
|
453
|
+
const qs = q.toString();
|
|
454
|
+
return api('GET', `/api/v1/boards/${seg(a.slug)}/scrape${qs ? `?${qs}` : ''}`);
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
list_sections: {
|
|
458
|
+
description: 'The automatic journey-section index of a board: every section (name, bounds, member card count, reading order, journey/pass when part of an iteration chain) PLUS "journeys": [{journey, title, passes, latest}] — the one-call orientation on "checkout has 3 passes, latest is pass 3". Cheaper than scrape_board when you only need the map of journeys — e.g. to pick a section for compose_board/arrange/scrape_board scoping or request_review, or a journey key for compose_board {journey, pass}.',
|
|
153
459
|
inputSchema: {
|
|
154
460
|
type: 'object',
|
|
155
|
-
properties: { slug: { type: 'string', description: 'Board slug
|
|
461
|
+
properties: { slug: { type: 'string', description: 'Board slug' } },
|
|
156
462
|
required: ['slug'],
|
|
157
463
|
additionalProperties: false,
|
|
158
464
|
},
|
|
159
|
-
handler: (a) => api('GET', `/api/v1/boards/${seg(a.slug)}/
|
|
465
|
+
handler: (a) => api('GET', `/api/v1/boards/${seg(a.slug)}/sections`),
|
|
466
|
+
},
|
|
467
|
+
get_templates: {
|
|
468
|
+
description: 'The board template library: the built-in markdown skeletons (QA session, iteration review, decision map, metrics dashboard, presentation deck, kanban triage) PLUS the saved REGISTRY templates (created with save_template, instantiable via compose_board {template, params} — each listed with its {{param}} slots). Fetch ONCE before structuring a new board and start from a matching template instead of inventing structure.',
|
|
469
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
470
|
+
handler: async () => {
|
|
471
|
+
const md = await api('GET', '/static/templates.md', undefined, { raw: true });
|
|
472
|
+
const reg = await api('GET', '/api/v1/templates');
|
|
473
|
+
const list = (reg.status === 200 && reg.body && Array.isArray(reg.body.templates))
|
|
474
|
+
? reg.body.templates : [];
|
|
475
|
+
if (md.status !== 200 || typeof md.body !== 'string' || list.length === 0) {
|
|
476
|
+
return md;
|
|
477
|
+
}
|
|
478
|
+
const lines = list.map((t) => `- **${t.name}**${t.title ? ` — ${t.title}` : ''}${t.params?.length ? ` · params: ${t.params.map((p) => `{{${p}}}`).join(' ')}` : ''}`);
|
|
479
|
+
return {
|
|
480
|
+
status: 200,
|
|
481
|
+
body: md.body + '\n\n---\n\n## Saved registry templates\n\nInstantiate with `compose_board {template: "<name>", params: {…}}`.\n\n' + lines.join('\n') + '\n',
|
|
482
|
+
};
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
request_review: {
|
|
486
|
+
description: 'Manually trigger an AI review pass on a board (needs the board\'s ai-review toggle ON), optionally scoped to one journey section — "review the Checkout journey" — or to a PASS CHAIN via journey: reviews the chain\'s LATEST pass with the previous pass attached as reviewer context, so the reviewer sees what changed between iterations. Returns the created pass; findings stream to the board\'s AI BOARD tab as eve completes. 409 = review off or a pass already running; 422 = no reviewable shot cards in scope.',
|
|
487
|
+
inputSchema: {
|
|
488
|
+
type: 'object',
|
|
489
|
+
properties: {
|
|
490
|
+
board: { type: 'string', description: 'Board slug' },
|
|
491
|
+
section: { type: 'string', description: 'Journey section name to scope the pass to (omit = whole board). Mutually exclusive with journey.' },
|
|
492
|
+
journey: { type: 'string', description: 'Pass-chain key (see list_sections journeys[]): review the LATEST pass with the previous pass as context' },
|
|
493
|
+
},
|
|
494
|
+
required: ['board'],
|
|
495
|
+
additionalProperties: false,
|
|
496
|
+
},
|
|
497
|
+
handler: (a) => api('POST', `/api/v1/boards/${seg(a.board)}/review-passes`, {
|
|
498
|
+
...(a.section ? { section: String(a.section) } : {}),
|
|
499
|
+
...(a.journey ? { journey: String(a.journey) } : {}),
|
|
500
|
+
}),
|
|
160
501
|
},
|
|
161
502
|
wait_for_work: {
|
|
162
|
-
description: 'BLOCK until annotation work exists — the continuous-work tool (board-v3). Long-polls the server: costs nothing while idle, returns up to 3 ready-to-act markdown capsules the instant the user annotates (or re-queues). {"idle":true} means the window elapsed quietly — simply call it again.
|
|
503
|
+
description: 'BLOCK until annotation work exists — the continuous-work tool (board-v3). Long-polls the server: costs nothing while idle, returns up to 3 ready-to-act markdown capsules the instant the user annotates (or re-queues). {"idle":true} means the window elapsed quietly — simply call it again. ALWAYS SCOPE to just your session\'s work: pass project + branch (or a board slug) — unscoped waits pick up other boards\' and other sessions\' items. A listening session (/vitrinka:listen) loops on this forever, scoped the same way its monitor is.',
|
|
163
504
|
inputSchema: {
|
|
164
505
|
type: 'object',
|
|
165
506
|
properties: {
|
|
@@ -193,7 +534,7 @@ const TOOLS = {
|
|
|
193
534
|
handler: (a) => api('GET', `/api/v1/annotations/${seg(a.id)}/capsule`, undefined, { raw: true }),
|
|
194
535
|
},
|
|
195
536
|
list_work: {
|
|
196
|
-
description: 'List annotation work items (the agent queue), FIFO. Each item: id, board, intent (fix|investigate|refactor|redesign|other), prompt, source set ref + file, crop URL.
|
|
537
|
+
description: 'List annotation work items (the agent queue), FIFO. Each item: id, board, intent (fix|investigate|refactor|redesign|reshoot|other; reshoot = NO code changes — just re-capture the screen per the capsule protocol and attach_after), prompt, source set ref + file, crop URL. ALWAYS SCOPE: pass board (slug) or project + branch (your repo+branch) — an unscoped call is the global firehose across every board and returns other sessions\' backlogs, which are NOT yours to work. Filter by status (open = waiting for an agent). Start here — scoped.',
|
|
197
538
|
inputSchema: {
|
|
198
539
|
type: 'object',
|
|
199
540
|
properties: {
|
|
@@ -332,7 +673,31 @@ async function handle(msg) {
|
|
|
332
673
|
+ 'the user annotates regions and thread replies route to claude by default (@eve only when '
|
|
333
674
|
+ 'explicitly mentioned) — a session in the app repo keeps listening via /vitrinka:listen, which '
|
|
334
675
|
+ 'arms a native background monitor (`vitrinka watch`) that re-invokes the session on each new '
|
|
335
|
-
+ 'annotation (zero-token idle); the session then drains the queue via wait_for_work.'
|
|
676
|
+
+ 'annotation (zero-token idle); the session then drains the queue via wait_for_work. '
|
|
677
|
+
+ 'WORK SCOPE: a working session cares ONLY about its own board\'s (or repo+branch\'s) '
|
|
678
|
+
+ 'annotations — always pass {board} or {project, branch} to list_work and wait_for_work, '
|
|
679
|
+
+ 'and never act on items from other boards that an unscoped call surfaces; those belong '
|
|
680
|
+
+ 'to other sessions\' queues. '
|
|
681
|
+
+ 'BOARD VOCABULARY (ai-expression-layer): you can PRESENT on boards, not just answer. '
|
|
682
|
+
+ 'compose_board places a whole thought in ONE call — markdown text cards (roles: '
|
|
683
|
+
+ 'heading/idea/pro/con/risk), data-only viz cards (table/matrix/flow/bars/timeline — '
|
|
684
|
+
+ 'always cheaper than an HTML artifact), cluster frames grouping a direction, wires, and '
|
|
685
|
+
+ 'anchored multiple-choice questions (options carry {label,hint,recommended}; answers '
|
|
686
|
+
+ 'arrive staged as choice items). Batch-or-bust: never place cards one call at a time. '
|
|
687
|
+
+ 'Never send coordinates — pick a layout (column/row/grid) and use arrange to tidy. '
|
|
688
|
+
+ 'JOURNEY SECTIONS: name board regions after customer journeys with kind "section" frames '
|
|
689
|
+
+ '("Onboarding", "Checkout") — they are always visible, auto-indexed (list_sections, pill '
|
|
690
|
+
+ 'navigation, scrape_board sections[]), and every tool scopes to them: compose_board '
|
|
691
|
+
+ '{section}, arrange {scope:"section:Name"} with gap/padding/cols knobs, scrape_board '
|
|
692
|
+
+ '{section} for an isolated digest, request_review {section} for a scoped AI review pass. '
|
|
693
|
+
+ 'Sections are also the ITERATION surface: when you produce the next take on a journey '
|
|
694
|
+
+ '(reworked screens, a redesign, round 2 of a flow), present it as its OWN section beside '
|
|
695
|
+
+ 'the current one ("Checkout — iteration 2") instead of mixing cards into the original — '
|
|
696
|
+
+ 'iterations then sit side by side, each pill-navigable, separately scrapeable and '
|
|
697
|
+
+ 'separately reviewable. '
|
|
698
|
+
+ 'update_cards edits/removes only your own layer (plus section frames — shared structure). '
|
|
699
|
+
+ 'PRESENTATION GENERATION: sections are slides — compose a deck as a series of sections (one heading + 2-3 supporting cards each: stat/line viz, callout, step referencing a shot), arrange each with save:true; the user presents with shift-P (arrows step sections). TEMPLATES: call get_templates for compose-ready skeletons (QA session, iteration review, decision map, metrics dashboard, presentation deck, kanban triage) — start from one instead of inventing structure. Everything else you place lives under the board\'s ✦ eve toggle, so express freely — the '
|
|
700
|
+
+ 'user can always mute the layer.',
|
|
336
701
|
});
|
|
337
702
|
return;
|
|
338
703
|
case 'notifications/initialized':
|
|
@@ -355,10 +720,40 @@ async function handle(msg) {
|
|
|
355
720
|
replyError(id ?? null, -32602, `unknown tool: ${name}`);
|
|
356
721
|
return;
|
|
357
722
|
}
|
|
723
|
+
// Enforce the declared inputSchema's required + additionalProperties —
|
|
724
|
+
// without this a misnamed argument (e.g. body instead of text) sails
|
|
725
|
+
// through and the handler stringifies undefined into real data.
|
|
726
|
+
{
|
|
727
|
+
const args = (params?.arguments ?? {});
|
|
728
|
+
const schema = tool.inputSchema;
|
|
729
|
+
const missing = (schema.required ?? []).filter((k) => args[k] === undefined);
|
|
730
|
+
const extra = schema.additionalProperties === false && schema.properties
|
|
731
|
+
? Object.keys(args).filter((k) => !(k in schema.properties))
|
|
732
|
+
: [];
|
|
733
|
+
if (missing.length || extra.length) {
|
|
734
|
+
reply(id, {
|
|
735
|
+
content: [{ type: 'text', text: `invalid arguments for ${name}: ${[
|
|
736
|
+
missing.length ? `missing required ${missing.join(', ')}` : '',
|
|
737
|
+
extra.length ? `unknown ${extra.join(', ')}` : '',
|
|
738
|
+
].filter(Boolean).join('; ')} — see the tool's inputSchema` }],
|
|
739
|
+
isError: true,
|
|
740
|
+
});
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
358
744
|
try {
|
|
359
745
|
const res = await tool.handler(params?.arguments ?? {});
|
|
360
746
|
const isErr = res.status >= 400;
|
|
361
|
-
|
|
747
|
+
// Compact JSON — pretty-printing burned ~25% of every tool result on
|
|
748
|
+
// whitespace the model pays for (ai-expression-layer §token discipline).
|
|
749
|
+
let text = typeof res.body === 'string' ? res.body : JSON.stringify(res.body);
|
|
750
|
+
if (res.status === 401) {
|
|
751
|
+
// Write auth failed — say why and where the token comes from, so the
|
|
752
|
+
// model can relay actionable guidance instead of a bare "unauthorized".
|
|
753
|
+
text += `\n\nwrite auth failed: ${TOKEN
|
|
754
|
+
? `the configured write token was rejected by ${BASE_URL} (stale or mistyped)`
|
|
755
|
+
: 'no write token is configured on this machine'} — the token is the SERVER's VITRINKA_TOKEN (one shared secret, no per-user mint); save it to ${tokenPath()} or $VITRINKA_TOKEN, and run \`vitrinka doctor\` for a full diagnosis.`;
|
|
756
|
+
}
|
|
362
757
|
reply(id, {
|
|
363
758
|
content: [{ type: 'text', text }],
|
|
364
759
|
isError: isErr,
|