analyzthis_design 2.5.1 → 2.6.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.
Files changed (100) hide show
  1. package/HOW-TO-USE.md +249 -326
  2. package/README.md +71 -25
  3. package/agents/calibration-benchmark.json +187 -0
  4. package/agents/cards/anuj.md +1 -1
  5. package/agents/cards/arjun.md +1 -1
  6. package/agents/cards/kavi.md +2 -1
  7. package/agents/cards/mood-board.md +7 -7
  8. package/agents/cards/noor.md +1 -1
  9. package/agents/cards/priya.md +1 -1
  10. package/agents/cards/zara.md +3 -3
  11. package/agents/chain.json +1 -1
  12. package/agents/design-spec-schema.json +18 -1
  13. package/agents/manifests/mood-board.json +13 -8
  14. package/agents/model-policy.json +12 -0
  15. package/agents/router.json +2 -2
  16. package/agents/session-schema.json +12 -2
  17. package/dist/HOW-TO-USE.md +249 -326
  18. package/dist/README.md +71 -25
  19. package/dist/agents/calibration-benchmark.json +187 -0
  20. package/dist/agents/cards/anuj.md +1 -1
  21. package/dist/agents/cards/arjun.md +1 -1
  22. package/dist/agents/cards/kavi.md +2 -1
  23. package/dist/agents/cards/mood-board.md +7 -7
  24. package/dist/agents/cards/noor.md +1 -1
  25. package/dist/agents/cards/priya.md +1 -1
  26. package/dist/agents/cards/zara.md +3 -3
  27. package/dist/agents/chain.json +1 -1
  28. package/dist/agents/design-spec-schema.json +18 -1
  29. package/dist/agents/manifests/mood-board.json +13 -8
  30. package/dist/agents/model-policy.json +12 -0
  31. package/dist/agents/router.json +2 -2
  32. package/dist/agents/session-schema.json +12 -2
  33. package/dist/bin/cli.js +308 -10
  34. package/dist/lib/calibrate.js +816 -0
  35. package/dist/lib/chunk-executor.js +41 -1
  36. package/dist/lib/chunk-models.js +4 -18
  37. package/dist/lib/chunk-router.js +22 -6
  38. package/dist/lib/chunk-run.js +55 -1
  39. package/dist/lib/chunk-synthesis.js +18 -2
  40. package/dist/lib/chunk-telemetry.js +77 -17
  41. package/dist/lib/collect.js +15 -1
  42. package/dist/lib/cost.js +15 -7
  43. package/dist/lib/design-spec.js +32 -2
  44. package/dist/lib/evolution-metrics.js +23 -1
  45. package/dist/lib/feedback-submit.js +50 -1
  46. package/dist/lib/feedback.js +15 -0
  47. package/dist/lib/install.js +1 -0
  48. package/dist/lib/lessons.js +8 -0
  49. package/dist/lib/mcp-server.js +189 -7
  50. package/dist/lib/model-policy.js +174 -0
  51. package/dist/lib/model-registry.js +71 -0
  52. package/dist/lib/moodboard-film.js +119 -0
  53. package/dist/lib/moodboard-view.js +426 -0
  54. package/dist/lib/moodboard.js +638 -27
  55. package/dist/lib/orchestrator/run.js +67 -8
  56. package/dist/lib/outcome.js +7 -2
  57. package/dist/lib/output-quality.js +95 -0
  58. package/dist/lib/reference-pack.js +9 -1
  59. package/dist/lib/session.js +48 -4
  60. package/dist/lib/system-prompt.js +3 -0
  61. package/dist/lib/ui-libraries.js +232 -0
  62. package/dist/skills/anuj/SKILL.md +4 -0
  63. package/dist/skills/arjun/SKILL.md +2 -0
  64. package/dist/skills/arjun/references/lens.md +2 -0
  65. package/dist/skills/design-director/SKILL.md +2 -1
  66. package/dist/skills/design-reference/SKILL.md +6 -0
  67. package/dist/skills/design-reference/motion.csv +11 -0
  68. package/dist/skills/design-reference/schema.json +12 -0
  69. package/dist/skills/design-reference/stacks/shadcn.csv +6 -0
  70. package/dist/skills/design-reference/ui-libraries.csv +46 -0
  71. package/dist/skills/design-spec/SKILL.md +1 -1
  72. package/dist/skills/getting-started/SKILL.md +2 -2
  73. package/dist/skills/kavi/SKILL.md +3 -2
  74. package/dist/skills/meera/SKILL.md +2 -0
  75. package/dist/skills/mood-board/SKILL.md +94 -87
  76. package/dist/skills/noor/SKILL.md +4 -0
  77. package/dist/skills/persona-orchestrator/references/lens.md +4 -4
  78. package/dist/skills/priya/SKILL.md +5 -0
  79. package/dist/skills/raj/SKILL.md +2 -0
  80. package/dist/skills/zara/SKILL.md +49 -4
  81. package/package.json +13 -2
  82. package/skills/anuj/SKILL.md +4 -0
  83. package/skills/arjun/SKILL.md +2 -0
  84. package/skills/arjun/references/lens.md +2 -0
  85. package/skills/design-director/SKILL.md +2 -1
  86. package/skills/design-reference/SKILL.md +6 -0
  87. package/skills/design-reference/motion.csv +11 -0
  88. package/skills/design-reference/schema.json +12 -0
  89. package/skills/design-reference/stacks/shadcn.csv +6 -0
  90. package/skills/design-reference/ui-libraries.csv +46 -0
  91. package/skills/design-spec/SKILL.md +1 -1
  92. package/skills/getting-started/SKILL.md +2 -2
  93. package/skills/kavi/SKILL.md +3 -2
  94. package/skills/meera/SKILL.md +2 -0
  95. package/skills/mood-board/SKILL.md +94 -87
  96. package/skills/noor/SKILL.md +4 -0
  97. package/skills/persona-orchestrator/references/lens.md +4 -4
  98. package/skills/priya/SKILL.md +5 -0
  99. package/skills/raj/SKILL.md +2 -0
  100. package/skills/zara/SKILL.md +49 -4
