@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -0
- package/dist/cli.js +6 -0
- package/dist/config/config.js +4 -3
- package/dist/install/plan.js +3 -3
- package/dist/install/settings.js +4 -0
- package/dist/trabalhos/conflitos.js +115 -0
- package/dist/update/update.js +4 -4
- package/package.json +1 -1
- package/templates/agents/auditor-plano.md +2 -0
- package/templates/hooks/lib/rastro.mjs +23 -9
- package/templates/hooks/proteger-contratos.mjs +35 -11
- package/templates/skills/executar-plan/SKILL.md +19 -3
- package/templates/skills/nova-tarefa/SKILL.md +2 -0
- package/templates/skills/nova-tarefa/references/regras-do-plano.md +2 -0
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)));
|
package/dist/config/config.js
CHANGED
|
@@ -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
|
|
77
|
-
* rastro só quando `rastro.versionar` é false.
|
|
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);
|
package/dist/install/plan.js
CHANGED
|
@@ -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,
|
|
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 ?
|
|
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(`
|
|
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
|
}
|
package/dist/install/settings.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/update/update.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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:
|
|
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 ? [`
|
|
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.
|
|
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
|
-
*
|
|
64
|
-
*
|
|
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
|
|
67
|
+
* @returns {string[]}
|
|
67
68
|
*/
|
|
68
|
-
export function
|
|
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
|
|
73
|
-
|
|
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
|
|
84
|
+
if (!existsSync(worksDir)) return [];
|
|
81
85
|
|
|
82
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
45
|
-
|
|
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 ${
|
|
53
|
-
`Registre a mudança de escopo em
|
|
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.
|
|
38
|
-
|
|
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.
|
|
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
|
|