shakers-cli 0.17.0 → 0.19.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 (67) hide show
  1. package/README.md +15 -5
  2. package/bin/add-agent.js +12 -6
  3. package/bin/ai-usage.js +2 -1
  4. package/bin/apply.js +66 -0
  5. package/bin/command-audit-worker.js +19 -0
  6. package/bin/login.js +4 -8
  7. package/bin/mcp.js +32 -19
  8. package/bin/register.js +10 -29
  9. package/bin/shakers-cli.js +21 -12
  10. package/package.json +1 -1
  11. package/src/agents-client.js +1 -0
  12. package/src/ai-usage-review.js +5 -3
  13. package/src/apply-client.js +164 -0
  14. package/src/apply-flow.js +105 -0
  15. package/src/auth-client.js +7 -107
  16. package/src/auth-session-store.js +6 -12
  17. package/src/backend-request.js +1 -24
  18. package/src/cli-skill.js +77 -0
  19. package/src/command-audit.js +58 -5
  20. package/src/config.js +15 -16
  21. package/src/consent-flow.js +7 -2
  22. package/src/detect-repos.js +4 -8
  23. package/src/detected-agents.js +40 -0
  24. package/src/detectors.js +23 -0
  25. package/src/device-auth-store.js +2 -2
  26. package/src/device-login.js +11 -9
  27. package/src/env-paths.js +9 -1
  28. package/src/i18n.js +258 -88
  29. package/src/mcp-ai-usage-result-tool.js +6 -4
  30. package/src/mcp-ai-usage-tool.js +73 -47
  31. package/src/mcp-apply-tools.js +152 -0
  32. package/src/mcp-choice.js +171 -34
  33. package/src/mcp-detector.js +26 -2
  34. package/src/mcp-device-login-tools.js +40 -8
  35. package/src/mcp-install.js +15 -11
  36. package/src/mcp-instructions.js +43 -17
  37. package/src/mcp-onboarding-tools.js +90 -3
  38. package/src/mcp-prompts.js +6 -4
  39. package/src/mcp-register-tools.js +25 -14
  40. package/src/mcp-role-tools.js +35 -17
  41. package/src/mcp-server-name.js +7 -0
  42. package/src/mcp-server.js +35 -7
  43. package/src/mcp-signup-client.js +3 -6
  44. package/src/mcp-signup-flow.js +389 -0
  45. package/src/mcp-signup-tools.js +237 -126
  46. package/src/onboarding-flow.js +30 -125
  47. package/src/opencode-sessions.js +92 -0
  48. package/src/profile-agents.js +26 -0
  49. package/src/saved-positions-flow.js +3 -3
  50. package/src/scanner.js +104 -12
  51. package/src/session-gate.js +1 -4
  52. package/src/session-normalizers.js +114 -8
  53. package/src/session-refresh.js +5 -16
  54. package/src/session-scan.js +167 -22
  55. package/src/share-payload.js +4 -1
  56. package/src/share.js +2 -2
  57. package/src/signup-client.js +12 -39
  58. package/src/signup-copy.js +506 -68
  59. package/src/skill-install.js +102 -0
  60. package/src/start-add-agents.js +106 -3
  61. package/src/tier-engine.js +14 -2
  62. package/src/trust-narration.js +32 -0
  63. package/src/usage-run-steps.js +16 -4
  64. package/src/usage-scan.js +3 -0
  65. package/src/brand-svg.js +0 -9
  66. package/src/inter-fonts.js +0 -6
  67. package/src/mcp-auth-form.js +0 -484
@@ -3,7 +3,8 @@
3
3
  const FLOOR_METHOD = 'floor';
4
4
  const FLOOR_CODE = 'other-1';
5
5
  const POLL_INTERVAL_MS = 5000;
6
- const POLL_CAP_MS = 90000;
6
+ // With the last inventory request (its own timeout) this answers within ~45 s, under Claude Desktop's ~60 s cut.
7
+ const POLL_CAP_MS = 30000;
7
8
  const INVENTORY_TIMEOUT_MS = 15000;
8
9
 
9
10
  const RESULT_INPUT_SCHEMA = { type: 'object', properties: {} };
