feat(scripts): deterministic worktree + issue CLI (next --go, kill, all)

This commit is contained in:
Francisco Solla 2026-08-07 01:27:22 -03:00
parent 21159a5602
commit 1c0648d59a
9 changed files with 457 additions and 1 deletions

View file

@ -0,0 +1,46 @@
---
name: worktree-next-issue
description: >-
Cria um worktree git a partir de main para a próxima Issue claimável da fila
do projeto, com branch legível no formato <código>-<slug> (ex.
C15-fullcalendar-em-campanha-agenda, slug pt-BR do título sem acentos).
Read-only no GitHub — NÃO claima, NÃO altera Issues. Usar quando o usuário
pedir "worktree da próxima issue", "cria o worktree para o próximo da fila",
"prepara o ambiente do próximo item", ou quiser começar a trabalhar a próxima
Issue sem abrir o pool/Cursor.
---
# Worktree da próxima Issue claimável
Prepara o ambiente git para a próxima Issue da fila de claim, **sem** tocá-la no GitHub (read-only por contrato — claim continua sendo `pnpm agent:claim`, feito à parte).
## Fonte canônica: `pnpm worktree` (script)
A lógica determinística (fila → código+slug → branch → worktree) vive em **`scripts/worktree.mjs`** — fonte única, coberta por `tests/unit/worktree.unit.spec.ts`. Não a reescreva à mão aqui.
```bash
pnpm worktree next # cria worktree de origin/main para a próxima claimável
pnpm worktree kill [--force] # destrói o worktree em que o shell atual está
```
No opencode, isso é o comando **`/worktree next`** / **`/worktree kill`** (`.opencode/commands/worktree.md`), que só repassa `$ARGUMENTS` para o script.
## Fluxo quando invocado como skill (agentes que não têm opencode)
1. Rode `pnpm worktree next` e leia a saída inteira. Se a fila estiver vazia, ele para sozinho — não crie worktree sem Issue.
2. Se a saída reclamar de conflito (worktree/branch já existentes), o script já reporta o que reutilizar.
3. Reporte ao usuário: código, `#<N>`, branch (`<code>-<slug>`), path — e que a Issue **não** foi claimada.
## O que o script garante (contrato)
- **Fila = `pnpm agent:claim --dry-run`** (ready + desbloqueadas, por `prio:P*` e mais antiga primeiro — mesma ordenação do pool e de `project-status`).
- **Branch `<code>-<slug>`**: `code` = `id` do frontmatter; `slug` = título pt-BR via `src/lib/slug.ts` (acentos fora, não-alfanumérico → hífen); truncamento só no slug, nunca no código; valida com `git check-ref-format --allow-onelevel`.
- **Base `origin/main`** (com `git fetch` antes); dir em `~/.cursor/worktrees/teqo/<branch>`.
- **`kill`**: recusa destruir o worktree principal (main); recusa worktree sujo sem `--force`; remove e apaga o branch.
- **NUNCA usa `--force` no `add`**, não inventa código de Issue, e é **read-only no GitHub**.
## NÃO faz
- Não roda `pnpm agent:claim` (mudaria labels no GitHub).
- Não usa `git worktree add --force` nem destrói worktrees existentes (isso é `kill`, explícito).
- Não duplica a lógica: se o script falhar ou faltar (gh fora do ar), reporte e pare.

View file

@ -0,0 +1,7 @@
---
description: Mostra a próxima Issue claimável (next) ou overview das Issues abertas (all)
---
Rode o script determinístico abaixo (não improvise a lógica) e reporte o resultado de forma concisa.
!`node scripts/issue.mjs $ARGUMENTS`

View file

@ -0,0 +1,10 @@
---
description: Cria o worktree da próxima Issue claimável (next [--go]) ou destrói o worktree atual (kill [--force])
---
Rode o script determinístico abaixo (não improvise a lógica) e reporte o resultado de forma concisa.
- `next [--go]`: cria (ou reutiliza) o worktree da próxima Issue claimável. Com `--go`, o script imprime `cd <dir>` na última linha — aplique-o na sessão de shell atual (`cd <dir>`) e reporte o novo diretório.
- `kill [--force]`: destrói o worktree em que você está.
!`node scripts/worktree.mjs $ARGUMENTS`

View file

