From c362b63adb72f5cde410d9e9af511f95a70ee250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Thu, 30 Jul 2026 05:15:04 -0300 Subject: [PATCH] feat: agent ops scripts (claim/register/prioritize/file-miss/promote) + roadmap issues seeded --- package.json | 5 ++ scripts/agent-claim.mjs | 134 +++++++++++++++++++++++++++++++++++ scripts/agent-file-miss.mjs | 41 +++++++++++ scripts/agent-prioritize.mjs | 22 ++++++ scripts/agent-promote.mjs | 114 +++++++++++++++++++++++++++++ scripts/agent-register.mjs | 57 +++++++++++++++ scripts/lib/agent-github.mjs | 111 +++++++++++++++++++++++++++++ 7 files changed, 484 insertions(+) create mode 100644 scripts/agent-claim.mjs create mode 100644 scripts/agent-file-miss.mjs create mode 100644 scripts/agent-prioritize.mjs create mode 100644 scripts/agent-promote.mjs create mode 100644 scripts/agent-register.mjs create mode 100644 scripts/lib/agent-github.mjs diff --git a/package.json b/package.json index 32d56ada..09232cba 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/agent-claim.mjs b/scripts/agent-claim.mjs new file mode 100644 index 00000000..46051abd --- /dev/null +++ b/scripts/agent-claim.mjs @@ -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).`, +) diff --git a/scripts/agent-file-miss.mjs b/scripts/agent-file-miss.mjs new file mode 100644 index 00000000..022c2f87 --- /dev/null +++ b/scripts/agent-file-miss.mjs @@ -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 [--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}`) diff --git a/scripts/agent-prioritize.mjs b/scripts/agent-prioritize.mjs new file mode 100644 index 00000000..bb19c2a6 --- /dev/null +++ b/scripts/agent-prioritize.mjs @@ -0,0 +1,22 @@ +/** + * `pnpm agent:prioritize` — re-prioritize a trackable issue. + * + * pnpm agent:prioritize -- 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 -- ') +} + +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}`, +) diff --git a/scripts/agent-promote.mjs b/scripts/agent-promote.mjs new file mode 100644 index 00000000..f925db6f --- /dev/null +++ b/scripts/agent-promote.mjs @@ -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.`) diff --git a/scripts/agent-register.mjs b/scripts/agent-register.mjs new file mode 100644 index 00000000..190e74d8 --- /dev/null +++ b/scripts/agent-register.mjs @@ -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 --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}`) diff --git a/scripts/lib/agent-github.mjs b/scripts/lib/agent-github.mjs new file mode 100644 index 00000000..c3e4baf9 --- /dev/null +++ b/scripts/lib/agent-github.mjs @@ -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 } +}