@@ -109,6 +109,15 @@ function patchIsApplied(patch) {
109
109
  function collectSignals(projectIds, persona) {
110
110
  var outcomes = []; // { value, source }
111
111
  var ratings = []; // numbers
112
+ var byModel = {};
113
+
114
+ function modelBucket(attribution) {
115
+ var id = attribution && (attribution.model_id ||
116
+ (attribution.provider && attribution.model && attribution.provider + '/' + attribution.model));
117
+ id = id || 'unknown/unknown';
118
+ if (!byModel[id]) byModel[id] = { outcomes: 0, ratings: 0, rating_total: 0 };
119
+ return byModel[id];
120
+ }
112
121
 
113
122
  for (var i = 0; i < projectIds.length; i++) {
114
123
  var state = session.show({ project: projectIds[i] });
@@ -120,8 +129,10 @@ function collectSignals(projectIds, persona) {
120
129
  // A confirmed outcome supersedes an inferred one for the same persona.
121
130
  if (confirmed[persona] && confirmed[persona].value) {
122
131
  outcomes.push({ value: confirmed[persona].value, source: 'confirmed' });
132
+ modelBucket(confirmed[persona].attribution).outcomes++;
123
133
  } else if (inferred[persona] && inferred[persona].value) {
124
134
  outcomes.push({ value: inferred[persona].value, source: 'inferred' });
135
+ modelBucket(inferred[persona].attribution).outcomes++;
125
136
  }
126
137
 
127
138
  var log = state.feedback_log || [];
@@ -129,10 +140,20 @@ function collectSignals(projectIds, persona) {
129
140
  if (log[j].persona !== persona) continue;
130
141
  if (log[j].rating == null) continue;
131
142
  ratings.push(Number(log[j].rating));
143
+ var bucket = modelBucket(log[j].attribution);
144
+ bucket.ratings++;
145
+ bucket.rating_total += Number(log[j].rating);
132
146
  }
133
147
  }
134
148
 
135
- return { outcomes: outcomes, ratings: ratings };
149
+ Object.keys(byModel).forEach(function(id) {
150
+ var item = byModel[id];
151
+ item.evidence_count = item.outcomes + item.ratings;
152
+ item.avg_rating = item.ratings ? Math.round((item.rating_total / item.ratings) * 10) / 10 : null;
153
+ item.sufficient_evidence = item.evidence_count >= MIN_EVIDENCE;
154
+ delete item.rating_total;
155
+ });
156
+ return { outcomes: outcomes, ratings: ratings, by_model: byModel };
136
157
  }
137
158
 
138
159
  /**
@@ -243,6 +264,7 @@ function computeEvolutionMetrics(opts) {
243
264
  outcome_breakdown: outcomeBreakdown,
244
265
  ratings: signals.ratings.length,
245
266
  avg_rating: avgRating,
267
+ model_breakdown: signals.by_model,
246
268
  patches_proposed: personaPatches.length,
247
269
  patches_applied: appliedPatches.length,
248
270
  avg_signal: Math.round(avgSignal * 10) / 10,
@@ -240,7 +240,7 @@ function askConsentQuestion() {
240
240
  rl.question(
241
241
  '\nShare anonymized persona feedback with analyzthis_design maintainers?\n'
242
242
  + ' Sends: persona, rating, outcome (shipped/revised/missed), task type,\n'
243
- + ' tags, comment/correction, redacted output snippets\n'
243
+ + ' tags, redacted corrections; or text-free fixed-benchmark aggregates\n'
244
244
  + ' Does NOT send: project paths, repo names, emails, or API keys\n'
245
245
  + 'Continue? [y/N] ',
246
246
  (answer) => {
@@ -388,6 +388,53 @@ async function submitScores({ dryRun = false } = {}) {
388
388
  return { sent, rows };
389
389
  }
390
390
 
391
+ /**
392
+ * Build text-free, fixed-benchmark calibration cells. Random install IDs are
393
+ * deduplication hints only; the server and maintainer must not treat them as identity.
394
+ */
395
+ function buildCalibrationPayloads(report, cfg) {
396
+ if (!report || report.official_benchmark !== true || report.private_tasks === true) {
397
+ throw new Error('Only the package fixed benchmark can be shared. Private-task results stay local.');
398
+ }
399
+ const cells = Array.isArray(report.community_rows) ? report.community_rows : [];
400
+ return cells.map((cell) => ({
401
+ install_id: cfg.installId,
402
+ package_version: cfg.packageVersion,
403
+ benchmark_version: String(report.benchmark_version || ''),
404
+ validator_version: String(report.validator_version || ''),
405
+ judge_policy_version: String(report.judge_policy_version || ''),
406
+ prompt_hash: String(cell.prompt_hash || ''),
407
+ persona: String(cell.persona || ''),
408
+ effort: String(cell.effort || ''),
409
+ output_format: String(cell.output_format || ''),
410
+ model_id: String(cell.model_id || ''),
411
+ sample_count: Number(cell.sample_count || 0),
412
+ score_mean: Number(cell.score_mean || 0),
413
+ score_variance: Number(cell.score_variance || 0),
414
+ pass_rate: Number(cell.pass_rate || 0),
415
+ latency_ms: Number(cell.latency_ms || 0),
416
+ availability_rate: Number(cell.availability_rate || 0),
417
+ cost_usd: Number(cell.cost_usd || 0),
418
+ }));
419
+ }
420
+
421
+ async function submitCalibration(report, { dryRun = false, yes = false } = {}) {
422
+ const cfg = resolveSubmitConfig();
423
+ if (!cfg.enabled) throw new Error('Feedback submit is disabled.');
424
+ const rows = buildCalibrationPayloads(report, cfg);
425
+ if (!rows.length) return { submitted: 0, skipped: 'no fixed-benchmark cells' };
426
+ if (dryRun) return { submitted: 0, dryRun: true, rows, endpoint: cfg.url };
427
+ const consented = await ensureConsent({ yes });
428
+ if (!consented) return { submitted: 0, cancelled: true };
429
+ let sent = 0;
430
+ for (let i = 0; i < rows.length; i += MAX_ROWS_PER_REQUEST) {
431
+ const batch = rows.slice(i, i + MAX_ROWS_PER_REQUEST);
432
+ await postJson(cfg.url, {}, { kind: 'calibration', rows: batch });
433
+ sent += batch.length;
434
+ }
435
+ return { submitted: sent, endpoint: cfg.url };
436
+ }
437
+
391
438
  module.exports = {
392
439
  anonymizeText,
393
440
  entryToSubmitPayload,
@@ -395,6 +442,8 @@ module.exports = {
395
442
  submitFeedback,
396
443
  submitScores,
397
444
  buildScorePayloads,
445
+ buildCalibrationPayloads,
446
+ submitCalibration,
398
447
  submitStatus,
399
448
  collectUnsentEntries,
400
449
  markEntriesSubmitted,
@@ -75,6 +75,11 @@ function recordFeedback(opts = {}) {
75
75
  if (!outputEntry) {
76
76
  throw new Error(`No output recorded for persona "${persona}" in this session yet.`);
77
77
  }
78
+ if (state.active_run && outputEntry.run_id !== state.active_run.id) {
79
+ const stale = new Error(`The "${persona}" output is from an older run. Run it again before recording feedback.`);
80
+ stale.code = 'STALE_OUTPUT';
81
+ throw stale;
82
+ }
78
83
 
79
84
  const entry = {
80
85
  id: crypto.randomBytes(8).toString('hex'),
@@ -86,6 +91,14 @@ function recordFeedback(opts = {}) {
86
91
  comment: String(comment).trim(),
87
92
  correction: String(correction).trim(),
88
93
  tags: tags.length ? tags : (satisfied ? ['positive'] : ['other']),
94
+ run_id: outputEntry.run_id || null,
95
+ attribution: outputEntry.attribution || {
96
+ provider: 'host',
97
+ model: 'unknown',
98
+ model_id: 'host/unknown',
99
+ effort: 'unknown',
100
+ },
101
+ quality: outputEntry.quality || null,
89
102
  original_output: typeof outputEntry.text === 'string'
90
103
  ? outputEntry.text.slice(0, 8000)
91
104
  : JSON.stringify(outputEntry).slice(0, 8000),
@@ -155,6 +168,8 @@ function exportCorrections({ persona, project, all = false, output, includePosit
155
168
  project_id: e.project_id,
156
169
  rating: e.rating,
157
170
  tags: e.tags,
171
+ run_id: e.run_id || null,
172
+ attribution: e.attribution || null,
158
173
  system_card: loadCard(e.persona),
159
174
  user: e.context?.task_map_summary || '',
160
175
  assistant_rejected: e.original_output,
@@ -59,6 +59,7 @@ const SKILLS = [
59
59
  'evolve-check',
60
60
  'devi',
61
61
  'design-director',
62
+ 'mood-board',
62
63
  'design-spec',
63
64
  'design-personas',
64
65
  'design-reference',
@@ -82,6 +82,8 @@ function extractLessonsFromSession(state, personaId) {
82
82
 
83
83
  var lessons = [];
84
84
  var text = entry.text || '';
85
+ var attribution = entry.attribution || null;
86
+ var runId = entry.run_id || null;
85
87
 
86
88
  var fixesMatch = text.match(/Top\s*2\s*fixes:[\s\S]*?(?:\n\n|```|$)/i);
87
89
  if (fixesMatch) {
@@ -103,6 +105,8 @@ function extractLessonsFromSession(state, personaId) {
103
105
  session_id: state.project_id,
104
106
  extracted_at: new Date().toISOString(),
105
107
  citation: entry.citations ? entry.citations[0] : ''
108
+ ,attribution: attribution
109
+ ,run_id: runId
106
110
  });
107
111
  }
108
112
  }
@@ -127,6 +131,8 @@ function extractLessonsFromSession(state, personaId) {
127
131
  session_id: state.project_id,
128
132
  extracted_at: new Date().toISOString(),
129
133
  citation: ''
134
+ ,attribution: attribution
135
+ ,run_id: runId
130
136
  });
131
137
  break;
132
138
  }
@@ -147,6 +153,8 @@ function extractLessonsFromSession(state, personaId) {
147
153
  session_id: state.project_id,
148
154
  extracted_at: new Date().toISOString(),
149
155
  citation: ''
156
+ ,attribution: attribution
157
+ ,run_id: runId
150
158
  });
151
159
  }
152
160
  }
@@ -72,7 +72,7 @@ var TOOLS = [
72
72
  },
73
73
  {
74
74
  name: 'analyzthis_zara',
75
- description: 'Zara — Delight moments. Identifies exactly ONE structural or surface delight moment in a design. Covers first impressions, onboarding flows, empty states, peak moments, and once-ever experiences. Use after the core critique is done.',
75
+ description: 'Zara — Delight moments, and scout for UI image boards. Identifies exactly ONE structural or surface delight moment. On a UI board she browses real shipped screens, picks 3-5 frames, and re-scouts if the jury skips. Use after the core critique, or when the user wants a UI image board.',
76
76
  inputSchema: {
77
77
  type: 'object',
78
78
  properties: {
@@ -208,15 +208,56 @@ var TOOLS = [
208
208
  },
209
209
  {
210
210
  name: 'analyzthis_mood_board',
211
- description: 'Mood Board — Visual direction. Collects web references + design-system patterns, tags them by style/mood/surface, and runs team deliberation (Arjun, Meera, Priya, Zara, Noor) using the UX Honeycomb matrix. Use for setting visual direction before designing.',
211
+ description: 'Visual mood board. Zara crops 20-24 real UI elements (cards, tables, type, colour, nav) from 6+ live sites into a sectioned collage with real colours and fonts. Arjun/Noor/Priya/Meera vote as advice. The board opens in the browser and the USER clicks keep / maybe / reject — the user\'s choice overrides every persona, and the locked direction is saved for all personas. kind=mood keeps the older link-and-tag board.',
212
212
  inputSchema: {
213
213
  type: 'object',
214
214
  properties: {
215
215
  task: { type: 'string', description: 'Visual direction brief (e.g., "B2B fintech dashboard, trustworthy, high-contrast, data-dense")' },
216
- urls: { type: 'array', items: { type: 'string' }, description: 'Web reference URLs to include' },
217
- auto: { type: 'boolean', description: 'Auto-collect references' },
216
+ kind: { type: 'string', enum: ['ui', 'mood'], description: 'ui = visual element board (default). mood = older tagged-URL board.' },
217
+ action: { type: 'string', enum: ['create', 'brief', 'capture_script', 'tiles', 'film', 'jury', 'votes', 'show', 'decide', 'rescout', 'lock', 'status', 'frames', 'verdict'], description: 'create (default) → brief → tiles (Zara crops) and film (records urls scrolling as motion tiles; needs playwright-core + Chrome) → jury → votes (advice) → show (user decides in browser) → rescout or lock. decide records a user choice from chat. frames/verdict are older names for tiles/votes.' },
218
+ board: { type: 'string', description: 'Board id' },
219
+ adjectives: { type: 'array', items: { type: 'string' }, description: 'Three feel words for the brief' },
220
+ not_this: { type: 'string', description: 'One thing this board must not look like' },
221
+ sections: { type: 'array', items: { type: 'string', enum: ['first_screen', 'typography', 'color', 'components', 'imagery', 'motion'] }, description: 'Sections this board needs' },
222
+ confirmed_by_user: { type: 'boolean', description: 'User approved the brief (action=brief)' },
223
+ selector: { type: 'string', description: 'CSS selector for action=capture_script' },
224
+ text: { type: 'string', description: 'Visible text that narrows the selector match (action=capture_script)' },
225
+ tiles: {
226
+ type: 'array',
227
+ description: 'Element crops (action=tiles)',
228
+ items: {
229
+ type: 'object',
230
+ properties: {
231
+ page_url: { type: 'string', description: 'Exact page the element came from (required)' },
232
+ image_path: { type: 'string', description: 'Local image file, or the saved Page.captureScreenshot JSON file' },
233
+ image_base64: { type: 'string', description: 'Inline Page.captureScreenshot data when no file was written' },
234
+ image_url: { type: 'string', description: 'Remote image URL if no local crop' },
235
+ video_path: { type: 'string', description: 'Local WebM/MP4 scroll film (motion tiles)' },
236
+ stills: { type: 'array', items: { type: 'string' }, description: 'Up to 4 waypoint still image paths for a film' },
237
+ section: { type: 'string', enum: ['first_screen', 'typography', 'color', 'components', 'imagery', 'motion'] },
238
+ element: { type: 'string', description: 'What the element is, e.g. "pricing card"' },
239
+ tokens: { type: 'object', description: 'From capture_script: colors, fonts, radius, spacing, font_size' },
240
+ steal: { type: 'string' }, refuse: { type: 'string' }, peak: { type: 'string' },
241
+ concept: { type: 'boolean', description: 'True if this is a concept shot, not shipped product' },
242
+ },
243
+ },
244
+ },
245
+ votes: {
246
+ type: 'array',
247
+ description: 'Persona advice (action=votes): [{tile, persona, vote: keep|skip, because}]',
248
+ items: { type: 'object' },
249
+ },
250
+ verdict: { type: 'string', enum: ['SHIP', 'REVISE', 'BLOCK'], description: 'Team advice verdict (action=votes)' },
251
+ summary: { type: 'string', description: 'Team because-lines (action=votes)' },
252
+ tile: { type: 'string', description: 'Tile number or id (action=decide)' },
253
+ decision: { type: 'string', enum: ['keep', 'maybe', 'reject', 'clear'], description: 'User decision (action=decide)' },
254
+ note: { type: 'string', description: 'User note (decide, rescout, lock)' },
255
+ urls: { type: 'array', items: { type: 'string' }, description: 'Reference URLs (kind=mood), or pages to film (action=film)' },
256
+ element: { type: 'string', description: 'Tile name (action=film), default "Scroll film — <site>"' },
257
+ section: { type: 'string', enum: ['first_screen', 'typography', 'color', 'components', 'imagery', 'motion'], description: 'Section for action=film (default motion)' },
258
+ steal: { type: 'string' }, refuse: { type: 'string' }, peak: { type: 'string' },
259
+ auto: { type: 'boolean', description: 'Auto-collect references (kind=mood only)' },
218
260
  },
219
- required: ['task'],
220
261
  },
221
262
  },
222
263
  {
@@ -466,7 +507,145 @@ function handleCombinationPass(skillId, args) {
466
507
  return { prompt: prompt, skill: skillId, task: task, persona: skillId, verdicts: 1 };
467
508
  }
468
509
 
510
+ // One live board server per board id, kept for the life of the MCP process.
511
+ var moodBoardServers = {};
512
+
513
+ function tileCountLine(board) {
514
+ var sites = {};
515
+ board.tiles.forEach(function(t) { if (t.site) sites[t.site] = true; });
516
+ return board.tiles.length + ' tile(s) from ' + Object.keys(sites).length + ' site(s)';
517
+ }
518
+
469
519
  function handleMoodBoard(args) {
520
+ var task = args.task || '';
521
+ var kind = args.kind === 'mood' ? 'mood' : 'ui';
522
+ var action = args.action || 'create';
523
+ var mb = require('./moodboard');
524
+ var view = require('./moodboard-view');
525
+ var ref = { boardId: args.board };
526
+ var zaraNext = function(board) {
527
+ return 'You are Zara on a Scout Pass. Open real pages in the browser. Do not invent URLs.\n\n' + board.scout.brief +
528
+ '\n\nBoard id: ' + board.board_id +
529
+ '\nToken script for Runtime.evaluate: call analyzthis_mood_board action=capture_script selector=<css>.' +
530
+ '\nSave tiles with action=tiles, board=' + board.board_id + ', tiles=[{page_url,image_path,section,element,tokens,steal,refuse,peak}].' +
531
+ '\nThen action=jury for advice, then action=show and open the URL so the user decides.';
532
+ };
533
+
534
+ try {
535
+ if (action === 'capture_script') {
536
+ return { result: mb.captureScript(args.selector || 'body', args.text || '') };
537
+ }
538
+
539
+ if (action === 'brief') {
540
+ var briefed = mb.setBrief({
541
+ boardId: args.board, adjectives: args.adjectives, not_this: args.not_this,
542
+ sections: args.sections, confirmed_by_user: args.confirmed_by_user,
543
+ });
544
+ return { result: 'Brief saved.', board_id: briefed.board.board_id, brief: briefed.board.brief };
545
+ }
546
+
547
+ if (action === 'tiles' || action === 'frames') {
548
+ var tiles = args.tiles || args.frames || [{
549
+ page_url: args.page_url || args.url, element: args.element || args.title, section: args.section,
550
+ image_path: args.image_path, image_url: args.image_url, tokens: args.tokens,
551
+ video_path: args.video_path, stills: args.stills, steal: args.steal, refuse: args.refuse, peak: args.peak,
552
+ }];
553
+ var added = mb.addTiles({ boardId: args.board, tiles: tiles });
554
+ return {
555
+ result: 'Saved ' + added.added.length + ' tile(s). Board now has ' + tileCountLine(added.board) +
556
+ '. Target ' + mb.TILE_TARGET.min + '-' + mb.TILE_TARGET.max + ' from ' + mb.TILE_TARGET.sites + '+ sites.' +
557
+ (added.skipped.length ? ' Skipped: ' + JSON.stringify(added.skipped) : ''),
558
+ board_id: added.board.board_id,
559
+ skipped: added.skipped,
560
+ };
561
+ }
562
+
563
+ if (action === 'jury') {
564
+ var juryBoard = mb.loadUiBoard(ref);
565
+ if (!juryBoard.tiles.length) return { error: 'NEED_SCOUT: Zara has not added tiles yet.' };
566
+ return { prompt: mb.juryPrompt(juryBoard, ''), board_id: juryBoard.board_id };
567
+ }
568
+
569
+ if (action === 'votes' || action === 'verdict') {
570
+ var voted = mb.recordPersonaVotes({
571
+ boardId: args.board, votes: args.votes || [], verdict: args.verdict, summary: args.summary,
572
+ });
573
+ return {
574
+ result: 'Team advice saved' + (voted.ignored.length ? ' (ignored: ' + JSON.stringify(voted.ignored) + ')' : '') +
575
+ '. The user decides next: call action=show and open the URL in the browser.',
576
+ board_id: voted.board.board_id,
577
+ scout: voted.board.scout,
578
+ };
579
+ }
580
+
581
+ if (action === 'film') {
582
+ return require('./moodboard-film').filmTiles({
583
+ boardId: args.board, urls: args.urls || (args.page_url ? [args.page_url] : []),
584
+ section: args.section, element: args.element, steal: args.steal, refuse: args.refuse, peak: args.peak,
585
+ }).then(function(out) {
586
+ var added = out.saved ? out.saved.added : [];
587
+ return {
588
+ result: 'Filmed ' + added.length + ' site(s) as motion tiles' + (out.saved ? '. Board now has ' + tileCountLine(out.saved.board) : '') + '.' +
589
+ (out.failed.length ? ' Failed: ' + JSON.stringify(out.failed) : '') +
590
+ (out.saved && out.saved.skipped.length ? ' Skipped: ' + JSON.stringify(out.saved.skipped) : ''),
591
+ board_id: args.board,
592
+ };
593
+ }).catch(function(e) { return { error: e.message }; });
594
+ }
595
+
596
+ if (action === 'show') {
597
+ var existing = moodBoardServers[args.board];
598
+ if (existing) return { result: 'Board is live at ' + existing.url + ' — open it with browser_navigate.', url: existing.url };
599
+ return view.serveBoard({ boardId: args.board }).then(function(live) {
600
+ moodBoardServers[args.board] = live;
601
+ return {
602
+ result: 'Board is live at ' + live.url + ' — open it with browser_navigate. The user clicks keep / maybe / reject; their choice overrides every persona.',
603
+ url: live.url,
604
+ };
605
+ }).catch(function(e) { return { error: e.message }; });
606
+ }
607
+
608
+ if (action === 'decide') {
609
+ var decided = mb.recordUserDecision({ boardId: args.board, tile: args.tile, decision: args.decision, note: args.note });
610
+ return { result: 'User decision saved on tile ' + args.tile + '. It outranks every persona vote.', board_id: decided.board.board_id };
611
+ }
612
+
613
+ if (action === 'rescout') {
614
+ var again = mb.requestRescout({ boardId: args.board, note: args.note });
615
+ return { prompt: zaraNext(again.board), board_id: again.board.board_id, scout: again.board.scout };
616
+ }
617
+
618
+ if (action === 'lock') {
619
+ var locked = mb.lockDirection({ boardId: args.board, note: args.note });
620
+ return { result: 'Direction locked by the user and saved to the session as design_direction. Every persona must follow it.', direction: locked.board.direction };
621
+ }
622
+
623
+ if (action === 'status') {
624
+ var b = mb.loadUiBoard(ref);
625
+ return { result: JSON.stringify({ board_id: b.board_id, scout: b.scout, brief: b.brief, tiles: tileCountLine(b), direction: mb.computeDirection(b) }, null, 2) };
626
+ }
627
+ } catch (e) {
628
+ return { error: e.message };
629
+ }
630
+
631
+ if (kind === 'ui') {
632
+ if (!task) return { error: 'task is required to open a mood board' };
633
+ return mb.buildMoodBoard({
634
+ task: task, kind: 'ui', discover: false,
635
+ brief: { adjectives: args.adjectives, not_this: args.not_this, sections: args.sections },
636
+ }).then(function(created) {
637
+ return {
638
+ prompt: 'First show the user this brief (job, 3 adjectives, one "not this", sections) and let them correct it with action=brief confirmed_by_user=true.\n\n' + zaraNext(created.board),
639
+ board_id: created.board.board_id,
640
+ brief: created.board.brief,
641
+ };
642
+ }).catch(function(e) { return { error: e.message }; });
643
+ }
644
+
645
+ return handleLinkMoodBoard(args);
646
+ }
647
+
648
+ function handleLinkMoodBoard(args) {
470
649
  var skill = readSkill('mood-board');
471
650
  var task = args.task || '';
472
651
  var urls = (args.urls || []).join('\n');
@@ -857,8 +1036,8 @@ function startStdioServer(catalog) {
857
1036
  if (method === 'tools/call') {
858
1037
  var toolName = params.name;
859
1038
  var toolArgs = params.arguments || {};
860
- var result = handleToolCall(toolName, toolArgs);
861
-
1039
+ // Some tools (mood board create / show) are async.
1040
+ Promise.resolve().then(function() { return handleToolCall(toolName, toolArgs); }).then(function(result) {
862
1041
  if (result.error) {
863
1042
  send({
864
1043
  jsonrpc: '2.0',
@@ -881,6 +1060,9 @@ function startStdioServer(catalog) {
881
1060
  },
882
1061
  });
883
1062
  }
1063
+ }).catch(function(e) {
1064
+ send({ jsonrpc: '2.0', id: id, error: { code: -32603, message: e.message } });
1065
+ });
884
1066
  return;
885
1067
  }
886
1068
 
@@ -0,0 +1,174 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared model preference resolver for chunked and unchunked execution.
5
+ * Effort/gate constraints are established by the caller before preferences.
6
+ */
7
+
8
+ var fs = require('fs');
9
+ var path = require('path');
10
+ var os = require('os');
11
+ var crypto = require('crypto');
12
+ var registry = require('./model-registry');
13
+ var provider = require('./provider');
14
+ var platforms = require('./platforms');
15
+ var outputQuality = require('./output-quality');
16
+
17
+ var PACKAGE_ROOT = platforms.resolvePackageRoot(__dirname);
18
+ var DEFAULT_LOCAL_FILE = path.join(os.homedir(), '.analyzthis_design', 'calibration.json');
19
+ var GLOBAL_FILE = path.join(PACKAGE_ROOT, 'agents', 'model-policy.json');
20
+
21
+ function readJson(file) {
22
+ if (!file || !fs.existsSync(file)) return {};
23
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
24
+ catch (e) { return {}; }
25
+ }
26
+
27
+ function localFile() {
28
+ return process.env.ANALYZTHIS_CALIBRATION_FILE || DEFAULT_LOCAL_FILE;
29
+ }
30
+
31
+ function promptHash(persona) {
32
+ var manifestPath = path.join(PACKAGE_ROOT, 'agents', 'manifests', persona + '.json');
33
+ var manifest = readJson(manifestPath);
34
+ var parts = [];
35
+ [manifest.system_card, manifest.system_skill].forEach(function(relative) {
36
+ if (!relative) return;
37
+ var file = path.join(PACKAGE_ROOT, relative);
38
+ if (fs.existsSync(file)) parts.push(fs.readFileSync(file));
39
+ });
40
+ return crypto.createHash('sha256').update(Buffer.concat(parts)).digest('hex').slice(0, 16);
41
+ }
42
+
43
+ function calibrationContract() {
44
+ var file = path.join(PACKAGE_ROOT, 'agents', 'calibration-benchmark.json');
45
+ var benchmark = readJson(file);
46
+ return {
47
+ version: benchmark.version,
48
+ judge_policy_version: benchmark.judge_version,
49
+ hash: Object.keys(benchmark).length
50
+ ? crypto.createHash('sha256').update(JSON.stringify(benchmark)).digest('hex').slice(0, 16)
51
+ : null,
52
+ };
53
+ }
54
+
55
+ function assignmentFrom(policy, persona, effort) {
56
+ var personas = policy.personas || policy.assignments || {};
57
+ return personas[persona] && personas[persona][effort];
58
+ }
59
+
60
+ function eligible(spec, effort) {
61
+ spec = registry.normalizeSpec(spec);
62
+ if (!spec.provider || !spec.model || registry.isExpired(spec)) return false;
63
+ if (spec.effort && spec.effort.indexOf(effort) === -1) return false;
64
+ if (spec.provider !== 'host' && !provider.hasKey(spec.provider)) return false;
65
+ return true;
66
+ }
67
+
68
+ function validWinner(winner, persona, effort, requireContract) {
69
+ if (!winner || !eligible(winner, effort)) return false;
70
+ if (requireContract) {
71
+ var hasJudge = winner.judge_policy_version != null || winner.judge_version != null;
72
+ if (!winner.prompt_hash || winner.validator_version == null ||
73
+ winner.benchmark_version == null || !winner.benchmark_hash ||
74
+ !hasJudge || !winner.expires_at) return false;
75
+ }
76
+ if (winner.prompt_hash && winner.prompt_hash !== promptHash(persona)) return false;
77
+ if (winner.validator_version != null && Number(winner.validator_version) !== outputQuality.VERSION) return false;
78
+ var contract = calibrationContract();
79
+ if (winner.benchmark_version != null && winner.benchmark_version !== contract.version) return false;
80
+ if (winner.benchmark_hash && winner.benchmark_hash !== contract.hash) return false;
81
+ var judgeVersion = winner.judge_policy_version != null ? winner.judge_policy_version : winner.judge_version;
82
+ if (judgeVersion != null && judgeVersion !== contract.judge_policy_version) return false;
83
+ return true;
84
+ }
85
+
86
+ function resolveAssignment(opts) {
87
+ opts = opts || {};
88
+ var persona = opts.persona || 'general';
89
+ var effort = opts.effort || 'standard';
90
+ var config = opts.config || {};
91
+ var candidates = [];
92
+
93
+ if (opts.explicitProvider || opts.explicitModel) {
94
+ var inferred = opts.explicitProvider;
95
+ if (!inferred && opts.explicitModel) {
96
+ var known = registry.MODELS.find(function(item) { return item.model === opts.explicitModel; });
97
+ inferred = known && known.provider;
98
+ }
99
+ candidates.push({
100
+ source: 'explicit',
101
+ value: { provider: inferred, model: opts.explicitModel },
102
+ strict: true,
103
+ });
104
+ }
105
+
106
+ var pinned = assignmentFrom(config.model_policy || {}, persona, effort);
107
+ if (!pinned && config.orchestrator && config.orchestrator.provider &&
108
+ config.orchestrator.provider !== 'auto' && config.orchestrator.model) {
109
+ pinned = { provider: config.orchestrator.provider, model: config.orchestrator.model };
110
+ }
111
+ if (pinned) candidates.push({ source: 'user_config', value: pinned, strict: false });
112
+ candidates.push({ source: 'local_calibration', value: assignmentFrom(readJson(localFile()), persona, effort) });
113
+ candidates.push({ source: 'package_policy', value: assignmentFrom(readJson(GLOBAL_FILE), persona, effort) });
114
+
115
+ for (var i = 0; i < candidates.length; i++) {
116
+ var candidate = candidates[i];
117
+ if (!candidate.value) continue;
118
+ var value = registry.normalizeSpec(candidate.value);
119
+ var requireContract = candidate.source === 'local_calibration' || candidate.source === 'package_policy';
120
+ if (validWinner(value, persona, effort, requireContract)) {
121
+ return { spec: value, source: candidate.source, prompt_hash: promptHash(persona) };
122
+ }
123
+ if (candidate.strict) {
124
+ var error = new Error('Requested model is unavailable or not eligible for effort=' + effort);
125
+ error.code = 'INELIGIBLE_MODEL';
126
+ throw error;
127
+ }
128
+ }
129
+ return null;
130
+ }
131
+
132
+ function prioritizePool(pool, opts) {
133
+ opts = opts || {};
134
+ if (opts.explicitProvider && !opts.explicitModel) {
135
+ var providerOnly = pool.filter(function(item) {
136
+ return item.provider === opts.explicitProvider;
137
+ });
138
+ if (!providerOnly.length) {
139
+ var providerError = new Error('No eligible model for requested provider: ' + opts.explicitProvider);
140
+ providerError.code = 'INELIGIBLE_MODEL';
141
+ throw providerError;
142
+ }
143
+ return { pool: providerOnly, policy: { source: 'explicit_provider' } };
144
+ }
145
+ var resolved = resolveAssignment(opts);
146
+ if (!resolved) return { pool: pool, policy: null };
147
+ var wanted = registry.id(resolved.spec);
148
+ var index = pool.findIndex(function(item) {
149
+ return registry.id(item) === wanted;
150
+ });
151
+ if (index === -1) {
152
+ if (resolved.source === 'explicit' || resolved.source === 'user_config') {
153
+ var error = new Error('Requested model is not in the available model pool: ' + wanted);
154
+ error.code = 'MODEL_NOT_IN_POOL';
155
+ throw error;
156
+ }
157
+ return { pool: pool, policy: null };
158
+ }
159
+ var ordered = pool.slice();
160
+ var selected = ordered.splice(index, 1)[0];
161
+ ordered.unshift(selected);
162
+ return { pool: ordered, policy: resolved };
163
+ }
164
+
165
+ module.exports = {
166
+ VERSION: 1,
167
+ GLOBAL_FILE: GLOBAL_FILE,
168
+ localFile: localFile,
169
+ promptHash: promptHash,
170
+ calibrationContract: calibrationContract,
171
+ eligible: eligible,
172
+ resolveAssignment: resolveAssignment,
173
+ prioritizePool: prioritizePool,
174
+ };