@aksp/opencrew 1.12.0 → 1.14.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/CHANGELOG.md +57 -0
- package/README.md +25 -3
- package/package.json +1 -1
- package/templates/AGENTS.md +3 -5
- package/templates/_opencrew/.opencrew-version +1 -1
- package/templates/_opencrew/core/formato-da-crew.md +1 -0
- package/templates/_opencrew/core/prompts/build.prompt.md +2 -0
- package/templates/_opencrew/core/prompts/repair.prompt.md +4 -1
- package/templates/_opencrew/core/runner/contrato-de-saida.md +33 -0
- package/templates/_opencrew/core/runner/escritorio.md +37 -0
- package/templates/_opencrew/core/runner/fim-da-execucao.md +99 -0
- package/templates/_opencrew/core/runner/fontes-pendentes.md +21 -0
- package/templates/_opencrew/core/runner/memoria.md +58 -0
- package/templates/_opencrew/core/runner/retomar.md +51 -0
- package/templates/_opencrew/core/runner/selecao-de-agentes.md +79 -0
- package/templates/_opencrew/core/runner/tarefas-do-agente.md +34 -0
- package/templates/_opencrew/core/runner.pipeline.md +52 -364
- package/templates/_opencrew/core/scripts/caminho/argumentos.mjs +7 -4
- package/templates/_opencrew/core/scripts/caminho/crew.mjs +20 -0
- package/templates/_opencrew/core/scripts/caminho/disco.mjs +4 -3
- package/templates/_opencrew/core/scripts/caminho.mjs +62 -29
- package/templates/_opencrew/core/scripts/conserto/achados.mjs +2 -0
- package/templates/_opencrew/core/scripts/conserto/aplicar.mjs +2 -1
- package/templates/_opencrew/core/scripts/conserto/argumentos.mjs +2 -1
- package/templates/_opencrew/core/scripts/conserto/crew.mjs +4 -3
- package/templates/_opencrew/core/scripts/conserto/historico.mjs +102 -0
- package/templates/_opencrew/core/scripts/conserto.mjs +9 -7
- package/templates/_opencrew/core/scripts/entrega/leiame.mjs +4 -2
- package/templates/_opencrew/core/scripts/entregar.mjs +2 -0
- package/templates/_opencrew/core/scripts/estado/arquivo.mjs +17 -10
- package/templates/_opencrew/core/scripts/execucao/argumentos.mjs +62 -0
- package/templates/_opencrew/core/scripts/execucao/historico.mjs +76 -0
- package/templates/_opencrew/core/scripts/execucao/registro.mjs +115 -0
- package/templates/_opencrew/core/scripts/execucao/retomar.mjs +123 -0
- package/templates/_opencrew/core/scripts/execucao.mjs +123 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// O registro de uma execução: `crews/<crew>/output/<run>/execucao.json`. Só os scripts o gravam
|
|
2
|
+
// (`caminho.mjs` e `execucao.mjs`); a gravação é inteira ou nenhuma, e um registro ilegível vale
|
|
3
|
+
// como ausente.
|
|
4
|
+
// Spec: fase-u5c-execucao-registrada.md, §4 e regras 1 a 5 e 8 (repositório do OpenCrew).
|
|
5
|
+
import { promises as fs } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { gravarTexto } from '../estado/arquivo.mjs';
|
|
8
|
+
|
|
9
|
+
export const ARQUIVO = 'execucao.json';
|
|
10
|
+
/** O `--resultado` do `fechar` → o `status` do registro. */
|
|
11
|
+
export const STATUS = { aprovado: 'aprovada', rejeitado: 'rejeitada', abortado: 'abortada', publicado: 'publicada' };
|
|
12
|
+
|
|
13
|
+
const RECUSA = { EACCES: 'a pasta está protegida contra gravação', EPERM: 'a pasta está protegida ou o arquivo está em uso', EBUSY: 'o arquivo está em uso por outro programa', EISDIR: 'há uma pasta no lugar do arquivo', ENOSPC: 'o disco está cheio', EROFS: 'o disco é só de leitura' };
|
|
14
|
+
/** O motivo de uma falha de gravação, em PT-BR. */
|
|
15
|
+
export const motivoDe = (erro) => RECUSA[erro?.code] ?? `erro ${erro?.code ?? String(erro?.message ?? erro).replace(/\s+/g, ' ').trim()}`;
|
|
16
|
+
export const AVISO = (motivo) => `Não consegui gravar o registro desta execução: ${motivo}`;
|
|
17
|
+
|
|
18
|
+
/** Uma linha só, cortada por caractere inteiro (um emoji não fica pela metade). */
|
|
19
|
+
const umaLinha = (texto, maximo) => Array.from(String(texto ?? '').replace(/\s+/g, ' ').trim()).slice(0, maximo).join('').trim();
|
|
20
|
+
/** Tema e descrição da saída: uma linha, sem `|` (é coluna do `runs.md`), até 160 caracteres. */
|
|
21
|
+
export const limparTema = (texto) => umaLinha(String(texto ?? '').replace(/\|/g, '/'), 160);
|
|
22
|
+
/** A correção pedida ou o motivo da rejeição: uma linha, até 300 caracteres. */
|
|
23
|
+
export const limparNota = (texto) => umaLinha(texto, 300);
|
|
24
|
+
|
|
25
|
+
const lista = (valor) => (Array.isArray(valor) ? valor.filter((i) => i && typeof i === 'object') : []);
|
|
26
|
+
const inteiro = (valor) => Number.isInteger(valor) && valor > 0;
|
|
27
|
+
|
|
28
|
+
/** O que veio do disco é dado: cada campo que vai para a tela vira uma linha, e passo sem número sai. */
|
|
29
|
+
function conferido(lido) {
|
|
30
|
+
if (!lido || typeof lido !== 'object' || Array.isArray(lido)) return null;
|
|
31
|
+
const passos = lista(lido.passos).filter((p) => inteiro(p.n)).map((p) => ({ ...p, arquivo: umaLinha(p.arquivo, 500), em: umaLinha(p.em, 40) }));
|
|
32
|
+
const marcos = lista(lido.marcos).filter((m) => inteiro(m.passo)).map((m) => ({ ...m, evento: umaLinha(m.evento, 20), resultado: umaLinha(m.resultado, 20), nota: limparNota(m.nota), em: umaLinha(m.em, 40) }));
|
|
33
|
+
return { ...lido, tema: limparTema(lido.tema), saida: limparTema(lido.saida), status: umaLinha(lido.status, 20), passos, marcos };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* O registro gravado, para quem vai regravá-lo: `null` quando falta ou está ilegível (cortado, sem
|
|
38
|
+
* ser um objeto); qualquer outra falha de leitura (arquivo em uso, sem permissão) sobe — um
|
|
39
|
+
* registro que existe e não pôde ser lido não é trocado por um vazio.
|
|
40
|
+
*/
|
|
41
|
+
async function lerParaGravar(pasta) {
|
|
42
|
+
try {
|
|
43
|
+
const texto = await fs.readFile(path.join(pasta, ARQUIVO), 'utf8');
|
|
44
|
+
return conferido(JSON.parse(texto.charCodeAt(0) === 0xfeff ? texto.slice(1) : texto));
|
|
45
|
+
} catch (erro) {
|
|
46
|
+
if (erro?.code === 'ENOENT' || erro instanceof SyntaxError) return null;
|
|
47
|
+
throw erro;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* O registro gravado, para quem só lê: `null` quando falta, está ilegível ou não pôde ser lido.
|
|
53
|
+
* @param {string} pasta a pasta da execução
|
|
54
|
+
*/
|
|
55
|
+
export const lerRegistro = (pasta) => lerParaGravar(pasta).catch(() => null);
|
|
56
|
+
|
|
57
|
+
/** Um registro novo, aberto. @param {{ crew, run, tema?, passos?, em }} base `em`: a hora, em ISO */
|
|
58
|
+
export function novoRegistro({ crew, run, tema = '', passos = null, em }) {
|
|
59
|
+
return { versao: 1, crew, run, tema, status: 'aberta', iniciadaEm: em, passosPrevistos: passos, passos: [], marcos: [], saida: '' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Os campos na ordem da spec (`fechadaEm` só existe depois do fecho). */
|
|
63
|
+
function ordenado(r) {
|
|
64
|
+
const { versao, crew, run, tema, status, iniciadaEm, fechadaEm, passosPrevistos, passos, marcos, saida, ...resto } = r;
|
|
65
|
+
const fecho = fechadaEm === undefined ? {} : { fechadaEm };
|
|
66
|
+
return { versao, crew, run, tema, status, iniciadaEm, ...fecho, passosPrevistos, passos, marcos, saida, ...resto };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** O passo conferido: troca a entrada do mesmo número (fica a última saída). */
|
|
70
|
+
export function comPasso(registro, { n, arquivo, em }) {
|
|
71
|
+
const outros = registro.passos.filter((p) => p.n !== n);
|
|
72
|
+
return { ...registro, passos: [...outros, { n, arquivo, em }].sort((a, b) => a.n - b.n) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Os marcos só acumulam, na ordem em que chegam. */
|
|
76
|
+
export const comMarco = (registro, marco) => ({ ...registro, marcos: [...registro.marcos, marco] });
|
|
77
|
+
|
|
78
|
+
/** O tema, quando o comando trouxe um; senão, o que já estava. */
|
|
79
|
+
export const comTema = (registro, tema) => (limparTema(tema) ? { ...registro, tema: limparTema(tema) } : registro);
|
|
80
|
+
|
|
81
|
+
export function fechado(registro, { resultado, saida, em }) {
|
|
82
|
+
return { ...registro, status: STATUS[resultado], fechadaEm: em, saida: limparTema(saida) || String(registro.saida ?? '') };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Regra 8: checkpoints aprovados sem correção ÷ checkpoints respondidos (os pulados não contam). */
|
|
86
|
+
export function score(registro) {
|
|
87
|
+
const respostas = (registro?.marcos ?? []).filter((m) => m.evento === 'checkpoint').map((m) => m.resultado);
|
|
88
|
+
const aprovados = respostas.filter((r) => r === 'aprovado').length;
|
|
89
|
+
const total = aprovados + respostas.filter((r) => r === 'corrigido').length;
|
|
90
|
+
return total ? `${aprovados}/${total}` : '—';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Grava o registro. @returns {Promise<string|null>} o aviso para o usuário, ou `null` */
|
|
94
|
+
export async function gravarRegistro(pasta, registro) {
|
|
95
|
+
try {
|
|
96
|
+
await gravarTexto(path.join(pasta, ARQUIVO), `${JSON.stringify(ordenado(registro), null, 2)}\n`);
|
|
97
|
+
return null;
|
|
98
|
+
} catch (erro) {
|
|
99
|
+
return AVISO(motivoDe(erro));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Lê o registro (ou começa um, com `base`), aplica a mudança e grava.
|
|
105
|
+
* @param {string} pasta a pasta da execução · @param {object} base ver `novoRegistro`
|
|
106
|
+
* @param {(registro: object) => object} mudar
|
|
107
|
+
* @returns {Promise<string|null>} o aviso de que não gravou, ou `null`
|
|
108
|
+
*/
|
|
109
|
+
export async function anotar(pasta, base, mudar) {
|
|
110
|
+
try {
|
|
111
|
+
return await gravarRegistro(pasta, mudar((await lerParaGravar(pasta)) ?? novoRegistro(base)));
|
|
112
|
+
} catch (erro) {
|
|
113
|
+
return AVISO(motivoDe(erro));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// O que os registros de uma crew dizem: qual execução ficou aberta e de que passo ela continua
|
|
2
|
+
// (`retomar`), e as correções das últimas execuções fechadas (lista do `fechar`). Só leitura.
|
|
3
|
+
// Spec: fase-u5c-execucao-registrada.md, regras 9 e 11 (repositório do OpenCrew).
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { pastasDe, temConteudo } from '../caminho/disco.mjs';
|
|
6
|
+
import { lerCrew } from '../conserto/crew.mjs';
|
|
7
|
+
import { lerRegistro } from './registro.mjs';
|
|
8
|
+
|
|
9
|
+
const MSG = {
|
|
10
|
+
abertas: 'Execuções abertas (a mais recente primeiro):',
|
|
11
|
+
naoAberta: (run) => `A execução ${run} não está aberta.`,
|
|
12
|
+
sumiu: ' (o arquivo não está mais lá)',
|
|
13
|
+
tudoFeito: 'Todos os passos já foram feitos: falta só encerrar a execução.',
|
|
14
|
+
refeitos: 'Já gravados, mas serão feitos de novo:',
|
|
15
|
+
voltaIncerta: (revisao, n) => `Não consegui ler na crew para qual passo a revisão do passo ${revisao} volta: recomeço do passo ${n}. Confira antes de continuar.`,
|
|
16
|
+
correcoes: 'Correções das últimas execuções:',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Os registros legíveis das execuções da crew: `{ run, ...registro }`. */
|
|
20
|
+
async function registrosDe(raiz, crew) {
|
|
21
|
+
const saida = path.resolve(raiz, 'crews', crew, 'output');
|
|
22
|
+
const lidos = await Promise.all(pastasDe(saida).map(async (run) => ({ run, registro: await lerRegistro(path.join(saida, run)) })));
|
|
23
|
+
return lidos.filter((l) => l.registro).map((l) => ({ ...l.registro, run: l.run }));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const maisNova = (campo) => (a, b) => String(b[campo] ?? '').localeCompare(String(a[campo] ?? '')) || b.run.localeCompare(a.run);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Regra 9: as notas dos marcos `corrigido` e `rejeitado` das 10 execuções fechadas mais recentes.
|
|
30
|
+
* @returns {Promise<string[]>} o título e uma linha por correção, a mais recente primeiro; `[]` sem nenhuma
|
|
31
|
+
*/
|
|
32
|
+
export async function correcoesRecentes(raiz, crew) {
|
|
33
|
+
const fechadas = (await registrosDe(raiz, crew)).filter((r) => r.status !== 'aberta').sort(maisNova('fechadaEm')).slice(0, 10);
|
|
34
|
+
const corrigiu = (m) => (m.resultado === 'corrigido' || m.resultado === 'rejeitado') && m.nota;
|
|
35
|
+
const linhas = fechadas.flatMap((r) => r.marcos.filter(corrigiu).map((m) => `- ${r.run} · passo ${m.passo} · ${m.nota}`));
|
|
36
|
+
return linhas.length ? [MSG.correcoes, ...linhas] : [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Os passos da crew (o número e, na revisão, o passo para onde a rejeição volta); `[]` se a crew não pôde ser lida. */
|
|
40
|
+
function passosDaCrew(raiz, crew) {
|
|
41
|
+
try {
|
|
42
|
+
return lerCrew(raiz, path.join('crews', crew)).passos;
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** O que foi feito por último: o passo conferido ou o marco mais recente (no empate, o do passo maior; depois, o marco). */
|
|
49
|
+
function ultimoFeito(registro) {
|
|
50
|
+
const feitos = [
|
|
51
|
+
...registro.passos.map((p) => ({ em: String(p.em ?? ''), passo: p.n, peso: 0 })),
|
|
52
|
+
...registro.marcos.map((m) => ({ em: String(m.em ?? ''), passo: m.passo, peso: 1, marco: m })),
|
|
53
|
+
];
|
|
54
|
+
return feitos.sort((a, b) => a.em.localeCompare(b.em) || a.passo - b.passo || a.peso - b.peso).at(-1) ?? null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Para onde volta a revisão rejeitada no passo `revisao`: o passo do `on_reject`; se a crew não
|
|
59
|
+
* diz (pipeline ilegível, alvo que não é número), o primeiro passo gravado antes da revisão —
|
|
60
|
+
* refazer a mais é mais seguro que mandar o revisor ler de novo o texto que ele rejeitou.
|
|
61
|
+
* @returns {{ n: number, incerto: boolean }}
|
|
62
|
+
*/
|
|
63
|
+
function voltaDe(registro, passos, revisao) {
|
|
64
|
+
const declarado = passos.find((p) => p.numero === revisao)?.volta ?? null;
|
|
65
|
+
if (declarado !== null) return { n: declarado, incerto: false };
|
|
66
|
+
const antes = registro.passos.map((p) => p.n).filter((n) => n < revisao);
|
|
67
|
+
return { n: antes.length ? Math.min(...antes) : revisao, incerto: true };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Regra 11 (decisão 5 da spec): continua do passo seguinte ao último feito; revisão rejeitada
|
|
72
|
+
* volta ao passo do `on_reject`; passo cujo arquivo sumiu é refeito.
|
|
73
|
+
* @returns {{ feito: number|null, n: number, fim: boolean, incerto: boolean }} `fim`: não há passo
|
|
74
|
+
* depois do último feito · `incerto`: o passo de volta da revisão foi deduzido
|
|
75
|
+
*/
|
|
76
|
+
function proximoPasso(registro, passos, existe) {
|
|
77
|
+
const numeros = passos.map((p) => p.numero).sort((a, b) => a - b);
|
|
78
|
+
const ultimo = ultimoFeito(registro);
|
|
79
|
+
const seguinte = (k) => numeros.find((n) => n > k) ?? k + 1;
|
|
80
|
+
const rejeitou = ultimo?.marco?.evento === 'revisao' && ultimo.marco.resultado === 'rejeitado';
|
|
81
|
+
const volta = rejeitou ? voltaDe(registro, passos, ultimo.passo) : null;
|
|
82
|
+
const pelaOrdem = ultimo ? volta?.n ?? seguinte(ultimo.passo) : numeros[0] ?? 1;
|
|
83
|
+
const sumidos = registro.passos.filter((p) => p.n < pelaOrdem && !existe(p.arquivo)).map((p) => p.n);
|
|
84
|
+
const fim = numeros.length > 0 && pelaOrdem > numeros.at(-1);
|
|
85
|
+
return { feito: ultimo?.passo ?? null, n: Math.min(pelaOrdem, ...sumidos), fim, incerto: volta?.incerto ?? false };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const comNota = (m) => `- passo ${m.passo}: ${m.resultado}${m.nota ? ` — ${m.nota}` : ''}`;
|
|
89
|
+
const grupo = (titulo, linhas) => (linhas.length ? [titulo, ...linhas] : []);
|
|
90
|
+
|
|
91
|
+
/** As linhas que descrevem uma execução aberta e a linha `EXECUCAO:RETOMAR`. */
|
|
92
|
+
function descrever(raiz, crew, registro) {
|
|
93
|
+
const existe = (arquivo) => temConteudo(path.resolve(raiz, String(arquivo)));
|
|
94
|
+
const { feito, n, fim, incerto } = proximoPasso(registro, passosDaCrew(raiz, crew), existe);
|
|
95
|
+
const linha = (p) => `- passo ${p.n}: ${p.arquivo}${existe(p.arquivo) ? '' : MSG.sumiu}`;
|
|
96
|
+
// Pronto é só o que fica antes do passo de onde a execução continua; o resto será feito de novo.
|
|
97
|
+
const prontos = registro.passos.filter((p) => p.n < n).map(linha);
|
|
98
|
+
const doEvento = (evento) => registro.marcos.filter((m) => m.evento === evento).map(comNota);
|
|
99
|
+
return [
|
|
100
|
+
`Execução: ${registro.run}`,
|
|
101
|
+
`Tema: ${registro.tema || '(sem tema)'}`,
|
|
102
|
+
...(prontos.length ? ['Passos conferidos:', ...prontos] : ['Passos conferidos: nenhum']),
|
|
103
|
+
...grupo(MSG.refeitos, registro.passos.filter((p) => p.n >= n).map(linha)),
|
|
104
|
+
...grupo('Checkpoints respondidos:', doEvento('checkpoint')),
|
|
105
|
+
...grupo('Revisões:', doEvento('revisao')),
|
|
106
|
+
...(feito === null ? [] : [`Parou depois do passo ${feito}.`]),
|
|
107
|
+
...(incerto ? [MSG.voltaIncerta(feito, n)] : []),
|
|
108
|
+
...(fim && n > feito ? [MSG.tudoFeito] : []),
|
|
109
|
+
`EXECUCAO:RETOMAR ${registro.run} ${n}`,
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Regra 11: a execução aberta mais recente da crew (ou a de `run`) e o passo de onde ela continua.
|
|
115
|
+
* @returns {Promise<string[]>} as linhas da resposta; a última é `EXECUCAO:RETOMAR <run> <passo>` ou `EXECUCAO:NADA`
|
|
116
|
+
*/
|
|
117
|
+
export async function retomar(raiz, crew, run) {
|
|
118
|
+
const abertas = (await registrosDe(raiz, crew)).filter((r) => r.status === 'aberta').sort(maisNova('iniciadaEm'));
|
|
119
|
+
if (!abertas.length) return [...(run ? [MSG.naoAberta(run)] : []), 'EXECUCAO:NADA'];
|
|
120
|
+
const lista = abertas.length > 1 ? [MSG.abertas, ...abertas.map((r) => `- ${r.run} — ${r.tema || 'sem tema'}`)] : [];
|
|
121
|
+
const alvo = run ? abertas.find((r) => r.run === run) : abertas[0];
|
|
122
|
+
return alvo ? [...lista, ...descrever(raiz, crew, alvo)] : [...lista, MSG.naoAberta(run), 'EXECUCAO:NADA'];
|
|
123
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Registro da execução de uma crew: os marcos (resposta de checkpoint, veredito da revisão), o
|
|
3
|
+
// fecho — que grava a linha do histórico (`runs.md`) — e de onde uma execução interrompida continua.
|
|
4
|
+
// Quem grava o registro e o histórico é este script (e o `caminho.mjs`), nunca a IA.
|
|
5
|
+
// Uso (na pasta do projeto): node _opencrew/core/scripts/execucao.mjs <crew> <ação> [opções]
|
|
6
|
+
// marcar --run <id> --passo N --evento checkpoint|revisao --resultado <r> [--nota "<texto>"] [--tema "<texto>"]
|
|
7
|
+
// checkpoint: aprovado, corrigido ou pulado · revisao: aprovado ou rejeitado
|
|
8
|
+
// fechar --run <id> --resultado aprovado|rejeitado|abortado|publicado [--saida "<descrição>"] [--tema "<texto>"]
|
|
9
|
+
// retomar [--run <id>] a execução aberta mais recente (ou a de --run)
|
|
10
|
+
// <crew> é o nome da pasta em `crews/`.
|
|
11
|
+
// Só grava `crews/<crew>/output/<id>/execucao.json` e, no `fechar`, uma linha de
|
|
12
|
+
// `crews/<crew>/_memory/runs.md` (posta ou trocada); `retomar` só lê. Nunca apaga.
|
|
13
|
+
// Última linha da saída (o runner lê esta linha): EXECUCAO:OK · EXECUCAO:FECHADA <resultado> <score>
|
|
14
|
+
// · EXECUCAO:RETOMAR <id> <próximo passo> · EXECUCAO:NADA (nenhuma execução aberta).
|
|
15
|
+
// Código de saída: 0 sempre que a linha EXECUCAO: sai — também quando o registro ou o histórico
|
|
16
|
+
// não pôde ser gravado (o aviso vem antes da linha) · 1 = erro de uso; nada é gravado.
|
|
17
|
+
// Spec: fase-u5c-execucao-registrada.md (repositório do OpenCrew).
|
|
18
|
+
import { promises as fs } from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { ehPrincipal, realDentroDe } from './comum.mjs';
|
|
21
|
+
import { limpar } from './caminho/argumentos.mjs';
|
|
22
|
+
import { acharCrew } from './caminho/crew.mjs';
|
|
23
|
+
import { ehPasta } from './caminho/disco.mjs';
|
|
24
|
+
import { gravarTexto } from './estado/arquivo.mjs';
|
|
25
|
+
import { USO, erroDeArgumentos, lerArgs } from './execucao/argumentos.mjs';
|
|
26
|
+
import { ROTULO, comLinha, dataDe, linhaDe } from './execucao/historico.mjs';
|
|
27
|
+
import { anotar, comMarco, comTema, fechado, gravarRegistro, lerRegistro, limparNota, motivoDe, novoRegistro, score } from './execucao/registro.mjs';
|
|
28
|
+
import { correcoesRecentes, retomar } from './execucao/retomar.mjs';
|
|
29
|
+
|
|
30
|
+
const MSG = {
|
|
31
|
+
semExecucao: (run) => `Execução não encontrada: ${limpar(run)}`,
|
|
32
|
+
semRegistro: 'O registro desta execução não existia ou estava ilegível: a linha do histórico saiu só com o que este comando informou.',
|
|
33
|
+
semHistorico: (motivo) => `Não consegui gravar o histórico desta execução: ${motivo}`,
|
|
34
|
+
naoUtf8: 'o arquivo runs.md não está em UTF-8; salve-o em UTF-8 e feche a execução de novo',
|
|
35
|
+
falha: (motivo) => `Não consegui registrar a execução: ${limpar(motivo)}`,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Onde fica a crew e, quando o comando cita uma execução, a pasta dela; ou o erro de uso. */
|
|
39
|
+
function localizar(raiz, args) {
|
|
40
|
+
const erro = erroDeArgumentos(args);
|
|
41
|
+
if (erro) return { erro };
|
|
42
|
+
const achada = acharCrew(raiz, args.crew);
|
|
43
|
+
if (achada.erro) return achada;
|
|
44
|
+
const saida = path.resolve(raiz, 'crews', achada.crew, 'output');
|
|
45
|
+
const pasta = path.join(saida, args.run ?? '');
|
|
46
|
+
// A pasta tem de existir e ficar mesmo dentro de `output/` (um atalho que leva para fora não vale).
|
|
47
|
+
const valida = ehPasta(pasta) && realDentroDe(saida, pasta);
|
|
48
|
+
return args.acao !== 'retomar' && !valida ? { erro: MSG.semExecucao(args.run) } : { crew: achada.crew, pasta };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function marcar({ pasta, base }, args) {
|
|
52
|
+
const marco = { passo: Number(args.passo), evento: args.evento, resultado: args.resultado, nota: limparNota(args.nota), em: base.em };
|
|
53
|
+
const aviso = await anotar(pasta, base, (registro) => comMarco(comTema(registro, args.tema), marco));
|
|
54
|
+
return [aviso, 'EXECUCAO:OK'];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** O texto do arquivo, com o BOM se houver; `undefined` quando os bytes não são UTF-8 (o arquivo não é regravado). */
|
|
58
|
+
function emUtf8(bytes) {
|
|
59
|
+
try {
|
|
60
|
+
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
61
|
+
} catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Regra 7: a linha da execução no `runs.md` da crew. @returns {Promise<string|null>} o aviso, ou `null` */
|
|
67
|
+
async function gravarHistorico({ raiz, crew, base: { run } }, linha) {
|
|
68
|
+
const arquivo = path.resolve(raiz, 'crews', crew, '_memory', 'runs.md');
|
|
69
|
+
try {
|
|
70
|
+
const bytes = await fs.readFile(arquivo).catch((erro) => (erro.code === 'ENOENT' ? null : Promise.reject(erro)));
|
|
71
|
+
const atual = bytes === null ? null : emUtf8(bytes);
|
|
72
|
+
if (atual === undefined) return MSG.semHistorico(MSG.naoUtf8);
|
|
73
|
+
await fs.mkdir(path.dirname(arquivo), { recursive: true });
|
|
74
|
+
await gravarTexto(arquivo, comLinha(atual, { crew, run, linha }));
|
|
75
|
+
return null;
|
|
76
|
+
} catch (erro) {
|
|
77
|
+
return MSG.semHistorico(motivoDe(erro));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function fechar(ctx, args) {
|
|
82
|
+
const { raiz, crew, pasta, base, agora } = ctx;
|
|
83
|
+
const lido = await lerRegistro(pasta);
|
|
84
|
+
const registro = fechado(comTema(lido ?? novoRegistro(base), args.tema), { resultado: args.resultado, saida: args.saida, em: base.em });
|
|
85
|
+
const avisos = [lido ? null : MSG.semRegistro, await gravarRegistro(pasta, registro)];
|
|
86
|
+
const pontos = score(registro);
|
|
87
|
+
const linha = linhaDe({ data: dataDe(base.run, agora), run: base.run, tema: registro.tema, saida: registro.saida, score: pontos, resultado: ROTULO[args.resultado] });
|
|
88
|
+
const semHistorico = await gravarHistorico(ctx, linha);
|
|
89
|
+
return [...avisos, semHistorico ?? linha, ...(await correcoesRecentes(raiz, crew)), `EXECUCAO:FECHADA ${args.resultado} ${pontos}`];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function responder(raiz, crew, pasta, args, agora) {
|
|
93
|
+
if (args.acao === 'retomar') return retomar(raiz, crew, args.run);
|
|
94
|
+
const base = { crew, run: args.run, em: agora.toISOString() };
|
|
95
|
+
const acao = args.acao === 'marcar' ? marcar : fechar;
|
|
96
|
+
return (await acao({ raiz, crew, pasta, base, agora }, args)).filter(Boolean);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {string[]} argv
|
|
101
|
+
* @param {object} [deps] `cwd` (a pasta do projeto), `escrever` e `agora` (o relógio)
|
|
102
|
+
* @returns {Promise<number>} 0 = a linha `EXECUCAO:` saiu · 1 = erro de uso ou falha de leitura
|
|
103
|
+
* (a linha de uso e o motivo, ou só o erro; sem linha `EXECUCAO:`)
|
|
104
|
+
*/
|
|
105
|
+
export async function main(argv, deps = {}) {
|
|
106
|
+
const { cwd = process.cwd(), escrever = (s) => process.stdout.write(`${s}\n`), agora = () => new Date() } = deps;
|
|
107
|
+
const args = lerArgs(argv);
|
|
108
|
+
const local = localizar(cwd, args);
|
|
109
|
+
if (local.erro) {
|
|
110
|
+
escrever(USO);
|
|
111
|
+
escrever(local.erro);
|
|
112
|
+
return 1;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
(await responder(cwd, local.crew, local.pasta, args, agora())).forEach((linha) => escrever(linha));
|
|
116
|
+
return 0;
|
|
117
|
+
} catch (erro) {
|
|
118
|
+
escrever(MSG.falha(erro?.message ?? erro));
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (ehPrincipal(import.meta.url)) process.exitCode = await main(process.argv.slice(2));
|