From 0ac6863ed8af7b0a2a2b82bf721aa8c955441f31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Thu, 30 Jul 2026 12:22:05 -0300 Subject: [PATCH] feat: project-status + agent:status --- .cursor/skills/project-status/SKILL.md | 27 +++ package.json | 3 +- scripts/agent-status.mjs | 232 +++++++++++++++++++++++++ 3 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 .cursor/skills/project-status/SKILL.md create mode 100644 scripts/agent-status.mjs diff --git a/.cursor/skills/project-status/SKILL.md b/.cursor/skills/project-status/SKILL.md new file mode 100644 index 00000000..a0df3e05 --- /dev/null +++ b/.cursor/skills/project-status/SKILL.md @@ -0,0 +1,27 @@ +--- +name: project-status +description: Apresenta o estado do projeto Teqo a partir das GitHub Issues rastreáveis — overview (estado/prio/kind), fila atual na ordem do agent:claim com o modelo de cada item, grafo mermaid de dependências, bloqueios com motivo e sugestões de consolidação (merge/break-down). Read-only; NUNCA modifica Issues, em particular nunca toca Issues in-progress. Usar quando o usuário pedir status/visão geral do projeto, "como está o projeto", "qual a fila", "o que está bloqueado", "mostra o grafo", ou perguntar o que dá para consolidar. +--- + +# Status do projeto (read-only) + +Esta skill apresenta o estado do projeto a partir das GitHub Issues rastreáveis — a fonte canônica de spec/status/deps/prio/modelo. **Read-only por contrato:** nenhuma Issue é criada, editada ou relabelada aqui, e Issues `in-progress` são intocáveis (risco de conflito com o agente que está executando). + +## Fluxo + +1. Rode o script e leia a saída inteira: + +```bash +pnpm agent:status +``` + +2. Apresente ao usuário, em pt-BR, nesta ordem: + - **Overview** — contadores por estado/prio/kind; últimas concluídas; `needs:migration|consent` abertas. + - **Fila atual** — a ordem exata do claim (prontas desbloqueadas por prio), topo destacado, `model:` por linha. Se o usuário perguntar "qual a próxima?", a resposta é o topo desta fila — não existe mais skill de sugestão: o próximo é o `pnpm agent:claim`. + - **Mermaid** — reproduza o grafo das abertas (setas de `depends`, estado/prio no label). + - **Bloqueios** — cada `blocked` com o motivo (dep aberta, jurídico, `requirements-changed`). + - **Sugestões de consolidação** — as heurísticas do script (merge por domínio+prio; break-down por gargalo de depends) como **texto para o usuário decidir**. Nunca aplique consolidação aqui: aplicação é manual ou via `plan-issue`/`pnpm agent:register`. Nunca proponha nada sobre Issues `in-progress`. + +3. Se o usuário pedir interpretação além da saída (ex.: "o que destrava mais coisa?"), responda a partir do grafo — sem inventar dados que o script não imprimiu; se faltar dado, rode `gh issue view ` somente-leitura. + +**NÃO faz:** editar `docs/roadmap.md` (legado congelado), claim, registro, qualquer escrita no GitHub. diff --git a/package.json b/package.json index 09232cba..7d53b7d7 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,8 @@ "test:unit": "cross-env NODE_OPTIONS=--no-deprecation DATABASE_URL=postgresql://invalid:invalid@127.0.0.1:1/unit_tests_must_not_connect vitest run --config ./vitest.unit.config.mts", "typecheck": "tsc --noEmit", "typecheck:tsc6": "tsc6 --noEmit", - "prepare": "husky" + "prepare": "husky", + "agent:status": "node scripts/agent-status.mjs" }, "dependencies": { "@base-ui/react": "^1.6.0", diff --git a/scripts/agent-status.mjs b/scripts/agent-status.mjs new file mode 100644 index 00000000..5c618540 --- /dev/null +++ b/scripts/agent-status.mjs @@ -0,0 +1,232 @@ +/** + * `pnpm agent:status` — read-only overview of the trackable-issue graph. + * + * Sections: overview counters (state/prio/kind), current claim queue (same + * ordering as `agent:claim --dry-run`, with `model:`), mermaid dependency + * graph of open issues, blockers with reasons, and consolidation suggestions + * (merge / break-down candidates). Never touches issues labeled + * `in-progress` — read-only over `gh issue list --state all`. + */ + +import { + dieAgent, + ghJson, + issuesById, + labelNames, + parseFrontmatter, + priorityRank, +} from './lib/agent-github.mjs' + +const die = dieAgent('status') + +let issues +try { + issues = ghJson([ + 'issue', + 'list', + '--state', + 'all', + '--limit', + '500', + '--json', + 'number,title,body,state,labels,createdAt', + ]) +} catch (error) { + die(`gh issue list failed: ${error.message}`) +} + +const rows = issues + .map((issue) => { + const { meta } = parseFrontmatter(issue.body) + if (typeof meta.id !== 'string' || meta.id.length === 0) return null + const labels = labelNames(issue) + const stateLabel = labels.find((label) => + ['ready', 'in-progress', 'blocked', 'done', 'in-prod'].includes(label), + ) + return { + issue, + meta, + labels, + state: + issue.state === 'CLOSED' + ? (stateLabel ?? 'done') + : (stateLabel ?? (issue.state === 'OPEN' ? 'open' : 'done')), + priority: labels.find((label) => /^prio:P[0-3]$/.test(label))?.replace('prio:', '') ?? 'P2', + kind: labels.find((label) => label.startsWith('kind:'))?.replace('kind:', '') ?? 'feature', + needs: labels.filter((label) => label.startsWith('needs:')), + depends: Array.isArray(meta.depends) ? meta.depends : [], + model: typeof meta.model === 'string' ? meta.model : null, + } + }) + .filter(Boolean) + +const open = rows.filter((row) => row.issue.state === 'OPEN') +const doneIds = new Set( + rows + .filter( + (row) => row.issue.state === 'CLOSED' || row.state === 'done' || row.state === 'in-prod', + ) + .map((row) => row.meta.id), +) + +// --- Overview ---------------------------------------------------------------- + +console.log('## Overview\n') +const countBy = (fn) => { + const counts = new Map() + for (const row of rows) { + const key = fn(row) + counts.set(key, (counts.get(key) ?? 0) + 1) + } + return [...counts.entries()].sort((a, b) => String(a[0]).localeCompare(String(b[0]))) +} +console.log(`Total rastreáveis: ${rows.length} (${open.length} abertas)`) +console.log( + 'Por estado: ' + + countBy((row) => row.state) + .map(([state, count]) => `${state}=${count}`) + .join(' '), +) +console.log( + 'Abertas por prio: ' + + countBy((row) => (row.issue.state === 'OPEN' ? row.priority : null)) + .filter(([prio]) => prio !== null) + .map(([prio, count]) => `${prio}=${count}`) + .join(' '), +) +console.log( + 'Abertas por kind: ' + + countBy((row) => (row.issue.state === 'OPEN' ? row.kind : null)) + .filter(([kind]) => kind !== null) + .map(([kind, count]) => `${kind}=${count}`) + .join(' '), +) + +const needsAttention = open.filter((row) => row.needs.length > 0) +if (needsAttention.length > 0) { + console.log('\nneeds:* abertas:') + for (const row of needsAttention) { + console.log( + ` #${row.issue.number} ${row.meta.id} [${row.needs.join(', ')}] ${row.issue.title}`, + ) + } +} + +const recentDone = rows.filter((row) => row.state === 'done' || row.state === 'in-prod').slice(-5) +if (recentDone.length > 0) { + console.log('\nÚltimas concluídas:') + for (const row of recentDone) { + console.log(` #${row.issue.number} ${row.meta.id} [${row.state}] ${row.issue.title}`) + } +} + +// --- Fila atual (mesma ordem do agent:claim --dry-run) ------------------------ + +console.log('\n## Fila atual (ordem do agent:claim)\n') +const byId = issuesById() +const queue = open + .filter((row) => row.state === 'ready') + .map((row) => ({ + row, + blockedBy: row.depends.filter((id) => byId.has(id) && !doneIds.has(id)), + })) + .filter((entry) => entry.blockedBy.length === 0) + .sort((a, b) => { + const rank = priorityRank(a.row.priority) - priorityRank(b.row.priority) + return rank !== 0 ? rank : a.row.issue.createdAt.localeCompare(b.row.issue.createdAt) + }) + +if (queue.length === 0) { + console.log('(vazia — nada `ready` desbloqueado)') +} else { + queue.forEach(({ row }, index) => { + const top = index === 0 ? ' ← topo' : '' + const model = row.model ? ` model:${row.model}` : ' model:—' + console.log( + ` #${row.issue.number} ${row.meta.id} [${row.priority}]${model} ${row.issue.title}${top}`, + ) + }) +} + +// --- Bloqueios --------------------------------------------------------------- + +const blocked = open.filter( + (row) => row.state === 'blocked' || row.depends.some((id) => byId.has(id) && !doneIds.has(id)), +) +console.log('\n## Bloqueios\n') +if (blocked.length === 0) { + console.log('(nenhum)') +} else { + for (const row of blocked) { + const depBlockers = row.depends.filter((id) => byId.has(id) && !doneIds.has(id)) + const reasons = [] + if (depBlockers.length > 0) reasons.push(`dep aberta: ${depBlockers.join(', ')}`) + for (const need of row.needs) reasons.push(need) + if (row.labels.includes('requirements-changed')) reasons.push('requirements-changed') + if (row.state === 'blocked' && reasons.length === 0) + reasons.push('label blocked (motivo no body)') + console.log( + ` #${row.issue.number} ${row.meta.id} [${row.priority}] ${row.issue.title} — ${reasons.join('; ')}`, + ) + } +} + +// --- Mermaid ----------------------------------------------------------------- + +console.log('\n## Grafo (abertas)\n') +console.log('```mermaid') +console.log('graph LR') +for (const row of open) { + const label = `${row.meta.id} ${row.state}/${row.priority}` + console.log(` ${row.meta.id}["${label}"]`) +} +for (const row of open) { + for (const dep of row.depends) { + if (byId.has(dep)) console.log(` ${dep} --> ${row.meta.id}`) + } +} +console.log('```') + +// --- Sugestões de consolidação (nunca toca in-progress) ----------------------- + +console.log('\n## Sugestões de consolidação (só leitura — decisão é humana)\n') +const candidates = open.filter((row) => row.state !== 'in-progress') +const mergeCandidates = [] +const byPrefix = new Map() +for (const row of candidates) { + // Domain proxy: the first two significant words of the title (after the + // "ID — " prefix). Items sharing domain + prio with no execution are the + // merge-review candidates; a bare same-track grouping is too noisy. + const words = row.issue.title + .replace(/^[A-Z0-9+]+\s*—\s*/, '') + .split(/[\s/—-]+/) + .filter(Boolean) + .slice(0, 2) + .join(' ') + .toLowerCase() + const key = `${words}/${row.priority}` + byPrefix.set(key, [...(byPrefix.get(key) ?? []), row]) +} +for (const [key, group] of byPrefix) { + if (group.length >= 2 && group.length <= 6) { + mergeCandidates.push( + `mesmo domínio/prio (${key}), ${group.length} abertas sem execução: ${group.map((row) => row.meta.id).join(', ')} — avaliar merge`, + ) + } +} +const dependentCount = new Map() +for (const row of candidates) { + for (const dep of row.depends) dependentCount.set(dep, (dependentCount.get(dep) ?? 0) + 1) +} +const breakdownCandidates = candidates.filter((row) => (dependentCount.get(row.meta.id) ?? 0) >= 3) + +if (mergeCandidates.length === 0 && breakdownCandidates.length === 0) { + console.log('(nenhuma heurística disparou)') +} else { + for (const line of mergeCandidates) console.log(` merge? ${line}`) + for (const row of breakdownCandidates) { + console.log( + ` break-down? #${row.issue.number} ${row.meta.id} bloqueia ${dependentCount.get(row.meta.id)} itens — avaliar fatiar (título guarda-chuva?)`, + ) + } +}