shakers-cli 0.15.0 → 0.17.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 (132) hide show
  1. package/README.md +56 -1203
  2. package/bin/ai-usage.js +32 -58
  3. package/bin/login.js +10 -10
  4. package/bin/mcp.js +17 -12
  5. package/bin/mcpb.js +1 -1
  6. package/bin/onboarding.js +3 -2
  7. package/bin/register.js +19 -5
  8. package/bin/report-html.js +2 -2
  9. package/bin/shakers-cli.js +352 -0
  10. package/bin/shakers.js +20 -344
  11. package/bin/share.js +1 -1
  12. package/bin/superadmin.js +5 -5
  13. package/bin/version.js +3 -0
  14. package/package.json +12 -3
  15. package/src/account-marker.js +40 -0
  16. package/src/account-trace.js +23 -0
  17. package/src/agent-category.js +1 -1
  18. package/src/agent-evaluation.js +3 -3
  19. package/src/agent-org-chart.js +5 -5
  20. package/src/agent-synthesis.js +3 -3
  21. package/src/agent-usage.js +1 -1
  22. package/src/agents-client.js +4 -4
  23. package/src/ai-usage-review.js +101 -0
  24. package/src/ai.js +1 -1
  25. package/src/ansi.js +1 -1
  26. package/src/auth-client.js +18 -4
  27. package/src/auth-method-picker.js +1 -1
  28. package/src/auth-methods.js +1 -1
  29. package/src/auth-session-store.js +18 -8
  30. package/src/authorship-mismatch.js +1 -1
  31. package/src/authorship.js +2 -2
  32. package/src/automations-detector.js +1 -1
  33. package/src/backend-request.js +5 -3
  34. package/src/brand-ansi.js +1 -1
  35. package/src/browser-tools-detector.js +1 -1
  36. package/src/certify-disclaimer.js +1 -1
  37. package/src/certify-remediation-prompt.js +1 -1
  38. package/src/cli-args.js +2 -2
  39. package/src/command-audit.js +8 -5
  40. package/src/config-dir.js +1 -1
  41. package/src/config.js +114 -48
  42. package/src/consent-flow.js +7 -6
  43. package/src/consent-skip.js +1 -1
  44. package/src/detection-evidence.js +2 -2
  45. package/src/detection-noise.js +1 -1
  46. package/src/detectors.js +1 -1
  47. package/src/device-login.js +12 -1
  48. package/src/email-verification.js +3 -3
  49. package/src/env-paths.js +1 -1
  50. package/src/find-projects-client.js +12 -0
  51. package/src/find-projects-flow.js +1 -13
  52. package/src/git-activity.js +1 -1
  53. package/src/graph-certs.js +3 -3
  54. package/src/graph-scan.js +9 -9
  55. package/src/i18n.js +148 -138
  56. package/src/interactive-select.js +1 -1
  57. package/src/interview-client.js +6 -1
  58. package/src/interview-transcript.js +1 -1
  59. package/src/legal-notice.js +1 -1
  60. package/src/maturity.js +6 -6
  61. package/src/mcp-ai-usage-result-tool.js +34 -12
  62. package/src/mcp-ai-usage-tool.js +282 -163
  63. package/src/mcp-auth-form.js +7 -3
  64. package/src/mcp-detector.js +3 -3
  65. package/src/mcp-device-login-tools.js +2 -1
  66. package/src/mcp-find-projects-tools.js +1 -2
  67. package/src/mcp-install.js +279 -44
  68. package/src/mcp-instructions.js +1 -1
  69. package/src/mcp-onboarding-tools.js +4 -4
  70. package/src/mcp-prompts.js +4 -4
  71. package/src/mcp-register-tools.js +1 -0
  72. package/src/mcp-role-tools.js +24 -1
  73. package/src/mcp-server.js +5 -1
  74. package/src/mcp-signup-client.js +2 -2
  75. package/src/mcp-signup-tools.js +39 -9
  76. package/src/memory-structure-detector.js +1 -1
  77. package/src/model-call-record.js +2 -2
  78. package/src/onboarding-flow.js +63 -42
  79. package/src/onboarding-funnel.js +153 -20
  80. package/src/open-file.js +1 -1
  81. package/src/osc-link.js +1 -1
  82. package/src/portfolio-client.js +11 -1
  83. package/src/portfolio-git-info.js +1 -1
  84. package/src/profile-flow.js +1 -1
  85. package/src/profile-switch.js +5 -12
  86. package/src/render-certification.js +10 -10
  87. package/src/render-html.js +6 -6
  88. package/src/render-report-text.js +1 -1
  89. package/src/render-sheet.js +10 -10
  90. package/src/render-terminal.js +6 -6
  91. package/src/repo-scope.js +132 -0
  92. package/src/report-gating.js +5 -3
  93. package/src/report-store.js +13 -8
  94. package/src/roadmap-content.js +5 -5
  95. package/src/roadmap-personalization.js +3 -3
  96. package/src/roadmap-prompt.js +2 -2
  97. package/src/roles-flow.js +8 -5
  98. package/src/scan-exclusions.js +1 -1
  99. package/src/scanner.js +14 -14
  100. package/src/secret-prompt.js +1 -1
  101. package/src/session-gate.js +1 -1
  102. package/src/session-refresh.js +33 -3
  103. package/src/session-scan.js +1 -1
  104. package/src/shakers-wordmark.js +1 -1
  105. package/src/share-card.js +2 -2
  106. package/src/share-certification.js +4 -4
  107. package/src/share-consent-state.js +9 -9
  108. package/src/share-payload.js +7 -7
  109. package/src/share.js +7 -7
  110. package/src/signup-client.js +1 -1
  111. package/src/signup-copy.js +177 -2
  112. package/src/skills-client.js +1 -1
  113. package/src/start-add-agents.js +2 -2
  114. package/src/start-add-portfolio.js +3 -13
  115. package/src/start-add-skills.js +2 -2
  116. package/src/stdin-ask.js +16 -4
  117. package/src/strip-internal-comments.js +1 -1
  118. package/src/tech-detector.js +2 -2
  119. package/src/templates/report-sheet.html +11 -13
  120. package/src/terminal-agents.js +8 -8
  121. package/src/terminal-format.js +3 -3
  122. package/src/terminal-sections.js +11 -11
  123. package/src/tier-engine.js +3 -3
  124. package/src/tier-projection.js +2 -2
  125. package/src/transcript-turns.js +1 -1
  126. package/src/typewriter.js +1 -1
  127. package/src/usage-model-calls.js +6 -6
  128. package/src/usage-oneshot-commands.js +5 -5
  129. package/src/usage-repo-scope.js +11 -129
  130. package/src/usage-run-steps.js +82 -181
  131. package/src/usage-scan.js +235 -0
  132. package/src/work-streams.js +1 -1
