From 1c0648d59adc5bbb9c4446bf5cbe2e90ed0af05d Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Fri, 7 Aug 2026 01:27:22 -0300 Subject: [PATCH] feat(scripts): deterministic worktree + issue CLI (next --go, kill, all) --- .agents/skills/worktree-next-issue/SKILL.md | 46 ++++++ .opencode/commands/issue.md | 7 + .opencode/commands/worktree.md | 10 ++ package.json | 4 +- scripts/issue.mjs | 116 +++++++++++++++ scripts/lib/agent-github.mjs | 16 +++ scripts/lib/worktree.mjs | 44 ++++++ scripts/worktree.mjs | 148 ++++++++++++++++++++ tests/unit/worktree.unit.spec.ts | 67 +++++++++ 9 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/worktree-next-issue/SKILL.md create mode 100644 .opencode/commands/issue.md create mode 100644 .opencode/commands/worktree.md create mode 100644 scripts/issue.mjs create mode 100644 scripts/lib/worktree.mjs create mode 100644 scripts/worktree.mjs create mode 100644 tests/unit/worktree.unit.spec.ts diff --git a/.agents/skills/worktree-next-issue/SKILL.md b/.agents/skills/worktree-next-issue/SKILL.md new file mode 100644 index 00000000..b33b2230 --- /dev/null +++ b/.agents/skills/worktree-next-issue/SKILL.md @@ -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 - (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, `#`, branch (`-`), 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` = `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/`. +- **`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. diff --git a/.opencode/commands/issue.md b/.opencode/commands/issue.md new file mode 100644 index 00000000..e7509621 --- /dev/null +++ b/.opencode/commands/issue.md @@ -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` diff --git a/.opencode/commands/worktree.md b/.opencode/commands/worktree.md new file mode 100644 index 00000000..29893697 --- /dev/null +++ b/.opencode/commands/worktree.md @@ -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 ` na última linha — aplique-o na sessão de shell atual (`cd `) e reporte o novo diretório. +- `kill [--force]`: destrói o worktree em que você está. + +!`node scripts/worktree.mjs $ARGUMENTS` diff --git a/package.json b/package.json index e3da190c..a8ef56f4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/issue.mjs b/scripts/issue.mjs new file mode 100644 index 00000000..9ccc363a --- /dev/null +++ b/scripts/issue.mjs @@ -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)) +} diff --git a/scripts/lib/agent-github.mjs b/scripts/lib/agent-github.mjs index 7f9858fe..1e563610 100644 --- a/scripts/lib/agent-github.mjs +++ b/scripts/lib/agent-github.mjs @@ -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 = [] diff --git a/scripts/lib/worktree.mjs b/scripts/lib/worktree.mjs new file mode 100644 index 00000000..18a4646d --- /dev/null +++ b/scripts/lib/worktree.mjs @@ -0,0 +1,44 @@ +/** + * Pure helpers for `scripts/worktree.mjs` — branch naming derived from the + * claim-queue issue, shared with the unit tests. Naming: `-` + * 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 ` — ` (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 } +} + +/** + * `-` 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)}` +} diff --git a/scripts/worktree.mjs b/scripts/worktree.mjs new file mode 100644 index 00000000..8b4e8f3a --- /dev/null +++ b/scripts/worktree.mjs @@ -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 `-`. + * Com `--go`, imprime `cd ` 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 -);') + console.log(' com --go imprime `cd ` 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)) +} diff --git a/tests/unit/worktree.unit.spec.ts b/tests/unit/worktree.unit.spec.ts new file mode 100644 index 00000000..f8b1942a --- /dev/null +++ b/tests/unit/worktree.unit.spec.ts @@ -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 => ({ + 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 - 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/) + }) +})