shakers-cli 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -405,6 +405,177 @@ const COPY = {
405
405
  },
406
406
  };
407
407
 
408
+ const REVIEW = {
409
+ es: {
410
+ cliQuestion: '¿Lo subo a tu perfil? [s/N]:',
411
+ cliIncludeExcerpts: '¿Incluir los extractos de texto? [S/n]:',
412
+ cliExcludeAgents: 'Si quieres dejar fuera algún agente, escribe sus nombres separados por comas (Enter para ninguno):',
413
+ cliUnknownAgents: (unknown, known) => `No reconozco: ${unknown}. Los agentes son: ${known}.`,
414
+ cliRepoHint: 'Para dejar fuera un repositorio, vuelve a ejecutar el comando eligiendo solo los que quieras con --repos.',
415
+ title: 'Todavía no se ha subido nada a tu perfil de Shakers. Esto es lo que se subiría:',
416
+ sentTitle: 'Esto es lo que se ha subido:',
417
+ tools: (list) => `Herramientas de IA detectadas: ${list}.`,
418
+ technologies: (list) => `Tecnologías de tus proyectos: ${list}.`,
419
+ activity: (sessions, days) => `Tu actividad con IA: ${sessions === 1 ? '1 sesión' : `${sessions} sesiones`}${days === null ? '' : `, unos ${days} días activos por semana`}.`,
420
+ repos: (list, commits) => `Repositorios analizados: ${list}. De ellos solo se envían cifras (${commits === 1 ? '1 commit' : `${commits} commits`}, líneas cambiadas, tipos de fichero), nunca su nombre ni su código.`,
421
+ agents: (list, definitions, summaries) => `Agentes de IA detectados: ${list}. De cada uno se envía ${listOf('es', ['su nombre', 'sus herramientas', 'su modelo', 'de qué agente depende', ...(definitions > 0 ? ['el texto de su definición (sin secretos)'] : []), ...(summaries > 0 ? ['el resumen de lo que hace'] : [])])}.`,
422
+ excerpts: (conversation, commands) => `Extractos de texto: ${conversation} frases cortas de lo que le pides a la IA y ${commands} comandos, de 200 caracteres como máximo, sin secretos, rutas ni emails.`,
423
+ noExcerpts: 'Extractos de texto: ninguno.',
424
+ notSent: 'No se envía: tu código, tus conversaciones completas ni los nombres de tus repositorios.',
425
+ explanationTitle: 'Qué hace Shakers con esto:',
426
+ explanation: [
427
+ 'Te sitúa en dos ejes. Configuración: qué tienes montado (herramientas, servidores MCP, agentes), de menos a más: Asistido, Extendido u Orquestado. Uso: cómo diriges a la IA (si le das instrucciones concretas, si revisas lo que propone), de menos a más: Reactivo, Deliberado o Riguroso.',
428
+ 'La configuración se calcula con reglas fijas a partir de lo detectado. El nivel de uso y los textos sobre cómo trabajas los escribe un modelo de IA en los servidores de Shakers.',
429
+ 'En tu perfil aparece la sección «Mi trabajo con IA»: tus dos niveles, tu casilla en la matriz (por ejemplo Practitioner o Architect), tus herramientas y agentes, y dos textos que puedes editar en la web.',
430
+ ],
431
+ afterwards: 'Si no estás de acuerdo con el resultado, pulsa «Solicitar revisión humana» en esa sección de la web o escribe a talent@shakersworks.com. Hoy no puedes ocultar la sección tú mismo; para pedir que se borre, escribe a ese mismo correo.',
432
+ excludeHint: 'Si quieres dejar fuera algún repositorio, algún agente o los extractos de texto, dímelo al responder.',
433
+ question: '¿Lo subo a tu perfil?',
434
+ yes: 'Subir a mi perfil',
435
+ no: 'No subir',
436
+ formExcerpts: 'Incluir los extractos de texto',
437
+ formRepo: (name) => `Incluir el repositorio ${name}`,
438
+ formAgent: (name) => `Incluir el agente ${name}`,
439
+ declined: 'No se ha subido nada. El análisis se queda solo en tu equipo.',
440
+ nothingLeft: 'Has dejado fuera todos los repositorios, así que no se ha subido nada.',
441
+ throttled: 'Ya subiste un análisis hace menos de una hora, así que no se ha subido nada. Puedes volver a intentarlo más tarde.',
442
+ uploaded: 'Subido a tu perfil. Shakers lo está evaluando y el resultado tarda unos minutos.',
443
+ uploadFailed: 'No se ha podido subir. Tu análisis se queda solo en tu equipo; puedes volver a intentarlo más tarde.',
444
+ resultTitle: 'Tu resultado en «Mi trabajo con IA»:',
445
+ setupLevel: (level) => `Configuración: ${level}.`,
446
+ usageLevel: (level) => `Uso: ${level}.`,
447
+ cell: (name) => `Tu casilla en la matriz: ${name}.`,
448
+ levels: { ASSISTED: 'Asistido', EXTENDED: 'Extendido', ORCHESTRATED: 'Orquestado', REACTIVE: 'Reactivo', DELIBERATE: 'Deliberado', RIGOROUS: 'Riguroso' },
449
+ resultPending: 'Tu resultado todavía no está en «Mi trabajo con IA». Si acabas de subir tu análisis, aparecerá en unos minutos.',
450
+ },
451
+ en: {
452
+ cliQuestion: 'Shall I upload it to your profile? [y/N]:',
453
+ cliIncludeExcerpts: 'Include the text excerpts? [Y/n]:',
454
+ cliExcludeAgents: 'If you want to leave out an agent, type their names separated by commas (Enter for none):',
455
+ cliUnknownAgents: (unknown, known) => `I do not recognise: ${unknown}. The agents are: ${known}.`,
456
+ cliRepoHint: 'To leave out a repository, run the command again choosing only the ones you want with --repos.',
457
+ title: 'Nothing has been uploaded to your Shakers profile yet. This is what would be uploaded:',
458
+ sentTitle: 'This is what was uploaded:',
459
+ tools: (list) => `AI tools detected: ${list}.`,
460
+ technologies: (list) => `Technologies in your projects: ${list}.`,
461
+ activity: (sessions, days) => `Your AI activity: ${sessions === 1 ? '1 session' : `${sessions} sessions`}${days === null ? '' : `, about ${days} active days a week`}.`,
462
+ repos: (list, commits) => `Repositories analysed: ${list}. Only figures about them are sent (${commits === 1 ? '1 commit' : `${commits} commits`}, lines changed, file types), never their name or their code.`,
463
+ agents: (list, definitions, summaries) => `AI agents detected: ${list}. For each one we send ${listOf('en', ['its name', 'its tools', 'its model', 'which agent it reports to', ...(definitions > 0 ? ['the text of its definition (with secrets removed)'] : []), ...(summaries > 0 ? ['the summary of what it does'] : [])])}.`,
464
+ excerpts: (conversation, commands) => `Text excerpts: ${conversation} short sentences of what you ask the AI and ${commands} commands, 200 characters at most, with secrets, paths and emails removed.`,
465
+ noExcerpts: 'Text excerpts: none.',
466
+ notSent: 'Not sent: your code, your full conversations or the names of your repositories.',
467
+ explanationTitle: 'What Shakers does with it:',
468
+ explanation: [
469
+ 'It places you on two axes. Setup: what you have in place (tools, MCP servers, agents), from less to more: Assisted, Extended or Orchestrated. Usage: how you steer the AI (whether you give it concrete instructions, whether you review what it proposes), from less to more: Reactive, Deliberate or Rigorous.',
470
+ 'Setup is worked out with fixed rules from what was detected. The usage level and the texts about how you work are written by an AI model on Shakers servers.',
471
+ 'Your profile shows the "My work with AI" section: your two levels, your square in the matrix (for example Practitioner or Architect), your tools and agents, and two texts you can edit on the web.',
472
+ ],
473
+ afterwards: 'If you disagree with the result, press "Request human review" in that section on the web or write to talent@shakersworks.com. Today you cannot hide the section yourself; to ask for it to be deleted, write to that same address.',
474
+ excludeHint: 'If you want to leave out a repository, an agent or the text excerpts, tell me when you answer.',
475
+ question: 'Shall I upload it to your profile?',
476
+ yes: 'Upload to my profile',
477
+ no: 'Do not upload',
478
+ formExcerpts: 'Include the text excerpts',
479
+ formRepo: (name) => `Include the repository ${name}`,
480
+ formAgent: (name) => `Include the agent ${name}`,
481
+ declined: 'Nothing was uploaded. The analysis stays on your computer only.',
482
+ nothingLeft: 'You left out every repository, so nothing was uploaded.',
483
+ throttled: 'You uploaded an analysis less than an hour ago, so nothing was uploaded. You can try again later.',
484
+ uploaded: 'Uploaded to your profile. Shakers is evaluating it and the result takes a few minutes.',
485
+ uploadFailed: 'It could not be uploaded. Your analysis stays on your computer only; you can try again later.',
486
+ resultTitle: 'Your result in "My work with AI":',
487
+ setupLevel: (level) => `Setup: ${level}.`,
488
+ usageLevel: (level) => `Usage: ${level}.`,
489
+ cell: (name) => `Your square in the matrix: ${name}.`,
490
+ levels: { ASSISTED: 'Assisted', EXTENDED: 'Extended', ORCHESTRATED: 'Orchestrated', REACTIVE: 'Reactive', DELIBERATE: 'Deliberate', RIGOROUS: 'Rigorous' },
491
+ resultPending: 'Your result is not in "My work with AI" yet. If you have just uploaded your analysis, it will show in a few minutes.',
492
+ },
493
+ it: {
494
+ cliQuestion: 'Lo carico sul tuo profilo? [s/N]:',
495
+ cliIncludeExcerpts: 'Includere gli estratti di testo? [S/n]:',
496
+ cliExcludeAgents: 'Se vuoi escludere un agente, scrivi i nomi separati da virgole (Invio per nessuno):',
497
+ cliUnknownAgents: (unknown, known) => `Non riconosco: ${unknown}. Gli agenti sono: ${known}.`,
498
+ cliRepoHint: 'Per escludere un repository, esegui di nuovo il comando scegliendo solo quelli che vuoi con --repos.',
499
+ title: "Non è stato ancora caricato nulla sul tuo profilo Shakers. Ecco cosa verrebbe caricato:",
500
+ sentTitle: "Ecco cosa è stato caricato:",
501
+ tools: (list) => `Strumenti di IA rilevati: ${list}.`,
502
+ technologies: (list) => `Tecnologie dei tuoi progetti: ${list}.`,
503
+ activity: (sessions, days) => `La tua attività con l'IA: ${sessions === 1 ? '1 sessione' : `${sessions} sessioni`}${days === null ? '' : `, circa ${days} giorni attivi a settimana`}.`,
504
+ repos: (list, commits) => `Repository analizzati: ${list}. Di questi si inviano solo cifre (${commits} commit, righe modificate, tipi di file), mai il nome né il codice.`,
505
+ agents: (list, definitions, summaries) => `Agenti di IA rilevati: ${list}. Di ognuno si inviano ${listOf('it', ['il nome', 'gli strumenti', 'il modello', 'da quale agente dipende', ...(definitions > 0 ? ['il testo della sua definizione (senza segreti)'] : []), ...(summaries > 0 ? ['il riassunto di ciò che fa'] : [])])}.`,
506
+ excerpts: (conversation, commands) => `Estratti di testo: ${conversation} frasi brevi di ciò che chiedi all'IA e ${commands} comandi, al massimo 200 caratteri, senza segreti, percorsi né email.`,
507
+ noExcerpts: 'Estratti di testo: nessuno.',
508
+ notSent: 'Non si inviano: il tuo codice, le tue conversazioni complete né i nomi dei tuoi repository.',
509
+ explanationTitle: 'Cosa fa Shakers con questi dati:',
510
+ explanation: [
511
+ "Ti colloca su due assi. Configurazione: cosa hai impostato (strumenti, server MCP, agenti), dal meno al più: Assistito, Esteso o Orchestrato. Uso: come guidi l'IA (se le dai istruzioni concrete, se controlli ciò che propone), dal meno al più: Reattivo, Deliberato o Rigoroso.",
512
+ "La configurazione si calcola con regole fisse da ciò che è stato rilevato. Il livello di uso e i testi su come lavori li scrive un modello di IA sui server di Shakers.",
513
+ "Sul tuo profilo compare la sezione «Il mio lavoro con l'IA»: i tuoi due livelli, la tua casella nella matrice (per esempio Practitioner o Architect), i tuoi strumenti e agenti, e due testi che puoi modificare sul sito.",
514
+ ],
515
+ afterwards: 'Se non sei d\'accordo con il risultato, premi «Richiedi una revisione umana» in quella sezione del sito o scrivi a talent@shakersworks.com. Oggi non puoi nascondere la sezione da solo; per chiedere che venga cancellata, scrivi allo stesso indirizzo.',
516
+ excludeHint: 'Se vuoi escludere un repository, un agente o gli estratti di testo, dimmelo quando rispondi.',
517
+ question: 'Lo carico sul tuo profilo?',
518
+ yes: 'Carica sul mio profilo',
519
+ no: 'Non caricare',
520
+ formExcerpts: 'Includi gli estratti di testo',
521
+ formRepo: (name) => `Includi il repository ${name}`,
522
+ formAgent: (name) => `Includi l'agente ${name}`,
523
+ declined: "Non è stato caricato nulla. L'analisi resta solo sul tuo computer.",
524
+ nothingLeft: 'Hai escluso tutti i repository, quindi non è stato caricato nulla.',
525
+ throttled: "Hai caricato un'analisi meno di un'ora fa, quindi non è stato caricato nulla. Puoi riprovare più tardi.",
526
+ uploaded: 'Caricato sul tuo profilo. Shakers lo sta valutando e il risultato richiede qualche minuto.',
527
+ uploadFailed: 'Non è stato possibile caricarlo. La tua analisi resta solo sul tuo computer; puoi riprovare più tardi.',
528
+ resultTitle: "Il tuo risultato in «Il mio lavoro con l'IA»:",
529
+ setupLevel: (level) => `Configurazione: ${level}.`,
530
+ usageLevel: (level) => `Uso: ${level}.`,
531
+ cell: (name) => `La tua casella nella matrice: ${name}.`,
532
+ levels: { ASSISTED: 'Assistito', EXTENDED: 'Esteso', ORCHESTRATED: 'Orchestrato', REACTIVE: 'Reattivo', DELIBERATE: 'Deliberato', RIGOROUS: 'Rigoroso' },
533
+ resultPending: "Il tuo risultato non è ancora in «Il mio lavoro con l'IA». Se hai appena caricato la tua analisi, comparirà tra qualche minuto.",
534
+ },
535
+ pt: {
536
+ cliQuestion: 'Carrego-o no seu perfil? [s/N]:',
537
+ cliIncludeExcerpts: 'Incluir os excertos de texto? [S/n]:',
538
+ cliExcludeAgents: 'Se quiser deixar de fora algum agente, escreva os nomes separados por vírgulas (Enter para nenhum):',
539
+ cliUnknownAgents: (unknown, known) => `Não reconheço: ${unknown}. Os agentes são: ${known}.`,
540
+ cliRepoHint: 'Para deixar de fora um repositório, volte a executar o comando escolhendo só os que quiser com --repos.',
541
+ title: 'Ainda não foi carregado nada no seu perfil da Shakers. Isto é o que seria carregado:',
542
+ sentTitle: 'Isto é o que foi carregado:',
543
+ tools: (list) => `Ferramentas de IA detetadas: ${list}.`,
544
+ technologies: (list) => `Tecnologias dos seus projetos: ${list}.`,
545
+ activity: (sessions, days) => `A sua atividade com IA: ${sessions === 1 ? '1 sessão' : `${sessions} sessões`}${days === null ? '' : `, cerca de ${days} dias ativos por semana`}.`,
546
+ repos: (list, commits) => `Repositórios analisados: ${list}. Deles só se enviam números (${commits === 1 ? '1 commit' : `${commits} commits`}, linhas alteradas, tipos de ficheiro), nunca o nome nem o código.`,
547
+ agents: (list, definitions, summaries) => `Agentes de IA detetados: ${list}. De cada um envia-se ${listOf('pt', ['o nome', 'as ferramentas', 'o modelo', 'de que agente depende', ...(definitions > 0 ? ['o texto da sua definição (sem segredos)'] : []), ...(summaries > 0 ? ['o resumo do que faz'] : [])])}.`,
548
+ excerpts: (conversation, commands) => `Excertos de texto: ${conversation} frases curtas do que pede à IA e ${commands} comandos, com 200 caracteres no máximo, sem segredos, caminhos nem emails.`,
549
+ noExcerpts: 'Excertos de texto: nenhum.',
550
+ notSent: 'Não se envia: o seu código, as suas conversas completas nem os nomes dos seus repositórios.',
551
+ explanationTitle: 'O que a Shakers faz com isto:',
552
+ explanation: [
553
+ 'Situa-o em dois eixos. Configuração: o que tem montado (ferramentas, servidores MCP, agentes), de menos para mais: Assistido, Estendido ou Orquestrado. Uso: como orienta a IA (se lhe dá instruções concretas, se revê o que propõe), de menos para mais: Reativo, Deliberado ou Rigoroso.',
554
+ 'A configuração calcula-se com regras fixas a partir do que foi detetado. O nível de uso e os textos sobre como trabalha são escritos por um modelo de IA nos servidores da Shakers.',
555
+ 'No seu perfil aparece a secção «Meu trabalho com IA»: os seus dois níveis, a sua casa na matriz (por exemplo Practitioner ou Architect), as suas ferramentas e agentes, e dois textos que pode editar na web.',
556
+ ],
557
+ afterwards: 'Se não concordar com o resultado, carregue em «Solicitar revisão humana» nessa secção da web ou escreva para talent@shakersworks.com. Hoje não pode ocultar a secção sozinho; para pedir que seja apagada, escreva para esse mesmo endereço.',
558
+ excludeHint: 'Se quiser deixar de fora algum repositório, algum agente ou os excertos de texto, diga-me ao responder.',
559
+ question: 'Carrego-o no seu perfil?',
560
+ yes: 'Carregar no meu perfil',
561
+ no: 'Não carregar',
562
+ formExcerpts: 'Incluir os excertos de texto',
563
+ formRepo: (name) => `Incluir o repositório ${name}`,
564
+ formAgent: (name) => `Incluir o agente ${name}`,
565
+ declined: 'Não foi carregado nada. A análise fica só no seu computador.',
566
+ nothingLeft: 'Deixou de fora todos os repositórios, por isso não foi carregado nada.',
567
+ throttled: 'Carregou uma análise há menos de uma hora, por isso não foi carregado nada. Pode tentar mais tarde.',
568
+ uploaded: 'Carregado no seu perfil. A Shakers está a avaliá-lo e o resultado demora alguns minutos.',
569
+ uploadFailed: 'Não foi possível carregá-lo. A sua análise fica só no seu computador; pode tentar mais tarde.',
570
+ resultTitle: 'O seu resultado em «Meu trabalho com IA»:',
571
+ setupLevel: (level) => `Configuração: ${level}.`,
572
+ usageLevel: (level) => `Uso: ${level}.`,
573
+ cell: (name) => `A sua casa na matriz: ${name}.`,
574
+ levels: { ASSISTED: 'Assistido', EXTENDED: 'Estendido', ORCHESTRATED: 'Orquestrado', REACTIVE: 'Reativo', DELIBERATE: 'Deliberado', RIGOROUS: 'Rigoroso' },
575
+ resultPending: 'O seu resultado ainda não está em «Meu trabalho com IA». Se acabou de carregar a sua análise, vai aparecer dentro de alguns minutos.',
576
+ },
577
+ };
578
+
408
579
  function toSignupLanguage(code) {
409
580
  const base = String(code || '').toLowerCase().split(/[-_]/)[0];
410
581
  return SIGNUP_LANGUAGES.includes(base) ? base : null;
@@ -418,6 +589,10 @@ function legalCopy(lang) {
418
589
  return LEGAL[toSignupLanguage(lang) || 'en'];
419
590
  }
420
591
 
592
+ function reviewCopy(lang) {
593
+ return REVIEW[toSignupLanguage(lang) || 'en'];
594
+ }
595
+
421
596
  // The profile draft the talent confirms before the account exists: one line per group, empty parts left out.
422
597
  function renderDraft(lang, d = {}) {
423
598
  const c = signupCopy(lang);
@@ -447,4 +622,4 @@ function renderRole(lang, { name, source, evidence, openProjects, recommended })
447
622
  ].filter(Boolean).join('\n');
448
623
  }
