@fontdata/ai-harness 2.2.0 → 2.3.0
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/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -2
- package/dist/cli.js +1 -0
- package/dist/contratos/artefatos.js +1 -1
- package/dist/doctor/checks.js +3 -0
- package/dist/doctor/migracao.js +97 -0
- package/dist/migrar/migrar.js +74 -3
- package/dist/trabalhos/conflitos.js +4 -2
- package/package.json +1 -1
- package/templates/hooks/comentarios/check.mjs +50 -13
- package/templates/hooks/lib/rastro.mjs +5 -2
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'],
|
package/dist/doctor/checks.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/migrar/migrar.js
CHANGED
|
@@ -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' ? (
|
|
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
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "2.3.0",
|
|
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);
|
|
@@ -21,14 +21,18 @@ const IGNORAR = [
|
|
|
21
21
|
];
|
|
22
22
|
|
|
23
23
|
const CABECALHO_PERMITIDO =
|
|
24
|
-
/eslint|ts-nocheck|ts-ignore|prettier|@license|copyright|spdx|jshint|globals|^#!|use (strict|client|server)/i;
|
|
24
|
+
/eslint|ts-nocheck|ts-check|ts-ignore|prettier|@license|copyright|spdx|jshint|globals|^#!|use (strict|client|server)/i;
|
|
25
25
|
|
|
26
26
|
const BANNER = /^[-=─-╿_*#~+]{3,}|[-=─-╿_*#~+]{3,}$/;
|
|
27
27
|
|
|
28
|
+
// Só a palavra ignora caixa: com a flag `i`, o romano casaria o "i" de "specialty".
|
|
29
|
+
const semCaixa = (texto) => texto.replace(/\p{L}/gu, (c) => `[${c.toLowerCase()}${c.toUpperCase()}]`);
|
|
30
|
+
const PROCESSO = new RegExp(`\\b(${semCaixa('task|tarefa|fase|etapa|plano|spec|sprint|epico|épico')})\\s*(\\d|[IVX]+\\b)`, 'u');
|
|
31
|
+
|
|
28
32
|
const RASTREIO = [
|
|
29
|
-
[
|
|
33
|
+
[PROCESSO, 'referência a task/fase/plano/spec'],
|
|
30
34
|
[/\bT\d+(\.\d+)+\b/, 'referência a task (T0.0)'],
|
|
31
|
-
[/docs\/(plans|specs|bugs|investigacoes)\//i, 'caminho de docs/ dentro do código'],
|
|
35
|
+
[/docs\/(plans|specs|bugs|investigacoes|trabalhos)\//i, 'caminho de docs/ dentro do código'],
|
|
32
36
|
[/\bachado d[oe]\b/i, 'achado de review no código'],
|
|
33
37
|
[/@[\w-]*(reviewer|auditor|orquestrador|agent)\b/i, 'menção a agent no código'],
|
|
34
38
|
[/\b(commit|sha|hash)\s+[0-9a-f]{7,40}\b/i, 'hash de commit'],
|
|
@@ -121,12 +125,47 @@ function analisar(caminho, fonte) {
|
|
|
121
125
|
return { achados, comentadas, uteis, densidade: uteis ? comentadas / uteis : 0 };
|
|
122
126
|
}
|
|
123
127
|
|
|
128
|
+
const raiz = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
129
|
+
const BASELINE = join(raiz, '.claude', 'hooks', 'comentarios', 'baseline.json');
|
|
130
|
+
|
|
131
|
+
// Pasta oculta na raiz (.claude, .ai-harness, .vscode...) é de ferramenta, não do projeto.
|
|
132
|
+
function emPastaOculta(caminho) {
|
|
133
|
+
const rel = relative(raiz, caminho);
|
|
134
|
+
if (!rel || rel.startsWith('..')) return false;
|
|
135
|
+
return rel.split(sep).slice(0, -1).some((parte) => parte.startsWith('.'));
|
|
136
|
+
}
|
|
137
|
+
|
|
124
138
|
function elegivel(caminho) {
|
|
125
|
-
return EXTENSOES.has(extname(caminho)) && !IGNORAR.some((re) => re.test(caminho));
|
|
139
|
+
return EXTENSOES.has(extname(caminho)) && !IGNORAR.some((re) => re.test(caminho)) && !emPastaOculta(caminho);
|
|
126
140
|
}
|
|
127
141
|
|
|
128
|
-
|
|
129
|
-
const
|
|
142
|
+
function gravarAtomico(arquivo, conteudo) {
|
|
143
|
+
const temp = `${arquivo}.${process.pid}.tmp`;
|
|
144
|
+
writeFileSync(temp, conteudo);
|
|
145
|
+
renameSync(temp, arquivo);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const esperar = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
149
|
+
|
|
150
|
+
// Vários agents editam em paralelo: a trava serializa o reler-e-gravar, e cada um mexe só na própria chave.
|
|
151
|
+
function atualizarBaseline(chave, valor) {
|
|
152
|
+
const trava = `${BASELINE}.lock`;
|
|
153
|
+
let travado = false;
|
|
154
|
+
for (let tentativa = 0; tentativa < 300 && !travado; tentativa++) {
|
|
155
|
+
try { closeSync(openSync(trava, 'wx')); travado = true; } catch {
|
|
156
|
+
try { if (Date.now() - statSync(trava).mtimeMs > 10_000) rmSync(trava, { force: true }); } catch {}
|
|
157
|
+
if (!travado) esperar(10);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const atual = JSON.parse(readFileSync(BASELINE, 'utf8'));
|
|
162
|
+
if (valor === 0) delete atual[chave];
|
|
163
|
+
else atual[chave] = Math.min(valor, atual[chave] ?? valor);
|
|
164
|
+
gravarAtomico(BASELINE, `${JSON.stringify(atual, null, 2)}\n`);
|
|
165
|
+
} catch {} finally {
|
|
166
|
+
if (travado) rmSync(trava, { force: true });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
130
169
|
|
|
131
170
|
function varrer(dir, saida = []) {
|
|
132
171
|
let itens;
|
|
@@ -134,7 +173,7 @@ function varrer(dir, saida = []) {
|
|
|
134
173
|
for (const item of itens) {
|
|
135
174
|
const cheio = join(dir, item.name);
|
|
136
175
|
if (item.isDirectory()) {
|
|
137
|
-
if (item.name === 'node_modules' || item.name
|
|
176
|
+
if (item.name === 'node_modules' || item.name.startsWith('.')) continue;
|
|
138
177
|
if (IGNORAR.some((re) => re.test(`${cheio}/`))) continue;
|
|
139
178
|
varrer(cheio, saida);
|
|
140
179
|
} else if (elegivel(cheio)) saida.push(cheio);
|
|
@@ -153,7 +192,7 @@ if (process.argv.includes('--gerar-baseline')) {
|
|
|
153
192
|
if (achados.length > 0) { mapa[relative(raiz, f)] = achados.length; arquivos++; violacoes += achados.length; }
|
|
154
193
|
}
|
|
155
194
|
if (!existsSync(dirname(BASELINE))) mkdirSync(dirname(BASELINE), { recursive: true });
|
|
156
|
-
|
|
195
|
+
gravarAtomico(BASELINE, `${JSON.stringify(mapa, null, 2)}\n`);
|
|
157
196
|
console.log(`[comentarios] baseline gravado: ${arquivos} arquivos legados, ${violacoes} violações toleradas.`);
|
|
158
197
|
console.log('[comentarios] arquivo novo responde ao padrão cheio; legado só não pode piorar.');
|
|
159
198
|
process.exit(0);
|
|
@@ -179,9 +218,7 @@ const tolerado = baseline?.[chave] ?? 0;
|
|
|
179
218
|
|
|
180
219
|
if (achados.length <= tolerado) {
|
|
181
220
|
if (baseline && tolerado > 0 && achados.length < tolerado) {
|
|
182
|
-
|
|
183
|
-
else baseline[chave] = achados.length;
|
|
184
|
-
try { writeFileSync(BASELINE, `${JSON.stringify(baseline, null, 2)}\n`); } catch {}
|
|
221
|
+
atualizarBaseline(chave, achados.length);
|
|
185
222
|
console.error(`[comentarios] ${rel}: ${tolerado} → ${achados.length} violações. Catraca desceu — não pode mais subir.`);
|
|
186
223
|
} else if (achados.length === 0 && densidade > DENSIDADE_ALVO) {
|
|
187
224
|
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
|
|
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
|
-
|
|
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
|
|