feat: agent ops scripts (claim/register/prioritize/file-miss/promote) + roadmap issues seeded

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-30 05:15:04 -03:00
parent 3120d0b8fa
commit c362b63adb
7 changed files with 484 additions and 0 deletions

View file

@ -32,6 +32,11 @@
"db:seed:projecao": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/import-projecao.mjs",
"db:seed:minimal": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-minimal.mjs",
"db:refresh:stage": "node scripts/refresh-stage.mjs",
"agent:claim": "node scripts/agent-claim.mjs",
"agent:register": "node scripts/agent-register.mjs",
"agent:prioritize": "node scripts/agent-prioritize.mjs",
"agent:file-miss": "node scripts/agent-file-miss.mjs",
"agent:promote": "node scripts/agent-promote.mjs",
"build:demographics": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-demographics.mjs",
"build:election-aggregates": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/build-election-aggregates.mjs",
"build:geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-geometries.mjs",

134
scripts/agent-claim.mjs Normal file
View file

@ -0,0 +1,134 @@
/**
* `pnpm agent:claim` — claim the next trackable issue for this agent run.
*
* Queue: open issues labeled `ready`, filtered to UNBLOCKED (every frontmatter
* `depends` id is closed or labeled `done`/`in-prod`), ordered by `prio:P0..P3`
* then oldest first. `--dry-run` prints the queue without claiming.
*
* Lock otimista: re-reads the issue right before flipping labels, refuses if
* someone else already took it (`in-progress` present / `ready` gone), then
* swaps ready→in-progress and leaves a claim comment with the run timestamp.
*
* Output: the brief on stdout (issue, id, priority, plan/spec body) — the
* agent reads it and starts; it does NOT create the branch (Cursor worktrees
* own that) and it never merges anywhere. PRs go `--base stage`.
*/
import {
dieAgent,
gh,
ghJson,
issuesById,
labelNames,
parseArgs,
parseFrontmatter,
priorityRank,
setLabels,
} from './lib/agent-github.mjs'
const die = dieAgent('claim')
const { flags } = parseArgs(process.argv.slice(2), new Set(['issue']))
const openReady = ghJson([
'issue',
'list',
'--state',
'open',
'--label',
'ready',
'--limit',
'200',
'--json',
'number,title,body,labels,createdAt',
])
const byId = issuesById()
const doneIds = new Set(
[...byId.entries()]
.filter(([, issue]) => {
const labels = labelNames(issue)
return issue.state === 'CLOSED' || labels.includes('done') || labels.includes('in-prod')
})
.map(([id]) => id),
)
const queue = openReady
.map((issue) => {
const { meta } = parseFrontmatter(issue.body)
const depends = Array.isArray(meta.depends) ? meta.depends : []
// A dep without an issue is a delivered roadmap item (B43/B47/B59…): they
// predate the Issues era and are never reopened, so they are satisfied —
// surfaced as a warning in the brief, never silently dropped.
const satisfiedWithoutIssue = depends.filter((id) => !byId.has(id))
const blockedBy = depends.filter((id) => byId.has(id) && !doneIds.has(id))
return {
issue,
meta,
priority: labelNames(issue).find((label) => /^prio:P[0-3]$/.test(label)) ?? 'prio:P2',
satisfiedWithoutIssue,
blockedBy,
}
})
.filter((entry) => entry.blockedBy.length === 0)
.sort((a, b) => {
const rank = priorityRank(a.priority.replace('prio:', '')) - priorityRank(b.priority.replace('prio:', ''))
return rank !== 0 ? rank : a.issue.createdAt.localeCompare(b.issue.createdAt)
})
if (flags['dry-run'] || flags['dryrun']) {
console.log(`[agent:claim] ${queue.length} unblocked ready issue(s):`)
for (const entry of queue) {
console.log(` #${entry.issue.number} [${entry.priority}] ${entry.issue.title}`)
}
process.exit(0)
}
const pick = flags.issue
? queue.find((entry) => entry.issue.number === Number(flags.issue))
: queue[0]
if (!pick) {
die(
flags.issue
? `Issue #${flags.issue} is not claimable (missing, not ready, or blocked by deps).`
: 'Nothing to claim — no unblocked issues labeled `ready`. Run with --dry-run to inspect.',
)
}
// Optimistic lock: re-read right before flipping.
const fresh = ghJson([
'issue',
'view',
String(pick.issue.number),
'--json',
'number,labels,state',
])
const freshLabels = labelNames(fresh)
if (fresh.state !== 'OPEN' || !freshLabels.includes('ready') || freshLabels.includes('in-progress')) {
die(`Issue #${pick.issue.number} was just claimed or closed by someone else. Re-run claim.`)
}
setLabels(pick.issue.number, { add: ['in-progress'], remove: ['ready'] })
gh([
'issue',
'comment',
String(pick.issue.number),
'--body',
`Claimed by agent run at ${new Date().toISOString()}. Lock otimista: outro claim deve falhar e re-rodar \`pnpm agent:claim\`.`,
])
const { rest } = parseFrontmatter(pick.issue.body)
console.log(`\n[agent:claim] Claimed #${pick.issue.number} — ${pick.issue.title}`)
console.log(` id: ${pick.meta.id ?? '(none)'} priority: ${pick.priority}`)
if (pick.satisfiedWithoutIssue.length > 0) {
console.log(
` deps sem issue (roadmap entregue, satisfeitas): ${pick.satisfiedWithoutIssue.join(', ')}`,
)
}
console.log(` url: https://github.com/fsolla/teqo/issues/${pick.issue.number}`)
console.log('\n--- spec ---\n')
console.log(rest.trim() || '(empty body — see linked plan)')
console.log(
'\n[agent:claim] Fluxo: implementar → fast gate → gh pr create --base stage (Closes #' +
`${pick.issue.number}) → PARAR. Promote a main é humano (pnpm agent:promote --i-am-human).`,
)