@@ -75,11 +75,36 @@ 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
+
80
+ const UPLOAD_INPUT_SCHEMA = {
81
+ type: 'object',
82
+ properties: {
83
+ confirm: {
84
+ type: 'boolean',
85
+ 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.',
86
+ },
87
+ exclude: {
88
+ type: 'object',
89
+ description: 'What the talent asked to leave out, with confirm:true. Names exactly as listed in `excludable`.',
90
+ properties: {
91
+ repos: { type: 'array', items: { type: 'string' } },
92
+ agents: { type: 'array', items: { type: 'string' } },
93
+ excerpts: { type: 'boolean', description: 'true = send no text excerpts.' },
94
+ },
95
+ },
96
+ waitSeconds: { type: 'number', description: 'Sign-up only: how long to wait for the background analysis before answering (max 45).' },
97
+ },
98
+ };
99
+
100
+ const MAX_WAIT_MS = 45000;
101
+
102
+ function makeAiUsageTools(deps = {}) {
79
103
  const {
80
- scanUsage = (argv) => require('../bin/ai-usage').scanUsage(argv),
104
+ scanUsage = (argv) => require('./usage-scan').scanUsage(argv),
81
105
  autoShare = require('./share').autoShare,
82
106
  recordConsent = require('./share').recordConsent,
107
+ sendThrottled = () => require('./share').isThrottled(require('./share').loadConsentState()),
83
108
  isValidEmail = require('./share').isValidEmail,
84
109
  persistFootprint = require('./report-store').persistFootprint,
85
110
  cwdIsRepo = defaultCwdIsRepo,
@@ -90,64 +115,218 @@ function makeAiUsageTool(deps = {}) {
90
115
  pollClassifiedInventory = require('./mcp-ai-usage-result-tool').pollClassifiedInventory,
91
116
  tierLadder = require('./tier-engine').tierLadder,
92
117
  inlineWaitMs = 35000,
93
- // "My work with AI" preview, from the same hub endpoint the front paints.
94
118
  resolveAiProfilePreview = require('./ai-profile-preview').resolveAiProfilePreview,
95
119
  ensureFreshSession = require('./session-refresh').ensureFreshSession,
96
120
  getAiProfileEndpoint = require('./config').getAiProfileEndpoint,
97
- // Plain-text render of the full report (terminal sections + matrix/vision/howIWork), shared with the CLI (no ANSI).
98
121
  renderFullReportText = require('./render-report-text').renderFullReportText,
99
122
  detectFlowLang = require('./i18n').detectFlowLang,
100
123
  aiFluencyWaitMs = 30000,
101
124
  signupPhase = () => require('./mcp-signup-tools').signupPhase(),
102
125
  usageDisclaimers = (lang) => require('./onboarding-flow').usageDisclaimers(lang),
103
126
  signupCopy = require('./signup-copy').signupCopy,
127
+ reviewCopy = require('./signup-copy').reviewCopy,
104
128
  signupLanguage = require('./signup-language').signupLanguage,
129
+ reviewTexts = require('./ai-usage-review'),
105
130
  } = deps;
106
131
 
107
- // The sign-up's one scan: started on the talent's yes once the account exists, uploaded in the background.
108
132
  let job = null;
109
133
  // The talent's answer in the consent dialog, so a later call cannot contradict it.
110
134
  let dialogConsent = null;
135
+ let pending = null;
111
136
 
112
- function startSignupScan(argv, root) {
113
- if (job) return job;
114
- job = { state: 'scanning', send: null, uploading: null };
137
+ function startSignupScan(argv, root, lang) {
138
+ if (job && job.state !== 'failed') return job;
139
+ job = { state: 'scanning', root, lang };
115
140
  // performScan is synchronous: start it after this answer has gone out.
116
141
  job.scan = new Promise((resolve) => { setImmediate(resolve); }).then(() => scanUsage(argv)).then((scan) => {
117
142
  try { persistFootprint({ root, report: scan.report, maturity: scan.maturity }); } catch { /* local copy is best-effort */ }
143
+ job.state = 'scanned';
118
144
  return scan;
119
145
  });
120
146
  job.scan.catch(() => { job.state = 'failed'; });
121
- job.root = root;
122
147
  return job;
123
148
  }
124
149
 
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' };
150
+ function activeSession() {
151
+ const session = loadAuthSession();
152
+ return sessionStatus(session) === 'active' && session && isValidEmail(session.email) ? session : null;
153
+ }
154
+
155
+ function openReview({ scan, root, lang, registration }) {
156
+ pending = { scan, root, lang, registration, askedInChat: false, outcome: null };
157
+ return pending;
158
+ }
159
+
160
+ function reviewFacts(review) {
161
+ return reviewTexts.uploadFacts({ report: review.scan.report, maturity: review.scan.maturity, repos: review.scan.repos || [] });
162
+ }
163
+
164
+ function reviewSchema(c, facts) {
165
+ const properties = { decision: { type: 'string', title: c.question, enum: [c.yes, c.no] } };
166
+ if (facts.conversationExcerpts + facts.commandExcerpts > 0) properties.excerpts = { type: 'boolean', title: c.formExcerpts, default: true };
167
+ if (facts.repos.length > 1) facts.repos.forEach((name, i) => { properties[`repo_${i}`] = { type: 'boolean', title: c.formRepo(name), default: true }; });
168
+ facts.agents.forEach((name, i) => { properties[`agent_${i}`] = { type: 'boolean', title: c.formAgent(name), default: true }; });
169
+ return { type: 'object', properties, required: ['decision'] };
170
+ }
171
+
172
+ function exclusionsFromForm(review, facts, content) {
173
+ const repos = review.scan.repos || [];
174
+ return {
175
+ repos: repos.filter((_, i) => content[`repo_${i}`] === false),
176
+ agents: facts.agents.filter((_, i) => content[`agent_${i}`] === false),
177
+ excerpts: content.excerpts !== false,
178
+ };
179
+ }
180
+
181
+ function exclusionsFromChat(review, exclude) {
182
+ const e = exclude && typeof exclude === 'object' ? exclude : {};
183
+ const names = new Set((Array.isArray(e.repos) ? e.repos : []).map(String));
184
+ const known = review.scan.repos || [];
185
+ const repos = known.filter((r) => names.has(r) || names.has(reviewTexts.repoName(r)));
186
+ const agentNames = reviewFacts(review).agents;
187
+ const agents = Array.isArray(e.agents) ? e.agents.map(String) : [];
188
+ const unknown = [
189
+ ...[...names].filter((n) => !known.some((r) => r === n || reviewTexts.repoName(r) === n)),
190
+ ...agents.filter((a) => !agentNames.includes(a)),
191
+ ];
192
+ return { repos, agents, excerpts: e.excerpts !== true, unknown };
193
+ }
194
+
195
+ function declineUpload(review) {
196
+ const c = reviewCopy(review.lang);
197
+ review.outcome = { ok: false, reason: 'upload-declined', relayVerbatim: c.declined, message: `Nothing was sent. ${VERBATIM} Then carry on.` };
198
+ return review.outcome;
199
+ }
200
+
201
+ async function presentReview(review, ctx) {
202
+ const c = reviewCopy(review.lang);
203
+ if (!review.registration && sendThrottled()) {
204
+ return { ok: false, reason: 'throttled', relayVerbatim: c.throttled, message: `Nothing was sent. ${VERBATIM}` };
205
+ }
206
+ const facts = reviewFacts(review);
207
+ const texts = [reviewTexts.summaryText(review.lang, facts), reviewTexts.explanationText(review.lang)];
208
+ if (ctx && ctx.elicitation && typeof ctx.elicit === 'function') {
209
+ const reply = await ctx.elicit({ message: [...texts, c.question].join('\n\n'), requestedSchema: reviewSchema(c, facts) });
210
+ const content = reply && reply.action === 'accept' && reply.content ? reply.content : null;
211
+ if (content && content.decision === c.yes) return upload(review, exclusionsFromForm(review, facts, content));
212
+ if ((content && content.decision === c.no) || (reply && reply.action === 'decline')) return declineUpload(review);
213
+ }
214
+ review.askedInChat = true;
215
+ const chat = chatChoice(c.question, [c.yes, c.no], [...texts, c.excludeHint]);
216
+ return {
217
+ ok: false,
218
+ reason: 'upload-approval-required',
219
+ say: chat.say,
220
+ options: chat.options,
221
+ excludable: { repos: facts.repos, agents: facts.agents, excerpts: facts.conversationExcerpts + facts.commandExcerpts > 0 },
222
+ message: `Nothing has been sent. ${chat.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.`,
223
+ };
224
+ }
225
+
226
+ async function upload(review, exclude) {
227
+ const c = reviewCopy(review.lang);
228
+ const session = activeSession();
229
+ if (!session) {
230
+ 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.' };
231
+ }
232
+ let scan = review.scan;
233
+ if (exclude.repos.length > 0) {
234
+ const kept = (scan.repos || []).filter((r) => !exclude.repos.includes(r));
235
+ if (kept.length === 0) {
236
+ review.outcome = { ok: false, reason: 'nothing-left', relayVerbatim: c.nothingLeft, message: `Nothing was sent. ${VERBATIM}` };
237
+ return review.outcome;
134
238
  }
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) };
239
+ const rescanned = await scanUsage(buildArgv({ root: review.root, repoScope: { mode: 'list', repos: kept }, lang: review.lang }));
240
+ if (!(rescanned.repos || []).every((r) => kept.includes(r))) {
241
+ return { ok: false, reason: 'scope-mismatch', relayVerbatim: c.uploadFailed, message: `Nothing was sent: the repositories left out could not be separated. ${VERBATIM}` };
144
242
  }
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;
243
+ scan = rescanned;
244
+ }
245
+ const trimmed = reviewTexts.withoutExcluded(scan.report, { agents: exclude.agents, excerpts: exclude.excerpts });
246
+ let send;
247
+ try {
248
+ recordConsent('granted', session.email, { verified: true });
249
+ send = await autoShare(trimmed.report, trimmed.maturity, { root: review.root, bypassThrottle: review.registration });
250
+ } catch (error) {
251
+ send = { ok: false, reason: 'upload-failed', message: error && error.message ? error.message : String(error) };
252
+ }
253
+ if (!send || !send.ok) {
254
+ return { ok: false, reason: (send && send.reason) || 'upload-failed', send, relayVerbatim: c.uploadFailed, message: `Nothing was saved in Shakers. ${VERBATIM}` };
255
+ }
256
+ const sent = reviewTexts.summaryText(review.lang, reviewTexts.uploadFacts({ report: trimmed.report, maturity: trimmed.maturity, repos: scan.repos || [] }), { sent: true });
257
+ const excluded = exclude.repos.length > 0 || exclude.agents.length > 0 || !exclude.excerpts;
258
+ const confirmation = excluded ? [c.uploaded, sent] : [c.uploaded];
259
+ review.outcome = { ok: true, uploaded: true, send, sentReport: trimmed, sent };
260
+ if (review.registration) {
261
+ review.outcome.relayVerbatim = [...confirmation, c.afterwards].join('\n\n');
262
+ review.outcome.next = 'ai_usage_result';
263
+ review.outcome.message = `${VERBATIM} Then carry on with the sign-up; do not wait for the result.`;
264
+ return review.outcome;
265
+ }
266
+ return Object.assign(review.outcome, await afterUpload(review, trimmed, confirmation));
150
267
  }
268
+
269
+ async function afterUpload(review, { report, maturity }, confirmation) {
270
+ const c = reviewCopy(review.lang);
271
+ const hasAgents = Array.isArray(report.agents) && report.agents.length > 0;
272
+ let enrichment = hasAgents ? 'pending' : 'none';
273
+ let enrichedAgents = null;
274
+ if (hasAgents) {
275
+ const session = loadAuthSession();
276
+ const endpoint = getUsageDiscoveredInventoryEndpoint();
277
+ if (sessionStatus(session) === 'active' && endpoint) {
278
+ const polled = await pollClassifiedInventory({ accessToken: session.accessToken, endpoint, requestDiscoveredInventory, capMs: inlineWaitMs });
279
+ if (polled.status === 'ready') {
280
+ enrichment = 'ready';
281
+ enrichedAgents = polled.agents;
282
+ }
283
+ }
284
+ }
285
+ let aiProfile = { status: 'unavailable', preview: null };
286
+ const usageSession = loadAuthSession();
287
+ if (sessionStatus(usageSession) === 'active') {
288
+ aiProfile = await resolveAiProfilePreview(
289
+ { session: usageSession, endpoint: getAiProfileEndpoint(), timeoutMs: aiFluencyWaitMs },
290
+ {
291
+ refreshToken: async () => {
292
+ const fresh = await ensureFreshSession(process.env);
293
+ return fresh ? { accessToken: fresh.accessToken, hubAccessToken: fresh.hubAccessToken } : null;
294
+ },
295
+ },
296
+ );
297
+ }
298
+ const displayAgents = enrichedAgents
299
+ ? enrichedAgents.map((a) => ({ name: a.name, category: a.category, role: a.role }))
300
+ : (report.agents || []).map((a) => ({ name: a.name }));
301
+ const text = renderFullReportText(report, maturity, { status: aiProfile.status, preview: aiProfile.preview }, { lang: review.lang });
302
+ return {
303
+ report,
304
+ maturity,
305
+ enrichment,
306
+ agents: enrichedAgents,
307
+ text,
308
+ aiProfile,
309
+ relayVerbatim: [...confirmation, reviewTexts.resultText(review.lang, aiProfile.status === 'ready' ? aiProfile.preview : null)].join('\n\n'),
310
+ next: enrichment === 'pending' || aiProfile.status !== 'ready' ? 'ai_usage_result' : null,
311
+ 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.' : ''}`,
312
+ display: {
313
+ tier: { key: maturity.tierKey, name: maturity.tierName, setupLevel: maturity.name, score: maturity.score },
314
+ aiProfile: {
315
+ status: aiProfile.status,
316
+ setup: aiProfile.preview ? aiProfile.preview.matrix.setupCode : null,
317
+ usage: aiProfile.preview ? aiProfile.preview.matrix.usageLevel : null,
318
+ cell: aiProfile.preview ? aiProfile.preview.matrix.cell : null,
319
+ isAiNative: aiProfile.preview ? aiProfile.preview.matrix.isAiNative : false,
320
+ vision: aiProfile.preview ? aiProfile.preview.vision : null,
321
+ howIWork: aiProfile.preview ? aiProfile.preview.howIWork : null,
322
+ traction: aiProfile.preview ? aiProfile.preview.traction : null,
323
+ },
324
+ tierLegend: tierLadder(),
325
+ agents: displayAgents,
326
+ },
327
+ };
328
+ }
329
+
151
330
  function declined(lang) {
152
331
  recordConsent('denied');
153
332
  if (signupPhase() === 'account') {
@@ -225,177 +404,117 @@ function makeAiUsageTool(deps = {}) {
225
404
  const email = typeof consent.email === 'string' ? consent.email.trim() : '';
226
405
  const argv = buildArgv({ root, repoScope, lang: args.lang });
227
406
 
228
- // In the sign-up the scan never holds the conversation: it runs and uploads in the background.
229
407
  if (phase === 'account') {
230
- const current = startSignupScan(argv, root);
231
- uploadSignupScan();
408
+ if (pending && pending.registration && pending.outcome && !pending.outcome.uploaded) pending = null;
409
+ startSignupScan(argv, root, textLang);
232
410
  return {
233
411
  ok: true,
234
412
  background: true,
235
- send: current.send || { ok: false, skipped: true, reason: 'uploading' },
236
413
  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.',
414
+ 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
415
  };
239
416
  }
240
417
 
241
- const verifySession = loadAuthSession();
242
- const emailVerifiedByLogin =
243
- sessionStatus(verifySession) === 'active'
244
- && !!verifySession
245
- && isValidEmail(verifySession.email);
246
-
418
+ const session = activeSession();
247
419
  if (!isValidEmail(email)) {
248
420
  throw new Error('consent.granted is true but consent.email is missing or invalid — ask the talent for their email.');
249
421
  }
250
- if (emailVerifiedByLogin) {
251
- recordConsent('granted', verifySession.email, { verified: true });
422
+ if (session) {
423
+ recordConsent('granted', session.email, { verified: true });
252
424
  } else {
253
425
  recordConsent('granted', email, { verified: false });
254
426
  }
255
427
 
256
- const canSubmit = emailVerifiedByLogin;
257
- const submittedEmail = canSubmit ? verifySession.email : null;
258
-
259
- const { report, maturity } = await scanUsage(argv);
260
-
428
+ const scan = await scanUsage(argv);
261
429
  let savedLocally = false;
262
430
  try {
263
- persistFootprint({ root, report, maturity });
431
+ persistFootprint({ root, report: scan.report, maturity: scan.maturity });
264
432
  savedLocally = true;
265
433
  } catch {
266
434
  savedLocally = false;
267
435
  }
268
436
 
269
- const send = canSubmit
270
- ? await autoShare(report, maturity, { root })
271
- : { ok: false, skipped: true, reason: 'email-unverified' };
272
- const submitted = !!(send && send.ok);
437
+ if (!session) {
438
+ const text = renderFullReportText(scan.report, scan.maturity, { status: 'unavailable', preview: null }, { lang });
439
+ return {
440
+ ok: true,
441
+ uploaded: false,
442
+ send: { ok: false, skipped: true, reason: 'email-unverified' },
443
+ savedLocally,
444
+ text,
445
+ scope: { mode, root },
446
+ 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.",
447
+ };
448
+ }
273
449
 
274
- const hasAgents = Array.isArray(report.agents) && report.agents.length > 0;
450
+ const review = openReview({ scan, root, lang: textLang, registration: false });
451
+ const reviewed = await presentReview(review, ctx);
452
+ return { ...reviewed, savedLocally, scope: { mode, root }, consent: { granted: true, email: session.email } };
453
+ }
275
454
 
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
- }
292
- }
293
- }
455
+ async function waitForSignupScan(waitMs) {
456
+ if (job.state !== 'scanning') return;
457
+ await Promise.race([job.scan.catch(() => null), new Promise((resolve) => { setTimeout(resolve, waitMs).unref(); })]);
458
+ }
294
459
 
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
- );
460
+ async function uploadHandler(args = {}, ctx = {}) {
461
+ if (!pending && job) {
462
+ const waitMs = Math.min(MAX_WAIT_MS, Math.max(0, Number(args.waitSeconds) || 0) * 1000);
463
+ await waitForSignupScan(waitMs);
464
+ if (job.state === 'scanning') {
465
+ 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.' };
466
+ }
467
+ if (job.state === 'failed') {
468
+ 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
469
  }
470
+ openReview({ scan: await job.scan, root: job.root, lang: job.lang, registration: true });
314
471
  }
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.`;
472
+ if (!pending) {
473
+ return { ok: false, reason: 'nothing-to-review', message: 'There is no analysis waiting for approval. Call ai_usage first.' };
342
474
  }
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
- };
475
+ if (pending.outcome) return pending.outcome.uploaded ? { ...pending.outcome, message: 'Already uploaded; it is not sent twice.' } : pending.outcome;
476
+ if (pending.askedInChat && args.confirm === false) return declineUpload(pending);
477
+ if (pending.askedInChat && args.confirm === true) {
478
+ const exclude = exclusionsFromChat(pending, args.exclude);
479
+ if (exclude.unknown.length > 0) {
480
+ const facts = reviewFacts(pending);
481
+ 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.` };
482
+ }
483
+ return upload(pending, exclude);
484
+ }
485
+ return presentReview(pending, ctx);
384
486
  }
