teqo/scripts/lib/agent-github.mjs
Francisco Solla f0f5b73f3f OPS33: worktree next claima deterministicamente a Issue (--issue N, reopen) e launch com /work-issue --issue N
Claim extraído para scripts/lib/agent-github.mjs (claimIssue/claimBriefLines/
claimQueueEntry/claimTargetVerdict), agent-claim.mjs vira CLI fino com mesma
saída; branch validada antes do claim (sem claim órfão); hint de falha
pós-claim; guard -B contra reset de commits de sessão; diretiva launch com
prompt citado + tokenização xargs no worktree.sh; --issue sem valor/posicional
morrem com motivo (sem misclaim); unit novos (directive com issueNumber,
claimQueueEntry, claimTargetVerdict); docs/textos (AGENTS.md, /worktree, skill
worktree-next-issue, AGENT-OPS, CHANGELOG).
2026-08-10 19:46:54 -03:00

290 lines
9.8 KiB
JavaScript

/**
* 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'
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)
/** Issue ids that count as a satisfied dependency (closed or done/in-prod). */
const doneIdsOf = (byId) =>
new Set(
[...byId.entries()]
.filter(([, issue]) => {
const labels = labelNames(issue)
return issue.state === 'CLOSED' || labels.includes('done') || labels.includes('in-prod')
})
.map(([id]) => id),
)
/**
* Claim-queue entry shape derived from ONE issue: meta, priority and the
* dependency verdicts. Shared by `buildClaimQueue` (the queue) and
* `claimQueueEntry` (single-issue contexts like reopening an in-progress
* session) — one derivation, no drift.
*/
const entryForIssue = (issue, byId, doneIds) => {
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.
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,
}
}
/**
* Claim queue shared by `agent:claim` and the agent pool: open `ready` issues
* filtered to UNBLOCKED (every frontmatter `depends` id is closed or labeled
* `done`/`in-prod`; a dep without an issue is a delivered roadmap item —
* satisfied, surfaced as a warning, never silently dropped), ordered by
* `prio:P0..P3` then oldest first. Extracted verbatim from agent-claim.mjs;
* behavior is pinned by agentPoolEligibility.unit.spec.ts.
*
* @param {Array<any>} openReady
* @param {Map<string, any>} byId
* @returns {Array<{ issue: any, meta: Record<string, any>, priority: string, satisfiedWithoutIssue: string[], blockedBy: string[] }>}
*/
export const buildClaimQueue = (openReady, byId) => {
const doneIds = doneIdsOf(byId)
return openReady
.map((issue) => entryForIssue(issue, byId, doneIds))
.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)
})
}
/**
* Entry shape for ONE issue regardless of its labels — the `worktree next
* --issue <N>` reopen path (an already-claimed `in-progress` issue must not be
* re-filtered by its deps: reopening is about the session, not the queue).
*/
export const claimQueueEntry = (issue, byId) => entryForIssue(issue, byId, doneIdsOf(byId))
/**
* Pure decision for `worktree next --issue <N>`: how to treat the target issue
* from its state/labels, WITHOUT touching GitHub. `reopen` = already claimed
* (`in-progress` — no re-claim, and it wins over a stale `ready` double label);
* `claim` = `ready`; `error` = anything else (closed, or neither label).
*/
export const claimTargetVerdict = (issue) => {
if (issue.state !== 'OPEN') {
return { kind: 'error', message: `Issue não está aberta (${issue.state}).` }
}
const names = labelNames(issue)
if (names.includes('in-progress')) return { kind: 'reopen' }
if (names.includes('ready')) return { kind: 'claim' }
return {
kind: 'error',
message: `Issue não é claimável (labels: ${names.join(', ') || 'nenhum'}).`,
}
}
/**
* Optimistic claim shared by `agent:claim` and `worktree next`: 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. `die` is injected by the
* caller so the failure carries the right script label. The pool keeps its own
* coordinated `claimIssueForPool` — this is the human/CLI lock.
*/
export const claimIssue = (entry, die) => {
const fresh = ghJson([
'issue',
'view',
String(entry.issue.number),
'--json',
'number,labels,state',
])
const freshLabels = labelNames(fresh)
if (
fresh.state !== 'OPEN' ||
!freshLabels.includes('ready') ||
freshLabels.includes('in-progress')
) {
die(
`Issue #${entry.issue.number} was just claimed or closed by someone else. ` +
'Re-run `pnpm agent:claim` or reopen the session with `pnpm worktree next --issue ' +
`${entry.issue.number}` +
'`.',
)
}
setLabels(entry.issue.number, { add: ['in-progress'], remove: ['ready'] })
gh([
'issue',
'comment',
String(entry.issue.number),
'--body',
`Claimed by agent run at ${new Date().toISOString()}. Lock otimista: outro claim deve falhar e re-rodar \`pnpm agent:claim\`.`,
])
}
/**
* Lines of the claim brief (id, priority, rename_chat, model, deps, url, spec
* body) printed by both claim surfaces (`agent:claim` and `worktree next`) —
* one source of truth for the claim presentation.
*/
export const claimBriefLines = (entry) => {
const { rest } = parseFrontmatter(entry.issue.body)
const issueId = entry.meta.id ?? null
let subject = entry.issue.title
if (issueId) {
const idPrefix = `${issueId} — `
if (subject.startsWith(idPrefix)) subject = subject.slice(idPrefix.length)
}
const sessionTitle = issueId
? `#${entry.issue.number} ${issueId} — ${subject}`
: `#${entry.issue.number} — ${subject}`
return [
` id: ${issueId ?? '(none)'} priority: ${entry.priority}`,
` rename_chat: ${sessionTitle.slice(0, 200)}`,
entry.meta.model
? ` model: ${entry.meta.model} (metadata consultiva — o work-issue não verifica modelo; o pool spawna nele; ver skill model-selection)`
: ' model: ausente — registrar slug único na Issue (gh issue edit; ver skill model-selection)',
...(entry.satisfiedWithoutIssue.length > 0
? [
` deps sem issue (roadmap entregue, satisfeitas): ${entry.satisfiedWithoutIssue.join(', ')}`,
]
: []),
` url: https://github.com/fsolla/teqo/issues/${entry.issue.number}`,
'',
'--- spec ---',
'',
rest.trim() || '(empty body — see linked plan)',
]
}
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 nextClaimableIssue = () => {
const openReady = ghJson([
'issue',
'list',
'--state',
'open',
'--label',
'ready',
'--limit',
'200',
'--json',
'number,title,body,labels,createdAt,state',
])
return buildClaimQueue(openReady, issuesById())[0] ?? null
}
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)) {
const next = argv[index + 1]
// A following `--flag` is not a value: `--issue --stay` leaves the
// flag unset instead of silently consuming another flag as its value.
flags[name] = typeof next === 'string' && next.startsWith('--') ? undefined : next
index += 1
} else {
flags[name] = true
}
} else {
positional.push(arg)
}
}
return { flags, positional }
}