shakers-cli 0.0.0-stage → 0.14.1

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 (237) hide show
  1. package/LICENSE +59 -0
  2. package/README.md +1233 -2
  3. package/bin/add-agent.js +88 -0
  4. package/bin/add-project.js +88 -0
  5. package/bin/add-role.js +61 -0
  6. package/bin/add-skill.js +88 -0
  7. package/bin/ai-usage.js +99 -0
  8. package/bin/alma.js +99 -0
  9. package/bin/applications.js +69 -0
  10. package/bin/availability.js +70 -0
  11. package/bin/certifications.js +69 -0
  12. package/bin/certify.js +73 -0
  13. package/bin/change-role.js +61 -0
  14. package/bin/config.js +124 -0
  15. package/bin/experiences.js +69 -0
  16. package/bin/find-projects.js +89 -0
  17. package/bin/invitations.js +69 -0
  18. package/bin/lang.js +70 -0
  19. package/bin/login.js +216 -0
  20. package/bin/mcp.js +124 -0
  21. package/bin/mcpb.js +5 -0
  22. package/bin/me.js +69 -0
  23. package/bin/onboarding.js +134 -0
  24. package/bin/portfolios.js +69 -0
  25. package/bin/rate.js +70 -0
  26. package/bin/register.js +337 -0
  27. package/bin/report-html.js +134 -0
  28. package/bin/save.js +77 -0
  29. package/bin/shakers.js +363 -0
  30. package/bin/share.js +72 -0
  31. package/bin/show-project.js +72 -0
  32. package/bin/socials.js +70 -0
  33. package/bin/superadmin.js +351 -0
  34. package/bin/update-check-worker.js +70 -0
  35. package/bin/update.js +111 -0
  36. package/package.json +40 -4
  37. package/src/agent-category.js +13 -0
  38. package/src/agent-evaluation.js +177 -0
  39. package/src/agent-org-chart.js +355 -0
  40. package/src/agent-synthesis.js +132 -0
  41. package/src/agent-usage.js +118 -0
  42. package/src/agents-client.js +165 -0
  43. package/src/ai-fluency-cell.js +72 -0
  44. package/src/ai-profile-client.js +45 -0
  45. package/src/ai-profile-preview.js +154 -0
  46. package/src/ai.js +66 -0
  47. package/src/alma-client.js +123 -0
  48. package/src/alma-flow.js +32 -0
  49. package/src/alma-repo-context.js +47 -0
  50. package/src/alma-shell.js +161 -0
  51. package/src/ansi.js +62 -0
  52. package/src/applications-client.js +79 -0
  53. package/src/applications-flow.js +58 -0
  54. package/src/auth-client.js +144 -0
  55. package/src/auth-device-client.js +118 -0
  56. package/src/auth-method-picker.js +44 -0
  57. package/src/auth-methods.js +35 -0
  58. package/src/auth-session-store.js +215 -0
  59. package/src/authorship-mismatch.js +62 -0
  60. package/src/authorship.js +204 -0
  61. package/src/automations-detector.js +170 -0
  62. package/src/availability-client.js +93 -0
  63. package/src/availability-flow.js +124 -0
  64. package/src/backend-request.js +191 -0
  65. package/src/blind-approval-extract.js +48 -0
  66. package/src/brand-ansi.js +14 -0
  67. package/src/brand-svg.js +9 -0
  68. package/src/browser-tools-detector.js +21 -0
  69. package/src/build-next-level.js +56 -0
  70. package/src/certifications-client.js +89 -0
  71. package/src/certifications-flow.js +75 -0
  72. package/src/certify-dimension-client.js +163 -0
  73. package/src/certify-dimension-flow.js +135 -0
  74. package/src/certify-disclaimer.js +51 -0
  75. package/src/certify-remediation-prompt.js +23 -0
  76. package/src/cli-args.js +83 -0
  77. package/src/command-audit.js +105 -0
  78. package/src/config-dir.js +120 -0
  79. package/src/config.js +877 -0
  80. package/src/consent-flow.js +115 -0
  81. package/src/consent-skip.js +27 -0
  82. package/src/cv-file.js +62 -0
  83. package/src/cv-text.js +92 -0
  84. package/src/decisions-extract.js +43 -0
  85. package/src/detect-repos.js +145 -0
  86. package/src/detected-tools.js +15 -0
  87. package/src/detection-evidence.js +105 -0
  88. package/src/detection-noise.js +132 -0
  89. package/src/detectors.js +221 -0
  90. package/src/device-auth-store.js +100 -0
  91. package/src/device-login.js +120 -0
  92. package/src/email-verification.js +152 -0
  93. package/src/env-paths.js +30 -0
  94. package/src/find-projects-cache.js +89 -0
  95. package/src/find-projects-client.js +148 -0
  96. package/src/find-projects-flow.js +164 -0
  97. package/src/git-activity.js +124 -0
  98. package/src/git-log-prefetch.js +53 -0
  99. package/src/graph-certs.js +58 -0
  100. package/src/graph-generator.js +230 -0
  101. package/src/graph-scan.js +239 -0
  102. package/src/i18n.js +3341 -0
  103. package/src/inter-fonts.js +6 -0
  104. package/src/interactive-select.js +183 -0
  105. package/src/interview-client.js +226 -0
  106. package/src/interview-transcript.js +56 -0
  107. package/src/inventory-client.js +90 -0
  108. package/src/invitations-client.js +66 -0
  109. package/src/invitations-flow.js +50 -0
  110. package/src/lang-client.js +147 -0
  111. package/src/lang-codes.js +14 -0
  112. package/src/lang-flow.js +85 -0
  113. package/src/legal-notice.js +53 -0
  114. package/src/locale.js +72 -0
  115. package/src/markdown-ansi.js +50 -0
  116. package/src/maturity.js +111 -0
  117. package/src/mcp-ai-usage-result-tool.js +111 -0
  118. package/src/mcp-ai-usage-tool.js +401 -0
  119. package/src/mcp-alma-tools.js +95 -0
  120. package/src/mcp-applications-tools.js +44 -0
  121. package/src/mcp-auth-form.js +480 -0
  122. package/src/mcp-availability-tools.js +45 -0
  123. package/src/mcp-certifications-tools.js +44 -0
  124. package/src/mcp-certify-dimension-tools.js +66 -0
  125. package/src/mcp-choice.js +95 -0
  126. package/src/mcp-detector.js +223 -0
  127. package/src/mcp-device-login-tools.js +177 -0
  128. package/src/mcp-find-projects-tools.js +69 -0
  129. package/src/mcp-install.js +275 -0
  130. package/src/mcp-instructions.js +27 -0
  131. package/src/mcp-invitations-tools.js +44 -0
  132. package/src/mcp-lang-tools.js +41 -0
  133. package/src/mcp-onboarding-tools.js +379 -0
  134. package/src/mcp-portfolios-tools.js +50 -0
  135. package/src/mcp-profile-tools.js +48 -0
  136. package/src/mcp-prompts.js +73 -0
  137. package/src/mcp-rate-tools.js +44 -0
  138. package/src/mcp-register-tools.js +441 -0
  139. package/src/mcp-report-tools.js +105 -0
  140. package/src/mcp-role-tools.js +372 -0
  141. package/src/mcp-saved-positions-tools.js +59 -0
  142. package/src/mcp-server.js +290 -0
  143. package/src/mcp-show-project-tools.js +52 -0
  144. package/src/mcp-signup-client.js +118 -0
  145. package/src/mcp-signup-tools.js +1171 -0
  146. package/src/mcp-socials-tools.js +41 -0
  147. package/src/memory-structure-detector.js +108 -0
  148. package/src/model-call-record.js +198 -0
  149. package/src/onboarding-availability.js +26 -0
  150. package/src/onboarding-client.js +374 -0
  151. package/src/onboarding-flow.js +1162 -0
  152. package/src/onboarding-funnel.js +272 -0
  153. package/src/onboarding-livekit-client.js +102 -0
  154. package/src/onboarding-status-client.js +57 -0
  155. package/src/open-file.js +32 -0
  156. package/src/osc-link.js +16 -0
  157. package/src/portfolio-client.js +164 -0
  158. package/src/portfolio-git-info.js +61 -0
  159. package/src/portfolios-client.js +85 -0
  160. package/src/portfolios-flow.js +79 -0
  161. package/src/profile-client.js +133 -0
  162. package/src/profile-flow.js +115 -0
  163. package/src/profile-switch.js +46 -0
  164. package/src/prompt-select.js +66 -0
  165. package/src/rate-client.js +81 -0
  166. package/src/rate-flow.js +137 -0
  167. package/src/register-context.js +145 -0
  168. package/src/render-ai-fluency.js +266 -0
  169. package/src/render-certification.js +177 -0
  170. package/src/render-html.js +140 -0
  171. package/src/render-report-text.js +27 -0
  172. package/src/render-sheet.js +261 -0
  173. package/src/render-terminal.js +109 -0
  174. package/src/render-usage-report-text.js +155 -0
  175. package/src/repo-context.js +215 -0
  176. package/src/report-gating.js +49 -0
  177. package/src/report-store.js +226 -0
  178. package/src/report-theme.js +177 -0
  179. package/src/report-view-client.js +50 -0
  180. package/src/reveal-output.js +29 -0
  181. package/src/roadmap-content.js +453 -0
  182. package/src/roadmap-personalization.js +132 -0
  183. package/src/roadmap-prompt.js +154 -0
  184. package/src/roles-client.js +112 -0
  185. package/src/roles-flow.js +192 -0
  186. package/src/sanitize-network-text.js +36 -0
  187. package/src/saved-positions-client.js +71 -0
  188. package/src/saved-positions-flow.js +50 -0
  189. package/src/scan-exclusions.js +50 -0
  190. package/src/scanner.js +624 -0
  191. package/src/scope-signals.js +148 -0
  192. package/src/secret-prompt.js +72 -0
  193. package/src/session-gate.js +30 -0
  194. package/src/session-normalizers.js +174 -0
  195. package/src/session-refresh.js +51 -0
  196. package/src/session-scan.js +475 -0
  197. package/src/session-signals.js +36 -0
  198. package/src/shakers-wordmark.js +17 -0
  199. package/src/share-card.js +362 -0
  200. package/src/share-certification.js +111 -0
  201. package/src/share-consent-state.js +157 -0
  202. package/src/share-payload.js +300 -0
  203. package/src/share.js +185 -0
  204. package/src/show-project-client.js +58 -0
  205. package/src/show-project-flow.js +119 -0
  206. package/src/signup-client.js +103 -0
  207. package/src/signup-copy.js +450 -0
  208. package/src/signup-language.js +21 -0
  209. package/src/skills-client.js +102 -0
  210. package/src/socials-client.js +91 -0
  211. package/src/socials-flow.js +83 -0
  212. package/src/specificity-extract.js +41 -0
  213. package/src/start-add-agents.js +306 -0
  214. package/src/start-add-portfolio.js +388 -0
  215. package/src/start-add-skills.js +260 -0
  216. package/src/stdin-ask.js +129 -0
  217. package/src/steering-extract.js +43 -0
  218. package/src/strip-internal-comments.js +45 -0
  219. package/src/tech-detector.js +295 -0
  220. package/src/templates/report-sheet.html +572 -0
  221. package/src/terminal-agents.js +207 -0
  222. package/src/terminal-format.js +85 -0
  223. package/src/terminal-progress.js +106 -0
  224. package/src/terminal-sections.js +219 -0
  225. package/src/tier-analysis.js +126 -0
  226. package/src/tier-engine.js +114 -0
  227. package/src/tier-projection.js +117 -0
  228. package/src/transcript-turns.js +49 -0
  229. package/src/translations-client.js +84 -0
  230. package/src/typewriter.js +154 -0
  231. package/src/update-flow.js +223 -0
  232. package/src/update-notifier.js +113 -0
  233. package/src/usage-model-calls.js +119 -0
  234. package/src/usage-oneshot-commands.js +180 -0
  235. package/src/usage-repo-scope.js +162 -0
  236. package/src/usage-run-steps.js +354 -0
  237. package/src/work-streams.js +79 -0