385
487
 
386
- return {
488
+ const usage = {
387
489
  name: 'ai_usage',
388
490
  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.',
491
+ 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. Then it shows you exactly what would go to your Shakers profile and uploads it only if you approve it; your code and prompts never leave your computer.',
390
492
  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.",
493
+ "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). 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. 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
494
  inputSchema: AI_USAGE_INPUT_SCHEMA,
393
495
  handler,
394
496
  };
497
+
498
+ const uploadTool = {
499
+ name: 'ai_usage_upload',
500
+ title: 'Review and upload your AI analysis',
501
+ 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.',
502
+ guidance:
503
+ "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 `say` to print word for word, 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.",
504
+ inputSchema: UPLOAD_INPUT_SCHEMA,
505
+ handler: uploadHandler,
506
+ };
507
+
508
+ return { usage, upload: uploadTool };
509
+ }
510
+
511
+ function makeAiUsageTool(deps = {}) {
512
+ return makeAiUsageTools(deps).usage;
395
513
  }
396
514
 
397
515
  module.exports = {
398
516
  makeAiUsageTool,
517
+ makeAiUsageTools,
399
518
  AI_USAGE_INPUT_SCHEMA,
400
519
  buildArgv,
401
520
  };
@@ -243,8 +243,9 @@ const LINKEDIN_IN = '<svg class="g" viewBox="0 0 24 24" aria-hidden="true">'
243
243
 
