@fontdata/ai-harness 3.2.3 → 3.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.
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +4 -0
- package/dist/banco-dados/lgpd/aplicar.js +7 -2
- package/dist/banco-dados/lgpd/detectar.js +4 -0
- package/dist/cli.js +8 -0
- package/dist/contratos/artefatos.js +5 -3
- package/dist/doctor/checks.js +99 -1
- package/dist/install/settings.js +1 -0
- package/dist/memoria/indice.js +64 -42
- package/dist/trabalhos/arquivar.js +196 -0
- package/dist/trabalhos/command.js +105 -0
- package/dist/trabalhos/indice-arquivo.js +68 -0
- package/dist/trabalhos/listar.js +37 -0
- package/package.json +1 -1
- package/templates/CLAUDE.md +1 -0
- package/templates/agents/investigador.md +2 -2
- package/templates/hooks/comentarios/check.mjs +4 -0
- package/templates/rules/comentarios.md +8 -2
- package/templates/skills/evoluir-harness/references/auditoria.md +3 -2
- package/templates/skills/evoluir-harness/references/skill.md +2 -1
- package/templates/skills/memoria/SKILL.md +2 -1
- package/templates/skills/nova-tarefa/SKILL.md +4 -3
- package/templates/skills/nova-tarefa/references/isso-ja-existe.md +2 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fontdata",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.1",
|
|
4
4
|
"mcpServers": {
|
|
5
|
-
"banco_dados": { "command": "npx", "args": ["-y", "@fontdata/ai-harness@3.
|
|
5
|
+
"banco_dados": { "command": "npx", "args": ["-y", "@fontdata/ai-harness@3.3.1", "banco-dados-mcp"] }
|
|
6
6
|
},
|
|
7
7
|
"description": "Harness de engenharia com IA da Fontdata para Claude Code",
|
|
8
8
|
"author": { "name": "Fontdata" },
|
package/README.md
CHANGED
|
@@ -162,6 +162,8 @@ projeto, cadastro, limites do filtro, produção, mascaramento e dúvidas freque
|
|
|
162
162
|
| `memoria [caminho]` | Índice de quem mexeu em cada arquivo (`--arquivo <caminho>`, `--regressao <id>`) |
|
|
163
163
|
| `evento <evento>` | Grava no rastro do trabalho (usado por fluxos e agents) |
|
|
164
164
|
| `conflitos <id>` | Aponta trabalhos em execução que mexem nos mesmos arquivos; `--aguardar` espera liberar (usado pelo `/executar-plan`) |
|
|
165
|
+
| `trabalhos arquivar [caminho]` | Simula e, com `--yes`, move para `docs/trabalhos/_arquivo/` os trabalhos concluídos há mais de 60 dias (`--antes-de <dias>`); `--remover` apaga do disco os que o commit do índice recupera |
|
|
166
|
+
| `trabalhos listar [caminho]` | Uma linha por trabalho, arquivados inclusive (`--busca`, `--tipo`, `--status`, `--limite`, padrão 20) |
|
|
165
167
|
| `banco-dados <subcomando>` | Conexões de banco somente leitura: `adicionar`, `listar`, `testar`, `usar`, `remover` |
|
|
166
168
|
| `banco-dados-mcp` | Servidor MCP de leitura do banco, iniciado pelo Claude Code |
|
|
167
169
|
| `comentarios [caminho]` | Instala o hook do padrão de comentários com a catraca do legado (`--yes`) |
|
|
@@ -178,6 +180,8 @@ projeto, cadastro, limites do filtro, produção, mascaramento e dúvidas freque
|
|
|
178
180
|
| `docs/trabalhos/<id>/DECISOES.md` | Decisões tomadas no planejamento e na execução |
|
|
179
181
|
| `docs/trabalhos/<id>/BLOQUEIOS.md` | Dúvidas que pararam uma task |
|
|
180
182
|
| `docs/trabalhos/<id>/relatorios/TECNICO.md` e `CLIENTE.md` | Relatório para engenharia e para cliente e suporte |
|
|
183
|
+
| `docs/trabalhos/_arquivo/<ano>/<id>/` e `INDICE.jsonl` | Trabalhos arquivados pelo `trabalhos arquivar` e o índice que mantém a memória deles |
|
|
184
|
+
| `.ignore` (raiz) | Linha `docs/trabalhos/_arquivo/`, que tira o histórico do Grep e do Glob dos agentes; o git continua versionando |
|
|
181
185
|
| `docs/eventos/<id>.jsonl` | Rastro de eventos (não versionado por padrão) |
|
|
182
186
|
| `.ai-harness/lock.json` e `config.json` | Lock da instalação e configuração dos hooks (versionar) |
|
|
183
187
|
|
|
@@ -106,9 +106,14 @@ function itensDoSelect(sql, dialeto, colunas) {
|
|
|
106
106
|
contagem: ehContagem(item),
|
|
107
107
|
}));
|
|
108
108
|
}
|
|
109
|
-
|
|
109
|
+
/** O SQL não diz de que tabela vem cada identificador, então só a entrada sem tabela de `liberar` vale aqui. */
|
|
110
|
+
function colunaPessoalCitada(ids, mascarar, liberar) {
|
|
110
111
|
const extras = new Set(mascarar.map((r) => r.coluna));
|
|
112
|
+
const livres = new Set(liberar.filter((r) => r.tabela === null).map((r) => r.coluna));
|
|
111
113
|
for (const id of ids) {
|
|
114
|
+
const nome = id.toLowerCase();
|
|
115
|
+
if (livres.has(nome) && !extras.has(nome))
|
|
116
|
+
continue;
|
|
112
117
|
const categoria = classificarColuna(id);
|
|
113
118
|
if (categoria)
|
|
114
119
|
return { categoria, identificador: id };
|
|
@@ -248,7 +253,7 @@ export function mascararResultado(resultado, opcoes) {
|
|
|
248
253
|
};
|
|
249
254
|
const citacao = (i) => {
|
|
250
255
|
if (!citacoes.has(i)) {
|
|
251
|
-
citacoes.set(i, colunaPessoalCitada(item(i)?.ids ?? identificadores(opcoes.sql, opcoes.dialeto), mascarar));
|
|
256
|
+
citacoes.set(i, colunaPessoalCitada(item(i)?.ids ?? identificadores(opcoes.sql, opcoes.dialeto), mascarar, liberar));
|
|
252
257
|
}
|
|
253
258
|
return citacoes.get(i);
|
|
254
259
|
};
|
|
@@ -39,6 +39,8 @@ const COMBINACOES = [
|
|
|
39
39
|
const PARTES_DE_TRIBUTO = new Set([
|
|
40
40
|
'valor', 'vl', 'vlr', 'percentual', 'perc', 'aliquota', 'aliq', 'bc', 'base', 'retencao', 'ret', 'cst', 'cofins',
|
|
41
41
|
]);
|
|
42
|
+
/** Com uma destas partes, `documento` é chave interna (`cod_loja_documento`, `id_documento`), não o número do documento. */
|
|
43
|
+
const PARTES_DE_CODIGO = new Set(['cod', 'codigo', 'id', 'seq', 'sequencial']);
|
|
42
44
|
const TERMOS_FORTES = [
|
|
43
45
|
['cpf', 'cpf'],
|
|
44
46
|
['cnpj', 'cnpj'],
|
|
@@ -73,6 +75,8 @@ export function classificarColuna(nome) {
|
|
|
73
75
|
const tem = new Set(partes);
|
|
74
76
|
if (partes.some((p) => PARTES_DE_TRIBUTO.has(p)))
|
|
75
77
|
tem.delete('pis');
|
|
78
|
+
if (partes.some((p) => PARTES_DE_CODIGO.has(p)))
|
|
79
|
+
tem.delete('documento');
|
|
76
80
|
for (const [categoria, listas] of COMBINACOES) {
|
|
77
81
|
if (listas.every((lista) => lista.some((p) => tem.has(p))))
|
|
78
82
|
return categoria;
|
package/dist/cli.js
CHANGED
|
@@ -15,6 +15,7 @@ const RESERVED = new Set([
|
|
|
15
15
|
'migrar',
|
|
16
16
|
'evento',
|
|
17
17
|
'conflitos',
|
|
18
|
+
'trabalhos',
|
|
18
19
|
'comentarios',
|
|
19
20
|
'memoria',
|
|
20
21
|
'banco-dados',
|
|
@@ -60,6 +61,9 @@ Gerenciar:
|
|
|
60
61
|
memoria [caminho] Índice de quem mexeu em cada arquivo (--arquivo <caminho>, --regressao <id do bug>)
|
|
61
62
|
evento <evento> Grava no rastro do trabalho (--trabalho, --task, --nome, --resultado, --dados '{json}')
|
|
62
63
|
conflitos <id> Aponta trabalhos em execução que mexem nos mesmos arquivos (--aguardar espera liberar)
|
|
64
|
+
trabalhos listar [caminho] Uma linha por trabalho, arquivados inclusive (--busca, --tipo, --status, --limite)
|
|
65
|
+
trabalhos arquivar [caminho] Move os concluídos há mais de 60 dias para docs/trabalhos/_arquivo (--yes aplica)
|
|
66
|
+
--antes-de <dias> muda o corte; --remover apaga do disco os que o git recupera
|
|
63
67
|
banco-dados <subcomando> Conexões de banco somente leitura: adicionar, listar, testar, usar, remover
|
|
64
68
|
banco-dados-mcp Servidor MCP de leitura do banco, usado pelo Claude Code (stdio)
|
|
65
69
|
list Lista todos os comandos disponíveis no catálogo
|
|
@@ -156,6 +160,10 @@ async function main() {
|
|
|
156
160
|
const { runConflictsCommand } = await import('./trabalhos/conflitos.js');
|
|
157
161
|
process.exit(await runConflictsCommand(argv.slice(1)));
|
|
158
162
|
}
|
|
163
|
+
if (cmd === 'trabalhos') {
|
|
164
|
+
const { runWorksCommand } = await import('./trabalhos/command.js');
|
|
165
|
+
process.exit(await runWorksCommand(argv.slice(1)));
|
|
166
|
+
}
|
|
159
167
|
if (cmd === 'banco-dados') {
|
|
160
168
|
const { runBancoCommand } = await import('./banco-dados/command.js');
|
|
161
169
|
process.exit(await runBancoCommand(argv.slice(1)));
|
|
@@ -4,6 +4,8 @@ import { parse, YAMLParseError } from 'yaml';
|
|
|
4
4
|
/** Versão do contrato de artefatos (docs/contratos/ARTEFATOS.md). */
|
|
5
5
|
export const SCHEMA_VERSION = 1;
|
|
6
6
|
export const WORKS_DIR = join('docs', 'trabalhos');
|
|
7
|
+
/** Subpasta de docs/trabalhos/ com os trabalhos arquivados; não é pasta de trabalho. */
|
|
8
|
+
export const ARCHIVE_DIR_NAME = '_arquivo';
|
|
7
9
|
/** Id de trabalho: `AAAA-MM-DD-<slug>` com slug em minúsculas, sem acento. */
|
|
8
10
|
export const WORK_ID = /^\d{4}-\d{2}-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
9
11
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
@@ -27,7 +29,7 @@ export const STATUS_BY_TYPE = {
|
|
|
27
29
|
};
|
|
28
30
|
export const WORK_TYPES = ['feature', 'bug', 'investigacao', 'auditoria'];
|
|
29
31
|
export const TASK_STATUS = ['pendente', 'em-andamento', 'concluida', 'bloqueada', 'pulada'];
|
|
30
|
-
const REQUIRED_BY_WORK_TYPE = {
|
|
32
|
+
export const REQUIRED_BY_WORK_TYPE = {
|
|
31
33
|
feature: ['SPEC.md', 'PLAN.md'],
|
|
32
34
|
bug: ['PLAN.md'],
|
|
33
35
|
investigacao: ['relatorios/TECNICO.md'],
|
|
@@ -222,7 +224,7 @@ function validateBlocks(file, data) {
|
|
|
222
224
|
});
|
|
223
225
|
}
|
|
224
226
|
/** Arquivos da pasta do trabalho, relativos a ela, com separador `/`. */
|
|
225
|
-
function listWorkFiles(dir) {
|
|
227
|
+
export function listWorkFiles(dir) {
|
|
226
228
|
return readdirSync(dir, { recursive: true, encoding: 'utf8' })
|
|
227
229
|
.filter((rel) => statSync(join(dir, rel)).isFile())
|
|
228
230
|
.map((rel) => rel.split(sep).join('/'))
|
|
@@ -284,7 +286,7 @@ export function validateAllWorks(root) {
|
|
|
284
286
|
if (!existsSync(dir))
|
|
285
287
|
return [];
|
|
286
288
|
return readdirSync(dir)
|
|
287
|
-
.filter((name) => statSync(join(dir, name)).isDirectory())
|
|
289
|
+
.filter((name) => name !== ARCHIVE_DIR_NAME && statSync(join(dir, name)).isDirectory())
|
|
288
290
|
.sort()
|
|
289
291
|
.flatMap((name) => validateWork(root, name));
|
|
290
292
|
}
|
package/dist/doctor/checks.js
CHANGED
|
@@ -6,10 +6,13 @@ import { caminhoCadastro, lerCadastro } from '../banco-dados/cadastro.js';
|
|
|
6
6
|
import { allCommands, loadCatalog } from '../catalog.js';
|
|
7
7
|
import { COMMENTS_HOOK_PATH } from '../comentarios/instalar.js';
|
|
8
8
|
import { CONFIG_PATH, readConfig } from '../config/config.js';
|
|
9
|
-
import { readArtifact, validateAllWorks, WORKS_DIR } from '../contratos/artefatos.js';
|
|
9
|
+
import { ARCHIVE_DIR_NAME, ARTIFACT_TYPES, listWorkFiles, readArtifact, validateAllWorks, WORKS_DIR } from '../contratos/artefatos.js';
|
|
10
10
|
import { validateEventLog } from '../contratos/eventos.js';
|
|
11
11
|
import { hashContent, LOCK_PATH, LOCK_VERSION } from '../install/lock.js';
|
|
12
12
|
import { SETTINGS_PATH } from '../install/settings.js';
|
|
13
|
+
import { planArchive } from '../trabalhos/arquivar.js';
|
|
14
|
+
import { ARCHIVE_DEFAULT_DAYS } from '../trabalhos/command.js';
|
|
15
|
+
import { ARCHIVE_INDEX, readArchiveIndex } from '../trabalhos/indice-arquivo.js';
|
|
13
16
|
import { adoptLegacyInstall } from '../update/legado.js';
|
|
14
17
|
import { checkLegacyDocsCitations, checkMigratedPlansRunning } from './migracao.js';
|
|
15
18
|
const MIN_NODE_MAJOR = 22;
|
|
@@ -19,6 +22,8 @@ const REINSTALL = 'Restaure com `npx @fontdata/ai-harness update --yes`.';
|
|
|
19
22
|
const CONTRACT_URL = 'https://github.com/fontdata-tecnologia/ai-harness/blob/master/docs/contratos/ARTEFATOS.md';
|
|
20
23
|
const EVENTS_CONTRACT_URL = 'https://github.com/fontdata-tecnologia/ai-harness/blob/master/docs/contratos/EVENTOS.md';
|
|
21
24
|
const EVENTS_DIR = join('docs', 'eventos');
|
|
25
|
+
const STALE_DRAFT_PLAN_DAYS = 30;
|
|
26
|
+
const ARCHIVE_SUGGESTION_MIN = 30;
|
|
22
27
|
/** Regras cuja violação impede ler o artefato ou seguir o plano; as demais são avisos. */
|
|
23
28
|
const CONTRACT_ERRORS = new Set(['yaml-invalido', 'schema-futuro', 'ciclo-dependencia']);
|
|
24
29
|
function checkNode(version) {
|
|
@@ -265,6 +270,95 @@ function checkStaleBlocks(root, now, maxDays) {
|
|
|
265
270
|
}
|
|
266
271
|
return findings;
|
|
267
272
|
}
|
|
273
|
+
function activeWorkFolders(root) {
|
|
274
|
+
const dir = join(root, WORKS_DIR);
|
|
275
|
+
if (!existsSync(dir))
|
|
276
|
+
return [];
|
|
277
|
+
return readdirSync(dir)
|
|
278
|
+
.filter((name) => name !== ARCHIVE_DIR_NAME && statSync(join(dir, name)).isDirectory())
|
|
279
|
+
.sort();
|
|
280
|
+
}
|
|
281
|
+
function checkArchiveIndex(root) {
|
|
282
|
+
const file = ARCHIVE_INDEX.split('\\').join('/');
|
|
283
|
+
return readArchiveIndex(root).problemas.map(({ linha, mensagem }) => ({
|
|
284
|
+
id: 'arquivo-indice-invalido',
|
|
285
|
+
severidade: 'aviso',
|
|
286
|
+
mensagem: `${file} (linha ${linha}): ${mensagem}. A linha fica fora da memória e da listagem de trabalhos.`,
|
|
287
|
+
correcao: `Corrija ou apague a linha ${linha} de ${file}; cada linha é um objeto JSON com \`id\` texto.`,
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
290
|
+
/** Sem os trabalhos ativos do estado.json, que exigem leitura assíncrona, a contagem é uma estimativa por cima. */
|
|
291
|
+
function checkArchiveSuggestion(root, now) {
|
|
292
|
+
const count = planArchive(root, { now, dias: ARCHIVE_DEFAULT_DAYS, ativos: [] }).elegiveis.length;
|
|
293
|
+
if (count < ARCHIVE_SUGGESTION_MIN)
|
|
294
|
+
return [];
|
|
295
|
+
return [
|
|
296
|
+
{
|
|
297
|
+
id: 'arquivamento-sugerido',
|
|
298
|
+
severidade: 'aviso',
|
|
299
|
+
mensagem: `${count} trabalhos concluídos há mais de ${ARCHIVE_DEFAULT_DAYS} dias podem ir para docs/trabalhos/_arquivo/ (estimativa).`,
|
|
300
|
+
correcao: 'Veja a simulação com `npx @fontdata/ai-harness trabalhos arquivar` e aplique com --yes.',
|
|
301
|
+
},
|
|
302
|
+
];
|
|
303
|
+
}
|
|
304
|
+
function checkStaleDraftPlans(root, now) {
|
|
305
|
+
const findings = [];
|
|
306
|
+
for (const work of activeWorkFolders(root)) {
|
|
307
|
+
const rel = `docs/trabalhos/${work}/PLAN.md`;
|
|
308
|
+
if (!existsSync(join(root, rel)))
|
|
309
|
+
continue;
|
|
310
|
+
const { data } = readArtifact(rel, readFileSync(join(root, rel), 'utf8'));
|
|
311
|
+
if (data?.status !== 'rascunho' || typeof data.atualizado_em !== 'string')
|
|
312
|
+
continue;
|
|
313
|
+
const updated = Date.parse(`${data.atualizado_em}T00:00:00Z`);
|
|
314
|
+
if (Number.isNaN(updated))
|
|
315
|
+
continue;
|
|
316
|
+
const age = Math.floor((now.getTime() - updated) / 86_400_000);
|
|
317
|
+
if (age <= STALE_DRAFT_PLAN_DAYS)
|
|
318
|
+
continue;
|
|
319
|
+
findings.push({
|
|
320
|
+
id: 'plano-rascunho-antigo',
|
|
321
|
+
severidade: 'aviso',
|
|
322
|
+
mensagem: `O ${rel} está em rascunho e não é atualizado há ${age} dias.`,
|
|
323
|
+
correcao: 'Retome o planejamento com /nova-tarefa ou marque o plano como cancelado.',
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return findings;
|
|
327
|
+
}
|
|
328
|
+
/** Script SQL entregável do trabalho: `<id>.sql` ou `<id>-<sufixo>.sql`, só na raiz da pasta. */
|
|
329
|
+
function isWorkScript(rel, work) {
|
|
330
|
+
if (rel === `${work}.sql`)
|
|
331
|
+
return true;
|
|
332
|
+
if (!rel.startsWith(`${work}-`) || !rel.endsWith('.sql'))
|
|
333
|
+
return false;
|
|
334
|
+
return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(rel.slice(work.length + 1, -'.sql'.length));
|
|
335
|
+
}
|
|
336
|
+
/** Nome que só difere de um artefato pela caixa já é apontado por `contrato-nome-fora-do-padrao`. */
|
|
337
|
+
function isOutsideContract(rel, work) {
|
|
338
|
+
if (rel in ARTIFACT_TYPES || rel.startsWith('anexos/') || isWorkScript(rel, work))
|
|
339
|
+
return false;
|
|
340
|
+
if (rel.split('/').at(-1)?.startsWith('.'))
|
|
341
|
+
return false;
|
|
342
|
+
return !Object.keys(ARTIFACT_TYPES).some((name) => name.toLowerCase() === rel.toLowerCase());
|
|
343
|
+
}
|
|
344
|
+
function checkFilesOutsideContract(root) {
|
|
345
|
+
return activeWorkFolders(root).flatMap((work) => {
|
|
346
|
+
const extras = listWorkFiles(join(root, WORKS_DIR, work)).filter((rel) => isOutsideContract(rel, work));
|
|
347
|
+
if (!extras.length)
|
|
348
|
+
return [];
|
|
349
|
+
const renomear = extras.some((rel) => rel.toLowerCase().endsWith('.sql'))
|
|
350
|
+
? ` Se é o script SQL do trabalho, renomeie para ${work}.sql (ou ${work}-<sufixo>.sql) na raiz da pasta.`
|
|
351
|
+
: '';
|
|
352
|
+
return [
|
|
353
|
+
{
|
|
354
|
+
id: 'arquivo-fora-do-contrato',
|
|
355
|
+
severidade: 'aviso',
|
|
356
|
+
mensagem: `docs/trabalhos/${work}/ tem arquivo(s) fora do contrato de artefatos: ${extras.join(', ')}.`,
|
|
357
|
+
correcao: `Se é relatório, mova o conteúdo para relatorios/TECNICO.md ou o arquivo para anexos/.${renomear} Contrato: ${CONTRACT_URL}`,
|
|
358
|
+
},
|
|
359
|
+
];
|
|
360
|
+
});
|
|
361
|
+
}
|
|
268
362
|
function eventLogFiles(root) {
|
|
269
363
|
const dir = join(root, EVENTS_DIR);
|
|
270
364
|
if (!existsSync(dir))
|
|
@@ -535,6 +629,10 @@ export function runDoctor(root, options = {}) {
|
|
|
535
629
|
...checkConfig(configProblems),
|
|
536
630
|
...checkBanco(root, config),
|
|
537
631
|
...checkWorks(root),
|
|
632
|
+
...checkArchiveIndex(root),
|
|
633
|
+
...checkStaleDraftPlans(root, options.now ?? new Date()),
|
|
634
|
+
...checkArchiveSuggestion(root, options.now ?? new Date()),
|
|
635
|
+
...checkFilesOutsideContract(root),
|
|
538
636
|
...checkStaleBlocks(root, options.now ?? new Date(), options.staleBlockDays ?? config.bloqueios.dias_para_aviso),
|
|
539
637
|
...checkEventLogs(root),
|
|
540
638
|
...checkCompletedTasks(root),
|
package/dist/install/settings.js
CHANGED
|
@@ -19,6 +19,7 @@ export const TOOL_PERMISSIONS = ['Read', 'Grep', 'Glob', 'Skill'];
|
|
|
19
19
|
export const READ_PERMISSIONS = [
|
|
20
20
|
'Bash(npx @fontdata/ai-harness doctor:*)',
|
|
21
21
|
'Bash(npx @fontdata/ai-harness memoria:*)',
|
|
22
|
+
'Bash(npx @fontdata/ai-harness trabalhos listar:*)',
|
|
22
23
|
'Bash(git status:*)',
|
|
23
24
|
'Bash(git diff:*)',
|
|
24
25
|
'Bash(git log:*)',
|
package/dist/memoria/indice.js
CHANGED
|
@@ -4,6 +4,7 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
4
4
|
import { ARTIFACT_TYPES, readArtifact, WORKS_DIR } from '../contratos/artefatos.js';
|
|
5
5
|
import { suggestFlag } from '../install/flags.js';
|
|
6
6
|
import { acharRaiz } from '../raiz.js';
|
|
7
|
+
import { ARCHIVE_DIR_NAME, ARCHIVE_INDEX, readArchiveIndex } from '../trabalhos/indice-arquivo.js';
|
|
7
8
|
export const INDEX_PATH = join('.ai-harness', 'memoria', 'indice.json');
|
|
8
9
|
export const INDEX_VERSION = 1;
|
|
9
10
|
const BACKSLASH = String.fromCharCode(92);
|
|
@@ -34,9 +35,56 @@ function conclusionDate(root, workId) {
|
|
|
34
35
|
}
|
|
35
36
|
return date;
|
|
36
37
|
}
|
|
38
|
+
/** Resumo de um trabalho ativo a partir dos artefatos; `resumo` é null quando nenhum artefato é legível. */
|
|
39
|
+
export function summarizeWork(root, workId) {
|
|
40
|
+
const worksDir = join(root, WORKS_DIR);
|
|
41
|
+
const problemas = [];
|
|
42
|
+
const summary = { trabalho_tipo: null, titulo: null, status: null, data: null, causa_raiz: null, criado_em: null, arquivos: [], arquivado: false };
|
|
43
|
+
const files = new Set();
|
|
44
|
+
let updatedAt = null;
|
|
45
|
+
let fallbackStatus = null;
|
|
46
|
+
let readable = false;
|
|
47
|
+
for (const rel of Object.keys(ARTIFACT_TYPES)) {
|
|
48
|
+
const path = join(worksDir, workId, rel);
|
|
49
|
+
if (!existsSync(path))
|
|
50
|
+
continue;
|
|
51
|
+
const { data, problems } = readArtifact(`docs/trabalhos/${workId}/${rel}`, readFileSync(path, 'utf8'));
|
|
52
|
+
if (!data) {
|
|
53
|
+
problemas.push(...problems.map(({ arquivo, mensagem }) => ({ arquivo, mensagem })));
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
readable = true;
|
|
57
|
+
summary.titulo ??= text(data.titulo);
|
|
58
|
+
summary.trabalho_tipo ??= text(data.trabalho_tipo);
|
|
59
|
+
if (rel === 'PLAN.md') {
|
|
60
|
+
summary.status = text(data.status);
|
|
61
|
+
summary.causa_raiz = text(data.causa_raiz);
|
|
62
|
+
}
|
|
63
|
+
fallbackStatus ??= text(data.status);
|
|
64
|
+
summary.criado_em = earliest(summary.criado_em, text(data.criado_em));
|
|
65
|
+
updatedAt = latest(updatedAt, text(data.atualizado_em));
|
|
66
|
+
if (Array.isArray(data.arquivos)) {
|
|
67
|
+
for (const file of data.arquivos)
|
|
68
|
+
if (typeof file === 'string')
|
|
69
|
+
files.add(normalizePath(file));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (!readable)
|
|
73
|
+
return { resumo: null, problemas };
|
|
74
|
+
summary.status ??= fallbackStatus;
|
|
75
|
+
summary.data = conclusionDate(root, workId) ?? updatedAt ?? summary.criado_em;
|
|
76
|
+
summary.arquivos = [...files].sort();
|
|
77
|
+
return { resumo: summary, problemas };
|
|
78
|
+
}
|
|
79
|
+
function addWork(index, id, summary) {
|
|
80
|
+
index.trabalhos[id] = summary;
|
|
81
|
+
const { trabalho_tipo, titulo, status, data, causa_raiz } = summary;
|
|
82
|
+
for (const file of summary.arquivos)
|
|
83
|
+
(index.arquivos[file] ??= []).push({ trabalho: id, trabalho_tipo, titulo, status, data, causa_raiz });
|
|
84
|
+
}
|
|
37
85
|
/**
|
|
38
|
-
* Índice "quem mexeu neste arquivo e por quê", gerado do frontmatter dos trabalhos e do
|
|
39
|
-
* Não é busca semântica: toda resposta aponta um trabalho que pode ser conferido.
|
|
86
|
+
* Índice "quem mexeu neste arquivo e por quê", gerado do frontmatter dos trabalhos, do rastro e do
|
|
87
|
+
* índice dos arquivados. Não é busca semântica: toda resposta aponta um trabalho que pode ser conferido.
|
|
40
88
|
*/
|
|
41
89
|
export function buildIndex(root, now = new Date()) {
|
|
42
90
|
const index = { indice_versao: INDEX_VERSION, gerado_em: now.toISOString(), trabalhos: {}, arquivos: {}, problemas: [] };
|
|
@@ -44,49 +92,23 @@ export function buildIndex(root, now = new Date()) {
|
|
|
44
92
|
if (!existsSync(worksDir))
|
|
45
93
|
return index;
|
|
46
94
|
const workIds = readdirSync(worksDir)
|
|
47
|
-
.filter((name) => statSync(join(worksDir, name)).isDirectory())
|
|
95
|
+
.filter((name) => name !== ARCHIVE_DIR_NAME && statSync(join(worksDir, name)).isDirectory())
|
|
48
96
|
.sort();
|
|
49
97
|
for (const id of workIds) {
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (!data) {
|
|
61
|
-
index.problemas.push(...problems.map(({ arquivo, mensagem }) => ({ arquivo, mensagem })));
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
readable = true;
|
|
65
|
-
summary.titulo ??= text(data.titulo);
|
|
66
|
-
summary.trabalho_tipo ??= text(data.trabalho_tipo);
|
|
67
|
-
if (rel === 'PLAN.md') {
|
|
68
|
-
summary.status = text(data.status);
|
|
69
|
-
summary.causa_raiz = text(data.causa_raiz);
|
|
70
|
-
}
|
|
71
|
-
fallbackStatus ??= text(data.status);
|
|
72
|
-
summary.criado_em = earliest(summary.criado_em, text(data.criado_em));
|
|
73
|
-
updatedAt = latest(updatedAt, text(data.atualizado_em));
|
|
74
|
-
if (Array.isArray(data.arquivos)) {
|
|
75
|
-
for (const file of data.arquivos)
|
|
76
|
-
if (typeof file === 'string')
|
|
77
|
-
files.add(normalizePath(file));
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
if (!readable)
|
|
98
|
+
const { resumo, problemas } = summarizeWork(root, id);
|
|
99
|
+
index.problemas.push(...problemas);
|
|
100
|
+
if (resumo)
|
|
101
|
+
addWork(index, id, resumo);
|
|
102
|
+
}
|
|
103
|
+
const archive = readArchiveIndex(root);
|
|
104
|
+
const archiveFile = ARCHIVE_INDEX.split(BACKSLASH).join('/');
|
|
105
|
+
index.problemas.push(...archive.problemas.map(({ linha, mensagem }) => ({ arquivo: archiveFile, mensagem: `linha ${linha}: ${mensagem}` })));
|
|
106
|
+
for (const entry of archive.trabalhos) {
|
|
107
|
+
if (workIds.includes(entry.id))
|
|
81
108
|
continue;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
index.trabalhos[id] = summary;
|
|
86
|
-
for (const file of summary.arquivos) {
|
|
87
|
-
const { trabalho_tipo, titulo, status, data, causa_raiz } = summary;
|
|
88
|
-
(index.arquivos[file] ??= []).push({ trabalho: id, trabalho_tipo, titulo, status, data, causa_raiz });
|
|
89
|
-
}
|
|
109
|
+
const { trabalho_tipo, titulo, status, criado_em, data, causa_raiz } = entry;
|
|
110
|
+
const arquivos = [...new Set(entry.arquivos.map(normalizePath))];
|
|
111
|
+
addWork(index, entry.id, { trabalho_tipo, titulo, status, data, causa_raiz, criado_em, arquivos, arquivado: true });
|
|
90
112
|
}
|
|
91
113
|
for (const entries of Object.values(index.arquivos)) {
|
|
92
114
|
entries.sort((a, b) => (a.data ?? '').localeCompare(b.data ?? '') || a.trabalho.localeCompare(b.trabalho));
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { ARCHIVE_DIR_NAME, readArtifact, REQUIRED_BY_WORK_TYPE, WORK_ID, WORKS_DIR } from '../contratos/artefatos.js';
|
|
5
|
+
import { summarizeWork } from '../memoria/indice.js';
|
|
6
|
+
import { ARCHIVE_DIR, appendArchiveEntries, readArchiveIndex } from './indice-arquivo.js';
|
|
7
|
+
export const IGNORE_FILE = '.ignore';
|
|
8
|
+
export const IGNORE_LINE = `${toPosix(ARCHIVE_DIR)}/`;
|
|
9
|
+
const CLOSED_PLAN = ['concluido', 'cancelado', 'legado'];
|
|
10
|
+
const REPORTS = ['relatorios/TECNICO.md', 'relatorios/CLIENTE.md'];
|
|
11
|
+
const PLANLESS_TYPES = ['investigacao', 'auditoria'];
|
|
12
|
+
const DAY_MS = 86_400_000;
|
|
13
|
+
function toPosix(path) {
|
|
14
|
+
return path.split('\\').join('/');
|
|
15
|
+
}
|
|
16
|
+
export function workPath(id) {
|
|
17
|
+
return toPosix(join(WORKS_DIR, id));
|
|
18
|
+
}
|
|
19
|
+
export function archiveDestination(id) {
|
|
20
|
+
return toPosix(join(ARCHIVE_DIR, id.slice(0, 4), id));
|
|
21
|
+
}
|
|
22
|
+
function frontmatter(root, id, rel) {
|
|
23
|
+
const path = join(root, WORKS_DIR, id, rel);
|
|
24
|
+
if (!existsSync(path))
|
|
25
|
+
return null;
|
|
26
|
+
return readArtifact(`${workPath(id)}/${rel}`, readFileSync(path, 'utf8')).data ?? null;
|
|
27
|
+
}
|
|
28
|
+
function hasOpenBlock(data) {
|
|
29
|
+
if (!data || !Array.isArray(data.bloqueios))
|
|
30
|
+
return false;
|
|
31
|
+
return data.bloqueios.some((entry) => typeof entry === 'object' && entry !== null && entry.status === 'aberto');
|
|
32
|
+
}
|
|
33
|
+
function ageInDays(date, now) {
|
|
34
|
+
const today = Date.parse(`${now.toISOString().slice(0, 10)}T00:00:00Z`);
|
|
35
|
+
return (today - Date.parse(`${date.slice(0, 10)}T00:00:00Z`)) / DAY_MS;
|
|
36
|
+
}
|
|
37
|
+
/** Motivo de o trabalho ficar onde está, ou null quando ele pode ser arquivado. */
|
|
38
|
+
function reasonToKeep(root, id, options) {
|
|
39
|
+
if (!WORK_ID.test(id))
|
|
40
|
+
return 'nome fora do padrão AAAA-MM-DD-<slug>';
|
|
41
|
+
if (options.ativos.includes(id))
|
|
42
|
+
return 'trabalho ativo';
|
|
43
|
+
const { resumo, problemas } = summarizeWork(root, id);
|
|
44
|
+
if (problemas.length)
|
|
45
|
+
return `frontmatter ilegível: ${problemas.map((p) => `${p.arquivo}: ${p.mensagem}`).join('; ')}`;
|
|
46
|
+
if (!resumo)
|
|
47
|
+
return 'frontmatter ilegível: nenhum artefato do contrato na pasta';
|
|
48
|
+
if (hasOpenBlock(frontmatter(root, id, 'BLOQUEIOS.md')))
|
|
49
|
+
return 'bloqueio aberto em BLOQUEIOS.md';
|
|
50
|
+
const plan = frontmatter(root, id, 'PLAN.md');
|
|
51
|
+
if (plan) {
|
|
52
|
+
const status = typeof plan.status === 'string' ? plan.status : 'PLAN.md sem status';
|
|
53
|
+
if (!CLOSED_PLAN.includes(status))
|
|
54
|
+
return status;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
const type = resumo.trabalho_tipo ?? '';
|
|
58
|
+
const required = PLANLESS_TYPES.includes(type) ? REQUIRED_BY_WORK_TYPE[type] : undefined;
|
|
59
|
+
if (!required) {
|
|
60
|
+
return `sem PLAN.md: só investigacao ou auditoria com relatórios final são arquivadas (trabalho_tipo ${resumo.trabalho_tipo ?? 'ausente'})`;
|
|
61
|
+
}
|
|
62
|
+
const reports = REPORTS.map((rel) => ({ rel, data: frontmatter(root, id, rel) })).filter((report) => report.data);
|
|
63
|
+
const missing = required.filter((rel) => !reports.some((report) => report.rel === rel));
|
|
64
|
+
if (missing.length)
|
|
65
|
+
return `relatório ausente: ${missing.join(', ')}`;
|
|
66
|
+
const pending = reports.filter((report) => report.data?.status !== 'final').map((report) => report.rel);
|
|
67
|
+
if (pending.length)
|
|
68
|
+
return `relatório ainda não final: ${pending.join(', ')}`;
|
|
69
|
+
}
|
|
70
|
+
if (!resumo.data)
|
|
71
|
+
return 'sem data de conclusão nem de atualização';
|
|
72
|
+
if (ageInDays(resumo.data, options.now) <= options.dias)
|
|
73
|
+
return `concluído há menos de ${options.dias} dias`;
|
|
74
|
+
const destino = archiveDestination(id);
|
|
75
|
+
if (existsSync(join(root, destino)))
|
|
76
|
+
return `destino já existe: ${destino}`;
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
/** Separa os trabalhos que podem ser arquivados dos que ficam, sem alterar nenhum arquivo. */
|
|
80
|
+
export function planArchive(root, options) {
|
|
81
|
+
const plan = { elegiveis: [], ignorados: [] };
|
|
82
|
+
const worksDir = join(root, WORKS_DIR);
|
|
83
|
+
if (!existsSync(worksDir))
|
|
84
|
+
return plan;
|
|
85
|
+
const folders = readdirSync(worksDir)
|
|
86
|
+
.filter((name) => name !== ARCHIVE_DIR_NAME && statSync(join(worksDir, name)).isDirectory())
|
|
87
|
+
.sort();
|
|
88
|
+
for (const id of folders) {
|
|
89
|
+
const motivo = reasonToKeep(root, id, options);
|
|
90
|
+
if (motivo)
|
|
91
|
+
plan.ignorados.push({ id, motivo });
|
|
92
|
+
else
|
|
93
|
+
plan.elegiveis.push({ id, destino: archiveDestination(id) });
|
|
94
|
+
}
|
|
95
|
+
return plan;
|
|
96
|
+
}
|
|
97
|
+
export function git(root, args) {
|
|
98
|
+
try {
|
|
99
|
+
return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export function isVersioned(root, path) {
|
|
106
|
+
return Boolean(git(root, ['ls-files', '--', path])?.trim());
|
|
107
|
+
}
|
|
108
|
+
function recoverableCommit(root, path) {
|
|
109
|
+
if (!isVersioned(root, path))
|
|
110
|
+
return null;
|
|
111
|
+
if (git(root, ['status', '--porcelain', '--', path]) !== '')
|
|
112
|
+
return null;
|
|
113
|
+
return git(root, ['rev-parse', 'HEAD'])?.trim() || null;
|
|
114
|
+
}
|
|
115
|
+
function ensureIgnoreLine(root) {
|
|
116
|
+
const file = join(root, IGNORE_FILE);
|
|
117
|
+
const current = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
118
|
+
const lines = current.split(/\r?\n/).map((line) => line.trim());
|
|
119
|
+
if (lines.includes(IGNORE_LINE) || lines.includes(`/${IGNORE_LINE}`))
|
|
120
|
+
return;
|
|
121
|
+
const prefix = current && !current.endsWith('\n') ? '\n' : '';
|
|
122
|
+
writeFileSync(file, `${current}${prefix}${IGNORE_LINE}\n`);
|
|
123
|
+
}
|
|
124
|
+
/** Move os elegíveis para `_arquivo/<ano>/<id>/`, registra cada um no índice e completa o `.ignore`. */
|
|
125
|
+
export function applyArchive(root, plan, options) {
|
|
126
|
+
const archivedAt = options.now.toISOString().slice(0, 10);
|
|
127
|
+
const archived = [];
|
|
128
|
+
let movedAny = false;
|
|
129
|
+
try {
|
|
130
|
+
for (const { id, destino } of plan.elegiveis) {
|
|
131
|
+
const caminho = workPath(id);
|
|
132
|
+
const { resumo } = summarizeWork(root, id);
|
|
133
|
+
if (!resumo)
|
|
134
|
+
throw new Error(`Trabalho sem artefato legível, não arquivado: ${caminho}`);
|
|
135
|
+
if (existsSync(join(root, destino)))
|
|
136
|
+
throw new Error(`Destino já existe, trabalho não arquivado: ${destino}`);
|
|
137
|
+
const commit = recoverableCommit(root, caminho);
|
|
138
|
+
mkdirSync(dirname(join(root, destino)), { recursive: true });
|
|
139
|
+
const moved = isVersioned(root, caminho) && git(root, ['mv', caminho, destino]) !== null;
|
|
140
|
+
if (!moved)
|
|
141
|
+
renameSync(join(root, caminho), join(root, destino));
|
|
142
|
+
movedAny = true;
|
|
143
|
+
const { trabalho_tipo, titulo, status, criado_em, data, arquivos, causa_raiz } = resumo;
|
|
144
|
+
appendArchiveEntries(root, [
|
|
145
|
+
{ v: 1, id, trabalho_tipo, titulo, status, criado_em, data, arquivos, causa_raiz, caminho, commit, arquivado_em: archivedAt },
|
|
146
|
+
]);
|
|
147
|
+
archived.push({ id, commit });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
if (movedAny)
|
|
152
|
+
ensureIgnoreLine(root);
|
|
153
|
+
}
|
|
154
|
+
return archived;
|
|
155
|
+
}
|
|
156
|
+
/** `rev:./path` resolve a partir de `root`, que pode ser subpasta do repositório. */
|
|
157
|
+
function treeOf(root, rev, path) {
|
|
158
|
+
return git(root, ['rev-parse', '--verify', '--quiet', `${rev}:./${path}`])?.trim() || null;
|
|
159
|
+
}
|
|
160
|
+
function reasonToKeepArchived(root, entry, destino) {
|
|
161
|
+
if (!entry.commit)
|
|
162
|
+
return 'sem commit para recuperar';
|
|
163
|
+
if (git(root, ['rev-parse', '--git-dir']) === null)
|
|
164
|
+
return 'projeto fora de um repositório git: o commit não tem como recuperar a pasta';
|
|
165
|
+
const original = treeOf(root, entry.commit, entry.caminho);
|
|
166
|
+
if (!original)
|
|
167
|
+
return `o commit ${entry.commit} não recupera ${entry.caminho}`;
|
|
168
|
+
const current = treeOf(root, 'HEAD', destino);
|
|
169
|
+
if (!current)
|
|
170
|
+
return 'arquivado agora: faça commit e rode de novo';
|
|
171
|
+
if (current !== original)
|
|
172
|
+
return 'alterada depois do arquivamento';
|
|
173
|
+
const status = (git(root, ['status', '--porcelain', '--ignored', '--', destino]) ?? '').split('\n').filter(Boolean);
|
|
174
|
+
if (status.some((line) => !line.startsWith('??') && !line.startsWith('!!')))
|
|
175
|
+
return 'alterada depois do arquivamento';
|
|
176
|
+
if (status.length)
|
|
177
|
+
return 'há arquivos não versionados ou ignorados na pasta';
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
/** Apaga com `git rm` as pastas arquivadas que o commit da linha do índice recupera; as demais ficam, com o motivo. */
|
|
181
|
+
export function removeArchived(root) {
|
|
182
|
+
const result = { removidos: [], mantidos: [] };
|
|
183
|
+
for (const entry of readArchiveIndex(root).trabalhos) {
|
|
184
|
+
const destino = archiveDestination(entry.id);
|
|
185
|
+
if (!existsSync(join(root, destino)))
|
|
186
|
+
continue;
|
|
187
|
+
const motivo = reasonToKeepArchived(root, entry, destino);
|
|
188
|
+
if (motivo)
|
|
189
|
+
result.mantidos.push({ id: entry.id, motivo });
|
|
190
|
+
else if (git(root, ['rm', '-r', '-q', '--', destino]) === null)
|
|
191
|
+
result.mantidos.push({ id: entry.id, motivo: 'git rm falhou' });
|
|
192
|
+
else
|
|
193
|
+
result.removidos.push(entry.id);
|
|
194
|
+
}
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { EOL } from 'node:os';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { suggestFlag } from '../install/flags.js';
|
|
5
|
+
import { buildIndex } from '../memoria/indice.js';
|
|
6
|
+
import { acharRaiz } from '../raiz.js';
|
|
7
|
+
import { loadRastroLib } from '../rastro/command.js';
|
|
8
|
+
import { applyArchive, planArchive, removeArchived } from './arquivar.js';
|
|
9
|
+
import { listWorks } from './listar.js';
|
|
10
|
+
export const ARCHIVE_DEFAULT_DAYS = 60;
|
|
11
|
+
const SUBCOMMANDS = ['arquivar', 'listar'];
|
|
12
|
+
const ARCHIVE_FLAGS = { valued: ['--antes-de'], boolean: ['--remover', '--yes'] };
|
|
13
|
+
const LIST_FLAGS = { valued: ['--busca', '--tipo', '--status', '--limite'], boolean: [] };
|
|
14
|
+
function parseArgs(argv, spec) {
|
|
15
|
+
let path;
|
|
16
|
+
const values = new Map();
|
|
17
|
+
const switches = new Set();
|
|
18
|
+
for (let i = 0; i < argv.length; i++) {
|
|
19
|
+
const token = argv[i] ?? '';
|
|
20
|
+
if (!token.startsWith('-')) {
|
|
21
|
+
if (path !== undefined)
|
|
22
|
+
throw new Error(`Argumento inesperado: ${token}`);
|
|
23
|
+
path = token;
|
|
24
|
+
}
|
|
25
|
+
else if (spec.boolean.includes(token)) {
|
|
26
|
+
switches.add(token);
|
|
27
|
+
}
|
|
28
|
+
else if (spec.valued.includes(token)) {
|
|
29
|
+
const value = argv[++i];
|
|
30
|
+
if (value === undefined)
|
|
31
|
+
throw new Error(`${token} precisa de um valor.`);
|
|
32
|
+
values.set(token, value);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
const suggestion = suggestFlag(token, [...spec.valued, ...spec.boolean]);
|
|
36
|
+
throw new Error(`Flag desconhecida: ${token}.${suggestion ? ` Você quis dizer ${suggestion}?` : ''}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const root = path === undefined ? acharRaiz() : resolve(process.cwd(), path);
|
|
40
|
+
if (!existsSync(root))
|
|
41
|
+
throw new Error(`Pasta não encontrada: ${root}`);
|
|
42
|
+
return { root, values, switches };
|
|
43
|
+
}
|
|
44
|
+
function parseCount(value, min, error) {
|
|
45
|
+
if (!/^\d+$/.test(value) || Number(value) < min)
|
|
46
|
+
throw new Error(error);
|
|
47
|
+
return Number(value);
|
|
48
|
+
}
|
|
49
|
+
async function runArchive(argv) {
|
|
50
|
+
const { root, values, switches } = parseArgs(argv, ARCHIVE_FLAGS);
|
|
51
|
+
const rawDays = values.get('--antes-de');
|
|
52
|
+
const dias = rawDays === undefined ? ARCHIVE_DEFAULT_DAYS : parseCount(rawDays, 1, '--antes-de precisa de um número de dias inteiro e maior que zero.');
|
|
53
|
+
const now = new Date();
|
|
54
|
+
const ativos = (await loadRastroLib()).findActiveWorks(root);
|
|
55
|
+
const plan = planArchive(root, { now, dias, ativos });
|
|
56
|
+
const lines = [
|
|
57
|
+
`Elegíveis para arquivar (${plan.elegiveis.length}):`,
|
|
58
|
+
...plan.elegiveis.map(({ id, destino }) => ` ${id} -> ${destino}`),
|
|
59
|
+
`Ignorados (${plan.ignorados.length}):`,
|
|
60
|
+
...plan.ignorados.map(({ id, motivo }) => ` ${id}: ${motivo}`),
|
|
61
|
+
];
|
|
62
|
+
if (!switches.has('--yes')) {
|
|
63
|
+
lines.push('Simulação — nada foi movido. Rode de novo com --yes para arquivar.');
|
|
64
|
+
console.log(lines.join(EOL));
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
const archived = applyArchive(root, plan, { now });
|
|
68
|
+
lines.push(`Arquivados (${archived.length}):`, ...archived.map(({ id, commit }) => ` ${id}${commit ? ` (commit ${commit.slice(0, 12)})` : ' (sem commit)'}`));
|
|
69
|
+
if (switches.has('--remover')) {
|
|
70
|
+
const { removidos, mantidos } = removeArchived(root);
|
|
71
|
+
lines.push(`Removidos do disco (${removidos.length}):`, ...removidos.map((id) => ` ${id}`), `Mantidos no disco (${mantidos.length}):`, ...mantidos.map(({ id, motivo }) => ` ${id}: ${motivo}`));
|
|
72
|
+
}
|
|
73
|
+
console.log(lines.join(EOL));
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
function runList(argv) {
|
|
77
|
+
const { root, values } = parseArgs(argv, LIST_FLAGS);
|
|
78
|
+
const options = {};
|
|
79
|
+
const busca = values.get('--busca');
|
|
80
|
+
const tipo = values.get('--tipo');
|
|
81
|
+
const status = values.get('--status');
|
|
82
|
+
const limite = values.get('--limite');
|
|
83
|
+
if (busca !== undefined)
|
|
84
|
+
options.busca = busca;
|
|
85
|
+
if (tipo !== undefined)
|
|
86
|
+
options.tipo = tipo;
|
|
87
|
+
if (status !== undefined)
|
|
88
|
+
options.status = status;
|
|
89
|
+
if (limite !== undefined)
|
|
90
|
+
options.limite = parseCount(limite, 0, '--limite precisa de um número inteiro.');
|
|
91
|
+
console.log(listWorks(buildIndex(root), options).join(EOL));
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
/** `ai-harness trabalhos arquivar [caminho] [--antes-de <dias>] [--remover] [--yes]` e `trabalhos listar [caminho] [filtros]`. */
|
|
95
|
+
export async function runWorksCommand(argv) {
|
|
96
|
+
const [subcommand, ...rest] = argv;
|
|
97
|
+
if (subcommand === 'arquivar')
|
|
98
|
+
return runArchive(rest);
|
|
99
|
+
if (subcommand === 'listar')
|
|
100
|
+
return runList(rest);
|
|
101
|
+
if (!subcommand)
|
|
102
|
+
throw new Error(`Informe o subcomando: ${SUBCOMMANDS.join(' ou ')}. Ex.: npx @fontdata/ai-harness trabalhos listar`);
|
|
103
|
+
const suggestion = suggestFlag(subcommand, SUBCOMMANDS);
|
|
104
|
+
throw new Error(`Subcomando desconhecido: ${subcommand}.${suggestion ? ` Você quis dizer ${suggestion}?` : ''} Use: ${SUBCOMMANDS.join(', ')}.`);
|
|
105
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { ARCHIVE_DIR_NAME, WORKS_DIR } from '../contratos/artefatos.js';
|
|
4
|
+
export { ARCHIVE_DIR_NAME };
|
|
5
|
+
export const ARCHIVE_DIR = join(WORKS_DIR, ARCHIVE_DIR_NAME);
|
|
6
|
+
export const ARCHIVE_INDEX = join(ARCHIVE_DIR, 'INDICE.jsonl');
|
|
7
|
+
const text = (value) => (typeof value === 'string' ? value : null);
|
|
8
|
+
function ordered(raw) {
|
|
9
|
+
return {
|
|
10
|
+
v: 1,
|
|
11
|
+
id: raw.id,
|
|
12
|
+
trabalho_tipo: text(raw.trabalho_tipo),
|
|
13
|
+
titulo: text(raw.titulo),
|
|
14
|
+
status: text(raw.status),
|
|
15
|
+
criado_em: text(raw.criado_em),
|
|
16
|
+
data: text(raw.data),
|
|
17
|
+
arquivos: Array.isArray(raw.arquivos) ? raw.arquivos.filter((a) => typeof a === 'string') : [],
|
|
18
|
+
causa_raiz: text(raw.causa_raiz),
|
|
19
|
+
caminho: text(raw.caminho) ?? join(WORKS_DIR, raw.id).replaceAll('\\', '/'),
|
|
20
|
+
commit: text(raw.commit),
|
|
21
|
+
arquivado_em: text(raw.arquivado_em) ?? '',
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** Lê o índice do arquivo; vale a última linha de cada id, e a última linha sem `\n` é ignorada. */
|
|
25
|
+
export function readArchiveIndex(root) {
|
|
26
|
+
const file = join(root, ARCHIVE_INDEX);
|
|
27
|
+
if (!existsSync(file))
|
|
28
|
+
return { trabalhos: [], problemas: [] };
|
|
29
|
+
const lines = readFileSync(file, 'utf8').split('\n');
|
|
30
|
+
lines.pop();
|
|
31
|
+
const byId = new Map();
|
|
32
|
+
const problemas = [];
|
|
33
|
+
lines.forEach((line, index) => {
|
|
34
|
+
if (!line.trim())
|
|
35
|
+
return;
|
|
36
|
+
let parsed;
|
|
37
|
+
try {
|
|
38
|
+
parsed = JSON.parse(line);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
problemas.push({ linha: index + 1, mensagem: 'linha não é JSON válido' });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || typeof parsed.id !== 'string') {
|
|
45
|
+
problemas.push({ linha: index + 1, mensagem: 'linha sem `id` texto' });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const { v } = parsed;
|
|
49
|
+
if (typeof v === 'number' && v > 1) {
|
|
50
|
+
problemas.push({ linha: index + 1, mensagem: `versão ${v} do índice é mais nova que a suportada (1); atualize o ai-harness` });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const entry = ordered(parsed);
|
|
54
|
+
byId.delete(entry.id);
|
|
55
|
+
byId.set(entry.id, entry);
|
|
56
|
+
});
|
|
57
|
+
return { trabalhos: [...byId.values()], problemas };
|
|
58
|
+
}
|
|
59
|
+
/** Acrescenta uma linha por entrada ao índice, sem reescrever as anteriores. */
|
|
60
|
+
export function appendArchiveEntries(root, entries) {
|
|
61
|
+
if (entries.length === 0)
|
|
62
|
+
return;
|
|
63
|
+
const file = join(root, ARCHIVE_INDEX);
|
|
64
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
65
|
+
const current = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
66
|
+
const prefix = current && !current.endsWith('\n') ? '\n' : '';
|
|
67
|
+
appendFileSync(file, prefix + entries.map((entry) => `${JSON.stringify(ordered(entry))}\n`).join(''));
|
|
68
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export const NO_WORKS_FOUND = 'Nenhum trabalho encontrado.';
|
|
2
|
+
const fold = (value) => value.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
|
|
3
|
+
const show = (value) => value ?? '?';
|
|
4
|
+
function compareWorks([idA, a], [idB, b]) {
|
|
5
|
+
if (a.data !== b.data) {
|
|
6
|
+
if (!a.data)
|
|
7
|
+
return 1;
|
|
8
|
+
if (!b.data)
|
|
9
|
+
return -1;
|
|
10
|
+
return a.data < b.data ? 1 : -1;
|
|
11
|
+
}
|
|
12
|
+
return idA < idB ? 1 : idA > idB ? -1 : 0;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Linhas `id | tipo | status | título` dos trabalhos do índice, arquivados incluídos, da data mais recente
|
|
16
|
+
* para a mais antiga. `busca` casa com id ou título sem diferenciar caixa nem acento; `tipo` e `status`
|
|
17
|
+
* comparam com o valor cru. Sem resultado, devolve só a linha de nenhum trabalho encontrado.
|
|
18
|
+
*/
|
|
19
|
+
export function listWorks(index, options = {}) {
|
|
20
|
+
const { busca, tipo, status, limite = 20 } = options;
|
|
21
|
+
const term = busca ? fold(busca) : null;
|
|
22
|
+
const lines = Object.entries(index.trabalhos)
|
|
23
|
+
.filter(([id, work]) => {
|
|
24
|
+
if (tipo !== undefined && work.trabalho_tipo !== tipo)
|
|
25
|
+
return false;
|
|
26
|
+
if (status !== undefined && work.status !== status)
|
|
27
|
+
return false;
|
|
28
|
+
return !term || fold(id).includes(term) || (work.titulo !== null && fold(work.titulo).includes(term));
|
|
29
|
+
})
|
|
30
|
+
.sort(compareWorks)
|
|
31
|
+
.slice(0, Math.max(0, limite))
|
|
32
|
+
.map(([id, work]) => {
|
|
33
|
+
const workStatus = work.arquivado ? `${show(work.status)} (arquivado)` : show(work.status);
|
|
34
|
+
return [id, show(work.trabalho_tipo), workStatus, show(work.titulo)].join(' | ');
|
|
35
|
+
});
|
|
36
|
+
return lines.length > 0 ? lines : [NO_WORKS_FOUND];
|
|
37
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fontdata/ai-harness",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.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": {
|
package/templates/CLAUDE.md
CHANGED
|
@@ -27,6 +27,7 @@ Comece por `/harness`, que conduz o setup em etapas.
|
|
|
27
27
|
| `docs/trabalhos/<id>/DECISOES.md` | Decisões tomadas no planejamento e na execução |
|
|
28
28
|
| `docs/trabalhos/<id>/BLOQUEIOS.md` | Dúvidas que pararam uma task |
|
|
29
29
|
| `docs/trabalhos/<id>/relatorios/TECNICO.md` e `relatorios/CLIENTE.md` | Relatório para engenharia e para cliente e suporte (sem jargão) |
|
|
30
|
+
| `docs/trabalhos/<id>/<id>.sql` | Script SQL entregável do trabalho, opcional (`<id>-<sufixo>.sql` se houver mais de um); apoio vai em `anexos/` |
|
|
30
31
|
| `docs/eventos/<id>.jsonl` | Rastro de eventos (não versionado por padrão) |
|
|
31
32
|
| `.ai-harness/` | Lock da instalação e configuração dos hooks (versionar) |
|
|
32
33
|
|
|
@@ -14,8 +14,8 @@ ou quando ficou claro que ela ainda é hipótese. Você não corrige o bug.
|
|
|
14
14
|
2. **Levante hipóteses** e, para cada uma, busque evidência que a confirme ou a descarte:
|
|
15
15
|
- leitura do código envolvido;
|
|
16
16
|
- histórico: `git log -p -S "<trecho>"`, `git log --follow <arquivo>`, `git blame -L <ini>,<fim> <arquivo>`;
|
|
17
|
-
- trabalhos anteriores que tocaram os mesmos arquivos
|
|
18
|
-
|
|
17
|
+
- trabalhos anteriores que tocaram os mesmos arquivos: `npx @fontdata/ai-harness memoria --arquivo <caminho>` e
|
|
18
|
+
`npx @fontdata/ai-harness memoria --regressao <id>`, que incluem os trabalhos arquivados.
|
|
19
19
|
3. **Comprove.** A causa raiz só é `comprovada` quando você mostra a linha ou a condição que produz o sintoma e
|
|
20
20
|
por que a reprodução falha por causa dela. Caso contrário, ela é `hipotese`.
|
|
21
21
|
4. Use `Bash` só para ler, buscar no histórico e reproduzir: você é somente leitura, então não altere arquivos, instale dependências,
|
|
@@ -170,6 +170,10 @@ const ehTexto = (c) => typeof c === 'string';
|
|
|
170
170
|
function analisar(caminho, fonte) {
|
|
171
171
|
const ext = extname(caminho);
|
|
172
172
|
const linhas = fonte.split('\n');
|
|
173
|
+
if (PASCAL.has(ext)) {
|
|
174
|
+
const fim = linhas.findLastIndex((l) => /^\s*end\.\s*$/i.test(l));
|
|
175
|
+
if (fim >= 0) linhas.length = fim + 1;
|
|
176
|
+
}
|
|
173
177
|
const achados = [];
|
|
174
178
|
const marca = (linha, msg) => achados.push({ linha, msg });
|
|
175
179
|
const ehComentario = classificar(linhas, ext);
|
|
@@ -46,7 +46,9 @@ Se o porquê não cabe em 3 linhas, **ele não pertence ao código**.
|
|
|
46
46
|
| Por que a mudança foi feita | mensagem de commit | comentário |
|
|
47
47
|
| Task / fase / plano / spec | `PLAN.md` e `SPEC.md` do trabalho em `docs/trabalhos/<id>/` | comentário |
|
|
48
48
|
| Diagnóstico e correção de um defeito | trabalho do tipo `bug` em `docs/trabalhos/<id>/` | comentário |
|
|
49
|
-
| Investigação, medição, auditoria, paridade, comparação |
|
|
49
|
+
| Investigação, medição, auditoria, paridade, comparação | `relatorios/TECNICO.md` do trabalho em `docs/trabalhos/<id>/` | comentário |
|
|
50
|
+
| Script SQL entregável do trabalho (DDL/DML) | raiz do trabalho, `docs/trabalhos/<id>/<id>.sql` | comentário |
|
|
51
|
+
| Documento de apoio do trabalho (imagem, planilha, export, contrato de API) | `anexos/` do trabalho em `docs/trabalhos/<id>/` | comentário |
|
|
50
52
|
| Achado de review | resposta do review, commit | comentário |
|
|
51
53
|
| Decisão de arquitetura | `SPEC.md` | comentário |
|
|
52
54
|
| Armadilha ao editar **aquela linha** | comentário de até 3 linhas | — |
|
|
@@ -56,7 +58,10 @@ correção aplicada no código — é trabalho do tipo `bug` (`/resolver-bug`).
|
|
|
56
58
|
auditoria, paridade com legado, medição, comparação, levantamento, handoff ou follow-up — é
|
|
57
59
|
`auditoria` ou `investigacao`. Se o documento descreve trabalho a fazer, é `feature` (`/nova-tarefa`).
|
|
58
60
|
|
|
59
|
-
Convenção: uma pasta por trabalho, `docs/trabalhos/<yyyy-mm-dd>-<slug>/`.
|
|
61
|
+
Convenção: uma pasta por trabalho, `docs/trabalhos/<yyyy-mm-dd>-<slug>/`. Relatório não ganha nome livre em
|
|
62
|
+
`relatorios/`: o conteúdo vai para `relatorios/TECNICO.md` e o arquivo de apoio para `anexos/`, senão o `doctor`
|
|
63
|
+
avisa `arquivo-fora-do-contrato`. O script SQL do trabalho é entregável, não apoio: fica na raiz como `<id>.sql`
|
|
64
|
+
(ou `<id>-<sufixo>.sql`).
|
|
60
65
|
|
|
61
66
|
## Antes e depois
|
|
62
67
|
|
|
@@ -154,6 +159,7 @@ function HashApiKey(const APlainKey: string): string;
|
|
|
154
159
|
- **TypeScript/JavaScript/Java/C#/C++/Go/Rust/PHP:** `/** ... */` no topo do símbolo **público**.
|
|
155
160
|
- **Python:** docstring `"""..."""` curta logo abaixo da assinatura.
|
|
156
161
|
- **Delphi/Pascal:** `///` (XMLDoc) nas rotinas públicas da `interface`; nunca repetir na `implementation`.
|
|
162
|
+
O que vem depois do último `end.` (o rodapé de histórico, que o compilador ignora) fica fora deste padrão e do hook.
|
|
157
163
|
- **SQL/Shell/YAML:** somente regra de negócio ou pegadinha não evidente.
|
|
158
164
|
|
|
159
165
|
## Enforcement
|
|
@@ -100,8 +100,9 @@ cada tipo na sua pasta dentro de `docs/`). Hoje cada trabalho mora em `docs/trab
|
|
|
100
100
|
|
|
101
101
|
## 8. O que criar
|
|
102
102
|
|
|
103
|
-
Sugira peças novas para dores recorrentes.
|
|
104
|
-
relatórios
|
|
103
|
+
Sugira peças novas para dores recorrentes. Comece por `npx @fontdata/ai-harness trabalhos listar` e busque
|
|
104
|
+
evidência em `DECISOES.md`, `BLOQUEIOS.md` e relatórios só dos trabalhos ativos e dos indicados por ele, em achados
|
|
105
|
+
de review repetidos e em `npx @fontdata/ai-harness memoria --arquivo <arquivo>`. Para cada
|
|
105
106
|
sugestão: tipo (`agent`, `skill`, `rule`, `hook`, `code-reviewer`, `design-system`), o que faria, quando seria
|
|
106
107
|
usada e qual **dor recorrente** elimina.
|
|
107
108
|
|
|
@@ -5,7 +5,8 @@ Cria uma skill do projeto em `.claude/skills/<nome>/` para um problema **recorre
|
|
|
5
5
|
## Leitura
|
|
6
6
|
|
|
7
7
|
`CLAUDE.md` e `SPEC.md` da raiz, as skills que já existem (do projeto e do harness) e, como evidência de
|
|
8
|
-
recorrência, `DECISOES.md`, `BLOQUEIOS.md` e relatórios
|
|
8
|
+
recorrência, `DECISOES.md`, `BLOQUEIOS.md` e relatórios dos trabalhos: comece por
|
|
9
|
+
`npx @fontdata/ai-harness trabalhos listar` e leia só os trabalhos ativos e os indicados por ele.
|
|
9
10
|
|
|
10
11
|
## Skill, rule, agent ou hook?
|
|
11
12
|
|
|
@@ -8,7 +8,8 @@ argument-hint: "[caminho de arquivo ou id de um bug]"
|
|
|
8
8
|
|
|
9
9
|
Pergunta: $ARGUMENTS
|
|
10
10
|
|
|
11
|
-
A memória é um **índice** gerado a partir do frontmatter dos trabalhos em `docs/trabalhos
|
|
11
|
+
A memória é um **índice** gerado a partir do frontmatter dos trabalhos em `docs/trabalhos/`, arquivados inclusive, e
|
|
12
|
+
do rastro de eventos.
|
|
12
13
|
Não é busca semântica: toda resposta aponta um trabalho e os documentos dele, e pode ser conferida.
|
|
13
14
|
|
|
14
15
|
## Passo 1 — Atualizar o índice
|
|
@@ -18,8 +18,8 @@ uma pergunta durante a execução é falha do plano.
|
|
|
18
18
|
|
|
19
19
|
1. Leia o `SPEC.md` e o `CLAUDE.md` da raiz e, se existir, a rule de projeto `.claude/rules/harness-projeto.md`: o
|
|
20
20
|
mapa de agents por domínio e os portões extras entram no plano.
|
|
21
|
-
2.
|
|
22
|
-
nem contradizer um trabalho em andamento.
|
|
21
|
+
2. Rode `npx @fontdata/ai-harness trabalhos listar --busca <termo do pedido>` (id, tipo, status e título, com os
|
|
22
|
+
arquivados) para não duplicar nem contradizer um trabalho em andamento.
|
|
23
23
|
3. **Start inicial** (não há trabalho do tipo `feature` ainda, ou o pedido é "start"/"primeira fatia"): derive o
|
|
24
24
|
recorte direto do `SPEC.md` — a primeira fatia vertical. **Nova tarefa:** siga para o Passo 1.
|
|
25
25
|
|
|
@@ -48,7 +48,8 @@ mais conservadora.
|
|
|
48
48
|
|
|
49
49
|
## Passo 3 — Pasta do trabalho e SPEC.md
|
|
50
50
|
|
|
51
|
-
1. Defina o id `AAAA-MM-DD-<slug>` (data de hoje, slug curto em minúsculas, sem acento)
|
|
51
|
+
1. Defina o id `AAAA-MM-DD-<slug>` (data de hoje, slug curto em minúsculas, sem acento) e confira que
|
|
52
|
+
`npx @fontdata/ai-harness trabalhos listar --busca <id>` não devolve nenhum trabalho, nem arquivado.
|
|
52
53
|
2. Crie `docs/trabalhos/<id>/SPEC.md` a partir de `templates/SPEC.md`, preenchendo `{{id}}`, `{{titulo}}` e
|
|
53
54
|
`{{data}}`. Os nomes dos arquivos da pasta são sempre em caixa alta.
|
|
54
55
|
A seção **Como vai operar** é obrigatória: é dela que o `/executar-plan` tira o relatório para o cliente e para
|
|
@@ -10,7 +10,8 @@ Antes de planejar, procure evidência — nunca presuma.
|
|
|
10
10
|
2. **Pontos de entrada:** rotas e controllers, menus e telas, comandos de CLI, jobs agendados.
|
|
11
11
|
3. **Dados:** tabelas e colunas (migrations, models, scripts SQL) que já guardam o que o pedido precisa.
|
|
12
12
|
4. **Configuração e flags:** a funcionalidade pode existir desligada por configuração ou feature flag.
|
|
13
|
-
5. **Trabalhos anteriores:** `
|
|
13
|
+
5. **Trabalhos anteriores:** `npx @fontdata/ai-harness trabalhos listar --busca <termo>` e, para os arquivos que o
|
|
14
|
+
pedido toca, `npx @fontdata/ai-harness memoria --arquivo <caminho>`; os dois incluem os trabalhos arquivados.
|
|
14
15
|
|
|
15
16
|
## Como concluir
|
|
16
17
|
|