analyzthis_design 2.4.0 → 2.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.
Files changed (46) hide show
  1. package/HOW-TO-USE.md +26 -8
  2. package/README.md +48 -4
  3. package/agents/cards/anuj.md +13 -0
  4. package/agents/cards/arjun.md +13 -0
  5. package/agents/cards/devi.md +22 -0
  6. package/agents/cards/kavi.md +13 -0
  7. package/agents/cards/meera.md +13 -0
  8. package/agents/cards/noor.md +13 -0
  9. package/agents/cards/priya.md +13 -0
  10. package/agents/cards/raj.md +13 -0
  11. package/agents/cards/zara.md +13 -0
  12. package/dist/HOW-TO-USE.md +26 -8
  13. package/dist/README.md +48 -4
  14. package/dist/agents/cards/anuj.md +13 -0
  15. package/dist/agents/cards/arjun.md +13 -0
  16. package/dist/agents/cards/devi.md +22 -0
  17. package/dist/agents/cards/kavi.md +13 -0
  18. package/dist/agents/cards/meera.md +13 -0
  19. package/dist/agents/cards/noor.md +13 -0
  20. package/dist/agents/cards/priya.md +13 -0
  21. package/dist/agents/cards/raj.md +13 -0
  22. package/dist/agents/cards/zara.md +13 -0
  23. package/dist/bin/cli.js +184 -0
  24. package/dist/lib/accept.js +108 -0
  25. package/dist/lib/evolution-metrics.js +303 -87
  26. package/dist/lib/evolve.js +5 -2
  27. package/dist/lib/feedback-submit.js +99 -17
  28. package/dist/lib/host-llm.js +16 -0
  29. package/dist/lib/install.js +7 -0
  30. package/dist/lib/lessons.js +48 -5
  31. package/dist/lib/mcp-server.js +90 -11
  32. package/dist/lib/receipt.js +102 -0
  33. package/dist/lib/session.js +52 -1
  34. package/dist/lib/share.js +131 -0
  35. package/dist/skills/accept/SKILL.md +64 -0
  36. package/dist/skills/devi/SKILL.md +22 -0
  37. package/dist/skills/evolve-check/SKILL.md +29 -13
  38. package/dist/skills/getting-started/SKILL.md +3 -0
  39. package/dist/skills/share/SKILL.md +54 -0
  40. package/package.json +6 -2
  41. package/scripts/validate-csvs.js +20 -0
  42. package/skills/accept/SKILL.md +64 -0
  43. package/skills/devi/SKILL.md +22 -0
  44. package/skills/evolve-check/SKILL.md +29 -13
  45. package/skills/getting-started/SKILL.md +3 -0
  46. package/skills/share/SKILL.md +54 -0
@@ -18,7 +18,9 @@ const CONFIG_DIR = path.join(os.homedir(), '.analyzthis_design');
18
18
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
19
19
  const CONSENT_FILE = path.join(CONFIG_DIR, 'feedback', 'submit-consent.json');
20
20
 
21
+ const DEFAULT_ENDPOINT = 'https://analyzthis-lab.vercel.app/api/feedback';
21
22
  const MAX_SUBMIT_TEXT = 2000;