View file

@ -0,0 +1,41 @@
/**
* `pnpm agent:file-miss` — file an agent-miss issue (guardrail pipeline).
*
* Misses feed the progressive-guardrail harvest: each miss becomes an issue
* with `kind:agent-miss`; periodically a human/agent harvests them into a
* programmatic guardrail (codebaseConventions spec, ESLint rule, CI check).
*
* pnpm agent:file-miss -- --title "migration merged sem atualizar seed:minimal" \
* [--body "contexto..."] [--kind agent-miss|defect]
*/
import { dieAgent, gh, parseArgs } from './lib/agent-github.mjs'
const die = dieAgent('file-miss')
const { flags } = parseArgs(process.argv.slice(2), new Set(['title', 'body', 'kind']))
if (!flags.title) {
die('Usage: pnpm agent:file-miss -- --title <what went wrong> [--body details] [--kind agent-miss|defect]')
}
const kind = flags.kind === 'defect' ? 'defect' : 'agent-miss'
const body = [
flags.body ?? '',
'',
'_Registrado por `pnpm agent:file-miss`. Harvest: avaliar guardrail programático (teste de convenção, ESLint, check de CI) e registrar em `docs/GUARDRAILS.md`._',
].join('\n')
const url = gh([
'issue',
'create',
'--title',
flags.title,
'--body',
body,
'--label',
`kind:${kind}`,
'--label',
'prio:P2',
])
console.log(`[agent:file-miss] filed ${url}`)

View file

@ -0,0 +1,22 @@
/**
* `pnpm agent:prioritize` — re-prioritize a trackable issue.
*
* pnpm agent:prioritize -- <issue-number> P1
*/
import { dieAgent, ghJson, labelNames, parseArgs, setLabels } from './lib/agent-github.mjs'
const die = dieAgent('prioritize')
const { positional } = parseArgs(process.argv.slice(2), new Set())
const [number, priority] = positional
if (!number || !/^P[0-3]$/.test(priority ?? '')) {
die('Usage: pnpm agent:prioritize -- <issue-number> <P0|P1|P2|P3>')
}
const issue = ghJson(['issue', 'view', number, '--json', 'number,title,labels'])
const current = labelNames(issue).filter((label) => /^prio:P[0-3]$/.test(label))
setLabels(number, { add: [`prio:${priority}`], remove: current })
console.log(
`[agent:prioritize] #${issue.number} — ${issue.title}: ${current.join(',') || '(none)'} → prio:${priority}`,
)

114
scripts/agent-promote.mjs Normal file
View file

