feat(OPS116): painel aceita -ui-design.html com retrocompat -ui-draft.html (Closes #1059)

O painel `pnpm issues:tui` volta a enxergar o design UI depois do rename do
OPS114: `siblingPlanPaths` deriva `-ui-design.html` (vigente) e
`-ui-draft.html` (legado) e o novo `resolveUiDraft(planPath, exists)` — puro,
com a existência injetada pelo CLI — é dono da precedência (design vence,
draft é fallback, nenhum → design com `exists:false`). O `readPlans` sonda
design→draft no disco e o caminho REAL flui por `plansByNumber`/`buildIssueRow`
até o view model; o contrato `--json` segue `version: 1` com o shape
`{ path, exists }` e a chave histórica `uiDraft`. As superfícies do painel
trocam "rascunho UI" por "design UI".

Retrocompat é só de leitura — o acervo legado (30 `*-ui-draft.html`) não é
tocado. Unit dos dois specs cobre design-only, draft-only, ambos, nenhum, o
caminho real, body linkando o impl e o shape do JSON. Sem migration.
This commit is contained in:
Francisco Solla 2026-09-16 01:02:02 -03:00
parent 6561f7fca7
commit ef2df24a80
6 changed files with 277 additions and 29 deletions

View file

@ -0,0 +1 @@
**OPS116 — Painel/TUI: aceitar `-ui-design.html` com retrocompat `-ui-draft.html` (2026-09-16):** o painel de issues (`pnpm issues:tui`) volta a enxergar o artefato de design dos planos depois do rename do OPS114. `siblingPlanPaths` (lib pura) passa a derivar `-ui-design.html` (nome vigente) e `-ui-draft.html` (legado), e o novo `resolveUiDraft(planPath, exists)` — puro, existência injetada pelo CLI — é dono da precedência "design vence, draft é fallback, nenhum → design com `exists:false`". O `readPlans` do CLI sonda design→draft no disco e devolve o **caminho real**, que flui por `plansByNumber`/`buildIssueRow` até o view model; o contrato `--json` segue `version: 1` com o shape `{ path, exists }` e a chave histórica `uiDraft` (nenhum campo novo). As superfícies do painel trocam "rascunho UI" por "design UI" (ajuda, linha de ação, rodapé e mensagem da tecla `w`). Retrocompat é só de leitura — o acervo legado (30 `*-ui-draft.html` + PNGs) não é tocado; HTML-only, sem render de imagem. Unit dos dois specs do painel cobre design-only, draft-only, ambos (design vence), nenhum, o caminho real vindo do contexto e o shape do JSON. Sem migration, sem deps novas, sem tocar `src/`.

View file

@ -0,0 +1,132 @@
# Impl: OPS116 — Painel/TUI: aceitar `-ui-design.html` com retrocompat `-ui-draft.html`
Status: aprovado (gate humano 2026-09-16)
Atualizado em: 2026-09-16
Issue: #1059
Intenção: docs/plans/ops116-painel-ui-design-retrocompat.md
Appetite restante: herdado (~0,5 dia eng)
## Leitura da intenção
- **Outcome:** o painel (`pnpm issues:tui`, OPS109) reconhece o artefato de design dos planos nos dois nomes — prefere `<base>-ui-design.html`, cai para `<base>-ui-draft.html` (legado, imutável) — mostra/abre o **caminho real** encontrado com a tecla `w` e passa a falar "design UI"; o contrato `--json` segue `version: 1` com o shape `{ path, exists }` carregando o caminho real.
- **O que NÃO negociar:** read-only de ponta a ponta; HTML-only (sem render de PNG/imagem); o acervo legado em `docs/plans/` não é tocado (retrocompat é de leitura); a chave `uiDraft` **não** é renomeada e `version: 1` não sobe; a existência continua checada no **CLI** (a lib é pura e não toca disco); as três strings exatas do painel; sem migration.
- **O que reavaliar (hipóteses da intenção):**
- "A lib deriva os dois candidatos e o CLI sonda" — confirmado, mas a passada base do `buildPanelViewModel` só expõe **um** caminho hoje (`row.uiDraft.path`); para o CLI sondar os dois sem quebrar a pureza, a ordem/precedência vira um **seletor puro com existência injetada** (`resolveUiDraft(planPath, exists)`), e é isso que torna "design vence" testável em unit (decisão 1).
- "Os unit tests cobrem design-only/draft-only/ambos/nenhum" — a lib não podia testar isso enquanto o `existsSync` morasse no CLI; com o predicado injetado, os quatro casos ficam puros e determinísticos (decisão 1), mantendo a sondagem de disco no CLI.
- "Basta estender `siblingPlanPaths`" — parcial: o nome dos dois irmãos mora nele (dono), mas a **precedência** (design > draft, e o default "nenhum → design") precisa de um dono único consumível; não é campo novo no view model (decisão 3).
## Abordagem recomendada
```mermaid
flowchart LR
BODY[issue.body] --> EX[extractPlanPath] --> SIB[siblingPlanPaths<br/>impl · uiDesign · uiDraft]
SIB --> RUD
CLI[issues-tui.readPlans] -->|injeta existsOnDisk| RUD[resolveUiDraft planPath, exists<br/>design → draft]
RUD --> REAL["{path, exists} real<br/>design, senão draft; nenhum → design,false"]
BODY --> VM[buildPanelViewModel pass 2]
REAL --> MAP[plansByNumber] --> VM
VM --> ROW[buildIssueRow<br/>view.uiDraft = path, exists]
ROW --> JSON[toJsonPayload<br/>version 1 · path, exists — shape intacto]
ROW --> TUI[renderDetail · ajuda · handler w<br/>w design UI]
```
**Opções consideradas:** A) `siblingPlanPaths` devolve `{ impl, uiDesign, uiDraft }` + contexto `uiDraft` de `buildIssueRow` aceita `{path,exists}`, com o CLI sondando os dois candidatos derivados de `row.plan.intention.path` e codificando a ordem design→draft inline; B) helper puro dedicado de candidatos (`uiDraftCandidates(planPath)`) consumido pelo CLI, `siblingPlanPaths` intocado; C) **A acrescido de um seletor puro** `resolveUiDraft(planPath, exists)` como dono único da precedência, com a existência **injetada** pelo CLI.
**Recomendação:** **C** — a ordem preferencial (design > draft) é conhecimento de nomes e deve viver num dono só; injetar o predicado de existência preserva a pureza da lib **e** deixa a regra "design vence" testável em unit sem tocar disco; não adiciona nenhum campo ao view model/JSON nem duplica a derivação de nomes.
**Rejeitadas:**
- **A** — deixa a ordem design→draft hardcoded no CLI, então "design vence" só se verifica por smoke manual (o aceite pede unit para os 4 casos) e não há dono explícito da precedência; é o mesmo conhecimento em dois lugares na prática.
- **B** — parte o conhecimento: `uiDesign` num helper e `uiDraft` em `siblingPlanPaths`, dois donos para a mesma convenção; o helper vira pass-through raso que só agrega valor colado ao `siblingPlanPaths` (depth check: não criar).
- **D** (rejeitada na intenção) — novo campo `variant`, rename `uiDraft`→`uiDesign` ou `version: 2`: churn de contrato sem consumidor novo; o `--json` é superfície pública e o shape `{path, exists}` é o contrato.
- **E** — deixar a lib chamar `existsSync` dentro de `buildIssueRow`: quebra a pureza e a divisão lib-pura/CLI-I/O estabelecida no OPS109 (twin de I/O); a existência fica no CLI e entra na lib só como predicado.
### Decisões de engenharia
**1. Como os candidatos são expostos e como o `{path, exists}` real volta (tensão central).**
Opções: A) `siblingPlanPaths` ganha `uiDesign`; CLI deriva os dois de `row.plan.intention.path` e codifica a ordem; B) helper `uiDraftCandidates` dedicado; C) `siblingPlanPaths` ganha `uiDesign` **e** um `resolveUiDraft(planPath, exists = () => false)` puro encapsula ordem + fallback + default; CLI injeta `existsSync`.
Recomendação: **C.** O fluxo: `siblingPlanPaths` (dono dos nomes) → `resolveUiDraft` (dono da precedência) → `readPlans` no CLI chama `resolveUiDraft(row.plan.intention.path, isFileOnDisk)` e devolve `{ path, exists }` com o caminho **real** → `loadViewModel` coloca esse objeto no `plansByNumber` (`Map<number, { intention, impl, uiDraft: {path, exists} }>`) → a passada 2 do `buildPanelViewModel` passa adiante → `buildIssueRow` usa `context.uiDraft` quando presente e cai em `resolveUiDraft(planPath)` (design, `exists:false`) como default → `view.uiDraft = { path, exists }` (shape inalterado) → `toJsonPayload` mapeia só `{ path, exists }` (nenhum campo novo). `row.plan.intention.path` é equivalente ao `planPath` de `buildIssueRow` porque ambos saem do mesmo `extractPlanPath`/`siblingPlanPaths` (que já remove o sufixo `-impl`), então não há divergência mesmo quando o body linka o impl ou não linka plano.
Rejeitadas: A e B pelos motivos acima; expor a lista de candidatos no view model (novo campo interno, mesmo que removido do JSON) é rejeitado por não comprar nada além da re-derivação via o mesmo helper exportado — o seletor puro já é o dono.
**2. Caminho exibido quando nenhum arquivo existe.**
Opções: A) candidato novo (`<base>-ui-design.html`) com `exists: false`; B) manter o antigo (`<base>-ui-draft.html`).
Recomendação: **A** — ensina o nome vigente; o fallback real continua valendo quando o legado existir. É exatamente o default de `resolveUiDraft` (design + `false`). _(assumido na intenção — validar no gate; se o gate preferir B, muda só o retorno do `resolveUiDraft`, um ponto.)_
**3. Onde comentar que `uiDraft` agora significa "design UI (novo ou legado)".**
Recomendação: **comentário no owner, sem campo novo** — JSDoc em `siblingPlanPaths` (nomes), `resolveUiDraft` (chave histórica + precedência), `buildIssueRow` (contexto `uiDraft` = verdict de design) e `toJsonPayload` (contrato `{path, exists}`, `version: 1`), mais um comentário curto em `readPlans`. Rejeitado duplicar campo `variant`/subir `version` (decisão D).
**4. `readPlans` sonda design→draft e devolve o caminho real?**
Recomendação: **sim** — `resolveUiDraft` sonda na ordem design→draft via `isFileOnDisk = (p) => Boolean(p) && existsSync(resolve(process.cwd(), p))` e devolve `{ path: encontrado ?? design, exists: Boolean(encontrado) }`. O que entra no JSON é o caminho do arquivo que existe, não o template.
### Componentes / mudanças
- **`siblingPlanPaths`** (`scripts/lib/issues-panel.mjs:95`): passa a devolver `{ impl, uiDesign, uiDraft }`; mantém `impl`/`uiDraft` (retrocompat) e registra em JSDoc que `uiDraft` é a chave histórica do "design UI (novo ou legado)".
- **`resolveUiDraft`** (novo, `scripts/lib/issues-panel.mjs`): puro, `(planPath, exists = () => false) → { path, exists }`; dono da precedência design→draft e do default "nenhum → design, false". Reusa `siblingPlanPaths`; não toca disco.
- **`buildIssueRow`** (`:203`): contexto `uiDraft` aceita `{ path, exists }` (não mais boolean); default `resolveUiDraft(planPath)`; `view.uiDraft = { path, exists }` — shape idêntico ao atual.
- **`buildPanelViewModel`** (`:316`): o valor `uiDraft` de `plansByNumber` passa a ser `{ path, exists }` repassado direto (sem `Boolean(...)`); a JSDoc do input atualiza o tipo.
- **`toJsonPayload`** (`:359`): **inalterado no shape** (`uiDraft: { path: row.uiDraft.path, exists: row.uiDraft.exists }`, `version: 1`); só comentário no owner.
- **`readPlans`** (`scripts/issues-tui.mjs:104`): usa `resolveUiDraft(row.plan.intention.path, isFileOnDisk)`; devolve o `{path, exists}` real (o resto do corpo fica igual).
- **Strings** (`scripts/issues-tui.mjs:19,339,340,359,637`): `w design UI` (ajuda e rodapé) · `w abrir design UI no browser · <path>` (linha de ação) · `w design UI: não tem (classe A)` (handler e estado ausente).
- **Testes** (`tests/unit/issuesPanel.unit.spec.ts`, `tests/unit/issuesPanelRegression.unit.spec.ts`): atualizados para o novo shape e para os 4 casos de `resolveUiDraft`.
- **Changelog:** `docs/changelog/2026-09-16-ops116.md` (uma entrada curta, convenção do repo).
- **Migration:** sem migration (nenhum schema).
- **Access / Consent:** N/A — tooling de terminal, sem DB/PII/Consent.
- **UI:** N/A — classe A; tooling de terminal, sem UI de produto; não há ciclo shape→craft→critique→polish.
### Dados → forma (se aplicável)
N/A — ferramenta de operação; não apresenta dado de produto. A "forma" aqui é o view model/JSON, já coberto acima.
## Fases verificáveis
1. **Camada pura + unit tests (quota ~50% ≈ 0,25 dia)** — `siblingPlanPaths` com `uiDesign`; `resolveUiDraft` puro; `buildIssueRow`/`buildPanelViewModel` com o shape `{path, exists}`. Escrever primeiro os testes: `siblingPlanPaths`, e `resolveUiDraft` nos 4 casos (design-only → design; draft-only → draft; ambos → design vence; nenhum → design, `exists:false`) com predicado fake; ajustar `buildIssueRow`/`buildPanelViewModel`/`toJsonPayload`. Prova: `pnpm test:unit -- tests/unit/issuesPanel.unit.spec.ts tests/unit/issuesPanelRegression.unit.spec.ts`.
2. **CLI (quota ~35% ≈ 0,17 dia)** — `readPlans` via `resolveUiDraft` + `isFileOnDisk`; strings "design UI"; comentário no `readPlans`. Prova: smoke `pnpm issues:tui --json` (exige `GITHUB_TOKEN`) conferindo Issue legada → `exists:true` com caminho `<base>-ui-draft.html`, Issue com design → `<base>-ui-design.html`, e Issue sem nenhum → `<base>-ui-design.html`/`exists:false`; abrir `pnpm issues:tui` e conferir `w design UI` no detalhe.
3. **Fechamento (quota ~15% ≈ 0,08 dia)** — entrada `docs/changelog/2026-09-16-ops116.md`; `pnpm gate:fast`; `node scripts/check-test-locations.mjs`; revisão do aceite de engenharia; push via `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- **Renderizar HTML/imagem no painel** — vira viewer embutido; corta em abrir browser/imprimir caminho (como hoje).
- **Migrar/renomear os 30 `*-ui-draft.html` (e PNGs) de `docs/plans/`** — acervo legado imutável; retrocompat é de leitura.
- **Limpar o vocabulário no repo inteiro** (skill/template/docs) — dono é OPS114; o diff deste item é só o painel/testes/changelog.
- **Mexer em `E2E_AFFECTED_MANIFEST`/`HIGH_RISK_EXACT`/`ci-scope.mjs`** — o manifest mapeia só `src/` e o risco curado não inclui os scripts do painel; não há mapping a acrescentar e mexer nisso é fora do appetite.
- **`check:cycles`** — roda só em `src/`; irrelevante aqui.
- **Exportar `uiDraftCandidates`/helper sem consumidor** — `knip` trata `scripts/*.mjs` como entry com `exports:"error"`; export novo só entra se for usado (o CLI e/ou os specs usam `resolveUiDraft`; `siblingPlanPaths` já é usado).
- **Renomear a chave `uiDraft` ou subir `version`** — contrato público sem consumidor novo; rejeitado.
- **Tocar em claim/registro/sessões/schema/`push`** — fora do item.
## Riscos e mitigação
- **Regressão silenciosa do `--json`** — manter `version: 1` e o shape `{ path, exists }`; nenhum campo novo; o spec já faz `JSON.parse(JSON.stringify(payload))` e asserts de shape — estender para garantir que a linha carrega o caminho real do arquivo existente.
- **Teste que fixa o nome antigo** — os dois specs têm expectativas literais `-ui-draft.html`; atualizar junto (senão o gate fica vermelho).
- **Divergência entre o `planPath` do `buildIssueRow` e o de `readPlans`** — ambos derivam do mesmo `extractPlanPath`+`siblingPlanPaths` (que remove `-impl`); cobrir com regressão: body linkando o impl e body sem link.
- **Ambos os arquivos existem** — precedência design coberta pelo unit com predicado fake (`exists: () => true`) e confirmada no smoke.
- **Nenhum arquivo existe** — JSON expõe o candidato novo com `exists:false` (assumido — validar no gate; ponto único de mudança no `resolveUiDraft`).
- **Acervo legado** — só leitura; nenhuma escrita em `docs/plans/`.
## Débitos (triagem `/simplify`)
Dois revisores (estrutural + qualidade) rodaram no diff; nada alto/médio sobreviveu (a regressão do body linkando o `-impl.md` — risco declarado acima — foi adicionada, e o `?? null` redundante do `buildPanelViewModel` foi removido). Nenhum achado virou Issue nova. Defers com gatilho (reavaliar quando o gatilho aparecer):
- **`readPlans`/`resolveUiDraft` sem teste de I-O** — a sonda real de disco só é exercitada no smoke manual (`GITHUB_TOKEN`); o glue `readPlans`↔`resolveUiDraft` não tem unit. Gatilho: `readPlans` ganhar lógica própria ou surgir teste de int do CLI.
- **Default fail-silent `exists = () => false`** em `resolveUiDraft` — exigido pelo fallback puro do `buildIssueRow` (a lib não toca disco). Gatilho: novo caller fora da lib que possa omitir o predicado.
- **`buildIssueRow` não normaliza `uiDraft: boolean` legado** — nenhum caller passa boolean hoje. Gatilho: surgir consumidor externo com o shape antigo.
Descartados por baixo valor (documentados, sem consumidor runtime): dead code pré-existente do fallback `siblings.impl`, `uiDraft` sem consumidor de produção, `@typedef` do shape `{ path, exists }`, mudança de semântica do `uiDraft.path` sob `version: 1` (decisão travada da intenção), as normalizações de fronteira `Boolean(...)`/`?? null` (contrato `--json`), o `Boolean(path)` defensivo do CLI, o prefixo `w` da mensagem do handler (literal aprovado) e o pin de `uiDesign`/`uiDraft` a partir de `-impl.md` (coberto por `resolveUiDraft`).
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto: design-only aparece e abre; draft-only segue funcionando; ambos usa design; superfícies falam "design UI"; `--json` `version:1`/`{path, exists}` com o caminho real; sem PNG.
- [x] Invariantes AGENTS/engineering-standards: strings pt / identificadores em inglês; **edit the owner, don't twin** (estende `siblingPlanPaths`, adiciona `resolveUiDraft` no mesmo dono, ajusta `buildIssueRow`/`buildPanelViewModel`/`toJsonPayload`/`readPlans`; sem segundo caminho de derivação); lib pura não toca disco (existência injetada); sem dep nova; sem migration/`push`; sem PII/DB.
- [x] Testes de domínio previstos: unit (`// @vitest-environment node`, import direto do `.mjs`) para `siblingPlanPaths` (3 nomes), `resolveUiDraft` nos 4 casos, `buildIssueRow` com `{path,exists}` e default design, e regressão do shape do `toJsonPayload`; sem int test (sem access/write path).
## Autoscore de decision-quality
**Score: 4.5 / 5.**
| Critério | Nota | Justificativa |
| ----------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Decisões caras com rejeitadas | 1.0 | A tensão central (como expor candidatos / fluir o `{path,exists}` / manter o JSON) tem Opções A–E, recomendação C e rejeitadas com porquê (pureza, dono único, contrato público). |
| Cabe no appetite | 1.0 | ~0,5 dia, tooling puro + CLI fina; as fases somam ~0,5 dia sem inflar escopo. |
| Rabbit holes nomeados | 1.0 | Render de imagem, migração do acervo, rename global, ci-scope/e2e/cycles, export morto no knip. |
| Depth check de reuso | 1.0 | Reusa `siblingPlanPaths`/`buildIssueRow`/`buildPanelViewModel`/`toJsonPayload`/`readPlans`; não cria módulo/collection/campo; existência injetada em vez de I/O na lib. |
| Intenção permanece satisfeita | 0.5 | Engenharia não reescreve o outcome; perde meio ponto porque "nenhum → design" é uma assunção da intenção ainda a validar no gate (ponto único de reversão). |
**Total: 4.5/5** (≥4 — pronto para o gate humano do impl plan).

View file

@ -16,7 +16,7 @@
* clara, nunca stack trace; sair nunca deixa o terminal em raw mode.
*
* Teclas na lista: ↑↓ mover · enter abrir · 1-4 filtrar · r recarregar · q sair
* No detalhe: esc voltar · enter abrir plano · w rascunho UI · s sessão · o GitHub
* No detalhe: esc voltar · enter abrir plano · w design UI · s sessão · o GitHub
* No leitor: ↑↓ rolar · pgup/pgdn página · esc voltar
*/
@ -48,6 +48,7 @@ import {
buildPanelViewModel,
filterByState,
planLabel,
resolveUiDraft,
toJsonPayload,
visibleWindow,
} from './lib/issues-panel.mjs'
@ -99,7 +100,9 @@ const classificationTone = (classification) =>
/**
* Read the plan files for one row from disk (repo root = cwd). Missing files
* come back null — “sem plano” / “impl não criado” are first-class states.
* The UI draft only needs its existence verdict (content never rendered).
* The design UI only needs its existence verdict and real path (content never
* rendered): `resolveUiDraft` probes current `-ui-design.html` first and falls
* back to the legacy `-ui-draft.html`, keeping the precedence in the pure lib.
*/
const readPlans = (row) => {
const readIfExists = (path) => {
@ -111,11 +114,11 @@ const readPlans = (row) => {
return null
}
}
const uiDraftPath = row.uiDraft?.path
const isFileOnDisk = (path) => Boolean(path) && existsSync(resolve(process.cwd(), path))
return {
intention: readIfExists(row.plan.intention?.path),
impl: readIfExists(row.plan.impl?.path),
uiDraft: Boolean(uiDraftPath) && existsSync(resolve(process.cwd(), uiDraftPath)),
uiDraft: resolveUiDraft(row.plan.intention?.path ?? null, isFileOnDisk),
}
}
@ -336,8 +339,8 @@ const renderDetail = (row, { width, height, message }) => {
lines.push(dim('ações'))
push(
row.uiDraft.exists
? `${style(SGR.cyan, 'w')} abrir rascunho UI no browser · ${row.uiDraft.path}`
: dim('w rascunho UI: não tem (classe A)'),
? `${style(SGR.cyan, 'w')} abrir design UI no browser · ${row.uiDraft.path}`
: dim('w design UI: não tem (classe A)'),
)
if (row.session) {
push(
@ -356,7 +359,7 @@ const renderDetail = (row, { width, height, message }) => {
? tone('ok', truncateToWidth(message, width))
: dim(
truncateToWidth(
'esc voltar · enter abrir plano · w rascunho UI · s sessão · o GitHub',
'esc voltar · enter abrir plano · w design UI · s sessão · o GitHub',
width,
),
),
@ -634,7 +637,7 @@ const runTui = async ({ limit }) => {
if (key === 'esc') state.screen = 'list'
else if (key === 'o') message = openGithub(state.row).message
else if (key === 'w') {
if (!state.row.uiDraft.exists) message = 'rascunho UI: não tem (classe A)'
if (!state.row.uiDraft.exists) message = 'w design UI: não tem (classe A)'
else message = openInBrowser(state.row.uiDraft.path).message
} else if (key === 's') {
message = attachSession(state.row).message

View file

@ -2,7 +2,7 @@
* OPS109 — derivação pura do painel de issues (`pnpm issues:tui`).
*
* Todo o conhecimento do painel mora aqui: estado/ação da Issue, leitura do
* `Status:` dos planos, descoberta do plano de intenção/impl/rascunho UI e o
* `Status:` dos planos, descoberta do plano de intenção/impl/design UI e o
* casamento com a sessão do agente do OPS110. Puro por contrato — recebe os
* insumos (issues normalizadas do `github-api`, sessões do
* `agent-session list --json` e os planos já lidos do disco) e devolve o view
@ -88,16 +88,43 @@ export const classifyPlanStatus = (status) => {
/**
* Sibling-file conventions of the plans (`docs/plans/<slug>.md`,
* `docs/plans/<slug>-impl.md`, `docs/plans/<slug>-ui-draft.html`). Given the
* intent path, derive the other two; the caller confirms existence on disk.
* `docs/plans/<slug>-impl.md`, `docs/plans/<slug>-ui-design.html`,
* `docs/plans/<slug>-ui-draft.html`). Given the intent path, derive the other
* three; the caller confirms existence on disk. `uiDesign` is the current
* name (OPS114) and `uiDraft` is the legacy name (OPS109) — the two are
* alternative names for the SAME artifact, never two artifacts. The
* historical `uiDraft` key stays for retrocompat; `resolveUiDraft` owns the
* precedence between them.
* @param {string | null} planPath
*/
export const siblingPlanPaths = (planPath) => {
const path = typeof planPath === 'string' ? planPath.trim() : ''
if (!/\.md$/.test(path)) return { impl: null, uiDraft: null }
if (!/\.md$/.test(path)) return { impl: null, uiDesign: null, uiDraft: null }
const stem = path.replace(/\.md$/, '')
const base = stem.endsWith('-impl') ? stem.slice(0, -'-impl'.length) : stem
return { impl: `${base}-impl.md`, uiDraft: `${base}-ui-draft.html` }
return {
impl: `${base}-impl.md`,
uiDesign: `${base}-ui-design.html`,
uiDraft: `${base}-ui-draft.html`,
}
}
/**
* Pick the design artifact of a plan: `<base>-ui-design.html` (current name,
* OPS114) wins over `<base>-ui-draft.html` (legacy, immutable acervo). Pure by
* contract — the caller injects the existence predicate, so the lib never
* touches disk. When neither file exists the verdict is the current name with
* `exists: false`: the panel teaches the live convention and the legacy
* fallback still applies on disk.
* @param {string | null} planPath
* @param {(path: string) => boolean} [exists]
* @returns {{ path: string | null, exists: boolean }}
*/
export const resolveUiDraft = (planPath, exists = () => false) => {
const { uiDesign, uiDraft } = siblingPlanPaths(planPath)
if (uiDesign && exists(uiDesign)) return { path: uiDesign, exists: true }
if (uiDraft && exists(uiDraft)) return { path: uiDraft, exists: true }
return { path: uiDesign, exists: false }
}
/** Which issue state label the panel shows (closed wins as `done`). */
@ -195,16 +222,18 @@ const planView = (kind, path, markdown) => {
/**
* Build one row of the panel from an issue and its already-read artifacts.
* `intention`/`impl` carry the file contents, with `null`/missing meaning
* “file does not exist”; `uiDraft` is the existence verdict (the CLI checks
* the sibling `.html`; the panel never reads its content).
* “file does not exist”; `uiDraft` is the design verdict — the CLI checks the
* sibling `.html` (current `-ui-design` first, legacy `-ui-draft` as fallback)
* and hands back the real `{ path, exists }`; the panel never reads its
* content. The legacy key name stays: it means “design UI, new or legacy”.
* @param {object} issue normalized GitHub issue
* @param {{ intention?: string|null, impl?: string|null, uiDraft?: boolean, sessions?: Array<object>, doneIds?: Set<string>, knownIds?: Set<string>, repository?: string }} [context]
* @param {{ intention?: string|null, impl?: string|null, uiDraft?: { path: string|null, exists: boolean }|null, sessions?: Array<object>, doneIds?: Set<string>, knownIds?: Set<string>, repository?: string }} [context]
*/
export const buildIssueRow = (issue, context = {}) => {
const {
intention = null,
impl = null,
uiDraft = false,
uiDraft = null,
sessions = [],
doneIds = new Set(),
knownIds = new Set(),
@ -215,7 +244,7 @@ export const buildIssueRow = (issue, context = {}) => {
const siblings = siblingPlanPaths(planPath)
const intentionPath = planPath ?? siblings.impl?.replace(/-impl\.md$/, '.md') ?? null
const implPath = planPath && /-impl\.md$/.test(planPath) ? planPath : siblings.impl
const uiDraftPath = siblings.uiDraft
const designVerdict = uiDraft ?? resolveUiDraft(planPath)
const plan = {
intention: planView('intention', intentionPath, intention),
@ -237,7 +266,7 @@ export const buildIssueRow = (issue, context = {}) => {
summary: issueSummary(issue),
url: issueUrl(issue, repository),
plan,
uiDraft: { path: uiDraftPath, exists: Boolean(uiDraft) },
uiDraft: { path: designVerdict.path ?? null, exists: Boolean(designVerdict.exists) },
session: session
? {
status: session.status ?? 'unknown',
@ -311,7 +340,7 @@ export const visibleWindow = ({ count, cursor, capacity }) => {
* Assemble the full panel view model. Pure: the caller (CLI) does fetch,
* spawn and disk reads, then hands the raw inputs here. `limit` is the
* effective page size (GitHub caps `per_page` at 100 — the CLI clamps it).
* @param {{ issues: Array<object>, sessions?: Array<object>, plansByNumber?: Map<number, { intention?: string|null, impl?: string|null, uiDraft?: boolean }>, repository?: string, limit?: number }} input
* @param {{ issues: Array<object>, sessions?: Array<object>, plansByNumber?: Map<number, { intention?: string|null, impl?: string|null, uiDraft?: { path: string|null, exists: boolean }|null }>, repository?: string, limit?: number }} input
*/
export const buildPanelViewModel = ({
issues = [],
@ -335,7 +364,7 @@ export const buildPanelViewModel = ({
return buildIssueRow(issue, {
intention: plans.intention ?? null,
impl: plans.impl ?? null,
uiDraft: Boolean(plans.uiDraft),
uiDraft: plans.uiDraft,
sessions,
doneIds,
knownIds,
@ -353,7 +382,10 @@ export const buildPanelViewModel = ({
/**
* Render one issue as JSON-safe line for `--json` (already plain objects).
* Kept as a helper so the CLI contract is explicit about the shape.
* Kept as a helper so the CLI contract is explicit about the shape. The
* `uiDraft` key is the historical name of the design artifact and carries the
* REAL path found on disk (current `-ui-design`, else legacy `-ui-draft`) —
* `version: 1` and the `{ path, exists }` shape are frozen.
* @param {object} viewModel
*/
export const toJsonPayload = (viewModel) => ({

View file

@ -15,6 +15,7 @@ import {
issueSummary,
parsePlanStatus,
planLabel,
resolveUiDraft,
siblingPlanPaths,
sortRows,
toJsonPayload,
@ -87,10 +88,11 @@ describe('classifyPlanStatus (OPS109 — leitura de produto)', () => {
})
})
describe('siblingPlanPaths (OPS109)', () => {
it('derives impl and ui-draft from the intent path', () => {
describe('siblingPlanPaths (OPS109 / OPS116)', () => {
it('derives impl, ui-design and the legacy ui-draft from the intent path', () => {
expect(siblingPlanPaths('docs/plans/ops109-x.md')).toEqual({
impl: 'docs/plans/ops109-x-impl.md',
uiDesign: 'docs/plans/ops109-x-ui-design.html',
uiDraft: 'docs/plans/ops109-x-ui-draft.html',
})
})
@ -100,8 +102,46 @@ describe('siblingPlanPaths (OPS109)', () => {
})
it('returns nulls for a non-markdown path', () => {
expect(siblingPlanPaths(null)).toEqual({ impl: null, uiDraft: null })
expect(siblingPlanPaths('docs/plans/x.html')).toEqual({ impl: null, uiDraft: null })
expect(siblingPlanPaths(null)).toEqual({ impl: null, uiDesign: null, uiDraft: null })
expect(siblingPlanPaths('docs/plans/x.html')).toEqual({
impl: null,
uiDesign: null,
uiDraft: null,
})
})
})
describe('resolveUiDraft (OPS116 — design wins over the legacy draft)', () => {
const intent = 'docs/plans/ops116-x.md'
const design = 'docs/plans/ops116-x-ui-design.html'
const draft = 'docs/plans/ops116-x-ui-draft.html'
const only =
(...existing: string[]) =>
(path: string) =>
existing.includes(path)
it('prefers the current -ui-design.html when only it exists', () => {
expect(resolveUiDraft(intent, only(design))).toEqual({ path: design, exists: true })
})
it('falls back to the legacy -ui-draft.html (retrocompat)', () => {
expect(resolveUiDraft(intent, only(draft))).toEqual({ path: draft, exists: true })
})
it('uses the current name when both exist', () => {
expect(resolveUiDraft(intent, only(design, draft))).toEqual({ path: design, exists: true })
})
it('shows the current name with exists:false when neither exists', () => {
expect(resolveUiDraft(intent, only())).toEqual({ path: design, exists: false })
})
it('is pure — defaults to no file on disk', () => {
expect(resolveUiDraft(intent)).toEqual({ path: design, exists: false })
})
it('returns a null path when the plan has no markdown path', () => {
expect(resolveUiDraft(null, only(draft))).toEqual({ path: null, exists: false })
})
})
@ -150,8 +190,34 @@ describe('buildIssueRow (OPS109)', () => {
exists: true,
classification: 'andou',
})
expect(row.uiDraft).toEqual({
path: 'docs/plans/ops109-painel-issues-no-terminal-ui-design.html',
exists: false,
})
})
it('carries the real design path the CLI resolved on disk', () => {
const row = buildIssueRow(issue(), {
...deps,
uiDraft: { path: 'docs/plans/ops109-painel-issues-no-terminal-ui-draft.html', exists: true },
})
expect(row.uiDraft).toEqual({
path: 'docs/plans/ops109-painel-issues-no-terminal-ui-draft.html',
exists: true,
})
})
it('returns a null design path when the plan is missing', () => {
const row = buildIssueRow(issue({ body: '---\nid: OPS999\n---\nsem plano' }), deps)
expect(row.uiDraft).toEqual({ path: null, exists: false })
})
it('derives the same design artifact from an impl plan link', () => {
const implLinked = issue({
body: '---\nid: OPS109\n---\nPlano: [`docs/plans/ops109-painel-issues-no-terminal-impl.md`](docs/plans/ops109-painel-issues-no-terminal-impl.md)',
})
expect(buildIssueRow(implLinked, deps).uiDraft).toEqual({
path: 'docs/plans/ops109-painel-issues-no-terminal-ui-design.html',
exists: false,
})
})
@ -262,7 +328,10 @@ describe('buildPanelViewModel / toJsonPayload (OPS109)', () => {
{
intention: 'Status: aprovado\n',
impl: 'Status: em execução\n',
uiDraft: true,
uiDraft: {
path: 'docs/plans/ops109-painel-issues-no-terminal-ui-design.html',
exists: true,
},
},
],
])
@ -287,7 +356,10 @@ describe('buildPanelViewModel / toJsonPayload (OPS109)', () => {
const row = payload.rows.find((entry: { code?: string }) => entry.code === 'OPS109')
expect(row.plan.intention).toMatchObject({ classification: 'aprovado', status: 'aprovado' })
expect(row.plan.impl).toMatchObject({ classification: 'andou', status: 'em execução' })
expect(row.uiDraft.exists).toBe(true)
expect(row.uiDraft).toEqual({
path: 'docs/plans/ops109-painel-issues-no-terminal-ui-design.html',
exists: true,
})
expect(row.session).toBeNull()
expect(JSON.parse(JSON.stringify(payload))).toEqual(payload)
})

View file

@ -149,7 +149,14 @@ describe('toJsonPayload / buildPanelViewModel (OPS109 — no regressions)', () =
}),
],
plansByNumber: new Map([
[999, { intention: 'Status: aprovado\n', impl: null, uiDraft: false }],
[
999,
{
intention: 'Status: aprovado\n',
impl: null,
uiDraft: { path: 'docs/plans/test999-ui-draft.html', exists: true },
},
],
]),
limit: 200,
})
@ -157,5 +164,6 @@ describe('toJsonPayload / buildPanelViewModel (OPS109 — no regressions)', () =
const row = payload.rows[0]
expect(row.plan.intention).toMatchObject({ classification: 'aprovado', status: 'aprovado' })
expect(row.plan.impl).toMatchObject({ exists: false, classification: 'não criado' })
expect(row.uiDraft).toEqual({ path: 'docs/plans/test999-ui-draft.html', exists: true })
})
})