@lovinka/vitrinka 1.1.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.1.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)
@@ -61,6 +61,33 @@ const TOOLS = {
61
61
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
62
62
  handler: () => api('GET', '/api/v1/projects'),
63
63
  },
64
+ get_project_memory: {
65
+ description: 'Read a project\'s memory document — the durable context (stack, conventions, decisions, gotchas) '
66
+ + 'maintained on the project page. ALWAYS read it before working a project\'s boards; it is the operator\'s '
67
+ + 'source of truth about the project.',
68
+ inputSchema: {
69
+ type: 'object',
70
+ properties: { project: selectorProps.project },
71
+ required: ['project'],
72
+ additionalProperties: false,
73
+ },
74
+ handler: (a) => api('GET', `/api/v1/projects/${seg(a.project)}/memory`),
75
+ },
76
+ save_project_memory: {
77
+ description: 'Replace a project\'s memory document (markdown, ≤64 KiB). Fetch the current body first and edit '
78
+ + 'surgically — this overwrites the whole document. Use for durable facts worth every future session knowing, '
79
+ + 'never for conversation-scoped notes.',
80
+ inputSchema: {
81
+ type: 'object',
82
+ properties: {
83
+ project: selectorProps.project,
84
+ body: { type: 'string', description: 'The full new markdown body' },
85
+ },
86
+ required: ['project', 'body'],
87
+ additionalProperties: false,
88
+ },
89
+ handler: (a) => api('PUT', `/api/v1/projects/${seg(a.project)}/memory`, { body: a.body }),
90
+ },
64
91
  list_sets: {
65
92
  description: 'List the artifact sets of a project (all branches) or of one branch — newest first, without file lists. Each set has a share URL, version, key, optional custom name and title.',
66
93
  inputSchema: {
@@ -81,6 +108,45 @@ const TOOLS = {
81
108
  },
82
109
  handler: (a) => api('GET', `/api/v1/sets/${seg(a.project)}/${seg(a.branch)}/${seg(a.selector)}`),
83
110
  },
111
+ search_shots: {
112
+ description: 'Search the cross-set screens library (shot index): every ingested screenshot, filterable by '
113
+ + 'project / journey / surface / branch / kind, free text over route·note·title·label (FTS prefix match), '
114
+ + 'and a time range. Default mode returns the NEWEST shot per screen (history = older occurrences, '
115
+ + 'fetch them via the returned screenKey at GET /api/v1/library/screen); all=true returns every occurrence. '
116
+ + 'Use this to pull screens onto boards, e.g. "all marketplace_pro screens" or the "inquiry" journey.',
117
+ inputSchema: {
118
+ type: 'object',
119
+ properties: {
120
+ project: selectorProps.project,
121
+ journey: { type: 'string', description: 'Journey tag, e.g. "inquiry" (case-insensitive; from manifest per-shot journey or the set journey title)' },
122
+ surface: { type: 'string', description: 'Capture surface, e.g. "ios", "web"' },
123
+ branch: { ...selectorProps.branch, description: 'Branch slug filter' },
124
+ kind: { type: 'string', description: 'Set kind filter (default sets are "screenshots")' },
125
+ q: { type: 'string', description: 'Free text over route, note, title, label, journey' },
126
+ since: { type: 'string', description: 'Inclusive RFC 3339 lower bound on capture ts' },
127
+ until: { type: 'string', description: 'Exclusive RFC 3339 upper bound on capture ts' },
128
+ all: { type: 'boolean', description: 'true = every occurrence flat; default false = newest per screen' },
129
+ limit: { type: 'number', description: 'Max results (default 200, cap 1000)' },
130
+ offset: { type: 'number', description: 'Pagination offset' },
131
+ },
132
+ additionalProperties: false,
133
+ },
134
+ handler: (a) => {
135
+ const p = new URLSearchParams();
136
+ for (const k of ['project', 'journey', 'surface', 'branch', 'kind', 'q', 'since', 'until']) {
137
+ if (a[k])
138
+ p.set(k, String(a[k]));
139
+ }
140
+ if (a.all)
141
+ p.set('all', '1');
142
+ if (a.limit)
143
+ p.set('limit', String(a.limit));
144
+ if (a.offset)
145
+ p.set('offset', String(a.offset));
146
+ const qs = p.toString();
147
+ return api('GET', '/api/v1/library' + (qs ? '?' + qs : ''));
148
+ },
149
+ },
84
150
  rename_set: {
85
151
  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
152
  inputSchema: {
@@ -115,6 +181,47 @@ const TOOLS = {
115
181
  return api('PATCH', `/api/v1/sets/${seg(a.project)}/${seg(a.branch)}/${seg(a.selector)}`, patch);
116
182
  },
117
183
  },
184
+ list_components: {
185
+ description: 'List a project\'s indexed UI components — compact rows (library, name, kind, one-line '
186
+ + 'summary). LOAD THIS BEFORE MOCKING a project\'s UI so artifact mockups use its real component '
187
+ + 'vocabulary (names, variants, tokens) instead of inventing one. Monorepos have several libraries '
188
+ + '(e.g. "expo" vs "web") — filter with library. Empty = the project has not run '
189
+ + '`vitrinka index-components` yet.',
190
+ inputSchema: {
191
+ type: 'object',
192
+ properties: {
193
+ project: { type: 'string', description: 'Project slug, e.g. "fixit"' },
194
+ library: { type: 'string', description: 'One UI kit only, e.g. "web"' },
195
+ query: { type: 'string', description: 'Substring filter over name + summary' },
196
+ },
197
+ required: ['project'],
198
+ additionalProperties: false,
199
+ },
200
+ handler: (a) => {
201
+ const q = new URLSearchParams();
202
+ if (a.library)
203
+ q.set('library', String(a.library));
204
+ if (a.query)
205
+ q.set('q', String(a.query));
206
+ const qs = q.toString();
207
+ return api('GET', `/api/v1/projects/${seg(a.project)}/components${qs ? '?' + qs : ''}`);
208
+ },
209
+ },
210
+ get_component: {
211
+ description: 'One indexed component in full: props (names/types), variant vocabulary, summary, source '
212
+ + 'path. Use after list_components when a mockup needs the exact prop shape.',
213
+ inputSchema: {
214
+ type: 'object',
215
+ properties: {
216
+ project: { type: 'string' },
217
+ library: { type: 'string' },
218
+ name: { type: 'string' },
219
+ },
220
+ required: ['project', 'library', 'name'],
221
+ additionalProperties: false,
222
+ },
223
+ handler: (a) => api('GET', `/api/v1/projects/${seg(a.project)}/components?library=${seg(a.library)}&name=${seg(a.name)}`),
224
+ },
118
225
  // ---- annotation-board work queue (design 2026-07-04) ----
119
226
  //
120
227
  // The agent loop: list_work → set_status working → fix the app →
@@ -122,21 +229,27 @@ const TOOLS = {
122
229
  // reply → set_status in_review. Only the USER resolves (accept in the
123
230
  // board UI); never set status "resolved" yourself.
124
231
  list_boards: {
125
- description: 'List the annotation boards (Freeform-style canvases over screenshot sets). Each has a slug, title and URL.',
126
- inputSchema: { type: 'object', properties: {}, additionalProperties: false },
127
- handler: () => api('GET', '/api/v1/boards'),
232
+ 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.',
233
+ inputSchema: {
234
+ type: 'object',
235
+ properties: { project: { type: 'string', description: 'Only boards of this project (e.g. "fixit"); omit for all' } },
236
+ additionalProperties: false,
237
+ },
238
+ handler: (a) => api('GET', `/api/v1/boards${a.project ? `?project=${seg(a.project)}` : ''}`),
128
239
  },
129
240
  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), so 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.',
241
+ 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
242
  inputSchema: {
132
243
  type: 'object',
133
244
  properties: {
134
245
  board: { type: 'string', description: 'Board slug (from list_boards)' },
135
246
  prompt: { type: 'string', description: 'The question' },
136
247
  options: {
137
- type: 'array', items: { type: 'string' },
138
- description: '1-12 answer options (the "Other" escape is appended automatically)',
248
+ type: 'array',
249
+ items: { anyOf: [{ type: 'string' }, { type: 'object', properties: { label: { type: 'string' }, hint: { type: 'string' }, recommended: { type: 'boolean' } }, required: ['label'], additionalProperties: false }] },
250
+ 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
251
  },
252
+ multiSelect: { type: 'boolean', description: 'Allow picking several options; the choice item then carries options: [labels] (default false)' },
140
253
  cardId: { type: 'number', description: 'Optional card id to anchor the question to (omit for a board-level question)' },
141
254
  },
142
255
  required: ['board', 'prompt', 'options'],
@@ -144,22 +257,279 @@ const TOOLS = {
144
257
  },
145
258
  handler: (a) => api('POST', `/api/v1/boards/${seg(a.board)}/questions`, {
146
259
  prompt: String(a.prompt),
147
- options: Array.isArray(a.options) ? a.options.map(String) : [],
260
+ options: Array.isArray(a.options) ? a.options : [],
261
+ ...(a.multiSelect ? { multiSelect: true } : {}),
148
262
  ...(a.cardId !== undefined ? { cardId: Number(a.cardId) } : {}),
149
263
  }),
150
264
  },
265
+ compose_board: {
266
+ description: 'Place a BATCH of AI-authored content on a board in one call — text blocks, notes, '
267
+ + 'shapes and questions, wired together. This is how you present ideas, decision maps and '
268
+ + 'brainstorm notes visually. Batch-or-bust: compose the whole thought in ONE call (never one '
269
+ + 'card per call); the server owns geometry (layout modes, no coordinates) and GUARANTEES the '
270
+ + 'batch lands in FREE space — an occupancy scan places it near your intent (anchor/section) '
271
+ + 'without ever overlapping existing cards, so express flow and order, never positions. '
272
+ + 'Everything appears live at once. Cards land on the toggleable "✦ eve" layer. Give cards a "ref" so edges/questions '
273
+ + 'in the same call can reference them. All-or-nothing: on 400 the error carries {code,index} '
274
+ + 'pointing at the bad item and nothing was placed. DEEP COMPOSITION: frames NEST — a cluster/'
275
+ + 'section may list other frames in "children" (max depth 5), and any frame may carry '
276
+ + 'payload.layout {mode, gap, cols, padding} to OWN its inner flow forever (the server reflows '
277
+ + 'it recursively on every change — one call renders a whole laid-out tree). TEMPLATES: pass '
278
+ + '{template: "name", params: {slot: "value"}} to instantiate a registry template (save_template '
279
+ + 'creates them; get_templates lists them) — its cards/edges/questions land first, then any '
280
+ + 'inline ones; {{slot}} placeholders substitute from params.',
281
+ inputSchema: {
282
+ type: 'object',
283
+ properties: {
284
+ board: { type: 'string', description: 'Board slug (from list_boards)' },
285
+ layout: { type: 'string', enum: ['column', 'row', 'grid'], description: 'How the batch flows: column (default — reading order), row, or grid (3 per row)' },
286
+ anchorCardId: { type: 'number', description: 'Place the batch beside this existing card (omit for free space beside the board)' },
287
+ 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.' },
288
+ 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.' },
289
+ 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.' },
290
+ 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' },
291
+ params: { type: 'object', description: 'Template {{slot}} substitutions, e.g. {version: "2.4"}; unfilled slots render literally' },
292
+ cards: {
293
+ type: 'array',
294
+ 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. kind "link" {url, mode?: preview|phone|widget|live} — a WEBSITE card: the server unfurls title/og:metadata/favicon async, probes iframe embeddability, and (when the capture sidecar is configured) takes real 16:10 desktop + phone-viewport screenshots; preview = 16:10 face, phone = mobile frame, widget = compact favicon+title row with hover peek, live = embedded iframe when the site allows framing. kind "board" {board: "<slug>", title?} — a PORTAL card: a live minimap window into another board (click-through, card/open-thread counts); the target board must exist, self-portals 400. Optional w/h override the per-kind default footprint.',
295
+ items: {
296
+ type: 'object',
297
+ properties: {
298
+ ref: { type: 'string', description: 'Batch-local handle for edges/questions/children (e.g. "idea1")' },
299
+ 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)' },
300
+ kind: { type: 'string', description: 'text|note|shape|viz|cluster|section|step|callout|checklist|compare|wireframe|doc|link|board — OPEN vocabulary: the server validates; an unknown kind 400s with the full allowed list, so new kinds work without a schema update' },
301
+ payload: { type: 'object', description: 'Kind-specific payload (see cards description)' },
302
+ w: { type: 'number' }, h: { type: 'number' },
303
+ },
304
+ required: ['kind', 'payload'],
305
+ additionalProperties: false,
306
+ },
307
+ },
308
+ edges: {
309
+ type: 'array',
310
+ description: 'Up to 40 wires between cards. from/to: a ref string from this batch, or an existing card id (number).',
311
+ items: {
312
+ type: 'object',
313
+ properties: {
314
+ from: { description: 'Card ref (string) or existing card id (number)' },
315
+ to: { description: 'Card ref (string) or existing card id (number)' },
316
+ label: { type: 'string' },
317
+ },
318
+ required: ['from', 'to'],
319
+ additionalProperties: false,
320
+ },
321
+ },
322
+ questions: {
323
+ type: 'array',
324
+ description: 'Up to 12 questions, optionally anchored to a batch card (cardRef) or existing card (cardId). Same staged-answer mechanics as ask_board.',
325
+ items: {
326
+ type: 'object',
327
+ properties: {
328
+ prompt: { type: 'string' },
329
+ 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)' },
330
+ multiSelect: { type: 'boolean' },
331
+ cardRef: { type: 'string' },
332
+ cardId: { type: 'number' },
333
+ },
334
+ required: ['prompt', 'options'],
335
+ additionalProperties: false,
336
+ },
337
+ },
338
+ },
339
+ required: ['board'],
340
+ additionalProperties: false,
341
+ },
342
+ handler: (a) => api('POST', `/api/v1/boards/${seg(a.board)}/compose`, {
343
+ ...(a.layout ? { layout: String(a.layout) } : {}),
344
+ ...(a.section ? { section: String(a.section) } : {}),
345
+ ...(a.journey ? { journey: String(a.journey) } : {}),
346
+ ...(a.pass !== undefined ? { pass: a.pass } : {}),
347
+ ...(a.anchorCardId !== undefined ? { anchor: { cardId: Number(a.anchorCardId) } } : {}),
348
+ ...(a.template ? { template: String(a.template) } : {}),
349
+ ...(a.params ? { params: a.params } : {}),
350
+ cards: Array.isArray(a.cards) ? a.cards : [],
351
+ edges: Array.isArray(a.edges) ? a.edges : [],
352
+ questions: Array.isArray(a.questions) ? a.questions : [],
353
+ }),
354
+ },
355
+ save_template: {
356
+ description: 'Save a REUSABLE board template into the registry (upsert by name). Two modes: '
357
+ + 'pass body {cards, edges?, questions?} (compose-shaped, {{slot}} placeholders in any string '
358
+ + 'payload field become instantiation params) — or pass board + section to EXTRACT a live '
359
+ + 'section\'s whole subtree (nested frames, layouts, children) as the skeleton. Instantiate '
360
+ + 'later with compose_board {template, params}; get_templates lists the registry. Blob-backed '
361
+ + 'cards (shots/media/artifacts) are skipped on extraction — reference them at instantiation '
362
+ + 'time instead.',
363
+ inputSchema: {
364
+ type: 'object',
365
+ properties: {
366
+ name: { type: 'string', description: 'Registry key — a slug (a-z, 0-9, dashes, ≤64); saving again overwrites' },
367
+ title: { type: 'string', description: 'Human title shown in listings' },
368
+ body: { type: 'object', description: 'Compose-shaped subtree {cards, edges?, questions?} with {{slot}} placeholders. Mutually exclusive with board+section.' },
369
+ board: { type: 'string', description: 'With section: extract from this board (slug)' },
370
+ section: { type: 'string', description: 'With board: the journey section name whose subtree becomes the template' },
371
+ },
372
+ required: ['name'],
373
+ additionalProperties: false,
374
+ },
375
+ handler: (a) => api('POST', '/api/v1/templates', {
376
+ name: String(a.name),
377
+ ...(a.title ? { title: String(a.title) } : {}),
378
+ ...(a.body ? { body: a.body } : {}),
379
+ ...(a.board ? { board: String(a.board) } : {}),
380
+ ...(a.section ? { section: String(a.section) } : {}),
381
+ }),
382
+ },
383
+ arrange: {
384
+ description: 'Auto-align board cards server-side — the LAYOUT ENGINE: geometry is computed in Go, '
385
+ + 'costs you zero tokens, snaps to the board lattice and lands as ONE atomic layout event that '
386
+ + 'returns every card\'s final bounds. Never do pixel math yourself — express intent with the '
387
+ + 'knobs (gap/padding "S"|"M"|"L" or px, cols, sizing uniform). Default scope is the AI '
388
+ + 'expression layer; scope "all" tidies everything; scope "section:<Name>" reflows one journey '
389
+ + 'section INSIDE its frame (which grows to fit); ids picks specific cards. Modes: column, row, '
390
+ + 'grid (cols per row), flow (row-wrap at the section\'s inner width), lanes (payload.lane rows — '
391
+ + 'journey steps × variants), masonry (column-balanced mixed heights), timeline (creation order), '
392
+ + 'timeaxis (one row positioned PROPORTIONALLY by real creation time — activity history with time ticks), '
393
+ + 'compare (sections: ["A","B"] side-by-side, members row-aligned pairwise — iterations), '
394
+ + 'align-left, align-top, distribute-h, distribute-v. AUTO-LAYOUT: pass save:true with a section '
395
+ + 'scope to PERSIST the layout onto the section — the server then re-flows it automatically on '
396
+ + 'every change (one arrange call, tidy forever; prefer this over repeated arrange calls).',
397
+ inputSchema: {
398
+ type: 'object',
399
+ properties: {
400
+ board: { type: 'string', description: 'Board slug' },
401
+ 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)' },
402
+ 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' },
403
+ sections: { type: 'array', items: { type: 'string' }, description: 'mode "compare" only: exactly two section names to align side-by-side' },
404
+ scope: { type: 'string', description: 'Which cards move: "ai" (default — the expression layer), "all", or "section:<Name>" (that journey\'s members, arranged inside the frame)' },
405
+ ids: { type: 'array', items: { type: 'number' }, description: 'Explicit card ids; overrides scope' },
406
+ 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' },
407
+ padding: { anyOf: [{ type: 'string', enum: ['S', 'M', 'L'] }, { type: 'number' }], description: 'Frame inner padding when scoped to a section (default 24)' },
408
+ cols: { type: 'number', description: 'grid mode: cards per row (default 3)' },
409
+ sizing: { type: 'string', enum: ['natural', 'uniform'], description: 'uniform = every card takes the selection\'s max footprint (even rows); default natural' },
410
+ },
411
+ required: ['board', 'mode'],
412
+ additionalProperties: false,
413
+ },
414
+ handler: (a) => api('POST', `/api/v1/boards/${seg(a.board)}/arrange`, {
415
+ mode: String(a.mode),
416
+ ...(a.scope ? { scope: String(a.scope) } : {}),
417
+ ...(Array.isArray(a.ids) && a.ids.length ? { ids: a.ids.map(Number) } : {}),
418
+ ...(a.gap !== undefined ? { gap: a.gap } : {}),
419
+ ...(a.padding !== undefined ? { padding: a.padding } : {}),
420
+ ...(a.cols !== undefined ? { cols: Number(a.cols) } : {}),
421
+ ...(a.sizing ? { sizing: String(a.sizing) } : {}),
422
+ ...(a.save ? { save: true } : {}),
423
+ ...(Array.isArray(a.sections) && a.sections.length ? { sections: a.sections.map(String) } : {}),
424
+ }),
425
+ },
426
+ update_cards: {
427
+ description: 'Batch-edit YOUR OWN board layer — patch payload/geometry or remove cards you '
428
+ + 'composed. Restricted to AI-layer cards (payload.layer "ai") plus section frames (shared '
429
+ + 'journey structure — rename/resize/remove is allowed; removing a frame never deletes its '
430
+ + 'members): the user\'s shots and notes are untouchable, a non-AI target 403s with '
431
+ + '{code:"not_ai_layer"}. Use it to refine a brainstorm in place (reword a text card, retitle '
432
+ + 'a cluster or section, drop a dead idea) instead of composing duplicates. Removals are SOFT '
433
+ + '— trashed, never fully deleted: restore lifts your trashed cards back (GET '
434
+ + '/api/v1/boards/{slug}/trash lists them; the response of a remove is your undo handle).',
435
+ inputSchema: {
436
+ type: 'object',
437
+ properties: {
438
+ board: { type: 'string', description: 'Board slug' },
439
+ patch: {
440
+ type: 'array',
441
+ items: {
442
+ type: 'object',
443
+ properties: {
444
+ id: { type: 'number' },
445
+ payload: { type: 'object', description: 'Replacement payload (the "layer" marker is preserved automatically)' },
446
+ x: { type: 'number' }, y: { type: 'number' }, w: { type: 'number' }, h: { type: 'number' },
447
+ },
448
+ required: ['id'],
449
+ additionalProperties: false,
450
+ },
451
+ },
452
+ remove: { type: 'array', items: { type: 'number' }, description: 'Card ids to TRASH (AI layer only; soft delete — restorable)' },
453
+ restore: { type: 'array', items: { type: 'number' }, description: 'Trashed card ids to lift back onto the board (AI layer only)' },
454
+ },
455
+ required: ['board'],
456
+ additionalProperties: false,
457
+ },
458
+ handler: (a) => api('POST', `/api/v1/boards/${seg(a.board)}/update-cards`, {
459
+ patch: Array.isArray(a.patch) ? a.patch : [],
460
+ remove: Array.isArray(a.remove) ? a.remove : [],
461
+ ...(Array.isArray(a.restore) && a.restore.length ? { restore: a.restore.map(Number) } : {}),
462
+ }),
463
+ },
151
464
  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]]). Use this to process a testing session end-to-end (pasted screenshots + notes) rather than acting on one annotation at a time; the flow tells you the user\'s step order. Images are absolute URLs (no auth on the mesh) — fetch them to SEE each step.',
