SLOPSHOPPER

ork-sentinela

Sentinela do Condutor do Orkastery no Claude Code: na sessão do dono, despacha a fase seguinte das threads #Auto só pelo núcleo, sem duplicar o condutor…

newcommandtoastprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ork-sentinela
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ ork-sentinela │ ⏺ Read(src/auth.ts) │ sentinela: a ponte ork-ponte não carregou; │ ⎿ Read 6 lines │ a Sentinela do Condutor fica desligada │ ⏺ Update(src/auth.ts) │ nesta sessão │ ⎿ Added 2 lines, removed 1 line ╰────────────────────────────────────────────╯ ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
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.ts 512 lines
1// A Sentinela do Condutor do Orkastery (RM-059, fatia F6; ficha M2 do plano dos mods). Na sessão do dono, mantém a
2// presença dela nesta máquina pelo núcleo, para o condutor externo ceder o despacho (DD5), relê o passo do condutor
3// de cada thread aberta quando um ledger muda e a cada batida, e despacha a fase seguinte das threads #Auto que o
4// núcleo manda despachar, só pelo `ork phase run` e uma vez por turno (T6.2); e avisa o dono, por notificação e na
5// faixa, do que só ele resolve, com o próximo passo do núcleo (T6.3), registrando pela ponte o que mostrou para o
6// Telegram não repetir. Lê só pelo núcleo (o `ork` e o `$.ork` da ponte), nunca `.orkastery/` por conta própria, e
7// nunca assume a condução de ninguém. A lógica fica nos módulos puros ao lado; aqui só os ganchos e as chamadas ao
8// `$`, escritas como `x.noun.metodo(...)`, como o validador exige.
9import type { EngineInterface, Register } from 'claude-code'
10import type { SentinelaEntrada, SentinelaPasso, SentinelaPresenca, SentinelaResumo } from '../types'
11import {
12  adiamentoDa, AMBIENTE_DA_RETOMADA, argvDaRetomada, argvDoDespacho, argvDoRender, chaveDaRetomada, chaveDoDespacho, conducaoLivre, lerDespacho,
13  lerRetomada, paraDespachar, paraRetomar, pedidoAbertoNoRetrato, PRAZO_DA_RETOMADA_MS, PRAZO_DO_DESPACHO_MS, PRAZO_DO_RENDER_MS,
14  promptDoRender, retomadaAutomatica,
15} from './despacho'
16import type { ResultadoDoDespacho } from './despacho'
17import { avisoDoDespacho, avisosDoPulse, avisosNovos, marcaDoAviso, PERIODO_DO_PULSE_MS, TITULO } from './alertas'
18import type { Aviso } from './alertas'
19import {
20  acrescentar, argvDaBatida, argvDoDesligar, argvDoStatus, BATIDA_MS, entrada, ESPERA_DO_LEDGER_MS, guardarChave, lerBatida, lerStatus, MOD,
21  PRAZO_DA_BATIDA_MS, PRAZO_DO_STATUS_MS, sessaoValida, TETO_DO_DIARIO, textoDaFaixa, textoDoDiario, threadDoLedger,
22} from './vigia'
23
24const RESUMO_VAZIO: SentinelaResumo = { lidoEm: null, presenca: 'desconhecida', passos: [], erro: null }
25const REF_DIARIO = { plugin: 'ork-sentinela', key: 'diario' } as const
26const REF_PAUSADA = { plugin: 'ork-sentinela', key: 'pausada' } as const
27const REF_RESUMO = { plugin: 'ork-sentinela', key: 'resumo' } as const
28const REF_DESPACHADOS = { plugin: 'ork-sentinela', key: 'despachados' } as const
29const REF_RETOMADOS = { plugin: 'ork-sentinela', key: 'retomados' } as const
30const REF_AVISADOS = { plugin: 'ork-sentinela', key: 'avisados' } as const
31
32/**
33 * O estado desta carga. Um despacho do host lê o `$.state` do momento em que começou, e a segunda gravação com
34 * `ifVersion` no mesmo despacho nunca acerta (o `update` do motor ficaria em laço): a fonte da verdade é esta memória
35 * do módulo, carregada do estado uma vez na ativação (depois de uma recarga, o que a carga anterior gravou), e cada
36 * mudança vai ao estado por `$.state.set` sem versão. Os despachos deste módulo rodam um de cada vez no mesmo laço de
37 * eventos, então a memória nunca diverge do que foi gravado por último.
38 */
39const memoria = {
40  diario: [] as SentinelaEntrada[], pausada: false, resumo: RESUMO_VAZIO, despachados: [] as string[], retomados: [] as string[],
41  avisados: [] as string[],
42}
43type Chave = keyof typeof memoria
44/** O servidor do núcleo pode conectar depois do início da sessão: o papel é conferido de novo nestas esperas. */
45const ESPERAS_DO_PAPEL_MS = [3000, 10000, 30000]
46/** Depois delas, enquanto dormente, a cada 5 min. */
47const ESPERA_LONGA_DO_PAPEL_MS = 300000
48/** A primeira rodada sai logo depois da ativação, fora do início da sessão. */
49const ESPERA_DA_PRIMEIRA_MS = 1000
50/** Ledgers mudam o tempo todo com sessões vivas: entre duas rodadas pedidas por arquivo, pelo menos isto. */
51const INTERVALO_MINIMO_MS = 15000
52
53// O que o módulo guarda entre ganchos. Uma recarga do módulo zera tudo, e o `session.start` dispara de novo nela; o
54// diário, a pausa e a última leitura ficam em `$.state`.
55let ativa = false
56let desistiu = false
57/** O id desta sessão do Claude Code, relido a cada batida: um `/clear` continua o processo com outro. */
58let sessao: string | null = null
59let ciclo: { cancel: () => void } | undefined
60let acordar: { cancel: () => void } | undefined
61let rodando: Promise<void> | undefined
62let pendente = false
63let ultimaRodadaEm = 0
64/**
65 * A última linha publicada na faixa. `null` é "não sei": uma carga nova não sabe o que a anterior deixou na faixa
66 * da ponte, então a primeira publicação sempre acontece, inclusive a que tira a linha.
67 */
68let ultimaFaixa: string | undefined | null = null
69/** A falha de uma rodada em segundo plano que nem o diário registrou; o `/sentinela` a mostra. */
70let falhaSemRegistro: string | null = null
71/** Quando o pulse foi lido pela última vez nesta carga, e quantos avisos ele trouxe (a faixa os conta). */
72let ultimoPulseEm = Number.NEGATIVE_INFINITY
73let avisosAtuais = 0
74let batendo: Promise<void> | undefined
75/** O que a última batida disse da presença desta sessão: `outra` segura o despacho e os avisos. */
76let ultimaBatida: SentinelaPresenca = 'desconhecida'
77/**
78 * Os turnos e bloqueios adiados nesta carga, com até quando. Só o despacho feito (ou o pedido repetido de quem já
79 * conduz) e a retomada que o plano declara do dono ficam de vez; a recusa que passa (outro condutor, sem vaga, pedido
80 * aberto, falha) volta depois do adiamento, porque o fim do turno que a chave carrega não muda com ela.
81 */
82const adiados = new Map<string, { ate: number; vezes: number }>()
83/** A sessão acabou (`session.end`): batida que chega depois disso desliga a presença de novo, em vez de refazê-la. */
84let encerrada = false
85
86function emEspera(agora: number): string[] {
87  return [...adiados].filter(([, a]) => a.ate > agora).map(([chave]) => chave)
88}
89
90/** Adia a chave pela vez seguida dela: a mesma recusa dobra a espera até o teto (3 min a 30 min). */
91function adiarChave(chave: string, agora: number): number {
92  const vezes = (adiados.get(chave)?.vezes ?? 0) + 1
93  const espera = adiamentoDa(vezes)
94  adiados.set(chave, { ate: agora + espera, vezes })
95  return espera
96}
97
98/** Tira do adiamento o que o núcleo não pede mais (o turno andou, o bloqueio saiu): o Map não cresce sem fim. */
99function podarAdiados(vivas: ReadonlySet<string>): void {
100  for (const chave of [...adiados.keys()]) if (!vivas.has(chave)) adiados.delete(chave)
101}
102
103function mensagem(erro: unknown): string {
104  return (erro instanceof Error ? erro.message : String(erro)).replace(/\s+/g, ' ').trim().slice(0, 200)
105}
106
107/** Carrega a memória do estado: o que esta sessão (ou a carga anterior do módulo) já gravou. */
108async function carregarMemoria($: EngineInterface): Promise<void> {
109  memoria.diario = (await $.state.get(REF_DIARIO)).value ?? []
110  memoria.pausada = (await $.state.get(REF_PAUSADA)).value ?? false
111  memoria.resumo = (await $.state.get(REF_RESUMO)).value ?? RESUMO_VAZIO
112  memoria.despachados = (await $.state.get(REF_DESPACHADOS)).value ?? []
113  memoria.retomados = (await $.state.get(REF_RETOMADOS)).value ?? []
114  memoria.avisados = (await $.state.get(REF_AVISADOS)).value ?? []
115}
116
117/** Muda uma chave da memória e a grava no estado, sem versão: a memória é a fonte desta carga. */
118async function mudar<K extends Chave>($: EngineInterface, chave: K, mudanca: (atual: (typeof memoria)[K]) => (typeof memoria)[K]): Promise<void> {
119  memoria[chave] = mudanca(memoria[chave])
120  if (chave === 'diario') await $.state.set(REF_DIARIO, memoria.diario)
121  else if (chave === 'pausada') await $.state.set(REF_PAUSADA, memoria.pausada)
122  else if (chave === 'resumo') await $.state.set(REF_RESUMO, memoria.resumo)
123  else if (chave === 'despachados') await $.state.set(REF_DESPACHADOS, memoria.despachados)
124  else if (chave === 'retomados') await $.state.set(REF_RETOMADOS, memoria.retomados)
125  else await $.state.set(REF_AVISADOS, memoria.avisados)
126}
127
128/**
129 * O papel pela ponte. `null` só quando o `$.ork` não existe (a ponte não carregou: a chamada nem acontece); uma falha
130 * da própria ponte conta como dormente e o papel é conferido de novo depois.
131 */
132async function papelDaSessao($: EngineInterface): Promise<string | null> {
133  try {
134    return String(await $.ork.papel())
135  } catch (erro) {
136    return erro instanceof TypeError ? null : 'dormente'
137  }
138}
139
140/** Uma linha no diário, sem repetir a anterior igual (a mesma falha a cada batida vira uma linha só). */
141async function anotar($: EngineInterface, e: SentinelaEntrada): Promise<void> {
142  await mudar($, 'diario', d => {
143    const ultima = d[d.length - 1]
144    if (ultima && ultima.tipo === e.tipo && ultima.thread === e.thread && ultima.texto === e.texto) return d
145    return acrescentar(d, e, TETO_DO_DIARIO)
146  })
147}
148
149/** Liga a Sentinela na sessão do dono: o `/sentinela`, a batida de 60 s e a primeira rodada. Falha deixa tudo desligado. */
150async function ativar($: EngineInterface): Promise<void> {
151  if (ativa) return
152  ativa = true
153  try {
154    await carregarMemoria($)
155    await $.command.register({
156      name: 'sentinela', description: 'Sentinela do Condutor: o passo de cada thread e o diário do que ela despachou, cedeu e avisou; pausar ou retomar',
157      immediate: true,
158    })
159  } catch (erro) {
160    ativa = false
161    falhaSemRegistro = mensagem(erro)
162    $.ui.toast(`sentinela: a Sentinela do Condutor não ligou nesta sessão (${mensagem(erro)})`)
163    return
164  }
165  ciclo?.cancel()
166  ciclo = $.clock.every(BATIDA_MS, () => tique($))
167  $.clock.after(ESPERA_DA_PRIMEIRA_MS, () => tique($))
168}
169
170/**
171 * A batida tem relógio próprio, fora da rodada: um despacho lento (até 200 s) dentro de uma rodada não deixa a presença
172 * vencer (3 min), e o condutor externo não volta a conduzir no meio dela. Uma por vez; pausada, nenhuma.
173 */
174function pedirBatida($: EngineInterface): Promise<void> | undefined {
175  if (!ativa || memoria.pausada || batendo) return undefined
176  const esta = (async () => {
177    try {
178      ultimaBatida = await bater($)
179      // A pausa ou o fim da sessão chegou com a batida no ar: ela refez a presença, que sai de novo.
180      if (memoria.pausada || encerrada) {
181        await desligar($, memoria.pausada ? 'batida que chegou depois da pausa' : 'batida que chegou depois do fim da sessão')
182        ultimaBatida = 'nenhuma'
183      }
184    } catch (erro) {
185      falhaSemRegistro = mensagem(erro)
186    } finally {
187      batendo = undefined
188    }
189  })()
190  batendo = esta
191  return esta
192}
193
194/** O relógio da Sentinela: a batida e, depois dela, uma rodada de reserva. Depois de um `/clear` o processo segue. */
195function tique($: EngineInterface): void {
196  encerrada = false
197  const batida = pedirBatida($)
198  if (batida) void batida.then(() => { void pedirRodada($) })
199  else void pedirRodada($)
200}
201
202/** Confere o papel: liga no dono, desiste na fase ou sem a ponte, e tenta de novo enquanto dormente. */
203async function conferirPapel($: EngineInterface, tentativa: number): Promise<void> {
204  if (ativa || desistiu) return
205  const papel = await papelDaSessao($)
206  if (papel === 'dono') return ativar($)
207  if (papel === 'fase' || papel === null) {
208    desistiu = true
209    if (papel === null) $.ui.toast('sentinela: a ponte ork-ponte não carregou; a Sentinela do Condutor fica desligada nesta sessão')
210    return
211  }
212  const espera = ESPERAS_DO_PAPEL_MS[tentativa] ?? ESPERA_LONGA_DO_PAPEL_MS
213  $.clock.after(espera, () => { void conferirPapel($, tentativa + 1).catch(erro => { falhaSemRegistro = mensagem(erro) }) })
214}
215
216/**
217 * Uma rodada por vez. Com uma em andamento, o pedido só marca que outra deve rodar logo depois e volta sem nada para
218 * esperar: quem pediu nunca espera a rodada de outro despacho, cuja espera contaria no orçamento do gancho. Devolve a
219 * rodada só a quem a começou.
220 */
221function pedirRodada($: EngineInterface): Promise<void> | undefined {
222  if (!ativa) return undefined
223  if (rodando) {
224    pendente = true
225    return undefined
226  }
227  const esta = (async () => {
228    try {
229      do {
230        pendente = false
231        await rodadaUmaVez($)
232      } while (pendente)
233    } catch (erro) {
234      // O `$` pode já não responder (a sessão fechou): o motivo fica numa variável do módulo, sem depender dele.
235      falhaSemRegistro = mensagem(erro)
236    } finally {
237      rodando = undefined
238    }
239  })()
240  rodando = esta
241  return esta
242}
243
244/** Um ledger mudou: a rodada sai depois de juntar as linhas do mesmo evento, e nunca antes do intervalo mínimo. */
245function agendar($: EngineInterface, agora: number): void {
246  if (acordar) return
247  const espera = Math.max(ESPERA_DO_LEDGER_MS, ultimaRodadaEm + INTERVALO_MINIMO_MS - agora)
248  acordar = $.clock.after(espera, () => { acordar = undefined; void pedirRodada($) })
249}
250
251/** A batida da presença desta sessão; falha vira linha do diário e presença desconhecida. */
252async function bater($: EngineInterface): Promise<SentinelaPresenca> {
253  sessao = await $.session.id().catch(() => sessao)
254  if (!sessaoValida(sessao)) return 'desconhecida'
255  try {
256    return lerBatida(await $.process.run(argvDaBatida(sessao), { timeoutMs: PRAZO_DA_BATIDA_MS }))
257  } catch (erro) {
258    await anotar($, entrada(await $.clock.now(), 'erro', null, `a batida da presença falhou: ${mensagem(erro)}`))
259    return 'desconhecida'
260  }
261}
262
263/** A rodada: o status do núcleo, o despacho e os avisos (só com a presença desta sessão) e a linha da faixa. */
264async function rodadaUmaVez($: EngineInterface): Promise<void> {
265  ultimaRodadaEm = await $.clock.now()
266  const pausa = memoria.pausada
267  let lido: ReturnType<typeof lerStatus>
268  try {
269    lido = lerStatus(await $.process.run(argvDoStatus(), { timeoutMs: PRAZO_DO_STATUS_MS }), sessao)
270  } catch (erro) {
271    const motivo = mensagem(erro)
272    await mudar($, 'resumo', r => ({ ...r, erro: motivo }))
273    await anotar($, entrada(await $.clock.now(), 'erro', null, `a leitura do núcleo falhou: ${motivo}`))
274    await publicarFaixa($)
275    return
276  }
277  const presenca: SentinelaPresenca = ultimaBatida === 'outra' ? 'outra' : lido.presenca
278  await mudar($, 'resumo', () => ({ lidoEm: ultimaRodadaEm, presenca, passos: lido.passos, erro: null }))
279  // Uma Sentinela por máquina: só a que tem a presença conduz e avisa, e nunca pausada (aí o condutor externo avisa).
280  if (!pausa && presenca === 'minha') {
281    await conduzir($, lido.passos)
282    await lerAvisos($, lido.passos)
283  } else avisosAtuais = 0
284  await publicarFaixa($)
285}
286
287/**
288 * T6.3: a cada 5 min, o pulse pela ponte vira aviso do que só o dono resolve, com o próximo passo do núcleo. Cada aviso
289 * sai uma vez por texto: notificação do sistema (ou toast, se o canal não escreveu), linha no diário e o registro do
290 * que foi mostrado na ponte, para o Telegram não repetir. Pulse que falha fica no diário e a rodada segue.
291 */
292async function lerAvisos($: EngineInterface, passos: readonly SentinelaPasso[]): Promise<void> {
293  const agora = await $.clock.now()
294  if (agora - ultimoPulseEm < PERIODO_DO_PULSE_MS) return
295  ultimoPulseEm = agora
296  let avisos: Aviso[]
297  try {
298    avisos = avisosDoPulse(await $.ork.ler({ consulta: 'pulse' }), passos)
299  } catch (erro) {
300    return anotar($, entrada(agora, 'erro', null, `os avisos do pulse ficaram indisponíveis nesta rodada (${mensagem(erro)})`))
301  }
302  avisosAtuais = avisos.length
303  for (const a of avisosNovos(avisos, memoria.avisados)) await avisar($, a)
304}
305
306/** Um aviso ao dono, uma vez por texto. */
307async function avisar($: EngineInterface, a: Aviso): Promise<void> {
308  const marca = marcaDoAviso(a)
309  if (memoria.avisados.includes(marca)) return
310  await mudar($, 'avisados', l => guardarChave(l, marca))
311  const enviado = await $.ui.notify(a.texto, { title: TITULO }).then(r => r.isSent, () => false)
312  if (!enviado) $.ui.toast(`sentinela: ${a.texto}`)
313  await anotar($, entrada(await $.clock.now(), 'aviso', a.thread, a.texto))
314  try {
315    await $.ork.mostrado({ mod: MOD, canal: enviado ? 'notificacao' : 'toast', texto: a.texto, chave: a.chave, ...(a.thread ? { thread: a.thread } : {}) })
316  } catch (erro) {
317    await anotar($, entrada(await $.clock.now(), 'erro', a.thread, `o aviso não entrou no registro do mostrado da ponte (${mensagem(erro)})`))
318  }
319}
320
321/** T6.2: o despacho e a retomada que o núcleo manda, cada turno feito uma vez só e o recusado depois do adiamento. */
322async function conduzir($: EngineInterface, passos: readonly SentinelaPasso[]): Promise<void> {
323  if (!sessaoValida(sessao)) return
324  podarAdiados(new Set(passos.flatMap(p => [chaveDoDespacho(p), chaveDaRetomada(p)]).filter((c): c is string => c !== null)))
325  const esperando = emEspera(await $.clock.now())
326  for (const p of paraDespachar(passos, [...memoria.despachados, ...esperando])) await despachar($, p, sessao)
327  for (const p of paraRetomar(passos, [...memoria.retomados, ...esperando])) await retomarThread($, p)
328}
329
330const FRASE_DO_RESULTADO: Record<ResultadoDoDespacho['resultado'], string> = {
331  despachada: 'despachada pelo núcleo', repetida: 'o mesmo pedido já estava em andamento; nada novo',
332  ocupada: 'outro condutor conduz agora; nada despachado', falhou: 'o despacho falhou',
333}
334
335/**
336 * Um despacho: a chave do turno entra antes (uma recarga no meio nunca pede o mesmo turno de novo); a condução e o
337 * pedido aberto ao dono são relidos pelo núcleo logo antes; o prompt sai do render com o pedido guardado; e o
338 * `ork phase run` decide, com o lease e a trava do kernel, se a sessão nova abre. Só o despacho feito e o pedido
339 * repetido ficam de vez; o resto devolve a chave e espera o adiamento, e o diário diz quando volta.
340 */
341async function despachar($: EngineInterface, p: SentinelaPasso, sessaoDoDono: string): Promise<void> {
342  const chave = chaveDoDespacho(p) as string
343  const fase = p.fase as string
344  const anotarAqui = async (tipo: SentinelaEntrada['tipo'], texto: string) => anotar($, entrada(await $.clock.now(), tipo, p.thread, texto))
345  let volta = ''
346  const adiar = async () => {
347    await mudar($, 'despachados', d => d.filter(c => c !== chave))
348    volta = `; tenta de novo em ${Math.round(adiarChave(chave, await $.clock.now()) / 60000)} min`
349  }
350  await mudar($, 'despachados', d => guardarChave(d, chave))
351  try {
352    if (!conducaoLivre(await $.ork.ler({ consulta: 'conducao.status', thread: p.thread }))) {
353      await adiar()
354      return anotarAqui('cedeu', `${fase}: outro condutor já conduz a thread; nada despachado${volta}`)
355    }
356    if (pedidoAbertoNoRetrato((await $.ork.retrato({ thread: p.thread })).retrato, p.thread)) {
357      await adiar()
358      return anotarAqui('cedeu', `${fase}: há pedido aberto ao dono na thread; o despacho espera a resposta${volta}`)
359    }
360  } catch (erro) {
361    await adiar()
362    return anotarAqui('erro', `${fase}: a condução não foi relida (${mensagem(erro)})${volta}`)
363  }
364  let render: ReturnType<typeof promptDoRender>
365  try {
366    render = promptDoRender(await $.process.run(argvDoRender(p.thread, fase), { timeoutMs: PRAZO_DO_RENDER_MS }))
367  } catch (erro) {
368    await adiar()
369    return anotarAqui('erro', `${fase}: o render do prompt falhou (${mensagem(erro)})${volta}`)
370  }
371  if ('motivo' in render) {
372    await adiar()
373    await anotarAqui('erro', `${fase}: não despachada: ${render.motivo}`)
374    return avisar($, avisoDoDespacho(p, render.motivo))
375  }
376  let r: ResultadoDoDespacho
377  try {
378    r = lerDespacho(await $.process.run(argvDoDespacho(p.thread, fase, render.prompt, sessaoDoDono), { timeoutMs: PRAZO_DO_DESPACHO_MS }))
379  } catch (erro) {
380    r = { resultado: 'falhou', detalhe: mensagem(erro) }
381  }
382  const deVez = r.resultado === 'despachada' || r.resultado === 'repetida'
383  if (!deVez) await adiar()
384  const tipo = r.resultado === 'despachada' ? 'despacho' : r.resultado === 'falhou' ? 'erro' : 'cedeu'
385  await anotarAqui(tipo, `${fase}: ${FRASE_DO_RESULTADO[r.resultado]}${r.detalhe ? ` (${r.detalhe})` : ''}${deVez ? '' : volta}`)
386  if (r.resultado === 'falhou') await avisar($, avisoDoDespacho(p, r.detalhe))
387}
388
389/**
390 * Uma retomada: a condução e o pedido aberto ao dono são relidos antes, como no despacho; o `ork retry plan` (pela
391 * ponte) decide, e o `ork retry run` só roda quando ele é automático. O plano que é do dono fica de vez para esse
392 * bloqueio; a recusa que passa espera o adiamento.
393 */
394async function retomarThread($: EngineInterface, p: SentinelaPasso): Promise<void> {
395  const chave = chaveDaRetomada(p) as string
396  const anotarAqui = async (texto: string) => anotar($, entrada(await $.clock.now(), 'retomada', p.thread, texto))
397  let volta = ''
398  const adiar = async () => {
399    await mudar($, 'retomados', d => d.filter(c => c !== chave))
400    volta = `; tenta de novo em ${Math.round(adiarChave(chave, await $.clock.now()) / 60000)} min`
401  }
402  await mudar($, 'retomados', d => guardarChave(d, chave))
403  let plano: unknown
404  try {
405    if (!conducaoLivre(await $.ork.ler({ consulta: 'conducao.status', thread: p.thread }))) {
406      await adiar()
407      return anotarAqui(`bloqueio ${p.bloqueio?.motivo}: outro condutor já conduz a thread${volta}`)
408    }
409    if (pedidoAbertoNoRetrato((await $.ork.retrato({ thread: p.thread })).retrato, p.thread)) {
410      await adiar()
411      return anotarAqui(`bloqueio ${p.bloqueio?.motivo}: há pedido aberto ao dono na thread; a retomada espera a resposta${volta}`)
412    }
413    plano = await $.ork.ler({ consulta: 'retry.plan', thread: p.thread })
414  } catch (erro) {
415    await adiar()
416    return anotarAqui(`a condução ou o retry plan não foram lidos (${mensagem(erro)})${volta}`)
417  }
418  if (!retomadaAutomatica(plano)) {
419    const { bloqueio } = (plano ?? {}) as { bloqueio?: unknown }
420    return anotarAqui(`bloqueio ${p.bloqueio?.motivo}: o retry plan não é automático (${String(bloqueio ?? 'sem plano')}); o passo é do dono`)
421  }
422  let r: ReturnType<typeof lerRetomada>
423  try {
424    r = lerRetomada(await $.process.run(argvDaRetomada(p.thread), { env: { ...AMBIENTE_DA_RETOMADA }, timeoutMs: PRAZO_DA_RETOMADA_MS }))
425  } catch (erro) {
426    r = { resultado: 'recusada', detalhe: mensagem(erro) }
427  }
428  if (r.resultado !== 'retomada') await adiar()
429  await anotarAqui(`bloqueio ${p.bloqueio?.motivo}: ${r.resultado === 'retomada' ? 'retomado pelo núcleo' : 'o núcleo não retomou'}${r.detalhe ? ` (${r.detalhe})` : ''}${r.resultado === 'retomada' ? '' : volta}`)
430}
431
432/** A linha da faixa pela ponte (D7 do plano dos mods), só quando o texto muda. */
433async function publicarFaixa($: EngineInterface): Promise<void> {
434  const texto = textoDaFaixa(memoria.resumo, memoria.pausada, await $.clock.now(), avisosAtuais)
435  if (texto === ultimaFaixa) return
436  try {
437    if (texto === undefined) await $.ork.faixa({ mod: MOD })
438    else await $.ork.faixa({ mod: MOD, texto })
439    ultimaFaixa = texto
440  } catch (erro) {
441    // A linha fica como estava; o motivo vai ao diário, e a próxima rodada tenta de novo.
442    await anotar($, entrada(await $.clock.now(), 'erro', null, `a linha da faixa não foi publicada: ${mensagem(erro)}`))
443  }
444}
445
446/** Tira a presença desta sessão: o condutor externo volta a conduzir. */
447async function desligar($: EngineInterface, motivo: string): Promise<void> {
448  if (!sessaoValida(sessao)) return
449  const r = await $.process.run(argvDoDesligar(sessao), { timeoutMs: PRAZO_DA_BATIDA_MS }).catch((erro: unknown) => erro)
450  if (r instanceof Error || (r as { exitCode?: number }).exitCode !== 0) {
451    await anotar($, entrada(await $.clock.now(), 'erro', null, `a presença não saiu (${motivo}); ela vence sozinha em 3 min`))
452  }
453}
454
455async function pausarSentinela($: EngineInterface): Promise<string> {
456  await mudar($, 'pausada', () => true)
457  await desligar($, 'pausa pelo dono')
458  ultimaBatida = 'nenhuma'
459  await anotar($, entrada(await $.clock.now(), 'pausa', null, 'pausada pelo /sentinela; o condutor externo conduz'))
460  await mudar($, 'resumo', r => ({ ...r, presenca: 'nenhuma' }))
461  await publicarFaixa($)
462  return 'Sentinela pausada: nada é despachado por ela, e o condutor externo volta a conduzir. Para voltar: /sentinela retomar.'
463}
464
465async function retomarSentinela($: EngineInterface): Promise<string> {
466  await mudar($, 'pausada', () => false)
467  await anotar($, entrada(await $.clock.now(), 'pausa', null, 'retomada pelo /sentinela'))
468  const batida = pedirBatida($)
469  if (batida) await batida
470  const propria = pedirRodada($)
471  if (propria) await propria
472  return 'Sentinela retomada: ela volta a conduzir nesta máquina quando a presença for confirmada pelo núcleo.'
473}
474
475export const register: Register = on => {
476  on('session.start', async ($, e, next) => {
477    if (e.isInteractive) await conferirPapel($, 0)
478    return next(e)
479  })
480
481  // Um ledger de thread mudou (a ponte já vigia a pasta das threads e os ledgers recentes no papel dono): a rodada
482  // relê o passo do condutor pelo núcleo.
483  on('classic.FileChanged', async ($, e, next) => {
484    if (ativa && threadDoLedger(e.file_path) !== null) agendar($, await $.clock.now())
485    return next(e)
486  }).catch(($, e, next) => next(e))
487
488  // A sessão acabou: a presença sai na hora, para o condutor externo não esperar o prazo. Num `/clear` o processo
489  // segue com outro id, e a próxima batida toma a presença com ele.
490  on('session.end', async ($, e, next) => {
491    encerrada = true
492    if (ativa && !memoria.pausada) await desligar($, `fim da sessão (${e.reason})`)
493    return next(e)
494  }).catch(($, e, next) => next(e))
495
496  // `/sentinela` relê o núcleo e mostra o passo de cada thread e o diário; `pausar` e `retomar` mudam quem conduz.
497  on('command.run', { command: 'sentinela' }, async ($, e) => {
498    if (!ativa && !desistiu) await conferirPapel($, ESPERAS_DO_PAPEL_MS.length)
499    if (!ativa) return { text: 'A Sentinela do Condutor só roda na sessão do dono de um projeto do Orkastery.' }
500    const pedido = String(e.args ?? '').trim()
501    if (pedido === 'pausar') return { text: await pausarSentinela($) }
502    if (pedido === 'retomar') return { text: await retomarSentinela($) }
503    if (pedido !== '') return { text: 'Uso: /sentinela (o passo de cada thread e o diário), /sentinela pausar ou /sentinela retomar.' }
504    const propria = pedirRodada($)
505    if (propria) await propria
506    const texto = textoDoDiario(memoria.resumo, memoria.diario, memoria.pausada, await $.clock.now())
507    const andamento = propria ? '' : '\nUma rodada já estava em andamento: o próximo /sentinela mostra a leitura nova.'
508    const falha = falhaSemRegistro ? `\nÚltima falha em segundo plano: ${falhaSemRegistro}` : ''
509    return { text: texto + andamento + falha }
510  })
511}
512
hooks/despacho.ts 157 lines
1// O despacho da fase seguinte pela Sentinela (RM-059, fatia F6, T6.2). Módulo puro: quais passos do núcleo despachar
2// ou retomar, uma vez por turno, o argv do render, do despacho e da retomada, e a leitura de cada resultado. A
3// Sentinela só despacha pelo `ork phase run`, que toma a condução da thread sob a trava do kernel e é idempotente; a
4// fase vem do núcleo (`ork conducao sentinela status`), nunca de uma regra daqui, e ela nunca assume a condução.
5import type { SentinelaPasso } from '../types'
6
7export const PRAZO_DO_RENDER_MS = 30000
8/** O mesmo teto que o condutor externo dá ao `ork phase run`, com folga. */
9export const PRAZO_DO_DESPACHO_MS = 200000
10export const PRAZO_DA_RETOMADA_MS = 200000
11/**
12 * A recusa que passa (outro condutor, projeto sem vaga, pedido aberto, falha) não queima o turno: a chave volta e o
13 * passo é tentado de novo depois disto. A batida e o adiamento são da Sentinela; quem diz se sai é sempre o núcleo.
14 */
15export const ADIAMENTO_MS = 3 * 60 * 1000
16/** A mesma recusa de novo dobra o adiamento, até este teto: falha que não passa não vira despacho a cada 3 min. */
17export const TETO_DO_ADIAMENTO_MS = 30 * 60 * 1000
18
19/** O adiamento da n-ésima recusa seguida do mesmo turno: 3, 6, 12 e 24 min, depois 30. */
20export function adiamentoDa(vez: number): number {
21  return Math.min(TETO_DO_ADIAMENTO_MS, ADIAMENTO_MS * 2 ** Math.max(0, Math.floor(vez) - 1))
22}
23/** A linha que o `ork prompt render` imprime quando usa o pedido guardado na criação da thread (T6.0). */
24export const MARCA_DO_PEDIDO = '# pedido guardado na criacao da thread'
25/** O teto do prompt que o despacho aceita (o mesmo do `ork_phase_run`). */
26export const TETO_DO_PROMPT = 65536
27export const CANAL = 'claude-code'
28/** O `ork retry run` não lê `--canal`: o canal da retomada vai pelo ambiente, como o núcleo o declara (D3). */
29export const AMBIENTE_DA_RETOMADA: Readonly<Record<string, string>> = { ORK_CANAL: CANAL }
30const FASES: readonly string[] = ['GOAL', 'PLAN', 'GO', 'CHECK', 'SHIP', 'MASTER']
31const ID_DE_THREAD = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/
32const SESSAO = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/
33/** Os estados de pedido HITL que ainda esperam o dono no retrato do Maestro. */
34const PEDIDO_ABERTO: readonly string[] = ['aberto', 'delivery.uncertain']
35
36type Saida = { exitCode: number; stdout: string; stderr: string }
37
38function exigirThread(thread: unknown): string {
39  if (typeof thread !== 'string' || !ID_DE_THREAD.test(thread)) throw new Error('ork-sentinela: id de thread invalido')
40  return thread
41}
42
43function exigirFase(fase: unknown): string {
44  if (typeof fase !== 'string' || !FASES.includes(fase)) throw new Error('ork-sentinela: fase fora do ciclo')
45  return fase
46}
47
48function ultimaLinha(texto: unknown): string {
49  return String(texto ?? '').trim().split('\n').filter(l => l.trim() !== '').slice(-1)[0]?.trim().slice(0, 200) ?? ''
50}
51
52function primeiraLinha(texto: unknown): string {
53  return String(texto ?? '').trim().split('\n').find(l => l.trim() !== '')?.trim().slice(0, 200) ?? ''
54}
55
56/** A chave de um despacho: a thread, a fase e o fim do turno que o pediu. O mesmo turno nunca sai duas vezes. */
57export function chaveDoDespacho(p: SentinelaPasso): string | null {
58  return p.acao === 'despachar' && p.fase && p.desdeEm ? `${p.thread}|${p.fase}|${p.desdeEm}` : null
59}
60
61/** A chave de uma retomada: a thread e o bloqueio. O mesmo bloqueio vai ao `retry plan` uma vez. */
62export function chaveDaRetomada(p: SentinelaPasso): string | null {
63  return p.acao === 'retomar' && p.bloqueio ? `${p.thread}|${p.bloqueio.motivo}|${p.bloqueio.em}` : null
64}
65
66/** Os passos a despachar agora: #Auto, sem condução, com fase do ciclo e turno ainda não despachado. */
67export function paraDespachar(passos: readonly SentinelaPasso[], feitos: readonly string[]): SentinelaPasso[] {
68  return passos.filter(p => {
69    const chave = chaveDoDespacho(p)
70    return chave !== null && !p.conduzida && p.modo === 'auto' && ID_DE_THREAD.test(p.thread) && FASES.includes(p.fase as string)
71      && !feitos.includes(chave)
72  })
73}
74
75/** Os passos a levar ao `retry plan` agora: #Auto, sem condução e bloqueio ainda não consultado. */
76export function paraRetomar(passos: readonly SentinelaPasso[], feitos: readonly string[]): SentinelaPasso[] {
77  return passos.filter(p => {
78    const chave = chaveDaRetomada(p)
79    return chave !== null && !p.conduzida && p.modo === 'auto' && ID_DE_THREAD.test(p.thread) && !feitos.includes(chave)
80  })
81}
82
83/** Ninguém conduz a thread: o `ork conducao status --json` (pela ponte) diz `conducao: null`. */
84export function conducaoLivre(status: unknown): boolean {
85  return !!status && typeof status === 'object' && 'conducao' in status && (status as { conducao: unknown }).conducao === null
86}
87
88/** O retrato da thread tem pedido ao dono ainda sem resposta (seção `hitl` do `ork.maestro-snapshot/v1`). */
89export function pedidoAbertoNoRetrato(retrato: unknown, thread: string): boolean {
90  const itens = (retrato as { sections?: { hitl?: { items?: unknown } } } | null)?.sections?.hitl?.items
91  if (!Array.isArray(itens)) return false
92  return itens.some(bruto => {
93    const i = bruto as { status?: unknown; refs?: unknown; facts?: { target?: unknown } } | null
94    if (!i || !PEDIDO_ABERTO.includes(String(i.status)) || i.facts?.target === 'decidido') return false
95    return Array.isArray(i.refs) && i.refs.some(r => (r as { kind?: unknown; id?: unknown })?.kind === 'thread' && (r as { id?: unknown }).id === thread)
96  })
97}
98
99/** O render do prompt da fase com o pedido guardado: sem `--pedido`, o núcleo usa o da criação da thread. */
100export function argvDoRender(thread: string, fase: string): string[] {
101  return ['ork', 'prompt', 'render', exigirThread(thread), exigirFase(fase)]
102}
103
104/** O prompt do render; sem a linha do pedido guardado ele seria o texto de exemplo, e não serve. */
105export function promptDoRender(r: Saida): { prompt: string } | { motivo: string } {
106  if (r.exitCode !== 0) return { motivo: `o render do prompt saiu ${r.exitCode}${ultimaLinha(r.stderr) ? `: ${ultimaLinha(r.stderr)}` : ''}` }
107  if (!r.stdout.split('\n').some(l => l.startsWith(MARCA_DO_PEDIDO))) {
108    return { motivo: 'a thread não tem o pedido guardado na criação (ork thread new --pedido): o render cairia no texto de exemplo' }
109  }
110  const prompt = r.stdout.trimEnd()
111  if (prompt.length > TETO_DO_PROMPT) return { motivo: `o prompt tem ${prompt.length} caracteres, acima do teto de ${TETO_DO_PROMPT}` }
112  return { prompt }
113}
114
115/** O despacho pelo núcleo, com o canal da Sentinela e a correlação que a marca no ledger (D3). */
116export function argvDoDespacho(thread: string, fase: string, prompt: string, sessao: string): string[] {
117  if (typeof prompt !== 'string' || prompt.trim() === '') throw new Error('ork-sentinela: prompt vazio')
118  if (typeof sessao !== 'string' || !SESSAO.test(sessao)) throw new Error('ork-sentinela: id de sessao invalido')
119  return ['ork', 'phase', 'run', exigirThread(thread), exigirFase(fase), '--prompt', prompt, '--canal', CANAL, '--correlacao', `sentinela:${sessao}`]
120}
121
122export type ResultadoDoDespacho = { resultado: 'despachada' | 'repetida' | 'ocupada' | 'falhou'; detalhe: string }
123
124/**
125 * O que o `ork phase run` disse. Saída 3: outro condutor conduz a thread, ou o projeto está sem vaga. Saída 1 com
126 * `HITL ocupado`: outro despacho tomou a trava do kernel no mesmo instante. Pedido repetido: a sessão de quem já
127 * conduz atende. Nenhum desses abre sessão nova, e nenhum é repetido pela Sentinela.
128 */
129export function lerDespacho(r: Saida): ResultadoDoDespacho {
130  if (r.exitCode === 0 && /Pedido ja em andamento|Request already in progress/.test(r.stdout)) {
131    return { resultado: 'repetida', detalhe: primeiraLinha(r.stdout) }
132  }
133  if (r.exitCode === 0) {
134    const sessao = /sessionId\s*:\s*([0-9a-f-]{8,})/.exec(r.stdout)?.[1]
135    return { resultado: 'despachada', detalhe: sessao ? `sessão ${sessao.slice(0, 8)}` : primeiraLinha(r.stdout) }
136  }
137  if (r.exitCode === 3) return { resultado: 'ocupada', detalhe: primeiraLinha(r.stdout) || ultimaLinha(r.stderr) }
138  if (r.exitCode === 1 && /HITL ocupado/.test(r.stderr)) return { resultado: 'ocupada', detalhe: 'outro despacho tomou a vez no mesmo instante' }
139  return { resultado: 'falhou', detalhe: `o despacho saiu ${r.exitCode}${ultimaLinha(r.stderr) ? `: ${ultimaLinha(r.stderr)}` : ''}` }
140}
141
142/** O plano do retry declara a retomada automática, sem bloqueio: só ela a Sentinela executa. */
143export function retomadaAutomatica(plano: unknown): boolean {
144  const p = plano as { automatica?: unknown; bloqueio?: unknown } | null
145  return !!p && p.automatica === true && (p.bloqueio === null || p.bloqueio === undefined)
146}
147
148/** A retomada pelo núcleo, sem `--por`: no #Auto o retry automático não pede autorização. */
149export function argvDaRetomada(thread: string): string[] {
150  return ['ork', 'retry', 'run', exigirThread(thread)]
151}
152
153export function lerRetomada(r: Saida): { resultado: 'retomada' | 'recusada'; detalhe: string } {
154  return r.exitCode === 0 ? { resultado: 'retomada', detalhe: primeiraLinha(r.stdout) }
155    : { resultado: 'recusada', detalhe: `o retry saiu ${r.exitCode}${ultimaLinha(r.stderr || r.stdout) ? `: ${ultimaLinha(r.stderr || r.stdout)}` : ''}` }
156}
157
hooks/alertas.ts 86 lines
1// Os avisos da Sentinela (RM-059, fatia F6, T6.3). Módulo puro: do `ork pulse --json` (pela ponte), o que só o dono
2// resolve e o que está parado no condutor fora do que o condutor externo ou a própria Sentinela fazem sozinhos, cada um
3// com o próximo passo que o próprio núcleo dá. Em #Auto, nunca o comando de pedir gate: o núcleo o recusa no modo sem
4// pausa humana (D7). O que vira aviso, e uma vez só por texto, sai daqui; as chamadas ao `$` ficam no register.ts.
5import type { SentinelaPasso } from '../types'
6
7export const CONTRATO_DO_PULSE = 'ork.pulse/v1'
8/** O pulse lê a fábrica inteira (e a forja): a cada 5 min, como resumo, e não a cada ledger que muda. */
9export const PERIODO_DO_PULSE_MS = 5 * 60 * 1000
10/** Numa rodada, no máximo estes avisos novos viram notificação; o resto fica para a seguinte. */
11export const TETO_DE_AVISOS_POR_RODADA = 5
12export const TITULO = 'Sentinela'
13/** Os casos parados no condutor que alguém faz sozinho: o condutor externo publica e mergeia; a Sentinela despacha. */
14export const CASOS_DE_OUTRO: readonly string[] = ['sem-push', 'pr-verde', 'fase-seguinte', 'check-sem-veredito']
15/** Os motivos que o condutor externo também não leva ao dono: a política do núcleo já reexecuta sozinha. */
16export const MOTIVOS_SEM_AVISO: readonly string[] = ['verify.timeout']
17const TETO_DO_TEXTO = 300
18const PEDIR_GATE = /`?ork gate request[^`;\n]*`?/g
19
20export type Aviso = { chave: string; thread: string | null; texto: string }
21
22function limpar(texto: string): string {
23  return texto.replace(/\s+/g, ' ').trim().slice(0, TETO_DO_TEXTO)
24}
25
26/** Em #Auto o núcleo recusa o pedido de gate: o comando sai do texto, e o aviso diz por quê (D7). */
27export function semPedirGate(texto: string, modo: string | null): string {
28  if (modo !== 'auto' || !/ork gate request/.test(texto)) return texto
29  const sem = texto.replace(PEDIR_GATE, '').replace(/\s+([;,.])/g, '$1').replace(/[:;,]\s*$/, '').replace(/\s+/g, ' ').trim()
30  return `${sem}${sem ? ' ' : ''}(em #Auto o núcleo não abre gate; resolva pela ação da thread)`
31}
32
33/**
34 * Os avisos de um pulse: o que precisa do dono agora, com o comando de resposta do núcleo (ou a recomendação), e o
35 * que está parado no condutor fora dos casos que alguém faz sozinho, com o próximo passo do núcleo. Thread que alguém
36 * conduz agora não vira aviso (é trabalho em curso, como no condutor externo). Pulse fora do contrato é erro.
37 */
38export function avisosDoPulse(pulse: unknown, passos: readonly SentinelaPasso[]): Aviso[] {
39  const p = pulse as { contrato?: unknown; precisaDeHumanoAgora?: unknown; paradoNoCondutor?: unknown } | null
40  if (!p || typeof p !== 'object' || p.contrato !== CONTRATO_DO_PULSE) throw new Error(`o pulse nao e o contrato ${CONTRATO_DO_PULSE}`)
41  const modo = new Map(passos.map(x => [x.thread, x.modo]))
42  const conduzidas = new Set(passos.filter(x => x.conduzida).map(x => x.thread))
43  const avisos: Aviso[] = []
44  for (const bruto of Array.isArray(p.precisaDeHumanoAgora) ? p.precisaDeHumanoAgora : []) {
45    const i = (bruto ?? {}) as { thread?: unknown; motivo?: unknown; comandoResposta?: unknown; recomendacao?: unknown; pergunta?: unknown }
46    const thread = typeof i.thread === 'string' && i.thread ? i.thread : null
47    const motivo = typeof i.motivo === 'string' && i.motivo ? i.motivo : 'pendente'
48    if (MOTIVOS_SEM_AVISO.includes(motivo) || (thread && conduzidas.has(thread))) continue
49    const comando = semPedirGate(typeof i.comandoResposta === 'string' ? i.comandoResposta.trim() : '', thread ? modo.get(thread) ?? null : null)
50    const reserva = typeof i.recomendacao === 'string' && i.recomendacao.trim() ? i.recomendacao : typeof i.pergunta === 'string' ? i.pergunta : ''
51    const passo = comando || reserva || 'veja o ork pulse'
52    avisos.push({ chave: `dono:${thread ?? '-'}:${motivo}`, thread, texto: limpar(`${thread ?? 'fábrica'} (${motivo}): ${passo}`) })
53  }
54  for (const bruto of Array.isArray(p.paradoNoCondutor) ? p.paradoNoCondutor : []) {
55    const x = (bruto ?? {}) as { thread?: unknown; caso?: unknown; proximoPasso?: unknown }
56    if (typeof x.thread !== 'string' || !x.thread || typeof x.caso !== 'string' || CASOS_DE_OUTRO.includes(x.caso) || conduzidas.has(x.thread)) continue
57    const passo = semPedirGate(typeof x.proximoPasso === 'string' ? x.proximoPasso : '', modo.get(x.thread) ?? null)
58    avisos.push({ chave: `parado:${x.thread}:${x.caso}`, thread: x.thread, texto: limpar(`${x.thread} parado no condutor (${x.caso}): ${passo || 'veja o ork pulse'}`) })
59  }
60  return avisos
61}
62
63/** A marca de um aviso no estado: a chave e o texto. Texto novo é aviso novo, como no condutor externo. */
64export function marcaDoAviso(a: Aviso): string {
65  return `${a.chave}|${a.texto}`
66}
67
68/** Os avisos que ainda não foram mostrados ao dono, até o teto da rodada. */
69export function avisosNovos(avisos: readonly Aviso[], avisados: readonly string[], teto = TETO_DE_AVISOS_POR_RODADA): Aviso[] {
70  const vistos = new Set(avisados)
71  const saida: Aviso[] = []
72  for (const a of avisos) {
73    const marca = marcaDoAviso(a)
74    if (vistos.has(marca)) continue
75    vistos.add(marca)
76    saida.push(a)
77    if (saida.length >= teto) break
78  }
79  return saida
80}
81
82/** O aviso de um despacho que a Sentinela não conseguiu fazer: vai ao dono uma vez por turno. */
83export function avisoDoDespacho(p: SentinelaPasso, motivo: string): Aviso {
84  return { chave: `despacho:${p.thread}:${p.fase}:${p.desdeEm}`, thread: p.thread, texto: limpar(`${p.thread}: a Sentinela não despachou o ${p.fase} (${motivo})`) }
85}
86
hooks/vigia.ts 187 lines
1// A leitura da condução pela Sentinela (RM-059, fatia F6, T6.1). Módulo puro: o argv do status e da batida, a
2// leitura do contrato `ork.conducao-sentinela/v1`, a thread de um ledger que mudou, o diário, as chaves guardadas e
3// a linha da faixa. As chamadas ao `$` ficam no register.ts.
4import type { SentinelaEntrada, SentinelaPasso, SentinelaPresenca, SentinelaResumo } from '../types'
5
6export const MOD = 'ork-sentinela'
7export const CONTRATO = 'ork.conducao-sentinela/v1'
8/** A batida da presença e a rodada de reserva: a mesma cadência do núcleo (`BATIDA_DA_SENTINELA_MS`). */
9export const BATIDA_MS = 60000
10/** Um ledger que muda acorda a rodada depois deste intervalo, para juntar as linhas do mesmo evento. */
11export const ESPERA_DO_LEDGER_MS = 1500
12/** O status lê todas as threads abertas: até 20 s, com folga sobre o que se mede (1 a 4 s). */
13export const PRAZO_DO_STATUS_MS = 20000
14export const PRAZO_DA_BATIDA_MS = 10000
15export const TETO_DO_DIARIO = 50
16export const TETO_DE_CHAVES = 200
17export const ACOES: readonly string[] = ['despachar', 'retomar', 'avisar', 'nada']
18export const FASES: readonly string[] = ['GOAL', 'PLAN', 'GO', 'CHECK', 'SHIP', 'MASTER']
19const ID_DE_THREAD = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/
20const SESSAO = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/
21const LEDGER = /\/\.orkastery\/threads\/([a-zA-Z0-9][a-zA-Z0-9._-]{0,79})\/ledger\.jsonl$/
22
23export function idDeThreadValido(t: unknown): t is string {
24  return typeof t === 'string' && ID_DE_THREAD.test(t)
25}
26
27export function sessaoValida(s: unknown): s is string {
28  return typeof s === 'string' && SESSAO.test(s)
29}
30
31function exigirSessao(sessao: unknown): string {
32  if (!sessaoValida(sessao)) throw new Error('ork-sentinela: id de sessao invalido')
33  return sessao
34}
35
36/** O status do núcleo: a presença desta máquina e o passo do condutor em cada thread aberta. */
37export function argvDoStatus(): string[] {
38  return ['ork', 'conducao', 'sentinela', 'status', '--json']
39}
40
41/** A batida da presença desta sessão. */
42export function argvDaBatida(sessao: string): string[] {
43  return ['ork', 'conducao', 'sentinela', 'bater', '--sessao', exigirSessao(sessao), '--json']
44}
45
46/** Tira a presença desta sessão (pausa e fim da sessão). */
47export function argvDoDesligar(sessao: string): string[] {
48  return ['ork', 'conducao', 'sentinela', 'desligar', '--sessao', exigirSessao(sessao), '--json']
49}
50
51type Saida = { exitCode: number; stdout: string; stderr: string }
52
53function ultimaLinha(texto: unknown): string {
54  return String(texto ?? '').trim().split('\n').filter(l => l.trim() !== '').slice(-1)[0]?.trim().slice(0, 200) ?? ''
55}
56
57function texto(v: unknown): string | null {
58  return typeof v === 'string' ? v : null
59}
60
61/** Um passo do contrato, ou `null` quando ele não tem a forma: passo torto nunca vira despacho. */
62export function lerPasso(bruto: unknown): SentinelaPasso | null {
63  const p = bruto as Record<string, unknown> | null
64  if (!p || typeof p !== 'object' || !idDeThreadValido(p.thread) || typeof p.acao !== 'string' || !ACOES.includes(p.acao)) return null
65  const fase = texto(p.fase)
66  if (p.acao === 'despachar' && (fase === null || !FASES.includes(fase))) return null
67  const b = p.bloqueio as { motivo?: unknown; em?: unknown } | null | undefined
68  return {
69    thread: p.thread, modo: texto(p.modo) ?? '', faseAtual: texto(p.faseAtual) ?? '', conduzida: p.conduzida === true,
70    acao: p.acao as SentinelaPasso['acao'], fase: fase && FASES.includes(fase) ? fase : null,
71    caso: texto(p.caso), proximoPasso: texto(p.proximoPasso), porque: texto(p.porque) ?? '', desdeEm: texto(p.desdeEm),
72    bloqueio: b && typeof b.motivo === 'string' && typeof b.em === 'string' ? { motivo: b.motivo, em: b.em } : null,
73    pedidoGuardado: p.pedidoGuardado === true,
74  }
75}
76
77/** A leitura do `status --json`: recusa o que não for o contrato; a presença diz de quem é, frente a esta sessão. */
78export function lerStatus(r: Saida, sessao: string | null): { presenca: SentinelaPresenca; passos: SentinelaPasso[] } {
79  if (r.exitCode !== 0) throw new Error(`o status do nucleo saiu ${r.exitCode}${ultimaLinha(r.stderr) ? `: ${ultimaLinha(r.stderr)}` : ''}`)
80  let s: { contrato?: unknown; presenca?: { ativa?: unknown; sessao?: unknown } | null; threads?: unknown }
81  try { s = JSON.parse(r.stdout) } catch { throw new Error('o status do nucleo nao e JSON') }
82  if (!s || s.contrato !== CONTRATO || !Array.isArray(s.threads)) throw new Error(`o status do nucleo nao e o contrato ${CONTRATO}`)
83  const p = s.presenca
84  const presenca: SentinelaPresenca = !p || p.ativa !== true ? 'nenhuma' : p.sessao === sessao ? 'minha' : 'outra'
85  const passos = s.threads.map(lerPasso).filter((x): x is SentinelaPasso => x !== null)
86  return { presenca, passos }
87}
88
89/** A batida: `minha` com saída 0 e `ok`; `outra` com saída 3 (outra sessão viva nesta máquina); o resto é erro. */
90export function lerBatida(r: Saida): SentinelaPresenca {
91  let ok: unknown
92  try { ok = (JSON.parse(r.stdout) as { ok?: unknown }).ok } catch { ok = undefined }
93  if (r.exitCode === 0 && ok === true) return 'minha'
94  if (r.exitCode === 3 && ok === false) return 'outra'
95  throw new Error(`a batida da presenca saiu ${r.exitCode}${ultimaLinha(r.stderr) ? `: ${ultimaLinha(r.stderr)}` : ''}`)
96}
97
98/** A thread de um ledger que mudou (`.../.orkastery/threads/<id>/ledger.jsonl`), ou `null`. */
99export function threadDoLedger(caminho: unknown): string | null {
100  const m = typeof caminho === 'string' ? LEDGER.exec(caminho) : null
101  return m ? (m[1] as string) : null
102}
103
104/** Acrescenta no fim e corta os mais velhos acima do teto. */
105export function acrescentar<T>(lista: readonly T[] | undefined, item: T, teto: number): T[] {
106  return [...(lista ?? []), item].slice(-teto)
107}
108
109/** Guarda uma chave sem repetir, com teto: as mais velhas saem primeiro. */
110export function guardarChave(chaves: readonly string[] | undefined, chave: string, teto = TETO_DE_CHAVES): string[] {
111  const resto = (chaves ?? []).filter(c => c !== chave)
112  return [...resto, chave].slice(-teto)
113}
114
115export function entrada(em: number, tipo: SentinelaEntrada['tipo'], thread: string | null, texto: string): SentinelaEntrada {
116  return { em, tipo, thread, texto: texto.replace(/\s+/g, ' ').trim().slice(0, 300) }
117}
118
119function minutosDesde(iso: string | null, agora: number): number | null {
120  const t = iso ? Date.parse(iso) : NaN
121  return Number.isFinite(t) ? Math.max(0, Math.floor((agora - t) / 60000)) : null
122}
123
124function contar(n: number, um: string, varios: string): string {
125  return `${n} ${n === 1 ? um : varios}`
126}
127
128/**
129 * Há quanto tempo, em degraus (mais de 15, 30 ou 45 min, depois horas inteiras): a linha da faixa não muda a cada
130 * minuto, e o registro do mostrado não ganha uma linha nova por minuto. Antes de 15 min, nada.
131 */
132export function haQuantoTempo(minutos: number): string {
133  if (minutos < 15) return ''
134  if (minutos < 60) return `há mais de ${Math.floor(minutos / 15) * 15} min`
135  return `há mais de ${Math.floor(minutos / 60)} h`
136}
137
138/**
139 * A linha da faixa: quantas threads andam, quantas estão paradas e há quanto tempo a mais antiga, quantas estão na vez
140 * da Sentinela e quantos avisos do pulse esperam o dono. Pausada, ou com outra sessão conduzindo, diz isso. Sem nada a
141 * dizer, a linha sai.
142 */
143export function textoDaFaixa(r: SentinelaResumo, pausada: boolean, agora: number, avisos = 0): string | undefined {
144  if (pausada) return 'pausada; o condutor externo conduz'
145  if (r.presenca === 'outra') return 'outra sessão conduz nesta máquina; esta só olha'
146  if (r.lidoEm === null) return r.erro ? `sem leitura do núcleo (${r.erro})` : undefined
147  const andando = r.passos.filter(p => p.conduzida).length
148  const paradas = r.passos.filter(p => !p.conduzida && p.acao === 'avisar')
149  const naVez = r.passos.filter(p => !p.conduzida && (p.acao === 'despachar' || p.acao === 'retomar')).length
150  if (andando === 0 && paradas.length === 0 && naVez === 0 && avisos === 0) return r.erro ? `sem leitura do núcleo (${r.erro})` : undefined
151  const partes = [contar(andando, 'thread andando', 'threads andando')]
152  if (paradas.length > 0) {
153    const ha = haQuantoTempo(Math.max(...paradas.map(p => minutosDesde(p.desdeEm, agora) ?? 0)))
154    partes.push(`${contar(paradas.length, 'parada', 'paradas')}${ha ? ` ${ha}` : ''}`)
155  }
156  if (naVez > 0) partes.push(`${naVez} na vez da Sentinela`)
157  if (avisos > 0) partes.push(contar(avisos, 'aviso para você', 'avisos para você'))
158  return partes.join(', ')
159}
160
161const ROTULO: Record<string, string> = { despachar: 'despachar', retomar: 'retomar', avisar: 'parada', nada: 'nada' }
162
163/** O `/sentinela`: o estado, o passo de cada thread e o diário mais recente. */
164export function textoDoDiario(r: SentinelaResumo, diario: readonly SentinelaEntrada[], pausada: boolean, agora: number): string {
165  const linhas: string[] = []
166  linhas.push(pausada ? 'Sentinela pausada: o condutor externo conduz. Para voltar: /sentinela retomar.'
167    : r.presenca === 'outra' ? 'Outra sessão conduz pela Sentinela nesta máquina; esta só olha.'
168    : r.presenca === 'minha' ? 'Sentinela conduzindo nesta máquina: o condutor externo cede o despacho e o retry.'
169    : 'Sentinela ligada, sem a presença confirmada pelo núcleo ainda.')
170  if (r.erro) linhas.push(`Última leitura do núcleo falhou: ${r.erro}`)
171  if (r.passos.length > 0) {
172    linhas.push('', 'Threads abertas:')
173    for (const p of r.passos) {
174      const acao = p.conduzida ? 'andando' : p.acao === 'despachar' ? `despachar ${p.fase}` : ROTULO[p.acao] ?? p.acao
175      const desde = !p.conduzida && p.acao === 'avisar' ? minutosDesde(p.desdeEm, agora) : null
176      linhas.push(`  ${p.thread}: ${acao}${desde ? ` há ${desde} min` : ''}${p.proximoPasso && p.acao === 'avisar' ? `; ${p.proximoPasso}` : ''}`)
177    }
178  }
179  const recentes = diario.slice(-10)
180  linhas.push('', recentes.length ? 'Diário (mais recente por último):' : 'Diário vazio: nada despachado nesta sessão.')
181  for (const e of recentes) {
182    const ha = Math.max(0, Math.floor((agora - e.em) / 60000))
183    linhas.push(`  ${ha === 0 ? 'agora' : `há ${ha} min`} ${e.tipo}${e.thread ? ` ${e.thread}` : ''}: ${e.texto}`)
184  }
185  return linhas.join('\n')
186}
187
types/index.d.ts 66 lines
1// Contrato do estado da Sentinela do Condutor (`ork-sentinela`, RM-059, fatia F6). O motor exige que cada chave de
2// `$.state` do mod esteja declarada aqui, em `PluginState`. A Sentinela não acrescenta noun ao `$`: lê o núcleo pelo
3// `$.ork` da `ork-ponte` (que o plugin.json lista em `dependencies`) e pelo `ork`, e despacha só pelo `ork phase run`.
4// Sem import nem referência.
5
6/** O que o núcleo diz que o condutor faz numa thread (contrato `ork.conducao-sentinela/v1`). */
7export type SentinelaAcao = 'despachar' | 'retomar' | 'avisar' | 'nada'
8
9/** O passo do condutor numa thread aberta, como o `ork conducao sentinela status --json` o devolve. */
10export type SentinelaPasso = {
11  thread: string
12  modo: string
13  faseAtual: string
14  conduzida: boolean
15  acao: SentinelaAcao
16  /** A fase do despacho, só com `acao: despachar`. */
17  fase: string | null
18  caso: string | null
19  proximoPasso: string | null
20  porque: string
21  /** O fim do turno (ou o bloqueio): a chave do despacho. */
22  desdeEm: string | null
23  bloqueio: { motivo: string; em: string } | null
24  pedidoGuardado: boolean
25}
26
27/** De quem é a presença da Sentinela nesta máquina, pela última batida. */
28export type SentinelaPresenca = 'minha' | 'outra' | 'nenhuma' | 'desconhecida'
29
30/** A última leitura do status do núcleo, para a faixa e o `/sentinela`. */
31export type SentinelaResumo = {
32  /** Quando a leitura terminou, em ms desde a época; null antes da primeira. */
33  lidoEm: number | null
34  presenca: SentinelaPresenca
35  passos: SentinelaPasso[]
36  /** Por que a última leitura falhou, quando falhou. */
37  erro: string | null
38}
39
40/** Uma linha do diário do `/sentinela`. */
41export type SentinelaEntrada = {
42  em: number
43  tipo: 'despacho' | 'cedeu' | 'retomada' | 'aviso' | 'erro' | 'pausa'
44  thread: string | null
45  texto: string
46}
47
48declare module 'claude-code' {
49  interface PluginState {
50    'ork-sentinela': {
51      /** O diário do que a Sentinela fez, do mais velho ao mais novo, com teto. */
52      diario: SentinelaEntrada[]
53      /** O dono pausou a Sentinela pelo `/sentinela pausar`: nada é despachado e a presença sai. */
54      pausada: boolean
55      /** As chaves `thread|fase|fim do turno` já despachadas: o mesmo turno nunca sai duas vezes. */
56      despachados: string[]
57      /** As chaves `thread|bloqueio` já levadas ao `ork retry plan`. */
58      retomados: string[]
59      /** As chaves dos avisos já mostrados, com o texto: o mesmo aviso não se repete. */
60      avisados: string[]
61      /** A última leitura do status do núcleo. */
62      resumo: SentinelaResumo
63    }
64  }
65}
66