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…

English · Português

In one sentence:
orkconducts AI coding agents (Claude Code and Codex) through parallel six-phase threads, checks every claim against the repository before it counts, and only calls you when the decision is really yours.
@orkastery/cli, with changes per version · reviewed on 2026-09-27main at PR #74, run 37102089623, 2026-10-03)docs/produto/ and docs/roadmap/, checked against the code by ork docs verificardocs/ are in Brazilian Portuguese, and the CLI reference is also in English. The CLI prints Brazilian Portuguese too, except ork doctor, ork init, ork onboarding, ork experiencia show, ork thread (except thread new --from-finding), ork phase list, ork phase run, ork verify, ork ship, ork pulse, ork master, ork retry plan, ork retry run, ork roadmap (status and reservations), ork worktree, ork claims, ork decisao placar, ork accounts, ork network (except network roadmap), ork docs, ork adapter and the help, which print English when owner.language or your locale asks for en; so do the orkastery.yaml comments, the AGENTS.md and CLAUDE.md blocks, the ork docs init templates and the skills, checklists, commands and subagents that ork adapter install copies; in English, read the quickstart on orkastery.com and the glossary of what the CLI printsnpm install -g @orkastery/cli
ork demo # 30 seconds: a false claim rejected, the fixed one accepted; no account, no model
ork doctor # what holds on this machine right now; exits != 0 when blocked
ork init # writes the orkastery.yaml; run it at the root of a git repository with at least one commit
ork modos # the four conduction modes
ork docs init # product docs and roadmap in the standard, with lint
The smallest full cycle, in #Fast mode (a single GO phase), right after ork init. <thread> is the ID that ork thread new prints.
git add orkastery.yaml AGENTS.md && git commit -m "ork init"
ork thread new "add an exclamation mark to greet" --modo fast
ork phase run <thread> GO --prompt "<the request>" # the agent runtime works in the thread's worktree
ork verify <thread> # re-runs the claims and the manifest checks on the real HEAD
ork ship <thread> --para main --autorizar-push <you> # #Fast never pushes without your authorization
ork master <thread> --aceitar-omissao # closes the thread with the index derived from the ledger
phase run stops with runtime.workspace-untrusted, run claude once in the worktree, accept the trust prompt, then ork retry run <thread>. ork pulse shows the same instruction.ork claims add <thread> <file> --claim "<statement>" --verificar "<command>".ork takes that seat. It is a deterministic TypeScript CLI, with no server and no embedded LLM.ork brings you is short and organized, with alternatives and one recommendation, so you decide in seconds without rereading a wall of text. That lightness is a design goal, meant also for people with ADHD: it keeps you conducting instead of drowning.Without ork | With ork |
|---|---|
| The agent says the tests pass | ork verify re-runs the command on the real HEAD, and CI re-runs it again on a clean runner |
| You ask for status several times a day | An hourly summary says what is pending, urgent, blocking or critical |
| Every obvious decision becomes a question | The obvious one comes decided and reported; you change it if you want |
| The runtime account runs out and the roadmap stops | The same prompt continues on the next profile or on the fallback runtime |
| Two sessions step on the same file | One worktree per thread, and leases with a queue |
| The docs lie about the code | ork docs verificar fails the PR when a page drifts |
Three layers, and business rules live only in the middle one:
flowchart TB
subgraph L1["Layer 1 - where you conduct"]
A["Claude Code, Codex, Hermes, OpenClaw<br/>adapters with no business rules"]
end
subgraph L2["Layer 2 - the ork core"]
B["schedules, isolates, verifies, serializes, records<br/>deterministic TypeScript, no LLM"]
end
subgraph L3["Layer 3 - who writes the code"]
C["claude-bg and codex runtimes<br/>their own accounts, profile rotation"]
end
A -->|"thread new, phase run, gate, ship"| B
B -->|"assembled prompt, sha256 in the ledger"| C
C -.->|"commits, diffs, command output"| B
B -.->|"summary, question batch, delivery"| A
flowchart LR
G(["GOAL"]) --> P(["PLAN"]) --> GO(["GO"]) --> C(["CHECK"]) --> S(["SHIP"]) --> M(["MASTER"])
C -.->|"failed: GO-FIX with a typed spec"| GO
| Phase | What it delivers | What ork requires to pass |
|---|---|---|
| GOAL | Objective, done criteria, claims with a command | a claim without a command is refused (claims.unverifiable) |
| PLAN | Tasks, decisions, an executable verify per task | a plan without an executable verify does not become GO |
| GO | One atomic commit per task, in the thread's worktree | writing outside the worktree is blocked by a lease |
| CHECK | Verification against the baseline, plus independent CI | a failed claim becomes claims.failed or verify.regression |
| SHIP | Serialized merge with a proven push | only counts when git ls-remote matches the local SHA |
| MASTER | Typed postmortem and a conduction index | the index comes from the ledger; nobody types it |
You write the #TAG in the request itself. The mode changes where the cycle waits for you; verifying the delivery is the same in all four.
| #TAG | Pauses | Cycle | What it is for |
|---|---|---|---|
| #Classic | 3 | GOAL* / PLAN* / GO-CHECK* / SHIP-MASTER | The ork init default: delicate premises |
| #Maestro | 1 | GOAL-PLAN* / GO-CHECK-SHIP / MASTER | A clear, quick solution |
| #Auto | 0 | GOAL-PLAN-GO-CHECK-SHIP-MASTER | Docs, studies, configuration, audits |
| #Fast | 0 | GO | A small, clear request that takes minutes; the push asks for authorization |
* marks the block that pauses. Source of truth: ork modos.#Look and #Ork were retired on 2026-09-24 (RM-043); what was recorded with them stays readable forever.#Fast runs only GO, with a minimal proof: a focused test, a cheap claim or an absence declared in the ledger. It never authorizes a push on its own and never touches a public contract (RM-042).The mode loosens the PAUSE. The mode NEVER loosens the VERIFICATION.
claims.jsonl. There are even negative claims, checked by the command that would falsify them.ork verify --baseline records the world before GO.ork ci prepare exports the claims, and the GitHub ork-verify job re-runs them on the exact SHA.git ls-remote, never by the exit code of git push.ork retry policy. cost.violation never gets an automatic retry.ork decisao registrar keeps the why, how to change it and the cost of changing it now or later; you are informed.owner.timezone, stated once per message.claude-bg (default) and codex, resolved by name in core/src/runtimes.ts; ork setup picks runtime, model and effort per block of each mode.ork accounts add <id> --runtime R --dir D runs the CLI's own login; ork never reads, copies or migrates a credential.cost.violation. Details and responsibilities in SECURITY.md.memory: orkmind, memory goes to the tenant's database; without it, it degrades to files with a typed reason.ork docs verificar: fails a source, symbol, contract or command that no longer exists, a merge commit outside main, and incoherent status.ork docs sincronizar --escrever: writes only facts from the ledger and git (merge, phase) and regenerates tables and indexes.documentacao job runs markdownlint and ork docs verificar on every PR.ork adapter list # the hosts and where each one installs
ork adapter install claude-code --dry-run
| Host | What gets installed |
|---|---|
| claude-code | A plugin with the catalog skills, phase subagents, the /orkastery:ork entry point and hooks |
| codex | The $ork entry point and the conduction catalog as project-local skills |
| hermes | A routing skill, the HITL ingress plugin and thread-opening scripts |
| openclaw | An extension with the ork_* tools, each one a CLI call |
ork modos --do-pedido; the core validates it.| Capability | Status |
|---|---|
| Threads, six phases, four modes, ledger, worktrees, leases, board | works |
Claims, baseline, verify on the real HEAD, 22 typed reasons, GO-FIX | works |
| CI as an independent CHECK on the exact SHA | works; native protection of main depends on the GitHub plan (RM-012) |
ork ship with a proven push | works |
| Account rotation across profiles and runtimes | works; account state is still per project (RM-040) |
| Layered HITL, a–d batch, decisions made and reported | works since 2026-09-24 (RM-041); automatic hourly summary since 2026-09-25 (RM-045) |
| Documentation as code with parity in CI | works (RM-044) |
#Fast mode: one phase, minimal proof, no push on its own | works since 2026-09-27 (RM-042) |
| Roadmap item reservations across machines | works since 2026-09-27 (RM-047); thread state is still per machine |
| Stable local verify on a machine whose CPU the hypervisor steals | does not exist: on such a machine, the reliable proof is CI (RM-037) |
A runtime_reported measurement of the context window | does not exist: claude-bg does not expose context usage, and the gate says unavailable |
| Deterministic code graph | works in the CLI (ork grafo: index, incremental update, queries and thread context, with each edge's provenance), for npm installs since 0.5.3; the MCP tools and the hint in the phase request stay behind the grafo.mcp flag, off by default (RM-031) |
The full picture, item by item and with git evidence, is in the roadmap index.
| Measure | Value | How to check |
|---|---|---|
| Core tests | 2,733, zero failing (CI on 2026-10-03) | npm --prefix core run test:ci |
| Behavior canaries | 24, all green | ork eval --so-canarios |
| Skills corpus | 20 skills, 101 cases, 199 assertions | ork eval --so-skills |
| Product docs and roadmap | zero parity errors | ork docs verificar |
| Runtime dependencies | 9, pinned | core/package.json |
On a virtual machine whose CPU the hypervisor steals (the steal in sar), the local verify can overrun the tests' time windows. That is why the merge gate is the independent CI, and the fix is planned in RM-037.
The docs home is organized by what you want to do now. Every page under docs/ is in Brazilian Portuguese; the CLI reference and the orchestration experience guide also have English versions, and the English quickstart lives on orkastery.com.
| Section | Start with |
|---|---|
| Getting started | Quickstart in English, and the full quickstart in Portuguese: from zero to the first full cycle |
| Guides | Modes, verification, human attention, memory, audits |
| Reference | CLI (the source is ork --help) and contracts |
| Concepts | Overview and architecture |
| Product and roadmap | What exists and what comes next, checked against the code |
Many commands still print Brazilian Portuguese (ork doctor, ork init, ork onboarding, ork experiencia show, ork thread (except thread new --from-finding), ork phase list, ork phase run, ork verify, ork ship, ork pulse, ork master, ork retry plan, ork retry run, ork roadmap (status and reservations), ork worktree, ork claims, ork decisao placar, ork accounts, ork network (except network roadmap), ork docs, ork adapter and the help follow an English locale). These are the words a first run shows.
| The CLI prints | It means |
|---|---|
PRONTO / BLOQUEADO | ready / blocked: the ork doctor verdict |
correcao | the fix for the line above it |
Proximo passo | next step |
VERDADE SUSTENTADA / REPROVADO | the claims held / failed: the ork verify verdict |
AGUARDANDO AUTORIZACAO HUMANA | waiting for your authorization |
Precisa de humano agora | needs a human now: the first line of ork pulse |
Recomendação, opção | recommendation, option |
aceitas por omissao | accepted by default, because nobody scored them |
pendente, respondida | pending, answered |
Horários em <zone> | times are shown in that time zone |
03/10 06:30 is October 3. The commands that follow an English locale print 2026-10-03 06:30.modos (modes), --modo (mode; --mode also works), --para (target branch), --por (who records it), --autorizar-push (authorize the push), --conteudo (content), --verificar (the command that verifies), decisao registrar (record a decision), docs verificar (check the docs), experiencia show (conversation preferences).owner.language: en-US sets the language of the conversation in your host (Claude Code, Codex), of the CLI commands that follow the locale and of what ork init, ork docs init and ork adapter install write for people to read (the orkastery.yaml comments, the AGENTS.md and CLAUDE.md blocks, the docs templates and the skills, checklists, commands and subagents the adapter installs); set it when machines with different locales conduct the same project, or those committed blocks switch language on each run; owner.language: pt-BR keeps them in Portuguese even with LANG=en_US.| Term | Meaning |
|---|---|
| HITL | human in the loop: a question only you can answer, with alternatives and one recommendation |
| ledger | the append-only event log of a thread, in .orkastery/threads/<thread>/ledger.jsonl; ork phase list <thread> prints it |
| claim | a statement, the file it is about and the command that judges it |
| worktree | the separate git checkout of each thread, under .claude/worktrees/ |
| lease | a lock on a shared resource, such as the main tree during a merge, with a queue |
| pulse | the hourly summary of what waits for you: ork pulse |
| maestro | the conversational overview in a host (say orkastery maestro), and the onboarding stage about you |
| GOAL … MASTER | the six phases, in the thread table |
| #Classic, #Maestro, #Auto, #Fast | the conduction modes, in the modes table |
ork docs sincronizar.ork roadmap pegar), and two never take the same one.| Project | What it is |
|---|---|
| orkastery | This repository: the ork core, the adapters, the skills and the auditors |
| OrkMind | The memory layer: Postgres with pgvector, an ontology and tag search; optional |
| orkastery.com | The website and user docs |
| orkmind.com | The OrkMind site |
orkastery maestro in a host with the adapter installed: you get an overview with sources, gaps, threads, sessions, HITL and next actions. In the terminal: ork maestro --json.No metronome ever turned anyone into Bernstein. Mastery is not tempo: it is knowing, after each performance, what you did, what it cost and how well it went, and being better at the next one. That is the phase we call MASTER, and it is why the project is called Orkastery.
hooks/register.tsx 494 lines1// 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}
494hooks/cartoes.tsx 247 lines1// 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}
247hooks/decidir.ts 111 lines1// 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}
111hooks/fila.ts 254 lines1// 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}
254hooks/obvia.ts 99 lines1// 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}
99types/index.d.ts 132 lines1// 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