paris-immersion 0.1.35 → 0.1.38

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.
@@ -0,0 +1,3 @@
1
+ export declare function conectarCommand(opts?: {
2
+ open?: boolean;
3
+ }): Promise<void>;
@@ -0,0 +1,168 @@
1
+ import { execFile, spawnSync } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { promises as fs } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { findMarker } from '../lib/marker.js';
7
+ import { apiAuthFetch, ApiError } from '../lib/api.js';
8
+ import { readCredentials } from '../lib/credentials.js';
9
+ import { c } from '../lib/colors.js';
10
+ import { loginCommand } from './login.js';
11
+ import { installCommand, ensureWorkspace } from './install.js';
12
+ import { locateWorkspace } from './repair.js';
13
+ import { maybeSelfUpdate } from '../lib/self-update.js';
14
+ const execFileAsync = promisify(execFile);
15
+ async function tokenOk() {
16
+ try {
17
+ const ws = await apiAuthFetch('/immersion-app/v1/workspace');
18
+ return { ok: true, slug: ws.immersion?.slug };
19
+ }
20
+ catch (err) {
21
+ if (err instanceof ApiError)
22
+ return { ok: false, status: err.status, detail: err.message };
23
+ return { ok: false, status: 0, detail: err instanceof Error ? err.message : String(err) };
24
+ }
25
+ }
26
+ /**
27
+ * A pasta da TURMA ATUAL (slug que o servidor devolve). O aluno pode ter a pasta
28
+ * do treino e a da turma lado a lado — consertar a errada deixaria o mentor cego.
29
+ * Ordem: a pasta onde ele está (se for da turma) → ~/paris-<slug> → qualquer
30
+ * ~/paris-* da turma → null (o chamador cria ~/paris-<slug>).
31
+ */
32
+ async function workspaceForSlug(slug) {
33
+ if (!slug)
34
+ return locateWorkspace();
35
+ const here = await findMarker();
36
+ if (here && here.marker.immersion_slug === slug)
37
+ return here.dir;
38
+ const home = homedir();
39
+ const names = await fs.readdir(home).catch(() => []);
40
+ const ordered = [`paris-${slug}`, ...names.filter((n) => n.startsWith('paris-') && n !== `paris-${slug}`)];
41
+ for (const name of ordered) {
42
+ const m = await findMarker(path.join(home, name)).catch(() => null);
43
+ if (m && m.dir === path.join(home, name) && m.marker.immersion_slug === slug)
44
+ return m.dir;
45
+ }
46
+ return null;
47
+ }
48
+ async function runHookCheck(dir) {
49
+ const hook = path.join(dir, '.paris', 'hooks', 'paris-hook.mjs');
50
+ try {
51
+ // `node` do PATH, igual ao Claude Code — se não achar, o hook do aluno
52
+ // também não roda, e é isso que queremos descobrir.
53
+ const { stdout } = await execFileAsync('node', [hook, 'check'], {
54
+ cwd: dir,
55
+ timeout: 60_000,
56
+ env: { ...process.env, CLAUDE_PROJECT_DIR: dir },
57
+ });
58
+ const line = stdout.trim().split('\n').pop() ?? '';
59
+ return JSON.parse(line);
60
+ }
61
+ catch (err) {
62
+ const e = err;
63
+ if (e.code === 'ENOENT')
64
+ return { error: '`node` não está no PATH — o Claude Code não consegue rodar o hook. Reinstale pelo instalador da imersão.' };
65
+ return { error: (e.stderr || e.message || String(err)).trim().slice(0, 300) };
66
+ }
67
+ }
68
+ // Windows: `paris`/`claude` são shims .cmd — precisam de shell pra resolver.
69
+ const SHELL = process.platform === 'win32';
70
+ export async function conectarCommand(opts = {}) {
71
+ console.log(c.bold('paris conectar'));
72
+ console.log();
73
+ // 0. Versão mais nova no npm? Atualiza e roda o conectar DA VERSÃO NOVA (o
74
+ // hook que vai pro workspace sai do template dela). É isto que substitui o
75
+ // `npm i -g paris-immersion@latest && ...` colado à mão no treino de 30/09.
76
+ if (!process.env.PARIS_CONECTAR_UPDATED) {
77
+ const updated = await maybeSelfUpdate((m) => console.log(c.dim(m)), true);
78
+ if (updated) {
79
+ const args = ['conectar', ...(opts.open === false ? ['--no-open'] : [])];
80
+ const r = spawnSync('paris', args, {
81
+ stdio: 'inherit',
82
+ shell: SHELL,
83
+ env: { ...process.env, PARIS_CONECTAR_UPDATED: '1' },
84
+ });
85
+ if (!r.error) {
86
+ process.exitCode = r.status ?? 0;
87
+ return;
88
+ }
89
+ console.log(c.yellow('○ Atualizei, mas não consegui reabrir o paris — seguindo nesta versão.'));
90
+ }
91
+ }
92
+ // 1. Login
93
+ let creds = await readCredentials().catch(() => null);
94
+ let tk = creds ? await tokenOk() : { ok: false, status: 401 };
95
+ if (!tk.ok && tk.status === 0) {
96
+ console.log(c.red(`✗ Sem conexão com o servidor (${tk.detail}).`));
97
+ console.log(c.dim(' Confira o Wi-Fi (ou use o roteador do celular) e rode `paris conectar` de novo.'));
98
+ console.log(c.dim(' Nada se perde: o que você fizer offline fica guardado e sobe quando a conexão voltar.'));
99
+ process.exitCode = 1;
100
+ return;
101
+ }
102
+ if (!tk.ok && (tk.status === 401 || tk.status === 403)) {
103
+ console.log(c.yellow('○ Seu login venceu ou não existe — vamos entrar de novo.'));
104
+ await loginCommand({ apiUrl: creds?.api_url });
105
+ creds = await readCredentials().catch(() => null);
106
+ tk = creds ? await tokenOk() : { ok: false, status: 401 };
107
+ }
108
+ if (!tk.ok) {
109
+ const why = tk.status === 410 ? 'a imersão já foi encerrada pra esta conta' : `${tk.status ?? ''} ${tk.detail ?? ''}`.trim();
110
+ console.log(c.red(`✗ Login não funcionou: ${why}.`));
111
+ console.log(c.dim(' Chame o mentor da sua mesa.'));
112
+ process.exitCode = 1;
113
+ return;
114
+ }
115
+ console.log(c.green(`✓ Login ok (${creds.email})`));
116
+ // 2. Workspace + hooks frescos
117
+ let wsDir = await workspaceForSlug(tk.slug);
118
+ if (!wsDir) {
119
+ // Mesmo lugar previsível que o terminal embutido usa (~/paris-<slug>).
120
+ console.log(c.yellow('○ Não achei a pasta da turma — criando agora.'));
121
+ wsDir = tk.slug ? path.join(homedir(), `paris-${tk.slug}`) : (await ensureWorkspace()).dir;
122
+ }
123
+ await installCommand({ dir: wsDir });
124
+ console.log(c.green(`✓ Pasta e ferramentas do Claude atualizadas (${wsDir})`));
125
+ // 3. Envio real pelo hook
126
+ const r = await runHookCheck(wsDir);
127
+ if ('error' in r) {
128
+ console.log(c.red(`✗ O hook do Claude não rodou: ${r.error}`));
129
+ process.exitCode = 1;
130
+ return;
131
+ }
132
+ if (r.lastStatus < 200 || r.lastStatus >= 300) {
133
+ const why = r.lastStatus === 0 ? 'sem resposta do servidor (rede)' : `servidor respondeu ${r.lastStatus}`;
134
+ console.log(c.red(`✗ Envio falhou: ${why}. ${r.pending} evento(s) seguem guardados.`));
135
+ console.log(c.dim(' Rode `paris conectar` de novo em 1 minuto.'));
136
+ process.exitCode = 1;
137
+ return;
138
+ }
139
+ console.log(c.green(`✓ Envio ok (${r.sent} evento(s) enviados${r.pending ? `, ${r.pending} ainda na fila` : ''})`));
140
+ // 4. Prova do lado do servidor
141
+ try {
142
+ const st = await apiAuthFetch('/api/activity/status/me');
143
+ const age = st.lastActivity ? Date.now() - new Date(st.lastActivity).getTime() : Infinity;
144
+ if (age > 5 * 60_000) {
145
+ console.log(c.red('✗ O servidor não registrou o evento — a conta logada não está na turma.'));
146
+ console.log(c.dim(` Você entrou como ${creds.email}. Se não é o e-mail da inscrição, rode \`paris logout\` e \`paris conectar\`.`));
147
+ process.exitCode = 1;
148
+ return;
149
+ }
150
+ }
151
+ catch (err) {
152
+ console.log(c.yellow(`○ Não consegui confirmar no servidor (${err instanceof Error ? err.message : String(err)}), mas o envio passou.`));
153
+ }
154
+ console.log();
155
+ console.log(c.green(c.bold('Conectado. O mentor está vendo seu terminal.')));
156
+ // Hooks e MCP só recarregam num Claude novo — reabre na pasta da imersão,
157
+ // continuando a última conversa (igual ao `paris repair`).
158
+ if (opts.open === false) {
159
+ console.log(c.dim('Se o Claude já estava aberto: saia com /exit e abra de novo na pasta da imersão com `claude --continue`.'));
160
+ return;
161
+ }
162
+ console.log(c.dim('Abrindo o Claude na pasta da imersão (continuando a última conversa)...'));
163
+ console.log();
164
+ const cont = spawnSync('claude', ['--continue'], { cwd: wsDir, stdio: 'inherit', shell: SHELL });
165
+ if (cont.error || (cont.status !== 0 && cont.status !== null)) {
166
+ spawnSync('claude', [], { cwd: wsDir, stdio: 'inherit', shell: SHELL });
167
+ }
168
+ }
@@ -7,7 +7,7 @@ import { findMarker, writeMarker } from '../lib/marker.js';
7
7
  import { readCredentials } from '../lib/credentials.js';
