shakers-cli 0.16.0 → 0.18.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 (69) hide show
  1. package/README.md +21 -6
  2. package/bin/ai-usage.js +31 -12
  3. package/bin/command-audit-worker.js +19 -0
  4. package/bin/login.js +4 -8
  5. package/bin/mcp.js +32 -21
  6. package/bin/register.js +12 -30
  7. package/bin/shakers-cli.js +15 -10
  8. package/package.json +1 -1
  9. package/src/account-marker.js +40 -0
  10. package/src/account-trace.js +23 -0
  11. package/src/agents-client.js +1 -0
  12. package/src/ai-usage-review.js +103 -0
  13. package/src/auth-client.js +7 -107
  14. package/src/auth-session-store.js +12 -11
  15. package/src/backend-request.js +3 -24
  16. package/src/cli-skill.js +77 -0
  17. package/src/command-audit.js +47 -4
  18. package/src/config.js +2 -16
  19. package/src/consent-flow.js +9 -3
  20. package/src/detect-repos.js +4 -8
  21. package/src/detectors.js +23 -0
  22. package/src/device-auth-store.js +2 -2
  23. package/src/device-login.js +11 -9
  24. package/src/env-paths.js +9 -1
  25. package/src/i18n.js +186 -102
  26. package/src/mcp-ai-usage-result-tool.js +38 -14
  27. package/src/mcp-ai-usage-tool.js +317 -172
  28. package/src/mcp-choice.js +171 -34
  29. package/src/mcp-detector.js +26 -2
  30. package/src/mcp-device-login-tools.js +40 -8
  31. package/src/mcp-install.js +292 -53
  32. package/src/mcp-instructions.js +43 -17
  33. package/src/mcp-onboarding-tools.js +1 -3
  34. package/src/mcp-prompts.js +8 -6
  35. package/src/mcp-register-tools.js +25 -14
  36. package/src/mcp-role-tools.js +59 -18
  37. package/src/mcp-server-name.js +7 -0
  38. package/src/mcp-server.js +35 -7
  39. package/src/mcp-signup-client.js +3 -6
  40. package/src/mcp-signup-flow.js +389 -0
  41. package/src/mcp-signup-tools.js +250 -133
  42. package/src/onboarding-flow.js +72 -146
  43. package/src/onboarding-funnel.js +46 -6
  44. package/src/opencode-sessions.js +92 -0
  45. package/src/profile-agents.js +26 -0
  46. package/src/render-report-text.js +1 -1
  47. package/src/report-gating.js +4 -2
  48. package/src/report-store.js +7 -2
  49. package/src/roles-flow.js +8 -5
  50. package/src/saved-positions-flow.js +3 -3
  51. package/src/scanner.js +104 -12
  52. package/src/session-gate.js +1 -4
  53. package/src/session-normalizers.js +114 -8
  54. package/src/session-refresh.js +5 -16
  55. package/src/session-scan.js +167 -22
  56. package/src/share-consent-state.js +2 -2
  57. package/src/share-payload.js +4 -1
  58. package/src/share.js +2 -2
  59. package/src/signup-client.js +12 -39
  60. package/src/signup-copy.js +545 -58
  61. package/src/skill-install.js +102 -0
  62. package/src/stdin-ask.js +13 -1
  63. package/src/tier-engine.js +14 -2
  64. package/src/trust-narration.js +32 -0
  65. package/src/usage-run-steps.js +68 -6
  66. package/src/usage-scan.js +11 -8
  67. package/src/brand-svg.js +0 -9
  68. package/src/inter-fonts.js +0 -6
  69. package/src/mcp-auth-form.js +0 -480
@@ -3,7 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const { execFileSync } = require('child_process');
5
5
  const { isRefusedWalkRoot } = require('./scan-exclusions');
6
- const { askChoice, chatChoice, queueNotice } = require('./mcp-choice');
6
+ const { askChoice, chatChoice, forChat, handOver, narrationOf, queueNotice, settle, withNextSay } = require('./mcp-choice');
7
7
 
