@fontdata/ai-harness 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fontdata",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "description": "Harness de engenharia com IA da Fontdata para Claude Code",
5
5
  "author": { "name": "Fontdata" },
6
6
  "commands": ["./templates/commands"],
package/README.md CHANGED
@@ -108,11 +108,12 @@ duas vezes.
108
108
  | Subcomando | O que faz |
109
109
  |---|---|
110
110
  | `init [caminho]` | Instala comandos, skills, agents e hooks no projeto |
111
- | `doctor [caminho]` | Diagnostica instalação, contrato dos documentos, rastro, bloqueios antigos e versão do Node |
111
+ | `doctor [caminho]` | Diagnostica instalação, contrato dos documentos, rastro, bloqueios antigos, citações às pastas antigas de `docs/` no código e versão do Node |
112
112
  | `update [caminho]` | Atualiza o que foi instalado sem sobrescrever alterações locais (`--check`, `--yes`, `--forcar`) |
113
- | `migrar [caminho]` | Move os documentos do layout anterior para `docs/trabalhos/<id>/` (`--yes`) |
113
+ | `migrar [caminho]` | Move os documentos do layout anterior para `docs/trabalhos/<id>/` (`--yes`); `--reclassificar` corrige planos migrados que ficaram em execução |
114
114
  | `memoria [caminho]` | Índice de quem mexeu em cada arquivo (`--arquivo <caminho>`, `--regressao <id>`) |
115
115
  | `evento <evento>` | Grava no rastro do trabalho (usado por fluxos e agents) |
116
+ | `conflitos <id>` | Aponta trabalhos em execução que mexem nos mesmos arquivos; `--aguardar` espera liberar (usado pelo `/executar-plan`) |
116
117
  | `comentarios [caminho]` | Instala o hook do padrão de comentários com a catraca do legado (`--yes`) |
117
118
  | `list` | Lista o catálogo |
118
119
 