@@ -0,0 +1,372 @@
1
+ 'use strict';
2
+
3
+ // MCP role tools: list, add and recommend roles, and set the main role (the sign-up's last step before open_web).
4
+
5
+ const { askChoice, askAfterTexts, chatChoice, optionFor, SAY_MESSAGE } = require('./mcp-choice');
6
+ const { signupCopy, renderRole } = require('./signup-copy');
7
+ const { signupLanguage } = require('./signup-language');
8
+
9
+ const EMPTY_SCHEMA = { type: 'object', properties: {} };
10
+
11
+ // Same bounds as the web's growth-direction step: certs finalizes an interview within ~90 s, polled every 2 s.
12
+ const FINALIZATION_WAIT_LIMIT_MS = 90000;
13
+ const POLL_MS = 2000;
14
+ const MAX_WAIT_SECONDS = 25;
15
+ const PENDING_FINALIZATION = new Set(['COLLECTING', 'PROCESSING']);
16
+ // Alma's signal from the interview first, then the CV import, as the web stacks them.
17
+ const SOURCE_ORDER = { ONBOARDING_INTERVIEW: 0, IMPORT: 1 };
18
+ // A catalogue this short is offered whole; a longer one only by the roles that match what the talent says.
19
+ const MAX_ROLE_OPTIONS = 5;
20
+
21
+ const fold = (text) => String(text || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
22
+
23
+ const wordsOf = (text) => fold(text).split(/[^a-z0-9+#.]+/).filter((w) => w.length >= 2);
24
+
25
+ // Words that name no specialty: a role sharing only these with the talent does not match.
26
+ const GENERIC_ROLE_WORDS = new Set(['developer', 'developers', 'dev', 'engineer', 'engineering', 'software', 'programmer', 'specialist', 'expert', 'consultant', 'senior', 'junior', 'lead', 'freelance',
27
+ 'desarrollador', 'desarrolladora', 'programador', 'programadora', 'ingeniero', 'ingeniera', 'especialista', 'experto', 'experta', 'consultor', 'consultora',
28
+ 'sviluppatore', 'sviluppatrice', 'programmatore', 'ingegnere', 'esperto', 'consulente', 'desenvolvedor', 'desenvolvedora', 'engenheiro', 'engenheira']);
29
+
30
+ // Catalogue roles by relevance: the exact name first, then shared role words, then stack words; a word many roles share (engineer) counts less, and only a specific word qualifies a role.
31
+ function rankRoles(roles, said, skills) {
32
+ const names = roles.map((r) => wordsOf(r.name));
33
+ const df = new Map();
34
+ for (const words of names) for (const w of new Set(words)) df.set(w, (df.get(w) || 0) + 1);
35
+ const weight = (words) => words.reduce((sum, w) => sum + 1 / df.get(w), 0);
36
+ const asked = new Set(wordsOf(said));
37
+ const stack = new Set(wordsOf(skills));
38
+ const exact = fold(said).trim();
39
+ const specific = (words) => words.filter((w) => !GENERIC_ROLE_WORDS.has(w));
40
+ const shown = (role, words) => specific(words).map((w) => role.name.split(/[^A-Za-z0-9+#.]+/).find((t) => fold(t) === w) || w);
41
+ return roles
42
+ .map((role, i) => {
43
+ const byRole = names[i].filter((w) => asked.has(w));
44
+ const byStack = names[i].filter((w) => stack.has(w) && !asked.has(w));
45
+ const isExact = !!exact && fold(role.name) === exact;
46
+ const score = (isExact ? 100 : 0) + weight(byRole) + weight(byStack) / 2;
47
+ const matches = isExact || specific([...byRole, ...byStack]).length > 0;
48
+ return { role, score, matches, byRole: shown(role, byRole), byStack: shown(role, byStack) };
49
+ })
50
+ .sort((a, b) => b.score - a.score);
51
+ }
52
+
53
+ const LIST_MY_ROLES_SCHEMA = {
54
+ type: 'object',
55
+ properties: {
56
+ waitSeconds: { type: 'number', description: `In the sign-up, wait up to this many seconds (max ${MAX_WAIT_SECONDS}) for the interview evaluation or the import to recommend roles.` },
57
+ },
58
+ };
59
+
60
+ const CLUSTER_SCHEMA = {
61
+ type: 'object',
62
+ properties: {
63
+ clusterId: { type: 'string', description: 'The role clusterId from list_available_roles / list_my_roles. Internal handle — refer to the role by its name to the talent, never show the id.' },
64
+ },
65
+ required: ['clusterId'],
66
+ };
67
+
68
+ const SET_MAIN_ROLE_SCHEMA = {
69
+ type: 'object',
70
+ properties: {
71
+ skills: { type: 'string', description: 'With nothing recommended: the talent\'s main technologies from the CV or draft, comma separated. They rank the catalogue roles offered.' },
72
+ evidence: {
73
+ type: 'object',
74
+ additionalProperties: { type: 'string' },
75
+ description: 'In the sign-up, for each recommended role (by clusterId from list_my_roles): ONE short concrete fact from the CV, the interview or the AI-usage analysis that shows why it fits (e.g. "6 years with Spark and Airflow"), in the talent\'s language. It is shown under the role.',
76
+ },
77
+ answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
78
+ clusterId: { type: 'string', description: 'The role the talent already picked, by clusterId from list_my_roles or from the roles this tool returned. Omit it in the sign-up: the tool asks the talent which recommended role is their main one (a dialog, or options it returns), with the catalogue as the last option.' },
79
+ catalogue: { type: 'boolean', description: 'true when the role comes from the catalogue: with clusterId it adds the role before setting it; without clusterId it asks from the catalogue.' },
80
+ },
81
+ };
82
+
83
+ // One option per role name: the talent picks by name, so two rows with the same name would be one choice.
84
+ function byName(roles) {
85
+ const seen = new Map();
86
+ for (const r of roles) if (r && r.name && !seen.has(r.name)) seen.set(r.name, r);
87
+ return [...seen.values()];
88
+ }
89
+
90
+ function makeRoleTools(deps = {}) {
91
+ const lang = () => signupLanguage(deps.lang || require('./i18n').detectFlowLang());
92
+ const copy = () => signupCopy(lang());
93
+ const {
94
+ getAvailableRolesEndpoint = require('./config').getAvailableRolesEndpoint,
95
+ getSetMainRoleEndpoint = require('./config').getSetMainRoleEndpoint,
96
+ getAssignedClustersEndpoint = require('./config').getAssignedClustersEndpoint,
97
+ getMyCertificationsEndpoint = require('./config').getMyCertificationsEndpoint,
98
+ requestAvailableRoles = require('./roles-client').requestAvailableRoles,
99
+ requestAssignedClusters = require('./roles-client').requestAssignedClusters,
100
+ requestAddGrowthRole = require('./roles-client').requestAddGrowthRole,
101
+ requestSetMainRole = require('./roles-client').requestSetMainRole,
102
+ requestMeCertifications = require('./certifications-client').requestMeCertifications,
103
+ loadAuthSession = require('./auth-session-store').loadAuthSession,
104
+ sessionStatus = require('./auth-session-store').sessionStatus,
105
+ dedupeByCluster = require('./roles-flow').dedupeByCluster,
106
+ getOnboardingInterviewsEndpoint = require('./config').getOnboardingInterviewsEndpoint,
107
+ requestInterviewFinalization = require('./onboarding-status-client').requestInterviewFinalization,
108
+ completedOnboardingInterview = () => require('./mcp-register-tools').completedOnboardingInterview(),
109
+ countOpenPositions = (opts) => require('./find-projects-client').countOpenPositions({}, opts),
110
+ sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
111
+ now = () => Date.now(),
112
+ funnel = require('./onboarding-funnel').NOOP_FUNNEL,
113
+ } = deps;
114
+
115
+ const requireSession = () => {
116
+ const session = loadAuthSession();
117
+ if (sessionStatus(session) !== 'active') {
118
+ throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first.');
119
+ }
120
+ return session;
121
+ };
122
+
123
+ const shape = (r) => ({
124
+ clusterId: r.clusterId,
125
+ name: r.name,
126
+ category: r.category,
127
+ ...(r.proposalKind ? { proposalKind: r.proposalKind } : {}),
128
+ ...(r.type ? { type: r.type } : {}),
129
+ ...(r.source ? { source: r.source } : {}),
130
+ });
131
+
132
+ async function listAvailableRoles() {
133
+ const session = requireSession();
134
+ const res = await requestAvailableRoles({ hubAccessToken: session.hubAccessToken }, { endpoint: getAvailableRolesEndpoint() });
135
+ if (!res.ok) return { ok: false, reason: res.reason };
136
+ return { ok: true, roles: dedupeByCluster(res.roles).map(shape) };
137
+ }
138
+
139
+ async function addRole(args = {}) {
140
+ const session = requireSession();
141
+ if (!args.clusterId) return { ok: false, reason: 'no-cluster' };
142
+ const res = await requestAddGrowthRole({ clusterId: args.clusterId, hubAccessToken: session.hubAccessToken }, { endpoint: getAssignedClustersEndpoint() });
143
+ if (!res.ok) {
144
+ if (res.reason === 'already-assigned') return { ok: true, clusterId: args.clusterId, alreadyAssigned: true };
145
+ return { ok: false, reason: res.reason };
146
+ }
147
+ return { ok: true, clusterId: args.clusterId };
148
+ }
149
+
150
+ // Whether certs is still evaluating the interview this process completed, within the web's wait.
151
+ async function evaluationPending(session) {
152
+ const interview = completedOnboardingInterview();
153
+ if (!interview || now() - interview.completedAt >= FINALIZATION_WAIT_LIMIT_MS) return false;
154
+ const res = await requestInterviewFinalization({ interviewId: interview.interviewId, accessToken: session.accessToken }, { base: getOnboardingInterviewsEndpoint() });
155
+ return res.ok && PENDING_FINALIZATION.has(res.status);
156
+ }
157
+
158
+ async function readRoles(hubAccessToken) {
159
+ const held = [];
160
+ let mainClusterId = null;
161
+ const cert = await requestMeCertifications({ hubAccessToken }, { endpoint: getMyCertificationsEndpoint() });
162
+ if (cert.ok) {
163
+ if (cert.mainRole && cert.mainRole.clusterId) { held.push(cert.mainRole); mainClusterId = cert.mainRole.clusterId; }
164
+ for (const g of cert.growingInto || []) held.push(g);
165
+ }
166
+ const assigned = await requestAssignedClusters({ hubAccessToken }, { endpoint: getAssignedClustersEndpoint() });
167
+ const clusters = assigned.ok ? assigned.clusters || [] : [];
168
+ const recommended = clusters
169
+ .filter((c) => c.type === 'RECOMMENDED')
170
+ .sort((a, b) => (SOURCE_ORDER[a.source] ?? 2) - (SOURCE_ORDER[b.source] ?? 2));
171
+ return { mainClusterId, roles: dedupeByCluster([...held, ...clusters]).map(shape), recommended: recommended.map(shape) };
172
+ }
173
+
174
+ async function listMyRoles(args = {}) {
175
+ const session = requireSession();
176
+ const deadline = now() + Math.min(Math.max(Number(args.waitSeconds) || 0, 0), MAX_WAIT_SECONDS) * 1000;
177
+ let pending = await evaluationPending(session);
178
+ while (pending && now() < deadline) {
179
+ await sleep(POLL_MS);
180
+ pending = await evaluationPending(session);
181
+ }
182
+ let read = await readRoles(session.hubAccessToken);
183
+ while (!pending && read.recommended.length === 0 && now() < deadline) {
184
+ await sleep(POLL_MS);
185
+ read = await readRoles(session.hubAccessToken);
186
+ }
187
+ let message;
188
+ let next = null;
189
+ if (pending) message = 'The interview evaluation is still recommending roles: tell the talent you are preparing their roles and call list_my_roles again with waitSeconds.';
190
+ else if (read.recommended.length) {
191
+ next = 'set_main_role';
192
+ message = 'Call set_main_role without clusterId: it asks the talent which recommended role is their main one, in a dialog or with options it returns for you to show.';
193
+ } else if (read.mainClusterId) {
194
+ next = 'open_web';
195
+ message = 'The talent already has a main role and nothing new was recommended: do not ask it again, go on to open_web. Only if the talent asks to change it, call set_main_role with catalogue:true.';
196
+ } else {
197
+ next = 'set_main_role';
198
+ message = 'Nothing was recommended: call set_main_role without clusterId, with answer = the role their CV or draft names and skills = their main technologies: it offers the closest catalogue roles for the talent to pick (or asks their role), then adds and sets only the one they pick.';
199
+ }
200
+ return { ok: true, ...read, pending, ...(next ? { next } : {}), message };
201
+ }
202
+
203
+ function roleChoice(asked, roles, extra) {
204
+ return {
205
+ ok: false,
206
+ reason: 'main-role-choice-required',
207
+ ...(asked.dismissed ? { dismissed: asked.dismissed } : {}),
208
+ ...asked.chat,
209
+ roles: roles.map((r) => ({ clusterId: r.clusterId, name: r.name })),
210
+ message: `${asked.chat.choiceMessage} Then call set_main_role with the clusterId (from roles) of the one they pick. ${extra}`,
211
+ };
212
+ }
213
+
214
+ // How many open projects look for each role; a failed count leaves its line out.
215
+ async function openCounts(hubAccessToken, roles) {
216
+ return Promise.all(roles.map(async (r) => {
217
+ try {
218
+ const res = await countOpenPositions({ hubAccessToken, clusterId: r.clusterId });
219
+ return res && res.ok ? res.total : null;
220
+ } catch {
221
+ return null;
222
+ }
223
+ }));
224
+ }
225
+
226
+ // The main-role step: why each recommended role fits and how many projects want it, then the question; the catalogue is the last option.
227
+ async function chooseMainRole(session, args, ctx) {
228
+ const read = await readRoles(session.hubAccessToken);
229
+ const recommended = byName(read.recommended);
230
+ if (!recommended.length && read.mainClusterId) {
231
+ return { reply: { ok: true, alreadySet: true, clusterId: read.mainClusterId, message: 'The talent already has a main role and nothing new was recommended: nothing to ask. Go on to open_web; only if they ask to change it, call set_main_role with catalogue:true.' } };
232
+ }
233
+ if (!recommended.length) return chooseFromCatalogue(args, ctx);
234
+ const c = copy();
235
+ const counts = await openCounts(session.hubAccessToken, recommended);
236
+ const evidence = args.evidence && typeof args.evidence === 'object' ? args.evidence : {};
237
+ const blocks = recommended.map((r, i) => renderRole(lang(), { name: r.name, source: r.source, evidence: evidence[r.clusterId], openProjects: counts[i], recommended: i === 0 }));
238
+ const asked = await askAfterTexts(ctx, {
239
+ tool: 'set_main_role',
240
+ texts: [[c.rolesIntro, ...blocks, c.rolesOutro].join('\n\n')],
241
+ question: c.mainRoleQuestion,
242
+ options: [...recommended.map((r) => r.name), c.mainRoleOther],
243
+ answer: args.answer,
244
+ });
245
+ if (asked.reply) {
246
+ return { reply: { ...asked.reply, roles: recommended.map((r) => ({ clusterId: r.clusterId, name: r.name })), message: `${asked.reply.message} Pass the same evidence again. If they pick the last option, call set_main_role with catalogue:true and no clusterId.` } };
247
+ }
248
+ if (asked.answer === c.mainRoleOther) return chooseFromCatalogue({}, ctx);
249
+ return { role: recommended.find((r) => r.name === asked.answer) };
250
+ }
251
+
252
+ // The catalogue roles last offered: an answer naming one of them is the talent's pick, any other text is only a query.
253
+ let offered = null;
254
+
255
+ function askOwnRole() {
256
+ offered = null;
257
+ return {
258
+ reply: {
259
+ ok: false,
260
+ reason: 'main-role-ask',
261
+ say: copy().mainRoleAsk,
262
+ message: `${SAY_MESSAGE} Then call set_main_role with catalogue:true and answer = what they say: it offers the closest catalogue roles.`,
263
+ },
264
+ };
265
+ }
266
+
267
+ async function chooseFromCatalogue(args, ctx) {
268
+ const c = copy();
269
+ const said = typeof args.answer === 'string' ? args.answer.trim() : '';
270
+ if (offered) {
271
+ const picked = optionFor(said, [...offered.map((r) => r.name), c.mainRoleNotListed]);
272
+ if (picked === c.mainRoleNotListed) return askOwnRole();
273
+ const role = picked && offered.find((r) => r.name === picked);
274
+ if (role) { offered = null; return { role, add: true }; }
275
+ }
276
+ const available = await listAvailableRoles();
277
+ if (!available.ok) return { reply: { ok: false, reason: available.reason } };
278
+ const roles = byName(available.roles);
279
+ if (!roles.length) return { reply: { ok: false, reason: 'no-roles' } };
280
+ const ranked = rankRoles(roles, said, args.skills);
281
+ const shown = roles.length <= MAX_ROLE_OPTIONS ? ranked : ranked.filter((m) => m.matches).slice(0, MAX_ROLE_OPTIONS);
282
+ if (!shown.length) return askOwnRole();
283
+ // This call's own list: another call may replace `offered` while the dialog is open.
284
+ const options = shown.map((m) => m.role);
285
+ offered = options;
286
+ // Same block as the recommended roles: intro, the best match marked, its reason, its open projects, then what the main profile is.
287
+ const fits = shown.filter((m) => m.byRole.length || m.byStack.length);
288
+ const session = loadAuthSession();
289
+ const counts = fits.length ? await openCounts(session && session.hubAccessToken, fits.map((m) => m.role)) : [];
290
+ const blocks = fits.map((m, i) => [
291
+ i === 0 ? c.roleRecommended(m.role.name) : c.roleName(m.role.name),
292
+ m.byRole.length ? c.catalogueReason.role(m.byRole.join(', ')) : null,
293
+ m.byStack.length ? c.catalogueReason.stack(m.byStack.join(', ')) : null,
294
+ typeof counts[i] === 'number' && counts[i] > 0 ? c.openProjects(counts[i]) : null,
295
+ ].filter(Boolean).join('\n'));
296
+ const texts = [[...(blocks.length ? [c.rolesIntro, ...blocks] : []), c.rolesOutro].join('\n\n')];
297
+ const asked = await askChoice(ctx, { texts, question: c.mainRoleQuestion, options: [...options.map((r) => r.name), c.mainRoleNotListed] });
298
+ if (asked.answer === c.mainRoleNotListed) return askOwnRole();
299
+ const role = asked.answer && options.find((r) => r.name === asked.answer);
300
+ if (role) {
301
+ offered = null;
302
+ return { role, add: true };
303
+ }
304
+ offered = options;
305
+ return { reply: roleChoice(asked.chat ? asked : { chat: chatChoice(c.mainRoleQuestion, [...options.map((r) => r.name), c.mainRoleNotListed], texts) }, options, `Pass catalogue:true too: it adds the role first. If they pick the last option, call set_main_role with catalogue:true and answer = that option.`) };
306
+ }
307
+
308
+ async function setMainRole(args = {}, ctx = {}) {
309
+ const session = requireSession();
310
+ if (args.clusterId && args.catalogue === true) {
311
+ const added = await addRole({ clusterId: args.clusterId });
312
+ if (!added.ok) return added;
313
+ }
314
+ if (!args.clusterId) {
315
+ const picked = args.catalogue === true ? await chooseFromCatalogue(args, ctx) : await chooseMainRole(session, args, ctx);
316
+ if (picked.reply) return picked.reply;
317
+ if (picked.add) {
318
+ const added = await addRole({ clusterId: picked.role.clusterId });
319
+ if (!added.ok) return added;
320
+ }
321
+ const set = await applyMainRole(session, picked.role.clusterId, { suggestion: !picked.add });
322
+ return set.ok ? { ...set, name: picked.role.name } : set;
323
+ }
324
+ return applyMainRole(session, args.clusterId, { suggestion: args.catalogue === true ? false : undefined });
325
+ }
326
+
327
+ async function applyMainRole(session, clusterId, { suggestion } = {}) {
328
+ const res = await requestSetMainRole({ clusterId, hubAccessToken: session.hubAccessToken }, { endpoint: getSetMainRoleEndpoint() });
329
+ if (res.ok === false && res.reason === 'http-400') return { ok: false, reason: res.reason, message: 'The talent does not hold that role yet: call add_role with it, then set_main_role again.' };
330
+ if (!res.ok) return { ok: false, reason: res.reason };
331
+ funnel.completed('supercluster', { supercluster: clusterId, was_suggestion_accepted: suggestion });
332
+ funnel.flush();
333
+ return { ok: true, clusterId };
334
+ }
335
+
336
+ return [
337
+ {
338
+ name: 'list_available_roles',
339
+ title: 'Roles you can add',
340
+ description: 'Shows the Shakers roles you can add to your profile.',
341
+ guidance: "List the roles the talent can add to their profile (the cluster catalog minus roles they already hold). Each row has clusterId (internal handle), name, category and an optional proposalKind (why it is suggested). Present roles to the talent by NAME; never show clusterId. Requires an active session.",
342
+ inputSchema: EMPTY_SCHEMA,
343
+ handler: listAvailableRoles,
344
+ },
345
+ {
346
+ name: 'add_role',
347
+ title: 'Add a role',
348
+ description: 'Adds the role you pick to your Shakers profile.',
349
+ guidance: "Add a role to the talent's profile (a GROWTH role) by clusterId from list_available_roles. Requires an active session. Returns { clusterId, alreadyAssigned? }.",
350
+ inputSchema: CLUSTER_SCHEMA,
351
+ handler: addRole,
352
+ },
353
+ {
354
+ name: 'list_my_roles',
355
+ title: 'Your roles',
356
+ description: 'Shows your roles on Shakers and the ones suggested by your interview and your CV.',
357
+ guidance: "List the roles the talent already holds, plus mainClusterId (their current main role) and `recommended`: the roles the onboarding interview and the CV import suggest, interview first. Use it before set_main_role. In the sign-up pass waitSeconds (20): it waits for the interview evaluation to recommend roles and says when to call again (`pending`) or to fall back to list_available_roles. Follow `message`. clusterId is an internal handle — refer to roles by name to the talent. Requires an active session.",
358
+ inputSchema: LIST_MY_ROLES_SCHEMA,
359
+ handler: listMyRoles,
360
+ },
361
+ {
362
+ name: 'set_main_role',
363
+ title: 'Choose your main role',
364
+ description: 'Asks you which role is your main one and saves it on your Shakers profile.',
365
+ guidance: "Set the talent's MAIN role. In the sign-up it is the last step before open_web: after onboarding_interview_complete (or when the talent skipped the interview) and once list_my_roles is no longer pending, call it without clusterId: it asks the talent which of the recommended roles is their main one, with the catalogue as the last option (or straight from the catalogue when nothing is recommended), in a dialog when the client has one; otherwise it returns `say` to print word for word and the roles, to call it again with the clusterId they pick. Then it sets that role (adding a catalogue role first). Also usable standalone with clusterId to change the main role later; catalogue:true adds a catalogue role first. Requires an active session.",
366
+ inputSchema: SET_MAIN_ROLE_SCHEMA,
367
+ handler: setMainRole,
368
+ },
369
+ ];
370
+ }
371
+
372
+ module.exports = { makeRoleTools, EMPTY_SCHEMA, CLUSTER_SCHEMA, LIST_MY_ROLES_SCHEMA, SET_MAIN_ROLE_SCHEMA };
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ // MCP: bookmark / un-bookmark a Position. Both idempotent, reversible, low-risk.
4
+
5
+ const ID_SCHEMA = {
6
+ type: 'object',
7
+ properties: { id: { type: 'string', description: 'Position id (UUID) — an internal handle from a prior list; never shown or mentioned to the talent.' } },
8
+ required: ['id'],
9
+ };
10
+
11
+ function makeSavedPositionsTools(deps = {}) {
12
+ const {
13
+ savePosition = (opts) => require('./saved-positions-client').savePosition({}, opts),
14
+ unsavePosition = (opts) => require('./saved-positions-client').unsavePosition({}, opts),
15
+ loadAuthSession = require('./auth-session-store').loadAuthSession,
16
+ sessionStatus = require('./auth-session-store').sessionStatus,
17
+ } = deps;
18
+
19
+ const requireLoginSession = () => {
20
+ const session = loadAuthSession();
21
+ if (sessionStatus(session) !== 'active') {
22
+ throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first (this tool is session-gated).');
23
+ }
24
+ return session;
25
+ };
26
+
27
+ const toggle = (op) => async (args = {}) => {
28
+ const session = requireLoginSession();
29
+ const positionId = typeof args.id === 'string' ? args.id.trim() : null;
30
+ if (!positionId) return { ok: false, reason: 'no-id' };
31
+ const res = await op({ hubAccessToken: session.hubAccessToken, positionId });
32
+ if (!res.ok) return { ok: false, reason: res.reason };
33
+ return { ok: true, positionId };
34
+ };
35
+
36
+ return [
37
+ {
38
+ name: 'save_project',
39
+ title: 'Save a project',
40
+ description: 'Saves a project to your list on Shakers.',
41
+ guidance: 'Bookmark a project/Position for the talent (Find Positions "Saved"). Idempotent — saving twice is a no-op. Requires an active session and the position id. The position id is an INTERNAL handle for calling tools — never show or mention it to the talent.',
42
+ inputSchema: ID_SCHEMA,
43
+ handler: toggle(savePosition),
44
+ },
45
+ {
46
+ name: 'unsave_project',
47
+ title: 'Remove a saved project',
48
+ description: 'Removes a project from your saved list on Shakers.',
49
+ guidance: 'Remove a project/Position bookmark for the talent. Idempotent — un-saving something not saved is not an error. Requires an active session and the position id. The position id is an INTERNAL handle for calling tools — never show or mention it to the talent.',
50
+ inputSchema: ID_SCHEMA,
51
+ handler: toggle(unsavePosition),
52
+ },
53
+ ];
54
+ }
55
+
56
+ module.exports = {
57
+ makeSavedPositionsTools,
58
+ ID_SCHEMA,
59
+ };