449
624
 
450
- module.exports = { SIGNUP_LANGUAGES, LEGAL, signupCopy, legalCopy, toSignupLanguage, renderDraft, renderRole, amount };
625
+ module.exports = { SIGNUP_LANGUAGES, LEGAL, REVIEW, signupCopy, legalCopy, reviewCopy, toSignupLanguage, renderDraft, renderRole, amount };
package/src/stdin-ask.js CHANGED
@@ -126,4 +126,16 @@ function createStdinAsk({ onInterrupt } = {}) {
126
126
  return ask;
127
127
  }
128
128
 
129
- module.exports = { createStdinAsk, createLineQueueAsk };
129
+ function createLazyStdinAsk() {
130
+ let real = null;
131
+ const open = () => { real = real || createStdinAsk(); return real; };
132
+ const ask = (question) => open()(question);
133
+ ask.secret = (question) => open().secret(question);
134
+ ask.drain = () => { if (real) real.drain(); };
135
+ ask.close = () => { if (real) real.close(); };
136
+ ask.suspend = () => { if (real) real.suspend(); };
137
+ ask.resume = () => { if (real) real.resume(); };
138
+ return ask;
139
+ }
140
+
141
+ module.exports = { createStdinAsk, createLazyStdinAsk, createLineQueueAsk };
@@ -8,6 +8,7 @@ const {
8
8
  getConsentDecision,
9
9
  autoShare,
10
10
  consentPath,
11
+ isThrottled,
11
12
  } = require('./share');
