diff --git a/docs/changelog/2026-09-16-ops119plus.md b/docs/changelog/2026-09-16-ops119plus.md new file mode 100644 index 00000000..ef01b230 --- /dev/null +++ b/docs/changelog/2026-09-16-ops119plus.md @@ -0,0 +1 @@ +**OPS119+ — launch do worktree: risk map das libs + pin do wiring do `plan` (2026-09-16):** fecha os dois achados de score 3 do `/simplify` da OPS119 (#1066) na superfície do launch. **F1:** `HIGH_RISK_EXACT` (`scripts/lib/test-affected-core.mjs`) só marcava `scripts/worktree.mjs`; um diff que tocasse **apenas** `scripts/agent-session.mjs`, `scripts/lib/agent-session.mjs` ou `scripts/lib/worktree.mjs` classificava `classifyTestScope` como `none` e a suíte unit não rodava — as libs puras eram pinadas por specs que o próprio diff que as quebrava nunca exercitava. Os três entram no piso de blast radius (fail-closed), com pin de membership/classificação em `tests/unit/testAffected.unit.spec.ts` e um caso novo em `ciSkipInvariants.unit.spec.ts` que também confere a existência em disco (entrada high-risk morta por rename falha alto). **F2:** o repasse `argument: bag` de `cmdPlan` → `cmdNamespaceBranch` não tinha pin unitário — um `argument` dropado no caller passava silencioso. Extraído `namespaceLaunchDescriptor({ purpose, bag })` para `scripts/lib/worktree.mjs` (mapa puro purpose→`{ noun, sessionLabel, argument }`, purpose desconhecido lança) e `cmdNamespaceBranch` passou a **derivar** o descriptor internamente de `{ purpose, bag }`: o parâmetro `argument` deixou de existir, então a classe do bug some estruturalmente (não há o que dropar). `cmdPlan`/`cmdNew`/`cmdFix` passam só `purpose`+`bag`; labels (`lote "…"`/`bag "…"`/`bug "…"`/`sequencial`) e `noun` preservados byte a byte. Sem migration, sem Consent/access, sem UI (Impeccable A). Testes: 7 casos unit novos no `describe` do descriptor (plan/new/fix com e sem bag, bag em branco, bag com aspas/backslash cru — sanitização segue só nos donos, purpose `'constructor'`/desconhecido lança) + os pins de high-risk; `pnpm gate:fast` verde (lint + typecheck + suíte unit full). Débito residual (risk map das ~40 `scripts/lib/*.mjs` unit-pinadas + static scope de `scripts/`) registrado como OPS119++ (#1077). Impl plan: `docs/plans/escala-dry-pos-ops119-impl.md`. diff --git a/docs/plans/escala-dry-pos-ops119-impl.md b/docs/plans/escala-dry-pos-ops119-impl.md new file mode 100644 index 00000000..4edb745f --- /dev/null +++ b/docs/plans/escala-dry-pos-ops119-impl.md @@ -0,0 +1,120 @@ +# Impl: OPS119+ — launch do worktree: risk map das libs + pin do wiring do `plan` + +Status: em execução +Atualizado em: 2026-09-16 +Issue: #1071 +Intenção: docs/plans/escala-dry-pos-ops119.md +Appetite restante: herdado (~1h eng) — F1 ~30min + F2 ~30min, sem corte + +## Leitura da intenção + +- **Outcome:** fechar os dois achados de score 3 do `/simplify` da OPS119 na mesma superfície (launch do worktree ↔ agent-session). F1: um diff que toca só `scripts/lib/worktree.mjs`, `scripts/lib/agent-session.mjs` ou `scripts/agent-session.mjs` hoje classifica `none` (a unit não roda e o static scope também é `none`) — o risk map deve forçar `full`, fail-closed. F2: o repasse `argument: bag` de `cmdPlan` → `cmdNamespaceBranch` não pode mais ser dropado em silêncio — o wiring vira estrutura pinada por unit. +- **O que NÃO negociar:** o outcome é integridade de guard (blast radius fail-closed) e pin do wiring, não refactor de arquitetura; Impeccable A (CLI/shell, sem superfície de produto); appetite ~1h; S1/S2/S3 e o prefill upstream ficam fora; a alternativa rejeitada na intenção (prefixo `scripts/lib/` inteiro em `HIGH_RISK_EXACT`) não pode voltar; migration/Consent/UI não se aplicam. +- **O que reavaliar:** (1) “o F2 precisa tornar `cmdNamespaceBranch` testável” — não precisa: **derivar o descriptor dentro do runner elimina o parâmetro dropável por construção**, sem importar `scripts/worktree.mjs` (que hoje dispara o CLI no import — `:950`, `:1043-1062`); (2) “o descriptor deveria sanear o bag” — a sanitização já tem dois donos (`opencodeLaunchDirective:228`, `purposeInvocation:198`) e S2 é defer, então sanear de novo reintroduziria a duplicação; (3) “o descriptor pertence a `scripts/lib/agent-session.mjs`” — apesar de `SESSION_COMMAND_BY_PURPOSE` morar lá, `noun`/`sessionLabel` são vocabulário de branch/namespace do worktree (decisão abaixo). + +## Abordagem recomendada + +```mermaid +flowchart LR + cmdPlan -->|purpose plan + bag| cmdNamespaceBranch + cmdNew -->|purpose new + bag| cmdNamespaceBranch + cmdFix -->|purpose fix + bag| cmdNamespaceBranch + cmdNamespaceBranch -->|deriva| namespaceLaunchDescriptor + namespaceLaunchDescriptor -->|noun, sessionLabel, argument| cmdNamespaceBranch + cmdNamespaceBranch --> opencodeLaunchDirective + cmdNamespaceBranch --> headlessDirective + worktree.unit.spec --> namespaceLaunchDescriptor + testAffected.unit.spec --> HIGH_RISK_EXACT + ciSkipInvariants.unit.spec --> HIGH_RISK_EXACT +``` + +**Opções consideradas:** A | B | C +**Recomendação:** A — extrair `namespaceLaunchDescriptor({ purpose, bag })` para `scripts/lib/worktree.mjs` e fazer `cmdNamespaceBranch` **derivar o descriptor internamente** a partir de `{ purpose, bag }`, removendo `noun`/`sessionLabel`/`argument` da assinatura; os 3 callers passam só `purpose`+`bag` (+ `branchName`, `flags`, `headless`, `directivePath`). Porque a classe do S4 (parâmetro dropado no caller) deixa de existir estruturalmente: não há `argument` para dropar. O pin unitário cai na lib pura que o spec já importa (`tests/unit/worktree.unit.spec.ts:17-35`), sem exigir guard de importabilidade em `worktree.mjs`. +**Rejeitadas:** B — tornar `cmdNamespaceBranch` injetável/importável e testar as opções com mocks: exigiria adicionar guard `isMain` a `scripts/worktree.mjs` e costurar seams de `git`/`fs`/`provision` que hoje são `execFileSync`/`readFileSync` diretos (`:696-746`) — refactor grande para ~0 teste novo além do descriptor; C — manter os parâmetros e exportar só o mapa: o pin pegaria o descriptor, mas o caller continuaria podendo dropar `argument`, ou seja, a classe do bug sobrevive; o teste que cria worktree de verdade segue rejeitado (lento, side effects, colide no slot, já descartado na intenção). + +### Componentes / mudanças + +- **`namespaceLaunchDescriptor`** (`scripts/lib/worktree.mjs`, **novo**): mapa puro `purpose`+`bag` → `{ noun, sessionLabel, argument }`, absorvendo os literais hoje inline nos 3 callers (`noun` por purpose; `sessionLabel` = `lote "…"`/`bag "…"`/`bug "…"` com bag, senão `sequencial`; `argument` = bag para `plan`/`fix`, `null` para `new`). Não reimplementa regra de branch nem sanitização — reusa a convenção já existente no módulo (prefixos e `namespaceBranchName` ficam intocados). +- **`cmdNamespaceBranch`** (`scripts/worktree.mjs:679-747`): remove `noun`/`sessionLabel`/`argument` da assinatura, recebe `bag`, e chama `namespaceLaunchDescriptor({ purpose, bag })` uma vez; os usos em `:709` (erro), `:716` (sessão exibida), `:737` (`headlessDirective({ report })`) e `:744` (`printLaunchDirective({ argument })`) passam a ler o descriptor. +- **`cmdPlan`/`cmdNew`/`cmdFix`** (`scripts/worktree.mjs:759-769`, `:780-789`, `:806-818`): passam `purpose`+`bag`; `branchName`, `flags`, `headless`, `directivePath` seguem iguais. +- **`HIGH_RISK_EXACT`** (`scripts/lib/test-affected-core.mjs:25-53`): acrescentar `scripts/agent-session.mjs`, `scripts/lib/agent-session.mjs`, `scripts/lib/worktree.mjs`. +- **Pins de teste:** `tests/unit/testAffected.unit.spec.ts` (loop `:22-35`), `tests/unit/ciSkipInvariants.unit.spec.ts` (`:100-116`), `tests/unit/worktree.unit.spec.ts` (novo `describe` do descriptor). +- **Migration:** sem migration. +- **Access / Consent:** n/a. +- **UI:** Impeccable A — N/A. + +### Decisões de engenharia + +- **Dono do descriptor: `scripts/lib/worktree.mjs` vs `scripts/lib/agent-session.mjs`.** Opções: A) `worktree.mjs`; B) `agent-session.mjs` (junto de `SESSION_COMMAND_BY_PURPOSE`). Recomendação: A — `noun`/`sessionLabel` são vocabulário de branch/namespace do worktree (mesmo módulo de `planBranchName`/`workBranchName`/`fixBranchName` e do `opencodeLaunchDirective` que consome `argument`), e o spec que já importa o módulo (`worktree.unit.spec.ts:17-35`) pina sem import cruzado novo. Rejeitadas: B porque espalharia metade do contrato de launch (label de branch) para o dono do ciclo de vida de sessão, criando dependência invertida; o que é genuinamente compartilhado com `agent-session` é só o flag `--argument` na borda CLI, que os dois specs já pinam de cada lado. +- **Bag no descriptor: cru vs saneado.** Opções: A) cru (trim só para decidir o label); B) aplicar `replace(/["\\]/g,'')` no descriptor. Recomendação: A — a sanitização é dos donos (`opencodeLaunchDirective:228` no caminho terminal, `purposeInvocation:198` no caminho do driver) e S2 é defer explícito. Rejeitadas: B porque criaria o **terceiro** call site da sanitização — exatamente o gatilho de S2 — e faria o `report` do `headlessDirective` divergir do comportamento atual (que sanitiza só na montagem do argv). +- **`purpose` desconhecido: fail-high vs fail-safe.** Opções: A) `namespaceLaunchDescriptor` lança em purpose não mapeado; B) degradar para um descriptor neutro. Recomendação: A — os únicos callers são `plan`/`new`/`fix`; um purpose novo sem descriptor é erro de wiring e deve falhar alto (mesmo padrão de `resolveWorktreeModel`). Rejeitadas: B porque inventaria `noun`/label silenciosamente, que é a própria classe de bug que o pin fecha. +- **Corte do pin de F1: membership-only vs membership+existência em disco.** Opções: A) só `HIGH_RISK_EXACT.has(path)`; B) membership + `fs` confirmando que cada um dos 3 paths resolve. Recomendação: B, no `it` dedicado de `ciSkipInvariants` — um literal high-risk morto (typo/rename) é exatamente a integridade que o S6 protege e não existe invariante de existência hoje. Rejeitadas: A deixa o valor do F1 degradar em silêncio; o invariante de existência **repo-wide** sobre toda a lista (que revelaria entradas antigas mortas) fica como follow-up nomeado, fora do appetite. +- **Cross-pin `purposeInvocation` × descriptor (2ª perna).** Opções: A) pinar os dois juntos num spec; B) manter os dois pins independentes. Recomendação: B — o contrato entre worktree e agent-session é o flag `--argument`, já pinado nas duas pontas (`opencodeLaunchDirective` por purpose e `purposeInvocation` por argument); um teste combo só reafirmaria os dois. Rejeitadas: A por duplicação e por acoplar dois módulos puros num spec; gatilho de revisitação: se o flag mudar de transporte (env/stdin) ou se S2 consolidar a sanitização num único dono. + +## Fases verificáveis + +1. **F1 — risk map (~30min).** Adicionar os 3 arquivos a `HIGH_RISK_EXACT` (`scripts/lib/test-affected-core.mjs:25-53`); acrescentar os 3 ao loop de `tests/unit/testAffected.unit.spec.ts:22-35`; novo `it` em `tests/unit/ciSkipInvariants.unit.spec.ts` com nome de contrato (“launch/agent-session modules are high-risk — fail-closed blast radius”) pinando membership + presença em disco dos 3. Verificação: `classifyTestScope([{ path: 'scripts/lib/worktree.mjs', status: 'M' }]).mode === 'full'` para cada um dos 3 (coberto pelo loop) e `pnpm test:unit` verde. +2. **F2 — descriptor + wiring (~30min).** Extrair `namespaceLaunchDescriptor` para `scripts/lib/worktree.mjs`; fazer `cmdNamespaceBranch` derivar internamente e remover `noun`/`sessionLabel`/`argument` da assinatura; simplificar os 3 callers para `purpose`+`bag`. Adicionar `describe('namespaceLaunchDescriptor …')` em `tests/unit/worktree.unit.spec.ts` cobrindo plan com/sem bag (argument threaded + label `lote "…"` vs `sequencial`), new com/sem bag (argument sempre `null`), fix com/sem bag, e purpose desconhecido → throw. Verificação: `pnpm test:unit` verde e `tsc --noEmit` limpo (assinatura nova sem sobras). +3. **Gates.** `pnpm gate:fast` (lint + typecheck + unit); push via `pnpm push`. Sem migration e sem e2e novo (Impeccable A, sem superfície de produto). + +## Rabbit holes / Não escopo (engenharia) + +- Guard `isMain` em `scripts/worktree.mjs` (padrão de `scripts/check-test-locations.mjs:68-70`): só seria preciso na opção B; com o descriptor derivado internamente, não é necessário — se um dia `worktree.mjs` precisar de teste direto, é Issue própria. +- Prefixo `scripts/lib/` inteiro em `HIGH_RISK_EXACT`: rejeitado na intenção (engoliria as ~40 libs `scripts/lib/*.mjs` sem harness e forçaria `full` em diffs triviais). O resíduo “lib-only diff → unit `none`” para as demais libs fica nomeado como follow-up, não implementado. +- Invariante de existência em disco sobre a lista inteira de `HIGH_RISK_EXACT`/`HIGH_RISK_PREFIXES`: escopo maior que os 3 alvos; follow-up. +- S1 (fallback fail-safe de `purposeInvocation:202`), S2 (sanitização duplicada, gatilho 3º call site), S3 (política driverless), prefill-sem-submit upstream: fora, conforme a intenção. +- Cross-pin `purposeInvocation` × descriptor: fora (decisão acima). + +## Adiado com gatilho (triage pós-simplify) + +- **Generalização do risk map para as demais `scripts/lib/*.mjs` unit-pinadas** + (ex.: `scripts/lib/worktree-env.mjs`, pinada por `tests/unit/worktree.unit.spec.ts:5-16`): + a intenção só pediu os 3 arquivos de launch/agent-session e rejeitou o prefixo + `scripts/lib/` inteiro. A classe remanescente (diff só-lib → unit `none`) virou + a Issue `OPS119++` (#1077, `depends: [OPS119+]`), plano + `docs/plans/escala-dry-pos-scripts-blast-radius.md`. +- **3ª codificação de “purpose carrega bag”** (`entry.carriesBag` em + `namespaceLaunchDescriptor` × `opencodeLaunchDirective` × `purposeInvocation`): + defer. Gatilho: 4º encoding **ou** mudança de transporte do `--argument` + (env/stdin) → consolidar membership+sanitização num dono único. +- **`classifyStaticScope` segue `none` para `scripts/*.mjs`** (lint/typecheck/knip + pulados num diff só-scripts): entrou como fase 2 da Issue `OPS119++`. + +## Explicitamente fora (triage pós-simplify) + +- Bag em branco → `argument` cru no descriptor × `opencodeHeadlessArgs` lançando + “report vazio”: comportamento pré-existente e intencional (fail-high do headless); + sem reuso bloqueado. Descartado. +- Drift cosmético do docblock/reason de `HIGH_RISK_EXACT` (“schema/lockfile/test + harness”): descartado (cheap_polish score ≤2, sem impacto funcional). +- `noun` como misnomer (`'de planejamento'`/`'neutro'`/`'de correção de bug'` são + qualificadores): rename de pureza mantido por continuidade com o código original. + Descartado. +- Duplicação dos pins de membership entre `testAffected` e `ciSkipInvariants`: + já resolvido/aceito no plano (camadas distintas — classificação `full` × + membership+existência). + +## Riscos e mitigação + +- **O descriptor não cobre o caminho `next`.** `cmdNext` não usa `cmdNamespaceBranch`; `namespaceLaunchDescriptor` só recebe `plan`/`new`/`fix`. Mitigação: `purpose` desconhecido lança (fail-high), então um futuro uso indevido falha alto em vez de gerar label errado. +- **Mudança de assinatura de `cmdNamespaceBranch` deixa parâmetro morto.** Mitigação: `tsc --noEmit` e a leitura dos 3 callers; nenhum outro caller existe (verificado — só `cmdPlan`/`cmdNew`/`cmdFix`). +- **Label/id divergirem do texto atual exibido.** Mitigação: os casos do novo `describe` fixam os três labels e o `sequencial` byte a byte, contra os literais de `:765`/`:786`/`:812`. +- **Entrada high-risk morta (path renomeado).** Mitigação: asserção de existência em disco no `it` novo de `ciSkipInvariants` para os 3 alvos. +- **F1 forçar `full` em diffs envolvendo essas libs aumenta custo de CI.** Esperado e desejado: são o harness de launch/agent-session; fail-closed é o outcome. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção coberto: diff só-lib de launch/agent-session classifica `full` (F1) e o repasse do bag não pode mais ser dropado por parâmetro (F2). +- [ ] Invariantes AGENTS/engineering-standards: identifiers em inglês, prosa pt-BR, sem comentários de código; nenhuma migration; nenhum acesso/Consent/UI tocado. +- [ ] Testes de domínio previstos: `tests/unit/testAffected.unit.spec.ts`, `tests/unit/ciSkipInvariants.unit.spec.ts`, `tests/unit/worktree.unit.spec.ts` — todos unit, sem harness novo. +- [ ] `pnpm gate:fast` verde; `pnpm push` no fluxo normal de PR. + +## Self-score decision-quality + +1. Decisões caras têm rejeitadas? **5/5** — dono do descriptor, cru-vs-saneado, fail-high-vs-neutro, corte do pin de F1 e cross-pin registraram Opções/Recomendação/Rejeitadas. +2. Cabe no appetite (~1h)? **5/5** — F1 ~30min (3 literais + 2 pins) e F2 ~30min (extração pequena em lib pura + rewire de 3 callers + 1 describe); sem migration, sem e2e. +3. Rabbit holes nomeados? **5/5** — guard `isMain`, prefixo `scripts/lib/` inteiro, existência repo-wide, S1/S2/S3, cross-pin e prefill. +4. Depth check reusa shells/helpers? **4/5** — reusa o spec que já importa `scripts/lib/worktree.mjs`, os padrões de pin de `HIGH_RISK_EXACT` e a convenção existente do módulo; não cria pass-through (o descriptor é dono real da derivação, não repassa nada). +5. Intenção (aceite de produto) intacta? **5/5** — a engenharia não reescreveu o outcome; o F2 foi feito _mais_ forte que a opção A original (o parâmetro some), sem ampliar escopo. + +**Score: 24/25 — aprovado para execução.** diff --git a/docs/plans/escala-dry-pos-scripts-blast-radius.md b/docs/plans/escala-dry-pos-scripts-blast-radius.md new file mode 100644 index 00000000..59518324 --- /dev/null +++ b/docs/plans/escala-dry-pos-scripts-blast-radius.md @@ -0,0 +1,47 @@ +# OPS119++ — blast radius de `scripts/`: risk map das libs restantes + static scope + +Status: rascunho +Atualizado em: 2026-09-16 +Issue: #1077 (`depends: [OPS119+]`) +Intenção: débito pós-entrega da OPS119+ (#1071) +Priority: P3 +Impeccable: A — N/A (CLI/CI, sem superfície de produto) +Appetite: ~1h eng + +## Origem + +Triagem do `/simplify` da OPS119+ (#1071): a entrega fechou a classe S6 +(diff só-lib classificava `none` → unit não rodava) apenas para os 3 arquivos de +launch/agent-session. O resíduo é a mesma classe nos demais módulos `scripts/`. + +- **F1 — risk map das libs restantes (expensive_lock, score 3):** + `HIGH_RISK_EXACT` (`scripts/lib/test-affected-core.mjs:25-56`) cobre só alguns + dos ~40 módulos `scripts/lib/*.mjs` importados por specs unit. + `scripts/lib/worktree-env.mjs` — mesma superfície de launch, pinada por + `tests/unit/worktree.unit.spec.ts:5-16` e importada por + `scripts/lib/auto-unblock.mjs:10` — segue classificando `none` num diff + só-nele: a unit que a cobre não roda. **Opções:** A) invariante derivado + (todo módulo `scripts/**` importado por um spec unit é high-risk) — fail-closed, + mata a classe na raiz; B) estender o allowlist literal com os arquivos + conhecidos — menor blast radius, mas reabre o buraco a cada lib nova. + **Recomendação:** A, com allowlist de exceções explícitas se houver falso + positivo. **Rejeitadas:** prefixo `scripts/lib/` inteiro (rejeitado na + intenção-mãe: engole libs sem harness e força `full` em diffs triviais). +- **F2 — static scope de `scripts/` (cheap_polish, score 2):** `isCodePath` + (`scripts/lib/test-affected-core.mjs:131-140`) não inclui `scripts/`, então + `classifyStaticScope` devolve `none` e lint/typecheck/knip ficam pulados num + diff só-scripts. Estender `isCodePath` a `scripts/` (revisar o custo de CI de + rodar o static num diff de `.mjs`). + +## Fases verificáveis + +1. **F1 — derivar/estender o risk map** e atualizar os pins do dono + (`testAffected.unit.spec.ts` / `ciSkipInvariants.unit.spec.ts`). Verificação: + um diff só-`scripts/lib/` devolve `full` em `classifyTestScope`. +2. **F2 — `isCodePath` cobrir `scripts/`**; pin em `ciSkipInvariants`. Verificação: + um diff só-scripts devolve `code` em `classifyStaticScope`. + +## Rabbit holes / Não escopo + +- Ampliar o escopo para qualquer superfície fora de `scripts/` (produto/UI): fora. +- Reescrever o harness de classificação: fora — só o risk map e `isCodePath`. diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index b1b34ec0..de55ab21 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -34,6 +34,9 @@ export const HIGH_RISK_EXACT = new Set([ 'scripts/seed-minimal.mjs', 'scripts/lib/seed-minimal-manifest.mjs', 'scripts/worktree.mjs', + 'scripts/agent-session.mjs', + 'scripts/lib/agent-session.mjs', + 'scripts/lib/worktree.mjs', 'scripts/lib/test-affected-core.mjs', 'scripts/lib/e2e-affected-manifest.mjs', 'scripts/test-affected.mjs', diff --git a/scripts/lib/worktree.mjs b/scripts/lib/worktree.mjs index 8340f0c0..31bc3b13 100644 --- a/scripts/lib/worktree.mjs +++ b/scripts/lib/worktree.mjs @@ -132,6 +132,8 @@ export const branchNameForIssue = (issue, maxLength = 60) => { return `${code}-${slug.slice(0, keep)}` } +const hasBag = (bag) => typeof bag === 'string' && bag.trim().length > 0 + /** * Shared branch naming for namespace worktrees NOT tied to the claim queue * (`plan`, `new`). Every invocation must land on a DIFFERENT branch so @@ -148,9 +150,7 @@ export const branchNameForIssue = (issue, maxLength = 60) => { * `-` branches. */ const namespaceBranchName = ({ prefix, bag = '', taken = new Set(), fallback }) => { - const hasBag = typeof bag === 'string' && bag.trim().length > 0 - - if (!hasBag) { + if (!hasBag(bag)) { for (let n = 1; ; n += 1) { const candidate = `${prefix}${n}` if (!taken.has(candidate)) return candidate @@ -290,3 +290,33 @@ export const workBranchName = ({ bag = '', taken = new Set() }) => */ export const fixBranchName = ({ bag = '', taken = new Set() }) => namespaceBranchName({ prefix: `${FIX_BRANCH_PREFIX}/`, bag, taken, fallback: 'fix' }) + +const NAMESPACE_LAUNCH = new Map([ + ['plan', { noun: 'de planejamento', labelPrefix: 'lote', carriesBag: true }], + ['new', { noun: 'neutro', labelPrefix: 'bag', carriesBag: false }], + ['fix', { noun: 'de correção de bug', labelPrefix: 'bug', carriesBag: true }], +]) + +/** + * Launch descriptor for a namespace worktree (`plan`/`new`/`fix`) — the pure + * purpose→options mapping the runner (`cmdNamespaceBranch`) derives from + * `purpose`+`bag` instead of receiving as droppable parameters. `noun` and + * `sessionLabel` are the human labels of the provisioning log (`lote "…"`/ + * `bag "…"`/`bug "…"`, or `sequencial` without a bag); `argument` rides the + * launch directive only for the purposes whose skill accepts a bag (`plan` + * opening message, `fix` bug description) — `new` is neutral and never carries + * one. Unknown purposes throw: a purpose without a descriptor is a wiring + * error, never a silently invented label. + * @param {{ purpose?: string | null, bag?: string | null }} [options] + */ +export const namespaceLaunchDescriptor = ({ purpose = null, bag = null } = {}) => { + const entry = NAMESPACE_LAUNCH.get(purpose) + if (!entry) { + throw new Error(`namespaceLaunchDescriptor: purpose desconhecido "${purpose}"`) + } + return { + noun: entry.noun, + sessionLabel: hasBag(bag) ? `${entry.labelPrefix} "${bag}"` : 'sequencial', + argument: entry.carriesBag ? bag : null, + } +} diff --git a/scripts/worktree.mjs b/scripts/worktree.mjs index 2a090720..fb2853b8 100644 --- a/scripts/worktree.mjs +++ b/scripts/worktree.mjs @@ -184,6 +184,7 @@ import { branchNameForIssue, fixBranchName, headlessDirective, + namespaceLaunchDescriptor, OPENCODE_PRESET_MODEL, opencodeLaunchDirective, planBranchName, @@ -672,25 +673,27 @@ const buildTakenBranchNames = () => { * `branchName`), provisions the same isolated env as `next` (with `purpose`) * and prints the `cd ` line by default (`--stay` suppresses). Every * invocation creates a DIFFERENT worktree — parallel sessions never share one. - * `argument` (optional) rides the launch prompt — for `fix`, the bug + * 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). + * the `/plan-issue` driver auto-submits (absent → driverless session); `new` + * carries none. */ const cmdNamespaceBranch = async ({ stay, skipMigrate, purpose, - noun, - sessionLabel, + bag = null, branchName, flags = {}, - argument = null, headless = false, directivePath = null, }) => { if (headless && !directivePath) { die('`--headless` requer `--directive ` (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']) @@ -761,11 +764,9 @@ const cmdPlan = async (stay, skipMigrate, bag, flags = {}) => stay, skipMigrate, purpose: 'plan', - noun: 'de planejamento', - sessionLabel: bag && bag.trim() ? `lote "${bag}"` : 'sequencial', + bag, branchName: (taken) => planBranchName({ bag, taken }), flags, - argument: bag, }) /** @@ -782,8 +783,7 @@ const cmdNew = async (stay, skipMigrate, bag, flags = {}) => stay, skipMigrate, purpose: 'new', - noun: 'neutro', - sessionLabel: bag && bag.trim() ? `bag "${bag}"` : 'sequencial', + bag, branchName: (taken) => workBranchName({ bag, taken }), flags, }) @@ -808,11 +808,9 @@ const cmdFix = async (stay, skipMigrate, bag, flags = {}, headless = false, dire stay, skipMigrate, purpose: 'fix', - noun: 'de correção de bug', - sessionLabel: bag && bag.trim() ? `bug "${bag}"` : 'sequencial', + bag, branchName: (taken) => fixBranchName({ bag, taken }), flags, - argument: bag, headless, directivePath, }) diff --git a/tests/unit/ciSkipInvariants.unit.spec.ts b/tests/unit/ciSkipInvariants.unit.spec.ts index 1ad35546..8eb04b26 100644 --- a/tests/unit/ciSkipInvariants.unit.spec.ts +++ b/tests/unit/ciSkipInvariants.unit.spec.ts @@ -1,6 +1,6 @@ // @vitest-environment node -import { readdirSync, readFileSync } from 'node:fs' +import { existsSync, readdirSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -115,6 +115,20 @@ describe('ciSkipInvariants', () => { } }) + it('launch/agent-session modules are high-risk — a lib-only diff cannot skip unit (OPS119+)', () => { + // The pure launch libs are unit-pinned, so a diff touching only them must + // still run the unit suite; the existence check keeps the entry from going + // stale on a rename. + for (const path of [ + 'scripts/agent-session.mjs', + 'scripts/lib/agent-session.mjs', + 'scripts/lib/worktree.mjs', + ]) { + expect(HIGH_RISK_EXACT.has(path), path).toBe(true) + expect(existsSync(join(repoRoot, path)), path).toBe(true) + } + }) + it('deploy starts on push to main with an active-run guard, dispatch kept (OPS104)', () => { // OPS104 supersedes the OPS71 manual-only pin: a push to main starts the // deploy run in the same workflow, and the preflight guard skips it when diff --git a/tests/unit/testAffected.unit.spec.ts b/tests/unit/testAffected.unit.spec.ts index 01631420..cd01e7da 100644 --- a/tests/unit/testAffected.unit.spec.ts +++ b/tests/unit/testAffected.unit.spec.ts @@ -26,6 +26,9 @@ describe('classifyTestScope (OPS5)', () => { 'src/collections/Post.ts', 'scripts/lib/seed-minimal-manifest.mjs', 'scripts/worktree.mjs', + 'scripts/agent-session.mjs', + 'scripts/lib/agent-session.mjs', + 'scripts/lib/worktree.mjs', 'scripts/lib/test-affected-core.mjs', 'tests/helpers/campaignFixtures.ts', 'tests/unit/ciSkipInvariants.unit.spec.ts', diff --git a/tests/unit/worktree.unit.spec.ts b/tests/unit/worktree.unit.spec.ts index ceb68161..143bcc63 100644 --- a/tests/unit/worktree.unit.spec.ts +++ b/tests/unit/worktree.unit.spec.ts @@ -20,6 +20,7 @@ import { fixBranchName, headlessDirective, issueCodeAndSubject, + namespaceLaunchDescriptor, OPENCODE_HEADLESS_COMMAND, OPENCODE_PRESET_MODEL, opencodeHeadlessArgs, @@ -318,6 +319,70 @@ describe('opencodeLaunchDirective (terminal-only agent-session launch, OPS26 + O }) }) +describe('namespaceLaunchDescriptor (purpose→options single source, OPS119+)', () => { + it('plan carries the bag as argument and labels the session with it', () => { + expect(namespaceLaunchDescriptor({ purpose: 'plan', bag: 'revisa o gate' })).toEqual({ + noun: 'de planejamento', + sessionLabel: 'lote "revisa o gate"', + argument: 'revisa o gate', + }) + }) + + it('plan without a bag is driverless: sequential label, no argument', () => { + expect(namespaceLaunchDescriptor({ purpose: 'plan' })).toEqual({ + noun: 'de planejamento', + sessionLabel: 'sequencial', + argument: null, + }) + }) + + it('a blank bag is labeled sequential (trim decides the label) but still rides raw', () => { + expect(namespaceLaunchDescriptor({ purpose: 'plan', bag: ' ' })).toEqual({ + noun: 'de planejamento', + sessionLabel: 'sequencial', + argument: ' ', + }) + }) + + it('does not sanitize the bag — quotes/backslashes ride raw (sanitization stays with the owners)', () => { + const descriptor = namespaceLaunchDescriptor({ purpose: 'plan', bag: 'a "b"\\c ideia' }) + expect(descriptor.argument).toBe('a "b"\\c ideia') + expect(descriptor.sessionLabel).toBe('lote "a "b"\\c ideia"') + }) + + it('new is neutral: it never carries an argument, even with a bag', () => { + expect(namespaceLaunchDescriptor({ purpose: 'new' })).toEqual({ + noun: 'neutro', + sessionLabel: 'sequencial', + argument: null, + }) + expect(namespaceLaunchDescriptor({ purpose: 'new', bag: 'ideia solta' })).toEqual({ + noun: 'neutro', + sessionLabel: 'bag "ideia solta"', + argument: null, + }) + }) + + it('fix carries the bug description and labels it', () => { + expect(namespaceLaunchDescriptor({ purpose: 'fix', bag: '500 no autosave' })).toEqual({ + noun: 'de correção de bug', + sessionLabel: 'bug "500 no autosave"', + argument: '500 no autosave', + }) + expect(namespaceLaunchDescriptor({ purpose: 'fix' })).toEqual({ + noun: 'de correção de bug', + sessionLabel: 'sequencial', + argument: null, + }) + }) + + it('throws on an unknown/absent purpose — a purpose without a descriptor is a wiring error', () => { + for (const purpose of [null, undefined, 'next', 'bogus', 'constructor']) { + expect(() => namespaceLaunchDescriptor({ purpose })).toThrow(/purpose desconhecido/) + } + }) +}) + describe('opencodeHeadlessArgs + headlessDirective (OPS106 auto-unblock)', () => { it('builds the opencode run argv with the bug-fix command and the report as one arg', () => { expect(