8
8
  function isExistingDir(p) {
9
9
  try {
@@ -75,11 +75,38 @@ function buildArgv({ root, repoScope, lang }) {
75
75
  return argv;
76
76
  }
77
77
 
78
- function makeAiUsageTool(deps = {}) {
78
+ const VERBATIM = 'Print relayVerbatim word for word now, as its own block, with nothing of your own added to it.';
79
+ const CONSENT_QUESTION = 'ai_usage:consent';
80
+
81
+ const UPLOAD_INPUT_SCHEMA = {
82
+ type: 'object',
83
+ properties: {
84
+ confirm: {
85
+ type: 'boolean',
86
+ description: 'Chat only: true ONLY when the talent picked the upload option in the review this tool or ai_usage showed them; false when they picked not to upload. Omit it to show the review.',
87
+ },
88
+ exclude: {
89
+ type: 'object',
90
+ description: 'What the talent asked to leave out, with confirm:true. Names exactly as listed in `excludable`.',
91
+ properties: {
92
+ repos: { type: 'array', items: { type: 'string' } },
93
+ agents: { type: 'array', items: { type: 'string' } },
94
+ excerpts: { type: 'boolean', description: 'true = send no text excerpts.' },
95
+ },
96
+ },
97
+ waitSeconds: { type: 'number', description: 'Sign-up only: how long to wait for the background analysis before answering (max 45).' },
98
+ },
99
+ };
100
+
101
+ const MAX_WAIT_MS = 45000;
102
+
103
+ function makeAiUsageTools(deps = {}) {
79
104
  const {
80
105
  scanUsage = (argv) => require('./usage-scan').scanUsage(argv),
81
106
  autoShare = require('./share').autoShare,
107
+ logCommandRun = require('./command-audit').logCommandRun,
82
108
  recordConsent = require('./share').recordConsent,
109
+ sendThrottled = () => require('./share').isThrottled(require('./share').loadConsentState()),
83
110
  isValidEmail = require('./share').isValidEmail,
84
111
  persistFootprint = require('./report-store').persistFootprint,
85
112
  cwdIsRepo = defaultCwdIsRepo,
@@ -90,64 +117,221 @@ function makeAiUsageTool(deps = {}) {
90
117
  pollClassifiedInventory = require('./mcp-ai-usage-result-tool').pollClassifiedInventory,
91
118
  tierLadder = require('./tier-engine').tierLadder,
92
119
  inlineWaitMs = 35000,
93
- // "My work with AI" preview, from the same hub endpoint the front paints.
94
120
  resolveAiProfilePreview = require('./ai-profile-preview').resolveAiProfilePreview,
95
121
  ensureFreshSession = require('./session-refresh').ensureFreshSession,
96
122
  getAiProfileEndpoint = require('./config').getAiProfileEndpoint,
97
- // Plain-text render of the full report (terminal sections + matrix/vision/howIWork), shared with the CLI (no ANSI).
98
123
  renderFullReportText = require('./render-report-text').renderFullReportText,
99
124
  detectFlowLang = require('./i18n').detectFlowLang,
100
125
  aiFluencyWaitMs = 30000,
126
+ signupInventoryWaitMs = 20000,
127
+ signupProfileWaitMs = 25000,
101
128
  signupPhase = () => require('./mcp-signup-tools').signupPhase(),
102
129
  usageDisclaimers = (lang) => require('./onboarding-flow').usageDisclaimers(lang),
103
130
  signupCopy = require('./signup-copy').signupCopy,
131
+ reviewCopy = require('./signup-copy').reviewCopy,
104
132
  signupLanguage = require('./signup-language').signupLanguage,
133
+ reviewTexts = require('./ai-usage-review'),
105
134
  } = deps;
106
135
 
107
- // The sign-up's one scan: started on the talent's yes once the account exists, uploaded in the background.
108
136
  let job = null;
109
137
  // The talent's answer in the consent dialog, so a later call cannot contradict it.
110
138
  let dialogConsent = null;
139
+ // In the sign-up a consent counts only after its question and the two disclaimers went to the chat.
140
+ let consentAsked = false;
141
+ let pending = null;
111
142
 
112
- function startSignupScan(argv, root) {
113
- if (job) return job;
114
- job = { state: 'scanning', send: null, uploading: null };
143
+ function startSignupScan(argv, root, lang) {
144
+ if (job && job.state !== 'failed') return job;
145
+ job = { state: 'scanning', root, lang };
115
146
  // performScan is synchronous: start it after this answer has gone out.
116
147
  job.scan = new Promise((resolve) => { setImmediate(resolve); }).then(() => scanUsage(argv)).then((scan) => {
117
148
  try { persistFootprint({ root, report: scan.report, maturity: scan.maturity }); } catch { /* local copy is best-effort */ }
149
+ job.state = 'scanned';
118
150
  return scan;
119
151
  });
120
152
  job.scan.catch(() => { job.state = 'failed'; });
121
- job.root = root;
122
153
  return job;
123
154
  }
124
155
 
125
- function uploadSignupScan() {
126
- if (!job) return Promise.resolve(null);
127
- if (job.uploading) return job.uploading;
128
- const current = job;
129
- current.uploading = current.scan.then(async (scan) => {
130
- const session = loadAuthSession();
131
- if (sessionStatus(session) !== 'active' || !session || !isValidEmail(session.email)) {
132
- current.uploading = null;
133
- return { ok: false, skipped: true, reason: 'no-session' };
156
+ function activeSession() {
157
+ const session = loadAuthSession();
158
+ return sessionStatus(session) === 'active' && session && isValidEmail(session.email) ? session : null;
159
+ }
160
+
161
+ function openReview({ scan, root, lang, registration }) {
162
+ pending = { scan, root, lang, registration, askedInChat: false, outcome: null };
163
+ return pending;
164
+ }
165
+
166
+ function reviewFacts(review) {
167
+ return reviewTexts.uploadFacts({ report: review.scan.report, maturity: review.scan.maturity, repos: review.scan.repos || [] });
168
+ }
169
+
170
+ function reviewSchema(c, facts) {
171
+ const properties = { decision: { type: 'string', title: c.question, enum: [c.yes, c.no] } };
172
+ if (facts.conversationExcerpts + facts.commandExcerpts > 0) properties.excerpts = { type: 'boolean', title: c.formExcerpts, default: true };
173
+ if (facts.repos.length > 1) facts.repos.forEach((name, i) => { properties[`repo_${i}`] = { type: 'boolean', title: c.formRepo(name), default: true }; });
174
+ facts.agents.forEach((name, i) => { properties[`agent_${i}`] = { type: 'boolean', title: c.formAgent(name), default: true }; });
175
+ return { type: 'object', properties, required: ['decision'] };
176
+ }
177
+
178
+ function exclusionsFromForm(review, facts, content) {
179
+ const repos = review.scan.repos || [];
180
+ return {
181
+ repos: repos.filter((_, i) => content[`repo_${i}`] === false),
182
+ agents: facts.agents.filter((_, i) => content[`agent_${i}`] === false),
183
+ excerpts: content.excerpts !== false,
184
+ };
185
+ }
186
+
187
+ function exclusionsFromChat(review, exclude) {
188
+ const e = exclude && typeof exclude === 'object' ? exclude : {};
189
+ const names = new Set((Array.isArray(e.repos) ? e.repos : []).map(String));
190
+ const known = review.scan.repos || [];
191
+ const repos = known.filter((r) => names.has(r) || names.has(reviewTexts.repoName(r)));
192
+ const agentNames = reviewFacts(review).agents;
193
+ const agents = Array.isArray(e.agents) ? e.agents.map(String) : [];
194
+ const unknown = [
195
+ ...[...names].filter((n) => !known.some((r) => r === n || reviewTexts.repoName(r) === n)),
196
+ ...agents.filter((a) => !agentNames.includes(a)),
197
+ ];
198
+ return { repos, agents, excerpts: e.excerpts !== true, unknown };
199
+ }
200
+
201
+ function declineUpload(review) {
202
+ const c = reviewCopy(review.lang);
203
+ review.outcome = { ok: false, reason: 'upload-declined', relayVerbatim: c.declined, message: `Nothing was sent. ${VERBATIM} Then carry on.` };
204
+ return review.outcome;
205
+ }
206
+
207
+ async function presentReview(review, ctx) {
208
+ const c = reviewCopy(review.lang);
209
+ if (!review.registration && sendThrottled()) {
210
+ return { ok: false, reason: 'throttled', relayVerbatim: c.throttled, message: `Nothing was sent. ${VERBATIM}` };
211
+ }
212
+ const facts = reviewFacts(review);
213
+ const texts = [reviewTexts.summaryText(review.lang, facts), reviewTexts.explanationText(review.lang)];
214
+ if (ctx && ctx.elicitation && typeof ctx.elicit === 'function') {
215
+ const reply = await ctx.elicit({ message: [...narrationOf(ctx), ...texts, c.question].join('\n\n'), requestedSchema: reviewSchema(c, facts) });
216
+ const content = reply && reply.action === 'accept' && reply.content ? reply.content : null;
217
+ if (content && content.decision === c.yes) return upload(review, exclusionsFromForm(review, facts, content));
218
+ if ((content && content.decision === c.no) || (reply && reply.action === 'decline')) return declineUpload(review);
219
+ }
220
+ review.askedInChat = true;
221
+ const { choiceMessage, ...shown } = chatChoice(c.question, [c.yes, c.no], [...texts, c.excludeHint]);
222
+ return {
223
+ ok: false,
224
+ reason: 'upload-approval-required',
225
+ ...shown,
226
+ excludable: { repos: facts.repos, agents: facts.agents, excerpts: facts.conversationExcerpts + facts.commandExcerpts > 0 },
227
+ message: `Nothing has been sent. ${choiceMessage} Then call ai_usage_upload with confirm:true only if they pick "${c.yes}" (adding exclude with the repos and agents they want left out, exactly as listed in excludable, and excerpts:true if they want no text excerpts), or confirm:false if they pick "${c.no}". Only the talent decides: never answer for them.`,
228
+ };
229
+ }
230
+
231
+ async function upload(review, exclude) {
232
+ const c = reviewCopy(review.lang);
233
+ const session = activeSession();
234
+ if (!session) {
235
+ return { ok: false, reason: 'no-session', message: 'Nothing was sent: there is no signed-in Shakers session. Ask the talent to sign in (social_signin_start or `shakers login`), then call ai_usage_upload again.' };
236
+ }
237
+ let scan = review.scan;
238
+ if (exclude.repos.length > 0) {
239
+ const kept = (scan.repos || []).filter((r) => !exclude.repos.includes(r));
240
+ if (kept.length === 0) {
241
+ review.outcome = { ok: false, reason: 'nothing-left', relayVerbatim: c.nothingLeft, message: `Nothing was sent. ${VERBATIM}` };
242
+ return review.outcome;
134
243
  }
135
- current.state = 'uploading';
136
- // Nobody awaits this promise: a local write error must end as a failed send, never as an
137
- // unhandled rejection that takes the MCP process down.
138
- let send;
139
- try {
140
- recordConsent('granted', session.email, { verified: true });
141
- send = await autoShare(scan.report, scan.maturity, { root: current.root, bypassThrottle: true });
142
- } catch (error) {
143
- send = { ok: false, reason: 'upload-failed', message: error && error.message ? error.message : String(error) };
244
+ const rescanned = await scanUsage(buildArgv({ root: review.root, repoScope: { mode: 'list', repos: kept }, lang: review.lang }));
245
+ if (!(rescanned.repos || []).every((r) => kept.includes(r))) {
246
+ return { ok: false, reason: 'scope-mismatch', relayVerbatim: c.uploadFailed, message: `Nothing was sent: the repositories left out could not be separated. ${VERBATIM}` };
144
247
  }
145
- current.state = send && send.ok ? 'sent' : 'failed';
146
- current.send = send;
147
- return send;
148
- }, () => ({ ok: false, skipped: true, reason: 'scan-failed' }));
149
- return current.uploading;
248
+ scan = rescanned;
249
+ }
250
+ const trimmed = reviewTexts.withoutExcluded(scan.report, { agents: exclude.agents, excerpts: exclude.excerpts });
251
+ let send;
252
+ try {
253
+ recordConsent('granted', session.email, { verified: true });
254
+ const cliCommandId = await logCommandRun({ command: 'mcp:ai_usage_upload:send' });
255
+ send = await autoShare(trimmed.report, trimmed.maturity, { root: review.root, bypassThrottle: review.registration, cliCommandId });
256
+ } catch (error) {
257
+ send = { ok: false, reason: 'upload-failed', message: error && error.message ? error.message : String(error) };
258
+ }
259
+ if (!send || !send.ok) {
260
+ return { ok: false, reason: (send && send.reason) || 'upload-failed', send, relayVerbatim: c.uploadFailed, message: `Nothing was saved in Shakers. ${VERBATIM}` };
261
+ }
262
+ const sent = reviewTexts.summaryText(review.lang, reviewTexts.uploadFacts({ report: trimmed.report, maturity: trimmed.maturity, repos: scan.repos || [] }), { sent: true });
263
+ const excluded = exclude.repos.length > 0 || exclude.agents.length > 0 || !exclude.excerpts;
264
+ const confirmation = excluded ? [c.uploaded, sent] : [c.uploaded];
265
+ review.outcome = { ok: true, uploaded: true, send, sentReport: trimmed, sent };
266
+ if (!review.registration) return Object.assign(review.outcome, await afterUpload(review, trimmed, confirmation));
267
+ // The sign-up shows the same result as outside it, at the same moment, on the next question: the review already
268
+ // explained the two axes and how to disagree, so the result does not repeat them.
269
+ const result = await afterUpload(review, trimmed, confirmation, { inventoryMs: signupInventoryWaitMs, profileMs: signupProfileWaitMs, explained: true });
270
+ queueNotice([result.relayVerbatim, result.text].filter(Boolean).join('\n\n'));
271
+ const { relayVerbatim: _shown, message: _rule, ...rest } = result;
272
+ return Object.assign(review.outcome, rest, {
273
+ message: `Uploaded. Say nothing about it: the result of the analysis, which the talent reads word for word, comes with the next question.${result.next ? ' Shakers is still evaluating part of it (normal, not an error): do not wait for it.' : ''}`,
274
+ });
150
275
  }
276
+
277
+ // The classified agents and the profile result are awaited together, so the answer stays under the client's cut.
278
+ async function afterUpload(review, { report, maturity }, confirmation, { inventoryMs = inlineWaitMs, profileMs = aiFluencyWaitMs, explained = false } = {}) {
279
+ const hasAgents = Array.isArray(report.agents) && report.agents.length > 0;
280
+ const classify = async () => {
281
+ if (!hasAgents) return null;
282
+ const session = loadAuthSession();
283
+ const endpoint = getUsageDiscoveredInventoryEndpoint();
284
+ if (sessionStatus(session) !== 'active' || !endpoint) return null;
285
+ return pollClassifiedInventory({ accessToken: session.accessToken, endpoint, requestDiscoveredInventory, capMs: inventoryMs });
286
+ };
287
+ const profile = async () => {
288
+ const usageSession = loadAuthSession();
289
+ if (sessionStatus(usageSession) !== 'active') return { status: 'unavailable', preview: null };
290
+ return resolveAiProfilePreview(
291
+ { session: usageSession, endpoint: getAiProfileEndpoint(), timeoutMs: profileMs },
292
+ {
293
+ refreshToken: async () => {
294
+ const fresh = await ensureFreshSession(process.env);
295
+ return fresh ? { accessToken: fresh.accessToken, hubAccessToken: fresh.hubAccessToken } : null;
296
+ },
297
+ },
298
+ );
299
+ };
300
+ const [polled, aiProfile] = await Promise.all([classify(), profile()]);
301
+ const enrichedAgents = polled && polled.status === 'ready' ? polled.agents : null;
302
+ const enrichment = !hasAgents ? 'none' : enrichedAgents ? 'ready' : 'pending';
303
+ const displayAgents = enrichedAgents
304
+ ? enrichedAgents.map((a) => ({ name: a.name, category: a.category, role: a.role }))
305
+ : (report.agents || []).map((a) => ({ name: a.name }));
306
+ const text = renderFullReportText(report, maturity, { status: aiProfile.status, preview: aiProfile.preview }, { lang: review.lang });
307
+ return {
308
+ report,
309
+ maturity,
310
+ enrichment,
311
+ agents: enrichedAgents,
312
+ text,
313
+ aiProfile,
314
+ relayVerbatim: [...confirmation, reviewTexts.resultText(review.lang, aiProfile.status === 'ready' ? aiProfile.preview : null, { explained })].join('\n\n'),
315
+ next: enrichment === 'pending' || aiProfile.status !== 'ready' ? 'ai_usage_result' : null,
316
+ message: `Uploaded. ${VERBATIM} Then show \`text\`, the full report, verbatim.${enrichment === 'pending' || aiProfile.status !== 'ready' ? ' Shakers is still evaluating (normal, not an error): call ai_usage_result in a few minutes for the result and its explanation.' : ''}`,
317
+ display: {
318
+ tier: { key: maturity.tierKey, name: maturity.tierName, setupLevel: maturity.name, score: maturity.score },
319
+ aiProfile: {
320
+ status: aiProfile.status,
321
+ setup: aiProfile.preview ? aiProfile.preview.matrix.setupCode : null,
322
+ usage: aiProfile.preview ? aiProfile.preview.matrix.usageLevel : null,
323
+ cell: aiProfile.preview ? aiProfile.preview.matrix.cell : null,
324
+ isAiNative: aiProfile.preview ? aiProfile.preview.matrix.isAiNative : false,
325
+ vision: aiProfile.preview ? aiProfile.preview.vision : null,
326
+ howIWork: aiProfile.preview ? aiProfile.preview.howIWork : null,
327
+ traction: aiProfile.preview ? aiProfile.preview.traction : null,
328
+ },
329
+ tierLegend: tierLadder(),
330
+ agents: displayAgents,
331
+ },
332
+ };
333
+ }
334
+
151
335
  function declined(lang) {
152
336
  recordConsent('denied');
153
337
  if (signupPhase() === 'account') {
@@ -157,27 +341,47 @@ function makeAiUsageTool(deps = {}) {
157
341
  return { ok: false, reason: 'consent-declined', relayVerbatim: signupCopy(lang).aiUsageSkipped, message: 'Nothing was scanned. Show relayVerbatim once, word for word.' };
158
342
  }
159
343
 
344
+ // The yes of a dialog in the sign-up: the disclaimers come in a second dialog, so they are read before anything is scanned.
345
+ async function confirmInDialog(ctx, c, texts) {
346
+ const again = await askChoice(ctx, { texts, question: c.aiUsageStart, options: [c.aiUsageYes, c.aiUsageSkip] });
347
+ return again.answer || null;
348
+ }
349
+
160
350
  async function askConsent(lang, ctx) {
161
351
  const c = signupCopy(lang);
162
- const texts = Object.values(usageDisclaimers(lang));
352
+ const disclaimers = Object.values(usageDisclaimers(lang));
353
+ // In the sign-up the question goes first and short; its disclaimers are read only after a yes, before any scan.
354
+ const signup = signupPhase() === 'account';
355
+ const question = signup ? c.aiUsageAsk : c.aiUsageQuestion;
356
+ const texts = signup ? [] : disclaimers;
357
+ const options = [c.aiUsageYes, c.aiUsageSkip];
163
358
  const asked = ctx && ctx.elicitation
164
- ? await askChoice(ctx, { texts, question: c.aiUsageQuestion, options: [c.aiUsageYes, c.aiUsageSkip] })
165
- : { chat: chatChoice(c.aiUsageQuestion, [c.aiUsageYes, c.aiUsageSkip], texts) };
166
- if (asked.answer === c.aiUsageSkip) {
359
+ ? await askChoice(ctx, { texts, question, options })
360
+ : { chat: chatChoice(question, options, texts) };
361
+ const answer = signup && asked.answer === c.aiUsageYes ? await confirmInDialog(ctx, c, disclaimers) : asked.answer;
362
+ if (answer === c.aiUsageSkip) {
167
363
  dialogConsent = 'denied';
168
364
  return declined(lang);
169
365
  }
170
- if (asked.answer === c.aiUsageYes) {
366
+ if (answer === c.aiUsageYes) {
171
367
  dialogConsent = 'granted';
172
368
  return { ok: true, reason: 'consent-granted', consent: { granted: true }, message: 'The talent said yes in the dialog: do not ask again. Call ai_usage with consent.granted:true (and their email) to run it.' };
173
369
  }
174
- return {
370
+ consentAsked = true;
371
+ const chat = asked.chat || chatChoice(question, options, texts);
372
+ const reply = {
175
373
  ok: false,
176
374
  reason: 'consent-required',
177
375
  ...(asked.dismissed ? { dismissed: asked.dismissed } : {}),
178
- ...asked.chat,
179
- message: `Nothing was scanned. ${asked.chat.choiceMessage} Then call ai_usage again with consent.granted:true only after they pick the first option, or consent.granted:false if they skip it.`,
376
+ ...forChat(chat),
377
+ message: `Nothing was scanned. ${chat.choiceMessage} Then call ai_usage again with consent.granted:true only after they pick the first option, or consent.granted:false if they skip it.`,
180
378
  };
379
+ if (!signup) return reply;
380
+ // A yes hands over the disclaimers, written before the scan starts; a skip hands over its fixed line, which the next question would carry.
381
+ return withNextSay(reply, [
382
+ handOver(CONSENT_QUESTION, { step: 'ai_usage', ifAnswer: [c.aiUsageYes], texts: disclaimers }),
383
+ handOver(CONSENT_QUESTION, { step: 'interview', ifAnswer: [c.aiUsageSkip], texts: [c.aiUsageSkipped] }),
384
+ ]);
181
385
  }
182
386
 
183
387
  async function handler(args = {}, ctx = {}) {
@@ -190,7 +394,8 @@ function makeAiUsageTool(deps = {}) {
190
394
  if (phase === 'waiting') {
191
395
  return { ok: false, reason: 'account-pending', message: 'Nothing was scanned. Ask about AI usage only once signup_status says the account exists.' };
192
396
  }
193
- const consent = args.consent || {};
397
+ const consent = phase === 'account' && !consentAsked && dialogConsent === null ? {} : args.consent || {};
398
+ if (typeof consent.granted === 'boolean') settle(CONSENT_QUESTION, consent.granted ? signupCopy(textLang).aiUsageYes : signupCopy(textLang).aiUsageSkip);
194
399
  if (consent.granted === false) return declined(textLang);
195
400
  if (consent.granted === true && dialogConsent === 'denied') {
196
401
  return { ok: false, reason: 'consent-declined', message: 'The talent skipped the AI-usage step in the dialog: nothing is scanned. Carry on without it. If they later ask for the scan themselves, call ai_usage without consent to ask them again.' };
@@ -225,177 +430,117 @@ function makeAiUsageTool(deps = {}) {
225
430
  const email = typeof consent.email === 'string' ? consent.email.trim() : '';
226
431
  const argv = buildArgv({ root, repoScope, lang: args.lang });
227
432
 
228
- // In the sign-up the scan never holds the conversation: it runs and uploads in the background.
229
433
  if (phase === 'account') {
230
- const current = startSignupScan(argv, root);
231
- uploadSignupScan();
434
+ if (pending && pending.registration && pending.outcome && !pending.outcome.uploaded) pending = null;
435
+ startSignupScan(argv, root, textLang);
232
436
  return {
233
437
  ok: true,
234
438
  background: true,
235
- send: current.send || { ok: false, skipped: true, reason: 'uploading' },
236
439
  scope: { mode, root },
237
- message: 'The analysis runs and uploads in the background; Shakers completes the profile with it in a few minutes. Do not wait for it and do not call ai_usage again: carry on with the interview offer.',
440
+ message: 'The analysis runs in the background and nothing is sent until the talent approves it. Say nothing about it. Next call ai_usage_upload (waitSeconds 30): it shows the talent what would be sent and asks. If it answers scan-running, carry on with the interview offer and call ai_usage_upload again right after the interview step.',
238
441
  };
239
442
  }
240
443
 
241
- const verifySession = loadAuthSession();
242
- const emailVerifiedByLogin =
243
- sessionStatus(verifySession) === 'active'
244
- && !!verifySession
245
- && isValidEmail(verifySession.email);
246
-
444
+ const session = activeSession();
247
445
  if (!isValidEmail(email)) {
248
446
  throw new Error('consent.granted is true but consent.email is missing or invalid — ask the talent for their email.');
249
447
  }
250
- if (emailVerifiedByLogin) {
251
- recordConsent('granted', verifySession.email, { verified: true });
448
+ if (session) {
449
+ recordConsent('granted', session.email, { verified: true });
252
450
  } else {
253
451
  recordConsent('granted', email, { verified: false });
254
452
  }
255
453
 
256
- const canSubmit = emailVerifiedByLogin;
257
- const submittedEmail = canSubmit ? verifySession.email : null;
258
-
259
- const { report, maturity } = await scanUsage(argv);
260
-
454
+ const scan = await scanUsage(argv);
261
455
  let savedLocally = false;
262
456
  try {
263
- persistFootprint({ root, report, maturity });
457
+ persistFootprint({ root, report: scan.report, maturity: scan.maturity });
264
458
  savedLocally = true;
265
459
  } catch {
266
460
  savedLocally = false;
267
461
  }
268
462
 
269
- const send = canSubmit
270
- ? await autoShare(report, maturity, { root })
271
- : { ok: false, skipped: true, reason: 'email-unverified' };
272
- const submitted = !!(send && send.ok);
463
+ if (!session) {
464
+ const text = renderFullReportText(scan.report, scan.maturity, { status: 'unavailable', preview: null }, { lang });
465
+ return {
466
+ ok: true,
467
+ uploaded: false,
468
+ send: { ok: false, skipped: true, reason: 'email-unverified' },
469
+ savedLocally,
470
+ text,
471
+ scope: { mode, root },
472
+ message: "Not sent: the talent is not signed in to Shakers with a verified email. Show `text` verbatim; to put it on their profile they sign in (`shakers login` or social_signin_start) and run ai_usage again. Nothing was sent to Shakers.",
473
+ };
474
+ }
475
+
476
+ const review = openReview({ scan, root, lang: textLang, registration: false });
477
+ const reviewed = await presentReview(review, ctx);
478
+ return { ...reviewed, savedLocally, scope: { mode, root }, consent: { granted: true, email: session.email } };
479
+ }
273
480
 
274
- const hasAgents = Array.isArray(report.agents) && report.agents.length > 0;
481
+ async function waitForSignupScan(waitMs) {
482
+ if (job.state !== 'scanning') return;
483
+ await Promise.race([job.scan.catch(() => null), new Promise((resolve) => { setTimeout(resolve, waitMs).unref(); })]);
484
+ }
275
485
 
276
- let enrichment = submitted && hasAgents ? 'pending' : 'none';
277
- let enrichedAgents = null;
278
- if (submitted && hasAgents) {
279
- const session = loadAuthSession();
280
- const endpoint = getUsageDiscoveredInventoryEndpoint();
281
- if (sessionStatus(session) === 'active' && endpoint) {
282
- const polled = await pollClassifiedInventory({
283
- accessToken: session.accessToken,
284
- endpoint,
285
- requestDiscoveredInventory,
286
- capMs: inlineWaitMs,
287
- });
288
- if (polled.status === 'ready') {
289
- enrichment = 'ready';
290
- enrichedAgents = polled.agents;
291
- }
486
+ async function uploadHandler(args = {}, ctx = {}) {
487
+ if (!pending && job) {
488
+ const waitMs = Math.min(MAX_WAIT_MS, Math.max(0, Number(args.waitSeconds) || 0) * 1000);
489
+ await waitForSignupScan(waitMs);
490
+ if (job.state === 'scanning') {
491
+ return { ok: false, reason: 'scan-running', message: 'The analysis is still running; nothing has been sent. Say nothing about it: carry on with the interview offer and call ai_usage_upload again right after the interview step, before list_my_roles.' };
292
492
  }
293
- }
294
-
295
- // "My work with AI" preview: only after a real submit this run.
296
- let aiProfile = { status: 'unavailable', preview: null };
297
- if (submitted) {
298
- const usageSession = loadAuthSession();
299
- if (sessionStatus(usageSession) === 'active') {
300
- aiProfile = await resolveAiProfilePreview(
301
- {
302
- session: usageSession,
303
- endpoint: getAiProfileEndpoint(),
304
- timeoutMs: aiFluencyWaitMs,
305
- },
306
- {
307
- refreshToken: async () => {
308
- const fresh = await ensureFreshSession(process.env);
309
- return fresh ? { accessToken: fresh.accessToken, hubAccessToken: fresh.hubAccessToken } : null;
310
- },
311
- },
312
- );
493
+ if (job.state === 'failed') {
494
+ return { ok: false, reason: 'scan-failed', message: 'The analysis could not finish; nothing was sent. Say nothing about it and carry on with the sign-up.' };
313
495
  }
496
+ openReview({ scan: await job.scan, root: job.root, lang: job.lang, registration: true });
314
497
  }
315
-
316
- const displayAgents = enrichedAgents
317
- ? enrichedAgents.map((a) => ({ name: a.name, category: a.category, role: a.role }))
318
- : (report.agents || []).map((a) => ({ name: a.name }));
319
-
320
- // Same full report the CLI terminal prints, as plain markdown (no ANSI), so a chat client shows the talent the same thing.
321
- const text = renderFullReportText(
322
- report,
323
- maturity,
324
- { status: aiProfile.status, preview: aiProfile.preview },
325
- { lang },
326
- );
327
-
328
- let message;
329
- if (submitted) {
330
- message =
331
- enrichment === 'ready'
332
- ? 'Classification complete — agent categories and descriptions are ready.'
333
- : enrichment === 'pending'
334
- ? 'Report submitted. Shakers is classifying the agents server-side (this is normal, not an error) — call ai_usage_result shortly to get their categories and descriptions.'
335
- : 'Report submitted.';
336
- } else if (send.reason === 'email-unverified') {
337
- message = "Not submitted — the talent's email is not verified. Ask them to log in / verify with the Shakers CLI (`shakers login`) first, then run ai_usage again; nothing was sent to Shakers.";
338
- } else if (send.skipped) {
339
- message = `Not submitted (${send.reason}). The report was kept on this machine only; nothing was sent to Shakers.`;
340
- } else {
341
- message = `Submit failed (${send.reason}). The report was kept on this machine only; retry later.`;
498
+ if (!pending) {
499
+ return { ok: false, reason: 'nothing-to-review', message: 'There is no analysis waiting for approval. Call ai_usage first.' };
342
500
  }
343
-
344
- return {
345
- report,
346
- maturity,
347
- send,
348
- savedLocally,
349
- enrichment,
350
- agents: enrichedAgents,
351
- // Plain-text "My work with AI" report (no ANSI), same content/layout as the CLI.
352
- text,
353
- // "My work with AI" preview: status ready | pending | unavailable.
354
- aiProfile,
355
- next: submitted && enrichment === 'pending' ? 'ai_usage_result' : null,
356
- message,
357
- display: {
358
- tier: {
359
- key: maturity.tierKey,
360
- name: maturity.tierName,
361
- setupLevel: maturity.name,
362
- score: maturity.score,
363
- },
364
- // Matrix (3x3) + vision (E) + howIWork (F) + traction; null until projected.
365
- aiProfile: {
366
- status: aiProfile.status,
367
- setup: aiProfile.preview ? aiProfile.preview.matrix.setupCode : null,
368
- usage: aiProfile.preview ? aiProfile.preview.matrix.usageLevel : null,
369
- cell: aiProfile.preview ? aiProfile.preview.matrix.cell : null,
370
- isAiNative: aiProfile.preview ? aiProfile.preview.matrix.isAiNative : false,
371
- vision: aiProfile.preview ? aiProfile.preview.vision : null,
372
- howIWork: aiProfile.preview ? aiProfile.preview.howIWork : null,
373
- traction: aiProfile.preview ? aiProfile.preview.traction : null,
374
- },
375
- tierLegend: tierLadder(),
376
- agents: displayAgents,
377
- },
378
- scope: {
379
- mode,
380
- root,
381
- },
382
- consent: { granted: true, email: canSubmit ? submittedEmail : email },
383
- };
501
+ if (pending.outcome) return pending.outcome.uploaded ? { ...pending.outcome, message: 'Already uploaded; it is not sent twice.' } : pending.outcome;
502
+ if (pending.askedInChat && args.confirm === false) return declineUpload(pending);
503
+ if (pending.askedInChat && args.confirm === true) {
504
+ const exclude = exclusionsFromChat(pending, args.exclude);
505
+ if (exclude.unknown.length > 0) {
506
+ const facts = reviewFacts(pending);
507
+ return { ok: false, reason: 'unknown-exclusion', unknown: exclude.unknown, excludable: { repos: facts.repos, agents: facts.agents }, message: `Nothing was sent: ${exclude.unknown.join(', ')} is not in excludable. Ask the talent which of the listed names they meant, then call ai_usage_upload again with those exact names.` };
508
+ }
509
+ return upload(pending, exclude);
510
+ }
511
+ return presentReview(pending, ctx);
384
512
  }
385
513
 
386
- return {
514
+ const usage = {
387
515
  name: 'ai_usage',
388
516
  title: 'Analyse how you work with AI',
389
- description: 'Asks you first and, only if you say yes, looks at which AI tools you use on this computer and the history of your code projects. With your yes it sends a summary with anonymised excerpts to your Shakers profile; your code and prompts never leave your computer.',
517
+ description: "Asks you first and, only if you say yes, looks at which AI tools you use on this computer, your conversations with AI assistants and the history of your code projects. It shows you exactly what would go to your Shakers profile (fragments of those conversations, sample commands and your agents' definitions, with anything that looks like a secret removed) and uploads it only if you approve it; an AI model from an external provider (OpenAI, Google or Anthropic) analyses that text for Shakers to compute your evaluation and then discards it, keeping only the result (levels, scores, counts and the names of your tools and agents).",
390
518
  guidance:
391
- "Measure how a Shakers talent uses AI in their work: scans this machine and repository for AI-tool usage (subagents, MCP servers, sessions, git activity), scores their AI-maturity tier (T0-T7), and, with the talent's consent, submits the report to Shakers so their profile reflects it. Runs entirely on the talent's own machine. Before any scan, call it without consent: it asks the AI-usage question with both disclaimers in the same block (a dialog, or `say` to print word for word); only an explicit yes allows consent.granted:true, and skipping is consent.granted:false. In the sign-up it is the step right after the account exists and before the interview: on yes it runs and uploads in the background, never wait for it. Submission also requires an active, email-verified Shakers session (via `shakers login` / registration OTP): without a verified session the report stays on this machine and nothing is sent — check the `send` field for the outcome. Always fast and deterministic: it runs no LLM on the machine. Agent descriptions and catalog classification are produced by Shakers server-side. This waits briefly (~30s) and, if classification finishes, returns the enriched agents inline; otherwise it returns enrichment:'pending' (normal progress, not an error) and you should call `ai_usage_result` to get them. The `text` field is a ready-to-show plain-text 'My work with AI' report (Markdown, no ANSI) mirroring the CLI terminal — render it verbatim to the talent; the matrix grid is inside a fenced code block, keep it fenced so it stays aligned. Use the `display` block for structured access: show the tier (with the tierLegend explaining T0-T7) and the agent list by NAME and category/role. IDs and codes (agent `code`, skillId, catalogId) are internal handles for the add_* tools only — never show raw IDs or codes to the talent.",
519
+ "Measure how a Shakers talent uses AI in their work: scans this machine and repository for AI-tool usage (subagents, MCP servers, sessions, git activity) and scores their AI-maturity tier (T0-T7). The scan runs on the talent's own machine; an approved upload sends derived signals plus secret-scrubbed text (conversation fragments, sample commands, agent definitions), which an AI model from an external provider (OpenAI, Google or Anthropic) analyses for Shakers and discards, keeping only the result. Before any scan, call it without consent: it asks the AI-usage question with both disclaimers in the same block (a dialog, or `say` to print word for word); only an explicit yes allows consent.granted:true, and skipping is consent.granted:false. The yes only scans: nothing is uploaded until the talent approves the review. Outside the sign-up, with a signed-in, email-verified session, the scan is followed by that review in the same call: a form when the client has one, otherwise `say` (what would be sent, what Shakers does with it, and the question) to print word for word, and then ai_usage_upload with the talent's answer. Without a verified session nothing can be sent and `text` is the report only. In the sign-up it is the step right after the account exists: on yes the scan runs in the background, then ai_usage_upload asks. After an upload, show relayVerbatim and `text` (the 'My work with AI' report, Markdown, no ANSI; keep the matrix grid fenced) verbatim; if next is ai_usage_result, call it in a few minutes. It runs no LLM on the machine. IDs and codes (agent `code`, skillId, catalogId) are internal handles for the add_* tools only — never show them.",
392
520
  inputSchema: AI_USAGE_INPUT_SCHEMA,
393
521
  handler,
394
522
  };
523
+
524
+ const uploadTool = {
525
+ name: 'ai_usage_upload',
526
+ title: 'Review and upload your AI analysis',
527
+ description: 'Shows you exactly what your AI analysis would send to your Shakers profile and what Shakers does with it, lets you leave out repositories, agents or text excerpts, and uploads it only if you approve.',
528
+ guidance:
529
+ "The only way an AI-usage analysis reaches Shakers. Call it without confirm: it shows the review (a form the talent fills in when the client has one; otherwise a question to ask as its message says, plus `excludable`). In the chat, call it again with confirm:true only when the talent picked the upload option, with exclude for what they asked to leave out, or confirm:false when they picked not to; never decide for them, and confirm counts only after the review was shown in the chat. In the sign-up pass waitSeconds 30: reason scan-running means carry on and call it again after the interview step. On an upload print relayVerbatim word for word. Nothing is sent twice.",
530
+ inputSchema: UPLOAD_INPUT_SCHEMA,
531
+ handler: uploadHandler,
532
+ };
533
+
534
+ return { usage, upload: uploadTool };
535
+ }
536
+
537
+ function makeAiUsageTool(deps = {}) {
538
+ return makeAiUsageTools(deps).usage;
395
539
  }
396
540
 
397
541
  module.exports = {
398
542
  makeAiUsageTool,
543
+ makeAiUsageTools,
399
544
  AI_USAGE_INPUT_SCHEMA,
400
545
  buildArgv,
401
546
  };