paris-immersion 0.1.24 → 0.1.26
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 +1 -1
- package/dist/templates/hook.d.ts +9 -3
- package/dist/templates/hook.js +159 -26
- package/dist/templates/settings.js +3 -3
- package/dist/templates/skill.js +1 -1
- package/package.json +1 -1
- 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.26');
|
|
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,7 +15,7 @@ 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.
|
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,102 @@ 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
|
+
|
|
90
214
|
// Sincroniza o progresso REAL do curso da ToStudy pro nosso lado. O aluno faz o
|
|
91
215
|
// curso pela tostudy CLI no terminal embutido, e essa conclusão NÃO dispara o
|
|
92
216
|
// webhook do cursos — então é daqui que a gestão aprende o progresso. A ToStudy
|
|
@@ -117,26 +241,31 @@ async function main() {
|
|
|
117
241
|
|
|
118
242
|
const apiUrl = marker.marker.api_url || creds.api_url
|
|
119
243
|
|
|
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
244
|
const hookInput = (PHASE === 'post-tool-use' || PHASE === 'user-prompt-submit') ? await readStdin() : null
|
|
123
245
|
|
|
124
246
|
if (PHASE === 'post-tool-use') {
|
|
125
|
-
//
|
|
247
|
+
// ZERO rede no caminho quente — só append local. Flush quando acumula.
|
|
126
248
|
if (hookInput && hookInput.tool_name) {
|
|
127
|
-
|
|
249
|
+
enqueueActivity(marker.dir, {
|
|
128
250
|
hook_event_name: 'PostToolUse',
|
|
129
251
|
tool_name: hookInput.tool_name,
|
|
130
|
-
tool_input: hookInput.tool_input
|
|
252
|
+
tool_input: slimToolInput(hookInput.tool_input),
|
|
253
|
+
tool_response: slimToolResponse(hookInput.tool_response),
|
|
131
254
|
session_id: hookInput.session_id,
|
|
132
255
|
cwd: hookInput.cwd,
|
|
256
|
+
at: Date.now(),
|
|
133
257
|
})
|
|
258
|
+
const queued = await readQueue(marker.dir)
|
|
259
|
+
if (queued.length >= QUEUE_FLUSH_AT) {
|
|
260
|
+
await flushActivityQueue(apiUrl, creds.token, marker.dir)
|
|
261
|
+
}
|
|
134
262
|
}
|
|
135
263
|
return
|
|
136
264
|
}
|
|
137
265
|
|
|
138
266
|
if (PHASE === 'stop') {
|
|
139
267
|
await apiPost(apiUrl, '/immersion-app/v1/events', creds.token, { type: 'turn_ended' })
|
|
268
|
+
await flushActivityQueue(apiUrl, creds.token, marker.dir)
|
|
140
269
|
// Logo após cada interação é o melhor momento pra capturar uma lição que o
|
|
141
270
|
// aluno acabou de concluir no curso da ToStudy.
|
|
142
271
|
await syncTostudyProgress(apiUrl, creds.token, marker.dir)
|
|
@@ -153,6 +282,9 @@ async function main() {
|
|
|
153
282
|
process.stderr.write('[paris-hook] não foi possível buscar contexto. Está offline?\\n')
|
|
154
283
|
return
|
|
155
284
|
}
|
|
285
|
+
// Mantém o marker em dia com o servidor — é o marker que silencia o
|
|
286
|
+
// PostToolUse pós-graduação (o caminho quente não consulta a API).
|
|
287
|
+
await refreshMarkerStatus(marker.dir, marker.marker, workspace.status)
|
|
156
288
|
if (workspace.status === 'graduated') return
|
|
157
289
|
|
|
158
290
|
// Onboarding: enquanto o curso não termina, o terminal roda o tutor do curso
|
|
@@ -194,7 +326,7 @@ async function main() {
|
|
|
194
326
|
if (current.current.description) lines.push('- **descrição:** ' + current.current.description)
|
|
195
327
|
if (current.current.socio_prompt) {
|
|
196
328
|
lines.push('')
|
|
197
|
-
lines.push('###
|
|
329
|
+
lines.push('### Instrução da tarefa')
|
|
198
330
|
lines.push(current.current.socio_prompt)
|
|
199
331
|
}
|
|
200
332
|
} else {
|
|
@@ -207,11 +339,12 @@ async function main() {
|
|
|
207
339
|
|
|
208
340
|
if (PHASE === 'session-start') {
|
|
209
341
|
await apiPost(apiUrl, '/immersion-app/v1/events', creds.token, { type: 'session_started' })
|
|
342
|
+
// Restos de fila de sessões anteriores (crash, rede) sobem agora.
|
|
343
|
+
await flushActivityQueue(apiUrl, creds.token, marker.dir)
|
|
210
344
|
}
|
|
211
345
|
|
|
212
|
-
// user-prompt-submit:
|
|
213
|
-
//
|
|
214
|
-
// pediu". Best-effort, depois do contexto já impresso.
|
|
346
|
+
// user-prompt-submit: dá ao mentor o "o que ele pediu" em tempo real.
|
|
347
|
+
// Best-effort, depois do contexto já impresso.
|
|
215
348
|
if (PHASE === 'user-prompt-submit') {
|
|
216
349
|
// turn_started fecha o ciclo do turno (com turn_ended no stop) → permite medir
|
|
217
350
|
// 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