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
package/src/mcp-server.js CHANGED
@@ -34,7 +34,28 @@ function errorResponse(id, code, message, data) {
34
34
  return { jsonrpc: JSONRPC, id: id === undefined ? null : id, error };
35
35
  }
36
36
 
37
- function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVersion, beforeCall = null, afterCall = null, onInitialize = null, instructions = null, elicitationTimeoutMs = ELICITATION_TIMEOUT_MS } = {}) {
37
+ // Cowork advertises elicitation, drops the request and leaves the call hanging ~180 s (anthropics/claude-ai-mcp#1046);
38
+ // it names itself in clientInfo ('claude-code', version '... (remote_cowork)'). Claude Desktop ('claude-ai') does not
39
+ // advertise it today and is matched so that advertising it later cannot lead to the same hang. Both ask in the chat.
40
+ // Matched on clientInfo only, the one thing the handshake says about the client.
41
+ function dropsElicitation(clientInfo = {}) {
42
+ const name = typeof clientInfo.name === 'string' ? clientInfo.name.toLowerCase() : '';
43
+ const version = typeof clientInfo.version === 'string' ? clientInfo.version.toLowerCase() : '';
44
+ return name === 'claude-ai' || name.includes('cowork') || version.includes('cowork');
45
+ }
46
+
47
+ // The tool's narration opens every result. A reply with `say` (text the talent must read) follows it in the same
48
+ // plain-text block, so the model writes both as one message, and the rest goes as JSON after it: a model writes a
49
+ // readable text before it reasons over a JSON field, and the talent never sees tool results.
50
+ function contentOf(value, narration = null) {
51
+ if (value && typeof value === 'object' && typeof value.say === 'string' && value.say) {
52
+ const { say, ...rest } = value;
53
+ return [{ type: 'text', text: [narration, say].filter(Boolean).join('\n\n') }, { type: 'text', text: JSON.stringify(rest, null, 2) }];
54
+ }
55
+ return [...(narration ? [{ type: 'text', text: narration }] : []), { type: 'text', text: JSON.stringify(value, null, 2) }];
56
+ }
57
+
58
+ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVersion, beforeCall = null, afterCall = null, narrate = null, onInitialize = null, instructions = null, elicitationTimeoutMs = ELICITATION_TIMEOUT_MS } = {}) {
38
59
  const registry = new Map();
39
60
  for (const t of tools) {
40
61
  if (!t || !t.name || typeof t.handler !== 'function') {
@@ -54,6 +75,7 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
54
75
  const advertisedVersion = protocolVersion || DEFAULT_PROTOCOL_VERSION;
55
76
 
56
77
  let clientCapabilities = {};
78
+ let clientInfo = {};
57
79
  let send = null;
58
80
  let lastRequestId = 0;
59
81
  const pending = new Map();
@@ -91,7 +113,7 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
91
113
  // (`{ url }` without `{ form }`), which only opens links and falls back to the chat.
92
114
  function canElicitForm() {
93
115
  const e = clientCapabilities.elicitation;
94
- if (!e) return false;
116
+ if (!e || dropsElicitation(clientInfo)) return false;
95
117
  if (typeof e === 'object' && e.url !== undefined && e.form === undefined) return false;
96
118
  return true;
97
119
  }
@@ -174,15 +196,20 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
174
196
  if (typeof beforeCall === 'function') {
175
197
  try { await beforeCall(name); } catch { /* hook must never break a tool call */ }
176
198
  }
199
+ const narration = (failed) => {
200
+ const text = typeof narrate === 'function' ? narrate(name, { failed, args }) : null;
201
+ return text ? [{ type: 'text', text }] : [];
202
+ };
177
203
  try {
178
- const value = await tool.handler(args, { elicit, elicitation: !!send && canElicitForm() });
204
+ const asking = narration(false);
205
+ const value = await tool.handler(args, { elicit, elicitation: !!send && canElicitForm(), narration: asking.length ? asking[0].text : null });
179
206
  return {
180
- content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
207
+ content: contentOf(value, asking.length ? asking[0].text : null),
181
208
  isError: false,
182
209
  };
183
210
  } catch (e) {
184
211
  return {
185
- content: [{ type: 'text', text: `${name} tool error: ${e && e.message ? e.message : String(e)}` }],
212
+ content: [...narration(true), { type: 'text', text: `${name} tool error: ${e && e.message ? e.message : String(e)}` }],
186
213
  isError: true,
187
214
  };
188
215
  } finally {
@@ -213,8 +240,9 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
213
240
  case 'initialize': {
214
241
  const requested = msg.params && msg.params.protocolVersion;
215
242
  clientCapabilities = (msg.params && msg.params.capabilities) || {};
243
+ clientInfo = (msg.params && msg.params.clientInfo) || {};
216
244
  if (typeof onInitialize === 'function') {
217
- try { onInitialize({ clientInfo: (msg.params && msg.params.clientInfo) || {} }); } catch { /* never breaks the handshake */ }
245
+ try { onInitialize({ clientInfo }); } catch { /* never breaks the handshake */ }
218
246
  }
219
247
  // One line per connection on stderr (never stdout: it carries JSON-RPC), so the host's
220
248
  // declared elicitation shape and the resulting decision are visible in the MCP log.
@@ -292,4 +320,4 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
292
320
  return { handleMessage, listTools, listAvailableTools, callTool, start };
293
321
  }
294
322
 
295
- module.exports = { createMcpServer, ERROR, DEFAULT_PROTOCOL_VERSION };
323
+ module.exports = { createMcpServer, dropsElicitation, ERROR, DEFAULT_PROTOCOL_VERSION };
@@ -96,13 +96,10 @@ async function requestMyImportStatus({ hubAccessToken } = {}, { endpoint, timeou
96
96
  return { ok: true, ...importStateOf(data) };
97
97
  }
98
98
 
99
- // better-auth reads the session from the cookie (email sign-in) or the Bearer session token (device flow).
100
- async function requestOneTimeToken({ cookie = null, sessionToken = null } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
99
+ async function requestOneTimeToken({ sessionToken = null } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
101
100
  if (!endpoint) return { ok: false, reason: 'no-endpoint' };
102
- const headers = {};
103
- if (cookie) headers.Cookie = cookie;
104
- else if (sessionToken) headers.Authorization = `Bearer ${sessionToken}`;
105
- else return { ok: false, reason: 'no-session' };
101
+ if (!sessionToken) return { ok: false, reason: 'no-session' };
102
+ const headers = { Authorization: `Bearer ${sessionToken}` };
106
103
  let res;
107
104
  try {
108
105
  res = await postJsonWithTimeout(endpoint, null, timeoutMs, 'GET', null, headers);
@@ -0,0 +1,389 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { expandHome } = require('./env-paths');
5
+ const { toSignupLanguage } = require('./signup-copy');
6
+ const { signupLanguage } = require('./signup-language');
7
+ const { CHOICE_RULE, QUESTION_RULE, DRAFT_RULE, nextSayRule } = require('./mcp-instructions');
8
+
9
+ // Claude Desktop asks for approval once per tool, so the whole sign-up runs through one tool: `signup`, with a
10
+ // `step` that delegates to the handler of the tool that step used to be. The talent reads the same texts.
11
+
12
+ // Step -> the tool whose handler runs it.
13
+ const STEP_TOOLS = {
14
+ start: 'signup_start',
15
+ find_cv: 'suggest_register_context',
16
+ read_cv: 'read_cv',
17
+ open_linkedin: 'open_linkedin_profile',
18
+ email: 'signup_email',
19
+ local_preview: 'signup_draft',
20
+ create_account: 'signup_create_account',
21
+ status: 'signup_status',
22
+ update_existing: 'update_existing_profile',
23
+ save_details: 'save_profile_details',
24
+ import: 'import_profile',
25
+ ai_usage: 'ai_usage',
26
+ ai_usage_upload: 'ai_usage_upload',
27
+ ai_usage_result: 'ai_usage_result',
28
+ interview: 'onboarding_interview_start',
29
+ interview_turn: 'onboarding_interview_turn',
30
+ interview_complete: 'onboarding_interview_complete',
31
+ roles: 'list_my_roles',
32
+ set_main_role: 'set_main_role',
33
+ open_web: 'open_web',
34
+ };
35
+ const STEPS = Object.keys(STEP_TOOLS);
36
+ const TOOL_STEPS = Object.fromEntries(Object.entries(STEP_TOOLS).map(([step, tool]) => [tool, step]));
37
+
38
+ // Tools only the sign-up used: they are no longer listed, so the model cannot reach them one by one.
39
+ const ABSORBED_TOOLS = new Set(['signup_start', 'suggest_register_context', 'read_cv', 'open_linkedin_profile', 'signup_email', 'signup_draft', 'signup_create_account', 'signup_status', 'update_existing_profile', 'save_profile_details', 'import_profile']);
40
+
41
+ // The waits each step holds in one call, as the old instructions passed them; the tool sets them itself.
42
+ const STEP_WAIT_SECONDS = { status: 45, roles: 45, ai_usage_upload: 30 };
43
+ // Claude Desktop cuts a tool call at ~60 s and ignores progress: a wait that has not answered by then gets a retry answer.
44
+ const WAIT_DEADLINE_MS = 50000;
45
+
46
+ // Steps of the sign-up before the account window, which a talent can no longer redo once it opened.
47
+ const INTERVIEW_STEPS = new Set(['interview', 'interview_turn', 'interview_complete']);
48
+ const INTRO_STEPS = new Set(['start', 'find_cv', 'read_cv', 'open_linkedin', 'email', 'local_preview']);
49
+ // Answers that still wait for the talent, so the step they belong to is not done yet.
50
+ const PENDING_REASONS = new Set(['answer-required', 'consent-required', 'where-required', 'answer-mode-required', 'main-role-choice-required', 'main-role-ask', 'upload-approval-required']);
51
+
52
+ // Steps that read the talent's files or send their data, and the steps that lead into one: their answers say what to do when Claude's permission check blocks the next call.
53
+ const GUARDED_STEPS = new Set(['start', 'find_cv', 'read_cv', 'local_preview', 'create_account', 'import']);
54
+ const BLOCKED_MESSAGE = 'If Claude blocks your next signup call for lack of permission, do not retry it silently and do not give up: tell the talent in one sentence what was blocked and ask them to repeat the instruction naming the data and where it goes, in their language (for example: "yes, read my CV for my Shakers sign-up"). Never suggest any other way around the block.';
55
+
56
+ const TOOL_NAME_RE = new RegExp(`\\b(${Object.keys(TOOL_STEPS).sort((a, b) => b.length - a.length).join('|')})\\b`, 'g');
57
+
58
+ // What the model reads, in step words instead of tool names; waits are the tool's own, so their arguments go.
59
+ function inStepWords(text) {
60
+ if (typeof text !== 'string') return text;
61
+ return text
62
+ .replace(/\s*\(waitSeconds[^)]*\)/g, '')
63
+ .replace(/ again with waitSeconds/g, ' again')
64
+ .split(/(?<=\.)\s+/)
65
+ .filter((sentence) => !/waitSeconds/.test(sentence))
66
+ .join(' ')
67
+ .replace(TOOL_NAME_RE, (name) => `signup step "${TOOL_STEPS[name]}"`);
68
+ }
69
+
70
+ // Only fields written for the model are renamed; `say`, `question`, relayVerbatim and the rest are the talent's texts.
71
+ function forModel(result) {
72
+ if (!result || typeof result !== 'object') return result;
73
+ const out = { ...result };
74
+ for (const key of ['message', 'cvNote', 'note']) if (typeof out[key] === 'string') out[key] = inStepWords(out[key]);
75
+ if (typeof out.next === 'string' && TOOL_STEPS[out.next]) out.next = TOOL_STEPS[out.next];
76
+ return out;
77
+ }
78
+
79
+ const resolvedPath = (p) => path.resolve(expandHome(String(p).trim()));
80
+ const isPending = (result) => !!result && PENDING_REASONS.has(result.reason);
81
+
82
+ function freshProgress() {
83
+ return { started: false, email: false, draft: false, requested: false, signedIn: false, account: null, updateAnswered: false, profile: false, aiUsage: false, interview: false, interviewId: null, interviewEnded: false, roles: false, mainRole: false };
84
+ }
85
+
86
+ // In order: what has to have happened, and the step that does it.
87
+ const MILESTONES = [
88
+ ['started', (p) => p.started, () => 'start'],
89
+ ['email', (p) => p.email, () => 'email'],
90
+ ['draft', (p) => p.draft, () => 'local_preview'],
91
+ ['requested', (p) => p.requested, () => 'create_account'],
92
+ ['account', (p) => p.account === 'created' || p.account === 'existing', () => 'status'],
93
+ ['profile', (p) => p.profile, (p) => (p.account === 'existing' && !p.updateAnswered ? 'update_existing' : 'save_details')],
94
+ ['aiUsage', (p) => p.aiUsage, () => 'ai_usage'],
95
+ ['interview', (p) => p.interview && !p.interviewId, (p) => interviewStep(p)],
96
+ ['roles', (p) => p.roles, () => 'roles'],
97
+ ['mainRole', (p) => p.mainRole, () => 'set_main_role'],
98
+ ];
99
+
100
+ // The last milestone each step needs.
101
+ const STEP_NEEDS = {
102
+ start: null,
103
+ find_cv: 'started',
104
+ read_cv: 'started',
105
+ open_linkedin: 'started',
106
+ email: 'started',
107
+ local_preview: 'email',
108
+ create_account: 'draft',
109
+ status: 'requested',
110
+ update_existing: 'account',
111
+ save_details: 'account',
112
+ import: 'account',
113
+ ai_usage: 'profile',
114
+ ai_usage_upload: 'aiUsage',
115
+ ai_usage_result: 'aiUsage',
116
+ interview: 'aiUsage',
117
+ interview_turn: 'aiUsage',
118
+ interview_complete: 'aiUsage',
119
+ roles: 'interview',
120
+ set_main_role: 'roles',
121
+ open_web: 'mainRole',
122
+ };
123
+
124
+ // A live interview goes on until it ends and is closed; without one, it is offered.
125
+ function interviewStep(p) {
126
+ if (!p.interviewId) return 'interview';
127
+ return p.interviewEnded ? 'interview_complete' : 'interview_turn';
128
+ }
129
+
130
+ function nextStep(progress) {
131
+ const missing = MILESTONES.find(([, done]) => !done(progress));
132
+ return missing ? missing[2](progress) : 'open_web';
133
+ }
134
+
135
+ function refusal(step, progress) {
136
+ if (step === 'import' && progress.signedIn) return null;
137
+ const windowOpen = progress.requested && progress.account !== 'failed';
138
+ if ((INTRO_STEPS.has(step) || step === 'create_account') && windowOpen && !(step === 'create_account' && progress.account === 'waiting')) return nextStep(progress);
139
+ const needs = STEP_NEEDS[step];
140
+ if (!needs) return null;
141
+ const upTo = MILESTONES.findIndex(([name]) => name === needs);
142
+ const missing = MILESTONES.slice(0, upTo + 1).find(([, done]) => !done(progress));
143
+ if (step === 'update_existing' && !missing && progress.account !== 'existing') return nextStep(progress);
144
+ // A room is opened once; while it lives the talent answers it or, as on the web, ends it early.
145
+ if (!missing && INTERVIEW_STEPS.has(step) && (step === 'interview') === !!progress.interviewId) return nextStep(progress);
146
+ return missing ? missing[2](progress) : null;
147
+ }
148
+
149
+ function record(progress, step, result) {
150
+ const r = result || {};
151
+ switch (step) {
152
+ case 'start':
153
+ progress.started = true;
154
+ break;
155
+ case 'email':
156
+ progress.email = r.ok === true && !!r.email;
157
+ break;
158
+ case 'local_preview':
159
+ if (r.ok === true) progress.draft = true;
160
+ else if (r.reason === 'draft-change') progress.draft = false;
161
+ break;
162
+ case 'create_account':
163
+ if (r.ok === true) { progress.requested = true; progress.account = 'waiting'; }
164
+ if (r.reason === 'already-signed-in') progress.signedIn = true;
165
+ break;
166
+ case 'status':
167
+ if (r.account && r.account.state) progress.account = r.account.state;
168
+ if (r.onboardingInterview && r.onboardingInterview.completed === true) progress.interview = true;
169
+ break;
170
+ case 'update_existing':
171
+ if (r.ok === true) { progress.updateAnswered = true; if (r.updated === false) progress.profile = true; }
172
+ break;
173
+ case 'save_details':
174
+ if (r.ok === true) progress.profile = true;
175
+ break;
176
+ case 'ai_usage':
177
+ if (r.background === true || r.reason === 'consent-declined') progress.aiUsage = true;
178
+ break;
179
+ case 'interview':
180
+ if (r.ok === true && r.interviewId) { progress.interviewId = r.interviewId; progress.interviewEnded = r.ended === true; }
181
+ else if (!isPending(r)) progress.interview = true;
182
+ break;
183
+ case 'interview_turn':
184
+ if (r.ok === true) { if (r.ended === true) progress.interviewEnded = true; break; }
185
+ // The room is gone: done if the talent does it on the web, otherwise offered again.
186
+ progress.interviewId = null;
187
+ progress.interviewEnded = false;
188
+ progress.interview = r.next === 'open_web';
189
+ break;
190
+ case 'interview_complete':
191
+ // As on the web, closing moves on to the roles whatever certs kept: an unsaved interview stays pending there.
192
+ progress.interviewId = null;
193
+ progress.interviewEnded = false;
194
+ progress.interview = true;
195
+ break;
196
+ case 'roles':
197
+ if (r.next === 'set_main_role' || r.next === 'open_web') progress.roles = true;
198
+ if (r.next === 'open_web') progress.mainRole = true;
199
+ break;
200
+ case 'set_main_role':
201
+ if (!isPending(r)) progress.mainRole = true;
202
+ break;
203
+ default:
204
+ break;
205
+ }
206
+ }
207
+
208
+ function withDeadline(promise, ms) {
209
+ let timer;
210
+ const expired = new Promise((resolve) => { timer = setTimeout(resolve, ms, null); });
211
+ return Promise.race([promise, expired]).finally(() => clearTimeout(timer));
212
+ }
213
+
214
+ function propertiesOf(schemas) {
215
+ const out = {};
216
+ for (const [step, schema] of schemas) {
217
+ for (const [key, prop] of Object.entries((schema && schema.properties) || {})) {
218
+ if (key === 'waitSeconds' || key === 'interviewId') continue;
219
+ if (out[key]) { out[key].steps.push(step); continue; }
220
+ out[key] = { prop, steps: [step] };
221
+ }
222
+ }
223
+ return Object.fromEntries(Object.entries(out).map(([key, { prop, steps }]) => {
224
+ const { enum: values, ...rest } = prop;
225
+ // A name two steps share with different vocabularies keeps each step's own check in its handler.
226
+ const shared = steps.length > 1 && values ? rest : prop;
227
+ return [key, { ...shared, description: inStepWords(`Step ${steps.join(', ')}: ${prop.description || ''}`.trim()) }];
228
+ }));
229
+ }
230
+
231
+ function makeSignupTool(tools, { waitSeconds = STEP_WAIT_SECONDS, waitDeadlineMs = WAIT_DEADLINE_MS } = {}) {
232
+ const byName = new Map(tools.map((t) => [t.name, t]));
233
+ const handlerOf = (step) => {
234
+ const tool = byName.get(STEP_TOOLS[step]);
235
+ if (!tool) throw new Error(`signup needs the ${STEP_TOOLS[step]} tool`);
236
+ return tool.handler;
237
+ };
238
+ STEPS.forEach(handlerOf);
239
+
240
+ let progress = freshProgress();
241
+ // The CV files the talent let read_cv read: the only ones create_account may upload.
242
+ let confirmedCvs = new Set();
243
+
244
+ async function run(step, args, ctx) {
245
+ const handler = handlerOf(step);
246
+ if (step === 'read_cv') {
247
+ const file = args.path || args.cvPath;
248
+ const result = await handler({ ...args, path: file }, ctx);
249
+ if (result && result.ok === true && result.mentionsTalent !== false && typeof file === 'string' && file.trim()) confirmedCvs.add(resolvedPath(file));
250
+ return result;
251
+ }
252
+ if (step === 'save_details') return handler(args.details || {}, ctx);
253
+ // The terms and the newsletter are asked one after the other: the talent's answer goes to whichever is pending.
254
+ if (step === 'create_account' && args.answer !== undefined) {
255
+ const { answer, ...rest } = args;
256
+ return handler({ termsAnswer: answer, newsletterAnswer: answer, ...rest }, ctx);
257
+ }
258
+ if (step === 'ai_usage_result') return handler({ ...args, explained: true }, ctx);
259
+ if (step === 'local_preview') return handler({ ...(args.preview || {}), ...(args.answer !== undefined ? { answer: args.answer } : {}) }, ctx);
260
+ if (step === 'interview_turn' || step === 'interview_complete') return handler({ ...args, interviewId: progress.interviewId }, ctx);
261
+ if (waitSeconds[step] !== undefined) return handler({ ...args, waitSeconds: waitSeconds[step] }, ctx);
262
+ return handler(args, ctx);
263
+ }
264
+
265
+ // Waits that poll: a repeat runs the step again.
266
+ const holdsAWait = (step, args) => step === 'status' || step === 'roles' || step === 'ai_usage_result' || (step === 'ai_usage_upload' && args.confirm === undefined);
267
+ // Work that must run once (opening the room, sending an answer, saving the interview): a repeat waits for the same work.
268
+ const runsOnce = (step, args) => step === 'interview_turn' || step === 'interview_complete' || (step === 'interview' && args.disclaimerAcknowledged === true);
269
+ let running = null;
270
+ const stillWaiting = (step) => ({ ok: false, reason: 'still-waiting', message: `Nothing changed yet. Say nothing about it and call signup with step "${step}" again.` });
271
+
272
+ async function handler(raw = {}, ctx = {}) {
273
+ const { step, ...args } = raw || {};
274
+ if (!STEPS.includes(step)) {
275
+ return { ok: false, reason: 'unknown-step', expected: nextStep(progress), message: `Call signup with step "${nextStep(progress)}".` };
276
+ }
277
+ if (running && running.step !== step) return { ...stillWaiting(running.step), expected: running.step };
278
+ let work = running && running.work;
279
+ if (!work) {
280
+ // A new sign-up starts over, as signup_start itself does.
281
+ if (step === 'start' && !(typeof args.answer === 'string' && args.answer.trim()) && args.linkedinMissing !== true && !(progress.requested && progress.account !== 'failed')) {
282
+ progress = freshProgress();
283
+ confirmedCvs = new Set();
284
+ }
285
+ const expected = refusal(step, progress);
286
+ if (expected) {
287
+ return { ok: false, reason: 'out-of-order', step, expected, message: `Not yet: nothing was done and the talent sees nothing of this. Call signup with step "${expected}" now.` };
288
+ }
289
+ if (step === 'local_preview' && !(args.preview && typeof args.preview === 'object')) {
290
+ return { ok: false, reason: 'preview-required', message: 'Nothing was shown. Pass your draft of the profile inside `preview` (name, role, rates, sources...), and the talent\'s pick as `answer` next to it.' };
291
+ }
292
+ if (step === 'create_account' && typeof args.cvPath === 'string' && args.cvPath.trim() && !confirmedCvs.has(resolvedPath(args.cvPath))) {
293
+ return { ok: false, reason: 'cv-not-confirmed', expected: 'read_cv', message: 'Only a CV the talent let you read with signup step "read_cv" can be sent: pass that same path as cvPath, or none.' };
294
+ }
295
+ work = run(step, args, ctx);
296
+ }
297
+ const once = !!running || runsOnce(step, args);
298
+ // Held before the first await, so a call that arrives meanwhile waits for this work instead of starting it again.
299
+ if (once && !running) running = { step, work };
300
+ const release = () => { if (running && running.work === work) running = null; };
301
+ let result;
302
+ try {
303
+ result = once || holdsAWait(step, args) ? await withDeadline(work, waitDeadlineMs) : await work;
304
+ } catch (e) {
305
+ release();
306
+ throw e;
307
+ }
308
+ if (result === null) {
309
+ work.catch(() => {});
310
+ return stillWaiting(step);
311
+ }
312
+ release();
313
+ record(progress, step, result);
314
+ const next = nextStep(progress);
315
+ if (INTERVIEW_STEPS.has(step) && result.next === 'open_web') return onTheWebLater(result, step, next);
316
+ // An answer still waiting for the talent goes back to the same step.
317
+ const out = forModel({ ...result, step: result.step || step, ...(result.ok === false && !isPending(result) ? {} : { nextStep: isPending(result) ? step : next }) });
318
+ return GUARDED_STEPS.has(step) ? { ...out, message: [out.message, BLOCKED_MESSAGE].filter(Boolean).join(' ') } : out;
319
+ }
320
+
321
+ // An interview that cannot run here is done on the web, which the last step opens: the sign-up goes on.
322
+ function onTheWebLater(result, step, next) {
323
+ const { next: _web, ...rest } = result;
324
+ const relay = typeof result.relayVerbatim === 'string' ? 'Print relayVerbatim word for word now. ' : '';
325
+ return { ...rest, step: result.step || step, nextStep: next, message: `${relay}The talent does the interview on the web, which signup step "open_web" opens at the end of the sign-up. Call signup with step "${next}" now.` };
326
+ }
327
+
328
+ const previewOf = (schema) => {
329
+ const { answer: _answer, ...fields } = schema.properties;
330
+ return { ...schema, description: `Shown to the talent here in the chat as a preview; nothing is sent to Shakers in this step or with the email question. ${schema.description}`, properties: fields };
331
+ };
332
+ const schemaOf = (step) => {
333
+ const schema = byName.get(STEP_TOOLS[step]).inputSchema;
334
+ if (step === 'save_details') return { properties: { details: schema } };
335
+ // The email question takes the same draft, so that its answer hands the draft over.
336
+ if (step === 'email') return { ...schema, properties: { ...schema.properties, preview: previewOf(byName.get(STEP_TOOLS.local_preview).inputSchema) } };
337
+ if (step !== 'local_preview') return schema;
338
+ return { properties: { preview: previewOf(schema), answer: schema.properties.answer } };
339
+ };
340
+ const schemas = STEPS.map((step) => [step, schemaOf(step)]);
341
+ const stepGuides = STEPS.map((step) => `- ${step}: ${inStepWords(byName.get(STEP_TOOLS[step]).guidance || '')}`);
342
+
343
+ return {
344
+ name: 'signup',
345
+ title: 'Sign up on Shakers',
346
+ description: 'Signs you up on Shakers from this chat (your CV and LinkedIn, your profile draft, your account, created on the Shakers website after you accept the Terms, your AI analysis, the interview offer and your main role), asking you before looking for or reading your CV on this computer or analysing how you work with AI, and sending nothing to Shakers until you confirm. The assistant speaks to you as Shakers, writes out the text of each step for you in full here in the chat, and never mentions tools, steps or checks.',
347
+ guidance: [
348
+ 'The whole Shakers sign-up, one step per call: pass `step` and that step\'s arguments. Every answer says in `message` what to do next and in nextStep which step comes next; a step out of order is refused with the step to call (`expected`) and nothing done. Waits happen inside the tool: call the same step again when it says so.',
349
+ `The text of a step that follows an answer of the talent (notices, legal texts, the whole draft) comes one answer early as nextSay, with the answer it follows: ${nextSayRule(['<that answer>'])} That question comes as \`question\` and its \`options\` (at most four): ${QUESTION_RULE} A text that only exists after an action of the same turn (what an existing account would fill, what the AI-usage analysis found, the recommended roles, a line about an import that failed) comes as \`say\`, first in the result, with the question and its options numbered at its end, and no \`options\` for you: ${CHOICE_RULE} ${DRAFT_RULE} An answer counts only in a call after the one that returned its question. One question per message, at its end. The welcome comes as \`say\` too, ending with an open line and no options: the talent answers it in their own words. local_preview only shows the draft here and sends nothing; nothing reaches Shakers before the talent confirms it.`,
350
+ 'Talk as Shakers talking to the talent. Never mention tools, MCP, steps, calls, arguments, permissions or a confirmation a tool asks for, or servers (never "let me check what the sign-up asks for", "the tool asks for its own confirmation").',
351
+ 'If Claude blocks a signup call for lack of permission, never retry it silently and never give up: tell the talent in one sentence what was blocked and ask them to repeat the instruction naming the data and where it goes (for example: "yes, read my CV for my Shakers sign-up"). Never suggest any other way around the block.',
352
+ 'Steps:',
353
+ ...stepGuides,
354
+ 'The interview runs through these steps too: interview, then interview_turn once per answer until ended, then interview_complete; the tool keeps the interview handle, so never pass interviewId. When it has to be done on the web, the web opens at signup step "open_web", the last step: go on with nextStep. Never call another tool during the sign-up.',
355
+ ].join('\n'),
356
+ // One tool that reads this computer and writes to Shakers, so not read-only; it never deletes and never overwrites a value the talent set, and it talks to Shakers.
357
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
358
+ inputSchema: {
359
+ type: 'object',
360
+ properties: {
361
+ step: { type: 'string', enum: STEPS, description: 'The sign-up step to run.' },
362
+ ...propertiesOf(schemas),
363
+ },
364
+ required: ['step'],
365
+ },
366
+ handler,
367
+ };
368
+ }
369
+
370
+ // The tool whose narration opens a result: each sign-up step says what its own tool does.
371
+ function narratedTool(name, args) {
372
+ const step = name === 'signup' && args && typeof args === 'object' ? args.step : null;
373
+ return STEP_TOOLS[step] || name;
374
+ }
375
+
376
+ // The narration's language: the first call of a sign-up names the talent's language before the step fixes it.
377
+ function narrationLanguage(name, args, fallback) {
378
+ const opening = name === 'signup' && args && args.step === 'start' && !args.answer && toSignupLanguage(args.language);
379
+ return opening || signupLanguage(fallback);
380
+ }
381
+
382
+ // A tool the sign-up also runs as a step stays listed for use outside it.
383
+ function outsideSignup(tool) {
384
+ const step = TOOL_STEPS[tool.name];
385
+ if (!step || ABSORBED_TOOLS.has(tool.name)) return tool;
386
+ return { ...tool, guidance: `During the sign-up use signup step "${step}" instead. ${tool.guidance || ''}`.trim() };
387
+ }
388
+
389
+ module.exports = { makeSignupTool, outsideSignup, narratedTool, narrationLanguage, inStepWords, ABSORBED_TOOLS, BLOCKED_MESSAGE, GUARDED_STEPS, STEP_TOOLS, STEP_WAIT_SECONDS, STEPS, WAIT_DEADLINE_MS };