Torre de Controle do Orkastery no Claude Code: a fábrica num painel, cada thread aberta numa linha com a fase, quem conduz, o bloqueio e a ação que o estado…

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 438 lines1// A Torre de Controle do Orkastery (RM-059, fatia F1; ficha M3 do plano dos mods). Na sessao do dono, um painel
2// com a fabrica inteira: uma linha por thread aberta desta maquina, lida do nucleo pela ponte (`$.ork.retrato`
3// e a lista fechada do `$.ork.ler`), nunca de `.orkastery/` por conta propria. A Torre so le: nenhum caminho
4// daqui age pelo dono. A logica fica nos modulos puros ao lado; aqui so os ganchos e as chamadas ao `$`,
5// sempre escritas como `x.noun.metodo(...)`, como o validador exige.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8import type { TorreDetalhe, TorreLeitura, TorreLinha, TorreMostrado, TorreQuadro, TorreRede } from '../types'
9import {
10 escolher, ESPERA_DO_LOTE_MS, IDADE_MAXIMA_ESCONDIDO_MS, IDADE_MAXIMA_MS, INTERVALO_ESCONDIDO_MS, INTERVALO_POR_THREAD_MS,
11 marcaDoArquivo, PERIODO_DO_TIQUE_MS, vencidas,
12} from './agenda'
13import type { Dono } from './detalhe'
14import {
15 desenharDetalhe, donosCarregados, jsonDoMcp, leituraFalha, leituraFeita, linhasDaObservacao, linhasDasClaims, linhasDoDetalhe,
16 linhasDoEstado, linhasDoGit, linhasDosEventos,
17} from './detalhe'
18import {
19 abertasDaLista, desenharPainel, linhaDaThread, linhasDoPainel, mostradosDoQuadro, mostradosVisiveis, ordenarLinhas,
20} from './painel'
21import { PERIODO_DA_REDE_MS, redeComErro, redeDoPanorama, textoDaFaixa } from './rede'
22
23const MOD = 'ork-torre'
24const PAINEL = 'torre'
25const TITULO = 'Torre'
26const QUADRO_VAZIO: TorreQuadro = { linhas: [], omitidas: 0, lidoEm: null, erro: null }
27const quadro = atom({ plugin: 'ork-torre', key: 'quadro' } as const, QUADRO_VAZIO)
28const aberta = atom({ plugin: 'ork-torre', key: 'aberta' } as const, null as string | null)
29const detalhes = atom({ plugin: 'ork-torre', key: 'detalhes' } as const, {} as Record<string, TorreDetalhe>)
30const donos = atom({ plugin: 'ork-torre', key: 'donos' } as const, {} as Record<string, string>)
31const rede = atom({ plugin: 'ork-torre', key: 'rede' } as const, null as TorreRede | null)
32const fechado = atom({ plugin: 'ork-torre', key: 'fechado' } as const, false)
33const DETALHE_VAZIO: TorreDetalhe = { eventos: null, claims: null, leituras: {} }
34/** O servidor do nucleo pode conectar depois do inicio da sessao: o papel e conferido de novo nestas esperas. */
35const ESPERAS_DO_PAPEL_MS = [3000, 10000, 30000]
36/** Depois delas, enquanto dormente, a cada 5 min: o servidor pode conectar tarde ou ser reconectado. */
37const ESPERA_LONGA_DO_PAPEL_MS = 300000
38/** Teto de cada leitura do nucleo: leitura que nao volta nao trava o laco de leitura (o retrato tem teto de 10 s). */
39const PRAZO_DA_LEITURA_MS = 20000
40
41// O que o modulo guarda entre ganchos e nao desenha. Uma recarga do modulo zera tudo, e o `session.start`
42// dispara de novo nela; o estado em `$.state` (o quadro, o detalhe, o painel fechado) fica.
43let ativa = false
44let desistiu = false
45let abriuSozinho = false
46let ciclo: { cancel: () => void } | undefined
47let lote: { cancel: () => void } | undefined
48let lendo: Promise<void> | undefined
49let pendente = false
50let tudoPedido = false
51let listaPedida = true
52let listaLidaEm: number | null = null
53const marcadas = new Set<string>()
54/** Threads que o dono mandou reler agora (o Atualizar do detalhe): passam pelo laco unico, sem o intervalo. */
55const forcadas = new Set<string>()
56const threadLidaEm: Record<string, number> = {}
57/** O que o desenho poe na janela de rolagem e ainda nao foi ao registro da ponte (D10). */
58const vistosNoDesenho = new Map<string, TorreMostrado>()
59/** O que ja foi ao registro nesta sessao, por chave e texto; sai quando o item some do quadro. */
60const jaMostrado = new Set<string>()
61/** A ultima tentativa de ler a rede (comeca na ativacao: nunca no inicio da sessao, D11). */
62let redeTentadaEm = 0
63/** O ultimo texto publicado na faixa; null antes da primeira publicacao. */
64let ultimaFaixa: string | undefined | null = null
65
66function mensagem(erro: unknown): string {
67 return (erro instanceof Error ? erro.message : String(erro)).replace(/\s+/g, ' ').trim().slice(0, 200)
68}
69
70/** A leitura com teto proprio: o relogio da sessao recusa a espera que passar dele. */
71function comPrazo<T>($: EngineInterface, leitura: Promise<T>, ms = PRAZO_DA_LEITURA_MS): Promise<T> {
72 return new Promise<T>((resolver, recusar) => {
73 const prazo = $.clock.after(ms, () => recusar(new Error(`a leitura passou de ${Math.round(ms / 1000)} s`)))
74 leitura.then(v => { prazo.cancel(); resolver(v) }, e => { prazo.cancel(); recusar(e) })
75 })
76}
77
78/**
79 * O papel pela ponte. `null` so quando o `$.ork` nao existe (a ponte nao carregou: a chamada nem acontece);
80 * uma falha da propria ponte conta como dormente e o papel e conferido de novo depois.
81 */
82async function papelDaSessao($: EngineInterface): Promise<string | null> {
83 try {
84 return String(await $.ork.papel())
85 } catch (erro) {
86 return erro instanceof TypeError ? null : 'dormente'
87 }
88}
89
90/** Liga a Torre na sessao do dono (D12): o `/torre`, o tique e a primeira leitura. */
91async function ativar($: EngineInterface): Promise<void> {
92 if (ativa) return
93 ativa = true
94 redeTentadaEm = await $.clock.now()
95 await $.command.register({ name: 'torre', description: 'Abre a Torre de Controle: a fábrica do Orkastery num painel', immediate: true })
96 ciclo?.cancel()
97 ciclo = $.clock.every(PERIODO_DO_TIQUE_MS, () => { void tique($) })
98 agendar($, 1)
99}
100
101/** Confere o papel: liga no dono, desiste na fase ou sem a ponte, e tenta de novo enquanto dormente. */
102async function conferirPapel($: EngineInterface, tentativa: number): Promise<void> {
103 if (ativa || desistiu) return
104 const papel = await papelDaSessao($)
105 if (papel === 'dono') return ativar($)
106 if (papel === 'fase' || papel === null) {
107 desistiu = true
108 if (papel === null) $.ui.toast('torre: a ponte ork-ponte não carregou; a Torre de Controle fica desligada nesta sessão')
109 return
110 }
111 const espera = ESPERAS_DO_PAPEL_MS[tentativa] ?? ESPERA_LONGA_DO_PAPEL_MS
112 $.clock.after(espera, () => { void conferirPapel($, tentativa + 1) })
113}
114
115/** Um lote de leitura daqui a pouco, um por vez: os eventos de arquivo se juntam nele (D7). */
116function agendar($: EngineInterface, ms = ESPERA_DO_LOTE_MS): void {
117 if (lote) return
118 lote = $.clock.after(Math.max(1, ms), () => {
119 lote = undefined
120 void atualizar($)
121 })
122}
123
124/** O painel da Torre esta posto e a vista do dono. */
125async function painelAVista($: EngineInterface): Promise<boolean> {
126 const paineis = await $.ui.panes().catch(() => [])
127 return paineis.some(p => p.id === PAINEL && p.isPlaced && p.isShown)
128}
129
130/** O tique do minuto: a lista das abertas, as linhas que ficaram velhas e, com o painel a vista, a rede. */
131async function tique($: EngineInterface): Promise<void> {
132 if (!ativa) return
133 const agora = await $.clock.now()
134 const aVista = await painelAVista($)
135 listaPedida = true
136 const idade = aVista ? IDADE_MAXIMA_MS : IDADE_MAXIMA_ESCONDIDO_MS
137 for (const t of vencidas((await read($, quadro)).linhas.map(l => l.thread), threadLidaEm, agora, idade)) marcadas.add(t)
138 await atualizar($)
139 if (aVista && agora - redeTentadaEm >= PERIODO_DA_REDE_MS) await lerRede($)
140 await registrarMostrados($)
141}
142
143/** A rede pelo panorama do nucleo (D11): pelo botao, ou no tique com o painel a vista, no maximo a cada 10 min. */
144async function lerRede($: EngineInterface): Promise<void> {
145 const agora = await $.clock.now()
146 redeTentadaEm = agora
147 const locais = (await read($, quadro)).linhas.map(l => l.thread)
148 try {
149 const nova = redeDoPanorama(jsonDoMcp(await comPrazo($, $.mcp.call('orkastery', 'ork_network_roadmap', {}))), locais, agora)
150 await update($, rede, () => nova)
151 } catch (erro) {
152 const motivo = mensagem(erro)
153 await update($, rede, anterior => redeComErro(anterior, motivo))
154 }
155 await publicarFaixa($)
156}
157
158/** A linha da faixa pela ponte (D9), so quando o texto muda; sem quadro lido, a linha sai da faixa. */
159async function publicarFaixa($: EngineInterface): Promise<void> {
160 const texto = textoDaFaixa(await read($, quadro), await read($, rede))
161 if (texto === ultimaFaixa) return
162 try {
163 if (texto === undefined) await $.ork.faixa({ mod: MOD })
164 else await $.ork.faixa({ mod: MOD, texto })
165 ultimaFaixa = texto
166 } catch {
167 // A ponte recusou ou falhou: a proxima leitura tenta de novo.
168 }
169}
170
171/** Uma leitura por vez; pedido que chega no meio volta a rodar logo depois, e quem pediu espera as duas. */
172async function atualizar($: EngineInterface, tudo = false): Promise<void> {
173 if (!ativa) return
174 if (tudo) { tudoPedido = true; listaPedida = true }
175 if (lendo) {
176 pendente = true
177 return lendo
178 }
179 lendo = (async () => {
180 try {
181 do {
182 pendente = false
183 await lerUmaVez($)
184 } while (pendente)
185 } finally {
186 lendo = undefined
187 }
188 })()
189 return lendo
190}
191
192/** O retrato de uma thread pela ponte; a linha so conta como lida quando o retrato veio (S2 da revisao). */
193async function lerThread($: EngineInterface, thread: string, agora: number): Promise<TorreLinha> {
194 try {
195 const r = await comPrazo($, $.ork.retrato({ thread }))
196 threadLidaEm[thread] = agora
197 return linhaDaThread(thread, r.retrato, { fonte: r.fonte, lidoEm: r.lidoEm })
198 } catch (erro) {
199 return linhaDaThread(thread, null, { fonte: null, lidoEm: null, erro: mensagem(erro) })
200 }
201}
202
203async function lerUmaVez($: EngineInterface): Promise<void> {
204 const tudo = tudoPedido
205 tudoPedido = false
206 const agora = await $.clock.now()
207 const atual = await read($, quadro)
208 let abertas = atual.linhas.map(l => l.thread)
209 let omitidas = atual.omitidas
210 let lidoEm = atual.lidoEm
211 let erro = atual.erro
212 if (listaPedida || listaLidaEm === null || agora - listaLidaEm >= PERIODO_DO_TIQUE_MS) {
213 listaPedida = false
214 try {
215 const lista = abertasDaLista(await comPrazo($, $.ork.ler({ consulta: 'thread.list' })))
216 abertas = lista.ids
217 omitidas = lista.omitidas
218 listaLidaEm = agora
219 lidoEm = agora
220 erro = null
221 } catch (e) {
222 erro = mensagem(e)
223 }
224 }
225 const conhecidas = new Set(atual.linhas.map(l => l.thread))
226 const agoraForcadas = abertas.filter(t => forcadas.has(t))
227 forcadas.clear()
228 const pedidas = tudo ? abertas : abertas.filter(t => (marcadas.has(t) || !conhecidas.has(t)) && !agoraForcadas.includes(t))
229 const intervalo = (await painelAVista($)) ? INTERVALO_POR_THREAD_MS : INTERVALO_ESCONDIDO_MS
230 const escolha = tudo ? { ler: pedidas, adiadas: [] as string[], proximaEm: null } : escolher(pedidas, threadLidaEm, agora, intervalo)
231 for (const t of [...marcadas]) if (pedidas.includes(t) || agoraForcadas.includes(t) || !abertas.includes(t)) marcadas.delete(t)
232 for (const t of escolha.adiadas) marcadas.add(t)
233 const novas = new Map<string, TorreLinha>()
234 for (const t of tudo ? escolha.ler : [...agoraForcadas, ...escolha.ler]) novas.set(t, await lerThread($, t, agora))
235 await update($, quadro, anterior => {
236 const antigas = new Map(anterior.linhas.map(l => [l.thread, l]))
237 const linhas = abertas.map(t => novas.get(t) ?? antigas.get(t) ??
238 linhaDaThread(t, null, { fonte: null, lidoEm: null, erro: 'ainda não lida' }))
239 return { linhas: ordenarLinhas(linhas), omitidas, lidoEm, erro }
240 })
241 if (escolha.proximaEm !== null) agendar($, escolha.proximaEm - agora)
242 await publicarFaixa($)
243 await registrarMostrados($)
244}
245
246/**
247 * So a thread do detalhe, pelo Atualizar dele (D16): ela entra no proximo passo do laco unico de leitura, sem o
248 * intervalo, e o resto do quadro fica como esta. Pelo laco, nenhuma leitura mais velha grava por cima dela.
249 */
250async function releerThread($: EngineInterface, thread: string): Promise<void> {
251 forcadas.add(thread)
252 await atualizar($)
253}
254
255/**
256 * O que o desenho pos na janela de rolagem do painel posto e a vista vai ao registro da ponte (M5 do dono,
257 * D10), uma vez por chave e texto enquanto o item existir no quadro: o Telegram nao repete o que o dono ja viu
258 * no terminal, e nada que nao estava na tela entra.
259 */
260async function registrarMostrados($: EngineInterface): Promise<void> {
261 const existentes = new Set(mostradosDoQuadro(await read($, quadro)).map(m => `${m.chave}|${m.texto}`))
262 for (const vista of [...jaMostrado]) if (!existentes.has(vista)) jaMostrado.delete(vista)
263 for (const vista of [...vistosNoDesenho.keys()]) if (!existentes.has(vista)) vistosNoDesenho.delete(vista)
264 if (vistosNoDesenho.size === 0) return
265 // Com o painel escondido, o que foi anotado e descartado: o proximo desenho a vista anota de novo (A-1).
266 if (!await painelAVista($)) { vistosNoDesenho.clear(); return }
267 for (const [vista, m] of [...vistosNoDesenho]) {
268 vistosNoDesenho.delete(vista)
269 if (jaMostrado.has(vista)) continue
270 try {
271 await $.ork.mostrado({ mod: MOD, canal: 'painel', chave: m.chave, thread: m.thread, texto: m.texto })
272 jaMostrado.add(vista)
273 } catch {
274 // A ponte recusou ou falhou: volta para a proxima vez.
275 vistosNoDesenho.set(vista, m)
276 }
277 }
278}
279
280/** O desenho anota o que pos na janela; quem registra e o laco de leitura, fora do desenho. */
281function anotarVistos(vistos: readonly TorreMostrado[]): void {
282 for (const m of vistos) vistosNoDesenho.set(`${m.chave}|${m.texto}`, m)
283}
284
285/**
286 * Grava o que o detalhe leu de uma thread, so se ela ainda e a aberta: leitura atrasada nao apaga a de outra.
287 * O mapa guarda so o detalhe aberto (o `abrir` o recomeca).
288 */
289async function gravarDetalhe($: EngineInterface, thread: string, mudar: (d: TorreDetalhe) => TorreDetalhe): Promise<void> {
290 if ((await read($, aberta)) !== thread) return
291 await update($, detalhes, todos => ({ ...todos, [thread]: mudar(todos[thread] ?? DETALHE_VAZIO) }))
292}
293
294/** Os comandos dos mods donos carregados agora, pelo plugin que registrou cada um (D4). */
295async function lerDonos($: EngineInterface): Promise<Record<string, string>> {
296 const carregados = donosCarregados(await $.command.list().catch(() => []))
297 await update($, donos, () => carregados)
298 return carregados
299}
300
301/** Ao abrir o detalhe: os donos das acoes, os ultimos eventos e as claims com estado da thread. */
302async function carregarDetalhe($: EngineInterface, thread: string): Promise<void> {
303 await lerDonos($)
304 const agora = await $.clock.now()
305 let eventos: TorreLeitura
306 try {
307 eventos = leituraFeita(linhasDosEventos(jsonDoMcp(await comPrazo($, $.mcp.call('orkastery', 'ork_phase_list', { threadId: thread }))), agora), agora)
308 } catch (erro) {
309 eventos = leituraFalha(mensagem(erro), agora)
310 }
311 let claims: TorreLeitura
312 try {
313 claims = leituraFeita(linhasDasClaims(jsonDoMcp(await comPrazo($, $.mcp.call('orkastery', 'ork_claims_list', { threadId: thread })))), agora)
314 } catch (erro) {
315 claims = leituraFalha(mensagem(erro), agora)
316 }
317 await gravarDetalhe($, thread, d => ({ ...d, eventos, claims }))
318}
319
320async function abrir($: EngineInterface, thread: string): Promise<void> {
321 await update($, aberta, () => thread)
322 await update($, detalhes, () => ({ [thread]: DETALHE_VAZIO }))
323 await carregarDetalhe($, thread)
324 await registrarMostrados($)
325}
326
327async function recarregar($: EngineInterface, thread: string): Promise<void> {
328 await releerThread($, thread)
329 await carregarDetalhe($, thread)
330 await registrarMostrados($)
331}
332
333/** As tres acoes de leitura do retrato (D4): o resultado vai para o detalhe, e nada mais roda. */
334async function lerAcao($: EngineInterface, thread: string, operacao: string): Promise<void> {
335 const agora = await $.clock.now()
336 let leitura: TorreLeitura
337 try {
338 if (operacao === 'thread.status') {
339 leitura = leituraFeita(linhasDoEstado(await comPrazo($, $.ork.ler({ consulta: 'thread.status', thread })), agora), agora)
340 } else if (operacao === 'thread.observe') {
341 leitura = leituraFeita(linhasDaObservacao(jsonDoMcp(await comPrazo($, $.mcp.call('orkastery', 'ork_observe', { threadId: thread })))), agora)
342 } else if (operacao === 'git.status') {
343 leitura = leituraFeita(linhasDoGit(jsonDoMcp(await comPrazo($, $.mcp.call('orkastery', 'ork_git_status', { threadId: thread })))), agora)
344 } else return
345 } catch (erro) {
346 leitura = leituraFalha(mensagem(erro), agora)
347 }
348 await gravarDetalhe($, thread, d => ({ ...d, leituras: { ...d.leituras, [operacao]: leitura } }))
349 await registrarMostrados($)
350}
351
352/**
353 * A acao com efeito vai ao mod dono dela (D4): o comando dele roda como se o dono o digitasse, com o id da
354 * thread, so se a lista de comandos agora o mostra registrado pelo plugin esperado. Senao, o detalhe redesenha
355 * com o comando do `ork` em texto, e nada roda.
356 */
357async function abrirMod($: EngineInterface, thread: string, dono: Dono): Promise<void> {
358 const carregados = await lerDonos($)
359 if (carregados[dono.comando] !== dono.plugin) return
360 await $.command.run({ command: dono.comando, args: thread }).catch(() => undefined)
361}
362
363export const register: Register = on => {
364 on('session.start', async ($, e, next) => {
365 if (e.isInteractive) await conferirPapel($, 0)
366 return next(e)
367 })
368
369 // `/torre` abre o painel em qualquer largura e rele tudo; o dono pediu, entao o painel volta a poder abrir
370 // sozinho. O comando so existe com a Torre ligada; a conferencia do papel aqui so cobre o caso raro de o
371 // comando ter sobrado de uma carga anterior.
372 on('command.run', { command: 'torre' }, async $ => {
373 if (!ativa && !desistiu) await conferirPapel($, ESPERAS_DO_PAPEL_MS.length)
374 if (!ativa) return { text: 'A Torre de Controle só abre na sessão do dono de um projeto do Orkastery.' }
375 await update($, fechado, () => false)
376 const aberto = await $.ui.open({ id: PAINEL, title: TITULO })
377 await atualizar($, true)
378 return { text: aberto.isPlaced ? 'Torre de Controle aberta.' : `Torre de Controle aberta, à espera de lugar: ${aberto.reason}` }
379 })
380
381 // O dono fechou o painel (a Torre nunca fecha o proprio): ele nao abre mais sozinho nesta sessao, nem depois
382 // de uma recarga do modulo. O fechamento segue sempre.
383 on('ui.close', { id: PAINEL }, async ($, e, next) => {
384 if (e.origin.kind !== 'unload') await update($, fechado, () => true)
385 return next(e)
386 }).catch(($, e, next) => next(e))
387
388 // O ledger de uma thread ou a pasta das threads mudou: a releitura entra no proximo lote (D7). Os caminhos
389 // vigiados sao os que a ponte pede para o papel dono; a Torre nao pede outros.
390 on('classic.FileChanged', async ($, e, next) => {
391 if (ativa) {
392 const marca = marcaDoArquivo(e.file_path)
393 if (marca?.tipo === 'thread') marcadas.add(marca.thread)
394 if (marca?.tipo === 'lista') listaPedida = true
395 if (marca) agendar($)
396 }
397 return next(e)
398 }).catch(($, e, next) => next(e))
399
400 // D8: sozinho, o painel so abre no fullscreen, uma vez, e nunca depois que o dono o fechou. A linha de dicas
401 // e desenhada em toda sessao do terminal e do desktop e traz o viewport; este gancho nao desenha nada e
402 // devolve a linha intacta.
403 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
404 if (ativa && !abriuSozinho && e.viewport?.isFullscreen === true && !(await read($, fechado))) {
405 abriuSozinho = true
406 void $.ui.open({ id: PAINEL, title: TITULO }).catch(() => undefined)
407 }
408 return next(e)
409 })
410
411 // O painel: a lista das threads, ou o detalhe da thread aberta (T1.2). O desenho nunca escreve: so anota o
412 // que pos na janela de rolagem, para o registro do mostrado (D10). Os gestos mudam o estado da Torre e leem o
413 // nucleo.
414 on('ui.render', { component: 'Pane', requestId: PAINEL }, async ($, e) => {
415 const { Box, Text, Button } = $.ui.resolve(e)
416 const q = await read($, quadro)
417 const agora = await $.clock.now()
418 const id = await read($, aberta)
419 const linha = id === null ? undefined : q.linhas.find(l => l.thread === id)
420 if (linha) {
421 const detalhe = (await read($, detalhes))[linha.thread]
422 anotarVistos(mostradosVisiveis(linhasDoDetalhe(linha, detalhe, agora), 1, e.props.scroll))
423 return desenharDetalhe({ Box, Text, Button }, linha, detalhe, await read($, donos), {
424 agora,
425 voltar: () => update($, aberta, () => null),
426 atualizar: () => recarregar($, linha.thread),
427 ler: operacao => lerAcao($, linha.thread, operacao),
428 abrirMod: dono => abrirMod($, linha.thread, dono),
429 })
430 }
431 const daRede = await read($, rede)
432 anotarVistos(mostradosVisiveis(linhasDoPainel(q, { agora, rede: daRede }), 0, e.props.scroll))
433 return desenharPainel({ Box, Text, Button }, q, {
434 agora, largura: e.props.bodyColumns, abrir: thread => abrir($, thread), rede: daRede, lerRede: () => lerRede($),
435 })
436 })
437}
438hooks/agenda.ts 61 lines1// Quando a Torre rele o nucleo (RM-059, Torre de Controle; D7 da thread). O ledger de uma thread ativa muda
2// a cada batida do sensor, e cada retrato custa um passeio pelo servidor MCP do dono: a Torre junta os
3// eventos de arquivo num lote so, rele cada thread no maximo a cada 10 s e confere a lista das abertas a
4// cada minuto. Modulo puro: so dados entram e saem; os relogios ficam no register.tsx.
5
6/** Espera entre o primeiro evento de arquivo e a leitura do lote. */
7export const ESPERA_DO_LOTE_MS = 2000
8/** Intervalo minimo entre duas leituras da mesma thread. */
9export const INTERVALO_POR_THREAD_MS = 10000
10/** Periodo do tique: confere a lista das abertas e o que ficou velho. */
11export const PERIODO_DO_TIQUE_MS = 60000
12/** Linha lida ha mais que isto volta a ser lida no tique, mesmo sem evento de arquivo. */
13export const IDADE_MAXIMA_MS = 300000
14/**
15 * Com o painel escondido so a faixa le o quadro: o intervalo por thread e a idade maxima se espacam, porque
16 * cada retrato roda a observacao nativa no servidor do dono (D16).
17 */
18export const INTERVALO_ESCONDIDO_MS = 60000
19export const IDADE_MAXIMA_ESCONDIDO_MS = 900000
20
21const ID_DE_THREAD = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/
22
23export type MarcaDoArquivo = { tipo: 'thread'; thread: string } | { tipo: 'lista' }
24
25/**
26 * O que um evento de arquivo marca: o ledger de uma thread marca a thread; a pasta das threads marca a
27 * lista das abertas (thread nova ou fechada). Outro caminho nao marca nada.
28 */
29export function marcaDoArquivo(caminho: unknown): MarcaDoArquivo | null {
30 if (typeof caminho !== 'string') return null
31 const c = caminho.replace(/\\/g, '/').replace(/\/+$/, '')
32 const m = /(?:^|\/)\.orkastery\/threads\/([^/]+)\/ledger\.jsonl$/.exec(c)
33 if (m) return ID_DE_THREAD.test(m[1] as string) ? { tipo: 'thread', thread: m[1] as string } : null
34 return /(?:^|\/)\.orkastery\/threads$/.test(c) ? { tipo: 'lista' } : null
35}
36
37/**
38 * Das threads marcadas, as que podem ser lidas agora (fora do intervalo minimo) e as que ficam para depois,
39 * com o instante mais cedo em que a primeira adiada pode ser lida.
40 */
41export function escolher(marcadas: Iterable<string>, lidaEm: Readonly<Record<string, number>>, agora: number,
42 intervalo = INTERVALO_POR_THREAD_MS): { ler: string[]; adiadas: string[]; proximaEm: number | null } {
43 const ler: string[] = []
44 const adiadas: string[] = []
45 let proximaEm: number | null = null
46 for (const t of [...new Set(marcadas)].sort()) {
47 const ultima = lidaEm[t]
48 if (typeof ultima !== 'number' || agora - ultima >= intervalo) { ler.push(t); continue }
49 adiadas.push(t)
50 const quando = ultima + intervalo
51 if (proximaEm === null || quando < proximaEm) proximaEm = quando
52 }
53 return { ler, adiadas, proximaEm }
54}
55
56/** No tique: as threads abertas que nunca foram lidas ou foram lidas ha mais que a idade maxima. */
57export function vencidas(abertas: readonly string[], lidaEm: Readonly<Record<string, number>>, agora: number,
58 idade = IDADE_MAXIMA_MS): string[] {
59 return abertas.filter(t => typeof lidaEm[t] !== 'number' || agora - (lidaEm[t] as number) > idade)
60}
61hooks/detalhe.tsx 237 lines1// O detalhe de uma thread na Torre de Controle (RM-059, fatia F1, T1.2; D4 da thread). Apertar a linha abre o
2// detalhe: sessoes, bloqueio, pedidos, entrega e MASTER, os ultimos eventos e as claims com estado. Cada acao
3// que o retrato declara disponivel vira um botao de leitura, que mostra o resultado aqui, ou, se tem efeito,
4// o atalho para o mod dono dela (que aplica as proprias regras) ou o comando do `ork` em texto, para o dono
5// rodar. Nenhuma acao com efeito roda pela Torre. Modulo puro: o `$` fica no register.tsx.
6import type { TorreDetalhe, TorreLeitura, TorreLinha } from '../types'
7import type { ItemDoPainel } from './painel'
8import { acoesRelevantes, cabecalhoDaLinha, emPortugues, idade, LEITURAS, ROTULO_DA_ACAO } from './painel'
9
10export type Dono = { plugin: string; comando: string; nome: string }
11export type ClasseDaAcao =
12 | { tipo: 'leitura' }
13 | { tipo: 'efeito'; dono: Dono | null; comando: ((thread: string) => string) | null; nota: string | null }
14
15/**
16 * D4: a classe de cada operacao do retrato (o enum `operation` do nucleo), o mod dono da acao com efeito
17 * (o nome do comando sai das fichas do plano dos mods) e o comando do `ork` que o dono roda sem ele.
18 */
19export const CLASSE_DA_ACAO: Readonly<Record<string, ClasseDaAcao>> = {
20 'thread.status': { tipo: 'leitura' },
21 'thread.observe': { tipo: 'leitura' },
22 'git.status': { tipo: 'leitura' },
23 'hitl.decide': { tipo: 'efeito', dono: { plugin: 'ork-mesa', comando: 'mesa', nome: 'a Mesa do Dono' },
24 comando: () => 'ork sessions hitl', nota: 'a resposta vai pelo Telegram ou pelo diálogo nativo' },
25 'ship': { tipo: 'efeito', dono: { plugin: 'ork-pista', comando: 'pista', nome: 'a Pista de Entrega' },
26 comando: t => `ork ship ${t} --para main`, nota: 'troque main pela branch base do projeto, se for outra' },
27 'phase': { tipo: 'efeito', dono: { plugin: 'ork-sentinela', comando: 'sentinela', nome: 'a Sentinela do Condutor' },
28 comando: t => `ork conducao status ${t}`, nota: 'a fase seguinte sai pelo condutor' },
29 'retry': { tipo: 'efeito', dono: { plugin: 'ork-sentinela', comando: 'sentinela', nome: 'a Sentinela do Condutor' },
30 comando: t => `ork retry plan ${t}`, nota: null },
31 'master': { tipo: 'efeito', dono: { plugin: 'ork-ritual', comando: 'nota', nome: 'o Ritual do MASTER' },
32 comando: t => `ork master pedir ${t}`, nota: null },
33 'verify': { tipo: 'efeito', dono: null, comando: t => `ork verify ${t}`, nota: null },
34 'git.commit': { tipo: 'efeito', dono: null, comando: null, nota: 'o commit é da sessão de GO, pelo ork_git_commit' },
35 'creation': { tipo: 'efeito', dono: null, comando: null, nota: 'não é ação de uma thread' },
36}
37
38/** Os comandos dos mods donos, um por mod: o que a Torre procura na lista de comandos da sessao. */
39export const COMANDOS_DOS_DONOS: readonly Dono[] = Object.values(CLASSE_DA_ACAO)
40 .flatMap(c => (c.tipo === 'efeito' && c.dono ? [c.dono] : []))
41 .filter((d, i, todos) => todos.findIndex(x => x.comando === d.comando) === i)
42
43/** Dos comandos da sessao (`$.command.list()`), os dos mods donos, com o plugin que registrou cada um. */
44export function donosCarregados(comandos: ReadonlyArray<{ name?: unknown; plugin?: unknown }>): Record<string, string> {
45 const saida: Record<string, string> = {}
46 for (const d of COMANDOS_DOS_DONOS) {
47 const achado = comandos.find(c => c.name === d.comando && typeof c.plugin === 'string')
48 if (achado) saida[d.comando] = achado.plugin as string
49 }
50 return saida
51}
52
53export type AcaoNoDetalhe =
54 | { operacao: string; rotulo: string; tipo: 'leitura' }
55 | { operacao: string; rotulo: string; tipo: 'mod'; dono: Dono }
56 | { operacao: string; rotulo: string; tipo: 'texto'; texto: string }
57
58/**
59 * Como cada acao que a Torre oferece aparece no detalhe (D4 e D15): leitura vira botao; efeito pedido pelo
60 * estado abre o mod dono so quando o comando dele esta carregado pelo plugin esperado; senao, o comando do
61 * `ork` (ou a nota) aparece em texto.
62 */
63export function acoesDoDetalhe(linha: TorreLinha, donos: Readonly<Record<string, string>>): AcaoNoDetalhe[] {
64 const saida: AcaoNoDetalhe[] = []
65 for (const { operacao } of acoesRelevantes(linha)) {
66 const classe = Object.prototype.hasOwnProperty.call(CLASSE_DA_ACAO, operacao) ? CLASSE_DA_ACAO[operacao] : undefined
67 if (!classe) continue
68 const rotulo = ROTULO_DA_ACAO[operacao] ?? operacao
69 if (classe.tipo === 'leitura') { saida.push({ operacao, rotulo, tipo: 'leitura' }); continue }
70 if (classe.dono && donos[classe.dono.comando] === classe.dono.plugin) { saida.push({ operacao, rotulo, tipo: 'mod', dono: classe.dono }); continue }
71 const comando = classe.comando ? classe.comando(linha.thread) : null
72 const texto = [comando ? `rode ${comando}` : null, classe.nota].filter((p): p is string => !!p).join('; ')
73 saida.push({ operacao, rotulo, tipo: 'texto', texto: `${rotulo}: ${texto}` })
74 }
75 return saida
76}
77
78/** O JSON do primeiro bloco de texto de uma resposta MCP; resposta com erro vira erro. */
79export function jsonDoMcp(r: { content?: ReadonlyArray<{ type?: string; text?: string }>; isError?: boolean } | null | undefined): unknown {
80 if (!r || r.isError) throw new Error('o núcleo respondeu com erro')
81 const texto = (r.content ?? []).find(c => c.type === 'text' && typeof c.text === 'string')?.text
82 if (texto === undefined) throw new Error('o núcleo não devolveu texto')
83 return JSON.parse(texto)
84}
85
86const curto = (v: unknown, n = 8) => (typeof v === 'string' ? v.slice(0, n) : '?')
87const cortar = (v: unknown, n: number) => {
88 const t = typeof v === 'string' ? v.replace(/\s+/g, ' ').trim() : ''
89 return t.length <= n ? t : `${t.slice(0, n - 1)}…`
90}
91function haQuanto(ts: unknown, agora: number): string {
92 const ms = typeof ts === 'string' ? Date.parse(ts) : NaN
93 return Number.isFinite(ms) ? `há ${idade(agora - ms)}` : 'sem hora'
94}
95
96/** Os ultimos eventos (`ork_phase_list`), do mais novo ao mais velho. */
97export function linhasDosEventos(resultado: unknown, agora: number, teto = 6): string[] {
98 const eventos = (resultado as { phases?: { eventos?: unknown } } | null)?.phases?.eventos
99 if (!Array.isArray(eventos)) throw new Error('a lista de eventos veio sem eventos')
100 if (eventos.length === 0) return ['nenhum evento de fase registrado']
101 return eventos.slice(-teto).reverse().map(e => {
102 const x = (e ?? {}) as Record<string, unknown>
103 return [String(x.tipo ?? '?'), typeof x.fase === 'string' ? x.fase : null, typeof x.classificacao === 'string' ? x.classificacao : null,
104 typeof x.motivo === 'string' ? x.motivo : null, haQuanto(x.ts, agora)].filter(p => p !== null).join(' · ')
105 })
106}
107
108/** As claims com estado (`ork_claims_list`): a conta por estado e as que ainda nao foram verificadas. */
109export function linhasDasClaims(resultado: unknown, teto = 5): string[] {
110 if (!Array.isArray(resultado)) throw new Error('a lista de claims veio em outro formato')
111 if (resultado.length === 0) return ['nenhuma claim registrada']
112 const conta = new Map<string, number>()
113 for (const c of resultado) conta.set(String(c?.estado ?? '?'), (conta.get(String(c?.estado ?? '?')) ?? 0) + 1)
114 const resumo = [...conta.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).map(([e, n]) => `${n} ${e}`).join(', ')
115 const abertas = resultado.filter(c => c?.estado !== 'verificado' && c?.estado !== 'retirada').slice(-teto)
116 return [`${resultado.length} claims: ${resumo}`, ...abertas.map(c => `${String(c?.id ?? '?')} ${String(c?.estado ?? '?')}: ${cortar(c?.alegacao, 90)}`)]
117}
118
119/** O estado da thread (`ork thread status --json`, pela ponte). */
120export function linhasDoEstado(resultado: unknown, agora: number): string[] {
121 const t = (resultado ?? {}) as Record<string, unknown>
122 if (typeof t.id !== 'string') throw new Error('o estado da thread veio sem id')
123 const sessoes = Array.isArray(t.sessoes) ? t.sessoes.length : 0
124 const claims = Array.isArray(t.claims) ? t.claims.length : 0
125 const saida = [`status ${String(t.status ?? '?')} · fase ${String(t.faseAtual ?? '?')} · ${sessoes} sessões · ${claims} claims · atualizada ${haQuanto(t.atualizadaEm, agora)}`]
126 const b = t.baseline as { commit?: unknown; comandos?: Array<{ nome?: unknown; ok?: unknown }> } | null | undefined
127 if (b && Array.isArray(b.comandos)) {
128 saida.push(`baseline em ${curto(b.commit)}: ${b.comandos.map(c => `${String(c.nome ?? '?')} ${c.ok === true ? 'passava' : 'falhava'}`).join(', ')}`)
129 } else saida.push('sem baseline gravada')
130 return saida
131}
132
133/** A observacao da thread (`ork_observe`): os pedidos pendentes e cada sessao nativa. */
134export function linhasDaObservacao(resultado: unknown): string[] {
135 const o = (resultado ?? {}) as { pendencias?: unknown; nativas?: unknown }
136 if (!Array.isArray(o.nativas)) throw new Error('a observação veio sem as sessões nativas')
137 const pendencias = Array.isArray(o.pendencias) ? o.pendencias.length : 0
138 const saida = [pendencias === 0 ? 'nenhum pedido pendente' : pendencias === 1 ? '1 pedido pendente' : `${pendencias} pedidos pendentes`]
139 for (const n of o.nativas as Array<Record<string, unknown>>) {
140 const sessao = (n.sessao ?? {}) as Record<string, unknown>
141 const estado = n.disponivel === true ? String(sessao.estado ?? sessao.state ?? sessao.status ?? 'confirmada') : 'não confirmada'
142 const turno = (n.turno ?? {}) as Record<string, unknown>
143 saida.push(`sessão ${curto(n.sessionId)} ${String(n.runtime ?? '?')}: ${estado}${typeof turno.conclusaoDeFase === 'string' ? ` · ${turno.conclusaoDeFase}` : ''}`)
144 }
145 return saida
146}
147
148/** O estado do git da thread (`ork_git_status`): a branch da thread e a de destino, com os HEADs. */
149export function linhasDoGit(resultado: unknown): string[] {
150 const g = (resultado ?? {}) as { source?: { branch?: unknown; head?: unknown }; destination?: { branch?: unknown; head?: unknown } }
151 if (!g.source || !g.destination) throw new Error('o estado do git veio sem origem e destino')
152 return [`${String(g.source.branch ?? '?')} @ ${curto(g.source.head)}`,
153 `destino ${String(g.destination.branch ?? '?')} @ ${curto(g.destination.head)}${g.source.head === g.destination.head ? ' (iguais)' : ''}`]
154}
155
156export function leituraFeita(linhas: string[], agora: number): TorreLeitura {
157 return { lidoEm: agora, linhas, erro: null }
158}
159export function leituraFalha(erro: string, agora: number): TorreLeitura {
160 return { lidoEm: agora, linhas: [], erro }
161}
162
163/** As linhas de uma secao lida: lendo, o erro, ou o que veio. */
164function linhasDaLeitura(l: TorreLeitura | null | undefined, agora: number): string[] {
165 if (!l) return [' lendo…']
166 if (l.erro) return [` não leu: ${l.erro}`]
167 return [...l.linhas.map(x => ` ${x}`), ` (lido há ${idade(agora - l.lidoEm)})`]
168}
169
170/**
171 * O texto do detalhe, linha a linha, na ordem em que aparece, com o que cada linha mostra ao dono (D10).
172 * Thread sem leitura so diz que nao leu e por que: nada de quem conduz nem de acao (D6).
173 */
174export function linhasDoDetalhe(linha: TorreLinha, detalhe: TorreDetalhe | undefined, agora: number): ItemDoPainel[] {
175 const saida: ItemDoPainel[] = [{ texto: cabecalhoDaLinha(linha) }]
176 if (linha.estado === 'sem-leitura') {
177 saida.push({ texto: `não li o retrato: ${linha.lacunas.join('; ')}` })
178 } else {
179 saida.push({ texto: linha.conducao ?? 'ninguém conduz agora' })
180 if (linha.bloqueio) {
181 saida.push({ texto: `bloqueio: ${linha.bloqueio.frase} (${linha.bloqueio.motivo})`,
182 mostrado: { chave: `bloqueio:${linha.thread}:${linha.bloqueio.motivo}`, thread: linha.thread, texto: `${linha.thread}: ${linha.bloqueio.frase}` } })
183 }
184 for (const p of linha.pedidos) {
185 saida.push({ texto: `pedido ${p.id}: ${p.texto}`,
186 mostrado: { chave: `hitl:${p.id}`, thread: linha.thread, texto: `${linha.thread}: espera você: ${p.texto || p.id}` } })
187 const sub = [p.prazo ? `prazo ${p.prazo}` : null, p.recomendacao ? `recomendação: ${p.recomendacao}` : null].filter(Boolean)
188 if (sub.length) saida.push({ texto: ` ${sub.join(' · ')}` })
189 }
190 for (const s of linha.sessoes) {
191 saida.push({ texto: `sessão ${curto(s.id)} ${s.rotulo}: ${emPortugues(s.estado)}${s.conduz ? ' · conduz' : ''}${s.confirmada ? '' : ' · não confirmada'}` })
192 }
193 if (linha.entrega) saida.push({ texto: `entrega: ${emPortugues(linha.entrega)}` })
194 if (linha.master) saida.push({ texto: `MASTER: ${emPortugues(linha.master)}` })
195 if (linha.lacunas.length) saida.push({ texto: `lacuna: ${linha.lacunas.join('; ')}` })
196 }
197 for (const t of ['últimos eventos', ...linhasDaLeitura(detalhe?.eventos, agora), 'claims', ...linhasDaLeitura(detalhe?.claims, agora)]) saida.push({ texto: t })
198 for (const op of LEITURAS) {
199 const l = detalhe?.leituras?.[op]
200 if (l) for (const t of [ROTULO_DA_ACAO[op] ?? op, ...linhasDaLeitura(l, agora)]) saida.push({ texto: t })
201 }
202 return saida
203}
204
205export type Gestos = {
206 voltar?: () => void
207 atualizar?: () => void
208 ler?: (operacao: string) => void
209 abrirMod?: (dono: Dono) => void
210}
211
212/** A arvore do detalhe: os botoes de voltar e atualizar, o texto, e uma linha por acao oferecida. */
213export function desenharDetalhe(el: { Box: any; Text: any; Button: any }, linha: TorreLinha, detalhe: TorreDetalhe | undefined,
214 donos: Readonly<Record<string, string>>, opcoes: { agora: number } & Gestos) {
215 const { Box, Text, Button } = el
216 const acoes = acoesDoDetalhe(linha, donos)
217 const lida = linha.estado !== 'sem-leitura'
218 return (
219 <Box flexDirection="column">
220 <Box key="gestos" flexDirection="row">
221 <Button key="voltar" label="Voltar" onPress={() => opcoes.voltar?.()} />
222 <Button key="atualizar" label="Atualizar" onPress={() => opcoes.atualizar?.()} />
223 </Box>
224 {linhasDoDetalhe(linha, detalhe, opcoes.agora).map((t, i) => (
225 <Text key={`detalhe:${i}`} dimColor={i > 0} wrap="truncate">{t.texto}</Text>
226 ))}
227 {acoes.length > 0 && <Text key="acoes-titulo" bold>ações</Text>}
228 {acoes.map(a => (a.tipo === 'leitura'
229 ? <Button key={`acao:${a.operacao}`} label={a.rotulo} onPress={() => opcoes.ler?.(a.operacao)} />
230 : a.tipo === 'mod'
231 ? <Button key={`acao:${a.operacao}`} label={`${a.rotulo}: abrir ${a.dono.nome}`} onPress={() => opcoes.abrirMod?.(a.dono)} />
232 : <Text key={`acao:${a.operacao}`} wrap="truncate">{a.texto}</Text>))}
233 {lida && acoes.length === 0 && <Text key="sem-acoes" dimColor>nenhuma ação pedida pelo estado desta thread</Text>}
234 </Box>
235 )
236}
237hooks/painel.tsx 345 lines1// O painel da Torre de Controle (RM-059, fatia F1; ficha M3 do plano dos mods). Uma linha por thread aberta
2// desta maquina, montada so do retrato da thread (`ork.maestro-snapshot/v1`) que a ponte entrega por
3// `$.ork.retrato`: o trilho das seis fases, o modo, quem conduz e desde quando, o bloqueio tipado numa frase,
4// os pedidos ao dono e as acoes que o nucleo declara disponiveis. Modulo puro: o `$` fica no register.tsx, e
5// o desenho recebe a tabela de elementos que a superficie resolveu.
6import type {
7 TorreAcao, TorreEstado, TorreFase, TorreLinha, TorreLinhaDaRede, TorreMostrado, TorrePedido, TorreQuadro, TorreRede, TorreSessao,
8} from '../types'
9import { fraseDoMotivo, MOTIVOS_DO_DONO } from './frases'
10
11export const FASES: readonly TorreFase[] = ['GOAL', 'PLAN', 'GO', 'CHECK', 'SHIP', 'MASTER']
12/** A ordem das linhas (D6): o que pede acao do dono primeiro. */
13export const ORDEM_DO_ESTADO: readonly TorreEstado[] = ['espera-voce', 'parada', 'andando', 'quieta', 'sem-leitura']
14/** Teto de threads no painel: cada uma custa um retrato. */
15export const TETO_DE_THREADS = 40
16/** As operacoes que so leem; as outras tem efeito e nunca rodam pela Torre (D4). */
17export const LEITURAS: readonly string[] = ['thread.status', 'thread.observe', 'git.status']
18/** A ordem e o rotulo de cada operacao do retrato (o enum `operation` do nucleo). */
19export const ROTULO_DA_ACAO: Readonly<Record<string, string>> = {
20 'thread.status': 'Estado',
21 'thread.observe': 'Observar',
22 'git.status': 'Git',
23 'hitl.decide': 'Decidir',
24 'retry': 'Retomar',
25 'phase': 'Fase seguinte',
26 'verify': 'Verificar',
27 'ship': 'Entregar',
28 'master': 'Nota do MASTER',
29 'git.commit': 'Commit',
30 'creation': 'Criação',
31}
32const ORDEM_DAS_ACOES = Object.keys(ROTULO_DA_ACAO)
33const ID_DE_THREAD = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/
34const SECOES_LIDAS = ['threads', 'sessions', 'blockers', 'hitl', 'nextActions']
35const TAG_DO_MODO: Readonly<Record<string, string>> = { classic: '#Classic', maestro: '#Maestro', auto: '#Auto', fast: '#Fast' }
36const MARCA_DO_ESTADO: Readonly<Record<TorreEstado, string>> = {
37 'espera-voce': '!', 'parada': 'x', 'andando': '>', 'quieta': '-', 'sem-leitura': '?',
38}
39/** Os valores do nucleo que chegam ao dono, em portugues; o que a tabela nao conhece aparece cru. */
40const EM_PORTUGUES: Readonly<Record<string, string>> = {
41 delivered: 'entregue', incomplete: 'incompleta', pending: 'pendente', recorded: 'gravado',
42 'ratificacao-pendente': 'ratificação pendente', unknown: 'desconhecido', working: 'trabalhando', running: 'rodando',
43 blocked: 'bloqueada', completed: 'concluída', done: 'concluída', stopped: 'parada', exited: 'encerrada',
44 conflict: 'em conflito', unavailable: 'indisponível', stale: 'velha', available: 'disponível',
45}
46
47export function emPortugues(valor: string): string {
48 return Object.prototype.hasOwnProperty.call(EM_PORTUGUES, valor) ? EM_PORTUGUES[valor] as string : valor
49}
50
51type Item = { id?: unknown; title?: unknown; status?: unknown; refs?: unknown; facts?: unknown; action?: unknown }
52type Retrato = { sections?: Record<string, { state?: unknown; items?: unknown } | undefined> } | null | undefined
53
54function texto(v: unknown): string | null {
55 return typeof v === 'string' && v.trim() !== '' ? v.trim() : null
56}
57
58function itens(retrato: Retrato, secao: string): Item[] {
59 const s = retrato?.sections?.[secao]
60 return Array.isArray(s?.items) ? (s?.items as unknown[]).filter((i): i is Item => !!i && typeof i === 'object') : []
61}
62
63function fatos(item: Item): Record<string, unknown> {
64 return item.facts && typeof item.facts === 'object' ? item.facts as Record<string, unknown> : {}
65}
66
67/** O item e desta thread pela referencia `thread`, nunca pelo titulo. */
68function daThread(item: Item, thread: string): boolean {
69 return Array.isArray(item.refs) && item.refs.some(r => !!r && typeof r === 'object' &&
70 (r as { kind?: unknown }).kind === 'thread' && (r as { id?: unknown }).id === thread)
71}
72
73/** As threads abertas da consulta `thread.list` da ponte (`ork thread list --json`), em ordem e com teto. */
74export function abertasDaLista(lista: unknown, teto = TETO_DE_THREADS): { ids: string[]; omitidas: number } {
75 const threads = (lista as { threads?: unknown } | null)?.threads
76 if (!Array.isArray(threads)) throw new Error('a lista das threads veio sem o campo threads')
77 const ids = [...new Set(threads.filter(t => !!t && typeof t === 'object' && typeof t.id === 'string' &&
78 ID_DE_THREAD.test(t.id) && t.status !== 'fechada').map(t => t.id as string))].sort()
79 return { ids: ids.slice(0, teto), omitidas: Math.max(0, ids.length - teto) }
80}
81
82/** O estado da linha (D6): pedido ou bloqueio do dono, outro bloqueio, alguem conduzindo, ou nada. */
83export function estadoDaLinha(l: Pick<TorreLinha, 'pedidos' | 'bloqueio' | 'conducao'>, lido: boolean): TorreEstado {
84 if (!lido) return 'sem-leitura'
85 if (l.pedidos.length > 0 || (l.bloqueio !== null && MOTIVOS_DO_DONO.includes(l.bloqueio.motivo))) return 'espera-voce'
86 if (l.bloqueio !== null) return 'parada'
87 return l.conducao !== null ? 'andando' : 'quieta'
88}
89
90/**
91 * A linha de uma thread a partir do retrato dela. Sem retrato (a leitura falhou), a linha diz que nao leu e
92 * por que, sem inventar fase nem estado.
93 */
94export function linhaDaThread(thread: string, retrato: unknown,
95 meta: { fonte: TorreLinha['fonte']; lidoEm: number | null; erro?: string }): TorreLinha {
96 const r = retrato as Retrato
97 const lido = !!r && typeof r === 'object' && !meta.erro
98 const linha = itens(r, 'threads').find(i => i.id === thread)
99 const f = linha ? fatos(linha) : {}
100 const sessoesDoRetrato = itens(r, 'sessions').filter(i => daThread(i, thread))
101 const conducao = sessoesDoRetrato.map(i => texto(fatos(i).conduction)).find((c): c is string => c !== null) ?? null
102 const sessoes: TorreSessao[] = sessoesDoRetrato.filter(i => typeof i.id === 'string' && !i.id.startsWith('conducao:'))
103 .map(i => ({ id: i.id as string, rotulo: texto(i.title) ?? '', estado: texto(i.status) ?? 'unknown',
104 conduz: texto(fatos(i).conduction) !== null, confirmada: fatos(i).nativeConfirmed === true }))
105 const gate = itens(r, 'blockers').find(i => daThread(i, thread) && i.id === `gate:${thread}`)
106 const motivo = gate ? texto(fatos(gate).reason) ?? texto(gate.title) : null
107 const pedidos: TorrePedido[] = itens(r, 'hitl').filter(i => daThread(i, thread) && i.status !== 'stale' && typeof i.id === 'string')
108 .map(i => ({ id: i.id as string, texto: texto(i.title) ?? '', prazo: texto(fatos(i).deadlineLocal) ?? '',
109 recomendacao: texto(fatos(i).recommendation) ?? '' }))
110 const acoes: TorreAcao[] = itens(r, 'nextActions').filter(i => daThread(i, thread))
111 .map(i => i.action as { operation?: unknown; available?: unknown } | undefined)
112 .filter((a): a is { operation: string; available: true } => !!a && a.available === true && typeof a.operation === 'string')
113 .map(a => ({ operacao: a.operation }))
114 .filter((a, i, todas) => todas.findIndex(b => b.operacao === a.operacao) === i)
115 .sort((a, b) => posicao(a.operacao) - posicao(b.operacao))
116 const lacunas = lido
117 ? [...(linha ? [] : ['a thread não veio no retrato']),
118 ...SECOES_LIDAS.filter(s => ['unavailable', 'conflict', 'stale'].includes(String(r?.sections?.[s]?.state)))
119 .map(s => `${s}: ${emPortugues(String(r?.sections?.[s]?.state))}`)]
120 : [meta.erro ?? 'o retrato não veio']
121 const base = {
122 thread,
123 fase: texto(f.phase),
124 modo: texto(f.mode),
125 conducao,
126 bloqueio: motivo ? { motivo, frase: fraseDoMotivo(motivo) } : null,
127 pedidos,
128 sessoes,
129 entrega: texto(itens(r, 'ship').find(i => daThread(i, thread))?.status),
130 master: texto(itens(r, 'master').find(i => daThread(i, thread))?.status),
131 acoes,
132 fonte: meta.fonte,
133 lidoEm: meta.lidoEm,
134 lacunas,
135 }
136 return { ...base, estado: estadoDaLinha(base, lido) }
137}
138
139function posicao(operacao: string): number {
140 const i = ORDEM_DAS_ACOES.indexOf(operacao)
141 return i < 0 ? ORDEM_DAS_ACOES.length : i
142}
143
144/** A acao com efeito que o estado da thread pede (D15); commit e criacao nunca sao acao do dono. */
145function pedidaPeloEstado(operacao: string, l: TorreLinha): boolean {
146 const motivo = l.bloqueio?.motivo ?? ''
147 switch (operacao) {
148 case 'hitl.decide': return l.pedidos.length > 0 || motivo === 'human.pending'
149 case 'retry': return l.estado === 'parada'
150 case 'phase': return l.estado === 'quieta' && l.fase !== 'MASTER'
151 case 'verify': return /^(claims|verify)\./.test(motivo)
152 case 'ship': return l.fase === 'SHIP' && l.entrega !== 'delivered' && l.estado !== 'andando'
153 case 'master': return (l.entrega === 'delivered' || l.fase === 'MASTER') && l.master !== 'recorded'
154 default: return false
155 }
156}
157
158/**
159 * As acoes que a Torre oferece ao dono (D15). O `available` do retrato so diz que a ferramenta existe no
160 * servidor (core/src/maestro-actions.ts): a leitura aparece sempre que disponivel, e a acao com efeito so
161 * quando, alem de disponivel, o estado da thread a pede.
162 */
163export function acoesRelevantes(l: TorreLinha): TorreAcao[] {
164 if (l.estado === 'sem-leitura') return []
165 return l.acoes.filter(a => LEITURAS.includes(a.operacao) || pedidaPeloEstado(a.operacao, l))
166}
167
168/** As linhas na ordem do estado (D6) e, dentro dele, pelo id. */
169export function ordenarLinhas(linhas: readonly TorreLinha[]): TorreLinha[] {
170 return [...linhas].sort((a, b) => ORDEM_DO_ESTADO.indexOf(a.estado) - ORDEM_DO_ESTADO.indexOf(b.estado) ||
171 (a.thread < b.thread ? -1 : a.thread > b.thread ? 1 : 0))
172}
173
174/** O trilho das seis fases: feita, atual, por vir; fase fora do ciclo deixa o trilho apagado. */
175export function trilho(fase: string | null): string {
176 const i = fase === null ? -1 : FASES.indexOf(fase as TorreFase)
177 return FASES.map((_, j) => (i < 0 ? '·' : j < i ? '●' : j === i ? '◉' : '○')).join('')
178}
179
180export function modoComTag(modo: string | null): string {
181 if (modo === null) return ''
182 return TAG_DO_MODO[modo] ?? `#${modo}`
183}
184
185/** Idade curta: segundos, minutos ou horas. */
186export function idade(ms: number): string {
187 const t = Math.max(0, ms)
188 if (t < 60000) return `${Math.floor(t / 1000)} s`
189 if (t < 3600000) return `${Math.floor(t / 60000)} min`
190 return `${Math.floor(t / 3600000)} h`
191}
192
193/** O placar de uma linha no topo do painel. */
194export function placar(q: TorreQuadro, agora: number): string {
195 if (q.lidoEm === null) return q.erro ? `Torre: não li o núcleo (${q.erro})` : 'Torre: lendo o núcleo…'
196 const lido = `lido há ${idade(agora - q.lidoEm)}`
197 const total = q.linhas.length + q.omitidas
198 if (total === 0) return `nenhuma thread aberta nesta máquina · ${lido}`
199 const conta = (e: TorreEstado) => q.linhas.filter(l => l.estado === e).length
200 const partes = [total === 1 ? '1 thread aberta' : `${total} threads abertas`]
201 const espera = conta('espera-voce')
202 const paradas = conta('parada')
203 const andando = conta('andando')
204 const semLeitura = conta('sem-leitura')
205 if (espera) partes.push(espera === 1 ? '1 espera você' : `${espera} esperam você`)
206 if (paradas) partes.push(paradas === 1 ? '1 parada' : `${paradas} paradas`)
207 if (andando) partes.push(`${andando} andando`)
208 if (semLeitura) partes.push(semLeitura === 1 ? '1 sem leitura' : `${semLeitura} sem leitura`)
209 partes.push(lido)
210 if (q.erro) partes.push(`a lista não foi relida: ${q.erro}`)
211 return partes.join(' · ')
212}
213
214/** A primeira linha de cada thread: estado, id, trilho, fase e modo. */
215export function cabecalhoDaLinha(l: TorreLinha): string {
216 return [MARCA_DO_ESTADO[l.estado], l.thread, `${trilho(l.fase)} ${l.fase ?? '?'}`, modoComTag(l.modo)]
217 .filter(p => p !== '').join(' ')
218}
219
220/** Uma linha de texto com o que ela mostra ao dono, quando e algo que o Telegram tambem avisaria (D10). */
221export type ItemDoPainel = { texto: string; mostrado?: TorreMostrado }
222
223/** As linhas de baixo: o que espera o dono, o bloqueio, quem conduz, as acoes pedidas e as lacunas. */
224export function detalhesDaLinha(l: TorreLinha): ItemDoPainel[] {
225 const saida: ItemDoPainel[] = []
226 for (const p of l.pedidos) {
227 const texto = `espera você: ${p.texto || p.id}`
228 saida.push({ texto: `${texto}${p.prazo ? ` (prazo ${p.prazo})` : ''}`, mostrado: { chave: `hitl:${p.id}`, thread: l.thread, texto: `${l.thread}: ${texto}` } })
229 }
230 if (l.bloqueio) {
231 const doDono = MOTIVOS_DO_DONO.includes(l.bloqueio.motivo)
232 saida.push({ texto: `${doDono ? 'espera você' : 'parada'}: ${l.bloqueio.frase} (${l.bloqueio.motivo})`,
233 mostrado: { chave: `bloqueio:${l.thread}:${l.bloqueio.motivo}`, thread: l.thread, texto: `${l.thread}: ${l.bloqueio.frase}` } })
234 }
235 if (l.estado !== 'sem-leitura') saida.push({ texto: l.conducao ?? 'ninguém conduz agora' })
236 const efeitos = acoesRelevantes(l).filter(a => !LEITURAS.includes(a.operacao)).map(a => ROTULO_DA_ACAO[a.operacao] ?? a.operacao)
237 if (efeitos.length) saida.push({ texto: `ações: ${efeitos.join(' · ')}` })
238 if (l.lacunas.length) saida.push({ texto: `lacuna: ${l.lacunas.join('; ')}` })
239 return saida
240}
241
242/** Tudo que o quadro mostraria e o Telegram tambem avisa: cada bloqueio e cada pedido, com a chave. */
243export function mostradosDoQuadro(q: TorreQuadro): TorreMostrado[] {
244 return q.linhas.flatMap(l => detalhesDaLinha(l).flatMap(d => (d.mostrado ? [d.mostrado] : [])))
245}
246
247/** A linha da rede no painel (D11): nao lida, lida com a idade e as lacunas, ou o erro da ultima tentativa. */
248export function placarDaRede(rede: TorreRede | null | undefined, agora: number): string {
249 if (!rede || rede.lidoEm === null) return rede?.erro ? `rede: não lida (${rede.erro})` : 'rede: não lida'
250 const n = rede.linhas.length
251 const maquinas = rede.maquinas === 1 ? '1 outra máquina' : `${rede.maquinas} outras máquinas`
252 const partes = [`rede: ${n === 0 ? 'nenhuma thread ativa' : n === 1 ? '1 thread' : `${n} threads`} em ${maquinas}, lida há ${idade(agora - rede.lidoEm)}`]
253 if (rede.lacunas) partes.push(rede.lacunas === 1 ? '1 lacuna' : `${rede.lacunas} lacunas`)
254 if (rede.erro) partes.push(`a última leitura falhou: ${rede.erro}`)
255 return partes.join(' · ')
256}
257
258/** As linhas da rede que nao sao threads abertas aqui: a local vence. */
259export function linhasDaRedeVisiveis(rede: TorreRede | null | undefined, q: TorreQuadro): TorreLinhaDaRede[] {
260 const locais = new Set(q.linhas.map(l => l.thread))
261 return (rede?.linhas ?? []).filter(l => !locais.has(l.thread))
262}
263
264/** A linha de uma thread de outra maquina: a marca, o id, o trilho, a fase, o modo, a maquina e a batida. */
265export function cabecalhoDaRede(l: TorreLinhaDaRede): string {
266 const batida = l.semBatida ? 'sem batida' : l.idadeMin === null ? 'batida ilegível' : `batida há ${l.idadeMin} min`
267 return [l.esperaVoce ? '!' : '~', l.thread, `${trilho(l.fase)} ${l.fase}`, l.modo, `@${l.maquina}, ${batida}`].filter(p => p !== '').join(' ')
268}
269
270/** Corta o texto a largura, quando ha uma, para a linha ocupar uma linha so da tela. */
271export function cortar(texto: string, largura?: number): string {
272 if (largura === undefined || largura < 2 || texto.length <= largura) return texto
273 return `${texto.slice(0, largura - 1)}…`
274}
275
276/** Uma linha da tela do painel, na ordem do desenho; cada uma ocupa uma linha so. */
277export type LinhaDoPainel =
278 | { tipo: 'placar' | 'rede' | 'omitidas'; texto: string; thread?: undefined; mostrado?: undefined }
279 | { tipo: 'cabecalho' | 'rede-cabecalho' | 'rede-pergunta'; texto: string; thread: string; mostrado?: undefined }
280 | { tipo: 'detalhe'; texto: string; thread: string; indice: number; mostrado?: TorreMostrado }
281
282/**
283 * As linhas da tela do painel, na ordem em que o desenho as poe: o placar, a linha da rede, cada thread desta
284 * maquina (a primeira linha e as de baixo), o que ficou alem do teto e as threads das outras maquinas. A
285 * janela de rolagem conta estas linhas, uma a uma.
286 */
287export function linhasDoPainel(q: TorreQuadro, opcoes: { agora: number; rede?: TorreRede | null }): LinhaDoPainel[] {
288 const saida: LinhaDoPainel[] = [{ tipo: 'placar', texto: placar(q, opcoes.agora) }, { tipo: 'rede', texto: placarDaRede(opcoes.rede, opcoes.agora) }]
289 for (const l of q.linhas) {
290 saida.push({ tipo: 'cabecalho', texto: cabecalhoDaLinha(l), thread: l.thread })
291 detalhesDaLinha(l).forEach((d, indice) => saida.push({ tipo: 'detalhe', texto: ` ${d.texto}`, thread: l.thread, indice,
292 ...(d.mostrado ? { mostrado: d.mostrado } : {}) }))
293 }
294 if (q.omitidas > 0) saida.push({ tipo: 'omitidas', texto: `+${q.omitidas} threads abertas além do teto de ${TETO_DE_THREADS}` })
295 for (const l of linhasDaRedeVisiveis(opcoes.rede, q)) {
296 saida.push({ tipo: 'rede-cabecalho', texto: cabecalhoDaRede(l), thread: l.thread })
297 if (l.esperaVoce) saida.push({ tipo: 'rede-pergunta', texto: ` espera você: ${l.pergunta ?? 'pausa humana valendo'}`, thread: l.thread })
298 }
299 return saida
300}
301
302/**
303 * O que esta dentro da janela de rolagem (D10): as linhas `primeira` em diante da tela, de `offset` ate
304 * `offset + bodyRows`. Sem janela, nada conta como visto: o registro nunca diz que o dono viu o que talvez
305 * nao estivesse na tela.
306 */
307export function mostradosVisiveis(linhas: ReadonlyArray<{ texto: string; mostrado?: TorreMostrado | undefined }>, primeira: number,
308 janela: { offset: number; bodyRows: number } | undefined): TorreMostrado[] {
309 if (!janela || !(janela.bodyRows > 0)) return []
310 const fim = janela.offset + janela.bodyRows
311 return linhas.flatMap((l, i) => (l.mostrado && primeira + i >= janela.offset && primeira + i < fim ? [l.mostrado] : []))
312}
313
314/**
315 * A arvore do painel com os elementos que a superficie resolveu (`$.ui.resolve(e)` no register.tsx), uma
316 * linha da tela por elemento. A primeira linha de cada thread desta maquina e um botao que abre o detalhe dela
317 * (T1.2); as threads das outras maquinas vem depois, sem botao (T1.3).
318 */
319export function desenharPainel(el: { Box: any; Text: any; Button: any }, q: TorreQuadro,
320 opcoes: { agora: number; largura?: number; abrir?: (thread: string) => void; rede?: TorreRede | null; lerRede?: () => void }) {
321 const { Box, Text, Button } = el
322 return (
323 <Box flexDirection="column">
324 {linhasDoPainel(q, opcoes).map(l => {
325 if (l.tipo === 'placar') return <Text key="placar" bold wrap="truncate">{l.texto}</Text>
326 if (l.tipo === 'rede') {
327 return (
328 <Box key="rede" flexDirection="row">
329 <Text key="placar-da-rede" dimColor wrap="truncate">{l.texto}</Text>
330 <Button key="ler-rede" label="Ler a rede" onPress={() => opcoes.lerRede?.()} />
331 </Box>
332 )
333 }
334 if (l.tipo === 'cabecalho') {
335 return <Button key={`abrir:${l.thread}`} plain onPress={() => opcoes.abrir?.(l.thread)}>{cortar(l.texto, opcoes.largura)}</Button>
336 }
337 if (l.tipo === 'detalhe') return <Text key={`detalhe:${l.thread}:${l.indice}`} dimColor wrap="truncate">{l.texto}</Text>
338 if (l.tipo === 'omitidas') return <Text key="omitidas" dimColor wrap="truncate">{l.texto}</Text>
339 if (l.tipo === 'rede-cabecalho') return <Text key={`rede-cabecalho:${l.thread}`} wrap="truncate">{l.texto}</Text>
340 return <Text key={`rede-pergunta:${l.thread ?? ''}`} dimColor wrap="truncate">{l.texto}</Text>
341 })}
342 </Box>
343 )
344}
345hooks/rede.ts 98 lines1// A rede e a linha da faixa na Torre de Controle (RM-059, fatia F1, T1.3; D9 e D11 da thread). O retrato do
2// nucleo nao le outras maquinas (`notConsulted` do `ork_maestro`): as threads delas vem do panorama da rede
3// (`ork_network_roadmap`, contrato `ork.network-roadmap/v1`), lido so quando o dono pede ou, com o painel a
4// vista, no maximo a cada 10 min. Lacuna nunca vira "vazio", e thread que esta aberta aqui nao se repete. A
5// linha da faixa resume o quadro numa linha so, e a ponte a desenha. Modulo puro.
6import type { TorreLinhaDaRede, TorreQuadro, TorreRede } from '../types'
7
8export const CONTRATO_DA_REDE = 'ork.network-roadmap/v1'
9/** Com o painel a vista, a rede e relida no maximo neste intervalo; o botao le na hora. */
10export const PERIODO_DA_REDE_MS = 600000
11export const TETO_DE_LINHAS_DA_REDE = 30
12/** O mesmo teto que a ponte aplica ao texto de cada linha da faixa. */
13export const TETO_DO_TEXTO_DA_FAIXA = 200
14
15const ID_DE_THREAD = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/
16
17function lista(v: unknown): Array<Record<string, unknown>> {
18 return Array.isArray(v) ? v.filter((x): x is Record<string, unknown> => !!x && typeof x === 'object') : []
19}
20
21/**
22 * As threads ativas das outras maquinas, lidas do panorama da rede que o `ork_network_roadmap` devolve
23 * (`{ texto, panorama }`). Entra so maquina que nao e esta e cujas threads foram lidas; thread aberta aqui
24 * (`locais`) ou repetida em outra maquina entra uma vez so. As lacunas do panorama sao contadas, nunca
25 * escondidas.
26 */
27export function redeDoPanorama(resposta: unknown, locais: readonly string[], agora: number): TorreRede {
28 const panorama = (resposta as { panorama?: unknown } | null)?.panorama as Record<string, unknown> | null | undefined
29 if (!panorama || panorama.contrato !== CONTRATO_DA_REDE || !Array.isArray(panorama.projetos)) {
30 throw new Error('o panorama da rede veio fora do contrato')
31 }
32 const vistas = new Set(locais)
33 const linhas: TorreLinhaDaRede[] = []
34 let maquinas = 0
35 let lacunas = lista(panorama.lacunas).length
36 for (const projeto of lista(panorama.projetos)) {
37 lacunas += lista(projeto.lacunas).length
38 for (const m of lista(projeto.maquinas)) {
39 if (m.estaMaquina === true || m.threadsLidas !== true) continue
40 maquinas++
41 for (const t of lista(m.ativas)) {
42 if (typeof t.id !== 'string' || !ID_DE_THREAD.test(t.id) || vistas.has(t.id) || t.status === 'fechada') continue
43 vistas.add(t.id)
44 linhas.push({
45 thread: t.id,
46 maquina: typeof m.maquina === 'string' ? m.maquina : '?',
47 fase: typeof t.fase === 'string' ? t.fase : '?',
48 modo: typeof t.modo === 'string' ? t.modo : '',
49 esperaVoce: t.esperaVoce === true,
50 pergunta: typeof t.pergunta === 'string' && t.pergunta.trim() !== '' ? t.pergunta.trim() : null,
51 idadeMin: typeof m.idadeMin === 'number' && Number.isFinite(m.idadeMin) ? Math.max(0, Math.round(m.idadeMin)) : null,
52 semBatida: m.semBatida === true,
53 })
54 }
55 }
56 }
57 linhas.sort((a, b) => Number(b.esperaVoce) - Number(a.esperaVoce) || (a.maquina < b.maquina ? -1 : a.maquina > b.maquina ? 1 : 0) ||
58 (a.thread < b.thread ? -1 : a.thread > b.thread ? 1 : 0))
59 return { lidoEm: agora, linhas: linhas.slice(0, TETO_DE_LINHAS_DA_REDE), maquinas, lacunas, erro: null }
60}
61
62/** A leitura que falhou guarda o que havia e diz o motivo. */
63export function redeComErro(anterior: TorreRede | null | undefined, erro: string): TorreRede {
64 return { lidoEm: anterior?.lidoEm ?? null, linhas: anterior?.linhas ?? [], maquinas: anterior?.maquinas ?? 0,
65 lacunas: anterior?.lacunas ?? 0, erro }
66}
67
68function cortar(texto: string, teto: number): string {
69 const limpo = texto.replace(/\s+/g, ' ').trim()
70 return limpo.length <= teto ? limpo : `${limpo.slice(0, teto - 1)}…`
71}
72
73/**
74 * A linha da faixa (D9): as threads abertas e o que pede acao primeiro (quem espera o dono e as paradas,
75 * com a primeira de cada), a rede quando lida e o aviso de lista velha. Sem quadro lido, nenhuma linha (a
76 * faixa fica com os outros).
77 */
78export function textoDaFaixa(q: TorreQuadro, rede: TorreRede | null): string | undefined {
79 if (q.lidoEm === null) return undefined
80 const total = q.linhas.length + q.omitidas
81 const partes = [total === 0 ? 'nenhuma thread aberta nesta máquina' : total === 1 ? '1 thread aberta' : `${total} threads abertas`]
82 const espera = q.linhas.filter(l => l.estado === 'espera-voce')
83 const paradas = q.linhas.filter(l => l.estado === 'parada')
84 const andando = q.linhas.filter(l => l.estado === 'andando').length
85 if (espera.length) partes.push(`${espera.length} ${espera.length === 1 ? 'espera' : 'esperam'} você (${espera[0]?.thread})`)
86 if (paradas.length) {
87 const p = paradas[0]
88 partes.push(`${paradas.length} ${paradas.length === 1 ? 'parada' : 'paradas'} (${p?.thread}: ${p?.bloqueio?.frase ?? '?'})`)
89 }
90 if (!espera.length && !paradas.length && andando) partes.push(`${andando} andando`)
91 if (rede && rede.lidoEm !== null) {
92 const esperaNaRede = rede.linhas.filter(l => l.esperaVoce).length
93 partes.push(`rede: ${rede.linhas.length} em outras máquinas${esperaNaRede ? `, ${esperaNaRede} espera você` : ''}`)
94 }
95 if (q.erro) partes.push('lista não relida')
96 return cortar(partes.join(' · '), TETO_DO_TEXTO_DA_FAIXA)
97}
98hooks/frases.ts 43 lines1// A frase curta de cada motivo tipado de gate do nucleo (RM-059, Torre de Controle; D5 da thread). O painel
2// mostra o bloqueio ao dono numa frase so, com acento. O teste do nucleo exige as mesmas chaves de
3// `DESCRICAO_DO_MOTIVO` (core/src/gates.ts): motivo novo no nucleo reprova o teste ate ganhar frase. Modulo
4// puro: so dados.
5
6export const FRASE_DO_MOTIVO: Readonly<Record<string, string>> = {
7 'artifact.missing': 'falta um artefato que a fase exige',
8 'claims.failed': 'uma claim reprovou no HEAD',
9 'claims.unverifiable': 'há claim sem comando de verificação',
10 'policy.violation': 'uma policy de bloqueio foi violada',
11 'runtime.autoconferencia': 'o CHECK caiu no mesmo runtime do GO',
12 'verify.regression': 'um teste que passava na baseline caiu',
13 'verify.failed': 'a verificação falhou, sem baseline para comparar',
14 'verify.timeout': 'a verificação estourou o prazo',
15 'verify.sem-veredito': 'a verificação terminou sem veredito',
16 'ci.failed': 'o CI independente não está verde',
17 'runtime.unavailable': 'o runtime não está disponível',
18 'runtime.silencio': 'a sessão parou de dar sinal',
19 'runtime.rate-limited': 'bateu o limite de uso da assinatura',
20 'runtime.quota-exhausted': 'a cota da conta acabou',
21 'runtime.auth-missing': 'a conta do runtime precisa de login',
22 'runtime.model-unavailable': 'o modelo pedido não está disponível',
23 'runtime.profile-invalid': 'o perfil pedido não existe',
24 'runtime.workspace-untrusted': 'o runtime pede que você confie na pasta',
25 'runtime.consent-pending': 'o runtime pede que você aceite os termos',
26 'cost.violation': 'o despacho iria para um provider pago',
27 'tree.blocked': 'a árvore de destino do merge está ocupada',
28 'lease.busy': 'outra thread segura a região',
29 'lease.resume-unavailable': 'a retomada do lease não está disponível',
30 'conducao.em-andamento': 'outra condução já roda nesta thread',
31 'hitl.formato': 'o pedido ao dono saiu fora do formato',
32 'human.pending': 'espera a sua autorização',
33}
34
35/** Os motivos que so o dono resolve (D6): a linha fica em "espera voce", nao em "parada". */
36export const MOTIVOS_DO_DONO: readonly string[] = ['human.pending', 'runtime.workspace-untrusted', 'runtime.consent-pending',
37 'runtime.auth-missing']
38
39/** A frase do motivo; motivo que a tabela nao conhece aparece cru, nunca some. */
40export function fraseDoMotivo(motivo: string): string {
41 return Object.prototype.hasOwnProperty.call(FRASE_DO_MOTIVO, motivo) ? FRASE_DO_MOTIVO[motivo] as string : motivo
42}
43types/index.d.ts 109 lines1// Contrato do estado da Torre de Controle (`ork-torre`, RM-059, fatia F1). O motor exige que cada chave de
2// `$.state` do mod esteja declarada aqui, em `PluginState`. A Torre nao acrescenta noun ao `$`: le o nucleo
3// pelo `$.ork` da `ork-ponte`, que o plugin.json lista em `dependencies`. Sem import nem referencia.
4
5/** As seis fases do ciclo, na ordem do trilho. */
6export type TorreFase = 'GOAL' | 'PLAN' | 'GO' | 'CHECK' | 'SHIP' | 'MASTER'
7
8/** O estado da linha (D6 da thread): o que pede acao vem primeiro. */
9export type TorreEstado = 'espera-voce' | 'parada' | 'andando' | 'quieta' | 'sem-leitura'
10
11/** Uma acao que o retrato do nucleo declara disponivel para a thread (`available: true`). */
12export type TorreAcao = { operacao: string }
13
14/** Um pedido HITL aberto da thread, como o retrato o mostra. */
15export type TorrePedido = { id: string; texto: string; prazo: string; recomendacao: string }
16
17/** Uma sessao da thread: o rotulo (`runtime · fase`), o estado nativo e se e ela quem conduz. */
18export type TorreSessao = { id: string; rotulo: string; estado: string; conduz: boolean; confirmada: boolean }
19
20/** Uma thread aberta desta maquina, como o retrato dela (`ork.maestro-snapshot/v1`) a mostra. */
21export type TorreLinha = {
22 thread: string
23 fase: string | null
24 modo: string | null
25 /** O texto de quem conduz e desde quando, como o nucleo o escreve. */
26 conducao: string | null
27 bloqueio: { motivo: string; frase: string } | null
28 pedidos: TorrePedido[]
29 sessoes: TorreSessao[]
30 /** O estado da entrega (`delivered`, `incomplete`) e do MASTER (`pending`, `recorded`), quando ha. */
31 entrega: string | null
32 master: string | null
33 acoes: TorreAcao[]
34 estado: TorreEstado
35 /** De onde veio o retrato (MCP, CLI ou cache da ponte), e quando a ponte o leu. */
36 fonte: 'mcp' | 'cli' | 'cache' | null
37 lidoEm: number | null
38 /** Secoes do retrato que nao vieram inteiras, ou o motivo de a leitura ter falhado. */
39 lacunas: string[]
40}
41
42/** O que o painel mostrou ao dono e o Telegram tambem avisaria: a chave junta o que e a mesma coisa (D10). */
43export type TorreMostrado = { chave: string; thread: string; texto: string }
44
45/** O quadro da fabrica: uma linha por thread aberta, na ordem do estado. */
46export type TorreQuadro = {
47 linhas: TorreLinha[]
48 /** Quantas threads abertas ficaram de fora pelo teto da Torre. */
49 omitidas: number
50 /** Quando a lista das abertas foi lida (ms desde a epoca); null antes da primeira leitura. */
51 lidoEm: number | null
52 /** Por que a lista das abertas nao foi lida, quando nao foi. */
53 erro: string | null
54}
55
56/** O resultado de uma leitura do detalhe: as linhas a mostrar, ou o motivo de nao ter lido. */
57export type TorreLeitura = { lidoEm: number; linhas: string[]; erro: string | null }
58
59/** O que o detalhe de uma thread leu: os ultimos eventos, as claims e cada acao de leitura pedida. */
60export type TorreDetalhe = {
61 eventos: TorreLeitura | null
62 claims: TorreLeitura | null
63 /** Por operacao de leitura do retrato (`thread.status`, `thread.observe`, `git.status`). */
64 leituras: Record<string, TorreLeitura>
65}
66
67/** Uma thread ativa de outra maquina, como o panorama da rede (`ork.network-roadmap/v1`) a mostra. */
68export type TorreLinhaDaRede = {
69 thread: string
70 maquina: string
71 fase: string
72 /** A #TAG do modo, como o retrato da maquina a escreve. */
73 modo: string
74 esperaVoce: boolean
75 pergunta: string | null
76 /** Minutos desde a batida da maquina, na hora em que a rede foi lida; null quando a batida e ilegivel. */
77 idadeMin: number | null
78 semBatida: boolean
79}
80
81/** A ultima leitura da rede: as threads das outras maquinas, quantas maquinas e lacunas, e o erro da ultima tentativa. */
82export type TorreRede = {
83 /** Quando a rede foi lida com sucesso pela ultima vez; null se nunca foi. */
84 lidoEm: number | null
85 linhas: TorreLinhaDaRede[]
86 maquinas: number
87 lacunas: number
88 erro: string | null
89}
90
91declare module 'claude-code' {
92 interface PluginState {
93 'ork-torre': {
94 /** O quadro que o painel desenha. */
95 quadro: TorreQuadro
96 /** A ultima leitura da rede, ou null antes da primeira. */
97 rede: TorreRede | null
98 /** A thread com o detalhe aberto no painel, ou null para a lista. */
99 aberta: string | null
100 /** O que o detalhe leu, por thread. */
101 detalhes: Record<string, TorreDetalhe>
102 /** Os comandos dos mods donos das acoes carregados nesta sessao, com o plugin que registrou cada um. */
103 donos: Record<string, string>
104 /** O dono fechou o painel nesta sessao: ele nao abre mais sozinho, nem depois de uma recarga do modulo. */
105 fechado: boolean
106 }
107 }
108}
109