@@ -75,7 +76,8 @@ function makeAiUsageResultTool(deps = {}) {
75
76
  lang = () => require('./signup-language').signupLanguage(require('./i18n').detectFlowLang()),
76
77
  } = deps;
77
78
 
78
- async function handler() {
79
+ // `explained` comes only from the sign-up, whose review already explained the axes and how to disagree.
80
+ async function handler(args = {}) {
79
81
  const session = loadAuthSession();
80
82
  if (sessionStatus(session) !== 'active') {
81
83
  throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first.');
@@ -109,7 +111,7 @@ function makeAiUsageResultTool(deps = {}) {
109
111
  return {
110
112
  ...result,
111
113
  aiProfile,
112
- relayVerbatim: resultText(lang(), profileReady ? aiProfile.preview : null),
114
+ relayVerbatim: resultText(lang(), profileReady ? aiProfile.preview : null, { explained: args.explained === true }),
113
115
  message: `${RESULT_MESSAGE} ${result.status === 'ready' ? READY_MESSAGE : PENDING_MESSAGE}${profileReady ? '' : ` ${PROFILE_PENDING_MESSAGE}`}`,
114
116
  };
115
117
  }
@@ -119,7 +121,7 @@ function makeAiUsageResultTool(deps = {}) {
119
121
  title: 'Results of your AI analysis',
120
122
  description: 'Fetches from Shakers the result of the AI analysis you approved, what it means, and the description of each AI agent found. It reads nothing on this computer.',
121
123
  guidance:
122
- "Retrieve the result after an approved upload: relayVerbatim is the 'My work with AI' result with its levels and explanation in plain words, and how to ask for a human review; print it word for word. It also returns the enriched Shakers classification for the talent's agents. Shakers classifies agents and writes their descriptions server-side (no client LLM), so this reads the result: per agent { name, tools, model, category, role, level, code, whatItDoes }. Session-keyed, no arguments. Server-side classification can take a few minutes, so this does a bounded internal poll (~90s max, never a long-held call): returns { status: 'ready', agents } once every agent has been classified, or { status: 'pending', agents } while Shakers is still processing (call it again to keep waiting — 'pending' is normal progress, not an error). Present agents to the talent by NAME and category/role only; the `code` field is an internal handle for add_agent, never show it. Requires an active session.",
124
+ "Retrieve the result after an approved upload: relayVerbatim is the 'My work with AI' result with its levels and explanation in plain words, and how to ask for a human review; print it word for word. It also returns the enriched Shakers classification for the talent's agents. Shakers classifies agents and writes their descriptions server-side (no client LLM), so this reads the result: per agent { name, tools, model, category, role, level, code, whatItDoes }. Session-keyed, no arguments. Server-side classification can take a few minutes, so this does a bounded internal poll (~30 s, never a long-held call): returns { status: 'ready', agents } once every agent has been classified, or { status: 'pending', agents } while Shakers is still processing (call it again to keep waiting — 'pending' is normal progress, not an error). Present agents to the talent by NAME and category/role only; the `code` field is an internal handle for add_agent, never show it. Requires an active session.",
123
125
  inputSchema: RESULT_INPUT_SCHEMA,
124
126
  handler,
125
127
  };
@@ -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 {
@@ -76,6 +76,7 @@ function buildArgv({ root, repoScope, lang }) {
76
76
  }
77
77
 
78
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';
79
80
 
80
81
  const UPLOAD_INPUT_SCHEMA = {
81
82
  type: 'object',
@@ -103,6 +104,7 @@ function makeAiUsageTools(deps = {}) {
103
104
  const {
104
105
  scanUsage = (argv) => require('./usage-scan').scanUsage(argv),
105
106
  autoShare = require('./share').autoShare,
107
+ logCommandRun = require('./command-audit').logCommandRun,
106
108
  recordConsent = require('./share').recordConsent,
107
109
  sendThrottled = () => require('./share').isThrottled(require('./share').loadConsentState()),
108
110
  isValidEmail = require('./share').isValidEmail,
@@ -121,6 +123,8 @@ function makeAiUsageTools(deps = {}) {
121
123
  renderFullReportText = require('./render-report-text').renderFullReportText,
122
124
  detectFlowLang = require('./i18n').detectFlowLang,
123
125
  aiFluencyWaitMs = 30000,
126
+ signupInventoryWaitMs = 20000,
127
+ signupProfileWaitMs = 25000,
124
128
  signupPhase = () => require('./mcp-signup-tools').signupPhase(),
125
129
  usageDisclaimers = (lang) => require('./onboarding-flow').usageDisclaimers(lang),
126
130
  signupCopy = require('./signup-copy').signupCopy,
@@ -132,6 +136,8 @@ function makeAiUsageTools(deps = {}) {
132
136
  let job = null;
133
137
  // The talent's answer in the consent dialog, so a later call cannot contradict it.
134
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;
135
141
  let pending = null;
136
142
 
137
143
  function startSignupScan(argv, root, lang) {
@@ -206,20 +212,19 @@ function makeAiUsageTools(deps = {}) {
206
212
  const facts = reviewFacts(review);
207
213
  const texts = [reviewTexts.summaryText(review.lang, facts), reviewTexts.explanationText(review.lang)];
208
214
  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) });
215
+ const reply = await ctx.elicit({ message: [...narrationOf(ctx), ...texts, c.question].join('\n\n'), requestedSchema: reviewSchema(c, facts) });
210
216
  const content = reply && reply.action === 'accept' && reply.content ? reply.content : null;
211
217
  if (content && content.decision === c.yes) return upload(review, exclusionsFromForm(review, facts, content));
212
218
  if ((content && content.decision === c.no) || (reply && reply.action === 'decline')) return declineUpload(review);
213
219
  }
214
220
  review.askedInChat = true;
215
- const chat = chatChoice(c.question, [c.yes, c.no], [...texts, c.excludeHint]);
221
+ const { choiceMessage, ...shown } = chatChoice(c.question, [c.yes, c.no], [...texts, c.excludeHint]);
216
222
  return {
217
223
  ok: false,
218
224
  reason: 'upload-approval-required',
219
- say: chat.say,
220
- options: chat.options,
225
+ ...shown,
221
226
  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.`,
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.`,
223
228
  };
224
229
  }
225
230
 
@@ -246,7 +251,8 @@ function makeAiUsageTools(deps = {}) {
246
251
  let send;
247
252
  try {
248
253
  recordConsent('granted', session.email, { verified: true });
249
- send = await autoShare(trimmed.report, trimmed.maturity, { root: review.root, bypassThrottle: review.registration });
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 });
250
256
  } catch (error) {
251
257
  send = { ok: false, reason: 'upload-failed', message: error && error.message ? error.message : String(error) };
252
258
  }
@@ -257,36 +263,32 @@ function makeAiUsageTools(deps = {}) {
257
263
  const excluded = exclude.repos.length > 0 || exclude.agents.length > 0 || !exclude.excerpts;
258
264
  const confirmation = excluded ? [c.uploaded, sent] : [c.uploaded];
259
265
  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));
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
+ });
267
275
  }
268
276
 
269
- async function afterUpload(review, { report, maturity }, confirmation) {
270
- const c = reviewCopy(review.lang);
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 } = {}) {
271
279
  const hasAgents = Array.isArray(report.agents) && report.agents.length > 0;
272
- let enrichment = hasAgents ? 'pending' : 'none';
273
- let enrichedAgents = null;
274
- if (hasAgents) {
280
+ const classify = async () => {
281
+ if (!hasAgents) return null;
275
282
  const session = loadAuthSession();
276
283
  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 },
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 },
290
292
  {
291
293
  refreshToken: async () => {
292
294
  const fresh = await ensureFreshSession(process.env);
@@ -294,7 +296,10 @@ function makeAiUsageTools(deps = {}) {
294
296
  },
295
297
  },
296
298
  );
297
- }
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';
298
303
  const displayAgents = enrichedAgents
