feat(ops): OPS17 — plan-issue blocked até plano em main

Register com --plan nasce blocked (não claimável); pnpm agent:ready
promove após o plano em main. Gate da skill plan-issue endurecido.

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-02 23:02:42 +00:00
parent ce0dcdb1cf
commit d2cc1d2693
No known key found for this signature in database
12 changed files with 330 additions and 18 deletions

View file

@ -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.

View file

@ -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 <ID> --title "<título>" --prio <P> --kind chore --plan docs/plans/<slug>.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 <ID> --title "<título>" --prio <P> --kind chore --plan docs/plans/<slug>.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 <N>` (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 <id-do-pai>` (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.

View file

@ -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/<slug>.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 <ID> --title "<título>" --prio <P0..P3> \
--depends <A,B> --kind <feature|chore|...> --plan docs/plans/<slug>.md \
--model <slug> --blocked
--model <slug>
```
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 <N[,N…]>
```
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

View file

@ -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 -- <issue> <P0..P3>` | 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`).

View file

@ -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

View file

@ -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

View file

@ -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",

60
scripts/agent-ready.mjs Normal file
View file

@ -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 <N[,N…]>')
}
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 <N[,N…]>')
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`)
}

View file

@ -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 <N>)'
: ''
console.log(`[agent:register] created ${url}${hint}`)

View file

@ -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 }
}

View file

@ -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<import('./agent-github.mjs').buildClaimQueue>[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 ?? '')
/**

View file

@ -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> = {}): 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',
})
})
})