@ -72,7 +72,9 @@
"typecheck": "tsc --noEmit",
"typecheck:tsc6": "tsc6 --noEmit",
"prepare": "husky",
"agent:status": "node scripts/agent-status.mjs"
"agent:status": "node scripts/agent-status.mjs",
"worktree": "node scripts/worktree.mjs",
"issue": "node scripts/issue.mjs"
},
"dependencies": {
"@ai-sdk/deepseek": "^3.0.19",

116
scripts/issue.mjs Normal file
View file

@ -0,0 +1,116 @@
/**
* `pnpm issue` — Issues da fila de claim, determinístico e read-only (nunca
* altera o GitHub; claim continua `pnpm agent:claim`).
*
* pnpm issue next imprime a próxima Issue claimável (topo da fila do claim)
* pnpm issue all overview compacto das Issues abertas (contadores + lista)
*/
import {
dieAgent,
ghJson,
labelNames,
nextClaimableIssue,
parseArgs,
parseFrontmatter,
priorityRank,
} from './lib/agent-github.mjs'
const die = dieAgent('issue')
const cmdNext = () => {
const pick = nextClaimableIssue()
if (!pick) {
die('Fila vazia — nada `ready` desbloqueado. Rode `pnpm issue all` para a overview.')
}
const { issue, meta, priority, satisfiedWithoutIssue } = pick
const id = typeof meta.id === 'string' ? meta.id : null
const prefix = id ? `${id} — ` : null
const subject =
prefix && issue.title.startsWith(prefix) ? issue.title.slice(prefix.length) : issue.title
console.log(`#${issue.number} ${id ?? ''} [${priority}]`.trim())
console.log(subject)
if (meta.model) console.log(`model: ${meta.model}`)
if (satisfiedWithoutIssue.length > 0) {
console.log(`deps satisfeitas (roadmap, sem Issue): ${satisfiedWithoutIssue.join(', ')}`)
}
console.log(`url: https://github.com/fsolla/teqo/issues/${issue.number}`)
}
const count = (map, key) => map.set(key, (map.get(key) ?? 0) + 1)
const cmdAll = () => {
const issues = ghJson([
'issue',
'list',
'--state',
'open',
'--limit',
'200',
'--json',
'number,title,body,labels,createdAt,state',
])
const rows = []
for (const issue of issues) {
const { meta } = parseFrontmatter(issue.body)
if (typeof meta.id !== 'string' || meta.id.length === 0) continue
const labels = labelNames(issue)
rows.push({
issue,
id: meta.id,
state: labels.find((label) => ['ready', 'in-progress', 'blocked'].includes(label)) ?? 'open',
priority: labels.find((label) => /^prio:P[0-3]$/.test(label))?.replace('prio:', '') ?? 'P2',
kind: labels.find((label) => label.startsWith('kind:'))?.replace('kind:', '') ?? 'feature',
})
}
const byState = new Map()
const byPrio = new Map()
const byKind = new Map()
for (const row of rows) {
count(byState, row.state)
count(byPrio, row.priority)
count(byKind, row.kind)
}
const fmt = (map) =>
[...map.entries()]
.sort(([a], [b]) => String(a).localeCompare(String(b)))
.map(([key, value]) => `${key}=${value}`)
.join(' ')
console.log(`Issues abertas: ${rows.length}`)
console.log(` por estado: ${fmt(byState) || '(—)'}`)
console.log(` por prio: ${fmt(byPrio) || '(—)'}`)
console.log(` por kind: ${fmt(byKind) || '(—)'}`)
console.log('')
const sorted = [...rows].sort((a, b) => {
const rank = priorityRank(a.priority) - priorityRank(b.priority)
return rank !== 0 ? rank : a.issue.number - b.issue.number
})
for (const row of sorted) {
console.log(
` #${row.issue.number} ${row.id} [${row.state}/${row.priority}] ${row.issue.title}`,
)
}
}
const { positional } = parseArgs(process.argv.slice(2), new Set())
const subcommand = positional[0]
if (!subcommand) {
console.log('Uso: pnpm issue next | all')
console.log(' next imprime a próxima Issue claimável (topo da fila do claim)')
console.log(' all overview compacto das Issues abertas')
process.exit(1)
}
try {
if (subcommand === 'next') cmdNext()
else if (subcommand === 'all') cmdAll()
else die(`subcomando desconhecido: ${subcommand} (esperado: next | all)`)
} catch (error) {
if (error?.stderr) die(error.stderr.toString().trim())
die(error?.message ?? String(error))
}

View file

@ -136,6 +136,22 @@ export const setLabels = (number, { add = [], remove = [] }) => {
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 = []

44
scripts/lib/worktree.mjs Normal file
View file

@ -0,0 +1,44 @@
/**
* Pure helpers for `scripts/worktree.mjs` — branch naming derived from the
* claim-queue issue, shared with the unit tests. Naming: `<code>-<slug>`
* where `code` is the frontmatter `id` (ex. `C15`) and `slug` is the pt-BR
* title slugified with the repo's `src/lib/slug.ts` (accents stripped,
* non-alphanumeric → `-`).
*/
import { slugify } from '../../src/lib/slug.ts'
/** Strip the leading `<code> — ` (or any dash variant) off a title. */
const stripCodePrefix = (title, code) => {
const escaped = code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return title.replace(new RegExp(`^${escaped}\\s*[—–:-]?\\s*`), '')
}
/**
* `code` = frontmatter `id` (falls back to the leading `ID — ` token of the
* title); `subject` = the title with that prefix removed.
*/
export const issueCodeAndSubject = (issue) => {
const title = issue.title ?? ''
const code = typeof issue.meta?.id === 'string' && issue.meta.id.length > 0 ? issue.meta.id : null
const subject = code
? stripCodePrefix(title, code)
: title.replace(/^[A-Za-z0-9+]+\s*[—–:-]?\s*/, '')
return { code, subject }
}
/**
* `<code>-<slug>` truncated to `maxLength` total characters (always keeping
* the code). Throws when the issue has no id — fail loudly, never invent.
*/
export const branchNameForIssue = (issue, maxLength = 60) => {
const { code, subject } = issueCodeAndSubject(issue)
if (!code) {
throw new Error(`Issue sem frontmatter id: #${issue.number} ${issue.title}`)
}
const slug = slugify(subject) || 'issue'
const full = `${code}-${slug}`
if (full.length <= maxLength) return full
const keep = Math.max(1, maxLength - code.length - 1)
return `${code}-${slug.slice(0, keep)}`
}

148
scripts/worktree.mjs Normal file
View file

@ -0,0 +1,148 @@
/**
* `pnpm worktree` — worktree management determinístico em torno da fila de
* claim do projeto (mesma fila de `agent:claim` / agent pool).
*
* pnpm worktree next [--go] cria worktree a partir de origin/main para a
* próxima Issue claimável; branch `<code>-<slug>`.
* Com `--go`, imprime `cd <dir>` no fim para o
* shell chamador aplicar (node não muda o cwd
* do shell pai; no terminal: `eval "$(pnpm
* worktree next --go)"`).
* pnpm worktree kill [--force] destrói o worktree em que o shell atual está
* (recusa worktree sujo sem `--force`)
*
* Read-only no GitHub: `next` NUNCA claima (claim = `pnpm agent:claim`).
* Dir raiz: `~/.cursor/worktrees/teqo/` (mesma casa dos worktrees do Cursor).
*/
import { execFileSync } from 'node:child_process'
import { homedir } from 'node:os'
import { join, resolve } from 'node:path'
import { dieAgent, nextClaimableIssue, parseArgs } from './lib/agent-github.mjs'
import { branchNameForIssue } from './lib/worktree.mjs'
const die = dieAgent('worktree')
const WORKTREES_ROOT = join(homedir(), '.cursor', 'worktrees', 'teqo')
/** `git` wrapper; `okIfFails` swallows the failure and returns null instead. */
const git = (args, { okIfFails = false } = {}) => {
try {
return execFileSync('git', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }).trim()
} catch (error) {
if (okIfFails) return null
const stderr = (error.stderr?.toString() ?? '').trim()
die(stderr || error.message || `git ${args.join(' ')} falhou`)
}
return null
}
/** Parse `git worktree list --porcelain` into [{ path, branch }]. */
const parseWorktreeList = (porcelain) => {
const entries = []
let current = null
for (const raw of porcelain.split('\n')) {
const line = raw.trimEnd()
if (line.startsWith('worktree ')) {
current = { path: line.slice('worktree '.length), branch: null }
entries.push(current)
} else if (line.startsWith('branch refs/heads/')) {
if (current) current.branch = line.slice('branch refs/heads/'.length)
} else if (line === '') {
current = null
}
}
return entries
}
const cmdNext = (go) => {
const pick = nextClaimableIssue()
if (!pick) {
die('Fila vazia — nada `ready` desbloqueado. Rode `pnpm agent:status` para ver a fila.')
}
const issue = pick.issue
const branch = branchNameForIssue({ ...issue, meta: pick.meta })
if (git(['check-ref-format', '--allow-onelevel', branch], { okIfFails: true }) === null) {
die(`Branch derivado inválido para refname: ${branch}`)
}
const dir = join(WORKTREES_ROOT, branch)
console.log(`Próxima da fila: #${issue.number} ${issue.title}`)
git(['fetch', 'origin'])
const entries = parseWorktreeList(git(['worktree', 'list', '--porcelain']))
if (entries.some((entry) => resolve(entry.path) === resolve(dir))) {
console.log(`Worktree já existe em ${dir} — reutilizando, sem duplicar.`)
} else {
const branchExists =
git(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], { okIfFails: true }) !== null
const flag = branchExists ? '-B' : '-b'
git(['worktree', 'add', flag, branch, dir, 'origin/main'])
console.log('Worktree criado:')
console.log(` branch: ${branch}`)
console.log(` path: ${dir}`)
console.log(' origem: origin/main')
console.log('Issue NÃO claimada — claim continua sendo `pnpm agent:claim`.')
}
if (go) console.log(`cd ${dir}`)
}
const cmdKill = (force) => {
const entries = parseWorktreeList(git(['worktree', 'list', '--porcelain']))
const mainRoot = entries[0]?.path
if (!mainRoot) die('Não consegui ler os worktrees deste repo.')
const top = git(['rev-parse', '--show-toplevel'])
if (resolve(top) === resolve(mainRoot)) {
die(
'Você está no worktree principal (main) — não dá para destruí-lo. Rode `git worktree list`.',
)
}
const entry = entries.find((candidate) => resolve(candidate.path) === resolve(top))
const branch = entry?.branch ?? null
const dirty = git(['-C', top, 'status', '--porcelain'])
if (dirty && !force) {
const sample = dirty
.split('\n')
.slice(0, 10)
.map((line) => ` ${line}`)
.join('\n')
die(
`Worktree ${top} está sujo. Commite/stacke ou confirme com \`pnpm worktree kill --force\`.\n${sample}`,
)
}
git(['-C', mainRoot, 'worktree', 'remove', '--force', top])
if (branch) git(['-C', mainRoot, 'branch', '-D', branch])
console.log(`Worktree destruído: ${top}`)
console.log(` branch removido: ${branch ?? '(detached — nada a remover)'}`)
console.log(`Volte ao main: cd ${mainRoot}`)
}
const { flags, positional } = parseArgs(process.argv.slice(2), new Set())
const subcommand = positional[0]
if (!subcommand) {
console.log('Uso: pnpm worktree next [--go] | kill [--force]')
console.log(' next [--go] cria worktree da próxima Issue claimável (branch <code>-<slug>);')
console.log(' com --go imprime `cd <dir>` no fim para o shell chamador aplicar')
console.log(' kill [--force] destrói o worktree em que você está (recusa sujo sem --force)')
process.exit(1)
}
try {
if (subcommand === 'next') cmdNext(Boolean(flags.go))
else if (subcommand === 'kill') {
if (flags.go) die('`--go` só faz sentido com `next`.')
cmdKill(Boolean(flags.force))
} else die(`subcomando desconhecido: ${subcommand} (esperado: next | kill)`)
} catch (error) {
if (error?.stderr) die(error.stderr.toString().trim())
die(error?.message ?? String(error))
}

