@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +25 -3
  3. package/package.json +1 -1
  4. package/templates/AGENTS.md +3 -5
  5. package/templates/_opencrew/.opencrew-version +1 -1
  6. package/templates/_opencrew/core/formato-da-crew.md +1 -0
  7. package/templates/_opencrew/core/prompts/build.prompt.md +2 -0
  8. package/templates/_opencrew/core/prompts/repair.prompt.md +4 -1
  9. package/templates/_opencrew/core/runner/contrato-de-saida.md +33 -0
  10. package/templates/_opencrew/core/runner/escritorio.md +37 -0
  11. package/templates/_opencrew/core/runner/fim-da-execucao.md +99 -0
  12. package/templates/_opencrew/core/runner/fontes-pendentes.md +21 -0
  13. package/templates/_opencrew/core/runner/memoria.md +58 -0
  14. package/templates/_opencrew/core/runner/retomar.md +51 -0
  15. package/templates/_opencrew/core/runner/selecao-de-agentes.md +79 -0
  16. package/templates/_opencrew/core/runner/tarefas-do-agente.md +34 -0
  17. package/templates/_opencrew/core/runner.pipeline.md +52 -364
  18. package/templates/_opencrew/core/scripts/caminho/argumentos.mjs +7 -4
  19. package/templates/_opencrew/core/scripts/caminho/crew.mjs +20 -0
  20. package/templates/_opencrew/core/scripts/caminho/disco.mjs +4 -3
  21. package/templates/_opencrew/core/scripts/caminho.mjs +62 -29
  22. package/templates/_opencrew/core/scripts/conserto/achados.mjs +2 -0
  23. package/templates/_opencrew/core/scripts/conserto/aplicar.mjs +2 -1
  24. package/templates/_opencrew/core/scripts/conserto/argumentos.mjs +2 -1
  25. package/templates/_opencrew/core/scripts/conserto/crew.mjs +4 -3
  26. package/templates/_opencrew/core/scripts/conserto/historico.mjs +102 -0
  27. package/templates/_opencrew/core/scripts/conserto.mjs +9 -7
  28. package/templates/_opencrew/core/scripts/entrega/leiame.mjs +4 -2
  29. package/templates/_opencrew/core/scripts/entregar.mjs +2 -0
  30. package/templates/_opencrew/core/scripts/estado/arquivo.mjs +17 -10
  31. package/templates/_opencrew/core/scripts/execucao/argumentos.mjs +62 -0
  32. package/templates/_opencrew/core/scripts/execucao/historico.mjs +76 -0
  33. package/templates/_opencrew/core/scripts/execucao/registro.mjs +115 -0
  34. package/templates/_opencrew/core/scripts/execucao/retomar.mjs +123 -0
  35. package/templates/_opencrew/core/scripts/execucao.mjs +123 -0
@@ -2,24 +2,32 @@
2
2
  // Caminho da execução de uma crew: diz onde cada passo grava, de onde lê e se o arquivo gravado
3
3
  // está lá. Quem calcula é este script, igual em qualquer sistema — a IA não monta o caminho.
4
4
  // Uso (na pasta do projeto): node _opencrew/core/scripts/caminho.mjs <crew> <ação> --run <id> [opções]
5
- // pasta [--run <id>] cria crews/<crew>/output/<id>/; sem --run, o id é a
6
- // data e a hora do computador (AAAA-MM-DD-HHmmss)
5
+ // pasta [--run <id>] [--tema "<texto>"] [--passos N]
6
+ // cria crews/<crew>/output/<id>/ e o registro da
7
+ // execução; sem --run, o id é a data e a hora do
8
+ // computador (AAAA-MM-DD-HHmmss)
7
9
  // saida --run <id> --arquivo <declarado> onde o passo grava (abre a pasta de versão seguinte)
8
10
  // entrada --run <id> --arquivo <declarado> a saída mais nova desse arquivo
9
- // conferir --arquivo <caminho já resolvido> [--secoes N] [--tldr]
11
+ // conferir --arquivo <caminho já resolvido> [--passo N] [--secoes N] [--tldr]
12
+ // com --passo, o arquivo aprovado entra no registro
10
13
  // <crew> é o nome da pasta em `crews/`. <declarado> é o `inputFile` ou `outputFile` do passo.
11
- // Só cria pastas, e só dentro de crews/<crew>/output/<id>/; nunca cria, altera nem apaga arquivo.
14
+ // Só cria pastas, e só dentro de crews/<crew>/output/<id>/. O único arquivo que grava é o registro
15
+ // da execução (`crews/<crew>/output/<id>/execucao.json`); nunca altera nem apaga outro arquivo.
16
+ // Registro que não pôde ser gravado não muda a linha CAMINHO: — o aviso sai antes dela.
12
17
  // Última linha da saída (o runner lê esta linha): CAMINHO:OK <caminho>, CAMINHO:FALTA <caminho>
13
18
  // ou CAMINHO:REPROVADO <motivo>; o caminho sai relativo à pasta do projeto, com `/`.
14
19
  // Código de saída: 0 sempre que a linha CAMINHO: sai · 1 = erro de uso (ação ou opção faltando,
15
20
  // pasta sem `_opencrew/`, crew inexistente ou fora do projeto); com código 1 não há linha
16
21
  // CAMINHO: e nada é criado.
17
- // Spec: fase-r3-runner-em-uso-real.md (repositório do OpenCrew).
22
+ // Specs: fase-r3-runner-em-uso-real.md e fase-u5c-execucao-registrada.md, regras 1 a 5
23
+ // (repositório do OpenCrew).
18
24
  import path from 'node:path';
19
25
  import { MSG, dentroDoProjeto, ehPrincipal, realDentroDe } from './comum.mjs';
