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.
- package/dist/commands/doctor.js +83 -0
- package/dist/commands/graduate.js +11 -0
- package/dist/commands/mentor.d.ts +12 -0
- package/dist/commands/mentor.js +62 -0
- package/dist/commands/serve.js +19 -0
- package/dist/index.js +6 -1
- package/dist/templates/claude-md.js +2 -1
- package/dist/templates/hook.d.ts +9 -3
- package/dist/templates/hook.js +198 -38
- package/dist/templates/settings.js +3 -3
- package/dist/templates/skill.js +1 -1
- package/package.json +3 -3
- package/templates/mcp/admin.mjs +18175 -0
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/commands/serve.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
package/dist/templates/hook.d.ts
CHANGED
|
@@ -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
|
|
4
|
-
* the workspace without needing the @paris/cli package to be
|
|
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;
|
package/dist/templates/hook.js
CHANGED
|
@@ -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
|
|
4
|
-
* the workspace without needing the @paris/cli package to be
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
54
|
-
|
|
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,
|
|
63
|
-
// cwd, ...) como JSON no stdin. Lê com timeout curto
|
|
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
|
-
//
|
|
284
|
+
// ZERO rede no caminho quente — só append local. Flush quando acumula.
|
|
126
285
|
if (hookInput && hookInput.tool_name) {
|
|
127
|
-
|
|
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('###
|
|
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:
|
|
225
|
-
//
|
|
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
|
-
//
|
|
52
|
-
//
|
|
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',
|
package/dist/templates/skill.js
CHANGED
|
@@ -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
|
|
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.
|
|
4
|
-
"description": "Paris Immersion CLI
|
|
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
|
-
"
|
|
16
|
+
"imersão"
|
|
17
17
|
],
|
|
18
18
|
"type": "module",
|
|
19
19
|
"bin": {
|