@fontdata/ai-harness 2.0.0 → 2.2.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.
Files changed (132) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +9 -2
  3. package/LICENSE +21 -0
  4. package/README.md +175 -140
  5. package/{src → dist}/catalog.js +5 -12
  6. package/dist/cli.js +180 -0
  7. package/dist/comentarios/command.js +74 -0
  8. package/dist/comentarios/instalar.js +33 -0
  9. package/dist/config/config.js +99 -0
  10. package/dist/contratos/artefatos.js +284 -0
  11. package/dist/contratos/eventos.js +103 -0
  12. package/dist/doctor/checks.js +424 -0
  13. package/dist/init.js +240 -0
  14. package/dist/install/atomic.js +51 -0
  15. package/dist/install/flags.js +99 -0
  16. package/dist/install/hooks.js +47 -0
  17. package/dist/install/lock.js +39 -0
  18. package/dist/install/plan.js +209 -0
  19. package/dist/install/selection.js +77 -0
  20. package/dist/install/settings.js +112 -0
  21. package/dist/memoria/indice.js +195 -0
  22. package/dist/migrar/migrar.js +426 -0
  23. package/dist/rastro/command.js +46 -0
  24. package/dist/run.js +86 -0
  25. package/dist/trabalhos/conflitos.js +115 -0
  26. package/dist/types.js +1 -0
  27. package/dist/update/legado.js +74 -0
  28. package/dist/update/update.js +284 -0
  29. package/package.json +17 -8
  30. package/templates/CLAUDE.md +59 -30
  31. package/templates/agents/auditor-plano.md +44 -0
  32. package/templates/agents/investigador.md +32 -0
  33. package/templates/agents/qa.md +44 -0
  34. package/templates/agents/revisor-testes.md +40 -0
  35. package/templates/catalog.json +24 -61
  36. package/templates/commands/atualizar-deps.md +43 -9
  37. package/templates/commands/diagrama.md +35 -10
  38. package/templates/commands/gerar-env.md +39 -10
  39. package/templates/commands/gerar-testes.md +42 -10
  40. package/templates/commands/git-commit-push.md +0 -0
  41. package/templates/commands/onboarding.md +44 -15
  42. package/templates/commands/refatorar.md +50 -10
  43. package/templates/commands/revisar-pr.md +56 -12
  44. package/templates/commands/sincronizar-claude-md.md +177 -9
  45. package/templates/commands/versao.md +0 -0
  46. package/templates/hooks/comentarios/check.mjs +210 -0
  47. package/templates/hooks/hooks.json +34 -0
  48. package/templates/hooks/lembrete.mjs +29 -0
  49. package/templates/hooks/lib/config.mjs +40 -0
  50. package/templates/hooks/lib/hook.mjs +85 -0
  51. package/templates/hooks/lib/rastro.mjs +144 -0
  52. package/templates/hooks/lib/segredos.mjs +91 -0
  53. package/templates/hooks/lib/temas.mjs +111 -0
  54. package/templates/hooks/lib/trabalho.mjs +94 -0
  55. package/templates/hooks/proteger-contratos.mjs +80 -0
  56. package/templates/hooks/segredo-no-commit.mjs +76 -0
  57. package/templates/hooks/sem-jargao.mjs +58 -0
  58. package/templates/hooks/task-so-fecha-verde.mjs +33 -0
  59. package/templates/legado/manifesto.json +1267 -0
  60. package/templates/rules/comentarios.md +166 -0
  61. package/templates/rules/harness-projeto.md +41 -0
  62. package/templates/skills/auditar-performance/SKILL.md +53 -0
  63. package/templates/skills/auditar-performance/references/pontos-quentes.md +35 -0
  64. package/templates/skills/auditar-performance/templates/relatorios/CLIENTE.md +26 -0
  65. package/templates/skills/auditar-performance/templates/relatorios/TECNICO.md +27 -0
  66. package/templates/skills/auditar-seguranca/SKILL.md +67 -0
  67. package/templates/skills/auditar-seguranca/references/seguranca-tecnica.md +23 -0
  68. package/templates/skills/auditar-seguranca/templates/relatorios/CLIENTE.md +30 -0
  69. package/templates/skills/auditar-seguranca/templates/relatorios/TECNICO.md +38 -0
  70. package/templates/skills/auditar-ui/SKILL.md +64 -0
  71. package/templates/skills/auditar-ui/references/lentes.md +33 -0
  72. package/templates/skills/auditar-ui/templates/relatorios/CLIENTE.md +26 -0
  73. package/templates/skills/auditar-ui/templates/relatorios/TECNICO.md +31 -0
  74. package/templates/skills/evoluir-harness/SKILL.md +81 -0
  75. package/templates/skills/evoluir-harness/references/agent.md +83 -0
  76. package/templates/skills/evoluir-harness/references/auditoria.md +107 -0
  77. package/templates/skills/evoluir-harness/references/code-reviewer.md +68 -0
  78. package/templates/skills/evoluir-harness/references/design-system.md +56 -0
  79. package/templates/skills/evoluir-harness/references/hook.md +95 -0
  80. package/templates/skills/evoluir-harness/references/rule.md +67 -0
  81. package/templates/skills/evoluir-harness/references/skill.md +63 -0
  82. package/templates/skills/executar-plan/SKILL.md +130 -0
  83. package/templates/skills/executar-plan/references/delegacao.md +43 -0
  84. package/templates/skills/executar-plan/references/retomada.md +33 -0
  85. package/templates/skills/executar-plan/templates/BLOQUEIOS.md +30 -0
  86. package/templates/skills/harness/SKILL.md +75 -0
  87. package/templates/skills/harness/references/auto-tag.md +118 -0
  88. package/templates/skills/harness/references/ignores.md +40 -0
  89. package/templates/skills/harness/references/scaffold.md +29 -0
  90. package/templates/skills/harness/references/validar-ideia.md +42 -0
  91. package/templates/skills/memoria/SKILL.md +52 -0
  92. package/templates/skills/nova-tarefa/SKILL.md +99 -0
  93. package/templates/skills/nova-tarefa/references/isso-ja-existe.md +24 -0
  94. package/templates/skills/nova-tarefa/references/regras-do-plano.md +35 -0
  95. package/templates/skills/nova-tarefa/templates/DECISOES.md +20 -0
  96. package/templates/skills/nova-tarefa/templates/PLAN.md +58 -0
  97. package/templates/skills/nova-tarefa/templates/SPEC.md +37 -0
  98. package/templates/skills/resolver-bug/SKILL.md +104 -0
  99. package/templates/skills/resolver-bug/references/relatorio-para-cliente.md +26 -0
  100. package/templates/skills/resolver-bug/templates/PLAN.md +62 -0
  101. package/templates/skills/resolver-bug/templates/relatorios/CLIENTE.md +29 -0
  102. package/templates/skills/resolver-bug/templates/relatorios/TECNICO.md +32 -0
  103. package/bin/cli.js +0 -122
  104. package/src/init.js +0 -317
  105. package/src/installer.js +0 -92
  106. package/src/run.js +0 -99
  107. package/templates/commands/auditar-performance.md +0 -27
  108. package/templates/commands/auditar-seguranca.md +0 -64
  109. package/templates/commands/auditar-ui.md +0 -38
  110. package/templates/commands/codigo-morto.md +0 -22
  111. package/templates/commands/configurar-auto-tag.md +0 -185
  112. package/templates/commands/configurar-comentarios.md +0 -543
  113. package/templates/commands/configurar-orquestrador.md +0 -13
  114. package/templates/commands/corrigir-review.md +0 -11
  115. package/templates/commands/criar-agents.md +0 -42
  116. package/templates/commands/criar-code-reviewer.md +0 -30
  117. package/templates/commands/criar-hooks.md +0 -32
  118. package/templates/commands/criar-rag.md +0 -25
  119. package/templates/commands/criar-rules.md +0 -31
  120. package/templates/commands/criar-skill.md +0 -22
  121. package/templates/commands/design-system.md +0 -27
  122. package/templates/commands/executar-plan.md +0 -36
  123. package/templates/commands/gerar-claude-md.md +0 -66
  124. package/templates/commands/gerar-ignore.md +0 -37
  125. package/templates/commands/gerar-spec.md +0 -33
  126. package/templates/commands/harness.md +0 -34
  127. package/templates/commands/nova-tarefa.md +0 -60
  128. package/templates/commands/resolver-bug.md +0 -25
  129. package/templates/commands/review-por-task.md +0 -16
  130. package/templates/commands/scaffold.md +0 -25
  131. package/templates/commands/validar-commands.md +0 -24
  132. package/templates/commands/validar-ideia.md +0 -36
