shakers-cli-beta 0.8.0-beta.1 → 0.8.0-beta.2

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 CHANGED
@@ -74,7 +74,7 @@ async function run(argv = process.argv.slice(2)) {
74
74
  return;
75
75
  }
76
76
 
77
- require('../src/config').ensureLocalConfigDefaults(process.env, { force: true });
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 onlyRemote = isYes(await ask(` ${c.onlyRemoteAsk} `));
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, onlyRemote, country, subdivision, timezone, longFullTimeProjects, telephoneCode, telephoneNumber };
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 { ensureLocalConfigDefaults } = require('../src/config');
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
- ensureLocalConfigDefaults(process.env);
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.1",
3
+ "version": "0.8.0-beta.2",
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
- onlyRemote: bool(it.onlyRemote),
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),
@@ -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.onlyRemote(a.onlyRemote === true ? fp.yes : a.onlyRemote === false ? fp.no : fp.unknown)));
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 remotePick = await pickYesNo(fp.askOnlyRemote);
61
- if (!remotePick) { io.notify(fp.setCancelled); return { ok: true, cancelled: true }; }
62
- const onlyRemote = remotePick.value;
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, onlyRemote };
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
- `${onlyRemote ? fp.yes : fp.no}`,
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 }; }
@@ -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 } : {}),
@@ -109,6 +116,8 @@ function postMultipartWithTimeout(url, fields, timeoutMs, method = 'POST', extra
109
116
  method,
110
117
  headers: {
111
118
  Accept: 'application/json',
119
+ // See postJsonWithTimeout's identical header for why.
120
+ 'X-Shakers-Client': 'cli',
112
121
  'Content-Type': `multipart/form-data; boundary=${boundary}`,
113
122
  'Content-Length': data.length,
114
123
  ...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
- return null;
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
- return v.ok ? v.value.replace(/\/+$/, '') : null;
514
+ if (v.ok) return v.value.replace(/\/+$/, '');
511
515
  }
512
- return null;
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
- return null;
627
- }
628
-
629
- // First-run local bootstrap: seed config.json with the baked flavor bases (ADR-065)
630
- // so the talent CLI/MCP works with zero exports; the flavor is resolved per-call
631
- // (SHAKERS_CLI_ENV > package name > dev) so env overrides still take precedence.
632
- function ensureLocalConfigDefaults(env = process.env, { force = false } = {}) {
633
- if (!force && !isTalentProfile(env)) return false;
634
- const defaults = getEnvProfile(env);
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 [key, value] of Object.entries(defaults)) {
638
- if (!(config[key] && String(config[key]).trim())) {
639
- config[key] = value;
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
- ensureLocalConfigDefaults,
771
+ sanitizeStaleBakedEndpoints,
748
772
  getSignUpEndpoint,
749
773
  getImportProfileEndpoint,
750
774
  getProfessionalDetailsEndpoint,
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
- onlyRemoteAsk: 'Trabajas solo en remoto? (s/n):',
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
- onlyRemote: (v) => `Solo remoto: ${v}`,
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
- askOnlyRemote: '¿Solo trabajo remoto?',
934
- setDiff: (open, hours, remote) => `Nuevo: abierto=${open} · ${hours} h/mes · solo remoto=${remote}`,
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
- onlyRemoteAsk: 'Do you work remote-only? (y/n):',
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
- onlyRemote: (v) => `Remote only: ${v}`,
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
- askOnlyRemote: 'Remote work only?',
2536
- setDiff: (open, hours, remote) => `New: open=${open} · ${hours} h/month · remote-only=${remote}`,
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, remote-only, 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.",
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}; onlyRemote (yes/no — accepts only remote work); 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.`,
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
- onlyRemote: { type: 'boolean', description: 'Whether the talent accepts only remote work (yes/no).' },
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.' },
@@ -100,7 +100,7 @@ function buildRegisterDescription(lang) {
100
100
  + labelledOptions(CHANGE_MOTIVATORS, L.changeMotivatorLabels)
101
101
  + " 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
102
  + MONTHLY_HOURS_SLASHED
103
- + ", onlyRemote yes/no, longFullTimeProjects yes/no, and LOCATION: country as ISO-2 e.g. ES, timezone as IANA e.g. Europe/Madrid) and `languages` (offer "
103
+ + ", 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
104
  + labelledOptions(LANGUAGE_CODES, L.languageNames)
105
105
  + ", each with a level "
106
106
  + labelledOptions(LANGUAGE_LEVELS, L.languageLevelLabels)
@@ -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
- if (typeof input.onlyRemote === 'boolean') body.onlyRemote = input.onlyRemote;
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' };
@@ -1025,6 +1037,7 @@ module.exports = {
1025
1037
  EMPLOYMENT_PARTICIPATIONS,
1026
1038
  FREELANCE_OPINIONS,
1027
1039
  CHANGE_MOTIVATORS,
1040
+ WORK_MODES,
1028
1041
  LANGUAGE_LEVELS,
1029
1042
  LANGUAGE_CODES,
1030
1043
  normalizeFreelanceType,
@@ -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
- module.exports = { promptSelect };
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 };
@@ -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