mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 08:53:11 +00:00
feat: project-status + agent:status
This commit is contained in:
parent
111308f00e
commit
0ac6863ed8
3 changed files with 261 additions and 1 deletions
27
.cursor/skills/project-status/SKILL.md
Normal file
27
.cursor/skills/project-status/SKILL.md
Normal file
|
|
@ -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 <N>` somente-leitura.
|
||||
|
||||
**NÃO faz:** editar `docs/roadmap.md` (legado congelado), claim, registro, qualquer escrita no GitHub.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
232
scripts/agent-status.mjs
Normal file
232
scripts/agent-status.mjs
Normal file
|
|
@ -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?)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue