mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
1097 lines
49 KiB
JavaScript
1097 lines
49 KiB
JavaScript
/**
|
|
* `pnpm worktree` — worktree management determinístico em torno da fila de
|
|
* claim do projeto (mesma fila de `agent:claim` / agent pool).
|
|
*
|
|
* pnpm worktree next [--issue N] [--stay] [--no-migrate] [--auto] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]
|
|
* claima a próxima Issue claimável ANTES de criar
|
|
* o worktree a partir de origin/main (mesma fila
|
|
* e lock otimista de `pnpm agent:claim`; claim
|
|
* falhou → motivo e saída SEM worktree órfão);
|
|
* branch `<code>-<slug>`.
|
|
* `--issue N` claima a Issue direcionada (`ready`)
|
|
* ou REABRE uma já claimada (`in-progress` — sem
|
|
* re-claim; worktree reutilizado/criado e launch
|
|
* na hora). O texto de saída avisa "já claimada —
|
|
* não rodar `pnpm agent:claim`".
|
|
* Por padrão imprime `cd <dir>` 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).
|
|
* `--stay` suprime a linha `cd` (o claim ainda
|
|
* acontece); `--cheap/--pro/--zen/--go/--alibaba/--glm/--free`
|
|
* escolhe o modelo por invocação no mapa fixo
|
|
* `WORKTREE_MODEL_MAP` (cheap=cheapestinference/
|
|
* deepseek-v4-flash, pro=deepseek/deepseek-v4-pro,
|
|
* zen=opencode/muse-spark-1.3-contributor-free, go=opencode-go/deepseek-v4.1-flash,
|
|
* alibaba=alibaba-token-plan/deepseek-v4-flash,
|
|
* glm=opencode-go/glm-5.3-flash, free=openrouter/
|
|
* openrouter/free; OPS95 + OPS100 + OPS112) — sem flag o preset
|
|
* `deepseek/deepseek-flash` permanece.
|
|
* Chamado do terminal interativo (com
|
|
* `TEQO_WORKTREE_TERMINAL=1`, que só a função shell
|
|
* seta), imprime também a diretiva `launch node
|
|
* scripts/agent-session.mjs start --purpose=next
|
|
* --dir=<dir> --model <preset|map> --issue=<N>`
|
|
* ANTES do `cd` — a função shell executa o cd e
|
|
* então a linha. Desde a OPS110 o launch não abre um
|
|
* TUI local dono da sessão: o `agent-session` sobe
|
|
* o `opencode serve` compartilhado, cria a sessão
|
|
* endereçável, dispara o driver destacado com
|
|
* `/work-issue --issue <N>` (auto-submit; a skill lê
|
|
* o resto do GitHub) e anexa o TUI
|
|
* (`opencode attach -s <sessionID>`) — fechar o
|
|
* terminal NÃO encerra o run; encerrar é
|
|
* `pnpm agent:session stop`. OPS95 continua: sem
|
|
* `--variant`; variantes ficam na config global da
|
|
* máquina (Ctrl+T). Presets e mapa em
|
|
* scripts/lib/worktree.mjs; ciclo de vida em
|
|
* scripts/lib/agent-session.mjs.
|
|
* OPS122: `--auto` (opt-out do GATE da skill)
|
|
* transposta para a invocation; flag desconhecida
|
|
* ou `--auto` sem destino falha alto.
|
|
* Sem o marcador (comando `/worktree` do opencode),
|
|
* a diretiva não é impressa — nunca abre TUI aninhado.
|
|
* Também PROVISIONA o ambiente isolado do worktree:
|
|
* porta do dev server + bancos próprios derivados
|
|
* deterministicamente do branch (ver
|
|
* scripts/lib/worktree-env.mjs), para agentes em
|
|
* paralelo não disputarem porta 3000 nem o
|
|
* `teqo_test` compartilhado. Migrations E seed
|
|
* mínimo (`db:seed:minimal`, OPS28) são aplicados
|
|
* aos dois bancos — paridade com a CI, que roda
|
|
* `migrate → seed:minimal` antes do e2e.
|
|
* Também copia do worktree principal os secretos
|
|
* gitignored de `.opencode/secrets/` (ex.
|
|
* `penpot-token` do MCP Penpot) — git não leva
|
|
* ignored files para o worktree e o opencode se
|
|
* recusa a abrir com `{file:…}` pendurado.
|
|
* `--no-migrate` pula migrations E o seed (que
|
|
* depende do catálogo migrado).
|
|
* pnpm worktree plan [bag] [--stay] [--no-migrate] [--auto] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]
|
|
* cria um worktree de PLANEJAMENTO novo para rodar
|
|
* a skill /plan-issue sem ocupar o main — cada
|
|
* invocação cria UM DIFERENTE, para sessões de
|
|
* planejamento paralelas: com `bag`, branch
|
|
* `plans/plan-issue-<bag>` (e `-2`, `-3`, … se o
|
|
* nome já estiver vivo); sem `bag`, o próximo
|
|
* sequencial `plans/plan-issue-<n>` livre. Nenhum
|
|
* deles (branch nem slot) colide com um `next`
|
|
* posterior (prefixo minúsculo `plans/…`).
|
|
* Mesmo provisionamento isolado do `next`; no
|
|
* terminal, mesma diretiva `launch` — `--model`
|
|
* `<map>` quando a flag está presente (OPS110:
|
|
* attach/detach). Com `bag`, o driver destacado
|
|
* auto-submete `/plan-issue <bag>`; sem `bag`, a
|
|
* sessão abre SEM driver (como o `new`) para o
|
|
* humano digitar o `/plan-issue` com contexto
|
|
* (OPS119; supersede o auto-envio incondicional
|
|
* do OPS31).
|
|
* pnpm worktree new [bag] [--stay] [--no-migrate] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]
|
|
* cria um worktree NEUTRO novo — sem função
|
|
* pré-definida (explorar ideia, conversar, ou
|
|
* planejar sem registrar nada): cada invocação
|
|
* cria UM DIFERENTE; com `bag`, branch
|
|
* `work/<bag>` (e `-2`, `-3`, … se o nome já
|
|
* estiver vivo); sem `bag`, o próximo sequencial
|
|
* `work/<n>` livre. Prefixo minúsculo `work/…` —
|
|
* nunca colide com `<code>-<slug>` de `next` nem
|
|
* com `plans/plan-issue-…` de `plan`.
|
|
* Mesmo provisionamento isolado do `next`/`plan`;
|
|
* no terminal, mesma diretiva `launch` — sessão
|
|
* sem skill (apenas conversar) e `--model <map>`
|
|
* quando a flag de modelo está presente (OPS110).
|
|
* `--stay` suprime a linha `cd` e a diretiva;
|
|
* `--cheap/--pro/--zen/--go/--alibaba/--glm/--free` escolhe o
|
|
* modelo por invocação (sem flag o preset permanece).
|
|
* Migrations E seed mínimo nos dois bancos, como
|
|
* o `next`/`plan` (OPS28).
|
|
* pnpm worktree fix [bag] [--stay] [--no-migrate] [--auto] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]
|
|
* cria um worktree de CORREÇÃO DE BUG (skill
|
|
* /bug-fix) DIFERENTE a cada invocação: com bag,
|
|
* branch `fix/<bag>` (e `-2`, `-3`, … se o nome
|
|
* já estiver vivo); sem bag, o próximo sequencial
|
|
* `fix/<n>` livre. Prefixo minúsculo `fix/…` —
|
|
* nunca colide com `<code>-<slug>` de `next`,
|
|
* com `plans/plan-issue-…` de `plan` nem com
|
|
* `work/…` de `new`. Mesmo provisionamento
|
|
* isolado do `next`/`plan`/`new`; no terminal, a
|
|
* diretiva `launch` carrega `--argument="<bag>"`
|
|
* — o driver destacado auto-submete `/bug-fix
|
|
* <bag>` e a descrição do bug chega ao agente
|
|
* junto com a skill (aspas/barra-invertida do bag
|
|
* são removidas — o xargs da camada shell não
|
|
* honra escapes); `--model <map>` quando a flag de
|
|
* modelo está presente (OPS110); `--stay` suprime
|
|
* cd e launch (nenhuma Issue é claimada nem
|
|
* criada — o registro do bug é o post-mortem).
|
|
* pnpm worktree kill [--force] destrói o worktree em que o shell atual está
|
|
* (recusa worktree sujo sem `--force`), ENCERRA a
|
|
* sessão persistente do run (OPS110: abort + fim
|
|
* do driver; o estado/lock são removidos — sem
|
|
* sessão órfã) e remove os bancos gerados do
|
|
* worktree (best-effort); por padrão termina
|
|
* imprimindo `cd <main>` para o shell voltar ao
|
|
* worktree principal — o cwd nunca fica num
|
|
* diretório destruído (não aceita `--stay`; sem
|
|
* launch, as flags de modelo são irrelevantes)
|
|
*
|
|
* Read-only no GitHub? NÃO — desde o OPS33 `next` CLAIMA: claim determinístico
|
|
* antes do worktree (mesma fila/ordem e lock otimista do `pnpm agent:claim`;
|
|
* `--issue N` claim direcionado ou reabre sessão já claimada). `plan`/`new`/
|
|
* `fix`/`kill` não tocam Issues. O claim do pool (supervisor coordenado) é intacto.
|
|
* Dir raiz: `~/.cursor/worktrees/teqo/` (mesma casa dos worktrees do Cursor).
|
|
*/
|
|
|
|
import { execFileSync } from 'node:child_process'
|
|
import { randomBytes } from 'node:crypto'
|
|
import {
|
|
copyFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
statSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from 'node:fs'
|
|
import { homedir } from 'node:os'
|
|
import { dirname, join, resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
import { parse as parseEnv } from 'dotenv'
|
|
import pg from 'pg'
|
|
|
|
import {
|
|
claimBriefLines,
|
|
claimIssue,
|
|
claimQueueEntry,
|
|
claimTargetVerdict,
|
|
dieAgent,
|
|
issuesById,
|
|
nextClaimableIssue,
|
|
parseArgs,
|
|
} from './lib/agent-forgejo.mjs'
|
|
import { sessionDirFromEnv, sessionStatePath, startLockPath } from './lib/agent-session.mjs'
|
|
import { startSharedPostgres } from './lib/db-start.mjs'
|
|
import { githubApi as api } from './lib/github-api.mjs'
|
|
import {
|
|
DEV_PORT_BASE,
|
|
GENERATED_ENV_MARKER,
|
|
isGeneratedDatabaseName,
|
|
s3EnvCopiedLines,
|
|
worktreeEnvFileContents,
|
|
worktreeEnvironment,
|
|
} from './lib/worktree-env.mjs'
|
|
import {
|
|
assertSkillAutoSupported,
|
|
branchNameForIssue,
|
|
fixBranchName,
|
|
headlessDirective,
|
|
namespaceLaunchDescriptor,
|
|
OPENCODE_PRESET_MODEL,
|
|
opencodeLaunchDirective,
|
|
planBranchName,
|
|
resolveWorktreeModel,
|
|
validateWorktreeFlags,
|
|
workBranchName,
|
|
WORKTREE_MODEL_FLAGS,
|
|
WORKTREE_MODEL_MAP,
|
|
WORKTREE_TERMINAL_ENV,
|
|
} from './lib/worktree.mjs'
|
|
|
|
const die = dieAgent('worktree')
|
|
const WORKTREES_ROOT = process.env.WORKTREES_ROOT ?? join(homedir(), '.cursor', 'worktrees', 'teqo')
|
|
/**
|
|
* Root deste checkout — o `stopAgentSession` reusa o CLI daqui e a diretiva de
|
|
* launch carrega o caminho ABSOLUTO dele (OPS110: worktree antigo não tem o
|
|
* arquivo novo no branch).
|
|
*/
|
|
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
|
|
/**
|
|
* True when the interactive terminal shell function (`.agents/shell/worktree.sh`)
|
|
* calls us: it sets `TEQO_WORKTREE_TERMINAL=1` and executes the `launch`
|
|
* directive we print. Without the marker (the `/worktree` opencode command,
|
|
* automation) the launch line is never printed — no nested TUI.
|
|
*/
|
|
const terminalShell = process.env[WORKTREE_TERMINAL_ENV] === '1'
|
|
|
|
/** Resolve the per-invocation model from the parsed flags (at-most-one, fail-high). */
|
|
const resolveLaunchModel = (flags) => {
|
|
try {
|
|
return resolveWorktreeModel(flags)
|
|
} catch (error) {
|
|
die(error.message)
|
|
}
|
|
}
|
|
|
|
/** Print the `launch` directive (only exists from the terminal shell); the `cd` line stays last. */
|
|
const printLaunchDirective = ({ dir, purpose, issueNumber, argument = null, flags = {} }) => {
|
|
const model = resolveLaunchModel(flags)
|
|
const line = opencodeLaunchDirective({
|
|
dir,
|
|
purpose,
|
|
terminal: terminalShell,
|
|
issueNumber,
|
|
model,
|
|
argument,
|
|
// OPS122: o `--auto` do humano viaja como flag interna (`--skill-auto`).
|
|
skillAuto: Boolean(flags.auto),
|
|
// Absoluto de propósito (OPS110): worktrees reabertos/criados antes do
|
|
// merge não têm `scripts/agent-session.mjs`; o relativo quebraria o launch.
|
|
sessionScript: join(REPO_ROOT, 'scripts', 'agent-session.mjs'),
|
|
})
|
|
if (line) console.log(line)
|
|
}
|
|
|
|
/**
|
|
* OPS122: falha alto antes de qualquer git quando `--auto` não tem para onde
|
|
* ir (purpose sem driver/skill ou `--stay`, que suprime o launch). Um worktree
|
|
* provisionado com a flag descartada seria órfão. O `die` do dispatch externo
|
|
* formata o erro.
|
|
*/
|
|
const assertAutoFlag = ({ purpose, bag = null, flags }) => {
|
|
if (!flags.auto) return
|
|
assertSkillAutoSupported({
|
|
purpose,
|
|
bag,
|
|
stay: Boolean(flags.stay),
|
|
headless: Boolean(flags.headless),
|
|
terminal: terminalShell,
|
|
})
|
|
}
|
|
|
|
/** Maintenance database on the shared local container — only ever localhost. */
|
|
const MAINTENANCE_DATABASE_URL = 'postgresql://teqo:teqo@localhost:5432/postgres'
|
|
|
|
/** `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
|
|
}
|
|
|
|
/**
|
|
* Slots already claimed by OTHER live worktrees, read from their generated
|
|
* `.env.local` (only files carrying the marker count — manual envs claim
|
|
* nothing and are never overwritten).
|
|
*/
|
|
const readLiveSlots = (entries, excludedDir) => {
|
|
const slots = new Set()
|
|
for (const entry of entries) {
|
|
if (resolve(entry.path) === resolve(excludedDir)) continue
|
|
try {
|
|
const text = readFileSync(join(entry.path, '.env.local'), 'utf8')
|
|
if (!text.includes(GENERATED_ENV_MARKER)) continue
|
|
const match = /^PORT=([0-9]+)/m.exec(text)
|
|
const slot = match ? Number(match[1]) - DEV_PORT_BASE : null
|
|
if (slot !== null && Number.isInteger(slot) && slot >= 0) slots.add(slot)
|
|
} catch {
|
|
// worktree dir without a generated env file — nothing claimed
|
|
}
|
|
}
|
|
return slots
|
|
}
|
|
|
|
/** Merge the main repo's `.env` then `.env.local` (local wins) — secret source. */
|
|
const readMainEnv = (mainRoot) => {
|
|
const merged = {}
|
|
for (const name of ['.env', '.env.local']) {
|
|
try {
|
|
Object.assign(merged, parseEnv(readFileSync(join(mainRoot, name), 'utf8')))
|
|
} catch {
|
|
// file missing — fine
|
|
}
|
|
}
|
|
return merged
|
|
}
|
|
|
|
const envFileIsGenerated = (dir, name) => {
|
|
try {
|
|
return readFileSync(join(dir, name), 'utf8').includes(GENERATED_ENV_MARKER)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Copies the main repo's gitignored `.opencode/secrets/*` into the worktree —
|
|
* e.g. `penpot-token`, referenced by the root `opencode.json` MCP config via
|
|
* `{file:…}`. Git worktrees don't carry ignored files, and opencode refuses to
|
|
* start with a dangling `{file:…}` reference ("bad file reference"), so the
|
|
* TUI launch directive would fail inside a fresh worktree. Never overwrites an
|
|
* existing worktree file (a manual copy wins).
|
|
*/
|
|
const copyOpendevSecrets = (mainRoot, dir) => {
|
|
const sourceDir = join(mainRoot, '.opencode', 'secrets')
|
|
if (!existsSync(sourceDir)) return
|
|
const targetDir = join(dir, '.opencode', 'secrets')
|
|
mkdirSync(targetDir, { recursive: true })
|
|
for (const name of readdirSync(sourceDir)) {
|
|
const source = join(sourceDir, name)
|
|
const target = join(targetDir, name)
|
|
if (!statSync(source).isFile() || existsSync(target)) continue
|
|
copyFileSync(source, target)
|
|
console.log(`[worktree] .opencode/secrets/${name} copiado do worktree principal.`)
|
|
}
|
|
}
|
|
|
|
/** `CREATE DATABASE` for each missing generated name on the shared container. */
|
|
const ensureDatabases = async (names) => {
|
|
const client = new pg.Client({
|
|
connectionString: MAINTENANCE_DATABASE_URL,
|
|
connectionTimeoutMillis: 5000,
|
|
})
|
|
await client.connect()
|
|
try {
|
|
for (const name of names) {
|
|
if (!isGeneratedDatabaseName(name)) die(`Nome de banco gerado inválido: ${name}`)
|
|
const { rows } = await client.query('SELECT 1 AS ok FROM pg_database WHERE datname = $1', [
|
|
name,
|
|
])
|
|
if (rows.length > 0) {
|
|
console.log(`[worktree] banco ${name} já existe.`)
|
|
continue
|
|
}
|
|
await client.query(`CREATE DATABASE ${name}`)
|
|
console.log(`[worktree] banco criado: ${name}`)
|
|
}
|
|
} finally {
|
|
await client.end().catch(() => {})
|
|
}
|
|
}
|
|
|
|
const ensureWorktreeDeps = (dir) => {
|
|
if (existsSync(join(dir, 'node_modules', '.bin', 'payload'))) return
|
|
console.log('[worktree] ▶ node_modules ausente — pnpm install --frozen-lockfile …')
|
|
execFileSync('pnpm', ['install', '--frozen-lockfile'], { cwd: dir, stdio: 'inherit' })
|
|
}
|
|
|
|
const runMigrate = (dir, databaseUrl, payloadSecret) => {
|
|
console.log(`[worktree] ▶ migrations em ${databaseUrl} (uma vez por worktree)…`)
|
|
execFileSync('pnpm', ['migrate'], {
|
|
cwd: dir,
|
|
env: { ...process.env, DATABASE_URL: databaseUrl, PAYLOAD_SECRET: payloadSecret },
|
|
stdio: 'inherit',
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Runs the minimal synthetic seed on a provisioned database — the same
|
|
* `pnpm db:seed:minimal` CI runs after `pnpm migrate`, so a worktree's dev and
|
|
* test databases carry the same content CI's e2e asserts on (OPS28: worktrees
|
|
* were migrate-only and the e2e diverged). Idempotent (upsert by stable key)
|
|
* and guarded by the seed's own `assertLocalDatabase` — it never touches a
|
|
* non-local database. Skipped alongside migrations by `--no-migrate`, since the
|
|
* seed requires the migrated municipality catalog.
|
|
*/
|
|
const runSeedMinimal = (dir, databaseUrl, payloadSecret) => {
|
|
console.log(`[worktree] ▶ seed mínimo em ${databaseUrl} (paridade com a CI)…`)
|
|
execFileSync('pnpm', ['db:seed:minimal'], {
|
|
cwd: dir,
|
|
env: { ...process.env, DATABASE_URL: databaseUrl, PAYLOAD_SECRET: payloadSecret },
|
|
stdio: 'inherit',
|
|
})
|
|
}
|
|
|
|
/** Cursor Cloud / no-Docker fallback: shared teqo + teqo_test, no port override. */
|
|
const writeFallbackEnv = (dir, branch) => {
|
|
const devEnv = [
|
|
GENERATED_ENV_MARKER,
|
|
'# fallback sem Docker (Cursor Cloud?): banco compartilhado, sem porta própria.',
|
|
`# branch ${branch}`,
|
|
'DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo',
|
|
'',
|
|
].join('\n')
|
|
const testEnv = [
|
|
GENERATED_ENV_MARKER,
|
|
'# fallback sem Docker (Cursor Cloud?): banco de teste compartilhado.',
|
|
`# branch ${branch}`,
|
|
'DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test',
|
|
'',
|
|
].join('\n')
|
|
writeFileSync(join(dir, '.env.local'), devEnv)
|
|
writeFileSync(join(dir, '.env.test.local'), testEnv)
|
|
}
|
|
/**
|
|
* Deterministic per-worktree environment: shared container + own databases +
|
|
* own dev-server port + env files + migrations + minimal seed. Skips everything
|
|
* when a manual `.env.local`/`.env.test.local` (no marker) exists — never
|
|
* clobbers. `purpose` labels the env comment (`next` | `plan` | `new`); `issue`
|
|
* is optional and fills the `issue #N` part of the comment only when present.
|
|
*/
|
|
const provision = async ({ dir, branch, issue, env, skipMigrate, mainRoot, purpose = 'next' }) => {
|
|
// Secreto gitignored que o opencode.json do worktree referencia (`{file:…}`)
|
|
// — sem ele o opencode se recusa a abrir; a cópia vale para todos os caminhos.
|
|
copyOpendevSecrets(mainRoot, dir)
|
|
|
|
const manual =
|
|
(existsSync(join(dir, '.env.local')) && !envFileIsGenerated(dir, '.env.local')) ||
|
|
(existsSync(join(dir, '.env.test.local')) && !envFileIsGenerated(dir, '.env.test.local'))
|
|
if (manual) {
|
|
console.warn(
|
|
'[worktree] .env.local/.env.test.local manuais presentes — sem provisionar nem sobrescrever.',
|
|
)
|
|
console.warn(
|
|
'[worktree] Remova os arquivos manuais e rode `pnpm worktree next` de novo para o ambiente isolado.',
|
|
)
|
|
return
|
|
}
|
|
|
|
const mainEnv = readMainEnv(mainRoot)
|
|
const payloadSecret = mainEnv.PAYLOAD_SECRET ?? randomBytes(24).toString('hex')
|
|
|
|
try {
|
|
// Start (or reuse) the ONE shared Postgres container from any worktree dir.
|
|
await startSharedPostgres({ cwd: dir })
|
|
} catch (error) {
|
|
console.warn('[worktree] Docker indisponível — usando o banco compartilhado (teqo/teqo_test).')
|
|
console.warn(`[worktree] (${error.message.split('\n')[0]})`)
|
|
writeFallbackEnv(dir, branch)
|
|
// OPS28 — parity with CI even on the degraded shared databases: migrate +
|
|
// seed the shared teqo/teqo_test so a Cloud worktree's e2e runs against the
|
|
// same content CI asserts on. Both are idempotent and local-only, though
|
|
// two CONCURRENT fallback provisionings can race the migrate/upserts — the
|
|
// loser dies loudly and a re-run converges (degraded mode; not lock-guarded).
|
|
if (!skipMigrate) {
|
|
console.warn(
|
|
'[worktree] atenção: o seed re-pina campaignGoals e 5 municípios no teqo/teqo_test ' +
|
|
'compartilhados (valores ajustados manualmente no banco compartilhado serão sobrescritos).',
|
|
)
|
|
ensureWorktreeDeps(dir)
|
|
runMigrate(dir, 'postgresql://teqo:teqo@localhost:5432/teqo', payloadSecret)
|
|
runSeedMinimal(dir, 'postgresql://teqo:teqo@localhost:5432/teqo', payloadSecret)
|
|
runMigrate(dir, 'postgresql://teqo:teqo@localhost:5432/teqo_test', payloadSecret)
|
|
runSeedMinimal(dir, 'postgresql://teqo:teqo@localhost:5432/teqo_test', payloadSecret)
|
|
} else {
|
|
console.log('[worktree] --no-migrate: migrations e seed mínimo ficam para depois.')
|
|
}
|
|
return
|
|
}
|
|
|
|
const devDatabase = env.devDatabase
|
|
const testDatabase = env.testDatabase
|
|
await ensureDatabases([devDatabase, testDatabase])
|
|
|
|
const devUrl = `postgresql://teqo:teqo@localhost:5432/${devDatabase}`
|
|
const testUrl = `postgresql://teqo:teqo@localhost:5432/${testDatabase}`
|
|
|
|
const copy = (key) => (mainEnv[key] ? [`${key}=${mainEnv[key]}`] : [])
|
|
|
|
const issueLabel = issue ? ` · issue #${issue.number}` : ''
|
|
const generatedBy = `gerado por pnpm worktree ${purpose}`
|
|
|
|
const { dev: devLines, test: testLines } = worktreeEnvFileContents({
|
|
branch,
|
|
issueLabel,
|
|
generatedBy,
|
|
env,
|
|
payloadSecret,
|
|
copiedLines: [
|
|
// Media storage (OPS52): all-or-nothing (see s3EnvCopiedLines).
|
|
...s3EnvCopiedLines(mainEnv),
|
|
...copy('NEXT_PUBLIC_VAPID_PUBLIC_KEY'),
|
|
...copy('VAPID_PUBLIC_KEY'),
|
|
...copy('VAPID_PRIVATE_KEY'),
|
|
...copy('VAPID_SUBJECT'),
|
|
...copy('RESEND_API_KEY'),
|
|
...copy('CAMPAIGN_EMAIL_FROM'),
|
|
...copy('CAMPAIGN_EMAIL_FROM_NAME'),
|
|
],
|
|
})
|
|
writeFileSync(join(dir, '.env.local'), devLines.join('\n'))
|
|
console.log(`[worktree] .env.local escrito (porta ${env.devPort}).`)
|
|
writeFileSync(join(dir, '.env.test.local'), testLines.join('\n'))
|
|
console.log('[worktree] .env.test.local escrito.')
|
|
|
|
if (skipMigrate) {
|
|
console.log('[worktree] --no-migrate: migrations e seed mínimo ficam para depois.')
|
|
return
|
|
}
|
|
ensureWorktreeDeps(dir)
|
|
runMigrate(dir, devUrl, payloadSecret)
|
|
runSeedMinimal(dir, devUrl, payloadSecret)
|
|
runMigrate(dir, testUrl, payloadSecret)
|
|
runSeedMinimal(dir, testUrl, payloadSecret)
|
|
}
|
|
|
|
/**
|
|
* Pick determinístico do `next` — READ-ONLY no GitHub: `--issue <N>` escolhe a
|
|
* Issue direcionada (via `claimTargetVerdict`: `ready` → claim, `in-progress` →
|
|
* reopen sem re-claim), sem a flag escolhe a próxima da fila (mesmo pick/ordem
|
|
* do `pnpm agent:claim`). O claim em si acontece só em `cmdNext`, DEPOIS da
|
|
* derivação/validação do branch — uma Issue sem frontmatter id (ou branch
|
|
* inválido) morre antes do flip de labels, nunca deixando claim órfão.
|
|
*/
|
|
const pickNextIssue = async ({ requestedIssueNumber, die }) => {
|
|
if (requestedIssueNumber !== null) {
|
|
const raw = String(requestedIssueNumber)
|
|
const number = Number(raw)
|
|
if (!Number.isInteger(number) || number <= 0) {
|
|
die(`--issue inválido: ${raw}`)
|
|
}
|
|
const target = await api.getIssue(number)
|
|
const verdict = claimTargetVerdict(target)
|
|
if (verdict.kind === 'error') die(verdict.message)
|
|
const entry = claimQueueEntry(target, await issuesById())
|
|
if (verdict.kind === 'reopen') {
|
|
// Sessão já claimada — reabrir não re-claima (reopen é sobre a sessão,
|
|
// nunca sobre a fila: deps atuais não importam).
|
|
return { entry, reopened: true, directed: true }
|
|
}
|
|
if (entry.blockedBy.length > 0) {
|
|
die(`Issue #${number} não é claimável (bloqueada por ${entry.blockedBy.join(', ')}).`)
|
|
}
|
|
return { entry, reopened: false, directed: true }
|
|
}
|
|
|
|
const { entry: pick, skippedWithoutId } = await nextClaimableIssue()
|
|
if (!pick) {
|
|
const skipped =
|
|
skippedWithoutId.length > 0
|
|
? ` Issues \`ready\` sem frontmatter id fora da fila (corrija antes de re-rodar): ${skippedWithoutId.map(({ issue }) => `#${issue.number}`).join(', ')}.`
|
|
: ''
|
|
die(
|
|
`Fila vazia — nada \`ready\` desbloqueado. Rode \`pnpm agent:status\` para ver a fila.${skipped}`,
|
|
)
|
|
}
|
|
return { entry: pick, reopened: false, directed: false }
|
|
}
|
|
|
|
const cmdNext = async (stay, skipMigrate, requestedIssueNumber, flags = {}) => {
|
|
// Fail-high on conflicting model flags before touching GitHub (no orphan claim).
|
|
resolveLaunchModel(flags)
|
|
const { entry, reopened, directed } = await pickNextIssue({ requestedIssueNumber, die })
|
|
|
|
const issue = entry.issue
|
|
const branch = branchNameForIssue({ ...issue, meta: entry.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)
|
|
|
|
// O claim flips labels — só depois da derivação do branch provar que a Issue
|
|
// tem tudo para virar worktree.
|
|
if (!reopened) await claimIssue(entry, die)
|
|
const claimedIssueNumber = reopened ? null : issue.number
|
|
|
|
const headline = reopened
|
|
? 'Sessão já claimada — reabrindo (sem re-claim)'
|
|
: directed
|
|
? 'Claimado (direcionado — `--issue`)'
|
|
: 'Claimado da fila'
|
|
console.log(`\n${headline}: #${issue.number} ${issue.title}`)
|
|
for (const line of claimBriefLines(entry)) console.log(line)
|
|
|
|
try {
|
|
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
|
|
if (branchExists) {
|
|
// Reopen com worktree removido à mão: `-B` resetaria commits de sessão.
|
|
const ahead = git(['log', '--oneline', `origin/main..${branch}`], { okIfFails: true })
|
|
if (ahead) {
|
|
die(
|
|
`Branch ${branch} tem commits fora de origin/main e o worktree não existe — reabrir com -B descartaria esses commits. Rode \`git branch -D ${branch}\` (sessão encerrada) ou resolva antes.`,
|
|
)
|
|
}
|
|
}
|
|
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(
|
|
reopened
|
|
? 'Issue já claimada (sessão reaberta) — NÃO rodar `pnpm agent:claim` (o claim é parte do `worktree next`).'
|
|
: 'Issue claimada por este comando — NÃO rodar `pnpm agent:claim` (o claim é parte do `worktree next`).',
|
|
)
|
|
|
|
const mainRoot = entries[0]?.path
|
|
const env = worktreeEnvironment({
|
|
branch,
|
|
code: entry.meta.id,
|
|
takenSlots: readLiveSlots(entries, dir),
|
|
})
|
|
await provision({ dir, branch, issue, env, skipMigrate, mainRoot })
|
|
|
|
console.log(`\nAmbiente isolado do worktree (slot ${env.slot}):`)
|
|
console.log(` dev server: http://localhost:${env.devPort} (pnpm dev)`)
|
|
console.log(` banco dev: postgresql://teqo:teqo@localhost:5432/${env.devDatabase}`)
|
|
console.log(` banco test: postgresql://teqo:teqo@localhost:5432/${env.testDatabase}`)
|
|
} catch (error) {
|
|
if (claimedIssueNumber !== null) {
|
|
console.error(
|
|
`\n[worktree] Issue #${claimedIssueNumber} ficou claimada (o worktree não foi concluído) — ` +
|
|
`reabra quando quiser: \`pnpm worktree next --issue ${claimedIssueNumber}\`.\n`,
|
|
)
|
|
}
|
|
throw error
|
|
}
|
|
|
|
if (!stay) {
|
|
printLaunchDirective({ dir, purpose: 'next', issueNumber: issue.number, flags })
|
|
console.log(`cd ${dir}`)
|
|
}
|
|
}
|
|
|
|
/** Branch short-names already alive — local refs plus origin (shortened). */
|
|
const buildTakenBranchNames = () => {
|
|
const taken = new Set()
|
|
for (const scope of [
|
|
(
|
|
git(['for-each-ref', '--format=%(refname:short)', 'refs/heads'], {
|
|
okIfFails: true,
|
|
}) ?? ''
|
|
).split('\n'),
|
|
(
|
|
git(['for-each-ref', '--format=%(refname:short)', 'refs/remotes/origin'], {
|
|
okIfFails: true,
|
|
}) ?? ''
|
|
)
|
|
.split('\n')
|
|
.map((name) => name.replace(/^origin\//, '')),
|
|
]) {
|
|
for (const name of scope) if (name) taken.add(name)
|
|
}
|
|
return taken
|
|
}
|
|
|
|
/**
|
|
* Shared runner for namespace worktrees NOT tied to the claim queue (`plan`,
|
|
* `new` and `fix`): fetches origin, picks a FRESH branch in the namespace (via
|
|
* `branchName`), provisions the same isolated env as `next` (with `purpose`)
|
|
* and prints the `cd <dir>` line by default (`--stay` suppresses). Every
|
|
* invocation creates a DIFFERENT worktree — parallel sessions never share one.
|
|
* The launch labels and the `argument` that rides the launch prompt are derived
|
|
* from `purpose`+`bag` by `namespaceLaunchDescriptor` (single source of truth —
|
|
* there is no droppable `argument` parameter). For `fix` the bag is the bug
|
|
* description the `/bug-fix` skill receives; for `plan`, the opening message
|
|
* the `/plan-issue` driver auto-submits (absent → driverless session); `new`
|
|
* carries none.
|
|
*/
|
|
const cmdNamespaceBranch = async ({
|
|
stay,
|
|
skipMigrate,
|
|
purpose,
|
|
bag = null,
|
|
branchName,
|
|
flags = {},
|
|
headless = false,
|
|
directivePath = null,
|
|
}) => {
|
|
if (headless && !directivePath) {
|
|
die('`--headless` requer `--directive <path>` (a diretiva vai para arquivo, nunca stdout).')
|
|
}
|
|
const { noun, sessionLabel, argument } = namespaceLaunchDescriptor({ purpose, bag })
|
|
// Fail-high on conflicting model flags before touching git.
|
|
const launchModel = resolveLaunchModel(flags)
|
|
git(['fetch', 'origin'])
|
|
|
|
const entries = parseWorktreeList(git(['worktree', 'list', '--porcelain']))
|
|
const taken = buildTakenBranchNames()
|
|
|
|
const branch = branchName(taken)
|
|
if (git(['check-ref-format', '--allow-onelevel', branch], { okIfFails: true }) === null) {
|
|
die(`Branch ${purpose} inválido para refname: ${branch}`)
|
|
}
|
|
const dir = join(WORKTREES_ROOT, branch)
|
|
|
|
if (entries.some((entry) => resolve(entry.path) === resolve(dir))) {
|
|
die(
|
|
`Já existe um worktree ${noun} em ${dir} (branch ${branch} não detectado nos refs). Rode \`pnpm worktree kill\` de dentro dele.`,
|
|
)
|
|
}
|
|
|
|
git(['worktree', 'add', '-b', branch, dir, 'origin/main'])
|
|
|
|
console.log(`Worktree ${noun} criado:`)
|
|
console.log(` sessão: ${sessionLabel}`)
|
|
console.log(` branch: ${branch}`)
|
|
console.log(` path: ${dir}`)
|
|
console.log(' origem: origin/main')
|
|
|
|
const mainRoot = entries[0]?.path
|
|
const env = worktreeEnvironment({
|
|
branch,
|
|
code: null,
|
|
takenSlots: readLiveSlots(entries, dir),
|
|
})
|
|
await provision({ dir, branch, env, skipMigrate, mainRoot, purpose })
|
|
|
|
console.log(`\nAmbiente isolado do worktree ${noun} (slot ${env.slot}):`)
|
|
console.log(` dev server: http://localhost:${env.devPort} (pnpm dev)`)
|
|
console.log(` banco dev: postgresql://teqo:teqo@localhost:5432/${env.devDatabase}`)
|
|
console.log(` banco test: postgresql://teqo:teqo@localhost:5432/${env.testDatabase}`)
|
|
|
|
if (headless) {
|
|
// OPS106: the auto-unblock wrapper consumes this file; stdout stays human
|
|
// logs (git/pnpm/seed children inherit stdio and would corrupt a JSON line).
|
|
const directive = headlessDirective({ dir, branch, model: launchModel, report: argument ?? '' })
|
|
writeFileSync(directivePath, `${JSON.stringify(directive)}\n`)
|
|
console.log(`[worktree] diretiva headless escrita em ${directivePath}`)
|
|
return
|
|
}
|
|
|
|
if (!stay) {
|
|
printLaunchDirective({ dir, purpose, argument, flags })
|
|
console.log(`cd ${dir}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `plan` — a `/plan-issue` planning worktree. Deliberately NOT tied to the
|
|
* claim queue and NOT named after any Issue: the branch is derived either from
|
|
* an optional `bag` slug (`plans/plan-issue-<bag>`, suffixed `-2`, `-3`, … on
|
|
* collision) or from the next free sequential `plans/plan-issue-<n>`, so a
|
|
* later `pnpm worktree next` for the next claimable Issue never collides with
|
|
* it (branch name nor slot — each plan env registers its hashed slot via the
|
|
* marker and `next` bumps around it). Same isolated env provisioning as
|
|
* `next`.
|
|
*/
|
|
const cmdPlan = async (stay, skipMigrate, bag, flags = {}) =>
|
|
cmdNamespaceBranch({
|
|
stay,
|
|
skipMigrate,
|
|
purpose: 'plan',
|
|
bag,
|
|
branchName: (taken) => planBranchName({ bag, taken }),
|
|
flags,
|
|
})
|
|
|
|
/**
|
|
* `new` — a NEUTRAL worktree with no pre-defined function (explore an idea,
|
|
* chat, or plan without registering anything). Deliberately NOT tied to the
|
|
* claim queue nor to `/plan-issue`: with `bag`, branch `work/<bag>` (suffixed
|
|
* `-2`, `-3`, … on collision); without, the next free sequential `work/<n>`.
|
|
* The lowercase `work/…` prefix can never collide with a `next` branch
|
|
* (uppercase-led `<code>-<slug>`) nor with a `plan` branch (`plans/…`), in
|
|
* branch name or slot space. Same isolated env provisioning as `plan`.
|
|
*/
|
|
const cmdNew = async (stay, skipMigrate, bag, flags = {}) =>
|
|
cmdNamespaceBranch({
|
|
stay,
|
|
skipMigrate,
|
|
purpose: 'new',
|
|
bag,
|
|
branchName: (taken) => workBranchName({ bag, taken }),
|
|
flags,
|
|
})
|
|
|
|
/**
|
|
* `fix` — a bug-fix worktree for the `/bug-fix` skill. Same detached-namespace
|
|
* shape as `new`: with `bag` (the bug description), branch `fix/<bag>`
|
|
* (suffixed `-2`, `-3`, … on collision); without, the next free sequential
|
|
* `fix/<n>`. The lowercase `fix/…` prefix can never collide with a `next`
|
|
* branch (uppercase-led `<code>-<slug>`), nor with `plans/…` or `work/…`. The
|
|
* launch prompt carries the bag as the skill's argument — the bug description
|
|
* arrives with `/bug-fix` (sanitized in the directive builder). Never claims
|
|
* nor creates Issues: the bug record is the skill's post-mortem.
|
|
*
|
|
* OPS106: `--headless --directive <path>` is the auto-unblock variant — it
|
|
* provisions exactly the same worktree and writes the machine-readable launch
|
|
* directive (opencode `run --command bug-fix`) to the file instead of printing
|
|
* the TUI launch/cd. The bug report arrives as the `bag`.
|
|
*/
|
|
const cmdFix = async (stay, skipMigrate, bag, flags = {}, headless = false, directivePath = null) =>
|
|
cmdNamespaceBranch({
|
|
stay,
|
|
skipMigrate,
|
|
purpose: 'fix',
|
|
bag,
|
|
branchName: (taken) => fixBranchName({ bag, taken }),
|
|
flags,
|
|
headless,
|
|
directivePath,
|
|
})
|
|
|
|
/** Generated database names referenced by a worktree's own env files. */
|
|
const worktreeDatabaseNamesOf = (dir) => {
|
|
const names = []
|
|
for (const file of ['.env.local', '.env.test.local']) {
|
|
try {
|
|
const text = readFileSync(join(dir, file), 'utf8')
|
|
if (!text.includes(GENERATED_ENV_MARKER)) continue
|
|
for (const line of text.split('\n')) {
|
|
const match = /^DATABASE_URL=postgresql:\/\/[^/]+\/([a-z0-9_]+)$/.exec(line.trim())
|
|
if (match && isGeneratedDatabaseName(match[1])) names.push(match[1])
|
|
}
|
|
} catch {
|
|
// no env file — nothing to clean
|
|
}
|
|
}
|
|
return [...new Set(names)]
|
|
}
|
|
|
|
/** Best-effort DROP DATABASE (WITH FORCE) of the destroyed worktree's DBs. */
|
|
const dropWorktreeDatabases = async (names) => {
|
|
if (names.length === 0) return
|
|
try {
|
|
const client = new pg.Client({
|
|
connectionString: MAINTENANCE_DATABASE_URL,
|
|
connectionTimeoutMillis: 5000,
|
|
})
|
|
await client.connect()
|
|
try {
|
|
for (const name of names) {
|
|
await client.query(`DROP DATABASE IF EXISTS ${name} WITH (FORCE)`)
|
|
console.log(`[worktree] banco removido: ${name}`)
|
|
}
|
|
} finally {
|
|
await client.end().catch(() => {})
|
|
}
|
|
} catch (error) {
|
|
console.warn(`[worktree] não consegui remover os bancos ${names.join(', ')}: ${error.message}`)
|
|
console.warn('[worktree] remova manualmente: docker exec teqo-postgres-1 dropdb -U teqo <nome>')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* OPS110 — encerra a sessão persistente do run deste worktree antes de
|
|
* destruí-lo (`stop`: abort via API + fim do driver). Só age quando existe
|
|
* estado registrado (worktrees anteriores à OPS110 não têm sessão); o estado e
|
|
* o lock são removidos ao fim para não deixar sessão órfã apontando para um
|
|
* diretório destruído.
|
|
*/
|
|
const stopAgentSession = (branch) => {
|
|
if (!branch) return
|
|
const sessionDir = sessionDirFromEnv()
|
|
const statePath = sessionStatePath({ sessionDir, branch })
|
|
const lockPath = startLockPath({ sessionDir, branch })
|
|
if (existsSync(statePath)) {
|
|
let stopped = true
|
|
try {
|
|
execFileSync(
|
|
process.execPath,
|
|
[join(REPO_ROOT, 'scripts', 'agent-session.mjs'), 'stop', `--branch=${branch}`],
|
|
{ stdio: 'inherit' },
|
|
)
|
|
} catch (error) {
|
|
stopped = false
|
|
console.warn(
|
|
`[worktree] não consegui encerrar a sessão do run (${String(error.message).split('\n')[0]}); o kill segue.`,
|
|
)
|
|
}
|
|
// Só descarta o endereço se o stop funcionou — um driver sobrevivente sem
|
|
// estado viraria órfão invisível (e o estado fica para o `agent:session`).
|
|
if (stopped) {
|
|
try {
|
|
unlinkSync(statePath)
|
|
} catch {
|
|
// já removido
|
|
}
|
|
} else {
|
|
console.warn(`[worktree] a sessão pode seguir viva — estado preservado em ${statePath}`)
|
|
}
|
|
}
|
|
// O lock é artefato transitório do `start` — não sobrevive ao worktree.
|
|
try {
|
|
unlinkSync(lockPath)
|
|
} catch {
|
|
// não havia lock
|
|
}
|
|
}
|
|
|
|
const cmdKill = async (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}`,
|
|
)
|
|
}
|
|
|
|
// Read the generated DB names BEFORE the directory is removed.
|
|
const databaseNames = worktreeDatabaseNamesOf(top)
|
|
|
|
// OPS110: encerra a sessão persistente do run antes de destruir o worktree.
|
|
stopAgentSession(branch)
|
|
|
|
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)'}`)
|
|
|
|
await dropWorktreeDatabases(databaseNames)
|
|
|
|
console.log(`cd ${mainRoot}`)
|
|
}
|
|
|
|
const { flags, positional } = parseArgs(process.argv.slice(2), new Set(['issue', 'directive']))
|
|
const subcommand = positional[0]
|
|
|
|
if (!subcommand) {
|
|
console.log(
|
|
'Uso: pnpm worktree next [--issue N] [--stay] [--no-migrate] [--auto] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free] | plan [bag] [--stay] [--no-migrate] [--auto] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free] | new [bag] [--stay] [--no-migrate] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free] | fix [bag] [--stay] [--no-migrate] [--auto] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free] | kill [--force]',
|
|
)
|
|
console.log(
|
|
' next [--issue N] [--stay] [--no-migrate] [--auto] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]',
|
|
)
|
|
console.log(' CLAIMA a próxima Issue claimável (mesma fila/ordem e lock otimista do')
|
|
console.log(' `pnpm agent:claim`) e cria o worktree dela (branch <code>-<slug>),')
|
|
console.log(' provisionando o ambiente isolado: porta de dev + bancos próprios;')
|
|
console.log(' claim falhou → motivo e saída sem worktree. `--issue N` claima a Issue')
|
|
console.log(' direcionada (`ready`) ou REABRE uma já claimada (`in-progress`, sem')
|
|
console.log(' re-claim). Por padrão imprime `cd <dir>` no fim (quem aplica o cd:')
|
|
console.log(' opencode command, ou a função `worktree()` de .agents/shell/worktree.sh);')
|
|
console.log(' no terminal (TEQO_WORKTREE_TERMINAL=1) imprime também a diretiva')
|
|
console.log(
|
|
' `launch node scripts/agent-session.mjs start --purpose=next --dir=<dir> --model <preset|map> --issue=<N>` (OPS26+OPS33+OPS93+OPS95+OPS110:',
|
|
)
|
|
console.log(
|
|
` abre a sessão com ${OPENCODE_PRESET_MODEL} (sem flag) ou com o mapa ${[
|
|
...WORKTREE_MODEL_FLAGS,
|
|
]
|
|
.map((flag) => `--${flag}=${WORKTREE_MODEL_MAP[flag]}`)
|
|
.join(' ')} + a Issue claimada auto-submetida pelo driver destacado;`,
|
|
)
|
|
console.log(
|
|
' fechar o terminal NÃO encerra o run — `pnpm agent:session attach|stop`); --stay suprime cd e launch (o claim ainda acontece); --no-migrate pula migrations e o',
|
|
)
|
|
console.log(
|
|
' seed mínimo (db:seed:minimal) nos bancos novos (OPS28: paridade com a CI); at-most-one de --cheap/--pro/--zen/--go/--alibaba/--glm/--free (múltiplas → erro)',
|
|
)
|
|
console.log(
|
|
`\n plan [bag] [--stay] [--no-migrate] [--auto] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`,
|
|
)
|
|
console.log(
|
|
' cria um worktree de planejamento DIFERENTE a cada invocação (sessões /plan-issue',
|
|
)
|
|
console.log(' paralelas): com bag, branch plans/plan-issue-<bag> (sufixo -2/-3 se o nome já')
|
|
console.log(' existir); sem bag, o próximo plans/plan-issue-<n> sequencial livre; o prefixo')
|
|
console.log(
|
|
' minúsculo plans/… nunca colide com o branch <code>-<slug> de `next`; no terminal,',
|
|
)
|
|
console.log(
|
|
' mesma diretiva `launch` — --model <map> quando a flag está presente; com bag o driver auto-submete /plan-issue <bag>, sem bag a sessão abre SEM driver (você digita o /plan-issue)',
|
|
)
|
|
console.log(
|
|
`\n new [bag] [--stay] [--no-migrate] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`,
|
|
)
|
|
console.log(' cria um worktree NEUTRO (sem função pré-definida) DIFERENTE a cada invocação:')
|
|
console.log(' com bag, branch work/<bag> (sufixo -2/-3 se o nome já existir); sem bag, o')
|
|
console.log(' próximo work/<n> sequencial livre; o prefixo minúsculo work/… nunca colide com')
|
|
console.log(' o branch <code>-<slug> de `next` nem com plans/plan-issue-… de `plan`; no')
|
|
console.log(
|
|
' terminal, mesma diretiva `launch` — sessão sem skill (apenas conversar) e --model <map> quando a flag está presente',
|
|
)
|
|
console.log(
|
|
`\n fix [bag] [--stay] [--no-migrate] [--headless --directive <path>] [--auto] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`,
|
|
)
|
|
console.log(' cria um worktree de CORREÇÃO DE BUG (skill /bug-fix) DIFERENTE a cada')
|
|
console.log(' invocação: com bag (a descrição do bug), branch fix/<bag> (sufixo -2/-3 se o')
|
|
console.log(' nome já existir); sem bag, o próximo fix/<n> sequencial livre; o prefixo')
|
|
console.log(' minúsculo fix/… nunca colide com `next`, `plan` nem `new`; não claima nem cria')
|
|
console.log(
|
|
' Issues — o registro do bug é o post-mortem da skill; no terminal, a diretiva `launch` carrega --argument="<bag>" (o driver destacado auto-submete /bug-fix <bag>, a descrição chega com a skill) e --model <map> quando a flag está presente',
|
|
)
|
|
console.log(
|
|
' OPS106: --headless --directive <path> provisiona o mesmo worktree e grava a diretiva',
|
|
)
|
|
console.log(
|
|
' machine-readable de launch (opencode run --command bug-fix) no arquivo, sem TUI/cd — é o',
|
|
)
|
|
console.log(' caminho do wrapper auto-unblock no homeserver.')
|
|
console.log(' kill [--force] destrói o worktree em que você está (recusa sujo sem --force),')
|
|
console.log(' ENCERRA a sessão persistente do run (OPS110: abort + fim do')
|
|
console.log(' driver; estado/lock removidos) e remove os bancos gerados do')
|
|
console.log(' worktree (best-effort); imprime')
|
|
console.log(' `cd <main>` no fim — o shell sempre volta ao worktree principal')
|
|
console.log(' --auto opt-out do GATE da skill: o driver auto-submete')
|
|
console.log(' /work-issue --issue <N> --auto | /plan-issue --auto <bag> |')
|
|
console.log(' /bug-fix --auto <bag>. Vale em next/fix e em plan COM bag; falha')
|
|
console.log(' alto em new, em plan sem bag, com --stay, com --headless e fora')
|
|
console.log(' do terminal interativo.')
|
|
console.log(' Flag desconhecida falha alto com sugestão (OPS122) — nunca é descartada.')
|
|
process.exit(1)
|
|
}
|
|
|
|
try {
|
|
// OPS122: falha alto ANTES de qualquer git/claim — flag desconhecida ou
|
|
// `--auto` sem destino não pode deixar worktree órfão (espelha OPS110-F1).
|
|
validateWorktreeFlags({ subcommand, flags })
|
|
if (subcommand === 'next') {
|
|
if ('issue' in flags && flags.issue === undefined) {
|
|
die('`--issue` requer um número (ex.: `--issue 595`).')
|
|
}
|
|
if (positional.length > 1) {
|
|
die('`next` não aceita argumento posicional — use `--issue <N>` para direcionar a Issue.')
|
|
}
|
|
assertAutoFlag({ purpose: 'next', flags })
|
|
await cmdNext(Boolean(flags.stay), Boolean(flags['no-migrate']), flags.issue ?? null, flags)
|
|
} else if (subcommand === 'plan') {
|
|
assertAutoFlag({ purpose: 'plan', bag: positional[1], flags })
|
|
await cmdPlan(Boolean(flags.stay), Boolean(flags['no-migrate']), positional[1], flags)
|
|
} else if (subcommand === 'new') {
|
|
assertAutoFlag({ purpose: 'new', flags })
|
|
await cmdNew(Boolean(flags.stay), Boolean(flags['no-migrate']), positional[1], flags)
|
|
} else if (subcommand === 'fix') {
|
|
assertAutoFlag({ purpose: 'fix', bag: positional[1], flags })
|
|
await cmdFix(
|
|
Boolean(flags.stay),
|
|
Boolean(flags['no-migrate']),
|
|
positional[1],
|
|
flags,
|
|
Boolean(flags.headless),
|
|
flags.directive ?? null,
|
|
)
|
|
} else if (subcommand === 'kill') {
|
|
if (flags.stay) die('`--stay` não se aplica a `kill` — ele sempre volta ao main.')
|
|
await cmdKill(Boolean(flags.force))
|
|
} else die(`subcomando desconhecido: ${subcommand} (esperado: next | plan | new | fix | kill)`)
|
|
} catch (error) {
|
|
if (error?.stderr) die(error.stderr.toString().trim())
|
|
die(error?.message ?? String(error))
|
|
}
|