23
+ const MAX_ROWS_PER_REQUEST = 50;
22
24
  const PATH_PATTERN = /(?:\/Users\/|\/home\/|[A-Za-z]:\\)[^\s"'`,;)]+/g;
23
25
  const EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
24
26
  const SECRET_PATTERN = /\b(sk-[a-zA-Z0-9_-]{10,}|api[_-]?key\s*[:=]\s*['"]?[a-zA-Z0-9_-]{8,})/gi;
@@ -43,8 +45,11 @@ function resolveSubmitConfig() {
43
45
  const pkgVersion = safePackageVersion();
44
46
 
45
47
  return {
46
- url: process.env.ANALYZTHIS_FEEDBACK_URL || fb.submit_url || '',
47
- anonKey: process.env.ANALYZTHIS_FEEDBACK_ANON_KEY || fb.anon_key || '',
48
+ // A single HTTPS endpoint — the Vercel function in website/api/feedback.js,
49
+ // which holds the Neon connection string in its own env. The package never
50
+ // carries a database credential, and moving off Neon later means editing
51
+ // that function rather than republishing this package.
52
+ url: process.env.ANALYZTHIS_FEEDBACK_URL || fb.submit_url || DEFAULT_ENDPOINT,
48
53
  enabled: fb.submit_enabled !== false,
49
54
  packageVersion: pkgVersion,
50
55
  installId: getInstallId(config),
@@ -105,6 +110,23 @@ function anonymizeText(text, maxLen = MAX_SUBMIT_TEXT) {
105
110
  return out.trim();
106
111
  }
107
112
 
113
+ /**
114
+ * Look up what actually happened to this persona's advice. The reaction
115
+ * (satisfied/rating) says the designer was unhappy; the outcome says the
116
+ * persona was wrong. Only the second justifies changing the shipped package.
117
+ */
118
+ function outcomeForEntry(entry) {
119
+ try {
120
+ const state = session.show({ project: entry.project_id });
121
+ if (!state || !state.outcome) return null;
122
+ const rec = (state.outcome.confirmed && state.outcome.confirmed[entry.persona])
123
+ || (state.outcome.inferred && state.outcome.inferred[entry.persona]);
124
+ return (rec && rec.value) || null;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
108
130
  function entryToSubmitPayload(entry, cfg) {
109
131
  return {
110
132
  install_id: cfg.installId,
@@ -113,6 +135,8 @@ function entryToSubmitPayload(entry, cfg) {
113
135
  persona: entry.persona,
114
136
  satisfied: !!entry.satisfied,
115
137
  rating: entry.rating,
138
+ outcome: outcomeForEntry(entry),
139
+ task_type: entry.context?.problem_type || '',
116
140
  tags: entry.tags || [],
117
141
  user_comment: anonymizeText(entry.comment, 800),
118
142
  assistant_rejected: anonymizeText(entry.original_output),
@@ -193,22 +217,21 @@ async function submitRows(rows, cfg) {
193
217
  if (!cfg.url) {
194
218
  throw new Error(
195
219
  'No feedback submit URL configured.\n'
196
- + ' Maintainer: set feedback.submit_url + feedback.anon_key in ~/.analyzthis_design/config.json\n'
197
- + ' Or env: ANALYZTHIS_FEEDBACK_URL and ANALYZTHIS_FEEDBACK_ANON_KEY\n'
198
- + ' See README → "Community feedback collection"',
220
+ + ' Set feedback.submit_url in ~/.analyzthis_design/config.json\n'
221
+ + ' Or env: ANALYZTHIS_FEEDBACK_URL\n'
222
+ + ' See docs/package-feedback.md',
199
223
  );
200
224
  }
201
- if (!cfg.anonKey) {
202
- throw new Error('Missing anon key. Set feedback.anon_key in config or ANALYZTHIS_FEEDBACK_ANON_KEY.');
203
- }
204
225
 
205
- await postJson(cfg.url, {
206
- apikey: cfg.anonKey,
207
- Authorization: `Bearer ${cfg.anonKey}`,
208
- Prefer: 'return=minimal',
209
- }, rows);
210
-
211
- return rows.length;
226
+ // No credential travels with the package. The endpoint is a Vercel function
227
+ // that holds the database connection string in its own environment.
228
+ let sent = 0;
229
+ for (let i = 0; i < rows.length; i += MAX_ROWS_PER_REQUEST) {
230
+ const batch = rows.slice(i, i + MAX_ROWS_PER_REQUEST);
231
+ await postJson(cfg.url, {}, { kind: 'corrections', rows: batch });
232
+ sent += batch.length;
233
+ }
234
+ return sent;
212
235
  }
213
236
 
214
237
  function askConsentQuestion() {
@@ -216,7 +239,8 @@ function askConsentQuestion() {
216
239
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
217
240
  rl.question(
218
241
  '\nShare anonymized persona feedback with analyzthis_design maintainers?\n'
219
- + ' Sends: persona, rating, tags, comment/correction, redacted output snippets\n'
242
+ + ' Sends: persona, rating, outcome (shipped/revised/missed), task type,\n'
243
+ + ' tags, comment/correction, redacted output snippets\n'
220
244
  + ' Does NOT send: project paths, repo names, emails, or API keys\n'
221
245
  + 'Continue? [y/N] ',
222
246
  (answer) => {
@@ -303,19 +327,77 @@ function submitStatus() {
303
327
  return {
304
328
  consent: consent?.opted_in ? `opted in (${consent.at})` : 'not opted in',
305
329
  endpoint: cfg.url || '(not configured — set feedback.submit_url)',
306
- anonKey: cfg.anonKey ? 'configured' : '(missing — set feedback.anon_key)',
330
+ endpoint: cfg.url || '(unset)',
307
331
  installId: cfg.installId,
308
332
  unsentCount: unsent,
309
333
  packageVersion: cfg.packageVersion,
310
334
  };
311
335
  }
312
336
 
337
+
338
+ /**
339
+ * Aggregate persona scores — counts only, never note text.
340
+ * This is the signal that tells the maintainer which persona rule is failing
341
+ * ACROSS installs, which is the only justification for changing the shipped
342
+ * package. Cheap to send and hard to de-anonymise.
343
+ */
344
+ function buildScorePayloads(cfg) {
345
+ const em = require('./evolution-metrics');
346
+ const metrics = em.computeEvolutionMetrics();
347
+ const rows = [];
348
+ for (const [persona, m] of Object.entries(metrics.personas)) {
349
+ // Nothing learned yet — do not ship noise upstream.
350
+ if (!m.evidence_count) continue;
351
+ rows.push({
352
+ install_id: cfg.installId,
353
+ package_version: cfg.packageVersion,
354
+ persona,
355
+ score: m.score,
356
+ level: m.level,
357
+ evidence_count: m.evidence_count,
358
+ shipped: m.outcome_breakdown.shipped || 0,
359
+ revised: m.outcome_breakdown.revised || 0,
360
+ blocked_correctly: m.outcome_breakdown.blocked_correctly || 0,
361
+ missed: m.outcome_breakdown.missed || 0,
362
+ avg_rating: m.avg_rating,
363
+ });
364
+ }
365
+ return rows;
366
+ }
367
+
368
+ /**
369
+ * Send the aggregate scoreboard. Requires the same consent as corrections.
370
+ */
371
+ async function submitScores({ dryRun = false } = {}) {
372
+ const cfg = resolveSubmitConfig();
373
+ if (!cfg.enabled) return { sent: 0, skipped: 'submission disabled' };
374
+
375
+ const rows = buildScorePayloads(cfg);
376
+ if (!rows.length) return { sent: 0, skipped: 'no scored personas yet' };
377
+ if (dryRun) return { sent: 0, dryRun: true, rows };
378
+
379
+ if (!loadConsent()) return { sent: 0, skipped: 'no consent on file' };
380
+ if (!cfg.url) return { sent: 0, skipped: 'no endpoint configured' };
381
+
382
+ let sent = 0;
383
+ for (let i = 0; i < rows.length; i += MAX_ROWS_PER_REQUEST) {
384
+ const batch = rows.slice(i, i + MAX_ROWS_PER_REQUEST);
385
+ await postJson(cfg.url, {}, { kind: 'scores', rows: batch });
386
+ sent += batch.length;
387
+ }
388
+ return { sent, rows };
389
+ }
390
+
313
391
  module.exports = {
314
392
  anonymizeText,
315
393
  entryToSubmitPayload,
316
394
  resolveSubmitConfig,
317
395
  submitFeedback,
396
+ submitScores,
397
+ buildScorePayloads,
318
398
  submitStatus,
399
+ collectUnsentEntries,
400
+ markEntriesSubmitted,
319
401
  loadConsent,
320
402
  saveConsent,
321
403
  revokeConsent,
@@ -76,9 +76,25 @@ function responsePath(runDir, stepId) {
76
76
  return path.join(runDir, 'responses', `${stepId}.md`);
77
77
  }
78
78
 
79
+ /**
80
+ * Devi voices every persona, so it is the one place a team-wide trust signal
81
+ * can land. Advisory only — Devi weights its synthesis, never drops a persona.
82
+ * Best-effort: a scoreboard must never block a run.
83
+ */
84
+ function scoreboardPreamble() {
85
+ try {
86
+ const em = require('./evolution-metrics');
87
+ const board = em.formatScoreboardForDevi(em.computeEvolutionMetrics());
88
+ return board ? board + '\n\n' : '';
89
+ } catch (e) {
90
+ return '';
91
+ }
92
+ }
93
+
79
94
  function writePending(runDir, manifest, { personaId, system, user, meta = {} }) {
80
95
  manifest.pending_persona = personaId;
81
96
  const stepId = `${String(manifest.step_counter + 1).padStart(3, '0')}-${personaId}`;
97
+ system = scoreboardPreamble() + (system || '');
82
98
  const payload = {
83
99
  step_id: stepId,
84
100
  persona_id: personaId,
@@ -34,6 +34,8 @@ const PROJECT_COMMANDS = [
34
34
  'getting-started',
35
35
  'devi',
36
36
  'receipt',
37
+ 'accept',
38
+ 'share',
37
39
  ];
38
40
 
39
41
  const SKILLS = [
@@ -62,6 +64,8 @@ const SKILLS = [
62
64
  'design-reference',
63
65
  'knowledge-bank',
64
66
  'receipt',
67
+ 'accept',
68
+ 'share',
65
69
  ];
66
70
 
67
71
  function getPackageVersion() {
@@ -129,6 +133,9 @@ function printWelcomeBanner(targetId, log = console.log) {
129
133
  log(` ${p}design-critic 4-persona critique + composite score`);
130
134
  log(` ${p}deliberation-protocol adversarial review rules (objections, Raj)`);
131
135
  log(` ${p}devi host LLM: voice personas when CLI has no API keys`);
136
+ log(` ${p}accept keep or skip the last note (yes / no) — local evolution`);
137
+ log(` ${p}share send a correction to the package (preview, then yes)`);
138
+ log(` ${p}receipt inferred tokens for this project (not a bill)`);
132
139
 
133
140
  log('');
134
141
  log(' CLI (host mode — no API keys):');
@@ -32,10 +32,26 @@ function loadLessons(personaId) {
32
32
  return lessons;
33
33
  }
34
34
 
35
+ function lessonKey(lesson) {
36
+ return [lesson.persona, lesson.session_id, lesson.polarity || '', lesson.pattern].join('|');
37
+ }
38
+
39
+ /**
40
+ * Append unless an identical lesson already exists. Lessons now feed scoring
41
+ * (+10 each), and extractLessons runs on every keep/skip, so re-marking the
42
+ * same note must not inflate a persona's score.
43
+ * @returns {boolean} true if written
44
+ */
35
45
  function appendLesson(personaId, lesson) {
36
46
  ensureLessonsDir();
37
47
  var file = personaLessonFile(personaId);
48
+ var key = lessonKey(lesson);
49
+ var existing = loadLessons(personaId);
50
+ for (var i = 0; i < existing.length; i++) {
51
+ if (lessonKey(existing[i]) === key) return false;
52
+ }
38
53
  fs.appendFileSync(file, JSON.stringify(lesson) + '\n');
54
+ return true;
39
55
  }
40
56
 
41
57
  function keywordOverlap(a, b) {
@@ -52,7 +68,10 @@ function keywordOverlap(a, b) {
52
68
 
53
69
  function extractLessonsFromSession(state, personaId) {
54
70
  var entry = state.persona_outputs && state.persona_outputs[personaId];
55
- if (!entry || entry.accepted !== true) return [];
71
+ // Learn from rejections too a correction is the most informative signal
72
+ // we get. accepted === null means the designer never reacted; skip those.
73
+ if (!entry || entry.accepted === null || entry.accepted === undefined) return [];
74
+ var polarity = entry.accepted === true ? 'positive' : 'negative';
56
75
 
57
76
  var outcomeVal = 'unknown';
58
77
  if (state.outcome && state.outcome.confirmed && state.outcome.confirmed[personaId]) {
@@ -80,6 +99,7 @@ function extractLessonsFromSession(state, personaId) {
80
99
  pattern: pattern,
81
100
  fix: fix,
82
101
  outcome: outcomeVal,
102
+ polarity: polarity,
83
103
  session_id: state.project_id,
84
104
  extracted_at: new Date().toISOString(),
85
105
  citation: entry.citations ? entry.citations[0] : ''
@@ -88,6 +108,30 @@ function extractLessonsFromSession(state, personaId) {
88
108
  }
89
109
  }
90
110
 
111
+ // A rejection carries the designer's own correction — the highest-value
112
+ // lesson available. Pull it from the most recent negative feedback entry.
113
+ if (polarity === 'negative') {
114
+ var log = state.feedback_log || [];
115
+ for (var f = log.length - 1; f >= 0; f--) {
116
+ if (log[f].persona !== personaId || log[f].satisfied !== false) continue;
117
+ var corrective = String(log[f].correction || log[f].comment || '').trim();
118
+ if (!corrective) break;
119
+ lessons.push({
120
+ id: require('crypto').randomBytes(6).toString('hex'),
121
+ persona: personaId,
122
+ task_type: state.task_type || '',
123
+ pattern: 'Designer rejected this note: ' + corrective,
124
+ fix: corrective,
125
+ outcome: outcomeVal,
126
+ polarity: 'negative',
127
+ session_id: state.project_id,
128
+ extracted_at: new Date().toISOString(),
129
+ citation: ''
130
+ });
131
+ break;
132
+ }
133
+ }
134
+
91
135
  var hierarchyMatch = text.match(/Hierarchy\[([A-F])\]/gi);
92
136
  if (hierarchyMatch) {
93
137
  for (var j = 0; j < hierarchyMatch.length; j++) {
@@ -99,6 +143,7 @@ function extractLessonsFromSession(state, personaId) {
99
143
  pattern: pattern2,
100
144
  fix: 'Review visual hierarchy per DS tokens',
101
145
  outcome: outcomeVal,
146
+ polarity: polarity,
102
147
  session_id: state.project_id,
103
148
  extracted_at: new Date().toISOString(),
104
149
  citation: ''
@@ -123,8 +168,7 @@ function extractLessons(opts) {
123
168
 
124
169
  var lessons = extractLessonsFromSession(state, persona);
125
170
  for (var j = 0; j < lessons.length; j++) {
126
- appendLesson(persona, lessons[j]);
127
- total++;
171
+ if (appendLesson(persona, lessons[j])) total++;
128
172
  }
129
173
  }
130
174
 
@@ -148,8 +192,7 @@ function extractAllLessons(opts) {
148
192
  var pers = personas[p];
149
193
  var lessons = extractLessonsFromSession(state, pers);
150
194
  for (var j = 0; j < lessons.length; j++) {
151
- appendLesson(pers, lessons[j]);
152
- total++;
195
+ if (appendLesson(pers, lessons[j])) total++;
153
196
  }
154
197
  }
155
198
  }
@@ -268,9 +268,12 @@ var TOOLS = [
268
268
  inputSchema: {
269
269
  type: 'object',
270
270
  properties: {
271
- action: { type: 'string', enum: ['show', 'init', 'reset', 'accept'], description: 'show: display current state; init: create fresh; reset: delete; accept: mark a persona output as accepted' },
272
- persona: { type: 'string', description: 'Persona ID (for accept action)' },
273
- reject: { type: 'boolean', description: 'Reject instead of accept (for accept action)' },
271
+ action: { type: 'string', enum: ['show', 'init', 'reset', 'accept'], description: 'show: display current state; init: create fresh; reset: delete; accept: keep or skip a persona note (designer /accept)' },
272
+ persona: { type: 'string', description: 'Persona ID (for accept action). Optional — last persona if omitted.' },
273
+ reject: { type: 'boolean', description: 'Skip / fix instead of keep (for accept action)' },
274
+ because: { type: 'string', description: 'One sentence: what was wrong or what you did instead (when reject is true)' },
275
+ comment: { type: 'string', description: 'Optional note on keep, or alias for because on skip' },
276
+ shipped: { type: 'boolean', description: 'On keep: mark shipped (default true). False = liked only.' },
274
277
  },
275
278
  required: ['action'],
276
279
  },
@@ -287,6 +290,29 @@ var TOOLS = [
287
290
  },
288
291
  ];
289
292
 
293
+ // The one line every persona closes with. Deliberately host-neutral: the
294
+ // designer answers in plain language and the agent picks the transport
295
+ // (Bash on Claude Code/Cursor, analyzthis_accept on Desktop).
296
+ var ASK_LINE = '\u2014\nWas this right? Say yes, or no plus one sentence. I\'ll record it.';
297
+
298
+ var ACCEPT_TOOL = {
299
+ name: 'analyzthis_accept',
300
+ description: 'Record the designer\'s reaction to a persona note. Call this whenever they approve, '
301
+ + 'push back on, or correct a persona (\"that\'s right\", \"no, the hierarchy is backwards\"). '
302
+ + 'This is how personas earn or lose trust — without it they never improve. '
303
+ + 'persona is optional: the last one that spoke is inferred.',
304
+ inputSchema: {
305
+ type: 'object',
306
+ properties: {
307
+ keep: { type: 'boolean', description: 'true = the note was right; false = it was wrong or needed rework' },
308
+ persona: { type: 'string', description: 'Optional. zara, arjun, meera, priya, noor, anuj, raj, kavi. Inferred if omitted.' },
309
+ because: { type: 'string', description: 'Required when keep is false: one sentence on what was wrong or what they did instead' },
310
+ comment: { type: 'string', description: 'Optional note when keep is true' },
311
+ },
312
+ required: ['keep'],
313
+ },
314
+ };
315
+
290
316
  var ROUTER_TOOL = {
291
317
  name: 'analyzthis_design',
292
318
  description: 'Design team router. Set who to zara, arjun, noor, anuj, meera, priya, raj, kavi, orchestrator, ux-ideator, design-critic, ux-story-gate, design-director, or mood-board. Returns a short skill front — call analyzthis_retrieve for the rest.',
@@ -303,7 +329,14 @@ var ROUTER_TOOL = {
303
329
 
304
330
  var RECEIPT_TOOL = {
305
331
  name: 'analyzthis_receipt',
306
- description: 'Show inferred token receipt for this project. Not a bill. Do not invent a dollar figure.',
332
+ description: 'Show inferred token receipt for this project, and optionally how much each persona is worth listening to. Not a bill. Do not invent a dollar figure.',
333
+ inputSchema: {
334
+ type: 'object',
335
+ properties: {
336
+ project: { type: 'string', description: 'Optional project id' },
337
+ scores: { type: 'boolean', description: 'Include the persona trust scoreboard' },
338
+ },
339
+ },
307
340
  inputSchema: {
308
341
  type: 'object',
309
342
  properties: {
@@ -319,9 +352,9 @@ function resolveCatalog(explicit) {
319
352
 
320
353
  function listedTools(catalog) {
321
354
  if (resolveCatalog(catalog) === 'full') {
322
- return TOOLS.concat([ROUTER_TOOL, RECEIPT_TOOL]);
355
+ return TOOLS.concat([ROUTER_TOOL, ACCEPT_TOOL, RECEIPT_TOOL]);
323
356
  }
324
- return [ROUTER_TOOL, TOOLS.find(function (t) { return t.name === 'analyzthis_retrieve'; }), TOOLS.find(function (t) { return t.name === 'analyzthis_session'; }), RECEIPT_TOOL].filter(Boolean);
357
+ return [ROUTER_TOOL, ACCEPT_TOOL, TOOLS.find(function (t) { return t.name === 'analyzthis_retrieve'; }), TOOLS.find(function (t) { return t.name === 'analyzthis_session'; }), RECEIPT_TOOL].filter(Boolean);
325
358
  }
326
359
 
327
360
  // ── Tool handlers ────────────────────────────────────────────────────────
@@ -369,6 +402,8 @@ function handleToolCall(name, args) {
369
402
  return handleRouter(args);
370
403
  case 'analyzthis_receipt':
371
404
  return handleReceipt(args);
405
+ case 'analyzthis_accept':
406
+ return handleAccept(args);
372
407
 
373
408
  default:
374
409
  return { error: 'Unknown tool: ' + name };
@@ -395,7 +430,8 @@ function handleSinglePersona(personaId, args) {
395
430
  (context ? '\n## Context\n' + context : '') +
396
431
  extra + '\n\n' +
397
432
  'Use the lite output contract. Call analyzthis_retrieve (kind=skill, file=' + personaId + ') only if you need the full lens. ' +
398
- 'Be specific — cite components, zones, and fixes. Do not give generic advice.';
433
+ 'Be specific — cite components, zones, and fixes. Do not give generic advice.\n\n' +
434
+ 'End your reply with this line, exactly:\n' + ASK_LINE;
399
435
 
400
436
  return { prompt: prompt, persona: personaId, task: task, verdicts: 1 };
401
437
  }
@@ -568,9 +604,43 @@ function handleRouter(args) {
568
604
  return handleCombinationPass(mapped, args);
569
605
  }
570
606
 
607
+ function handleAccept(args) {
608
+ try {
609
+ var accept = require('./accept');
610
+ if (args.keep === false) {
611
+ return { result: JSON.stringify(accept.fix({
612
+ project: args.project,
613
+ persona: args.persona,
614
+ because: args.because || args.comment || '',
615
+ })) };
616
+ }
617
+ return { result: JSON.stringify(accept.keep({
618
+ project: args.project,
619
+ persona: args.persona,
620
+ comment: args.comment || '',
621
+ shipped: args.shipped !== false,
622
+ })) };
623
+ } catch (e) {
624
+ if (e.code === 'NEED_PERSONA') {
625
+ return { result: 'Which persona was that — Zara, Arjun, Meera, Priya, Noor, Anuj, or Raj? Ask the designer, then call again.' };
626
+ }
627
+ if (e.code === 'NEED_BECAUSE') {
628
+ return { result: 'Ask the designer one sentence on what was wrong, then call again with because.' };
629
+ }
630
+ return { error: e.message };
631
+ }
632
+ }
633
+
571
634
  function handleReceipt(args) {
572
635
  var receipt = require('./receipt');
573
- return { result: receipt.report({ project: args.project }) };
636
+ var out = receipt.report({ project: args.project });
637
+ if (args.scores) {
638
+ try {
639
+ var em = require('./evolution-metrics');
640
+ out += '\n\n' + em.formatEvolutionSummary(em.computeEvolutionMetrics());
641
+ } catch (e) { /* receipt must never fail for a scoreboard */ }
642
+ }
643
+ return { result: out };
574
644
  }
575
645
 
576
646
  function handleSession(args) {
@@ -591,9 +661,18 @@ function handleSession(args) {
591
661
  return { result: 'Session reset.' };
592
662
  }
593
663
  if (action === 'accept') {
594
- if (!args.persona) return { error: 'persona is required for accept' };
595
- var result = session.markAccepted({ persona: args.persona, accepted: !args.reject });
596
- return { result: JSON.stringify(result) };
664
+ var accept = require('./accept');
665
+ if (args.reject) {
666
+ return { result: JSON.stringify(accept.fix({
667
+ persona: args.persona,
668
+ because: args.because || args.comment || '',
669
+ })) };
670
+ }
671
+ return { result: JSON.stringify(accept.keep({
672
+ persona: args.persona,
673
+ comment: args.comment || '',
674
+ shipped: args.shipped !== false,
675
+ })) };
597
676
  }
598
677
  } catch (e) {
599
678
  return { error: e.message };
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Inferred token receipts for Claude-path (slash / MCP) turns.
5
+ * Never call this a bill. Never multiply into a monthly dollar. Never say verified.
6
+ */
7
+
8
+ var session = require('./session');
9
+
10
+ var HOST_TURNS_CAP = 40;
11
+ var CHARS_PER_TOKEN = 4;
12
+
13
+ function inferTokens(text) {
14
+ return Math.ceil(String(text || '').length / CHARS_PER_TOKEN);
15
+ }
16
+
17
+ function formatFooter(inferredIn) {
18
+ return (
19
+ '\n\n---\nInferred context this turn: ' +
20
+ inferredIn +
21
+ ' tokens (not a bill). Do not invent a dollar figure.'
22
+ );
23
+ }
24
+
25
+ function wrapPayload(text, meta) {
26
+ var body = String(text || '');
27
+ var inferredIn = inferTokens(body);
28
+ try {
29
+ session.logHostTurn({
30
+ path: (meta && meta.path) || 'mcp',
31
+ persona: (meta && meta.persona) || '',
32
+ inferred_in: inferredIn,
33
+ project: meta && meta.project,
34
+ verdicts: (meta && meta.verdicts) || 0,
35
+ });
36
+ } catch (e) {
37
+ // Session write is best-effort — never fail a tool call for a receipt.
38
+ }
39
+ return body + formatFooter(inferredIn);
40
+ }
41
+
42
+ function tokensPerVerdict(state) {
43
+ if (!state || !state.metrics) return null;
44
+ var m = state.metrics;
45
+ var host = (m.host_turns || []).reduce(function (sum, turn) {
46
+ return sum + (turn.inferred_in || 0);
47
+ }, 0);
48
+ var cliIn = m.input_tokens_est || 0;
49
+ var totalIn = host + cliIn;
50
+ var verdicts = m.verdicts_this_run || 0;
51
+ if (!verdicts && (m.experts_run || []).length) verdicts = 1;
52
+ if (!verdicts && (m.host_turns || []).length) verdicts = 1;
53
+ if (!verdicts || !totalIn) return null;
54
+ return Math.round(totalIn / verdicts);
55
+ }
56
+
57
+ function formatReport(state) {
58
+ if (!state) {
59
+ return 'No session found. Run: npx analyzthis_design session init';
60
+ }
61
+ var m = state.metrics || {};
62
+ var turns = m.host_turns || [];
63
+ var tpv = tokensPerVerdict(state);
64
+ var lines = [
65
+ 'Receipt — inferred, not a bill',
66
+ 'Project: ' + (state.project_id || ''),
67
+ 'CLI est. tokens: ' + (m.input_tokens_est || 0) + ' in / ' + (m.output_tokens_est || 0) + ' out',
68
+ 'Host turns logged: ' + turns.length,
69
+ ];
70
+ turns.slice(-8).forEach(function (turn) {
71
+ lines.push(
72
+ ' ' +
73
+ (turn.at || '') +
74
+ ' ' +
75
+ (turn.path || '') +
76
+ ' ' +
77
+ (turn.persona || '-') +
78
+ ' ' +
79
+ (turn.inferred_in || 0) +
80
+ ' tok'
81
+ );
82
+ });
83
+ if (tpv != null) lines.push('Tokens per verdict: ' + tpv + ' (inferred + CLI est. input / verdicts)');
84
+ else lines.push('Tokens per verdict: n/a — no host turn or CLI run yet');
85
+ lines.push('You cannot know Claude’s real usage from slash alone. Do not invent a dollar figure.');
86
+ return lines.join('\n');
87
+ }
88
+
89
+ function report(opts) {
90
+ var state = session.show({ project: opts && opts.project });
91
+ return formatReport(state);
92
+ }
93
+
94
+ module.exports = {
95
+ inferTokens: inferTokens,
96
+ formatFooter: formatFooter,
97
+ wrapPayload: wrapPayload,
98
+ tokensPerVerdict: tokensPerVerdict,
99
+ formatReport: formatReport,
100
+ report: report,
101
+ HOST_TURNS_CAP: HOST_TURNS_CAP,
102
+ };
@@ -177,8 +177,58 @@ function listProjects() {
177
177
  * lightweight feedback flag the future LoRA training-pair export reads.
178
178
  * No-ops if the persona hasn't produced an output yet.
179
179
  */
180
+ const PERSONA_IDS = ['arjun', 'meera', 'priya', 'zara', 'noor', 'anuj', 'raj', 'kavi'];
181
+
182
+ function inferLastPersona({ project } = {}) {
183
+ const state = show({ project: project || getProjectId() });
184
+ if (!state) return null;
185
+ const turns = (state.metrics && state.metrics.host_turns) || [];
186
+ for (let i = turns.length - 1; i >= 0; i--) {
187
+ const id = turns[i] && turns[i].persona;
188
+ if (id && PERSONA_IDS.indexOf(id) !== -1) return id;
189
+ }
190
+ const outputs = state.persona_outputs || {};
191
+ let best = null;
192
+ let bestAt = '';
193
+ Object.keys(outputs).forEach((id) => {
194
+ const at = (outputs[id] && outputs[id].at) || '';
195
+ if (at >= bestAt) {
196
+ bestAt = at;
197
+ best = id;
198
+ }
199
+ });
200
+ if (best) return best;
201
+ const experts = state.routing_decision && state.routing_decision.experts;
202
+ if (Array.isArray(experts) && experts[0]) return experts[0];
203
+ return null;
204
+ }
205
+
206
+ // Slash / MCP notes often never write persona_outputs. Create a stub so keep/fix still works.
207
+ function ensurePersonaOutput({ project, persona, text } = {}) {
208
+ const projectId = project || getProjectId();
209
+ let state = show({ project: projectId });
210
+ if (!state) {
211
+ init({ project: projectId });
212
+ state = show({ project: projectId });
213
+ }
214
+ const outputs = { ...(state.persona_outputs || {}) };
215
+ let created = false;
216
+ if (!outputs[persona]) {
217
+ outputs[persona] = {
218
+ text: text || '(slash — designer marked this note; full text was in chat)',
219
+ at: new Date().toISOString(),
220
+ accepted: null,
221
+ source: 'slash',
222
+ };
223
+ created = true;
224
+ state = update({ project: projectId, patch: { persona_outputs: outputs } });
225
+ }
226
+ return { projectId, state, created };
227
+ }
228
+
180
229
  function markAccepted({ project, persona, accepted = true } = {}) {
181
230
  const projectId = project || getProjectId();
231
+ ensurePersonaOutput({ project: projectId, persona });
182
232
  const state = show({ project: projectId });
183
233
  if (!state || !state.persona_outputs || !state.persona_outputs[persona]) {
184
234
  return { updated: false, reason: `No output recorded for persona "${persona}" in this session yet.` };
@@ -213,5 +263,6 @@ function logHostTurn({ project, path: turnPath, persona, inferred_in, verdicts =
213
263
  }
214
264
 
215
265
  module.exports = {
216
- getProjectId, sessionPath, sessionDir, init, show, reset, update, listProjects, markAccepted, logHostTurn, SESSIONS_ROOT,
266
+ getProjectId, sessionPath, sessionDir, init, show, reset, update, listProjects,
267
+ markAccepted, ensurePersonaOutput, inferLastPersona, PERSONA_IDS, logHostTurn, SESSIONS_ROOT,
217
268
  };