package/dist/cli.js CHANGED
@@ -11,6 +11,7 @@ const RESERVED = new Set([
11
11
  'update',
12
12
  'migrar',
13
13
  'evento',
14
+ 'conflitos',
14
15
  'comentarios',
15
16
  'memoria',
16
17
  'list',
@@ -47,9 +48,11 @@ Gerenciar:
47
48
  doctor [caminho] Diagnostica a instalação (arquivos, lock, skills, agents, hooks, Node)
48
49
  update [caminho] Atualiza o que foi instalado: --check só lista, --yes aplica, --forcar sobrescreve conflitos
49
50
  migrar [caminho] Move docs/specs, plans, bugs e investigacoes para docs/trabalhos/<id>/ (--yes aplica)
51
+ --reclassificar: planos já migrados em em-execucao viram concluido ou legado
50
52
  comentarios [caminho] Instala o hook de comentários e a catraca do legado (--yes aplica)
51
53
  memoria [caminho] Índice de quem mexeu em cada arquivo (--arquivo <caminho>, --regressao <id do bug>)
52
54
  evento <evento> Grava no rastro do trabalho (--trabalho, --task, --nome, --resultado, --dados '{json}')
55
+ conflitos <id> Aponta trabalhos em execução que mexem nos mesmos arquivos (--aguardar espera liberar)
53
56
  list Lista todos os comandos disponíveis no catálogo
54
57
 
55
58
  Init sem perguntas (CI):
@@ -139,6 +142,10 @@ async function main() {
139
142
  const { runEventCommand } = await import('./rastro/command.js');
140
143
  process.exit(await runEventCommand(argv.slice(1)));
141
144
  }
145
+ if (cmd === 'conflitos') {
146
+ const { runConflictsCommand } = await import('./trabalhos/conflitos.js');
147
+ process.exit(await runConflictsCommand(argv.slice(1)));
148
+ }
142
149
  if (cmd === 'update') {
143
150
  const { runUpdateCommand } = await import('./update/update.js');
144
151
  process.exit(runUpdateCommand(argv.slice(1)));
@@ -73,13 +73,14 @@ export function writeDefaultConfig(root) {
73
73
  const BLOCK_START = '# ai-harness (gerenciado pelo harness; não edite entre estas linhas)';
74
74
  const BLOCK_END = '# /ai-harness';
75
75
  /**
76
- * Mantém no .gitignore um bloco próprio: a memória gerada é sempre ignorada e o
77
- * rastro só quando `rastro.versionar` é false. Linhas fora do bloco não são tocadas.
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.
78
79
  */
79
80
  export function syncGitignore(root, config) {
80
81
  const file = join(root, '.gitignore');
81
82
  const current = existsSync(file) ? readFileSync(file, 'utf8') : '';
82
- const entries = ['.ai-harness/memoria/', ...(config.rastro.versionar ? [] : ['docs/eventos/'])];
83
+ const entries = ['.ai-harness/memoria/', '.ai-harness/estado.json', ...(config.rastro.versionar ? [] : ['docs/eventos/'])];
83
84
  const block = [BLOCK_START, ...entries, BLOCK_END].join('\n');
84
85
  const start = current.indexOf(BLOCK_START);
85
86
  const end = start === -1 ? -1 : current.indexOf(BLOCK_END, start);
@@ -19,7 +19,7 @@ export const ARTIFACT_TYPES = {
19
19
  };
20
20
  export const STATUS_BY_TYPE = {
21
21
  spec: ['rascunho', 'aguardando-aprovacao', 'aprovado', 'substituido'],
22
- plano: ['rascunho', 'aguardando-aprovacao', 'em-execucao', 'concluido', 'cancelado'],
22
+ plano: ['rascunho', 'aguardando-aprovacao', 'em-execucao', 'concluido', 'cancelado', 'legado'],
23
23
  decisoes: ['ativo'],
24
24
  bloqueios: ['aberto', 'resolvido'],
25
25
  relatorio_tecnico: ['rascunho', 'final'],
@@ -8,6 +8,7 @@ import { validateEventLog } from '../contratos/eventos.js';
8
8
  import { hashContent, LOCK_PATH, LOCK_VERSION } from '../install/lock.js';
9
9
  import { SETTINGS_PATH } from '../install/settings.js';
10
10
  import { adoptLegacyInstall } from '../update/legado.js';
11
+ import { checkLegacyDocsCitations, checkMigratedPlansRunning } from './migracao.js';
11
12
  const MIN_NODE_MAJOR = 22;
12
13
  const PLUGIN_ID = 'fontdata@ai-harness';
13
14
  const HARNESS_HOOKS_DIR = '.claude/hooks/ai-harness/';
@@ -413,6 +414,8 @@ export function runDoctor(root, options = {}) {
413
414
  ...checkStaleBlocks(root, options.now ?? new Date(), options.staleBlockDays ?? config.bloqueios.dias_para_aviso),
414
415
  ...checkEventLogs(root),
415
416
  ...checkCompletedTasks(root),
417
+ ...checkMigratedPlansRunning(root),
418
+ ...checkLegacyDocsCitations(root),
416
419
  ];
417
420
  }
418
421
  export function formatFindings(findings) {
@@ -0,0 +1,97 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { extname, join } from 'node:path';
3
+ import { readArtifact, WORKS_DIR } from '../contratos/artefatos.js';
4
+ /** As mesmas extensões e pastas ignoradas do hook de comentários (templates/hooks/comentarios/check.mjs). */
5
+ const CODE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.cs', '.php', '.pas', '.dpr', '.sql', '.sh']);
6
+ const SKIPPED_DIRS = new Set(['node_modules', 'dist', 'build', 'coverage', 'out', 'vendor', 'generated', '__generated__']);
7
+ const MAX_FILE_BYTES = 1_000_000;
8
+ const LEGACY_CITATION = /\b[Dd]ocs\/(?:specs|plans|bugs|investigacoes)\//g;
9
+ const TEST_FILE = /\.(test|spec)\.[jt]sx?$/;
10
+ const COMMENT_LINE = /^\s*(\*|\(\*|\{(?!\$))/;
11
+ const COMMENT_MARKER = /\/\/|\/\*|#|--/;
12
+ const EXAMPLES_SHOWN = 5;
13
+ function codeFiles(root, dir = '', found = []) {
14
+ let entries;
15
+ try {
16
+ entries = readdirSync(join(root, dir), { withFileTypes: true });
17
+ }
18
+ catch {
19
+ return found;
20
+ }
21
+ for (const entry of entries) {
22
+ const rel = dir ? `${dir}/${entry.name}` : entry.name;
23
+ if (entry.isDirectory()) {
24
+ if (!entry.name.startsWith('.') && !SKIPPED_DIRS.has(entry.name))
25
+ codeFiles(root, rel, found);
26
+ }
27
+ else if (entry.isFile() && CODE_EXTENSIONS.has(extname(entry.name)) && !entry.name.endsWith('.d.ts') && !TEST_FILE.test(entry.name)) {
28
+ found.push(rel);
29
+ }
30
+ }
31
+ return found;
32
+ }
33
+ /**
34
+ * O `migrar` reescreve os links dos documentos, mas não toca no código: a citação às pastas antigas num comentário
35
+ * vira caminho quebrado, e a rule de comentários já proíbe citar docs/ no código.
36
+ */
37
+ export function checkLegacyDocsCitations(root) {
38
+ const places = [];
39
+ let total = 0;
40
+ for (const rel of codeFiles(root).sort()) {
41
+ const file = join(root, rel);
42
+ if (statSync(file).size > MAX_FILE_BYTES)
43
+ continue;
44
+ const lines = readFileSync(file, 'utf8').split('\n');
45
+ lines.forEach((line, index) => {
46
+ // Só a citação em comentário: a mesma string num literal de código é dado, não rastreabilidade.
47
+ const inComment = COMMENT_LINE.test(line)
48
+ ? [...line.matchAll(LEGACY_CITATION)]
49
+ : [...line.matchAll(LEGACY_CITATION)].filter((match) => COMMENT_MARKER.test(line.slice(0, match.index)));
50
+ const count = inComment.length;
51
+ if (!count)
52
+ return;
53
+ total += count;
54
+ places.push(`${rel}:${index + 1}`);
55
+ });
56
+ }
57
+ if (!total)
58
+ return [];
59
+ const files = new Set(places.map((place) => place.slice(0, place.lastIndexOf(':')))).size;
60
+ const examples = places.slice(0, EXAMPLES_SHOWN).join(', ');
61
+ const more = places.length > EXAMPLES_SHOWN ? ` e mais ${places.length - EXAMPLES_SHOWN}` : '';
62
+ return [
63
+ {
64
+ id: 'citacao-docs-em-codigo',
65
+ severidade: 'aviso',
66
+ mensagem: `${total} citação(ões) em ${files} arquivo(s) de código apontam para docs/specs, docs/plans, docs/bugs ou ` +
67
+ `docs/investigacoes, pastas que o migrar substituiu por docs/trabalhos/: ${examples}${more}.`,
68
+ correcao: 'Remova a citação do comentário (o vínculo fica no commit ou em docs/trabalhos/), como pede a rule ' +
69
+ '.claude/rules/comentarios.md. Arquivo tolerado pela catraca pode ser limpo aos poucos.',
70
+ },
71
+ ];
72
+ }
73
+ /** Planos migrados antes da 2.3.0 ficaram `em-execucao` e aparecem como ativos em todo `conflitos`. */
74
+ export function checkMigratedPlansRunning(root) {
75
+ const dir = join(root, WORKS_DIR);
76
+ if (!existsSync(dir))
77
+ return [];
78
+ const stuck = readdirSync(dir).filter((id) => {
79
+ const rel = `docs/trabalhos/${id}/PLAN.md`;
80
+ if (!existsSync(join(root, rel)))
81
+ return false;
82
+ const { data } = readArtifact(rel, readFileSync(join(root, rel), 'utf8'));
83
+ const arquivos = Array.isArray(data?.arquivos) ? data.arquivos : [];
84
+ return data?.migrado === true && data.status === 'em-execucao' && !arquivos.length;
85
+ });
86
+ if (!stuck.length)
87
+ return [];
88
+ return [
89
+ {
90
+ id: 'plano-migrado-em-execucao',
91
+ severidade: 'aviso',
92
+ mensagem: `${stuck.length} plano(s) migrado(s) do layout antigo estão como em-execucao sem declarar arquivos: ${stuck.slice(0, EXAMPLES_SHOWN).join(', ')}${stuck.length > EXAMPLES_SHOWN ? ' …' : ''}.`,
93
+ correcao: 'Rode `npx @fontdata/ai-harness migrar --reclassificar --yes`: plano sem caixa aberta vira concluido, e o ' +
94
+ 'restante vira legado, que não conta como trabalho ativo.',
95
+ },
96
+ ];
97
+ }
@@ -7,7 +7,7 @@ import { buildLockItem, LOCK_VERSION, readLock, writeLock } from './lock.js';
7
7
  import { HOOKS_ITEM_ID, PROJECT_HOOKS_DIR, projectHooksConfig } from './hooks.js';
8
8
  import { neededSkillsFor, resolveSelection } from './selection.js';
9
9
  import { COMMENTS_RULE_PATH, commentsHooksConfig, ensureCommentsBaseline } from '../comentarios/instalar.js';
10
- import { combineHooks, EVENT_PERMISSION, mergeSettings } from './settings.js';
10
+ import { combineHooks, HARNESS_PERMISSIONS, mergeSettings } from './settings.js';
11
11
  /** Rule de convenção com o que é específico do projeto (D-38): é do projeto, então fica fora do lock. */
12
12
  export const PROJECT_RULE_PATH = '.claude/rules/harness-projeto.md';
13
13
  /** Esqueleto da rule de projeto; os comandos `-legado` renomeados pelo update entram na última seção. */
@@ -162,7 +162,7 @@ export function executePlan(root, plan, pkg, now = new Date()) {
162
162
  const permission = needsEventPermission(plan);
163
163
  const hooks = combineHooks(plan.registerHooks ? projectHooksConfig() : {}, plan.registerComments ? commentsHooksConfig() : {});
164
164
  if (Object.keys(hooks).length || permission)
165
- mergeSettings(root, { hooks, allow: permission ? [EVENT_PERMISSION] : [] }, now);
165
+ mergeSettings(root, { hooks, allow: permission ? HARNESS_PERMISSIONS : [] }, now);
166
166
  if (plan.registerComments)
167
167
  ensureCommentsBaseline(root);
168
168
  const items = new Map();
@@ -201,7 +201,7 @@ export function describePlan(plan) {
201
201
  if (plan.registerHooks)
202
202
  lines.push('Hooks do harness registrados em .claude/settings.json (sem duplicar os existentes).');
203
203
  if (needsEventPermission(plan))
204
- lines.push(`Permissão ${EVENT_PERMISSION} em .claude/settings.json, para o rastro não pedir aprovação.`);
204
+ lines.push(`Permissões ${HARNESS_PERMISSIONS.join(' e ')} em .claude/settings.json, para o rastro e a checagem de conflitos não pedirem aprovação.`);
205
205
  if (plan.registerComments) {
206
206
  lines.push('Padrão de comentários: rule carregada em toda sessão, hook que bloqueia a violação e catraca do legado (gerada só na primeira instalação).');
207
207
  }
@@ -3,6 +3,10 @@ import { join } from 'node:path';
3
3
  export const SETTINGS_PATH = join('.claude', 'settings.json');
4
4
  /** As skills de fluxo e os agents registram o rastro por este comando; sem a regra, cada evento pede aprovação. */
5
5
  export const EVENT_PERMISSION = 'Bash(npx @fontdata/ai-harness evento:*)';
6
+ /** O /executar-plan checa (e aguarda) conflito de arquivos com outros trabalhos por este comando. */
7
+ export const CONFLICTS_PERMISSION = 'Bash(npx @fontdata/ai-harness conflitos:*)';
8
+ /** Regras de `permissions.allow` que o init e o update gravam para as skills e os agents do harness. */
9
+ export const HARNESS_PERMISSIONS = [EVENT_PERMISSION, CONFLICTS_PERMISSION];
6
10
  function isPlainObject(value) {
7
11
  return typeof value === 'object' && value !== null && !Array.isArray(value);
8
12
  }
@@ -179,6 +179,10 @@ function rootCauseOf(body) {
179
179
  return 'hipotese';
180
180
  return UNCONFIRMED.test(body.slice(label.index, label.index + 600)) ? 'hipotese' : 'comprovada';
181
181
  }
182
+ /** Plano antigo com caixa aberta não está necessariamente em execução: `legado` não conta como trabalho ativo. */
183
+ function legacyPlanStatus(body) {
184
+ return OPEN_BOX.test(body) ? 'legado' : 'concluido';
185
+ }
182
186
  const TASK_HEADING = /^(#{2,6})\s*(?:(?:task|tarefa)\s+|T-?)(\d+)\.(\d+)\b\s*[—–:.-]*\s*(.*)$/i;
183
187
  const TASK_ITEM = /^\s*[-*]\s+\[([ xX])\]\s+\*{0,2}(?:(?:task|tarefa)\s+|T-?)(\d+)\.(\d+)\b\s*[—–:.-]*\s*(.*)$/i;
184
188
  const ANY_HEADING = /^(#{1,6})\s/;
@@ -256,7 +260,7 @@ function contractFrontmatter(content, move, today) {
256
260
  const existing = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : {};
257
261
  const body = match ? content.slice(match[0].length) : content;
258
262
  const heading = /^#\s+(.+)$/m.exec(body)?.[1]?.trim();
259
- const status = move.tipo === 'spec' ? 'aprovado' : move.tipo === 'plano' ? (OPEN_BOX.test(body) ? 'em-execucao' : 'concluido') : 'final';
263
+ const status = move.tipo === 'spec' ? 'aprovado' : move.tipo === 'plano' ? legacyPlanStatus(body) : 'final';
260
264
  const data = {
261
265
  schema_versao: SCHEMA_VERSION,
262
266
  tipo: move.tipo,
@@ -366,18 +370,83 @@ export function applyMigration(root, plan) {
366
370
  for (const dir of SOURCE_DIRS)
367
371
  removeLegacyDir(root, `docs/${dir}`, git);
368
372
  }
369
- const MIGRATION_FLAGS = ['--yes', '-y', '--sim'];
373
+ const FRONTMATTER = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
374
+ /**
375
+ * Planos migrados antes da 2.3.0 ficaram `em-execucao` quando tinham caixa aberta. Os que não declaram `arquivos`
376
+ * ganham o status que a migração daria hoje; quem preencheu `arquivos` está trabalhando neles e fica como está.
377
+ */
378
+ export function planReclassification(root) {
379
+ const dir = join(root, 'docs', 'trabalhos');
380
+ if (!existsSync(dir))
381
+ return [];
382
+ const found = [];
383
+ for (const id of readdirSync(dir).sort()) {
384
+ const file = join(dir, id, 'PLAN.md');
385
+ if (!existsSync(file))
386
+ continue;
387
+ const content = readFileSync(file, 'utf8');
388
+ const match = FRONTMATTER.exec(content);
389
+ if (!match)
390
+ continue;
391
+ let data;
392
+ try {
393
+ data = parse(match[1] ?? '');
394
+ }
395
+ catch {
396
+ continue;
397
+ }
398
+ if (typeof data !== 'object' || data === null)
399
+ continue;
400
+ const { migrado, status, arquivos } = data;
401
+ if (migrado !== true || status !== 'em-execucao' || (Array.isArray(arquivos) && arquivos.length))
402
+ continue;
403
+ found.push({ id, de: status, para: legacyPlanStatus(content.slice(match[0].length)) });
404
+ }
405
+ return found;
406
+ }
407
+ /** Troca só a linha `status:` do frontmatter, para não reformatar o resto do arquivo. */
408
+ export function applyReclassification(root, changes) {
409
+ for (const change of changes) {
410
+ const file = join(root, 'docs', 'trabalhos', change.id, 'PLAN.md');
411
+ const content = readFileSync(file, 'utf8');
412
+ const match = FRONTMATTER.exec(content);
413
+ if (!match)
414
+ continue;
415
+ const frontmatter = match[0].replace(/^status:[ \t]*['"]?em-execucao['"]?[ \t]*$/m, `status: ${change.para}`);
416
+ writeFileSync(file, frontmatter + content.slice(match[0].length));
417
+ }
418
+ }
419
+ function runReclassification(root, yes) {
420
+ const changes = planReclassification(root);
421
+ if (!changes.length) {
422
+ console.log('Nenhum plano migrado sem `arquivos` está como em-execucao: nada a reclassificar.');
423
+ return 0;
424
+ }
425
+ console.log([`Planos migrados a reclassificar (${changes.length}):`, ...changes.map((change) => ` ${change.id}: ${change.de} → ${change.para}`)].join('\n'));
426
+ if (!yes) {
427
+ console.log('\nSimulação — nada foi alterado. Rode de novo com --yes para reclassificar.');
428
+ return 0;
429
+ }
430
+ applyReclassification(root, changes);
431
+ console.log(`\nReclassificados: ${changes.length} plano(s).`);
432
+ return 0;
433
+ }
434
+ const MIGRATION_FLAGS = ['--yes', '-y', '--sim', '--reclassificar'];
370
435
  const REFERENCES_SHOWN = 40;
371
- /** `ai-harness migrar [caminho] [--yes]`; sem `--yes` só simula. */
436
+ /** `ai-harness migrar [caminho] [--reclassificar] [--yes]`; sem `--yes` só simula. */
372
437
  export function runMigrationCommand(argv) {
373
438
  let path;
374
439
  let yes = false;
440
+ let reclassify = false;
375
441
  for (const token of argv) {
376
442
  if (!token.startsWith('-')) {
377
443
  if (path !== undefined)
378
444
  throw new Error(`Argumento inesperado: ${token}`);
379
445
  path = token;
380
446
  }
447
+ else if (token === '--reclassificar') {
448
+ reclassify = true;
449
+ }
381
450
  else if (MIGRATION_FLAGS.includes(token)) {
382
451
  yes = true;
383
452
  }
@@ -389,6 +458,8 @@ export function runMigrationCommand(argv) {
389
458
  const root = resolve(process.cwd(), path ?? '.');
390
459
  if (!existsSync(root))
391
460
  throw new Error(`Pasta não encontrada: ${root}`);
461
+ if (reclassify)
462
+ return runReclassification(root, yes);
392
463
  const plan = planMigration(root);
393
464
  if (!plan.moves.length && !plan.conflicts.length) {
394
465
  const ignored = plan.ignored.length ? `\nIgnorados: ${plan.ignored.join(', ')}` : '';
@@ -0,0 +1,117 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { setTimeout as sleep } from 'node:timers/promises';
4
+ import { readArtifact } from '../contratos/artefatos.js';
5
+ import { suggestFlag } from '../install/flags.js';
6
+ import { loadRastroLib } from '../rastro/command.js';
7
+ const FINISHED = new Set(['concluido', 'cancelado']);
8
+ const normalize = (path) => path.trim().replace(/^\.\//, '').replace(/\/+$/, '');
9
+ /** Mesmo arquivo, ou um dos caminhos é uma pasta que contém o outro. */
10
+ function overlaps(a, b) {
11
+ return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
12
+ }
13
+ function readPlan(root, workId) {
14
+ const rel = `docs/trabalhos/${workId}/PLAN.md`;
15
+ const file = join(root, rel);
16
+ if (!existsSync(file))
17
+ return null;
18
+ const { data } = readArtifact(rel, readFileSync(file, 'utf8'));
19
+ if (!data)
20
+ return null;
21
+ const listed = Array.isArray(data.arquivos) ? data.arquivos : [];
22
+ const arquivos = listed.filter((entry) => typeof entry === 'string').map(normalize).filter(Boolean);
23
+ return { status: String(data.status ?? ''), arquivos, migrado: data.migrado === true };
24
+ }
25
+ /**
26
+ * Trabalhos em execução (PLAN.md `em-execucao` ou listados no estado.json) que declaram arquivos em comum
27
+ * com o plano do trabalho informado. Várias sessões executam trabalhos na mesma pasta; dois trabalhos
28
+ * mexendo no mesmo código ao mesmo tempo quebram os testes um do outro.
29
+ */
30
+ export async function findConflicts(root, workId) {
31
+ const own = readPlan(root, workId);
32
+ if (!own)
33
+ throw new Error(`Não há docs/trabalhos/${workId}/PLAN.md com frontmatter legível.`);
34
+ const listed = new Set((await loadRastroLib()).findActiveWorks(root));
35
+ const report = { arquivos: own.arquivos, conflitos: [], semArquivos: [] };
36
+ for (const id of readdirSync(join(root, 'docs', 'trabalhos'))) {
37
+ if (id === workId)
38
+ continue;
39
+ const plan = readPlan(root, id);
40
+ if (!plan || FINISHED.has(plan.status) || (plan.status !== 'em-execucao' && !listed.has(id)))
41
+ continue;
42
+ if (!plan.arquivos.length) {
43
+ // Plano migrado do layout antigo não diz o que tocou; só avisa quando alguém o está executando de fato.
44
+ if (!plan.migrado || listed.has(id))
45
+ report.semArquivos.push(id);
46
+ continue;
47
+ }
48
+ const shared = own.arquivos.filter((a) => plan.arquivos.some((b) => overlaps(a, b)));
49
+ if (shared.length)
50
+ report.conflitos.push({ trabalho: id, arquivos: shared });
51
+ }
52
+ return report;
53
+ }
54
+ export function formatConflicts(workId, report) {
55
+ const lines = [];
56
+ if (!report.arquivos.length) {
57
+ lines.push(`O PLAN.md de ${workId} não declara arquivos em \`arquivos\`: sem eles não há como detectar conflito com outros trabalhos.`);
58
+ }
59
+ for (const id of report.semArquivos) {
60
+ lines.push(`O trabalho ${id} está em execução, mas o PLAN.md dele não declara arquivos: não há como comparar com ele.`);
61
+ }
62
+ if (!report.conflitos.length) {
63
+ lines.push(`Nenhum trabalho em execução mexe nos arquivos de ${workId}.`);
64
+ return lines.join('\n');
65
+ }
66
+ lines.push(`${workId} mexe em arquivos que estão em uso por outro trabalho em execução:`, ...report.conflitos.map((conflict) => ` - ${conflict.trabalho}: ${conflict.arquivos.join(', ')}`), `Aguarde a conclusão (npx @fontdata/ai-harness conflitos ${workId} --aguardar). Se um desses trabalhos foi ` +
67
+ 'abandonado, troque o status do PLAN.md dele para `cancelado`.');
68
+ return lines.join('\n');
69
+ }
70
+ /** Espera até nenhum trabalho em execução mexer nos mesmos arquivos. */
71
+ export async function waitForFreeFiles(root, workId, options) {
72
+ const wait = options.wait ?? sleep;
73
+ let previous = '';
74
+ for (;;) {
75
+ const report = await findConflicts(root, workId);
76
+ if (!report.conflitos.length)
77
+ return report;
78
+ const holders = report.conflitos.map((conflict) => conflict.trabalho).join(', ');
79
+ if (holders !== previous)
80
+ options.log(`Aguardando ${holders} concluir para liberar os arquivos de ${workId}...`);
81
+ previous = holders;
82
+ await wait(options.intervalMs);
83
+ }
84
+ }
85
+ const FLAGS = ['--aguardar', '--intervalo'];
86
+ /** `ai-harness conflitos <id> [--aguardar] [--intervalo <segundos>]` — sai com 1 quando há conflito sem --aguardar. */
87
+ export async function runConflictsCommand(argv, root = process.cwd()) {
88
+ const [workId, ...rest] = argv;
89
+ if (!workId || workId.startsWith('-')) {
90
+ throw new Error('Informe o id do trabalho. Ex.: npx @fontdata/ai-harness conflitos 2026-09-18-login --aguardar');
91
+ }
92
+ let waitMode = false;
93
+ let intervalMs = 30_000;
94
+ for (let i = 0; i < rest.length; i++) {
95
+ const flag = rest[i] ?? '';
96
+ if (flag === '--aguardar')
97
+ waitMode = true;
98
+ else if (flag === '--intervalo') {
99
+ const seconds = Number(rest[++i]);
100
+ if (!Number.isFinite(seconds) || seconds <= 0)
101
+ throw new Error('--intervalo precisa de um número de segundos maior que zero.');
102
+ intervalMs = seconds * 1000;
103
+ }
104
+ else {
105
+ const suggestion = suggestFlag(flag, FLAGS);
106
+ throw new Error(`Flag desconhecida: ${flag}.${suggestion ? ` Você quis dizer ${suggestion}?` : ''}`);
107
+ }
108
+ }
109
+ if (!waitMode) {
110
+ const report = await findConflicts(root, workId);
111
+ console.log(formatConflicts(workId, report));
112
+ return report.conflitos.length ? 1 : 0;
113
+ }
114
+ const report = await waitForFreeFiles(root, workId, { intervalMs, log: (line) => console.log(line) });
115
+ console.log(formatConflicts(workId, report));
116
+ return 0;
117
+ }
@@ -9,7 +9,7 @@ import { HOOKS_ITEM_ID, isHarnessPluginEnabled, projectHooksConfig } from '../in
9
9
  import { hashContent, LOCK_PATH, LOCK_VERSION, readLock, writeLock } from '../install/lock.js';
10
10
  import { itemSources, packageInfo, PROJECT_RULE_PATH, projectRuleContent } from '../install/plan.js';
11
11
  import { neededSkillsFor } from '../install/selection.js';
12
- import { combineHooks, EVENT_PERMISSION, hasAllowRule, mergeSettings } from '../install/settings.js';
12
+ import { combineHooks, HARNESS_PERMISSIONS, hasAllowRule, mergeSettings } from '../install/settings.js';
13
13
  import { adoptLegacyInstall } from './legado.js';
14
14
  /** `<id>-legado.md`, com sufixo numérico quando o nome já está em uso. */
15
15
  function legacyPath(root, path) {
@@ -115,7 +115,7 @@ export function checkUpdate(root, catalog = loadCatalog(), legacy, home) {
115
115
  adotada,
116
116
  registrarHooks: adotada && !pluginHooks,
117
117
  registrarComentarios: adotada,
118
- permissaoAusente: !hasAllowRule(root, EVENT_PERMISSION),
118
+ permissaoAusente: !HARNESS_PERMISSIONS.every((rule) => hasAllowRule(root, rule)),
119
119
  };
120
120
  }
121
121
  /**
@@ -154,7 +154,7 @@ export function applyUpdate(root, report, options, pkg, now = new Date()) {
154
154
  return { de: path, para };
155
155
  });
156
156
  const hooks = combineHooks(report.registrarHooks ? projectHooksConfig() : {}, report.registrarComentarios ? commentsHooksConfig() : {});
157
- mergeSettings(root, { hooks, allow: [EVENT_PERMISSION] }, now);
157
+ mergeSettings(root, { hooks, allow: HARNESS_PERMISSIONS }, now);
158
158
  if (report.registrarComentarios)
159
159
  ensureCommentsBaseline(root);
160
160
  if (report.adotada) {
@@ -220,7 +220,7 @@ export function describeUpdate(report) {
220
220
  'a rule de projeto e a permissão do `ai-harness evento`.',
221
221
  ]
222
222
  : [];
223
- const permission = report.permissaoAusente && !report.adotada ? [`Falta a permissão ${EVENT_PERMISSION} em .claude/settings.json; o --yes a acrescenta.`] : [];
223
+ const permission = report.permissaoAusente && !report.adotada ? [`Faltam permissões do harness (${HARNESS_PERMISSIONS.join(', ')}) em .claude/settings.json; o --yes as acrescenta.`] : [];
224
224
  if (!report.changes.length) {
225
225
  const status = adopted.length || permission.length ? 'Nenhum arquivo a atualizar.' : 'A instalação está em dia: nenhum arquivo a atualizar.';
226
226
  return [...adopted, ...permission, status].join('\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fontdata/ai-harness",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "description": "Harness de engenharia com IA da Fontdata para Claude Code: roda os slash commands direto na pasta do projeto via npx, sem instalar nada — ou instala-os em .claude/commands.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,8 @@ ninguém pergunte nada no meio. Você não edita o plano: aponta o que precisa m
22
22
  4. **Critérios de aceite binários:** cada critério pode ser verificado com sim ou não, por comando ou observação.
23
23
  5. **Dependências:** toda task que usa algo criado por outra declara `depende_de`; sem ciclos.
24
24
  6. **Paralelismo:** tasks `paralelizavel: true` da mesma fase não alteram os mesmos arquivos.
25
+ O `arquivos` do frontmatter lista tudo o que as tasks vão criar ou alterar: é por ele que o `/executar-plan`
26
+ impede dois trabalhos em paralelo de mexerem no mesmo código. Lista vazia num plano com código é BLOQUEANTE.
25
27
  7. **Tamanho:** cada task cabe numa sessão de trabalho; task grande demais é dividida.
26
28
  8. **Legado e riscos:** áreas de código legado tocadas estão identificadas, com o padrão local a preservar.
27
29
 
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'node:fs';
3
- import { extname, relative, join, dirname } from 'node:path';
2
+ import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, openSync, closeSync, renameSync, rmSync, statSync } from 'node:fs';
3
+ import { extname, relative, join, dirname, sep } from 'node:path';
4
4
 
5
5
  const MAX_BLOCO = Number(process.env.COMENTARIOS_MAX_BLOCO ?? 3);
6
6
  const MAX_DOCSTRING = Number(process.env.COMENTARIOS_MAX_DOCSTRING ?? 5);
@@ -21,14 +21,18 @@ const IGNORAR = [
21
21
  ];
22
22
 
23
23
  const CABECALHO_PERMITIDO =
24
- /eslint|ts-nocheck|ts-ignore|prettier|@license|copyright|spdx|jshint|globals|^#!|use (strict|client|server)/i;
24
+ /eslint|ts-nocheck|ts-check|ts-ignore|prettier|@license|copyright|spdx|jshint|globals|^#!|use (strict|client|server)/i;
25
25
 
26
26
  const BANNER = /^[-=─-╿_*#~+]{3,}|[-=─-╿_*#~+]{3,}$/;
27
27
 
28
+ // Só a palavra ignora caixa: com a flag `i`, o romano casaria o "i" de "specialty".
29
+ const semCaixa = (texto) => texto.replace(/\p{L}/gu, (c) => `[${c.toLowerCase()}${c.toUpperCase()}]`);
30
+ const PROCESSO = new RegExp(`\\b(${semCaixa('task|tarefa|fase|etapa|plano|spec|sprint|epico|épico')})\\s*(\\d|[IVX]+\\b)`, 'u');
31
+
28
32
  const RASTREIO = [
29
- [/\b(task|tarefa|fase|etapa|plano|spec|sprint|epico|épico)\s*[\dIVX]/i, 'referência a task/fase/plano/spec'],
33
+ [PROCESSO, 'referência a task/fase/plano/spec'],
30
34
  [/\bT\d+(\.\d+)+\b/, 'referência a task (T0.0)'],
31
- [/docs\/(plans|specs|bugs|investigacoes)\//i, 'caminho de docs/ dentro do código'],
35
+ [/docs\/(plans|specs|bugs|investigacoes|trabalhos)\//i, 'caminho de docs/ dentro do código'],
32
36
  [/\bachado d[oe]\b/i, 'achado de review no código'],
33
37
  [/@[\w-]*(reviewer|auditor|orquestrador|agent)\b/i, 'menção a agent no código'],
34
38
  [/\b(commit|sha|hash)\s+[0-9a-f]{7,40}\b/i, 'hash de commit'],
@@ -121,12 +125,47 @@ function analisar(caminho, fonte) {
121
125
  return { achados, comentadas, uteis, densidade: uteis ? comentadas / uteis : 0 };
122
126
  }
123
127
 
128
+ const raiz = process.env.CLAUDE_PROJECT_DIR || process.cwd();
129
+ const BASELINE = join(raiz, '.claude', 'hooks', 'comentarios', 'baseline.json');
130
+
131
+ // Pasta oculta na raiz (.claude, .ai-harness, .vscode...) é de ferramenta, não do projeto.
132
+ function emPastaOculta(caminho) {
133
+ const rel = relative(raiz, caminho);
134
+ if (!rel || rel.startsWith('..')) return false;
135
+ return rel.split(sep).slice(0, -1).some((parte) => parte.startsWith('.'));
136
+ }
137
+
124
138
  function elegivel(caminho) {
125
- return EXTENSOES.has(extname(caminho)) && !IGNORAR.some((re) => re.test(caminho));
139
+ return EXTENSOES.has(extname(caminho)) && !IGNORAR.some((re) => re.test(caminho)) && !emPastaOculta(caminho);
126
140
  }
127
141
 
128
- const raiz = process.env.CLAUDE_PROJECT_DIR || process.cwd();
129
- const BASELINE = join(raiz, '.claude', 'hooks', 'comentarios', 'baseline.json');
142
+ function gravarAtomico(arquivo, conteudo) {
143
+ const temp = `${arquivo}.${process.pid}.tmp`;
144
+ writeFileSync(temp, conteudo);
145
+ renameSync(temp, arquivo);
146
+ }
147
+
148
+ const esperar = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
149
+
150
+ // Vários agents editam em paralelo: a trava serializa o reler-e-gravar, e cada um mexe só na própria chave.
151
+ function atualizarBaseline(chave, valor) {
152
+ const trava = `${BASELINE}.lock`;
153
+ let travado = false;
154
+ for (let tentativa = 0; tentativa < 300 && !travado; tentativa++) {
155
+ try { closeSync(openSync(trava, 'wx')); travado = true; } catch {
156
+ try { if (Date.now() - statSync(trava).mtimeMs > 10_000) rmSync(trava, { force: true }); } catch {}
157
+ if (!travado) esperar(10);
158
+ }
159
+ }
160
+ try {
161
+ const atual = JSON.parse(readFileSync(BASELINE, 'utf8'));
162
+ if (valor === 0) delete atual[chave];
163
+ else atual[chave] = Math.min(valor, atual[chave] ?? valor);
164
+ gravarAtomico(BASELINE, `${JSON.stringify(atual, null, 2)}\n`);
165
+ } catch {} finally {
166
+ if (travado) rmSync(trava, { force: true });
167
+ }
168
+ }
130
169
 
131
170
  function varrer(dir, saida = []) {
132
171
  let itens;
@@ -134,7 +173,7 @@ function varrer(dir, saida = []) {
134
173
  for (const item of itens) {
135
174
  const cheio = join(dir, item.name);
136
175
  if (item.isDirectory()) {
137
- if (item.name === 'node_modules' || item.name === '.git') continue;
176
+ if (item.name === 'node_modules' || item.name.startsWith('.')) continue;
138
177
  if (IGNORAR.some((re) => re.test(`${cheio}/`))) continue;
139
178
  varrer(cheio, saida);
140
179
  } else if (elegivel(cheio)) saida.push(cheio);
@@ -153,7 +192,7 @@ if (process.argv.includes('--gerar-baseline')) {
153
192
  if (achados.length > 0) { mapa[relative(raiz, f)] = achados.length; arquivos++; violacoes += achados.length; }
154
193
  }
155
194
  if (!existsSync(dirname(BASELINE))) mkdirSync(dirname(BASELINE), { recursive: true });
156
- writeFileSync(BASELINE, `${JSON.stringify(mapa, null, 2)}\n`);
195
+ gravarAtomico(BASELINE, `${JSON.stringify(mapa, null, 2)}\n`);
157
196
  console.log(`[comentarios] baseline gravado: ${arquivos} arquivos legados, ${violacoes} violações toleradas.`);
158
197
  console.log('[comentarios] arquivo novo responde ao padrão cheio; legado só não pode piorar.');
159
198
  process.exit(0);
@@ -179,9 +218,7 @@ const tolerado = baseline?.[chave] ?? 0;
179
218
 
180
219
  if (achados.length <= tolerado) {
181
220
  if (baseline && tolerado > 0 && achados.length < tolerado) {
182
- if (achados.length === 0) delete baseline[chave];
183
- else baseline[chave] = achados.length;
184
- try { writeFileSync(BASELINE, `${JSON.stringify(baseline, null, 2)}\n`); } catch {}
221
+ atualizarBaseline(chave, achados.length);
185
222
  console.error(`[comentarios] ${rel}: ${tolerado} → ${achados.length} violações. Catraca desceu — não pode mais subir.`);
186
223
  } else if (achados.length === 0 && densidade > DENSIDADE_ALVO) {
187
224
  console.error(`[comentarios] aviso: ${rel} tem ${(densidade * 100).toFixed(0)}% de linhas de comentário (${comentadas}/${uteis}), alvo <= ${DENSIDADE_ALVO * 100}%.`);
@@ -60,33 +60,50 @@ export function buildEvent(fields) {
60
60
  }
61
61
 
62
62
  /**
63
- * Trabalho ativo: o `trabalho_ativo` do .ai-harness/estado.json ou, sem ele,
64
- * o único PLAN.md com `status: em-execucao`. Ambíguo ou ausente, retorna null.
63
+ * Trabalhos ativos: os `trabalhos_ativos` do .ai-harness/estado.json (ou o `trabalho_ativo` do formato
64
+ * anterior) ou, sem eles, os PLAN.md com `status: em-execucao` (menos os migrados sem `arquivos`). Várias sessões podem executar
65
+ * trabalhos diferentes na mesma pasta, então a lista pode ter mais de um id.
65
66
  * @param {string} root
66
- * @returns {string | null}
67
+ * @returns {string[]}
67
68
  */
68
- export function findActiveWork(root) {
69
+ export function findActiveWorks(root) {
69
70
  const statePath = join(root, '.ai-harness', 'estado.json');
70
71
  if (existsSync(statePath)) {
71
72
  try {
72
- const active = JSON.parse(readFileSync(statePath, 'utf8')).trabalho_ativo;
73
- if (typeof active === 'string' && active) return active;
73
+ const state = JSON.parse(readFileSync(statePath, 'utf8'));
74
+ /** @type {unknown[]} */
75
+ const listed = Array.isArray(state.trabalhos_ativos) ? state.trabalhos_ativos : [state.trabalho_ativo];
76
+ const active = [...new Set(listed.filter(/** @returns {id is string} */ (id) => typeof id === 'string' && id !== ''))];
77
+ if (active.length) return active;
74
78
  } catch {
75
79
  // estado ilegível: segue para os planos.
76
80
  }
77
81
  }
78
82
 
79
83
  const worksDir = join(root, 'docs', 'trabalhos');
80
- if (!existsSync(worksDir)) return null;
84
+ if (!existsSync(worksDir)) return [];
81
85
 
82
- const running = readdirSync(worksDir).filter((id) => {
86
+ return readdirSync(worksDir).filter((id) => {
83
87
  const plan = join(worksDir, id, 'PLAN.md');
84
88
  if (!existsSync(plan)) return false;
85
89
  const frontmatter = /^\s*---\r?\n([\s\S]*?)\r?\n---/.exec(readFileSync(plan, 'utf8'))?.[1] ?? '';
86
90
  // Só o status do plano, na coluna zero; os status das tasks ficam indentados.
87
- return /^status:\s*['"]?em-execucao['"]?\s*$/m.test(frontmatter);
91
+ if (!/^status:\s*['"]?em-execucao['"]?\s*$/m.test(frontmatter)) return false;
92
+ // Plano migrado do layout antigo sem `arquivos` ficou em-execucao pela migração, não por alguém executá-lo.
93
+ const migradoSemArquivos = /^migrado:\s*true\s*$/m.test(frontmatter) && !/^arquivos:[ \t]*(\[[ \t]*[^\]\s]|\r?\n[ \t]*-\s)/m.test(frontmatter);
94
+ return !migradoSemArquivos;
88
95
  });
89
- return running.length === 1 ? (running[0] ?? null) : null;
96
+ }
97
+
98
+ /**
99
+ * O trabalho ao qual atribuir um evento sem `--trabalho`: o único ativo. Com nenhum ou vários, retorna
100
+ * null e o evento vai para _geral.jsonl, para não cair no rastro do trabalho errado.
101
+ * @param {string} root
102
+ * @returns {string | null}
103
+ */
104
+ export function findActiveWork(root) {
105
+ const active = findActiveWorks(root);
106
+ return active.length === 1 ? (active[0] ?? null) : null;
90
107
  }
91
108
 
92
109
  /**
@@ -1,13 +1,14 @@
1
1
  // @ts-check
2
- // Hook PreToolUse (Edit/Write): durante a execução de um trabalho, SPEC, PLAN e CLAUDE.md não são reescritos.
2
+ // Hook PreToolUse (Edit/Write): durante a execução de um trabalho, o SPEC e o PLAN dele não são reescritos,
3
+ // nem o CLAUDE.md e o SPEC.md da raiz enquanto houver algum trabalho em execução.
3
4
  // Atualizar só o status das tasks (e a data de atualização) continua permitido.
4
- import { existsSync, readFileSync } from 'node:fs';
5
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
5
6
  import { join } from 'node:path';
6
7
  import { runHook } from './lib/hook.mjs';
7
- import { findActiveWork } from './lib/rastro.mjs';
8
8
  import { relativeToRoot, workEvents } from './lib/trabalho.mjs';
9
9
 
10
- const PROTECTED = [/^CLAUDE\.md$/, /^SPEC\.md$/, /^docs\/trabalhos\/[^/]+\/(SPEC|PLAN)\.md$/];
10
+ const WORK_CONTRACT = /^docs\/trabalhos\/([^/]+)\/(SPEC|PLAN)\.md$/;
11
+ const PROTECTED = [/^CLAUDE\.md$/, /^SPEC\.md$/, WORK_CONTRACT];
11
12
  const ALLOWED_LINE = /^\s*(-\s+)?(status|atualizado_em):/;
12
13
 
13
14
  /** @param {unknown} text */
@@ -18,6 +19,30 @@ function withoutAllowedLines(text) {
18
19
  .join('\n');
19
20
  }
20
21
 
22
+ /**
23
+ * O trabalho tem task iniciada no rastro e ainda não foi concluído?
24
+ * @param {string} root
25
+ * @param {string} workId
26
+ */
27
+ function isRunning(root, workId) {
28
+ const events = workEvents(root, workId);
29
+ return events !== null && events.some((event) => event.evento === 'task_iniciada') && !events.some((event) => event.evento === 'trabalho_concluido');
30
+ }
31
+
32
+ /**
33
+ * Trabalhos em execução afetados pelo arquivo: o dono do SPEC/PLAN pelo caminho ou, para os contratos
34
+ * da raiz, todos os que estão em execução.
35
+ * @param {string} root
36
+ * @param {string} rel
37
+ */
38
+ function runningWorks(root, rel) {
39
+ const owner = WORK_CONTRACT.exec(rel)?.[1];
40
+ if (owner) return isRunning(root, owner) ? [owner] : [];
41
+ const worksDir = join(root, 'docs', 'trabalhos');
42
+ if (!existsSync(worksDir)) return [];
43
+ return readdirSync(worksDir).filter((id) => isRunning(root, id));
44
+ }
45
+
21
46
  /**
22
47
  * A edição só troca linhas de status e data?
23
48
  * @param {string} root
@@ -41,16 +66,15 @@ await runHook('proteger-contratos', (input, { root }) => {
41
66
  const rel = relativeToRoot(root, String(input.tool_input?.file_path ?? ''));
42
67
  if (!rel || !PROTECTED.some((pattern) => pattern.test(rel))) return null;
43
68
 
44
- const active = findActiveWork(root);
45
- const events = active ? workEvents(root, active) : null;
46
- const running =
47
- events !== null && events.some((event) => event.evento === 'task_iniciada') && !events.some((event) => event.evento === 'trabalho_concluido');
48
- if (!active || !running || onlyStatusChange(root, rel, input)) return null;
69
+ const running = runningWorks(root, rel);
70
+ if (!running.length || onlyStatusChange(root, rel, input)) return null;
49
71
 
72
+ const works = running.join(', ');
73
+ const decisions = `docs/trabalhos/${running.length === 1 ? works : '<id>'}/DECISOES.md`;
50
74
  return {
51
75
  motivo:
52
- `${rel} é contrato do trabalho ${active}, que está em execução, e não deve ser reescrito agora. ` +
53
- `Registre a mudança de escopo em docs/trabalhos/${active}/DECISOES.md (ou abra um bloqueio em BLOQUEIOS.md); ` +
76
+ `${rel} é contrato ${running.length === 1 ? `do trabalho ${works}, que está` : `dos trabalhos ${works}, que estão`} em execução, ` +
77
+ `e não deve ser reescrito agora. Registre a mudança de escopo em ${decisions} (ou abra um bloqueio em BLOQUEIOS.md); ` +
54
78
  'no PLAN.md, só o status das tasks pode ser atualizado.',
55
79
  };
56
80
  });
@@ -20,6 +20,19 @@ mantém o estado em disco. **Sempre relate no chat qual agent está ativo**, pri
20
20
  depois de `revisor-testes` e `qa` e passe aos gates o comando da suíte declarado.
21
21
  3. Confirme que o plano pode ser executado: `auditoria_plano: aprovado` no frontmatter e nenhum achado `contrato-*`
22
22
  deste trabalho em `npx @fontdata/ai-harness doctor`. Sem isso, pare e sugira `/nova-tarefa`.
23
+ 4. Outras sessões podem estar executando outros trabalhos nesta mesma pasta. Confira se algum deles mexe nos
24
+ mesmos arquivos:
25
+
26
+ ```bash
27
+ npx @fontdata/ai-harness conflitos <id>
28
+ ```
29
+
30
+ - Se o plano não declara `arquivos`, preencha a lista no frontmatter do `PLAN.md` com os arquivos que as tasks
31
+ vão tocar (tirados da prosa do plano) e rode a checagem de novo.
32
+ - Com conflito (saída com código 1), **não comece**: avise o usuário que este trabalho aguarda os trabalhos
33
+ citados concluírem e rode `npx @fontdata/ai-harness conflitos <id> --aguardar` em segundo plano. Ele só
34
+ termina quando os arquivos ficam livres; aí siga para o Passo 1. Se o usuário disser que o outro trabalho
35
+ foi abandonado, oriente a trocar o status do `PLAN.md` dele para `cancelado`.
23
36
 
24
37
  ## Passo 1 — Retomada
25
38
 
@@ -34,8 +47,10 @@ abertos e eventos do rastro. Apresente ao usuário:
34
47
  ## Passo 2 — Iniciar
35
48
 
36
49
  1. No `PLAN.md`, troque apenas a linha `status:` do plano para `em-execucao` (e `atualizado_em`).
37
- 2. Grave `.ai-harness/estado.json` com `{"trabalho_ativo": "<id>"}` — é por ele que os hooks e o rastro sabem
38
- qual trabalho está em curso.
50
+ 2. Acrescente `<id>` à lista `trabalhos_ativos` de `.ai-harness/estado.json` (crie o arquivo com
51
+ `{"trabalhos_ativos": ["<id>"]}` se ele não existir) — é por ele que os hooks e o rastro sabem quais trabalhos
52
+ estão em curso. Outras sessões podem estar executando outros trabalhos na mesma pasta: leia o arquivo logo
53
+ antes de gravar e preserve os ids que já estão lá.
39
54
 
40
55
  ## Passo 3 — Executar fase por fase
41
56
 
@@ -101,7 +116,8 @@ portões extras (com os achados IMPORTANTE e SUGESTÃO do `code-reviewer`), esta
101
116
  npx @fontdata/ai-harness evento trabalho_concluido --trabalho <id> --nome executar-plan
102
117
  ```
103
118
 
104
- 2. Grave `{"trabalho_ativo": null}` em `.ai-harness/estado.json`.
119
+ 2. Retire `<id>` da lista `trabalhos_ativos` de `.ai-harness/estado.json`, lendo o arquivo logo antes de gravar
120
+ e preservando os ids das outras sessões.
105
121
  3. Com bloqueios abertos, apresente cada um ao usuário. Cada resposta vira decisão em `DECISOES.md`
106
122
  (`decisao_registrada`), o bloqueio é fechado (`bloqueio_fechado`) e a task volta para `pendente` numa nova
107
123
  rodada desta skill.
@@ -61,6 +61,8 @@ Crie `docs/trabalhos/<id>/PLAN.md` a partir de `templates/PLAN.md`, seguindo `re
61
61
  - as tasks aparecem **no frontmatter** (`tasks`) e na prosa, com os mesmos ids;
62
62
  - toda task de código tem pelo menos 2 testes críticos concretos e critério de aceite binário;
63
63
  - tasks paralelizáveis da mesma fase não tocam os mesmos arquivos;
64
+ - `arquivos` do frontmatter lista todos os arquivos (ou pastas) que as tasks vão criar ou alterar — é por ele que
65
+ o `/executar-plan` impede dois trabalhos de mexerem no mesmo código ao mesmo tempo;
64
66
  - **plano condensado:** com 1 sprint e 1 fase, use `condensado: true` e omita os títulos de sprint e fase — o
65
67
  contrato das tasks continua completo.
66
68
 
@@ -20,6 +20,8 @@ Um plano aprovado precisa poder ser executado sem que ninguém pergunte nada no
20
20
  5. `depende_de` declara toda dependência real; a ordem das fases respeita essas dependências.
21
21
  6. Use os nomes de módulos do `SPEC.md` — sem nomenclatura nova.
22
22
  7. Uma task cabe numa sessão de trabalho; task grande é dividida.
23
+ 8. `arquivos` no frontmatter lista todos os arquivos (ou pastas) que as tasks vão criar ou alterar. Trabalhos
24
+ executados em paralelo na mesma pasta são barrados quando essas listas se cruzam.
23
25
 
24
26
  ## Plano condensado
25
27