shakers-cli 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +15 -5
  2. package/bin/command-audit-worker.js +19 -0
  3. package/bin/login.js +4 -8
  4. package/bin/mcp.js +29 -19
  5. package/bin/register.js +10 -29
  6. package/bin/shakers-cli.js +14 -9
  7. package/package.json +1 -1
  8. package/src/agents-client.js +1 -0
  9. package/src/ai-usage-review.js +5 -3
  10. package/src/auth-client.js +7 -107
  11. package/src/auth-session-store.js +6 -12
  12. package/src/backend-request.js +1 -24
  13. package/src/cli-skill.js +77 -0
  14. package/src/command-audit.js +47 -4
  15. package/src/config.js +0 -16
  16. package/src/consent-flow.js +7 -2
  17. package/src/detect-repos.js +4 -8
  18. package/src/detectors.js +23 -0
  19. package/src/device-auth-store.js +2 -2
  20. package/src/device-login.js +11 -9
  21. package/src/env-paths.js +9 -1
  22. package/src/i18n.js +162 -88
  23. package/src/mcp-ai-usage-result-tool.js +6 -4
  24. package/src/mcp-ai-usage-tool.js +72 -46
  25. package/src/mcp-choice.js +171 -34
  26. package/src/mcp-detector.js +26 -2
  27. package/src/mcp-device-login-tools.js +40 -8
  28. package/src/mcp-install.js +15 -11
  29. package/src/mcp-instructions.js +43 -17
  30. package/src/mcp-onboarding-tools.js +1 -3
  31. package/src/mcp-prompts.js +6 -4
  32. package/src/mcp-register-tools.js +25 -14
  33. package/src/mcp-role-tools.js +35 -17
  34. package/src/mcp-server-name.js +7 -0
  35. package/src/mcp-server.js +35 -7
  36. package/src/mcp-signup-client.js +3 -6
  37. package/src/mcp-signup-flow.js +389 -0
  38. package/src/mcp-signup-tools.js +237 -126
  39. package/src/onboarding-flow.js +30 -125
  40. package/src/opencode-sessions.js +92 -0
  41. package/src/profile-agents.js +26 -0
  42. package/src/saved-positions-flow.js +3 -3
  43. package/src/scanner.js +104 -12
  44. package/src/session-gate.js +1 -4
  45. package/src/session-normalizers.js +114 -8
  46. package/src/session-refresh.js +5 -16
  47. package/src/session-scan.js +167 -22
  48. package/src/share-payload.js +4 -1
  49. package/src/share.js +2 -2
  50. package/src/signup-client.js +12 -39
  51. package/src/signup-copy.js +380 -68
  52. package/src/skill-install.js +102 -0
  53. package/src/tier-engine.js +14 -2
  54. package/src/trust-narration.js +32 -0
  55. package/src/usage-run-steps.js +12 -3
  56. package/src/usage-scan.js +3 -0
  57. package/src/brand-svg.js +0 -9
  58. package/src/inter-fonts.js +0 -6
  59. package/src/mcp-auth-form.js +0 -484
@@ -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 };