diff --git a/.agents/shell/worktree.sh b/.agents/shell/worktree.sh new file mode 100644 index 00000000..0958b001 --- /dev/null +++ b/.agents/shell/worktree.sh @@ -0,0 +1,49 @@ +# worktree — CLI determinístico de worktrees em torno da fila de claim, com um +# `--go` que realmente troca de diretório. +# +# A lógica determinística (fila → branch → worktree) vive em scripts/worktree.mjs; +# esta função é só a camada de shell que APLICA o `cd
-`), 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..3ecf1899
--- /dev/null
+++ b/scripts/worktree.mjs
@@ -0,0 +1,150 @@
+/**
+ * `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 — node
+ * não muda o cwd do shell pai; o shell chamador
+ * aplica (opencode/CDP usa o opencode command;
+ * terminal interativo: função `worktree()` em
+ * `.agents/shell/worktree.sh`, sourced no profile).
+ * 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 (quem aplica o cd: opencode')
+ console.log(' command, ou a função `worktree()` de .agents/shell/worktree.sh)')
+ 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..0e375897
--- /dev/null
+++ b/tests/unit/worktree.unit.spec.ts
@@ -0,0 +1,69 @@
+// @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/,
+ )
+ })
+})