package/dist/cli.js ADDED
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { allCommands, loadCatalog } from './catalog.js';
6
+ import { runCommand, runnableIds, suggestIds } from './run.js';
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const RESERVED = new Set([
9
+ 'init',
10
+ 'doctor',
11
+ 'update',
12
+ 'migrar',
13
+ 'evento',
14
+ 'conflitos',
15
+ 'comentarios',
16
+ 'memoria',
17
+ 'list',
18
+ 'run',
19
+ 'help',
20
+ '--help',
21
+ '-h',
22
+ '--version',
23
+ '-v',
24
+ ]);
25
+ function pkgVersion() {
26
+ try {
27
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
28
+ return pkg.version;
29
+ }
30
+ catch {
31
+ return '0.0.0';
32
+ }
33
+ }
34
+ function printHelp() {
35
+ console.log(`
36
+ @fontdata/ai-harness — harness de engenharia com IA para Claude Code
37
+
38
+ Uso:
39
+ npx @fontdata/ai-harness <comando> [contexto] roda o comando na pasta atual
40
+ npx @fontdata/ai-harness init instala os comandos no projeto
41
+
42
+ Executar sem instalar:
43
+ <comando> Abre o Claude Code aqui já executando o comando (ex.: harness)
44
+ run <comando> Forma explícita, útil quando o nome colide com uma palavra reservada
45
+
46
+ Gerenciar:
47
+ init [caminho] Instala os slash commands em .claude/commands do projeto (interativo)
48
+ doctor [caminho] Diagnostica a instalação (arquivos, lock, skills, agents, hooks, Node)
49
+ update [caminho] Atualiza o que foi instalado: --check só lista, --yes aplica, --forcar sobrescreve conflitos
50
+ migrar [caminho] Move docs/specs, plans, bugs e investigacoes para docs/trabalhos/<id>/ (--yes aplica)
51
+ comentarios [caminho] Instala o hook de comentários e a catraca do legado (--yes aplica)
52
+ memoria [caminho] Índice de quem mexeu em cada arquivo (--arquivo <caminho>, --regressao <id do bug>)
53
+ evento <evento> Grava no rastro do trabalho (--trabalho, --task, --nome, --resultado, --dados '{json}')
54
+ conflitos <id> Aponta trabalhos em execução que mexem nos mesmos arquivos (--aguardar espera liberar)
55
+ list Lista todos os comandos disponíveis no catálogo
56
+
57
+ Init sem perguntas (CI):
58
+ --escopo recomendado|tudo ou --comandos a,b,c
59
+ --sem-harness não inclui o comando-mestre /harness
60
+ --sem-hooks não instala os hooks do harness
61
+ --sem-agents não instala os agents somente leitura
62
+ --sem-comentarios não instala a rule, o hook e a catraca de comentários
63
+ --claude-md criar|manter|backup|sobrescrever (padrão: criar)
64
+ --conflito sobrescrever|pular (padrão: pular)
65
+ --yes aplica; sem ele o init só simula
66
+ --help, -h Mostra esta ajuda
67
+ --version, -v Mostra a versão
68
+
69
+ Exemplos:
70
+ npx @fontdata/ai-harness harness
71
+ npx @fontdata/ai-harness resolver-bug "login 500 no refresh token"
72
+ npx @fontdata/ai-harness run versao minor
73
+ npx @fontdata/ai-harness harness -- --model claude-opus-5
74
+
75
+ Tudo depois de \`--\` é repassado ao Claude Code (--model, --effort, -p, ...).
76
+ `);
77
+ }
78
+ function printList() {
79
+ const catalog = loadCatalog();
80
+ console.log('\nComandos disponíveis:\n');
81
+ for (const group of catalog.groups) {
82
+ console.log(` ${group.title}`);
83
+ for (const cmd of group.commands) {
84
+ const star = cmd.recommended ? ' *' : ' ';
85
+ console.log(` ${star} ${cmd.title.padEnd(26)} ${cmd.description}`);
86
+ }
87
+ console.log('');
88
+ }
89
+ console.log(` (* = conjunto recomendado · total: ${allCommands(catalog).length} comandos + /harness)\n`);
90
+ }
91
+ /** Separa os argumentos do comando dos flags destinados ao Claude Code. */
92
+ function splitArgs(rest) {
93
+ const sep = rest.indexOf('--');
94
+ if (sep === -1)
95
+ return { args: rest, claudeFlags: [] };
96
+ return { args: rest.slice(0, sep), claudeFlags: rest.slice(sep + 1) };
97
+ }
98
+ async function main() {
99
+ const argv = process.argv.slice(2);
100
+ const cmd = argv[0];
101
+ if (cmd === '--version' || cmd === '-v') {
102
+ console.log(pkgVersion());
103
+ return;
104
+ }
105
+ if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
106
+ printHelp();
107
+ return;
108
+ }
109
+ if (cmd === 'list') {
110
+ printList();
111
+ return;
112
+ }
113
+ if (cmd === 'init') {
114
+ // Import dinâmico: só carrega @clack/prompts quando o init é realmente usado.
115
+ const { runInit } = await import('./init.js');
116
+ await runInit(argv.slice(1));
117
+ return;
118
+ }
119
+ if (cmd === 'doctor') {
120
+ const { formatFindings, runDoctor } = await import('./doctor/checks.js');
121
+ const root = resolve(process.cwd(), argv[1] ?? '.');
122
+ if (!existsSync(root))
123
+ throw new Error(`Pasta não encontrada: ${root}`);
124
+ const findings = runDoctor(root);
125
+ console.log(formatFindings(findings));
126
+ process.exit(findings.some((finding) => finding.severidade === 'erro') ? 1 : 0);
127
+ }
128
+ if (cmd === 'memoria') {
129
+ const { runMemoryCommand } = await import('./memoria/indice.js');
130
+ process.exit(runMemoryCommand(argv.slice(1)));
131
+ }
132
+ if (cmd === 'comentarios') {
133
+ const { runCommentsCommand } = await import('./comentarios/command.js');
134
+ process.exit(runCommentsCommand(argv.slice(1)));
135
+ }
136
+ if (cmd === 'migrar') {
137
+ const { runMigrationCommand } = await import('./migrar/migrar.js');
138
+ process.exit(runMigrationCommand(argv.slice(1)));
139
+ }
140
+ if (cmd === 'evento') {
141
+ const { runEventCommand } = await import('./rastro/command.js');
142
+ process.exit(await runEventCommand(argv.slice(1)));
143
+ }
144
+ if (cmd === 'conflitos') {
145
+ const { runConflictsCommand } = await import('./trabalhos/conflitos.js');
146
+ process.exit(await runConflictsCommand(argv.slice(1)));
147
+ }
148
+ if (cmd === 'update') {
149
+ const { runUpdateCommand } = await import('./update/update.js');
150
+ process.exit(runUpdateCommand(argv.slice(1)));
151
+ }
152
+ if (cmd === 'run') {
153
+ const id = argv[1];
154
+ if (!id) {
155
+ console.error('Informe o comando a executar. Ex.: npx @fontdata/ai-harness run harness\n');
156
+ printHelp();
157
+ process.exit(1);
158
+ }
159
+ const { args, claudeFlags } = splitArgs(argv.slice(2));
160
+ process.exit(runCommand(id, args, claudeFlags));
161
+ }
162
+ const ids = runnableIds();
163
+ if (ids.includes(cmd.replace(/^\//, ''))) {
164
+ const { args, claudeFlags } = splitArgs(argv.slice(1));
165
+ process.exit(runCommand(cmd, args, claudeFlags));
166
+ }
167
+ if (!RESERVED.has(cmd)) {
168
+ const near = suggestIds(cmd.replace(/^\//, ''), ids);
169
+ console.error(`Comando desconhecido: ${cmd}`);
170
+ if (near.length)
171
+ console.error(`\nVocê quis dizer: ${near.join(', ')}`);
172
+ console.error('');
173
+ }
174
+ printHelp();
175
+ process.exit(1);
176
+ }
177
+ main().catch((err) => {
178
+ console.error('Erro:', err instanceof Error ? err.message : err);
179
+ process.exit(1);
180
+ });
@@ -0,0 +1,74 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { EOL } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+ import { templatesDir } from '../catalog.js';
5
+ import { suggestFlag } from '../install/flags.js';
6
+ import { hashContent } from '../install/lock.js';
7
+ import { executePlan, packageInfo } from '../install/plan.js';
8
+ import { mergeSettings } from '../install/settings.js';
9
+ import { COMMENTS_BASELINE_PATH, COMMENTS_HOOK_PATH, COMMENTS_RULE_PATH, commentsHooksConfig, ensureCommentsBaseline } from './instalar.js';
10
+ export { COMMENTS_HOOK_PATH } from './instalar.js';
11
+ const FLAGS = ['--yes', '-y', '--sim'];
12
+ /** `ai-harness comentarios [caminho] [--yes]`: instala a rule, o hook de comentários e a catraca do legado. */
13
+ export function runCommentsCommand(argv) {
14
+ let path;
15
+ let yes = false;
16
+ for (const token of argv) {
17
+ if (!token.startsWith('-')) {
18
+ if (path !== undefined)
19
+ throw new Error(`Argumento inesperado: ${token}`);
20
+ path = token;
21
+ }
22
+ else if (FLAGS.includes(token)) {
23
+ yes = true;
24
+ }
25
+ else {
26
+ const suggestion = suggestFlag(token, FLAGS);
27
+ throw new Error(`Flag desconhecida: ${token}.${suggestion ? ` Você quis dizer ${suggestion}?` : ''}`);
28
+ }
29
+ }
30
+ const root = resolve(process.cwd(), path ?? '.');
31
+ if (!existsSync(root))
32
+ throw new Error(`Pasta não encontrada: ${root}`);
33
+ const rule = readFileSync(join(templatesDir, 'rules', 'comentarios.md'));
34
+ const ruleTarget = join(root, COMMENTS_RULE_PATH);
35
+ // Uma rule com outro conteúdo é do projeto: fica como está.
36
+ const keepRule = existsSync(ruleTarget) && hashContent(readFileSync(ruleTarget)) !== hashContent(rule);
37
+ const hasBaseline = existsSync(join(root, COMMENTS_BASELINE_PATH));
38
+ console.log([
39
+ 'Padrão de comentários:',
40
+ keepRule ? ` = ${COMMENTS_RULE_PATH} já existe com outro conteúdo e será mantida` : ` + ${COMMENTS_RULE_PATH}`,
41
+ ` + ${COMMENTS_HOOK_PATH}`,
42
+ ' + hook PostToolUse (Write|Edit|MultiEdit) em .claude/settings.json, sem duplicar',
43
+ hasBaseline
44
+ ? ` = ${COMMENTS_BASELINE_PATH} já existe e será mantido (a catraca só desce)`
45
+ : ` + ${COMMENTS_BASELINE_PATH} com as violações atuais do código (catraca do legado)`,
46
+ ].join(EOL));
47
+ if (!yes) {
48
+ console.log(`${EOL}Simulação — nada foi gravado. Rode de novo com --yes para instalar.`);
49
+ return 0;
50
+ }
51
+ const plan = {
52
+ files: [
53
+ ...(keepRule ? [] : [{ path: COMMENTS_RULE_PATH, content: rule }]),
54
+ { path: COMMENTS_HOOK_PATH, content: readFileSync(join(templatesDir, 'hooks', 'comentarios', 'check.mjs')) },
55
+ ],
56
+ skipped: keepRule ? [COMMENTS_RULE_PATH] : [],
57
+ items: [
58
+ ...(keepRule ? [] : [{ tipo: 'rule', id: 'comentarios', paths: [COMMENTS_RULE_PATH] }]),
59
+ { tipo: 'hook', id: 'comentarios', paths: [COMMENTS_HOOK_PATH] },
60
+ ],
61
+ autoAdded: [],
62
+ claudeMdBackup: false,
63
+ registerHooks: false,
64
+ registerComments: false,
65
+ };
66
+ executePlan(root, plan, packageInfo());
67
+ mergeSettings(root, { hooks: commentsHooksConfig() });
68
+ const legacy = ensureCommentsBaseline(root);
69
+ if (legacy !== null) {
70
+ console.log(`${EOL}Catraca gerada: ${legacy} arquivo(s) legado(s) com violações toleradas. Eles não precisam ser limpos de uma vez, só não podem piorar.`);
71
+ }
72
+ console.log(`${EOL}Padrão de comentários instalado. Versione .claude/rules/comentarios.md, .claude/hooks/comentarios/ e o .claude/settings.json.`);
73
+ return 0;
74
+ }
@@ -0,0 +1,33 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { templatesDir } from '../catalog.js';
5
+ import { hashContent } from '../install/lock.js';
6
+ export const COMMENTS_HOOK_PATH = '.claude/hooks/comentarios/check.mjs';
7
+ export const COMMENTS_BASELINE_PATH = '.claude/hooks/comentarios/baseline.json';
8
+ export const COMMENTS_RULE_PATH = '.claude/rules/comentarios.md';
9
+ /** Mesmo texto das instalações feitas pelas versões antigas do /configurar-comentarios, para o merge não duplicar. */
10
+ export const COMMENTS_HOOK_COMMAND = 'node "${CLAUDE_PROJECT_DIR}/.claude/hooks/comentarios/check.mjs"';
11
+ export function commentsHooksConfig() {
12
+ return { PostToolUse: [{ matcher: 'Write|Edit|MultiEdit', hooks: [{ type: 'command', command: COMMENTS_HOOK_COMMAND }] }] };
13
+ }
14
+ /**
15
+ * Gera a catraca do legado na primeira instalação e devolve quantos arquivos entraram nela. Devolve `null` quando
16
+ * ela já existe (regerar reabriria tolerâncias que já desceram) ou quando o `check.mjs` do projeto não é o do
17
+ * pacote, que pode não saber gerá-la.
18
+ */
19
+ export function ensureCommentsBaseline(root) {
20
+ const hook = join(root, COMMENTS_HOOK_PATH);
21
+ if (existsSync(join(root, COMMENTS_BASELINE_PATH)) || !existsSync(hook))
22
+ return null;
23
+ const packaged = readFileSync(join(templatesDir, 'hooks', 'comentarios', 'check.mjs'));
24
+ if (hashContent(readFileSync(hook)) !== hashContent(packaged))
25
+ return null;
26
+ const result = spawnSync(process.execPath, [hook, '--gerar-baseline'], {
27
+ env: { ...process.env, CLAUDE_PROJECT_DIR: root },
28
+ encoding: 'utf8',
29
+ });
30
+ if (result.status !== 0)
31
+ throw new Error(`Falha ao gerar a catraca do legado: ${result.stderr}`);
32
+ return Object.keys(JSON.parse(readFileSync(join(root, COMMENTS_BASELINE_PATH), 'utf8'))).length;
33
+ }
@@ -0,0 +1,99 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ export const CONFIG_PATH = join('.ai-harness', 'config.json');
4
+ export const CONFIG_VERSION = 1;
5
+ export const HOOK_MODES = ['aviso', 'bloqueio', 'desligado'];
6
+ /** Hooks de segurança nascem em bloqueio; hooks de método nascem em aviso (D-13). */
7
+ export const DEFAULT_HOOK_MODES = {
8
+ lembrete: 'aviso',
9
+ 'segredo-no-commit': 'bloqueio',
10
+ 'task-so-fecha-verde': 'aviso',
11
+ 'proteger-contratos': 'aviso',
12
+ 'sem-jargao': 'aviso',
13
+ };
14
+ function isObject(value) {
15
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
16
+ }
17
+ export function defaultConfig() {
18
+ return {
19
+ config_versao: CONFIG_VERSION,
20
+ hooks: { ...DEFAULT_HOOK_MODES },
21
+ rastro: { versionar: false },
22
+ bloqueios: { dias_para_aviso: 7 },
23
+ };
24
+ }
25
+ /** Lê a config sobre os padrões; valores inválidos são ignorados e viram problema, nunca erro. */
26
+ export function readConfig(root) {
27
+ const config = defaultConfig();
28
+ const file = join(root, CONFIG_PATH);
29
+ if (!existsSync(file))
30
+ return { config, problems: [] };
31
+ let raw;
32
+ try {
33
+ raw = JSON.parse(readFileSync(file, 'utf8'));
34
+ }
35
+ catch {
36
+ return { config, problems: [`O ${CONFIG_PATH} está com JSON inválido; valem os padrões.`] };
37
+ }
38
+ if (!isObject(raw))
39
+ return { config, problems: [`O ${CONFIG_PATH} precisa ser um objeto JSON; valem os padrões.`] };
40
+ const problems = [];
41
+ if (isObject(raw.hooks)) {
42
+ for (const [hook, mode] of Object.entries(raw.hooks)) {
43
+ if (HOOK_MODES.includes(mode))
44
+ config.hooks[hook] = mode;
45
+ else
46
+ problems.push(`Modo "${String(mode)}" inválido para o hook ${hook} em ${CONFIG_PATH}; use aviso, bloqueio ou desligado.`);
47
+ }
48
+ }
49
+ if (isObject(raw.rastro) && 'versionar' in raw.rastro) {
50
+ if (typeof raw.rastro.versionar === 'boolean')
51
+ config.rastro.versionar = raw.rastro.versionar;
52
+ else
53
+ problems.push(`rastro.versionar deve ser true ou false em ${CONFIG_PATH}.`);
54
+ }
55
+ if (isObject(raw.bloqueios) && 'dias_para_aviso' in raw.bloqueios) {
56
+ const days = raw.bloqueios.dias_para_aviso;
57
+ if (typeof days === 'number' && Number.isInteger(days) && days > 0)
58
+ config.bloqueios.dias_para_aviso = days;
59
+ else
60
+ problems.push(`bloqueios.dias_para_aviso deve ser um inteiro positivo em ${CONFIG_PATH}.`);
61
+ }
62
+ return { config, problems };
63
+ }
64
+ /** Cria a config com os padrões; retorna false se já existia (nunca sobrescreve). */
65
+ export function writeDefaultConfig(root) {
66
+ const file = join(root, CONFIG_PATH);
67
+ if (existsSync(file))
68
+ return false;
69
+ mkdirSync(dirname(file), { recursive: true });
70
+ writeFileSync(file, `${JSON.stringify(defaultConfig(), null, 2)}\n`);
71
+ return true;
72
+ }
73
+ const BLOCK_START = '# ai-harness (gerenciado pelo harness; não edite entre estas linhas)';
74
+ const BLOCK_END = '# /ai-harness';
75
+ /**
76
+ * Mantém no .gitignore um bloco próprio: a memória gerada e o estado local da sessão
77
+ * (`estado.json`) são sempre ignorados, e o rastro só quando `rastro.versionar` é false.
78
+ * Linhas fora do bloco não são tocadas.
79
+ */
80
+ export function syncGitignore(root, config) {
81
+ const file = join(root, '.gitignore');
82
+ const current = existsSync(file) ? readFileSync(file, 'utf8') : '';
83
+ const entries = ['.ai-harness/memoria/', '.ai-harness/estado.json', ...(config.rastro.versionar ? [] : ['docs/eventos/'])];
84
+ const block = [BLOCK_START, ...entries, BLOCK_END].join('\n');
85
+ const start = current.indexOf(BLOCK_START);
86
+ const end = start === -1 ? -1 : current.indexOf(BLOCK_END, start);
87
+ let next;
88
+ if (start !== -1 && end !== -1) {
89
+ next = current.slice(0, start) + block + current.slice(end + BLOCK_END.length);
90
+ }
91
+ else {
92
+ const separator = !current ? '' : current.endsWith('\n') ? '\n' : '\n\n';
93
+ next = `${current}${separator}${block}\n`;
94
+ }
95
+ if (next === current)
96
+ return { changed: false };
97
+ writeFileSync(file, next);
98
+ return { changed: true };
99
+ }
@@ -0,0 +1,284 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { isAbsolute, join, sep } from 'node:path';
3
+ import { parse, YAMLParseError } from 'yaml';
4
+ /** Versão do contrato de artefatos (docs/contratos/ARTEFATOS.md). */
5
+ export const SCHEMA_VERSION = 1;
6
+ export const WORKS_DIR = join('docs', 'trabalhos');
7
+ /** Id de trabalho: `AAAA-MM-DD-<slug>` com slug em minúsculas, sem acento. */
8
+ export const WORK_ID = /^\d{4}-\d{2}-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
9
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
10
+ const TASK_ID = /^T-\d{2}\.\d{2}$/;
11
+ /** Arquivo do trabalho → `tipo` que ele declara. */
12
+ export const ARTIFACT_TYPES = {
13
+ 'SPEC.md': 'spec',
14
+ 'PLAN.md': 'plano',
15
+ 'DECISOES.md': 'decisoes',
16
+ 'BLOQUEIOS.md': 'bloqueios',
17
+ 'relatorios/TECNICO.md': 'relatorio_tecnico',
18
+ 'relatorios/CLIENTE.md': 'relatorio_cliente',
19
+ };
20
+ export const STATUS_BY_TYPE = {
21
+ spec: ['rascunho', 'aguardando-aprovacao', 'aprovado', 'substituido'],
22
+ plano: ['rascunho', 'aguardando-aprovacao', 'em-execucao', 'concluido', 'cancelado'],
23
+ decisoes: ['ativo'],
24
+ bloqueios: ['aberto', 'resolvido'],
25
+ relatorio_tecnico: ['rascunho', 'final'],
26
+ relatorio_cliente: ['rascunho', 'final'],
27
+ };
28
+ export const WORK_TYPES = ['feature', 'bug', 'investigacao', 'auditoria'];
29
+ export const TASK_STATUS = ['pendente', 'em-andamento', 'concluida', 'bloqueada', 'pulada'];
30
+ const REQUIRED_BY_WORK_TYPE = {
31
+ feature: ['SPEC.md', 'PLAN.md'],
32
+ bug: ['PLAN.md'],
33
+ investigacao: ['relatorios/TECNICO.md'],
34
+ auditoria: ['relatorios/TECNICO.md', 'relatorios/CLIENTE.md'],
35
+ };
36
+ const COMMON_KEYS = ['schema_versao', 'tipo', 'id', 'trabalho_tipo', 'titulo', 'status', 'criado_em', 'atualizado_em', 'arquivos'];
37
+ const PLAN_KEYS = ['condensado', 'auditoria_plano', 'causa_raiz', 'tasks'];
38
+ const TASK_KEYS = ['id', 'titulo', 'status', 'depende_de', 'paralelizavel', 'testes_criticos'];
39
+ const BLOCK_KEYS = ['id', 'task', 'status', 'aberto_em', 'resolvido_em', 'decisao'];
40
+ function isData(value) {
41
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
42
+ }
43
+ function isStringList(value) {
44
+ return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
45
+ }
46
+ /** Lê o frontmatter de um artefato; `data` ausente quando há problema de leitura. */
47
+ export function readArtifact(file, content) {
48
+ const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(content);
49
+ if (!match) {
50
+ return {
51
+ problems: [{ arquivo: file, regra: 'frontmatter-ausente', mensagem: 'O artefato não começa com um frontmatter YAML entre ---.', linha: 1 }],
52
+ };
53
+ }
54
+ let data;
55
+ try {
56
+ data = parse(match[1] ?? '');
57
+ }
58
+ catch (error) {
59
+ // +1 porque a primeira linha do arquivo é o `---` de abertura.
60
+ const line = error instanceof YAMLParseError ? (error.linePos?.[0]?.line ?? 0) + 1 : null;
61
+ const reason = error instanceof Error ? error.message.split('\n')[0] : String(error);
62
+ return { problems: [{ arquivo: file, regra: 'yaml-invalido', mensagem: `YAML inválido no frontmatter: ${reason}`, linha: line }] };
63
+ }
64
+ if (!isData(data)) {
65
+ return { problems: [{ arquivo: file, regra: 'formato-invalido', mensagem: 'O frontmatter precisa ser um mapa de chaves.', linha: 2 }] };
66
+ }
67
+ return { data, problems: [] };
68
+ }
69
+ function problem(arquivo, regra, mensagem) {
70
+ return { arquivo, regra, mensagem, linha: null };
71
+ }
72
+ function missingKeys(file, data, keys, context = '') {
73
+ return keys
74
+ .filter((key) => !(key in data))
75
+ .map((key) => problem(file, 'chave-ausente', `Chave obrigatória ausente${context}: ${key}.`));
76
+ }
77
+ function validateCommon(file, data, expectedType, folderId) {
78
+ const problems = missingKeys(file, data, COMMON_KEYS);
79
+ if (typeof data.schema_versao === 'number' && data.schema_versao > SCHEMA_VERSION) {
80
+ problems.push(problem(file, 'schema-futuro', `schema_versao ${data.schema_versao} é mais nova que a suportada (${SCHEMA_VERSION}); atualize o ai-harness.`));
81
+ }
82
+ else if ('schema_versao' in data && data.schema_versao !== SCHEMA_VERSION) {
83
+ problems.push(problem(file, 'formato-invalido', `schema_versao deve ser ${SCHEMA_VERSION}.`));
84
+ }
85
+ if ('tipo' in data && data.tipo !== expectedType) {
86
+ problems.push(problem(file, 'tipo-desconhecido', `tipo "${String(data.tipo)}" inválido para este arquivo; esperado: ${expectedType}.`));
87
+ }
88
+ if ('trabalho_tipo' in data && !WORK_TYPES.includes(String(data.trabalho_tipo))) {
89
+ problems.push(problem(file, 'tipo-desconhecido', `trabalho_tipo "${String(data.trabalho_tipo)}" desconhecido. Use: ${WORK_TYPES.join(', ')}.`));
90
+ }
91
+ const allowed = STATUS_BY_TYPE[expectedType] ?? [];
92
+ if ('status' in data && !allowed.includes(String(data.status))) {
93
+ problems.push(problem(file, 'status-invalido', `status "${String(data.status)}" inválido para ${expectedType}. Use: ${allowed.join(', ')}.`));
94
+ }
95
+ if ('id' in data && data.id !== folderId) {
96
+ problems.push(problem(file, 'id-divergente', `id "${String(data.id)}" difere do nome da pasta do trabalho (${folderId}).`));
97
+ }
98
+ if ('titulo' in data && (typeof data.titulo !== 'string' || !data.titulo.trim())) {
99
+ problems.push(problem(file, 'formato-invalido', 'titulo deve ser um texto não vazio.'));
100
+ }
101
+ for (const key of ['criado_em', 'atualizado_em']) {
102
+ if (key in data && (typeof data[key] !== 'string' || !DATE.test(data[key]))) {
103
+ problems.push(problem(file, 'formato-invalido', `${key} deve ser uma data AAAA-MM-DD.`));
104
+ }
105
+ }
106
+ if ('arquivos' in data) {
107
+ if (!isStringList(data.arquivos)) {
108
+ problems.push(problem(file, 'formato-invalido', 'arquivos deve ser uma lista de caminhos.'));
109
+ }
110
+ else {
111
+ for (const path of data.arquivos) {
112
+ if (isAbsolute(path) || /^[A-Za-z]:[\\/]/.test(path)) {
113
+ problems.push(problem(file, 'caminho-absoluto', `Caminho absoluto em arquivos: ${path}. Use caminhos relativos à raiz do projeto.`));
114
+ }
115
+ }
116
+ }
117
+ }
118
+ if ('migrado' in data && typeof data.migrado !== 'boolean') {
119
+ problems.push(problem(file, 'formato-invalido', 'migrado, quando presente, deve ser true ou false.'));
120
+ }
121
+ return problems;
122
+ }
123
+ function findCycle(tasks) {
124
+ const deps = new Map(tasks.map((task) => [task.id, task.deps]));
125
+ const state = new Map();
126
+ const stack = [];
127
+ const visit = (id) => {
128
+ if (state.get(id) === 'feito')
129
+ return null;
130
+ if (state.get(id) === 'visitando')
131
+ return [...stack.slice(stack.indexOf(id)), id];
132
+ state.set(id, 'visitando');
133
+ stack.push(id);
134
+ for (const dep of deps.get(id) ?? []) {
135
+ if (!deps.has(dep))
136
+ continue;
137
+ const cycle = visit(dep);
138
+ if (cycle)
139
+ return cycle;
140
+ }
141
+ stack.pop();
142
+ state.set(id, 'feito');
143
+ return null;
144
+ };
145
+ for (const task of tasks) {
146
+ const cycle = visit(task.id);
147
+ if (cycle)
148
+ return cycle;
149
+ }
150
+ return null;
151
+ }
152
+ function validatePlan(file, data) {
153
+ const problems = missingKeys(file, data, PLAN_KEYS);
154
+ if ('condensado' in data && typeof data.condensado !== 'boolean') {
155
+ problems.push(problem(file, 'formato-invalido', 'condensado deve ser true ou false.'));
156
+ }
157
+ if ('auditoria_plano' in data && !['pendente', 'aprovado', 'reprovado'].includes(String(data.auditoria_plano))) {
158
+ problems.push(problem(file, 'status-invalido', 'auditoria_plano inválida. Use: pendente, aprovado, reprovado.'));
159
+ }
160
+ if ('causa_raiz' in data && data.causa_raiz !== null && !['hipotese', 'comprovada'].includes(String(data.causa_raiz))) {
161
+ problems.push(problem(file, 'status-invalido', 'causa_raiz inválida. Use: hipotese, comprovada ou null.'));
162
+ }
163
+ if (!('tasks' in data))
164
+ return problems;
165
+ if (!Array.isArray(data.tasks))
166
+ return [...problems, problem(file, 'formato-invalido', 'tasks deve ser uma lista.')];
167
+ const tasks = [];
168
+ data.tasks.forEach((entry, index) => {
169
+ if (!isData(entry)) {
170
+ problems.push(problem(file, 'formato-invalido', `tasks[${index}] deve ser um mapa de chaves.`));
171
+ return;
172
+ }
173
+ const label = typeof entry.id === 'string' ? entry.id : `tasks[${index}]`;
174
+ problems.push(...missingKeys(file, entry, TASK_KEYS, ` na task ${label}`));
175
+ if ('id' in entry && (typeof entry.id !== 'string' || !TASK_ID.test(entry.id))) {
176
+ problems.push(problem(file, 'formato-invalido', `id de task "${String(entry.id)}" fora do padrão T-SS.NN.`));
177
+ }
178
+ if ('status' in entry && !TASK_STATUS.includes(String(entry.status))) {
179
+ problems.push(problem(file, 'status-invalido', `status "${String(entry.status)}" inválido na task ${label}. Use: ${TASK_STATUS.join(', ')}.`));
180
+ }
181
+ if ('depende_de' in entry && !isStringList(entry.depende_de)) {
182
+ problems.push(problem(file, 'formato-invalido', `depende_de da task ${label} deve ser uma lista de ids.`));
183
+ }
184
+ if ('paralelizavel' in entry && typeof entry.paralelizavel !== 'boolean') {
185
+ problems.push(problem(file, 'formato-invalido', `paralelizavel da task ${label} deve ser true ou false.`));
186
+ }
187
+ if ('testes_criticos' in entry && !isStringList(entry.testes_criticos)) {
188
+ problems.push(problem(file, 'formato-invalido', `testes_criticos da task ${label} deve ser uma lista de textos.`));
189
+ }
190
+ tasks.push({ id: label, deps: isStringList(entry.depende_de) ? entry.depende_de : [] });
191
+ });
192
+ const ids = new Set(tasks.map((task) => task.id));
193
+ for (const task of tasks) {
194
+ for (const dep of task.deps) {
195
+ if (!ids.has(dep)) {
196
+ problems.push(problem(file, 'dependencia-inexistente', `A task ${task.id} depende de ${dep}, que não existe no plano.`));
197
+ }
198
+ }
199
+ }
200
+ const cycle = findCycle(tasks);
201
+ if (cycle)
202
+ problems.push(problem(file, 'ciclo-dependencia', `Ciclo de dependência entre tasks: ${cycle.join(' → ')}.`));
203
+ return problems;
204
+ }
205
+ function validateBlocks(file, data) {
206
+ if (!('bloqueios' in data))
207
+ return [problem(file, 'chave-ausente', 'Chave obrigatória ausente: bloqueios.')];
208
+ if (!Array.isArray(data.bloqueios))
209
+ return [problem(file, 'formato-invalido', 'bloqueios deve ser uma lista.')];
210
+ return data.bloqueios.flatMap((entry, index) => {
211
+ if (!isData(entry))
212
+ return [problem(file, 'formato-invalido', `bloqueios[${index}] deve ser um mapa de chaves.`)];
213
+ const label = typeof entry.id === 'string' ? entry.id : `bloqueios[${index}]`;
214
+ const problems = missingKeys(file, entry, BLOCK_KEYS, ` no bloqueio ${label}`);
215
+ if ('status' in entry && !['aberto', 'resolvido'].includes(String(entry.status))) {
216
+ problems.push(problem(file, 'status-invalido', `status "${String(entry.status)}" inválido no bloqueio ${label}. Use: aberto, resolvido.`));
217
+ }
218
+ return problems;
219
+ });
220
+ }
221
+ /** Arquivos da pasta do trabalho, relativos a ela, com separador `/`. */
222
+ function listWorkFiles(dir) {
223
+ return readdirSync(dir, { recursive: true, encoding: 'utf8' })
224
+ .filter((rel) => statSync(join(dir, rel)).isFile())
225
+ .map((rel) => rel.split(sep).join('/'))
226
+ .sort();
227
+ }
228
+ export function validateWork(root, folderId) {
229
+ const base = `docs/trabalhos/${folderId}`;
230
+ const problems = [];
231
+ if (!WORK_ID.test(folderId)) {
232
+ problems.push(problem(base, 'nome-fora-do-padrao', `A pasta do trabalho "${folderId}" não segue o padrão AAAA-MM-DD-<slug>.`));
233
+ }
234
+ const artifactNames = Object.keys(ARTIFACT_TYPES);
235
+ const present = new Set();
236
+ const parsed = new Map();
237
+ for (const rel of listWorkFiles(join(root, WORKS_DIR, folderId))) {
238
+ const file = `${base}/${rel}`;
239
+ const expectedType = ARTIFACT_TYPES[rel];
240
+ if (!expectedType) {
241
+ const canonical = artifactNames.find((name) => name.toLowerCase() === rel.toLowerCase());
242
+ if (canonical) {
243
+ problems.push(problem(file, 'nome-fora-do-padrao', `Os artefatos têm nome em caixa alta: renomeie ${rel} para ${canonical}.`));
244
+ }
245
+ continue;
246
+ }
247
+ present.add(rel);
248
+ const { data, problems: readProblems } = readArtifact(file, readFileSync(join(root, WORKS_DIR, folderId, rel), 'utf8'));
249
+ problems.push(...readProblems);
250
+ if (!data)
251
+ continue;
252
+ parsed.set(rel, data);
253
+ problems.push(...validateCommon(file, data, expectedType, folderId));
254
+ if (expectedType === 'plano')
255
+ problems.push(...validatePlan(file, data));
256
+ if (expectedType === 'bloqueios')
257
+ problems.push(...validateBlocks(file, data));
258
+ }
259
+ const declared = [...parsed.values()].map((data) => data.trabalho_tipo).find((tipo) => WORK_TYPES.includes(String(tipo)));
260
+ // Trabalho migrado do layout antigo não tem os artefatos que o fluxo antigo não produzia (D-37).
261
+ const migrated = [...parsed.values()].some((data) => data.migrado === true);
262
+ if (typeof declared === 'string' && !migrated) {
263
+ const required = [...(REQUIRED_BY_WORK_TYPE[declared] ?? [])];
264
+ if (declared === 'bug' && parsed.get('PLAN.md')?.status === 'concluido') {
265
+ required.push('relatorios/TECNICO.md', 'relatorios/CLIENTE.md');
266
+ }
267
+ for (const rel of required) {
268
+ if (!present.has(rel)) {
269
+ problems.push(problem(base, 'artefato-obrigatorio-ausente', `Trabalho do tipo ${declared} sem o artefato obrigatório ${rel}.`));
270
+ }
271
+ }
272
+ }
273
+ return problems;
274
+ }
275
+ /** Valida todos os trabalhos em docs/trabalhos/; projeto sem a pasta não tem problemas. */
276
+ export function validateAllWorks(root) {
277
+ const dir = join(root, WORKS_DIR);
278
+ if (!existsSync(dir))
279
+ return [];
280
+ return readdirSync(dir)
281
+ .filter((name) => statSync(join(dir, name)).isDirectory())
282
+ .sort()
283
+ .flatMap((name) => validateWork(root, name));
284
+ }