@aksp/opencrew 1.6.2 → 1.7.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 (71) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +70 -13
  3. package/package.json +2 -2
  4. package/src/cli.js +15 -38
  5. package/src/commands/init.js +41 -44
  6. package/src/commands/update.js +78 -75
  7. package/src/lib/blocos.js +148 -0
  8. package/src/lib/deteccao.js +69 -0
  9. package/src/lib/fsx.js +1 -55
  10. package/src/lib/ides.js +4 -0
  11. package/src/lib/legado.js +142 -0
  12. package/src/lib/manifest.js +67 -26
  13. package/src/lib/mcp.js +131 -0
  14. package/src/lib/migrations.js +77 -74
  15. package/src/lib/node-version.js +43 -0
  16. package/src/lib/prompts.js +25 -2
  17. package/src/lib/resumo.js +125 -0
  18. package/templates/.mcp.json +1 -1
  19. package/templates/AGENTS.md +20 -6
  20. package/templates/_opencrew/.opencrew-version +1 -1
  21. package/templates/_opencrew/core/best-practices/social-networks-publishing.md +14 -14
  22. package/templates/_opencrew/core/escritorio/animacao.js +64 -0
  23. package/templates/_opencrew/core/escritorio/app.js +137 -0
  24. package/templates/_opencrew/core/escritorio/cena.js +132 -0
  25. package/templates/_opencrew/core/escritorio/demo.js +79 -0
  26. package/templates/_opencrew/core/escritorio/escala.js +27 -0
  27. package/templates/_opencrew/core/escritorio/index.html +166 -0
  28. package/templates/_opencrew/core/escritorio/modelo-agentes.js +93 -0
  29. package/templates/_opencrew/core/escritorio/modelo-estado.js +71 -0
  30. package/templates/_opencrew/core/escritorio/modelo-mesas.js +81 -0
  31. package/templates/_opencrew/core/escritorio/modelo-pagina.js +95 -0
  32. package/templates/_opencrew/core/escritorio/modelo-textos.js +65 -0
  33. package/templates/_opencrew/core/escritorio/modelo-visao.js +91 -0
  34. package/templates/_opencrew/core/escritorio/modelo.js +29 -0
  35. package/templates/_opencrew/core/escritorio/painel.js +120 -0
  36. package/templates/_opencrew/core/escritorio/quadro.js +106 -0
  37. package/templates/_opencrew/core/escritorio/rota.js +62 -0
  38. package/templates/_opencrew/core/escritorio/rotulos.js +78 -0
  39. package/templates/_opencrew/core/escritorio/sprites-mesa.js +122 -0
  40. package/templates/_opencrew/core/escritorio/sprites-sala.js +92 -0
  41. package/templates/_opencrew/core/escritorio/sprites.js +187 -0
  42. package/templates/_opencrew/core/prompts/build.prompt.md +3 -3
  43. package/templates/_opencrew/core/prompts/export.prompt.md +1 -1
  44. package/templates/_opencrew/core/prompts/repair.prompt.md +7 -12
  45. package/templates/_opencrew/core/prompts/sherlock-shared.md +5 -5
  46. package/templates/_opencrew/core/runner.pipeline.md +76 -139
  47. package/templates/_opencrew/core/scripts/comum.mjs +49 -4
  48. package/templates/_opencrew/core/scripts/conferir-fontes/busca.mjs +42 -3
  49. package/templates/_opencrew/core/scripts/conferir-fontes/relatorio.mjs +18 -6
  50. package/templates/_opencrew/core/scripts/conferir-fontes.mjs +97 -39
  51. package/templates/_opencrew/core/scripts/escritorio/leitura.mjs +31 -0
  52. package/templates/_opencrew/core/scripts/escritorio/porta.mjs +98 -0
  53. package/templates/_opencrew/core/scripts/escritorio/projeto.mjs +29 -0
  54. package/templates/_opencrew/core/scripts/escritorio/servidor.mjs +78 -0
  55. package/templates/_opencrew/core/scripts/escritorio.mjs +117 -0
  56. package/templates/_opencrew/core/scripts/estado/argumentos.mjs +61 -0
  57. package/templates/_opencrew/core/scripts/estado/arquivo.mjs +53 -0
  58. package/templates/_opencrew/core/scripts/estado/decisao.mjs +56 -0
  59. package/templates/_opencrew/core/scripts/estado/elenco.mjs +58 -0
  60. package/templates/_opencrew/core/scripts/estado/nucleo.mjs +113 -0
  61. package/templates/_opencrew/core/scripts/estado/preferencia.mjs +24 -0
  62. package/templates/_opencrew/core/scripts/estado.mjs +96 -0
  63. package/templates/_opencrew/core/scripts/verificar.mjs +7 -4
  64. package/templates/_opencrew/core/skills.engine.md +7 -3
  65. package/templates/gitignore +1 -0
  66. package/templates/skills/blotato/SKILL.md +39 -10
  67. package/templates/skills/image-ai-generator/SKILL.md +18 -5
  68. package/templates/skills/image-ai-generator/scripts/generate.py +52 -10
  69. package/templates/skills/instagram-publisher/SKILL.md +4 -0
  70. package/templates/skills/opencrew-skill-creator/references/skill-format.md +1 -0
  71. package/templates/skills/resend/SKILL.md +52 -13