20
- import { USO, erroDeArgumentos, lerArgs, limpar } from './caminho/argumentos.mjs';
26
+ import { RUN, USO, erroDeArgumentos, lerArgs, limpar } from './caminho/argumentos.mjs';
27
+ import { acharCrew } from './caminho/crew.mjs';
28
+ import { ARQUIVO, anotar, comPasso, limparTema } from './execucao/registro.mjs';
21
29
  import { MOTIVO, daMaisNova, motivoDeReprovacao, naExecucao, normalizar, novoRun, proximaVersao } from './caminho/nucleo.mjs';
22
- import { criarPasta, ehPasta, lerTexto, pastasDe, temConteudo } from './caminho/disco.mjs';
30
+ import { criarPasta, lerTexto, pastasDe, temConteudo } from './caminho/disco.mjs';
23
31
 
24
32
  const ok = (caminho) => `CAMINHO:OK ${caminho}`;
25
33
  const falta = (caminho) => `CAMINHO:FALTA ${caminho}`;
@@ -50,15 +58,10 @@ function erroDoArquivo(raiz, crew, { acao, run, arquivo }) {
50
58
  function localizar(raiz, args) {
51
59
  const erro = erroDeArgumentos(args);
52
60
  if (erro) return { erro };
53
- if (!ehPasta(path.join(raiz, '_opencrew'))) return { erro: MSG.semRaiz };
54
- const base = path.resolve(raiz, 'crews');
55
- const nome = args.crew.replace(/^crews[\\/]+/, '');
56
- if (!dentroDoProjeto(base, nome)) return { erro: MSG.foraDoProjeto(limpar(args.crew)) };
57
- const pasta = path.resolve(base, nome);
58
- if (path.dirname(pasta) !== base || !ehPasta(pasta)) return { erro: MSG.crewNaoEncontrada(limpar(args.crew)) };
59
- const crew = path.basename(pasta);
60
- const doArquivo = args.arquivo ? erroDoArquivo(raiz, crew, args) : null;
61
- return doArquivo ? { erro: doArquivo } : { crew };
61
+ const achada = acharCrew(raiz, args.crew);
62
+ if (achada.erro) return achada;
63
+ const doArquivo = args.arquivo ? erroDoArquivo(raiz, achada.crew, args) : null;
64
+ return doArquivo ? { erro: doArquivo } : achada;
62
65
  }
63
66
 
64
67
  /** Regras 2 e 3: o caminho em que o passo grava; a pasta dele é criada. */
@@ -90,23 +93,53 @@ function conferir(raiz, { arquivo, secoes, tldr }) {
90
93
  return motivo ? reprovado(motivo) : ok(caminho);
91
94
  }
92
95
 
93
- function responder(raiz, crew, args, agora) {
94
- if (args.acao === 'saida') return saida(raiz, crew, args);
95
- if (args.acao === 'entrada') return entrada(raiz, crew, args);
96
- if (args.acao === 'conferir') return conferir(raiz, args);
97
- const run = args.run || novoRun(agora(), pastasDe(path.resolve(raiz, 'crews', crew, 'output')));
98
- const pasta = `crews/${crew}/output/${run}`;
99
- criarPasta(path.resolve(raiz, pasta));
100
- return ok(pasta);
96
+ /**
97
+ * A execução de um arquivo conferido: a pasta logo depois de `crews/<crew>/output/`, se ela é de
98
+ * execução (tem registro, ou o nome começa por data) e fica mesmo dentro de `output/` — uma pasta
99
+ * qualquer do usuário, ou um atalho que leva para fora, não ganha registro.
100
+ */
101
+ function execucaoDe(raiz, caminho, crew) {
102
+ const saida = `crews/${crew}/output/`;
103
+ const [run, ...resto] = caminho.startsWith(saida) ? caminho.slice(saida.length).split('/') : [];
104
+ if (!resto.length || !RUN.test(run) || resto.includes('..')) return null;
105
+ const pasta = path.resolve(raiz, saida, run);
106
+ const ehExecucao = /^\d{4}-\d{2}-\d{2}/.test(run) || temConteudo(path.join(pasta, ARQUIVO));
107
+ return ehExecucao && realDentroDe(path.resolve(raiz, saida), pasta) ? run : null;
108
+ }
109
+
110
+ /** Regra 2 da U5c: o arquivo que passou na conferência entra no registro, como a saída do passo. */
111
+ async function conferirERegistrar(raiz, crew, args, em) {
112
+ const linha = conferir(raiz, args);
113
+ const [, caminho] = /^CAMINHO:OK (.*)$/.exec(linha) ?? [];
114
+ const run = caminho && args.passo ? execucaoDe(raiz, caminho, crew) : null;
115
+ if (!run) return [linha];
116
+ const pasta = path.resolve(raiz, 'crews', crew, 'output', run);
117
+ return [await anotar(pasta, { crew, run, em }, (registro) => comPasso(registro, { n: Number(args.passo), arquivo: caminho, em })), linha];
118
+ }
119
+
120
+ /** A pasta da execução; a que nasce agora ganha o registro (regras 1 e 3 da U5c: a que já existia fica como está). */
121
+ async function pasta(raiz, crew, args, agora) {
122
+ const run = args.run || novoRun(agora, pastasDe(path.resolve(raiz, 'crews', crew, 'output')));
123
+ const rel = `crews/${crew}/output/${run}`;
124
+ const nova = criarPasta(path.resolve(raiz, rel));
125
+ const base = { crew, run, tema: limparTema(args.tema), passos: args.passos ? Number(args.passos) : null, em: agora.toISOString() };
126
+ return [nova ? await anotar(path.resolve(raiz, rel), base, (registro) => registro) : null, ok(rel)];
127
+ }
128
+
129
+ async function responder(raiz, crew, args, agora) {
130
+ if (args.acao === 'saida') return [saida(raiz, crew, args)];
131
+ if (args.acao === 'entrada') return [entrada(raiz, crew, args)];
132
+ if (args.acao === 'conferir') return conferirERegistrar(raiz, crew, args, agora.toISOString());
133
+ return pasta(raiz, crew, args, agora);
101
134
  }
102
135
 
103
136
  /**
104
137
  * @param {string[]} argv
105
138
  * @param {object} [deps] `cwd` (a pasta do projeto), `escrever` e `agora` (o relógio, para o id da execução)
106
- * @returns {number} 0 = a linha `CAMINHO:` saiu · 1 = erro de uso, ou falha ao ler ou criar pasta
107
- * (a linha de uso e o motivo, ou só o erro; sem linha `CAMINHO:`)
139
+ * @returns {Promise<number>} 0 = a linha `CAMINHO:` saiu · 1 = erro de uso, ou falha ao ler ou
140
+ * criar pasta (a linha de uso e o motivo, ou só o erro; sem linha `CAMINHO:`)
108
141
  */
109
- export function main(argv, deps = {}) {
142
+ export async function main(argv, deps = {}) {
110
143
  const { cwd = process.cwd(), escrever = (s) => process.stdout.write(`${s}\n`), agora = () => new Date() } = deps;
111
144
  const args = lerArgs(argv);
112
145
  const local = localizar(cwd, args);
@@ -116,7 +149,7 @@ export function main(argv, deps = {}) {
116
149
  return 1;
117
150
  }
118
151
  try {
119
- escrever(responder(cwd, local.crew, args, agora));
152
+ (await responder(cwd, local.crew, args, agora())).filter(Boolean).forEach((linha) => escrever(linha));
120
153
  return 0;
121
154
  } catch (erro) {
122
155
  escrever(`Não consegui resolver o caminho: ${limpar(erro?.message ?? erro)}`);
@@ -124,4 +157,4 @@ export function main(argv, deps = {}) {
124
157
  }
125
158
  }
126
159
 
127
- if (ehPrincipal(import.meta.url)) process.exitCode = main(process.argv.slice(2));
160
+ if (ehPrincipal(import.meta.url)) process.exitCode = await main(process.argv.slice(2));
@@ -6,6 +6,7 @@ import { lerFrontmatter, semBom } from '../verificar/leitura.mjs';
6
6
  import { itensDeProibicao } from '../verificar/proibicoes.mjs';
7
7
  import { idDoAgente, lerBruto, listaDe } from './crew.mjs';
8
8
  import { lerCsv } from './edicoes.mjs';
9
+ import { achadoDoHistorico } from './historico.mjs';
9
10
 
10
11
  const EXPORTACAO = new Set(['pdf', 'csv', 'formatted-post']);
11
12
  const plural = (n, um, varios) => (n === 1 ? um : varios);
@@ -146,6 +147,7 @@ const CONFERENCIAS = [
146
147
  ['nome-de-agente', nomeDeAgente], ['manifesto', manifesto], ['formato', formato], ['fontes', fontes],
147
148
  ['proibicao', proibicao], ['irreversivel', semMarca], ['sem-revisao', semRevisao],
148
149
  ['sem-aprovacao-final', semAprovacaoFinal], ['publica-antes', publicaAntes], ['passo-faltando', passoFaltando],
150
+ ['historico', achadoDoHistorico],
149
151
  ];
150
152
 
151
153
  /**
@@ -8,6 +8,7 @@ import { lerFrontmatter, semAcento, semBom } from '../verificar/leitura.mjs';
8
8
  import { ehRevisaoHumana, itensDeProibicao, lerLinha } from '../verificar/proibicoes.mjs';
9
9
  import { comChave, comFonte, comSufixo, comTitulo, montarCsv } from './edicoes.mjs';
10
10
  import { erroAntesDeGravar } from './gravar.mjs';
11
+ import { historico } from './historico.mjs';
11
12
 
12
13
  const MSG = {
13
14
  semValor: (item) => `Sobra ou falta valor em --aplicar "${item.escrito}". Veja os itens com --ajuda.`,
@@ -134,7 +135,7 @@ function proibicao(crew, item, plano) {
134
135
  return plano.trocar(memoria, crew.memoria, novo);
135
136
  }
136
137
 
137
- const ACOES = { manifesto, nome, formato, fonte, proibicao, irreversivel };
138
+ const ACOES = { manifesto, nome, formato, fonte, proibicao, irreversivel, historico };
138
139
 
139
140
  /**
140
141
  * Valida os itens e monta as mudanças, sem gravar.
@@ -13,9 +13,10 @@ export const AJUDA = [
13
13
  ' proibicao:<n>=<trecho> o trecho do item n vira trava do verificador',
14
14
  ' proibicao:<n>=revisao-humana o item n fica para o revisor',
15
15
  ' irreversivel:<passo> marca o passo que publica ou envia',
16
+ ' historico:<execução>=<tema> põe no histórico a execução que ficou sem linha',
16
17
  ];
17
18
 
18
- export const TIPOS = ['manifesto', 'nome', 'formato', 'fonte', 'proibicao', 'irreversivel'];
19
+ export const TIPOS = ['manifesto', 'nome', 'formato', 'fonte', 'proibicao', 'irreversivel', 'historico'];
19
20
  const OPCAO = /^--(crew|aplicar|ajuda)(?:=(.*))?$/s;
20
21
  const ITEM = /^([a-z-]+)(?::([^=]*))?(?:=(.*))?$/s;
21
22
 
@@ -1,5 +1,5 @@
1
- // Leitura de uma crew para o conserto: `crew.yaml`, os passos do pipeline, os agentes, o manifesto
2
- // e a memória. A leitura é tolerante (aceita as formas que versões antigas gravaram) e nunca grava.
1
+ // Leitura de uma crew para o conserto: `crew.yaml`, os passos do pipeline, os agentes, o manifesto,
2
+ // a memória e o histórico (`runs.md`). A leitura é tolerante (aceita as formas que versões antigas gravaram) e nunca grava.
3
3
  // Spec: fase-u4a-conserto-de-crews.md, regra 5 (repositório do OpenCrew).
4
4
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
5
5
  import path from 'node:path';
@@ -121,6 +121,7 @@ export function lerCrew(raiz, crew) {
121
121
  agentes: agentesDe(pasta),
122
122
  csv: lerBruto(arquivo('crew-party.csv')),
123
123
  memoria: lerBruto(arquivo('_memory', 'memories.md')),
124
- arquivos: { yaml: arquivo('crew.yaml'), csv: arquivo('crew-party.csv'), memoria: arquivo('_memory', 'memories.md') },
124
+ runs: lerBruto(arquivo('_memory', 'runs.md')),
125
+ arquivos: { yaml: arquivo('crew.yaml'), csv: arquivo('crew-party.csv'), memoria: arquivo('_memory', 'memories.md'), runs: arquivo('_memory', 'runs.md') },
125
126
  };
126
127
  }
@@ -0,0 +1,102 @@
1
+ // O histórico no conserto: pastas de execução sem linha no `runs.md` (achado), pasta vazia e linha
2
+ // sem pasta (só apontadas), e o item que grava a linha "Registrada depois". Nunca apaga nem cria
3
+ // registro de execução.
4
+ // Spec: fase-u5c-execucao-registrada.md, regras 12 e 13 (repositório do OpenCrew).
5
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
6
+ import path from 'node:path';
7
+ import { REGISTRADA_DEPOIS, comLinha, dataDe, linhaDe, runsDoHistorico } from '../execucao/historico.mjs';
8
+ import { RUN } from '../caminho/argumentos.mjs';
9
+ import { ARQUIVO, limparTema } from '../execucao/registro.mjs';
10
+
11
+ const MSG = {
12
+ semLinha: (n) => `${n} ${n === 1 ? 'execução' : 'execuções'} sem linha no histórico.`,
13
+ vazias: (runs) => `${runs.length} ${runs.length === 1 ? 'execução abandonada (pasta vazia)' : 'execuções abandonadas (pasta vazia)'}: ${runs.join(', ')}`,
14
+ semPasta: (runs) => `${runs.length} ${runs.length === 1 ? 'linha' : 'linhas'} do histórico sem a pasta da execução: ${runs.join(', ')} (só aponto: nada é apagado)`,
15
+ interrompida: (crew) => ` (interrompida: /opencrew retomar ${crew} continua de onde parou)`,
16
+ comoRegistrar: 'Para registrar: --aplicar "historico:<execução>=<tema>"',
17
+ semValor: (item) => `Sobra ou falta valor em --aplicar "${item.escrito}". Veja os itens com --ajuda.`,
18
+ semExecucao: (item) => `Pasta de execução não encontrada em --aplicar "${item.escrito}".`,
19
+ jaTemLinha: (run) => `A execução ${run} já tem linha no histórico.`,
20
+ };
21
+
22
+ const COM_DATA = /^\d{4}-\d{2}-\d{2}/;
23
+ const MAXIMO_DE_NOMES = 8;
24
+
25
+ const entradas = (pasta) => (existsSync(pasta) ? readdirSync(pasta, { withFileTypes: true }) : []);
26
+ const saidaDe = (crew) => path.join(crew.pasta, 'output');
27
+
28
+ /** O `status` do registro da pasta, ou `null` (sem registro, ou ilegível). */
29
+ function statusDe(pasta) {
30
+ try {
31
+ return JSON.parse(readFileSync(path.join(pasta, ARQUIVO), 'utf8')).status ?? null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /** Uma pasta da execução, como o usuário a reconhece: pelos arquivos dela (`v1/ata.md`); sem arquivo direto, `v1/`. */
38
+ function daPasta(execucao, nome) {
39
+ const arquivos = entradas(path.join(execucao, nome)).filter((e) => e.isFile()).map((e) => `${nome}/${e.name}`);
40
+ return arquivos.length ? arquivos : [`${nome}/`];
41
+ }
42
+
43
+ /** As pastas de `output/` que são de execução: o nome começa por uma data, ou a pasta tem registro. */
44
+ function execucoes(crew) {
45
+ const pastas = entradas(saidaDe(crew)).filter((e) => e.isDirectory()).map((e) => e.name).sort();
46
+ return pastas.map((run) => {
47
+ const pasta = path.join(saidaDe(crew), run);
48
+ // O registro (e um temporário dele) não conta como arquivo da execução: a pasta que só tem isso é abandonada.
49
+ const doRegistro = (nome) => nome === ARQUIVO || (nome.startsWith(`${ARQUIVO}.`) && nome.endsWith('.tmp'));
50
+ const topo = entradas(pasta).filter((e) => !doRegistro(e.name)).flatMap((e) => (e.isDirectory() ? daPasta(pasta, e.name) : [e.name])).sort();
51
+ return { run, topo, status: statusDe(pasta), ehExecucao: COM_DATA.test(run) || existsSync(path.join(pasta, ARQUIVO)) };
52
+ }).filter((e) => e.ehExecucao);
53
+ }
54
+
55
+ /** O que não bate entre as pastas e o `runs.md`. */
56
+ function situacao(crew) {
57
+ const comLinhaNoHistorico = runsDoHistorico(crew.runs);
58
+ const pastas = execucoes(crew);
59
+ const semLinha = pastas.filter((e) => !comLinhaNoHistorico.includes(e.run));
60
+ return {
61
+ semLinha: semLinha.filter((e) => e.topo.length),
62
+ vazias: semLinha.filter((e) => !e.topo.length).map((e) => e.run),
63
+ semPasta: comLinhaNoHistorico.filter((run) => !existsSync(path.join(saidaDe(crew), run))),
64
+ };
65
+ }
66
+
67
+ /** O que é só apontado: a pasta vazia e a linha sem pasta. */
68
+ function apontados({ vazias, semPasta }) {
69
+ return [...(vazias.length ? [MSG.vazias(vazias)] : []), ...(semPasta.length ? [MSG.semPasta(semPasta)] : [])];
70
+ }
71
+
72
+ function linhaDaPasta(crew, { run, topo, status }) {
73
+ const nomes = topo.length > MAXIMO_DE_NOMES ? [...topo.slice(0, MAXIMO_DE_NOMES), '…'] : topo;
74
+ return `${run}: ${nomes.join(', ')}${status === 'aberta' ? MSG.interrompida(crew.nome) : ''}`;
75
+ }
76
+
77
+ /** O achado `historico`: só existe quando há pasta com arquivos e sem linha. @returns {string[]|null} */
78
+ export function achadoDoHistorico(crew) {
79
+ const s = situacao(crew);
80
+ if (!s.semLinha.length) return null;
81
+ return [MSG.semLinha(s.semLinha.length), ...s.semLinha.map((e) => linhaDaPasta(crew, e)), ...apontados(s), MSG.comoRegistrar];
82
+ }
83
+
84
+ /** Sem o achado, o que é só apontado sai como nota: não deixa a crew pendente. @returns {string[]} */
85
+ export function notasDoHistorico(crew) {
86
+ const s = situacao(crew);
87
+ return s.semLinha.length ? [] : apontados(s).map((linha) => `Nota: ${linha}`);
88
+ }
89
+
90
+ /** O item `historico:<execução>=<tema>`: a linha "Registrada depois", na posição da data dela. */
91
+ export function historico(crew, item, plano) {
92
+ const run = item.alvo;
93
+ const pasta = path.join(saidaDe(crew), run);
94
+ if (!RUN.test(run) || !entradas(saidaDe(crew)).some((e) => e.isDirectory() && e.name === run)) return MSG.semExecucao(item);
95
+ const { runs } = crew.arquivos;
96
+ const texto = plano.texto(runs, crew.runs);
97
+ if (runsDoHistorico(texto).includes(run)) return MSG.jaTemLinha(run);
98
+ if (!limparTema(item.valor)) return MSG.semValor(item);
99
+ const data = dataDe(run, statSync(pasta).mtime); // id sem data: o dia em que a pasta mudou pela última vez
100
+ const linha = linhaDe({ data, run, tema: limparTema(item.valor), saida: '', score: '', resultado: REGISTRADA_DEPOIS });
101
+ return plano.trocar(runs, crew.runs, comLinha(texto, { crew: crew.nome, run, linha, porData: true }));
102
+ }
@@ -6,14 +6,15 @@
6
6
  // node _opencrew/core/scripts/conserto.mjs --crew "crews/<crew>" --aplicar "<item>" grava
7
7
  // Itens de --aplicar: manifesto · nome:<agente>=<Nome Sobrenome> · formato:<passo>=<formato> ·
8
8
  // fonte:<caminho>=<para que> · proibicao:<n>=<trecho> · proibicao:<n>=revisao-humana ·
9
- // irreversivel:<passo> (ver --ajuda).
10
- // Só grava `crew.yaml`, `crew-party.csv`, `agents/*.agent.md`, `pipeline/steps/*.md` e
11
- // `_memory/memories.md` da crew, e a cópia `<arquivo>.bak` de cada um (a cópia que já existe
12
- // não é sobrescrita). Nunca apaga.
9
+ // irreversivel:<passo> · historico:<execução>=<tema> (ver --ajuda).
10
+ // Só grava `crew.yaml`, `crew-party.csv`, `agents/*.agent.md`, `pipeline/steps/*.md`,
11
+ // `_memory/memories.md` e `_memory/runs.md` da crew, e a cópia `<arquivo>.bak` de cada um (a
12
+ // cópia que já existe não é sobrescrita). Nunca apaga.
13
13
  // Última linha da saída: CONSERTO:OK (nada a consertar) · CONSERTO:PENDENTE (há achados) ·
14
14
  // CONSERTO:APLICADO (tudo o que foi pedido está gravado) · CONSERTO:ERRO (nada foi gravado).
15
15
  // Código de saída: 0, menos com CONSERTO:ERRO (1).
16
- // Spec: fase-u4a-conserto-de-crews.md (repositório do OpenCrew).
16
+ // Specs: fase-u4a-conserto-de-crews.md e fase-u5c-execucao-registrada.md, regras 12 e 13
17
+ // (repositório do OpenCrew).
17
18
  import { existsSync } from 'node:fs';
18
19
  import path from 'node:path';
19
20
  import { ehPrincipal, erroDeUso } from './comum.mjs';
@@ -22,6 +23,7 @@ import { planejar } from './conserto/aplicar.mjs';
22
23
  import { AJUDA, lerArgs, lerItem, limpar } from './conserto/argumentos.mjs';
23
24
  import { lerCrew } from './conserto/crew.mjs';
24
25
  import { gravar } from './conserto/gravar.mjs';
26
+ import { notasDoHistorico } from './conserto/historico.mjs';
25
27
 
26
28
  const MSG = {
27
29
  estranho: (arg) => `Argumento desconhecido: ${limpar(arg)}. Veja as opções com --ajuda.`,
@@ -46,9 +48,9 @@ function erroDaChamada(raiz, args) {
46
48
  /** As linhas do diagnóstico: um bloco por achado e a linha de situação. */
47
49
  function relatorio(crew) {
48
50
  const achados = diagnosticar(crew);
49
- if (!achados.length) return [MSG.emDia(crew.nome), 'CONSERTO:OK'];
51
+ if (!achados.length) return [MSG.emDia(crew.nome), ...notasDoHistorico(crew), 'CONSERTO:OK'];
50
52
  const blocos = achados.flatMap(({ codigo, linhas: [titulo, ...resto] }) => ['', `[${codigo}] ${titulo}`, ...resto.map((l) => ` ${l}`)]);
51
- return [MSG.titulo(crew.nome, achados.length), ...blocos, '', 'CONSERTO:PENDENTE'];
53
+ return [MSG.titulo(crew.nome, achados.length), ...blocos, '', ...notasDoHistorico(crew), 'CONSERTO:PENDENTE'];
52
54
  }
53
55
 
54
56
  /** As linhas de um `--aplicar`: o que foi gravado, as cópias e a linha de situação. */
@@ -2,7 +2,8 @@
2
2
  // aparece. Arquivo da entrega é citado com caminho relativo à pasta do LEIA-ME; o de origem,
3
3
  // relativo ao projeto. Sem hora: as mesmas entradas geram os mesmos bytes. PT-BR fixo.
4
4
  // Specs: fase-u3a1-pasta-de-entrega.md, §4 e §6, e fase-u3a2-entrega-no-projeto.md, §4 e §6
5
- // (ressalvas, a cópia e "Sobre esta pasta"), no repositório do OpenCrew.
5
+ // (ressalvas, a cópia e "Sobre esta pasta"), e fase-u5c-execucao-registrada.md, regra 10 (o tema
6
+ // no título), no repositório do OpenCrew.
6
7
  import { CANAIS, EDITAVEIS, OUTROS } from './canais.mjs';
7
8
  import { frasesDePendencia } from './pendencias.mjs';
8
9
  import { NAO_EDITE_O_WORD, passosDe } from './passos.mjs';
@@ -97,6 +98,7 @@ function sobre(d) {
97
98
  /**
98
99
  * @param {object} d
99
100
  * @param {string} d.crew nome da crew · @param {string} d.run id da execução
101
+ * @param {string} [d.tema] o tema do registro da execução; com ele, o título é `{tema} ({run})`
100
102
  * @param {string[]} d.pastas os canais presentes, na ordem das seções
101
103
  * @param {object[]} d.arquivos `{ pasta, nome, tipo, origem, titulo, numerado }` — no LEIA-ME da
102
104
  * cópia, só os que foram copiados
@@ -113,7 +115,7 @@ function sobre(d) {
113
115
  export function montarLeiame(d) {
114
116
  const temMd = d.arquivos.some((a) => a.nome.toLowerCase().endsWith('.md'));
115
117
  const partes = [
116
- `# Entrega — ${d.crew} — ${d.run}${d.reentrega > 1 ? ` (reentrega ${d.reentrega})` : ''}`,
118
+ `# Entrega — ${d.crew} — ${d.tema ? `${d.tema} (${d.run})` : d.run}${d.reentrega > 1 ? ` (reentrega ${d.reentrega})` : ''}`,
117
119
  ...antesDeUsar(d),
118
120
  ...d.pastas.flatMap((pasta) => secaoDoCanal(d, pasta)),
119
121
  ...secaoDaPasta(d, OUTROS, 'Outros arquivos'),
@@ -35,6 +35,7 @@ import { DOCUMENTOS, converterDocumentos, naoConferidoDoWord } from './entrega/d
35
35
  import { MSG as COPIA, guardar } from './entrega/guardar.mjs';
36
36
  import { gravarEntrega } from './entrega/gravar.mjs';
37
37
  import { montarLeiame } from './entrega/leiame.mjs';
38
+ import { lerRegistro } from './execucao/registro.mjs';
38
39
  import { lembrarDestino } from './entrega/lembrar.mjs';
39
40
  import { nomear } from './entrega/nomes.mjs';
40
41
  import { alertasDeTamanho, naoConferido, pendenciasPorPasta } from './entrega/pendencias.mjs';
@@ -95,6 +96,7 @@ async function montar(raiz, args, itens, aceitas) {
95
96
  const presentes = new Set([...arquivos.map((a) => a.pasta), ...todas.keys()]);
96
97
  const dados = {
97
98
  crew: path.basename(path.resolve(raiz, args.crew)), run: args.run, arquivos, avisos,
99
+ tema: (await lerRegistro(path.resolve(raiz, args.crew, 'output', args.run)))?.tema ?? '',
98
100
  ...separarPendencias(todas, aceitas, args.aceitar),
99
101
  pastas: Object.keys(CANAIS).filter((c) => presentes.has(c)),
100
102
  alertas: await alertasDeTamanho(raiz, itens, arquivos, verificacao),
@@ -32,22 +32,29 @@ async function trocarNome(de, para, { renomear = fs.rename, esperar = pausa }) {
32
32
  }
33
33
 
34
34
  /**
35
- * Grava o estado num temporário da mesma pasta (`state.json.<pid>.tmp`) e troca o nome: quem lê
36
- * vê o arquivo antigo ou o novo, nunca um pela metade.
37
- * @param {string} arquivo caminho do `state.json`
38
- * @param {object} estado
35
+ * Grava o texto num temporário da mesma pasta (`<arquivo>.<pid>.tmp`) e troca o nome: quem lê vê
36
+ * o arquivo antigo ou o novo, nunca um pela metade. Quando desiste, o arquivo fica como estava, o
37
+ * temporário é apagado e o erro sobe.
39
38
  * @param {{ renomear?: Function, esperar?: Function }} [deps] SÓ PARA TESTE: a troca de nome
40
39
  * (`(de, para) => Promise`) e a espera entre as tentativas (`(ms) => Promise`)
41
- * @returns {Promise<boolean>} `false` quando desistiu: o arquivo fica como estava e o temporário é apagado
42
40
  */
43
- export async function gravarEstado(arquivo, estado, deps = {}) {
41
+ export async function gravarTexto(arquivo, texto, deps = {}) {
44
42
  const temporario = `${arquivo}.${process.pid}.tmp`;
45
43
  try {
46
- await fs.writeFile(temporario, `${JSON.stringify(estado, null, 2)}\n`);
44
+ await fs.writeFile(temporario, texto);
47
45
  await trocarNome(temporario, arquivo, deps);
48
- return true;
49
- } catch {
46
+ } catch (erro) {
50
47
  await fs.rm(temporario, { force: true }).catch(() => {});
51
- return false;
48
+ throw erro;
52
49
  }
53
50
  }
51
+
52
+ /**
53
+ * Grava o `state.json`, inteiro ou nada (ver `gravarTexto`).
54
+ * @param {string} arquivo caminho do `state.json`
55
+ * @param {object} estado
56
+ * @returns {Promise<boolean>} `false` quando desistiu
57
+ */
58
+ export async function gravarEstado(arquivo, estado, deps = {}) {
59
+ return gravarTexto(arquivo, `${JSON.stringify(estado, null, 2)}\n`, deps).then(() => true, () => false);
60
+ }
@@ -0,0 +1,62 @@
1
+ // Linha de comando do `execucao.mjs`: a crew, a ação, as opções e a linha de uso.
2
+ // Spec: fase-u5c-execucao-registrada.md, §3 (repositório do OpenCrew).
3
+ import { MSG } from '../comum.mjs';
4
+ import { RUN, limpar } from '../caminho/argumentos.mjs';
5
+
6
+ export const USO = 'Uso: node _opencrew/core/scripts/execucao.mjs <crew> <marcar|fechar|retomar> [opções]';
7
+ export const ACOES = ['marcar', 'fechar', 'retomar'];
8
+ /** Os resultados que cada evento do `marcar` aceita. */
9
+ export const RESULTADOS = { checkpoint: ['aprovado', 'corrigido', 'pulado'], revisao: ['aprovado', 'rejeitado'] };
10
+ export const FECHOS = ['aprovado', 'rejeitado', 'abortado', 'publicado'];
11
+
12
+ const OPCAO = /^--(run|passo|evento|resultado|nota|saida|tema)(?:=(.*))?$/s;
13
+ const INTEIRO = /^[1-9]\d{0,8}$/;
14
+ const lista = (itens) => `${itens.slice(0, -1).join(', ')} ou ${itens.at(-1)}`;
15
+
16
+ /**
17
+ * `argv` → `{ crew, acao, run, passo, evento, resultado, nota, saida, tema }`. Os dois primeiros
18
+ * argumentos soltos são a crew e a ação. Opção vale como `--nome valor` e `--nome=valor`; opção
19
+ * ausente fica `undefined`.
20
+ */
21
+ export function lerArgs(argv) {
22
+ const soltos = [];
23
+ const opcoes = {};
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const [, nome, colado] = argv[i].match(OPCAO) ?? [];
26
+ if (!nome) soltos.push(argv[i]);
27
+ else if (colado !== undefined) opcoes[nome] = colado;
28
+ else opcoes[nome] = i + 1 < argv.length && !OPCAO.test(argv[i + 1]) ? argv[++i] : '';
29
+ }
30
+ const [crew, acao] = soltos.filter((s) => !s.startsWith('--'));
31
+ return { crew, acao, ...opcoes };
32
+ }
33
+
34
+ function erroDoMarcar({ passo, evento, resultado }) {
35
+ if (!passo) return MSG.faltaOpcao('--passo');
36
+ if (!INTEIRO.test(passo)) return 'O --passo é um número inteiro a partir de 1.';
37
+ if (!evento) return MSG.faltaOpcao('--evento');
38
+ if (!RESULTADOS[evento]) return 'O --evento é checkpoint ou revisao.';
39
+ if (!resultado) return MSG.faltaOpcao('--resultado');
40
+ return RESULTADOS[evento].includes(resultado) ? null : `O --resultado de ${evento} é ${lista(RESULTADOS[evento])}.`;
41
+ }
42
+
43
+ function erroDoFechar({ resultado }) {
44
+ if (!resultado) return MSG.faltaOpcao('--resultado');
45
+ return FECHOS.includes(resultado) ? null : `O --resultado de fechar é ${lista(FECHOS)}.`;
46
+ }
47
+
48
+ /**
49
+ * O que falta ou está errado na linha de comando, antes de olhar o disco. Só o `retomar` dispensa
50
+ * o `--run` (sem ele, vale a execução aberta mais recente).
51
+ * @returns {string|null} o motivo em PT-BR, ou `null`
52
+ */
53
+ export function erroDeArgumentos(args) {
54
+ const { crew, acao, run } = args;
55
+ if (!crew) return 'Falta o nome da crew.';
56
+ if (!acao) return `Falta a ação. Ações: ${ACOES.join(', ')}.`;
57
+ if (!ACOES.includes(acao)) return `Ação desconhecida: ${limpar(acao)}. Ações: ${ACOES.join(', ')}.`;
58
+ if (run === '' || (acao !== 'retomar' && !run)) return MSG.faltaOpcao('--run');
59
+ if (run !== undefined && !RUN.test(run)) return 'O --run só aceita letras, dígitos, ponto, sublinhado e hífen.';
60
+ if (acao === 'marcar') return erroDoMarcar(args);
61
+ return acao === 'fechar' ? erroDoFechar(args) : null;
62
+ }
@@ -0,0 +1,76 @@
1
+ // A tabela do `runs.md` de uma crew, em funções puras: a linha de uma execução e o texto do
2
+ // arquivo com essa linha posta ou trocada. Nenhuma outra linha muda, byte a byte (fim de linha
3
+ // incluído). Usado pelo `execucao.mjs fechar` e pelo conserto do histórico.
4
+ // Spec: fase-u5c-execucao-registrada.md, §4 e regras 7 e 13 (repositório do OpenCrew).
5
+
6
+ const COLUNAS = '| Data | Run ID | Tema | Output | Score | Resultado |';
7
+ const TRACOS = '|------|--------|------|--------|-------|-----------|';
8
+ /** O `runs.md` que nasce quando a crew ainda não tem um. */
9
+ export const cabecalho = (crew) => `# Run History: ${crew}\n\n${COLUNAS}\n${TRACOS}\n`;
10
+
11
+ /** O `--resultado` do `fechar` como aparece na coluna Resultado. */
12
+ export const ROTULO = { aprovado: 'Aprovado', rejeitado: 'Rejeitado', abortado: 'Abortado', publicado: 'Publicado' };
13
+ export const REGISTRADA_DEPOIS = 'Registrada depois';
14
+
15
+ const celula = (texto) => String(texto ?? '').replace(/\|/g, '/').replace(/\s+/g, ' ').trim() || '—';
16
+ /** @param {{ data, run, tema, saida, score, resultado }} execucao célula vazia sai como `—` */
17
+ export const linhaDe = ({ data, run, tema, saida, score, resultado }) => `| ${[data, run, tema, saida, score, resultado].map(celula).join(' | ')} |`;
18
+
19
+ const dois = (n) => String(n).padStart(2, '0');
20
+ /** A data de uma execução: os 10 primeiros caracteres do `run_id`; id sem data, o dia de `quando`. */
21
+ export function dataDe(run, quando) {
22
+ const [doRun] = /^\d{4}-\d{2}-\d{2}/.exec(run) ?? [];
23
+ return doRun ?? `${quando.getFullYear()}-${dois(quando.getMonth() + 1)}-${dois(quando.getDate())}`;
24
+ }
25
+
26
+ const celulas = (linha) => (linha.trim().startsWith('|') ? linha.trim().replace(/\|$/, '').split('|').slice(1).map((c) => c.trim()) : null);
27
+ const ehTracos = (linha) => /^\s*\|[\s:|-]*-[\s:|-]*$/.test(linha);
28
+
29
+ /** Onde está a tabela: o índice da linha de traços e o da última linha de execução (ou o dos traços). */
30
+ function tabela(pedacos) {
31
+ const comRunId = (i) => i > 0 && (celulas(pedacos[i - 1]) ?? []).some((c) => /^run\s*id$/i.test(c));
32
+ let tracos = pedacos.findIndex((p, i) => ehTracos(p) && comRunId(i));
33
+ if (tracos < 0) tracos = pedacos.findIndex((p, i) => ehTracos(p) && i > 0 && celulas(pedacos[i - 1]));
34
+ if (tracos < 0) return null;
35
+ // Linha em branco no meio não encerra a tabela, se depois dela ainda há linha de execução.
36
+ let fim = tracos;
37
+ for (let i = tracos + 1; i < pedacos.length && (celulas(pedacos[i]) || !pedacos[i].trim()); i++) if (celulas(pedacos[i])) fim = i;
38
+ return { tracos, fim };
39
+ }
40
+
41
+ /** Os `run_id` que já têm linha no histórico. */
42
+ export function runsDoHistorico(texto) {
43
+ const pedacos = String(texto ?? '').split(/(?<=\n)/);
44
+ const t = tabela(pedacos);
45
+ return t ? pedacos.slice(t.tracos + 1, t.fim + 1).map((p) => celulas(p)?.[1]).filter(Boolean) : [];
46
+ }
47
+
48
+ /** Onde a linha entra: logo abaixo dos traços, ou (`porData`) antes da primeira execução mais antiga. */
49
+ function posicao(pedacos, { tracos, fim }, data) {
50
+ if (data === null) return tracos + 1;
51
+ for (let i = tracos + 1; i <= fim; i++) if (celulas(pedacos[i]) && (celulas(pedacos[i])[0] ?? '') < data) return i;
52
+ return fim + 1;
53
+ }
54
+
55
+ /**
56
+ * O texto do `runs.md` com a linha da execução: trocada, se o `run` já tem linha; senão, posta.
57
+ * @param {string|null} texto o arquivo como está no disco (`null`: não existe)
58
+ * @param {{ crew: string, run: string, linha: string, porData?: boolean }} dados `porData`: a linha
59
+ * entra na posição da data dela (conserto); sem isso, logo abaixo do cabeçalho (a mais nova em cima)
60
+ */
61
+ export function comLinha(texto, { crew, run, linha, porData = false }) {
62
+ if (texto === null || texto === '') return `${cabecalho(crew)}${linha}\n`;
63
+ const fimDeLinha = /\r\n/.test(texto) ? '\r\n' : '\n';
64
+ const pedacos = texto.split(/(?<=\n)/);
65
+ const t = tabela(pedacos);
66
+ if (!t) return `${texto}${texto.endsWith('\n') ? '' : fimDeLinha}${fimDeLinha}${[COLUNAS, TRACOS, linha].join(fimDeLinha)}${fimDeLinha}`;
67
+ const igual = pedacos.findIndex((p, i) => i > t.tracos && i <= t.fim && celulas(p)?.[1] === run);
68
+ if (igual >= 0) pedacos[igual] = `${linha}${/\r?\n$/.exec(pedacos[igual])?.[0] ?? ''}`;
69
+ else {
70
+ const i = posicao(pedacos, t, porData ? linha.split('|')[1].trim() : null);
71
+ // Depois de uma linha sem quebra no fim (a última do arquivo), a quebra vem antes da linha nova.
72
+ const semQuebra = i > 0 && !pedacos[i - 1].endsWith('\n');
73
+ pedacos.splice(i, 0, semQuebra ? `${fimDeLinha}${linha}` : `${linha}${fimDeLinha}`);
74
+ }
75
+ return pedacos.join('');
76
+ }