@aksp/opencrew 1.4.2 → 1.5.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 CHANGED
@@ -3,6 +3,40 @@
3
3
  All notable changes to opencrew are documented here.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/).
5
5
 
6
+ ## [1.5.0] — 2026-10-02
7
+
8
+ Trilha U1 "Revisor com dentes" — primeira melhoria vinda do uso real
9
+ (`docs/jornada/2026-10-02-uso-real.md`, `specs/fase-u1-revisor-com-dentes.md`).
10
+
11
+ ### Added
12
+ - **Verificador automático** (`_opencrew/core/scripts/verificar.mjs`, Node puro): mede o texto
13
+ ANTES do revisor usando os limites `constraints:` dos best-practices — título e meta description
14
+ do blog, legenda e hashtags do Instagram, slides do carrossel, post do LinkedIn, tweets, links
15
+ (alerta quando abaixo do mínimo). Bloqueia placeholders (`wa.me/55…9999…`, `[Empresa X]`,
16
+ `lorem ipsum`…), termos entre aspas em `## Proibições Explícitas` da memória da crew e
17
+ `[PREENCHER: …]`; alerta afirmações em 1ª pessoa com dado concreto (R$, %, ano passado,
18
+ "N clientes"). Relatório em PT-BR com valor medido × limite; última linha
19
+ `VERIFICACAO:OK | BLOQUEADA | AGUARDANDO_USUARIO`.
20
+ - **Regras de veracidade** injetadas em todo passo de criação: nunca inventar casos, depoimentos,
21
+ números ou histórias em 1ª pessoa — usar `[PREENCHER: o que falta]`.
22
+
23
+ ### Changed
24
+ - **Revisão com trava**: antes de todo passo com `on_reject`, o runner verifica **todas** as saídas
25
+ desde o redator (não só a entrada do revisor — no uso real as legendas nunca eram revisadas);
26
+ `VERIFICACAO:BLOQUEADA` força REJECT seja qual for a nota; no limite de ciclos o usuário escolhe
27
+ corrigir, aceitar (registrado) ou abortar; a aprovação final mostra o resumo e pede os
28
+ `[PREENCHER]`.
29
+ - `review.md`: o revisor copia os números do relatório (nunca estima), não aprova com bloqueio e
30
+ tem nota máxima 7/10 com alerta não resolvido. `copywriting.md` e o build: regra de não inventar.
31
+ - **Instagram em 4:5**: carrossel/feed agora 1080×1350 (a API do Instagram só publica de 4:5 a
32
+ 1,91:1), no máximo 10 slides; presets do `image-creator`, `template-designer` (e modelos-base),
33
+ `image-fetcher` e best-practices atualizados. Limites com nomes canônicos (`hashtags_max`).
34
+
35
+ ### Internal
36
+ - Docs de jornada (`docs/jornada/`: uso real, roteiro de teste U0, medições) e roadmap de trilhas U.
37
+ - Regras de dev 12 (limite só vale se medido) e 13 (PT-BR para o usuário); alerta de tamanho e
38
+ lint cobrem os scripts do runtime.
39
+
6
40
  ## [1.4.2] — 2026-10-02
7
41
 
8
42
  Hotfix "parar de causar dano" (Fase 1 da auditoria — `specs/fase-1-hotfix.md`).
package/README.md CHANGED
@@ -35,6 +35,11 @@ dentro da sua IDE.**
35
35
  - 🎛️ **Seleção inteligente de agentes** — o sistema analisa seu pedido e
36
36
  sugere quais agentes são necessários para aquela tarefa. Você confirma ou
37
37
  ajusta com um clique. Agentes pulados não gastam tokens naquele run.
38
+ - 🔎 **Revisor com dentes** — antes da revisão, um verificador automático mede o texto
39
+ (tamanho de título, meta description, legenda, hashtags, slides), barra placeholders,
40
+ termos que você proibiu e `[PREENCHER]` pendentes, e aponta afirmações a confirmar.
41
+ Bloqueio não passa, seja qual for a nota do revisor. A crew não inventa casos nem números:
42
+ quando falta um dado real, ela pergunta na aprovação final.
38
43
 
39
44
  ---
40
45
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aksp/opencrew",
3
- "version": "1.4.2",
3
+ "version": "1.5.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -1 +1 @@
1
- 1.4.2
1
+ 1.5.0
@@ -17,7 +17,10 @@ version: "1.0.0"
17
17
  5. Present 3 distinct hook options before drafting the body.
18
18
  6. Align completely with brand voice and audience-specific vocabulary.
19
19
  7. Write concise, one-idea sentences and short paragraphs.
20
- 8. Use specific numbers and concrete details instead of vague claims.
20
+ 8. Use specific numbers and concrete details instead of vague claims — but only REAL ones
21
+ (briefing, research with source, company profile). Never invent cases, testimonials, clients,
22
+ numbers or first-person stories: nunca invente; write `[PREENCHER: o que falta]` instead and
23
+ the user fills it in at final approval.
21
24
  9. Select one dominant psychological driver and anchor the piece to it.
22
25
  10. Deploy the appropriate framework (AIDA, PAS, BAB) based on the funnel stage.
23
26
  11. Inject an objection neutralizer immediately before the CTA.
@@ -88,7 +88,7 @@ Present all rendered images to the user or downstream agent. Include the design
88
88
  ## Platform Specifications
89
89
 
90
90
  ### Instagram Post / Carousel
91
- - **Viewport**: 1080 x 1440 (3:4 portrait)
91
+ - **Viewport**: 1080 x 1350 (4:5 portrait)
92
92
  - **Min font sizes**: Hero 58px, Heading 43px, Body 34px, Caption 24px
93
93
  - **Optimal slide count**: 5-10 slides. Under 5 feels incomplete, over 10 causes drop-off.
94
94
  - **Structure**: Hook on slide 1, CTA on last slide, value in between.
