mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Mapa fixo commitado em scripts/lib/worktree.mjs (cheap=cheapestinference/deepseek-v4-flash, pro=opencode-go/qwen3.7-max, zen=opencode-go/ox-alpha-free, go=opencode-go/mimo-v2.5, alibaba=alibaba-token-plan/qwen3.7-max) com WORKTREE_VARIANT=max sempre; resolveWorktreeModel fail-high em múltiplas flags; opencodeLaunchDirective passa a emitir --model <map|preset> --variant max --auto; scripts/worktree.mjs valida at-most-one antes do claim e repassa modelo nos 3 propósitos (next/plan/new) com help/docblock atualizados; .agents/shell/worktree.sh, .opencode/commands/worktree.md, .agents/skills/worktree-next-issue/SKILL.md e local-database skill com contrato remapeado (--go deixa de ser no-op); tests/unit/worktree.unit.spec.ts pina as 5 flags, variant e guardrail. Closes #859
221 lines
9.6 KiB
JavaScript
221 lines
9.6 KiB
JavaScript
/**
|
|
* 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'
|
|
|
|
/**
|
|
* Prefix of every `/plan-issue` planning-worktree branch (`pnpm worktree
|
|
* plan`). Lowercase-led `plans/…`, so it can never collide with a `next`
|
|
* branch — `<Code>-<slug>` is always uppercase-led — neither in branch name
|
|
* nor, structurally, in the port/slot space derived from the branch.
|
|
*/
|
|
export const PLAN_BRANCH_PREFIX = 'plans/plan-issue'
|
|
|
|
/** Env var the interactive-terminal `worktree()` function sets to request the opencode launch. */
|
|
export const WORKTREE_TERMINAL_ENV = 'TEQO_WORKTREE_TERMINAL'
|
|
|
|
/**
|
|
* Preset model for the opencode launch — fallback comum aos 3 repos do fluxo
|
|
* (infra-solla, teqo, iara-pwa). Override sem editar código: global via
|
|
* `export OPENCODE_WORKTREE_MODEL=…`, ou por repo na chave
|
|
* `OPENCODE_WORKTREE_MODEL` da `.forgejo/worktree.env`. Ex-OPS78/OPS82: o
|
|
* preset era o flavor `-0731` via Vercel AI Gateway; voltou ao provider
|
|
* direto porque o gateway está sem créditos (erros "positive credit balance"
|
|
* em 24/08/2026) e porque unificar o default reduz a sobrescrita confusa de
|
|
* seleção de modelo do TUI (opencode issues #8349/#13456). OPS93: com
|
|
* `--cheap/--pro/--zen/--go/--alibaba` o mapa `WORKTREE_MODEL_MAP` escolhe o
|
|
* modelo por invocação; sem flag o preset permanece.
|
|
*/
|
|
export const OPENCODE_PRESET_MODEL =
|
|
process.env.OPENCODE_WORKTREE_MODEL || 'deepseek/deepseek-v4-flash'
|
|
|
|
/**
|
|
* Per-invocation model map — fixed menu of 5 named flags (OPS93). The
|
|
* directive picks `WORKTREE_MODEL_MAP[flag]` when a single flag is present,
|
|
* otherwise falls back to `OPENCODE_PRESET_MODEL`. Always launched with
|
|
* `--variant max` (fail-high if the target model doesn't expose the variant).
|
|
*/
|
|
export const WORKTREE_MODEL_MAP = {
|
|
cheap: 'cheapestinference/deepseek-v4-flash',
|
|
pro: 'opencode-go/qwen3.7-max',
|
|
zen: 'opencode-go/ox-alpha-free',
|
|
go: 'opencode-go/mimo-v2.5',
|
|
alibaba: 'alibaba-token-plan/qwen3.7-max',
|
|
}
|
|
|
|
/** Set of flag names that select a model (keys of WORKTREE_MODEL_MAP). */
|
|
export const WORKTREE_MODEL_FLAGS = new Set(Object.keys(WORKTREE_MODEL_MAP))
|
|
|
|
/** Variant always used for worktree launches — effort max (OPS78 guardrail). */
|
|
export const WORKTREE_VARIANT = 'max'
|
|
|
|
/**
|
|
* Resolve the model for a parsed `flags` bag (from `parseArgs`). At most one
|
|
* model flag may be present — multiple → throw (fail-high, never guess).
|
|
* No flag → `OPENCODE_PRESET_MODEL`.
|
|
*/
|
|
export const resolveWorktreeModel = (flags = {}) => {
|
|
const active = [...WORKTREE_MODEL_FLAGS].filter((flag) => flags[flag])
|
|
if (active.length > 1) {
|
|
throw new Error(
|
|
`Flags de modelo conflitantes: --${active.join(' --')} (use apenas um de --cheap/--pro/--zen/--go/--alibaba)`,
|
|
)
|
|
}
|
|
if (active.length === 1) return WORKTREE_MODEL_MAP[active[0]]
|
|
return OPENCODE_PRESET_MODEL
|
|
}
|
|
|
|
/**
|
|
* Skill command sent as the launch's initial message per purpose. `next`
|
|
* sends `/work-issue` (the OPS25 command executes the full cycle; the OPS33
|
|
* launch appends `--issue <N>` — the claimed issue — via
|
|
* `opencodeLaunchDirective`); `plan` sends `/plan-issue` (OPS31 — auto-submit
|
|
* is the real need of the flow) and `new` sends nothing ("apenas conversar",
|
|
* no skill at all). The opencode CLI's `--prompt` always auto-submits, there
|
|
* is no prefill-without-submit flag; prefill stays an upstream-only feature
|
|
* request (proposition annotated), not a Teqo fallback.
|
|
*/
|
|
export const OPENCODE_SKILL_COMMAND_BY_PURPOSE = {
|
|
next: '/work-issue',
|
|
plan: '/plan-issue',
|
|
new: null,
|
|
}
|
|
|
|
/**
|
|
* Prefix of every neutral-worktree branch (`pnpm worktree new`). Lowercase-led
|
|
* `work/…` — structurally disjoint from `next`'s uppercase-led `<Code>-<slug>`
|
|
* and from `plan`'s `plans/plan-issue-…`, in branch name and slot space alike.
|
|
*/
|
|
export const WORK_BRANCH_PREFIX = 'work'
|
|
|
|
/** Total branch-name budget — mirrors `branchNameForIssue` (60). */
|
|
const NAMESPACE_BRANCH_MAX_LENGTH = 60
|
|
|
|
/** 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)}`
|
|
}
|
|
|
|
/**
|
|
* Shared branch naming for namespace worktrees NOT tied to the claim queue
|
|
* (`plan`, `new`). Every invocation must land on a DIFFERENT branch so
|
|
* parallel sessions coexist:
|
|
* - `bag` given → `<prefix><slug>`; if that name is already taken,
|
|
* `<prefix><slug>-2`, `-3`, …
|
|
* - no `bag` → the next free sequential `<prefix>1`, `<prefix>2`, …
|
|
* `prefix` carries the namespace AND its separator (`plans/plan-issue-` for
|
|
* plan, `work/` for new), so the sequential and bag forms share one spelling
|
|
* per namespace. `taken` = branch short-names already alive (local refs +
|
|
* origin); when a name is free it is reused only as a name-free slot — the
|
|
* branch is created fresh from `origin/main` each time. Both namespaces are
|
|
* lowercase-led, so neither can collide with `next`'s uppercase-led
|
|
* `<Code>-<slug>` branches.
|
|
*/
|
|
const namespaceBranchName = ({ prefix, bag = '', taken = new Set(), fallback }) => {
|
|
const hasBag = typeof bag === 'string' && bag.trim().length > 0
|
|
|
|
if (!hasBag) {
|
|
for (let n = 1; ; n += 1) {
|
|
const candidate = `${prefix}${n}`
|
|
if (!taken.has(candidate)) return candidate
|
|
}
|
|
}
|
|
|
|
const slug = slugify(bag) || fallback
|
|
const base = `${prefix}${slug.slice(0, NAMESPACE_BRANCH_MAX_LENGTH - prefix.length)}`
|
|
if (!taken.has(base)) return base
|
|
|
|
for (let n = 2; ; n += 1) {
|
|
const suffix = `-${n}`
|
|
const keep = Math.max(1, NAMESPACE_BRANCH_MAX_LENGTH - prefix.length - suffix.length)
|
|
const candidate = `${prefix}${slug.slice(0, keep)}${suffix}`
|
|
if (!taken.has(candidate)) return candidate
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Launch directive for the opencode TUI, printed by `worktree next`/`plan`
|
|
* right before the `cd <dir>` line when called from the interactive terminal
|
|
* (`WORKTREE_TERMINAL=1`): `launch opencode <dir> --model <preset|map>
|
|
* --variant max --auto [--prompt "<command>"]` (OPS93: `--variant max` sempre;
|
|
* modelo vem do mapa `WORKTREE_MODEL_MAP` quando a flag `--cheap/--pro/--zen/
|
|
* --go/--alibaba` está presente, senão do preset). The shell function
|
|
* (`.agents/shell/worktree.sh`) applies the `cd` first, then tokenizes and
|
|
* executes this line (xargs — quote-aware, never eval) — the dir is always
|
|
* `<root without spaces>/<slugified branch>`, so the line never needs quoting
|
|
* for the path. `next` with an `issueNumber` sends `/work-issue --issue <N>`
|
|
* (OPS33: the launch delivers the claimed issue to the agent; the skill reads
|
|
* the rest from GitHub), the prompt value is quoted in the line because it
|
|
* now carries a space. Returns `null` outside the terminal so the `/worktree`
|
|
* opencode command never launches a nested TUI.
|
|
* @param {{ dir: string, purpose: string, terminal?: boolean, issueNumber?: number | null, model?: string | null }} options
|
|
*/
|
|
export const opencodeLaunchDirective = ({
|
|
dir,
|
|
purpose,
|
|
terminal = false,
|
|
issueNumber = null,
|
|
model = null,
|
|
}) => {
|
|
if (!terminal) return null
|
|
const prompt = OPENCODE_SKILL_COMMAND_BY_PURPOSE[purpose]
|
|
const selectedModel = model ?? OPENCODE_PRESET_MODEL
|
|
const args = [dir, '--model', selectedModel, '--variant', WORKTREE_VARIANT, '--auto']
|
|
if (prompt) {
|
|
// The issue suffix belongs to `next` alone — `plan`/`new` never carry a
|
|
// claimed issue (fail-safe: a stray issueNumber must not break /plan-issue).
|
|
const value = purpose === 'next' && issueNumber ? `${prompt} --issue ${issueNumber}` : prompt
|
|
// JSON.stringify quotes the value — the directive carries spaces now.
|
|
args.push('--prompt', JSON.stringify(value))
|
|
}
|
|
return `launch opencode ${args.join(' ')}`
|
|
}
|
|
|
|
/**
|
|
* Branch for a `/plan-issue` planning worktree — see `namespaceBranchName`
|
|
* (namespace `plans/plan-issue-…`, fallback label `plano`).
|
|
*/
|
|
export const planBranchName = ({ bag = '', taken = new Set() }) =>
|
|
namespaceBranchName({ prefix: `${PLAN_BRANCH_PREFIX}-`, bag, taken, fallback: 'plano' })
|
|
|
|
/**
|
|
* Branch for a neutral worktree (`pnpm worktree new`) — see
|
|
* `namespaceBranchName` (namespace `work/…`, fallback label `work`).
|
|
*/
|
|
export const workBranchName = ({ bag = '', taken = new Set() }) =>
|
|
namespaceBranchName({ prefix: `${WORK_BRANCH_PREFIX}/`, bag, taken, fallback: 'work' })
|