244
244
  // Each window offers these methods, in this order. The sign-up window has no LinkedIn: only Google carries the claim code there.
245
245
  const WINDOW_METHODS = { login: ['google', 'linkedin', 'email'], register: ['google', 'email'] };
246
+ const FORM_MISTAKES = new Set(['legal-not-shown', 'missing-fields', 'password-mismatch', 'weak-password']);
246
247
 
247
- // The method-choice screen (talents-ai-score) — the FIRST screen of the loopback form when more than one method is offered for this flow.
248
+ // The method-choice screen — the FIRST screen of the loopback form when more than one method is offered for this flow.
248
249
  function methodChoiceHtml(mode, lang, methods) {
249
250
  const c = copy(lang);
250
251
  const title = mode === 'register' ? c.chooseTitleRegister : c.chooseTitleLogin;
@@ -364,12 +365,13 @@ function runLoopbackAuth(
364
365
  const showMethodScreen = !method && methods.length > 1;
365
366
  return new Promise((resolve) => {
366
367
  let settled = false;
368
+ let failedAttempts = 0;
367
369
  const finish = (result) => {
368
370
  if (settled) return;
369
371
  settled = true;
370
372
  clearTimeout(timer);
371
373
  try { server.close(); } catch { /* already closing */ }
372
- resolve(result);
374
+ resolve(result && result.ok && mode === 'register' ? { ...result, attempts: failedAttempts + 1 } : result);
373
375
  };
374
376
 
375
377
  // Any page in the talent's browser can reach a loopback port, and a rebound DNS name can read it: only this
@@ -426,6 +428,7 @@ function runLoopbackAuth(
426
428
  if (req.method === 'GET' && url.pathname === '/google' && methods.includes('google') && typeof onGoogle === 'function') {
427
429
  const target = await onGoogle();
428
430
  if (!target || !target.ok || !target.url) {
431
+ failedAttempts += 1;
429
432
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
430
433
  res.end(formHtml(mode, lang, { ...formOpts, error: (target && target.reason) || 'google-unavailable' }));
431
434
  return;
@@ -455,6 +458,7 @@ function runLoopbackAuth(
455
458
  res.end(successHtml(lang));
456
459
  finish(outcome);
457
460
  } else {
461
+ if (outcome.reason && !FORM_MISTAKES.has(outcome.reason)) failedAttempts += 1;
458
462
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
459
463
  res.end(outcome.existing
460
464
  ? existingAccountHtml(lang, { email: outcome.email, error: outcome.reason })
@@ -477,4 +481,4 @@ function runLoopbackAuth(
477
481
  });
478
482
  }
479
483
 
480
- module.exports = { runLoopbackAuth, doAuth, loginFormHtml, registerFormHtml, existingAccountHtml, methodChoiceHtml, socialHandoffHtml, DEFAULT_TIMEOUT_MS };
484
+ module.exports = { runLoopbackAuth, WINDOW_METHODS, doAuth, loginFormHtml, registerFormHtml, existingAccountHtml, methodChoiceHtml, socialHandoffHtml, DEFAULT_TIMEOUT_MS };
@@ -4,7 +4,7 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { getHomeDir } = require('./env-paths');
6
6
 
7
- // KEYWORD -> { SERVICE LABEL, CATEGORY } (issue 110).
7
+ // KEYWORD -> { SERVICE LABEL, CATEGORY }.
8
8
  const SERVICE_CATALOG = [
9
9
  // --- data ---
10
10
  { kw: 'postgresql', label: 'Postgres', category: 'data' },
@@ -87,7 +87,7 @@ function serviceForMcpServerName(name) {
87
87
  return hit && hit.label ? hit.label : null;
88
88
  }
89
89
 
90
- // THE single inventory of MCP config locations (issue 112).
90
+ // THE single inventory of MCP config locations.
91
91
  const MCP_CONFIG_LOCATIONS = [
92
92
  // --- Claude Code (already read before 112) ---
93
93
  { tool: 'claude-code', scope: 'project', keys: ['mcpServers'], confidence: 'high', file: (root) => path.join(root, '.mcp.json') },
@@ -181,7 +181,7 @@ function detectMcpServers(root) {
181
181
  // 'project' wins when a server is declared in both: the most specific
182
182
  // scope is the honest label for "this repo uses it".
183
183
  scope: entry.scopes.has('project') ? 'project' : 'home',
184
- // Issue 110: the PRODUCT, derived from the name only. `null` when the
184
+ // The PRODUCT, derived from the name only. `null` when the
185
185
  // name carries none — a state, not a hole.
186
186
  service: serviceForMcpServerName(name),
187
187
  };
@@ -24,6 +24,7 @@ function makeDeviceLoginTools(deps = {}) {
24
24
  pollDeviceToken = require('./device-login').pollDeviceToken,
25
25
  finalizeDeviceSession = require('./device-login').finalizeDeviceSession,
26
26
  appendProviderParam = require('./device-login').appendProviderParam,
27
+ deviceSessionRecord = require('./device-login').deviceSessionRecord,
27
28
  savePendingDevice = require('./device-auth-store').savePendingDevice,
28
29
  loadPendingDevice = require('./device-auth-store').loadPendingDevice,
29
30
  clearPendingDevice = require('./device-auth-store').clearPendingDevice,
@@ -144,7 +145,7 @@ function makeDeviceLoginTools(deps = {}) {
144
145
  return { ok: false, status: 'error', reason: fin.reason };
145
146
  }
146
147
  try {
147
- saveAuthSession({ accessToken: fin.token, hubAccessToken: fin.token, email: res.email || pending.email || null });
148
+ saveAuthSession(deviceSessionRecord({ token: fin.token, sessionToken: res.accessToken, sessionExpiresIn: res.expiresIn, email: res.email || pending.email || null }));
148
149
  } catch {
149
150
  clearPendingDevice();
150
151
  return { ok: false, status: 'error', reason: 'session-persist-failed' };