diff --git a/.cursor/skills/agent-pool/SKILL.md b/.cursor/skills/agent-pool/SKILL.md index e96ced86..8018f84e 100644 --- a/.cursor/skills/agent-pool/SKILL.md +++ b/.cursor/skills/agent-pool/SKILL.md @@ -93,6 +93,8 @@ Os wrappers disparam `gh workflow run agent-pool.yml -f action=…`; acompanhe c `ready` → (tick: claim coordenado, flip + marcador) → `in-progress` → worker spawnado com o `model:` da Issue (fallback `composer-2.5` + **`fast=false`** — omitir `fast` na API vira `composer-2.5-fast` no usage) → PR `--base main` com `Closes #N` → CI green → auto-merge → `done`+`in-prod` (flip determinístico do CI) → tick arquiva o agente e repõe o slot. +Issues nascidas de `plan-issue` com `--plan` **não** entram como `ready`: ficam `blocked` até o plano de intenção estar em `main` e `pnpm agent:ready` (OPS17). O tick não promove — só consome `ready`. + - **Falha terminal** (run ERROR/CANCELLED/EXPIRED ou fim sem PR): tick comenta, move a Issue para `blocked` e arquiva o agente. **Triage humana**: ler o run em cursor.com/agents, decidir — re-`ready` manual se transitório (o circuit breaker recusa a 3ª tentativa automática), corrigir a spec se sistêmico. - **Worker travado**: archive em cursor.com/agents → o próximo tick reconcilia como falha documentada. - **Duplicata**: impossível em condições normais (alocador único + lock otimista + `agentId` idempotente); o tick cancela runs extras se alguém spawnar manualmente. diff --git a/.cursor/skills/capture-review-debts/SKILL.md b/.cursor/skills/capture-review-debts/SKILL.md index e17fb194..639b207c 100644 --- a/.cursor/skills/capture-review-debts/SKILL.md +++ b/.cursor/skills/capture-review-debts/SKILL.md @@ -130,7 +130,7 @@ Inclua baldes **já_resolvido**, **descartar** e **defer** com uma linha de raci Para cada lote com destino **registrar** ou **absorver**: -1. Lotes de engenharia/UX: `pnpm agent:register -- --id --title "" --prio

--kind chore --plan docs/plans/.md` — **um lote mesclado, não um item por achado micro**. Inclua appetite e rabbit holes do lote no plano curto. +1. Lotes de engenharia/UX: `pnpm agent:register -- --id --title "" --prio

--kind chore --plan docs/plans/.md` — **um lote mesclado, não um item por achado micro**. Inclua appetite e rabbit holes do lote no plano curto. Com `--plan`, a Issue nasce `blocked` (OPS17) até o plano estar em `main` e `pnpm agent:ready -- --issue ` (no fechamento da sessão após o merge, ou via OPS18). 2. Défice comportamental do fluxo de agentes (algo que o agente errou e a convenção não pegou): `pnpm agent:file-miss` (`kind:agent-miss`) — alimenta o harvest do `engineering-audit`. 3. Se **absorver** num plano existente: edite o plano (nova fase/seção) — mesmo precedente "Gap vs 2022". **Se a Issue dona do plano estiver `in-progress`, NÃO a edite** — registra como Issue nova com `--depends ` (destrava sozinha quando o pai flipar `done`). 4. Se **defer**: anote o gatilho no plano-pai (Adiado com gatilho / Explicitamente fora) ou na mensagem de triage confirmada — não crie Issue só por isso. diff --git a/.cursor/skills/plan-issue/SKILL.md b/.cursor/skills/plan-issue/SKILL.md index 1d997d1d..43d3c845 100644 --- a/.cursor/skills/plan-issue/SKILL.md +++ b/.cursor/skills/plan-issue/SKILL.md @@ -20,10 +20,10 @@ Esta skill transforma ideias soltas em: (1) um **plano de intenção** em `docs/ ```text rascunho local (Issue: —) → GATE (Passo 5) + confirmação explícita do lote - → register com --plan → Issue NÃO claimável (blocked) + → register com --plan → Issue NÃO claimável (blocked; script enforça) → commit + PR dos planos (Related #N, nunca Closes) → merge em main - → promote blocked→ready (agente no Passo 6 + Action no merge — dual, idempotente) + → promote blocked→ready (`pnpm agent:ready` + Action OPS18 — dual, idempotente) → fila / pool ``` @@ -31,8 +31,8 @@ rascunho local (Issue: —) 1. **Nada no GitHub antes do gate.** Antes da confirmação explícita do Passo 5: proibido `pnpm agent:register`, `gh issue create`, `gh pr create` / push de PR de planos. Planos locais (`Issue: —`) ok. 2. **Confirmação = OK ao overview do lote** (ex. “confirma”, “pode registrar”). “Ok” ambíguo no meio da edição **não** dispara o Passo 6. -3. **Register com `--plan` não nasce `ready`.** Use `--blocked` (ou o default futuro de `agent:register --plan`) até o plano estar em `main`. Sem `--plan` (chore body-only / `file-miss`), pode nascer `ready`. -4. **Promote só depois do plano em `main`.** Nunca flipar para `ready` com o PR ainda aberto — isso recria a race de claim. Caminhos: (A) agente no fim do Passo 6 após merge; (B) Action determinística no merge que lê `Related #N` (OPS18). Ambos idempotentes. +3. **Register com `--plan` não nasce `ready`.** O script aplica `blocked` automaticamente quando `--plan` está presente (`--blocked` explícito ainda vale para chores sem plano). Sem `--plan` (chore body-only / `file-miss`), pode nascer `ready`. +4. **Promote só depois do plano em `main`.** Nunca flipar para `ready` com o PR ainda aberto — isso recria a race de claim. Caminhos: (A) `pnpm agent:ready -- --issue N` no fim do Passo 6 após merge; (B) Action determinística no merge que lê `Related #N` (OPS18). Ambos idempotentes. 5. **Planos de Issues `in-progress` / `done` / `in-prod` são imutáveis.** Não editar `docs/plans/.md` nem o body de intenção dessas Issues. Refino → **plano + Issue novos** (sucessor; `depends` no pai se fizer sentido). Enquanto a Issue ainda é só `blocked`/`ready` (sem claim), editar o mesmo plano ainda é barato. **Canvas UI (obrigatório se muda UI):** itens com superfície Impeccable **B / C / D** (ou qualquer mudança do que o usuário vê/toca) devem ter um **Cursor canvas** de rascunho UI/UX no gate — ver [ui-draft-canvas.md](ui-draft-canvas.md). Classe A / sem UI → sem canvas. @@ -58,8 +58,13 @@ Aqui **não** se implementa código de produto, **não** se escreve plano de imp - [ ] 2. Reserva de IDs de uma vez por trilha (roadmap legacy + issuesById()) - [ ] 3. Por item (ordem topológica): classificar → fatiar → explorar só o suficiente → intenção completa - [ ] 4. Sugestão de modelo × effort via model-selection (uma linha por item) +<<<<<<< HEAD - [ ] 5. GATE: overview do lote + canvas UI/UX (se muda UI) + esboços de fluxo → confirmar/iterar (PARAR aqui até o humano confirmar) - [ ] 6. Registro: register --blocked (se --plan) → PR Related → merge → promote ready +======= +- [ ] 5. GATE: overview do lote + esboços de fluxo (B/C/D) → confirmar/iterar (PARAR aqui até o humano confirmar; sem Issue/PR) +- [ ] 6. Registro: `agent:register` (`--plan` → `blocked`) → PR `Related #N` → merge → `pnpm agent:ready` +>>>>>>> 8fb041f4 (feat(ops): OPS17 — plan-issue blocked até plano em main) ``` ## Passo 1 — Parse e dedup @@ -132,26 +137,38 @@ Antes de criar Issues **ou** abrir PR de planos: - Esboço textual de fluxo só se ajudar; não substitui o canvas quando há UI - Perguntas acumuladas numa rodada, recomendação de produto primeiro +<<<<<<< HEAD **Pare e espere.** Itere (incluindo o canvas, se houver UI) até confirmação explícita do lote. Só então Passo 6. +======= +**Pare e espere.** Itere até confirmação explícita do lote (não basta um “ok” solto durante a edição). Só então Passo 6. +>>>>>>> 8fb041f4 (feat(ops): OPS17 — plan-issue blocked até plano em main) -## Passo 6 — Registro (ordem fixa) +## Passo 6 — Registro (não claimável até plano em `main`) -1. **Register** (com `--model`; com `--plan` → **`--blocked`** até o plano em `main`): +Ordem obrigatória (OPS17): + +1. **Register** (com `--model`; com `--plan` o script nasce `blocked` — não precisa `--blocked` extra): ```bash pnpm agent:register -- --id --title "" --prio \ --depends --kind --plan docs/plans/.md \ - --model --blocked + --model ``` -Sem `--plan`: omita `--blocked` se a Issue deve nascer claimável. +Sem `--plan`: nasce `ready` (use `--blocked` só se quiser não-claimável sem plano). 2. Atualize `Issue: #N` (e status) no plano local. 3. Commit + **`pnpm push`** + PR **Ready** `--base main` com **`Related #N`** (nunca `Closes #N` em PR só de `docs/plans/` — `plans-only-closes`). 4. Auto-merge (`gh pr merge --auto --merge`); espere o merge em `main`. -5. **Promote** `blocked`→`ready` na Issue (e comente o motivo). A Action de merge (OPS18) é safety net se este passo falhar — não pule o promote do agente no caminho feliz. +5. **Promote** com o script (idempotente; só Issues `blocked` + link `docs/plans/`): -**NÃO faz:** editar `docs/roadmap.md`; implementar código; claim; escrever `*-impl.md`; editar plano de Issue `in-progress`/`done`/`in-prod`; marcar `ready` antes do plano em `main`. +```bash +pnpm agent:ready -- --issue +``` + +A Action de merge (OPS18) é safety net se este passo falhar — não pule o promote do agente no caminho feliz. + +**NÃO faz:** editar `docs/roadmap.md`; implementar código; claim; escrever `*-impl.md`; editar plano de Issue `in-progress`/`done`/`in-prod`; marcar `ready` antes do plano em `main`; registrar/abrir PR antes do gate. ## Resumo final diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index 863c5c72..b157e5c6 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -37,7 +37,8 @@ main → ci.yml full suite → vercel deploy --prod (se verde) → requeue se HE | Comando | Faz | | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | `pnpm agent:claim [-- --dry-run]` | Fila ready+unblocked por prio → `in-progress` + brief | -| `pnpm agent:register -- --id X --title T [--prio P1] [--depends A,B] [--plan docs/plans/x.md] [--model slug]` | Cria Issue | +| `pnpm agent:register -- --id X --title T [--prio P1] [--depends A,B] [--plan docs/plans/x.md] [--model slug]` | Cria Issue (`--plan` ⇒ `blocked` até `agent:ready`; sem plano ⇒ `ready`) | +| `pnpm agent:ready -- --issue N[,N…]` | Pós-merge do plano: `blocked`→`ready` (só Issues com link `docs/plans/`) | | `pnpm agent:status` | Overview / fila / mermaid | | `pnpm agent:prioritize -- ` | Troca `prio:*` | | `pnpm agent:file-miss -- --title ...` | Issue `kind:agent-miss` | @@ -48,6 +49,8 @@ main → ci.yml full suite → vercel deploy --prod (se verde) → requeue se HE Labels: `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `needs:*`. +**plan-issue (OPS17):** gate pós-overview fecha o lote antes de qualquer Issue/PR; register com `--plan` nasce `blocked` (não entra na fila do claim/pool); após o PR de planos mergear em `main` (`Related #N`), `pnpm agent:ready` promove a `ready`. Chores sem plano continuam nascendo `ready`. + ## Contrato de PR 1. Schema → atualizar `db:seed:minimal` no mesmo PR (`needs:migration`). diff --git a/docs/plans/plan-issue-lifecycle-ready-impl.md b/docs/plans/plan-issue-lifecycle-ready-impl.md new file mode 100644 index 00000000..5a71d974 --- /dev/null +++ b/docs/plans/plan-issue-lifecycle-ready-impl.md @@ -0,0 +1,85 @@ +# Impl: Ciclo de vida do plan-issue — colaboração fechada antes de claim + +Status: aprovado +Atualizado em: 2026-08-02 +Issue: #292 +Intenção: docs/plans/plan-issue-lifecycle-ready.md +Appetite restante: ~0,5d (scripts + skills + pins; sem UI; Action dual = OPS18) + +Nota de execução: retomado após kill-all do worker anterior (`9ff0b84d`); cherry-pick em cima de `main` com resolução de conflito em `plan-issue/SKILL.md` (main já tinha o ciclo em prosa; esta entrega amarra o enforce em script + `agent:ready`). + +## Leitura da intenção + +- **Outcome:** colaborar no `/plan-issue` não cria Issue/PR até confirmação explícita pós-overview; Issue com plano só fica claimável (`ready`) depois do plano de intenção em `main`. +- **O que NÃO negociar:** chores sem `--plan` nascem `ready`; não inventar segundo tracker; não exigir review humano no PR de planos; não verificar blob do plano a cada tick do pool. +- **O que reavaliar:** hipótese “só skill soft” — insuficiente; contrato tem de viver em `agent:register` (default com `--plan`) + promote explícito. Action no merge = sucessor OPS18 (#296), fora deste item. + +## Abordagem recomendada + +```mermaid +flowchart LR + Gate["Passo 5 overview + OK lote"] --> Reg["agent:register --plan"] + Reg -->|"estado blocked"| Issue["Issue #N"] + Issue --> PlansPR["PR Related #N → main"] + PlansPR --> Ready["agent:ready → ready"] + Ready --> Pool["fila claim/pool"] +``` + +**Opções consideradas:** A) só checklist na skill (soft) · B) `--plan` ⇒ `blocked` no register + `agent:ready` pós-merge (sessão) · C) novo label `needs:plan` + gate no pool +**Recomendação:** **B** — reusa `blocked` (já exclui claim/pool); enforce no script (não depende do agente lembrar `--blocked`); promote determinístico na sessão após merge. +**Rejeitadas:** A (já falhou na prática — race com pool); C (prolifera estados; intenção cortou rabbit hole). + +### Decisões de engenharia + +1. **Estado enquanto espera plano em `main`:** `blocked` (não `needs:plan`). + - Opções: A `blocked` | B `needs:plan` | C sem Issue até merge + - Recomendação: A — vocabulário existente; claim/pool já excluem. + - Rejeitadas: B (label nova sem necessidade); C (quebra `Related #N`). + +2. **Enforce no register vs só skill:** default `blocked` quando `--plan` presente. + - Opções: A default no script | B skill manda `--blocked` | C pool checa arquivo em `main` + - Recomendação: A — fail-closed no ponto de criação. + - Rejeitadas: B (esquecível); C (IO no tick — rabbit hole da intenção). + +3. **Promote v1:** `pnpm agent:ready -- --issue N[,N…]` na mesma sessão (Passo 6 pós-merge). + - Opções: A script de sessão | B Action no merge | C humano sempre + - Recomendação: A neste item; B = OPS18 (#296). + - Rejeitadas: C (opaco); B fora de escopo (comentário na Issue #292). + +4. **Nome do comando:** `agent:ready` (não reviver `agent:promote` do cutover stage→main). + +### Componentes / mudanças + +- **`scripts/lib/agent-plan-lifecycle.mjs`:** puro — `resolveRegisterStateLabel({ hasPlan, explicitBlocked })`, `canPromotePlanIssue(issue)` (blocked + link `docs/plans/` + open + sem in-progress/done/in-prod). +- **`scripts/agent-register.mjs`:** usa o helper; com `--plan` nasce `blocked` (mesmo sem `--blocked`); sem `--plan` preserva `ready` / `--blocked`. +- **`scripts/agent-ready.mjs` + `pnpm agent:ready`:** flip `blocked`→`ready` idempotente; comenta motivo; recusa se não for “aguardando plano”. +- **`.cursor/skills/plan-issue/SKILL.md`:** Passo 5 = gate duro (sem Issue/PR até OK explícito ao lote); Passo 6 = register → PR `Related #N` → merge → `agent:ready`. +- **`docs/AGENT-OPS.md` + skill `agent-pool` (menção curta) + nota em `capture-review-debts`:** contrato de labels / promote após plano em `main`. +- **Tests:** `tests/unit/agentPlanLifecycle.unit.spec.ts` (register label + canPromote). +- **Migration / Access / Consent / UI:** N/A (Impeccable A). + +## Fases verificáveis + +1. **Tracer** — helper puro + register + pins unit; `agent:ready` + skill/docs. +2. **Gates** — `pnpm gate:fast`; entrega com `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- **Action dual no merge (OPS18 #296).** +- **Checagem de blob do plano no tick do pool.** +- **Label `needs:plan`.** +- **Reescrever `capture-review-debts` para auto-promote** (só nota + defer para OPS18). +- **Rename cosmético `canPromotePlanIssue` → `canMarkPlanIssueReady`** — defer; API estável para OPS18 Action. + +## Riscos e mitigação + +- **Issue com `--plan` fica `blocked` para sempre se Passo 6 pular promote** — mitiga OPS18; v1 documenta `agent:ready` obrigatório no Passo 6. +- **Confundir `blocked` por dep de produto com “aguardando plano”** — `canPromotePlanIssue` exige link `docs/plans/` no body; promote sem link falha fechado. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto +- [x] Invariantes AGENTS/engineering-standards (N/A runtime app) +- [x] Testes de domínio previstos (unit no lifecycle) + +Self-score decision-quality: 5/5 diff --git a/docs/plans/plan-issue-lifecycle-ready.md b/docs/plans/plan-issue-lifecycle-ready.md index 9ec3b3dc..80749ea5 100644 --- a/docs/plans/plan-issue-lifecycle-ready.md +++ b/docs/plans/plan-issue-lifecycle-ready.md @@ -1,6 +1,6 @@ # Ciclo de vida do plan-issue — colaboração fechada antes de claim -Status: registrado (blocked até plano em main) +Status: em execução (impl em andamento) Atualizado em: 2026-08-02 Issue: #292 Priority: P1 diff --git a/package.json b/package.json index b7e13fe6..ebbb019d 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "db:seed:minimal": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-minimal.mjs", "agent:claim": "node scripts/agent-claim.mjs", "agent:register": "node scripts/agent-register.mjs", + "agent:ready": "node scripts/agent-ready.mjs", "agent:prioritize": "node scripts/agent-prioritize.mjs", "agent:file-miss": "node scripts/agent-file-miss.mjs", "agent:pool": "node scripts/agent-pool.mjs", diff --git a/scripts/agent-ready.mjs b/scripts/agent-ready.mjs new file mode 100644 index 00000000..5b50cb85 --- /dev/null +++ b/scripts/agent-ready.mjs @@ -0,0 +1,60 @@ +/** + * `pnpm agent:ready` — flip plan-issue Issues from `blocked` → `ready` after + * the intention plan is on `main` (OPS17 Passo 6). Idempotent per issue. + * + * pnpm agent:ready -- --issue 292 + * pnpm agent:ready -- --issue 292,293 + * + * Fail-closed: only Issues that are open, labeled `blocked`, and link + * `docs/plans/` in the body. Does not promote product-blocked Issues without + * a plan link (those stay for human triage). + */ + +import { dieAgent, gh, ghJson, labelNames, parseArgs, setLabels } from './lib/agent-github.mjs' +import { canPromotePlanIssue } from './lib/agent-plan-lifecycle.mjs' + +const die = dieAgent('ready') +const { flags } = parseArgs(process.argv.slice(2), new Set(['issue'])) + +if (!flags.issue) { + die('Usage: pnpm agent:ready -- --issue ') +} + +const numbers = String(flags.issue) + .split(',') + .map((value) => value.trim()) + .filter(Boolean) + .map((value) => { + if (!/^\d+$/.test(value)) die(`Invalid issue number "${value}".`) + return Number(value) + }) + +if (numbers.length === 0) die('Usage: pnpm agent:ready -- --issue ') + +for (const number of numbers) { + const issue = ghJson([ + 'issue', + 'view', + String(number), + '--json', + 'number,title,body,state,labels', + ]) + const verdict = canPromotePlanIssue(issue) + if (!verdict.ok) { + if (verdict.reason === 'not-blocked' && labelNames(issue).includes('ready')) { + console.log(`[agent:ready] #${number} already ready — skip`) + continue + } + die(`#${number} cannot mark ready (${verdict.reason}).`) + } + + setLabels(number, { add: ['ready'], remove: ['blocked'] }) + gh([ + 'issue', + 'comment', + String(number), + '--body', + 'Promovido a `ready`: plano de intenção em `main` (ciclo OPS17 — `pnpm agent:ready`).', + ]) + console.log(`[agent:ready] #${number} blocked → ready`) +} diff --git a/scripts/agent-register.mjs b/scripts/agent-register.mjs index d31b29db..108afaaa 100644 --- a/scripts/agent-register.mjs +++ b/scripts/agent-register.mjs @@ -5,10 +5,13 @@ * --prio P1 --depends B79 --kind feature --plan docs/plans/x.md [--body "..."] * * Body = frontmatter (id/depends/priority) + spec (linked plan + --body text). - * Starts life labeled `ready` (or `blocked` when --blocked). + * Starts life labeled `ready`, or `blocked` when `--blocked` / `--plan` + * (OPS17: plan-linked Issues are not claimable until the plan is on `main` + * and `pnpm agent:ready` promotes them). */ import { dieAgent, gh, issuesById, parseArgs, serializeFrontmatter } from './lib/agent-github.mjs' +import { resolveRegisterStateLabel } from './lib/agent-plan-lifecycle.mjs' const die = dieAgent('register') const { flags } = parseArgs( @@ -46,8 +49,12 @@ const body = serializeFrontmatter( .join('\n\n'), ) +const stateLabel = resolveRegisterStateLabel({ + hasPlan: Boolean(flags.plan), + explicitBlocked: Boolean(flags.blocked), +}) const labels = [ - flags.blocked ? 'blocked' : 'ready', + stateLabel, `prio:${priority}`, `kind:${flags.kind ?? 'feature'}`, ...(flags.labels ?? '') @@ -66,4 +73,8 @@ const url = gh([ ...labels.flatMap((label) => ['--label', label]), ]) -console.log(`[agent:register] created ${url}`) +const hint = + stateLabel === 'blocked' && flags.plan && !flags.blocked + ? ' (blocked até plano em main — depois: pnpm agent:ready -- --issue )' + : '' +console.log(`[agent:register] created ${url}${hint}`) diff --git a/scripts/lib/agent-plan-lifecycle.mjs b/scripts/lib/agent-plan-lifecycle.mjs new file mode 100644 index 00000000..6a48e5c8 --- /dev/null +++ b/scripts/lib/agent-plan-lifecycle.mjs @@ -0,0 +1,43 @@ +/** + * Plan-issue lifecycle helpers (OPS17): register with `--plan` starts non-claimable; + * promote to `ready` only after the intention plan is on `main`. + * + * Pure — no IO. Used by `agent-register` / `agent-ready` and pinned by unit tests. + */ + +import { HUMAN_GATE_LABELS, issueHasPlanLink } from './agent-pool-eligibility.mjs' +import { labelNames } from './agent-github.mjs' + +/** + * Initial state label for a newly registered issue. + * + * @param {{ hasPlan: boolean, explicitBlocked?: boolean }} options + * @returns {'ready' | 'blocked'} + */ +export const resolveRegisterStateLabel = ({ hasPlan, explicitBlocked = false }) => { + if (explicitBlocked || hasPlan) return 'blocked' + return 'ready' +} + +const TERMINAL_OR_ACTIVE = ['in-progress', 'done', 'in-prod'] + +/** + * Whether an issue is waiting for its intention plan on `main` and may be + * flipped `blocked` → `ready` by `agent:ready` (or the OPS18 Action). + * + * @param {{ state?: string, body?: string, labels?: Array<{ name: string }> }} issue + * @returns {{ ok: true } | { ok: false, reason: string }} + */ +export const canPromotePlanIssue = (issue) => { + if ((issue?.state ?? 'OPEN') !== 'OPEN') return { ok: false, reason: 'not-open' } + const labels = labelNames(issue ?? { labels: [] }) + if (!labels.includes('blocked')) return { ok: false, reason: 'not-blocked' } + if (TERMINAL_OR_ACTIVE.some((label) => labels.includes(label))) { + return { ok: false, reason: 'state-label' } + } + if (HUMAN_GATE_LABELS.some((label) => labels.includes(label))) { + return { ok: false, reason: 'needs-human' } + } + if (!issueHasPlanLink(issue)) return { ok: false, reason: 'no-plan-link' } + return { ok: true } +} diff --git a/scripts/lib/agent-pool-eligibility.mjs b/scripts/lib/agent-pool-eligibility.mjs index af057f40..a3d97bb4 100644 --- a/scripts/lib/agent-pool-eligibility.mjs +++ b/scripts/lib/agent-pool-eligibility.mjs @@ -15,7 +15,7 @@ import { labelNames } from './agent-github.mjs' export const POOL_CIRCUIT_BREAKER_FAILURES = 2 const EXCLUDED_STATE_LABELS = ['in-progress', 'blocked', 'done', 'in-prod'] -const HUMAN_GATE_LABELS = ['requirements-changed', 'needs:consent'] +export const HUMAN_GATE_LABELS = ['requirements-changed', 'needs:consent'] /** * @param {ReturnType[number]} entry queue entry from buildClaimQueue @@ -47,7 +47,8 @@ export const isAutonomousClaimable = ( return { ok: true } } -/** Plan link is a preference, never a blocker (v1) — status surfaces it as a warn. */ +/** Plan link is a preference for claim/pool status (warn), never a claim blocker. + * Promote-to-ready (`canPromotePlanIssue`) requires the link — different concern. */ export const issueHasPlanLink = (issue) => /docs\/plans\//.test(issue?.body ?? '') /** diff --git a/tests/unit/agentPlanLifecycle.unit.spec.ts b/tests/unit/agentPlanLifecycle.unit.spec.ts new file mode 100644 index 00000000..158a76e2 --- /dev/null +++ b/tests/unit/agentPlanLifecycle.unit.spec.ts @@ -0,0 +1,89 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + canPromotePlanIssue, + resolveRegisterStateLabel, +} from '../../scripts/lib/agent-plan-lifecycle.mjs' + +type TestIssue = { + number: number + title: string + body: string + state: string + labels: Array<{ name: string }> +} + +const issue = (over: Partial = {}): TestIssue => ({ + number: 1, + title: 'Issue de teste', + body: 'Plano: [`docs/plans/x.md`](docs/plans/x.md)', + state: 'OPEN', + labels: [{ name: 'blocked' }, { name: 'prio:P2' }], + ...over, +}) + +describe('resolveRegisterStateLabel', () => { + it('keeps chores without a plan as ready', () => { + expect(resolveRegisterStateLabel({ hasPlan: false })).toBe('ready') + }) + + it('starts plan-linked issues as blocked (OPS17)', () => { + expect(resolveRegisterStateLabel({ hasPlan: true })).toBe('blocked') + }) + + it('honors explicit --blocked even without a plan', () => { + expect(resolveRegisterStateLabel({ hasPlan: false, explicitBlocked: true })).toBe('blocked') + }) + + it('stays blocked when both --plan and --blocked are set', () => { + expect(resolveRegisterStateLabel({ hasPlan: true, explicitBlocked: true })).toBe('blocked') + }) +}) + +describe('canPromotePlanIssue', () => { + it('accepts an open blocked issue with a docs/plans/ link', () => { + expect(canPromotePlanIssue(issue())).toEqual({ ok: true }) + }) + + it('rejects closed issues', () => { + expect(canPromotePlanIssue(issue({ state: 'CLOSED' }))).toEqual({ + ok: false, + reason: 'not-open', + }) + }) + + it('rejects issues that are not blocked', () => { + expect(canPromotePlanIssue(issue({ labels: [{ name: 'ready' }] }))).toEqual({ + ok: false, + reason: 'not-blocked', + }) + }) + + it.each(['in-progress', 'done', 'in-prod'])( + 'rejects when %s coexists with blocked', + (stateLabel) => { + expect( + canPromotePlanIssue(issue({ labels: [{ name: 'blocked' }, { name: stateLabel }] })), + ).toEqual({ ok: false, reason: 'state-label' }) + }, + ) + + it('rejects human-gated blocked issues even when they link a plan', () => { + expect( + canPromotePlanIssue( + issue({ + labels: [{ name: 'blocked' }, { name: 'needs:consent' }], + }), + ), + ).toEqual({ ok: false, reason: 'needs-human' }) + }) + + it('rejects blocked issues without a plan link (product block, not awaiting plan)', () => { + expect(canPromotePlanIssue(issue({ body: 'bloqueio jurídico' }))).toEqual({ + ok: false, + reason: 'no-plan-link', + }) + }) +})