@@ -135,7 +135,7 @@ Present all rendered images to the user or downstream agent. Include the design
135
135
  DESIGN SYSTEM
136
136
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
137
137
  Platform: Instagram Carousel
138
- Viewport: 1080 x 1440
138
+ Viewport: 1080 x 1350
139
139
  Slides: 7 (hook + 5 content + CTA)
140
140
 
141
141
  Colors:
@@ -181,7 +181,7 @@ File: slide-01.html
181
181
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@500;700&display=swap');
182
182
  * { margin: 0; padding: 0; box-sizing: border-box; }
183
183
  body {
184
- width: 1080px; height: 1440px; overflow: hidden;
184
+ width: 1080px; height: 1350px; overflow: hidden;
185
185
  background: #1A1A2E;
186
186
  font-family: 'Inter', sans-serif;
187
187
  display: flex; flex-direction: column;
@@ -342,7 +342,7 @@ Design rationale: Clean white background matches LinkedIn's professional aesthet
342
342
 
343
343
  3. **Document design rationale.** After each completed design, briefly explain why you made the key visual choices: color rationale, font selection, layout strategy. This helps the user understand the design thinking and makes iteration faster.
344
344
 
345
- 4. **Match viewport exactly.** Body width and height in CSS must match the browser viewport resize dimensions exactly. A 1080x1440 carousel slide means body { width: 1080px; height: 1440px; }.
345
+ 4. **Match viewport exactly.** Body width and height in CSS must match the browser viewport resize dimensions exactly. A 1080x1350 carousel slide means body { width: 1080px; height: 1350px; }.
346
346
 
347
347
  ## Vocabulary Guidance
348
348
 
@@ -350,7 +350,7 @@ Design rationale: Clean white background matches LinkedIn's professional aesthet
350
350
 
351
351
  - **"Design system"**: The foundational term for consistent visual identity across pieces. Always define it before creating individual assets.
352
352
  - **"Visual hierarchy"**: How the eye moves through the design. Use this when explaining font size, weight, and positioning choices.
353
- - **"Viewport: WxH"**: Always state the target dimensions explicitly. "Instagram carousel at 1080x1440" not "standard Instagram size."
353
+ - **"Viewport: WxH"**: Always state the target dimensions explicitly. "Instagram carousel at 1080x1350" not "standard Instagram size."
354
354
  - **"Contrast ratio"**: Reference WCAG contrast standards when justifying color combinations. "4.5:1 minimum for body text."
355
355
  - **"Self-contained HTML"**: The non-negotiable constraint. Reinforce that every file must render independently without external dependencies.
356
356
  - **"Rendering verification"**: The step where you visually confirm the screenshot matches the intended design before proceeding.
@@ -8,14 +8,14 @@ whenToUse: |
8
8
  constraints:
9
9
  caption_max_chars: 2200
10
10
  caption_visible_chars: 125
11
- max_hashtags: 30
11
+ hashtags_max: 30
12
12
  recommended_hashtags: "5-15"
13
- carousel_max_slides: 20
13
+ carousel_max_slides: 10
14
14
  recommended_slides: "8-10"
15
15
  min_words_per_slide: 40
16
16
  max_words_per_slide: 80
17
- image_ratio: "3:4 portrait"
18
- image_resolution: "1080x1440px"
17
+ image_ratio: "4:5 portrait"
18
+ image_resolution: "1080x1350px"
19
19
  version: "1.0.0"
20
20
  ---
21
21
 
@@ -11,7 +11,7 @@ constraints:
11
11
  aspect_ratio: "9:16 vertical"
12
12
  caption_max_chars: 2200
13
13
  caption_visible_chars: 125
14
- max_hashtags: 30
14
+ hashtags_max: 30
15
15
  version: "1.0.0"
16
16
  ---
17
17
 
@@ -22,6 +22,13 @@ version: "1.0.0"
22
22
  10. Assign an APPROVE verdict only if overall score is >= 7/10 and no criterion is < 4/10.
23
23
  11. Include at least one acknowledged strength, even in REJECT reviews.
24
24
  12. Provide a specific fix or rewrite example for every required change.
25
+ 13. **Números medidos, nunca estimados** — nunca estime contagens (caracteres, hashtags, links,
26
+ slides): copie os valores do relatório `--- VERIFICAÇÃO AUTOMÁTICA ---`. Checklist só marca ✓
27
+ o que o relatório confirma.
28
+ 14. **Sem APPROVE com bloqueio** — qualquer bloqueio no relatório da verificação automática é
29
+ REJECT, seja qual for a nota (exceto `[PREENCHER]`, que vai para o usuário na aprovação final).
30
+ 15. **Alerta limita a nota** — com alerta não resolvido nem justificado, a nota máxima é 7/10;
31
+ liste cada alerta e diga se foi resolvido.
25
32
 
26
33
  <!-- End Compact Rules. Full reference below. -->
27
34
 
@@ -150,13 +150,13 @@ Platform: Instagram (carousel)
150
150
  Account: @brandname
151
151
  Skill: instagram-publisher
152
152
  Images: 7 slides
153
- 1. slide-01.jpg (1080x1440, JPEG, 287KB)
154
- 2. slide-02.jpg (1080x1440, JPEG, 195KB)
155
- 3. slide-03.jpg (1080x1440, JPEG, 213KB)
156
- 4. slide-04.jpg (1080x1440, JPEG, 178KB)
157
- 5. slide-05.jpg (1080x1440, JPEG, 201KB)
158
- 6. slide-06.jpg (1080x1440, JPEG, 192KB)
159
- 7. slide-07.jpg (1080x1440, JPEG, 244KB)
153
+ 1. slide-01.jpg (1080x1350, JPEG, 287KB)
154
+ 2. slide-02.jpg (1080x1350, JPEG, 195KB)
155
+ 3. slide-03.jpg (1080x1350, JPEG, 213KB)
156
+ 4. slide-04.jpg (1080x1350, JPEG, 178KB)
157
+ 5. slide-05.jpg (1080x1350, JPEG, 201KB)
158
+ 6. slide-06.jpg (1080x1350, JPEG, 192KB)
159
+ 7. slide-07.jpg (1080x1350, JPEG, 244KB)
160
160
 
161
161
  Caption (1,847 / 2,200 chars):
162
162
  "You are doing 100 things to grow on Instagram.
@@ -173,7 +173,7 @@ Caption (1,847 / 2,200 chars):
173
173
  VALIDATION
174
174
  Image format: JPEG (required: JPEG)
175
175
  Image count: 7 (required: 2-10)
176
- Image dimensions: 1080x1440 (valid carousel)
176
+ Image dimensions: 1080x1350 (valid carousel)
177
177
  Caption length: 1,847 chars (max: 2,200)
178
178
  Hashtags: 5 (recommended: 5-8)
179
179
  Rate limit: 3/25 posts used in last 24h
@@ -271,6 +271,10 @@ with placeholders. 1 example acceptable if it is comprehensive; 2 preferred if s
271
271
  3. [Specific mistake]: [Why it's harmful]
272
272
  4. [Specific mistake]: [Why it's harmful]
273
273
  (Minimum 4 items. Each sourced from research on common domain mistakes.)
274
+ **Every agent that writes content** (writer, copywriter, creator, consultant, strategist) MUST
275
+ also include this item, verbatim in meaning: "Never invent cases, testimonials, clients, numbers,
276
+ dates or first-person stories — nunca invente; when real data is missing, write
277
+ `[PREENCHER: o que falta]` so the user fills it in at final approval."
274
278
 
275
279
  ### Always Do
276
280
  1. [Specific positive practice]: [Why it matters]
@@ -310,6 +310,15 @@ Before executing any step that references an agent:
310
310
  c. Inject this block immediately after the agent definition and BEFORE format/skill context.
311
311
  d. Skip sections that are empty or not relevant to the current agent (e.g., skip Design Visual for a writer agent).
312
312
  e. If `memories.md` has no accumulated rules → skip injection entirely (no empty block).
313
+ f. **Truthfulness block (always)** — for every agent step that is not a checkpoint and not a
314
+ review step (no `on_reject:`), inject right after the crew memory block:
315
+ ```
316
+ --- REGRAS DE VERACIDADE ---
317
+ - Nunca invente casos, depoimentos, clientes, números, datas ou histórias em 1ª pessoa.
318
+ - Use só fatos do briefing, da pesquisa (com fonte) ou do perfil da empresa.
319
+ - Faltou um dado real? Escreva [PREENCHER: o que falta] no lugar — o usuário completa
320
+ na aprovação final. Um [PREENCHER] honesto vale mais que um exemplo inventado.
321
+ ```
313
322
 
314
323
  ### Context Compression (Summary-Based Handoff)
315
324
 
@@ -628,11 +637,39 @@ catching obvious issues early and reducing review cycle waste.
628
637
 
629
638
  ### Review Loops
630
639
 
631
- When a step has `on_reject: {step-id}`:
632
- - Track the review cycle count
633
- - If reviewer rejects, go back to the referenced step
634
- - Pass reviewer feedback to the writer agent
635
- - If max_review_cycles reached, present to user for manual decision
640
+ When a step has `on_reject: {step-id}` (a review step):
641
+
642
+ 1. **Automatic check BEFORE the reviewer runs** — run the checker on **all outputs** (todas as
643
+ saídas) of every non-checkpoint step from the `on_reject` step up to the step right before
644
+ the review, using the transformed paths of this run (run_id/vN):
645
+ ```bash
646
+ node _opencrew/core/scripts/verificar.mjs --crew crews/{name} --arquivo "{path1},{path2},…" --formato {blog format id of those steps, if any}
647
+ ```
648
+ Save the full output to `crews/{name}/output/{run_id}/verificacao-ciclo-{N}.md` and inject it
649
+ into the reviewer's context as `--- VERIFICAÇÃO AUTOMÁTICA ---`. The reviewer must copy the
650
+ measured values from it (see best-practices `review.md`). If the command itself fails (no
651
+ Node, unexpected error), tell the user "⚠️ A verificação automática não rodou: {motivo}" and
652
+ continue with the normal review.
653
+ 2. **A block cannot be approved** — if the last line of the checker output is
654
+ `VERIFICACAO:BLOQUEADA`, the verdict is **REJECT** regardless of the score (qualquer que seja a
655
+ nota). Send the report (blocks first) to the writer together with the reviewer's feedback.
656
+ If the last line is `VERIFICACAO:AGUARDANDO_USUARIO`, the only blocks are `[PREENCHER: …]`
657
+ (real data only the user has): do NOT reject for them — the reviewer judges the rest, and the
658
+ final approval below collects the missing data from the user.
659
+ 3. Track the review cycle count. If the reviewer rejects, go back to the referenced step.
660
+ 4. If max_review_cycles is reached with blocks remaining, present the report to the user:
661
+ ```
662
+ ⚠️ A revisão ainda encontra bloqueios depois de {N} ciclos:
663
+ {lista de bloqueios do relatório}
664
+
665
+ 1. Corrigir eu mesmo (eu edito o texto e você verifica de novo)
666
+ 2. Aceitar assim mesmo (fica registrado no histórico da execução)
667
+ 3. Abortar
668
+ ```
669
+ 5. **Final approval checkpoint** (the checkpoint after the review): show the summary of the last
670
+ report — `Verificação automática: {N} bloqueios, {M} alertas` — plus the list of alerts. If the
671
+ approved text still contains `[PREENCHER: …]`, ask the user for each missing piece of real
672
+ information and write it into the text before approving.
636
673
 
637
674
  ### Dashboard Handoff (between steps)
638
675
 
@@ -0,0 +1,99 @@
1
+ // Leitura para o verificador automático: frontmatter, limites dos formatos (constraints:),
2
+ // seções de um texto e proibições da memória da crew. Node puro, sem dependências.
3
+ import { readFile } from 'node:fs/promises';
4
+ import { existsSync } from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ function valorYaml(bruto) {
8
+ const v = bruto.trim();
9
+ const aspas = v.match(/^"(.*)"$/) ?? v.match(/^'(.*)'$/);
10
+ if (aspas) return aspas[1].replace(/\\"/g, '"');
11
+ if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v);
12
+ return v;
13
+ }
14
+
15
+ /** Frontmatter YAML simples: `chave: valor` e um nível de bloco (ex.: `constraints:`). */
16
+ export function lerFrontmatter(texto) {
17
+ const m = texto.match(/^---\r?\n([\s\S]*?)\r?\n---/);
18
+ if (!m) return null;
19
+ const dados = {};
20
+ let bloco = null;
21
+ for (const linha of m[1].split(/\r?\n/)) {
22
+ const topo = linha.match(/^([\w-]+):\s*(.*)$/);
23
+ if (topo) {
24
+ const [, chave, valor] = topo;
25
+ bloco = valor.trim() === '' ? chave : null;
26
+ dados[chave] = bloco ? {} : valorYaml(valor);
27
+ continue;
28
+ }
29
+ const filho = linha.match(/^\s+([\w-]+):\s*(.*)$/);
30
+ if (filho && bloco) dados[bloco][filho[1]] = valorYaml(filho[2]);
31
+ }
32
+ return dados;
33
+ }
34
+
35
+ /** Corpo do texto sem o frontmatter. */
36
+ export function semFrontmatter(texto) {
37
+ return texto.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
38
+ }
39
+
40
+ /** Limites do formato: `constraints:` de `_opencrew/core/best-practices/<id>.md`. */
41
+ export async function lerLimites(raiz, formatoId) {
42
+ const arquivo = path.join(raiz, '_opencrew', 'core', 'best-practices', `${formatoId}.md`);
43
+ if (!existsSync(arquivo)) return null;
44
+ return lerFrontmatter(await readFile(arquivo, 'utf8'))?.constraints ?? {};
45
+ }
46
+
47
+ /**
48
+ * Seções por cabeçalho markdown. O corpo vai até o próximo cabeçalho de nível igual ou
49
+ * maior, ou até uma linha `---` (separador).
50
+ */
51
+ export function lerSecoes(texto) {
52
+ const linhas = semFrontmatter(texto).split(/\r?\n/);
53
+ const secoes = [];
54
+ linhas.forEach((linha, i) => {
55
+ const h = linha.match(/^(#{1,6})\s+(.*)$/);
56
+ if (!h) return;
57
+ const nivel = h[1].length;
58
+ const corpo = [];
59
+ for (const seguinte of linhas.slice(i + 1)) {
60
+ const h2 = seguinte.match(/^(#{1,6})\s/);
61
+ if ((h2 && h2[1].length <= nivel) || /^---\s*$/.test(seguinte)) break;
62
+ corpo.push(seguinte);
63
+ }
64
+ secoes.push({ titulo: h[2].trim(), nivel, corpo: corpo.join('\n').trim() });
65
+ });
66
+ return secoes;
67
+ }
68
+
69
+ const semAcento = (s) => s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
70
+ export { semAcento };
71
+
72
+ const ASPAS = /"([^"]+)"|“([^”]+)”|‘([^’]+)’|`([^`]+)`|(?<![\p{L}])'([^']+)'(?![\p{L}])/gu;
73
+
74
+ /**
75
+ * Termos proibidos: o que estiver entre aspas nos itens de `## Proibições Explícitas`
76
+ * do `memories.md` da crew. Itens sem aspas são contados, mas não verificados.
77
+ */
78
+ export async function lerProibicoes(raiz, crew) {
79
+ const arquivo = path.join(raiz, crew, '_memory', 'memories.md');
80
+ if (!existsSync(arquivo)) return { existe: false, termos: [], semAspas: 0 };
81
+ const secao = lerSecoes(await readFile(arquivo, 'utf8'))
82
+ .find((s) => s.nivel === 2 && semAcento(s.titulo).startsWith('proibicoes explicitas'));
83
+ const termos = [];
84
+ let semAspas = 0;
85
+ for (const linha of (secao?.corpo ?? '').split('\n').filter((l) => /^\s*[-*]\s+/.test(l))) {
86
+ const achados = [...linha.matchAll(ASPAS)].map((m) => m.slice(1).find(Boolean));
87
+ if (achados.length) termos.push(...achados);
88
+ else semAspas += 1;
89
+ }
90
+ return { existe: true, termos, semAspas };
91
+ }
92
+
93
+ /** Domínio do site da empresa (para separar links internos de externos), se houver. */
94
+ export async function lerDominioDoSite(raiz) {
95
+ const arquivo = path.join(raiz, '_opencrew', '_memory', 'company.md');
96
+ if (!existsSync(arquivo)) return null;
97
+ const m = (await readFile(arquivo, 'utf8')).match(/(?:site|website)[^\n]*?https?:\/\/([^\s/)>\]]+)/i);
98
+ return m ? m[1].replace(/^www\./, '').toLowerCase() : null;
99
+ }
@@ -0,0 +1,127 @@
1
+ // Regras do verificador automático (specs/fase-u1-revisor-com-dentes.md §5).
2
+ // Máximos bloqueiam (fácil de corrigir: encurtar); mínimos alertam (podem depender do usuário).
3
+ import { lerFrontmatter, semFrontmatter, semAcento } from './leitura.mjs';
4
+
5
+ const segmentador = new Intl.Segmenter('pt', { granularity: 'grapheme' });
6
+
7
+ /** Bloqueio que só o usuário resolve ([PREENCHER: …]). */
8
+ export const FALTA_INFO = 'Falta informação sua';
9
+
10
+ /** Caracteres visíveis: sem marcadores de negrito/itálico; emoji conta 1. */
11
+ export function contar(texto) {
12
+ const limpo = String(texto).replace(/\*\*|__/g, '').replace(/\*([^*\n]+)\*/g, '$1').trim();
13
+ return [...segmentador.segment(limpo)].length;
14
+ }
15
+
16
+ const contarHashtags = (texto) => (texto.match(/#[\p{L}\p{N}_]+/gu) ?? []).length;
17
+
18
+ const item = (nome, medido, limite, nivel, detalhe = '') => ({ item: nome, medido, limite, nivel, detalhe });
19
+
20
+ function maximo(nome, medido, limite) {
21
+ if (typeof limite !== 'number') return null;
22
+ return item(nome, medido, limite, medido > limite ? 'bloqueio' : 'ok');
23
+ }
24
+
25
+ function minimo(nome, medido, limite) {
26
+ if (typeof limite !== 'number') return null;
27
+ return item(nome, medido, limite, medido < limite ? 'alerta' : 'ok');
28
+ }
29
+
30
+ const NAO_CONTA = /^(mailto:|tel:|https?:\/\/(wa\.me|api\.whatsapp\.com)\/)/i;
31
+
32
+ function contarLinks(corpo, dominio) {
33
+ const urls = [...corpo.matchAll(/\[[^\]]*\]\(([^)\s]+)[^)]*\)/g)].map((m) => m[1]).filter((u) => !NAO_CONTA.test(u));
34
+ const interno = (u) => !/^https?:\/\//i.test(u) || (dominio && new URL(u).hostname.replace(/^www\./, '') === dominio);
35
+ return { internos: urls.filter(interno).length, externos: urls.filter((u) => !interno(u)).length };
36
+ }
37
+
38
+ /** Blog: frontmatter com título → título, meta description e links. */
39
+ export function regrasBlog(texto, limites, dominio) {
40
+ const fm = lerFrontmatter(texto);
41
+ const titulo = fm?.title ?? fm?.titulo;
42
+ if (titulo == null || !limites) return [];
43
+ const meta = fm.meta_description ?? fm.meta_descricao;
44
+ const { internos, externos } = contarLinks(semFrontmatter(texto), dominio);
45
+ return [
46
+ maximo('Título (SEO) — caracteres', contar(titulo), limites.title_max_chars),
47
+ meta != null ? maximo('Meta description — caracteres', contar(meta), limites.meta_description_chars) : null,
48
+ minimo('Links internos', internos, limites.min_internal_links),
49
+ minimo('Links externos', externos, limites.min_external_links),
50
+ ].filter(Boolean);
51
+ }
52
+
53
+ /** Seções por canal (cabeçalhos "Legenda Instagram", "Post LinkedIn", "Tweet"…). */
54
+ export function regrasCanais(secoes, limitesPorFormato) {
55
+ const itens = [];
56
+ for (const s of secoes) {
57
+ const t = semAcento(s.titulo);
58
+ const ig = limitesPorFormato['instagram-feed'];
59
+ if (/instagram/.test(t) && /legenda|caption/.test(t) && ig) {
60
+ itens.push(maximo('Legenda Instagram — caracteres', contar(s.corpo), ig.caption_max_chars));
61
+ itens.push(maximo('Legenda Instagram — hashtags', contarHashtags(s.corpo), ig.hashtags_max));
62
+ } else if (/instagram/.test(t) && /carrossel|carousel/.test(t) && ig) {
63
+ const slides = (s.corpo.match(/^\s*(?:#{3,6}\s*|\*\*\s*)slide\s*\d+/gim) ?? []).length;
64
+ if (slides) itens.push(maximo('Carrossel Instagram — slides', slides, ig.carousel_max_slides));
65
+ }
66
+ const li = limitesPorFormato['linkedin-post'];
67
+ if (/linkedin/.test(t) && !/carrossel|carousel/.test(t) && li) {
68
+ itens.push(maximo('Post LinkedIn — caracteres', contar(s.corpo), li.post_max_chars));
69
+ itens.push(maximo('Post LinkedIn — hashtags', contarHashtags(s.corpo), li.hashtags_max));
70
+ }
71
+ const tw = limitesPorFormato['twitter-post'];
72
+ if (/tweet|twitter/.test(t) && tw) {
73
+ s.corpo.split(/\n\s*\n/).filter((p) => p.trim()).forEach((p, i) => {
74
+ const r = maximo(`Tweet ${i + 1} — caracteres`, contar(p), tw.tweet_max_chars);
75
+ if (r?.nivel === 'bloqueio') itens.push(r);
76
+ });
77
+ }
78
+ }
79
+ return itens.filter(Boolean);
80
+ }
81
+
82
+ const PLACEHOLDERS = [
83
+ /(?:https?:\/\/)?[^\s()[\]<>"']*?(\d)\1{5,}[^\s()[\]<>"']*/g, // 6+ dígitos repetidos (wa.me/5584999999999)
84
+ /\[(?:empresa|cliente|nome|feira|evento|produto|cidade|link|url|telefone|e-?mail|data)\b[^\]]*\](?!\()/giu,
85
+ /lorem ipsum/gi,
86
+ /\bX{3,}\b/g,
87
+ /\{\{[^}]+\}\}/g,
88
+ /\b(?:example\.com|seusite\.com(?:\.br)?|suaempresa\.com(?:\.br)?)\b/gi,
89
+ ];
90
+
91
+ const PRIMEIRA_PESSOA = /(?<!\p{L})(eu|nós|nosso|nossa|nossos|nossas|investimos|atendemos|fizemos|ajudamos|fundamos|começamos|criamos|entregamos|nossa equipe)(?!\p{L})/iu;
92
+ const DADO_CONCRETO = /R\$\s?\d|US\$\s?\d|\d+([.,]\d+)?\s?%|\d+\s+(clientes|empresas|eventos|anos|projetos|pessoas)/iu;
93
+
94
+ // Ano só conta como dado concreto se for passado: "Congresso 2026" (ano atual/futuro) é nome de
95
+ // evento, não afirmação sobre a história da empresa (achado no uso real).
96
+ function temDadoConcreto(frase) {
97
+ if (DADO_CONCRETO.test(frase)) return true;
98
+ const atual = new Date().getFullYear();
99
+ return [...frase.matchAll(/(?<!\d)((?:19|20)\d{2})(?!\d)/g)].some((m) => Number(m[1]) < atual);
100
+ }
101
+
102
+ const citar = (frase) => {
103
+ const limpa = frase.replace(/\*\*|__/g, '').replace(/\*([^*\n]+)\*/g, '$1').trim();
104
+ return limpa.length > 160 ? `${limpa.slice(0, 160).trimEnd()}…` : limpa;
105
+ };
106
+
107
+ /** Checagens que valem para qualquer texto. */
108
+ export function regrasGerais(texto, proibidos) {
109
+ const corpo = semFrontmatter(texto);
110
+ const itens = [];
111
+ for (const rx of PLACEHOLDERS) {
112
+ for (const m of texto.matchAll(rx)) itens.push(item('Placeholder', null, null, 'bloqueio', m[0].replace(/[.,;:!?]+$/, '')));
113
+ }
114
+ for (const m of texto.matchAll(/\[PREENCHER:?\s*([^\]]*)\]/giu)) {
115
+ itens.push(item(FALTA_INFO, null, null, 'bloqueio', m[1].trim() || m[0]));
116
+ }
117
+ const normal = semAcento(texto);
118
+ for (const termo of proibidos) {
119
+ if (normal.includes(semAcento(termo))) itens.push(item('Termo proibido (memória da crew)', null, null, 'bloqueio', termo));
120
+ }
121
+ for (const frase of corpo.split(/(?<=[.!?])\s+|\n+/)) {
122
+ if (PRIMEIRA_PESSOA.test(frase) && temDadoConcreto(frase)) {
123
+ itens.push(item('Afirmação a confirmar', null, null, 'alerta', citar(frase)));
124
+ }
125
+ }
126
+ return itens;
127
+ }
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ // Verificador automático do OpenCrew — mede o texto ANTES do revisor.
3
+ // Uso: node _opencrew/core/scripts/verificar.mjs --crew crews/<nome> --arquivo <a.md>[,<b.md>] [--formato blog-post|blog-seo]
4
+ // Os limites vêm do frontmatter `constraints:` dos best-practices (fonte única).
5
+ // Última linha da saída: VERIFICACAO:OK ou VERIFICACAO:BLOQUEADA (o runner lê esta linha).
6
+ // Spec: specs/fase-u1-revisor-com-dentes.md (repositório do OpenCrew).
7
+ import { readFile } from 'node:fs/promises';
8
+ import { existsSync } from 'node:fs';
9
+ import path from 'node:path';
10
+ import { pathToFileURL } from 'node:url';
11
+ import { lerLimites, lerSecoes, lerProibicoes, lerDominioDoSite } from './verificar/leitura.mjs';
12
+ import { regrasBlog, regrasCanais, regrasGerais, FALTA_INFO } from './verificar/regras.mjs';
13
+
14
+ const FORMATOS_DE_CANAL = ['instagram-feed', 'linkedin-post', 'twitter-post'];
15
+
16
+ export async function verificar({ raiz, crew, arquivos, formato = 'blog-post' }) {
17
+ const notas = [];
18
+ const limites = {};
19
+ for (const id of [formato, ...FORMATOS_DE_CANAL]) {
20
+ limites[id] = await lerLimites(raiz, id);
21
+ if (!limites[id]) notas.push(`Formato "${id}" não encontrado em _opencrew/core/best-practices/ — sem limites para ele.`);
22
+ }
23
+ const proibicoes = await lerProibicoes(raiz, crew);
24
+ if (!proibicoes.existe) notas.push('Sem proibições registradas (a crew não tem memories.md).');
25
+ if (proibicoes.semAspas) {
26
+ const n = proibicoes.semAspas;
27
+ notas.push(`${n} ${n === 1 ? 'proibição' : 'proibições'} sem termo entre aspas ${n === 1 ? 'não é verificada' : 'não são verificadas'} automaticamente — escreva o termo entre aspas na memória para virar trava.`);
28
+ }
29
+ const dominio = await lerDominioDoSite(raiz);
30
+
31
+ const resultado = [];
32
+ for (const rel of arquivos) {
33
+ const texto = await readFile(path.join(raiz, rel), 'utf8');
34
+ const itens = [
35
+ ...regrasBlog(texto, limites[formato], dominio),
36
+ ...regrasCanais(lerSecoes(texto), limites),
37
+ ...regrasGerais(texto, proibicoes.termos),
38
+ ];
39
+ resultado.push({ arquivo: rel, itens });
40
+ }
41
+ const todos = resultado.flatMap((a) => a.itens);
42
+ const bloqueios = todos.filter((i) => i.nivel === 'bloqueio');
43
+ const alertas = todos.filter((i) => i.nivel === 'alerta').length;
44
+ // [PREENCHER] só o usuário resolve: não força REJECT (o redator não tem o dado), mas a
45
+ // aprovação final não fecha sem ele.
46
+ const reais = bloqueios.filter((i) => i.item !== FALTA_INFO).length;
47
+ const status = reais ? 'BLOQUEADA' : bloqueios.length ? 'AGUARDANDO_USUARIO' : 'OK';
48
+ return { arquivos: resultado, notas, bloqueios: bloqueios.length, alertas, status };
49
+ }
50
+
51
+ const ROTULO = { bloqueio: '❌ Bloqueio', alerta: '⚠️ Alerta', ok: '✅ OK' };
52
+ const plural = (n, um, varios) => `${n} ${n === 1 ? um : varios}`;
53
+
54
+ export function formatarRelatorio(r) {
55
+ const linhas = ['## Verificação automática', ''];
56
+ for (const a of r.arquivos) {
57
+ linhas.push(`### ${a.arquivo}`, '');
58
+ const medidos = a.itens.filter((i) => i.medido != null);
59
+ if (medidos.length) {
60
+ linhas.push('| Item | Medido | Limite | Resultado |', '|---|---|---|---|');
61
+ for (const i of medidos) {
62
+ const op = i.nivel === 'alerta' || /links/i.test(i.item) ? '≥' : '≤';
63
+ linhas.push(`| ${i.item} | ${i.medido} | ${op} ${i.limite} | ${ROTULO[i.nivel]} |`);
64
+ }
65
+ linhas.push('');
66
+ }
67
+ for (const i of a.itens.filter((x) => x.medido == null)) {
68
+ linhas.push(`- ${ROTULO[i.nivel]} — ${i.item}: "${i.detalhe}"`);
69
+ }
70
+ if (!a.itens.length) linhas.push('- ✅ Nada a apontar.');
71
+ linhas.push('');
72
+ }
73
+ if (r.notas.length) linhas.push('**Notas:**', ...r.notas.map((n) => `- ${n}`), '');
74
+ linhas.push(`**Resumo: ${plural(r.bloqueios, 'bloqueio', 'bloqueios')}, ${plural(r.alertas, 'alerta', 'alertas')}**`, '');
75
+ linhas.push(`VERIFICACAO:${r.status}`);
76
+ return linhas.join('\n');
77
+ }
78
+
79
+ function lerArgs(argv) {
80
+ const args = {};
81
+ for (let i = 0; i < argv.length; i++) {
82
+ const m = argv[i].match(/^--(crew|arquivo|formato)$/);
83
+ if (m && i + 1 < argv.length) args[m[1]] = argv[++i];
84
+ }
85
+ return args;
86
+ }
87
+
88
+ const USO = 'Uso: node _opencrew/core/scripts/verificar.mjs --crew crews/<nome> --arquivo <a.md>[,<b.md>] [--formato blog-post|blog-seo]';
89
+
90
+ /** @returns {Promise<number>} 0 = verificou (OK ou BLOQUEADA) · 1 = erro de uso */
91
+ export async function main(argv, { cwd = process.cwd(), escrever = (s) => process.stdout.write(`${s}\n`) } = {}) {
92
+ const args = lerArgs(argv);
93
+ if (!args.crew || !args.arquivo) {
94
+ escrever(USO);
95
+ return 1;
96
+ }
97
+ const arquivos = args.arquivo.split(',').map((s) => s.trim()).filter(Boolean);
98
+ for (const rel of [args.crew, ...arquivos]) {
99
+ const abs = path.resolve(cwd, rel);
100
+ if (path.relative(cwd, abs).startsWith('..') || path.isAbsolute(path.relative(cwd, abs))) {
101
+ escrever(`Caminho fora do projeto: ${rel}`);
102
+ return 1;
103
+ }
104
+ }
105
+ const faltando = arquivos.find((rel) => !existsSync(path.join(cwd, rel)));
106
+ if (faltando) {
107
+ escrever(`Arquivo não encontrado: ${faltando}`);
108
+ return 1;
109
+ }
110
+ const r = await verificar({ raiz: cwd, crew: args.crew, arquivos, formato: args.formato || 'blog-post' });
111
+ escrever(formatarRelatorio(r));
112
+ return 0;
113
+ }
114
+
115
+ const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
116
+ if (isMain) {
117
+ main(process.argv.slice(2)).then((code) => { process.exitCode = code; });
118
+ }
@@ -100,7 +100,7 @@ Each item can optionally include a `"reference": "path/to/ref.png"` field.
100
100
  ### Prompt guidelines
101
101
 
102
102
  - Be specific about composition, lighting, style, and mood
103
- - Specify aspect ratio or orientation when relevant (e.g., "portrait 3:4", "landscape 16:9")
103
+ - Specify aspect ratio or orientation when relevant (e.g., "portrait 4:5", "landscape 16:9")
104
104
  - Include "hyper realistic, 4K quality" for photographic styles
105
105
  - Include "clean composition" to avoid cluttered outputs
106
106
  - Avoid requesting text in images — AI models struggle with text rendering
@@ -60,7 +60,7 @@ Use the Visual Renderer when you need to generate production-ready images from H
60
60
 
61
61
  Use these standard dimensions:
62
62
  - Instagram Post: 1080 x 1080
63
- - Instagram Carousel: 1080 x 1440
63
+ - Instagram Carousel: 1080 x 1350
64
64
  - Instagram Story/Reel: 1080 x 1920
65
65
  - Facebook Post: 1200 x 630
66
66
  - Twitter/X Post: 1200 x 675
@@ -86,7 +86,7 @@ Example minimal structure:
86
86
  <meta charset="UTF-8">
87
87
  <style>
88
88
  * { margin: 0; padding: 0; box-sizing: border-box; }
89
- body { width: 1080px; height: 1440px; overflow: hidden; }
89
+ body { width: 1080px; height: 1350px; overflow: hidden; }
90
90
  /* ... your design ... */
91
91
  </style>
92
92
  </head>
@@ -47,7 +47,7 @@ When taking a screenshot:
47
47
  1. Navigate to the URL with `browser_navigate`
48
48
  2. Set viewport: `browser_resize` with width/height for target format
49
49
  - Instagram post: 1080x1080
50
- - Instagram carousel: 1080x1440
50
+ - Instagram carousel: 1080x1350
51
51
  - Story/Reel: 1080x1920
52
52
  - Generic: 1280x720
53
53
  3. Wait for page load (`browser_wait_for` if needed)
@@ -46,7 +46,7 @@ Before generating any template, read and internalize the design best practices:
46
46
  - `_opencrew/core/best-practices/image-design.md` — **REQUIRED reading**. Contains platform-specific minimum font sizes, typography rules, spacing guidelines, color palette constraints, contrast requirements, and layout methodology. Every template you generate MUST comply with these rules.
47
47
 
48
48
  Key rules to always follow:
49
- - **Font sizes**: Hero 58px, Heading 43px, Body 34px, Caption 24px minimum for Instagram carousel (1080x1440). Absolute minimum 20px for any readable text on any platform.
49
+ - **Font sizes**: Hero 58px, Heading 43px, Body 34px, Caption 24px minimum for Instagram carousel (1080x1350). Absolute minimum 20px for any readable text on any platform.
50
50
  - **Font weight**: 500 or higher for body text and above.
51
51
  - **Colors**: Maximum 5 colors per design system (primary, secondary, accent, background, text).
52
52
  - **Contrast**: WCAG AA minimum 4.5:1 for all text against background.
@@ -61,7 +61,7 @@ You should also apply general web design best practices: proper white space, vis
61
61
  These rules are NON-NEGOTIABLE. Every template must comply:
62
62
 
63
63
  **Fixed Dimensions (never use height: auto or flexible height):**
64
- - Instagram Carousel: `width: 1080px; height: 1440px` (3:4 portrait)
64
+ - Instagram Carousel: `width: 1080px; height: 1350px` (4:5 portrait)
65
65
  - Instagram Story/Reel: `width: 1080px; height: 1920px` (9:16 portrait)
66
66
  - Instagram Post: `width: 1080px; height: 1080px` (1:1 square)
67
67
  - LinkedIn Post: `width: 1200px; height: 627px` (1.91:1 horizontal)
@@ -155,7 +155,7 @@ When the user approves, create two files:
155
155
 
156
156
  Save to: `crews/{code}/pipeline/data/template-reference.html`
157
157
 
158
- The complete, self-contained HTML/CSS of the approved template at full resolution (e.g., 1080x1440). This is the literal example the design agent will use.
158
+ The complete, self-contained HTML/CSS of the approved template at full resolution (e.g., 1080x1350). This is the literal example the design agent will use.
159
159
 
160
160
  ### 2. Visual Identity Rules
161
161
 
@@ -2,7 +2,7 @@
2
2
  <!-- Style: Black background, tweet-style layout with avatar, verified badge, editorial text, contextual image -->
3
3
  <!-- Fonts: Inter (body), weight 400-700 -->
4
4
  <!-- Colors: #000 bg, #fff text, #1D9BF0 verified, #71767B muted, #2a2a2a borders/avatar, #1a1a2e image gradient -->
5
- <div style="width:1080px;height:1440px;background:#000;color:#fff;font-family:'Inter',sans-serif;display:flex;flex-direction:column;padding:72px;gap:0;position:relative;overflow:hidden;">
5
+ <div style="width:1080px;height:1350px;background:#000;color:#fff;font-family:'Inter',sans-serif;display:flex;flex-direction:column;padding:72px;gap:0;position:relative;overflow:hidden;">
6
6
  <!-- Tweet header -->
7
7
  <div style="display:flex;gap:24px;align-items:center;">
8
8
  <div style="width:80px;height:80px;border-radius:50%;background:#2a2a2a;flex-shrink:0;"></div>
@@ -2,7 +2,7 @@
2
2
  <!-- Style: Warm cream background, bold uppercase title, numbered cards with warm gradient progression -->
3
3
  <!-- Fonts: Montserrat 900 (title), Inter 500-700 (body) -->
4
4
  <!-- Colors: #FDF6EE bg, #E85D2A primary orange, #D4742A/#C05A1A/#A04010 gradient steps, #FFF5E6-#D07030 card backgrounds -->
5
- <div style="width:1080px;height:1440px;background:#FDF6EE;color:#111;font-family:'Inter',sans-serif;display:flex;flex-direction:column;padding:72px;justify-content:center;gap:48px;position:relative;overflow:hidden;">
5
+ <div style="width:1080px;height:1350px;background:#FDF6EE;color:#111;font-family:'Inter',sans-serif;display:flex;flex-direction:column;padding:72px;justify-content:center;gap:48px;position:relative;overflow:hidden;">
6
6
  <!-- Title -->
7
7
  <div style="text-align:center;">
8
8
  <h1 style="font-size:58px;font-weight:900;line-height:1.15;margin:0;text-transform:uppercase;font-family:'Montserrat',sans-serif;">
@@ -2,7 +2,7 @@
2
2
  <!-- Style: Purple-dark gradient, data metric cards, decorative glow effects -->
3
3
  <!-- Fonts: Montserrat 900 (title, metrics), Inter 500 (body, labels) -->
4
4
  <!-- Colors: gradient #0f0326-#1a0a3e-#0d0d0d bg, #F59E0B yellow, #EC4899 pink, #7C3AED purple, #4ADE80 green accents -->
5
- <div style="width:1080px;height:1440px;background:linear-gradient(160deg,#0f0326,#1a0a3e 40%,#0d0d0d);color:#fff;font-family:'Inter',sans-serif;display:flex;flex-direction:column;padding:72px;position:relative;overflow:hidden;">
5
+ <div style="width:1080px;height:1350px;background:linear-gradient(160deg,#0f0326,#1a0a3e 40%,#0d0d0d);color:#fff;font-family:'Inter',sans-serif;display:flex;flex-direction:column;padding:72px;position:relative;overflow:hidden;">
6
6
  <!-- Glow effects -->
7
7
  <div style="position:absolute;top:-120px;right:-120px;width:480px;height:480px;background:radial-gradient(circle,#7C3AED33,transparent 70%);pointer-events:none;"></div>
8
8
  <div style="position:absolute;bottom:-100px;left:-100px;width:400px;height:400px;background:radial-gradient(circle,#F59E0B22,transparent 70%);pointer-events:none;"></div>