shakers-cli-beta 0.8.0-beta.1 → 0.8.0-beta.3
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.
- package/bin/mcp.js +2 -2
- package/bin/register.js +13 -4
- package/bin/shakers.js +2 -2
- package/package.json +1 -1
- package/src/availability-client.js +2 -1
- package/src/availability-flow.js +25 -6
- package/src/backend-request.js +17 -0
- package/src/config.js +40 -16
- package/src/cv-text.js +57 -0
- package/src/env-paths.js +15 -1
- package/src/i18n.js +20 -8
- package/src/mcp-availability-tools.js +1 -1
- package/src/mcp-register-tools.js +65 -11
- package/src/onboarding-client.js +36 -2
- package/src/onboarding-flow.js +130 -1
- package/src/prompt-select.js +38 -1
- package/src/register-context.js +128 -0
- package/src/usage-run-steps.js +12 -0
package/bin/mcp.js
CHANGED
|
@@ -42,7 +42,7 @@ const HELP = `\n shakers mcp — local MCP server (stdio)\n\n`
|
|
|
42
42
|
+ ` MCP client, not run by hand.\n`
|
|
43
43
|
+ ` Tools: ai_usage, ai_usage_result, login, logout, social_signin_start, social_signin_poll,\n`
|
|
44
44
|
+ ` list_addable, add_skill, add_agent,\n`
|
|
45
|
-
+ ` add_project, register, register_status, onboarding_interview_start,\n`
|
|
45
|
+
+ ` add_project, suggest_register_context, read_cv, register_preview, register, register_status, onboarding_interview_start,\n`
|
|
46
46
|
+ ` onboarding_interview_turn, onboarding_interview_complete,\n`
|
|
47
47
|
+ ` repeat_onboarding_interview, onboarding_finish,\n`
|
|
48
48
|
+ ` list_available_roles, add_role, list_my_roles, set_main_role,\n`
|
|
@@ -74,7 +74,7 @@ async function run(argv = process.argv.slice(2)) {
|
|
|
74
74
|
return;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
require('../src/config').
|
|
77
|
+
require('../src/config').sanitizeStaleBakedEndpoints(process.env);
|
|
78
78
|
|
|
79
79
|
const server = buildServer();
|
|
80
80
|
const rl = server.start({
|
package/bin/register.js
CHANGED
|
@@ -8,10 +8,10 @@ const { run: runLogin } = require('./login');
|
|
|
8
8
|
const { run: runAiUsage } = require('./ai-usage');
|
|
9
9
|
const {
|
|
10
10
|
runOnboarding, makeDeps, CURRENCIES, MONTHLY_HOURS, normalizeAmount,
|
|
11
|
-
WORK_SITUATIONS, EMPLOYMENT_PARTICIPATIONS, FREELANCE_OPINIONS, CHANGE_MOTIVATORS,
|
|
11
|
+
WORK_SITUATIONS, EMPLOYMENT_PARTICIPATIONS, FREELANCE_OPINIONS, CHANGE_MOTIVATORS, WORK_MODES,
|
|
12
12
|
LANGUAGE_CODES, LANGUAGE_LEVELS, showsFreelanceOpinion, showsChangeMotivators,
|
|
13
13
|
} = require('../src/onboarding-flow');
|
|
14
|
-
const { promptSelect } = require('../src/prompt-select');
|
|
14
|
+
const { promptSelect, promptMultiSelect } = require('../src/prompt-select');
|
|
15
15
|
const { chooseAuthMethod } = require('../src/auth-method-picker');
|
|
16
16
|
const { methodIdsForFlow } = require('../src/auth-methods');
|
|
17
17
|
const { palette } = require('../src/ansi');
|
|
@@ -123,7 +123,16 @@ function makeIo({ ask, lang, out, acceptDisclaimer, stdinIsTTY = false }) {
|
|
|
123
123
|
askAvailability: async (c) => {
|
|
124
124
|
const available = isYes(await ask(` ${c.availableAsk} `));
|
|
125
125
|
const monthlyHours = await promptSelect({ ask, stdinIsTTY, out: selectOut, items: MONTHLY_HOURS, header: ` ${c.monthlyHoursAsk}` });
|
|
126
|
-
const
|
|
126
|
+
const workModes = await promptMultiSelect({
|
|
127
|
+
ask,
|
|
128
|
+
stdinIsTTY,
|
|
129
|
+
out: selectOut,
|
|
130
|
+
items: WORK_MODES,
|
|
131
|
+
labelFor: (v) => c.workModeLabels[v] || v,
|
|
132
|
+
header: ` ${c.workModesAsk}`,
|
|
133
|
+
hint: c.workModesHint,
|
|
134
|
+
});
|
|
135
|
+
if (workModes.length === 1 && workModes[0] === 'REMOTE') selectOut(c.onlyRemoteNote);
|
|
127
136
|
let country = '';
|
|
128
137
|
for (;;) {
|
|
129
138
|
country = (await ask(` ${c.countryAsk} `)).trim().toUpperCase();
|
|
@@ -137,7 +146,7 @@ function makeIo({ ask, lang, out, acceptDisclaimer, stdinIsTTY = false }) {
|
|
|
137
146
|
const longFullTimeProjects = isYes(await ask(` ${c.longFullTimeAsk} `));
|
|
138
147
|
const telephoneCode = (await ask(` ${c.phonePrefixAsk} `)).trim();
|
|
139
148
|
const telephoneNumber = (await ask(` ${c.phoneNumberAsk} `)).trim();
|
|
140
|
-
return { available, monthlyHours,
|
|
149
|
+
return { available, monthlyHours, workModes, country, subdivision, timezone, longFullTimeProjects, telephoneCode, telephoneNumber };
|
|
141
150
|
},
|
|
142
151
|
askSignUp: async (c) => {
|
|
143
152
|
const name = (await ask(` ${c.askName} `)).trim();
|
package/bin/shakers.js
CHANGED
|
@@ -14,7 +14,7 @@ const { detectReportLang, getCatalog } = require('../src/i18n');
|
|
|
14
14
|
const { promptSelect } = require('../src/prompt-select');
|
|
15
15
|
const { createStdinAsk } = require('../src/stdin-ask');
|
|
16
16
|
const { migrateLegacyConfigDir } = require('../src/config-dir');
|
|
17
|
-
const {
|
|
17
|
+
const { sanitizeStaleBakedEndpoints } = require('../src/config');
|
|
18
18
|
const { ensureFreshSession } = require('../src/session-refresh');
|
|
19
19
|
const { hasUsableSession } = require('../src/session-gate');
|
|
20
20
|
const { run: runAiUsage } = require('./ai-usage');
|
|
@@ -290,7 +290,7 @@ async function main() {
|
|
|
290
290
|
const lang = parseLang(argv) || detectReportLang();
|
|
291
291
|
|
|
292
292
|
migrateLegacyConfigDir(process.env);
|
|
293
|
-
|
|
293
|
+
sanitizeStaleBakedEndpoints(process.env);
|
|
294
294
|
// Mint a fresh hub JWT from the stored cookie once per command (email sessions only; a no-op for Google/legacy or when no session exists).
|
|
295
295
|
try { await ensureFreshSession(process.env); } catch { /* never blocks a command */ }
|
|
296
296
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shakers-cli-beta",
|
|
3
|
-
"version": "0.8.0-beta.
|
|
3
|
+
"version": "0.8.0-beta.3",
|
|
4
4
|
"description": "Shakers AI usage & skills evaluator: a local-first CLI (`shakers` binary) plus a local MCP server. Commands: ai-usage (scan + classify your AI-tool setup, T0-T7), certify (certify Skills from your code), share (branded LinkedIn card).",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
const { postJsonWithTimeout } = require('./backend-request');
|
|
7
7
|
const { reasonForError } = require('./onboarding-client');
|
|
8
|
+
const { WORK_MODES } = require('./onboarding-flow');
|
|
8
9
|
|
|
9
10
|
const DEFAULT_TIMEOUT_MS = 20000;
|
|
10
11
|
|
|
@@ -36,7 +37,7 @@ function normalizeAvailability(data) {
|
|
|
36
37
|
available: bool(it.available),
|
|
37
38
|
// A numeric hours value (incl. 0) must survive, not be dropped as "absent".
|
|
38
39
|
monthlyHours: typeof it.monthlyHours === 'number' ? String(it.monthlyHours) : str(it.monthlyHours),
|
|
39
|
-
|
|
40
|
+
workModes: Array.isArray(it.workModes) ? it.workModes.filter((m) => WORK_MODES.includes(m)) : [],
|
|
40
41
|
country: str(it.country),
|
|
41
42
|
subdivision: str(it.subdivision),
|
|
42
43
|
timezone: str(it.timezone),
|
package/src/availability-flow.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// (interactive, explicit confirmation — it writes your profile). Talent Bearer.
|
|
5
5
|
|
|
6
6
|
const { styleLabelPrefix } = require('./ansi');
|
|
7
|
+
const { WORK_MODES, validateAvailability } = require('./onboarding-flow');
|
|
7
8
|
|
|
8
9
|
function makeAvailabilityDeps(overrides = {}) {
|
|
9
10
|
const client = require('./availability-client');
|
|
@@ -11,10 +12,15 @@ function makeAvailabilityDeps(overrides = {}) {
|
|
|
11
12
|
fetchAvailability: (opts) => client.fetchAvailability({}, opts),
|
|
12
13
|
saveAvailability: (opts) => client.saveAvailability({}, opts),
|
|
13
14
|
promptSelect: (opts) => require('./prompt-select').promptSelect(opts),
|
|
15
|
+
promptMultiSelect: (opts) => require('./prompt-select').promptMultiSelect(opts),
|
|
14
16
|
...overrides,
|
|
15
17
|
};
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
function workModesLine(fp, modes) {
|
|
21
|
+
return modes && modes.length ? modes.map((m) => fp.workModeLabels[m] || m).join(', ') : fp.unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
function locationLine(a) {
|
|
19
25
|
return [a.country, a.subdivision, a.timezone].filter(Boolean).join(' · ') || null;
|
|
20
26
|
}
|
|
@@ -23,7 +29,7 @@ function renderView(io, fp, a) {
|
|
|
23
29
|
io.section(fp.title);
|
|
24
30
|
io.notify(styleLabelPrefix(fp.openToWork(a.available === true ? fp.yes : a.available === false ? fp.no : fp.unknown)));
|
|
25
31
|
io.notify(styleLabelPrefix(fp.monthlyHours(a.monthlyHours != null ? a.monthlyHours : fp.unknown)));
|
|
26
|
-
io.notify(styleLabelPrefix(fp.
|
|
32
|
+
io.notify(styleLabelPrefix(fp.workModes(workModesLine(fp, a.workModes))));
|
|
27
33
|
const loc = locationLine(a);
|
|
28
34
|
if (loc) io.notify(styleLabelPrefix(fp.location(loc)));
|
|
29
35
|
if (a.availabilityExpiringDate) io.notify(styleLabelPrefix(fp.expires(a.availabilityExpiringDate)));
|
|
@@ -57,18 +63,31 @@ async function runSet({ io, ask, session, catalog, opts, deps }) {
|
|
|
57
63
|
const available = openPick.value;
|
|
58
64
|
const hoursRaw = (await io.ask(fp.askMonthlyHours)).trim();
|
|
59
65
|
if (hoursRaw && !/^\d{1,3}$/.test(hoursRaw)) { io.error(fp.monthlyHoursInvalid); return { ok: false, reason: 'bad-hours' }; }
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
66
|
+
const pickedModes = await deps.promptMultiSelect({
|
|
67
|
+
ask,
|
|
68
|
+
stdinIsTTY: opts.stdinIsTTY,
|
|
69
|
+
out: selOut,
|
|
70
|
+
items: WORK_MODES,
|
|
71
|
+
labelFor: (v) => fp.workModeLabels[v] || v,
|
|
72
|
+
header: fp.workModesAsk,
|
|
73
|
+
hint: fp.workModesHint,
|
|
74
|
+
initialMarked: (current && Array.isArray(current.workModes)) ? current.workModes.map((m) => WORK_MODES.indexOf(m)).filter((i) => i >= 0) : [],
|
|
75
|
+
input: opts.input,
|
|
76
|
+
output: opts.output,
|
|
77
|
+
});
|
|
78
|
+
// Reuse the register validation (dedupe/upcase, enum check) — no duplicated logic.
|
|
79
|
+
const workModes = validateAvailability({ workModes: pickedModes }).availability.workModes || [];
|
|
80
|
+
if (workModes.length === 1 && workModes[0] === 'REMOTE') io.notify(fp.onlyRemoteNote);
|
|
63
81
|
|
|
64
82
|
// Only the asked fields ride the partial upsert.
|
|
65
|
-
const body = { available
|
|
83
|
+
const body = { available };
|
|
66
84
|
if (hoursRaw) body.monthlyHours = hoursRaw;
|
|
85
|
+
if (workModes.length) body.workModes = workModes;
|
|
67
86
|
|
|
68
87
|
io.notify(fp.setDiff(
|
|
69
88
|
`${available ? fp.yes : fp.no}`,
|
|
70
89
|
hoursRaw || (current && current.monthlyHours) || fp.unknown,
|
|
71
|
-
|
|
90
|
+
workModesLine(fp, workModes),
|
|
72
91
|
));
|
|
73
92
|
const confirmed = await io.confirm(fp.setConfirm);
|
|
74
93
|
if (!confirmed) { io.notify(fp.setCancelled); return { ok: true, cancelled: true }; }
|
package/src/backend-request.js
CHANGED
|
@@ -39,6 +39,13 @@ function postJsonWithTimeout(url, body, timeoutMs, method = 'POST', idempotencyK
|
|
|
39
39
|
method,
|
|
40
40
|
headers: {
|
|
41
41
|
Accept: 'application/json',
|
|
42
|
+
// Lets the backend (and Datadog) tell CLI/MCP traffic apart from the
|
|
43
|
+
// browser without touching auth — same value regardless of which
|
|
44
|
+
// bin/ entrypoint is running, since none of them plumb through here
|
|
45
|
+
// separately today. Set unconditionally so no caller can drop it via
|
|
46
|
+
// extraHeaders (sanitizeExtraHeaders runs after this, so it can only
|
|
47
|
+
// add headers, not remove this one).
|
|
48
|
+
'X-Shakers-Client': 'cli',
|
|
42
49
|
// Only declared when there IS a body — see the bodyless note above.
|
|
43
50
|
...(data ? { 'Content-Type': 'application/json', 'Content-Length': data.length } : {}),
|
|
44
51
|
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
|
|
@@ -98,6 +105,14 @@ function postMultipartWithTimeout(url, fields, timeoutMs, method = 'POST', extra
|
|
|
98
105
|
const chunks = [];
|
|
99
106
|
for (const [name, value] of Object.entries(fields || {})) {
|
|
100
107
|
if (value === undefined || value === null) continue;
|
|
108
|
+
// A file part: { filename, contentType, data: Buffer }.
|
|
109
|
+
if (typeof value === 'object' && Buffer.isBuffer(value.data)) {
|
|
110
|
+
const filename = String(value.filename || 'file').replace(/["\r\n\0]/g, '');
|
|
111
|
+
const contentType = String(value.contentType || 'application/octet-stream').replace(UNSAFE_HEADER_VALUE_RE, '');
|
|
112
|
+
chunks.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"; filename="${filename}"\r\nContent-Type: ${contentType}\r\n\r\n`));
|
|
113
|
+
chunks.push(value.data, Buffer.from('\r\n'));
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
101
116
|
const clean = String(value).replace(UNSAFE_HEADER_VALUE_RE, '');
|
|
102
117
|
chunks.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${clean}\r\n`));
|
|
103
118
|
}
|
|
@@ -109,6 +124,8 @@ function postMultipartWithTimeout(url, fields, timeoutMs, method = 'POST', extra
|
|
|
109
124
|
method,
|
|
110
125
|
headers: {
|
|
111
126
|
Accept: 'application/json',
|
|
127
|
+
// See postJsonWithTimeout's identical header for why.
|
|
128
|
+
'X-Shakers-Client': 'cli',
|
|
112
129
|
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
113
130
|
'Content-Length': data.length,
|
|
114
131
|
...sanitizeExtraHeaders(extraHeaders),
|
package/src/config.js
CHANGED
|
@@ -196,7 +196,11 @@ function getCertsBase(env = process.env) {
|
|
|
196
196
|
}
|
|
197
197
|
const ingest = explicitIngest(env);
|
|
198
198
|
if (ingest) return ingest.replace(/\/usage\/reports\/?$/, '').replace(/\/+$/, '');
|
|
199
|
-
|
|
199
|
+
// No explicit override anywhere -> the baked flavor profile (never null when a
|
|
200
|
+
// profile exists), so switching the published flavor "just works" without a
|
|
201
|
+
// seeded config.json (ADR-065 revised).
|
|
202
|
+
const baked = getEnvProfile(env).certsBase;
|
|
203
|
+
return baked ? baked.replace(/\/+$/, '') : null;
|
|
200
204
|
}
|
|
201
205
|
|
|
202
206
|
function getIngestEndpoint(env = process.env) {
|
|
@@ -507,9 +511,10 @@ function getHubBase(env = process.env) {
|
|
|
507
511
|
const fromFile = loadConfigFile(env).hubBase;
|
|
508
512
|
if (fromFile && String(fromFile).trim()) {
|
|
509
513
|
const v = validateEndpoint(String(fromFile).trim());
|
|
510
|
-
|
|
514
|
+
if (v.ok) return v.value.replace(/\/+$/, '');
|
|
511
515
|
}
|
|
512
|
-
|
|
516
|
+
const baked = getEnvProfile(env).hubBase;
|
|
517
|
+
return baked ? baked.replace(/\/+$/, '') : null;
|
|
513
518
|
}
|
|
514
519
|
|
|
515
520
|
function deriveFromHub(env, relativePath) {
|
|
@@ -623,20 +628,39 @@ function getTalentProfileUrl(env = process.env) {
|
|
|
623
628
|
if (explicit && explicit.trim()) return explicit.trim().replace(/\/+$/, '');
|
|
624
629
|
const fromFile = loadConfigFile(env).profileUrl;
|
|
625
630
|
if (fromFile && String(fromFile).trim()) return String(fromFile).trim().replace(/\/+$/, '');
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
631
|
+
const baked = getEnvProfile(env).profileUrl;
|
|
632
|
+
return baked ? baked.replace(/\/+$/, '') : null;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// The endpoint keys a flavor profile owns. config.json must hold only EXPLICIT
|
|
636
|
+
// user overrides of these, never the baked profile itself.
|
|
637
|
+
const PROFILE_ENDPOINT_KEYS = Object.keys(ENV_PROFILES[DEFAULT_ENV_PROFILE]);
|
|
638
|
+
|
|
639
|
+
// Boot migration (ADR-065 revised). The CLI used to SEED config.json with the
|
|
640
|
+
// baked bases on first run; the getters now fall back to getEnvProfile() directly,
|
|
641
|
+
// so seeding is unnecessary AND harmful: `npm uninstall` leaves the config dir, so
|
|
642
|
+
// a config.json seeded by a previous flavor (e.g. the private @shakers build → dev)
|
|
643
|
+
// survives into a new flavor (the public beta → staging) and pins the old endpoints
|
|
644
|
+
// forever, since config.json wins over the baked profile. This sweep removes any
|
|
645
|
+
// endpoint key whose value is byte-identical to that SAME field in a KNOWN profile
|
|
646
|
+
// OTHER than the baked one — i.e. a value that could only have gotten there by the
|
|
647
|
+
// old seeding of a different flavor. A value that matches no known profile is a
|
|
648
|
+
// genuine user override (set via `shakers config set`) and is left untouched. We
|
|
649
|
+
// never touch env, auth-session.json or consent.json, and never remove the dir.
|
|
650
|
+
function sanitizeStaleBakedEndpoints(env = process.env) {
|
|
651
|
+
const bakedName = getEnvProfileName(env);
|
|
635
652
|
const config = loadConfigFile(env);
|
|
636
653
|
let changed = false;
|
|
637
|
-
for (const
|
|
638
|
-
|
|
639
|
-
|
|
654
|
+
for (const key of PROFILE_ENDPOINT_KEYS) {
|
|
655
|
+
const current = config[key];
|
|
656
|
+
if (typeof current !== 'string' || !current.trim()) continue;
|
|
657
|
+
const normalized = current.trim().replace(/\/+$/, '');
|
|
658
|
+
const matchesForeignProfile = Object.entries(ENV_PROFILES).some(
|
|
659
|
+
([name, profile]) =>
|
|
660
|
+
name !== bakedName && String(profile[key]).replace(/\/+$/, '') === normalized,
|
|
661
|
+
);
|
|
662
|
+
if (matchesForeignProfile) {
|
|
663
|
+
delete config[key];
|
|
640
664
|
changed = true;
|
|
641
665
|
}
|
|
642
666
|
}
|
|
@@ -744,7 +768,7 @@ module.exports = {
|
|
|
744
768
|
// Onboarding / register flow (evaluation-mcp, ADR-011..015/024).
|
|
745
769
|
getCertsBase,
|
|
746
770
|
getHubBase,
|
|
747
|
-
|
|
771
|
+
sanitizeStaleBakedEndpoints,
|
|
748
772
|
getSignUpEndpoint,
|
|
749
773
|
getImportProfileEndpoint,
|
|
750
774
|
getProfessionalDetailsEndpoint,
|
package/src/cv-text.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Plain text out of the talent's CV PDF, so the AI can pre-fill from what the CV
|
|
4
|
+
// says. Zero-dependency on purpose (install.sh copies the CLI without npm
|
|
5
|
+
// install): macOS reads it with the system PDFKit through JXA, elsewhere with
|
|
6
|
+
// poppler's pdftotext when it is installed. With neither, the caller asks the
|
|
7
|
+
// talent to attach the CV to the chat instead.
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { execFileSync } = require('child_process');
|
|
12
|
+
const { expandHome } = require('./env-paths');
|
|
13
|
+
|
|
14
|
+
const MAX_BYTES = 10 * 1024 * 1024;
|
|
15
|
+
const MAX_CHARS = 20000;
|
|
16
|
+
|
|
17
|
+
const JXA_PDF_TEXT = 'function run(argv){ObjC.import("PDFKit");'
|
|
18
|
+
+ 'const d=$.PDFDocument.alloc.initWithURL($.NSURL.fileURLWithPath(argv[0]));'
|
|
19
|
+
+ 'return d.isNil()?"":d.string.js}';
|
|
20
|
+
|
|
21
|
+
function run(cmd, args, exec) {
|
|
22
|
+
return String(exec(cmd, args, { encoding: 'utf8', timeout: 20000, maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }) || '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function extractors(platform) {
|
|
26
|
+
const list = [];
|
|
27
|
+
if (platform === 'darwin') list.push({ name: 'pdfkit', cmd: 'osascript', args: (file) => ['-l', 'JavaScript', '-e', JXA_PDF_TEXT, file] });
|
|
28
|
+
list.push({ name: 'pdftotext', cmd: 'pdftotext', args: (file) => ['-layout', '-q', file, '-'] });
|
|
29
|
+
return list;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readCvText(input, { platform = process.platform, exec = execFileSync, env = process.env } = {}) {
|
|
33
|
+
const file = expandHome(input, env);
|
|
34
|
+
if (typeof file !== 'string' || !path.isAbsolute(file) || !/\.pdf$/i.test(file)) return { ok: false, reason: 'not-a-pdf-path' };
|
|
35
|
+
let stat;
|
|
36
|
+
try {
|
|
37
|
+
stat = fs.statSync(file);
|
|
38
|
+
} catch {
|
|
39
|
+
return { ok: false, reason: 'not-found' };
|
|
40
|
+
}
|
|
41
|
+
if (!stat.isFile()) return { ok: false, reason: 'not-found' };
|
|
42
|
+
if (stat.size > MAX_BYTES) return { ok: false, reason: 'too-large' };
|
|
43
|
+
|
|
44
|
+
for (const extractor of extractors(platform)) {
|
|
45
|
+
let text;
|
|
46
|
+
try {
|
|
47
|
+
text = run(extractor.cmd, extractor.args(file), exec).trim();
|
|
48
|
+
} catch {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (!text) continue;
|
|
52
|
+
return { ok: true, extractor: extractor.name, truncated: text.length > MAX_CHARS, text: text.slice(0, MAX_CHARS) };
|
|
53
|
+
}
|
|
54
|
+
return { ok: false, reason: 'no-text-extracted' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { readCvText, MAX_CHARS };
|
package/src/env-paths.js
CHANGED
|
@@ -13,4 +13,18 @@ function getHomeDir(env = process.env) {
|
|
|
13
13
|
return readEnv(env, 'HOME_DIR') || os.homedir();
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// `~/Documents/cv.pdf` -> absolute, so a model can pass the short form it was shown.
|
|
17
|
+
function expandHome(file, env = process.env) {
|
|
18
|
+
if (typeof file !== 'string') return file;
|
|
19
|
+
if (file === '~') return getHomeDir(env);
|
|
20
|
+
if (file.startsWith('~/')) return require('path').join(getHomeDir(env), file.slice(2));
|
|
21
|
+
return file;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The short `~/...` form of a path under home, for showing (and copying) without typos.
|
|
25
|
+
function tildify(file, env = process.env) {
|
|
26
|
+
const home = getHomeDir(env);
|
|
27
|
+
return typeof file === 'string' && file.startsWith(`${home}/`) ? `~/${file.slice(home.length + 1)}` : file;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { getHomeDir, readEnv, expandHome, tildify };
|
package/src/i18n.js
CHANGED
|
@@ -86,7 +86,10 @@ const catalogs = {
|
|
|
86
86
|
availabilityAsk: 'Quieres indicar tu disponibilidad y ubicacion ahora? (s/n):',
|
|
87
87
|
availableAsk: 'Estas disponible para nuevos proyectos? (s/n):',
|
|
88
88
|
monthlyHoursAsk: 'Cuantas horas al mes?',
|
|
89
|
-
|
|
89
|
+
workModesAsk: 'En que modalidad quieres trabajar? (marca una o varias)',
|
|
90
|
+
workModeLabels: { REMOTE: 'Remoto', HYBRID: 'Hibrido', IN_PERSON: 'Presencial' },
|
|
91
|
+
workModesHint: 'Espacio para marcar/desmarcar · Enter para confirmar',
|
|
92
|
+
onlyRemoteNote: 'Has marcado solo Remoto: no veras proyectos hibridos ni presenciales.',
|
|
90
93
|
countryAsk: 'Pais (codigo ISO-2 de 2 letras, p.ej. ES):',
|
|
91
94
|
countryInvalid: 'Codigo de pais no valido. Usa 2 letras (p.ej. ES, US, GB).',
|
|
92
95
|
subdivisionAsk: 'Provincia o region (opcional, Enter para omitir):',
|
|
@@ -918,7 +921,7 @@ const catalogs = {
|
|
|
918
921
|
notFound: 'Aún no has definido tu disponibilidad.',
|
|
919
922
|
openToWork: (v) => `Abierto a trabajar: ${v}`,
|
|
920
923
|
monthlyHours: (v) => `Horas al mes: ${v}`,
|
|
921
|
-
|
|
924
|
+
workModes: (v) => `Modalidad: ${v}`,
|
|
922
925
|
location: (v) => `Ubicación: ${v}`,
|
|
923
926
|
expires: (v) => `Caduca: ${v}`,
|
|
924
927
|
yes: 'sí',
|
|
@@ -930,8 +933,11 @@ const catalogs = {
|
|
|
930
933
|
askOpenToWork: '¿Estás abierto a trabajar?',
|
|
931
934
|
askMonthlyHours: 'Horas al mes (número, p. ej. 40; Enter para no cambiar):',
|
|
932
935
|
monthlyHoursInvalid: 'Horas no válidas. Escribe solo el número (p. ej. 40).',
|
|
933
|
-
|
|
934
|
-
|
|
936
|
+
workModesAsk: 'En que modalidad quieres trabajar? (marca una o varias)',
|
|
937
|
+
workModeLabels: { REMOTE: 'Remoto', HYBRID: 'Hibrido', IN_PERSON: 'Presencial' },
|
|
938
|
+
workModesHint: 'Espacio para marcar/desmarcar · Enter para confirmar',
|
|
939
|
+
onlyRemoteNote: 'Has marcado solo Remoto: no veras proyectos hibridos ni presenciales.',
|
|
940
|
+
setDiff: (open, hours, modes) => `Nuevo: abierto=${open} · ${hours} h/mes · modalidad=${modes}`,
|
|
935
941
|
setConfirm: 'Esto actualiza tu perfil. ¿Confirmas? (s/n):',
|
|
936
942
|
setCancelled: 'Cancelado. No se cambió tu disponibilidad.',
|
|
937
943
|
setSaving: 'Guardando tu disponibilidad…',
|
|
@@ -1696,7 +1702,10 @@ const catalogs = {
|
|
|
1696
1702
|
availabilityAsk: 'Do you want to set your availability and location now? (y/n):',
|
|
1697
1703
|
availableAsk: 'Are you available for new projects? (y/n):',
|
|
1698
1704
|
monthlyHoursAsk: 'How many hours per month?',
|
|
1699
|
-
|
|
1705
|
+
workModesAsk: 'Which work modes do you want? (select one or more)',
|
|
1706
|
+
workModeLabels: { REMOTE: 'Remote', HYBRID: 'Hybrid', IN_PERSON: 'On-site' },
|
|
1707
|
+
workModesHint: 'Space to toggle · Enter to confirm',
|
|
1708
|
+
onlyRemoteNote: 'You selected Remote only: you will not see hybrid or on-site projects.',
|
|
1700
1709
|
countryAsk: 'Country (2-letter ISO-2 code, e.g. ES):',
|
|
1701
1710
|
countryInvalid: 'Invalid country code. Use 2 letters (e.g. ES, US, GB).',
|
|
1702
1711
|
subdivisionAsk: 'State or province (optional, Enter to skip):',
|
|
@@ -2520,7 +2529,7 @@ const catalogs = {
|
|
|
2520
2529
|
notFound: 'You have not set your availability yet.',
|
|
2521
2530
|
openToWork: (v) => `Open to work: ${v}`,
|
|
2522
2531
|
monthlyHours: (v) => `Monthly hours: ${v}`,
|
|
2523
|
-
|
|
2532
|
+
workModes: (v) => `Work modes: ${v}`,
|
|
2524
2533
|
location: (v) => `Location: ${v}`,
|
|
2525
2534
|
expires: (v) => `Expires: ${v}`,
|
|
2526
2535
|
yes: 'yes',
|
|
@@ -2532,8 +2541,11 @@ const catalogs = {
|
|
|
2532
2541
|
askOpenToWork: 'Are you open to work?',
|
|
2533
2542
|
askMonthlyHours: 'Monthly hours (number, e.g. 40; Enter to keep):',
|
|
2534
2543
|
monthlyHoursInvalid: 'Invalid hours. Type only the number (e.g. 40).',
|
|
2535
|
-
|
|
2536
|
-
|
|
2544
|
+
workModesAsk: 'Which work modes do you want? (select one or more)',
|
|
2545
|
+
workModeLabels: { REMOTE: 'Remote', HYBRID: 'Hybrid', IN_PERSON: 'On-site' },
|
|
2546
|
+
workModesHint: 'Space to toggle · Enter to confirm',
|
|
2547
|
+
onlyRemoteNote: 'You selected Remote only: you will not see hybrid or on-site projects.',
|
|
2548
|
+
setDiff: (open, hours, modes) => `New: open=${open} · ${hours} h/month · work modes=${modes}`,
|
|
2537
2549
|
setConfirm: 'This updates your profile. Confirm? (y/n):',
|
|
2538
2550
|
setCancelled: 'Cancelled. Your availability was not changed.',
|
|
2539
2551
|
setSaving: 'Saving your availability…',
|
|
@@ -30,7 +30,7 @@ function makeAvailabilityTools(deps = {}) {
|
|
|
30
30
|
return [
|
|
31
31
|
{
|
|
32
32
|
name: 'get_availability',
|
|
33
|
-
description: "View the talent's availability: open-to-work, monthly hours,
|
|
33
|
+
description: "View the talent's availability: open-to-work, monthly hours, work modes (a combinable array of REMOTE/HYBRID/IN_PERSON), and location. Read-only. Requires an active session. Any internal ids in the result (e.g. numeric city id) are handles for tools only — never show or mention them to the talent.",
|
|
34
34
|
inputSchema: GET_AVAILABILITY_SCHEMA,
|
|
35
35
|
handler: getAvailability,
|
|
36
36
|
},
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const {
|
|
4
4
|
FREELANCE_TYPES, WORK_SITUATIONS, EMPLOYMENT_PARTICIPATIONS, FREELANCE_OPINIONS,
|
|
5
|
-
CHANGE_MOTIVATORS, MONTHLY_HOURS, LANGUAGE_CODES, LANGUAGE_LEVELS,
|
|
5
|
+
CHANGE_MOTIVATORS, WORK_MODES, MONTHLY_HOURS, LANGUAGE_CODES, LANGUAGE_LEVELS,
|
|
6
6
|
} = require('./onboarding-flow');
|
|
7
7
|
const { getCatalog } = require('./i18n');
|
|
8
8
|
const { TranscriptBuffer } = require('./interview-transcript');
|
|
@@ -55,11 +55,11 @@ function buildSchemas(lang) {
|
|
|
55
55
|
|
|
56
56
|
const AVAILABILITY_SCHEMA = {
|
|
57
57
|
type: 'object',
|
|
58
|
-
description: `Availability AND location (ADR-028). ALWAYS ask this — it must not be skipped. There is NO separate location field: the talent's LOCATION (country, timezone, region) lives here. Offer the options: available (yes/no); monthlyHours must be one of ${MONTHLY_HOURS_QUOTED};
|
|
58
|
+
description: `Availability AND location (ADR-028). ALWAYS ask this — it must not be skipped. There is NO separate location field: the talent's LOCATION (country, timezone, region) lives here. Offer the options: available (yes/no); monthlyHours must be one of ${MONTHLY_HOURS_QUOTED}; workModes is a MULTI-VALUE array, any combination of ${WORK_MODES.join('/')} (the talent can pick several); longFullTimeProjects (yes/no — open to long full-time projects). For location, ask the talent their country and timezone (region is optional). All fields optional/nullable, but collect what you can. monthlyHours is a STRICT enum string (not an int). city is not collected here.`,
|
|
59
59
|
properties: {
|
|
60
60
|
available: { type: 'boolean', description: 'Whether the talent is currently available (yes/no).' },
|
|
61
61
|
monthlyHours: { type: 'string', enum: MONTHLY_HOURS, description: `Monthly hours the talent can work, exactly one of: ${MONTHLY_HOURS_QUOTED} (send '> 160' verbatim, with the space).` },
|
|
62
|
-
|
|
62
|
+
workModes: { type: 'array', items: { type: 'string', enum: WORK_MODES }, description: `Work modes the talent accepts — a combinable array, any subset of ${WORK_MODES.join('/')} (e.g. ['REMOTE','HYBRID']). Only REMOTE means the talent will not see hybrid or on-site projects.` },
|
|
63
63
|
country: { type: 'string', description: 'LOCATION — ISO-2 country code, 2 letters (e.g. ES, US, GB).' },
|
|
64
64
|
timezone: { type: 'string', description: 'LOCATION — IANA timezone (e.g. Europe/Madrid, America/New_York). Max 100 chars.' },
|
|
65
65
|
subdivision: { type: 'string', description: 'LOCATION — optional region/state code (e.g. ES-CT). Max 10 chars.' },
|
|
@@ -81,14 +81,22 @@ function buildSchemas(lang) {
|
|
|
81
81
|
required: ['name', 'lastName', 'email', 'freelanceType', 'profile', 'workSituation', 'pricing'],
|
|
82
82
|
};
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
// Same shape as REGISTER_SCHEMA but NOTHING is required: the preview accepts a
|
|
85
|
+
// partial, pre-filled payload and reports what is still missing.
|
|
86
|
+
const PREVIEW_SCHEMA = {
|
|
87
|
+
type: 'object',
|
|
88
|
+
description: 'A partial, pre-filled register payload to REVIEW before writing. Every field is optional — fill only what the talent has said or what is verifiable from context, omit the rest. Returns what WOULD be written, what is still missing to ask, and any invalid values. Writes nothing.',
|
|
89
|
+
properties: { ...REGISTER_SCHEMA.properties },
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return { ACCOUNT_FIELDS, WORK_SITUATION_SCHEMA, LANGUAGES_SCHEMA, AVAILABILITY_SCHEMA, REGISTER_SCHEMA, PREVIEW_SCHEMA };
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
// The register tool's own long description, per language: the batched
|
|
88
96
|
// collection guide with friendly labels inline for every choice field.
|
|
89
97
|
function buildRegisterDescription(lang) {
|
|
90
98
|
const L = getCatalog(lang).onboarding;
|
|
91
|
-
return "The talent registers THEMSELVES on Shakers (self-service; no prior session needed). Your job is to relay the non-secret profile details the talent gives you in the chat as arguments. Credentials never touch the chat or any tool argument: the tool opens a sign-up window on the talent's own machine (127.0.0.1) where they either Continue with Google (no password at all) or type an email and password directly into that local window.\n\nCOLLECT THE DETAILS IN A FEW GROUPED BATCHES, not one field at a time, and for every CHOICE field PRESENT THE VALID OPTIONS so the talent just picks one. For every choice field SHOW THE TALENT THE FRIENDLY LABEL, never the raw code (e.g. show 'Nativo', not 'NATIVE'), and pass the code as the value. Suggested batches:\n1. Identity — name, lastName, email (optional: preferredLanguage, newsletterConsent).\n2. Profile — `profile.linkedinUrl` (required source; optional GitHub/website/CV path).\n3. Work — freelanceType (offer: "
|
|
99
|
+
return "The talent registers THEMSELVES on Shakers (self-service; no prior session needed). Your job is to relay the non-secret profile details the talent gives you in the chat as arguments. Credentials never touch the chat or any tool argument: the tool opens a sign-up window on the talent's own machine (127.0.0.1) where they either Continue with Google (no password at all) or type an email and password directly into that local window.\n\nSAVE THE TALENT TYPING: first call suggest_register_context (timezone, local git identity, GitHub login, CV files) and PROPOSE those values for them to confirm; then call register_preview with the partial payload to get a review (what would be written, what is still missing) and show the talent a short summary to confirm or correct BEFORE calling register. Pre-fill EVERY field you can reasonably infer from what you know about the talent (your memory of past conversations, the chat, context) — including `pricing` and `workSituation.changeMotivators`. PRICING: whenever you know anything about their role, seniority, stack or location, ALWAYS propose a concrete per-project amount yourself (full-time and/or part-time, in their currency), labelled as your estimate — do not hand the question back to the talent; only ask for it when you know nothing at all about their work. MOTIVATORS: pick the one that best matches what they have told you. What you know about the person outranks suggest_register_context: if the git identity does not match the person you know, ignore it. For each inferred value briefly say where it comes from. Ask ONLY for what you genuinely have no basis for. Use suggest_register_context's githubUrl and cvCandidates to propose profile.githubUrl and profile.cvPath (cvCandidates often include OTHER people's CVs, e.g. candidates they downloaded: propose a CV ONLY if its file name matches the talent's name — nameMatch:true or a name you know — and never one named after someone else; if none matches but there is a generic:true one like cv.pdf, ASK the talent whether it is theirs and whether you may read it and send it with the registration; otherwise ask for the path). Once the talent confirms a CV and allows it, ALWAYS call read_cv BEFORE register_preview and use its text to pre-fill or correct the other fields (languages and levels, experience, location); if the CV adds or changes any value, show the talent just those changes and get their OK before calling register; pass paths exactly as given, the short ~/ form is fine. For optional fields you have no basis for (phone, website, newsletter), do not ask one by one: add a single closing line offering them (e.g. '¿Quieres añadir teléfono o web?'). The goal is that the talent only has to answer 'sí' or correct a value. Never write without the talent's confirmation of the summary. All of this is self-declared profile data.\n\nCOLLECT THE DETAILS IN A FEW GROUPED BATCHES, not one field at a time, and for every CHOICE field PRESENT THE VALID OPTIONS so the talent just picks one. For every choice field SHOW THE TALENT THE FRIENDLY LABEL, never the raw code (e.g. show 'Nativo', not 'NATIVE'), and pass the code as the value. Suggested batches:\n1. Identity — name, lastName, email (optional: preferredLanguage, newsletterConsent).\n2. Profile — `profile.linkedinUrl` (required source; optional GitHub/website/CV path).\n3. Work — freelanceType (offer: "
|
|
92
100
|
+ labelledOptions(FREELANCE_TYPES, L.freelanceTypeLabels)
|
|
93
101
|
+ ") and `workSituation` (offer situation: "
|
|
94
102
|
+ labelledOptions(WORK_SITUATIONS, L.workSituationLabels)
|
|
@@ -100,7 +108,7 @@ function buildRegisterDescription(lang) {
|
|
|
100
108
|
+ labelledOptions(CHANGE_MOTIVATORS, L.changeMotivatorLabels)
|
|
101
109
|
+ " when the talent is or is considering freelance).\n4. Pricing — per-PROJECT price; currency USD, EUR or GBP.\n5. Availability + languages — `availability` (offer monthlyHours "
|
|
102
110
|
+ MONTHLY_HOURS_SLASHED
|
|
103
|
-
+ ",
|
|
111
|
+
+ ", workModes as a combinable array of " + WORK_MODES.join('/') + ", longFullTimeProjects yes/no, and LOCATION: country as ISO-2 e.g. ES, timezone as IANA e.g. Europe/Madrid) and `languages` (offer "
|
|
104
112
|
+ labelledOptions(LANGUAGE_CODES, L.languageNames)
|
|
105
113
|
+ ", each with a level "
|
|
106
114
|
+ labelledOptions(LANGUAGE_LEVELS, L.languageLevelLabels)
|
|
@@ -121,7 +129,7 @@ const PROFILE_SCHEMA = {
|
|
|
121
129
|
type: 'object',
|
|
122
130
|
properties: {
|
|
123
131
|
linkedinUrl: { type: 'string', description: "The talent's LinkedIn URL. Required — it is the mandatory profile source." },
|
|
124
|
-
cvPath: { type: 'string', description: '
|
|
132
|
+
cvPath: { type: 'string', description: 'Path to the CV (PDF/DOC/DOCX, max 10 MB) on this machine, absolute or ~/... (optional). Only a CV the talent confirmed is theirs. The FILE is uploaded to Shakers with the profile import.' },
|
|
125
133
|
githubUrl: { type: 'string', description: 'GitHub URL (optional).' },
|
|
126
134
|
websiteUrl: { type: 'string', description: 'Personal website URL (optional).' },
|
|
127
135
|
},
|
|
@@ -143,7 +151,7 @@ const PRICING_SCHEMA = {
|
|
|
143
151
|
|
|
144
152
|
// Default (English) static exports for callers/tests that read the module-level
|
|
145
153
|
// schemas directly. The live tools rebuild these per session language.
|
|
146
|
-
const { ACCOUNT_FIELDS, WORK_SITUATION_SCHEMA, LANGUAGES_SCHEMA, AVAILABILITY_SCHEMA, REGISTER_SCHEMA } = buildSchemas('en');
|
|
154
|
+
const { ACCOUNT_FIELDS, WORK_SITUATION_SCHEMA, LANGUAGES_SCHEMA, AVAILABILITY_SCHEMA, REGISTER_SCHEMA, PREVIEW_SCHEMA } = buildSchemas('en');
|
|
147
155
|
|
|
148
156
|
const STATUS_SCHEMA = {
|
|
149
157
|
type: 'object',
|
|
@@ -172,7 +180,7 @@ const INTERVIEW_TURN_SCHEMA = {
|
|
|
172
180
|
type: 'object',
|
|
173
181
|
properties: {
|
|
174
182
|
interviewId: { type: 'string', description: 'Internal handle for this interview — for calling the interview tools ONLY; never shown or mentioned to the talent.' },
|
|
175
|
-
message: { type: 'string', description: "The talent's
|
|
183
|
+
message: { type: 'string', description: "The talent's answer to the current question: in conduit mode their REAL, VERBATIM words; in AI-drafted mode (only if the talent opted in) the answer they APPROVED after seeing your draft, with their edits. Never a value they have not seen and confirmed. If they have not answered or approved yet, do NOT call this." },
|
|
176
184
|
},
|
|
177
185
|
required: ['interviewId', 'message'],
|
|
178
186
|
};
|
|
@@ -446,6 +454,26 @@ function makeRegisterTools(deps = {}) {
|
|
|
446
454
|
};
|
|
447
455
|
}
|
|
448
456
|
|
|
457
|
+
// PURE preview: reuses the write-path validators, touches no network, no session.
|
|
458
|
+
async function registerPreview(args = {}) {
|
|
459
|
+
return flow.buildRegisterPreview(args || {});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Content-free context the model may PROPOSE to the talent (never auto-write).
|
|
463
|
+
async function suggestContext() {
|
|
464
|
+
const read = deps.readRegisterContextSuggestions || require('./register-context').readRegisterContextSuggestions;
|
|
465
|
+
return { ok: true, suggestions: read({}), note: 'Propose these to the talent to confirm; never write them without confirmation.' };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Text of the CV the talent confirmed as theirs, read only with their permission.
|
|
469
|
+
async function readCv(args = {}) {
|
|
470
|
+
if (args.talentConsent !== true) return { ok: false, reason: 'consent-required', message: 'Ask the talent first whether this CV is theirs and whether you may read it; call again with talentConsent:true only after they say yes.' };
|
|
471
|
+
const read = deps.readCvText || require('./cv-text').readCvText;
|
|
472
|
+
const result = read(args.path);
|
|
473
|
+
if (!result.ok) return { ...result, message: 'Could not read the CV text here. Ask the talent to attach the CV to the chat, or to tell you what it says.' };
|
|
474
|
+
return result;
|
|
475
|
+
}
|
|
476
|
+
|
|
449
477
|
return [
|
|
450
478
|
{
|
|
451
479
|
name: 'register',
|
|
@@ -453,6 +481,31 @@ function makeRegisterTools(deps = {}) {
|
|
|
453
481
|
inputSchema: schemas.REGISTER_SCHEMA,
|
|
454
482
|
handler: register,
|
|
455
483
|
},
|
|
484
|
+
{
|
|
485
|
+
name: 'register_preview',
|
|
486
|
+
description: "REVIEW a (partial) register payload BEFORE writing — the preferred way to register with the least typing. Fill every field you can reasonably infer, then call this. It writes NOTHING; it returns { wouldWrite: the normalized data per hub endpoint, missingFields: what is still needed (required:true) or just suggested (required:false) to ASK the talent, warnings: any value that is invalid, mustConfirm }. Show the talent a short human summary of wouldWrite and the missing items, let them confirm or correct, then call `register` with the final payload. Pre-fill every field you can reasonably infer (memory, chat, context), including pricing and changeMotivators; omit only what you have no basis for. The talent confirms the whole summary once; fields in `mustConfirm` just need to be visible in that summary, not asked separately. All register data is self-declared profile data.",
|
|
487
|
+
inputSchema: schemas.PREVIEW_SCHEMA,
|
|
488
|
+
handler: registerPreview,
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
name: 'suggest_register_context',
|
|
492
|
+
description: "Get local SUGGESTIONS to speed up registration: the environment timezone, the local git identity (user.name / user.email), the GitHub login the gh CLI is signed in with (githubUser/githubUrl), and cvCandidates — PDF files in Desktop/Documents/Downloads whose NAME looks like a CV (names only, never read), each with nameMatch (file name contains the git name). Only propose a CV that is clearly the talent's own; never propose one named after someone else. A generic:true name (cv.pdf, curriculum_2024.pdf) may be theirs: ask 'is this CV yours? may I read it and send it with your registration?'. These are PROPOSALS only — present them to the talent to confirm (e.g. 'I see your timezone is Europe/Madrid and your git email is X — use these?') and never write any of them without the talent's confirmation. No session needed. Use it before register_preview to pre-fill availability.timezone, name/email, profile.githubUrl and profile.cvPath.",
|
|
493
|
+
inputSchema: { type: 'object', properties: {} },
|
|
494
|
+
handler: suggestContext,
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
name: 'read_cv',
|
|
498
|
+
description: "Read the TEXT of the talent's CV PDF so you can pre-fill the register and the onboarding from what it says (experience, projects, stack, languages, education). Only for a CV the talent has CONFIRMED is theirs, and only after they gave you PERMISSION to read it — set talentConsent:true only then. Typical path: one of suggest_register_context's cvCandidates. Local only: the file is read on this machine and its text comes back to you; nothing is uploaded by this tool (the CV is sent to Shakers only if the talent also confirms it as profile.cvPath in register). Returns { ok, text, truncated } or { ok:false, reason } — on failure ask the talent to attach the CV to the chat.",
|
|
499
|
+
inputSchema: {
|
|
500
|
+
type: 'object',
|
|
501
|
+
properties: {
|
|
502
|
+
path: { type: 'string', description: 'Path to the PDF the talent confirmed as their CV, exactly as suggest_register_context gave it (the short ~/ form is fine) or absolute.' },
|
|
503
|
+
talentConsent: { type: 'boolean', description: 'true ONLY after the talent explicitly agreed that this is their CV and that you may read it.' },
|
|
504
|
+
},
|
|
505
|
+
required: ['path', 'talentConsent'],
|
|
506
|
+
},
|
|
507
|
+
handler: readCv,
|
|
508
|
+
},
|
|
456
509
|
{
|
|
457
510
|
name: 'register_status',
|
|
458
511
|
description: 'Check the state of the background profile import started by register. Pass the importJobId (an internal handle — never shown to the talent). Returns { state } (e.g. running | done | failed).',
|
|
@@ -461,7 +514,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
461
514
|
},
|
|
462
515
|
{
|
|
463
516
|
name: 'onboarding_interview_start',
|
|
464
|
-
description: "Start the onboarding interview (step 5, optional). Runs over LiveKit: the server joins a live room and the agent conducts it, transparently resolving the branch (role/dedication only when AI-usage evidence exists, otherwise the AI-usage blocks too) — the client does NOT decide the branch. You MUST first show the talent BOTH interview disclaimers separately (from register's disclaimers.interview) and only then call with disclaimerAcknowledged:true. Returns { interviewId, greeting, ended }: relay the greeting to the talent VERBATIM, then drive the interview with onboarding_interview_turn.
|
|
517
|
+
description: "Start the onboarding interview (step 5, optional). Runs over LiveKit: the server joins a live room and the agent conducts it, transparently resolving the branch (role/dedication only when AI-usage evidence exists, otherwise the AI-usage blocks too) — the client does NOT decide the branch. You MUST first show the talent BOTH interview disclaimers separately (from register's disclaimers.interview) and only then call with disclaimerAcknowledged:true. Returns { interviewId, greeting, ended }: relay the greeting to the talent VERBATIM, then drive the interview with onboarding_interview_turn. ANSWER MODE — before calling this, ASK the talent once: do they want you to draft their answers from what you already know about them (your memory of past conversations, the chat, the register data), so they only confirm, correct or add their opinion? (1) If they say YES (AI-drafted mode): FIRST gather everything you can know about them — your memory, a search of your past conversations with them if you have such a tool, their CV (read_cv, with their permission, if not read yet), their AI-usage report and the register data — and show ONE short sheet 'esto es lo que sé de ti' (roles and projects with the concrete facts, stack, languages, what they want next). Let them correct it once; those corrections override your memory for every later answer. Then for every interview question show the question VERBATIM and your proposed answer in the first person: one or two short sentences in their plain voice, built only from facts on the confirmed sheet — no embellishment, no adjectives, metrics or claims they did not give, no repeating a fact already said unless asked. If the sheet does not cover the question, do not guess a fact: ask them briefly. Ask them to confirm, edit or add their view. Send ONLY the version they approve — their edits and opinions included — never a draft they have not seen. For questions about wishes, preferences or opinions, give your best guess but make clear it is a guess. (2) If they say NO: you are a pure CONDUIT — show every question VERBATIM and pass only the talent's REAL, VERBATIM words back; do not opine, guide or answer for them. In both modes never reword, summarize or translate the interview's questions. `interviewId` is an internal handle for the interview tools only — never show or mention it to the talent. If it returns reason 'livekit-not-installed', relay the `message` (it tells the talent how to install the optional @livekit/rtc-node package). If the talent declines the interview, do not call this — instead run the final main-role step (list_my_roles + set_main_role) and offer them their profile URL from register. If the talent has ALREADY completed onboarding this refuses with an already-completed notice unless confirmRepeat:true; note a completed onboarding interview cannot actually be restarted (it returns reason 'interview-already-started') — in that case just run set_main_role.",
|
|
465
518
|
inputSchema: INTERVIEW_START_SCHEMA,
|
|
466
519
|
handler: interviewStart,
|
|
467
520
|
available: async () => {
|
|
@@ -474,7 +527,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
474
527
|
},
|
|
475
528
|
{
|
|
476
529
|
name: 'onboarding_interview_turn',
|
|
477
|
-
description: "Send the talent's answer to the current onboarding-interview question and get the next one.
|
|
530
|
+
description: "Send the talent's answer to the current onboarding-interview question and get the next one. Follow the ANSWER MODE the talent chose at onboarding_interview_start. AI-drafted mode: `message` is the answer the talent APPROVED after seeing your draft (with their edits/opinion); never send a draft they have not confirmed. Conduit mode: `message` is ONLY the talent's REAL, VERBATIM answer — never invent, infer or complete it. In both modes, if the talent has not answered or approved yet, do NOT call this, wait for them. Relay the returned `response` (the next question) to the talent VERBATIM — do not reword, summarize or translate it. Keep calling with their next `message` until `ended` is true, then call onboarding_interview_complete. Do NOT show or mention `interviewId` to the talent (internal handle). If it returns reason 'unknown-interview' (the server was restarted and the live room is gone), start again with onboarding_interview_start.",
|
|
478
531
|
inputSchema: INTERVIEW_TURN_SCHEMA,
|
|
479
532
|
handler: interviewTurn,
|
|
480
533
|
},
|
|
@@ -503,6 +556,7 @@ module.exports = {
|
|
|
503
556
|
makeRegisterTools,
|
|
504
557
|
ACCOUNT_FIELDS,
|
|
505
558
|
REGISTER_SCHEMA,
|
|
559
|
+
PREVIEW_SCHEMA,
|
|
506
560
|
STATUS_SCHEMA,
|
|
507
561
|
INTERVIEW_START_SCHEMA,
|
|
508
562
|
INTERVIEW_TURN_SCHEMA,
|
package/src/onboarding-client.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { postJsonWithTimeout, postMultipartWithTimeout } = require('./backend-request');
|
|
4
6
|
const { WORKS_CODE_TO_REASON } = require('./skills-client');
|
|
5
7
|
const { toLowerLang, toUpperLang } = require('./lang-codes');
|
|
8
|
+
const { expandHome } = require('./env-paths');
|
|
6
9
|
|
|
7
10
|
const DEFAULT_TIMEOUT_MS = 20000;
|
|
8
11
|
const IMPORT_KICKOFF_TIMEOUT_MS = 90000;
|
|
@@ -48,6 +51,28 @@ function buildImportSources({ linkedinUrl, cvPath, githubUrl, websiteUrl, langua
|
|
|
48
51
|
return sources;
|
|
49
52
|
}
|
|
50
53
|
|
|
54
|
+
// The CV formats and size hub accepts on import-profile.
|
|
55
|
+
const CV_CONTENT_TYPES = {
|
|
56
|
+
'.pdf': 'application/pdf',
|
|
57
|
+
'.doc': 'application/msword',
|
|
58
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
59
|
+
};
|
|
60
|
+
const CV_MAX_BYTES = 10 * 1024 * 1024;
|
|
61
|
+
|
|
62
|
+
function readCvUpload(input) {
|
|
63
|
+
const cvPath = expandHome(input);
|
|
64
|
+
const contentType = CV_CONTENT_TYPES[path.extname(cvPath).toLowerCase()];
|
|
65
|
+
if (!contentType) return { ok: false, reason: 'cv-bad-format' };
|
|
66
|
+
let data;
|
|
67
|
+
try {
|
|
68
|
+
data = fs.readFileSync(cvPath);
|
|
69
|
+
} catch {
|
|
70
|
+
return { ok: false, reason: 'cv-not-found' };
|
|
71
|
+
}
|
|
72
|
+
if (data.length > CV_MAX_BYTES) return { ok: false, reason: 'cv-too-large' };
|
|
73
|
+
return { ok: true, file: { filename: path.basename(cvPath), contentType, data } };
|
|
74
|
+
}
|
|
75
|
+
|
|
51
76
|
async function requestImportProfile(
|
|
52
77
|
{ linkedinUrl, cvPath, githubUrl, websiteUrl, language, accessToken, hubAccessToken } = {},
|
|
53
78
|
{ endpoint, timeoutMs = IMPORT_KICKOFF_TIMEOUT_MS } = {},
|
|
@@ -59,7 +84,16 @@ async function requestImportProfile(
|
|
|
59
84
|
const body = buildImportSources({ linkedinUrl, cvPath, githubUrl, websiteUrl, language });
|
|
60
85
|
let res;
|
|
61
86
|
try {
|
|
62
|
-
|
|
87
|
+
if (body.cvPath) {
|
|
88
|
+
// Hub reads the CV as an uploaded `cv` file (multipart), never as a local
|
|
89
|
+
// path: a path on the talent's machine means nothing to the server.
|
|
90
|
+
const cv = readCvUpload(body.cvPath);
|
|
91
|
+
if (!cv.ok) return cv;
|
|
92
|
+
const { cvPath: _localPath, ...fields } = body;
|
|
93
|
+
res = await postMultipartWithTimeout(endpoint, { ...fields, cv: cv.file }, timeoutMs, 'POST', hubHeaders({ hubAccessToken }));
|
|
94
|
+
} else {
|
|
95
|
+
res = await postJsonWithTimeout(endpoint, body, timeoutMs, 'POST', null, hubHeaders({ hubAccessToken }));
|
|
96
|
+
}
|
|
63
97
|
} catch (e) {
|
|
64
98
|
return { ok: false, reason: (e && e.kind) || 'network-error' };
|
|
65
99
|
}
|
package/src/onboarding-flow.js
CHANGED
|
@@ -10,6 +10,8 @@ const WORK_SITUATIONS = ['FREELANCE', 'EMPLOYED', 'BETWEEN_JOBS', 'STUDYING', 'O
|
|
|
10
10
|
const EMPLOYMENT_PARTICIPATIONS = ['FULL_TIME', 'PART_TIME'];
|
|
11
11
|
const FREELANCE_OPINIONS = ['WAS_FREELANCE_BEFORE', 'OPEN_TO_FREELANCE', 'NOT_INTERESTED'];
|
|
12
12
|
const CHANGE_MOTIVATORS = ['HIGHER_RATE', 'SPECIFIC_PROJECT', 'LEARNING_CERTIFICATION', 'FLEXIBILITY', 'COMMUNITY', 'LIFESTYLE_CHANGE'];
|
|
13
|
+
// Work modes are combinable (multi-value array), mirroring the hub availability contract.
|
|
14
|
+
const WORK_MODES = ['REMOTE', 'HYBRID', 'IN_PERSON'];
|
|
13
15
|
const LANGUAGE_LEVELS = ['INTERMEDIATE_WRITTEN', 'INTERMEDIATE', 'ADVANCED', 'NATIVE'];
|
|
14
16
|
const LANGUAGE_CODES = ['es', 'en', 'fr', 'ca', 'de', 'eu', 'it', 'pt'];
|
|
15
17
|
|
|
@@ -217,7 +219,17 @@ function validateAvailability(input = {}) {
|
|
|
217
219
|
if (!mh) return { ok: false, reason: 'bad-monthly-hours' };
|
|
218
220
|
body.monthlyHours = mh;
|
|
219
221
|
}
|
|
220
|
-
|
|
222
|
+
// workModes is a multi-value array (REMOTE/HYBRID/IN_PERSON); onlyRemote is derived server-side and no longer sent.
|
|
223
|
+
if (input.workModes != null) {
|
|
224
|
+
if (!Array.isArray(input.workModes)) return { ok: false, reason: 'bad-work-modes' };
|
|
225
|
+
const modes = [];
|
|
226
|
+
for (const raw of input.workModes) {
|
|
227
|
+
const mode = typeof raw === 'string' ? raw.trim().toUpperCase() : '';
|
|
228
|
+
if (!WORK_MODES.includes(mode)) return { ok: false, reason: 'bad-work-modes' };
|
|
229
|
+
if (!modes.includes(mode)) modes.push(mode);
|
|
230
|
+
}
|
|
231
|
+
if (modes.length) body.workModes = modes;
|
|
232
|
+
}
|
|
221
233
|
if (input.country != null && input.country !== '') {
|
|
222
234
|
const cc = normalizeCountry(input.country);
|
|
223
235
|
if (!cc) return { ok: false, reason: 'bad-country' };
|
|
@@ -229,6 +241,120 @@ function validateAvailability(input = {}) {
|
|
|
229
241
|
return { ok: true, availability: body };
|
|
230
242
|
}
|
|
231
243
|
|
|
244
|
+
// Fields the talent must ALWAYS confirm explicitly — never inferred/invented by
|
|
245
|
+
// the model (a wrong price or motivation is worse than a gap).
|
|
246
|
+
const MUST_CONFIRM_FIELDS = ['pricing', 'workSituation.changeMotivators'];
|
|
247
|
+
|
|
248
|
+
// PURE preview of a (possibly partial) register payload: what WOULD be written,
|
|
249
|
+
// what is still missing, and what is invalid — reusing the same validators the
|
|
250
|
+
// write path uses. Touches no network and needs no session, so the model can show
|
|
251
|
+
// the talent a review before the single `register` write.
|
|
252
|
+
function buildRegisterPreview(args = {}) {
|
|
253
|
+
const wouldWrite = [];
|
|
254
|
+
const missingFields = [];
|
|
255
|
+
const warnings = [];
|
|
256
|
+
const addMissing = (field, required) => missingFields.push({ field, required });
|
|
257
|
+
|
|
258
|
+
// --- account (always written at registration; password never in the payload) ---
|
|
259
|
+
const account = {
|
|
260
|
+
name: args.name,
|
|
261
|
+
lastName: args.lastName,
|
|
262
|
+
email: args.email,
|
|
263
|
+
freelanceType: args.freelanceType,
|
|
264
|
+
};
|
|
265
|
+
if (!isNonEmptyString(account.name)) addMissing('name', true);
|
|
266
|
+
if (!isNonEmptyString(account.lastName)) addMissing('lastName', true);
|
|
267
|
+
if (!isNonEmptyString(account.email)) addMissing('email', true);
|
|
268
|
+
const ftype = normalizeFreelanceType(account.freelanceType);
|
|
269
|
+
if (!ftype) {
|
|
270
|
+
if (isNonEmptyString(account.freelanceType)) warnings.push({ field: 'freelanceType', reason: 'bad-freelance-type' });
|
|
271
|
+
else addMissing('freelanceType', true);
|
|
272
|
+
}
|
|
273
|
+
wouldWrite.push({
|
|
274
|
+
section: 'account',
|
|
275
|
+
endpoint: 'hub: auth/sign-up/email + works/auth/complete-registration',
|
|
276
|
+
data: {
|
|
277
|
+
name: isNonEmptyString(account.name) ? account.name : null,
|
|
278
|
+
lastName: isNonEmptyString(account.lastName) ? account.lastName : null,
|
|
279
|
+
email: isNonEmptyString(account.email) ? account.email : null,
|
|
280
|
+
freelanceType: ftype || null,
|
|
281
|
+
preferredLanguage: isNonEmptyString(args.preferredLanguage) ? args.preferredLanguage : null,
|
|
282
|
+
newsletterConsent: args.newsletterConsent === true,
|
|
283
|
+
},
|
|
284
|
+
note: 'password is never part of this payload — the talent types it in the local sign-up window',
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// --- profile import (linkedinUrl is the required source) ---
|
|
288
|
+
const profile = args.profile && typeof args.profile === 'object' ? args.profile : {};
|
|
289
|
+
if (!isNonEmptyString(profile.linkedinUrl)) addMissing('profile.linkedinUrl', true);
|
|
290
|
+
else {
|
|
291
|
+
const pdata = { linkedinUrl: profile.linkedinUrl };
|
|
292
|
+
for (const k of ['githubUrl', 'websiteUrl', 'cvPath']) if (isNonEmptyString(profile[k])) pdata[k] = profile[k];
|
|
293
|
+
wouldWrite.push({ section: 'profile', endpoint: 'hub: works/me/import-profile', data: pdata });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// --- work situation (situation required) ---
|
|
297
|
+
const ws = args.workSituation && typeof args.workSituation === 'object' ? args.workSituation : {};
|
|
298
|
+
if (!isNonEmptyString(ws.situation)) addMissing('workSituation.situation', true);
|
|
299
|
+
else {
|
|
300
|
+
const vws = validateWorkSituation(ws);
|
|
301
|
+
if (!vws.ok) warnings.push({ field: 'workSituation', reason: vws.reason });
|
|
302
|
+
else wouldWrite.push({ section: 'workSituation', endpoint: 'hub: works/me/professional-details', data: vws.professionalDetails });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// --- pricing (at least one variant; ALWAYS confirm) ---
|
|
306
|
+
const pricing = args.pricing && typeof args.pricing === 'object' ? args.pricing : {};
|
|
307
|
+
const pricingEmpty = pricing.fullTimeSelected !== true && pricing.partTimeSelected !== true
|
|
308
|
+
&& pricing.fullTimeAmount == null && pricing.partTimeAmount == null;
|
|
309
|
+
if (pricingEmpty) addMissing('pricing', true);
|
|
310
|
+
else {
|
|
311
|
+
const vp = validatePricing(pricing);
|
|
312
|
+
if (!vp.ok) warnings.push({ field: 'pricing', reason: vp.reason });
|
|
313
|
+
else wouldWrite.push({ section: 'pricing', endpoint: 'hub: works/talents/me/work-details/pricing-rate', data: vp.pricing });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// --- availability (optional; suggest the sub-fields the talent did not give) ---
|
|
317
|
+
if (args.availability && typeof args.availability === 'object') {
|
|
318
|
+
const va = validateAvailability(args.availability);
|
|
319
|
+
if (!va.ok) warnings.push({ field: 'availability', reason: va.reason });
|
|
320
|
+
else {
|
|
321
|
+
wouldWrite.push({ section: 'availability', endpoint: 'hub: works/talents/me/work-details/availability', data: va.availability });
|
|
322
|
+
for (const k of ['available', 'monthlyHours', 'workModes', 'country', 'timezone']) {
|
|
323
|
+
if (!(k in va.availability)) addMissing(`availability.${k}`, false);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
} else {
|
|
327
|
+
addMissing('availability', false);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// --- languages (optional; ids resolve at write-time, so the preview shows codes) ---
|
|
331
|
+
if (Array.isArray(args.languages) && args.languages.length > 0) {
|
|
332
|
+
const vl = validateLanguageRows(args.languages);
|
|
333
|
+
if (!vl.ok) warnings.push({ field: 'languages', reason: vl.reason });
|
|
334
|
+
else wouldWrite.push({ section: 'languages', endpoint: 'hub: works/talents/me/work-details/languages', data: vl.rows, note: 'language codes resolve to ids at write time' });
|
|
335
|
+
} else {
|
|
336
|
+
addMissing('languages', false);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// --- phone (optional) ---
|
|
340
|
+
if (args.phone && typeof args.phone === 'object') {
|
|
341
|
+
const vph = validatePhone(args.phone);
|
|
342
|
+
if (!vph.ok) warnings.push({ field: 'phone', reason: vph.reason });
|
|
343
|
+
else wouldWrite.push({ section: 'phone', endpoint: 'hub: works/me', data: { telephoneCode: vph.telephoneCode, telephoneNumber: vph.telephoneNumber } });
|
|
344
|
+
} else {
|
|
345
|
+
addMissing('phone', false);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
ok: true,
|
|
350
|
+
provenance: 'self-declared',
|
|
351
|
+
wouldWrite,
|
|
352
|
+
missingFields,
|
|
353
|
+
warnings,
|
|
354
|
+
mustConfirm: MUST_CONFIRM_FIELDS,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
232
358
|
function makeDeps(overrides = {}) {
|
|
233
359
|
const config = require('./config');
|
|
234
360
|
const client = require('./onboarding-client');
|
|
@@ -1025,6 +1151,7 @@ module.exports = {
|
|
|
1025
1151
|
EMPLOYMENT_PARTICIPATIONS,
|
|
1026
1152
|
FREELANCE_OPINIONS,
|
|
1027
1153
|
CHANGE_MOTIVATORS,
|
|
1154
|
+
WORK_MODES,
|
|
1028
1155
|
LANGUAGE_LEVELS,
|
|
1029
1156
|
LANGUAGE_CODES,
|
|
1030
1157
|
normalizeFreelanceType,
|
|
@@ -1039,6 +1166,8 @@ module.exports = {
|
|
|
1039
1166
|
buildPricingUpsert,
|
|
1040
1167
|
isAcceptablePassword,
|
|
1041
1168
|
validateRegisterFields,
|
|
1169
|
+
buildRegisterPreview,
|
|
1170
|
+
MUST_CONFIRM_FIELDS,
|
|
1042
1171
|
deriveCurrentEmploymentStatus,
|
|
1043
1172
|
showsFreelanceOpinion,
|
|
1044
1173
|
deriveFreelanceIntent,
|
package/src/prompt-select.js
CHANGED
|
@@ -26,4 +26,41 @@ async function promptSelect({ ask, stdinIsTTY, out, items, labelFor = (x) => Str
|
|
|
26
26
|
return items.find((it) => labelFor(it).toLowerCase() === lowered || String(it).toLowerCase() === lowered) || null;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
// Multi-pick variant: SPACE toggles, ENTER confirms; returns the chosen items as
|
|
30
|
+
// an array (possibly empty). Non-TTY fallback accepts space/comma-separated
|
|
31
|
+
// indices or labels.
|
|
32
|
+
async function promptMultiSelect({ ask, stdinIsTTY, out, items, labelFor = (x) => String(x), header = '', hint = '', initialMarked = [], input, output } = {}) {
|
|
33
|
+
if (stdinIsTTY && typeof ask.suspend === 'function') {
|
|
34
|
+
ask.suspend();
|
|
35
|
+
const picked = await runInteractiveMultiSelect({
|
|
36
|
+
items,
|
|
37
|
+
labelFor,
|
|
38
|
+
header,
|
|
39
|
+
hint,
|
|
40
|
+
single: false,
|
|
41
|
+
initialMarked,
|
|
42
|
+
...(input ? { input } : {}),
|
|
43
|
+
...(output ? { output } : {}),
|
|
44
|
+
});
|
|
45
|
+
ask.resume();
|
|
46
|
+
return Array.isArray(picked) ? picked : [];
|
|
47
|
+
}
|
|
48
|
+
if (header) out(header);
|
|
49
|
+
items.forEach((it, i) => out(` ${i + 1}) ${labelFor(it)}`));
|
|
50
|
+
const raw = (await ask(' > ')).trim();
|
|
51
|
+
if (!raw) return [];
|
|
52
|
+
const chosen = [];
|
|
53
|
+
for (const tok of raw.split(/[\s,]+/).filter(Boolean)) {
|
|
54
|
+
const n = parseInt(tok, 10);
|
|
55
|
+
let item = null;
|
|
56
|
+
if (Number.isInteger(n) && n >= 1 && n <= items.length) item = items[n - 1];
|
|
57
|
+
else {
|
|
58
|
+
const lowered = tok.toLowerCase();
|
|
59
|
+
item = items.find((it) => labelFor(it).toLowerCase() === lowered || String(it).toLowerCase() === lowered) || null;
|
|
60
|
+
}
|
|
61
|
+
if (item != null && !chosen.includes(item)) chosen.push(item);
|
|
62
|
+
}
|
|
63
|
+
return chosen;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { promptSelect, promptMultiSelect };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Local context SUGGESTIONS for the register flow: the environment timezone, the
|
|
4
|
+
// local git identity, the GitHub login and CV candidates. These are proposals the
|
|
5
|
+
// model offers the talent to confirm — they are NEVER auto-written. No file
|
|
6
|
+
// content, no commit scanning, no network, no session: CV candidates are file
|
|
7
|
+
// NAMES only. This is deliberately NOT the authorship/evidence path.
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { execFileSync } = require('child_process');
|
|
12
|
+
const { getHomeDir, tildify } = require('./env-paths');
|
|
13
|
+
|
|
14
|
+
// Where a talent keeps a CV, and how deep we look under each folder.
|
|
15
|
+
const CV_DIRS = ['Desktop', 'Documents', 'Downloads', 'Escritorio', 'Documentos', 'Descargas'];
|
|
16
|
+
const CV_MAX_DEPTH = 2;
|
|
17
|
+
const CV_MAX_RESULTS = 5;
|
|
18
|
+
const CV_NAME = /(^|[^a-z])(cv|curriculum|curr[ií]culum|resume|r[ée]sum[ée])([^a-z]|$)/i;
|
|
19
|
+
|
|
20
|
+
function detectTimezone() {
|
|
21
|
+
try {
|
|
22
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || null;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function gitConfig(key, cwd) {
|
|
29
|
+
try {
|
|
30
|
+
const out = execFileSync('git', ['config', '--get', key], {
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
timeout: 5000,
|
|
33
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
34
|
+
cwd: cwd || process.cwd(),
|
|
35
|
+
});
|
|
36
|
+
const value = String(out || '').trim();
|
|
37
|
+
return value || null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The login the GitHub CLI is signed in with, read from its local hosts file
|
|
44
|
+
// (no network), else the conventional `github.user` git key.
|
|
45
|
+
function detectGithubUser(home, cwd) {
|
|
46
|
+
try {
|
|
47
|
+
const hosts = fs.readFileSync(path.join(home, '.config', 'gh', 'hosts.yml'), 'utf8');
|
|
48
|
+
const block = hosts.split(/^github\.com:\s*$/m)[1];
|
|
49
|
+
const match = block && block.match(/^\s+user:\s*(\S+)\s*$/m);
|
|
50
|
+
if (match) return match[1];
|
|
51
|
+
} catch {
|
|
52
|
+
// no gh CLI config
|
|
53
|
+
}
|
|
54
|
+
return gitConfig('github.user', cwd);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const fold = (text) => String(text || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
|
58
|
+
|
|
59
|
+
// Whether a file name carries a token of the person's name. People who hire keep
|
|
60
|
+
// other people's CVs in Downloads; only a name match makes a candidate theirs.
|
|
61
|
+
function matchesName(fileName, personName) {
|
|
62
|
+
const tokens = fold(personName).split(/[^a-z0-9]+/).filter((t) => t.length >= 3);
|
|
63
|
+
const file = fold(fileName);
|
|
64
|
+
return tokens.some((t) => file.includes(t));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Words that say nothing about whose CV it is: `cv.pdf`, `Curriculum_2024_ES.pdf`,
|
|
68
|
+
// `resume-final-v2.pdf`. Such a file may be the talent's own, so it is worth asking.
|
|
69
|
+
const GENERIC_WORDS = /\b(cv|curriculum|curriculo|resume|vitae|mi|my|el|the|de|of|final|actualizado|updated|nuevo|new|latest|es|en|esp|eng|spanish|english|espanol|ingles|v\d+|copia|copy)\b/g;
|
|
70
|
+
|
|
71
|
+
function isGenericCvName(fileName) {
|
|
72
|
+
const rest = fold(fileName.replace(/\.pdf$/i, '')).replace(/[^a-z0-9]+/g, ' ').replace(GENERIC_WORDS, ' ').replace(/\d+/g, ' ');
|
|
73
|
+
return rest.trim().length === 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// PDFs whose NAME looks like a CV: name matches first, then generic names, then
|
|
77
|
+
// newest. Never opens a file.
|
|
78
|
+
function findCvCandidates(home, personName) {
|
|
79
|
+
const found = [];
|
|
80
|
+
const walk = (dir, depth) => {
|
|
81
|
+
let entries;
|
|
82
|
+
try {
|
|
83
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
84
|
+
} catch {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
if (entry.name.startsWith('.')) continue;
|
|
89
|
+
const full = path.join(dir, entry.name);
|
|
90
|
+
if (entry.isDirectory()) {
|
|
91
|
+
if (depth < CV_MAX_DEPTH) walk(full, depth + 1);
|
|
92
|
+
} else if (entry.isFile() && /\.pdf$/i.test(entry.name) && CV_NAME.test(entry.name.replace(/\.pdf$/i, ''))) {
|
|
93
|
+
try {
|
|
94
|
+
found.push({
|
|
95
|
+
path: full,
|
|
96
|
+
nameMatch: matchesName(entry.name, personName),
|
|
97
|
+
generic: isGenericCvName(entry.name),
|
|
98
|
+
mtimeMs: fs.statSync(full).mtimeMs,
|
|
99
|
+
});
|
|
100
|
+
} catch {
|
|
101
|
+
// vanished between readdir and stat
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
for (const name of CV_DIRS) walk(path.join(home, name), 1);
|
|
107
|
+
return found
|
|
108
|
+
.sort((a, b) => Number(b.nameMatch) - Number(a.nameMatch) || Number(b.generic) - Number(a.generic) || b.mtimeMs - a.mtimeMs)
|
|
109
|
+
.slice(0, CV_MAX_RESULTS)
|
|
110
|
+
.map(({ path: cvPath, nameMatch, generic }) => ({ path: tildify(cvPath, { SHAKERS_CLI_HOME_DIR: home }), nameMatch, generic }));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Returns the signals found, each clearly a SUGGESTION to confirm.
|
|
114
|
+
function readRegisterContextSuggestions({ cwd, env = process.env } = {}) {
|
|
115
|
+
const home = getHomeDir(env);
|
|
116
|
+
const githubUser = detectGithubUser(home, cwd);
|
|
117
|
+
const gitName = gitConfig('user.name', cwd);
|
|
118
|
+
return {
|
|
119
|
+
timezone: detectTimezone(),
|
|
120
|
+
gitName,
|
|
121
|
+
gitEmail: gitConfig('user.email', cwd),
|
|
122
|
+
githubUser,
|
|
123
|
+
githubUrl: githubUser ? `https://github.com/${githubUser}` : null,
|
|
124
|
+
cvCandidates: findCvCandidates(home, gitName),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { readRegisterContextSuggestions, detectTimezone, findCvCandidates, detectGithubUser, isGenericCvName };
|
package/src/usage-run-steps.js
CHANGED
|
@@ -9,6 +9,9 @@ const { persistFootprint } = require('./report-store');
|
|
|
9
9
|
const {
|
|
10
10
|
loadConsentState,
|
|
11
11
|
getConsentDecision,
|
|
12
|
+
recordConsent,
|
|
13
|
+
isValidEmail,
|
|
14
|
+
normalizeEmail,
|
|
12
15
|
autoShare,
|
|
13
16
|
consentPath,
|
|
14
17
|
} = require('./share');
|
|
@@ -117,9 +120,12 @@ async function resolveAiConsent({ opts, catalog, gate, injectedAsk }) {
|
|
|
117
120
|
});
|
|
118
121
|
|
|
119
122
|
let aiConsentGranted;
|
|
123
|
+
// A REUSED grant (skip/--json) carries a stale consent email; a fresh prompt re-anchors itself.
|
|
124
|
+
let reusedStoredConsent = false;
|
|
120
125
|
if (opts.json) {
|
|
121
126
|
// `--json` never had an interactive prompt — its whole point is a single parseable stdout document, and it does not gain one now.
|
|
122
127
|
aiConsentGranted = decision === 'granted' && (!state || state.emailVerified !== false);
|
|
128
|
+
reusedStoredConsent = true;
|
|
123
129
|
} else {
|
|
124
130
|
if (consentSkip.message) {
|
|
125
131
|
process.stdout.write(`\n ${consentSkip.message}\n`);
|
|
@@ -128,6 +134,7 @@ async function resolveAiConsent({ opts, catalog, gate, injectedAsk }) {
|
|
|
128
134
|
// A TERMINAL decision already exists (a prior run's explicit grant or
|
|
129
135
|
// denial) — never re-asked, per ADR-007/011.
|
|
130
136
|
aiConsentGranted = decision === 'granted';
|
|
137
|
+
reusedStoredConsent = true;
|
|
131
138
|
} else {
|
|
132
139
|
const ask = injectedAsk || createStdinAsk();
|
|
133
140
|
let result;
|
|
@@ -145,6 +152,11 @@ async function resolveAiConsent({ opts, catalog, gate, injectedAsk }) {
|
|
|
145
152
|
process.stdout.write(`\n ${termColors.warning}${catalog.consent.noReportWithoutConsent}${termColors.reset}\n\n`);
|
|
146
153
|
}
|
|
147
154
|
}
|
|
155
|
+
// Re-anchor a reused grant to the session's login-proven email (ADR-006): else a stale consent email ingests the report under another account and this session's AI profile never populates.
|
|
156
|
+
if (reusedStoredConsent && aiConsentGranted && gate.loggedIn
|
|
157
|
+
&& isValidEmail(gate.email) && state && state.email !== normalizeEmail(gate.email)) {
|
|
158
|
+
recordConsent('granted', gate.email, { verified: true });
|
|
159
|
+
}
|
|
148
160
|
return aiConsentGranted;
|
|
149
161
|
}
|
|
150
162
|
|