@ -0,0 +1,114 @@
/**
* `pnpm agent:promote -- --i-am-human` — promote `stage` → `main` (production).
*
* HUMAN-ONLY. The flag is the review: branch protection on `main` requires a
* human review, and this script is it. It refuses to run without the flag and
* never runs from Cursor Automations.
*
* Checks (in order):
* 1. `main` has not diverged from `stage` (hotfix direto em main exige antes
* merge/rebase de main em stage + CI stage green de novo).
* 2. Latest ci-stage run on `stage` is green.
* 3. Creates the PR stage→main if none is open, then requires the PR checks
* (ci-pr: checks + migration-lock) green before merging.
* 4. Merges. Labels on done issues gain `in-prod` for the promoted range.
*/
import { execFileSync } from 'node:child_process'
import { dieAgent, gh, ghJson, parseArgs } from './lib/agent-github.mjs'
const die = dieAgent('promote')
const { flags } = parseArgs(process.argv.slice(2), new Set())
if (flags['i-am-human'] !== true) {
die(
'Refused: promote is human-only. Re-run as a human with:\n' +
' pnpm agent:promote -- --i-am-human\n' +
'Agents stop at merge-to-stage. Promote moves stage → main → produção (Vercel).',
)
}
const git = (args) => execFileSync('git', args, { encoding: 'utf8' }).trim()
git(['fetch', 'origin', 'main', 'stage'])
// 1. Divergence: main must be an ancestor of stage.
try {
git(['merge-base', '--is-ancestor', 'origin/main', 'origin/stage'])
} catch {
die(
'`main` divergiu de `stage` (hotfix direto em main?). Antes do promote:\n' +
' git checkout stage && git merge origin/main && git push origin stage\n' +
'…e espere o CI stage ficar verde de novo.',
)
}
// 2. CI stage green on the stage head.
const stageSha = git(['rev-parse', 'origin/stage'])
const stageRuns = ghJson([
'run',
'list',
'--branch',
'stage',
'--workflow',
'ci-stage.yml',
'--limit',
'5',
'--json',
'headSha,status,conclusion,url',
])
const latestStageRun = stageRuns.find((run) => run.headSha === stageSha)
if (!latestStageRun || latestStageRun.status !== 'completed' || latestStageRun.conclusion !== 'success') {
die(
`CI stage não está verde no head de stage (${stageSha.slice(0, 8)}). ` +
`Último run: ${latestStageRun ? `${latestStageRun.status}/${latestStageRun.conclusion} — ${latestStageRun.url}` : 'nenhum'}`,
)
}
console.log('[agent:promote] CI stage green ✓')
// 3. PR stage → main.
let pr = ghJson([
'pr',
'list',
'--state',
'open',
'--base',
'main',
'--head',
'stage',
'--json',
'number,url',
])[0]
if (!pr) {
const url = gh([
'pr',
'create',
'--base',
'main',
'--head',
'stage',
'--title',
`Promote stage → main (${new Date().toISOString().slice(0, 10)})`,
'--body',
'Promote humano via `pnpm agent:promote --i-am-human`. CI stage green verificado antes da criação.',
])
pr = { number: url.split('/').pop(), url }
console.log(`[agent:promote] created PR ${pr.url}`)
} else {
console.log(`[agent:promote] reusing open PR ${pr.url}`)
}
// 4. PR checks green, then merge (the human running this script IS the review
// required by main's branch protection; --admin applies that judgment).
const checks = ghJson(['pr', 'checks', String(pr.number), '--json', 'name,state'])
const notGreen = checks.filter((check) => check.state !== 'SUCCESS')
if (notGreen.length > 0) {
die(
`PR #${pr.number} ainda tem checks não-verdes: ${notGreen
.map((check) => `${check.name}=${check.state}`)
.join(', ')}. Espere o ci-pr terminar e re-rode o promote.`,
)
}
gh(['pr', 'merge', String(pr.number), '--admin', '--merge'])
console.log(`[agent:promote] merged stage → main via PR #${pr.number}. Deploy Vercel a caminho.`)

View file

