Unified development workflow — bridges superpowers (discipline/TDD) with dotcontext (agents/PREVC/context)

Pare de codar sem processo. DevFlow transforma o Claude Code em um time completo de engenharia — com planejamento, revisão, TDD, agentes especialistas e validação automática.
DevFlow é um plugin que conecta superpowers (disciplina, TDD, brainstorming) com dotcontext (agentes, workflow PREVC, contexto do projeto) em um fluxo unificado.
Markdown puro + shell. Zero dependências de runtime. Funciona como plugin para Claude Code, Cursor, Codex e Gemini CLI.
supervised (padrão), assisted (humano nas pontas), autonomous (loop completo com safety net)assets/skills/profiles/<fw>/ e é copiado para o .context/ do projeto só quando o perfil casa — nunca registrado no namespace global--from-prd converte PRD em stories.yaml, upgrade de autonomia mid-workflow┌──────────────────────────────────────────────────────┐
│ DevFlow │
│ (skills + agents + hooks + gates) │
├────────────────────┬─────────────────────────────────┤
│ superpowers │ dotcontext │
│ (disciplina) │ (contexto + workflow) │
├────────────────────┼─────────────────────────────────┤
│ brainstorming │ fases PREVC │
│ TDD iron law │ 20 agentes via MCP │
│ SDD (subagents) │ análise semântica │
│ code review 2x │ gestão de planos │
│ anti-racional. │ sync multi-tool │
│ git worktrees │ escala adaptativa │
├────────────────────┴─────────────────────────────────┤
│ Funcionalidades exclusivas DevFlow │
├──────────────────────────────────────────────────────┤
│ Loop autônomo (stories.yaml + contexto fresco/story) │
│ TDD HARD-GATE (RED→GREEN→REFACTOR, todos os modos) │
│ Geração de PRD (product-manager + RICE + MoSCoW) │
│ git-strategy gate (proteção de branch via hook) │
│ checkpoint/rehydration (PreCompact + PostCompact) │
│ context-sync (/devflow:devflow-sync) │
│ roteamento de escala (QUICK/SMALL/MEDIUM/LARGE) │
│ devflow-runner.mjs (safety net externo) │
└──────────────────────────────────────────────────────┘
# 1. Instalar dependências (uma vez)
claude plugin install superpowers@claude-plugins-official --scope user
claude plugin marketplace add NEXUZ-SYS/devflow
claude plugin install devflow@NEXUZ-SYS --scope user
# 2. Inicializar no projeto
cd meu-projeto && claude
/devflow init
# 3. Começar a trabalhar
/devflow add autenticação com OAuth
Para instruções detalhadas de instalação, configuração e uso completo, veja o Manual do Usuário.
| Documento | Conteúdo |
|---|---|
| Manual do Usuário | Instalação, configuração, fluxo completo, exemplos por escala, troubleshooting |
| Guia ADR/Standards/Linter | Referência de uso da camada de contexto: quando criar cada artefato, encaixe no PREVC, troubleshooting |
| Enforcement de standards | Standards como gate: níveis, baseline, gate no CI, override, limites e migração |
| /devflow help | Referência completa de comandos (também acessível via /devflow help no Claude Code) |
| Skills Map | Mapa completo de skills nos 3 sistemas (DevFlow, superpowers, dotcontext) |
Os standards deixam de ser lembrete e viram gate: um motor único aplica os linters, classifica cada achado em block, warn ou review e compara com um baseline versionado que só encolhe. Só a violação nova de nível block bloqueia, na edição (hook síncrono), no commit (pre-commit), no CI e na fase V. Aumentar o baseline ou rebaixar um nível é decisão do humano, no terminal dele.
/devflow init e o /devflow:devflow-sync oferecem, um item por vez, o shim e o pre-commit, o job de CI (GitHub Actions ou GitLab), verify.standards e o CODEOWNERS. Sem baseline (baseline init, no terminal do operador) o hook não bloqueia..context/bin/devflow-plugin.ref (lida da branch de destino) e roda gate --base-ref=refs/remotes/origin/<alvo> --ci. Um enfraquecimento deliberado no GitHub é liberado pelo rótulo standards-ratchet-approved e por um review APPROVED no último commit, de quem é dono dos arquivos da catraca (o sinal rodado pelo executor repassa o override com DEVFLOW_PR_NUMBER e DEVFLOW_REPO). Configurar o job como required check e "Require review from Code Owners" é do operador.Guia completo: docs/guia-enforcement-standards.md · decisão: ADR-015.
O DevFlow pode escolher modelo e esforço conforme a fase do PREVC, a skill ativa, o agente e a task do plano: a sessão principal troca de modelo só na fronteira de fase e os subagentes recebem o tier que o trabalho pede, sempre limitados ao modelo e ao esforço que você escolheu. É opt-in duplo (models.enabled no repositório e DEVFLOW_MODEL_ROUTING=1 no seu ambiente), funciona no Claude Code (mod ou fallback clássico; a variável CLAUDE_CODE_ENABLE_FUNCTION_HOOKS é opcional nas versões testadas) e no omp, e mede a economia em tokens por modelo. Um monitor ao vivo (sempre ligado, só observa) mostra acima do prompt uma linha por agente em execução, com modelo, esforço, origem da escolha, tempo, falhas de ferramenta e retentativas da mesma task.
Guia completo: docs/model-routing.md · decisão: ADR-017.
| Ferramenta | Subagents | MCP | Hooks |
|---|---|---|---|
| Claude Code | Completo | Completo | Completo |
| Cursor | Sequencial | Completo | Completo |
| Codex | Completo | -- | -- |
| Gemini CLI | Sequencial | Completo | -- |
| OpenCode | Sequencial | Completo | -- |
O DevFlow roda sob o omp via uma camada de extensão nativa (tool-gating, compact e contexto dinâmico) que reaproveita os hooks bash existentes. Lance a sessão pelo launcher devflow omp para contexto autoritativo desde o turno 1.
Detalhes de instalação, pré-requisitos e cobertura por subsistema: docs/omp-integration.md.
A partir da v1.0, o .context/ ganha 4 dimensões novas que transformam o DevFlow de "orquestrador de skills" em harness completo para projetos reais:
| Pasta/arquivo | Função | ADR |
|---|---|---|
.context/adrs/ | ADRs com path canônico (era .context/docs/adrs/); dual-read até v1.2 | ADR-001 |
.context/standards/ | Standards com tripla camada (Markdown + LLM frontmatter + linter sandboxed SI-4) | ADR-002 |
.context/stacks/ | Docs versionadas por library indexadas no store global do docs-mcp-server (mcpIndexed no manifest; refs .md são legado) | ADR-003 |
.context/permissions.yaml | Gramática vendor-neutral deny → allow → mode → callback | ADR-004 |
.context/observability.yaml | OTel GenAI semconv opt-in; gen_ai.* + devflow.* namespace | ADR-005 |
.context/.devflow.yaml → verify: | Contrato de sinal verificável: a fase V observa um ledger produzido por código (executor argv + runners + CI árbitro) em vez de afirmar | ADR-013 |
.context/.lock | Hashes de conteúdo para reproducibility token | — |
Comandos novos:
devflow stacks scrape-batch --from-package # bootstrap stack docs from package.json
devflow stacks validate # check artisanalRef integrity + SI-6 fence
devflow stacks reconcile # casa o manifesto com a série real (poda só com --yes)
devflow standards new error-handling # scaffold std-<id>.md + linter template
devflow standards verify --strict # fail CI on weak-standards
Security invariants (SI-1 a SI-7) aplicados: no node -e interpolation, execFile sempre, URL allowlist (cloud metadata + RFC1918 + link-local + trailing-dot), linter sandboxing (.context/standards/machine/** realpath gate), glob subset, snippet sanitization (sha256 canary), hook sequencing.
Filosofia de dependências: zero npm deps em runtime. Seis primitivas in-house em scripts/lib/ substituem micromatch/gray-matter/tiktoken. OTel SDK é a única exceção, lazy-loaded só com observability.enabled: true.
Ver .context/adrs/00[1-5]-*.md e .context/plans/context-layer-v2.md para o detalhe de design e cobertura de testes (55 tests, 27 novos arquivos).
A partir da v1.8, o .context/ adota um layout DDC de 4 níveis que transforma o contexto de projeto em memória narrativa consultável durante o PREVC.
.context/ com DDC.context/
├── business/ # visão, ICP, métricas, glossário, compliance, modelo de negócio
├── product/ # visão, persona, tom de voz, design system, políticas
├── operations/ # runbooks, on-call, SLOs, infra configs
└── engineering/ # container único dos subsistemas técnicos
├── adrs/ # ADRs (path canônico re-canonicalizado de .context/adrs/ por ADR-006)
├── standards/ # standards tripla-camada (ADR-002)
├── stacks/ # pipeline artesanal de docs (ADR-003)
└── templates/ # templates de scaffolding
Por que engineering/ como container: garante que hooks e scripts lêem um path determinístico; context-paths.mjs é o keystone — nenhum script hardcoda paths, sempre consulta esse módulo.
| Mecanismo | Como usar | Quando usar |
|---|---|---|
| Standards | devflow standards new <concern> | Guardrails LLM para concerns operacionais (ex: runtime-validation) |
| ADRs | /devflow:devflow-adr new | Decisões de arquitetura com impacto duradouro |
| Stacks | devflow stacks scrape-batch --from-package | Docs de libraries consultáveis offline |
| Knowledge | devflow:knowledge skill (CREATE/AUDIT) | Narrativa de domínio (visão, ICP, personas, infra) |
O mecanismo Knowledge é novo na v1.8: a skill devflow:knowledge scaffolda e audita docs narrativos em .context/<layer>/ via CLI:
node scripts/devflow-knowledge.mjs new --type=<type-id> --name=<name> --project=<path>
node scripts/devflow-knowledge.mjs audit --name=<name> --project=<path>
Cada camada tem um agente responsável pela sua manutenção. Eles são o front door para escrita no .context/ — nunca escreva nas pastas de camada diretamente sem passar por um curador.
| Agente | Camada | Responsabilidade |
|---|---|---|
business-context | .context/business/ | Visão estratégica, ICP, métricas, glossário, compliance |
product-context | .context/product/ | Visão de produto, persona, tom de voz, design system, políticas |
operations-context | .context/operations/ | Runbooks, on-call, SLOs, configurações de infra |
engineering-context | .context/engineering/ | Arquitetura, subsistemas, roteamento de briefings técnicos |
Se o projeto usa o layout anterior (.context/adrs/, .context/standards/, .context/stacks/ na raiz do .context/), migre para o container engineering/:
/devflow update migration
# ou equivalente:
/devflow migration
O comando invoca devflow:migration, que reloca os subsistemas para .context/engineering/ e reescreve todas as cross-references sem perder histórico.
KNOWLEDGE_INDEX — mapa de cross-references das camadas gerado por scripts/lib/print-knowledge-index.mjs — carregado 1x por sessão.scripts/lib/print-knowledge-bodies.mjs) — recuperação on-demand.devflow:knowledge-filter para selecionar apenas os docs de camada relevantes à task antes de entrar no brainstorming.Compatibilidade com dotcontext: os diretórios gerenciados pelo dotcontext (docs/, agents/, skills/, plans/) não são tocados pelo DDC.
Ver .context/engineering/adrs/006-context-layer-knowledge-ddc-v1.0.0.md para o design completo e rationale.
| Versão | Data | Destaques |
|---|---|---|
| 1.25.0 | 2026-07-01 | Feat: Versionamento via pipeline de CI + finish consciente do modo. Aposenta o auto-bump local (causa do "pulo" de versão): release.yml (workflow_dispatch → bump-version.sh → release PR), version-guard.mjs (consistência dos 3 files + transição semver, zero-dep), tag-release.yml (tag + GitHub Release no merge, notas via changelog-extract.mjs), hook pre-commit → validação (não bumpa mais). Finish 3-way git.versioning (local/pipeline/none): o finish não bumpa e o BUMP WARNING some em pipeline/none/sem-mecanismo; /devflow config (P5b) detecta e oferece o modo. Histórico reconciliado (1.24.0 Instinct, 1.23.4 AO, backfill 1.21/1.22/1.23.0). TDD: version-guard 7 + changelog-extract 5 + pre-commit 8 + post-tool-use 22. |
| 1.24.0 | 2026-06-23 | Feat: Instinct System (continuous learning) — primeiro item importado do ECC. Loop de aprendizado automático: hooks observam tool-use → destila instincts (gatilho→ação) pontuados por confiança (0.3→0.9) num store Node zero-dep XDG project-scoped → recall bounded no SessionStart → pontes que PROPÕEM napkin/MemPalace (complementar, não duplica — alimenta os outros). Captura redige PII/credenciais (env-var UPPER_SNAKE, AWS/GH/Stripe/JWT/PEM/Slack/Google/GitLab, URL-cred); store nunca commitado. Ativação N2 estrita: opt-in pelo YAML (instincts.enabled: true), env só restringe (DEVFLOW_INSTINCTS_ENABLED=0/DEVFLOW_INSTINCT_PROFILE=off) — env nunca habilita o que o YAML desligou; pergunta enquadrada no /devflow config (distinta de MemPalace/napkin) + comando /devflow instinct. Promoção project→global (≥2 projetos), prune por TTL. Auditoria de segurança adversarial: 8 achados (path-traversal no id→safeId, stored prompt-injection no digest, fail-closed do hook sob set -u, vazamentos de credencial) corrigidos via TDD. ADR-005 → v1.1.0 (disciplina opt-in/local/redação consumer-agnostic). 51 testes da feature (unit+integração+e2e); regressão do repo verde. |
| 1.23.4 | 2026-06-19 | Feat: AO como 3ª pata do bridge — execução paralela da fase E via Agent Orchestrator (AO) (Planos 1–3). Nova seção orchestrator: no .context/.devflow.yaml (entrevista devflow:config Step 2.6 + reuso project-init Step 0.6 via orchestrator-config.mjs:orchestratorBlock(); pré-condição user-scope bloqueante). Libs puras computeWaves/readyStories/shouldParallelize + geradores aoRulesContent()/agentOrchestratorYaml() (permissions: permissionless, merge sempre manual). autonomous-loop ganha Step 1.6 (gate paralelo; AO_OK=false tem precedência sobre --parallel) e Step 1.7 (ondas via ao start, polling, workers /devflow scale:SMALL com TDD, V+C globais), com fallback sequencial obrigatório; flags --parallel/--no-parallel. Reactions (Plano 4) ficam OFF. Consolida os auto-bumps 1.23.4–1.23.10; version files seguem em 1.24.0 (Instinct). |
| 1.23.3 | 2026-06-19 | Feat: /devflow init valida o escopo do plugin para uso com Agent Orchestrator (AO) (novo Step 0.6 no project-init). Quando o projeto é operado via AO (@aoagents/ao — detectado por command -v ao, ~/.agent-orchestrator/ ou agent-orchestrator.yaml, ou informado pelo usuário), o init valida que DevFlow e superpowers estão instalados em escopo user, não project. Motivo: workers do AO rodam em git worktrees efêmeros fora do projeto; plugins só em escopo project não resolvem lá (Unknown command: /devflow, trilho PREVC/TDD não ativa). Orienta a reinstalar via --scope user. Descoberto no PoC AO × DevFlow. |
| 1.23.2 | 2026-06-18 | Fix: permissions.yaml deny acionável + detecção de schema legado (GAP-PERM-ROOT). Um permissions.yaml legado (version:0, deny/allow listas, mode:{default}) reprovava o schema e fazia fail-closed mode:deny repo-wide, mas só mostrava o opaco mode: deny (erros de schema iam p/ stderr descartado pelo hook). Agora loadPermissions anexa um __denyReason acionável que trafega via stdout→hook→usuário (sem mudar o hook); novo detectLegacySchema disjuntivo emite "migre p/ devflow-permissions/v0 (/devflow init/config)" no lugar do críptico "[object Object]", com fallback mode não-string que fecha o risco de fail-open; __denyReason montado só com marcadores controlados (anti-injeção). Skill config 5.3 dividida em 2 perguntas (3+2) p/ respeitar o cap de 4 opções do AskUserQuestion. Novo check permissions-health no devflow-doctor (detecção proativa + repair /devflow config). +23 testes (anti-fail-open, anti-injeção, hook E2E, lint da skill, doctor); suíte rastreada 1531/1531. |
| 1.23.1 | 2026-06-18 | Fix: pre-tool-use não localizava a config quando o evento chega sem cwd. O gate de configuração resolvia .context/.devflow.yaml só por $CWD (sem o fallback ${CWD:-$PWD} dos demais blocos); quando o harness não envia cwd no PreToolUse, $CWD vinha vazio → o hook concluía "sem config" e negava 100% das edições (Edit/Write) mesmo com config válida em branch não-protegida. Correção: DEVFLOW_CONFIG="${CWD:-$PWD}/.context/.devflow.yaml". Sem over-allow (branch protegida com cwd vazio segue bloqueada via branch protection). TDD: testes 15–16 em test-pre-tool-use.sh (22/22). |
| 1.23.0 | 2026-06-17 | Feat: Sync provenance-aware (context-sync/project-init). O sync deixa de pular cegamente todo artefato existente (regra "ausente"/status: filled) e passa a distinguir, por hash, deploy intocado (auto-atualiza para a versão nova do plugin) de edição local real (preserva + reporta) — para skills e standards de profile (agents seguem o fluxo fillSingle, fora). Nova lib determinística scripts/lib/provenance-sync.mjs: resolveArtifacts (profiles compostos → {src,dest,framework}), decideArtifact (7 linhas, incl. pluginHash==null), applySync contido (isWithinDir src⊂plugin/dest⊂.context + recusa de symlink + refused, report em paths relativos), CLI apply. Manifesto .context/.provenance.json (por projeto) + registry assets/provenance/known-hashes.json (270 hashes) gerado por histórico de commits (gen-known-hashes.mjs; git tags não servem — releases são commits), com bump-version.sh --append no release. Resolve o caso real: deploys antigos intocados (ex.: odoo-development@1.19.1) agora atualizam no sync. Segurança: testes RED de path-traversal + symlink. 22 testes da feature; regressão do repo 396/396. |
| 1.22.0 | 2026-06-17 | Feat: Artefatos Odoo multi-versão (12–18) em 3 camadas. Os artefatos Odoo do plugin foram reestruturados separando framework genérico de conhecimento de empresa: L1 (odoo-development + frontend-specialist-odoo) viram core genérico país-agnóstico cobrindo Odoo 12–18 (frontend legacy widgets 12–14 → OWL1/2/3), com env desacoplado e grounding híbrido (tabelas + ponteiros docs-mcp/OCA); L2 nova skill odoo-l10n-br (localização BR reutilizável: l10n_br/NFC-e/SEFAZ/DANFE, nomes OCA); L3 nova skill odoo-nxz-overlay (arquitetura/grafo/bridges NXZ), implantada só em projetos NXZ via novo profiles/nxz.yaml que compõe sobre o profile odoo. detect-framework.mjs ganha detecção por dirPrefixes/manifestContent (match por chave, symlink-safe, cap anti-DoS) e standardsWithOrigin resolvendo a origem de cada std na composição. Standards NXZ (oca-separation, fiscal-br-integrity) migram p/ profiles/nxz/; demais 15 ficam genéricos no profiles/odoo/. Novo stack backend/odoo.md + wishlist 12–18. Suíte de lint TDD dedicada (8 critérios: cobertura de versão, env, separação de camadas, integridade estrutural, grounding, cross-refs, front-matter, integridade de profile). Env de projeto sai p/ .context/odoo-project.md (novo template). 75 testes do workflow + suíte do repo 330/330. |
| 1.21.0 | 2026-06-15 | Feat: Importador Reversa → DevFlow (skill devflow:import-reversa + comando /devflow import-reversa <source> + lib scripts/reversa-import/). Lê um projeto gerado pelo Reversa e o aterrissa como projeto DevFlow executável com fidelidade híbrida (executar + preservar): deriva PRD faseado, ADRs, plans.json, esqueletos de plano e stories.yaml da 1ª onda (decompõe o resto via --from-prd), e preserva os artefatos ricos originais linkados em .context/imported/reversa/. Arquitetura na fronteira do IR (parsers plugáveis → IR → emitters), pipeline puro + escrita não-destrutiva. Pre-flight Readiness Gate (triangula sinais; state.json não é gospel) e Plan Consistency Validation (7 checks) com reconciliação interativa. Confiança 🟦🟢🟡🔴 inline + fidelity-report.md (🔴 → stories "resolver lacuna"). Segurança: toSlug+isWithinDir (anti path-traversal), recusa de symlink na cópia, stripInjection (SI-6) no conteúdo de terceiro. Re-import não-destrutivo com manifesto de proveniência + diff por hash. 96 testes (unit+integração+E2E contra fixture real em tmpdir; contratos validados contra runner-lib/adr-frontmatter reais); suíte do repo 329/329. |
| 1.19.0 | 2026-06-12 | Feat: Integração ADR↔decisão cross-aware no PREVC. O Step 3.5 do prevc-planning passa a cruzar a decisão detectada com as ADRs já carregadas e oferecer EVOLVE (quando toca uma ADR existente), CREATE (quando não há) ou silêncio (quando alinhada) — antes só oferecia CREATE. Heurística de detecção s
hooks/router.mjs 366 lines1// hooks/router.mjs — adaptador MOD do roteamento de modelos (spec §4.2/§4.3, D18–D21).
2// Só traduz eventos do engine para scripts/lib/router-core.mjs. Qualquer falha → next(e) intocado.
3// Também hospeda o monitor ao vivo (seção "Monitor ao vivo"): o engine aceita um módulo por plugin.
4// Exigência do validate: toda função que recebe `$` é declarada aqui no topo; o estado é do módulo.
5import * as core from "../scripts/lib/router-core.mjs";
6import { readModels } from "../scripts/lib/models-config.mjs";
7import { effectiveConfig, phaseFromPrevcJson, workflowFromPrevcJson } from "../scripts/lib/model-routing.mjs";
8import { rubricPrompt, parseAnswers, combine } from "../scripts/lib/escalation.mjs";
9import { buildEntry, ledgerDirFrom } from "../scripts/lib/routing-ledger.mjs";
10import * as mc from "../scripts/lib/monitor-core.mjs";
11
12const MAX_FILE = 256 * 1024;
13const MAX_LEDGER_LINES = 2000;
14const ROUTING = { plugin: "devflow", key: "routing" }; // lido pelo monitor ao vivo (seção abaixo)
15const MAX_PUB_LOOPS = 100;
16const S = {
17 core: core.createRouterState(),
18 table: null,
19 config: readModels(""),
20 loaded: false,
21 disabled: false,
22 sessionOff: false,
23 pendingSwitch: false,
24 cwd: null,
25 workflow: null,
26 ledgerPath: null,
27 lines: [],
28 dirty: false,
29 pub: {},
30 pubLast: "",
31 sessionKey: `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`,
32};
33
34const active = () => !S.disabled && S.config.enabled && !!S.table;
35
36function pubLoop(id, entry) {
37 if (JSON.stringify(S.pub[id]) === JSON.stringify(entry)) return;
38 delete S.pub[id];
39 S.pub[id] = entry;
40 const ks = Object.keys(S.pub);
41 for (let i = 0; i < ks.length - MAX_PUB_LOOPS; i++) delete S.pub[ks[i]];
42}
43
44// Monitor (spec 2026-10-09-router-monitor-toolbar §3.4): só publica; nunca muda o que o router decide.
45async function publish($) {
46 try {
47 const value = { active: active(), failureStreak: S.config.midRun?.failureStreak ?? 3, loops: S.pub };
48 const json = JSON.stringify(value);
49 if (json === S.pubLast) return;
50 await $.state.set(ROUTING, value);
51 S.pubLast = json; // só depois do set: falha é tentada de novo na próxima publicação
52 } catch {}
53}
54
55// Leitura de arquivo do repositório com a contenção da ADR-014: sem link, só arquivo regular,
56// tamanho limitado, caminho real sob a raiz do projeto. Qualquer dúvida → null.
57async function safeRead($, rel) {
58 try {
59 if (!S.cwd) return null;
60 const abs = `${S.cwd}/${rel}`; // $.fs resolve relativo contra o cwd da sessão; absoluto não depende disso
61 const st = await $.fs.stat(abs, { resolve: true });
62 if (st.isLink || st.kind !== "file" || st.size > MAX_FILE) return null;
63 if (!st.realPath || !st.realPath.startsWith(S.cwd + "/")) return null;
64 return await $.fs.read(abs);
65 } catch {
66 return null;
67 }
68}
69
70async function ensure($) {
71 if (S.loaded) return;
72 S.loaded = true;
73 try {
74 // Raiz = cwd da sessão (segue worktree, /cd), não o PWD do ambiente.
75 const cwd = await $.session.cwd();
76 const st = cwd ? await $.fs.stat(cwd, { resolve: true }) : null;
77 S.cwd = st?.kind === "dir" && st.realPath ? st.realPath : null;
78 } catch { S.cwd = null; }
79 try { S.table = JSON.parse(await $.fs.read(`${$.plugin.root}/assets/model-routing/routes.json`)); } catch { S.table = null; }
80 try {
81 const optIn = await $.env.get("DEVFLOW_MODEL_ROUTING");
82 S.config = effectiveConfig(readModels((await safeRead($, ".context/.devflow.yaml")) ?? ""), optIn);
83 if (S.config.enabled && S.config.ledger) {
84 const home = await $.env.get("HOME");
85 const xdg = await $.env.get("XDG_DATA_HOME");
86 if (home && S.cwd) S.ledgerPath = `${ledgerDirFrom({ xdgDataHome: xdg, home, cwd: S.cwd })}/${S.sessionKey}.jsonl`;
87 }
88 } catch { S.config = effectiveConfig(readModels(""), undefined); S.ledgerPath = null; } // falha ao ler o ambiente → roteamento desligado
89 if (S.config.enabled) await detectOtherRouter($);
90 await publish($);
91}
92
93// D17: outro roteador de sessão habilitado → a camada de sessão do DevFlow se desliga.
94async function detectOtherRouter($) {
95 try {
96 const s = await $.settings.read();
97 // Só entradas habilitadas (=== true): plugin instalado mas desabilitado não conta.
98 const names = Object.entries(s?.enabledPlugins ?? {}).filter(([, v]) => v === true).map(([n]) => n);
99 if (names.some((n) => /jev[-_]?router/i.test(n))) {
100 S.sessionOff = true;
101 $.ui.toast("DevFlow: outro roteador de sessão está habilitado — a camada de sessão do DevFlow fica desligada; subagentes seguem roteados.");
102 }
103 } catch { /* sem leitura de settings: segue */ }
104}
105
106function ledger(fields) {
107 if (!S.ledgerPath || S.lines.length >= MAX_LEDGER_LINES) return;
108 S.lines.push(JSON.stringify(buildEntry({ ts: new Date().toISOString(), sessionId: S.sessionKey, adapter: "mod", ...fields })));
109 S.dirty = true;
110}
111
112async function flushLedger($) {
113 if (!S.ledgerPath || !S.dirty) return;
114 S.dirty = false;
115 try { await $.fs.write(S.ledgerPath, S.lines.join("\n") + "\n"); } catch { S.ledgerPath = null; }
116}
117
118async function decideMidRun($, agentId) {
119 const a = S.core.agents[agentId];
120 let decision = { action: "keep", tier: a.tier };
121 try {
122 const out = await $.model.complete({ model: "haiku", prompt: rubricPrompt({ agentType: "subagente", tier: a.tier, report: core.midRunReport(S.core, agentId), midRun: true }), effort: "low", timeoutMs: 8000 });
123 if (out?.isAnswered) decision = combine(parseAnswers(out.text), { current: a.tier, ceiling: a.ceiling, maxTier: S.config.maxTier, signalRed: true, midRun: true, thresholds: S.config.thresholds });
124 } catch { /* decisor falhou: mantém o tier */ }
125 const applied = core.applyMidRun(S.core, agentId, decision, S.config.maxTier);
126 ledger({ scope: "subagent", agentId, escalation: { at: "midRun", from: a.tier, to: applied ? decision.tier : a.tier, action: applied ? "escalate" : "keep" } });
127}
128
129async function onSessionStart($, e, next) {
130 const r = await next(e);
131 await ensure($);
132 try { await $.command.register({ name: "devflow-route", description: "Roteamento de modelos do DevFlow: status | on | off | session off" }); } catch {}
133 return r;
134}
135
136async function onCommand($, e, next) {
137 if (e.command !== "devflow-route") return next(e);
138 await ensure($);
139 const a = String(e.args ?? "").trim();
140 if (a === "off") S.disabled = true;
141 else if (a === "on") S.disabled = false;
142 else if (a === "session off") S.sessionOff = true;
143 await publish($);
144 const c = S.core;
145 return {
146 text: [
147 `roteamento: ${active() ? "ligado" : "desligado"}${S.sessionOff ? " (sessão off)" : ""}${S.config.enabled ? "" : " — exige models.enabled no repo e DEVFLOW_MODEL_ROUTING=1"}`,
148 `fase: ${c.phase ?? "—"} · skill: ${c.skill ?? "—"}`,
149 `teto: ${c.userModel ?? "?"} · ${c.userEffort ?? "?"} · IDs conhecidos: ${Object.values(c.ids).join(", ") || "—"}`,
150 `sessão no tier: ${c.sessionTier ?? "—"} · subagentes rastreados: ${Object.keys(c.agents).length}`,
151 ].join("\n"),
152 };
153}
154
155async function onTurnStart($, e, next) {
156 await ensure($);
157 // Lê o prevc.json uma vez por turno, com ou sem roteamento: fase p/ o roteador, workflow p/ o escopo do monitor.
158 const prevc = (await safeRead($, ".context/runtime/workflows/prevc.json")) ?? "";
159 S.workflow = workflowFromPrevcJson(prevc);
160 if (active()) core.onTurnStart(S.core, { phase: phaseFromPrevcJson(prevc) });
161 return next(e);
162}
163
164async function onAgentSpawn($, e, next) {
165 await ensure($);
166 if (!active()) return next(e);
167 const route = core.onSpawn(S.core, e, { table: S.table, config: S.config, phase: S.core.phase, skill: S.core.skill });
168 const res = await next(route?.model ? { ...e, model: route.model } : e);
169 if (res && "agentId" in res) {
170 core.onSpawned(S.core, res.agentId, route, res.model, e.subagentType);
171 if (route) ledger({ scope: "subagent", agentId: res.agentId, agentType: e.subagentType, phase: S.core.phase, tier: route.tier, model: res.model, effort: route.effort, source: route.source, ceiling: route.ceiling });
172 if (route) {
173 const effortRouted = S.core.userEffort != null && route.effort != null && route.effort !== S.core.userEffort;
174 pubLoop(res.agentId, { model: res.model ?? null, effort: route.effort ?? null, origin: route.tier !== route.ceiling || effortRouted ? "roteado" : "teto" });
175 await publish($);
176 }
177 }
178 return res;
179}
180
181async function onToolCall($, e, next) {
182 const res = await next(e);
183 try {
184 // skill.prompt dispara também dentro de subagentes e não traz agentId: a skill da SESSÃO vem da ferramenta Skill.
185 if (e.tool === "Skill" && !e.agentId && !res?.isError) core.onSkill(S.core, { skill: e.skill });
186 if (active() && e.agentId && S.core.agents[e.agentId]) {
187 const isError = !!res?.isError;
188 const summary = isError ? `${e.tool}: ${String(res?.text ?? "").slice(0, 200)}` : "";
189 if (core.onSubagentTool(S.core, e.agentId, { isError, summary }, S.config).trigger) await decideMidRun($, e.agentId);
190 }
191 } catch { /* nunca afeta o resultado da ferramenta */ }
192 return res;
193}
194
195async function onTurnComplete($, e, next) {
196 const res = await next(e);
197 try {
198 if (active() && res?.usage) {
199 const u = res.usage;
200 const total = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
201 // A troca de fase marcada no turn.step vai na próxima linha da sessão que tem usage (o relatório só lê essas).
202 const switched = !e.agentId && S.pendingSwitch ? true : undefined;
203 if (!e.agentId) S.pendingSwitch = false;
204 ledger({ scope: e.agentId ? "subagent" : "session", agentId: e.agentId, agentType: e.agentId ? S.core.agentTypes[e.agentId] : undefined, switched, phase: S.core.phase, skill: S.core.skill, model: u.model, usage: u, cacheReadRatio: total ? (u.cache_read_input_tokens ?? 0) / total : undefined });
205 if (!e.agentId) await flushLedger($);
206 }
207 } catch { /* ledger nunca quebra o turno */ }
208 return res;
209}
210
211async function* routerTurnStep($, e, next) {
212 let patch = null;
213 try {
214 await ensure($);
215 if (active()) {
216 if (e.agentId) patch = core.onSubagentStep(S.core, e);
217 else {
218 core.observeSession(S.core, e); // teto observado sempre (D17), mesmo com a sessão desligada
219 core.learnId(S.core, e.model); // ID completo do tier do teto sempre conhecido
220 if (!S.sessionOff) patch = core.onSessionStep(S.core, e, { table: S.table, config: S.config });
221 if (patch?.switched) S.pendingSwitch = true;
222 if (patch) $.ui.status(`devflow → ${patch.model ?? e.model} · ${patch.effort ?? e.effort ?? "-"} · fase ${S.core.phase ?? "-"}`);
223 }
224 if (e.agentId && S.pub[e.agentId]) {
225 // republica a cada passo: sem patch o valor enviado pode ter mudado (escalada por streak) e o publicado ficaria velho
226 const cur = S.pub[e.agentId];
227 pubLoop(e.agentId, { ...cur, model: patch?.model ?? e.model ?? cur.model, effort: patch?.effort ?? e.effort ?? cur.effort });
228 } else if (!e.agentId) {
229 pubLoop("main", { model: patch?.model ?? e.model ?? null, effort: patch?.effort ?? e.effort ?? null, origin: patch?.model || patch?.effort ? "roteado" : "teto" });
230 }
231 await publish($);
232 }
233 } catch { patch = null; }
234 const rw = {};
235 if (patch?.model) rw.model = patch.model;
236 if (patch?.effort) rw.effort = patch.effort;
237 return yield* next(Object.keys(rw).length ? { ...e, ...rw } : e);
238}
239
240// ─── Monitor ao vivo (spec 2026-10-09-router-monitor-toolbar, M5/M9) ─────────────────────────────
241// Só observa: cada mon* devolve o resultado de next com e intacto; a lógica própria fica em try.
242// Mora neste arquivo porque o engine só segue `$` até funções declaradas no arquivo dos on(...).
243const ROWS = { plugin: "devflow", key: "monitorRows" };
244const RETRIES = { plugin: "devflow", key: "monitorRetries" };
245const TICK_MS = 1000;
246const M = { st: null, hydrating: null, timer: null, inTick: false };
247
248async function monLoad($) {
249 const [rows, retries] = await Promise.all([$.state.get(ROWS), $.state.get(RETRIES)]);
250 return { rows: Array.isArray(rows?.value) ? rows.value.map((r) => ({ ...r })) : [], retries: { ...(retries?.value ?? {}) } };
251}
252
253// Cópia de trabalho única: hooks concorrentes esperam a mesma leitura (nenhum sobrescreve o outro).
254async function monHydrate($) {
255 if (M.st) return M.st;
256 if (!M.hydrating) M.hydrating = monLoad($).then((s) => (M.st ??= s)).finally(() => { M.hydrating = null; });
257 return M.st ?? await M.hydrating;
258}
259
260async function monTick($) {
261 if (M.inTick || !M.st || !mc.isLive(M.st)) return; // sem linha viva: nada a escrever
262 M.inTick = true;
263 try {
264 const now = await $.clock.now(); // antes da lista: linha criada depois fica dentro da carência
265 let list = null;
266 try { list = await $.agent.list(); } catch { list = null; }
267 mc.reap(M.st, { list, now });
268 await $.state.set(ROWS, M.st.rows); // grava sempre: a escrita redesenha a faixa e anda o cronômetro
269 } catch {} finally { M.inTick = false; }
270}
271
272async function monSessionStart($, e, next) {
273 const r = await next(e);
274 try { if (!M.timer) M.timer = $.clock.every(TICK_MS, () => { void monTick($); }); } catch {}
275 try { await monHydrate($); } catch {}
276 return r;
277}
278
279async function monTurnStart($, e, next) {
280 try {
281 await monHydrate($);
282 mc.openMain(M.st, { now: await $.clock.now() });
283 await $.state.set(ROWS, M.st.rows);
284 } catch {}
285 return next(e);
286}
287
288async function monAgentSpawn($, e, next) {
289 const res = await next(e);
290 try {
291 if (res && typeof res.agentId === "string") {
292 await monHydrate($);
293 mc.onSpawned(M.st, { agentId: res.agentId, subagentType: e.subagentType, description: e.description, prompt: e.prompt, model: res.model, now: await $.clock.now(), scope: S.workflow });
294 await $.state.set(ROWS, M.st.rows);
295 await $.state.set(RETRIES, M.st.retries);
296 }
297 } catch {}
298 return res;
299}
300
301async function monToolCall($, e, next) {
302 const res = await next(e);
303 try {
304 await monHydrate($);
305 if (mc.onTool(M.st, { loopId: e.agentId ?? "main", isError: !!res?.isError, now: await $.clock.now() })) await $.state.set(ROWS, M.st.rows);
306 } catch {}
307 return res;
308}
309
310async function monTurnComplete($, e, next) {
311 const res = await next(e);
312 try {
313 if (!e.agentId) {
314 await monHydrate($);
315 if (mc.closeMain(M.st)) await $.state.set(ROWS, M.st.rows);
316 }
317 } catch {}
318 return res;
319}
320
321async function* monTurnStep($, e, next) {
322 try {
323 await monHydrate($);
324 if (mc.onStep(M.st, { loopId: e.agentId ?? "main", model: e.model, effort: e.effort, now: await $.clock.now() })) await $.state.set(ROWS, M.st.rows);
325 } catch {}
326 return yield* next(e);
327}
328
329// Desenho: lê de $.state (assina o redesenho), nunca escreve aqui. h(...) global, sem JSX, para seguir
330// importável no node. Sem cor, a prop `color` fica de fora.
331async function monRender($, e, next) {
332 if (e.props?.hasSurvey) return next(e);
333 let rows, routing;
334 try {
335 [rows, routing] = await Promise.all([$.state.get(ROWS), $.state.get(ROUTING)]);
336 } catch { return next(e); }
337 const st = { rows: Array.isArray(rows?.value) ? rows.value : [], retries: {} };
338 if (!mc.isLive(st)) return next(e);
339 const v = mc.view(st, { routing: routing?.value, now: await $.clock.now(), visible: mc.visibleFor(e.props?.maxRows) });
340 const { Box, Text } = $.ui.resolve(e);
341 const tint = (color) => (color ? { color } : {});
342 const line = (r) => h(Text, { wrap: "truncate-end" },
343 h(Text, { bold: true }, r.label.padEnd(mc.LABEL_COLS)),
344 ` Modelo: ${r.model} `,
345 h(Text, tint(r.originColor), `(${r.origin})`),
346 ` | Tempo: ${r.time} | `,
347 h(Text, tint(r.streakColor), `Falhas: ${r.streak}`),
348 " | ",
349 h(Text, tint(r.retriesColor), `Retentativas: ${r.retries}`));
350 return h(Box, { flexDirection: "column" },
351 ...v.rows.map(line),
352 v.more ? h(Text, { dimColor: true }, `+${v.more} agentes`) : null);
353}
354
355/** @type {import('claude-code').Register} */
356export const register = (on) => {
357 on("session.start", ($, e, next) => monSessionStart($, e, (x) => onSessionStart($, x, next))).catch(($, e, next) => next(e));
358 on("command.run", onCommand).catch(($, e, next) => next(e));
359 on("turn.start", ($, e, next) => monTurnStart($, e, (x) => onTurnStart($, x, next))).catch(($, e, next) => next(e));
360 on("agent.spawn", ($, e, next) => monAgentSpawn($, e, (x) => onAgentSpawn($, x, next))).catch(($, e, next) => next(e));
361 on("tool.call", ($, e, next) => monToolCall($, e, (x) => onToolCall($, x, next))).catch(($, e, next) => next(e));
362 on("turn.complete", ($, e, next) => monTurnComplete($, e, (x) => onTurnComplete($, x, next))).catch(($, e, next) => next(e));
363 on("turn.step", async function* ($, e, next) { return yield* monTurnStep($, e, (x) => routerTurnStep($, x, next)); });
364 on("ui.render", { component: "AbovePrompt" }, monRender).catch(($, e, next) => next(e));
365};
366scripts/lib/router-core.mjs 121 lines1// scripts/lib/router-core.mjs — máquina de estado do mod de roteamento (spec §4.2/§4.3). PURO.
2// O adaptador (hooks/router.mjs) só traduz eventos do engine para estas funções.
3import { resolveSessionRoute, resolveSubagentRoute, stepEffort, tierOf, TIERS, capAtCeiling, minTier } from "./model-routing.mjs";
4
5const MAX_ERRORS = 6;
6const MAX_SUMMARY = 300;
7const rank = (t) => TIERS.indexOf(t);
8
9export function createRouterState() {
10 return { userModel: null, userEffort: null, phase: null, skill: null, sessionTier: null, lastSessionModel: null, ids: {}, agents: {}, agentTypes: {} };
11}
12
13// Sondas R-3: e.model/e.effort que chegam ao turn.step da sessão são sempre os do usuário.
14export function observeSession(state, { model, effort }) {
15 if (typeof model === "string" && model) state.userModel = model;
16 if (effort !== undefined && effort !== null) state.userEffort = effort;
17}
18
19export function onTurnStart(state, { phase }) {
20 const p = phase ?? null;
21 if (p !== state.phase) { state.phase = p; state.skill = null; }
22}
23
24export function onSkill(state, { skill, agentId }) {
25 if (agentId) return;
26 state.skill = typeof skill === "string" ? skill : null;
27}
28
29export function learnId(state, modelId) {
30 if (typeof modelId !== "string" || !/^claude-[a-z0-9-]+$/.test(modelId)) return;
31 const t = tierOf(modelId);
32 if (t) state.ids[t] = modelId;
33}
34
35// e.model chega SEMPRE com o modelo do usuário (sonda R-3), mesmo depois de uma troca. Por isso a troca
36// é medida contra o último modelo EFETIVAMENTE aplicado à sessão (lastSessionModel). No 1o passo a sessão
37// estava no modelo do usuário. Troca só de esforço não conta (não custa cache).
38function markSwitch(state, rw, model) {
39 const effective = rw.model ?? model;
40 const before = state.lastSessionModel ?? model;
41 rw.switched = typeof effective === "string" && typeof before === "string" && effective !== before;
42 if (typeof effective === "string") state.lastSessionModel = effective;
43}
44
45export function onSessionStep(state, { model, effort }, { table, config }) {
46 if (!state.userModel) return null;
47 const route = resolveSessionRoute({ table, config, phase: state.phase, skill: state.skill, userModel: state.userModel, userEffort: state.userEffort });
48 const rw = { switched: false };
49 if (!route) { markSwitch(state, rw, model); return rw.switched ? rw : null; }
50 state.sessionTier = route.tier;
51 if (route.tier !== route.ceiling) {
52 const id = state.ids[route.tier];
53 if (id && id !== model) rw.model = id; // D21: sem ID aprendido, só esforço
54 }
55 if (route.effort && route.effort !== effort) rw.effort = route.effort;
56 markSwitch(state, rw, model);
57 return rw.model || rw.effort || rw.switched ? rw : null;
58}
59
60export function onSpawn(state, e, { table, config, phase, skill }) {
61 if (e?.fork || e?.workflow) return null;
62 return resolveSubagentRoute({
63 table, config, agentType: e?.subagentType, phase: phase ?? state.phase, skill: skill ?? null, taskTier: null,
64 explicitModel: e?.model ?? null, ceilingModel: e?.parentModel ?? state.userModel, ceilingEffort: state.userEffort,
65 });
66}
67
68// agentType de TODO subagente despachado (roteado ou não): o ledger de consumo o agrupa por tipo.
69export function onSpawned(state, agentId, route, resolvedModel, agentType) {
70 learnId(state, resolvedModel);
71 if (agentId && typeof agentType === "string") state.agentTypes[agentId] = agentType;
72 if (!agentId || !route) return;
73 state.agents[agentId] = {
74 agentType: typeof agentType === "string" ? agentType : null,
75 tier: tierOf(resolvedModel) ?? route.tier, ceiling: route.ceiling, effortBase: route.effort,
76 streak: 0, errors: [], decided: false, escalatedTo: null,
77 };
78}
79
80export function onSubagentTool(state, agentId, { isError, summary }, config) {
81 const a = state.agents[agentId];
82 if (!a) return { trigger: false };
83 if (!isError) { a.streak = 0; return { trigger: false }; }
84 a.streak += 1;
85 a.errors.push(String(summary ?? "erro").slice(0, MAX_SUMMARY));
86 if (a.errors.length > MAX_ERRORS) a.errors.shift();
87 const limit = config?.midRun?.failureStreak ?? 3;
88 const trigger = !!config?.midRun?.enabled && !a.decided && a.streak === limit;
89 if (trigger) a.decided = true; // uma consulta por subagente (segurança 4)
90 return { trigger };
91}
92
93export function onSubagentStep(state, { agentId, model, effort }) {
94 const a = state.agents[agentId];
95 if (!a) return null;
96 const rw = {};
97 if (a.escalatedTo) {
98 const cap = capAtCeiling(a.escalatedTo, tierOf(state.userModel) ?? a.ceiling);
99 const id = cap ? state.ids[cap] : null;
100 if (id && id !== model) rw.model = id;
101 }
102 const eff = stepEffort(a.effortBase, a.streak, state.userEffort);
103 if (eff && eff !== effort) rw.effort = eff;
104 return rw.model || rw.effort ? rw : null;
105}
106
107export function midRunReport(state, agentId) {
108 const a = state.agents[agentId];
109 return a ? `Falhas recentes de ferramenta (${a.streak} seguidas):\n- ${a.errors.join("\n- ")}` : "";
110}
111
112export function applyMidRun(state, agentId, decision, maxTier = null) {
113 const a = state.agents[agentId];
114 if (!a || a.escalatedTo || decision?.action !== "escalate") return false;
115 const teto = minTier(a.ceiling, tierOf(state.userModel) ?? a.ceiling);
116 const to = capAtCeiling(decision.tier, teto, maxTier);
117 if (!to || to !== decision.tier || rank(to) <= rank(a.tier) || !state.ids[to]) return false;
118 a.escalatedTo = to;
119 return true;
120}
121scripts/lib/models-config.mjs 76 lines1// scripts/lib/models-config.mjs — leitor ÚNICO do bloco `models:` do .devflow.yaml (ADR-011).
2// PURO (o mod importa). Nunca lança: qualquer problema → roteamento desligado ou entrada ignorada.
3// Lembrete (D18): este bloco é o PEDIDO do repositório; quem liga é effectiveConfig + env do usuário.
4import { namedBlock, dedentBlock } from "./yaml-block.mjs";
5import { parseYaml } from "./frontmatter.mjs";
6import { TIERS, PHASES } from "./model-routing.mjs";
7
8const MAX_BYTES = 256 * 1024;
9const AGENT_RE = /^[A-Za-z0-9_-]+$/;
10
11function defaults() {
12 return {
13 enabled: false,
14 session: true,
15 subagents: true,
16 maxTier: null,
17 ledger: false,
18 overrides: { agents: {}, phases: {}, session: { phases: {} } },
19 midRun: { enabled: false, failureStreak: 3 },
20 thresholds: { capability: 0.6, claimsDone: 0.8 },
21 };
22}
23
24const clean = (v) => (typeof v === "string" ? v.replace(/\s+#.*$/, "").trim() : v);
25const isTrue = (v) => clean(v) === true || clean(v) === "true";
26const isFalse = (v) => clean(v) === false || clean(v) === "false";
27const asTier = (v) => (TIERS.includes(clean(v)) ? clean(v) : null);
28const isMap = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
29
30export function readModels(src) {
31 const out = defaults();
32 if (typeof src !== "string" || src.length > MAX_BYTES) return out;
33 let data;
34 try {
35 const block = namedBlock(src, "models");
36 if (!block.some((l) => l.trim() !== "")) return out;
37 data = parseYaml(dedentBlock(block));
38 } catch {
39 return out;
40 }
41 if (!isMap(data)) return out;
42
43 out.enabled = isTrue(data.enabled);
44 if (data.session !== undefined) out.session = !isFalse(data.session);
45 if (data.subagents !== undefined) out.subagents = !isFalse(data.subagents);
46 out.ledger = isTrue(data.ledger);
47 out.maxTier = asTier(data.maxTier);
48
49 out.midRun.enabled = isTrue(data.midRun?.enabled);
50 const fs = Number(clean(data.midRun?.failureStreak));
51 if (Number.isInteger(fs) && fs >= 1 && fs <= 10) out.midRun.failureStreak = fs;
52 for (const k of ["capability", "claimsDone"]) {
53 const n = Number(clean(data.thresholds?.[k]));
54 if (Number.isFinite(n) && n >= 0 && n <= 1) out.thresholds[k] = n;
55 }
56
57 const ov = isMap(data.overrides) ? data.overrides : {};
58 for (const [agent, v] of Object.entries(isMap(ov.agents) ? ov.agents : {})) {
59 const t = asTier(v?.tier);
60 if (AGENT_RE.test(agent) && t) out.overrides.agents[agent] = t;
61 }
62 for (const [ph, m] of Object.entries(isMap(ov.phases) ? ov.phases : {})) {
63 if (!PHASES.includes(ph) || !isMap(m)) continue;
64 for (const [agent, v] of Object.entries(m)) {
65 const t = asTier(v?.tier);
66 if (AGENT_RE.test(agent) && t) (out.overrides.phases[ph] ??= {})[agent] = t;
67 }
68 }
69 const sp = isMap(ov.session) && isMap(ov.session.phases) ? ov.session.phases : {};
70 for (const [ph, v] of Object.entries(sp)) {
71 const t = clean(v);
72 if (PHASES.includes(ph) && (TIERS.includes(t) || t === "ceiling")) out.overrides.session.phases[ph] = t;
73 }
74 return out;
75}
76scripts/lib/model-routing.mjs 169 lines1// scripts/lib/model-routing.mjs — núcleo do roteamento de modelos do DevFlow
2// (spec docs/superpowers/specs/2026-10-08-model-routing-design.md).
3// PURO: sem import de node:* — o mod (function hooks, sem Node) importa este arquivo.
4
5export const TIERS = Object.freeze(["cheap", "standard", "capable", "top"]);
6export const EFFORTS = Object.freeze(["low", "medium", "high", "xhigh", "max"]);
7export const PHASES = Object.freeze(["P", "R", "E", "V", "C"]);
8
9// Mesma verdade do Claude Code: 1|true|yes|on, com trim e sem diferenciar caixa.
10export function functionHooksOn(v) {
11 return ["1", "true", "yes", "on"].includes(String(v ?? "").trim().toLowerCase());
12}
13
14const ALIAS = Object.freeze({ cheap: "haiku", standard: "sonnet", capable: "opus", top: "fable" });
15const ROLE = Object.freeze({ cheap: "pi/smol", standard: "default", capable: "pi/slow", top: "pi/plan" });
16const FAMILY = [
17 [/(^|[^a-z])haiku/, "cheap"],
18 [/(^|[^a-z])sonnet/, "standard"],
19 [/(^|[^a-z])opus/, "capable"],
20 [/(^|[^a-z])fable/, "top"],
21];
22
23const rank = (t) => TIERS.indexOf(t);
24const erank = (e) => EFFORTS.indexOf(e);
25
26export function tierOf(value) {
27 if (typeof value !== "string") return null;
28 const s = value.trim().toLowerCase();
29 if (!s) return null;
30 if (TIERS.includes(s)) return s;
31 for (const [tier, role] of Object.entries(ROLE)) if (role === s) return tier;
32 for (const [re, tier] of FAMILY) if (re.test(s)) return tier;
33 return null;
34}
35
36export const toAlias = (tier) => (Object.hasOwn(ALIAS, tier) ? ALIAS[tier] : null);
37export const toRole = (tier) => (Object.hasOwn(ROLE, tier) ? ROLE[tier] : null);
38
39export function nextTier(tier) {
40 const i = rank(tier);
41 return i < 0 ? null : TIERS[Math.min(i + 1, TIERS.length - 1)];
42}
43
44export function minTier(a, b) {
45 if (rank(a) < 0) return rank(b) < 0 ? null : b;
46 if (rank(b) < 0) return a;
47 return rank(a) <= rank(b) ? a : b;
48}
49
50// D5: o resultado nunca passa do teto nem do maxTier. Teto ilegível → null (não roteia).
51export function capAtCeiling(tier, ceilingTier, maxTierCfg = null) {
52 if (rank(tier) < 0 || rank(ceilingTier) < 0) return null;
53 let t = minTier(tier, ceilingTier);
54 if (rank(maxTierCfg) >= 0) t = minTier(t, maxTierCfg);
55 return t;
56}
57
58// Teto de esforço desconhecido (ausente ou numérico) → null: o adaptador não mexe no esforço.
59export function capEffort(effort, ceilingEffort) {
60 if (erank(effort) < 0 || erank(ceilingEffort) < 0) return null;
61 return erank(effort) <= erank(ceilingEffort) ? effort : ceilingEffort;
62}
63
64// D13: falha de ferramenta sobe um degrau no passo seguinte; sucesso volta ao base.
65export function stepEffort(base, failureStreak, ceilingEffort) {
66 if (erank(base) < 0) return null;
67 const up = failureStreak > 0 ? EFFORTS[Math.min(erank(base) + 1, EFFORTS.length - 1)] : base;
68 return capEffort(up, ceilingEffort);
69}
70
71// prevc.json não é confiável (ADR-014): só a fase, por allowlist.
72export function phaseFromPrevcJson(text) {
73 if (typeof text !== "string" || !text) return null;
74 try {
75 const p = JSON.parse(text)?.status?.project?.current_phase;
76 return PHASES.includes(p) ? p : null;
77 } catch {
78 return null;
79 }
80}
81
82// Nome do workflow ativo (status.project.name do prevc.json); só [A-Za-z0-9._-], até 128 chars.
83// Escopo das retentativas do monitor: não confiável (ADR-014), por isso a allowlist.
84export function workflowFromPrevcJson(text) {
85 if (typeof text !== "string" || !text) return null;
86 try {
87 const n = JSON.parse(text)?.status?.project?.name;
88 return typeof n === "string" && /^[A-Za-z0-9._-]{1,128}$/.test(n) ? n : null;
89 } catch {
90 return null;
91 }
92}
93
94// D18: o repositório pede (models.enabled); só o usuário liga (DEVFLOW_MODEL_ROUTING=1).
95export function effectiveConfig(config, envValue) {
96 const base = config && typeof config === "object" ? config : {};
97 return { ...base, enabled: base.enabled === true && envValue === "1" };
98}
99
100// ---- resolvedores (spec §5, D3 revisada na fase R, D21) ----
101
102export function agentName(agentType) {
103 return String(agentType ?? "").replace(/^devflow:/, "");
104}
105
106function isRoutable(table, agentType) {
107 const t = String(agentType ?? "");
108 return (table?.routable ?? []).includes(t) || (table?.routablePrefix ? t.startsWith(table.routablePrefix) : false);
109}
110
111function pick(...cands) {
112 for (const [tier, source] of cands) if (TIERS.includes(tier)) return { tier, source };
113 return null;
114}
115
116export function resolveSubagentRoute({ table, config, agentType, phase, skill, taskTier, explicitModel, ceilingModel, ceilingEffort }) {
117 if (!config?.enabled || !config.subagents || !table) return null;
118 if (!isRoutable(table, agentType)) return null;
119 const ceiling = tierOf(ceilingModel);
120 if (!ceiling) return null;
121 const name = agentName(agentType);
122
123 let chosen;
124 const hasExplicit = explicitModel !== undefined && explicitModel !== null && explicitModel !== "";
125 if (hasExplicit) {
126 const t = tierOf(explicitModel);
127 if (!t) return null;
128 chosen = { tier: t, source: "explicit" };
129 } else {
130 chosen = pick(
131 [taskTier, "plan"],
132 [table.skills?.[skill]?.[name] ?? table.skills?.[skill]?.["*"], "skill"],
133 [config.overrides?.phases?.[phase]?.[name], "project"],
134 [table.phases?.[phase]?.[name], "phase"],
135 [config.overrides?.agents?.[name], "project"],
136 [table.agents?.[name]?.tier, "agent"],
137 ) ?? { tier: ceiling, source: "inherit" };
138 }
139
140 const tier = capAtCeiling(chosen.tier, ceiling, config.maxTier);
141 if (!tier) return null;
142 // No próprio teto (inclui a rota inherit) o esforço do usuário é preservado: o do tier não rebaixa xhigh.
143 const wantEffort = table.agents?.[name]?.effort ?? (tier === ceiling ? ceilingEffort : table.effortByTier?.[tier]);
144 const effort = capEffort(wantEffort, ceilingEffort);
145 // D21: alias só quando muda algo. Igual ao teto (herda) ou explícito já dentro do teto → não toca.
146 const unchanged = hasExplicit ? tier === chosen.tier : tier === ceiling;
147 return { tier, model: unchanged ? null : toAlias(tier), effort, source: chosen.source, ceiling };
148}
149
150export function resolveSessionRoute({ table, config, phase, skill, userModel, userEffort }) {
151 if (!config?.enabled || !config.session || !table) return null;
152 const ceiling = tierOf(userModel);
153 if (!ceiling) return null;
154
155 let want = ceiling;
156 let source = "inherit";
157 if (PHASES.includes(phase)) {
158 const raw = config.overrides?.session?.phases?.[phase] ?? table.session?.phases?.[phase];
159 want = raw === "ceiling" ? ceiling : TIERS.includes(raw) ? raw : ceiling;
160 source = "phase";
161 }
162 const tier = capAtCeiling(want, ceiling, config.maxTier);
163 if (!tier) return null;
164 const rawEffort = table.session?.skills?.[skill];
165 // Sem esforço mapeado e no próprio teto: nada foi pedido além do que o usuário já escolheu.
166 const wantEffort = rawEffort === "ceiling" ? userEffort : rawEffort ?? (tier === ceiling ? userEffort : table.effortByTier?.[tier]);
167 return { tier, effort: capEffort(wantEffort, userEffort), source, ceiling };
168}
169scripts/lib/escalation.mjs 58 lines1// scripts/lib/escalation.mjs — rubrica e combinação da escalada (spec §6). PURO.
2// O julgamento vem do controlador (entre tentativas) ou de $.model.complete (no meio);
3// a DECISÃO é deste código, por limiares (D7/D14).
4import { TIERS, nextTier, capAtCeiling } from "./model-routing.mjs";
5import { redact } from "./instinct-redact.mjs";
6
7const PRE_CUT = 16000; // a redação é quadrática no pior caso (segurança 7): corta antes
8const MAX_REPORT = 8000;
9const KEYS = ["failure_is_capability", "claims_done_with_evidence", "is_stuck"];
10
11export function rubricPrompt({ agentType, tier, report, midRun }) {
12 const body = redact(String(report ?? "").slice(0, PRE_CUT)).slice(0, MAX_REPORT);
13 return [
14 `Avalie o resultado de um subagente (${String(agentType).slice(0, 64)}, tier atual: ${String(tier).slice(0, 16)}${midRun ? ", ainda em execução" : ""}).`,
15 "Responda SOMENTE um objeto JSON com exatamente estas chaves:",
16 '- "failure_is_capability": número 0..1 — a falha vem de dificuldade de raciocínio/desenho, e não de ambiente, ferramenta, acesso ou informação faltando?',
17 '- "claims_done_with_evidence": número 0..1 — o relato afirma conclusão citando evidência concreta (comando de teste e saída)?',
18 '- "is_stuck": número 0..1 — o agente diz estar bloqueado, inseguro ou sem convergir?',
19 `- "needed_tier": um de ${TIERS.join(" | ")} — que tier o trabalho restante exige?`,
20 "",
21 "Relato (redigido e truncado):",
22 "<<<",
23 body,
24 ">>>",
25 ].join("\n");
26}
27
28export function parseAnswers(text) {
29 if (typeof text !== "string") return null;
30 const m = text.match(/\{[\s\S]*\}/);
31 if (!m) return null;
32 let o;
33 try { o = JSON.parse(m[0]); } catch { return null; }
34 const out = {};
35 for (const k of KEYS) {
36 if (typeof o?.[k] !== "number" || !(o[k] >= 0 && o[k] <= 1)) return null;
37 out[k] = o[k];
38 }
39 if (!TIERS.includes(o.needed_tier)) return null;
40 out.needed_tier = o.needed_tier;
41 return out;
42}
43
44const rank = (t) => TIERS.indexOf(t);
45
46export function combine(answers, { current, ceiling, maxTier = null, signalRed = false, midRun = false, thresholds }) {
47 const th = thresholds ?? { capability: 0.6, claimsDone: 0.8 };
48 const soft = (reason) => ({ action: midRun ? "keep" : "human", tier: current, reason });
49 if (!answers) return { action: "keep", tier: current, reason: "respostas inválidas ou ausentes" };
50 if (!midRun && signalRed && answers.claims_done_with_evidence >= th.claimsDone)
51 return { action: "human", tier: current, reason: "relato diz concluído, sinal vermelho contradiz" };
52 if (answers.failure_is_capability < th.capability) return soft("falha não é de capacidade");
53 const top = capAtCeiling("top", ceiling, maxTier);
54 if (!top || rank(current) >= rank(top)) return soft("tier atual já é o teto");
55 const want = rank(answers.needed_tier) > rank(nextTier(current)) ? answers.needed_tier : nextTier(current);
56 return { action: "escalate", tier: capAtCeiling(want, ceiling, maxTier), reason: "falha de capacidade" };
57}
58scripts/lib/routing-ledger.mjs 67 lines1// scripts/lib/routing-ledger.mjs — formato do ledger de roteamento (spec §8). PURO.
2// Chaves por allowlist e VALORES por regex/enum: nunca prompt, resposta ou texto livre (ADR-005).
3import { TIERS, EFFORTS, PHASES } from "./model-routing.mjs";
4
5export const LEDGER_KEYS = Object.freeze([
6 "ts", "sessionId", "scope", "agentId", "agentType", "phase", "skill", "tier", "model", "effort",
7 "source", "ceiling", "adapter", "usage", "cacheReadRatio", "switched", "escalation",
8]);
9const SAFE = /^[A-Za-z0-9:_./@[\]-]{1,64}$/;
10const TS = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
11const ENUM = {
12 scope: ["session", "subagent"],
13 phase: PHASES,
14 tier: TIERS,
15 ceiling: TIERS,
16 effort: EFFORTS,
17 source: ["plan", "skill", "project", "phase", "agent", "explicit", "inherit"],
18 adapter: ["mod", "classic", "omp", "cli"],
19};
20const SAFE_KEYS = ["sessionId", "agentId", "agentType", "skill", "model"];
21const USAGE_KEYS = ["input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"];
22const ESC = { at: ["retry", "midRun"], from: TIERS, to: TIERS, action: ["keep", "human", "escalate"] };
23
24const num = (v) => typeof v === "number" && Number.isFinite(v);
25
26export function buildEntry(fields) {
27 const out = {};
28 const f = fields ?? {};
29 if (typeof f.ts === "string" && TS.test(f.ts)) out.ts = f.ts;
30 for (const k of SAFE_KEYS) if (typeof f[k] === "string" && SAFE.test(f[k])) out[k] = f[k];
31 for (const [k, allowed] of Object.entries(ENUM)) if (allowed.includes(f[k])) out[k] = f[k];
32 if (f.usage && typeof f.usage === "object") {
33 const u = {};
34 for (const k of USAGE_KEYS) if (num(f.usage[k])) u[k] = f.usage[k];
35 if (Object.keys(u).length) out.usage = u;
36 }
37 if (num(f.cacheReadRatio) && f.cacheReadRatio >= 0 && f.cacheReadRatio <= 1) out.cacheReadRatio = f.cacheReadRatio;
38 if (typeof f.switched === "boolean") out.switched = f.switched;
39 if (f.escalation && typeof f.escalation === "object") {
40 const e = {};
41 for (const [k, allowed] of Object.entries(ESC)) if (allowed.includes(f.escalation[k])) e[k] = f.escalation[k];
42 if (f.escalation.scores && typeof f.escalation.scores === "object") {
43 const s = {};
44 for (const k of ["failure_is_capability", "claims_done_with_evidence", "is_stuck"]) if (num(f.escalation.scores[k])) s[k] = f.escalation.scores[k];
45 if (Object.keys(s).length) e.scores = s;
46 }
47 if (Object.keys(e).length) out.escalation = e;
48 }
49 return out;
50}
51
52// FNV-1a 64 bits — puro (o mod não tem node:crypto).
53export function projectKey(path) {
54 let h = 0xcbf29ce484222325n;
55 const s = String(path);
56 for (let i = 0; i < s.length; i++) {
57 h ^= BigInt(s.charCodeAt(i));
58 h = (h * 0x100000001b3n) & 0xffffffffffffffffn;
59 }
60 return h.toString(16).padStart(16, "0");
61}
62
63export function ledgerDirFrom({ xdgDataHome, home, cwd }) {
64 const base = typeof xdgDataHome === "string" && xdgDataHome.startsWith("/") ? xdgDataHome : `${home}/.local/share`;
65 return `${base}/devflow-model-routing/${projectKey(cwd)}`;
66}
67scripts/lib/monitor-core.mjs 158 lines1// Estado da faixa do monitor de roteamento (puro: sem `$`, sem node:*). Spec: 2026-10-09-router-monitor-toolbar.
2export const MAX_ROWS = 50;
3export const MAX_KEYS = 500;
4export const PROMPT_SCAN = 2048;
5export const STALE_MS = 30_000;
6export const GRACE_MS = 3000; // linha nova ausente da lista (lida antes do spawn) não sai
7
8const DONE = new Set(["completed", "failed", "killed"]);
9const TASK_RE = /\bTask (\d+[a-z]?)\b/;
10const STORY_RE = /^\s*(?:[-*]\s+)?Current story:\s*(S\d+)\b/m;
11const IMPLEMENT_RE = /^\s*(?:Implement|Fix)\b/i;
12const REVIEW_RE = /^\s*(?:Re-?review|Review)\b/i;
13
14export function createMonitorState() {
15 return { rows: [], retries: {} };
16}
17
18export function extractTaskId({ description, prompt } = {}) {
19 const d = typeof description === "string" ? description.match(TASK_RE) : null;
20 if (d) return `Task ${d[1]}`;
21 const p = typeof prompt === "string" ? prompt.slice(0, PROMPT_SCAN).match(STORY_RE) : null;
22 return p ? p[1] : null;
23}
24
25// O SDD despacha implementer e reviewer como general-purpose: o papel separa as chaves.
26export function extractRole(description) {
27 if (typeof description !== "string") return null;
28 if (IMPLEMENT_RE.test(description)) return "implement";
29 if (REVIEW_RE.test(description)) return "review";
30 return null;
31}
32
33function bumpRetry(state, key) {
34 const seen = state.retries[key] ?? 0;
35 delete state.retries[key]; // reinsere no fim: a ordem de inserção é a idade
36 state.retries[key] = seen + 1;
37 const keys = Object.keys(state.retries);
38 for (let i = 0; i < keys.length - MAX_KEYS; i++) delete state.retries[keys[i]];
39 return seen;
40}
41
42export function onSpawned(state, { agentId, subagentType, description, prompt, model, now, scope }) {
43 if (typeof agentId !== "string" || !agentId) return null;
44 const type = typeof subagentType === "string" && subagentType ? subagentType : "agente";
45 const taskId = extractTaskId({ description, prompt });
46 const role = taskId ? extractRole(description) : null;
47 const retries = taskId ? bumpRetry(state, `${scope ?? "-"}::${type}::${role ?? "-"}::${taskId}`) : null;
48 const label = taskId ? `${type} · ${taskId}${role ? ` · ${role}` : ""}` : type;
49 const row = {
50 id: agentId, label, startedAt: now, lastEventAt: now,
51 model: typeof model === "string" && model ? model : null, effort: null, streak: 0, retries,
52 };
53 state.rows = state.rows.filter((r) => r.id !== agentId);
54 state.rows.push(row);
55 while (state.rows.length > MAX_ROWS) {
56 const i = state.rows.findIndex((r) => r.id !== "main");
57 state.rows.splice(i < 0 ? 0 : i, 1);
58 }
59 return row;
60}
61
62export function openMain(state, { now }) {
63 state.rows = state.rows.filter((r) => r.id !== "main");
64 state.rows.unshift({ id: "main", label: "sessão", startedAt: now, lastEventAt: now, model: null, effort: null, streak: 0, retries: null });
65}
66
67export function closeMain(state) {
68 const n = state.rows.length;
69 state.rows = state.rows.filter((r) => r.id !== "main");
70 return state.rows.length !== n;
71}
72
73export function onTool(state, { loopId, isError, now }) {
74 const row = state.rows.find((r) => r.id === loopId);
75 if (!row) return false;
76 const antes = row.streak;
77 row.streak = isError ? row.streak + 1 : 0;
78 row.lastEventAt = now;
79 return row.streak !== antes; // só a sequência de erros pede gravação; lastEventAt fica em memória
80}
81
82export function onStep(state, { loopId, model, effort, now }) {
83 const row = state.rows.find((r) => r.id === loopId);
84 if (!row) return false;
85 let changed = false;
86 if (typeof model === "string" && model && model !== row.model) { row.model = model; changed = true; }
87 if (typeof effort === "string" && effort && effort !== row.effort) { row.effort = effort; changed = true; }
88 row.lastEventAt = now;
89 return changed;
90}
91
92export function reap(state, { list, now }) {
93 const before = state.rows.length;
94 const status = Array.isArray(list) ? new Map(list.map((a) => [a?.id, a?.status])) : null;
95 state.rows = state.rows.filter((r) => {
96 if (r.id === "main") return true;
97 if (status) return status.has(r.id) ? !DONE.has(status.get(r.id)) : now - r.startedAt <= GRACE_MS;
98 return now - r.lastEventAt <= STALE_MS;
99 });
100 return state.rows.length !== before;
101}
102
103export const isLive = (state) => state.rows.length > 0;
104
105export const VISIBLE = 6;
106export const LABEL_COLS = 40;
107export const DEFAULT_FAILURE_STREAK = 3;
108const ORIGIN_COLOR = { roteado: "success", teto: "subtle", "router off": "inactive" };
109
110export function fmtDuration(ms) {
111 const s = Math.max(0, Math.floor(ms / 1000));
112 const h = Math.floor(s / 3600);
113 const p = (n) => String(n).padStart(2, "0");
114 const mmss = `${p(Math.floor((s % 3600) / 60))}:${p(s % 60)}`;
115 return h ? `${h}:${mmss}` : mmss;
116}
117
118export function shortModel(model) {
119 return typeof model === "string" && model ? model.replace(/^claude-/, "") : "?";
120}
121
122export function originOf(routing, loopId) {
123 if (routing?.active !== true) return "router off";
124 return routing.loops?.[loopId]?.origin === "roteado" ? "roteado" : "teto";
125}
126
127// Linhas que cabem na faixa: o prop maxRows da AbovePrompt menos a linha "+N agentes".
128export function visibleFor(maxRows) {
129 return Number.isInteger(maxRows) && maxRows > 0 ? Math.max(1, Math.min(VISIBLE, maxRows - 1)) : VISIBLE;
130}
131
132const cut = (s, n) => (s.length <= n ? s : `${s.slice(0, n - 1)}…`);
133
134export function view(state, { routing, now, visible = VISIBLE }) {
135 const fs = routing?.failureStreak;
136 const limit = Number.isInteger(fs) && fs > 0 ? fs : DEFAULT_FAILURE_STREAK;
137 const subs = state.rows.filter((r) => r.id !== "main").sort((a, b) => a.startedAt - b.startedAt);
138 const ordered = [...state.rows.filter((r) => r.id === "main"), ...subs];
139 const rows = ordered.slice(0, visible).map((r) => {
140 const pub = routing?.active === true ? routing.loops?.[r.id] : undefined; // desligado: o publicado é velho
141 const origin = originOf(routing, r.id);
142 return {
143 id: r.id,
144 label: cut(r.label, LABEL_COLS),
145 model: `${shortModel(pub?.model ?? r.model)}·${pub?.effort ?? r.effort ?? "-"}`,
146 origin, originColor: ORIGIN_COLOR[origin],
147 time: fmtDuration(now - r.startedAt),
148 streak: r.streak, streakColor: r.streak <= 0 ? undefined : r.streak >= limit ? "error" : "warning",
149 retries: r.retries === null ? "—" : String(r.retries), retriesColor: r.retries > 0 ? "warning" : undefined,
150 };
151 });
152 return { rows, more: Math.max(0, ordered.length - visible) };
153}
154
155export function lineText(v) {
156 return `${v.label.padEnd(LABEL_COLS)} Modelo: ${v.model} (${v.origin}) | Tempo: ${v.time} | Falhas: ${v.streak} | Retentativas: ${v.retries}`;
157}
158scripts/lib/yaml-block.mjs 30 lines1// scripts/lib/yaml-block.mjs — extração de bloco de topo do .devflow.yaml. PURO.
2// Única implementação (ADR-011): devflow-config.mjs e models-config.mjs importam daqui.
3
4export function normalizeNewlines(text) {
5 return String(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
6}
7
8// Linhas DENTRO do bloco `<name>:` (sem valor) até a 1ª linha não-indentada não-vazia.
9export function namedBlock(text, name) {
10 const esc = String(name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11 const head = new RegExp("^" + esc + ":\\s*$");
12 const block = [];
13 let inBlock = false;
14 for (const line of normalizeNewlines(text).split("\n")) {
15 if (!inBlock) {
16 if (head.test(line)) inBlock = true;
17 continue;
18 }
19 if (line.trim() !== "" && !/^\s/.test(line)) break;
20 block.push(line);
21 }
22 return block;
23}
24
25export function dedentBlock(lines) {
26 const widths = lines.filter((l) => l.trim() !== "").map((l) => l.match(/^(\s*)/)[1].length);
27 const ind = widths.length ? Math.min(...widths) : 0;
28 return lines.map((l) => l.slice(Math.min(ind, l.length - l.trimStart().length))).join("\n");
29}
30scripts/lib/frontmatter.mjs 205 lines1// scripts/lib/frontmatter.mjs — gray-matter substitute (YAML subset only)
2//
3// Supported YAML constructs:
4// - top-level scalars: string, number, boolean, null
5// - quoted strings: "double" and 'single'
6// - inline arrays: []
7// - block lists: - item
8// - one-level nested maps: key:\n subkey: value
9// - comments: # ... (full-line, NOT mid-line on quoted strings)
10//
11// Rejected (throw):
12// - anchors (&name)
13// - references (*name when used as a value, not as a glob char)
14// - multi-line block scalars (|, >)
15//
16// This is sufficient for ADR/standard/manifest frontmatter as defined by
17// devflow's templates. Fancier YAML belongs in a real parser.
18
19const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
20
21function unquote(s) {
22 if (s.length >= 2) {
23 if ((s[0] === '"' && s[s.length - 1] === '"') ||
24 (s[0] === "'" && s[s.length - 1] === "'")) {
25 return s.slice(1, -1);
26 }
27 }
28 return s;
29}
30
31function splitTopLevelCommas(inner) {
32 // Split on commas that are NOT inside quotes or brace-globs ({a,b}).
33 // Needed because applyTo values like "**/*.{ts,tsx,js}" carry commas that
34 // are part of the value, not item separators.
35 const parts = [];
36 let buf = "";
37 let quote = null; // "'" or '"' when inside a quoted string
38 let brace = 0; // brace-glob nesting depth
39 for (const ch of inner) {
40 if (quote) {
41 buf += ch;
42 if (ch === quote) quote = null;
43 continue;
44 }
45 if (ch === '"' || ch === "'") { quote = ch; buf += ch; continue; }
46 if (ch === "{") { brace++; buf += ch; continue; }
47 if (ch === "}") { if (brace > 0) brace--; buf += ch; continue; }
48 if (ch === "," && brace === 0) { parts.push(buf); buf = ""; continue; }
49 buf += ch;
50 }
51 parts.push(buf);
52 return parts;
53}
54
55function parseInlineArray(v) {
56 // v is "[item1, item2, ...]" — strip brackets, split on TOP-LEVEL commas
57 // (ignoring commas inside quotes or brace-globs), unquote each.
58 const inner = v.slice(1, -1).trim();
59 if (inner === "") return [];
60 return splitTopLevelCommas(inner).map(s => unquote(s.trim()));
61}
62
63function parseScalar(raw) {
64 const v = raw.trim();
65 if (v === "") return "";
66 if (v === "null" || v === "~") return null;
67 if (v === "true") return true;
68 if (v === "false") return false;
69 if (v === "[]") return [];
70 if (v === "{}") return {};
71 if (v.startsWith("[") && v.endsWith("]")) return parseInlineArray(v);
72 // Detect YAML reference (*name) used as value — reject
73 if (/^\*[A-Za-z_]/.test(v)) {
74 throw new Error(`YAML reference (*) not supported in devflow subset: ${raw}`);
75 }
76 // Detect YAML anchor (&name ...) — reject
77 if (/^&[A-Za-z_]/.test(v)) {
78 throw new Error(`YAML anchor (&) not supported in devflow subset: ${raw}`);
79 }
80 // Numeric scalars (avoid converting version strings like "1.0.0")
81 if (/^-?\d+$/.test(v)) return parseInt(v, 10);
82 if (/^-?\d+\.\d+$/.test(v)) return parseFloat(v);
83 return unquote(v);
84}
85
86function indentOf(line) {
87 const m = line.match(/^( *)/);
88 return m ? m[1].length : 0;
89}
90
91// Recursive parser: parseBlock(lines, startIdx, baseIndent) returns
92// [parsed, nextIdx] where parsed is the map at indent=baseIndent+, and
93// nextIdx is the line after the block ends. Supports arbitrary nesting
94// of maps and lists.
95function parseBlock(lines, startIdx, baseIndent) {
96 const data = {};
97 let i = startIdx;
98
99 while (i < lines.length) {
100 const line = lines[i];
101 if (line.trim() === "" || /^\s*#/.test(line)) {
102 i++;
103 continue;
104 }
105
106 const indent = indentOf(line);
107 if (indent < baseIndent) break; // dedent — end of this block
108 if (indent > baseIndent) { i++; continue; } // skip mis-indented (shouldn't happen)
109
110 const m = line.match(/^\s*([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
111 if (!m) { i++; continue; }
112 const key = m[1];
113 const rawValue = m[2];
114
115 if (rawValue !== "") {
116 data[key] = parseScalar(rawValue);
117 i++;
118 continue;
119 }
120
121 // Empty value — peek next non-blank line
122 let j = i + 1;
123 while (j < lines.length && (lines[j].trim() === "" || /^\s*#/.test(lines[j]))) j++;
124 if (j >= lines.length) {
125 data[key] = null;
126 i = j;
127 continue;
128 }
129 const childIndent = indentOf(lines[j]);
130 if (childIndent <= baseIndent) {
131 data[key] = null;
132 i++;
133 continue;
134 }
135
136 // Detect block list vs nested map
137 const isList = /^\s*-\s/.test(lines[j]);
138 if (isList) {
139 const arr = [];
140 while (j < lines.length) {
141 const ln = lines[j];
142 if (ln.trim() === "" || /^\s*#/.test(ln)) { j++; continue; }
143 if (indentOf(ln) < childIndent) break;
144 const lm = ln.match(/^\s*-\s+(.*)$/);
145 if (!lm) break;
146 const itemContent = lm[1];
147 // Detect list-of-maps: "- key: value" starts an inline-keyed map
148 // whose continuation is at a deeper indent than the dash.
149 const dashIndent = indentOf(ln);
150 const inlineKey = itemContent.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
151 if (inlineKey && j + 1 < lines.length) {
152 // Peek next non-blank line; if indented deeper than the dash content,
153 // this is a multi-key map item.
154 let k = j + 1;
155 while (k < lines.length && (lines[k].trim() === "" || /^\s*#/.test(lines[k]))) k++;
156 // Content position = dashIndent + 2 (for "- ")
157 const contentIndent = dashIndent + 2;
158 if (k < lines.length && indentOf(lines[k]) >= contentIndent && /^\s*[A-Za-z_]/.test(lines[k])) {
159 // Multi-key map item: parse rest of map at contentIndent
160 const item = {};
161 // First key from the dash line
162 item[inlineKey[1]] = parseScalar(inlineKey[2]);
163 // Continuation
164 const [rest, nextK] = parseBlock(lines, k, contentIndent);
165 Object.assign(item, rest);
166 arr.push(item);
167 j = nextK;
168 continue;
169 }
170 }
171 // Plain scalar list item
172 arr.push(parseScalar(itemContent));
173 j++;
174 }
175 data[key] = arr;
176 i = j;
177 } else {
178 // Nested map — recurse
179 const [sub, nextI] = parseBlock(lines, j, childIndent);
180 data[key] = sub;
181 i = nextI;
182 }
183 }
184 return [data, i];
185}
186
187function parseYamlSubset(yaml) {
188 const lines = yaml.split(/\r?\n/);
189 const [data] = parseBlock(lines, 0, 0);
190 return data;
191}
192
193export function parseYaml(text) { return parseYamlSubset(text); }
194
195export function parseFrontmatter(source) {
196 const m = source.match(FM_RE);
197 if (!m) {
198 return { data: {}, body: source };
199 }
200 const yaml = m[1];
201 const body = m[2];
202 const data = parseYamlSubset(yaml);
203 return { data, body };
204}
205scripts/lib/instinct-redact.mjs 54 lines1// scripts/lib/instinct-redact.mjs
2// Redação best-effort para observações do instinct store (ADR-005 v1.1.0).
3// Ordem: URL-cred → key=value → tokens → email → IPv4 → sequências longas.
4// SHAs/paths preservados. NÃO é PCI/PHI-grade (PII com separadores passa).
5// Limite best-effort (ADR-005): um secret de alta entropia 100% alfanumérico SEM
6// contexto de chave (ex.: AWS secret 40c base64 como token nu, sem `key=`/`secret=`)
7// NÃO é redigido — um blanket [A-Za-z0-9/+]{40} mutilaria file paths (conteúdo primário
8// das observações). Vetores ALCANÇÁVEIS pela captura binary-only (env-assignment
9// `ENV=valor`) são cobertos por KV_RE/TOKEN_RE; ambientes regulados usam scrubber externo.
10
11// Credencial embutida em URL: scheme://user:senha@host (reusa heurística do projectId)
12const URLCRED_RE = /:\/\/[^/@\s:]+:[^/@\s]+@/g;
13// Par chave=valor sensível. F1: a chave pode ser um identificador UPPER_SNAKE
14// (CLIENT_SECRET, AWS_SECRET_ACCESS_KEY, GITHUB_TOKEN) — `_` é word-char, então
15// o antigo \b falhava. Usamos lookbehind de fronteira não-alfanumérica e
16// permitimos prefixo/sufixo de word-chars ao redor da palavra sensível.
17// Over-redação é segura aqui (store de observação); vazar credencial é hard-fail (ADR-005).
18// Lookahead nega placeholders já redigidos ([TOKEN] etc.).
19// F2-residual: `access[-_]?key`/`aws[-_]?access` cobrem nomes de var AWS sem secret/token
20// (ACCESS_KEY=, AWS_ACCESS=) cujo valor (secret 40c) não casa o TOKEN_RE. Específicos
21// o bastante p/ não pegar `monkey`/`keyboard`.
22const KV_RE = /(?<![A-Za-z0-9])([A-Za-z0-9_]*(?:password|passwd|pwd|secret|token|api[-_]?key|apikey|access[-_]?key|aws[-_]?access|auth|cred|credential|pgpassword|mysql_pwd)[A-Za-z0-9_]*)(\s*[=:]\s*)(?!\[(?:TOKEN|REDACTED|EMAIL|IP|NUM)\])(\S+)/gi;
23// Tokens conhecidos (uma alternativa por classe de credencial real).
24const TOKEN_RE = new RegExp([
25 'AKIA[0-9A-Z]{16}', // AWS access key id
26 'gh[opsur]_[A-Za-z0-9]{16,}', // GitHub classic/oauth/server/user/refresh
27 'github_pat_[A-Za-z0-9_]{20,}', // GitHub fine-grained
28 '(?:sk|pk|rk)[-_](?:live|test|proj|ant)[-_A-Za-z0-9]+', // Stripe/OpenAI/Anthropic (com label)
29 'sk-[A-Za-z0-9]{16,}', // OpenAI legacy (sk- puro)
30 'glpat-[A-Za-z0-9_-]{16,}', // GitLab PAT
31 'npm_[A-Za-z0-9]{20,}', // npm token
32 'AIza[0-9A-Za-z_-]{30,}', // Google API key
33 'xox[baprs]-[A-Za-z0-9-]{8,}', // Slack bot/user
34 'xapp-[A-Za-z0-9-]{8,}', // Slack app-level
35 'eyJ[A-Za-z0-9_-]+\\.eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+', // JWT
36 '-----BEGIN[^-]+PRIVATE KEY-----', // PEM
37 'bearer\\s+[A-Za-z0-9._-]{12,}', // bearer ...
38].join('|'), 'gi');
39const EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g;
40const IPV4_RE = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g;
41// Sequência ≥9 dígitos PUROS (não dentro de hash/path). SHA git é hex c/ letras → não casa \d+.
42const LONGNUM_RE = /(?<![\w./])\d{9,}(?![\w./])/g;
43
44export function redact(text) {
45 if (typeof text !== 'string' || !text) return text ?? '';
46 return text
47 .replace(URLCRED_RE, '://[REDACTED]@')
48 .replace(TOKEN_RE, '[TOKEN]') // antes do KV: token reconhecido → [TOKEN]
49 .replace(KV_RE, (_m, k) => `${k}=[REDACTED]`)
50 .replace(EMAIL_RE, '[EMAIL]')
51 .replace(IPV4_RE, '[IP]')
52 .replace(LONGNUM_RE, '[NUM]');
53}
54types/index.d.ts 20 lines1// Contrato do $.state do plugin devflow (o claude plugin validate confere as chaves usadas no módulo).
2// Só exports de tipo: o validate recusa qualquer outro export.
3export type RoutingOrigin = "roteado" | "teto";
4export type RoutingLoop = { model: string | null; effort: string | null; origin: RoutingOrigin };
5export type RoutingSnapshot = { active: boolean; failureStreak: number; loops: Record<string, RoutingLoop> };
6export type MonitorRow = {
7 id: string; label: string; startedAt: number; lastEventAt: number;
8 model: string | null; effort: string | null; streak: number; retries: number | null;
9};
10
11declare module "claude-code" {
12 interface PluginState {
13 devflow: {
14 routing: RoutingSnapshot;
15 monitorRows: MonitorRow[];
16 monitorRetries: Record<string, number>;
17 };
18 }
19}
20