465
+ 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.',
466
+ inputSchema: {
467
+ type: 'object',
468
+ properties: {
469
+ slug: { type: 'string', description: 'Board slug (from list_boards)' },
470
+ section: { type: 'string', description: 'Journey section name — digest only that frame\'s members (404 if no such section)' },
471
+ 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.' },
472
+ },
473
+ required: ['slug'],
474
+ additionalProperties: false,
475
+ },
476
+ handler: (a) => {
477
+ const q = new URLSearchParams();
478
+ if (a.section)
479
+ q.set('section', String(a.section));
480
+ if (Array.isArray(a.diff) && a.diff.length === 2)
481
+ q.set('diff', a.diff.map(String).join(','));
482
+ const qs = q.toString();
483
+ return api('GET', `/api/v1/boards/${seg(a.slug)}/scrape${qs ? `?${qs}` : ''}`);
484
+ },
485
+ },
486
+ list_sections: {
487
+ 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
488
  inputSchema: {
154
489
  type: 'object',
155
- properties: { slug: { type: 'string', description: 'Board slug (from list_boards)' } },
490
+ properties: { slug: { type: 'string', description: 'Board slug' } },
156
491
  required: ['slug'],
157
492
  additionalProperties: false,
158
493
  },
159
- handler: (a) => api('GET', `/api/v1/boards/${seg(a.slug)}/scrape`),
494
+ handler: (a) => api('GET', `/api/v1/boards/${seg(a.slug)}/sections`),
495
+ },
496
+ get_templates: {
497
+ 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.',
498
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
499
+ handler: async () => {
500
+ const md = await api('GET', '/static/templates.md', undefined, { raw: true });
501
+ const reg = await api('GET', '/api/v1/templates');
502
+ const list = (reg.status === 200 && reg.body && Array.isArray(reg.body.templates))
503
+ ? reg.body.templates : [];
504
+ if (md.status !== 200 || typeof md.body !== 'string' || list.length === 0) {
505
+ return md;
506
+ }
507
+ const lines = list.map((t) => `- **${t.name}**${t.title ? ` — ${t.title}` : ''}${t.params?.length ? ` · params: ${t.params.map((p) => `{{${p}}}`).join(' ')}` : ''}`);
508
+ return {
509
+ status: 200,
510
+ body: md.body + '\n\n---\n\n## Saved registry templates\n\nInstantiate with `compose_board {template: "<name>", params: {…}}`.\n\n' + lines.join('\n') + '\n',
511
+ };
512
+ },
513
+ },
514
+ request_review: {
515
+ 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.',
516
+ inputSchema: {
517
+ type: 'object',
518
+ properties: {
519
+ board: { type: 'string', description: 'Board slug' },
520
+ section: { type: 'string', description: 'Journey section name to scope the pass to (omit = whole board). Mutually exclusive with journey.' },
521
+ journey: { type: 'string', description: 'Pass-chain key (see list_sections journeys[]): review the LATEST pass with the previous pass as context' },
522
+ },
523
+ required: ['board'],
524
+ additionalProperties: false,
525
+ },
526
+ handler: (a) => api('POST', `/api/v1/boards/${seg(a.board)}/review-passes`, {
527
+ ...(a.section ? { section: String(a.section) } : {}),
528
+ ...(a.journey ? { journey: String(a.journey) } : {}),
529
+ }),
160
530
  },