299
304
  ? enrichedAgents.map((a) => ({ name: a.name, category: a.category, role: a.role }))
300
305
  : (report.agents || []).map((a) => ({ name: a.name }));
@@ -306,9 +311,9 @@ function makeAiUsageTools(deps = {}) {
306
311
  agents: enrichedAgents,
307
312
  text,
308
313
  aiProfile,
309
- relayVerbatim: [...confirmation, reviewTexts.resultText(review.lang, aiProfile.status === 'ready' ? aiProfile.preview : null)].join('\n\n'),
314
+ relayVerbatim: [...confirmation, reviewTexts.resultText(review.lang, aiProfile.status === 'ready' ? aiProfile.preview : null, { explained })].join('\n\n'),
310
315
  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.' : ''}`,
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.' : ''}${hasAgents && !explained ? ' Then call add_detected_agents to offer adding the detected agents to the profile.' : ''}`,
312
317
  display: {
313
318
  tier: { key: maturity.tierKey, name: maturity.tierName, setupLevel: maturity.name, score: maturity.score },
314
319
  aiProfile: {
@@ -336,27 +341,47 @@ function makeAiUsageTools(deps = {}) {
336
341
  return { ok: false, reason: 'consent-declined', relayVerbatim: signupCopy(lang).aiUsageSkipped, message: 'Nothing was scanned. Show relayVerbatim once, word for word.' };
337
342
  }
338
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
+
339
350
  async function askConsent(lang, ctx) {
340
351
  const c = signupCopy(lang);
341
- 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];
342
358
  const asked = ctx && ctx.elicitation
343
- ? await askChoice(ctx, { texts, question: c.aiUsageQuestion, options: [c.aiUsageYes, c.aiUsageSkip] })
344
- : { chat: chatChoice(c.aiUsageQuestion, [c.aiUsageYes, c.aiUsageSkip], texts) };
345
- 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) {
346
363
  dialogConsent = 'denied';
347
364
  return declined(lang);
348
365
  }
349
- if (asked.answer === c.aiUsageYes) {
366
+ if (answer === c.aiUsageYes) {
350
367
  dialogConsent = 'granted';
351
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.' };
352
369
  }
353
- return {
370
+ consentAsked = true;
371
+ const chat = asked.chat || chatChoice(question, options, texts);
372
+ const reply = {
354
373
  ok: false,
355
374
  reason: 'consent-required',
356
375
  ...(asked.dismissed ? { dismissed: asked.dismissed } : {}),
357
- ...asked.chat,
358
- 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.`,
359
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
+ ]);
360
385
  }
361
386
 
362
387
  async function handler(args = {}, ctx = {}) {
@@ -369,7 +394,8 @@ function makeAiUsageTools(deps = {}) {
369
394
  if (phase === 'waiting') {
370
395
  return { ok: false, reason: 'account-pending', message: 'Nothing was scanned. Ask about AI usage only once signup_status says the account exists.' };
371
396
  }
372
- 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);
373
399
  if (consent.granted === false) return declined(textLang);
374
400
  if (consent.granted === true && dialogConsent === 'denied') {
375
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.' };
@@ -488,9 +514,9 @@ function makeAiUsageTools(deps = {}) {
488
514
  const usage = {
489
515
  name: 'ai_usage',
490
516
  title: 'Analyse how you work with AI',
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.',
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).",
492
518
  guidance:
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.",
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.",
494
520
  inputSchema: AI_USAGE_INPUT_SCHEMA,
495
521
  handler,
496
522
  };
@@ -500,7 +526,7 @@ function makeAiUsageTools(deps = {}) {
500
526
  title: 'Review and upload your AI analysis',
501
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.',
502
528
  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.",
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.",
504
530
  inputSchema: UPLOAD_INPUT_SCHEMA,
505
531
  handler: uploadHandler,
506
532
  };
@@ -0,0 +1,152 @@
1
+ 'use strict';
2
+
3
+ const ID_PROPERTY = { type: 'string', description: 'Position id (UUID or legacy Mongo id) from find_projects or show_project. An internal handle: never show it to the talent.' };
4
+
5
+ const PREPARE_APPLICATION_SCHEMA = {
6
+ type: 'object',
7
+ properties: { id: ID_PROPERTY },
8
+ required: ['id'],
9
+ };
10
+
11
+ const APPLY_TO_PROJECT_SCHEMA = {
12
+ type: 'object',
13
+ properties: {
14
+ id: ID_PROPERTY,
15
+ message: { type: 'string', description: "The talent's own message for the team, in their words, answering mainMotivationQuestion. Never write it for them." },
16
+ answers: {
17
+ type: 'array',
18
+ description: 'The answers the talent gave, one per question they answered; leave out the ones they skipped.',
19
+ items: {
20
+ type: 'object',
21
+ properties: {
22
+ question: { type: 'integer', description: 'The question number from prepare_application.' },
23
+ answer: { type: 'string', description: 'For OPEN, the talent\'s text. For MULTIPLE_CHOICE, exactly one of that question\'s options.' },
24
+ },
25
+ required: ['question', 'answer'],
26
+ },
27
+ },
28
+ confirm: { type: 'boolean', description: 'true ONLY after the talent explicitly said yes to the summary this tool returned for these exact arguments.' },
29
+ },
30
+ required: ['id'],
31
+ };
32
+
33
+ function parseAnswers(raw, questions) {
34
+ const answers = [];
35
+ const invalid = [];
36
+ for (const entry of Array.isArray(raw) ? raw : []) {
37
+ const q = entry && Number.isInteger(entry.question) ? questions[entry.question - 1] : null;
38
+ const said = entry && typeof entry.answer === 'string' ? entry.answer.trim() : '';
39
+ if (!q) { invalid.push({ question: entry && entry.question, reason: 'unknown-question' }); continue; }
40
+ if (!said) continue;
41
+ if (q.type === 'MULTIPLE_CHOICE') {
42
+ const option = q.options.find((o) => o.toLowerCase() === said.toLowerCase());
43
+ if (!option) { invalid.push({ question: entry.question, reason: 'not-an-option', options: q.options }); continue; }
44
+ answers.push({ question: q.text, answer: [option] });
45
+ } else {
46
+ answers.push({ question: q.text, answer: said });
47
+ }
48
+ }
49
+ return { answers, invalid };
50
+ }
51
+
52
+ function makeApplyTools(deps = {}) {
53
+ const {
54
+ loadApplication = (opts) => require('./apply-client').loadApplication({}, opts),
55
+ submitApplication = (opts) => require('./apply-client').submitApplication({}, opts),
56
+ applicationSummary = require('./apply-client').applicationSummary,
57
+ loadAuthSession = require('./auth-session-store').loadAuthSession,
58
+ sessionStatus = require('./auth-session-store').sessionStatus,
59
+ applyCopy = () => require('./signup-copy').applyCopy(require('./signup-language').signupLanguage(require('./i18n').detectFlowLang())),
60
+ } = deps;
61
+
62
+ const shownSummaries = new Set();
63
+
64
+ const requireLoginSession = () => {
65
+ const session = loadAuthSession();
66
+ if (sessionStatus(session) !== 'active') {
67
+ throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first (this tool is session-gated).');
68
+ }
69
+ return session;
70
+ };
71
+
72
+ async function readApplication(args) {
73
+ const session = requireLoginSession();
74
+ const positionId = typeof args.id === 'string' ? args.id.trim() : '';
75
+ if (!positionId) return { session, result: { ok: false, reason: 'no-id' } };
76
+ const app = await loadApplication({ hubAccessToken: session.hubAccessToken, positionId });
77
+ if (!app.ok) return { session, result: { ok: false, reason: app.reason } };
78
+ if (!app.open) return { session, result: { ok: false, reason: 'not-open', relayVerbatim: applyCopy().notOpen, message: 'Nothing was sent. Print relayVerbatim word for word.' } };
79
+ return { session, positionId, app };
80
+ }
81
+
82
+ async function prepareApplication(args = {}) {
83
+ const { result, app } = await readApplication(args);
84
+ if (result) return result;
85
+ const t = applyCopy();
86
+ return {
87
+ ok: true,
88
+ title: app.title,
89
+ company: app.company,
90
+ mainMotivationQuestion: app.mainMotivationQuestion || t.mainMotivationFallback,
91
+ questions: app.questions.map((q, i) => ({ number: i + 1, ...q })),
92
+ reviewedByShakers: !!app.doubleCheck && app.doubleCheck !== 'NO',
93
+ message: 'Nothing has been sent. Ask the talent the mainMotivationQuestion and then each question, one at a time, in their language; for MULTIPLE_CHOICE list its options numbered. Every answer is optional. Use only what the talent writes: never invent, complete or improve the message or the answers. Then call apply_to_project with them.',
94
+ };
95
+ }
96
+
97
+ async function applyToProject(args = {}) {
98
+ const { session, positionId, app, result } = await readApplication(args);
99
+ if (result) return result;
100
+ const t = applyCopy();
101
+ const { answers, invalid } = parseAnswers(args.answers, app.questions);
102
+ if (invalid.length) {
103
+ return { ok: false, reason: 'invalid-answers', invalid, message: 'Nothing was sent. Some answers do not match the questions from prepare_application: ask the talent again for those and call apply_to_project with the question number and, for MULTIPLE_CHOICE, one of its options exactly.' };
104
+ }
105
+ const message = typeof args.message === 'string' ? args.message.trim() : '';
106
+ const key = JSON.stringify([positionId, message, answers]);
107
+ if (args.confirm !== true || !shownSummaries.has(key)) {
108
+ shownSummaries.add(key);
109
+ return {
110
+ ok: false,
111
+ reason: 'confirmation-required',
112
+ say: [...applicationSummary(t, app, { message, answers }), t.confirmQuestion].join('\n\n'),
113
+ message: "Nothing has been sent. Print say word for word and wait for the talent. Call apply_to_project again with exactly the same arguments and confirm:true only if the talent explicitly says yes to sending it; if they want changes, collect them and call it without confirm. Only the talent decides: never confirm on their behalf, and never invent the message or the answers.",
114
+ };
115
+ }
116
+ shownSummaries.delete(key);
117
+ const mainMotivationQuestion = app.mainMotivationQuestion || t.mainMotivationFallback;
118
+ const res = await submitApplication({ hubAccessToken: session.hubAccessToken, positionId, message, answers, mainMotivationQuestion });
119
+ if (!res.ok) {
120
+ if (res.reason === 'already-applied') return { ok: false, reason: res.reason, applicationStatus: res.applicationStatus, relayVerbatim: t.alreadyApplied(res.applicationStatus), message: 'Print relayVerbatim word for word.' };
121
+ if (res.reason === 'position-unavailable') return { ok: false, reason: res.reason, relayVerbatim: t.notOpen, message: 'Print relayVerbatim word for word.' };
122
+ return { ok: false, reason: res.reason, message: 'The application was not sent. Tell the talent it could not be sent and that they can try again later.' };
123
+ }
124
+ return {
125
+ ok: true,
126
+ status: res.status,
127
+ relayVerbatim: res.status === 'PENDING' ? t.sentPending : t.sent,
128
+ message: 'Print relayVerbatim word for word.',
129
+ };
130
+ }
131
+
132
+ return [
133
+ {
134
+ name: 'prepare_application',
135
+ title: 'See what applying to a project asks',
136
+ description: 'Reads the questions you are asked when you apply to a Shakers project. It sends nothing.',
137
+ guidance: "Read-only first step to apply to a project/Position as an already-registered talent: returns its title, company, the main motivation question and the extra questions (OPEN or MULTIPLE_CHOICE with options), numbered. If reason is not-open, the talent already applied or the project is closed: print relayVerbatim and stop. Then ask the talent each question in the chat and call apply_to_project with their own words. Never invent or complete answers. Requires an active session and the position id.",
138
+ inputSchema: PREPARE_APPLICATION_SCHEMA,
139
+ handler: prepareApplication,
140
+ },
141
+ {
142
+ name: 'apply_to_project',
143
+ title: 'Apply to a project',
144
+ description: 'Shows you exactly what your application to a Shakers project will send and sends it only after you say yes.',
145
+ guidance: "Apply to a project/Position for the talent with their message and their answers to the prepare_application questions. Only the message and the answers are sent: never budget, phone, location or availability. Call it first without confirm: it returns `say`, the literal summary and the question, to print word for word. Only when the talent explicitly answers yes, call it again with exactly the same arguments and confirm:true; a confirm without that summary shown counts for nothing. Never confirm on the talent's behalf and never invent, complete or improve their message or answers. Print relayVerbatim word for word. Requires an active session and the position id, which is an internal handle never shown to the talent.",
146
+ inputSchema: APPLY_TO_PROJECT_SCHEMA,
147
+ handler: applyToProject,
148
+ },
149
+ ];
150
+ }
151
+
152
+ module.exports = { makeApplyTools, parseAnswers, PREPARE_APPLICATION_SCHEMA, APPLY_TO_PROJECT_SCHEMA };