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…

English · Português

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