SLOPSHOPPER

ork-mesa

Mesa do Dono do Orkastery no Claude Code: tudo que espera o dono num painel, um cartão por pedido com a recomendação na frente e a decisão pelo diálogo nativo…

newpanecommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ork-mesa
│ ┃ mesa ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ 0 esperam você · 0 decididas sem te chamar … │ ork-mesa │ │ ┃ Pedidos abertos ⏺ Read(src/auth.ts) │ mesa: a ponte ork-ponte não carregou; a │ │ ┃ nenhum pedido aberto agora ⎿ Read 6 lines │ Mesa do Dono fica desligada nesta sessão │ │ ┃ Decididas sem te chamar ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ nenhuma decisão tomada sem te chamar na j… ⎿ Added 2 lines, removed 1 line │ ⏺ 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 · mesa
0 esperam você · 0 decididas sem te chamar · 0 paradas · ai… Pedidos abertos nenhum pedido aberto agora Decididas sem te chamar nenhuma decisão tomada sem te chamar na janela do pulse
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 6 files
hooks/register.tsx 494 lines
1// A Mesa do Dono do Orkastery (RM-059, fatia F5; ficha M4 do plano dos mods). Na sessao do dono, um painel com tudo
2// que espera o dono, lido do nucleo pela ponte (`$.ork.retrato` e `$.ork.ler`) e por tres ferramentas MCP, nunca de
3// `.orkastery/` por conta propria. O botao Decidir so pede ao nucleo o dialogo nativo do pedido
4// (`ork_request_decision`, com os dois ids); a decisao obvia, pelo teste das quatro condicoes, vira decisao autonoma do
5// nucleo (`ork_decision_record`). Nenhum caminho daqui responde o pedido. A logica fica nos modulos puros ao lado; aqui
6// so os ganchos e as chamadas ao `$`, sempre escritas como `x.noun.metodo(...)`, como o validador exige.
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9import type { MesaDecidindo, MesaFila, MesaObvia, MesaPergunta } from '../types'
10import type { MesaMostrado } from './cartoes'
11import { desenharMesa, linhasDaMesa, mostradosVisiveis, paginaDoLote } from './cartoes'
12import { argsDaDecisaoObvia, argsDoDecidir, jaDecidida, motivoDaNotificacao, resultadoDoDecidir, soltarAbrindo } from './decidir'
13import {
14  assinaturaDoRetrato, conferirNoNucleo, decisoesVistas, esperamODono, FILA_VAZIA, filaDoPulse, jsonDoMcp, ordenarPerguntas,
15  pedidosNotificados, textoDaFaixa, threadsComPergunta, umaLinha,
16} from './fila'
17import { testeDaObvia } from './obvia'
18
19const MOD = 'ork-mesa'
20const PAINEL = 'mesa'
21const TITULO = 'Mesa do Dono'
22const fila = atom({ plugin: 'ork-mesa', key: 'fila' } as const, FILA_VAZIA)
23const foco = atom({ plugin: 'ork-mesa', key: 'foco' } as const, null as string | null)
24const pagina = atom({ plugin: 'ork-mesa', key: 'pagina' } as const, 0)
25const decidindo = atom({ plugin: 'ork-mesa', key: 'decidindo' } as const, {} as Record<string, MesaDecidindo>)
26const obvias = atom({ plugin: 'ork-mesa', key: 'obvias' } as const, {} as Record<string, MesaObvia>)
27
28/** O tique: a vista, o retrato a cada um; escondido, a cada quatro (D3). */
29const PERIODO_DO_TIQUE_MS = 30000
30const TIQUES_ESCONDIDO = 4
31/** O pulse velho e relido mesmo sem mudanca no retrato. */
32const IDADE_DO_PULSE_A_VISTA_MS = 5 * 60000
33const IDADE_DO_PULSE_ESCONDIDO_MS = 15 * 60000
34/** Teto de cada leitura do nucleo; o Decidir espera o dialogo, que o ingresso local fecha em ate 300 s. */
35const PRAZO_DA_LEITURA_MS = 20000
36const PRAZO_DO_DECIDIR_MS = 310000
37/** A gravacao da decisao obvia e local e rapida; passado este teto ela fica incerta, nunca recusada de cara. */
38const PRAZO_DA_DECISAO_MS = 60000
39/** A decisao incerta que o pulse nao mostra nesse tempo vira recusada, e a pergunta volta a esperar o dono. */
40const INCERTEZA_MAXIMA_MS = 10 * 60000
41/** O servidor do nucleo pode conectar depois do inicio da sessao: o papel e conferido de novo nestas esperas. */
42const ESPERAS_DO_PAPEL_MS = [3000, 10000, 30000]
43const ESPERA_LONGA_DO_PAPEL_MS = 300000
44const MAXIMO_DE_AVISOS = 5
45/** O pulse que falha volta com recuo: 30 s, depois o dobro, ate 5 min (o retrato que muda nao fura o recuo). */
46const RECUO_INICIAL_MS = 30000
47const RECUO_MAXIMO_MS = 300000
48const ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/
49
50// O que o modulo guarda entre ganchos e nao desenha. Uma recarga do modulo zera tudo, e o `session.start` dispara
51// de novo nela; o estado em `$.state` (a fila, o foco, o Decidir e as decisoes obvias) fica.
52let ativa = false
53let desistiu = false
54let ciclo: { cancel: () => void } | undefined
55let tiques = 0
56let assinatura: string | null = null
57let pulseLidoEm: number | null = null
58/** A ultima falha do pulse e o recuo ate a proxima tentativa automatica. */
59let pulseFalhouEm: number | null = null
60let recuoMs = 0
61/** O retrato mudou e o pulse ainda nao foi relido por causa disso (a mudanca vista no recuo nao se perde). */
62let mudancaPendente = false
63let lendo: Promise<void> | undefined
64let pendente = false
65/** Pedidos que esta sessao ja levou ao dono por notificacao. */
66const notificados = new Set<string>()
67/** Pedidos com a decisao obvia sendo registrada agora. */
68const registrando = new Set<string>()
69/** Pedidos com o Decidir em curso. */
70const emDialogo = new Set<string>()
71/** O que o desenho pos na janela de rolagem e ainda nao foi ao registro da ponte. */
72const vistosNoDesenho = new Map<string, MesaMostrado>()
73/** O que ja foi ao registro nesta sessao, por chave e texto. */
74const jaMostrado = new Set<string>()
75/** O ultimo texto publicado na faixa; null antes da primeira publicacao. */
76let ultimaFaixa: string | undefined | null = null
77/** A falha de um trabalho em segundo plano que nem o aviso do painel conseguiu guardar. */
78let problemaSemAviso: string | null = null
79
80function mensagem(erro: unknown): string {
81  return umaLinha(erro instanceof Error ? erro.message : String(erro))
82}
83
84/** A leitura com teto proprio: o relogio da sessao recusa a espera que passar dele. */
85function comPrazo<T>($: EngineInterface, leitura: Promise<T>, ms = PRAZO_DA_LEITURA_MS): Promise<T> {
86  return new Promise<T>((resolver, recusar) => {
87    const prazo = $.clock.after(ms, () => recusar(new Error(`a leitura passou de ${Math.round(ms / 1000)} s`)))
88    leitura.then(v => { prazo.cancel(); resolver(v) }, e => { prazo.cancel(); recusar(e) })
89  })
90}
91
92/** Um aviso no painel, sem repetir e com teto: o que falhou fica a vista do dono. */
93async function anotarAviso($: EngineInterface, texto: string): Promise<void> {
94  await update($, fila, f => (f.avisos.includes(texto) ? f : { ...f, avisos: [...f.avisos, texto].slice(-MAXIMO_DE_AVISOS) }))
95}
96
97/**
98 * Trabalho em segundo plano (o tique, a primeira leitura, os gestos do painel): a falha vai ao aviso do painel; sem
99 * como gravar o aviso (a sessao acabou ou o estado recusou), ela fica guardada aqui e o proximo desenho a mostra.
100 */
101function emSegundoPlano($: EngineInterface, trabalho: Promise<unknown>, oQue: string): void {
102  trabalho.catch(async erro => {
103    const texto = `${oQue} parou: ${mensagem(erro)}`
104    try {
105      await anotarAviso($, texto)
106    } catch (erroDoAviso) {
107      problemaSemAviso = `${texto} (o aviso também falhou: ${mensagem(erroDoAviso)})`
108    }
109  })
110}
111
112/**
113 * O papel pela ponte. `null` so quando o `$.ork` nao existe (a ponte nao carregou); uma falha da propria ponte conta
114 * como dormente e o papel e conferido de novo depois.
115 */
116async function papelDaSessao($: EngineInterface): Promise<string | null> {
117  try {
118    return String(await $.ork.papel())
119  } catch (erro) {
120    return erro instanceof TypeError ? null : 'dormente'
121  }
122}
123
124/** Liga a Mesa na sessao do dono: o `/mesa`, o tique e a primeira leitura. */
125async function ativar($: EngineInterface): Promise<void> {
126  if (ativa) return
127  ativa = true
128  try {
129    await $.command.register({ name: 'mesa', description: 'Abre a Mesa do Dono: o que espera você no Orkastery, com a recomendação na frente', immediate: true })
130  } catch (erro) {
131    // Sem o /mesa o painel nao abre: a Mesa volta a dormente e tenta de novo na espera longa.
132    ativa = false
133    $.clock.after(ESPERA_LONGA_DO_PAPEL_MS, () => { emSegundoPlano($, conferirPapel($, ESPERAS_DO_PAPEL_MS.length), 'a conferência do papel') })
134    throw erro
135  }
136  // Depois de uma recarga do modulo, nenhum Decidir esta em curso: o que ficou "abrindo" no estado volta a ter botao.
137  await update($, decidindo, soltarAbrindo)
138  ciclo?.cancel()
139  ciclo = $.clock.every(PERIODO_DO_TIQUE_MS, () => { emSegundoPlano($, tique($), 'o tique da Mesa') })
140  emSegundoPlano($, atualizar($), 'a primeira leitura da fila')
141}
142
143/** Confere o papel: liga no dono, desiste na fase ou sem a ponte, e tenta de novo enquanto dormente. */
144async function conferirPapel($: EngineInterface, tentativa: number): Promise<void> {
145  if (ativa || desistiu) return
146  const papel = await papelDaSessao($)
147  if (papel === 'dono') return ativar($)
148  if (papel === 'fase' || papel === null) {
149    desistiu = true
150    if (papel === null) $.ui.toast('mesa: a ponte ork-ponte não carregou; a Mesa do Dono fica desligada nesta sessão')
151    return
152  }
153  const espera = ESPERAS_DO_PAPEL_MS[tentativa] ?? ESPERA_LONGA_DO_PAPEL_MS
154  $.clock.after(espera, () => { emSegundoPlano($, conferirPapel($, tentativa + 1), 'a conferência do papel') })
155}
156
157/** O painel da Mesa esta posto e a vista do dono. */
158async function painelAVista($: EngineInterface): Promise<boolean> {
159  const paineis = await $.ui.panes().catch(() => [])
160  return paineis.some(p => p.id === PAINEL && p.isPlaced && p.isShown)
161}
162
163/** O tique: a conferencia do retrato (a vista, toda vez; escondido, a cada quatro) e o registro do mostrado. */
164async function tique($: EngineInterface): Promise<void> {
165  if (!ativa) return
166  tiques++
167  const aVista = await painelAVista($)
168  if (aVista || tiques % TIQUES_ESCONDIDO === 0) await conferirMudanca($, aVista)
169  await registrarMostrados($)
170}
171
172/** O retrato da secao `hitl` como sinal de mudanca, e o pulse quando o retrato muda ou o pulse fica velho (D3). */
173async function conferirMudanca($: EngineInterface, aVista: boolean): Promise<void> {
174  const agora = await $.clock.now()
175  try {
176    const r = await comPrazo($, $.ork.retrato({ secao: 'hitl' }))
177    const nova = assinaturaDoRetrato(r.retrato)
178    if (assinatura !== null && nova !== assinatura) mudancaPendente = true
179    assinatura = nova
180  } catch (erro) {
181    await anotarAviso($, `não li o retrato do núcleo: ${mensagem(erro)}`)
182  }
183  const idade = pulseLidoEm === null ? Number.POSITIVE_INFINITY : agora - pulseLidoEm
184  if (pulseFalhouEm !== null && agora - pulseFalhouEm < recuoMs) return
185  // Decisao obvia incerta de pedido ainda na fila: o pulse e relido a cada conferencia ate ela se resolver.
186  const naFila = new Set((await read($, fila)).perguntas.map(p => p.pedidoId))
187  const incerta = Object.entries(await read($, obvias)).some(([id, o]) => o.estado === 'incerta' && naFila.has(id))
188  if (mudancaPendente || incerta || idade >= (aVista ? IDADE_DO_PULSE_A_VISTA_MS : IDADE_DO_PULSE_ESCONDIDO_MS)) {
189    mudancaPendente = false
190    await atualizar($)
191  }
192}
193
194/** Uma leitura por vez; pedido que chega no meio volta a rodar logo depois, e quem pediu espera as duas. */
195async function atualizar($: EngineInterface): Promise<void> {
196  if (!ativa) return
197  if (lendo) {
198    pendente = true
199    return lendo
200  }
201  lendo = (async () => {
202    try {
203      do {
204        pendente = false
205        await lerUmaVez($)
206      } while (pendente)
207    } finally {
208      lendo = undefined
209    }
210  })()
211  return lendo
212}
213
214/** O pulse, as decisoes ja mostradas e os pedidos de cada thread com pergunta; depois a decisao obvia e o aviso. */
215async function lerUmaVez($: EngineInterface): Promise<void> {
216  const agora = await $.clock.now()
217  let pulse: unknown
218  try {
219    pulse = await comPrazo($, $.ork.ler({ consulta: 'pulse' }))
220  } catch (erro) {
221    const motivo = mensagem(erro)
222    pulseFalhouEm = agora
223    recuoMs = Math.min(recuoMs ? recuoMs * 2 : RECUO_INICIAL_MS, RECUO_MAXIMO_MS)
224    await update($, fila, anterior => ({ ...anterior, erro: motivo }))
225    await publicarFaixa($)
226    return
227  }
228  pulseLidoEm = agora
229  pulseFalhouEm = null
230  recuoMs = 0
231  const avisos: string[] = []
232  let vistas = new Set<string>()
233  let jaNotificados = new Set<string>()
234  try {
235    const registro = await comPrazo($, $.ork.mostrados())
236    vistas = decisoesVistas(registro)
237    jaNotificados = pedidosNotificados(registro)
238  } catch (erro) {
239    avisos.push(`não li o registro do que a Mesa já mostrou: ${mensagem(erro)}`)
240  }
241  const base = filaDoPulse(pulse, vistas, agora)
242  const perguntas: MesaPergunta[] = []
243  for (const thread of threadsComPergunta(base.perguntas)) {
244    const daThread = base.perguntas.filter(p => p.thread === thread)
245    try {
246      const pendentes = jsonDoMcp(await comPrazo($, $.mcp.call('orkastery', 'ork_hitl_pending', { threadId: thread })))
247      for (const p of daThread) {
248        const conferida = conferirNoNucleo(p, pendentes)
249        if (conferida) perguntas.push(conferida)
250      }
251    } catch (erro) {
252      avisos.push(`não conferi os pedidos da ${thread} no núcleo: ${mensagem(erro)}`)
253      perguntas.push(...daThread)
254    }
255  }
256  const nova: MesaFila = { ...base, perguntas: ordenarPerguntas(perguntas), lidoEm: agora, erro: null, avisos }
257  await update($, fila, () => nova)
258  const registrou = await registrarObvias($, nova, agora)
259  await notificarNovas($, nova, jaNotificados)
260  await publicarFaixa($)
261  await registrarMostrados($)
262  // A decisao pedida agora chega no lote seguinte: o pulse e relido logo depois (e a incerta se resolve nele).
263  if (registrou) pendente = true
264}
265
266/**
267 * A decisao obvia (D5 e D6): para cada pergunta conferida no nucleo que passa no teste das quatro condicoes e que a
268 * Mesa ainda nao decidiu (nesta sessao, nem na janela do pulse), uma chamada de `ork_decision_record`. Nunca responde
269 * o pedido e nunca abre o dialogo. Chamada que falha ou passa do prazo fica `incerta`, porque a escrita pode ter
270 * chegado ao nucleo: o pulse e relido ate ela aparecer (`registrada`) ou vencer a incerteza (`recusada`: a pergunta
271 * volta a esperar o dono, com o motivo no cartao). Decisao que aparece no pulse depois sempre vira `registrada`.
272 */
273async function registrarObvias($: EngineInterface, f: MesaFila, agora: number): Promise<boolean> {
274  let pediu = false
275  const ja = await read($, obvias)
276  // A incerta de pedido que saiu da fila (respondido, substituido ou vencido) nao tem mais o que confirmar.
277  const naFila = new Set(f.perguntas.map(p => p.pedidoId))
278  const sairam = Object.keys(ja).filter(id => ja[id]?.estado === 'incerta' && !naFila.has(id))
279  if (sairam.length) await update($, obvias, o => Object.fromEntries(Object.entries(o).filter(([id]) => !sairam.includes(id))))
280  for (const p of f.perguntas) {
281    const anterior = ja[p.pedidoId]
282    if (anterior) {
283      // A incerteza vence em 10 min ou no prazo do pedido, o que vier antes: o dono ainda precisa de tempo para decidir.
284      const prazo = Date.parse(p.prazo ?? '')
285      const vence = Math.min(anterior.em + INCERTEZA_MAXIMA_MS, Number.isFinite(prazo) ? prazo : Number.POSITIVE_INFINITY)
286      if (anterior.estado !== 'registrada' && jaDecidida(f.decisoes, p)) {
287        await update($, obvias, o => ({ ...o, [p.pedidoId]: { estado: 'registrada', texto: 'registrada no núcleo (conferida no pulse)', em: agora } }))
288      } else if (anterior.estado === 'incerta' && agora >= vence) {
289        await update($, obvias, o => ({ ...o, [p.pedidoId]: { estado: 'recusada',
290          texto: `o pulse não mostrou a decisão gravada a tempo (${anterior.texto}); vale como pergunta`, em: agora } }))
291      }
292      continue
293    }
294    if (registrando.has(p.pedidoId) || !p.conferida) continue
295    const teste = testeDaObvia(p, agora)
296    if (!teste.obvia) continue
297    if (jaDecidida(f.decisoes, p)) {
298      await update($, obvias, o => ({ ...o, [p.pedidoId]: { estado: 'registrada', texto: 'já registrada pela Mesa', em: agora } }))
299      continue
300    }
301    registrando.add(p.pedidoId)
302    let obvia: MesaObvia
303    try {
304      const args = argsDaDecisaoObvia(p, teste.alternativa, teste.referencia, new Date(agora).toISOString())
305      let resposta: unknown
306      try {
307        resposta = await comPrazo($, $.mcp.call('orkastery', 'ork_decision_record', args), PRAZO_DA_DECISAO_MS)
308      } catch (erro) {
309        // Sem resposta do nucleo (prazo ou transporte): a escrita pode ter chegado.
310        resposta = undefined
311        obvia = { estado: 'incerta', texto: mensagem(erro), em: agora }
312      }
313      if (resposta !== undefined) {
314        try {
315          jsonDoMcp(resposta)
316          obvia = { estado: 'registrada', texto: 'registrada no núcleo', em: agora }
317        } catch (erro) {
318          // O nucleo respondeu com recusa: nada foi gravado.
319          obvia = { estado: 'recusada', texto: mensagem(erro), em: agora }
320        }
321      }
322    } finally {
323      registrando.delete(p.pedidoId)
324    }
325    const final = obvia!
326    await update($, obvias, o => ({ ...o, [p.pedidoId]: final }))
327    pediu = true
328  }
329  return pediu
330}
331
332/**
333 * A pergunta nova que espera o dono (nao obvia, ainda nao notificada nesta sessao nem no registro da ponte) vira uma
334 * notificacao do sistema, uma so por leitura. So a que saiu de fato conta como notificada e entra no registro do
335 * mostrado com o canal `notificacao` (D8 e M5): a que nao saiu fica no aviso do painel e volta na leitura seguinte.
336 */
337async function notificarNovas($: EngineInterface, f: MesaFila, jaNotificados: ReadonlySet<string>): Promise<void> {
338  const novas = esperamODono(f, await read($, obvias)).filter(p => !notificados.has(p.pedidoId) && !jaNotificados.has(p.pedidoId))
339  if (!novas.length) return
340  const primeira = novas[0] as MesaPergunta
341  const texto = novas.length === 1
342    ? umaLinha(`${primeira.thread} espera você: ${primeira.pergunta}`)
343    : umaLinha(`${novas.length} pedidos esperam você: ${[...new Set(novas.map(p => p.thread))].join(', ')}`)
344  let motivo: string | null
345  try {
346    const enviada = await $.ui.notify(texto, { title: TITULO })
347    if (!enviada.isSent && enviada.reason === 'disabled') {
348      // O dono desligou as notificacoes: escolha dele, sem aviso e sem nova tentativa; a faixa e o painel seguem.
349      for (const p of novas) notificados.add(p.pedidoId)
350      return
351    }
352    motivo = enviada.isSent ? null : motivoDaNotificacao(enviada.reason)
353  } catch (erro) {
354    motivo = mensagem(erro)
355  }
356  if (motivo !== null) {
357    await anotarAviso($, `não avisei o dono do pedido novo: ${motivo}; a faixa e o painel seguem`)
358    return
359  }
360  for (const p of novas) notificados.add(p.pedidoId)
361  try {
362    for (const p of novas) await $.ork.mostrado({ mod: MOD, canal: 'notificacao', chave: `hitl:${p.pedidoId}`, thread: p.thread, texto })
363  } catch (erro) {
364    await anotarAviso($, `não registrei a notificação na ponte: ${mensagem(erro)}`)
365  }
366}
367
368/** A linha da faixa pela ponte, so quando o texto muda; sem nada, a linha sai da faixa. */
369async function publicarFaixa($: EngineInterface): Promise<void> {
370  const texto = textoDaFaixa(await read($, fila), await read($, obvias))
371  if (texto === ultimaFaixa) return
372  try {
373    if (texto === undefined) await $.ork.faixa({ mod: MOD })
374    else await $.ork.faixa({ mod: MOD, texto })
375    ultimaFaixa = texto
376  } catch (erro) {
377    await anotarAviso($, `não publiquei a linha da faixa: ${mensagem(erro)}`)
378  }
379}
380
381/** O desenho anota o que pos na janela; quem registra e o laco de leitura e o tique, fora do desenho. */
382function anotarVistos(vistos: readonly MesaMostrado[]): void {
383  for (const m of vistos) vistosNoDesenho.set(`${m.chave}|${m.texto}`, m)
384}
385
386/**
387 * O que o desenho pos na janela do painel posto e a vista vai ao registro da ponte (M5 do dono, D8), uma vez por
388 * chave e texto nesta sessao. Com o painel escondido, o anotado e descartado: o proximo desenho a vista anota de novo.
389 */
390async function registrarMostrados($: EngineInterface): Promise<void> {
391  if (vistosNoDesenho.size === 0) return
392  if (!await painelAVista($)) {
393    vistosNoDesenho.clear()
394    return
395  }
396  for (const [vista, m] of [...vistosNoDesenho]) {
397    vistosNoDesenho.delete(vista)
398    if (jaMostrado.has(vista)) continue
399    try {
400      const thread = m.thread && ID.test(m.thread) ? { thread: m.thread } : {}
401      await $.ork.mostrado({ mod: MOD, canal: 'painel', chave: m.chave, ...thread, texto: m.texto })
402      jaMostrado.add(vista)
403    } catch (erro) {
404      vistosNoDesenho.set(vista, m)
405      await anotarAviso($, `não registrei o que o painel mostrou: ${mensagem(erro)}`)
406      return
407    }
408  }
409}
410
411/**
412 * O Decidir (D7): so com o canal `claude-code` disponivel para o pedido, um por pedido, em segundo plano. A chamada
413 * leva exatamente o `threadId` e o `pedidoId`; quem responde e o dono, no dialogo nativo do host.
414 */
415async function decidir($: EngineInterface, pedidoId: string): Promise<void> {
416  if (!ID.test(pedidoId) || emDialogo.has(pedidoId)) return
417  // Marcado antes de qualquer espera: dois toques ao mesmo tempo abrem um dialogo so.
418  emDialogo.add(pedidoId)
419  let emCurso = false
420  try {
421    const p = (await read($, fila)).perguntas.find(x => x.pedidoId === pedidoId)
422    if (!p || p.canalClaudeCode !== 'disponivel') return
423    const pedidoEm = await $.clock.now()
424    await update($, decidindo, d => ({ ...d, [pedidoId]: { estado: 'abrindo', texto: 'diálogo pedido', em: pedidoEm } }))
425    emCurso = true
426    pedirDialogo($, p)
427  } finally {
428    if (!emCurso) emDialogo.delete(pedidoId)
429  }
430}
431
432/** O pedido do dialogo ao nucleo, em segundo plano; quem libera o pedido para outro Decidir e o fim desta espera. */
433function pedirDialogo($: EngineInterface, p: MesaPergunta): void {
434  const pedidoId = p.pedidoId
435  emSegundoPlano($, (async () => {
436    let r: MesaDecidindo
437    try {
438      const resposta = jsonDoMcp(await comPrazo($, $.mcp.call('orkastery', 'ork_request_decision', argsDoDecidir(p)), PRAZO_DO_DECIDIR_MS))
439      r = resultadoDoDecidir(resposta, await $.clock.now())
440    } catch (erro) {
441      r = { estado: 'erro', texto: `o Decidir falhou: ${mensagem(erro)}; use a reserva`, em: await $.clock.now() }
442    } finally {
443      emDialogo.delete(pedidoId)
444    }
445    await update($, decidindo, d => ({ ...d, [pedidoId]: r }))
446    await atualizar($)
447  })(), `o Decidir do pedido ${pedidoId.slice(0, 8)}`)
448}
449
450export const register: Register = on => {
451  on('session.start', async ($, e, next) => {
452    if (e.isInteractive) await conferirPapel($, 0)
453    return next(e)
454  })
455
456  // `/mesa` abre o painel e rele a fila; `/mesa <thread>` (a Torre o usa para a acao `hitl.decide`) mostra so a
457  // thread pedida. O comando so existe com a Mesa ligada; a conferencia do papel aqui cobre o comando que sobrou de
458  // uma carga anterior.
459  on('command.run', { command: 'mesa' }, async ($, e) => {
460    if (!ativa && !desistiu) await conferirPapel($, ESPERAS_DO_PAPEL_MS.length)
461    if (!ativa) return { text: 'A Mesa do Dono só abre na sessão do dono de um projeto do Orkastery.' }
462    const pedida = String(e.args ?? '').trim()
463    const thread = ID.test(pedida) ? pedida : null
464    await update($, foco, () => thread)
465    await update($, pagina, () => 0)
466    const aberto = await $.ui.open({ id: PAINEL, title: TITULO })
467    await atualizar($)
468    const sufixo = thread ? ` (só ${thread})` : ''
469    return { text: aberto.isPlaced ? `Mesa do Dono aberta${sufixo}.` : `Mesa do Dono aberta${sufixo}, à espera de lugar: ${aberto.reason}` }
470  })
471
472  // O painel: o placar, o lote de perguntas, as decisoes por voce e as paradas. O desenho nunca escreve: so anota o
473  // que pos na janela de rolagem, para o registro do mostrado. Os gestos mudam o estado da Mesa e pedem ao nucleo.
474  on('ui.render', { component: 'Pane', requestId: PAINEL }, async ($, e) => {
475    const { Box, Text, Button } = $.ui.resolve(e)
476    const lida = await read($, fila)
477    const estado = {
478      fila: problemaSemAviso ? { ...lida, avisos: [...lida.avisos, problemaSemAviso] } : lida, foco: await read($, foco),
479      pagina: await read($, pagina), decidindo: await read($, decidindo), obvias: await read($, obvias),
480    }
481    const linhas = linhasDaMesa(estado, { agora: await $.clock.now() })
482    anotarVistos(mostradosVisiveis(linhas, e.props.scroll))
483    const total = estado.foco ? estado.fila.perguntas.filter(p => p.thread === estado.foco).length : estado.fila.perguntas.length
484    return desenharMesa({ Box, Text, Button }, linhas, {
485      decidir: pedidoId => { emSegundoPlano($, decidir($, pedidoId), 'o Decidir') },
486      atualizar: () => { emSegundoPlano($, atualizar($), 'a releitura da fila') },
487      verTodas: () => { emSegundoPlano($, update($, foco, () => null), 'o Ver todas') },
488      // A pagina guardada pode passar do fim depois que a fila encolhe: o passo parte da pagina que esta na tela.
489      anteriores: () => { emSegundoPlano($, update($, pagina, n => paginaDoLote(total, paginaDoLote(total, n).pagina - 1).pagina), 'o lote anterior') },
490      proximas: () => { emSegundoPlano($, update($, pagina, n => paginaDoLote(total, paginaDoLote(total, n).pagina + 1).pagina), 'o lote seguinte') },
491    })
492  })
493}
494
hooks/cartoes.tsx 247 lines
1// Os cartoes da Mesa do Dono (RM-059 F5; D4 da thread ork-rm059mesa), em funcoes puras: a fila vira linhas da tela,
2// uma por elemento, e as linhas viram a arvore do painel com os elementos que a superficie resolveu. Cada pergunta
3// traz a recomendada primeiro, com o porque, depois as outras alternativas com a consequencia; a decisao que a
4// fabrica (ou a Mesa) tomou pelo dono traz o que, o porque, como mudar e o custo de reverter. O unico botao com
5// efeito e o Decidir, que so pede ao nucleo o dialogo nativo do pedido. So importa tipos e modulos puros da Mesa.
6import type { MesaDecidindo, MesaDecisao, MesaFila, MesaObvia, MesaOutro, MesaPergunta } from '../types'
7import { duracao, esperamODono, MAXIMO_DE_DECISOES, MAXIMO_DE_OUTROS, TAMANHO_DO_LOTE } from './fila'
8import { FALHA_DA_CONDICAO, testeDaObvia } from './obvia'
9
10export type MesaMostrado = { chave: string; thread: string | null; texto: string }
11
12export type LinhaDaMesa = {
13  tipo: 'titulo' | 'aviso' | 'foco' | 'secao' | 'cabecalho' | 'texto' | 'dim' | 'decidir' | 'nav'
14  chave: string
15  texto: string
16  /** O pedido do botao Decidir. */
17  pedidoId?: string
18  /** O que vai ao registro do mostrado da ponte quando a linha esta na janela de rolagem do painel a vista. */
19  mostrado?: MesaMostrado
20}
21
22export type EstadoDaMesa = {
23  fila: MesaFila
24  foco: string | null
25  pagina: number
26  decidindo: Readonly<Record<string, MesaDecidindo>>
27  obvias: Readonly<Record<string, MesaObvia>>
28}
29
30function plural(n: number, um: string, varios: string): string {
31  return `${n} ${n === 1 ? um : varios}`
32}
33
34/** O prazo como o dono le: o horario local que o nucleo mandou, ou quanto falta. */
35function textoDoPrazo(p: MesaPergunta, agora: number): string | null {
36  if (p.prazoLocal) return `prazo ${p.prazoLocal}`
37  const t = Date.parse(p.prazo ?? '')
38  if (!Number.isFinite(t)) return null
39  return t > agora ? `vence em ${duracao(t - agora)}` : `venceu há ${duracao(agora - t)}`
40}
41
42/** As paginas do lote de perguntas, e a pagina pedida dentro delas. */
43export function paginaDoLote(total: number, pagina: number): { pagina: number; paginas: number } {
44  const paginas = Math.max(1, Math.ceil(total / TAMANHO_DO_LOTE))
45  return { pagina: Math.min(Math.max(0, Math.floor(pagina) || 0), paginas - 1), paginas }
46}
47
48/** O cartao de uma pergunta. */
49export function linhasDaPergunta(p: MesaPergunta, e: EstadoDaMesa, agora: number): LinhaDaMesa[] {
50  const k = (s: string) => `${p.pedidoId}:${s}`
51  const obvia = e.obvias[p.pedidoId]
52  const decidida = obvia?.estado === 'registrada'
53  const decidindo = e.decidindo[p.pedidoId]
54  const cabecalho = [p.thread, p.fase, p.codigo ? `código ${p.codigo}` : null, textoDoPrazo(p, agora)].filter(Boolean).join(' · ')
55  const linhas: LinhaDaMesa[] = [{
56    tipo: 'cabecalho', chave: k('cabecalho'), texto: `${decidida ? '✓' : '?'} ${cabecalho}`,
57    mostrado: { chave: `hitl:${p.pedidoId}`, thread: p.thread, texto: `${p.thread}: espera você: ${p.pergunta}` },
58  }]
59  linhas.push({ tipo: 'texto', chave: k('pergunta'), texto: `  ${p.pergunta}` })
60  if (decidida) {
61    linhas.push({ tipo: 'texto', chave: k('obvia'), texto: `  decidido sem te chamar: segue a recomendada no prazo; ${comoMudarDaObvia(p, agora)}` })
62  } else if (obvia?.estado === 'incerta') {
63    linhas.push({ tipo: 'dim', chave: k('obvia'), texto: '  a Mesa pediu ao núcleo a decisão óbvia e confere na próxima leitura se ela foi gravada' })
64  } else if (obvia?.estado === 'recusada') {
65    linhas.push({ tipo: 'aviso', chave: k('obvia'), texto: `  a Mesa não decidiu: ${obvia.texto}` })
66  } else if (!p.conferida) {
67    linhas.push({ tipo: 'dim', chave: k('porque-pergunta'), texto: '  não conferido no núcleo agora: sem Decidir e sem decisão óbvia; use a reserva' })
68  } else {
69    // Sem criterio escrito e o caso de todo gate que espera o dono: dizer isso em cada cartao so repetiria a regra.
70    const teste = testeDaObvia(p, agora)
71    const motivo = teste.obvia ? undefined : teste.falhou[0]
72    if (motivo && motivo !== 'criterio') linhas.push({ tipo: 'dim', chave: k('porque-pergunta'), texto: `  pergunta: ${FALHA_DA_CONDICAO[motivo]}` })
73  }
74  const recomendada = p.alternativas.find(a => a.recomendada)
75  if (recomendada) {
76    linhas.push({ tipo: 'texto', chave: k('recomendada'), texto: `  Recomendada: ${recomendada.chave}) ${recomendada.texto}` })
77    if (recomendada.porque) linhas.push({ tipo: 'dim', chave: k('porque'), texto: `    porque: ${recomendada.porque}` })
78    if (recomendada.consequencia) linhas.push({ tipo: 'dim', chave: k('consequencia'), texto: `    se escolher: ${recomendada.consequencia}` })
79  } else if (p.recomendacao) {
80    linhas.push({ tipo: 'texto', chave: k('recomendada'), texto: `  Recomendação: ${p.recomendacao}` })
81  }
82  for (const a of p.alternativas) {
83    if (a.recomendada) continue
84    linhas.push({ tipo: 'dim', chave: k(`alt:${a.chave}`), texto: `  ${a.chave}) ${a.texto}${a.consequencia ? `: ${a.consequencia}` : ''}` })
85  }
86  if (p.irreversivel) linhas.push({ tipo: 'aviso', chave: k('sem-volta'), texto: `  sem volta: ${p.ato ?? 'ato irreversível'}` })
87  if (decidindo?.estado === 'abrindo') {
88    linhas.push({ tipo: 'texto', chave: k('decidindo'), texto: '  diálogo pedido: responda na janela do Claude Code' })
89  } else {
90    if (decidindo) linhas.push({ tipo: decidindo.estado === 'erro' ? 'aviso' : 'dim', chave: k('decidindo'), texto: `  ${decidindo.texto}` })
91    if (p.canalClaudeCode === 'disponivel') linhas.push({ tipo: 'decidir', chave: `decidir:${p.pedidoId}`, texto: 'Decidir', pedidoId: p.pedidoId })
92  }
93  if (p.reserva) {
94    const telegram = p.contrato === 'ork.hitl/v2' && p.alvo === 'gate' && p.codigo !== null
95    linhas.push({ tipo: 'dim', chave: k('reserva'), texto: telegram ? `  reserva: ${p.reserva} pelo Telegram` : `  reserva: ${p.reserva}` })
96  }
97  return linhas
98}
99
100/** Como o dono muda a decisao obvia que a Mesa registrou: o Decidir ou a reserva antes do prazo; depois dele, nada. */
101function comoMudarDaObvia(p: MesaPergunta, agora: number): string {
102  const prazo = Date.parse(p.prazo ?? '')
103  if (Number.isFinite(prazo) && prazo <= agora) return 'o prazo passou e a recomendada seguiu'
104  return p.canalClaudeCode === 'disponivel' ? 'para mudar, Decidir antes do prazo' : 'para mudar, use a reserva antes do prazo'
105}
106
107/** O cartao de uma decisao que a fabrica (ou a Mesa) tomou sem chamar o dono. */
108export function linhasDaDecisao(d: MesaDecisao, agora: number): LinhaDaMesa[] {
109  const k = (s: string) => `${d.id}:${s}`
110  const em = Date.parse(d.em)
111  const quando = Number.isFinite(em) ? ` · há ${duracao(agora - em)}` : ''
112  const marca = d.revertidaPor ? ' · revertida' : d.nova ? ' · nova' : ''
113  return [
114    { tipo: 'cabecalho', chave: k('cabecalho'), texto: `${d.revertidaPor ? '↺' : '✓'} ${d.thread} · ${d.fase}${quando}${marca}`,
115      mostrado: { chave: `decisao:${d.id}`, thread: d.thread || null, texto: `${d.thread}: decidido sem te chamar: ${d.decidido}` } },
116    { tipo: 'texto', chave: k('decidido'), texto: `  ${d.decidido}` },
117    ...(d.revertidaPor ? [{ tipo: 'aviso' as const, chave: k('revertida'), texto: `  desfeita pela decisão ${d.revertidaPor.slice(0, 8)}` }] : []),
118    ...(d.reverte ? [{ tipo: 'dim' as const, chave: k('reverte'), texto: `  desfaz a decisão ${d.reverte.slice(0, 8)}` }] : []),
119    { tipo: 'dim', chave: k('porque'), texto: `  porque: ${d.porque}` },
120    { tipo: 'dim', chave: k('como-mudar'), texto: `  como mudar: ${d.comoMudar}` },
121    { tipo: 'dim', chave: k('custo'), texto: `  reverter: agora, ${d.custoAgora}; depois, ${d.custoDepois}` },
122    { tipo: 'dim', chave: k('quem'), texto: `  quem decidiu: ${d.quemDecidiu}` },
123  ]
124}
125
126/** O cartao de uma parada sem pedido ou de um impedimento: so leitura, com a recomendacao e o comando. */
127export function linhasDoOutro(o: MesaOutro, agora: number): LinhaDaMesa[] {
128  const k = (s: string) => `${o.chave}:${s}`
129  const desde = Date.parse(o.desdeEm ?? '')
130  const partes = [o.thread ?? 'sem thread', o.fase, o.motivo, Number.isFinite(desde) ? `há ${duracao(agora - desde)}` : null,
131    o.impedimento ? 'só você resolve' : null].filter(Boolean)
132  const linhas: LinhaDaMesa[] = [{
133    tipo: 'cabecalho', chave: k('cabecalho'), texto: `! ${partes.join(' · ')}`,
134    mostrado: { chave: `parada:${o.chave}`, thread: o.thread, texto: `${o.thread ?? 'sem thread'}: ${o.motivo}: ${o.texto}` },
135  }]
136  if (o.texto) linhas.push({ tipo: 'texto', chave: k('texto'), texto: `  ${o.texto}` })
137  if (o.recomendacao) linhas.push({ tipo: 'dim', chave: k('recomendacao'), texto: `  recomendação: ${o.recomendacao}` })
138  if (o.comando && o.comando !== o.recomendacao) linhas.push({ tipo: 'dim', chave: k('comando'), texto: `  comando: ${o.comando}` })
139  return linhas
140}
141
142/** As linhas do painel: o placar, o foco, as perguntas do lote, as decisoes por voce e as paradas. */
143export function linhasDaMesa(e: EstadoDaMesa, opcoes: { agora: number }): LinhaDaMesa[] {
144  const { fila, foco } = e
145  const agora = opcoes.agora
146  const daThread = <T extends { thread: string | null }>(xs: readonly T[]): T[] => (foco ? xs.filter(x => x.thread === foco) : [...xs])
147  const perguntas = daThread(fila.perguntas)
148  const decisoes = daThread(fila.decisoes)
149  const outros = daThread(fila.outros)
150  const esperam = esperamODono({ ...fila, perguntas }, e.obvias)
151  const desde = esperam.map(p => Date.parse(p.desdeEm ?? '')).filter(Number.isFinite)
152  const novas = decisoes.filter(d => d.nova).length
153  const placar = [
154    `${plural(esperam.length, 'espera', 'esperam')} você${desde.length ? ` (a mais antiga há ${duracao(agora - Math.min(...desde))})` : ''}`,
155    `${plural(decisoes.length, 'decidida', 'decididas')} sem te chamar${novas ? ` (${plural(novas, 'nova', 'novas')})` : ''}`,
156    plural(outros.length, 'parada', 'paradas'),
157    fila.lidoEm === null ? 'ainda não li a fila' : `lido há ${duracao(agora - fila.lidoEm)}`,
158  ]
159  const linhas: LinhaDaMesa[] = [{ tipo: 'titulo', chave: 'titulo', texto: placar.join(' · ') }]
160  if (fila.erro) linhas.push({ tipo: 'aviso', chave: 'erro', texto: `não reli a fila: ${fila.erro}` })
161  fila.avisos.forEach((a, i) => linhas.push({ tipo: 'aviso', chave: `aviso:${i}`, texto: a }))
162  if (foco) linhas.push({ tipo: 'foco', chave: 'foco', texto: `só ${foco}` })
163
164  // A secao lista todo pedido aberto; o placar conta so os que esperam o dono (a decisao obvia nao espera).
165  linhas.push({ tipo: 'secao', chave: 'secao-perguntas', texto: 'Pedidos abertos' })
166  if (!perguntas.length) linhas.push({ tipo: 'dim', chave: 'sem-perguntas', texto: '  nenhum pedido aberto agora' })
167  const { pagina, paginas } = paginaDoLote(perguntas.length, e.pagina)
168  for (const p of perguntas.slice(pagina * TAMANHO_DO_LOTE, (pagina + 1) * TAMANHO_DO_LOTE)) linhas.push(...linhasDaPergunta(p, e, agora))
169  if (paginas > 1) linhas.push({ tipo: 'nav', chave: 'nav', texto: `lote ${pagina + 1} de ${paginas}` })
170
171  linhas.push({ tipo: 'secao', chave: 'secao-decisoes', texto: 'Decididas sem te chamar' })
172  if (!decisoes.length) linhas.push({ tipo: 'dim', chave: 'sem-decisoes', texto: '  nenhuma decisão tomada sem te chamar na janela do pulse' })
173  for (const d of decisoes.slice(0, MAXIMO_DE_DECISOES)) linhas.push(...linhasDaDecisao(d, agora))
174  if (decisoes.length > MAXIMO_DE_DECISOES) {
175    linhas.push({ tipo: 'dim', chave: 'mais-decisoes', texto: `  e mais ${decisoes.length - MAXIMO_DE_DECISOES} na janela do pulse (7 dias)` })
176  }
177
178  if (outros.length) {
179    linhas.push({ tipo: 'secao', chave: 'secao-outros', texto: 'Paradas e impedimentos' })
180    for (const o of outros.slice(0, MAXIMO_DE_OUTROS)) linhas.push(...linhasDoOutro(o, agora))
181    if (outros.length > MAXIMO_DE_OUTROS) linhas.push({ tipo: 'dim', chave: 'mais-outros', texto: `  e mais ${outros.length - MAXIMO_DE_OUTROS}` })
182  }
183  return linhas
184}
185
186/**
187 * O que o desenho poe na janela de rolagem e vai ao registro do mostrado: so linha com `mostrado` cuja posicao esta
188 * entre `offset` e `offset + bodyRows`. Sem janela, nada: o que nao estava na tela nao conta como visto.
189 */
190export function mostradosVisiveis(linhas: readonly LinhaDaMesa[], janela: { offset: number; bodyRows: number } | undefined): MesaMostrado[] {
191  if (!janela || !(janela.bodyRows > 0)) return []
192  const fim = janela.offset + janela.bodyRows
193  return linhas.flatMap((l, i) => (l.mostrado && i >= janela.offset && i < fim ? [l.mostrado] : []))
194}
195
196export type AcoesDaMesa = {
197  decidir?: (pedidoId: string) => void
198  atualizar?: () => void
199  verTodas?: () => void
200  anteriores?: () => void
201  proximas?: () => void
202}
203
204/** A arvore do painel com os elementos que a superficie resolveu (`$.ui.resolve(e)` no register.tsx). */
205export function desenharMesa(el: { Box: any; Text: any; Button: any }, linhas: readonly LinhaDaMesa[], acoes: AcoesDaMesa) {
206  const { Box, Text, Button } = el
207  return (
208    <Box flexDirection="column">
209      {linhas.map(l => {
210        if (l.tipo === 'titulo') {
211          return (
212            <Box key="titulo" flexDirection="row">
213              <Text key="placar" bold wrap="truncate">{l.texto}</Text>
214              <Button key="atualizar" label="Atualizar" onPress={() => acoes.atualizar?.()} />
215            </Box>
216          )
217        }
218        if (l.tipo === 'foco') {
219          return (
220            <Box key="foco" flexDirection="row">
221              <Text key="foco-texto" dimColor wrap="truncate">{l.texto}</Text>
222              <Button key="ver-todas" label="Ver todas" onPress={() => acoes.verTodas?.()} />
223            </Box>
224          )
225        }
226        if (l.tipo === 'nav') {
227          return (
228            <Box key="nav" flexDirection="row">
229              <Button key="anteriores" label="Anteriores" onPress={() => acoes.anteriores?.()} />
230              <Text key="nav-texto" dimColor wrap="truncate">{l.texto}</Text>
231              <Button key="proximas" label="Próximas" onPress={() => acoes.proximas?.()} />
232            </Box>
233          )
234        }
235        if (l.tipo === 'decidir') {
236          const pedidoId = l.pedidoId ?? ''
237          return <Button key={l.chave} label="Decidir" onPress={() => acoes.decidir?.(pedidoId)} />
238        }
239        if (l.tipo === 'secao' || l.tipo === 'cabecalho') return <Text key={l.chave} bold wrap="truncate">{l.texto}</Text>
240        if (l.tipo === 'aviso') return <Text key={l.chave} color="warning" wrap="truncate">{l.texto}</Text>
241        if (l.tipo === 'dim') return <Text key={l.chave} dimColor wrap="truncate">{l.texto}</Text>
242        return <Text key={l.chave} wrap="truncate">{l.texto}</Text>
243      })}
244    </Box>
245  )
246}
247
hooks/decidir.ts 111 lines
1// O que a Mesa do Dono manda ao nucleo (RM-059 F5; D6 e D7 da thread ork-rm059mesa), em funcoes puras. Sao so duas
2// ferramentas com efeito e uma de leitura, todas no servidor `orkastery`:
3//  - `ork_request_decision` recebe exatamente `threadId` e `pedidoId`: o nucleo abre o dialogo nativo do host e grava
4//    a resposta que o dono der, com o recibo do ingresso local. A Mesa nunca manda opcao, letra nem texto.
5//  - `ork_decision_record` registra a decisao obvia (teste das quatro condicoes) como decisao autonoma do nucleo,
6//    com criterio, evidencia, como mudar e o custo de reverter. Nao responde o pedido.
7//  - `ork_hitl_pending` le os pedidos abertos de uma thread.
8// So importa tipos.
9import type { MesaDecidindo, MesaDecisao, MesaPergunta } from '../types'
10
11export const SERVIDOR = 'orkastery'
12/** A lista fechada de ferramentas do nucleo que a Mesa chama (D2 e D7). */
13export const FERRAMENTAS_DA_MESA: readonly string[] = ['ork_hitl_pending', 'ork_request_decision', 'ork_decision_record']
14/** Quem decidiu, no rastro do nucleo: a Mesa, nunca o dono (o nucleo recusa rastro que comeca pelo dono). */
15export const QUEM_DECIDIU = 'mod ork-mesa na sessão do dono, pelo teste das quatro condições'
16/** Os tetos do nucleo para a decisao informada (`TETOS_HITL_V2` e o esquema do `ork_decision_record`). */
17export const TETO_DO_CAMPO = 200
18export const TETO_DO_CUSTO = 140
19export const TETO_DA_REFERENCIA = 300
20
21/** Uma linha so, cortada no teto, para caber no contrato do nucleo. */
22export function linhaNoTeto(v: string, teto: number): string {
23  const t = v.replace(/\s+/g, ' ').trim()
24  return t.length <= teto ? t : `${t.slice(0, teto - 1).trimEnd()}…`
25}
26
27/** Os argumentos do Decidir: so os dois ids, nada que seja resposta. */
28export function argsDoDecidir(p: Pick<MesaPergunta, 'thread' | 'pedidoId'>): { threadId: string; pedidoId: string } {
29  return { threadId: p.thread, pedidoId: p.pedidoId }
30}
31
32/** A marca do pedido no texto da decisao obvia: e por ela que a Mesa sabe que ja decidiu este pedido. */
33export function marcaDoPedido(pedidoId: string): string {
34  return `(${pedidoId.slice(0, 8)})`
35}
36
37/** Ja existe, na janela do pulse, a decisao obvia que a Mesa registrou para este pedido. */
38export function jaDecidida(decisoes: readonly MesaDecisao[], p: Pick<MesaPergunta, 'thread' | 'pedidoId'>): boolean {
39  const marca = marcaDoPedido(p.pedidoId)
40  return decisoes.some(d => d.thread === p.thread && d.quemDecidiu.startsWith('mod ork-mesa') && d.decidido.includes(marca))
41}
42
43export type ArgsDaDecisaoObvia = {
44  threadId: string
45  decidido: string
46  porque: string
47  comoMudar: string
48  custoAgora: string
49  custoDepois: string
50  criterio: { tipo: 'medicao'; referencia: string }
51  quemDecidiu: string
52  evidencia: string
53}
54
55/**
56 * Os argumentos do `ork_decision_record` para uma decisao obvia: o que, o porque, como mudar, o custo de reverter
57 * antes e depois do prazo, o criterio (a medicao que mostra o pedido declarando `seguir-recomendada`) e a evidencia
58 * (a leitura do `ork_hitl_pending`). Cada campo cabe nos tetos do nucleo, numa linha.
59 */
60export function argsDaDecisaoObvia(p: MesaPergunta, recomendada: { chave: string; texto: string }, referencia: string,
61  lidoEmIso: string): ArgsDaDecisaoObvia {
62  const pedido = p.codigo ?? p.pedidoId.slice(0, 8)
63  const prazo = p.prazoLocal ?? p.prazo ?? 'no prazo do pedido'
64  const reserva = p.reserva ? ` ou ${p.reserva} pelo Telegram` : ''
65  return {
66    threadId: p.thread,
67    decidido: linhaNoTeto(`Sem te chamar: o pedido ${pedido} ${marcaDoPedido(p.pedidoId)} da ${p.thread} segue a recomendada ` +
68      `${recomendada.chave} (${recomendada.texto}) no prazo ${prazo}`, TETO_DO_CAMPO),
69    porque: linhaNoTeto('O pedido declara que segue a recomendada no prazo, é reversível antes da entrega e passou nas quatro ' +
70      'condições da decisão óbvia (M4)', TETO_DO_CAMPO),
71    comoMudar: linhaNoTeto(`Na Mesa, Decidir no cartão do pedido ${pedido} antes do prazo${reserva}`, TETO_DO_CAMPO),
72    custoAgora: linhaNoTeto('Nenhum: o pedido segue aberto até o prazo e o dono escolhe outra alternativa no diálogo', TETO_DO_CUSTO),
73    custoDepois: linhaNoTeto('Depois do prazo a recomendada já seguiu; mudar pede nova decisão na thread', TETO_DO_CUSTO),
74    criterio: { tipo: 'medicao', referencia: linhaNoTeto(referencia, TETO_DA_REFERENCIA) },
75    quemDecidiu: QUEM_DECIDIU,
76    evidencia: linhaNoTeto(`ork_hitl_pending ${p.thread} lido em ${lidoEmIso}: pedido ${p.pedidoId}, estado ${p.estado}, ` +
77      `acaoPadraoAoExpirar ${p.acaoAoExpirar ?? '-'}, irreversivel ${String(p.irreversivel)}, fase ${p.fase ?? '-'}`, 4096),
78  }
79}
80
81/**
82 * Depois de uma recarga do modulo nenhum Decidir esta em curso: o que ficou `abrindo` no estado vira `sem-resposta`,
83 * e o cartao volta a ter o botao.
84 */
85export function soltarAbrindo(decidindo: Readonly<Record<string, MesaDecidindo>>): Record<string, MesaDecidindo> {
86  return Object.fromEntries(Object.entries(decidindo).map(([id, v]) => [id, v.estado === 'abrindo'
87    ? { estado: 'sem-resposta' as const, texto: 'o diálogo ficou sem resposta (a Mesa recarregou); o pedido segue aberto', em: v.em } : v]))
88}
89
90/** Por que a notificacao do sistema nao saiu, em pt-BR (o motor manda o codigo em ingles). */
91export function motivoDaNotificacao(reason: unknown): string {
92  const motivos: Record<string, string> = {
93    'no-channel': 'nenhum canal de notificação configurado',
94    'no-surface': 'nenhuma superfície para mostrar',
95    refused: 'o terminal recusou a notificação',
96    disabled: 'as notificações estão desligadas',
97  }
98  return `a notificação do sistema não saiu (${motivos[String(reason)] ?? String(reason)})`
99}
100
101/** O que o cartao diz do Decidir, a partir do que o nucleo devolveu (`{ ok, pedidoId, estado, repetida }`). */
102export function resultadoDoDecidir(resposta: unknown, agora: number): MesaDecidindo {
103  const r = (resposta && typeof resposta === 'object' ? resposta : {}) as { ok?: unknown; estado?: unknown; repetida?: unknown }
104  const estado = typeof r.estado === 'string' ? r.estado : ''
105  if (r.ok === true) {
106    if (r.repetida === true) return { estado: 'respondido', texto: `o pedido já estava respondido no núcleo: ${estado || 'registrado'}`, em: agora }
107    return { estado: 'respondido', texto: `respondido pelo diálogo: ${estado || 'registrado'}`, em: agora }
108  }
109  return { estado: 'sem-resposta', texto: 'o diálogo fechou sem resposta; o pedido segue aberto', em: agora }
110}
111
hooks/fila.ts 254 lines
1// A fila da Mesa do Dono (RM-059 F5), em funcoes puras: o que o pulse, o retrato e o `ork_hitl_pending` do nucleo
2// trazem vira pergunta, parada ou decisao, e a faixa sai daqui. So importa tipos: o register.tsx le o nucleo pela
3// ponte e entrega os objetos a estas funcoes. Nada aqui responde pedido nem escolhe opcao pelo dono.
4import type { MesaAlternativa, MesaCanal, MesaDecisao, MesaFila, MesaObvia, MesaOutro, MesaPergunta } from '../types'
5
6/** Perguntas por pagina: o lote de ate cinco da RM-041 e da RM-057. */
7export const TAMANHO_DO_LOTE = 5
8/** Decisoes informadas que o painel mostra por inteiro; o resto vira uma linha com a contagem. */
9export const MAXIMO_DE_DECISOES = 5
10/** Paradas e impedimentos que o painel mostra por inteiro. */
11export const MAXIMO_DE_OUTROS = 5
12/** So a decisao das ultimas 24 h que a Mesa ainda nao mostrou conta como nova. */
13export const JANELA_DE_NOVAS_MS = 24 * 60 * 60 * 1000
14export const TETO_DO_TEXTO = 200
15export const TETO_DA_FAIXA = 200
16const ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/
17
18export const FILA_VAZIA: MesaFila = { perguntas: [], outros: [], decisoes: [], totalDeDecisoes: 0, lidoEm: null, erro: null, avisos: [] }
19
20type Bruto = Record<string, unknown>
21const objeto = (v: unknown): v is Bruto => !!v && typeof v === 'object' && !Array.isArray(v)
22const texto = (v: unknown): string => (typeof v === 'string' ? v : '')
23const textoOuNulo = (v: unknown): string | null => (typeof v === 'string' && v.trim() !== '' ? v : null)
24
25/** Uma linha so, sem quebra e sem espaco repetido, cortada no teto. */
26export function umaLinha(v: unknown, teto = TETO_DO_TEXTO): string {
27  const t = texto(v).replace(/\s+/g, ' ').trim()
28  return t.length <= teto ? t : `${t.slice(0, teto - 1).trimEnd()}…`
29}
30
31/** Quanto tempo, como o dono le: segundos, minutos, horas e dias. */
32export function duracao(ms: number): string {
33  const s = Math.max(0, Math.floor(ms / 1000))
34  if (s < 60) return `${s} s`
35  const min = Math.floor(s / 60)
36  if (min < 60) return `${min} min`
37  const h = Math.floor(min / 60)
38  if (h < 48) return `${h} h`
39  return `${Math.floor(h / 24)} dias`
40}
41
42/** O texto que um JSON traz, ou `undefined` quando nao e JSON. */
43function lerJson(t: string): unknown {
44  try {
45    return JSON.parse(t)
46  } catch {
47    return undefined
48  }
49}
50
51/** O JSON que uma ferramenta MCP do nucleo devolve no primeiro bloco de texto; erro vira excecao com o motivo. */
52export function jsonDoMcp(r: unknown): unknown {
53  const bruto = (objeto(r) ? r : {}) as { content?: unknown; isError?: unknown }
54  const blocos = Array.isArray(bruto.content) ? bruto.content.filter(objeto) : []
55  const corpo = blocos.find(c => c.type === 'text' && typeof c.text === 'string')?.text as string | undefined
56  if (bruto.isError === true) {
57    const j = corpo === undefined ? undefined : lerJson(corpo)
58    throw new Error(umaLinha(objeto(j) && typeof j.erro === 'string' ? j.erro : corpo ?? 'o nucleo respondeu com erro'))
59  }
60  if (corpo === undefined) throw new Error('o nucleo nao devolveu texto')
61  const j = lerJson(corpo)
62  if (j === undefined) throw new Error('o nucleo devolveu texto que nao e JSON')
63  return j
64}
65
66/** As alternativas de um pedido: letras no `ork.hitl/v2`, numeros no `ork.hitl/v1`, como o nucleo as declara. */
67export function alternativasDoPedido(p: Bruto): MesaAlternativa[] {
68  if (p.contrato === 'ork.hitl/v2' && Array.isArray(p.alternativas)) {
69    return p.alternativas.filter(objeto).map(a => ({
70      chave: texto(a.letra), texto: umaLinha(a.texto), acao: texto(a.acao), consequencia: textoOuNulo(a.consequencia),
71      recomendada: a.recomendada === true, porque: textoOuNulo(a.porque),
72    }))
73  }
74  if (Array.isArray(p.opcoes)) {
75    return p.opcoes.filter(objeto).map(o => ({
76      chave: typeof o.numero === 'number' ? String(o.numero) : texto(o.numero), texto: umaLinha(o.texto), acao: texto(o.acao),
77      consequencia: null, recomendada: false, porque: null,
78    }))
79  }
80  return []
81}
82
83/**
84 * A pergunta de um pedido aberto (`ork.hitl/v1`, ou `ork.hitl/v2` de classe `pergunta`); `null` para o que nao e
85 * pergunta (a decisao informada nao pede nada) ou nao tem identificacao valida.
86 */
87export function perguntaDoPedido(pedido: unknown, extra: { desdeEm?: string | null; reserva?: string | null } = {}): MesaPergunta | null {
88  if (!objeto(pedido)) return null
89  const v2 = pedido.contrato === 'ork.hitl/v2'
90  if (!v2 && pedido.contrato !== 'ork.hitl/v1') return null
91  if (v2 && pedido.classe !== 'pergunta') return null
92  const alvo = objeto(pedido.alvo) ? pedido.alvo.tipo : undefined
93  if (alvo !== 'gate' && alvo !== 'session') return null
94  const thread = texto(pedido.thread)
95  const id = texto(pedido.id)
96  if (!ID.test(thread) || !ID.test(id)) return null
97  const alternativas = alternativasDoPedido(pedido)
98  const recomendada = alternativas.find(a => a.recomendada)
99  const codigo = v2 ? textoOuNulo(pedido.codigo) : null
100  return {
101    thread, fase: textoOuNulo(pedido.fase), modo: textoOuNulo(pedido.modo), pedidoId: id, codigo,
102    contrato: v2 ? 'ork.hitl/v2' : 'ork.hitl/v1', alvo, motivo: texto(pedido.motivo), pergunta: umaLinha(pedido.pergunta),
103    recomendacao: v2 ? (recomendada ? `${recomendada.chave}) ${recomendada.texto}` : '') : umaLinha(pedido.recomendacao),
104    alternativas, irreversivel: pedido.irreversivel === true, ato: textoOuNulo(pedido.ato),
105    tipoDeResposta: v2 ? textoOuNulo(pedido.tipoDeResposta) : null,
106    respostaAceita: objeto(pedido.respostaAceita) ? textoOuNulo(pedido.respostaAceita.tipo) : null,
107    dependenciaTecnica: pedido.dependenciaTecnica !== undefined, acaoAoExpirar: textoOuNulo(pedido.acaoPadraoAoExpirar),
108    prazo: textoOuNulo(pedido.prazo), prazoLocal: null, desdeEm: extra.desdeEm ?? textoOuNulo(pedido.criadoEm),
109    estado: 'desconhecido', conferida: false, canalClaudeCode: 'desconhecido',
110    reserva: extra.reserva ?? (codigo && recomendada ? `${codigo} ${recomendada.chave}` : null),
111  }
112}
113
114/** As chaves que a Mesa ja registrou na ponte por um canal (`decisao:` no painel, `hitl:` na notificacao), sem o prefixo. */
115function chavesDaMesa(registro: unknown, prefixo: string, canal?: string): Set<string> {
116  const itens = objeto(registro) && Array.isArray(registro.itens) ? registro.itens.filter(objeto) : []
117  const chaves = new Set<string>()
118  for (const i of itens) {
119    const chave = texto(i.chave)
120    if (i.mod === 'ork-mesa' && chave.startsWith(prefixo) && (canal === undefined || i.canal === canal)) chaves.add(chave.slice(prefixo.length))
121  }
122  return chaves
123}
124
125/** Os ids das decisoes que a Mesa ja mostrou ao dono, pelo registro do mostrado da ponte. */
126export function decisoesVistas(registro: unknown): Set<string> {
127  return chavesDaMesa(registro, 'decisao:')
128}
129
130/** Os pedidos que a Mesa ja levou ao dono por notificacao, pelo registro da ponte: valem depois de uma recarga do modulo. */
131export function pedidosNotificados(registro: unknown): Set<string> {
132  return chavesDaMesa(registro, 'hitl:', 'notificacao')
133}
134
135/** A ordem das perguntas: o prazo mais perto primeiro, depois a mais antiga; sem prazo vai para o fim. */
136export function ordenarPerguntas(perguntas: readonly MesaPergunta[]): MesaPergunta[] {
137  const prazo = (p: MesaPergunta) => {
138    const t = Date.parse(p.prazo ?? '')
139    return Number.isFinite(t) ? t : Number.POSITIVE_INFINITY
140  }
141  const desde = (p: MesaPergunta) => {
142    const t = Date.parse(p.desdeEm ?? '')
143    return Number.isFinite(t) ? t : Number.POSITIVE_INFINITY
144  }
145  return [...perguntas].sort((a, b) => prazo(a) - prazo(b) || desde(a) - desde(b) || a.pedidoId.localeCompare(b.pedidoId))
146}
147
148/**
149 * A fila a partir do `ork pulse --json`: as perguntas com pedido, as paradas e os impedimentos de
150 * `precisaDeHumanoAgora`, e as decisoes que a fabrica tomou pelo dono na janela do pulse.
151 */
152export function filaDoPulse(pulse: unknown, vistas: ReadonlySet<string>, agora: number): Omit<MesaFila, 'lidoEm' | 'erro' | 'avisos'> {
153  const p = objeto(pulse) ? pulse : {}
154  const humanos = Array.isArray(p.precisaDeHumanoAgora) ? p.precisaDeHumanoAgora.filter(objeto) : []
155  const perguntas: MesaPergunta[] = []
156  const outros: MesaOutro[] = []
157  for (const item of humanos) {
158    const pergunta = item.pedido === undefined ? null
159      : perguntaDoPedido(item.pedido, { desdeEm: textoOuNulo(item.desdeEm), reserva: textoOuNulo(item.comandoResposta) })
160    if (pergunta) {
161      if (!perguntas.some(x => x.pedidoId === pergunta.pedidoId)) perguntas.push(pergunta)
162      continue
163    }
164    if (item.classe === 'score_pendente') continue
165    const impedimento = objeto(item.impedimento) ? item.impedimento : null
166    outros.push({
167      chave: umaLinha(item.id, 160), thread: textoOuNulo(item.thread), fase: textoOuNulo(item.fase), motivo: texto(item.motivo),
168      texto: umaLinha(item.pergunta), recomendacao: umaLinha(item.recomendacao),
169      comando: umaLinha(impedimento ? impedimento.comando : item.comandoResposta), desdeEm: textoOuNulo(item.desdeEm),
170      impedimento: impedimento !== null,
171    })
172  }
173  const brutas = Array.isArray(p.decisoes) ? p.decisoes.filter(objeto) : []
174  // A decisao que outra desfez continua na janela, marcada como revertida, e nunca conta como nova.
175  const revertidaPor = new Map<string, string>()
176  for (const d of brutas) if (typeof d.reverte === 'string' && ID.test(d.reverte)) revertidaPor.set(d.reverte, texto(d.id))
177  const decisoes: MesaDecisao[] = brutas.flatMap(d => {
178    const id = texto(d.id)
179    if (!ID.test(id)) return []
180    const custo = objeto(d.custoDeReverter) ? d.custoDeReverter : {}
181    const em = texto(d.em)
182    const recente = Number.isFinite(Date.parse(em)) && agora - Date.parse(em) <= JANELA_DE_NOVAS_MS
183    const desfeita = revertidaPor.get(id) ?? null
184    return [{
185      id, thread: texto(d.thread), fase: texto(d.fase), em, decidido: umaLinha(d.decidido), porque: umaLinha(d.porque),
186      comoMudar: umaLinha(d.comoMudar), custoAgora: umaLinha(custo.agora, 140), custoDepois: umaLinha(custo.depois, 140),
187      quemDecidiu: umaLinha(d.quemDecidiu, 120), reverte: typeof d.reverte === 'string' && ID.test(d.reverte) ? d.reverte : null,
188      revertidaPor: desfeita, nova: recente && desfeita === null && !vistas.has(id),
189    }]
190  })
191  decisoes.sort((a, b) => Number(b.nova) - Number(a.nova) || b.em.localeCompare(a.em))
192  return { perguntas: ordenarPerguntas(perguntas), outros, decisoes, totalDeDecisoes: decisoes.length }
193}
194
195/**
196 * A assinatura do retrato da secao `hitl`: cada pedido com o estado dele. Muda quando um pedido abre, e respondido,
197 * vence ou fica velho, e e o sinal barato para reler o pulse.
198 */
199export function assinaturaDoRetrato(retrato: unknown): string {
200  const secoes = objeto(retrato) && objeto(retrato.sections) ? retrato.sections : {}
201  const hitl = objeto(secoes.hitl) ? secoes.hitl : {}
202  const itens = Array.isArray(hitl.items) ? hitl.items.filter(objeto) : []
203  return itens.map(i => `${texto(i.id)}:${texto(i.status)}`).sort().join(',')
204}
205
206/**
207 * Confere a pergunta no `ork_hitl_pending` da thread: o pedido de agora, o estado, o prazo local e a oferta do canal
208 * `claude-code`. Pedido que o nucleo nao lista mais saiu da fila (respondido, substituido ou de outra fase): `null`.
209 */
210export function conferirNoNucleo(pergunta: MesaPergunta, resposta: unknown): MesaPergunta | null {
211  const pendencias = objeto(resposta) && Array.isArray(resposta.pendencias) ? resposta.pendencias.filter(objeto) : []
212  const achada = pendencias.find(x => objeto(x.pedido) && x.pedido.id === pergunta.pedidoId)
213  if (!achada) return null
214  const fresca = perguntaDoPedido(achada.pedido, { desdeEm: pergunta.desdeEm, reserva: pergunta.reserva })
215  if (!fresca) return null
216  const canais = Array.isArray(achada.canais) ? achada.canais.filter(objeto) : []
217  const claudeCode = canais.find(c => c.canal === 'claude-code')
218  const canal: MesaCanal = claudeCode?.estado === 'disponivel' ? 'disponivel' : 'indisponivel'
219  return { ...fresca, estado: textoOuNulo(achada.estado) ?? 'desconhecido', conferida: true, prazoLocal: textoOuNulo(achada.prazoLocal),
220    canalClaudeCode: canal }
221}
222
223/** As threads que tem pergunta na fila, sem repetir, na ordem da fila. */
224export function threadsComPergunta(perguntas: readonly MesaPergunta[]): string[] {
225  return [...new Set(perguntas.map(p => p.thread))]
226}
227
228/**
229 * O que espera o dono de fato: a pergunta que a Mesa decidiu pelo nucleo nao conta, nem a que ainda confere no nucleo
230 * se a decisao chegou (ela volta a esperar o dono se a leitura seguinte nao a mostrar).
231 */
232export function esperamODono(fila: MesaFila, obvias: Readonly<Record<string, MesaObvia>>): MesaPergunta[] {
233  return fila.perguntas.filter(p => {
234    const estado = obvias[p.pedidoId]?.estado
235    return estado !== 'registrada' && estado !== 'incerta'
236  })
237}
238
239/**
240 * A linha da Mesa na faixa: quantos pedidos esperam o dono (com a thread do primeiro), as paradas e as decisoes novas
241 * tomadas por ele. Sem idade no texto, para a linha so mudar quando a fila muda. Sem nada, a linha sai.
242 */
243export function textoDaFaixa(fila: MesaFila, obvias: Readonly<Record<string, MesaObvia>>): string | undefined {
244  if (fila.lidoEm === null) return undefined
245  const partes: string[] = []
246  const esperam = esperamODono(fila, obvias)
247  if (esperam.length) partes.push(`${esperam.length} ${esperam.length === 1 ? 'espera' : 'esperam'} você (${esperam[0]?.thread})`)
248  if (fila.outros.length) partes.push(`${fila.outros.length} ${fila.outros.length === 1 ? 'parada' : 'paradas'}`)
249  const novas = fila.decisoes.filter(d => d.nova).length
250  if (novas) partes.push(`${novas} ${novas === 1 ? 'decidida' : 'decididas'} sem te chamar`)
251  if (!partes.length) return undefined
252  return umaLinha(`mesa: ${partes.join(' · ')}`, TETO_DA_FAIXA)
253}
254
hooks/obvia.ts 99 lines
1// O teste das quatro condicoes da decisao obvia (M4 do dono, RM-059; D5 da thread ork-rm059mesa), em funcao pura.
2// E o mesmo teste do nucleo (`classificarDecisao` em core/src/hitl-contract.ts, I-41): as portas fechadas primeiro
3// (irreversivel, dinheiro, escopo ou produto), depois as quatro condicoes na ordem do GOAL. A Mesa so calcula os sinais
4// a partir do pedido que o nucleo serve; o teste do nucleo confere que a classe sai igual a do `classificarDecisao`.
5// Decidir, para a Mesa, e registrar a decisao autonoma do nucleo (`ork_decision_record`): nunca responder o pedido. Um
6// pedido aberto so se resolve pela resposta humana, pela substituicao, pelo prazo com `seguir-recomendada` ou pelo
7// fechamento da thread; por isso o criterio escrito que a Mesa le pelo nucleo e o proprio pedido declarar que segue a
8// recomendada no prazo. Falhou uma condicao, e pergunta. So importa tipos.
9import type { MesaAlternativa, MesaPergunta } from '../types'
10
11/** As portas fechadas e as quatro condicoes, na ordem do `classificarDecisao` do nucleo. */
12export type Condicao = 'irreversivel' | 'dinheiro' | 'escopo' | 'criterio' | 'alternativas' | 'reversivel' | 'barato'
13export const CONDICOES: readonly Condicao[] = ['irreversivel', 'dinheiro', 'escopo', 'criterio', 'alternativas', 'reversivel', 'barato']
14
15/** Como o cartao diz o que impediu a decisao obvia. */
16export const FALHA_DA_CONDICAO: Readonly<Record<Condicao, string>> = {
17  irreversivel: 'o ato é irreversível',
18  dinheiro: 'o ato gasta dinheiro',
19  escopo: 'é decisão de escopo ou produto (GOAL, PLAN ou política)',
20  criterio: 'não há critério escrito que a Mesa leia pelo núcleo',
21  alternativas: 'as alternativas não são estritamente piores pelo critério',
22  reversivel: 'não dá para desfazer antes da entrega',
23  barato: 'errar não sai barato, ou só o dono resolve',
24}
25
26/** As fases antes da entrega: depois do CHECK, o que se decide vai para a `main`. */
27export const FASES_ANTES_DA_ENTREGA: readonly string[] = ['GOAL', 'PLAN', 'GO', 'CHECK']
28/**
29 * GOAL e PLAN decidem objetivo, premissa e plano: o que se pergunta ali muda escopo ou produto, e e sempre do dono
30 * (M4). Por isso o nucleo abre esses pedidos em profundidade `profunda` (`profundidadeDoPedido`).
31 */
32export const FASES_DE_ESCOPO: readonly string[] = ['GOAL', 'PLAN']
33/** Credencial so o dono entrega; o erro de decidir por ele nao e barato. */
34export const MOTIVOS_SO_DO_DONO: readonly string[] = ['hitl.credencial']
35
36/** Os sinais do teste, na forma do `SinaisDaDecisao` do nucleo. */
37export type SinaisDaObvia = {
38  criterio?: { tipo: 'medicao'; referencia: string }
39  alternativasEstritamentePiores: boolean
40  reversivelAntesDaEntrega: boolean
41  erroBaratoEDetectavel: boolean
42  irreversivel: boolean
43  gastaDinheiro: boolean
44  mudaEscopoOuProduto: boolean
45}
46
47export type ResultadoDaObvia =
48  | { obvia: true; alternativa: MesaAlternativa; referencia: string }
49  | { obvia: false; falhou: Condicao[] }
50
51/**
52 * A referencia do criterio que a Mesa cita: a medicao como comando que o CHECK roda contra o ledger, onde o pedido fica
53 * escrito para sempre (sai 0 so se o pedido declara seguir a recomendada no prazo).
54 */
55export function referenciaDoCriterio(p: MesaPergunta): string {
56  return `grep -F '"id":"${p.pedidoId}"' .orkastery/threads/${p.thread}/ledger.jsonl | grep -qF '"acaoPadraoAoExpirar":"seguir-recomendada"'`
57}
58
59/** Os sinais a partir do pedido servido pelo nucleo e do instante da leitura, em ms. */
60export function sinaisDaPergunta(p: MesaPergunta, agora: number): SinaisDaObvia {
61  const recomendadas = p.alternativas.filter(a => a.recomendada).length
62  const prazo = Date.parse(p.prazo ?? '')
63  return {
64    // Criterio escrito: o pedido v2 declara, no ledger, que a fabrica segue a recomendada no prazo.
65    ...(p.contrato === 'ork.hitl/v2' && p.acaoAoExpirar === 'seguir-recomendada' ? { criterio: { tipo: 'medicao', referencia: referenciaDoCriterio(p) } } : {}),
66    // Estritamente piores pelo criterio: ha escolha, e o criterio aponta uma so.
67    alternativasEstritamentePiores: p.alternativas.length >= 2 && recomendadas === 1,
68    // Desfaz antes da entrega: a fase ainda nao entrega (o ato sem volta ja fecha a porta antes).
69    reversivelAntesDaEntrega: FASES_ANTES_DA_ENTREGA.includes(p.fase ?? ''),
70    // Barato e detectavel: o pedido conferido agora no nucleo (sem isso a evidencia seria inventada), gate (responder
71    // sessao e falar pelo dono), escolha objetiva, nada que so o dono resolve, o pedido ainda aberto e o prazo por vir,
72    // para o dono poder mudar antes.
73    erroBaratoEDetectavel: p.conferida && p.alvo === 'gate' && p.tipoDeResposta === 'objetiva' && p.respostaAceita === 'opcao' &&
74      !p.dependenciaTecnica && !MOTIVOS_SO_DO_DONO.includes(p.motivo) && p.estado === 'aberto' && Number.isFinite(prazo) && prazo > agora,
75    irreversivel: p.irreversivel || p.ato !== null,
76    gastaDinheiro: p.ato === 'dinheiro' || p.motivo === 'cost.violation',
77    mudaEscopoOuProduto: p.motivo === 'policy.violation' || FASES_DE_ESCOPO.includes(p.fase ?? ''),
78  }
79}
80
81/**
82 * O teste: as portas fechadas e as quatro condicoes, na ordem do nucleo. Devolve a alternativa recomendada e a
83 * referencia do criterio quando a decisao e obvia, ou tudo que falhou, na ordem.
84 */
85export function testeDaObvia(p: MesaPergunta, agora: number): ResultadoDaObvia {
86  const s = sinaisDaPergunta(p, agora)
87  const falhou: Condicao[] = []
88  if (s.irreversivel) falhou.push('irreversivel')
89  if (s.gastaDinheiro) falhou.push('dinheiro')
90  if (s.mudaEscopoOuProduto) falhou.push('escopo')
91  if (!s.criterio) falhou.push('criterio')
92  if (!s.alternativasEstritamentePiores) falhou.push('alternativas')
93  if (!s.reversivelAntesDaEntrega) falhou.push('reversivel')
94  if (!s.erroBaratoEDetectavel) falhou.push('barato')
95  const recomendada = p.alternativas.find(a => a.recomendada)
96  if (falhou.length || !s.criterio || !recomendada) return { obvia: false, falhou: falhou.length ? falhou : ['alternativas'] }
97  return { obvia: true, alternativa: recomendada, referencia: s.criterio.referencia }
98}
99
types/index.d.ts 132 lines
1// Contrato do estado da Mesa do Dono (`ork-mesa`, RM-059 fatia F5; ficha M4 do plano dos mods). Sem import nem
2// referencia, como o motor exige de um contrato: as chaves de `$.state` que a Mesa usa ficam declaradas aqui. A
3// Mesa le o nucleo pela ponte (`$.ork.retrato` e `$.ork.ler`) e por tres ferramentas MCP; nada aqui e resposta
4// do dono, e nenhum campo guarda a opcao que o dono escolheu.
5
6/** Uma alternativa de um pedido, como a Mesa a mostra: letra no `ork.hitl/v2`, numero no `ork.hitl/v1`. */
7export type MesaAlternativa = {
8  chave: string
9  texto: string
10  acao: string
11  /** O que acontece se escolher, em uma linha (so no v2). */
12  consequencia: string | null
13  recomendada: boolean
14  /** O porque da recomendada (so no v2). */
15  porque: string | null
16}
17
18/** A oferta do canal `claude-code` para o pedido, lida do `ork_hitl_pending`; sem leitura, desconhecida. */
19export type MesaCanal = 'disponivel' | 'indisponivel' | 'desconhecido'
20
21/** Um pedido aberto que espera o dono, montado do pulse e conferido no `ork_hitl_pending`. */
22export type MesaPergunta = {
23  thread: string
24  fase: string | null
25  modo: string | null
26  pedidoId: string
27  codigo: string | null
28  contrato: 'ork.hitl/v1' | 'ork.hitl/v2'
29  alvo: 'gate' | 'session'
30  motivo: string
31  pergunta: string
32  /** A recomendacao em texto, como o nucleo a escreve. */
33  recomendacao: string
34  alternativas: MesaAlternativa[]
35  irreversivel: boolean
36  ato: string | null
37  tipoDeResposta: string | null
38  respostaAceita: string | null
39  dependenciaTecnica: boolean
40  acaoAoExpirar: string | null
41  prazo: string | null
42  prazoLocal: string | null
43  desdeEm: string | null
44  /** O estado do pedido no nucleo (`aberto`, `esperar`, `escalar`, `seguir-recomendada`); `desconhecido` sem conferir. */
45  estado: string
46  /** O pedido foi conferido agora no `ork_hitl_pending`; sem isso a Mesa nao decide nem oferece o Decidir. */
47  conferida: boolean
48  canalClaudeCode: MesaCanal
49  /** O que o dono digita pelo Telegram ou no terminal, quando o dialogo nao serve. */
50  reserva: string | null
51}
52
53/** Uma parada sem pedido ou um impedimento que so o dono resolve: so leitura. */
54export type MesaOutro = {
55  chave: string
56  thread: string | null
57  fase: string | null
58  motivo: string
59  texto: string
60  recomendacao: string
61  comando: string
62  desdeEm: string | null
63  impedimento: boolean
64}
65
66/** Uma decisao que a fabrica (ou a Mesa) tomou pelo dono, como o pulse a traz. */
67export type MesaDecisao = {
68  id: string
69  thread: string
70  fase: string
71  em: string
72  decidido: string
73  porque: string
74  comoMudar: string
75  custoAgora: string
76  custoDepois: string
77  quemDecidiu: string
78  /** A decisao que esta desfaz, quando desfaz uma. */
79  reverte: string | null
80  /** A decisao posterior que desfez esta, quando ha. */
81  revertidaPor: string | null
82  /** Ainda nao mostrada ao dono pela Mesa (registro do mostrado da ponte), das ultimas 24 h e nao revertida. */
83  nova: boolean
84}
85
86export type MesaFila = {
87  perguntas: MesaPergunta[]
88  outros: MesaOutro[]
89  decisoes: MesaDecisao[]
90  /** Quantas decisoes a janela do pulse tinha; o painel mostra so as primeiras. */
91  totalDeDecisoes: number
92  lidoEm: number | null
93  erro: string | null
94  /** O que nao saiu inteiro na ultima leitura (uma conferencia, a faixa, o registro): o painel mostra cada um. */
95  avisos: string[]
96}
97
98/** O Decidir de um pedido: o dialogo pedido ao nucleo e o que ele devolveu. */
99export type MesaDecidindo = {
100  estado: 'abrindo' | 'respondido' | 'sem-resposta' | 'erro'
101  texto: string
102  em: number
103}
104
105/**
106 * A decisao obvia que a Mesa pediu ao nucleo: `registrada` quando ele aceitou ou o pulse a mostra; `incerta` quando a
107 * chamada falhou ou passou do prazo e a escrita pode ter chegado (a proxima leitura do pulse decide); `recusada` quando
108 * a leitura seguinte nao a mostra.
109 */
110export type MesaObvia = {
111  estado: 'registrada' | 'incerta' | 'recusada'
112  texto: string
113  em: number
114}
115
116declare module 'claude-code' {
117  interface PluginState {
118    'ork-mesa': {
119      /** A fila lida do nucleo. */
120      fila: MesaFila
121      /** A thread que o `/mesa <thread>` pediu; `null` mostra todas. */
122      foco: string | null
123      /** A pagina do lote de perguntas, de cinco em cinco. */
124      pagina: number
125      /** O Decidir de cada pedido, pelo id do pedido. */
126      decidindo: Record<string, MesaDecidindo>
127      /** As decisoes obvias desta sessao, pelo id do pedido. */
128      obvias: Record<string, MesaObvia>
129    }
130  }
131}
132