SLOPSHOPPER

ork-ritual

Ritual do MASTER do Orkastery no Claude Code: as entregas que esperam a sua nota, a prova de cada uma (o que foi entregue, o índice derivado do ledger e as…

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

Draws

Pane · ritual
Ritual do MASTER · 0 entregas esperam a sua nota · ainda nã… lendo as entregas no núcleo...
README

Orkastery

English · Português

npm MIT license

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

In one sentence: ork conducts AI coding agents (Claude Code and Codex) through parallel six-phase threads, checks every claim against the repository before it counts, and only calls you when the decision is really yours.

  • Status: in daily use · on npm as @orkastery/cli, with changes per version · reviewed on 2026-09-27
  • Proof: GitHub CI on every PR: 2,733 core tests, 24 canaries and 20 skills with 199 assertions, zero failures (CI of the main at PR #74, run 37102089623, 2026-10-03)
  • Product and roadmap: docs/produto/ and docs/roadmap/, checked against the code by ork docs verificar
  • Language: this page is the canonical entry point, mirrored in Portuguese. The pages under docs/ are in Brazilian Portuguese, and the CLI reference is also in English. The CLI prints Brazilian Portuguese too, except ork doctor, ork init, ork onboarding, ork experiencia show, ork thread (except thread new --from-finding), ork phase list, ork phase run, ork verify, ork ship, ork pulse, ork master, ork retry plan, ork retry run, ork roadmap (status and reservations), ork worktree, ork claims, ork decisao placar, ork accounts, ork network (except network roadmap), ork docs, ork adapter and the help, which print English when owner.language or your locale asks for en; so do the orkastery.yaml comments, the AGENTS.md and CLAUDE.md blocks, the ork docs init templates and the skills, checklists, commands and subagents that ork adapter install copies; in English, read the quickstart on orkastery.com and the glossary of what the CLI prints
npm install -g @orkastery/cli
ork demo          # 30 seconds: a false claim rejected, the fixed one accepted; no account, no model
ork doctor        # what holds on this machine right now; exits != 0 when blocked
ork init          # writes the orkastery.yaml; run it at the root of a git repository with at least one commit
ork modos         # the four conduction modes
ork docs init     # product docs and roadmap in the standard, with lint

Your first thread

The smallest full cycle, in #Fast mode (a single GO phase), right after ork init. <thread> is the ID that ork thread new prints.

git add orkastery.yaml AGENTS.md && git commit -m "ork init"
ork thread new "add an exclamation mark to greet" --modo fast
ork phase run <thread> GO --prompt "<the request>"   # the agent runtime works in the thread's worktree
ork verify <thread>                                  # re-runs the claims and the manifest checks on the real HEAD
ork ship <thread> --para main --autorizar-push <you> # #Fast never pushes without your authorization
ork master <thread> --aceitar-omissao                # closes the thread with the index derived from the ledger
  • If phase run stops with runtime.workspace-untrusted, run claude once in the worktree, accept the trust prompt, then ork retry run <thread>. ork pulse shows the same instruction.
  • A claim is what the agent states plus the command that judges it: ork claims add <thread> <file> --claim "<statement>" --verificar "<command>".

Why it exists

  • Coding agents got good. Conducting agents did not.
  • Three agents in the same repository edit the same files, skip the same steps and hand over a report saying it worked. The report is the part that is worth nothing.
  • Someone is missing between you and them: someone who holds the roadmap, isolates the loops, serializes the merge, re-runs the claim and only interrupts you when it matters.
  • ork takes that seat. It is a deterministic TypeScript CLI, with no server and no embedded LLM.
  • Decisions sized for a full day. Whoever conducts agents is also running dozens of other things. Every question ork brings you is short and organized, with alternatives and one recommendation, so you decide in seconds without rereading a wall of text. That lightness is a design goal, meant also for people with ADHD: it keeps you conducting instead of drowning.

What changes for builders

Without orkWith ork
The agent says the tests passork verify re-runs the command on the real HEAD, and CI re-runs it again on a clean runner
You ask for status several times a dayAn hourly summary says what is pending, urgent, blocking or critical
Every obvious decision becomes a questionThe obvious one comes decided and reported; you change it if you want
The runtime account runs out and the roadmap stopsThe same prompt continues on the next profile or on the fallback runtime
Two sessions step on the same fileOne worktree per thread, and leases with a queue
The docs lie about the codeork docs verificar fails the PR when a page drifts

How it works

Three layers, and business rules live only in the middle one:

flowchart TB
    subgraph L1["Layer 1 - where you conduct"]
        A["Claude Code, Codex, Hermes, OpenClaw<br/>adapters with no business rules"]
    end
    subgraph L2["Layer 2 - the ork core"]
        B["schedules, isolates, verifies, serializes, records<br/>deterministic TypeScript, no LLM"]
    end
    subgraph L3["Layer 3 - who writes the code"]
        C["claude-bg and codex runtimes<br/>their own accounts, profile rotation"]
    end
    A -->|"thread new, phase run, gate, ship"| B
    B -->|"assembled prompt, sha256 in the ledger"| C
    C -.->|"commits, diffs, command output"| B
    B -.->|"summary, question batch, delivery"| A

The thread: six phases no agent skips

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

Four modes, chosen by #TAG

You write the #TAG in the request itself. The mode changes where the cycle waits for you; verifying the delivery is the same in all four.

#TAGPausesCycleWhat it is for
#Classic3GOAL* / PLAN* / GO-CHECK* / SHIP-MASTERThe ork init default: delicate premises
#Maestro1GOAL-PLAN* / GO-CHECK-SHIP / MASTERA clear, quick solution
#Auto0GOAL-PLAN-GO-CHECK-SHIP-MASTERDocs, studies, configuration, audits
#Fast0GOA small, clear request that takes minutes; the push asks for authorization
  • * marks the block that pauses. Source of truth: ork modos.
  • #Look and #Ork were retired on 2026-09-24 (RM-043); what was recorded with them stays readable forever.
  • #Fast runs only GO, with a minimal proof: a focused test, a cheap claim or an absence declared in the ledger. It never authorizes a push on its own and never touches a public contract (RM-042).
The mode loosens the PAUSE. The mode NEVER loosens the VERIFICATION.

Truth, not reports

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

Layered human attention

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

Runtimes and accounts

  • Two runtimes: claude-bg (default) and codex, resolved by name in core/src/runtimes.ts; ork setup picks runtime, model and effort per block of each mode.
  • Accounts per profile: ork accounts add <id> --runtime R --dir D runs the CLI's own login; ork never reads, copies or migrates a credential.
  • Rotation: an exhausted quota or a lost login sends the same prompt to the next profile or to the block's fallback; a short rate limit waits in the queue.
  • Subscription only: a paid key in the factory's environment is a cost.violation. Details and responsibilities in SECURITY.md.

Context and memory

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

Documentation as code

  • Two versioned standards: product documentation and product roadmap, v1.1.
  • Three readers per page: a person with ADHD (answer first, one idea per line), the parity checker, and AI agents (YAML frontmatter with stable IDs).
  • ork docs verificar: fails a source, symbol, contract or command that no longer exists, a merge commit outside main, and incoherent status.
  • ork docs sincronizar --escrever: writes only facts from the ledger and git (merge, phase) and regenerates tables and indexes.
  • In CI: the documentacao job runs markdownlint and ork docs verificar on every PR.

Install on your host

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

What exists today, and what does not

CapabilityStatus
Threads, six phases, four modes, ledger, worktrees, leases, boardworks
Claims, baseline, verify on the real HEAD, 22 typed reasons, GO-FIXworks
CI as an independent CHECK on the exact SHAworks; native protection of main depends on the GitHub plan (RM-012)
ork ship with a proven pushworks
Account rotation across profiles and runtimesworks; account state is still per project (RM-040)
Layered HITL, a–d batch, decisions made and reportedworks since 2026-09-24 (RM-041); automatic hourly summary since 2026-09-25 (RM-045)
Documentation as code with parity in CIworks (RM-044)
#Fast mode: one phase, minimal proof, no push on its ownworks since 2026-09-27 (RM-042)
Roadmap item reservations across machinesworks since 2026-09-27 (RM-047); thread state is still per machine
Stable local verify on a machine whose CPU the hypervisor stealsdoes not exist: on such a machine, the reliable proof is CI (RM-037)
A runtime_reported measurement of the context windowdoes not exist: claude-bg does not expose context usage, and the gate says unavailable
Deterministic code graphworks in the CLI (ork grafo: index, incremental update, queries and thread context, with each edge's provenance), for npm installs since 0.5.3; the MCP tools and the hint in the phase request stay behind the grafo.mcp flag, off by default (RM-031)

The full picture, item by item and with git evidence, is in the roadmap index.

Verified in this tree

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

On a virtual machine whose CPU the hypervisor steals (the steal in sar), the local verify can overrun the tests' time windows. That is why the merge gate is the independent CI, and the fix is planned in RM-037.

Documentation

The docs home is organized by what you want to do now. Every page under docs/ is in Brazilian Portuguese; the CLI reference and the orchestration experience guide also have English versions, and the English quickstart lives on orkastery.com.

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

Reading the CLI in English

Many commands still print Brazilian Portuguese (ork doctor, ork init, ork onboarding, ork experiencia show, ork thread (except thread new --from-finding), ork phase list, ork phase run, ork verify, ork ship, ork pulse, ork master, ork retry plan, ork retry run, ork roadmap (status and reservations), ork worktree, ork claims, ork decisao placar, ork accounts, ork network (except network roadmap), ork docs, ork adapter and the help follow an English locale). These are the words a first run shows.

The CLI printsIt means
PRONTO / BLOQUEADOready / blocked: the ork doctor verdict
correcaothe fix for the line above it
Proximo passonext step
VERDADE SUSTENTADA / REPROVADOthe claims held / failed: the ork verify verdict
AGUARDANDO AUTORIZACAO HUMANAwaiting for your authorization
Precisa de humano agoraneeds a human now: the first line of ork pulse
Recomendação, opçãorecommendation, option
aceitas por omissaoaccepted by default, because nobody scored them
pendente, respondidapending, answered
Horários em <zone>times are shown in that time zone
  • Dates are day/month in Portuguese output: 03/10 06:30 is October 3. The commands that follow an English locale print 2026-10-03 06:30.
  • Commands and flags in Portuguese: modos (modes), --modo (mode; --mode also works), --para (target branch), --por (who records it), --autorizar-push (authorize the push), --conteudo (content), --verificar (the command that verifies), decisao registrar (record a decision), docs verificar (check the docs), experiencia show (conversation preferences).
  • owner.language: en-US sets the language of the conversation in your host (Claude Code, Codex), of the CLI commands that follow the locale and of what ork init, ork docs init and ork adapter install write for people to read (the orkastery.yaml comments, the AGENTS.md and CLAUDE.md blocks, the docs templates and the skills, checklists, commands and subagents the adapter installs); set it when machines with different locales conduct the same project, or those committed blocks switch language on each run; owner.language: pt-BR keeps them in Portuguese even with LANG=en_US.
TermMeaning
HITLhuman in the loop: a question only you can answer, with alternatives and one recommendation
ledgerthe append-only event log of a thread, in .orkastery/threads/<thread>/ledger.jsonl; ork phase list <thread> prints it
claima statement, the file it is about and the command that judges it
worktreethe separate git checkout of each thread, under .claude/worktrees/
leasea lock on a shared resource, such as the main tree during a merge, with a queue
pulsethe hourly summary of what waits for you: ork pulse
maestrothe conversational overview in a host (say orkastery maestro), and the onboarding stage about you
GOAL … MASTERthe six phases, in the thread table
#Classic, #Maestro, #Auto, #Fastthe conduction modes, in the modes table

Built with itself

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

Ecosystem

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

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

  • Say orkastery maestro in a host with the adapter installed: you get an overview with sources, gaps, threads, sessions, HITL and next actions. In the terminal: ork maestro --json.
  • The query does not open work and does not grant a gate.
  • Top priority: HITL usability. Questions in short bullets, with a recommendation and clear options; the UUID stays internal.
  • Telegram is optional. There is no web control panel: Orkastery conducts in the conversation, and OrkMind keeps the knowledge.
  • Status: code package delivered on 2026-09-19 (PR #13). Proof of activation in a new session per host is still pending (RM-032).

Credits and license

No metronome ever turned anyone into Bernstein. Mastery is not tempo: it is knowing, after each performance, what you did, what it cost and how well it went, and being better at the next one. That is the phase we call MASTER, and it is why the project is called Orkastery.

Source 5 files
hooks/register.tsx 495 lines
1// O Ritual do MASTER do Orkastery (RM-059, fatia F8; ficha M8 do plano dos mods). Na sessao do dono, um painel com as
2// entregas que esperam a nota do dono, lidas do nucleo pelo retrato da ponte (`$.ork.retrato`, secao `master`), e o
3// replay da prova de cada uma, com as claims pelo `ork_claims_list`. O botao "Dar a nota" so pede ao nucleo o dialogo
4// nativo (`ork_request_master_note`, com o `threadId` e nada mais), e a reserva pelo Telegram so pede o codigo
5// (`ork master pedir`), no clique do dono. Nunca le `.orkastery/` por conta propria e nunca da a nota pelo dono. A
6// logica fica nos modulos puros ao lado; aqui so os ganchos e as chamadas ao `$`, sempre escritas como
7// `x.noun.metodo(...)`, como o validador exige.
8import type { EngineInterface, Register } from 'claude-code'
9import type { RitualMostrado, RitualNota, RitualProva, RitualQuadro, RitualReserva } from '../types'
10import {
11  argsDaNota, argvDaReserva, lerReserva, notaDoErro, PRAZO_DA_NOTA_MS, PRAZO_DA_RESERVA_MS, resultadoDaNota, soltarAbrindo,
12} from './nota'
13import { desenharRitual, linhasDoPainel, mostradosVisiveis } from './replay'
14import {
15  argsDasClaims, avisosDasLacunas, claimsDaProva, entregasDaJanela, exigirThread, jsonDoMcp, paginaDoMaster, plural, QUADRO_VAZIO,
16  semNotaDada, SERVIDOR, TETO_DE_PAGINAS, textoDaFaixa, threadDoLedger, threadsSemLeitura, umaLinha,
17} from './ritual'
18
19const MOD = 'ork-ritual'
20const PAINEL = 'ritual'
21const TITULO = 'Ritual do MASTER'
22const QUADRO = { plugin: 'ork-ritual', key: 'quadro' } as const
23const FOCO = { plugin: 'ork-ritual', key: 'foco' } as const
24const PASSO = { plugin: 'ork-ritual', key: 'passo' } as const
25const PROVA = { plugin: 'ork-ritual', key: 'prova' } as const
26const NOTAS = { plugin: 'ork-ritual', key: 'notas' } as const
27const RESERVAS = { plugin: 'ork-ritual', key: 'reservas' } as const
28/** O tique: idade das entregas e registro do mostrado. */
29const PERIODO_DO_TIQUE_MS = 60000
30/** As entregas sao relidas por idade: a cada 5 min com o painel a vista, a cada 15 min escondido. */
31const IDADE_A_VISTA_MS = 5 * 60000
32const IDADE_ESCONDIDO_MS = 15 * 60000
33/**
34 * Entre duas leituras que o dono nao pediu (ledger que mudou, idade), no minimo isto com o painel a vista; escondido, a
35 * idade de 15 min. Cada leitura rele todas as paginas da secao `master` do retrato, e cada pagina le os ledgers de todas
36 * as threads: a faixa nao precisa de mais que isso, e a entrega anda em minutos, nao em segundos.
37 */
38const INTERVALO_A_VISTA_MS = 60000
39/** Os ledgers que mudam juntos entram numa leitura so. */
40const ESPERA_DO_LOTE_MS = 5000
41/** Teto de cada chamada ao nucleo (uma pagina do retrato ou as claims de uma thread). */
42const PRAZO_DA_LEITURA_MS = 20000
43/** O servidor do nucleo pode conectar depois do inicio da sessao: o papel e conferido de novo nestas esperas. */
44const ESPERAS_DO_PAPEL_MS = [3000, 10000, 30000]
45const ESPERA_LONGA_DO_PAPEL_MS = 300000
46
47// O que o modulo guarda entre ganchos. O estado em `$.state` (quadro, foco, passo e prova) e o que o desenho le; aqui
48// ficam a copia dele, sempre gravada inteira por `$.state.set` sem versao, uma gravacao por mudanca (licao da Sentinela
49// e da Pista), e o que nao se desenha. Uma recarga do modulo zera tudo, e o `session.start` dispara de novo nela.
50let ativa = false
51let desistiu = false
52let ciclo: { cancel: () => void } | undefined
53let lote: { cancel: () => void } | undefined
54let lendo: Promise<void> | undefined
55let pendente = false
56let tentadaEm = 0
57let quadro: RitualQuadro = QUADRO_VAZIO
58let foco: string | null = null
59let passo = 0
60let prova: RitualProva | null = null
61/** A leitura das claims em curso e de qual thread. */
62let lendoProva: Promise<void> | undefined
63let lendoProvaDe: string | null = null
64/** O "Dar a nota" e a reserva de cada entrega, e as threads com um dialogo ou um pedido rodando no nucleo agora. */
65let notas: Record<string, RitualNota> = {}
66let reservas: Record<string, RitualReserva> = {}
67const emDialogo = new Set<string>()
68const pedindo = new Set<string>()
69/** O que o desenho pos na janela de rolagem e ainda nao foi ao registro da ponte. */
70const vistosNoDesenho = new Map<string, RitualMostrado>()
71/** O que ja foi ao registro nesta sessao, por chave e texto. */
72const jaMostrado = new Set<string>()
73/** O ultimo texto publicado na faixa; null antes da primeira publicacao. */
74let ultimaFaixa: string | undefined | null = null
75/** A falha de um trabalho em segundo plano que nem o quadro conseguiu guardar. */
76let problemaSemAviso: string | null = null
77
78function mensagem(erro: unknown): string {
79  return umaLinha(erro, 200)
80}
81
82/** A leitura com teto proprio: o relogio da sessao recusa a espera que passar dele. */
83function comPrazo<T>($: EngineInterface, leitura: Promise<T>, ms = PRAZO_DA_LEITURA_MS): Promise<T> {
84  return new Promise<T>((resolver, recusar) => {
85    const prazo = $.clock.after(ms, () => recusar(new Error(`a leitura passou de ${Math.round(ms / 1000)} s`)))
86    leitura.then(v => { prazo.cancel(); resolver(v) }, e => { prazo.cancel(); recusar(e) })
87  })
88}
89
90/**
91 * Trabalho em segundo plano (o tique, o lote, os gestos do painel): a falha vai ao quadro; sem como gravar (a sessao
92 * acabou ou o estado recusou), ela fica guardada aqui e o proximo desenho a mostra. O `.catch` nao depende do `$`.
93 */
94function emSegundoPlano($: EngineInterface, trabalho: Promise<unknown>, oQue: string): void {
95  trabalho.catch(async erro => {
96    const texto = `${oQue} parou: ${mensagem(erro)}`
97    try {
98      await gravarQuadro($, { ...quadro, erro: texto })
99    } catch (erroDoAviso) {
100      problemaSemAviso = `${texto} (o aviso também falhou: ${mensagem(erroDoAviso)})`
101    }
102  })
103}
104
105async function gravarQuadro($: EngineInterface, novo: RitualQuadro): Promise<void> {
106  quadro = novo
107  await $.state.set(QUADRO, novo)
108}
109
110/**
111 * O papel pela ponte. `null` so quando o `$.ork` nao existe (a ponte nao carregou); uma falha da propria ponte conta
112 * como dormente e o papel e conferido de novo depois.
113 */
114async function papelDaSessao($: EngineInterface): Promise<string | null> {
115  try {
116    return String(await $.ork.papel())
117  } catch (erro) {
118    return erro instanceof TypeError ? null : 'dormente'
119  }
120}
121
122/** Liga o Ritual na sessao do dono: o `/nota`, o tique e a primeira leitura. */
123async function ativar($: EngineInterface): Promise<void> {
124  if (ativa) return
125  ativa = true
126  try {
127    await $.command.register({ name: 'nota', description: 'Abre o Ritual do MASTER: as entregas que esperam a sua nota, com a prova de cada uma', immediate: true })
128  } catch (erro) {
129    // Sem o /nota o painel nao abre: o Ritual volta a dormente e tenta de novo na espera longa.
130    ativa = false
131    $.clock.after(ESPERA_LONGA_DO_PAPEL_MS, () => { emSegundoPlano($, conferirPapel($, ESPERAS_DO_PAPEL_MS.length), 'a conferência do papel') })
132    throw erro
133  }
134  quadro = (await $.state.get(QUADRO)).value ?? QUADRO_VAZIO
135  foco = (await $.state.get(FOCO)).value ?? null
136  passo = (await $.state.get(PASSO)).value ?? 0
137  prova = (await $.state.get(PROVA)).value ?? null
138  // Depois de uma recarga do modulo, nenhum dialogo nem pedido esta rodando: o que ficou em curso volta a ter o botao.
139  notas = soltarAbrindo((await $.state.get(NOTAS)).value ?? {})
140  await $.state.set(NOTAS, notas)
141  const guardadas = (await $.state.get(RESERVAS)).value ?? {}
142  reservas = Object.fromEntries(Object.entries(guardadas).map(([t, r]) => [t, r.estado === 'pedindo'
143    ? { ...r, estado: 'erro' as const, texto: 'o pedido pelo Telegram foi interrompido por uma recarga; peça de novo' } : r]))
144  await $.state.set(RESERVAS, reservas)
145  ciclo?.cancel()
146  ciclo = $.clock.every(PERIODO_DO_TIQUE_MS, () => { emSegundoPlano($, tique($), 'o tique do Ritual') })
147  agendar($, 1)
148}
149
150/** Confere o papel: liga no dono, desiste na fase ou sem a ponte, e tenta de novo enquanto dormente. */
151async function conferirPapel($: EngineInterface, tentativa: number): Promise<void> {
152  if (ativa || desistiu) return
153  const papel = await papelDaSessao($)
154  if (papel === 'dono') return ativar($)
155  if (papel === 'fase' || papel === null) {
156    desistiu = true
157    if (papel === null) $.ui.toast('ritual: a ponte ork-ponte não carregou; o Ritual do MASTER fica desligado nesta sessão')
158    return
159  }
160  const espera = ESPERAS_DO_PAPEL_MS[tentativa] ?? ESPERA_LONGA_DO_PAPEL_MS
161  $.clock.after(espera, () => { emSegundoPlano($, conferirPapel($, tentativa + 1), 'a conferência do papel') })
162}
163
164/** Uma leitura daqui a pouco, uma por vez: os ledgers que mudam juntos entram nela. */
165function agendar($: EngineInterface, ms = ESPERA_DO_LOTE_MS): void {
166  if (lote) return
167  lote = $.clock.after(Math.max(1, ms), () => {
168    lote = undefined
169    emSegundoPlano($, atualizar($), 'a leitura das entregas')
170  })
171}
172
173/** O painel do Ritual esta posto e a vista do dono. */
174async function painelAVista($: EngineInterface): Promise<boolean> {
175  const paineis = await $.ui.panes().catch(() => [])
176  return paineis.some(p => p.id === PAINEL && p.isPlaced && p.isShown)
177}
178
179/**
180 * Uma leitura por vez; pedido que chega no meio volta a rodar logo depois. Fora do pedido do dono (`forcar`), a leitura
181 * respeita o intervalo: com o painel a vista, volta a ser agendada para quando ele vencer; escondido, espera o tique da
182 * idade.
183 */
184async function atualizar($: EngineInterface, forcar = false): Promise<void> {
185  if (!ativa) return
186  const agora = await $.clock.now()
187  if (!forcar && tentadaEm > 0) {
188    const aVista = await painelAVista($)
189    const espera = (aVista ? INTERVALO_A_VISTA_MS : IDADE_ESCONDIDO_MS) - (agora - tentadaEm)
190    if (espera > 0) {
191      if (aVista) agendar($, espera)
192      return
193    }
194  }
195  if (lendo) {
196    pendente = true
197    return lendo
198  }
199  lendo = (async () => {
200    try {
201      do {
202        pendente = false
203        await lerUmaVez($)
204      } while (pendente)
205    } finally {
206      lendo = undefined
207    }
208  })()
209  return lendo
210}
211
212/**
213 * As entregas pelo retrato da ponte, pagina a pagina da secao `master` (D2): a que espera a nota do dono, na janela de
214 * dias, sem a que teve a nota registrada nesta sessao (a pagina guardada pela ponte pode ainda lista-la). A falha fica
215 * no quadro, com a leitura anterior.
216 */
217async function lerUmaVez($: EngineInterface): Promise<void> {
218  tentadaEm = await $.clock.now()
219  const itens: unknown[] = []
220  const lacunas: string[] = []
221  const avisos: string[] = []
222  let offset = 0
223  try {
224    for (let pagina = 0; ; pagina++) {
225      if (pagina >= TETO_DE_PAGINAS) {
226        avisos.push(`a seção master passou de ${TETO_DE_PAGINAS} páginas; o resto ficou de fora`)
227        break
228      }
229      const p = paginaDoMaster(await comPrazo($, $.ork.retrato({ secao: 'master', offset })))
230      itens.push(...p.itens)
231      lacunas.push(...p.lacunas)
232      if (p.proxima === null || p.proxima <= offset) break
233      offset = p.proxima
234    }
235  } catch (erro) {
236    await gravarQuadro($, { ...quadro, erro: mensagem(erro) })
237    await publicarFaixa($)
238    return
239  }
240  const agora = await $.clock.now()
241  const { entregas, fora, alemDoTeto } = entregasDaJanela(itens, agora)
242  avisos.unshift(...avisosDasLacunas(lacunas))
243  if (alemDoTeto) avisos.push(`${plural(alemDoTeto, 'entrega além do teto do painel ficou', 'entregas além do teto do painel ficaram')} de fora (ork master)`)
244  await gravarQuadro($, { entregas: semNotaDada(entregas, notas), lidoEm: agora, erro: null, avisos, foraDaJanela: fora,
245    semLeitura: threadsSemLeitura(lacunas) })
246  await publicarFaixa($)
247  await registrarMostrados($)
248}
249
250/** As claims da thread em foco pelo `ork_claims_list`, uma leitura por vez; a da thread que saiu do foco e descartada. */
251async function lerProva($: EngineInterface, thread: string): Promise<void> {
252  if (lendoProva) {
253    if (lendoProvaDe === thread) return lendoProva
254    await lendoProva.catch(() => undefined)
255  }
256  lendoProvaDe = thread
257  lendoProva = (async () => {
258    let nova: RitualProva
259    try {
260      const { claims, retiradas } = claimsDaProva(jsonDoMcp(await comPrazo($, $.mcp.call(SERVIDOR, 'ork_claims_list', argsDasClaims(thread)))))
261      nova = { thread, claims, retiradas, lidoEm: await $.clock.now(), erro: null }
262    } catch (erro) {
263      const anterior = prova?.thread === thread ? prova : null
264      nova = { thread, claims: anterior?.claims ?? [], retiradas: anterior?.retiradas ?? 0, lidoEm: await $.clock.now(), erro: mensagem(erro) }
265    }
266    if (foco !== thread) return
267    prova = nova
268    passo = Math.min(passo, Math.max(0, nova.claims.length - 1))
269    await $.state.set(PROVA, prova)
270    await $.state.set(PASSO, passo)
271  })()
272  try {
273    await lendoProva
274  } finally {
275    lendoProva = undefined
276    lendoProvaDe = null
277  }
278}
279
280/** Abre o replay de uma thread: o foco e o passo agora; as claims, em segundo plano. */
281async function focar($: EngineInterface, thread: string): Promise<void> {
282  if (foco !== thread || prova?.thread !== thread) {
283    foco = thread
284    passo = 0
285    prova = null
286    await $.state.set(FOCO, foco)
287    await $.state.set(PASSO, passo)
288    await $.state.set(PROVA, prova)
289  }
290  emSegundoPlano($, lerProva($, thread), 'a leitura das claims')
291}
292
293/** Fecha o replay: volta a lista das entregas. */
294async function soltarFoco($: EngineInterface): Promise<void> {
295  if (foco === null) return
296  foco = null
297  passo = 0
298  prova = null
299  await $.state.set(FOCO, foco)
300  await $.state.set(PASSO, passo)
301  await $.state.set(PROVA, prova)
302}
303
304/** Anda uma claim para tras ou para frente no replay, dentro da lista lida. */
305async function andar($: EngineInterface, delta: number): Promise<void> {
306  if (!prova || prova.thread !== foco || !prova.claims.length) return
307  const novo = Math.min(Math.max(0, passo + delta), prova.claims.length - 1)
308  if (novo === passo) return
309  passo = novo
310  await $.state.set(PASSO, passo)
311}
312
313async function gravarNota($: EngineInterface, thread: string, n: RitualNota): Promise<void> {
314  notas = { ...notas, [thread]: n }
315  await $.state.set(NOTAS, notas)
316}
317
318async function gravarReserva($: EngineInterface, thread: string, r: RitualReserva): Promise<void> {
319  reservas = { ...reservas, [thread]: r }
320  await $.state.set(RESERVAS, reservas)
321}
322
323/**
324 * O "Dar a nota" (T8.3, DD2): um dialogo por thread, em segundo plano. A chamada leva exatamente o `threadId`; quem da a
325 * nota e o porque e o dono, no dialogo nativo do host, e quem grava e o nucleo. A entrega anotada sai da lista e da
326 * faixa assim que o nucleo responde, e a releitura logo depois confirma.
327 */
328async function darNota($: EngineInterface, thread: string): Promise<void> {
329  if (emDialogo.has(thread)) return
330  // Marcado antes de qualquer espera: dois toques ao mesmo tempo abrem um dialogo so.
331  emDialogo.add(thread)
332  try {
333    exigirThread(thread)
334    await gravarNota($, thread, { estado: 'abrindo', texto: '', em: await $.clock.now() })
335    let n: RitualNota
336    try {
337      n = resultadoDaNota(jsonDoMcp(await comPrazo($, $.mcp.call(SERVIDOR, 'ork_request_master_note', argsDaNota(thread)), PRAZO_DA_NOTA_MS)),
338        thread, await $.clock.now())
339    } catch (erro) {
340      n = notaDoErro(erro, await $.clock.now())
341    }
342    await gravarNota($, thread, n)
343    if (n.estado === 'registrada' || n.estado === 'repetida') {
344      await gravarQuadro($, { ...quadro, entregas: semNotaDada(quadro.entregas, notas) })
345      await publicarFaixa($)
346    }
347  } finally {
348    emDialogo.delete(thread)
349  }
350  await atualizar($, true)
351}
352
353/**
354 * A reserva pelo Telegram (T8.3, D7): so no clique do dono, o pedido de nota do nucleo (`ork master pedir`), que guarda
355 * o pedido e devolve o codigo. O mod nunca roda `ork master` com nota no argv.
356 */
357async function pedirTelegram($: EngineInterface, thread: string): Promise<void> {
358  if (pedindo.has(thread)) return
359  pedindo.add(thread)
360  try {
361    exigirThread(thread)
362    await gravarReserva($, thread, { estado: 'pedindo', texto: '', codigo: null, em: await $.clock.now() })
363    let r: RitualReserva
364    try {
365      r = lerReserva(await $.process.run(argvDaReserva(thread), { timeoutMs: PRAZO_DA_RESERVA_MS }), thread, await $.clock.now())
366    } catch (erro) {
367      r = { estado: 'erro', texto: `o pedido pelo Telegram falhou: ${mensagem(erro)}`, codigo: null, em: await $.clock.now() }
368    }
369    await gravarReserva($, thread, r)
370  } finally {
371    pedindo.delete(thread)
372  }
373  await registrarMostrados($)
374}
375
376/** O tique: a idade das entregas e o registro do que o painel mostrou. */
377async function tique($: EngineInterface): Promise<void> {
378  if (!ativa) return
379  const agora = await $.clock.now()
380  const aVista = await painelAVista($)
381  const idade = quadro.lidoEm === null ? Number.POSITIVE_INFINITY : agora - quadro.lidoEm
382  if (idade >= (aVista ? IDADE_A_VISTA_MS : IDADE_ESCONDIDO_MS)) await atualizar($)
383  await registrarMostrados($)
384}
385
386/** A linha da faixa pela ponte, so quando o texto muda; sem entrega que espere a nota, a linha sai. */
387async function publicarFaixa($: EngineInterface): Promise<void> {
388  const texto = textoDaFaixa(quadro)
389  if (texto === ultimaFaixa) return
390  try {
391    if (texto === undefined) await $.ork.faixa({ mod: MOD })
392    else await $.ork.faixa({ mod: MOD, texto })
393    ultimaFaixa = texto
394  } catch {
395    // A ponte recusou ou falhou: a proxima leitura tenta de novo.
396  }
397}
398
399/** O desenho anota o que pos na janela; quem registra e o tique ou a leitura, fora do desenho. */
400function anotarVistos(vistos: readonly RitualMostrado[]): void {
401  for (const m of vistos) vistosNoDesenho.set(`${m.chave}|${m.texto}`, m)
402}
403
404/**
405 * O que o desenho pos na janela de rolagem do painel posto e a vista vai ao registro da ponte (M5 do dono), uma vez por
406 * chave e texto enquanto o texto existir no painel: o Telegram nao repete o que o dono ja viu no terminal.
407 */
408async function registrarMostrados($: EngineInterface): Promise<void> {
409  const existentes = new Set(linhasDoPainel(quadro, { agora: 0, foco, prova, passo, notas, reservas })
410    .flatMap(l => (l.mostrado ? [`${l.mostrado.chave}|${l.mostrado.texto}`] : [])))
411  for (const vista of [...jaMostrado]) if (!existentes.has(vista)) jaMostrado.delete(vista)
412  for (const vista of [...vistosNoDesenho.keys()]) if (!existentes.has(vista)) vistosNoDesenho.delete(vista)
413  if (vistosNoDesenho.size === 0) return
414  // Com o painel escondido, o que foi anotado e descartado: o proximo desenho a vista anota de novo.
415  if (!await painelAVista($)) { vistosNoDesenho.clear(); return }
416  for (const [vista, m] of [...vistosNoDesenho]) {
417    vistosNoDesenho.delete(vista)
418    if (jaMostrado.has(vista)) continue
419    try {
420      await $.ork.mostrado({ mod: MOD, canal: 'painel', chave: m.chave, thread: m.thread, texto: m.texto })
421      jaMostrado.add(vista)
422    } catch {
423      // A ponte recusou ou falhou: volta para a proxima vez.
424      vistosNoDesenho.set(vista, m)
425    }
426  }
427}
428
429export const register: Register = on => {
430  on('session.start', async ($, e, next) => {
431    if (e.isInteractive) await conferirPapel($, 0)
432    return next(e)
433  })
434
435  // `/nota [thread]` abre o painel em qualquer largura e rele as entregas agora. Com uma thread (a Torre abre o Ritual
436  // assim), o painel abre no replay da prova dela. O comando so existe com o Ritual ligado; a conferencia do papel
437  // aqui so cobre o caso raro de o comando ter sobrado de uma carga anterior.
438  on('command.run', { command: 'nota' }, async ($, e) => {
439    if (!ativa && !desistiu) await conferirPapel($, ESPERAS_DO_PAPEL_MS.length)
440    if (!ativa) return { text: 'O Ritual do MASTER só abre na sessão do dono de um projeto do Orkastery.' }
441    const pedida = typeof e.args === 'string' ? e.args.trim() : ''
442    let thread: string | null = null
443    let aviso = ''
444    if (pedida) {
445      try {
446        thread = exigirThread(pedida)
447      } catch {
448        aviso = ` O id ${umaLinha(pedida, 80)} não é de thread; o painel abre na lista das entregas.`
449      }
450    }
451    if (thread) await focar($, thread)
452    else await soltarFoco($)
453    const aberto = await $.ui.open({ id: PAINEL, title: TITULO })
454    emSegundoPlano($, atualizar($, true), 'a leitura das entregas')
455    const onde = thread ? ` na prova da ${thread}` : ''
456    return { text: (aberto.isPlaced ? `Ritual do MASTER aberto${onde}.` : `Ritual do MASTER aberto${onde}, à espera de lugar: ${aberto.reason}`) + aviso }
457  })
458
459  // O ledger de uma thread mudou (entrega, MASTER, nota): a releitura entra no proximo lote. Os caminhos vigiados sao
460  // os que a ponte pede para o papel dono; o Ritual nao pede outros.
461  on('classic.FileChanged', async ($, e, next) => {
462    if (ativa && threadDoLedger(e.file_path) !== null) agendar($)
463    return next(e)
464  }).catch(($, e, next) => next(e))
465
466  // O painel: a lista das entregas ou o replay da prova. O desenho nunca escreve: so anota o que pos na janela de
467  // rolagem, para o registro do mostrado.
468  on('ui.render', { component: 'Pane', requestId: PAINEL }, async ($, e) => {
469    const { Box, Text, Button } = $.ui.resolve(e)
470    const q = (await $.state.get(QUADRO)).value ?? QUADRO_VAZIO
471    const emFoco = (await $.state.get(FOCO)).value ?? null
472    const p = (await $.state.get(PASSO)).value ?? 0
473    const pr = (await $.state.get(PROVA)).value ?? null
474    const ns = (await $.state.get(NOTAS)).value ?? {}
475    const rs = (await $.state.get(RESERVAS)).value ?? {}
476    const agora = await $.clock.now()
477    const comProblema: RitualQuadro = problemaSemAviso ? { ...q, avisos: [...q.avisos, problemaSemAviso] } : q
478    const linhas = linhasDoPainel(comProblema, { agora, foco: emFoco, prova: pr, passo: p, notas: ns, reservas: rs })
479    anotarVistos(mostradosVisiveis(linhas, e.props.scroll, e.props.bodyColumns))
480    return desenharRitual({ Box, Text, Button }, linhas, {
481      foco: emFoco,
482      atualizar: () => {
483        emSegundoPlano($, atualizar($, true), 'a releitura das entregas')
484        if (emFoco) emSegundoPlano($, lerProva($, emFoco), 'a releitura das claims')
485      },
486      verProva: thread => { emSegundoPlano($, focar($, thread), 'o Ver a prova') },
487      voltar: () => { emSegundoPlano($, soltarFoco($), 'o Voltar') },
488      anterior: () => { emSegundoPlano($, andar($, -1), 'o Anterior') },
489      proxima: () => { emSegundoPlano($, andar($, 1), 'o Próxima') },
490      darNota: thread => { emSegundoPlano($, darNota($, thread), 'o Dar a nota') },
491      telegram: thread => { emSegundoPlano($, pedirTelegram($, thread), 'o pedido pelo Telegram') },
492    })
493  })
494}
495
hooks/nota.ts 89 lines
1// O que o Ritual do MASTER pede ao nucleo para a nota (RM-059, fatia F8, T8.3), em funcoes puras. Sao dois caminhos, e
2// em nenhum o mod da a nota pelo dono:
3//  - "Dar a nota" chama `ork_request_master_note` com exatamente `{ threadId }`: o nucleo abre o dialogo nativo do host,
4//    o dono escolhe de 0 a 5 e escreve o porque, e o nucleo grava o MASTER com o recibo do ingresso local. Nenhum
5//    argumento leva nota, porque ou texto.
6//  - "Pelo Telegram" (a reserva, so no clique do dono, D7) roda `ork master pedir <thread> --formato json`: o nucleo guarda
7//    o pedido de nota e devolve o codigo que o dono responde pelo Telegram. O pedido nao responde nada.
8// So importa tipos.
9import type { RitualNota, RitualReserva } from '../types'
10import { exigirThread, umaLinha } from './ritual'
11
12export const FERRAMENTA_DA_NOTA = 'ork_request_master_note'
13/**
14 * O dialogo do nucleo fecha em ate 300 s, a gravacao espera a trava da thread por ate 60 s e a resposta sai depois da
15 * soltura ao fechar (o git das reservas e o runtime); o botao espera tudo isso, com folga.
16 */
17export const PRAZO_DA_NOTA_MS = 430000
18/** O pedido pelo Telegram so grava o pedido de nota e devolve o codigo. */
19export const PRAZO_DA_RESERVA_MS = 60000
20
21/** Os argumentos do "Dar a nota": so o id da thread, nada que seja nota. */
22export function argsDaNota(thread: string): { threadId: string } {
23  return { threadId: exigirThread(thread) }
24}
25
26/** O argv da reserva pelo Telegram, montado so aqui: o pedido de nota do nucleo, nunca a nota. */
27export function argvDaReserva(thread: string): string[] {
28  return ['ork', 'master', 'pedir', exigirThread(thread), '--formato', 'json']
29}
30
31/** O que o cartao diz do "Dar a nota", a partir do que o nucleo devolveu (`{ ok, estado, repetida, score }`). */
32export function resultadoDaNota(resposta: unknown, thread: string, agora: number): RitualNota {
33  const r = (resposta && typeof resposta === 'object' ? resposta : {}) as { ok?: unknown; estado?: unknown; repetida?: unknown; score?: unknown }
34  const nota = typeof r.score === 'number' && Number.isInteger(r.score) ? `${r.score}/5` : 'a nota'
35  if (r.ok === true && r.estado === 'registrada') {
36    return r.repetida === true
37      ? { estado: 'repetida', texto: `a ${thread} já tinha nota humana ${nota} no núcleo`, em: agora }
38      : { estado: 'registrada', texto: `nota ${nota} da ${thread} registrada pelo diálogo nativo`, em: agora }
39  }
40  return { estado: 'pendente', texto: 'o diálogo fechou sem nota; a entrega segue esperando', em: agora }
41}
42
43/**
44 * O que o cartao diz quando o "Dar a nota" falhou: sem dialogo nativo (ou sem a ferramenta) a reserva aparece; a thread
45 * que seguiu ocupada depois da resposta volta a ter o botao, sem reserva.
46 */
47export function notaDoErro(erro: unknown, agora: number): RitualNota {
48  const motivo = umaLinha(erro)
49  if (/master\.nota\.trava-ocupada/.test(motivo)) {
50    return { estado: 'pendente', texto: 'a thread seguiu ocupada e a nota não foi gravada; dê de novo', em: agora }
51  }
52  if (/master\.nota\.sem-dialogo/.test(motivo)) {
53    return { estado: 'sem-dialogo', texto: 'o diálogo nativo não abriu: este cliente MCP não abre formulário; peça a nota pelo Telegram', em: agora }
54  }
55  if (/mcp\.tool\.unknown/.test(motivo)) {
56    return { estado: 'sem-dialogo', texto: 'o núcleo desta sessão não tem a ferramenta da nota; atualize o Orkastery ou peça a nota pelo Telegram', em: agora }
57  }
58  return { estado: 'erro', texto: `o Dar a nota falhou: ${motivo}; peça a nota pelo Telegram`, em: agora }
59}
60
61/** A reserva aparece quando o dialogo nao serviu (sem dialogo nativo ou erro do nucleo). */
62export function pedeReserva(nota: RitualNota | undefined): boolean {
63  return nota?.estado === 'sem-dialogo' || nota?.estado === 'erro'
64}
65
66/** Depois de uma recarga do modulo nenhum dialogo esta em curso: o que ficou `abrindo` volta a ter o botao. */
67export function soltarAbrindo(notas: Readonly<Record<string, RitualNota>>): Record<string, RitualNota> {
68  return Object.fromEntries(Object.entries(notas).map(([t, n]) => [t, n.estado === 'abrindo'
69    ? { estado: 'pendente' as const, texto: 'o diálogo ficou sem resposta (o Ritual recarregou); a entrega segue esperando', em: n.em } : n]))
70}
71
72/** A saida do `ork master pedir --formato json`: o codigo que o dono responde pelo Telegram, ou o motivo da falha. */
73export function lerReserva(saida: { exitCode: number; stdout: string; stderr: string }, thread: string, agora: number): RitualReserva {
74  if (saida.exitCode !== 0) {
75    const fim = umaLinha(saida.stderr.trim().split('\n').slice(-2).join(' ') || `saiu ${saida.exitCode}`)
76    return { estado: 'erro', texto: `o pedido pelo Telegram falhou: ${fim}`, codigo: null, em: agora }
77  }
78  let p: { contrato?: unknown; thread?: unknown; codigo?: unknown }
79  try {
80    p = JSON.parse(saida.stdout) as typeof p
81  } catch {
82    return { estado: 'erro', texto: 'o pedido pelo Telegram não devolveu o contrato ork.master-nota/v1', codigo: null, em: agora }
83  }
84  if (!p || p.contrato !== 'ork.master-nota/v1' || p.thread !== thread || typeof p.codigo !== 'string' || !/^[0-9A-Za-z]{3,8}$/.test(p.codigo)) {
85    return { estado: 'erro', texto: 'o pedido pelo Telegram não devolveu o contrato ork.master-nota/v1', codigo: null, em: agora }
86  }
87  return { estado: 'pronta', texto: `pelo Telegram, responda: ${p.codigo} <0 a 5> <porquê>`, codigo: p.codigo, em: agora }
88}
89
hooks/replay.tsx 272 lines
1// O painel do Ritual do MASTER (RM-059, fatia F8, T8.2): a lista das entregas que esperam a nota do dono e o replay da
2// prova de uma delas, uma claim por vez, como o replay-theater percorre edicoes. Modulo puro: monta as linhas e a
3// arvore com os elementos que a superficie resolveu; o `$` fica no register.tsx. Estado sempre com rotulo e glifo,
4// nunca so cor; "espera sua nota" e o ponto de HITL do cartao; numero vem com o comando que o prova (Partitura).
5import type { RitualEntrega, RitualMostrado, RitualNota, RitualProva, RitualQuadro, RitualReserva } from '../types'
6import { pedeReserva } from './nota'
7import { JANELA_DIAS, numero, plural } from './ritual'
8
9/** Os botoes de uma entrega, na linha de acoes do cartao. */
10export type BotaoDoCartao = 'prova' | 'nota' | 'telegram'
11
12/** Uma linha do painel: o tipo decide o elemento; `mostrado` e o que o registro da ponte guarda (M5). */
13export type LinhaDoPainel = {
14  tipo: 'placar' | 'aviso' | 'feita' | 'vazio' | 'cabecalho' | 'situacao' | 'indice' | 'postmortem' | 'acoes' | 'nota' | 'reserva'
15    | 'prova-placar' | 'claim' | 'alegacao' | 'comando' | 'navegacao'
16  texto: string
17  thread?: string
18  /** So na linha de acoes: os botoes que ela tem, na ordem. */
19  botoes?: BotaoDoCartao[]
20  mostrado?: RitualMostrado
21}
22
23/** Os rotulos dos botoes do cartao. */
24export const ROTULO_DO_BOTAO: Readonly<Record<BotaoDoCartao, string>> = { prova: 'Ver a prova', nota: 'Dar a nota', telegram: 'Pelo Telegram' }
25
26/** O glifo e o rotulo de cada estado de claim que o nucleo registra. */
27export const ESTADO_DA_CLAIM: Readonly<Record<string, { glifo: string; rotulo: string }>> = {
28  verificado: { glifo: '✓', rotulo: 'verificada' },
29  reprovado: { glifo: '×', rotulo: 'reprovada' },
30  pendente: { glifo: '·', rotulo: 'pendente' },
31  'nao-verificavel': { glifo: '?', rotulo: 'não verificável' },
32}
33
34/** Quanto tempo faz a leitura, em palavras curtas. */
35export function haQuanto(desde: number | null, agora: number): string {
36  if (desde === null) return 'ainda não lido'
37  const s = Math.max(0, Math.round((agora - desde) / 1000))
38  if (s < 60) return `lido há ${s} s`
39  const min = Math.round(s / 60)
40  return min < 60 ? `lido há ${min} min` : `lido há ${Math.round(min / 60)} h`
41}
42
43/** A situacao da nota na entrega, em uma linha. */
44export function situacaoDaEntrega(e: RitualEntrega): string {
45  if (e.status === 'recorded') return `aceita por omissão${e.score === null ? '' : ` com ${e.score}/5`}; a sua nota sobrescreve`
46  if (e.status === 'ratificacao-pendente') return 'proposta da fábrica esperando a sua nota'
47  return 'sem nota: sem a sua, o MASTER fecha por omissão'
48}
49
50/** O cartao de uma entrega: cabecalho, situacao, indice e POSTMORTEM. */
51export function linhasDaEntrega(e: RitualEntrega): LinhaDoPainel[] {
52  const quando = e.entregueEmLocal ?? e.entregueEm
53  const merge = e.merge ? ` · merge ${e.merge.slice(0, 8)}` : e.artefato ? ` · artefato ${e.artefato}` : ''
54  const indice = e.indice === null ? 'índice derivado do ledger: lacuna (ork master)'
55    : `índice derivado do ledger: ${numero(e.indice)} de 5 (${e.parcelas || 'sem desconto'}) · ork master`
56  const reprovacoes = plural(e.reprovacoes, 'reprovação tipada', 'reprovações tipadas')
57  return [
58    { tipo: 'cabecalho', thread: e.thread, texto: `● espera sua nota · ${e.thread} · entregue ${quando}${merge}` },
59    { tipo: 'situacao', thread: e.thread, texto: `  ${situacaoDaEntrega(e)}` },
60    { tipo: 'indice', thread: e.thread, texto: `  ${indice}`,
61      mostrado: { chave: `nota:${e.thread}`, thread: e.thread,
62        texto: `${e.thread}: entregue, espera sua nota${e.indice === null ? '' : ` (índice ${numero(e.indice)} de 5)`}` } },
63    // Na proposta, as classes sao as que a fabrica propos; a nota do dono grava as que o nucleo infere dos gates, como
64    // no terminal e no Telegram (D14).
65    { tipo: 'postmortem', thread: e.thread, texto: e.classes
66      ? `  POSTMORTEM${e.status === 'ratificacao-pendente' ? ' da proposta' : ''}: classes ${e.classes} · ${reprovacoes}`
67      : `  POSTMORTEM: nasce no fechamento · ${reprovacoes}` },
68  ]
69}
70
71/**
72 * A linha de acoes e o resultado do "Dar a nota" e da reserva de uma entrega (T8.3). O "Dar a nota" some com o dialogo
73 * aberto e com a nota ja registrada; a reserva pelo Telegram so aparece quando o dialogo nao serviu (D7).
74 */
75export function linhasDaNota(thread: string, opcoes: { comProva: boolean; nota?: RitualNota; reserva?: RitualReserva }): LinhaDoPainel[] {
76  const { nota, reserva } = opcoes
77  const botoes: BotaoDoCartao[] = []
78  if (opcoes.comProva) botoes.push('prova')
79  if (!nota || !['abrindo', 'registrada', 'repetida'].includes(nota.estado)) botoes.push('nota')
80  if (pedeReserva(nota) && reserva?.estado !== 'pedindo') botoes.push('telegram')
81  const saida: LinhaDoPainel[] = []
82  if (botoes.length) saida.push({ tipo: 'acoes', thread, botoes, texto: botoes.map(b => ROTULO_DO_BOTAO[b]).join(' | ') })
83  if (nota) saida.push({ tipo: 'nota', thread, texto: `  ${nota.estado === 'abrindo' ? 'diálogo da nota aberto no Claude Code: responda lá' : nota.texto}` })
84  if (reserva) {
85    saida.push({ tipo: 'reserva', thread, texto: `  ${reserva.estado === 'pedindo' ? 'pedindo o código ao núcleo...' : reserva.texto}`,
86      ...(reserva.estado === 'pronta' && reserva.codigo
87        ? { mostrado: { chave: `nota:${thread}`, thread, texto: `${thread}: pelo Telegram, responda ${reserva.codigo} <0 a 5> <porquê>` } } : {}) })
88  }
89  return saida
90}
91
92/** As notas desta sessao que ja sairam da lista (registradas ou ja dadas), as mais novas primeiro, no maximo tres. */
93export function notasFeitas(q: RitualQuadro, notas: Readonly<Record<string, RitualNota>>): LinhaDoPainel[] {
94  const naLista = new Set(q.entregas.map(e => e.thread))
95  return Object.entries(notas).filter(([t, n]) => (n.estado === 'registrada' || n.estado === 'repetida') && !naLista.has(t))
96    .sort((a, b) => b[1].em - a[1].em).slice(0, 3)
97    .map(([thread, n]) => ({ tipo: 'feita' as const, thread, texto: `✓ ${n.texto}` }))
98}
99
100/** As linhas do replay: a claim do passo, com estado, arquivo, alegacao e comandos; nunca a saida do comando (D6). */
101export function linhasDaProva(foco: string, prova: RitualProva | null, passo: number): LinhaDoPainel[] {
102  if (!prova || prova.thread !== foco || (prova.lidoEm === null && !prova.erro)) return [{ tipo: 'vazio', thread: foco, texto: 'lendo as claims no núcleo...' }]
103  const saida: LinhaDoPainel[] = []
104  if (prova.erro) saida.push({ tipo: 'aviso', thread: foco, texto: `as claims não vieram: ${prova.erro}` })
105  if (!prova.claims.length) {
106    if (!prova.erro) saida.push({ tipo: 'vazio', thread: foco, texto: `Nenhuma claim ativa registrada na ${foco}.` })
107    return saida
108  }
109  const i = Math.min(Math.max(0, passo), prova.claims.length - 1)
110  const c = prova.claims[i]!
111  const estado = ESTADO_DA_CLAIM[c.estado] ?? { glifo: '?', rotulo: c.estado }
112  const retiradas = prova.retiradas ? ` · ${plural(prova.retiradas, 'retirada fora', 'retiradas fora')}` : ''
113  saida.push({ tipo: 'prova-placar', thread: foco, texto: `  claim ${i + 1} de ${prova.claims.length}${retiradas} · ork claims list ${foco}` })
114  saida.push({ tipo: 'claim', thread: foco, texto: `  ${estado.glifo} ${c.id} · ${estado.rotulo}${c.arquivo ? ` · ${c.arquivo}` : ''}` })
115  if (c.alegacao) saida.push({ tipo: 'alegacao', thread: foco, texto: `  ${c.alegacao}` })
116  for (const comando of c.comandos) saida.push({ tipo: 'comando', thread: foco, texto: `  $ ${comando}` })
117  if (!c.comandos.length) saida.push({ tipo: 'comando', thread: foco, texto: '  sem comando registrado' })
118  saida.push({ tipo: 'navegacao', thread: foco, texto: `${i > 0 ? 'anterior' : ''}|${i < prova.claims.length - 1 ? 'proxima' : ''}` })
119  return saida
120}
121
122/**
123 * As linhas do painel, na ordem do desenho. Sem foco: o placar, os avisos e um cartao por entrega com "Ver a prova".
124 * Com foco (`/nota <thread>` ou "Ver a prova"): o placar do replay, o cartao da thread e a claim do passo.
125 */
126export function linhasDoPainel(q: RitualQuadro, opcoes: { agora: number; foco?: string | null; prova?: RitualProva | null; passo?: number;
127  notas?: Readonly<Record<string, RitualNota>>; reservas?: Readonly<Record<string, RitualReserva>> }): LinhaDoPainel[] {
128  const saida: LinhaDoPainel[] = []
129  const foco = opcoes.foco ?? null
130  const notas = opcoes.notas ?? {}
131  const reservas = opcoes.reservas ?? {}
132  const quantas = q.semLeitura && !q.entregas.length ? `o retrato não leu ${plural(q.semLeitura, 'thread', 'threads')}`
133    : `${q.semLeitura ? 'pelo menos ' : ''}${plural(q.entregas.length, 'entrega espera', 'entregas esperam')} a sua nota`
134  saida.push({ tipo: 'placar', texto: foco ? `Ritual do MASTER · prova da ${foco}` : `Ritual do MASTER · ${quantas} · ${haQuanto(q.lidoEm, opcoes.agora)}` })
135  if (q.erro) saida.push({ tipo: 'aviso', texto: `a última leitura falhou: ${q.erro}` })
136  for (const aviso of q.avisos) saida.push({ tipo: 'aviso', texto: aviso })
137  saida.push(...notasFeitas(q, notas))
138  if (foco) {
139    const e = q.entregas.find(x => x.thread === foco)
140    if (e) saida.push(...linhasDaEntrega(e), ...linhasDaNota(foco, { comProva: false, nota: notas[foco], reserva: reservas[foco] }))
141    else if (q.lidoEm !== null) saida.push({ tipo: 'aviso', thread: foco, texto: `a ${foco} não está entre as entregas dos últimos ${JANELA_DIAS} dias que esperam a sua nota` })
142    saida.push(...linhasDaProva(foco, opcoes.prova ?? null, opcoes.passo ?? 0))
143    return saida
144  }
145  if (q.foraDaJanela) {
146    saida.push({ tipo: 'aviso', texto: `${plural(q.foraDaJanela, 'entrega sem a sua nota ficou', 'entregas sem a sua nota ficaram')} fora da janela de ${JANELA_DIAS} dias (ork master)` })
147  }
148  if (!q.entregas.length) {
149    saida.push({ tipo: 'vazio', texto: q.lidoEm === null ? 'lendo as entregas no núcleo...'
150      : q.semLeitura ? `Nenhuma das entregas que o retrato leu espera a sua nota nos últimos ${JANELA_DIAS} dias; ork master lista as das threads que ele não leu.`
151        : `Nada espera a sua nota nos últimos ${JANELA_DIAS} dias.` })
152    return saida
153  }
154  for (const e of q.entregas) {
155    saida.push(...linhasDaEntrega(e), ...linhasDaNota(e.thread, { comProva: true, nota: notas[e.thread], reserva: reservas[e.thread] }))
156  }
157  return saida
158}
159
160/**
161 * As linhas da tela que um texto com `wrap="wrap"` ocupa em `colunas` celulas: quebra por palavra, e a palavra maior que
162 * a linha quebra no meio. A `folga` tira colunas da conta: com ela a estimativa sobra.
163 */
164export function linhasDaTela(texto: string, colunas: number | undefined, folga = 1): number {
165  if (typeof colunas !== 'number' || !(colunas > folga)) return 1
166  const largura = colunas - folga
167  let linhas = 1
168  let usadas = 0
169  texto.split(' ').forEach((palavra, i) => {
170    let tamanho = Array.from(palavra).length
171    const espaco = i === 0 ? 0 : 1
172    if (usadas + espaco + tamanho <= largura) {
173      usadas += espaco + tamanho
174      return
175    }
176    if (usadas > 0) {
177      linhas++
178      usadas = 0
179    }
180    while (tamanho > largura) {
181      linhas++
182      tamanho -= largura
183    }
184    usadas = tamanho
185  })
186  return linhas
187}
188
189/** A altura de uma linha do painel na tela, sem e com a folga: so a alegacao quebra; as outras truncam numa so. */
190function alturasNaTela(l: { tipo?: string; texto?: string }, colunas: number | undefined): [number, number] {
191  if (l.tipo !== 'alegacao') return [1, 1]
192  return [linhasDaTela(l.texto ?? '', colunas, 0), linhasDaTela(l.texto ?? '', colunas, 1)]
193}
194
195/**
196 * O que esta inteiro dentro da janela de rolagem do painel: so isso conta como mostrado ao dono (M5). A linha que quebra
197 * tem o topo mais cedo (sem folga) e o mais tarde (com folga): ela so conta com os dois dentro da janela.
198 */
199export function mostradosVisiveis(linhas: ReadonlyArray<{ tipo?: string; texto?: string; mostrado?: RitualMostrado }>,
200  janela: { offset: number; bodyRows: number } | undefined, colunas?: number): RitualMostrado[] {
201  if (!janela || !(janela.bodyRows > 0)) return []
202  const fim = janela.offset + janela.bodyRows
203  const vistos: RitualMostrado[] = []
204  let cedo = 0
205  let tarde = 0
206  for (const l of linhas) {
207    const [menor, maior] = alturasNaTela(l, colunas)
208    if (l.mostrado && cedo >= janela.offset && tarde + maior <= fim) vistos.push(l.mostrado)
209    cedo += menor
210    tarde += maior
211  }
212  return vistos
213}
214
215/** Os gestos do painel: reler, abrir e fechar o replay, andar entre as claims, pedir a nota e a reserva. */
216export type Gestos = {
217  atualizar?: () => void
218  verProva?: (thread: string) => void
219  voltar?: () => void
220  anterior?: () => void
221  proxima?: () => void
222  darNota?: (thread: string) => void
223  telegram?: (thread: string) => void
224}
225
226/**
227 * A arvore do painel com os elementos que a superficie resolveu (no register.tsx), uma linha da tela por elemento: so
228 * `Box`, `Text` e `Button`, no terminal e no desktop.
229 */
230export function desenharRitual(el: { Box: any; Text: any; Button: any }, linhas: readonly LinhaDoPainel[], opcoes: { foco?: string | null } & Gestos) {
231  const { Box, Text, Button } = el
232  return (
233    <Box flexDirection="column">
234      {linhas.map((l, i) => {
235        if (l.tipo === 'placar') {
236          return (
237            <Box key="placar" flexDirection="row">
238              <Text key="placar-texto" bold wrap="truncate">{l.texto}</Text>
239              {opcoes.foco ? <Button key="voltar" label="Voltar" onPress={() => opcoes.voltar?.()} /> : null}
240              <Button key="atualizar" label="Atualizar" onPress={() => opcoes.atualizar?.()} />
241            </Box>
242          )
243        }
244        if (l.tipo === 'cabecalho') return <Text key={`cabecalho:${l.thread}`} bold wrap="truncate">{l.texto}</Text>
245        if (l.tipo === 'acoes' && l.thread) {
246          const thread = l.thread
247          const gesto: Record<BotaoDoCartao, ((t: string) => void) | undefined> = { prova: opcoes.verProva, nota: opcoes.darNota, telegram: opcoes.telegram }
248          return (
249            <Box key={`acoes:${thread}`} flexDirection="row">
250              {(l.botoes ?? []).map(b => <Button key={`${b}:${thread}`} label={ROTULO_DO_BOTAO[b]} onPress={() => gesto[b]?.(thread)} />)}
251            </Box>
252          )
253        }
254        if (l.tipo === 'feita') return <Text key={`feita:${l.thread}`} wrap="truncate">{l.texto}</Text>
255        if (l.tipo === 'navegacao') {
256          const [anterior, proxima] = l.texto.split('|')
257          return (
258            <Box key="navegacao" flexDirection="row">
259              {anterior ? <Button key="anterior" label="Anterior" onPress={() => opcoes.anterior?.()} /> : null}
260              {proxima ? <Button key="proxima" label="Próxima" onPress={() => opcoes.proxima?.()} /> : null}
261            </Box>
262          )
263        }
264        if (l.tipo === 'claim') return <Text key="claim" bold wrap="truncate">{l.texto}</Text>
265        if (l.tipo === 'alegacao') return <Text key="alegacao" wrap="wrap">{l.texto}</Text>
266        if (l.tipo === 'aviso') return <Text key={`aviso:${i}`} color="warning" wrap="truncate">{l.texto}</Text>
267        return <Text key={`${l.tipo}:${l.thread ?? ''}:${i}`} dimColor={l.tipo !== 'comando'} wrap="truncate">{l.texto}</Text>
268      })}
269    </Box>
270  )
271}
272
hooks/ritual.ts 219 lines
1// O que o Ritual do MASTER le do nucleo (RM-059, fatia F8; ficha M8 do plano dos mods), em funcoes puras: as entregas
2// que esperam a nota do dono, saidas da secao `master` do retrato da ponte (D2 da thread ork-rm059ritual), a
3// linha da faixa e as claims do replay, saidas do `ork_claims_list`. Nada aqui le `.orkastery/` nem chama o `$`: as
4// chamadas ficam no register.tsx. So importa tipos.
5import type { RitualClaim, RitualEntrega, RitualNota, RitualQuadro } from '../types'
6
7/** D1: a entrega dos ultimos 7 dias sem nota humana (sem MASTER, com proposta ou aceita por omissao). */
8export const JANELA_DIAS = 7
9/** A secao `master` vem em paginas de ate 50 itens; mais que isso e retrato quebrado, nao fila. */
10export const TETO_DE_PAGINAS = 20
11/** O painel mostra no maximo isto; o resto vira aviso com a contagem. */
12export const TETO_DE_ENTREGAS = 40
13export const TETO_DE_TEXTO = 200
14export const SERVIDOR = 'orkastery'
15export const QUADRO_VAZIO: RitualQuadro = { entregas: [], lidoEm: null, erro: null, avisos: [], foraDaJanela: 0, semLeitura: 0 }
16
17const ID_DE_THREAD = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/
18const DIA_MS = 86400000
19
20/** Uma linha so, sem caractere de controle e com teto. */
21export function umaLinha(v: unknown, teto = TETO_DE_TEXTO): string {
22  const s = typeof v === 'string' ? v : v instanceof Error ? v.message : String(v)
23  return Array.from(s.replace(/\p{Cc}/gu, ' ').replace(/\s+/g, ' ').trim()).slice(0, teto).join('')
24}
25
26export function plural(n: number, um: string, varios: string): string {
27  return `${n} ${n === 1 ? um : varios}`
28}
29
30export function exigirThread(thread: unknown): string {
31  if (typeof thread !== 'string' || !ID_DE_THREAD.test(thread)) throw new Error('ork-ritual: id de thread invalido')
32  return thread
33}
34
35/** O numero com virgula, como o dono le: 3.75 vira "3,75". */
36export function numero(n: number): string {
37  return String(n).replace('.', ',')
38}
39
40/** O JSON do primeiro bloco de texto de uma resposta MCP; a resposta com erro vira erro com o motivo do nucleo. */
41export function jsonDoMcp(r: { content?: ReadonlyArray<{ type?: string; text?: string }>; isError?: boolean } | null | undefined): unknown {
42  const texto = (r?.content ?? []).find(c => c.type === 'text' && typeof c.text === 'string')?.text
43  if (!r || r.isError) {
44    let motivo = 'o núcleo respondeu com erro'
45    try {
46      const corpo = JSON.parse(texto ?? '') as { erro?: unknown }
47      if (typeof corpo.erro === 'string' && corpo.erro.trim()) motivo = umaLinha(corpo.erro)
48    } catch {
49      // Sem JSON no corpo do erro: fica o motivo generico.
50    }
51    throw new Error(motivo)
52  }
53  if (texto === undefined) throw new Error('o núcleo não devolveu texto')
54  return JSON.parse(texto)
55}
56
57/** Uma pagina da secao `master` do retrato: os itens, a proxima pagina e as lacunas. */
58export function paginaDoMaster(r: unknown): { itens: unknown[]; proxima: number | null; lacunas: string[] } {
59  const secao = (r as { retrato?: { sections?: { master?: unknown } } } | null)?.retrato?.sections?.master as
60    { items?: unknown; gaps?: unknown; coverage?: { nextOffset?: unknown } } | undefined
61  if (!secao || !Array.isArray(secao.items)) throw new Error('o retrato do núcleo não trouxe a seção master')
62  const proxima = secao.coverage?.nextOffset
63  return {
64    itens: secao.items as unknown[],
65    proxima: typeof proxima === 'number' && Number.isInteger(proxima) && proxima >= 0 ? proxima : null,
66    lacunas: Array.isArray(secao.gaps) ? (secao.gaps as unknown[]).filter((g): g is string => typeof g === 'string').map(g => umaLinha(g, 120)) : [],
67  }
68}
69
70const textoOuNulo = (v: unknown, teto = TETO_DE_TEXTO): string | null => (typeof v === 'string' && v.trim() ? umaLinha(v, teto) : null)
71const numeroOuNulo = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null)
72
73/**
74 * A entrega de um item da secao `master`, quando ela espera a nota do dono: sem MASTER (`pending`), com proposta
75 * (`ratificacao-pendente`) ou aceita por omissao (`recorded` com regime `omissao`), entregue por `ship_done` ou por
76 * artefato. Nota humana registrada, item sem entrega ou fora do contrato devolvem null.
77 */
78export function entregaDoItem(item: unknown): RitualEntrega | null {
79  const i = (item ?? {}) as { id?: unknown; status?: unknown; facts?: Record<string, unknown> }
80  const m = typeof i.id === 'string' ? /^master:(.+)$/.exec(i.id) : null
81  const thread = m && ID_DE_THREAD.test(m[1] as string) ? m[1] as string : null
82  const f = i.facts ?? {}
83  if (!thread || (i.status !== 'pending' && i.status !== 'ratificacao-pendente' && i.status !== 'recorded')) return null
84  if (i.status === 'recorded' && f.regime !== 'omissao') return null
85  const entregueEm = typeof f.deliveredAt === 'string' && Number.isFinite(Date.parse(f.deliveredAt)) ? f.deliveredAt : null
86  if (!entregueEm) return null
87  return {
88    thread, status: i.status, regime: textoOuNulo(f.regime, 40), score: numeroOuNulo(f.score), indice: numeroOuNulo(f.index),
89    parcelas: textoOuNulo(f.indexDetail) ?? '', reprovacoes: numeroOuNulo(f.reprovals) ?? 0, classes: textoOuNulo(f.classes),
90    entregueEm, entregueEmLocal: textoOuNulo(f.deliveredAtLocal, 80),
91    merge: typeof f.deliveredSha === 'string' && /^[a-f0-9]{40}$/.test(f.deliveredSha) ? f.deliveredSha : null,
92    artefato: textoOuNulo(f.deliveredArtifact, 160),
93  }
94}
95
96/** As entregas da janela, a mais nova primeiro, com o teto do painel; e quantas ficaram fora da janela. */
97export function entregasDaJanela(itens: readonly unknown[], agora: number, dias = JANELA_DIAS): { entregas: RitualEntrega[]; fora: number; alemDoTeto: number } {
98  const todas = itens.map(entregaDoItem).filter((e): e is RitualEntrega => e !== null)
99  const desde = agora - dias * DIA_MS
100  const dentro = todas.filter(e => Date.parse(e.entregueEm) >= desde)
101    .sort((a, b) => Date.parse(b.entregueEm) - Date.parse(a.entregueEm) || (a.thread < b.thread ? -1 : 1))
102  return { entregas: dentro.slice(0, TETO_DE_ENTREGAS), fora: todas.length - dentro.length, alemDoTeto: Math.max(0, dentro.length - TETO_DE_ENTREGAS) }
103}
104
105/** A lacuna que nao falta nada: o corte da pagina por bytes (a proxima pagina traz o resto, e o Ritual le todas). */
106const SO_PAGINACAO = new Set(['maestro.page.byte_limit'])
107const OMITIDAS = /^maestro\.messages\.omitted:(\d+)$/
108const SEM_LEITURA = /^(?:ledger\.unavailable|master\.invalid):([a-zA-Z0-9][a-zA-Z0-9._-]{0,79})$/
109
110/**
111 * As lacunas da secao `master`, somadas de todas as paginas (cada pagina repete as da secao): as threads que o retrato
112 * nao leu (o ledger passou do teto de leitura do Maestro, ou o MASTER log nao vale), quantas lacunas o retrato omitiu
113 * alem das quatro que publica (na secao `master` elas sao por thread) e as outras. O corte da pagina por bytes nao
114 * falta nada.
115 */
116export function lacunasDoMaster(lacunas: readonly string[]): { semLeitura: string[]; omitidas: number; outras: string[] } {
117  const semLeitura: string[] = []
118  const outras: string[] = []
119  let omitidas = 0
120  for (const lacuna of new Set(lacunas)) {
121    if (SO_PAGINACAO.has(lacuna)) continue
122    const omitida = OMITIDAS.exec(lacuna)
123    if (omitida) {
124      omitidas = Math.max(omitidas, Number(omitida[1]))
125      continue
126    }
127    const thread = SEM_LEITURA.exec(lacuna)?.[1]
128    if (thread) {
129      if (!semLeitura.includes(thread)) semLeitura.push(thread)
130      continue
131    }
132    outras.push(lacuna)
133  }
134  return { semLeitura, omitidas, outras }
135}
136
137/** Quantas threads o retrato nao leu: as nomeadas nas lacunas e as omitidas. */
138export function threadsSemLeitura(lacunas: readonly string[]): number {
139  const { semLeitura, omitidas } = lacunasDoMaster(lacunas)
140  return semLeitura.length + omitidas
141}
142
143/** Os avisos da secao que veio incompleta, uma linha cada: as threads que o retrato nao leu e as outras lacunas. */
144export function avisosDasLacunas(lacunas: readonly string[]): string[] {
145  const { semLeitura, omitidas, outras } = lacunasDoMaster(lacunas)
146  const avisos: string[] = []
147  const sem = semLeitura.length + omitidas
148  if (sem) {
149    const nomes = semLeitura.slice(0, 3)
150    const resto = sem - nomes.length
151    const quais = nomes.length ? ` (${nomes.join(', ')}${resto > 0 ? ` e +${resto}` : ''})` : ''
152    avisos.push(`o retrato não leu ${plural(sem, 'thread', 'threads')}${quais}: a lista pode estar incompleta; ork master lista todas`)
153  }
154  if (outras.length) {
155    avisos.push(`o retrato do MASTER veio incompleto: ${plural(outras.length, 'lacuna', 'lacunas')} (${outras.slice(0, 3).join(', ')}${outras.length > 3 ? ', ...' : ''})`)
156  }
157  return avisos
158}
159
160/**
161 * A entrega cuja nota o nucleo registrou nesta sessao (ou que ja tinha nota humana) sai da lista na hora: a pagina que a
162 * ponte guarda por ate 60 s ainda pode lista-la. A entrega mais nova que a nota volta a esperar.
163 */
164export function semNotaDada(entregas: readonly RitualEntrega[], notas: Readonly<Record<string, RitualNota>>): RitualEntrega[] {
165  return entregas.filter(e => {
166    const n = notas[e.thread]
167    return !n || (n.estado !== 'registrada' && n.estado !== 'repetida') || n.em < Date.parse(e.entregueEm)
168  })
169}
170
171/**
172 * A linha da faixa: quantas entregas esperam a nota e quais; sem nenhuma (ou antes da primeira leitura), sem linha. Com
173 * thread que o retrato nao leu, a contagem e "pelo menos", e sem entrega visivel a linha aponta o `ork master`.
174 */
175export function textoDaFaixa(q: RitualQuadro): string | undefined {
176  if (q.lidoEm === null) return undefined
177  if (!q.entregas.length) {
178    return q.semLeitura ? `o retrato não leu ${plural(q.semLeitura, 'thread', 'threads')}: ork master lista as entregas que esperam a sua nota · /nota` : undefined
179  }
180  const nomes = q.entregas.slice(0, 3).map(e => e.thread)
181  const resto = q.entregas.length - nomes.length
182  return `${q.semLeitura ? 'pelo menos ' : ''}${plural(q.entregas.length, 'entrega espera', 'entregas esperam')} a sua nota: ${nomes.join(', ')}${resto > 0 ? ` e +${resto}` : ''} · /nota`
183}
184
185/** A thread do ledger que mudou (`classic.FileChanged`), quando o caminho e o ledger de uma thread. */
186export function threadDoLedger(caminho: unknown): string | null {
187  if (typeof caminho !== 'string') return null
188  const m = /\/\.orkastery\/threads\/([a-zA-Z0-9][a-zA-Z0-9._-]{0,79})\/ledger\.jsonl$/.exec(caminho)
189  return m ? m[1] as string : null
190}
191
192/** Os argumentos do `ork_claims_list`: so o id da thread. */
193export function argsDasClaims(thread: string): { threadId: string } {
194  return { threadId: exigirThread(thread) }
195}
196
197const numeroDoId = (id: string) => { const m = /^C(\d+)$/.exec(id); return m ? Number(m[1]) : Number.POSITIVE_INFINITY }
198
199/**
200 * As claims do `ork_claims_list` (o arquivo e so de acrescimo: a retirada e uma linha nova): o ultimo registro de cada
201 * id, as retiradas contadas a parte, na ordem dos ids. Registro fora do contrato fica de fora. Nunca a saida do comando:
202 * o nucleo nao a devolve aqui, e o replay nao a mostra (D6).
203 */
204export function claimsDaProva(resposta: unknown): { claims: RitualClaim[]; retiradas: number } {
205  if (!Array.isArray(resposta)) throw new Error('o ork_claims_list não devolveu a lista de claims')
206  const ultimas = new Map<string, RitualClaim>()
207  for (const bruto of resposta as unknown[]) {
208    const c = (bruto ?? {}) as Record<string, unknown>
209    if (typeof c.id !== 'string' || !/^C\d+$/.test(c.id) || typeof c.estado !== 'string') continue
210    ultimas.set(c.id, {
211      id: c.id, estado: umaLinha(c.estado, 40), arquivo: typeof c.arquivo === 'string' ? umaLinha(c.arquivo, 300) : '',
212      alegacao: typeof c.alegacao === 'string' ? umaLinha(c.alegacao, 1200) : '',
213      comandos: Array.isArray(c.verificar) ? (c.verificar as unknown[]).filter((v): v is string => typeof v === 'string').map(v => umaLinha(v, 600)) : [],
214    })
215  }
216  const todas = [...ultimas.values()].sort((a, b) => numeroDoId(a.id) - numeroDoId(b.id) || (a.id < b.id ? -1 : 1))
217  return { claims: todas.filter(c => c.estado !== 'retirada'), retiradas: todas.filter(c => c.estado === 'retirada').length }
218}
219
types/index.d.ts 107 lines
1// Contrato do estado do Ritual do MASTER (`ork-ritual`, RM-059, fatia F8; ficha M8 do plano dos mods). O motor exige
2// que cada chave de `$.state` do mod esteja declarada aqui, em `PluginState`. O Ritual nao acrescenta noun ao `$`: le as
3// entregas pelo retrato da `ork-ponte` (`$.ork.retrato`, secao `master`) e as claims pelo `ork_claims_list` do nucleo.
4// Nenhum campo guarda o porque do dono, e a nota so aparece como o texto do que o nucleo registrou: quem recebe e grava
5// a nota e o nucleo, pelo dialogo nativo do host. Sem import nem referencia.
6
7/** Uma entrega que espera a nota do dono, como a secao `master` do retrato do nucleo a da. */
8export type RitualEntrega = {
9  thread: string
10  /** `pending` (sem MASTER), `ratificacao-pendente` (proposta) ou `recorded` (so a aceita por omissao entra aqui). */
11  status: 'pending' | 'ratificacao-pendente' | 'recorded'
12  /** O regime do MASTER log: `omissao` na aceita por omissao; null sem MASTER log. */
13  regime: string | null
14  /** O score que a omissao gravou (o indice arredondado), quando ha. */
15  score: number | null
16  /** O indice derivado do ledger, de 0 a 5, e as parcelas que o descontaram. */
17  indice: number | null
18  parcelas: string
19  /** Os gates que barraram a thread (reprovacoes tipadas), fora os de ensaio. */
20  reprovacoes: number
21  /** As classes de falha do MASTER log (ou da proposta); null quando o POSTMORTEM ainda nao nasceu. */
22  classes: string | null
23  /** A entrega (`ship_done` ou artefato), em ISO e na hora local que o nucleo formatou, e o merge. */
24  entregueEm: string
25  entregueEmLocal: string | null
26  merge: string | null
27  /** O arquivo da entrega por artefato (a thread so de planejamento); ausente no quadro gravado antes deste campo. */
28  artefato?: string | null
29}
30
31/** A ultima leitura das entregas. */
32export type RitualQuadro = {
33  entregas: RitualEntrega[]
34  /** Quando foi lida (ms desde a epoca); null antes da primeira leitura. */
35  lidoEm: number | null
36  /** Por que a ultima leitura falhou, quando falhou (a anterior continua no quadro). */
37  erro: string | null
38  /** O que veio incompleto do retrato (lacunas da secao, paginas que sobraram). */
39  avisos: string[]
40  /** Quantas entregas sem nota humana ficaram fora da janela de dias. */
41  foraDaJanela: number
42  /**
43   * Quantas threads o retrato nao leu (o ledger passou do teto de leitura do Maestro, ou o MASTER log nao vale): com
44   * alguma, a contagem da faixa e "pelo menos". Ausente no quadro gravado antes deste campo.
45   */
46  semLeitura?: number
47}
48
49/** Uma claim no replay: o ultimo registro de cada id, como o `ork_claims_list` a da. Nunca a saida do comando. */
50export type RitualClaim = {
51  id: string
52  estado: string
53  arquivo: string
54  alegacao: string
55  comandos: string[]
56}
57
58/** A prova de uma thread no replay. */
59export type RitualProva = {
60  thread: string
61  /** As claims ativas, na ordem dos ids. */
62  claims: RitualClaim[]
63  retiradas: number
64  lidoEm: number | null
65  erro: string | null
66}
67
68/** O que o painel mostrou ao dono e o Telegram tambem diria: a chave junta o que e a mesma coisa (M5). */
69export type RitualMostrado = { chave: string; thread: string; texto: string }
70
71/**
72 * O "Dar a nota" de uma entrega (T8.3): o dialogo pedido ao nucleo e o que ele devolveu. So o estado e o texto do
73 * resultado; a nota e o porque que o dono deu ficam no nucleo, nunca aqui.
74 */
75export type RitualNota = {
76  estado: 'abrindo' | 'registrada' | 'repetida' | 'pendente' | 'sem-dialogo' | 'erro'
77  texto: string
78  em: number
79}
80
81/** A reserva pelo Telegram (T8.3): o pedido de nota do nucleo e o codigo que o dono responde por la. */
82export type RitualReserva = {
83  estado: 'pedindo' | 'pronta' | 'erro'
84  texto: string
85  codigo: string | null
86  em: number
87}
88
89declare module 'claude-code' {
90  interface PluginState {
91    'ork-ritual': {
92      /** A ultima leitura das entregas. */
93      quadro: RitualQuadro
94      /** A thread no replay da prova, pedida por "Ver a prova" ou por `/nota <thread>`. */
95      foco: string | null
96      /** A claim que o replay mostra, a partir de 0. */
97      passo: number
98      /** As claims da thread em foco. */
99      prova: RitualProva | null
100      /** O "Dar a nota" de cada entrega, pelo id da thread. */
101      notas: Record<string, RitualNota>
102      /** A reserva pelo Telegram de cada entrega, pelo id da thread. */
103      reservas: Record<string, RitualReserva>
104    }
105  }
106}
107