@fontdata/ai-harness 2.2.0 → 2.3.1

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fontdata",
3
- "version": "2.2.0",
3
+ "version": "2.3.1",
4
4
  "description": "Harness de engenharia com IA da Fontdata para Claude Code",
5
5
  "author": { "name": "Fontdata" },
6
6
  "commands": ["./templates/commands"],
package/README.md CHANGED
@@ -108,9 +108,9 @@ duas vezes.
108
108
  | Subcomando | O que faz |
109
109
  |---|---|
110
110
  | `init [caminho]` | Instala comandos, skills, agents e hooks no projeto |
111
- | `doctor [caminho]` | Diagnostica instalação, contrato dos documentos, rastro, bloqueios antigos e versão do Node |
111
+ | `doctor [caminho]` | Diagnostica instalação, contrato dos documentos, rastro, bloqueios antigos, citações às pastas antigas de `docs/` no código e versão do Node |
112
112
  | `update [caminho]` | Atualiza o que foi instalado sem sobrescrever alterações locais (`--check`, `--yes`, `--forcar`) |
113
- | `migrar [caminho]` | Move os documentos do layout anterior para `docs/trabalhos/<id>/` (`--yes`) |
113
+ | `migrar [caminho]` | Move os documentos do layout anterior para `docs/trabalhos/<id>/` (`--yes`); `--reclassificar` corrige planos migrados que ficaram em execução |
114
114
  | `memoria [caminho]` | Índice de quem mexeu em cada arquivo (`--arquivo <caminho>`, `--regressao <id>`) |
115
115
  | `evento <evento>` | Grava no rastro do trabalho (usado por fluxos e agents) |
116
116
  | `conflitos <id>` | Aponta trabalhos em execução que mexem nos mesmos arquivos; `--aguardar` espera liberar (usado pelo `/executar-plan`) |
package/dist/cli.js CHANGED
@@ -48,6 +48,7 @@ Gerenciar:
48
48
  doctor [caminho] Diagnostica a instalação (arquivos, lock, skills, agents, hooks, Node)
49
49
  update [caminho] Atualiza o que foi instalado: --check só lista, --yes aplica, --forcar sobrescreve conflitos
50
50
  migrar [caminho] Move docs/specs, plans, bugs e investigacoes para docs/trabalhos/<id>/ (--yes aplica)
51
+ --reclassificar: planos já migrados em em-execucao viram concluido ou legado
51
52
  comentarios [caminho] Instala o hook de comentários e a catraca do legado (--yes aplica)
52
53
  memoria [caminho] Índice de quem mexeu em cada arquivo (--arquivo <caminho>, --regressao <id do bug>)
53
54
  evento <evento> Grava no rastro do trabalho (--trabalho, --task, --nome, --resultado, --dados '{json}')
@@ -19,7 +19,7 @@ export const ARTIFACT_TYPES = {
19
19
  };
20
20
  export const STATUS_BY_TYPE = {
21
21
  spec: ['rascunho', 'aguardando-aprovacao', 'aprovado', 'substituido'],
22
- plano: ['rascunho', 'aguardando-aprovacao', 'em-execucao', 'concluido', 'cancelado'],
22
+ plano: ['rascunho', 'aguardando-aprovacao', 'em-execucao', 'concluido', 'cancelado', 'legado'],
23
23
  decisoes: ['ativo'],
24
24
  bloqueios: ['aberto', 'resolvido'],
25
25
  relatorio_tecnico: ['rascunho', 'final'],
@@ -8,6 +8,7 @@ import { validateEventLog } from '../contratos/eventos.js';
8
8
  import { hashContent, LOCK_PATH, LOCK_VERSION } from '../install/lock.js';
9
9
  import { SETTINGS_PATH } from '../install/settings.js';
10
10
  import { adoptLegacyInstall } from '../update/legado.js';
11
+ import { checkLegacyDocsCitations, checkMigratedPlansRunning } from './migracao.js';
11
12
  const MIN_NODE_MAJOR = 22;
12
13
  const PLUGIN_ID = 'fontdata@ai-harness';
13
14
  const HARNESS_HOOKS_DIR = '.claude/hooks/ai-harness/';
@@ -413,6 +414,8 @@ export function runDoctor(root, options = {}) {
413
414
  ...checkStaleBlocks(root, options.now ?? new Date(), options.staleBlockDays ?? config.bloqueios.dias_para_aviso),
414
415
  ...checkEventLogs(root),
415
416
  ...checkCompletedTasks(root),
417
+ ...checkMigratedPlansRunning(root),
418
+ ...checkLegacyDocsCitations(root),
416
419
  ];
417
420
  }
