@aksp/opencrew 1.4.1 → 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 +116 -0
- package/README.md +38 -17
- package/bin/opencrew.js +4 -4
- package/package.json +4 -3
- package/src/cli.js +93 -43
- package/src/commands/init.js +79 -53
- package/src/commands/update.js +44 -17
- package/src/lib/errors.js +12 -0
- package/src/lib/fsx.js +18 -9
- package/src/lib/ides.js +9 -34
- package/templates/AGENTS.md +42 -63
- package/templates/_opencrew/.opencrew-version +1 -1
- package/templates/_opencrew/core/best-practices/copywriting.md +4 -1
- package/templates/_opencrew/core/best-practices/image-design.md +5 -5
- package/templates/_opencrew/core/best-practices/instagram-feed.md +4 -4
- package/templates/_opencrew/core/best-practices/instagram-reels.md +1 -1
- package/templates/_opencrew/core/best-practices/review.md +7 -0
- package/templates/_opencrew/core/best-practices/social-networks-publishing.md +8 -8
- package/templates/_opencrew/core/prompts/build.prompt.md +25 -0
- package/templates/_opencrew/core/prompts/design.prompt.md +9 -5
- package/templates/_opencrew/core/runner.pipeline.md +56 -7
- package/templates/_opencrew/core/scripts/verificar/leitura.mjs +99 -0
- package/templates/_opencrew/core/scripts/verificar/regras.mjs +127 -0
- package/templates/_opencrew/core/scripts/verificar.mjs +118 -0
- package/templates/gitignore +3 -1
- package/templates/skills/image-ai-generator/SKILL.md +9 -5
- package/templates/skills/image-creator/SKILL.md +5 -3
- package/templates/skills/image-fetcher/SKILL.md +1 -1
- package/templates/skills/instagram-publisher/SKILL.md +34 -16
- package/templates/skills/instagram-publisher/scripts/publish.js +58 -27
- package/templates/skills/template-designer/SKILL.md +3 -3
- package/templates/skills/template-designer/base-templates/model-a.html +1 -1
- package/templates/skills/template-designer/base-templates/model-b.html +1 -1
- package/templates/skills/template-designer/base-templates/model-c.html +1 -1
|
@@ -8,14 +8,14 @@ whenToUse: |
|
|
|
8
8
|
constraints:
|
|
9
9
|
caption_max_chars: 2200
|
|
10
10
|
caption_visible_chars: 125
|
|
11
|
-
|
|
11
|
+
hashtags_max: 30
|
|
12
12
|
recommended_hashtags: "5-15"
|
|
13
|
-
carousel_max_slides:
|
|
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: "
|
|
18
|
-
image_resolution: "
|
|
17
|
+
image_ratio: "4:5 portrait"
|
|
18
|
+
image_resolution: "1080x1350px"
|
|
19
19
|
version: "1.0.0"
|
|
20
20
|
---
|
|
21
21
|
|
|
@@ -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 (
|
|
154
|
-
2. slide-02.jpg (
|
|
155
|
-
3. slide-03.jpg (
|
|
156
|
-
4. slide-04.jpg (
|
|
157
|
-
5. slide-05.jpg (
|
|
158
|
-
6. slide-06.jpg (
|
|
159
|
-
7. slide-07.jpg (
|
|
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:
|
|
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]
|
|
@@ -390,9 +394,16 @@ model_tier: fast # ONLY for execution: subagent. fast = lightweight model;
|
|
|
390
394
|
# Set fast for: investigator agents (data extraction, Sherlock subagents), researcher agents (web search, data gathering)
|
|
391
395
|
# Set powerful for: writer, creator, reviewer, strategy agents
|
|
392
396
|
# Omit model_tier for execution: inline steps
|
|
397
|
+
side_effects: irreversible # REQUIRED for any step that publishes, posts, sends email or otherwise
|
|
398
|
+
# distributes outside the project (it cannot be undone). The Pipeline
|
|
399
|
+
# Runner never retries these automatically, and Gate 2c places them last.
|
|
400
|
+
# Omit for every other step.
|
|
393
401
|
---
|
|
394
402
|
```
|
|
395
403
|
|
|
404
|
+
**Irreversible steps must run inline** (`execution: inline`), so the user sees the dry run and
|
|
405
|
+
gives the explicit go-ahead in the main conversation.
|
|
406
|
+
|
|
396
407
|
For **checkpoints**, use this frontmatter instead:
|
|
397
408
|
```yaml
|
|
398
409
|
---
|
|
@@ -572,6 +583,20 @@ If ANY check fails:
|
|
|
572
583
|
4. Generate a step file for the new checkpoint that asks the user to review and approve the preceding agent's output before the visual/publish step runs
|
|
573
584
|
5. Re-validate Gate 2b. Max 2 fix attempts — after that, present to user for manual decision.
|
|
574
585
|
|
|
586
|
+
### Gate 2c: Irreversible Steps Last (BLOCKING)
|
|
587
|
+
|
|
588
|
+
For EACH step that publishes, posts, sends email or distributes outside the project:
|
|
589
|
+
- [ ] Its frontmatter declares `side_effects: irreversible` and `execution: inline`
|
|
590
|
+
- [ ] It comes AFTER the Review step (the reviewer has already approved the final content)
|
|
591
|
+
- [ ] The IMMEDIATELY preceding step is a `type: checkpoint` (Final Approval) that itself comes after the Review
|
|
592
|
+
- [ ] Only other irreversible steps follow it (nothing is created, rendered or reviewed after publishing)
|
|
593
|
+
|
|
594
|
+
If ANY check fails:
|
|
595
|
+
1. Add the missing `side_effects: irreversible` / `execution: inline` fields
|
|
596
|
+
2. Move the irreversible step(s) to the end of the pipeline, after Review → Final Approval checkpoint
|
|
597
|
+
(create the Final Approval checkpoint if it does not exist), and renumber the steps
|
|
598
|
+
3. Re-validate Gate 2c. Max 2 fix attempts — after that, present to user for manual decision.
|
|
599
|
+
|
|
575
600
|
### Gate 3: Pipeline Coherence (ADVISORY)
|
|
576
601
|
|
|
577
602
|
Verify:
|
|
@@ -525,10 +525,14 @@ ERRADO: 5 noticias diferentes = NAO sao angulos, sao pautas distintas
|
|
|
525
525
|
|
|
526
526
|
#### Pipeline Patterns
|
|
527
527
|
|
|
528
|
-
- **Standard (fixed source):** Research → Angle Selection checkpoint → Creation → Content Approval checkpoint → [
|
|
529
|
-
- **News-based (multiple stories):** Research → News Selection checkpoint → Creator[generate-angles] → Angle Selection checkpoint → Creator[create+optimize] → Content Approval checkpoint → [
|
|
528
|
+
- **Standard (fixed source):** Research → Angle Selection checkpoint → Creation → Content Approval checkpoint → [Render Steps] → Review → Final Approval checkpoint → [Publish/Send Steps]
|
|
529
|
+
- **News-based (multiple stories):** Research → News Selection checkpoint → Creator[generate-angles] → Angle Selection checkpoint → Creator[create+optimize] → Content Approval checkpoint → [Render Steps] → Review → Final Approval checkpoint → [Publish/Send Steps]
|
|
530
530
|
|
|
531
|
-
**
|
|
531
|
+
**Render Steps** (image generation, visual rendering, slides) are reversible and run BEFORE the Review, so the reviewer sees the final visuals.
|
|
532
|
+
|
|
533
|
+
**Publish/Send Steps** (social media posting, email sending, any distribution outside the project) are IRREVERSIBLE: they ALWAYS come last — after the Review and immediately after the Final Approval checkpoint — and their step files declare `side_effects: irreversible` (see build.prompt.md, Pipeline Step Format and Gate 2c). Never place a publish/send step before the Review. Omit either bracket when the crew has no such step.
|
|
534
|
+
|
|
535
|
+
**Content Approval checkpoint is MANDATORY** whenever the pipeline includes any render step after content creation. Never place a render step immediately after a creation step without a checkpoint in between.
|
|
532
536
|
|
|
533
537
|
On reject: loop back to creation step (re-execute full creator, not individual tasks).
|
|
534
538
|
|
|
@@ -555,8 +559,8 @@ I'll create a crew with N agents:
|
|
|
555
559
|
Format: [format name, if applicable]
|
|
556
560
|
...
|
|
557
561
|
|
|
558
|
-
Pipeline (fixed source): [Research] → checkpoint Select Angle → [Creator] → checkpoint Approve Content → [
|
|
559
|
-
Pipeline (news-based): [Research] → checkpoint Select News → [Creator: generate angles] → checkpoint Select Angle → [Creator: create content] → checkpoint Approve Content → [
|
|
562
|
+
Pipeline (fixed source): [Research] → checkpoint Select Angle → [Creator] → checkpoint Approve Content → [Render] → [Review] → checkpoint Final Approval → [Publish/Send]
|
|
563
|
+
Pipeline (news-based): [Research] → checkpoint Select News → [Creator: generate angles] → checkpoint Select Angle → [Creator: create content] → checkpoint Approve Content → [Render] → [Review] → checkpoint Final Approval → [Publish/Send]
|
|
560
564
|
Formats: [list of selected formats, e.g., instagram-feed, twitter-thread]
|
|
561
565
|
|
|
562
566
|
Reference materials: [list of data files]
|
|
@@ -20,7 +20,8 @@ Before starting execution:
|
|
|
20
20
|
optional, opt-in feature that most installs never use (it requires running the
|
|
21
21
|
separate dashboard app from source — see README). Scan the already-loaded
|
|
22
22
|
`preferences.md` for a `Dashboard:` field:
|
|
23
|
-
- If
|
|
23
|
+
- If its value is `enabled` (as written by onboarding: `- **Dashboard:** enabled`, or the
|
|
24
|
+
plain form `Dashboard: enabled`) → set `dashboard_enabled = true` for this run.
|
|
24
25
|
- Otherwise (`disabled`, missing, or preferences.md not configured yet) →
|
|
25
26
|
set `dashboard_enabled = false`. This is the default.
|
|
26
27
|
Store `dashboard_enabled` in working memory for the rest of this run. Every
|
|
@@ -309,6 +310,15 @@ Before executing any step that references an agent:
|
|
|
309
310
|
c. Inject this block immediately after the agent definition and BEFORE format/skill context.
|
|
310
311
|
d. Skip sections that are empty or not relevant to the current agent (e.g., skip Design Visual for a writer agent).
|
|
311
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
|
+
```
|
|
312
322
|
|
|
313
323
|
### Context Compression (Summary-Based Handoff)
|
|
314
324
|
|
|
@@ -546,7 +556,12 @@ Use the **stored transformed path** (after Output Path Transformation Steps 1 an
|
|
|
546
556
|
|
|
547
557
|
**Rules:**
|
|
548
558
|
- If ALL output files return `VALIDATION:PASS` → proceed to Veto Condition Enforcement.
|
|
549
|
-
-
|
|
559
|
+
- **Irreversible step** (`side_effects: irreversible` — publish, post, send) with ANY
|
|
560
|
+
`VALIDATION:FAIL` → NEVER re-execute it. Tell the user: "⚠️ {Agent Name} did not save its
|
|
561
|
+
output, but the action may already have happened (post published / email sent). Check
|
|
562
|
+
before retrying." Then offer: 1. Retry step (only after the user checked) · 2. Mark as done
|
|
563
|
+
and continue · 3. Abort pipeline.
|
|
564
|
+
- If ANY output file returns `VALIDATION:FAIL` (any other step):
|
|
550
565
|
1. **Retry once**: re-execute the entire step with the same input and context.
|
|
551
566
|
2. After re-execution, run the validation again for all output files.
|
|
552
567
|
3. If second attempt returns `VALIDATION:PASS` for all files → proceed normally.
|
|
@@ -612,6 +627,9 @@ After an agent completes a step (before moving to the next step):
|
|
|
612
627
|
- Ask the agent to fix the specific issue (re-execute with targeted correction)
|
|
613
628
|
- Maximum 2 veto fix attempts per step
|
|
614
629
|
- After 2 failed attempts, present to user for manual decision
|
|
630
|
+
- **Never auto-fix an irreversible step** (`side_effects: irreversible`): re-executing it
|
|
631
|
+
would publish/send again. Report the veto, warn the user that
|
|
632
|
+
the action may already have happened, and let the user decide.
|
|
615
633
|
4. If no veto conditions triggered: proceed to next step
|
|
616
634
|
|
|
617
635
|
This creates an internal quality loop BEFORE the reviewer sees the content,
|
|
@@ -619,11 +637,39 @@ catching obvious issues early and reducing review cycle waste.
|
|
|
619
637
|
|
|
620
638
|
### Review Loops
|
|
621
639
|
|
|
622
|
-
When a step has `on_reject: {step-id}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
-
|
|
626
|
-
|
|
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.
|
|
627
673
|
|
|
628
674
|
### Dashboard Handoff (between steps)
|
|
629
675
|
|
|
@@ -809,6 +855,9 @@ This archives the run state for the `runs` command while keeping crew history av
|
|
|
809
855
|
## Error Handling
|
|
810
856
|
|
|
811
857
|
- If a subagent fails, retry once. If it fails again, inform the user and offer to skip the step or abort.
|
|
858
|
+
- If an irreversible step (`side_effects: irreversible`) fails, NEVER retry it automatically:
|
|
859
|
+
the post/email may already have happened. Tell the user so, ask them to check, and let them
|
|
860
|
+
choose: retry, mark as done, or abort.
|
|
812
861
|
- If a step file is missing, inform the user and suggest running `/opencrew edit {crew}` to fix.
|
|
813
862
|
- If company.md is empty, stop and redirect to onboarding.
|
|
814
863
|
- Never continue past a checkpoint without user input.
|
|
@@ -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
|
+
}
|
package/templates/gitignore
CHANGED