SLOPSHOPPER

ork-torre

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…

newpanespinnercommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ork-torre
│ ┃ torre ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Torre: lendo o núcleo… │ ork-torre │ │ ┃ rede: não lida[ Ler a rede ] ⏺ Read(src/auth.ts) │ torre: a ponte ork-ponte não carregou; a │ │ ⎿ Read 6 lines │ Torre de Controle 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 · torre
Torre: lendo o núcleo… rede: não lida[ Ler a rede ]
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 7 files
hooks/register.tsx 438 lines
1// 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}
438
hooks/agenda.ts 61 lines
1// 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}
61
hooks/detalhe.tsx 237 lines
1// 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}
237
hooks/painel.tsx 345 lines
1// 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}
345
hooks/rede.ts 98 lines
1// 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}
98
hooks/frases.ts 43 lines
1// 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}
43
types/index.d.ts 109 lines
1// 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