paris-immersion 0.1.25 → 0.1.27

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.
@@ -9,6 +9,27 @@ import { readCredentials, credentialsPath } from '../lib/credentials.js';
9
9
  import { findMarker } from '../lib/marker.js';
10
10
  import { c } from '../lib/colors.js';
11
11
  const execFileAsync = promisify(execFile);
12
+ /** `<bin> --version` responde? Best-effort com timeout curto — um binário
13
+ * ausente ou travado nunca pode segurar o doctor. */
14
+ async function hasCommand(bin) {
15
+ try {
16
+ await execFileAsync(bin, ['--version'], { timeout: 5000 });
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ /** Roda `<bin> <args>` só pelo exit code (usado nos checks de login). */
24
+ async function commandSucceeds(bin, args) {
25
+ try {
26
+ await execFileAsync(bin, args, { timeout: 8000 });
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
12
33
  export async function doctorCommand(opts = {}) {
13
34
  const checks = [];
14
35
  // 1. Credentials file
@@ -94,6 +115,68 @@ export async function doctorCommand(opts = {}) {
94
115
  ok: ptyOk,
95
116
  detail: ptyOk ? 'ok' : 'falhou ao carregar — o terminal embutido não abre. Reinstale: npm i -g paris-immersion@latest',
96
117
  });
118
+ // 9. Kit da imersão (gh, railway, orca, skills) — TODOS informativos: entram
119
+ // no doctor pra o aluno/mentor ver o que falta, mas NÃO bloqueiam o gate do
120
+ // install-status. Um aluno sem `gh auth` ainda faz o curso; ele conecta depois.
121
+ const ghPresent = await hasCommand('gh');
122
+ if (!ghPresent) {
123
+ checks.push({ name: 'github cli', ok: false, info: true, detail: 'não instalado — rode o instalador de novo ou veja https://cli.github.com' });
124
+ }
125
+ else {
126
+ const ghAuthed = await commandSucceeds('gh', ['auth', 'status']);
127
+ checks.push({
128
+ name: 'github cli',
129
+ ok: ghAuthed,
130
+ info: true,
131
+ detail: ghAuthed ? 'instalado e conectado' : 'instalado, mas sem conta conectada — rode: gh auth login',
132
+ });
133
+ }
134
+ const railwayPresent = await hasCommand('railway');
135
+ if (!railwayPresent) {
136
+ checks.push({ name: 'railway cli', ok: false, info: true, detail: 'não instalado — rode: npm i -g @railway/cli' });
137
+ }
138
+ else {
139
+ const railwayAuthed = await commandSucceeds('railway', ['whoami']);
140
+ checks.push({
141
+ name: 'railway cli',
142
+ ok: railwayAuthed,
143
+ info: true,
144
+ detail: railwayAuthed ? 'instalado e conectado' : 'instalado, mas sem conta conectada — rode: railway login',
145
+ });
146
+ }
147
+ // Orca: o CLI fica dentro do bundle do app, então checamos o symlink que o
148
+ // instalador cria E o .app em si (o aluno pode ter instalado só o app).
149
+ const orcaOnPath = await hasCommand('orca');
150
+ const orcaApp = orcaOnPath ||
151
+ (await Promise.all([
152
+ path.join(homedir(), '.local', 'bin', 'orca'),
153
+ '/Applications/Orca.app',
154
+ path.join(process.env.LOCALAPPDATA ?? '', 'Programs', 'Orca', 'Orca.exe'),
155
+ ].map((p) => (p ? fs.access(p).then(() => true).catch(() => false) : Promise.resolve(false)))).then((r) => r.some(Boolean)));
156
+ checks.push({
157
+ name: 'orca',
158
+ ok: orcaApp,
159
+ info: true,
160
+ detail: orcaApp ? 'instalado' : 'não encontrado — baixe em https://www.onorca.dev/download',
161
+ });
162
+ if (claudeInstalled) {
163
+ let pluginOk = false;
164
+ try {
165
+ const { stdout } = await execFileAsync('claude', ['plugin', 'list'], { timeout: 10_000 });
166
+ pluginOk = /imersao/i.test(stdout);
167
+ }
168
+ catch {
169
+ pluginOk = false;
170
+ }
171
+ checks.push({
172
+ name: 'skills da imersão',
173
+ ok: pluginOk,
174
+ info: true,
175
+ detail: pluginOk
176
+ ? 'plugin instalado'
177
+ : 'plugin ausente — no Claude Code rode: /plugin marketplace add https://github.com/parisgroup-ai/imersao-ia-setup e /plugin install imersao@imersao-ia',
178
+ });
179
+ }
97
180
  // Report
98
181
  console.log();
99
182
  console.log(c.bold('paris doctor'));
@@ -1,9 +1,20 @@
1
1
  import { apiAuthFetch } from '../lib/api.js';
2
+ import { findMarker, writeMarker } from '../lib/marker.js';
2
3
  import { c } from '../lib/colors.js';
3
4
  export async function graduateCommand() {
4
5
  const res = await apiAuthFetch('/immersion-app/v1/graduate', {
5
6
  method: 'POST',
6
7
  });
8
+ // Silencia o watcher IMEDIATAMENTE: o PostToolUse só consulta o marker local
9
+ // (caminho quente, sem API) — sem esta escrita ele continuaria reportando até
10
+ // a próxima session-start refrescar o marker.
11
+ const found = await findMarker();
12
+ if (found) {
13
+ try {
14
+ await writeMarker(found.dir, { ...found.marker, status: 'graduated' });
15
+ }
16
+ catch { /* best-effort */ }
17
+ }
7
18
  if (res.status === 'already_graduated') {
8
19
  console.log(c.yellow(`Já estava graduado em ${res.graduated_at.slice(0, 10)}.`));
9
20
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `paris mentor` — instala o MCP paris-admin no Claude Code do MENTOR/ADMIN.
3
+ *
4
+ * O mentor loga com `paris login` (mesmo device-flow do aluno; o token carrega
5
+ * a role) e roda `paris mentor` uma vez: o bundle vendorizado vai pra
6
+ * ~/.paris/admin-mcp/index.mjs e é registrado no Claude Code em escopo de
7
+ * usuário (`claude mcp add --scope user`). A partir daí ele pergunta em
8
+ * linguagem natural ("como está o João?", "quem travou hoje?") e o Claude
9
+ * responde com dado real via tools turma_overview / find_student /
10
+ * student_activity / who_needs_help / add_note.
11
+ */
12
+ export declare function mentorCommand(): Promise<void>;
@@ -0,0 +1,62 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { execFile } from 'node:child_process';
6
+ import { readCredentials } from '../lib/credentials.js';
7
+ import { c } from '../lib/colors.js';
8
+ /**
9
+ * `paris mentor` — instala o MCP paris-admin no Claude Code do MENTOR/ADMIN.
10
+ *
11
+ * O mentor loga com `paris login` (mesmo device-flow do aluno; o token carrega
12
+ * a role) e roda `paris mentor` uma vez: o bundle vendorizado vai pra
13
+ * ~/.paris/admin-mcp/index.mjs e é registrado no Claude Code em escopo de
14
+ * usuário (`claude mcp add --scope user`). A partir daí ele pergunta em
15
+ * linguagem natural ("como está o João?", "quem travou hoje?") e o Claude
16
+ * responde com dado real via tools turma_overview / find_student /
17
+ * student_activity / who_needs_help / add_note.
18
+ */
19
+ export async function mentorCommand() {
20
+ const creds = await readCredentials();
21
+ if (!creds) {
22
+ console.log(c.red('Você precisa estar logado. Rode `paris login` com sua conta de mentor/admin primeiro.'));
23
+ process.exitCode = 1;
24
+ return;
25
+ }
26
+ // 1. Vendoriza o bundle admin em ~/.paris/admin-mcp/index.mjs
27
+ const bundlePath = fileURLToPath(new URL('../../templates/mcp/admin.mjs', import.meta.url));
28
+ let bundle;
29
+ try {
30
+ bundle = await fs.readFile(bundlePath, 'utf-8');
31
+ }
32
+ catch {
33
+ console.log(c.red(`Bundle do MCP admin não encontrado (${bundlePath}). Reinstale a CLI: npm i -g paris-immersion`));
34
+ process.exitCode = 1;
35
+ return;
36
+ }
37
+ const targetDir = path.join(homedir(), '.paris', 'admin-mcp');
38
+ await fs.mkdir(targetDir, { recursive: true });
39
+ const target = path.join(targetDir, 'index.mjs');
40
+ await fs.writeFile(target, bundle, { mode: 0o755 });
41
+ console.log(c.green(`✓ MCP paris-admin vendorizado em ${target}`));
42
+ // 2. Registra no Claude Code (escopo do usuário — funciona de qualquer pasta).
43
+ const registered = await new Promise((resolve) => {
44
+ try {
45
+ execFile('claude', ['mcp', 'add', '--scope', 'user', 'paris-admin', '--', 'node', target], { timeout: 15000, windowsHide: true }, (err) => resolve(!err));
46
+ }
47
+ catch {
48
+ resolve(false);
49
+ }
50
+ });
51
+ if (registered) {
52
+ console.log(c.green('✓ Registrado no Claude Code (escopo user) como "paris-admin".'));
53
+ console.log('');
54
+ console.log('Abra o Claude Code em qualquer pasta e pergunte, por exemplo:');
55
+ console.log(c.dim(' "Como está a turma?" · "Quem travou hoje?" · "O que o João fez na última hora?"'));
56
+ }
57
+ else {
58
+ console.log(c.yellow('Não consegui registrar automaticamente (o binário `claude` não respondeu).'));
59
+ console.log('Registre manualmente com:');
60
+ console.log(c.dim(` claude mcp add --scope user paris-admin -- node ${target}`));
61
+ }
62
+ }
@@ -8,6 +8,7 @@ import { ensureWorkspace } from './install.js';
8
8
  import { startRelayAgent } from '../lib/relay-agent.js';
9
9
  import { renderTostudyCommand } from '../templates/tostudy-command.js';
10
10
  import { renderHookScript } from '../templates/hook.js';
11
+ import { renderSettingsJson } from '../templates/settings.js';
11
12
  import { fileURLToPath } from 'node:url';
12
13
  import { maybeSelfUpdate } from '../lib/self-update.js';
13
14
  import { c } from '../lib/colors.js';
@@ -195,6 +196,24 @@ function ensureWorkspaceVendored(cwd) {
195
196
  catch {
196
197
  /* best-effort */
197
198
  }
199
+ // CRÍTICO (watcher): é o settings.json que DECLARA os hooks (inclusive o
200
+ // PostToolUse). Workspaces criados antes da 0.1.24 têm o hook novo no disco
201
+ // mas um settings velho sem PostToolUse → watcher mudo. Reescrever aqui
202
+ // (como já fazemos com o hook e o MCP) auto-cura toda a base instalada.
203
+ try {
204
+ const raw = readFileSync(path.join(cwd, '.paris', 'workspace.json'), 'utf-8');
205
+ const marker = JSON.parse(raw);
206
+ if (marker.api_url) {
207
+ const claudeDir = path.join(cwd, '.claude');
208
+ mkdirSync(claudeDir, { recursive: true });
209
+ const payload = JSON.stringify(renderSettingsJson({ apiUrl: marker.api_url }), null, 2);
210
+ writeFileSync(path.join(claudeDir, 'settings.json'), payload);
211
+ writeFileSync(path.join(claudeDir, 'settings.local.json'), payload);
212
+ }
213
+ }
214
+ catch {
215
+ /* best-effort */
216
+ }
198
217
  try {
199
218
  const mcpSrc = fileURLToPath(new URL('../../templates/mcp/index.mjs', import.meta.url));
200
219
  const mcpDir = path.join(cwd, '.paris', 'mcp');
package/dist/index.js CHANGED
@@ -11,10 +11,11 @@ import { logoutCommand } from './commands/logout.js';
11
11
  import { serveCommand } from './commands/serve.js';
12
12
  import { daemonCommand, restartCommand } from './commands/daemon.js';
13
13
  import { tostudyLoginCommand } from './commands/tostudy-login.js';
14
+ import { mentorCommand } from './commands/mentor.js';
14
15
  const program = new Command()
15
16
  .name('paris')
16
17
  .description('Paris Immersion — CLI do aluno (login, workspace, tarefas)')
17
- .version('0.1.25');
18
+ .version('0.1.27');
18
19
  program
19
20
  .command('login')
20
21
  .description('Autenticar com sua conta Paris (device-code OR --token direto)')
@@ -71,6 +72,10 @@ program
71
72
  .description('Conecta o ToStudy CLI à conta da imersão (rede de segurança quando o curso não abre). Respeita sessão existente.')
72
73
  .option('--force', 'Re-loga mesmo se já houver sessão ToStudy')
73
74
  .action(tostudyLoginCommand);
75
+ program
76
+ .command('mentor')
77
+ .description('Instala o MCP paris-admin no Claude Code do mentor/admin (consulta da turma em linguagem natural).')
78
+ .action(mentorCommand);
74
79
  program.parseAsync().catch((err) => {
75
80
  console.error('\n[paris] erro:', err?.message ?? err);
76
81
  process.exit(1);
@@ -15,12 +15,13 @@ export function renderClaudeMd(opts) {
15
15
  1. **Coach, não tribunal.** Antes de marcar uma tarefa como concluída, você DEVE:
16
16
  - Rodar os testes/validações pertinentes ao \`deliverable_type\` da tarefa.
17
17
  - Pedir evidência objetiva ao aluno (link, arquivo, número, screenshot).
18
- - Checar o \`socio_prompt\` da tarefa pra garantir que o aluno respondeu o que a imersão pediu.
18
+ - Checar a instrução da tarefa (campo \`socio_prompt\` na API) pra garantir que o aluno respondeu o que a imersão pediu.
19
19
  - Apresentar um mini-checklist do que falta ANTES de aceitar um "acabei".
20
20
  2. **Respeite a decisão final do aluno.** Se ele disser "acabei" depois do checklist, marque (via MCP \`mark_task_complete\`). Você não é juiz.
21
21
  3. **Transparência total.** Tudo aqui é gravado em audit_logs e o instrutor pode reabrir qualquer tarefa.
22
22
  4. **Nunca invente progresso.** Só marque o que de fato foi feito nessa sessão.
23
23
  5. **Inicie a sessão falando primeiro.** O hook SessionStart te entrega o cronograma + a tarefa atual antes do aluno digitar — abra o dia com um resumo claro (fase, tarefa, primeiro passo sugerido).
24
+ 6. **O dossiê do projeto é sagrado.** \`produto/visao.md\` precisa SEMPRE refletir o que é o projeto, a dor, a ideia, como resolve e pra quem (o /visao do Prototipador escreve nesse formato). Toda decisão nova de escopo vai pro arquivo na hora. Ele sincroniza sozinho com a equipe no fim do turno e é pré-requisito pra concluir missões de entregável — sem dossiê, o mark_task_complete recusa.
24
25
 
25
26
  ## MCP tools que você tem
26
27
 
@@ -1,12 +1,18 @@
1
1
  /**
2
2
  * The hook script that Claude Code invokes on SessionStart / UserPromptSubmit /
3
- * Stop. We ship it as a string so `paris install` can write it verbatim into
4
- * the workspace without needing the @paris/cli package to be installed in the
5
- * student's node_modules.
3
+ * Stop / PostToolUse. We ship it as a string so `paris install` can write it
4
+ * verbatim into the workspace without needing the @paris/cli package to be
5
+ * installed in the student's node_modules.
6
6
  *
7
7
  * The script is pure stdlib: walks up to find the marker, reads credentials,
8
8
  * calls the API, prints a system-reminder to stdout (which Claude Code feeds
9
9
  * back to the model). Failures degrade gracefully — they print to stderr so
10
10
  * the student can still get useful debug info, but never block the session.
11
+ *
12
+ * Watcher (2026-08): PostToolUse NÃO faz rede — dá append numa fila NDJSON
13
+ * local (.paris/activity-queue.ndjsonl) e sai em ~1ms. O flush em lote
14
+ * acontece no Stop/SessionStart (ou quando a fila passa de 25 linhas), via
15
+ * POST /api/activity/claude-code { events: [...] }. Todo fetch tem timeout
16
+ * de 4s pra nunca segurar o loop do aluno.
11
17
  */
12
18
  export declare function renderHookScript(): string;
@@ -1,25 +1,37 @@
1
1
  /**
2
2
  * The hook script that Claude Code invokes on SessionStart / UserPromptSubmit /
3
- * Stop. We ship it as a string so `paris install` can write it verbatim into
4
- * the workspace without needing the @paris/cli package to be installed in the
5
- * student's node_modules.
3
+ * Stop / PostToolUse. We ship it as a string so `paris install` can write it
4
+ * verbatim into the workspace without needing the @paris/cli package to be
5
+ * installed in the student's node_modules.
6
6
  *
7
7
  * The script is pure stdlib: walks up to find the marker, reads credentials,
8
8
  * calls the API, prints a system-reminder to stdout (which Claude Code feeds
9
9
  * back to the model). Failures degrade gracefully — they print to stderr so
10
10
  * the student can still get useful debug info, but never block the session.
11
+ *
12
+ * Watcher (2026-08): PostToolUse NÃO faz rede — dá append numa fila NDJSON
13
+ * local (.paris/activity-queue.ndjsonl) e sai em ~1ms. O flush em lote
14
+ * acontece no Stop/SessionStart (ou quando a fila passa de 25 linhas), via
15
+ * POST /api/activity/claude-code { events: [...] }. Todo fetch tem timeout
16
+ * de 4s pra nunca segurar o loop do aluno.
11
17
  */
12
18
  export function renderHookScript() {
13
19
  return `#!/usr/bin/env node
14
20
  // Auto-generated by \`paris install\`. Do not edit — regenerate via \`paris install\`.
15
21
 
16
22
  import { promises as fs } from 'node:fs'
23
+ import { appendFileSync } from 'node:fs'
17
24
  import { homedir } from 'node:os'
18
25
  import path from 'node:path'
19
26
  import { execFile } from 'node:child_process'
20
27
 
21
28
  const PHASE = process.argv[2] // session-start | user-prompt-submit | stop | post-tool-use
22
29
 
30
+ const FETCH_TIMEOUT_MS = 4000
31
+ const QUEUE_FLUSH_AT = 25 // flush quando a fila acumula isso
32
+ const QUEUE_MAX_LINES = 2000 // cap duro — descarta o mais antigo
33
+ const BATCH_SIZE = 50 // eventos por request de flush
34
+
23
35
  async function loadCredentials() {
24
36
  try {
25
37
  const file = process.env.PARIS_CREDENTIALS_FILE || path.join(homedir(), '.paris', 'credentials')
@@ -41,26 +53,42 @@ async function loadMarker() {
41
53
  }
42
54
  }
43
55
 
56
+ function timeoutSignal() {
57
+ try { return AbortSignal.timeout(FETCH_TIMEOUT_MS) } catch { return undefined }
58
+ }
59
+
44
60
  async function apiGet(apiUrl, pathname, token) {
45
- const res = await fetch(new URL(pathname, apiUrl), {
46
- headers: { authorization: 'Bearer ' + token, accept: 'application/json' },
47
- })
48
- if (!res.ok) return null
49
- return res.json()
61
+ try {
62
+ const res = await fetch(new URL(pathname, apiUrl), {
63
+ headers: { authorization: 'Bearer ' + token, accept: 'application/json' },
64
+ signal: timeoutSignal(),
65
+ })
66
+ if (!res.ok) return null
67
+ return res.json()
68
+ } catch { return null }
69
+ }
70
+
71
+ // Retorna o status HTTP (0 = rede/timeout) — o flush da fila decide reter/descartar.
72
+ async function apiPostStatus(apiUrl, pathname, token, body) {
73
+ try {
74
+ const res = await fetch(new URL(pathname, apiUrl), {
75
+ method: 'POST',
76
+ headers: { authorization: 'Bearer ' + token, 'content-type': 'application/json' },
77
+ body: JSON.stringify(body),
78
+ signal: timeoutSignal(),
79
+ })
80
+ return res.status
81
+ } catch { return 0 }
50
82
  }
51
83
 
52
84
  async function apiPost(apiUrl, pathname, token, body) {
53
- const res = await fetch(new URL(pathname, apiUrl), {
54
- method: 'POST',
55
- headers: { authorization: 'Bearer ' + token, 'content-type': 'application/json' },
56
- body: JSON.stringify(body),
57
- })
58
- if (!res.ok) return null
59
- return res.json().catch(() => null)
85
+ const status = await apiPostStatus(apiUrl, pathname, token, body)
86
+ return status >= 200 && status < 300
60
87
  }
61
88
 
62
- // Claude Code passa o payload do hook (session_id, tool_name, tool_input, prompt,
63
- // cwd, ...) como JSON no stdin. Lê com timeout curto pra NUNCA travar a sessão.
89
+ // Claude Code passa o payload do hook (session_id, tool_name, tool_input,
90
+ // tool_response, prompt, cwd, ...) como JSON no stdin. Lê com timeout curto
91
+ // pra NUNCA travar a sessão.
64
92
  function readStdin() {
65
93
  return new Promise((resolve) => {
66
94
  try {
@@ -87,6 +115,139 @@ function runCmd(cmd, args, cwd) {
87
115
  })
88
116
  }
89
117
 
118
+ // ---------------------------------------------------------------------------
119
+ // Fila local de atividade (watcher). PostToolUse só faz appendFileSync (~1ms).
120
+ // ---------------------------------------------------------------------------
121
+
122
+ function queuePath(markerDir) {
123
+ return path.join(markerDir, '.paris', 'activity-queue.ndjsonl')
124
+ }
125
+
126
+ function truncate(str, max) {
127
+ if (typeof str !== 'string') return str
128
+ return str.length > max ? str.slice(0, max) + '…' : str
129
+ }
130
+
131
+ // Mantém só o que interessa do tool_input/tool_response, com caps de tamanho —
132
+ // o exit code / stderr do Bash é o sinal mais útil pro mentor.
133
+ function slimToolInput(input) {
134
+ if (!input || typeof input !== 'object') return {}
135
+ const out = {}
136
+ if (typeof input.file_path === 'string') out.file_path = truncate(input.file_path, 500)
137
+ if (typeof input.command === 'string') out.command = truncate(input.command, 1000)
138
+ if (typeof input.description === 'string') out.description = truncate(input.description, 300)
139
+ if (Object.keys(out).length === 0) {
140
+ try { out.raw = truncate(JSON.stringify(input), 2000) } catch {}
141
+ }
142
+ return out
143
+ }
144
+
145
+ function slimToolResponse(resp) {
146
+ if (!resp || typeof resp !== 'object') return undefined
147
+ const out = {}
148
+ for (const k of ['exit_code', 'exitCode', 'interrupted', 'is_error', 'isError']) {
149
+ if (resp[k] !== undefined) out[k] = resp[k]
150
+ }
151
+ if (typeof resp.stderr === 'string' && resp.stderr) out.stderr = truncate(resp.stderr, 1000)
152
+ if (typeof resp.stdout === 'string' && resp.stdout) out.stdout = truncate(resp.stdout, 500)
153
+ return Object.keys(out).length > 0 ? out : undefined
154
+ }
155
+
156
+ function enqueueActivity(markerDir, event) {
157
+ try {
158
+ appendFileSync(queuePath(markerDir), JSON.stringify(event) + '\\n')
159
+ } catch { /* fila é best-effort */ }
160
+ }
161
+
162
+ async function readQueue(markerDir) {
163
+ try {
164
+ const raw = await fs.readFile(queuePath(markerDir), 'utf-8')
165
+ const lines = raw.split('\\n').filter(Boolean)
166
+ // Cap duro: descarta o mais antigo.
167
+ const kept = lines.length > QUEUE_MAX_LINES ? lines.slice(lines.length - QUEUE_MAX_LINES) : lines
168
+ const events = []
169
+ for (const l of kept) { try { events.push(JSON.parse(l)) } catch {} }
170
+ return events
171
+ } catch { return [] }
172
+ }
173
+
174
+ async function writeQueue(markerDir, events) {
175
+ try {
176
+ if (events.length === 0) {
177
+ await fs.rm(queuePath(markerDir), { force: true })
178
+ } else {
179
+ await fs.writeFile(queuePath(markerDir), events.map((e) => JSON.stringify(e)).join('\\n') + '\\n')
180
+ }
181
+ } catch { /* best-effort */ }
182
+ }
183
+
184
+ // Flush em lotes de BATCH_SIZE. Trunca em 2xx E em 4xx (fila envenenada não
185
+ // pode travar pra sempre); retém em 5xx/rede pra tentar de novo depois.
186
+ async function flushActivityQueue(apiUrl, token, markerDir) {
187
+ const events = await readQueue(markerDir)
188
+ if (events.length === 0) return
189
+ let idx = 0
190
+ while (idx < events.length) {
191
+ const chunk = events.slice(idx, idx + BATCH_SIZE)
192
+ const status = await apiPostStatus(apiUrl, '/api/activity/claude-code', token, {
193
+ session_id: chunk[chunk.length - 1].session_id,
194
+ cwd: chunk[chunk.length - 1].cwd,
195
+ events: chunk,
196
+ })
197
+ const dropChunk = (status >= 200 && status < 300) || (status >= 400 && status < 500)
198
+ if (!dropChunk) break // 5xx/rede: para e retém o resto
199
+ idx += chunk.length
200
+ }
201
+ await writeQueue(markerDir, events.slice(idx))
202
+ }
203
+
204
+ async function refreshMarkerStatus(markerDir, marker, status) {
205
+ if (!status || marker.status === status) return
206
+ try {
207
+ await fs.writeFile(
208
+ path.join(markerDir, '.paris', 'workspace.json'),
209
+ JSON.stringify({ ...marker, status }, null, 2),
210
+ )
211
+ } catch { /* best-effort */ }
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Dossiê do projeto (produto/visao.md + roadmap.md do Prototipador) → Caso do
216
+ // aluno no servidor. DETERMINÍSTICO: roda em todo Stop/SessionStart e envia
217
+ // quando o arquivo mudou — o mentor sempre vê o que é o projeto, a dor e o
218
+ // plano, sem depender do modelo lembrar de anotar.
219
+ // ---------------------------------------------------------------------------
220
+
221
+ async function syncCaseDossier(apiUrl, token, markerDir) {
222
+ const statePath = path.join(markerDir, '.paris', 'case-sync.json')
223
+ let state = {}
224
+ try { state = JSON.parse(await fs.readFile(statePath, 'utf-8')) } catch {}
225
+
226
+ const files = {
227
+ visao: path.join(markerDir, 'produto', 'visao.md'),
228
+ roadmap: path.join(markerDir, 'produto', 'roadmap.md'),
229
+ }
230
+ const payload = {}
231
+ const newState = { ...state }
232
+ for (const [key, fp] of Object.entries(files)) {
233
+ try {
234
+ const st = await fs.stat(fp)
235
+ const mtime = st.mtimeMs
236
+ if (state[key] !== mtime) {
237
+ const content = await fs.readFile(fp, 'utf-8')
238
+ payload[key] = content.slice(0, 16384)
239
+ newState[key] = mtime
240
+ }
241
+ } catch { /* arquivo ainda não existe — normal antes do /visao */ }
242
+ }
243
+ if (Object.keys(payload).length === 0) return
244
+
245
+ const ok = await apiPost(apiUrl, '/immersion-app/v1/case/sync', token, payload)
246
+ if (ok) {
247
+ try { await fs.writeFile(statePath, JSON.stringify(newState)) } catch {}
248
+ }
249
+ }
250
+
90
251
  // Sincroniza o progresso REAL do curso da ToStudy pro nosso lado. O aluno faz o
91
252
  // curso pela tostudy CLI no terminal embutido, e essa conclusão NÃO dispara o
92
253
  // webhook do cursos — então é daqui que a gestão aprende o progresso. A ToStudy
@@ -117,26 +278,32 @@ async function main() {
117
278
 
118
279
  const apiUrl = marker.marker.api_url || creds.api_url
119
280
 
120
- // Atividade do Claude Code do aluno → endpoint pronto (/api/activity/claude-code)
121
- // que alimenta socio_messages, o coaching proativo do Sócio e a visão do mentor.
122
281
  const hookInput = (PHASE === 'post-tool-use' || PHASE === 'user-prompt-submit') ? await readStdin() : null
123
282
 
124
283
  if (PHASE === 'post-tool-use') {
125
- // Rápido e silencioso (sem stdout, sem apiGets) — roda após cada Write/Edit/Bash.
284
+ // ZERO rede no caminho quente — só append local. Flush quando acumula.
126
285
  if (hookInput && hookInput.tool_name) {
127
- await apiPost(apiUrl, '/api/activity/claude-code', creds.token, {
286
+ enqueueActivity(marker.dir, {
128
287
  hook_event_name: 'PostToolUse',
129
288
  tool_name: hookInput.tool_name,
130
- tool_input: hookInput.tool_input || {},
289
+ tool_input: slimToolInput(hookInput.tool_input),
290
+ tool_response: slimToolResponse(hookInput.tool_response),
131
291
  session_id: hookInput.session_id,
132
292
  cwd: hookInput.cwd,
293
+ at: Date.now(),
133
294
  })
295
+ const queued = await readQueue(marker.dir)
296
+ if (queued.length >= QUEUE_FLUSH_AT) {
297
+ await flushActivityQueue(apiUrl, creds.token, marker.dir)
298
+ }
134
299
  }
135
300
  return
136
301
  }
137
302
 
138
303
  if (PHASE === 'stop') {
139
304
  await apiPost(apiUrl, '/immersion-app/v1/events', creds.token, { type: 'turn_ended' })
305
+ await flushActivityQueue(apiUrl, creds.token, marker.dir)
306
+ await syncCaseDossier(apiUrl, creds.token, marker.dir)
140
307
  // Logo após cada interação é o melhor momento pra capturar uma lição que o
141
308
  // aluno acabou de concluir no curso da ToStudy.
142
309
  await syncTostudyProgress(apiUrl, creds.token, marker.dir)
@@ -153,6 +320,9 @@ async function main() {
153
320
  process.stderr.write('[paris-hook] não foi possível buscar contexto. Está offline?\\n')
154
321
  return
155
322
  }
323
+ // Mantém o marker em dia com o servidor — é o marker que silencia o
324
+ // PostToolUse pós-graduação (o caminho quente não consulta a API).
325
+ await refreshMarkerStatus(marker.dir, marker.marker, workspace.status)
156
326
  if (workspace.status === 'graduated') return
157
327
 
158
328
  // Onboarding: enquanto o curso não termina, o terminal roda o tutor do curso
@@ -194,7 +364,7 @@ async function main() {
194
364
  if (current.current.description) lines.push('- **descrição:** ' + current.current.description)
195
365
  if (current.current.socio_prompt) {
196
366
  lines.push('')
197
- lines.push('### Sócio prompt')
367
+ lines.push('### Instrução da tarefa')
198
368
  lines.push(current.current.socio_prompt)
199
369
  }
200
370
  } else {
@@ -203,27 +373,17 @@ async function main() {
203
373
  }
204
374
  }
205
375
 
206
- // #3: proativos do Sócio pendentes → injeta no contexto (servidor marca
207
- // entregue ao ler). É assim que o coaching proativo chega ao terminal do aluno.
208
- const pending = await apiGet(apiUrl, '/immersion-app/v1/socio/pending', creds.token)
209
- if (pending && Array.isArray(pending.messages) && pending.messages.length > 0) {
210
- lines.push('')
211
- lines.push('## Mensagens do Sócio pra você (proativo)')
212
- lines.push('Repasse isto ao aluno com naturalidade, no momento certo:')
213
- for (const m of pending.messages) {
214
- lines.push('- ' + String(m.content || '').split('\\n').join(' '))
215
- }
216
- }
217
-
218
376
  process.stdout.write(lines.join('\\n') + '\\n')
219
377
 
220
378
  if (PHASE === 'session-start') {
221
379
  await apiPost(apiUrl, '/immersion-app/v1/events', creds.token, { type: 'session_started' })
380
+ // Restos de fila de sessões anteriores (crash, rede) sobem agora.
381
+ await flushActivityQueue(apiUrl, creds.token, marker.dir)
382
+ await syncCaseDossier(apiUrl, creds.token, marker.dir)
222
383
  }
223
384
 
224
- // user-prompt-submit: encaminha o prompt do aluno pro Sócio (gera coaching
225
- // proativo server-side, agora não-bloqueante) + dá ao mentor o "o que ele
226
- // pediu". Best-effort, depois do contexto já impresso.
385
+ // user-prompt-submit: dá ao mentor o "o que ele pediu" em tempo real.
386
+ // Best-effort, depois do contexto já impresso.
227
387
  if (PHASE === 'user-prompt-submit') {
228
388
  // turn_started fecha o ciclo do turno (com turn_ended no stop) → permite medir
229
389
  // cadência/duração de turno e detectar "mandou prompt e não houve resposta".
@@ -47,9 +47,9 @@ export function renderSettingsJson(opts) {
47
47
  },
48
48
  ],
49
49
  // PostToolUse: telemetria da atividade REAL do aluno (arquivos editados +
50
- // comandos) → /api/activity/claude-code, pra o mentor enxergar o trabalho
51
- // e o Sócio coachar. Matcher de alto sinal (Write|Edit|Bash); Read fica de
52
- // fora pra não inundar o feed.
50
+ // comandos) → /api/activity/claude-code, pra o mentor enxergar o trabalho.
51
+ // Matcher de alto sinal (Write|Edit|Bash); Read fica de fora pra não
52
+ // inundar o feed.
53
53
  PostToolUse: [
54
54
  {
55
55
  matcher: 'Write|Edit|Bash',
@@ -15,7 +15,7 @@ Tarefa exige uma resposta escrita (validação, análise, plano, reflexão).
15
15
  Checklist:
16
16
  1. O aluno produziu pelo menos 1 parágrafo concreto, não 1 frase genérica.
17
17
  2. Inclui exemplos ou evidência (números, citação, comparação).
18
- 3. Responde diretamente ao \`socio_prompt\` da tarefa (releia antes de marcar).
18
+ 3. Responde diretamente à instrução da tarefa (campo \`socio_prompt\` na API — releia antes de marcar).
19
19
  4. Aluno releu antes de você marcar.
20
20
 
21
21
  Se algo falta: explique o que e ofereça pra ajudar a estruturar.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "paris-immersion",
3
- "version": "0.1.25",
4
- "description": "Paris Immersion CLI \u2014 login, install workspace, status, task control. Para alunos das imers\u00f5es da Paris Group.",
3
+ "version": "0.1.27",
4
+ "description": "Paris Immersion CLI — login, install workspace, status, task control. Para alunos das imersões da Paris Group.",
5
5
  "homepage": "https://parisgroup.ai",
6
6
  "repository": {
7
7
  "type": "git",
@@ -13,7 +13,7 @@
13
13
  "paris-group",
14
14
  "paris-immersion",
15
15
  "cli",
16
- "imers\u00e3o"
16
+ "imersão"
17
17
  ],
18
18
  "type": "module",
19
19
  "bin": {