418
421
  export function formatFindings(findings) {
@@ -0,0 +1,97 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { extname, join } from 'node:path';
3
+ import { readArtifact, WORKS_DIR } from '../contratos/artefatos.js';
4
+ /** As mesmas extensões e pastas ignoradas do hook de comentários (templates/hooks/comentarios/check.mjs). */
5
+ const CODE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.cs', '.php', '.pas', '.dpr', '.sql', '.sh']);
6
+ const SKIPPED_DIRS = new Set(['node_modules', 'dist', 'build', 'coverage', 'out', 'vendor', 'generated', '__generated__']);
7
+ const MAX_FILE_BYTES = 1_000_000;
8
+ const LEGACY_CITATION = /\b[Dd]ocs\/(?:specs|plans|bugs|investigacoes)\//g;
9
+ const TEST_FILE = /\.(test|spec)\.[jt]sx?$/;
10
+ const COMMENT_LINE = /^\s*(\*|\(\*|\{(?!\$))/;
11
+ const COMMENT_MARKER = /\/\/|\/\*|#|--/;
12
+ const EXAMPLES_SHOWN = 5;
13
+ function codeFiles(root, dir = '', found = []) {
14
+ let entries;
15
+ try {
16
+ entries = readdirSync(join(root, dir), { withFileTypes: true });
17
+ }
18
+ catch {
19
+ return found;
20
+ }
21
+ for (const entry of entries) {
22
+ const rel = dir ? `${dir}/${entry.name}` : entry.name;
23
+ if (entry.isDirectory()) {
24
+ if (!entry.name.startsWith('.') && !SKIPPED_DIRS.has(entry.name))
25
+ codeFiles(root, rel, found);
26
+ }
27
+ else if (entry.isFile() && CODE_EXTENSIONS.has(extname(entry.name)) && !entry.name.endsWith('.d.ts') && !TEST_FILE.test(entry.name)) {
28
+ found.push(rel);
29
+ }
30
+ }
31
+ return found;
32
+ }
33
+ /**
34
+ * O `migrar` reescreve os links dos documentos, mas não toca no código: a citação às pastas antigas num comentário
35
+ * vira caminho quebrado, e a rule de comentários já proíbe citar docs/ no código.
36
+ */
37
+ export function checkLegacyDocsCitations(root) {
38
+ const places = [];
39
+ let total = 0;
40
+ for (const rel of codeFiles(root).sort()) {
41
+ const file = join(root, rel);
42
+ if (statSync(file).size > MAX_FILE_BYTES)
43
+ continue;
44
+ const lines = readFileSync(file, 'utf8').split('\n');
45
+ lines.forEach((line, index) => {
46
+ // Só a citação em comentário: a mesma string num literal de código é dado, não rastreabilidade.
47
+ const inComment = COMMENT_LINE.test(line)
48
+ ? [...line.matchAll(LEGACY_CITATION)]
49
+ : [...line.matchAll(LEGACY_CITATION)].filter((match) => COMMENT_MARKER.test(line.slice(0, match.index)));
50
+ const count = inComment.length;
51
+ if (!count)
52
+ return;
53
+ total += count;
54
+ places.push(`${rel}:${index + 1}`);
55
+ });
56
+ }
57
+ if (!total)
58
+ return [];
59
+ const files = new Set(places.map((place) => place.slice(0, place.lastIndexOf(':')))).size;
60
+ const examples = places.slice(0, EXAMPLES_SHOWN).join(', ');
61
+ const more = places.length > EXAMPLES_SHOWN ? ` e mais ${places.length - EXAMPLES_SHOWN}` : '';
62
+ return [
63
+ {
64
+ id: 'citacao-docs-em-codigo',
65
+ severidade: 'aviso',
66
+ mensagem: `${total} citação(ões) em ${files} arquivo(s) de código apontam para docs/specs, docs/plans, docs/bugs ou ` +
67
+ `docs/investigacoes, pastas que o migrar substituiu por docs/trabalhos/: ${examples}${more}.`,
68
+ correcao: 'Remova a citação do comentário (o vínculo fica no commit ou em docs/trabalhos/), como pede a rule ' +
69
+ '.claude/rules/comentarios.md. Arquivo tolerado pela catraca pode ser limpo aos poucos.',
70
+ },
71
+ ];
72
+ }
73
+ /** Planos migrados antes da 2.3.0 ficaram `em-execucao` e aparecem como ativos em todo `conflitos`. */
74
+ export function checkMigratedPlansRunning(root) {
75
+ const dir = join(root, WORKS_DIR);
76
+ if (!existsSync(dir))
77
+ return [];
78
+ const stuck = readdirSync(dir).filter((id) => {
79
+ const rel = `docs/trabalhos/${id}/PLAN.md`;
80
+ if (!existsSync(join(root, rel)))
81
+ return false;
82
+ const { data } = readArtifact(rel, readFileSync(join(root, rel), 'utf8'));
83
+ const arquivos = Array.isArray(data?.arquivos) ? data.arquivos : [];
84
+ return data?.migrado === true && data.status === 'em-execucao' && !arquivos.length;
85
+ });
86
+ if (!stuck.length)
87
+ return [];
88
+ return [
89
+ {
90
+ id: 'plano-migrado-em-execucao',
91
+ severidade: 'aviso',
92
+ mensagem: `${stuck.length} plano(s) migrado(s) do layout antigo estão como em-execucao sem declarar arquivos: ${stuck.slice(0, EXAMPLES_SHOWN).join(', ')}${stuck.length > EXAMPLES_SHOWN ? ' …' : ''}.`,
93
+ correcao: 'Rode `npx @fontdata/ai-harness migrar --reclassificar --yes`: plano sem caixa aberta vira concluido, e o ' +
94
+ 'restante vira legado, que não conta como trabalho ativo.',
95
+ },
96
+ ];
97
+ }
@@ -179,6 +179,10 @@ function rootCauseOf(body) {
179
179
  return 'hipotese';
180
180
  return UNCONFIRMED.test(body.slice(label.index, label.index + 600)) ? 'hipotese' : 'comprovada';
181
181
  }
182
+ /** Plano antigo com caixa aberta não está necessariamente em execução: `legado` não conta como trabalho ativo. */
183
+ function legacyPlanStatus(body) {
184
+ return OPEN_BOX.test(body) ? 'legado' : 'concluido';
185
+ }
182
186
  const TASK_HEADING = /^(#{2,6})\s*(?:(?:task|tarefa)\s+|T-?)(\d+)\.(\d+)\b\s*[—–:.-]*\s*(.*)$/i;
183
187
  const TASK_ITEM = /^\s*[-*]\s+\[([ xX])\]\s+\*{0,2}(?:(?:task|tarefa)\s+|T-?)(\d+)\.(\d+)\b\s*[—–:.-]*\s*(.*)$/i;
184
188
  const ANY_HEADING = /^(#{1,6})\s/;
@@ -256,7 +260,7 @@ function contractFrontmatter(content, move, today) {
256
260
  const existing = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : {};
257
261
  const body = match ? content.slice(match[0].length) : content;
258
262
  const heading = /^#\s+(.+)$/m.exec(body)?.[1]?.trim();
259
- const status = move.tipo === 'spec' ? 'aprovado' : move.tipo === 'plano' ? (OPEN_BOX.test(body) ? 'em-execucao' : 'concluido') : 'final';
263
+ const status = move.tipo === 'spec' ? 'aprovado' : move.tipo === 'plano' ? legacyPlanStatus(body) : 'final';
260
264
  const data = {
261
265
  schema_versao: SCHEMA_VERSION,
262
266
  tipo: move.tipo,
@@ -366,18 +370,83 @@ export function applyMigration(root, plan) {
366
370
  for (const dir of SOURCE_DIRS)
367
371
  removeLegacyDir(root, `docs/${dir}`, git);
368
372
  }
369
- const MIGRATION_FLAGS = ['--yes', '-y', '--sim'];
373
+ const FRONTMATTER = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
374
+ /**
375
+ * Planos migrados antes da 2.3.0 ficaram `em-execucao` quando tinham caixa aberta. Os que não declaram `arquivos`
376
+ * ganham o status que a migração daria hoje; quem preencheu `arquivos` está trabalhando neles e fica como está.
377
+ */
378
+ export function planReclassification(root) {
379
+ const dir = join(root, 'docs', 'trabalhos');
380
+ if (!existsSync(dir))
381
+ return [];
382
+ const found = [];
383
+ for (const id of readdirSync(dir).sort()) {
384
+ const file = join(dir, id, 'PLAN.md');
385
+ if (!existsSync(file))
386
+ continue;
387
+ const content = readFileSync(file, 'utf8');
388
+ const match = FRONTMATTER.exec(content);
389
+ if (!match)
390
+ continue;
391
+ let data;
392
+ try {
393
+ data = parse(match[1] ?? '');
394
+ }
395
+ catch {
396
+ continue;
397
+ }
398
+ if (typeof data !== 'object' || data === null)
399
+ continue;
400
+ const { migrado, status, arquivos } = data;
401
+ if (migrado !== true || status !== 'em-execucao' || (Array.isArray(arquivos) && arquivos.length))
402
+ continue;
403
+ found.push({ id, de: status, para: legacyPlanStatus(content.slice(match[0].length)) });
404
+ }
405
+ return found;
406
+ }
407
+ /** Troca só a linha `status:` do frontmatter, para não reformatar o resto do arquivo. */
408
+ export function applyReclassification(root, changes) {
409
+ for (const change of changes) {
410
+ const file = join(root, 'docs', 'trabalhos', change.id, 'PLAN.md');
411
+ const content = readFileSync(file, 'utf8');
412
+ const match = FRONTMATTER.exec(content);
413
+ if (!match)
414
+ continue;
415
+ const frontmatter = match[0].replace(/^status:[ \t]*['"]?em-execucao['"]?[ \t]*$/m, `status: ${change.para}`);
416
+ writeFileSync(file, frontmatter + content.slice(match[0].length));
417
+ }
418
+ }
419
+ function runReclassification(root, yes) {
420
+ const changes = planReclassification(root);
421
+ if (!changes.length) {
422
+ console.log('Nenhum plano migrado sem `arquivos` está como em-execucao: nada a reclassificar.');
423
+ return 0;
424
+ }
425
+ console.log([`Planos migrados a reclassificar (${changes.length}):`, ...changes.map((change) => ` ${change.id}: ${change.de} → ${change.para}`)].join('\n'));
426
+ if (!yes) {
427
+ console.log('\nSimulação — nada foi alterado. Rode de novo com --yes para reclassificar.');
428
+ return 0;
429
+ }
430
+ applyReclassification(root, changes);
431
+ console.log(`\nReclassificados: ${changes.length} plano(s).`);
432
+ return 0;
433
+ }
434
+ const MIGRATION_FLAGS = ['--yes', '-y', '--sim', '--reclassificar'];
370
435
  const REFERENCES_SHOWN = 40;
371
- /** `ai-harness migrar [caminho] [--yes]`; sem `--yes` só simula. */
436
+ /** `ai-harness migrar [caminho] [--reclassificar] [--yes]`; sem `--yes` só simula. */
372
437
  export function runMigrationCommand(argv) {
373
438
  let path;
374
439
  let yes = false;
440
+ let reclassify = false;
375
441
  for (const token of argv) {
376
442
  if (!token.startsWith('-')) {
377
443
  if (path !== undefined)
378
444
  throw new Error(`Argumento inesperado: ${token}`);
379
445
  path = token;
380
446
  }
447
+ else if (token === '--reclassificar') {
448
+ reclassify = true;
449
+ }
381
450
  else if (MIGRATION_FLAGS.includes(token)) {
382
451
  yes = true;
383
452
  }
@@ -389,6 +458,8 @@ export function runMigrationCommand(argv) {
389
458
  const root = resolve(process.cwd(), path ?? '.');
390
459
  if (!existsSync(root))
391
460
  throw new Error(`Pasta não encontrada: ${root}`);
461
+ if (reclassify)
462
+ return runReclassification(root, yes);
392
463
  const plan = planMigration(root);
393
464
  if (!plan.moves.length && !plan.conflicts.length) {
394
465
  const ignored = plan.ignored.length ? `\nIgnorados: ${plan.ignored.join(', ')}` : '';
@@ -20,7 +20,7 @@ function readPlan(root, workId) {
20
20
  return null;
21
21
  const listed = Array.isArray(data.arquivos) ? data.arquivos : [];
22
22
  const arquivos = listed.filter((entry) => typeof entry === 'string').map(normalize).filter(Boolean);
23
- return { status: String(data.status ?? ''), arquivos };
23
+ return { status: String(data.status ?? ''), arquivos, migrado: data.migrado === true };
24
24
  }
25
25
  /**
26
26
  * Trabalhos em execução (PLAN.md `em-execucao` ou listados no estado.json) que declaram arquivos em comum
@@ -40,7 +40,9 @@ export async function findConflicts(root, workId) {
40
40
  if (!plan || FINISHED.has(plan.status) || (plan.status !== 'em-execucao' && !listed.has(id)))
41
41
  continue;
42
42
  if (!plan.arquivos.length) {
43
- report.semArquivos.push(id);
43
+ // Plano migrado do layout antigo não diz o que tocou; só avisa quando alguém o está executando de fato.
44
+ if (!plan.migrado || listed.has(id))
45
+ report.semArquivos.push(id);
44
46
  continue;
45
47
  }
46
48
  const shared = own.arquivos.filter((a) => plan.arquivos.some((b) => overlaps(a, b)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fontdata/ai-harness",
3
- "version": "2.2.0",
3
+ "version": "2.3.1",
4
4
  "description": "Harness de engenharia com IA da Fontdata para Claude Code: roda os slash commands direto na pasta do projeto via npx, sem instalar nada — ou instala-os em .claude/commands.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'node:fs';
3
- import { extname, relative, join, dirname } from 'node:path';
2
+ import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, openSync, closeSync, renameSync, rmSync, statSync } from 'node:fs';
3
+ import { extname, relative, join, dirname, sep } from 'node:path';
4
4
 
5
5
  const MAX_BLOCO = Number(process.env.COMENTARIOS_MAX_BLOCO ?? 3);
6
6
  const MAX_DOCSTRING = Number(process.env.COMENTARIOS_MAX_DOCSTRING ?? 5);
@@ -9,7 +9,8 @@ const DENSIDADE_ALVO = 0.1;
9
9
  const EXTENSOES = new Set([
10
10
  '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
11
11
  '.py', '.go', '.rs', '.java', '.cs', '.php',
12
- '.pas', '.dpr', '.sql', '.sh',
12
+ '.pas', '.dpr', '.dpk', '.lpr', '.sql', '.sh', '.bash', '.zsh',
13
+ '.ps1', '.psm1', '.bat', '.cmd',
13
14
  ]);
14
15
 
15
16
  const IGNORAR = [
@@ -21,14 +22,18 @@ const IGNORAR = [
21
22
  ];
22
23
 
23
24
  const CABECALHO_PERMITIDO =
24
- /eslint|ts-nocheck|ts-ignore|prettier|@license|copyright|spdx|jshint|globals|^#!|use (strict|client|server)/i;
25
+ /eslint|ts-nocheck|ts-check|ts-ignore|prettier|@license|copyright|spdx|jshint|globals|use (strict|client|server)|code generated|do not edit|auto-generated/i;
25
26
 
26
27
  const BANNER = /^[-=─-╿_*#~+]{3,}|[-=─-╿_*#~+]{3,}$/;
27
28
 
29
+ // Só a palavra ignora caixa: com a flag `i`, o romano casaria o "i" de "specialty".
30
+ const semCaixa = (texto) => texto.replace(/\p{L}/gu, (c) => `[${c.toLowerCase()}${c.toUpperCase()}]`);
31
+ const PROCESSO = new RegExp(`\\b(${semCaixa('task|tarefa|fase|etapa|plano|spec|sprint|epico|épico')})\\s*(\\d|[IVX]+\\b)`, 'u');
32
+
28
33
  const RASTREIO = [
29
- [/\b(task|tarefa|fase|etapa|plano|spec|sprint|epico|épico)\s*[\dIVX]/i, 'referência a task/fase/plano/spec'],
34
+ [PROCESSO, 'referência a task/fase/plano/spec'],
30
35
  [/\bT\d+(\.\d+)+\b/, 'referência a task (T0.0)'],
31
- [/docs\/(plans|specs|bugs|investigacoes)\//i, 'caminho de docs/ dentro do código'],
36
+ [/docs\/(plans|specs|bugs|investigacoes|trabalhos)\//i, 'caminho de docs/ dentro do código'],
32
37
  [/\bachado d[oe]\b/i, 'achado de review no código'],
33
38
  [/@[\w-]*(reviewer|auditor|orquestrador|agent)\b/i, 'menção a agent no código'],
34
39
  [/\b(commit|sha|hash)\s+[0-9a-f]{7,40}\b/i, 'hash de commit'],
@@ -38,33 +43,136 @@ const DATA = /(^|[^"'\w])20\d{2}-\d{2}-\d{2}([^"'\w]|$)/;
38
43
  const CODIGO_TOKEN = /(^|\s)(const|let|var|function|return|if|for|while|await|import|export|def|class)\b/;
39
44
  const CODIGO_FIM = /[;{}]\s*$/;
40
45
 
41
- function comentario(linha, ext) {
42
- const t = linha.trim();
43
- if (!t) return null;
44
- if (ext === '.py' || ext === '.sh') return t.startsWith('#') ? t.replace(/^#+/, '').trim() : null;
45
- if (ext === '.sql') {
46
- if (t.startsWith('--')) return t.replace(/^-+/, '').trim();
47
- if (t.startsWith('/*') || t.startsWith('*')) return t.replace(/^[/*]+/, '').trim();
48
- return null;
46
+ const so = (...exts) => new Set(exts);
47
+ const JS = so('.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs');
48
+ const C_LIKE = so(...JS, '.go', '.rs', '.java', '.cs', '.php');
49
+ const PASCAL = so('.pas', '.dpr', '.dpk', '.lpr');
50
+ const SHELL = so('.sh', '.bash', '.zsh');
51
+ const POWERSHELL = so('.ps1', '.psm1');
52
+ const BATCH = so('.bat', '.cmd');
53
+
54
+ const SINTAXE = [
55
+ [so('.php'), { linha: ['//', '#'], blocos: [['/*', '*/']], naoComentario: /^#\[/ }],
56
+ [C_LIKE, { linha: ['//'], blocos: [['/*', '*/']] }],
57
+ [PASCAL, { linha: ['//'], blocos: [['{', '}'], ['(*', '*)']] }],
58
+ [so('.sql'), { linha: ['--'], blocos: [['/*', '*/']] }],
59
+ [POWERSHELL, { linha: ['#'], blocos: [['<#', '#>']] }],
60
+ [so('.py', ...SHELL), { linha: ['#'], blocos: [] }],
61
+ [BATCH, { linha: ['::'], blocos: [] }],
62
+ ];
63
+
64
+ // Têm forma de comentário, mas são lidas pelo compilador, pelo interpretador ou por uma ferramenta.
65
+ const DIRETIVA = Symbol('diretiva');
66
+ const DIRETIVAS = [
67
+ [PASCAL, /^(\{|\(\*)[$%]/],
68
+ [C_LIKE, /^\/[/*]\s*(#?region\b|#?endregion\b|<\/?editor-fold|@formatter:)/i],
69
+ [JS, /^\/\/\/\s*<(reference|amd-module|amd-dependency)\b/],
70
+ [JS, /^\/[/*]\*?\s*(@ts-(check|nocheck|ignore|expect-error)\b|eslint|jshint|jscs:|tslint:|prettier-ignore|(istanbul|c8|v8) ignore|biome-ignore|deno-|oxlint-|@flow\b|@jsx|@vite-ignore|@vitest-environment|@jest-environment|webpack[A-Z]\w*:|@refresh\b)/i],
71
+ [JS, /^\/\*+\s*globals?\s/],
72
+ [JS, /^\/\/[#@]\s*source(Mapping)?URL=/],
73
+ [so('.go'), /^\/\/(go:|line |export |extern )|^\/\/\s*(\+build\b|nolint\b|lint:ignore\b)/],
74
+ [so('.rs'), /^#!\[/],
75
+ [so('.java'), /^\/[/*]\s*(noinspection\b|NOSONAR\b|CHECKSTYLE[:.]|\$NON-NLS)/],
76
+ [so('.cs'), /^#\s*(region|endregion|if|elif|else|endif|define|undef|pragma|nullable|warning|error|line)\b/],
77
+ [so('.cs'), /^\/\/\s*(ReSharper\s|<\/?auto-generated)/i],
78
+ [so('.php'), /^\/[/*]\*?\s*(phpcs:|@phpstan-|@psalm-|@codeCoverageIgnore|@noinspection\b|@var\s)/],
79
+ [so('.sql'), /^\/\*[!+]/],
80
+ [so('.sql'), /^--\s*(liquibase\s|changeset\s|rollback\b|precondition|comment:|validCheckSum\b|ignoreLines:|\+(goose|migrate)\b|name:\s*\S+\s*:|noqa\b|sqlfluff:|#?(region|endregion)\b)/i],
81
+ [so('.py'), /^#\s*(-\*-.*-\*-|(en)?coding[:=]|vim?:|noqa\b|nosec\b|type:|pylint:|mypy:|pyright:|pyre-|pytype:|ruff:|isort:|fmt:|pragma\b)/i],
82
+ [SHELL, /^#\s*(shellcheck\s|-\*-.*-\*-|vim?:|ex:)|^#(compdef|autoload)\b/i],
83
+ [POWERSHELL, /^#(requires|region|endregion)\b/i],
84
+ ];
85
+ const BLOCOS_DIRETIVA = [
86
+ [SHELL, /^###\s*BEGIN INIT INFO/, /^###\s*END INIT INFO/],
87
+ [POWERSHELL, /^#\s*SIG\s*#\s*Begin signature block/i, /^#\s*SIG\s*#\s*End signature block/i],
88
+ ];
89
+ const HEREDOC = /(?<!<)<<(?!<)-?\s*(['"]?)([A-Za-z_]\w*)\1/g;
90
+
91
+ const limpar = (texto) => texto.replace(/^\*+/, '').trim();
92
+
93
+ function comentarioDeLinha(t, sintaxe, ext) {
94
+ if (BATCH.has(ext)) {
95
+ if (t.startsWith('::')) return t.replace(/^:+/, '').trim();
96
+ const rem = t.match(/^@?rem(?:[\s.:/\\](.*))?$/i);
97
+ return rem ? (rem[1] ?? '').trim() : null;
98
+ }
99
+ const marcador = sintaxe.linha.find((m) => t.startsWith(m));
100
+ if (!marcador || sintaxe.naoComentario?.test(t)) return null;
101
+ let k = 0;
102
+ while (t[k] === marcador[0]) k++;
103
+ return t.slice(k).trim();
104
+ }
105
+
106
+ // Devolve, por linha: o texto do comentário, DIRETIVA, ou null para código e linha em branco.
107
+ function classificar(linhas, ext) {
108
+ const sintaxe = SINTAXE.find(([exts]) => exts.has(ext))?.[1] ?? { linha: ['//'], blocos: [['/*', '*/']] };
109
+ const saida = [];
110
+ const heredocs = [];
111
+ let fimTexto = null;
112
+ let fimDiretiva = null;
113
+ let bloco = null;
114
+ for (let i = 0; i < linhas.length; i++) {
115
+ const t = linhas[i].trim();
116
+ if (heredocs.length) {
117
+ if (t === heredocs[0]) heredocs.shift();
118
+ saida.push(null);
119
+ continue;
120
+ }
121
+ if (fimTexto) {
122
+ if (fimTexto.test(t)) fimTexto = null;
123
+ saida.push(null);
124
+ continue;
125
+ }
126
+ if (bloco) {
127
+ const pos = t.indexOf(bloco.fim);
128
+ saida.push(bloco.diretiva ? DIRETIVA : limpar(pos === -1 ? t : t.slice(0, pos)));
129
+ if (pos !== -1) bloco = null;
130
+ continue;
131
+ }
132
+ if (!t) { saida.push(null); continue; }
133
+ if (fimDiretiva) {
134
+ if (fimDiretiva.test(t)) fimDiretiva = null;
135
+ saida.push(DIRETIVA);
136
+ continue;
137
+ }
138
+ const linhaDiretiva = BLOCOS_DIRETIVA.find(([exts, abre]) => exts.has(ext) && abre.test(t));
139
+ if (linhaDiretiva) { fimDiretiva = linhaDiretiva[2]; saida.push(DIRETIVA); continue; }
140
+
141
+ const diretiva = (i === 0 && t.startsWith('#!')) || DIRETIVAS.some(([exts, re]) => exts.has(ext) && re.test(t));
142
+ const abre = sintaxe.blocos.find(([inicio]) => t.startsWith(inicio));
143
+ if (abre) {
144
+ const resto = t.slice(abre[0].length);
145
+ const pos = resto.indexOf(abre[1]);
146
+ if (pos === -1) bloco = { fim: abre[1], diretiva };
147
+ saida.push(diretiva ? DIRETIVA : limpar(pos === -1 ? resto : resto.slice(0, pos)));
148
+ continue;
149
+ }
150
+ if (diretiva) { saida.push(DIRETIVA); continue; }
151
+
152
+ const c = comentarioDeLinha(t, sintaxe, ext);
153
+ saida.push(c);
154
+ if (c !== null) continue;
155
+ if (SHELL.has(ext)) for (const m of t.matchAll(HEREDOC)) heredocs.push(m[2]);
156
+ if (POWERSHELL.has(ext) && /@["']$/.test(t)) fimTexto = t.endsWith('"') ? /^"@/ : /^'@/;
49
157
  }
50
- if (ext === '.pas' || ext === '.dpr') {
51
- if (t.startsWith('//')) return t.replace(/^\/+/, '').trim();
52
- if (t.startsWith('(*')) return t.replace(/^\(\*/, '').trim();
53
- if (t.startsWith('{') && !t.startsWith('{$')) return t.replace(/^\{/, '').trim();
54
- return null;
158
+ // O comentário colado em `import "C"` é o preâmbulo do cgo: código C, não comentário.
159
+ if (ext === '.go') {
160
+ for (let i = 0; i < linhas.length; i++) {
161
+ if (linhas[i].trim() !== 'import "C"') continue;
162
+ for (let j = i - 1; j >= 0 && typeof saida[j] === 'string'; j--) saida[j] = DIRETIVA;
163
+ }
55
164
  }
56
- if (t.startsWith('//')) return t.replace(/^\/+/, '').trim();
57
- if (t.startsWith('/*')) return t.replace(/^\/\*+/, '').trim();
58
- if (t.startsWith('*')) return t.replace(/^\*+\/?/, '').trim();
59
- return null;
165
+ return saida;
60
166
  }
61
167
 
168
+ const ehTexto = (c) => typeof c === 'string';
169
+
62
170
  function analisar(caminho, fonte) {
63
171
  const ext = extname(caminho);
64
172
  const linhas = fonte.split('\n');
65
173
  const achados = [];
66
174
  const marca = (linha, msg) => achados.push({ linha, msg });
67
- const ehComentario = linhas.map((l) => comentario(l, ext));
175
+ const ehComentario = classificar(linhas, ext);
68
176
 
69
177
  let primeiroCodigo = -1;
70
178
  for (let i = 0; i < linhas.length; i++) {
@@ -73,10 +181,10 @@ function analisar(caminho, fonte) {
73
181
 
74
182
  let i = 0;
75
183
  while (i < linhas.length) {
76
- if (ehComentario[i] === null) { i++; continue; }
184
+ if (!ehTexto(ehComentario[i])) { i++; continue; }
77
185
  const inicio = i;
78
186
  const conteudo = [];
79
- while (i < linhas.length && ehComentario[i] !== null) { conteudo.push(ehComentario[i]); i++; }
187
+ while (i < linhas.length && ehTexto(ehComentario[i])) { conteudo.push(ehComentario[i]); i++; }
80
188
  const fim = i - 1;
81
189
  const corpo = conteudo.filter(Boolean);
82
190
  const texto = corpo.join(' ');
@@ -99,9 +207,9 @@ function analisar(caminho, fonte) {
99
207
  }
100
208
 
101
209
  let proxima = fim + 1;
102
- while (proxima < linhas.length && !linhas[proxima].trim()) proxima++;
210
+ while (proxima < linhas.length && (!linhas[proxima].trim() || ehComentario[proxima] === DIRETIVA)) proxima++;
103
211
  const ehDocstring =
104
- /^(\/\*\*|"""|'''|\/\/\/)/.test(linhas[inicio].trim()) &&
212
+ /^(\/\*\*|"""|'''|\/\/\/|<#)/.test(linhas[inicio].trim()) &&
105
213
  proxima < linhas.length &&
106
214
  ehComentario[proxima] === null;
107
215
  const teto = ehDocstring ? MAX_DOCSTRING : MAX_BLOCO;
@@ -116,17 +224,52 @@ function analisar(caminho, fonte) {
116
224
  }
117
225
  }
118
226
 
119
- const comentadas = ehComentario.filter((c) => c !== null).length;
227
+ const comentadas = ehComentario.filter(ehTexto).length;
120
228
  const uteis = linhas.filter((l) => l.trim()).length;
121
229
  return { achados, comentadas, uteis, densidade: uteis ? comentadas / uteis : 0 };
122
230
  }
123
231
 
232
+ const raiz = process.env.CLAUDE_PROJECT_DIR || process.cwd();
233
+ const BASELINE = join(raiz, '.claude', 'hooks', 'comentarios', 'baseline.json');
234
+
235
+ // Pasta oculta na raiz (.claude, .ai-harness, .vscode...) é de ferramenta, não do projeto.
236
+ function emPastaOculta(caminho) {
237
+ const rel = relative(raiz, caminho);
238
+ if (!rel || rel.startsWith('..')) return false;
239
+ return rel.split(sep).slice(0, -1).some((parte) => parte.startsWith('.'));
240
+ }
241
+
124
242
  function elegivel(caminho) {
125
- return EXTENSOES.has(extname(caminho)) && !IGNORAR.some((re) => re.test(caminho));
243
+ return EXTENSOES.has(extname(caminho)) && !IGNORAR.some((re) => re.test(caminho)) && !emPastaOculta(caminho);
126
244
  }
127
245
 
128
- const raiz = process.env.CLAUDE_PROJECT_DIR || process.cwd();
129
- const BASELINE = join(raiz, '.claude', 'hooks', 'comentarios', 'baseline.json');
246
+ function gravarAtomico(arquivo, conteudo) {
247
+ const temp = `${arquivo}.${process.pid}.tmp`;
248
+ writeFileSync(temp, conteudo);
249
+ renameSync(temp, arquivo);
250
+ }
251
+
252
+ const esperar = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
253
+
254
+ // Vários agents editam em paralelo: a trava serializa o reler-e-gravar, e cada um mexe só na própria chave.
255
+ function atualizarBaseline(chave, valor) {
256
+ const trava = `${BASELINE}.lock`;
257
+ let travado = false;
258
+ for (let tentativa = 0; tentativa < 300 && !travado; tentativa++) {
259
+ try { closeSync(openSync(trava, 'wx')); travado = true; } catch {
260
+ try { if (Date.now() - statSync(trava).mtimeMs > 10_000) rmSync(trava, { force: true }); } catch {}
261
+ if (!travado) esperar(10);
262
+ }
263
+ }
264
+ try {
265
+ const atual = JSON.parse(readFileSync(BASELINE, 'utf8'));
266
+ if (valor === 0) delete atual[chave];
267
+ else atual[chave] = Math.min(valor, atual[chave] ?? valor);
268
+ gravarAtomico(BASELINE, `${JSON.stringify(atual, null, 2)}\n`);
269
+ } catch {} finally {
270
+ if (travado) rmSync(trava, { force: true });
271
+ }
272
+ }
130
273
 
131
274
  function varrer(dir, saida = []) {
132
275
  let itens;
@@ -134,7 +277,7 @@ function varrer(dir, saida = []) {
134
277
  for (const item of itens) {
135
278
  const cheio = join(dir, item.name);
136
279
  if (item.isDirectory()) {
137
- if (item.name === 'node_modules' || item.name === '.git') continue;
280
+ if (item.name === 'node_modules' || item.name.startsWith('.')) continue;
138
281
  if (IGNORAR.some((re) => re.test(`${cheio}/`))) continue;
139
282
  varrer(cheio, saida);
140
283
  } else if (elegivel(cheio)) saida.push(cheio);
@@ -153,7 +296,7 @@ if (process.argv.includes('--gerar-baseline')) {
153
296
  if (achados.length > 0) { mapa[relative(raiz, f)] = achados.length; arquivos++; violacoes += achados.length; }
154
297
  }
155
298
  if (!existsSync(dirname(BASELINE))) mkdirSync(dirname(BASELINE), { recursive: true });
156
- writeFileSync(BASELINE, `${JSON.stringify(mapa, null, 2)}\n`);
299
+ gravarAtomico(BASELINE, `${JSON.stringify(mapa, null, 2)}\n`);
157
300
  console.log(`[comentarios] baseline gravado: ${arquivos} arquivos legados, ${violacoes} violações toleradas.`);
158
301
  console.log('[comentarios] arquivo novo responde ao padrão cheio; legado só não pode piorar.');
159
302
  process.exit(0);
@@ -179,9 +322,7 @@ const tolerado = baseline?.[chave] ?? 0;
179
322
 
180
323
  if (achados.length <= tolerado) {
181
324
  if (baseline && tolerado > 0 && achados.length < tolerado) {
182
- if (achados.length === 0) delete baseline[chave];
183
- else baseline[chave] = achados.length;
184
- try { writeFileSync(BASELINE, `${JSON.stringify(baseline, null, 2)}\n`); } catch {}
325
+ atualizarBaseline(chave, achados.length);
185
326
  console.error(`[comentarios] ${rel}: ${tolerado} → ${achados.length} violações. Catraca desceu — não pode mais subir.`);
186
327
  } else if (achados.length === 0 && densidade > DENSIDADE_ALVO) {
187
328
  console.error(`[comentarios] aviso: ${rel} tem ${(densidade * 100).toFixed(0)}% de linhas de comentário (${comentadas}/${uteis}), alvo <= ${DENSIDADE_ALVO * 100}%.`);
@@ -61,7 +61,7 @@ export function buildEvent(fields) {
61
61
 
62
62
  /**
63
63
  * Trabalhos ativos: os `trabalhos_ativos` do .ai-harness/estado.json (ou o `trabalho_ativo` do formato
64
- * anterior) ou, sem eles, os PLAN.md com `status: em-execucao`. Várias sessões podem executar
64
+ * anterior) ou, sem eles, os PLAN.md com `status: em-execucao` (menos os migrados sem `arquivos`). Várias sessões podem executar
65
65
  * trabalhos diferentes na mesma pasta, então a lista pode ter mais de um id.
66
66
  * @param {string} root
67
67
  * @returns {string[]}
@@ -88,7 +88,10 @@ export function findActiveWorks(root) {
88
88
  if (!existsSync(plan)) return false;
89
89
  const frontmatter = /^\s*---\r?\n([\s\S]*?)\r?\n---/.exec(readFileSync(plan, 'utf8'))?.[1] ?? '';
90
90
  // Só o status do plano, na coluna zero; os status das tasks ficam indentados.
91
- return /^status:\s*['"]?em-execucao['"]?\s*$/m.test(frontmatter);
91
+ if (!/^status:\s*['"]?em-execucao['"]?\s*$/m.test(frontmatter)) return false;
92
+ // Plano migrado do layout antigo sem `arquivos` ficou em-execucao pela migração, não por alguém executá-lo.
93
+ const migradoSemArquivos = /^migrado:\s*true\s*$/m.test(frontmatter) && !/^arquivos:[ \t]*(\[[ \t]*[^\]\s]|\r?\n[ \t]*-\s)/m.test(frontmatter);
94
+ return !migradoSemArquivos;
92
95
  });
93
96
  }
94
97