@ -0,0 +1,57 @@
/**
* `pnpm agent:register` — create a trackable issue (spec + prio + deps).
*
* pnpm agent:register -- --id B91 --title "Ações rápidas — X" \
* --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).
*/
import {
dieAgent,
gh,
issuesById,
parseArgs,
serializeFrontmatter,
} from './lib/agent-github.mjs'
const die = dieAgent('register')
const { flags } = parseArgs(
process.argv.slice(2),
new Set(['id', 'title', 'prio', 'depends', 'kind', 'plan', 'body', 'labels']),
)
if (!flags.id || !flags.title) die('Usage: pnpm agent:register -- --id <ID> --title <title> [--prio P2] [--depends A,B] [--kind feature] [--plan path] [--body text] [--labels extra,labels] [--blocked]')
const priority = /^P[0-3]$/.test(flags.prio ?? 'P2') ? (flags.prio ?? 'P2') : die(`Invalid --prio "${flags.prio}" (P0..P3).`)
const depends = (flags.depends ?? '').split(',').map((v) => v.trim()).filter(Boolean)
const existing = issuesById().get(flags.id)
if (existing) die(`An issue with id ${flags.id} already exists: #${existing.number} — ${existing.title}`)
const body = serializeFrontmatter(
{ id: flags.id, depends, serializes: [], priority },
[flags.plan ? `Plano: [\`${flags.plan}\`](${flags.plan})` : null, flags.body ?? '']
.filter((chunk) => chunk !== null)
.join('\n\n'),
)
const labels = [
flags.blocked ? 'blocked' : 'ready',
`prio:${priority}`,
`kind:${flags.kind ?? 'feature'}`,
...(flags.labels ?? '').split(',').map((v) => v.trim()).filter(Boolean),
]
const url = gh([
'issue',
'create',
'--title',
`${flags.id} — ${flags.title}`,
'--body',
body,
...labels.flatMap((label) => ['--label', label]),
])
console.log(`[agent:register] created ${url}`)

View file

@ -0,0 +1,111 @@
/**
* Shared helpers for the agent ops scripts (`pnpm agent:*`). Everything goes
* through the authenticated `gh` CLI — agents never get a GitHub token of
* their own, and these scripts never touch stage/prod database URLs.
*
* Issue contract (spec + status + deps in GitHub Issues, per the parallel
* paradigm plan): each trackable issue carries a YAML-ish frontmatter block
* at the top of the body:
*
* ---
* id: B73
* depends: [B43, B65]
* serializes: [migrations]
* priority: P1
* ---
*
* State lives in labels: ready | in-progress | blocked | done (+ in-prod).
*/
import { execFileSync } from 'node:child_process'
import { dieWithLabel } from './cli.mjs'
export const STATE_LABELS = ['ready', 'in-progress', 'blocked', 'done', 'in-prod']
export const PRIORITIES = ['P0', 'P1', 'P2', 'P3']
export const priorityRank = (priority) => PRIORITIES.indexOf(priority)
export const dieAgent = (script) => dieWithLabel(`agent:${script}`)
/** Run gh and return stdout (throws with the gh stderr on failure). */
export const gh = (args) =>
execFileSync('gh', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }).trim()
export const ghJson = (args) => JSON.parse(gh(args))
export const parseFrontmatter = (body) => {
const match = /^---\n([\s\S]*?)\n---\n?/.exec(body ?? '')
if (!match) return { meta: {}, rest: body ?? '' }
const meta = {}
for (const line of match[1].split('\n')) {
const kv = /^([a-zA-Z]+):\s*(.*)$/.exec(line.trim())
if (!kv) continue
const [, key, raw] = kv
meta[key] = raw.startsWith('[')
? raw
.slice(1, -1)
.split(',')
.map((value) => value.trim())
.filter(Boolean)
: raw
}
return { meta, rest: body.slice(match[0].length) }
}
export const serializeFrontmatter = (meta, rest) => {
const lines = ['---']
for (const [key, value] of Object.entries(meta)) {
lines.push(`${key}: ${Array.isArray(value) ? `[${value.join(', ')}]` : value}`)
}
lines.push('---', '')
return lines.join('\n') + (rest ?? '')
}
/** All issues (open or closed) that carry a frontmatter `id`, keyed by id. */
export const issuesById = () => {
const issues = ghJson([
'issue',
'list',
'--state',
'all',
'--limit',
'500',
'--json',
'number,title,body,state,labels',
])
const byId = new Map()
for (const issue of issues) {
const { meta } = parseFrontmatter(issue.body)
if (typeof meta.id === 'string' && meta.id.length > 0) byId.set(meta.id, issue)
}
return byId
}
export const labelNames = (issue) => issue.labels.map((label) => label.name)
export const setLabels = (number, { add = [], remove = [] }) => {
const args = ['issue', 'edit', String(number)]
for (const label of add) args.push('--add-label', label)
for (const label of remove) args.push('--remove-label', label)
if (add.length + remove.length > 0) gh(args)
}
export const parseArgs = (argv, flagsWithValue) => {
const flags = {}
const positional = []
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg.startsWith('--')) {
const name = arg.slice(2)
if (flagsWithValue.has(name)) {
flags[name] = argv[index + 1]
index += 1
} else {
flags[name] = true
}
} else {
positional.push(arg)
}
}
return { flags, positional }
}