161
531
  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. Scope to just your session\'s work with project + branch (or a board slug). A listening session (/vitrinka:listen) loops on this forever.',
532
+ 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
533
  inputSchema: {
164
534
  type: 'object',
165
535
  properties: {
@@ -193,7 +563,7 @@ const TOOLS = {
193
563
  handler: (a) => api('GET', `/api/v1/annotations/${seg(a.id)}/capsule`, undefined, { raw: true }),
194
564
  },
195
565
  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. Filter by status (open = waiting for an agent), board slug, and/or project + branch (scope to your repo+branch). Start here.',
566
+ 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
567
  inputSchema: {
198
568
  type: 'object',
199
569
  properties: {
@@ -219,7 +589,7 @@ const TOOLS = {
219
589
  },
220
590
  },
221
591
  get_annotation: {
222
- description: 'Get one annotation\'s full brief: region, intent, prompt revisions, thread messages (incl. file attachments the user added — fetch their URLs), fix attempts (before/after history), the element context (elements: E-numbers the region overlaps + screen siblings) and its card\'s source set. Fetch the cropUrl (PNG, no auth on the mesh) to SEE the annotated region — user markup strokes are baked in.',
592
+ description: 'Get one annotation\'s full brief: region, intent, prompt revisions, thread messages (incl. file attachments the user added — fetch their URLs), fix attempts (before/after history), explicit references (refs: the prompt\'s ⌖E tokens — elements the USER deliberately pointed at, each with its own cropUrl; treat them as intent, ranked above the implicit context), the element context (elements: E-numbers the region overlaps + screen siblings) and its card\'s source set. Fetch the cropUrl (PNG, no auth on the mesh) to SEE the annotated region — user markup strokes are baked in; fetch each ref\'s cropUrl to SEE what its ⌖E token points at (image-less targets like notes carry no crop).',
223
593
  inputSchema: {
224
594
  type: 'object',
225
595
  properties: { id: { type: 'number', description: 'Annotation id (from list_work)' } },
@@ -330,9 +700,41 @@ async function handle(msg) {
330
700
  + 'the flow board (vitrinka board-from-set) and hand the user the /boards/<slug> URL as the '
331
701
  + 'primary link; set/share pages are the archive behind it. Boards are live and interactive: '
332
702
  + 'the user annotates regions and thread replies route to claude by default (@eve only when '
333
- + 'explicitly mentioned) — a session in the app repo keeps listening via /vitrinka:listen, which '
334
- + '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.',
703
+ + 'explicitly mentioned) — after publishing a board, the session in the app repo attaches the '
704
+ + 'listener AUTOMATICALLY (no need to offer or wait to be asked): follow '
705
+ + '~/.claude/skills/vitrinka/listen/SKILL.md, which arms a native background monitor '
706
+ + '(`vitrinka watch`) that re-invokes the session on each new annotation (zero-token idle); '
707
+ + 'the session then drains the queue via wait_for_work. /vitrinka:listen (re)arms it manually. '
708
+ + 'WORK SCOPE: a working session cares ONLY about its own board\'s (or repo+branch\'s) '
709
+ + 'annotations — always pass {board} or {project, branch} to list_work and wait_for_work, '
710
+ + 'and never act on items from other boards that an unscoped call surfaces; those belong '
711
+ + 'to other sessions\' queues. '
712
+ + 'BOARD VOCABULARY (ai-expression-layer): you can PRESENT on boards, not just answer. '
713
+ + 'compose_board places a whole thought in ONE call — markdown text cards (roles: '
714
+ + 'heading/idea/pro/con/risk), data-only viz cards (table/matrix/flow/bars/timeline — '
715
+ + 'always cheaper than an HTML artifact), cluster frames grouping a direction, wires, and '
716
+ + 'anchored multiple-choice questions (options carry {label,hint,recommended}; answers '
717
+ + 'arrive staged as choice items). Batch-or-bust: never place cards one call at a time. '
718
+ + 'Never send coordinates — express INTENT (anchor, section, layout mode, order) and the server '
719
+ + 'guarantees collision-free placement near it (occupancy scan) plus geometry-true image '
720
+ + 'aspect (uniform sizing never squishes a screenshot). Pick a layout (column/row/grid) '
721
+ + 'and use arrange to tidy. Every mutation is undoable server-side per actor: your '
722
+ + 'compose/arrange batches group into single steps on YOUR stack (POST '
723
+ + '/api/v1/boards/{slug}/undo reverts your last batch if you mis-compose; the user\'s '
724
+ + '⌘Z never touches your work). '
725
+ + 'JOURNEY SECTIONS: name board regions after customer journeys with kind "section" frames '
726
+ + '("Onboarding", "Checkout") — they are always visible, auto-indexed (list_sections, pill '
727
+ + 'navigation, scrape_board sections[]), and every tool scopes to them: compose_board '
728
+ + '{section}, arrange {scope:"section:Name"} with gap/padding/cols knobs, scrape_board '
729
+ + '{section} for an isolated digest, request_review {section} for a scoped AI review pass. '
730
+ + 'Sections are also the ITERATION surface: when you produce the next take on a journey '
731
+ + '(reworked screens, a redesign, round 2 of a flow), present it as its OWN section beside '
732
+ + 'the current one ("Checkout — iteration 2") instead of mixing cards into the original — '
733
+ + 'iterations then sit side by side, each pill-navigable, separately scrapeable and '
734
+ + 'separately reviewable. '
735
+ + 'update_cards edits/removes only your own layer (plus section frames — shared structure). '
736
+ + '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 '
737
+ + 'user can always mute the layer.',
336
738
  });
337
739
  return;
338
740
  case 'notifications/initialized':
@@ -355,10 +757,40 @@ async function handle(msg) {
355
757
  replyError(id ?? null, -32602, `unknown tool: ${name}`);
356
758
  return;
357
759
  }
760
+ // Enforce the declared inputSchema's required + additionalProperties —
761
+ // without this a misnamed argument (e.g. body instead of text) sails
762
+ // through and the handler stringifies undefined into real data.
763
+ {
764
+ const args = (params?.arguments ?? {});
765
+ const schema = tool.inputSchema;
766
+ const missing = (schema.required ?? []).filter((k) => args[k] === undefined);
767
+ const extra = schema.additionalProperties === false && schema.properties
768
+ ? Object.keys(args).filter((k) => !(k in schema.properties))
769
+ : [];
770
+ if (missing.length || extra.length) {
771
+ reply(id, {
772
+ content: [{ type: 'text', text: `invalid arguments for ${name}: ${[
773
+ missing.length ? `missing required ${missing.join(', ')}` : '',
774
+ extra.length ? `unknown ${extra.join(', ')}` : '',
775
+ ].filter(Boolean).join('; ')} — see the tool's inputSchema` }],
776
+ isError: true,
777
+ });
778
+ return;
779
+ }
780
+ }
358
781
  try {
359
782
  const res = await tool.handler(params?.arguments ?? {});
360
783
  const isErr = res.status >= 400;
361
- const text = typeof res.body === 'string' ? res.body : JSON.stringify(res.body, null, 2);
784
+ // Compact JSON pretty-printing burned ~25% of every tool result on
785
+ // whitespace the model pays for (ai-expression-layer §token discipline).
786
+ let text = typeof res.body === 'string' ? res.body : JSON.stringify(res.body);
787
+ if (res.status === 401) {
788
+ // Write auth failed — say why and where the token comes from, so the
789
+ // model can relay actionable guidance instead of a bare "unauthorized".
790
+ text += `\n\nwrite auth failed: ${TOKEN
791
+ ? `the configured write token was rejected by ${BASE_URL} (stale or mistyped)`
792
+ : '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.`;
793
+ }
362
794
  reply(id, {
363
795
  content: [{ type: 'text', text }],
364
796
  isError: isErr,