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.
- package/dist/commands/conectar.d.ts +3 -0
- package/dist/commands/conectar.js +168 -0
- package/dist/commands/install.js +3 -1
- package/dist/commands/mentor.d.ts +1 -1
- package/dist/commands/mentor.js +1 -1
- package/dist/commands/repair.d.ts +12 -0
- package/dist/commands/repair.js +48 -0
- package/dist/commands/serve.js +6 -1
- package/dist/index.js +12 -0
- package/dist/templates/hook.js +114 -32
- package/dist/templates/settings.d.ts +8 -0
- package/dist/templates/settings.js +30 -4
- package/package.json +1 -1
- package/templates/mcp/admin.mjs +556 -149
- package/templates/mcp/index.mjs +550 -149
|
@@ -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
|
+
}
|
package/dist/commands/install.js
CHANGED
|
@@ -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>;
|
package/dist/commands/mentor.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/commands/serve.js
CHANGED
|
@@ -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')
|
package/dist/templates/hook.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
185
|
-
//
|
|
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
|
|
188
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
410
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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