View file

@ -0,0 +1,67 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { branchNameForIssue, issueCodeAndSubject } from '../../scripts/lib/worktree.mjs'
type TestIssue = {
number: number
title: string
meta?: { id?: string }
}
const issue = (over: Partial<TestIssue> = {}): TestIssue => ({
number: 1,
title: 'C15 — FullCalendar em /campanha/agenda',
meta: { id: 'C15' },
...over,
})
describe('issueCodeAndSubject', () => {
it('extracts the frontmatter id and strips the em-dash prefix', () => {
expect(issueCodeAndSubject(issue())).toEqual({
code: 'C15',
subject: 'FullCalendar em /campanha/agenda',
})
})
it('tolerates dash variants after the code', () => {
for (const title of ['C15- FullCalendar', 'C15: FullCalendar', 'C15 — FullCalendar']) {
expect(issueCodeAndSubject(issue({ title })).subject).toBe('FullCalendar')
}
})
it('falls back to the leading title token when the frontmatter id is missing', () => {
expect(issueCodeAndSubject(issue({ meta: {}, title: 'B164 — Barra de nav' }))).toEqual({
code: null,
subject: 'Barra de nav',
})
})
})
describe('branchNameForIssue', () => {
it('builds <code>-<slug> with the repo slugify', () => {
expect(
branchNameForIssue(issue({ title: 'C15 — FullCalendar em /campanha/agenda' })),
).toBe('C15-fullcalendar-em-campanha-agenda')
})
it('strips accents from the pt-BR title', () => {
expect(
branchNameForIssue(
issue({ title: 'B164 — Barra de navegação inferior no mobile', meta: { id: 'B164' } }),
),
).toBe('B164-barra-de-navegacao-inferior-no-mobile')
})
it('truncates long slugs but keeps the code', () => {
const title = 'X9 — ' + 'palavra '.repeat(20).trim()
const branch = branchNameForIssue(issue({ title, meta: { id: 'X9' } }), 30)
expect(branch.startsWith('X9-')).toBe(true)
expect(branch.length).toBeLessThanOrEqual(30)
})
it('throws when the issue has no code — never invents', () => {
expect(() => branchNameForIssue(issue({ meta: {}, title: 'Sem código' }))).toThrow(/frontmatter/)
})
})