Pista de Entrega do Orkastery no Claude Code: do CHECK verde ao merge, cada entrega numa pista com a prova de cada etapa (bundle, branch publicada, PR, CI…

English · Português

In one sentence:
orkconducts AI coding agents (Claude Code and Codex) through parallel six-phase threads, checks every claim against the repository before it counts, and only calls you when the decision is really yours.
@orkastery/cli, with changes per version · reviewed on 2026-09-27main at PR #74, run 37102089623, 2026-10-03)docs/produto/ and docs/roadmap/, checked against the code by ork docs verificardocs/ are in Brazilian Portuguese, and the CLI reference is also in English. The CLI prints Brazilian Portuguese too, except ork doctor, ork init, ork onboarding, ork experiencia show, ork thread (except thread new --from-finding), ork phase list, ork phase run, ork verify, ork ship, ork pulse, ork master, ork retry plan, ork retry run, ork roadmap (status and reservations), ork worktree, ork claims, ork decisao placar, ork accounts, ork network (except network roadmap), ork docs, ork adapter and the help, which print English when owner.language or your locale asks for en; so do the orkastery.yaml comments, the AGENTS.md and CLAUDE.md blocks, the ork docs init templates and the skills, checklists, commands and subagents that ork adapter install copies; in English, read the quickstart on orkastery.com and the glossary of what the CLI printsnpm install -g @orkastery/cli
ork demo # 30 seconds: a false claim rejected, the fixed one accepted; no account, no model
ork doctor # what holds on this machine right now; exits != 0 when blocked
ork init # writes the orkastery.yaml; run it at the root of a git repository with at least one commit
ork modos # the four conduction modes
ork docs init # product docs and roadmap in the standard, with lint
The smallest full cycle, in #Fast mode (a single GO phase), right after ork init. <thread> is the ID that ork thread new prints.
git add orkastery.yaml AGENTS.md && git commit -m "ork init"
ork thread new "add an exclamation mark to greet" --modo fast
ork phase run <thread> GO --prompt "<the request>" # the agent runtime works in the thread's worktree
ork verify <thread> # re-runs the claims and the manifest checks on the real HEAD
ork ship <thread> --para main --autorizar-push <you> # #Fast never pushes without your authorization
ork master <thread> --aceitar-omissao # closes the thread with the index derived from the ledger
phase run stops with runtime.workspace-untrusted, run claude once in the worktree, accept the trust prompt, then ork retry run <thread>. ork pulse shows the same instruction.ork claims add <thread> <file> --claim "<statement>" --verificar "<command>".ork takes that seat. It is a deterministic TypeScript CLI, with no server and no embedded LLM.ork brings you is short and organized, with alternatives and one recommendation, so you decide in seconds without rereading a wall of text. That lightness is a design goal, meant also for people with ADHD: it keeps you conducting instead of drowning.Without ork | With ork |
|---|---|
| The agent says the tests pass | ork verify re-runs the command on the real HEAD, and CI re-runs it again on a clean runner |
| You ask for status several times a day | An hourly summary says what is pending, urgent, blocking or critical |
| Every obvious decision becomes a question | The obvious one comes decided and reported; you change it if you want |
| The runtime account runs out and the roadmap stops | The same prompt continues on the next profile or on the fallback runtime |
| Two sessions step on the same file | One worktree per thread, and leases with a queue |
| The docs lie about the code | ork docs verificar fails the PR when a page drifts |
Three layers, and business rules live only in the middle one:
flowchart TB
subgraph L1["Layer 1 - where you conduct"]
A["Claude Code, Codex, Hermes, OpenClaw<br/>adapters with no business rules"]
end
subgraph L2["Layer 2 - the ork core"]
B["schedules, isolates, verifies, serializes, records<br/>deterministic TypeScript, no LLM"]
end
subgraph L3["Layer 3 - who writes the code"]
C["claude-bg and codex runtimes<br/>their own accounts, profile rotation"]
end
A -->|"thread new, phase run, gate, ship"| B
B -->|"assembled prompt, sha256 in the ledger"| C
C -.->|"commits, diffs, command output"| B
B -.->|"summary, question batch, delivery"| A
flowchart LR
G(["GOAL"]) --> P(["PLAN"]) --> GO(["GO"]) --> C(["CHECK"]) --> S(["SHIP"]) --> M(["MASTER"])
C -.->|"failed: GO-FIX with a typed spec"| GO
| Phase | What it delivers | What ork requires to pass |
|---|---|---|
| GOAL | Objective, done criteria, claims with a command | a claim without a command is refused (claims.unverifiable) |
| PLAN | Tasks, decisions, an executable verify per task | a plan without an executable verify does not become GO |
| GO | One atomic commit per task, in the thread's worktree | writing outside the worktree is blocked by a lease |
| CHECK | Verification against the baseline, plus independent CI | a failed claim becomes claims.failed or verify.regression |
| SHIP | Serialized merge with a proven push | only counts when git ls-remote matches the local SHA |
| MASTER | Typed postmortem and a conduction index | the index comes from the ledger; nobody types it |
You write the #TAG in the request itself. The mode changes where the cycle waits for you; verifying the delivery is the same in all four.
| #TAG | Pauses | Cycle | What it is for |
|---|---|---|---|
| #Classic | 3 | GOAL* / PLAN* / GO-CHECK* / SHIP-MASTER | The ork init default: delicate premises |
| #Maestro | 1 | GOAL-PLAN* / GO-CHECK-SHIP / MASTER | A clear, quick solution |
| #Auto | 0 | GOAL-PLAN-GO-CHECK-SHIP-MASTER | Docs, studies, configuration, audits |
| #Fast | 0 | GO | A small, clear request that takes minutes; the push asks for authorization |
* marks the block that pauses. Source of truth: ork modos.#Look and #Ork were retired on 2026-09-24 (RM-043); what was recorded with them stays readable forever.#Fast runs only GO, with a minimal proof: a focused test, a cheap claim or an absence declared in the ledger. It never authorizes a push on its own and never touches a public contract (RM-042).The mode loosens the PAUSE. The mode NEVER loosens the VERIFICATION.
claims.jsonl. There are even negative claims, checked by the command that would falsify them.ork verify --baseline records the world before GO.ork ci prepare exports the claims, and the GitHub ork-verify job re-runs them on the exact SHA.git ls-remote, never by the exit code of git push.ork retry policy. cost.violation never gets an automatic retry.ork decisao registrar keeps the why, how to change it and the cost of changing it now or later; you are informed.owner.timezone, stated once per message.claude-bg (default) and codex, resolved by name in core/src/runtimes.ts; ork setup picks runtime, model and effort per block of each mode.ork accounts add <id> --runtime R --dir D runs the CLI's own login; ork never reads, copies or migrates a credential.cost.violation. Details and responsibilities in SECURITY.md.memory: orkmind, memory goes to the tenant's database; without it, it degrades to files with a typed reason.ork docs verificar: fails a source, symbol, contract or command that no longer exists, a merge commit outside main, and incoherent status.ork docs sincronizar --escrever: writes only facts from the ledger and git (merge, phase) and regenerates tables and indexes.documentacao job runs markdownlint and ork docs verificar on every PR.ork adapter list # the hosts and where each one installs
ork adapter install claude-code --dry-run
| Host | What gets installed |
|---|---|
| claude-code | A plugin with the catalog skills, phase subagents, the /orkastery:ork entry point and hooks |
| codex | The $ork entry point and the conduction catalog as project-local skills |
| hermes | A routing skill, the HITL ingress plugin and thread-opening scripts |
| openclaw | An extension with the ork_* tools, each one a CLI call |
ork modos --do-pedido; the core validates it.| Capability | Status |
|---|---|
| Threads, six phases, four modes, ledger, worktrees, leases, board | works |
Claims, baseline, verify on the real HEAD, 22 typed reasons, GO-FIX | works |
| CI as an independent CHECK on the exact SHA | works; native protection of main depends on the GitHub plan (RM-012) |
ork ship with a proven push | works |
| Account rotation across profiles and runtimes | works; account state is still per project (RM-040) |
| Layered HITL, a–d batch, decisions made and reported | works since 2026-09-24 (RM-041); automatic hourly summary since 2026-09-25 (RM-045) |
| Documentation as code with parity in CI | works (RM-044) |
#Fast mode: one phase, minimal proof, no push on its own | works since 2026-09-27 (RM-042) |
| Roadmap item reservations across machines | works since 2026-09-27 (RM-047); thread state is still per machine |
| Stable local verify on a machine whose CPU the hypervisor steals | does not exist: on such a machine, the reliable proof is CI (RM-037) |
A runtime_reported measurement of the context window | does not exist: claude-bg does not expose context usage, and the gate says unavailable |
| Deterministic code graph | works in the CLI (ork grafo: index, incremental update, queries and thread context, with each edge's provenance), for npm installs since 0.5.3; the MCP tools and the hint in the phase request stay behind the grafo.mcp flag, off by default (RM-031) |
The full picture, item by item and with git evidence, is in the roadmap index.
| Measure | Value | How to check |
|---|---|---|
| Core tests | 2,733, zero failing (CI on 2026-10-03) | npm --prefix core run test:ci |
| Behavior canaries | 24, all green | ork eval --so-canarios |
| Skills corpus | 20 skills, 101 cases, 199 assertions | ork eval --so-skills |
| Product docs and roadmap | zero parity errors | ork docs verificar |
| Runtime dependencies | 9, pinned | core/package.json |
On a virtual machine whose CPU the hypervisor steals (the steal in sar), the local verify can overrun the tests' time windows. That is why the merge gate is the independent CI, and the fix is planned in RM-037.
The docs home is organized by what you want to do now. Every page under docs/ is in Brazilian Portuguese; the CLI reference and the orchestration experience guide also have English versions, and the English quickstart lives on orkastery.com.
| Section | Start with |
|---|---|
| Getting started | Quickstart in English, and the full quickstart in Portuguese: from zero to the first full cycle |
| Guides | Modes, verification, human attention, memory, audits |
| Reference | CLI (the source is ork --help) and contracts |
| Concepts | Overview and architecture |
| Product and roadmap | What exists and what comes next, checked against the code |
Many commands still print Brazilian Portuguese (ork doctor, ork init, ork onboarding, ork experiencia show, ork thread (except thread new --from-finding), ork phase list, ork phase run, ork verify, ork ship, ork pulse, ork master, ork retry plan, ork retry run, ork roadmap (status and reservations), ork worktree, ork claims, ork decisao placar, ork accounts, ork network (except network roadmap), ork docs, ork adapter and the help follow an English locale). These are the words a first run shows.
| The CLI prints | It means |
|---|---|
PRONTO / BLOQUEADO | ready / blocked: the ork doctor verdict |
correcao | the fix for the line above it |
Proximo passo | next step |
VERDADE SUSTENTADA / REPROVADO | the claims held / failed: the ork verify verdict |
AGUARDANDO AUTORIZACAO HUMANA | waiting for your authorization |
Precisa de humano agora | needs a human now: the first line of ork pulse |
Recomendação, opção | recommendation, option |
aceitas por omissao | accepted by default, because nobody scored them |
pendente, respondida | pending, answered |
Horários em <zone> | times are shown in that time zone |
03/10 06:30 is October 3. The commands that follow an English locale print 2026-10-03 06:30.modos (modes), --modo (mode; --mode also works), --para (target branch), --por (who records it), --autorizar-push (authorize the push), --conteudo (content), --verificar (the command that verifies), decisao registrar (record a decision), docs verificar (check the docs), experiencia show (conversation preferences).owner.language: en-US sets the language of the conversation in your host (Claude Code, Codex), of the CLI commands that follow the locale and of what ork init, ork docs init and ork adapter install write for people to read (the orkastery.yaml comments, the AGENTS.md and CLAUDE.md blocks, the docs templates and the skills, checklists, commands and subagents the adapter installs); set it when machines with different locales conduct the same project, or those committed blocks switch language on each run; owner.language: pt-BR keeps them in Portuguese even with LANG=en_US.| Term | Meaning |
|---|---|
| HITL | human in the loop: a question only you can answer, with alternatives and one recommendation |
| ledger | the append-only event log of a thread, in .orkastery/threads/<thread>/ledger.jsonl; ork phase list <thread> prints it |
| claim | a statement, the file it is about and the command that judges it |
| worktree | the separate git checkout of each thread, under .claude/worktrees/ |
| lease | a lock on a shared resource, such as the main tree during a merge, with a queue |
| pulse | the hourly summary of what waits for you: ork pulse |
| maestro | the conversational overview in a host (say orkastery maestro), and the onboarding stage about you |
| GOAL … MASTER | the six phases, in the thread table |
| #Classic, #Maestro, #Auto, #Fast | the conduction modes, in the modes table |
ork docs sincronizar.ork roadmap pegar), and two never take the same one.| Project | What it is |
|---|---|
| orkastery | This repository: the ork core, the adapters, the skills and the auditors |
| OrkMind | The memory layer: Postgres with pgvector, an ontology and tag search; optional |
| orkastery.com | The website and user docs |
| orkmind.com | The OrkMind site |
orkastery maestro in a host with the adapter installed: you get an overview with sources, gaps, threads, sessions, HITL and next actions. In the terminal: ork maestro --json.No metronome ever turned anyone into Bernstein. Mastery is not tempo: it is knowing, after each performance, what you did, what it cost and how well it went, and being better at the next one. That is the phase we call MASTER, and it is why the project is called Orkastery.
hooks/register.tsx 440 lines1// A Pista de Entrega do Orkastery (RM-059, fatia F7; ficha M7 do plano dos mods). Na sessao do dono, um painel com a
2// pista de cada entrega: do CHECK verde ao merge, cada etapa com a prova que o nucleo deu e o proximo passo que ele
3// declara. A Pista le o nucleo pela ponte (`$.ork.retrato`, que diz quando uma entrega andou) e pelo comando de
4// leitura `ork ship pista --json`, nunca o estado em `.orkastery/` por conta propria (D8 da thread ork-rm059pista). A
5// logica fica nos modulos puros ao lado; aqui so os ganchos e as chamadas ao `$`, sempre escritas como
6// `x.noun.metodo(...)`, como o validador exige.
7import type { EngineInterface, Register } from 'claude-code'
8import type { PistaAcao, PistaCi, PistaLinha, PistaMostrado, PistaQuadro } from '../types'
9import type { AcaoDoBotao } from './acoes'
10import { argvDoPublicar, argvDoRegistro, botaoDaPista, lerPublicacao, lerRegistro, ROTULO_DO_BOTAO } from './acoes'
11import {
12 argvDoCi, comCi, INTERVALO_DO_CI_A_VISTA_MS, INTERVALO_DO_CI_ESCONDIDO_MS, lerCi, mudouOCi, pistasDoEvento, vencidasDoCi,
13} from './ci'
14import type { BotaoDaPista } from './pista'
15import {
16 argvDaPista, assinaturaDoRetrato, desenharPista, exigirThread, lerPista, linhasDoPainel, mostradosVisiveis, textoDaFaixa,
17 threadDoLedger, umaLinha,
18} from './pista'
19
20const MOD = 'ork-pista'
21const PAINEL = 'pista'
22const TITULO = 'Pista de Entrega'
23const QUADRO = { plugin: 'ork-pista', key: 'quadro' } as const
24const FOCO = { plugin: 'ork-pista', key: 'foco' } as const
25const ACOES = { plugin: 'ork-pista', key: 'acoes' } as const
26const CI = { plugin: 'ork-pista', key: 'ci' } as const
27const QUADRO_VAZIO: PistaQuadro = { linhas: [], lidoEm: null, erro: null, avisos: [] }
28/** O tique: idade da pista, assinatura do retrato e registro do mostrado. */
29const PERIODO_DO_TIQUE_MS = 60000
30/** A pista e relida por idade: a cada 5 min com o painel a vista, a cada 15 min escondido. */
31const IDADE_A_VISTA_MS = 5 * 60000
32const IDADE_ESCONDIDO_MS = 15 * 60000
33/** Entre duas leituras que nao foram pedidas pelo dono, no minimo isto: a leitura vai ao remoto e a forja. */
34const INTERVALO_MINIMO_MS = 30000
35/** Os ledgers que mudam juntos entram numa leitura so. */
36const ESPERA_DO_LOTE_MS = 5000
37/**
38 * Teto do `ork ship pista`: o `ls-remote --heads` (ate 60 s), a leitura dos PRs (ate 60 s) e o git local; 3 min deixam
39 * folga em rede lenta (S11 da revisao).
40 */
41const PRAZO_DA_PISTA_MS = 180000
42/**
43 * Teto do `ork ship publicar`: o pior caso do nucleo (ls-remote, lista de PRs, push, ls-remote, criacao e releitura do
44 * PR) soma 435 s; 9 min cabem nele com folga, abaixo dos 10 min que o motor da a um processo.
45 */
46const PRAZO_DO_PUBLICAR_MS = 540000
47/** Teto do `ork ship registrar-pr` (fetch, prova e CI). */
48const PRAZO_DO_REGISTRO_MS = 180000
49/** Teto do `ork ci status`: uma consulta ao GitHub. */
50const PRAZO_DO_CI_MS = 60000
51/** O servidor do nucleo pode conectar depois do inicio da sessao: o papel e conferido de novo nestas esperas. */
52const ESPERAS_DO_PAPEL_MS = [3000, 10000, 30000]
53const ESPERA_LONGA_DO_PAPEL_MS = 300000
54
55// O que o modulo guarda entre ganchos. O estado em `$.state` (o quadro e o foco) e o que o desenho le; aqui ficam a
56// copia dele, sempre gravada inteira por `$.state.set` sem versao (uma gravacao por mudanca, nunca o laco de
57// releitura no mesmo despacho), e o que nao se desenha. Uma recarga do modulo zera tudo, e o `session.start` dispara
58// de novo nela.
59let ativa = false
60let desistiu = false
61let ciclo: { cancel: () => void } | undefined
62let lote: { cancel: () => void } | undefined
63let lendo: Promise<void> | undefined
64let pendente = false
65let quadro: PistaQuadro = QUADRO_VAZIO
66let foco: string | null = null
67/** O ultimo botao de cada thread, como o estado o guarda; e as threads com um clique rodando no nucleo agora. */
68let acoes: Record<string, PistaAcao> = {}
69const emCurso = new Set<string>()
70/** O CI lido de cada pista (T7.4), e as pistas com uma consulta de CI rodando agora. */
71let ci: Record<string, PistaCi> = {}
72const ciEmCurso = new Set<string>()
73/** As leituras do CI que ja fizeram a pista ser relida (thread, head e estado): a mesma nao rele de novo. */
74const relidasPeloCi = new Set<string>()
75/** A ultima tentativa de leitura (ms), lida ou nao: segura o intervalo minimo. */
76let tentadaEm = 0
77let assinatura: string | null = null
78/** O que o desenho pos na janela de rolagem e ainda nao foi ao registro da ponte (D9). */
79const vistosNoDesenho = new Map<string, PistaMostrado>()
80/** O que ja foi ao registro nesta sessao, por chave e texto. */
81const jaMostrado = new Set<string>()
82/** O ultimo texto publicado na faixa; null antes da primeira publicacao. */
83let ultimaFaixa: string | undefined | null = null
84/** A falha de um trabalho em segundo plano que nem o quadro conseguiu guardar. */
85let problemaSemAviso: string | null = null
86
87function mensagem(erro: unknown): string {
88 return umaLinha(erro, 200)
89}
90
91/**
92 * Trabalho em segundo plano (o tique, o lote, a leitura pedida): a falha vai ao quadro, como aviso; sem como gravar o
93 * aviso (a sessao acabou ou o estado recusou), ela fica guardada aqui e o proximo desenho a mostra.
94 */
95function emSegundoPlano($: EngineInterface, trabalho: Promise<unknown>, oQue: string): void {
96 trabalho.catch(async erro => {
97 const texto = `${oQue} parou: ${mensagem(erro)}`
98 try {
99 await gravarQuadro($, { ...quadro, erro: texto })
100 } catch (erroDoAviso) {
101 problemaSemAviso = `${texto} (o aviso também falhou: ${mensagem(erroDoAviso)})`
102 }
103 })
104}
105
106async function gravarQuadro($: EngineInterface, novo: PistaQuadro): Promise<void> {
107 quadro = novo
108 await $.state.set(QUADRO, novo)
109}
110
111/**
112 * O papel pela ponte. `null` so quando o `$.ork` nao existe (a ponte nao carregou); uma falha da propria ponte conta
113 * como dormente e o papel e conferido de novo depois.
114 */
115async function papelDaSessao($: EngineInterface): Promise<string | null> {
116 try {
117 return String(await $.ork.papel())
118 } catch (erro) {
119 return erro instanceof TypeError ? null : 'dormente'
120 }
121}
122
123/** Liga a Pista na sessao do dono: o `/pista`, o tique e a primeira leitura. */
124async function ativar($: EngineInterface): Promise<void> {
125 if (ativa) return
126 ativa = true
127 try {
128 await $.command.register({ name: 'pista', description: 'Abre a Pista de Entrega: do CHECK verde ao merge, cada passo com o botão do núcleo', immediate: true })
129 } catch (erro) {
130 // Sem o /pista o painel nao abre: a Pista volta a dormente e tenta de novo na espera longa.
131 ativa = false
132 $.clock.after(ESPERA_LONGA_DO_PAPEL_MS, () => { emSegundoPlano($, conferirPapel($, ESPERAS_DO_PAPEL_MS.length), 'a conferência do papel') })
133 throw erro
134 }
135 quadro = (await $.state.get(QUADRO)).value ?? QUADRO_VAZIO
136 foco = (await $.state.get(FOCO)).value ?? null
137 // Depois de uma recarga do modulo, nenhum clique esta rodando: o que ficou "rodando" no estado vira falha.
138 const guardadas = (await $.state.get(ACOES)).value ?? {}
139 acoes = Object.fromEntries(Object.entries(guardadas).map(([t, a]) =>
140 [t, a.estado === 'rodando' ? { ...a, estado: 'falhou' as const, texto: 'o clique anterior foi interrompido por uma recarga' } : a]))
141 await $.state.set(ACOES, acoes)
142 ci = (await $.state.get(CI)).value ?? {}
143 ciclo?.cancel()
144 ciclo = $.clock.every(PERIODO_DO_TIQUE_MS, () => { emSegundoPlano($, tique($), 'o tique da Pista') })
145 agendar($, 1)
146}
147
148/** Confere o papel: liga no dono, desiste na fase ou sem a ponte, e tenta de novo enquanto dormente. */
149async function conferirPapel($: EngineInterface, tentativa: number): Promise<void> {
150 if (ativa || desistiu) return
151 const papel = await papelDaSessao($)
152 if (papel === 'dono') return ativar($)
153 if (papel === 'fase' || papel === null) {
154 desistiu = true
155 if (papel === null) $.ui.toast('pista: a ponte ork-ponte não carregou; a Pista de Entrega fica desligada nesta sessão')
156 return
157 }
158 const espera = ESPERAS_DO_PAPEL_MS[tentativa] ?? ESPERA_LONGA_DO_PAPEL_MS
159 $.clock.after(espera, () => { emSegundoPlano($, conferirPapel($, tentativa + 1), 'a conferência do papel') })
160}
161
162/** Uma leitura daqui a pouco, uma por vez: os ledgers que mudam juntos entram nela. */
163function agendar($: EngineInterface, ms = ESPERA_DO_LOTE_MS): void {
164 if (lote) return
165 lote = $.clock.after(Math.max(1, ms), () => {
166 lote = undefined
167 emSegundoPlano($, atualizar($), 'a leitura da pista')
168 })
169}
170
171/** O painel da Pista esta posto e a vista do dono. */
172async function painelAVista($: EngineInterface): Promise<boolean> {
173 const paineis = await $.ui.panes().catch(() => [])
174 return paineis.some(p => p.id === PAINEL && p.isPlaced && p.isShown)
175}
176
177/**
178 * Uma leitura por vez; pedido que chega no meio volta a rodar logo depois. Fora do pedido do dono (`forcar`), a
179 * leitura respeita o intervalo minimo e volta a ser agendada para quando ele vencer.
180 */
181async function atualizar($: EngineInterface, forcar = false): Promise<void> {
182 if (!ativa) return
183 const agora = await $.clock.now()
184 if (!forcar && agora - tentadaEm < INTERVALO_MINIMO_MS) {
185 agendar($, INTERVALO_MINIMO_MS - (agora - tentadaEm))
186 return
187 }
188 if (lendo) {
189 pendente = true
190 return lendo
191 }
192 lendo = (async () => {
193 try {
194 do {
195 pendente = false
196 await lerUmaVez($)
197 } while (pendente)
198 } finally {
199 lendo = undefined
200 }
201 })()
202 return lendo
203}
204
205/** A pista pelo nucleo (`ork ship pista --json`); a falha fica no quadro, com a leitura anterior. */
206async function lerUmaVez($: EngineInterface): Promise<void> {
207 tentadaEm = await $.clock.now()
208 try {
209 const r = await $.process.run(argvDaPista(), { timeoutMs: PRAZO_DA_PISTA_MS })
210 await gravarQuadro($, lerPista(r, await $.clock.now()))
211 // A leitura do CI que nao e mais do head de um PR aberto sai: o painel nao mostra CI de commit velho.
212 const atuais = Object.fromEntries(Object.entries(ci).filter(([t, c]) => quadro.linhas.some(l => l.thread === t && l.pr?.head === c.sha)))
213 if (Object.keys(atuais).length !== Object.keys(ci).length) {
214 ci = atuais
215 await $.state.set(CI, ci)
216 }
217 } catch (erro) {
218 await gravarQuadro($, { ...quadro, erro: mensagem(erro) })
219 }
220 await publicarFaixa($)
221 await registrarMostrados($)
222}
223
224/**
225 * O CI das pistas pedidas pelo nucleo (`ork ci status --sha <head> --json`, D7): pelo evento do GitHub que as cita, pelo
226 * relogio de reserva ou pelo dono. Uma consulta por pista por vez. Quando a leitura diz do CI outra coisa que a pista (o
227 * CI fechou, ou mudou depois de uma rodada nova), a pista inteira e relida, uma vez por leitura: o proximo passo do
228 * nucleo muda com ele.
229 */
230async function lerCiDe($: EngineInterface, threads: readonly string[], motivo: PistaCi['motivo']): Promise<void> {
231 let mudou = false
232 for (const thread of threads) {
233 const linha = quadro.linhas.find(l => l.thread === thread)
234 if (!linha?.pr || linha.pr.estado !== 'aberto' || ciEmCurso.has(thread)) continue
235 ciEmCurso.add(thread)
236 try {
237 const lido = lerCi(await $.process.run(argvDoCi(linha.pr.head), { timeoutMs: PRAZO_DO_CI_MS }), linha.pr.head, await $.clock.now(), motivo)
238 const chave = `${thread}|${lido.sha}|${lido.estado}`
239 if (mudouOCi(linha, lido) && !relidasPeloCi.has(chave)) {
240 relidasPeloCi.add(chave)
241 mudou = true
242 }
243 ci = { ...ci, [thread]: lido }
244 await $.state.set(CI, ci)
245 } catch (erro) {
246 ci = { ...ci, [thread]: { sha: linha.pr.head, estado: 'unavailable', texto: `o CI não foi lido: ${mensagem(erro)}`, url: null,
247 lidoEm: await $.clock.now(), motivo } }
248 await $.state.set(CI, ci)
249 } finally {
250 ciEmCurso.delete(thread)
251 }
252 }
253 await publicarFaixa($)
254 if (mudou) await atualizar($, true)
255}
256
257/** O tique: a assinatura do retrato (uma entrega andou), a idade da pista e o registro do que o painel mostrou. */
258async function tique($: EngineInterface): Promise<void> {
259 if (!ativa) return
260 const agora = await $.clock.now()
261 const aVista = await painelAVista($)
262 let nova: string | null = null
263 try {
264 nova = assinaturaDoRetrato(await $.ork.retrato({ secao: 'ship' }))
265 } catch {
266 // O retrato nao veio: so a idade decide nesta volta.
267 }
268 const mudou = nova !== null && assinatura !== null && nova !== assinatura
269 if (nova !== null) assinatura = nova
270 const idade = quadro.lidoEm === null ? Infinity : agora - quadro.lidoEm
271 if (mudou || idade >= (aVista ? IDADE_A_VISTA_MS : IDADE_ESCONDIDO_MS)) await atualizar($)
272 // A reserva do CI sem evento do GitHub (D7): so as pistas com PR aberto e CI ainda aberto.
273 const vencidas = vencidasDoCi(quadro.linhas, ci, agora, aVista ? INTERVALO_DO_CI_A_VISTA_MS : INTERVALO_DO_CI_ESCONDIDO_MS)
274 if (vencidas.length) await lerCiDe($, vencidas, 'consulta')
275 await registrarMostrados($)
276}
277
278/** A linha da faixa pela ponte (D9), so quando o texto muda; sem nada que peca o dono, a linha sai da faixa. */
279async function publicarFaixa($: EngineInterface): Promise<void> {
280 const texto = textoDaFaixa(comCi(quadro, ci))
281 if (texto === ultimaFaixa) return
282 try {
283 if (texto === undefined) await $.ork.faixa({ mod: MOD })
284 else await $.ork.faixa({ mod: MOD, texto })
285 ultimaFaixa = texto
286 } catch {
287 // A ponte recusou ou falhou: a proxima leitura tenta de novo.
288 }
289}
290
291/**
292 * O que o desenho pos na janela de rolagem do painel posto e a vista vai ao registro da ponte (M5 do dono, D9), uma
293 * vez por chave e texto enquanto o texto existir na pista: o Telegram nao repete o que o dono ja viu no terminal.
294 */
295async function registrarMostrados($: EngineInterface): Promise<void> {
296 const existentes = new Set(linhasDoPainel(comCi(quadro, ci), { agora: 0, foco, acoes }).flatMap(l => (l.mostrado ? [`${l.mostrado.chave}|${l.mostrado.texto}`] : [])))
297 for (const vista of [...jaMostrado]) if (!existentes.has(vista)) jaMostrado.delete(vista)
298 for (const vista of [...vistosNoDesenho.keys()]) if (!existentes.has(vista)) vistosNoDesenho.delete(vista)
299 if (vistosNoDesenho.size === 0) return
300 // Com o painel escondido, o que foi anotado e descartado: o proximo desenho a vista anota de novo.
301 if (!await painelAVista($)) { vistosNoDesenho.clear(); return }
302 for (const [vista, m] of [...vistosNoDesenho]) {
303 vistosNoDesenho.delete(vista)
304 if (jaMostrado.has(vista)) continue
305 try {
306 await $.ork.mostrado({ mod: MOD, canal: 'painel', chave: m.chave, thread: m.thread, texto: m.texto })
307 jaMostrado.add(vista)
308 } catch {
309 // A ponte recusou ou falhou: volta para a proxima vez.
310 vistosNoDesenho.set(vista, m)
311 }
312 }
313}
314
315/** Os botoes que o nucleo declara agora, um por pista (D6). */
316function botoesDo(q: PistaQuadro): Record<string, BotaoDaPista> {
317 const saida: Record<string, BotaoDaPista> = {}
318 for (const l of q.linhas) {
319 const acao = botaoDaPista(l)
320 if (acao) saida[l.thread] = { acao, rotulo: ROTULO_DO_BOTAO[acao] }
321 }
322 return saida
323}
324
325async function gravarAcao($: EngineInterface, thread: string, a: PistaAcao): Promise<void> {
326 acoes = { ...acoes, [thread]: a }
327 await $.state.set(ACOES, acoes)
328}
329
330/**
331 * O clique num botao (D6). Primeiro relê so a pista da thread: o clique vale contra o que o nucleo declara agora, e
332 * nao contra o desenho de antes. Se a acao continua disponivel, roda o comando do nucleo pelo construtor de argv deste
333 * mod (nunca `git` nem `gh`, nunca texto vindo do JSON) e guarda o resultado na linha da pista. Um clique por vez por
334 * thread; o painel e relido no fim.
335 */
336async function acionar($: EngineInterface, thread: string, acao: AcaoDoBotao): Promise<void> {
337 if (emCurso.has(thread)) return
338 emCurso.add(thread)
339 try {
340 await gravarAcao($, thread, { acao, estado: 'rodando', texto: '', em: await $.clock.now() })
341 let atual: PistaLinha | undefined
342 try {
343 const relida = lerPista(await $.process.run(argvDaPista(thread), { timeoutMs: PRAZO_DA_PISTA_MS }), await $.clock.now())
344 atual = relida.linhas.find(l => l.thread === thread)
345 } catch (erro) {
346 await gravarAcao($, thread, { acao, estado: 'falhou', texto: `a releitura da pista falhou: ${mensagem(erro)}`, em: await $.clock.now() })
347 return
348 }
349 if (!atual || botaoDaPista(atual) !== acao) {
350 const agora = atual ? atual.proximo.texto : 'a thread saiu da pista'
351 await gravarAcao($, thread, { acao, estado: 'mudou', texto: `o núcleo não declara mais esta ação: ${agora}`, em: await $.clock.now() })
352 return
353 }
354 try {
355 const r = acao === 'publicar'
356 ? lerPublicacao(await $.process.run(argvDoPublicar(thread), { timeoutMs: PRAZO_DO_PUBLICAR_MS }))
357 : lerRegistro(await $.process.run(argvDoRegistro(thread), { timeoutMs: PRAZO_DO_REGISTRO_MS }), thread)
358 await gravarAcao($, thread, { acao, estado: r.estado, texto: r.texto, em: await $.clock.now() })
359 } catch (erro) {
360 await gravarAcao($, thread, { acao, estado: 'falhou', texto: mensagem(erro), em: await $.clock.now() })
361 }
362 } finally {
363 emCurso.delete(thread)
364 await atualizar($, true)
365 }
366}
367
368/** O desenho anota o que pos na janela; quem registra e o tique ou a leitura, fora do desenho. */
369function anotarVistos(vistos: readonly PistaMostrado[]): void {
370 for (const m of vistos) vistosNoDesenho.set(`${m.chave}|${m.texto}`, m)
371}
372
373export const register: Register = on => {
374 on('session.start', async ($, e, next) => {
375 if (e.isInteractive) await conferirPapel($, 0)
376 return next(e)
377 })
378
379 // `/pista [thread]` abre o painel em qualquer largura e rele a pista agora. Com uma thread (a Torre abre a Pista
380 // assim), ela fica em foco no alto do painel. O comando so existe com a Pista ligada; a conferencia do papel aqui so
381 // cobre o caso raro de o comando ter sobrado de uma carga anterior.
382 on('command.run', { command: 'pista' }, async ($, e) => {
383 if (!ativa && !desistiu) await conferirPapel($, ESPERAS_DO_PAPEL_MS.length)
384 if (!ativa) return { text: 'A Pista de Entrega só abre na sessão do dono de um projeto do Orkastery.' }
385 const pedida = typeof e.args === 'string' ? e.args.trim() : ''
386 let aviso = ''
387 if (pedida) {
388 try {
389 foco = exigirThread(pedida)
390 } catch {
391 aviso = ` O id ${umaLinha(pedida, 80)} não é de thread; a pista abre sem foco.`
392 foco = null
393 }
394 } else foco = null
395 await $.state.set(FOCO, foco)
396 const aberto = await $.ui.open({ id: PAINEL, title: TITULO })
397 emSegundoPlano($, atualizar($, true), 'a leitura da pista')
398 return { text: (aberto.isPlaced ? 'Pista de Entrega aberta.' : `Pista de Entrega aberta, à espera de lugar: ${aberto.reason}`) + aviso }
399 })
400
401 // Uma entrega do GitHub chegou a sessao (D7): o evento que cita o PR, a branch ou o head de uma pista so dispara a
402 // consulta do CI pelo nucleo, em segundo plano, e o conteudo dele nunca vira estado. A entrega segue sempre para o
403 // modelo, como chegou: a Pista nao consome nem reescreve nada.
404 on('session.receive', async ($, e, next) => {
405 if (ativa && e.event?.source === 'github') {
406 const citadas = pistasDoEvento(e.event, quadro.linhas)
407 if (citadas.length) emSegundoPlano($, lerCiDe($, citadas, 'evento'), 'o CI pelo evento do GitHub')
408 }
409 return next(e)
410 }).catch(($, e, next) => next(e))
411
412 // O ledger de uma thread mudou (commit, verify, claim, entrega): a releitura entra no proximo lote. Os caminhos
413 // vigiados sao os que a ponte pede para o papel dono; a Pista nao pede outros.
414 on('classic.FileChanged', async ($, e, next) => {
415 if (ativa && threadDoLedger(e.file_path) !== null) agendar($)
416 return next(e)
417 }).catch(($, e, next) => next(e))
418
419 // O painel: uma pista por entrega, com as etapas e o proximo passo do nucleo. O desenho nunca escreve: so anota o
420 // que pos na janela de rolagem, para o registro do mostrado (D9).
421 on('ui.render', { component: 'Pane', requestId: PAINEL }, async ($, e) => {
422 const { Box, Text, Button } = $.ui.resolve(e)
423 const q = (await $.state.get(QUADRO)).value ?? QUADRO_VAZIO
424 const emFoco = (await $.state.get(FOCO)).value ?? null
425 const ultimas = (await $.state.get(ACOES)).value ?? {}
426 const lidos = (await $.state.get(CI)).value ?? {}
427 const agora = await $.clock.now()
428 const comLeitura = comCi(q, lidos)
429 const comProblema: PistaQuadro = problemaSemAviso ? { ...comLeitura, avisos: [...comLeitura.avisos, problemaSemAviso] } : comLeitura
430 const botoes = botoesDo(comProblema)
431 anotarVistos(mostradosVisiveis(linhasDoPainel(comProblema, { agora, foco: emFoco, acoes: ultimas, botoes }), e.props.scroll, e.props.bodyColumns))
432 return desenharPista({ Box, Text, Button }, comProblema, {
433 agora, foco: emFoco, acoes: ultimas, botoes,
434 atualizar: () => { emSegundoPlano($, atualizar($, true), 'a leitura da pista') },
435 publicar: thread => { emSegundoPlano($, acionar($, thread, 'publicar'), 'o botão Publicar') },
436 registrar: thread => { emSegundoPlano($, acionar($, thread, 'registrar'), 'o botão Registrar') },
437 })
438 })
439}
440hooks/acoes.ts 72 lines1// Os botoes da Pista (RM-059, fatia F7, T7.3; D6 da thread ork-rm059pista). Modulo puro: o botao que o nucleo declara
2// para cada pista, o argv dos dois comandos que um botao chama e a leitura do resultado de cada um. Botao so existe
3// quando o nucleo declara a acao com `disponivel`, e o argv sai sempre daqui, nunca do texto que veio no JSON: a Pista
4// so publica pelo `ork ship publicar` (que so age em #Auto e #Maestro) e so registra pelo `ork ship registrar-pr`.
5import type { PistaAcao, PistaLinha } from '../types'
6import { exigirThread, jsonDaSaida, umaLinha } from './pista'
7import type { SaidaDoProcesso } from './pista'
8
9export type AcaoDoBotao = PistaAcao['acao']
10
11/** O rotulo de cada botao no painel. */
12export const ROTULO_DO_BOTAO: Readonly<Record<AcaoDoBotao, string>> = { publicar: 'Publicar', registrar: 'Registrar a entrega' }
13
14/** O argv do `ork ship publicar`, sem flag de autorizacao: quem decide se o modo autoriza o push e o nucleo. */
15export function argvDoPublicar(thread: string): string[] {
16 return ['ork', 'ship', 'publicar', exigirThread(thread), '--json']
17}
18
19/** O argv do `ork ship registrar-pr` da thread: o nucleo confere o merge na base e o CI antes de gravar o `ship_done`. */
20export function argvDoRegistro(thread: string): string[] {
21 return ['ork', 'ship', 'registrar-pr', exigirThread(thread), '--json']
22}
23
24/**
25 * O botao da pista (D6): so `publicar`, no modo que ja autoriza o push, e `registrar`, e so quando o nucleo os declara
26 * disponiveis. Fora disso, nenhum botao: o proximo passo fica como texto, com o comando para o dono.
27 */
28export function botaoDaPista(l: PistaLinha): AcaoDoBotao | null {
29 if (!l.proximo.disponivel) return null
30 if (l.proximo.acao === 'publicar') return l.publicavel ? 'publicar' : null
31 if (l.proximo.acao === 'registrar') return 'registrar'
32 return null
33}
34
35export type ResultadoDoBotao = { estado: 'feita' | 'recusada' | 'falhou'; texto: string }
36
37const curto = (sha: unknown) => (typeof sha === 'string' && /^[0-9a-f]{7,40}$/.test(sha) ? sha.slice(0, 7) : '-')
38const texto = (v: unknown, teto = 300) => (typeof v === 'string' ? umaLinha(v, teto) : '')
39
40/** O resultado do `ork ship publicar --json` (`ork.ship-publicar/v1`), numa linha para o painel. */
41export function lerPublicacao(saida: SaidaDoProcesso): ResultadoDoBotao {
42 const r = jsonDaSaida(saida, 'ork ship publicar') as Record<string, unknown> | null
43 if (!r || typeof r !== 'object' || r.contrato !== 'ork.ship-publicar/v1') {
44 return { estado: 'falhou', texto: 'a saída do ork ship publicar não é o contrato ork.ship-publicar/v1' }
45 }
46 const pr = (r.pr ?? null) as { numero?: unknown; criado?: unknown } | null
47 const numero = pr && Number.isSafeInteger(pr.numero) ? `PR #${pr.numero as number}` : 'sem PR'
48 if (r.acao === 'publicou') {
49 return { estado: 'feita', texto: `publicada no HEAD ${curto(r.head)}, push provado pelo ls-remote; ${numero} ${pr?.criado === true ? 'aberto agora' : 'reaproveitado'}` }
50 }
51 if (r.acao === 'ja-publicada') {
52 return { estado: 'feita', texto: `já estava publicada no HEAD ${curto(r.head)}; ${numero}${texto(r.correcao) ? `; ${texto(r.correcao)}` : ''}` }
53 }
54 if (r.acao === 'recusada') {
55 const correcao = texto(r.correcao, 400)
56 return { estado: 'recusada', texto: `recusada (${texto(r.motivo, 60) || 'sem motivo'}): ${texto(r.detalhe) || 'sem detalhe'}${correcao ? `; ${correcao}` : ''}` }
57 }
58 return { estado: 'falhou', texto: `o ork ship publicar respondeu ${texto(r.acao, 40) || 'sem ação'}` }
59}
60
61/** O resultado do `ork ship registrar-pr <thread> --json` (uma lista de entregas), numa linha para o painel. */
62export function lerRegistro(saida: SaidaDoProcesso, thread: string): ResultadoDoBotao {
63 const lista = jsonDaSaida(saida, 'ork ship registrar-pr')
64 const item = Array.isArray(lista) ? (lista as Array<Record<string, unknown> | null>).find(x => x?.thread === thread) : undefined
65 if (!item) return { estado: 'falhou', texto: `o ork ship registrar-pr não respondeu pela thread ${thread}` }
66 const motivo = texto(item.motivo) || 'sem motivo'
67 if (item.acao === 'registrou') return { estado: 'feita', texto: `ship_done registrado: ${motivo}` }
68 if (item.acao === 'ja-registrada') return { estado: 'feita', texto: `a entrega já estava registrada: ${motivo}` }
69 if (item.acao === 'sem-merge' || item.acao === 'recusada') return { estado: 'recusada', texto: `${String(item.acao)}: ${motivo}` }
70 return { estado: 'falhou', texto: `o ork ship registrar-pr respondeu ${texto(item.acao, 40) || 'sem ação'}` }
71}
72hooks/ci.ts 122 lines1// O CI de cada pista (RM-059, fatia F7, T7.4; D7 da thread ork-rm059pista). Modulo puro: o argv da consulta ao nucleo
2// (`ork ci status --sha <sha> --json`, contrato `ork.ci-status/v2`; a v1 dos nucleos anteriores continua lida), a
3// leitura dela, as pistas que um evento do GitHub cita, as que o relogio de reserva consulta e a etapa de CI que o
4// painel mostra com a leitura mais nova. O evento so dispara a consulta: o conteudo dele nunca vira estado, e quem diz o
5// CI e o check do nucleo no sha exato.
6import type { PistaCi, PistaEstadoDaEtapa, PistaLinha, PistaQuadro } from '../types'
7import { jsonDaSaida, umaLinha } from './pista'
8import type { SaidaDoProcesso } from './pista'
9
10/** A reserva sem evento: a cada 2 min com o painel a vista, a cada 10 min escondido. */
11export const INTERVALO_DO_CI_A_VISTA_MS = 2 * 60000
12export const INTERVALO_DO_CI_ESCONDIDO_MS = 10 * 60000
13const SHA = /^[0-9a-f]{40}$/
14const ESTADOS: readonly PistaCi['estado'][] = ['success', 'pending', 'failure', 'missing', 'unavailable', 'disabled']
15
16/** O argv da consulta do CI no sha exato do head do PR, nunca por ref nem prefixo. */
17export function argvDoCi(sha: string): string[] {
18 if (typeof sha !== 'string' || !SHA.test(sha)) throw new Error('ork-pista: sha invalido para o ork ci status')
19 return ['ork', 'ci', 'status', '--sha', sha, '--json']
20}
21
22/** Os contratos do `ork ci status --json` que a Pista le: a v2 (forja, fonte e produtor) e a v1 de antes. */
23const CONTRATOS_DO_CI: readonly string[] = ['ork.ci-status/v1', 'ork.ci-status/v2']
24
25/**
26 * A saida do `ork ci status --sha <sha> --json`: o comando sai 0 so com o check verde, e o JSON vem nos dois casos.
27 * Leitura de outro sha nao vale para a pista.
28 */
29export function lerCi(saida: SaidaDoProcesso, sha: string, agora: number, motivo: PistaCi['motivo']): PistaCi {
30 const r = jsonDaSaida(saida, 'ork ci status') as Record<string, unknown> | null
31 if (!r || typeof r !== 'object' || !CONTRATOS_DO_CI.includes(r.schema as string) || !ESTADOS.includes(r.state as PistaCi['estado'])) {
32 throw new Error('a saída do ork ci status não é o contrato ork.ci-status (v1 ou v2)')
33 }
34 if (r.sha !== sha) throw new Error(`o ork ci status respondeu pelo commit ${umaLinha(r.sha, 40)}, e não pelo head ${sha.slice(0, 7)}`)
35 const estado = r.state as PistaCi['estado']
36 const contexto = typeof r.context === 'string' && r.context ? umaLinha(r.context, 60) : 'ork-verify'
37 const curto = sha.slice(0, 7)
38 const texto = estado === 'success' ? `check ${contexto} verde no head ${curto}`
39 : estado === 'failure' ? `check ${contexto} vermelho no head ${curto}`
40 : estado === 'pending' ? `check ${contexto} em andamento no head ${curto}`
41 : estado === 'missing' ? `o check ${contexto} ainda não apareceu no head ${curto}`
42 : estado === 'disabled' ? 'CI não exigido pelo manifesto'
43 : `o CI não foi lido: ${umaLinha(r.detail, 160) || 'sem detalhe'}`
44 const url = typeof r.url === 'string' && /^https:\/\/\S+$/.test(r.url) ? umaLinha(r.url, 300) : null
45 return { sha, estado, texto, url, lidoEm: agora, motivo }
46}
47
48/** A pista com PR aberto cujo CI a leitura da pista ainda nao fechou (nem verde nem vermelho). */
49export function precisaDoCi(l: PistaLinha): boolean {
50 const etapa = l.etapas.find(e => e.id === 'ci')
51 return l.pr?.estado === 'aberto' && SHA.test(l.pr.head) && (etapa?.estado === 'pendente' || etapa?.estado === 'nao-lida')
52}
53
54/** As pistas que o relogio de reserva consulta agora: as que precisam, sem leitura do head atual dentro do intervalo. */
55export function vencidasDoCi(linhas: readonly PistaLinha[], ci: Readonly<Record<string, PistaCi>>, agora: number, intervalo: number): string[] {
56 return linhas.filter(l => {
57 if (!precisaDoCi(l)) return false
58 const lido = ci[l.thread]
59 return !lido || lido.sha !== l.pr?.head || agora - lido.lidoEm >= intervalo
60 }).map(l => l.thread)
61}
62
63/**
64 * As pistas que um evento do GitHub cita, so pelos campos que o servidor assere (fora de `untrustedKeys`): o PR (o
65 * numero, ou `dono/repo#N`), a branch ou o sha. Evento de outra fonte, ou que nao cita pista nenhuma, nao consulta nada.
66 */
67export function pistasDoEvento(evento: unknown, linhas: readonly PistaLinha[]): string[] {
68 if (!evento || typeof evento !== 'object') return []
69 const e = evento as { source?: unknown; data?: unknown; untrustedKeys?: unknown }
70 if (e.source !== 'github' || !e.data || typeof e.data !== 'object') return []
71 const deTerceiros = new Set(Array.isArray(e.untrustedKeys) ? e.untrustedKeys.map(String) : [])
72 const numeros = new Set<number>()
73 const textos = new Set<string>()
74 for (const [chave, valor] of Object.entries(e.data as Record<string, unknown>)) {
75 if (deTerceiros.has(chave)) continue
76 if (typeof valor === 'number' && Number.isSafeInteger(valor) && valor > 0 && /^(pr|number|pull_request|pull)$/i.test(chave)) numeros.add(valor)
77 if (typeof valor !== 'string') continue
78 const pr = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+#(\d{1,9})$/.exec(valor.trim()) ?? (/^(pr|number|pull_request|pull)$/i.test(chave) ? /^#?(\d{1,9})$/.exec(valor.trim()) : null)
79 if (pr) numeros.add(Number(pr[1]))
80 textos.add(valor.trim())
81 }
82 return linhas.filter(l => (l.pr && numeros.has(l.pr.numero)) || textos.has(l.branch) || (!!l.head && textos.has(l.head)) ||
83 (!!l.pr && textos.has(l.pr.head))).map(l => l.thread)
84}
85
86const ETAPA_DO_CI: Readonly<Record<PistaCi['estado'], PistaEstadoDaEtapa>> = {
87 success: 'feita', failure: 'falhou', pending: 'pendente', missing: 'pendente', unavailable: 'nao-lida', disabled: 'nao-se-aplica',
88}
89
90/** A consulta leu o check no head: `unavailable` (a consulta falhou) e `disabled` (o CI nao e exigido) nao leram nada. */
91function leuOCheck(lido: PistaCi): boolean {
92 return lido.estado !== 'unavailable' && lido.estado !== 'disabled'
93}
94
95/**
96 * A pista com a leitura do CI mais nova por cima da etapa da leitura da pista, so quando ela e do head do PR aberto. A
97 * consulta que nao leu nada nunca apaga o que o nucleo ja leu: a etapa fica como o nucleo disse, com a falha ao lado.
98 */
99export function comCi(q: PistaQuadro, ci: Readonly<Record<string, PistaCi>>): PistaQuadro {
100 return {
101 ...q,
102 linhas: q.linhas.map(l => {
103 const lido = ci[l.thread]
104 if (!lido || l.pr?.estado !== 'aberto' || lido.sha !== l.pr.head) return l
105 if (!leuOCheck(lido)) {
106 return { ...l, etapas: l.etapas.map(e => (e.id === 'ci' ? { ...e, texto: `${e.texto} (consulta: ${lido.texto})` } : e)) }
107 }
108 const origem = lido.motivo === 'evento' ? ' (pelo evento do GitHub)' : ''
109 return { ...l, etapas: l.etapas.map(e => (e.id === 'ci' ? { id: 'ci', estado: ETAPA_DO_CI[lido.estado], texto: `${lido.texto}${origem}` } : e)) }
110 }),
111 }
112}
113
114/**
115 * A leitura nova diz do CI outra coisa que a etapa da pista: o CI fechou, ou mudou depois de uma rodada nova. O proximo
116 * passo do nucleo muda com ele, e a pista inteira e relida. A consulta que nao leu nada nao muda nada.
117 */
118export function mudouOCi(l: PistaLinha, lido: PistaCi): boolean {
119 if (!leuOCheck(lido) || l.pr?.estado !== 'aberto' || lido.sha !== l.pr.head) return false
120 return l.etapas.find(e => e.id === 'ci')?.estado !== ETAPA_DO_CI[lido.estado]
121}
122hooks/pista.tsx 357 lines1// A pista de entrega (RM-059, fatia F7; ficha M7 do plano dos mods). Modulo puro: o argv da leitura do nucleo
2// (`ork ship pista --json`, contrato `ork.ship-pista/v1`), a conferencia da saida campo a campo, a ordem das pistas, a
3// linha da faixa, as linhas do painel com o que cada uma mostra ao dono e o desenho. Quem calcula as etapas e o
4// proximo passo e o nucleo (D4 da thread ork-rm059pista); aqui so se le, se ordena e se desenha. O `$` fica no
5// register.tsx, e o desenho recebe a tabela de elementos que a superficie resolveu.
6import type { PistaAcao, PistaEstadoDaEtapa, PistaEtapa, PistaIdDaEtapa, PistaLinha, PistaMostrado, PistaPr, PistaProximo, PistaQuadro } from '../types'
7
8export const ETAPAS: readonly PistaIdDaEtapa[] = ['check', 'verify', 'bundle', 'branch', 'pr', 'ci', 'conflito', 'entrega']
9const ESTADOS: readonly PistaEstadoDaEtapa[] = ['feita', 'pendente', 'falhou', 'nao-lida', 'nao-se-aplica']
10const ID_DE_THREAD = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/
11const SHA = /^[0-9a-f]{40}$/
12/** O nome curto de cada etapa no painel. */
13export const NOME_DA_ETAPA: Readonly<Record<PistaIdDaEtapa, string>> = {
14 check: 'CHECK', verify: 'verify', bundle: 'bundle', branch: 'branch', pr: 'PR', ci: 'CI', conflito: 'conflito', entrega: 'entrega',
15}
16/** A marca de cada estado de etapa: o que falta e o que falhou saltam aos olhos. */
17export const MARCA_DA_ETAPA: Readonly<Record<PistaEstadoDaEtapa, string>> = {
18 'feita': '✓', 'pendente': '·', 'falhou': '✗', 'nao-lida': '?', 'nao-se-aplica': '-',
19}
20/** Teto de pistas no quadro: cada uma ocupa umas cinco linhas do painel. */
21export const TETO_DE_PISTAS = 30
22const TETO_DE_TEXTO = 300
23
24export type SaidaDoProcesso = { exitCode: number; stdout: string; stderr: string }
25
26/** Uma linha so, sem caractere de controle, com teto: texto que vem do nucleo e vai para a tela. */
27export function umaLinha(v: unknown, teto = TETO_DE_TEXTO): string {
28 const s = typeof v === 'string' ? v : v instanceof Error ? v.message : String(v)
29 return Array.from(s.replace(/\p{Cc}/gu, ' ').replace(/\s+/g, ' ').trim()).slice(0, teto).join('')
30}
31
32export function exigirThread(thread: unknown): string {
33 if (typeof thread !== 'string' || !ID_DE_THREAD.test(thread)) throw new Error('ork-pista: id de thread invalido')
34 return thread
35}
36
37/** O argv da leitura da pista, de todas as threads ou de uma: o unico jeito de a Pista ler as etapas. */
38export function argvDaPista(thread?: string): string[] {
39 return thread === undefined ? ['ork', 'ship', 'pista', '--json'] : ['ork', 'ship', 'pista', exigirThread(thread), '--json']
40}
41
42/** A saida de um comando `--json` do nucleo, ou o motivo de nao ter sido lida (o CLI sem o subcomando incluso). */
43export function jsonDaSaida(saida: SaidaDoProcesso, comando: string): unknown {
44 if (saida.exitCode === 2 && /(subcomando desconhecido|uso: ork ship|usage: ork ship|comando desconhecido)/i.test(`${saida.stderr}\n${saida.stdout}`)) {
45 throw new Error(`o ork deste PATH não tem ${comando}: atualize o CLI do Orkastery`)
46 }
47 try {
48 return JSON.parse(saida.stdout)
49 } catch {
50 const motivo = umaLinha(saida.stderr || saida.stdout, 160) || `saída ${saida.exitCode}`
51 throw new Error(`${comando} não devolveu JSON (saída ${saida.exitCode}): ${motivo}`)
52 }
53}
54
55const texto = (v: unknown, teto = TETO_DE_TEXTO): string | null => (typeof v === 'string' && v.trim() !== '' ? umaLinha(v, teto) : null)
56
57function etapaDe(bruto: unknown, id: PistaIdDaEtapa): PistaEtapa | null {
58 if (!bruto || typeof bruto !== 'object') return null
59 const e = bruto as Record<string, unknown>
60 if (e.id !== id || !ESTADOS.includes(e.estado as PistaEstadoDaEtapa)) return null
61 return { id, estado: e.estado as PistaEstadoDaEtapa, texto: texto(e.texto) ?? '' }
62}
63
64function prDe(bruto: unknown): PistaPr | null | undefined {
65 if (bruto === null) return null
66 if (!bruto || typeof bruto !== 'object') return undefined
67 const p = bruto as Record<string, unknown>
68 if (!Number.isSafeInteger(p.numero) || (p.numero as number) < 1 || !['aberto', 'mesclado', 'fechado'].includes(String(p.estado)) ||
69 typeof p.rascunho !== 'boolean' || typeof p.head !== 'string' || !SHA.test(p.head)) return undefined
70 const url = typeof p.url === 'string' && /^https:\/\/\S+$/.test(p.url) ? umaLinha(p.url, 300) : null
71 return { numero: p.numero as number, url, estado: p.estado as PistaPr['estado'], rascunho: p.rascunho, head: p.head }
72}
73
74function proximoDe(bruto: unknown): PistaProximo | null {
75 if (!bruto || typeof bruto !== 'object') return null
76 const p = bruto as Record<string, unknown>
77 const acao = p.acao === 'publicar' || p.acao === 'registrar' ? p.acao : p.acao === null ? null : undefined
78 if (acao === undefined || typeof p.disponivel !== 'boolean') return null
79 const t = texto(p.texto)
80 if (!t) return null
81 // So uma acao conhecida pode estar disponivel; o resto vira texto.
82 return { acao, disponivel: acao !== null && p.disponivel, texto: t, comando: texto(p.comando, 600) }
83}
84
85/** Uma pista do nucleo, conferida campo a campo; `null` quando ela nao tem a forma do contrato. */
86export function linhaDaPista(bruto: unknown): PistaLinha | null {
87 if (!bruto || typeof bruto !== 'object') return null
88 const p = bruto as Record<string, unknown>
89 if (typeof p.thread !== 'string' || !ID_DE_THREAD.test(p.thread) || typeof p.publicavel !== 'boolean' || typeof p.conduzida !== 'boolean') return null
90 if (!Array.isArray(p.etapas) || p.etapas.length !== ETAPAS.length) return null
91 const etapas = ETAPAS.map((id, i) => etapaDe((p.etapas as unknown[])[i], id))
92 if (etapas.some(e => e === null)) return null
93 const pr = prDe(p.pr)
94 const proximo = proximoDe(p.proximo)
95 const head = typeof p.head === 'string' && SHA.test(p.head) ? p.head : null
96 if (pr === undefined || !proximo) return null
97 return {
98 thread: p.thread, nome: texto(p.nome, 120) ?? p.thread, tag: texto(p.tag, 20) ?? '', publicavel: p.publicavel, conduzida: p.conduzida,
99 branch: texto(p.branch, 200) ?? '', head, pr, etapas: etapas as PistaEtapa[], proximo,
100 }
101}
102
103/**
104 * A saida do `ork ship pista --json`, conferida: o contrato, uma pista por thread (no maximo `TETO_DE_PISTAS`) e os
105 * avisos do que nao foi lido. Saida que nao e o contrato lanca, e o quadro anterior fica.
106 */
107export function lerPista(saida: SaidaDoProcesso, agora: number): PistaQuadro {
108 const bruto = jsonDaSaida(saida, 'ork ship pista') as Record<string, unknown> | null
109 if (!bruto || typeof bruto !== 'object' || bruto.contrato !== 'ork.ship-pista/v1' || !Array.isArray(bruto.pistas)) {
110 throw new Error('a saída do ork ship pista não é o contrato ork.ship-pista/v1')
111 }
112 const linhas = (bruto.pistas as unknown[]).map(linhaDaPista).filter((l): l is PistaLinha => l !== null)
113 const avisos: string[] = []
114 if (bruto.remotoLido === false) avisos.push(`o remoto ${texto(bruto.remoto, 60) ?? ''} não respondeu ao ls-remote`)
115 if (bruto.prsLidos === false) avisos.push(`os PRs não foram lidos: ${texto(bruto.prsNaoLidos, 200) ?? 'sem motivo'}`)
116 const descartadas = (bruto.pistas as unknown[]).length - linhas.length
117 if (descartadas > 0) avisos.push(`${descartadas} pista(s) fora do contrato ficaram de fora`)
118 if (linhas.length > TETO_DE_PISTAS) avisos.push(`${linhas.length - TETO_DE_PISTAS} pista(s) além do teto ficaram de fora`)
119 return { linhas: ordenarLinhas(linhas).slice(0, TETO_DE_PISTAS), lidoEm: agora, erro: null, avisos }
120}
121
122/**
123 * A assinatura da secao `ship` do retrato do nucleo (o retrato da ponte): muda quando uma entrega anda (`ship_done`,
124 * `ship_started`, `ship_blocked`), e entao a pista e relida antes da idade. `null` quando o retrato nao veio.
125 */
126export function assinaturaDoRetrato(r: unknown): string | null {
127 const secao = (r as { retrato?: { sections?: { ship?: { items?: unknown } } } } | null)?.retrato?.sections?.ship
128 if (!secao || !Array.isArray(secao.items)) return null
129 return (secao.items as unknown[]).map(i => {
130 const x = (i ?? {}) as { id?: unknown; status?: unknown; facts?: { receiptAt?: unknown } }
131 return `${String(x.id)}|${String(x.status)}|${String(x.facts?.receiptAt ?? '')}`
132 }).sort().join(';')
133}
134
135/** O ledger de thread que mudou (`classic.FileChanged`), pelo caminho; `null` para outro arquivo. */
136export function threadDoLedger(caminho: unknown): string | null {
137 if (typeof caminho !== 'string') return null
138 const m = /\/\.orkastery\/threads\/([a-zA-Z0-9][a-zA-Z0-9._-]{0,79})\/ledger\.jsonl$/.exec(caminho)
139 return m ? m[1] as string : null
140}
141
142/** O peso de uma pista na ordem: o que tem botao, o que falhou, o que anda, o que ja foi entregue. */
143export function pesoDaPista(l: PistaLinha): number {
144 if (l.proximo.disponivel) return 0
145 if (l.proximo.acao === 'publicar') return 1
146 if (l.etapas.some(e => e.estado === 'falhou')) return 2
147 if (l.etapas.find(e => e.id === 'entrega')?.estado === 'feita') return 4
148 return 3
149}
150
151export function ordenarLinhas(linhas: readonly PistaLinha[]): PistaLinha[] {
152 return [...linhas].sort((a, b) => pesoDaPista(a) - pesoDaPista(b) || a.thread.localeCompare(b.thread))
153}
154
155/** A marca da pista no painel: `!` com botao, `?` pede o dono, `x` falhou, `>` anda, `-` entregue. */
156export function marcaDaPista(l: PistaLinha): string {
157 return ['!', '?', 'x', '>', '-'][pesoDaPista(l)] as string
158}
159
160const plural = (n: number, um: string, varios: string) => `${n} ${n === 1 ? um : varios}`
161
162/**
163 * A linha da faixa (D9): so o que pede o dono, sem repetir o que anda sozinho. Sem nada disso, a linha sai da
164 * faixa. A ponte ja escreve `pista: ` antes dela.
165 */
166export function textoDaFaixa(q: PistaQuadro): string | undefined {
167 const prontas = q.linhas.filter(l => l.proximo.acao === 'publicar' && l.proximo.disponivel).length
168 const doDono = q.linhas.filter(l => l.proximo.acao === 'publicar' && !l.proximo.disponivel).length
169 const registrar = q.linhas.filter(l => l.proximo.acao === 'registrar' && l.proximo.disponivel).length
170 const vermelhas = q.linhas.filter(l => l.etapas.some(e => (e.id === 'ci' || e.id === 'conflito') && e.estado === 'falhou')).length
171 const partes = [
172 prontas ? plural(prontas, 'pronta para publicar', 'prontas para publicar') : null,
173 doDono ? plural(doDono, 'pede o seu push', 'pedem o seu push') : null,
174 registrar ? plural(registrar, 'para registrar', 'para registrar') : null,
175 vermelhas ? plural(vermelhas, 'com CI vermelho ou conflito', 'com CI vermelho ou conflito') : null,
176 ].filter((p): p is string => p !== null)
177 return partes.length ? `${partes.join(' · ')} · /pista` : undefined
178}
179
180/** Quanto tempo faz, em palavras curtas. */
181export function haQuanto(desde: number | null, agora: number): string {
182 if (desde === null) return 'ainda não lida'
183 const s = Math.max(0, Math.round((agora - desde) / 1000))
184 if (s < 60) return `lida há ${s} s`
185 const min = Math.round(s / 60)
186 return min < 60 ? `lida há ${min} min` : `lida há ${Math.round(min / 60)} h`
187}
188
189/** Uma linha do painel, com o tipo que decide o elemento e o que ela mostra ao dono (para o registro da ponte). */
190export type LinhaDoPainel = {
191 tipo: 'placar' | 'aviso' | 'vazio' | 'cabecalho' | 'etapas' | 'etapa' | 'proximo' | 'botao' | 'comando' | 'resultado'
192 texto: string
193 thread?: string
194 /** So na linha do botao: o comando do nucleo que ele chama (T7.3). */
195 acao?: 'publicar' | 'registrar'
196 mostrado?: PistaMostrado
197}
198
199/** O botao que o register.tsx decidiu para uma pista (o nucleo o declara disponivel), com o rotulo. */
200export type BotaoDaPista = { acao: 'publicar' | 'registrar'; rotulo: string }
201
202const linhaDasEtapas = (l: PistaLinha) => l.etapas.map(e => {
203 const nome = e.id === 'pr' && l.pr ? `PR #${l.pr.numero}` : NOME_DA_ETAPA[e.id]
204 return `${nome} ${MARCA_DA_ETAPA[e.estado]}`
205}).join(' · ')
206
207/** O texto do resultado do ultimo botao da thread, quando ha. */
208export function textoDaAcao(a: PistaAcao | undefined): string | null {
209 if (!a) return null
210 const rotulo = a.acao === 'publicar' ? 'publicar' : 'registrar'
211 if (a.estado === 'rodando') return `${rotulo}: rodando no núcleo...`
212 return `${rotulo}: ${a.texto}`
213}
214
215/**
216 * As linhas do painel, na ordem do desenho. A pista em foco (`/pista <thread>`) vem primeiro. Cada pista: o
217 * cabecalho, as oito etapas numa linha, o texto de cada etapa que nao esta feita, o proximo passo do nucleo, o
218 * comando (para o dono, quando o modo pede o push dele) e o resultado do ultimo botao.
219 */
220export function linhasDoPainel(q: PistaQuadro, opcoes: { agora: number; foco?: string | null; acoes?: Readonly<Record<string, PistaAcao>>;
221 botoes?: Readonly<Record<string, BotaoDaPista>> }): LinhaDoPainel[] {
222 const saida: LinhaDoPainel[] = []
223 const prontas = q.linhas.filter(l => l.proximo.disponivel).length
224 saida.push({ tipo: 'placar', texto: `Pista de Entrega · ${plural(q.linhas.length, 'entrega', 'entregas')}` +
225 `${prontas ? ` · ${plural(prontas, 'com botão', 'com botão')}` : ''} · ${haQuanto(q.lidoEm, opcoes.agora)}` })
226 if (q.erro) saida.push({ tipo: 'aviso', texto: `a última leitura falhou: ${q.erro}` })
227 for (const aviso of q.avisos) saida.push({ tipo: 'aviso', texto: aviso })
228 if (!q.linhas.length) {
229 saida.push({ tipo: 'vazio', texto: q.lidoEm === null ? 'lendo a pista do núcleo...'
230 : 'Nenhuma entrega na pista: a thread entra aqui depois do CHECK, com a branch publicada ou com PR.' })
231 return saida
232 }
233 const foco = opcoes.foco ?? null
234 const ordem = foco ? [...q.linhas.filter(l => l.thread === foco), ...q.linhas.filter(l => l.thread !== foco)] : q.linhas
235 for (const l of ordem) {
236 const emFoco = l.thread === foco ? ' (em foco)' : ''
237 saida.push({ tipo: 'cabecalho', thread: l.thread, texto: `${marcaDaPista(l)} ${l.thread} ${l.tag} ${l.branch}${emFoco}` })
238 saida.push({ tipo: 'etapas', thread: l.thread, texto: ` ${linhaDasEtapas(l)}` })
239 for (const e of l.etapas.filter(x => x.estado === 'pendente' || x.estado === 'falhou' || x.estado === 'nao-lida')) {
240 saida.push({ tipo: 'etapa', thread: l.thread, texto: ` ${NOME_DA_ETAPA[e.id]}: ${e.texto}` })
241 }
242 const proximo = ` próximo: ${l.proximo.texto}`
243 saida.push({ tipo: 'proximo', thread: l.thread, texto: proximo,
244 mostrado: { chave: `pista:${l.thread}`, thread: l.thread, texto: `${l.thread}: ${l.proximo.texto}` } })
245 // O botao so aparece com a acao declarada e sem outro clique da mesma thread rodando no nucleo.
246 const botao = opcoes.botoes?.[l.thread]
247 if (botao && opcoes.acoes?.[l.thread]?.estado !== 'rodando') saida.push({ tipo: 'botao', thread: l.thread, acao: botao.acao, texto: botao.rotulo })
248 if (l.proximo.comando && !l.proximo.disponivel) {
249 const quem = l.proximo.acao === 'publicar' ? 'no seu terminal' : 'comando do núcleo'
250 saida.push({ tipo: 'comando', thread: l.thread, texto: ` ${quem}: ${l.proximo.comando}` })
251 }
252 const acao = textoDaAcao(opcoes.acoes?.[l.thread])
253 if (acao) saida.push({ tipo: 'resultado', thread: l.thread, texto: ` ${acao}`,
254 mostrado: { chave: `pista:${l.thread}:acao`, thread: l.thread, texto: `${l.thread}: ${acao}` } })
255 }
256 return saida
257}
258
259/**
260 * As linhas da tela que um texto com `wrap="wrap"` ocupa em `colunas` celulas: quebra por palavra, e a palavra maior que
261 * a linha quebra no meio. A `folga` (uma coluna, por padrao) tira colunas da conta: com ela a estimativa sobra.
262 */
263export function linhasDaTela(texto: string, colunas: number | undefined, folga = 1): number {
264 if (typeof colunas !== 'number' || !(colunas > folga)) return 1
265 const largura = colunas - folga
266 let linhas = 1
267 let usadas = 0
268 texto.split(' ').forEach((palavra, i) => {
269 let tamanho = Array.from(palavra).length
270 const espaco = i === 0 ? 0 : 1
271 if (usadas + espaco + tamanho <= largura) {
272 usadas += espaco + tamanho
273 return
274 }
275 if (usadas > 0) {
276 linhas++
277 usadas = 0
278 }
279 while (tamanho > largura) {
280 linhas++
281 tamanho -= largura
282 }
283 usadas = tamanho
284 })
285 return linhas
286}
287
288/**
289 * A altura de uma linha do painel na tela, sem e com a folga: `proximo` e `comando` quebram (`wrap="wrap"`); as outras
290 * truncam numa so.
291 */
292function alturasNaTela(l: { tipo?: string; texto?: string }, colunas: number | undefined): [number, number] {
293 if (l.tipo !== 'proximo' && l.tipo !== 'comando') return [1, 1]
294 return [linhasDaTela(l.texto ?? '', colunas, 0), linhasDaTela(l.texto ?? '', colunas, 1)]
295}
296
297/**
298 * O que esta inteiro dentro da janela de rolagem do painel: so isso conta como mostrado ao dono (M5). A janela conta
299 * linhas da tela (`offset` e `bodyRows`), e a linha que quebra nas `colunas` do corpo ocupa mais de uma. Como a quebra
300 * do motor e estimada, cada linha tem o topo mais cedo (a conta sem folga) e o mais tarde (com folga): ela so conta com
301 * o mais cedo abaixo do alto da janela e o fim mais tarde acima do pe dela, nas duas bordas.
302 */
303export function mostradosVisiveis(linhas: ReadonlyArray<{ tipo?: string; texto?: string; mostrado?: PistaMostrado }>,
304 janela: { offset: number; bodyRows: number } | undefined, colunas?: number): PistaMostrado[] {
305 if (!janela || !(janela.bodyRows > 0)) return []
306 const fim = janela.offset + janela.bodyRows
307 const vistos: PistaMostrado[] = []
308 let cedo = 0
309 let tarde = 0
310 for (const l of linhas) {
311 const [menor, maior] = alturasNaTela(l, colunas)
312 if (l.mostrado && cedo >= janela.offset && tarde + maior <= fim) vistos.push(l.mostrado)
313 cedo += menor
314 tarde += maior
315 }
316 return vistos
317}
318
319/** Os gestos do painel: atualizar a pista e, na T7.3, os botoes que o nucleo declara. */
320export type Gestos = {
321 atualizar?: () => void
322 publicar?: (thread: string) => void
323 registrar?: (thread: string) => void
324}
325
326/**
327 * A arvore do painel com os elementos que a superficie resolveu (no register.tsx), uma linha da
328 * tela por elemento: so `Box`, `Text` e `Button`, no terminal e no desktop.
329 */
330export function desenharPista(el: { Box: any; Text: any; Button: any }, q: PistaQuadro,
331 opcoes: { agora: number; foco?: string | null; acoes?: Readonly<Record<string, PistaAcao>>; botoes?: Readonly<Record<string, BotaoDaPista>> } & Gestos) {
332 const { Box, Text, Button } = el
333 return (
334 <Box flexDirection="column">
335 {linhasDoPainel(q, opcoes).map((l, i) => {
336 if (l.tipo === 'placar') {
337 return (
338 <Box key="placar" flexDirection="row">
339 <Text key="placar-texto" bold wrap="truncate">{l.texto}</Text>
340 <Button key="atualizar" label="Atualizar" onPress={() => opcoes.atualizar?.()} />
341 </Box>
342 )
343 }
344 if (l.tipo === 'cabecalho') return <Text key={`cabecalho:${l.thread}`} bold wrap="truncate">{l.texto}</Text>
345 if (l.tipo === 'botao' && l.thread && l.acao) {
346 const thread = l.thread
347 const apertar = l.acao === 'publicar' ? opcoes.publicar : opcoes.registrar
348 return <Button key={`${l.acao}:${thread}`} label={l.texto} onPress={() => apertar?.(thread)} />
349 }
350 if (l.tipo === 'comando') return <Text key={`comando:${l.thread}`} wrap="wrap">{l.texto}</Text>
351 if (l.tipo === 'proximo') return <Text key={`proximo:${l.thread}`} wrap="wrap">{l.texto}</Text>
352 return <Text key={`${l.tipo}:${l.thread ?? ''}:${i}`} dimColor wrap="truncate">{l.texto}</Text>
353 })}
354 </Box>
355 )
356}
357types/index.d.ts 86 lines1// Contrato do estado da Pista de Entrega (`ork-pista`, RM-059, fatia F7). O motor exige que cada chave de `$.state`
2// do mod esteja declarada aqui, em `PluginState`. A Pista nao acrescenta noun ao `$`: le o nucleo pelo `$.ork` da
3// `ork-ponte`, que o plugin.json lista em `dependencies`, e pelos comandos `ork ... --json` da lista fechada dela.
4// Sem import nem referencia.
5
6/** As oito etapas da pista, na ordem do contrato `ork.ship-pista/v1` do nucleo. */
7export type PistaIdDaEtapa = 'check' | 'verify' | 'bundle' | 'branch' | 'pr' | 'ci' | 'conflito' | 'entrega'
8
9export type PistaEstadoDaEtapa = 'feita' | 'pendente' | 'falhou' | 'nao-lida' | 'nao-se-aplica'
10
11export type PistaEtapa = { id: PistaIdDaEtapa; estado: PistaEstadoDaEtapa; texto: string }
12
13/** O proximo passo que o nucleo declara: so `publicar` e `registrar` viram botao, e so com `disponivel`. */
14export type PistaProximo = {
15 acao: 'publicar' | 'registrar' | null
16 disponivel: boolean
17 texto: string
18 /** O comando do nucleo que resolve, ou o que o dono roda no terminal dele (texto, nunca rodado pela Pista). */
19 comando: string | null
20}
21
22export type PistaPr = { numero: number; url: string | null; estado: 'aberto' | 'mesclado' | 'fechado'; rascunho: boolean; head: string }
23
24/** A pista de uma thread, como o `ork ship pista --json` a dá. */
25export type PistaLinha = {
26 thread: string
27 nome: string
28 tag: string
29 /** O modo da thread ja autoriza o push (#Auto e #Maestro). */
30 publicavel: boolean
31 conduzida: boolean
32 branch: string
33 head: string | null
34 pr: PistaPr | null
35 etapas: PistaEtapa[]
36 proximo: PistaProximo
37}
38
39/** A leitura da pista inteira: uma linha por thread aberta na entrega. */
40export type PistaQuadro = {
41 linhas: PistaLinha[]
42 /** Quando a pista foi lida (ms desde a epoca); null antes da primeira leitura. */
43 lidoEm: number | null
44 /** Por que a ultima leitura falhou, quando falhou (a anterior continua no quadro). */
45 erro: string | null
46 /** Avisos da leitura do nucleo: o remoto ou os PRs que nao responderam. */
47 avisos: string[]
48}
49
50/** O que um botao fez, uma linha por thread: em curso, ou o resultado do comando do nucleo. */
51export type PistaAcao = {
52 acao: 'publicar' | 'registrar'
53 estado: 'rodando' | 'feita' | 'recusada' | 'mudou' | 'falhou'
54 texto: string
55 em: number
56}
57
58/** O CI de uma pista lido por consulta ao nucleo (`ork ci status --sha`), pelo evento do GitHub ou pelo relogio. */
59export type PistaCi = {
60 sha: string
61 estado: 'success' | 'pending' | 'failure' | 'missing' | 'unavailable' | 'disabled'
62 texto: string
63 url: string | null
64 lidoEm: number
65 /** O que pediu a leitura: um evento do GitHub, o relogio de reserva ou o dono. */
66 motivo: 'evento' | 'consulta' | 'dono'
67}
68
69/** O que o painel mostrou ao dono e o Telegram tambem diria: a chave junta o que e a mesma coisa (M5). */
70export type PistaMostrado = { chave: string; thread: string; texto: string }
71
72declare module 'claude-code' {
73 interface PluginState {
74 'ork-pista': {
75 /** A ultima leitura da pista do nucleo. */
76 quadro: PistaQuadro
77 /** A thread em foco, pedida por `/pista <thread>` (a Torre abre a Pista assim). */
78 foco: string | null
79 /** O ultimo botao de cada thread. */
80 acoes: Record<string, PistaAcao>
81 /** O CI lido de cada thread com PR aberto. */
82 ci: Record<string, PistaCi>
83 }
84 }
85}
86