12
13
  const { runConsentPrompt } = require('./consent-flow');
13
14
  const { createStdinAsk } = require('./stdin-ask');
@@ -22,8 +23,10 @@ const { withSpinner } = require('./terminal-progress');
22
23
  const { computeConsentSkip } = require('./consent-skip');
23
24
  const { collectIdentityAndUsage, storedAiConsent, reanchorReusedGrant } = require('./usage-scan');
24
25
  const { doBuildNextLevel } = require('./usage-oneshot-commands');
26
+ const { uploadFacts, withoutExcluded, summaryText, explanationText } = require('./ai-usage-review');
27
+ const { reviewCopy } = require('./signup-copy');
28
+ const { askYesNo } = require('./consent-flow');
25
29
 
26
- // Automatic, silent PERSISTING once consent is `granted` (consent controls persistence only, never what's shown).
27
30
  async function maybeAutoShare(report, maturity, root, catalog, { bypassThrottle = false } = {}) {
28
31
  try {
29
32
  const result = await autoShare(report, maturity, { root, bypassThrottle });
@@ -103,7 +106,54 @@ async function resolveAiConsent({ opts, catalog, gate, injectedAsk }) {
103
106
  return aiConsentGranted;
104
107
  }
105
108
 
106
- async function renderAndFinalize({ report, maturity, lang, opts, root, gate, catalog, bypassThrottle = false }) {
109
+ function matchNames(raw, known) {
110
+ const wanted = String(raw || '').split(',').map((x) => x.trim()).filter(Boolean);
111
+ const byLower = new Map(known.map((name) => [name.toLowerCase(), name]));
112
+ return {
113
+ matched: wanted.map((w) => byLower.get(w.toLowerCase())).filter(Boolean),
114
+ unknown: wanted.filter((w) => !byLower.has(w.toLowerCase())),
115
+ };
116
+ }
117
+
118
+ async function askExcludedAgents(ask, c, known, out) {
119
+ for (let attempt = 0; attempt < 3; attempt += 1) {
120
+ const { matched, unknown } = matchNames(await ask(c.cliExcludeAgents), known);
121
+ if (unknown.length === 0) return matched;
122
+ out.write(` ${c.cliUnknownAgents(unknown.join(', '), known.join(', '))}\n`);
123
+ }
124
+ return null;
125
+ }
126
+
127
+ async function reviewUpload({ report, maturity, repos = [], lang, catalog, injectedAsk = null, out = process.stdout, bypassThrottle = false }) {
128
+ const state = loadConsentState();
129
+ if (getConsentDecision(state) !== 'granted' || !state || state.emailVerified === false || !state.email || (!bypassThrottle && isThrottled(state))) {
130
+ return { approved: true, report, maturity };
131
+ }
132
+ const c = reviewCopy(lang);
133
+ const facts = uploadFacts({ report, maturity, repos });
134
+ out.write(`\n${summaryText(lang, facts)}\n\n${explanationText(lang)}\n\n`);
135
+ if (facts.repos.length > 1) out.write(`${c.cliRepoHint}\n\n`);
136
+ const ask = injectedAsk || createStdinAsk();
137
+ try {
138
+ const approved = await askYesNo(ask, c.cliQuestion, catalog, { defaultNo: true });
139
+ if (!approved) {
140
+ out.write(` ${c.declined}\n`);
141
+ return { approved: false };
142
+ }
143
+ const agents = facts.agents.length > 0 ? await askExcludedAgents(ask, c, facts.agents, out) : [];
144
+ if (agents === null) {
145
+ out.write(` ${c.declined}\n`);
146
+ return { approved: false };
147
+ }
148
+ const hasExcerpts = facts.conversationExcerpts + facts.commandExcerpts > 0;
149
+ const excerpts = hasExcerpts ? (await askYesNo(ask, c.cliIncludeExcerpts, catalog, { defaultYes: true })) === true : true;
150
+ return { approved: true, ...withoutExcluded(report, { agents, excerpts }) };
151
+ } finally {
152
+ if (!injectedAsk) ask.close();
153
+ }
154
+ }
155
+
156
+ async function renderAndFinalize({ report, maturity, lang, opts, root, gate, catalog, bypassThrottle = false, repos = [], injectedAsk = null }) {
107
157
  if (opts.json) {
108
158
  process.stdout.write(JSON.stringify({ report, maturity }, null, 2) + '\n');
109
159
  return;
@@ -133,8 +183,10 @@ async function renderAndFinalize({ report, maturity, lang, opts, root, gate, cat
133
183
  doBuildNextLevel(root, maturity, opts.force, catalog);
134
184
  }
135
185
 
136
- // Auto-send at the end (bypassThrottle only true on the register path).
137
- const shareResult = await maybeAutoShare(report, maturity, root, catalog, { bypassThrottle });
186
+ const reviewed = await reviewUpload({ report, maturity, repos, lang, catalog, injectedAsk, bypassThrottle });
187
+ if (!reviewed.approved) return;
188
+ const shareResult = await maybeAutoShare(reviewed.report, reviewed.maturity, root, catalog, { bypassThrottle });
189
+ if (shareResult && shareResult.ok) process.stdout.write(`\n ${reviewCopy(lang).afterwards}\n`);
138
190
 
139
191
  // "My work with AI" preview, best-effort after the send; never blocks/breaks the run.
140
192
  await maybeRenderAiProfile({ report, maturity, lang, root, save: opts.save, submitOk: !!(shareResult && shareResult.ok), catalog });
@@ -194,6 +246,7 @@ async function maybeRenderAiProfile({ report, maturity, lang, root, save, submit
194
246
  }
195
247
 
196
248
  module.exports = {
249
+ reviewUpload,
197
250
  prepareIdentityAndUsage,
198
251
  resolveAiConsent,
199
252
  renderAndFinalize,
package/src/usage-scan.js CHANGED
@@ -124,9 +124,10 @@ async function runModelCalls({
124
124
  const willAttemptSynthesis = aiConsentGranted && hasAgents && !!getSynthesisEndpoint();
125
125
  const willAttemptEvaluation = aiConsentGranted && hasAgents && !!getAgentEvaluationEndpoint();
126
126
  const roadmapEntryForTier = maturity.tierKey ? getRoadmapEntry(maturity.tierKey, lang) : null;
127
+ const roadmapRequested = opts.roadmap === true && gate.showRoadmap;
127
128
  const willAttemptRoadmapPersonalization =
128
129
  aiConsentGranted
129
- && gate.showRoadmap
130
+ && roadmapRequested
130
131
  && !!roadmapEntryForTier && !roadmapEntryForTier.maxTier
131
132
  && !!getRoadmapEndpoint();
132
133
 
@@ -151,7 +152,7 @@ async function runModelCalls({
151
152
  )
152
153
  : null;
153
154
  // `runPhase` runs `call()` whether or not a spinner is shown, so gating the roadmap CANNOT be done by flipping `willAttempt` — that only hides the spinner.
154
- roadmapPersonalization = gate.showRoadmap
155
+ roadmapPersonalization = roadmapRequested
155
156
  ? await runPhase(
156
157
  willAttemptRoadmapPersonalization,
157
158
  catalog.cli.personalizingRoadmapLabel,
@@ -183,8 +184,7 @@ async function computeUsage({
183
184
  // 1. Local scan + classify (always computed and shown).
184
185
  const { report, root, rootDir } = performScan({ opts, catalog });
185
186
 
186
- // 2. Identity gate + local agent-usage signal. Roadmap only for anonymous runs
187
- // (gate.showRoadmap = !loggedIn) AND never during register/onboarding.
187
+ // 2. Identity gate + local agent-usage signal.
188
188
  const { gate: identityGate } = prepareIdentity({ report, catalog, quiet: opts.json === true });
189
189
  const gate = (inRegistration && identityGate.showRoadmap)
190
190
  ? { ...identityGate, showRoadmap: false }
@@ -212,15 +212,15 @@ async function computeUsage({
212
212
  await runModelCalls({
213
213
  report, maturity, aiConsentGranted, opts, gate, hasAgents, agentRoots, rootDir, lang, catalog,
214
214
  });
215
- return { report, maturity, root, gate };
215
+ return { report, maturity, root, gate, repos: scope.toplevels };
216
216
  }
217
217
 
218
218
  // The MCP's scan: same steps, returned instead of printed, since stdout is its JSON-RPC channel.
219
219
  async function scanUsage(argv) {
220
- const opts = parseArgs(argv);
220
+ const opts = { ...parseArgs(argv), roadmap: false };
221
221
  const lang = opts.lang || detectReportLang();
222
- const { report, maturity } = await computeUsage({ opts, catalog: getCatalog(lang), lang });
223
- return { report, maturity };
222
+ const { report, maturity, repos } = await computeUsage({ opts, catalog: getCatalog(lang), lang });
223
+ return { report, maturity, repos };
224
224
  }
225
225
 
226
226
  module.exports = {