8
8
  import { c } from '../lib/colors.js';
9
9
  import { renderClaudeMd } from '../templates/claude-md.js';
10
- import { renderSettingsJson } from '../templates/settings.js';
10
+ import { renderSettingsJson, renderMcpJson } from '../templates/settings.js';
11
11
  import { renderParisCoachSkill } from '../templates/skill.js';
12
12
  import { renderHookScript } from '../templates/hook.js';
13
13
  import { renderTostudyCommand } from '../templates/tostudy-command.js';
@@ -119,6 +119,8 @@ async function writeWorkspaceFiles(targetDir, boot, apiUrl, email) {
119
119
  const settingsPayload = renderSettingsJson({ apiUrl });
120
120
  await fs.writeFile(path.join(claudeDir, 'settings.json'), JSON.stringify(settingsPayload, null, 2));
121
121
  await fs.writeFile(path.join(claudeDir, 'settings.local.json'), JSON.stringify(settingsPayload, null, 2));
122
+ // .mcp.json — é daqui que o Claude Code carrega o MCP do projeto (não do settings).
123
+ await fs.writeFile(path.join(targetDir, '.mcp.json'), JSON.stringify(renderMcpJson({ apiUrl }), null, 2));
122
124
  // .claude/skills/paris-coach/SKILL.md
123
125
  const skillDir = path.join(claudeDir, 'skills', 'paris-coach');
124
126
  await fs.mkdir(skillDir, { recursive: true });
@@ -7,6 +7,6 @@
7
7
  * usuário (`claude mcp add --scope user`). A partir daí ele pergunta em
8
8
  * linguagem natural ("como está o João?", "quem travou hoje?") e o Claude
9
9
  * responde com dado real via tools turma_overview / find_student /
10
- * student_activity / who_needs_help / add_note.
10
+ * student_activity / who_needs_help / add_note / minha_mesa.
11
11
  */
12
12
  export declare function mentorCommand(): Promise<void>;
@@ -14,7 +14,7 @@ import { c } from '../lib/colors.js';
14
14
  * usuário (`claude mcp add --scope user`). A partir daí ele pergunta em
15
15
  * linguagem natural ("como está o João?", "quem travou hoje?") e o Claude
16
16
  * responde com dado real via tools turma_overview / find_student /
17
- * student_activity / who_needs_help / add_note.
17
+ * student_activity / who_needs_help / add_note / minha_mesa.
18
18
  */
19
19
  export async function mentorCommand() {
20
20
  const creds = await readCredentials();
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `paris repair` — uma palavra só, sem `cd` nem `&&`. Existe porque o comando
3
+ * longo colado do WhatsApp quebrava (o app troca espaços por NBSP e o shell lê
4
+ * `cd: too many arguments`). Acha o workspace sozinho (a partir da pasta atual,
5
+ * subindo; senão o `~/paris-*` mais recente), reescreve hooks, MCP (.mcp.json),
6
+ * settings e CLAUDE.md SEM tocar no trabalho do aluno, e reabre o Claude na
7
+ * raiz continuando a última conversa.
8
+ */
9
+ export declare function locateWorkspace(): Promise<string | null>;
10
+ export declare function repairCommand(opts: {
11
+ open?: boolean;
12
+ }): Promise<void>;
@@ -0,0 +1,48 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { homedir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { findMarker } from '../lib/marker.js';
6
+ import { c } from '../lib/colors.js';
7
+ import { installCommand } from './install.js';
8
+ /**
9
+ * `paris repair` — uma palavra só, sem `cd` nem `&&`. Existe porque o comando
10
+ * longo colado do WhatsApp quebrava (o app troca espaços por NBSP e o shell lê
11
+ * `cd: too many arguments`). Acha o workspace sozinho (a partir da pasta atual,
12
+ * subindo; senão o `~/paris-*` mais recente), reescreve hooks, MCP (.mcp.json),
13
+ * settings e CLAUDE.md SEM tocar no trabalho do aluno, e reabre o Claude na
14
+ * raiz continuando a última conversa.
15
+ */
16
+ export async function locateWorkspace() {
17
+ const here = await findMarker();
18
+ if (here)
19
+ return here.dir;
20
+ const home = homedir();
21
+ const entries = await fs.readdir(home).catch(() => []);
22
+ let best = null;
23
+ for (const name of entries) {
24
+ if (!name.startsWith('paris-'))
25
+ continue;
26
+ const marker = path.join(home, name, '.paris', 'workspace.json');
27
+ const st = await fs.stat(marker).catch(() => null);
28
+ if (st && (!best || st.mtimeMs > best.mtime))
29
+ best = { dir: path.join(home, name), mtime: st.mtimeMs };
30
+ }
31
+ return best?.dir ?? null;
32
+ }
33
+ export async function repairCommand(opts) {
34
+ const dir = await locateWorkspace();
35
+ if (!dir) {
36
+ console.error(c.red('Não achei a pasta da imersão. Rode `paris install` primeiro.'));
37
+ process.exit(1);
38
+ }
39
+ console.log(c.dim(`Consertando o workspace em ${dir}...`));
40
+ await installCommand({ dir });
41
+ if (opts.open === false)
42
+ return;
43
+ console.log(c.green('✓ Pronto. Abrindo o Claude na pasta da imersão (continuando a última conversa)...'));
44
+ const cont = spawnSync('claude', ['--continue'], { cwd: dir, stdio: 'inherit' });
45
+ if (cont.error || (cont.status !== 0 && cont.status !== null)) {
46
+ spawnSync('claude', [], { cwd: dir, stdio: 'inherit' });
47
+ }
48
+ }
@@ -9,7 +9,7 @@ import { startRelayAgent } from '../lib/relay-agent.js';
9
9
  import { parseMissionAction, startMissionInOrca } from '../lib/orca-launcher.js';
10
10
  import { renderTostudyCommand } from '../templates/tostudy-command.js';
11
11
  import { renderHookScript } from '../templates/hook.js';
12
- import { renderSettingsJson } from '../templates/settings.js';
12
+ import { renderSettingsJson, renderMcpJson } from '../templates/settings.js';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { maybeSelfUpdate } from '../lib/self-update.js';
15
15
  import { c } from '../lib/colors.js';
@@ -210,6 +210,7 @@ function ensureWorkspaceVendored(cwd) {
210
210
  const payload = JSON.stringify(renderSettingsJson({ apiUrl: marker.api_url }), null, 2);
211
211
  writeFileSync(path.join(claudeDir, 'settings.json'), payload);
212
212
  writeFileSync(path.join(claudeDir, 'settings.local.json'), payload);
213
+ writeFileSync(path.join(cwd, '.mcp.json'), JSON.stringify(renderMcpJson({ apiUrl: marker.api_url }), null, 2));
213
214
  }
214
215
  }
215
216
  catch {
@@ -478,6 +479,10 @@ export async function serveCommand(opts) {
478
479
  try {
479
480
  const { dir, created } = await ensureWorkspace({});
480
481
  cwd = dir;
482
+ // Auto-cura já no boot do daemon (não só quando o terminal embutido abre):
483
+ // quem usa o Claude no terminal próprio/Orca também recebe .mcp.json/hook novos
484
+ // assim que o self-update reinicia o serve.
485
+ ensureWorkspaceVendored(dir);
481
486
  console.log(created
482
487
  ? ` ${c.green('✓')} workspace criado em ${c.bold(dir)}`
483
488
  : ` ${c.dim('workspace:')} ${dir}`);
package/dist/index.js CHANGED
@@ -3,6 +3,8 @@ import { Command } from 'commander';
3
3
  import { createRequire } from 'node:module';
4
4
  import { loginCommand } from './commands/login.js';
5
5
  import { installCommand } from './commands/install.js';
6
+ import { repairCommand } from './commands/repair.js';
7
+ import { conectarCommand } from './commands/conectar.js';
6
8
  import { doctorCommand } from './commands/doctor.js';
7
9
  import { tasksCommand } from './commands/tasks.js';
8
10
  import { statusCommand } from './commands/status.js';
@@ -32,6 +34,16 @@ program
32
34
  .option('-d, --dir <path>', 'pasta destino (padrão: cria uma nova com o slug da imersão)')
33
35
  .option('--immersion <slug>', 'imersão específica (caso o aluno esteja em mais de uma)')
34
36
  .action(installCommand);
37
+ program
38
+ .command('repair')
39
+ .description('Conserta a pasta da imersão (hooks, ferramentas do Claude) sem apagar nada e reabre o Claude')
40
+ .option('--no-open', 'só conserta, não abre o Claude')
41
+ .action(repairCommand);
42
+ program
43
+ .command('conectar')
44
+ .description('Comando de emergência: atualiza a CLI, reloga se preciso, conserta a pasta, prova que o mentor está te vendo e reabre o Claude')
45
+ .option('--no-open', 'só conecta, não abre o Claude')
46
+ .action(conectarCommand);
35
47
  program
36
48
  .command('doctor')
37
49
  .description('Diagnostica credentials, marker, hooks, conectividade')
@@ -25,7 +25,7 @@ import { homedir } from 'node:os'
25
25
  import path from 'node:path'
26
26
  import { execFile } from 'node:child_process'
27
27
 
28
- const PHASE = process.argv[2] // session-start | user-prompt-submit | stop | post-tool-use
28
+ const PHASE = process.argv[2] // session-start | user-prompt-submit | stop | post-tool-use | check
29
29
 
30
30
  const FETCH_TIMEOUT_MS = 4000
31
31
  const QUEUE_FLUSH_AT = 25 // flush quando a fila acumula isso
@@ -159,34 +159,78 @@ function enqueueActivity(markerDir, event) {
159
159
  } catch { /* fila é best-effort */ }
160
160
  }
161
161
 
162
+ function parseLines(raw) {
163
+ const events = []
164
+ for (const l of raw.split('\\n')) {
165
+ if (!l) continue
166
+ try { events.push(JSON.parse(l)) } catch {}
167
+ }
168
+ return events
169
+ }
170
+
162
171
  async function readQueue(markerDir) {
163
172
  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
173
+ return parseLines(await fs.readFile(queuePath(markerDir), 'utf-8'))
171
174
  } catch { return [] }
172
175
  }
173
176
 
174
- async function writeQueue(markerDir, events) {
177
+ // Devolve eventos não enviados pra fila com APPEND (nunca sobrescreve — o que
178
+ // outro hook enfileirou enquanto a rede rodava continua lá). Cap duro aplicado
179
+ // no próximo claim.
180
+ function requeue(markerDir, events) {
181
+ if (events.length === 0) return
175
182
  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
- }
183
+ appendFileSync(queuePath(markerDir), events.map((e) => JSON.stringify(e)).join('\\n') + '\\n')
181
184
  } catch { /* best-effort */ }
182
185
  }
183
186
 
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.
187
+ // Status em que o lote é VENENO (nunca vai passar) → descarta. Todo o resto
188
+ // (rede, 5xx, 401/403 de token, 408, 429 de rate-limit) RETÉM pra tentar depois.
189
+ // Antes, qualquer 4xx descartava — um token vencido ou um 429 apagava a fila.
190
+ function isPoison(status) {
191
+ return status === 400 || status === 413 || status === 422
192
+ }
193
+
194
+ // Flush em lotes de BATCH_SIZE com CLAIM ATÔMICO: renomeia a fila pra um arquivo
195
+ // .sending exclusivo deste processo antes de ler. Assim hooks concorrentes
196
+ // (tool calls em paralelo, Stop + PostToolUse) nunca mandam o mesmo evento duas
197
+ // vezes nem apagam o que foi enfileirado durante o envio (o writeFile antigo
198
+ // sobrescrevia a fila inteira — eventos e PROMPTS sumiam). Também adota .sending
199
+ // órfãos (> 2 min) de um hook que morreu no meio.
200
+ // Retorna { sent, pending, lastStatus }.
186
201
  async function flushActivityQueue(apiUrl, token, markerDir) {
187
- const events = await readQueue(markerDir)
188
- if (events.length === 0) return
202
+ const dir = path.join(markerDir, '.paris')
203
+ const claimed = path.join(dir, 'activity-queue.' + process.pid + '-' + Date.now() + '.sending')
204
+ let events = []
205
+ try {
206
+ await fs.rename(queuePath(markerDir), claimed)
207
+ events = parseLines(await fs.readFile(claimed, 'utf-8'))
208
+ } catch { /* fila vazia ou outro hook pegou */ }
209
+ try {
210
+ const now = Date.now()
211
+ for (const name of await fs.readdir(dir)) {
212
+ if (!name.endsWith('.sending')) continue
213
+ const fp = path.join(dir, name)
214
+ if (fp === claimed) continue
215
+ const st = await fs.stat(fp).catch(() => null)
216
+ if (!st || now - st.mtimeMs < 120000) continue
217
+ const orphan = path.join(dir, name + '.adopt-' + process.pid)
218
+ try {
219
+ await fs.rename(fp, orphan)
220
+ events = events.concat(parseLines(await fs.readFile(orphan, 'utf-8')))
221
+ await fs.rm(orphan, { force: true })
222
+ } catch {}
223
+ }
224
+ } catch {}
225
+ if (events.length > QUEUE_MAX_LINES) events = events.slice(events.length - QUEUE_MAX_LINES)
226
+ if (events.length === 0) {
227
+ await fs.rm(claimed, { force: true }).catch(() => {})
228
+ return { sent: 0, pending: 0, lastStatus: 200 }
229
+ }
230
+
189
231
  let idx = 0
232
+ let sent = 0
233
+ let lastStatus = 0
190
234
  while (idx < events.length) {
191
235
  const chunk = events.slice(idx, idx + BATCH_SIZE)
192
236
  const status = await apiPostStatus(apiUrl, '/api/activity/claude-code', token, {
@@ -194,11 +238,16 @@ async function flushActivityQueue(apiUrl, token, markerDir) {
194
238
  cwd: chunk[chunk.length - 1].cwd,
195
239
  events: chunk,
196
240
  })
197
- const dropChunk = (status >= 200 && status < 300) || (status >= 400 && status < 500)
198
- if (!dropChunk) break // 5xx/rede: para e retém o resto
241
+ lastStatus = status
242
+ const ok = status >= 200 && status < 300
243
+ if (!ok && !isPoison(status)) break // rede/5xx/401/429: retém o resto
244
+ if (ok) sent += chunk.length
199
245
  idx += chunk.length
200
246
  }
201
- await writeQueue(markerDir, events.slice(idx))
247
+ const rest = events.slice(idx)
248
+ requeue(markerDir, rest)
249
+ await fs.rm(claimed, { force: true }).catch(() => {})
250
+ return { sent, pending: rest.length, lastStatus }
202
251
  }
203
252
 
204
253
  async function refreshMarkerStatus(markerDir, marker, status) {
@@ -315,6 +364,33 @@ async function main() {
315
364
  return
316
365
  }
317
366
 
367
+ // PROMPT PRIMEIRO, antes de qualquer rede: vai pra fila local com o horário
368
+ // real. Antes o prompt só subia DEPOIS de 3 GETs de contexto — se a API
369
+ // demorasse/caísse o hook saía cedo e o prompt sumia; e não havia retry.
370
+ // Agora ele fica na fila até o servidor confirmar.
371
+ let promptFlush = null
372
+ if (PHASE === 'user-prompt-submit' && hookInput && typeof hookInput.prompt === 'string' && hookInput.prompt.trim()) {
373
+ enqueueActivity(marker.dir, {
374
+ hook_event_name: 'UserPromptSubmit',
375
+ prompt: truncate(hookInput.prompt, 8000),
376
+ session_id: hookInput.session_id,
377
+ cwd: hookInput.cwd,
378
+ at: Date.now(),
379
+ })
380
+ // Sobe em paralelo com a busca de contexto (não soma latência).
381
+ promptFlush = flushActivityQueue(apiUrl, creds.token, marker.dir).catch(() => null)
382
+ }
383
+
384
+ // \`paris conectar\` roda o PRÓPRIO hook com 'check': enfileira um evento de
385
+ // conexão e faz o flush pelo caminho real (mesmo node, mesma fila, mesmo
386
+ // token, mesma URL). Imprime o resultado em JSON pro comando ler.
387
+ if (PHASE === 'check') {
388
+ enqueueActivity(marker.dir, { hook_event_name: 'ConnectionCheck', cwd: marker.dir, at: Date.now() })
389
+ const r = await flushActivityQueue(apiUrl, creds.token, marker.dir)
390
+ process.stdout.write(JSON.stringify({ ...r, apiUrl }) + '\\n')
391
+ return
392
+ }
393
+
318
394
  if (PHASE === 'stop') {
319
395
  await apiPost(apiUrl, '/immersion-app/v1/events', creds.token, { type: 'turn_ended' })
320
396
  await flushActivityQueue(apiUrl, creds.token, marker.dir)
@@ -332,18 +408,23 @@ async function main() {
332
408
  ])
333
409
 
334
410
  if (!workspace) {
335
- process.stderr.write('[paris-hook] não foi possível buscar contexto. Está offline?\\n')
411
+ if (promptFlush) await promptFlush
412
+ process.stderr.write('[paris-hook] não foi possível buscar contexto. Está offline? (seu prompt ficou salvo e sobe quando a conexão voltar)\\n')
336
413
  return
337
414
  }
338
415
  // Mantém o marker em dia com o servidor — é o marker que silencia o
339
416
  // PostToolUse pós-graduação (o caminho quente não consulta a API).
340
417
  await refreshMarkerStatus(marker.dir, marker.marker, workspace.status)
341
- if (workspace.status === 'graduated') return
418
+ if (workspace.status === 'graduated') { if (promptFlush) await promptFlush; return }
342
419
 
343
420
  // Onboarding: enquanto o curso não termina, o terminal roda o tutor do curso
344
421
  // (/tostudy) — isso tem prioridade sobre as missões da imersão (que nem existem
345
422
  // na fase pre).
346
423
  const inCourse = !!(course && course.courseCompleted === false)
424
+ // Timer da turma: a Missão 1 (o curso) ainda não abriu — D1 cedo, na abertura
425
+ // com o apresentador. Sem isto o hook mandava rodar /tostudy às 08:00 e o
426
+ // Claude começava o curso no meio da apresentação.
427
+ const courseNotOpenYet = inCourse && !!(current && current.reason === 'waiting_next_window' && current.current && current.current.status === 'locked')
347
428
 
348
429
  const lines = []
349
430
  if (PHASE === 'session-start') {
@@ -357,7 +438,14 @@ async function main() {
357
438
  lines.push('')
358
439
  }
359
440
 
360
- if (inCourse) {
441
+ if (courseNotOpenYet) {
442
+ lines.push('## ANTES DA PRIMEIRA MISSÃO')
443
+ lines.push(current.current.socio_prompt || ('A primeira missão abre no horário do bloco dela. Não rode /tostudy antes.'))
444
+ if (PHASE === 'session-start') {
445
+ lines.push('')
446
+ lines.push('**Comece falando primeiro**, em 2 ou 3 linhas: confirme que está tudo conectado e diga a que horas a Missão 1 começa.')
447
+ }
448
+ } else if (inCourse) {
361
449
  lines.push('## FASE DO CURSO DE ONBOARDING')
362
450
  lines.push('O aluno está na etapa do curso **' + course.title + '** (progresso: **' + course.lessonsCompleted + '/' + course.totalLessons + '**).')
363
451
  lines.push('Rode **/tostudy** pra conduzir o curso da ToStudy (o comando está instalado no workspace e o aluno já chega logado). Ele ativa o curso e ensina lição por lição. NÃO trabalhe em missões da imersão enquanto o curso não terminar. (Fallback: ferramentas MCP **get_course**/**complete_lesson** do paris.)')
@@ -406,14 +494,8 @@ async function main() {
406
494
  // turn_started fecha o ciclo do turno (com turn_ended no stop) → permite medir
407
495
  // cadência/duração de turno e detectar "mandou prompt e não houve resposta".
408
496
  await apiPost(apiUrl, '/immersion-app/v1/events', creds.token, { type: 'turn_started' })
409
- if (hookInput && hookInput.prompt) {
410
- await apiPost(apiUrl, '/api/activity/claude-code', creds.token, {
411
- hook_event_name: 'UserPromptSubmit',
412
- prompt: hookInput.prompt,
413
- session_id: hookInput.session_id,
414
- cwd: hookInput.cwd,
415
- })
416
- }
497
+ // O prompt já está na fila (topo do main) — aqui só espera o envio terminar.
498
+ if (promptFlush) await promptFlush
417
499
  }
418
500
 
419
501
  // Na fase do curso, sincroniza o progresso da ToStudy também ao abrir/retomar
@@ -10,3 +10,11 @@
10
10
  export declare function renderSettingsJson(opts: {
11
11
  apiUrl: string;
12
12
  }): Record<string, unknown>;
13
+ /**
14
+ * `.mcp.json` do workspace — o ÚNICO lugar de onde o Claude Code carrega MCP de
15
+ * projeto. Sem ele o aluno fica sem get_current_task/mark_task_complete/
16
+ * log_event e a missão não conclui sozinha (só pelo botão da War Room).
17
+ */
18
+ export declare function renderMcpJson(opts: {
19
+ apiUrl: string;
20
+ }): Record<string, unknown>;
@@ -7,6 +7,10 @@
7
7
  * already a prerequisite. Paths are relative to the workspace root, which is
8
8
  * the cwd Claude Code uses when launching hooks and MCP servers.
9
9
  */
10
+ // Hooks apontam pra RAIZ do projeto ($CLAUDE_PROJECT_DIR, setado pelo Claude Code
11
+ // em todo hook). Com caminho relativo, bastava o Claude dar `cd` numa subpasta
12
+ // (ex.: vault/10-SOPs) pra TODO hook falhar com MODULE_NOT_FOUND — o aluno some
13
+ // do painel e a sessão enche de "Stop hook error" (treino 30/09, 2026-09-30).
10
14
  export function renderSettingsJson(opts) {
11
15
  return {
12
16
  $schema: 'https://json.schemastore.org/claude-code-settings.json',
@@ -14,6 +18,12 @@ export function renderSettingsJson(opts) {
14
18
  // pode não conectar o server numa sessão não-interativa (terminal embutido),
15
19
  // e o tutor abre sem as tools get_course/complete_lesson.
16
20
  enableAllProjectMcpServers: true,
21
+ // O Claude Code NÃO carrega `mcpServers` do settings.json — MCP de projeto
22
+ // vive no `.mcp.json` (renderMcpJson abaixo). Este bloco fica só por
23
+ // compatibilidade; o que liga o server de verdade é o .mcp.json + esta
24
+ // pré-aprovação (sem ela o server fica "Pending approval"). Achado no
25
+ // teste real de 2026-09-29: o Claude do aluno abria SEM mark_task_complete.
26
+ enabledMcpjsonServers: ['paris-immersion'],
17
27
  mcpServers: {
18
28
  'paris-immersion': {
19
29
  command: 'node',
@@ -30,7 +40,7 @@ export function renderSettingsJson(opts) {
30
40
  hooks: [
31
41
  {
32
42
  type: 'command',
33
- command: 'node .paris/hooks/paris-hook.mjs session-start',
43
+ command: 'node "$CLAUDE_PROJECT_DIR/.paris/hooks/paris-hook.mjs" session-start',
34
44
  },
35
45
  ],
36
46
  },
@@ -41,7 +51,7 @@ export function renderSettingsJson(opts) {
41
51
  hooks: [
42
52
  {
43
53
  type: 'command',
44
- command: 'node .paris/hooks/paris-hook.mjs user-prompt-submit',
54
+ command: 'node "$CLAUDE_PROJECT_DIR/.paris/hooks/paris-hook.mjs" user-prompt-submit',
45
55
  },
46
56
  ],
47
57
  },
@@ -56,7 +66,7 @@ export function renderSettingsJson(opts) {
56
66
  hooks: [
57
67
  {
58
68
  type: 'command',
59
- command: 'node .paris/hooks/paris-hook.mjs post-tool-use',
69
+ command: 'node "$CLAUDE_PROJECT_DIR/.paris/hooks/paris-hook.mjs" post-tool-use',
60
70
  },
61
71
  ],
62
72
  },
@@ -67,7 +77,7 @@ export function renderSettingsJson(opts) {
67
77
  hooks: [
68
78
  {
69
79
  type: 'command',
70
- command: 'node .paris/hooks/paris-hook.mjs stop',
80
+ command: 'node "$CLAUDE_PROJECT_DIR/.paris/hooks/paris-hook.mjs" stop',
71
81
  },
72
82
  ],
73
83
  },
@@ -75,3 +85,19 @@ export function renderSettingsJson(opts) {
75
85
  },
76
86
  };
77
87
  }
88
+ /**
89
+ * `.mcp.json` do workspace — o ÚNICO lugar de onde o Claude Code carrega MCP de
90
+ * projeto. Sem ele o aluno fica sem get_current_task/mark_task_complete/
91
+ * log_event e a missão não conclui sozinha (só pelo botão da War Room).
92
+ */
93
+ export function renderMcpJson(opts) {
94
+ return {
95
+ mcpServers: {
96
+ 'paris-immersion': {
97
+ command: 'node',
98
+ args: ['.paris/mcp/index.mjs'],
99
+ env: { PARIS_API_URL: opts.apiUrl },
100
+ },
101
+ },
102
+ };
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paris-immersion",
3
- "version": "0.1.35",
3
+ "version": "0.1.38",
4
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": {