@fontdata/ai-harness 2.1.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fontdata",
3
- "version": "2.1.0",
3
+ "version": "2.2.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
@@ -113,6 +113,7 @@ duas vezes.
113
113
  | `migrar [caminho]` | Move os documentos do layout anterior para `docs/trabalhos/<id>/` (`--yes`) |
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',
@@ -50,6 +51,7 @@ Gerenciar:
50
51
  comentarios [caminho] Instala o hook de comentários e a catraca do legado (--yes aplica)
51
52
  memoria [caminho] Índice de quem mexeu em cada arquivo (--arquivo <caminho>, --regressao <id do bug>)
52
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)
53
55
  list Lista todos os comandos disponíveis no catálogo
54
56
 
55
57
  Init sem perguntas (CI):
@@ -139,6 +141,10 @@ async function main() {
139
141
  const { runEventCommand } = await import('./rastro/command.js');
140
142
  process.exit(await runEventCommand(argv.slice(1)));
141
143
  }
144
+ if (cmd === 'conflitos') {
145
+ const { runConflictsCommand } = await import('./trabalhos/conflitos.js');
146
+ process.exit(await runConflictsCommand(argv.slice(1)));
147
+ }
142
148
  if (cmd === 'update') {
143
149
  const { runUpdateCommand } = await import('./update/update.js');
144
150
  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);
@@ -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
  }
@@ -0,0 +1,115 @@
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 };
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
+ report.semArquivos.push(id);
44
+ continue;
45
+ }
46
+ const shared = own.arquivos.filter((a) => plan.arquivos.some((b) => overlaps(a, b)));
47
+ if (shared.length)
48
+ report.conflitos.push({ trabalho: id, arquivos: shared });
49
+ }
50
+ return report;
51
+ }
52
+ export function formatConflicts(workId, report) {
53
+ const lines = [];
54
+ if (!report.arquivos.length) {
55
+ lines.push(`O PLAN.md de ${workId} não declara arquivos em \`arquivos\`: sem eles não há como detectar conflito com outros trabalhos.`);
56
+ }
57
+ for (const id of report.semArquivos) {
58
+ 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.`);
59
+ }
60
+ if (!report.conflitos.length) {
61
+ lines.push(`Nenhum trabalho em execução mexe nos arquivos de ${workId}.`);
62
+ return lines.join('\n');
63
+ }
64
+ 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 ` +
65
+ 'abandonado, troque o status do PLAN.md dele para `cancelado`.');
66
+ return lines.join('\n');
67
+ }
68
+ /** Espera até nenhum trabalho em execução mexer nos mesmos arquivos. */
69
+ export async function waitForFreeFiles(root, workId, options) {
70
+ const wait = options.wait ?? sleep;
71
+ let previous = '';
72
+ for (;;) {
73
+ const report = await findConflicts(root, workId);
74
+ if (!report.conflitos.length)
75
+ return report;
76
+ const holders = report.conflitos.map((conflict) => conflict.trabalho).join(', ');
77
+ if (holders !== previous)
78
+ options.log(`Aguardando ${holders} concluir para liberar os arquivos de ${workId}...`);
79
+ previous = holders;
80
+ await wait(options.intervalMs);
81
+ }
82
+ }
83
+ const FLAGS = ['--aguardar', '--intervalo'];
84
+ /** `ai-harness conflitos <id> [--aguardar] [--intervalo <segundos>]` — sai com 1 quando há conflito sem --aguardar. */
85
+ export async function runConflictsCommand(argv, root = process.cwd()) {
86
+ const [workId, ...rest] = argv;
87
+ if (!workId || workId.startsWith('-')) {
88
+ throw new Error('Informe o id do trabalho. Ex.: npx @fontdata/ai-harness conflitos 2026-09-18-login --aguardar');
89
+ }
90
+ let waitMode = false;
91
+ let intervalMs = 30_000;
92
+ for (let i = 0; i < rest.length; i++) {
93
+ const flag = rest[i] ?? '';
94
+ if (flag === '--aguardar')
95
+ waitMode = true;
96
+ else if (flag === '--intervalo') {
97
+ const seconds = Number(rest[++i]);
98
+ if (!Number.isFinite(seconds) || seconds <= 0)
99
+ throw new Error('--intervalo precisa de um número de segundos maior que zero.');
100
+ intervalMs = seconds * 1000;
101
+ }
102
+ else {
103
+ const suggestion = suggestFlag(flag, FLAGS);
104
+ throw new Error(`Flag desconhecida: ${flag}.${suggestion ? ` Você quis dizer ${suggestion}?` : ''}`);
105
+ }
106
+ }
107
+ if (!waitMode) {
108
+ const report = await findConflicts(root, workId);
109
+ console.log(formatConflicts(workId, report));
110
+ return report.conflitos.length ? 1 : 0;
111
+ }
112
+ const report = await waitForFreeFiles(root, workId, { intervalMs, log: (line) => console.log(line) });
113
+ console.log(formatConflicts(workId, report));
114
+ return 0;
115
+ }
@@ -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.2.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
 
@@ -60,33 +60,47 @@ 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`. 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
91
  return /^status:\s*['"]?em-execucao['"]?\s*$/m.test(frontmatter);
88
92
  });
89
- return running.length === 1 ? (running[0] ?? null) : null;
93
+ }
94
+
95
+ /**
96
+ * O trabalho ao qual atribuir um evento sem `--trabalho`: o único ativo. Com nenhum ou vários, retorna
97
+ * null e o evento vai para _geral.jsonl, para não cair no rastro do trabalho errado.
98
+ * @param {string} root
99
+ * @returns {string | null}
100
+ */
101
+ export function findActiveWork(root) {
102
+ const active = findActiveWorks(root);
103
+ return active.length === 1 ? (active[0] ?? null) : null;
90
104
  }
91
105
 
92
106
  /**
@@ -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