@@ -0,0 +1,53 @@
1
+ // Leitura e gravação de `crews/<crew>/state.json`: inteira ou nenhuma.
2
+ // Spec: fase-e1-escritorio-ao-vivo.md, regras 3 e 6 (repositório do OpenCrew).
3
+ import { promises as fs } from 'node:fs';
4
+
5
+ /** O Windows recusa a troca de nome com um destes códigos enquanto outro processo lê o arquivo. */
6
+ const RECUSAS = ['EPERM', 'EBUSY', 'EACCES'];
7
+ /** Depois da primeira tentativa: até 3 repetições, uma a cada 100 ms (cerca de 300 ms ao todo). */
8
+ const REPETICOES = 3;
9
+ const INTERVALO_MS = 100;
10
+
11
+ const pausa = (ms) => new Promise((seguir) => { setTimeout(seguir, ms); });
12
+
13
+ /** O estado gravado, ou `null` quando o arquivo falta ou está ilegível (pela metade, sem `agents` em lista). */
14
+ export async function lerEstado(arquivo) {
15
+ try {
16
+ const estado = JSON.parse((await fs.readFile(arquivo, 'utf8')).replace(/^\uFEFF/, ''));
17
+ return Array.isArray(estado?.agents) ? estado : null;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ async function trocarNome(de, para, { renomear = fs.rename, esperar = pausa }) {
24
+ for (let repeticao = 0; ; repeticao++) {
25
+ try {
26
+ return await renomear(de, para);
27
+ } catch (erro) {
28
+ if (!RECUSAS.includes(erro?.code) || repeticao === REPETICOES) throw erro;
29
+ await esperar(INTERVALO_MS);
30
+ }
31
+ }
32
+ }
33
+
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
39
+ * @param {{ renomear?: Function, esperar?: Function }} [deps] SÓ PARA TESTE: a troca de nome
40
+ * (`(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
+ */
43
+ export async function gravarEstado(arquivo, estado, deps = {}) {
44
+ const temporario = `${arquivo}.${process.pid}.tmp`;
45
+ try {
46
+ await fs.writeFile(temporario, `${JSON.stringify(estado, null, 2)}\n`);
47
+ await trocarNome(temporario, arquivo, deps);
48
+ return true;
49
+ } catch {
50
+ await fs.rm(temporario, { force: true }).catch(() => {});
51
+ return false;
52
+ }
53
+ }
@@ -0,0 +1,56 @@
1
+ // O que fazer com um evento: aplicar, montar o estado de novo antes, ou ignorar. Puro, como o
2
+ // núcleo: recebe o estado lido (ou `null`) e o evento, não toca em disco.
3
+ // Spec: fase-e1-escritorio-ao-vivo.md, regra 5 e §6 (repositório do OpenCrew).
4
+ import { proximoEstado } from './nucleo.mjs';
5
+
6
+ /** Os motivos de `ESTADO:IGNORADO` (o texto depois do travessão). */
7
+ export const MOTIVO = {
8
+ desligado: 'escritório desligado (ligue com /opencrew dashboard)',
9
+ semElenco: 'crew-party.csv ausente ou sem agentes',
10
+ agenteDesconhecido: (id, ids) => `agente "${id}" não está no elenco da crew. Ids válidos: ${ids.join(', ')}`,
11
+ pularSemAgente: (ids) => `o evento pular precisa de --agente. Ids válidos: ${ids.join(', ')}`,
12
+ semEstado: 'sem estado desta execução',
13
+ naoGravou: 'não foi possível gravar o estado',
14
+ inesperado: (erro) => `erro inesperado: ${erro?.message ?? erro}`,
15
+ };
16
+
17
+ const USAM_AGENTE = ['passo', 'checkpoint', 'pular'];
18
+ /** Eventos que montam o estado quando ele falta; os outros são ignorados sem ele. */
19
+ const MONTAM = ['iniciar', 'passo', 'checkpoint'];
20
+ const ENCERRADA = ['completed', 'failed'];
21
+
22
+ function motivoParaIgnorar(anterior, { tipo, agente, elenco }) {
23
+ const ids = elenco.map((a) => a.id);
24
+ if (!ids.length) return MOTIVO.semElenco;
25
+ const pedeAgente = USAM_AGENTE.includes(tipo);
26
+ if (pedeAgente && agente && !ids.includes(agente)) return MOTIVO.agenteDesconhecido(agente, ids);
27
+ if (tipo === 'pular' && !agente) return MOTIVO.pularSemAgente(ids);
28
+ return anterior || MONTAM.includes(tipo) ? null : MOTIVO.semEstado;
29
+ }
30
+
31
+ /**
32
+ * `passo` e `checkpoint` montam o estado de novo quando ele falta ou está ilegível, quando o
33
+ * `passo --n 1` chega sobre uma execução encerrada, e quando o agente do evento está no elenco
34
+ * mas não no estado gravado (elenco editado sem um `iniciar` depois).
35
+ */
36
+ function pedeRecriar(anterior, { tipo, n, agente }) {
37
+ if (tipo !== 'passo' && tipo !== 'checkpoint') return false;
38
+ if (!anterior) return true;
39
+ if (tipo === 'passo' && n === 1 && ENCERRADA.includes(anterior.status)) return true;
40
+ return Boolean(agente) && !anterior.agents.some((a) => a?.id === agente);
41
+ }
42
+
43
+ /**
44
+ * @param {object|null} anterior o estado lido do `state.json`; `null` se falta ou está ilegível
45
+ * @param {object} evento o evento do núcleo, sempre com `crew`, `elenco` e `agora`
46
+ * @returns {{ ignorado: string } | { estado: object, recriado: boolean }} o motivo para não
47
+ * mexer em nada, ou o estado a gravar (`recriado`: foi montado de novo, como no `iniciar`,
48
+ * herdando só o `step.total`)
49
+ */
50
+ export function decidir(anterior, evento) {
51
+ const motivo = motivoParaIgnorar(anterior, evento);
52
+ if (motivo) return { ignorado: motivo };
53
+ const recriado = pedeRecriar(anterior, evento);
54
+ const base = recriado ? proximoEstado(null, { ...evento, tipo: 'iniciar', passos: anterior?.step?.total }) : anterior;
55
+ return { estado: proximoEstado(base, evento), recriado };
56
+ }
@@ -0,0 +1,58 @@
1
+ // O elenco de uma crew, lido de `crew-party.csv` (cabeçalho id,displayName,title,icon,path,execution;
2
+ // crew antiga pode não ter a coluna `id`).
3
+ // Spec: fase-e1-escritorio-ao-vivo.md, regra 2 (repositório do OpenCrew).
4
+ import { promises as fs } from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ /**
8
+ * Texto CSV → linhas, cada uma com os seus campos (sem as aspas e sem espaço nas pontas). Entre
9
+ * aspas o campo pode ter vírgula, quebra de linha e `""` (uma aspa). Uma passada só, caractere a
10
+ * caractere: aspa que não fecha leva o resto do texto para o campo, nunca trava.
11
+ */
12
+ export function lerCsv(texto) {
13
+ const linhas = [];
14
+ let linha = [];
15
+ let campo = '';
16
+ let aspas = false;
17
+ const fechaCampo = () => { linha.push(campo.trim()); campo = ''; };
18
+ const fechaLinha = () => { fechaCampo(); linhas.push(linha); linha = []; };
19
+ for (let i = 0; i < texto.length; i++) {
20
+ const c = texto[i];
21
+ if (c === '"' && aspas && texto[i + 1] === '"') campo += texto[i++];
22
+ else if (c === '"') aspas = !aspas;
23
+ else if (aspas || !',\r\n'.includes(c)) campo += c;
24
+ else if (c === ',') fechaCampo();
25
+ else if (c === '\n' || texto[i + 1] !== '\n') fechaLinha(); // `\r\n` fecha a linha uma vez só
26
+ }
27
+ if (campo || linha.length) fechaLinha();
28
+ return linhas;
29
+ }
30
+
31
+ /** O id que o nome do arquivo do agente dá: `./agents/x.agent.md` → `x`; outro caminho, nenhum. */
32
+ const idDoCaminho = (caminho = '') => caminho.match(/([^/\\]+)\.agent\.md$/i)?.[1] ?? '';
33
+
34
+ /**
35
+ * O elenco, na ordem do arquivo: `[{ id, name, icon }]`. As colunas `id`, `displayName`, `icon` e
36
+ * `path` são achadas pelo nome no cabeçalho. Linha sem `id` (crew antiga, de antes de o cabeçalho
37
+ * ser fixo) usa o nome do arquivo em `path`. Sem nenhum dos dois, ou com `id` repetido, a linha
38
+ * fica de fora; sem `displayName`, o nome é o `id`.
39
+ */
40
+ export function elencoDoCsv(texto) {
41
+ const [cabecalho = [], ...linhas] = lerCsv(texto.replace(/^\uFEFF/, ''));
42
+ const colunas = ['id', 'displayname', 'icon', 'path'].map((c) => cabecalho.findIndex((h) => h.toLowerCase() === c));
43
+ const [id, nome, icone, caminho] = colunas;
44
+ const vistos = new Set();
45
+ return linhas
46
+ .map((l) => ({ l, i: l[id] || idDoCaminho(l[caminho]) }))
47
+ .map(({ l, i }) => ({ id: i, name: l[nome] || i, icon: l[icone] ?? '' }))
48
+ .filter((a) => a.id && !vistos.has(a.id) && vistos.add(a.id));
49
+ }
50
+
51
+ /** O elenco da crew; lista vazia quando o `crew-party.csv` falta, não abre ou não tem agentes. */
52
+ export async function lerElenco(pastaDaCrew) {
53
+ try {
54
+ return elencoDoCsv(await fs.readFile(path.join(pastaDaCrew, 'crew-party.csv'), 'utf8'));
55
+ } catch {
56
+ return [];
57
+ }
58
+ }
@@ -0,0 +1,113 @@
1
+ // Núcleo puro do estado da execução: (estado, evento) → estado novo. Não lê relógio nem disco:
2
+ // a hora chega em `evento.agora` e o elenco em `evento.elenco`. O estado recebido nunca é alterado.
3
+ // Spec: fase-e1-escritorio-ao-vivo.md, regras 1 e 2 e §4 (repositório do OpenCrew).
4
+
5
+ /** Agente que ainda não entregou. `delivering` só existe em arquivo da 1.6.x. */
6
+ const ATIVOS = ['working', 'checkpoint', 'delivering'];
7
+ /** Quem o `falhar` derruba. */
8
+ const EM_CURSO = ['working', 'checkpoint'];
9
+
10
+ const texto = (v) => (typeof v === 'string' ? v : '');
11
+ const aPartirDe = (minimo, v) => (Number.isInteger(v) && v >= minimo ? v : null);
12
+
13
+ function agenteNormal(a) {
14
+ return { id: texto(a?.id), name: texto(a?.name), icon: texto(a?.icon), status: texto(a?.status) || 'idle', label: texto(a?.label) };
15
+ }
16
+
17
+ /** O estado lido, no formato de hoje: agentes sem `desk` e com `label`, `step` completo. */
18
+ function normalizar(e) {
19
+ const { step, handoff } = e;
20
+ return {
21
+ ...e,
22
+ crew: texto(e.crew),
23
+ status: texto(e.status),
24
+ step: { current: aPartirDe(0, step?.current) ?? 0, total: aPartirDe(1, step?.total), label: texto(step?.label) },
25
+ agents: (Array.isArray(e.agents) ? e.agents : []).map(agenteNormal),
26
+ handoff: handoff && typeof handoff === 'object' ? handoff : null,
27
+ startedAt: e.startedAt ?? null,
28
+ };
29
+ }
30
+
31
+ /** Só os campos da §4, na ordem dela; o que é de um status só não sobrevive a ele. */
32
+ function fechar(e, agora) {
33
+ return {
34
+ crew: e.crew,
35
+ status: e.status,
36
+ step: e.step,
37
+ agents: e.agents,
38
+ handoff: e.handoff,
39
+ ...(e.status === 'failed' ? { motivo: texto(e.motivo) } : {}),
40
+ startedAt: e.startedAt,
41
+ updatedAt: agora,
42
+ ...(e.status === 'completed' ? { completedAt: e.completedAt } : {}),
43
+ ...(e.status === 'failed' ? { failedAt: e.failedAt } : {}),
44
+ };
45
+ }
46
+
47
+ const trocar = (agents, quem, status) => agents.map((a) => (quem(a) ? { ...a, status } : a));
48
+
49
+ /** `--n` grava `step.current`; `--rotulo` grava `step.label` (sem ele: vazio). */
50
+ const comPasso = (step, { n, rotulo }) => ({ ...step, current: aPartirDe(1, n) ?? step.current, label: texto(rotulo) });
51
+
52
+ function iniciar(_anterior, { crew, elenco, passos, agora }) {
53
+ return {
54
+ crew: texto(crew),
55
+ status: 'running',
56
+ step: { current: 0, total: aPartirDe(1, passos), label: '' },
57
+ agents: elenco.map((a) => agenteNormal({ ...a, status: 'idle', label: '' })),
58
+ handoff: null,
59
+ startedAt: agora,
60
+ };
61
+ }
62
+
63
+ function passo(estado, evento) {
64
+ const base = { ...estado, status: 'running', step: comPasso(estado.step, evento) };
65
+ const alvo = evento.agente ? estado.agents.find((a) => a.id === evento.agente) : null;
66
+ if (!alvo) return base;
67
+ const outros = estado.agents.filter((a) => a !== alvo && ATIVOS.includes(a.status));
68
+ const agents = estado.agents.map((a) => {
69
+ if (a === alvo) return { ...a, status: 'working', label: texto(evento.rotulo) };
70
+ return outros.includes(a) ? { ...a, status: 'done' } : a;
71
+ });
72
+ const bastao = { from: outros[0]?.id, to: alvo.id, message: texto(evento.mensagem), completedAt: evento.agora };
73
+ return { ...base, agents, handoff: outros.length ? bastao : estado.handoff };
74
+ }
75
+
76
+ function checkpoint(estado, evento) {
77
+ const agents = evento.agente ? trocar(estado.agents, (a) => a.id === evento.agente, 'checkpoint') : estado.agents;
78
+ return { ...estado, status: 'checkpoint', step: comPasso(estado.step, evento), agents };
79
+ }
80
+
81
+ function pular(estado, { agente }) {
82
+ return { ...estado, agents: agente ? trocar(estado.agents, (a) => a.id === agente, 'skipped') : estado.agents };
83
+ }
84
+
85
+ function concluir(estado, { agora }) {
86
+ return { ...estado, status: 'completed', completedAt: agora, agents: trocar(estado.agents, (a) => a.status !== 'skipped', 'done') };
87
+ }
88
+
89
+ function falhar(estado, { motivo, agora }) {
90
+ const agents = trocar(estado.agents, (a) => EM_CURSO.includes(a.status), 'failed');
91
+ return { ...estado, status: 'failed', failedAt: agora, motivo: texto(motivo), agents };
92
+ }
93
+
94
+ const TRANSICOES = { iniciar, passo, checkpoint, pular, concluir, falhar };
95
+
96
+ /** Os seis eventos da regra 2, na ordem em que o runner os usa. */
97
+ export const EVENTOS = Object.keys(TRANSICOES);
98
+
99
+ /**
100
+ * O estado depois do evento.
101
+ * @param {object|null} estado o estado atual, como está no `state.json` (da 1.6.x também); o
102
+ * `iniciar` não o lê
103
+ * @param {object} evento `{ tipo, agora, … }` · `tipo`: um dos `EVENTOS` · `agora`: a hora, em ISO
104
+ * · no `iniciar`: `crew`, `elenco` (`[{ id, name, icon }]`) e `passos` · nos outros, conforme o
105
+ * evento: `n`, `agente`, `rotulo`, `mensagem`, `motivo`. Agente que não está no estado vale como
106
+ * evento sem agente.
107
+ * @returns {object} o estado novo, no formato da §4 da spec (`step.total` vazio é `null`)
108
+ */
109
+ export function proximoEstado(estado, evento) {
110
+ if (!Object.hasOwn(TRANSICOES, evento.tipo)) throw new Error(`Evento desconhecido: ${evento.tipo}`);
111
+ const atual = evento.tipo === 'iniciar' ? null : normalizar(estado);
112
+ return fechar(TRANSICOES[evento.tipo](atual, evento), evento.agora);
113
+ }
@@ -0,0 +1,24 @@
1
+ // O escritório está ligado? A preferência mora em `_opencrew/_memory/preferences.md`.
2
+ // Spec: fase-e1-escritorio-ao-vivo.md, regra 9 (repositório do OpenCrew).
3
+ import { promises as fs } from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ // A linha da preferência: `- **Dashboard:** <valor>` (a que o onboarding grava) ou
7
+ // `Dashboard: <valor>`, sem diferenciar maiúsculas. Comentário e texto corrido não contam.
8
+ const LINHA = /^[ \t]*(?:[-*+][ \t]+)?(?:\*\*)?dashboard(?:\*\*)?:(?:\*\*)?[ \t]*(\S*)/im;
9
+
10
+ /**
11
+ * Pura: o texto de `preferences.md` liga o escritório? Vale a primeira linha da preferência. A marca
12
+ * de ordem de bytes que o PowerShell do Windows e alguns editores gravam no início do arquivo não
13
+ * faz parte da linha.
14
+ */
15
+ export const ligadoNoTexto = (texto) => texto.replace(/^\uFEFF/, '').match(LINHA)?.[1].toLowerCase() === 'enabled';
16
+
17
+ /** Desligado por padrão: sem o arquivo, sem a linha ou com outro valor, a resposta é `false`. */
18
+ export async function escritorioLigado(raiz) {
19
+ try {
20
+ return ligadoNoTexto(await fs.readFile(path.join(raiz, '_opencrew', '_memory', 'preferences.md'), 'utf8'));
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ // Estado da execução de uma crew, para o escritório ao vivo: grava `crews/<crew>/state.json`,
3
+ // e só ele. Quem grava é este script, um comando por evento — a IA não monta o JSON.
4
+ // Uso (na pasta do projeto): node _opencrew/core/scripts/estado.mjs <crew> <evento> [opções]
5
+ // iniciar [--passos N]
6
+ // passo [--n K] [--agente <id>] [--rotulo "<texto>"] [--mensagem "<texto>"]
7
+ // checkpoint [--n K] [--agente <id>] [--rotulo "<texto>"]
8
+ // pular --agente <id>
9
+ // concluir
10
+ // falhar [--motivo "<texto>"]
11
+ // <crew> é o nome da pasta em `crews/`. Só grava com `Dashboard: enabled` em
12
+ // `_opencrew/_memory/preferences.md`.
13
+ // Última linha da saída (o runner lê esta linha): ESTADO:OK, ESTADO:OK — estado recriado ou
14
+ // ESTADO:IGNORADO — <motivo>. Código de saída: 0 sempre que a linha ESTADO: sai · 1 = erro de uso
15
+ // (crew ou evento faltando, evento fora da lista, pasta sem `_opencrew/`, crew inexistente ou
16
+ // fora de `crews/`); com código 1 não há linha ESTADO: e nada é escrito.
17
+ // Spec: fase-e1-escritorio-ao-vivo.md (repositório do OpenCrew).
18
+ import { statSync } from 'node:fs';
19
+ import path from 'node:path';
20
+ import { MSG, dentroDoProjeto, ehPrincipal } from './comum.mjs';
21
+ import { USO, lerArgs, limparTexto } from './estado/argumentos.mjs';
22
+ import { EVENTOS } from './estado/nucleo.mjs';
23
+ import { MOTIVO, decidir } from './estado/decisao.mjs';
24
+ import { lerElenco } from './estado/elenco.mjs';
25
+ import { lerEstado, gravarEstado } from './estado/arquivo.mjs';
26
+ import { escritorioLigado } from './estado/preferencia.mjs';
27
+
28
+ const OK = 'ESTADO:OK';
29
+ const RECRIADO = 'ESTADO:OK — estado recriado';
30
+ /** A resposta é sempre uma linha só, mesmo que o motivo traga um id ou um erro com quebra. */
31
+ const ignorado = (motivo) => `ESTADO:IGNORADO — ${motivo}`.replace(/\s*[\r\n]+\s*/g, ' ');
32
+
33
+ const LISTA = EVENTOS.join(', ');
34
+
35
+ function ehPasta(caminho) {
36
+ try {
37
+ return statSync(caminho).isDirectory();
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Onde fica a crew, ou o erro de uso. A crew é uma pasta direta de `crews/` (`crews/<nome>`
45
+ * também vale): só assim o único arquivo escrito é `crews/<crew>/state.json`.
46
+ * @returns {{ erro: string } | { pasta: string, crew: string }}
47
+ */
48
+ function localizar(raiz, { crew, evento }) {
49
+ if (!crew) return { erro: 'Falta o nome da crew.' };
50
+ if (!evento) return { erro: `Falta o evento. Eventos: ${LISTA}.` };
51
+ if (!EVENTOS.includes(evento)) return { erro: `Evento desconhecido: ${limparTexto(evento)}. Eventos: ${LISTA}.` };
52
+ if (!ehPasta(path.join(raiz, '_opencrew'))) return { erro: MSG.semRaiz };
53
+ const base = path.resolve(raiz, 'crews');
54
+ const nome = crew.replace(/^crews[\\/]+/, '');
55
+ if (!dentroDoProjeto(base, nome)) return { erro: MSG.foraDoProjeto(limparTexto(crew)) };
56
+ const pasta = path.resolve(base, nome);
57
+ if (path.dirname(pasta) !== base || !ehPasta(pasta)) return { erro: MSG.crewNaoEncontrada(limparTexto(crew)) };
58
+ return { pasta, crew: path.basename(pasta) };
59
+ }
60
+
61
+ /** Confere a preferência, lê o elenco e o estado, decide e grava. Devolve a linha `ESTADO:`. */
62
+ async function responder({ raiz, pasta, crew }, args, deps) {
63
+ if (!(await escritorioLigado(raiz))) return ignorado(MOTIVO.desligado);
64
+ const agora = (deps.agora ?? (() => new Date().toISOString()))();
65
+ const evento = { tipo: args.evento, ...args.opcoes, crew, elenco: await lerElenco(pasta), agora };
66
+ const arquivo = path.join(pasta, 'state.json');
67
+ const decisao = decidir(await lerEstado(arquivo), evento);
68
+ if (decisao.ignorado) return ignorado(decisao.ignorado);
69
+ if (!(await gravarEstado(arquivo, decisao.estado, deps))) return ignorado(MOTIVO.naoGravou);
70
+ return decisao.recriado ? RECRIADO : OK;
71
+ }
72
+
73
+ /**
74
+ * @param {string[]} argv
75
+ * @param {object} [deps] `cwd` (a pasta do projeto) e `escrever`; SÓ PARA TESTE: `agora` (a hora,
76
+ * em ISO), `renomear` e `esperar` (a troca de nome e a espera da gravação)
77
+ * @returns {Promise<number>} 0 = a linha `ESTADO:` saiu · 1 = erro de uso (a linha de uso e o
78
+ * motivo, sem linha `ESTADO:`, nada escrito)
79
+ */
80
+ export async function main(argv, deps = {}) {
81
+ const { cwd = process.cwd(), escrever = (s) => process.stdout.write(`${s}\n`) } = deps;
82
+ const args = lerArgs(argv);
83
+ const local = localizar(cwd, args);
84
+ if (local.erro) {
85
+ escrever(USO);
86
+ escrever(local.erro);
87
+ return 1;
88
+ }
89
+ // O escritório nunca para a execução: o que der errado daqui em diante vira ESTADO:IGNORADO.
90
+ escrever(await responder({ raiz: cwd, ...local }, args, deps).catch((erro) => ignorado(MOTIVO.inesperado(erro))));
91
+ return 0;
92
+ }
93
+
94
+ if (ehPrincipal(import.meta.url)) {
95
+ main(process.argv.slice(2)).then((code) => { process.exitCode = code; });
96
+ }
@@ -11,10 +11,11 @@
11
11
  // obrigatória faltando, pasta sem `_opencrew/`, crew inexistente, crew ou caminho fora do
12
12
  // projeto, nenhum caminho da lista existe) ou erro que impediu a verificação inteira; com
13
13
  // código 1 não há linha VERIFICACAO:.
14
- // Specs: fase-u1-revisor-com-dentes.md e fase-r1-reparos-1-6-1.md (repositório do OpenCrew).
14
+ // Specs: fase-u1-revisor-com-dentes.md, fase-r1-reparos-1-6-1.md e fase-r2-update-e-envio-seguros.md
15
+ // (regra 23: "dentro do projeto" pelo texto ou pelo lugar real), no repositório do OpenCrew.
15
16
  import { existsSync } from 'node:fs';
16
17
  import path from 'node:path';
17
- import { erroDeUso, ehPrincipal } from './comum.mjs';
18
+ import { erroDeUso, ehPrincipal, relativoAoProjeto } from './comum.mjs';
18
19
  import { USO, lerArgs, lerItemDaLista } from './verificar/argumentos.mjs';
19
20
  import { lerItem } from './verificar/arquivos.mjs';
20
21
  import { lerLimites, lerDominioDoSite, semFrontmatter } from './verificar/leitura.mjs';
@@ -102,9 +103,11 @@ function semRepetidas(raiz, entradas) {
102
103
  });
103
104
  }
104
105
 
105
- /** Caminho absoluto de dentro do projeto aparece no relatório como o relativo. */
106
+ /** No relatório, o absoluto de dentro do projeto (também por link, junção ou nome curto) sai como o relativo. */
106
107
  function nomeNoRelatorio(raiz, arquivo) {
107
- return path.isAbsolute(arquivo) ? path.relative(raiz, arquivo).split(path.sep).join('/') : arquivo;
108
+ const relativo = relativoAoProjeto(raiz, arquivo);
109
+ const comoEscrito = !path.isAbsolute(arquivo) && path.resolve(raiz, relativo) === path.resolve(raiz, arquivo);
110
+ return comoEscrito ? arquivo : relativo;
108
111
  }
109
112
 
110
113
  function resumir(arquivos, naoTexto, notas) {
@@ -54,6 +54,7 @@ Frontmatter fields:
54
54
  - `dependencies`: Array of npm/pip packages to install
55
55
  - `env` (array): List of required environment variable names
56
56
  - `categories` (array): Classification tags (e.g., scraping, design, analytics)
57
+ - `side_effects` (string, optional): `irreversible` for a skill that publishes or sends — read by Operation 6
57
58
 
58
59
  Body: Markdown instructions injected into agent context at runtime.
59
60
 
@@ -382,7 +383,7 @@ For each skill declared in an agent's `.agent.md` frontmatter `skills:` field:
382
383
  1. **Skip native skills**: `web_search` and `web_fetch` do not need instruction injection —
383
384
  they are handled natively.
384
385
 
385
- 2. **Read each skill's SKILL.md** frontmatter only: Extract `name`, `description`, and `type` fields.
386
+ 2. **Read each skill's SKILL.md** frontmatter only: Extract `name`, `description`, `type` and `side_effects` fields.
386
387
 
387
388
  3. **Build the Tier 1 index** and append after all agent instructions:
388
389
  ```
@@ -394,8 +395,9 @@ For each skill declared in an agent's `.agent.md` frontmatter `skills:` field:
394
395
  you are invoking and the system will load its full instructions.
395
396
 
396
397
  - {skill-id}: {description from frontmatter} (type: {type})
397
- - {skill-id}: {description from frontmatter} (type: {type})
398
+ - {skill-id}: {description from frontmatter} (type: {type}) — irreversível: carregue as instruções desta skill e peça a confirmação antes de usar
398
399
  ```
400
+ The second form is for every skill with `side_effects: irreversible`.
399
401
 
400
402
  4. **Tier 2 loading** — When the step's instructions explicitly reference a skill
401
403
  (e.g., the step file says "use image-creator to render the slides"), OR when the
@@ -412,7 +414,9 @@ For each skill declared in an agent's `.agent.md` frontmatter `skills:` field:
412
414
  5. **Step-level skill hints**: If the step's frontmatter contains a `skills_needed:` field
413
415
  (e.g., `skills_needed: [image-creator]`), load Tier 2 for those skills immediately
414
416
  without waiting for the agent to request them. This allows the Architect to pre-declare
415
- which skills a step will need.
417
+ which skills a step will need. A skill with `side_effects: irreversible` always gets Tier 2
418
+ before its first use, even when no step names it: an MCP tool can be called without the body,
419
+ and the confirmation rules live there.
416
420
 
417
421
  6. **Missing skill handling**: If a skill listed in an agent's frontmatter was not resolved
418
422
  during Operation 5, skip it silently — the user was already warned during resolution.
@@ -8,4 +8,5 @@ _opencrew/_memory/company.md
8
8
  _opencrew/_memory/preferences.md
9
9
  _opencrew/_browser_profile/
10
10
  _opencrew/logs/
11
+ .opencrew-backup/
11
12
  .claude/settings.local.json
@@ -20,6 +20,7 @@ mcp:
20
20
  url: "https://mcp.blotato.com/mcp"
21
21
  headers:
22
22
  blotato-api-key: BLOTATO_API_KEY
23
+ side_effects: irreversible
23
24
  env:
24
25
  - BLOTATO_API_KEY
25
26
  categories: [social-media, automation, publishing, scheduling]
@@ -35,19 +36,47 @@ Use Blotato when you need to publish or schedule social media posts across multi
35
36
 
36
37
  You have access to Blotato for social media publishing.
37
38
 
38
- ### Key workflow
39
+ ### Workflow
39
40
 
40
- 1. Use `blotato_list_accounts` to get account IDs and platforms
41
- 2. If post includes images or videos, upload them with `blotato_upload_media` first and use the returned media IDs in `blotato_create_post`
42
- 3. Use `blotato_create_post` to publish or schedule
43
- 4. Use `blotato_get_post_status` to confirm success
41
+ Publishing is **irreversible**: a post cannot be taken back once it is live. The rule below is
42
+ about the **action**, whatever the tool is called on the server: before ANY call that publishes,
43
+ schedules or deletes, follow this order. The messages to the user are in PT-BR, as written here.
44
+
45
+ 1. List the connected accounts (`blotato_list_accounts`, read-only) to get the account IDs and
46
+ platforms. Send nothing to Blotato yet, not even media.
47
+ 2. **Preview (prévia)** — show the user exactly this, filled in:
48
+ ```
49
+ Vou publicar isto:
50
+ Contas: {conta} ({rede}), …
51
+ Quando: agora, ou agendado para {data e hora}
52
+ Texto ({N} caracteres): {texto}
53
+ Mídia: {arquivos}, ou nenhuma
54
+ Para publicar, responda com a palavra publicar. Qualquer outra resposta cancela.
55
+ ```
56
+ (`Quando`: write `agora` or `agendado para …`. `Mídia`: the file names, or `nenhuma`.)
57
+ 3. Wait for the word **publicar**. Any other answer — including silence, "ok" or "sim" — cancels:
58
+ say "Nada foi publicado." and stop.
59
+ 4. Only after the word: upload the media, if any (`blotato_upload_media`), then make **one single
60
+ call** that publishes or schedules (`blotato_create_post`; if the server takes one account per
61
+ call, once per account of the preview and never twice for the same account).
62
+ 5. On success: check the result (`blotato_get_post_status`) and save the post URL and ID to the
63
+ step output file immediately.
64
+ 6. On failure, timeout or missing answer: do NOT repeat the call again, in this step or in a retry.
65
+ Tell the user: "⚠️ Não recebi a confirmação do Blotato. A publicação pode já ter saído. Confira
66
+ no painel antes de tentar de novo. Não vou repetir sozinho."
67
+ 7. One confirmation is worth one publication. If this step runs again in the same run (a retry, or
68
+ back from a rejected review), show the preview again, after this line: "Este passo já tentou
69
+ publicar nesta execução. Confira se saiu antes de confirmar de novo." — and wait for the word.
70
+ 8. A call that **deletes** (a post, a scheduled post, media) follows the same order with its own
71
+ word: "Vou apagar isto: {o que será apagado}. Para apagar, responda com a palavra apagar.
72
+ Qualquer outra resposta cancela." Any other answer: "Nada foi apagado."
44
73
 
45
74
  ### Best practices
46
75
 
47
- - Always call `blotato_list_accounts` first to get valid account IDs
48
76
  - For scheduled posts, use ISO 8601 format for datetime
49
- - After posting, poll `blotato_get_post_status` until status is "published" or "scheduled"
50
- - If status is "failed", report the error details to the user
77
+ - After the publishing call, read `blotato_get_post_status` until status is "published" or
78
+ "scheduled" — reading the status is safe; calling `blotato_create_post` again is not
79
+ - If status is "failed", report the error details to the user and let them decide
51
80
 
52
81
  ### Requirements
53
82
 
@@ -57,7 +86,7 @@ You have access to Blotato for social media publishing.
57
86
  ## Available operations
58
87
 
59
88
  - **List Accounts** -- Retrieve connected social media accounts and their platform types
60
- - **Upload Media** -- Upload images and videos for use in posts
61
- - **Create Post** -- Publish or schedule a post to one or more platforms
89
+ - **Upload Media** -- Upload images and videos for use in posts (only after the word)
90
+ - **Create Post** -- Publish or schedule a post to one or more platforms (only after the word)
62
91
  - **Get Post Status** -- Monitor publishing status (published, scheduled, failed)
63
92
  - **Multi-platform Publishing** -- Post the same content across Instagram, LinkedIn, Twitter/X, TikTok, YouTube simultaneously
@@ -15,7 +15,7 @@ version: "1.0.0"
15
15
  script:
16
16
  path: scripts/generate.py
17
17
  runtime: python
18
- invoke: "python3 {skill_path}/scripts/generate.py --prompt \"{prompt}\" --output \"{output}\" --mode \"{mode}\""
18
+ invoke: "python3 {skill_path}/scripts/generate.py --prompt-file \"{prompt_file}\" --output \"{output}\" --mode \"{mode}\""
19
19
  env:
20
20
  - OPENROUTER_API_KEY
21
21
  categories: [assets, images, ai, generation]
@@ -56,11 +56,22 @@ Use the Image Generator when you need to create visual assets from text prompts.
56
56
  `python3` (macOS/Linux). **On Windows** use `py -3` instead (or `python` if the `py` launcher is
57
57
  not installed).
58
58
 
59
+ **The prompt goes in a file.** Write it with your file-writing tool, in UTF-8, next to the image
60
+ (`crews/{crew}/output/{run_id}/assets/image-name.prompt.txt`). Never put the prompt inside a shell
61
+ command: write it to a file and pass `--prompt-file`. The shell rewrites `$`, quotes and backticks
62
+ in a typed text (a price like "R$50" reaches the model wrong, and the image is still charged). The
63
+ old `--prompt` option is still accepted, for crews created before; do not use it.
64
+
65
+ **File names.** Every path in the command (`--prompt-file`, `--output`, `--reference`, `--batch`)
66
+ follows the safe-name rule (nome seguro) of `_opencrew/core/runner.pipeline.md` — letters, digits,
67
+ space and `. _ - / \ : ( )`, between double quotes. With any other character do not run the
68
+ command: ask the user to rename the file.
69
+
59
70
  ### Single image generation
60
71
 
61
72
  ```bash
62
73
  python3 {skill_path}/scripts/generate.py \
63
- --prompt "A detailed description of the image to generate" \
74
+ --prompt-file "crews/{crew}/output/{run_id}/assets/image-name.prompt.txt" \
64
75
  --output "crews/{crew}/output/{run_id}/assets/image-name.jpg" \
65
76
  --mode test
66
77
  ```
@@ -71,7 +82,7 @@ Use `--reference` to send a local image to the model as visual context. The mode
71
82
 
72
83
  ```bash
73
84
  python3 {skill_path}/scripts/generate.py \
74
- --prompt "A social media banner featuring the company logo prominently in the center" \
85
+ --prompt-file "crews/{crew}/output/{run_id}/assets/banner.prompt.txt" \
75
86
  --output "crews/{crew}/output/{run_id}/assets/banner.jpg" \
76
87
  --reference "crews/{crew}/assets/logo.png" \
77
88
  --mode production
@@ -87,7 +98,8 @@ python3 {skill_path}/scripts/generate.py \
87
98
  --mode production
88
99
  ```
89
100
 
90
- The batch JSON file should contain:
101
+ Write the batch JSON file with your file-writing tool, in UTF-8 (the prompts inside it never go
102
+ through the shell). It should contain:
91
103
  ```json
92
104
  [
93
105
  {"prompt": "Description of image 1", "output": "path/to/image1.jpg"},
@@ -115,13 +127,14 @@ Each item can optionally include a `"reference": "path/to/ref.png"` field.
115
127
 
116
128
  ## Available operations
117
129
 
118
- - **Single generation** — Generate one image from a text prompt
130
+ - **Single generation** — Generate one image from a text prompt saved in a file
119
131
  - **Batch generation** — Generate multiple images from a JSON batch file
120
132
  - **Mode selection** — Choose between test (cheap) and production (high-quality) models
121
133
  - **Reference image** — Send a logo/mascot/brand asset as visual context for the generation
122
134
 
123
135
  ## Error handling
124
136
 
137
+ - If the prompt file is missing or empty, or the batch file cannot be read (not UTF-8, invalid JSON), the script says so in one line and exits with code 1. Show the message to the user; nothing was generated or charged.
125
138
  - If `OPENROUTER_API_KEY` is not set, the script exits with an error message. Set it in your `.env` file or environment.
126
139
  - If the API returns an error, the script prints the error code and body, then exits with code 1.
127
140
  - If no image is found in the API response, the script reports which model was used and exits with code 1.