SLOPSHOPPER

ork-pista

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…

newpanecommandtoastprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ork-pista
│ ┃ pista ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Pista de Entrega · 0 entregas · ainda não l… │ ork-pista │ │ ┃ lendo a pista do núcleo... ⏺ Read(src/auth.ts) │ pista: a ponte ork-ponte não carregou; a │ │ ⎿ Read 6 lines │ Pista de Entrega fica desligada nesta │ │ ⏺ Update(src/auth.ts) │ sessão │ │ ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · pista
Pista de Entrega · 0 entregas · ainda não lida[ Atualizar ] lendo a pista do núcleo...
README

Orkastery

English · Português

npm MIT license

A software factory of AI agents that proves its work: GOAL, PLAN, GO, CHECK, SHIP and MASTER.

In one sentence: ork conducts 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.

  • Status: in daily use · on npm as @orkastery/cli, with changes per version · reviewed on 2026-09-27
  • Proof: GitHub CI on every PR: 2,733 core tests, 24 canaries and 20 skills with 199 assertions, zero failures (CI of the main at PR #74, run 37102089623, 2026-10-03)
  • Product and roadmap: docs/produto/ and docs/roadmap/, checked against the code by ork docs verificar
  • Language: this page is the canonical entry point, mirrored in Portuguese. The pages under docs/ 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 prints
npm 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

Your first thread

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
  • If 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.
  • A claim is what the agent states plus the command that judges it: ork claims add <thread> <file> --claim "<statement>" --verificar "<command>".

Why it exists

  • Coding agents got good. Conducting agents did not.
  • Three agents in the same repository edit the same files, skip the same steps and hand over a report saying it worked. The report is the part that is worth nothing.
  • Someone is missing between you and them: someone who holds the roadmap, isolates the loops, serializes the merge, re-runs the claim and only interrupts you when it matters.
  • ork takes that seat. It is a deterministic TypeScript CLI, with no server and no embedded LLM.
  • Decisions sized for a full day. Whoever conducts agents is also running dozens of other things. Every question 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.

What changes for builders

Without orkWith ork
The agent says the tests passork 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 dayAn hourly summary says what is pending, urgent, blocking or critical
Every obvious decision becomes a questionThe obvious one comes decided and reported; you change it if you want
The runtime account runs out and the roadmap stopsThe same prompt continues on the next profile or on the fallback runtime
Two sessions step on the same fileOne worktree per thread, and leases with a queue
The docs lie about the codeork docs verificar fails the PR when a page drifts

How it works

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

The thread: six phases no agent skips

flowchart LR
    G(["GOAL"]) --> P(["PLAN"]) --> GO(["GO"]) --> C(["CHECK"]) --> S(["SHIP"]) --> M(["MASTER"])
    C -.->|"failed: GO-FIX with a typed spec"| GO
PhaseWhat it deliversWhat ork requires to pass
GOALObjective, done criteria, claims with a commanda claim without a command is refused (claims.unverifiable)
PLANTasks, decisions, an executable verify per taska plan without an executable verify does not become GO
GOOne atomic commit per task, in the thread's worktreewriting outside the worktree is blocked by a lease
CHECKVerification against the baseline, plus independent CIa failed claim becomes claims.failed or verify.regression
SHIPSerialized merge with a proven pushonly counts when git ls-remote matches the local SHA
MASTERTyped postmortem and a conduction indexthe index comes from the ledger; nobody types it

Four modes, chosen by #TAG

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.

#TAGPausesCycleWhat it is for
#Classic3GOAL* / PLAN* / GO-CHECK* / SHIP-MASTERThe ork init default: delicate premises
#Maestro1GOAL-PLAN* / GO-CHECK-SHIP / MASTERA clear, quick solution
#Auto0GOAL-PLAN-GO-CHECK-SHIP-MASTERDocs, studies, configuration, audits
#Fast0GOA 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.

Truth, not reports

  • A claim is a contract: the statement, the file and the command that judges it, in claims.jsonl. There are even negative claims, checked by the command that would falsify them.
  • The baseline separates regression from debt: ork verify --baseline records the world before GO.
  • Independent CHECK: ork ci prepare exports the claims, and the GitHub ork-verify job re-runs them on the exact SHA.
  • Proven push: by git ls-remote, never by the exit code of git push.
  • Prompt sha256 and the effective trio in the ledger: the runtime, model and effort of each phase are facts, not statements.
  • 22 typed gate reasons, each with its own retry action: ork retry policy. cost.violation never gets an automatic retry.

Layered human attention

  • Hourly summary: how many items are pending, how many are urgent, how many block a thread, how many are critical, and "can I send them now?".
  • Batch: with your yes, up to 5 objective questions at a time, each with alternatives a–d and one recommended; then up to 2 open ones, one at a time.
  • The obvious decision comes made: ork decisao registrar keeps the why, how to change it and the cost of changing it now or later; you are informed.
  • An expired deadline waits or escalates, never approves. An answer only counts with the HMAC proof of the authenticated channel; the agent never answers for you.
  • Times in your time zone: owner.timezone, stated once per message.

Runtimes and accounts

  • Two runtimes: 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.
  • Accounts per profile: ork accounts add <id> --runtime R --dir D runs the CLI's own login; ork never reads, copies or migrates a credential.
  • Rotation: an exhausted quota or a lost login sends the same prompt to the next profile or to the block's fallback; a short rate limit waits in the queue.
  • Subscription only: a paid key in the factory's environment is a cost.violation. Details and responsibilities in SECURITY.md.

Context and memory

  • Token gate: decides between continuing in the session or opening another; without a measurement it does not rotate, and it says so.
  • Triaged handoff: CRITICAL goes inline, IMPORTANT becomes a pointer with the moment to fetch it, SUMMARIZABLE goes with provenance.
  • Optional OrkMind: with memory: orkmind, memory goes to the tenant's database; without it, it degrades to files with a typed reason.

Documentation as code

  • Two versioned standards: product documentation and product roadmap, v1.1.
  • Three readers per page: a person with ADHD (answer first, one idea per line), the parity checker, and AI agents (YAML frontmatter with stable IDs).
  • 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.
  • In CI: the documentacao job runs markdownlint and ork docs verificar on every PR.

Install on your host

ork adapter list                     # the hosts and where each one installs
ork adapter install claude-code --dry-run
HostWhat gets installed
claude-codeA plugin with the catalog skills, phase subagents, the /orkastery:ork entry point and hooks
codexThe $ork entry point and the conduction catalog as project-local skills
hermesA routing skill, the HITL ingress plugin and thread-opening scripts
openclawAn extension with the ork_* tools, each one a CLI call
  • Zero business rules in the host: the #TAG becomes a mode because the host calls ork modos --do-pedido; the core validates it.
  • Installation with a receipt: sha256 per file; a divergence on both sides asks for a decision, file by file.

What exists today, and what does not

CapabilityStatus
Threads, six phases, four modes, ledger, worktrees, leases, boardworks
Claims, baseline, verify on the real HEAD, 22 typed reasons, GO-FIXworks
CI as an independent CHECK on the exact SHAworks; native protection of main depends on the GitHub plan (RM-012)
ork ship with a proven pushworks
Account rotation across profiles and runtimesworks; account state is still per project (RM-040)
Layered HITL, a–d batch, decisions made and reportedworks since 2026-09-24 (RM-041); automatic hourly summary since 2026-09-25 (RM-045)
Documentation as code with parity in CIworks (RM-044)
#Fast mode: one phase, minimal proof, no push on its ownworks since 2026-09-27 (RM-042)
Roadmap item reservations across machinesworks since 2026-09-27 (RM-047); thread state is still per machine
Stable local verify on a machine whose CPU the hypervisor stealsdoes not exist: on such a machine, the reliable proof is CI (RM-037)
A runtime_reported measurement of the context windowdoes not exist: claude-bg does not expose context usage, and the gate says unavailable
Deterministic code graphworks 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.

Verified in this tree

MeasureValueHow to check
Core tests2,733, zero failing (CI on 2026-10-03)npm --prefix core run test:ci
Behavior canaries24, all greenork eval --so-canarios
Skills corpus20 skills, 101 cases, 199 assertionsork eval --so-skills
Product docs and roadmapzero parity errorsork docs verificar
Runtime dependencies9, pinnedcore/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.

Documentation

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.

SectionStart with
Getting startedQuickstart in English, and the full quickstart in Portuguese: from zero to the first full cycle
GuidesModes, verification, human attention, memory, audits
ReferenceCLI (the source is ork --help) and contracts
ConceptsOverview and architecture
Product and roadmapWhat exists and what comes next, checked against the code

Reading the CLI in English

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 printsIt means
PRONTO / BLOQUEADOready / blocked: the ork doctor verdict
correcaothe fix for the line above it
Proximo passonext step
VERDADE SUSTENTADA / REPROVADOthe claims held / failed: the ork verify verdict
AGUARDANDO AUTORIZACAO HUMANAwaiting for your authorization
Precisa de humano agoraneeds a human now: the first line of ork pulse
Recomendação, opçãorecommendation, option
aceitas por omissaoaccepted by default, because nobody scored them
pendente, respondidapending, answered
Horários em <zone>times are shown in that time zone
  • Dates are day/month in Portuguese output: 03/10 06:30 is October 3. The commands that follow an English locale print 2026-10-03 06:30.
  • Commands and flags in Portuguese: 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.
TermMeaning
HITLhuman in the loop: a question only you can answer, with alternatives and one recommendation
ledgerthe append-only event log of a thread, in .orkastery/threads/<thread>/ledger.jsonl; ork phase list <thread> prints it
claima statement, the file it is about and the command that judges it
worktreethe separate git checkout of each thread, under .claude/worktrees/
leasea lock on a shared resource, such as the main tree during a merge, with a queue
pulsethe hourly summary of what waits for you: ork pulse
maestrothe conversational overview in a host (say orkastery maestro), and the onboarding stage about you
GOAL … MASTERthe six phases, in the thread table
#Classic, #Maestro, #Auto, #Fastthe conduction modes, in the modes table

Built with itself

  • Orkastery is built with Orkastery: every change starts in a thread, with claims that CI re-runs on the exact SHA before the merge.
  • Each thread's ledger stays on the machine that conducts it; what reaches this repository is the PR with the proof and the roadmap synced from git by ork docs sincronizar.
  • More than one machine works in parallel: before starting, each one reserves the roadmap item (ork roadmap pegar), and two never take the same one.

Ecosystem

ProjectWhat it is
orkasteryThis repository: the ork core, the adapters, the skills and the auditors
OrkMindThe memory layer: Postgres with pgvector, an ontology and tag search; optional
orkastery.comThe website and user docs
orkmind.comThe OrkMind site

Maestro in the conversation (code delivered, live activation pending)

  • Say 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.
  • The query does not open work and does not grant a gate.
  • Top priority: HITL usability. Questions in short bullets, with a recommendation and clear options; the UUID stays internal.
  • Telegram is optional. There is no web control panel: Orkastery conducts in the conversation, and OrkMind keeps the knowledge.
  • Status: code package delivered on 2026-09-19 (PR #13). Proof of activation in a new session per host is still pending (RM-032).

Credits and license

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.

Source 5 files
hooks/register.tsx 440 lines
1// 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}
440
hooks/acoes.ts 72 lines
1// 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}
72
hooks/ci.ts 122 lines
1// 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}
122
hooks/pista.tsx 357 lines
1// 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}
357
types/index.d.ts 86 lines
1// 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