Freio de Lote do Orkastery no Claude Code: na sessão de fase, acha as claims que o lote de ferramentas mexeu, pede ao núcleo a conferência delas no sandbox do…

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 286 lines1// O Freio de Lote (RM-059, fatia F2). Na sessao de fase, acha as claims que o lote de ferramentas mexeu, pede
2// ao nucleo a conferencia delas no sandbox do verify (`ork claims conferir`) e devolve o que quebrou no lote
3// seguinte. Nunca para o laco agentico, nunca roda comando de claim, nunca grava estado de claim e nunca fala
4// pelo dono. A logica fica nos modulos puros ao lado; aqui so as chamadas ao `$`, sempre escritas como
5// `x.noun.metodo(...)`, como o validador exige.
6import type { EngineInterface, Register } from 'claude-code'
7import type { FreioCarga, FreioFoto, FreioVigia } from '../types'
8import {
9 argsDaFerramenta, cargaVazia, claimsDoNucleo, claimsVelhas, FERRAMENTA_DAS_CLAIMS, FERRAMENTA_DO_PLANO, jsonDoMcp,
10 maiorNumeroDeClaim, planoVelho, SERVIDOR, statusNoRetrato, textoDoPlano, threadAtiva, touchPathsDoPlano,
11} from './carga'
12import {
13 aplicarLote, ARGV_DO_STATUS, assinatura, caminhosDoLote, diferenca, juntar, lerStatus, mexeuNasClaims, mexeuNoPlano,
14 soLeitura, TETO_DE_STAT, TETO_DO_STATUS_MS, textoDoFora, tomarFora, vigiaVazia,
15} from './lote'
16import type { Chamada } from './lote'
17import {
18 acumular, argvDaConferencia, ESPERA_DO_FIM_MS, fecharRodada, indisponivelSemRodada, iniciarRodada, lerConferencia, limitarTexto,
19 MOTIVO_PROCESSO_ABERTO, precisaDeLaco, TETO_POR_RODADA, textoDaEntrega, textoDoStatus, tomarEntrega, VIGIA_DA_CONFERENCIA_MS,
20} from './conferencia'
21import type { Leitura } from './conferencia'
22
23const CARGA = { plugin: 'ork-freio', key: 'carga' } as const
24const VIGIA = { plugin: 'ork-freio', key: 'vigia' } as const
25const TENTATIVAS_DE_ESCRITA = 5
26/**
27 * O laco de conferencia desta carga do modulo esta rodando (D10). Fica no modulo, nao no `$.state`: uma troca do
28 * worker mata o laco junto com a marca, e a rodada que ficou aberta no estado e retomada pelo laco seguinte.
29 */
30let rodando = false
31/**
32 * O processo da ultima rodada ainda nao fechou (o vigia venceu e o `return` nao chegou a ele, como num processo que
33 * nao escreve nada). Enquanto ele existir, nenhuma rodada nova abre: duas conferencias nunca correm juntas.
34 */
35let fluxoAberto: Promise<unknown> | null = null
36
37/** A sessao de fase com thread, pelo contexto da ponte; qualquer outra sessao fica sem Freio (D10). */
38type ContextoDeFase = { thread: string; raiz: string }
39
40const mensagem = (erro: unknown) => (erro instanceof Error ? erro.message : String(erro)).slice(0, 300)
41
42async function contextoDeFase($: EngineInterface): Promise<ContextoDeFase | null> {
43 const ctx = await $.ork.contexto()
44 return ctx.papel === 'fase' && ctx.thread ? { thread: ctx.thread, raiz: ctx.raiz } : null
45}
46
47/** Grava a carga com `ifVersion`, aplicando `mudar` sobre a versao mais nova (como a ponte faz com a faixa). */
48async function atualizarCarga($: EngineInterface, thread: string, mudar: (atual: FreioCarga) => FreioCarga): Promise<FreioCarga> {
49 for (let i = 0; i < TENTATIVAS_DE_ESCRITA; i++) {
50 const lida = await $.state.get(CARGA)
51 const atual = lida.value && lida.value.thread === thread ? lida.value : cargaVazia(thread)
52 const nova = mudar(atual)
53 const escrita = await $.state.set(CARGA, nova, { ifVersion: lida.version })
54 if (escrita.isSet) return nova
55 }
56 throw new Error('ork-freio: a carga nao foi guardada')
57}
58
59/** Grava a vigia do mesmo jeito; `mudar` e pura e pode rodar de novo quando outra escrita passou na frente. */
60async function atualizarVigia<T>($: EngineInterface, thread: string, mudar: (atual: FreioVigia) => { vigia: FreioVigia; extra: T }): Promise<{ vigia: FreioVigia; extra: T }> {
61 for (let i = 0; i < TENTATIVAS_DE_ESCRITA; i++) {
62 const lida = await $.state.get(VIGIA)
63 const atual = lida.value && lida.value.thread === thread ? lida.value : vigiaVazia(thread)
64 const r = mudar(atual)
65 const escrita = await $.state.set(VIGIA, r.vigia, { ifVersion: lida.version })
66 if (escrita.isSet) return r
67 }
68 throw new Error('ork-freio: a vigia nao foi guardada')
69}
70
71/**
72 * A carga (T2.1): o estado da thread pelo retrato da ponte, as claims e o plano pelas duas ferramentas de
73 * leitura do nucleo (D2). Cada parte falha sozinha e fica para a proxima leitura.
74 */
75async function carregar($: EngineInterface, ctx: ContextoDeFase, partes: { retrato: boolean; claims: boolean; plano: boolean }): Promise<FreioCarga> {
76 const agora = await $.clock.now()
77 const erros: string[] = []
78 let status: string | null | undefined
79 let claims: FreioCarga['claims'] | undefined
80 let maiorClaim = 0
81 let touchPaths: string[] | null = null
82 let planoLido = false
83 if (partes.retrato) {
84 try {
85 status = statusNoRetrato((await $.ork.retrato({ thread: ctx.thread })).retrato, ctx.thread)
86 } catch (erro) { erros.push(`retrato: ${mensagem(erro)}`) }
87 }
88 if (partes.claims) {
89 try {
90 const r = await $.mcp.call(SERVIDOR, FERRAMENTA_DAS_CLAIMS, argsDaFerramenta(FERRAMENTA_DAS_CLAIMS, ctx.thread))
91 const lista = jsonDoMcp(r, FERRAMENTA_DAS_CLAIMS)
92 claims = claimsDoNucleo(lista)
93 maiorClaim = maiorNumeroDeClaim(lista)
94 } catch (erro) { erros.push(`claims: ${mensagem(erro)}`) }
95 }
96 if (partes.plano) {
97 try {
98 const r = await $.mcp.call(SERVIDOR, FERRAMENTA_DO_PLANO, argsDaFerramenta(FERRAMENTA_DO_PLANO, ctx.thread))
99 touchPaths = touchPathsDoPlano(textoDoPlano(jsonDoMcp(r, FERRAMENTA_DO_PLANO)))
100 planoLido = true
101 } catch (erro) { erros.push(`plano: ${mensagem(erro)}`) }
102 }
103 // Uma leitura mais velha nunca sobrescreve outra mais nova que gravou antes dela (duas cargas ao mesmo tempo).
104 return atualizarCarga($, ctx.thread, atual => ({
105 ...atual,
106 ...(status !== undefined ? { statusDaThread: status } : {}),
107 ...(claims !== undefined && agora >= atual.claimsEm ? { claims, claimsEm: agora, maiorClaim } : {}),
108 ...(planoLido && agora >= atual.planoEm ? { touchPaths, planoLido: true, planoEm: agora } : {}),
109 erros,
110 }))
111}
112
113/** A foto do `git status` da worktree (D4), com o `stat` de cada caminho sujo ate o teto; `null` se o git falhar. */
114async function fotografar($: EngineInterface, raiz: string): Promise<FreioFoto | null> {
115 let r
116 try {
117 // Sem lock opcional: o `git status` do Freio nunca segura o `index.lock` que um commit do modelo precisa.
118 r = await $.process.run(ARGV_DO_STATUS, { cwd: raiz, timeoutMs: TETO_DO_STATUS_MS, env: { GIT_OPTIONAL_LOCKS: '0' } })
119 } catch { return null }
120 if (r.exitCode !== 0) return null
121 const lido = lerStatus(r.stdout)
122 const caminhos: Record<string, string> = {}
123 for (let i = 0; i < lido.entradas.length; i++) {
124 const entrada = lido.entradas[i] as { caminho: string; xy: string }
125 const comStat = i < TETO_DE_STAT
126 const stat = comStat ? await $.fs.stat(juntar(raiz, entrada.caminho)).catch(() => null) : null
127 caminhos[entrada.caminho] = assinatura(entrada.xy, stat, comStat)
128 }
129 return { head: lido.head, caminhos }
130}
131
132/** A primeira foto, guardada so quando ainda nao ha foto (o reload nao apaga o que o lote anterior viu). */
133async function primeiraFoto($: EngineInterface, ctx: ContextoDeFase): Promise<void> {
134 const foto = await fotografar($, ctx.raiz)
135 if (!foto) return
136 await atualizarVigia($, ctx.thread, v => ({ vigia: v.foto ? v : { ...v, foto }, extra: null }))
137}
138
139/**
140 * Uma rodada no nucleo (D6): `ork claims conferir` por `$.process.spawn`, na raiz da sessao, com o vigia do mod.
141 * O processo que nao abre e a saida fora do formato viram conferencia indisponivel (D7).
142 */
143async function rodarConferencia($: EngineInterface, ctx: ContextoDeFase, ids: readonly string[]): Promise<Leitura> {
144 let stdout = ''
145 let stderr = ''
146 let vigia: { cancel: () => void } | undefined
147 try {
148 const fluxo = $.process.spawn({ argv: argvDaConferencia(ctx.thread, ids), cwd: ctx.raiz })
149 const lido = (async () => {
150 for await (const pedaco of fluxo) {
151 if (pedaco.stream === 'stdout') stdout = acumular(stdout, pedaco.text, false)
152 else stderr = acumular(stderr, pedaco.text, true)
153 }
154 return fluxo.result
155 })()
156 lido.catch(() => undefined)
157 fluxoAberto = lido
158 void lido.catch(() => undefined).then(() => { if (fluxoAberto === lido) fluxoAberto = null })
159 const vencido = new Promise<'vencido'>(resolver => { vigia = $.clock.after(VIGIA_DA_CONFERENCIA_MS, () => resolver('vencido')) })
160 const fim = await Promise.race([lido, vencido])
161 if (fim === 'vencido') {
162 // Pede o fim do processo; a volta seguinte do laco espera ele fechar (uma espera so, com teto) antes de abrir
163 // rodada nova, e com ele aberto a conferencia fica indisponivel.
164 void fluxo.return(undefined as never).catch(() => undefined)
165 return { tipo: 'prazo' }
166 }
167 return lerConferencia(stdout, fim.code, stderr)
168 } catch (erro) {
169 return { tipo: 'indisponivel', motivo: `ork claims conferir nao rodou: ${mensagem(erro)}` }
170 } finally {
171 vigia?.cancel()
172 }
173}
174
175/** As claims ativas da carga e o maior numero ja dado, para a rodada nao levar claim retirada; `null` sem lista lida. */
176async function claimsAtivas($: EngineInterface, ctx: ContextoDeFase): Promise<{ ids: string[]; maior: number } | null> {
177 const carga = (await $.state.get(CARGA)).value
178 return carga && carga.thread === ctx.thread && carga.claims ? { ids: carga.claims.map(c => c.id), maior: carga.maiorClaim ?? 0 } : null
179}
180
181/**
182 * O laco de fundo (T2.3): uma rodada por vez, ate a fila acabar, a pausa chegar ou a conferencia faltar. A recusa
183 * de uma claim so rele as claims e segue com as outras; a conducao alheia pausa um minuto.
184 */
185async function conferirEmLaco($: EngineInterface, ctx: ContextoDeFase): Promise<void> {
186 if (rodando) return
187 rodando = true
188 try {
189 for (;;) {
190 if (fluxoAberto) {
191 const aberto = fluxoAberto
192 await Promise.race([aberto.catch(() => undefined), new Promise(resolver => { $.clock.after(ESPERA_DO_FIM_MS, () => resolver(undefined)) })])
193 if (fluxoAberto === aberto) {
194 const quando = await $.clock.now()
195 const parada = await atualizarVigia($, ctx.thread, v => ({ vigia: indisponivelSemRodada(v, MOTIVO_PROCESSO_ABERTO, quando), extra: null }))
196 $.ui.status(textoDoStatus(parada.vigia, quando))
197 break
198 }
199 }
200 const agora = await $.clock.now()
201 const ativas = await claimsAtivas($, ctx)
202 const { vigia } = await atualizarVigia($, ctx.thread, v => ({ vigia: iniciarRodada(v, agora, TETO_POR_RODADA, ativas), extra: null }))
203 if (!vigia.rodada) break
204 $.ui.status(textoDoStatus(vigia, agora))
205 const leitura = await rodarConferencia($, ctx, vigia.rodada.ids)
206 const depois = await $.clock.now()
207 const fechada = await atualizarVigia($, ctx.thread, v => ({ vigia: fecharRodada(v, leitura, depois), extra: null }))
208 $.ui.status(textoDoStatus(fechada.vigia, depois))
209 if (leitura.tipo === 'recusada') await carregar($, ctx, { retrato: false, claims: true, plano: false })
210 if (leitura.tipo === 'indisponivel' || leitura.tipo === 'ocupada') break
211 }
212 } finally {
213 rodando = false
214 }
215}
216
217/** Abre o laco fora do despacho do lote, por `$.clock.after`; o laco que ja roda pega a fila nova. */
218function pedirConferencia($: EngineInterface, ctx: ContextoDeFase): void {
219 if (rodando) return
220 $.clock.after(0, () => { void conferirEmLaco($, ctx).catch(() => undefined) })
221}
222
223export const register: Register = on => {
224 // A carga roda fora do inicio da sessao: o MCP pode ainda estar conectando, e o inicio nao espera por ela.
225 // O `session.start` dispara de novo num reload, e a carga refaz a leitura do mesmo jeito (D10).
226 on('session.start', async ($, e, next) => {
227 const iniciada = await next(e)
228 try {
229 const ctx = await contextoDeFase($)
230 if (ctx) {
231 $.clock.after(0, () => {
232 void (async () => {
233 await carregar($, ctx, { retrato: true, claims: true, plano: true }).catch(() => undefined)
234 await primeiraFoto($, ctx).catch(() => undefined)
235 })()
236 })
237 }
238 } catch { /* sem a ponte, o Freio fica dormente */ }
239 return iniciada
240 })
241
242 // O lote (T2.2 e T2.3): devolve o que a conferencia apurou desde o lote anterior, acha os arquivos tocados e as
243 // claims afetadas, pede a conferencia ao nucleo e avisa os `touch_paths`. Responde so com `additionalContext` e
244 // nunca para o laco: na sessao de fundo nao ha quem o retome. Falha deixa o lote passar. O lote de subagente
245 // (`agent_id`) conta os toques, mas a entrega e o aviso ficam para o lote do laco principal, que e quem os le.
246 on('classic.PostToolBatch', async ($, e, next) => {
247 const resultado = await next(e)
248 const ctx = await contextoDeFase($)
249 if (!ctx) return resultado
250 let carga = (await $.state.get(CARGA)).value
251 if (carga && carga.thread === ctx.thread && !threadAtiva(carga.statusDaThread)) return resultado
252 const chamadas: Chamada[] = Array.isArray(e.tool_calls) ? e.tool_calls : []
253 const doSubagente = typeof e.agent_id === 'string' && e.agent_id !== ''
254 const doLote = caminhosDoLote(chamadas, ctx.raiz)
255 const foto = soLeitura(chamadas) ? null : await fotografar($, ctx.raiz)
256 const mexeu = doLote.length > 0 || foto !== null
257 // O lote que mexeu nas claims ou no plano rele na hora; a lista velha so e relida quando ha caminho para casar.
258 const lida = (await $.state.get(VIGIA)).value
259 const anterior = lida && lida.thread === ctx.thread ? lida.foto : null
260 const previstos = doLote.length + (foto ? diferenca(anterior, foto).length : 0)
261 const nasClaims = mexeuNasClaims(chamadas)
262 const noPlano = mexeuNoPlano(chamadas)
263 if (previstos > 0 || nasClaims || noPlano) {
264 const agora = await $.clock.now()
265 const atual = carga && carga.thread === ctx.thread ? carga : cargaVazia(ctx.thread)
266 const relerClaims = nasClaims || (previstos > 0 && claimsVelhas(atual, agora, false))
267 const relerPlano = noPlano || (previstos > 0 && planoVelho(atual, agora, false))
268 if (relerClaims || relerPlano) carga = await carregar($, ctx, { retrato: false, claims: relerClaims, plano: relerPlano })
269 }
270 const lote = { foto, doLote, claims: carga?.thread === ctx.thread ? carga.claims : null, touchPaths: carga?.thread === ctx.thread ? carga.touchPaths : null }
271 const { vigia, extra } = await atualizarVigia($, ctx.thread, v => {
272 const aplicada = mexeu ? aplicarLote(v, lote).vigia : v
273 if (doSubagente) return { vigia: aplicada, extra: { entrega: null, fora: [] as string[] } }
274 const f = tomarFora(aplicada)
275 const t = tomarEntrega(f.vigia)
276 return { vigia: t.vigia, extra: { entrega: t.entrega, fora: f.fora } }
277 })
278 // A rodada que ficou aberta no estado (o laco morreu com o worker) e retomada como a fila.
279 if (precisaDeLaco(vigia)) pedirConferencia($, ctx)
280 $.ui.status(textoDoStatus(vigia, await $.clock.now()))
281 const partes = [extra.entrega ? textoDaEntrega(extra.entrega) : '', textoDoFora(extra.fora)].filter(t => t !== '')
282 const texto = limitarTexto(partes.join('\n'))
283 return texto ? { ...resultado, additionalContext: [...(resultado.additionalContext ?? []), texto] } : resultado
284 }).catch(($, e, next) => next(e))
285}
286hooks/carga.ts 161 lines1// A carga do Freio de Lote (RM-059, F2, T2.1): o que ele le do nucleo para saber quais claims vigiar.
2// Modulo puro: interpreta o que as ferramentas de leitura do servidor `orkastery` devolvem e o retrato
3// da ponte. As chamadas ao `$` ficam no register.ts; nada aqui le `.orkastery/` do disco (D2).
4import type { FreioCarga, FreioClaim } from '../types'
5
6export const SERVIDOR = 'orkastery'
7/**
8 * A lista fechada de ferramentas MCP do Freio (D2): as duas de leitura. O `$.mcp.call` nao pede permissao
9 * ao dono, entao o teste do nucleo confere pela AST que o mod so chama estas.
10 */
11export const FERRAMENTA_DAS_CLAIMS = 'ork_claims_list'
12export const FERRAMENTA_DO_PLANO = 'ork_artifact_read'
13export const FERRAMENTAS_DO_FREIO: readonly string[] = [FERRAMENTA_DAS_CLAIMS, FERRAMENTA_DO_PLANO]
14/** Claims com mais idade que isto sao relidas no lote que precisa delas. */
15export const IDADE_DAS_CLAIMS_MS = 60000
16/** O plano com mais idade que isto e relido no lote que precisa dele. */
17export const IDADE_DO_PLANO_MS = 300000
18
19const ID_DE_CLAIM = /^C\d+$/
20const ID_DE_THREAD = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/
21/** O rotulo de `touch_paths` de uma tarefa nos formatos em uso nos planos; o grupo 1 e o resto da linha. */
22const ROTULO_DE_TOUCH_PATHS = /^\s*(?:[-*+]\s+)?(?:\*\*touch_paths:?\*\*\s*:?|`touch_paths`\s*:|touch_paths\s*:)\s*(.*)$/i
23/** Linha que abre outro item de lista, cabecalho ou cerca de codigo (a mesma regra do verify da tarefa no nucleo). */
24const ABRE_BLOCO = /^\s*(?:[-*+]\s|\d+[.)]\s|#{1,6}\s|```|~~~)/
25const TRECHO = /`([^`]+)`/g
26
27export class ErroDeCarga extends Error {}
28
29/** Os argumentos de cada ferramenta da lista fechada para a thread do despacho. */
30export function argsDaFerramenta(ferramenta: string, thread: string): Record<string, unknown> {
31 if (!ID_DE_THREAD.test(thread)) throw new ErroDeCarga('ork-freio: id de thread invalido')
32 if (ferramenta === FERRAMENTA_DAS_CLAIMS) return { threadId: thread }
33 if (ferramenta === FERRAMENTA_DO_PLANO) return { threadId: thread, tipo: 'plan' }
34 throw new ErroDeCarga(`ork-freio: ferramenta fora da lista fechada: ${ferramenta}`)
35}
36
37/** O JSON do primeiro bloco de texto de um resultado MCP; resultado com erro vira excecao. */
38export function jsonDoMcp(r: unknown, ferramenta: string): unknown {
39 const resultado = r as { content?: ReadonlyArray<{ type?: string; text?: unknown }>; isError?: boolean } | null
40 const texto = (resultado?.content ?? []).find(c => c?.type === 'text' && typeof c.text === 'string')?.text as string | undefined
41 if (resultado?.isError) throw new ErroDeCarga(`ork-freio: ${ferramenta} respondeu com erro${texto ? `: ${texto.slice(0, 200)}` : ''}`)
42 if (texto === undefined) throw new ErroDeCarga(`ork-freio: ${ferramenta} nao devolveu texto`)
43 try {
44 return JSON.parse(texto)
45 } catch {
46 throw new ErroDeCarga(`ork-freio: ${ferramenta} devolveu texto que nao e JSON`)
47 }
48}
49
50/** Caminho relativo normalizado: barras para a frente, sem `./` na frente nem barra no fim. */
51export function normalizar(caminho: string): string {
52 return caminho.trim().replace(/\\/g, '/').replace(/^(?:\.\/)+/, '').replace(/\/+$/, '')
53}
54
55/**
56 * As claims nao retiradas, com o que o Freio precisa: id, arquivo e comandos. O `claims.jsonl` e append-only e
57 * o nucleo ja devolve a versao mais nova de cada claim; se vier repetida, a ultima vence do mesmo jeito.
58 */
59export function claimsDoNucleo(bruto: unknown): FreioClaim[] {
60 if (!Array.isArray(bruto)) throw new ErroDeCarga('ork-freio: a lista de claims nao e lista')
61 const porId = new Map<string, FreioClaim | null>()
62 for (const item of bruto) {
63 const c = item as { id?: unknown; arquivo?: unknown; verificar?: unknown; estado?: unknown } | null
64 if (!c || typeof c.id !== 'string' || !ID_DE_CLAIM.test(c.id)) continue
65 if (c.estado === 'retirada') { porId.set(c.id, null); continue }
66 const comandos = Array.isArray(c.verificar) ? c.verificar.filter((v): v is string => typeof v === 'string' && v.trim() !== '') : []
67 const arquivo = typeof c.arquivo === 'string' && c.arquivo.trim() !== '' ? normalizar(c.arquivo) : null
68 porId.set(c.id, { id: c.id, arquivo, comandos })
69 }
70 return [...porId.values()].filter((c): c is FreioClaim => c !== null)
71 .sort((a, b) => Number(a.id.slice(1)) - Number(b.id.slice(1)))
72}
73
74/**
75 * O maior numero de claim que o nucleo ja deu na thread, retiradas incluidas: claim com numero acima dele foi
76 * cadastrada depois desta leitura, e a rodada nao a tira da fila por nao estar na lista.
77 */
78export function maiorNumeroDeClaim(bruto: unknown): number {
79 if (!Array.isArray(bruto)) return 0
80 let maior = 0
81 for (const item of bruto) {
82 const id = (item as { id?: unknown } | null)?.id
83 if (typeof id === 'string' && ID_DE_CLAIM.test(id)) maior = Math.max(maior, Number(id.slice(1)))
84 }
85 return maior
86}
87
88/** O texto do plano que o `ork_artifact_read` devolve, ou `null` quando a thread ainda nao tem plano. */
89export function textoDoPlano(bruto: unknown): string | null {
90 const documento = bruto as { tipo?: unknown; conteudo?: unknown } | null
91 if (!documento || typeof documento !== 'object') throw new ErroDeCarga('ork-freio: o plano lido nao e objeto')
92 return typeof documento.conteudo === 'string' ? documento.conteudo : null
93}
94
95function ehCaminho(texto: string): boolean {
96 return texto !== '' && !/\s/.test(texto) && !texto.startsWith('-') && !texto.split('/').includes('..')
97}
98
99/**
100 * A uniao dos `touch_paths` de todas as tarefas do plano (D9): os trechos entre crases do item do rotulo inteiro,
101 * como o Markdown o junta e como o nucleo le o verify da tarefa: a linha do rotulo, as linhas mais recuadas que
102 * ela (sub-itens e continuacao quebrada) e a continuacao logo abaixo que nao abre outro bloco. `null` sem plano
103 * ou sem nenhum caminho.
104 */
105export function touchPathsDoPlano(conteudo: string | null): string[] | null {
106 if (typeof conteudo !== 'string') return null
107 const achados = new Set<string>()
108 const juntar = (texto: string) => {
109 for (const m of texto.matchAll(TRECHO)) {
110 const caminho = normalizar(m[1] as string)
111 if (ehCaminho(caminho)) achados.add(caminho)
112 }
113 }
114 let recuoDoRotulo = -1
115 let depoisDeBranco = false
116 for (const linha of conteudo.replace(/\r\n/g, '\n').split('\n')) {
117 const recuo = linha.length - linha.trimStart().length
118 const rotulo = ROTULO_DE_TOUCH_PATHS.exec(linha)
119 if (rotulo) {
120 juntar(rotulo[1] as string)
121 recuoDoRotulo = recuo
122 depoisDeBranco = false
123 continue
124 }
125 if (recuoDoRotulo < 0) continue
126 if (linha.trim() === '') { depoisDeBranco = true; continue }
127 if (recuo <= recuoDoRotulo && (depoisDeBranco || ABRE_BLOCO.test(linha))) { recuoDoRotulo = -1; continue }
128 juntar(linha)
129 depoisDeBranco = false
130 }
131 return achados.size > 0 ? [...achados].sort() : null
132}
133
134/** O status da thread na secao `threads` do retrato `ork.maestro-snapshot/v1`, ou `null` quando ele nao diz. */
135export function statusNoRetrato(retrato: unknown, thread: string): string | null {
136 const itens = (retrato as { sections?: { threads?: { items?: unknown } } } | null)?.sections?.threads?.items
137 if (!Array.isArray(itens)) return null
138 const item = itens.find(i => (i as { id?: unknown } | null)?.id === thread) as { status?: unknown } | undefined
139 return typeof item?.status === 'string' ? item.status : null
140}
141
142/** O Freio age na thread? So deixa de agir com a thread fechada no retrato (D10). */
143export function threadAtiva(status: string | null): boolean {
144 return status !== 'fechada'
145}
146
147/** A carga de uma thread antes da primeira leitura. */
148export function cargaVazia(thread: string): FreioCarga {
149 return { thread, statusDaThread: null, claims: null, claimsEm: 0, maiorClaim: 0, touchPaths: null, planoLido: false, planoEm: 0, erros: [] }
150}
151
152/** As claims precisam ser relidas: nunca lidas, velhas, ou o lote mexeu nelas. */
153export function claimsVelhas(carga: FreioCarga, agora: number, loteMexeuNasClaims: boolean): boolean {
154 return carga.claims === null || loteMexeuNasClaims || !(agora - carga.claimsEm >= 0 && agora - carga.claimsEm < IDADE_DAS_CLAIMS_MS)
155}
156
157/** O plano precisa ser relido: nunca lido, velho, ou o lote o regravou. */
158export function planoVelho(carga: FreioCarga, agora: number, loteMexeuNoPlano: boolean): boolean {
159 return !carga.planoLido || loteMexeuNoPlano || !(agora - carga.planoEm >= 0 && agora - carga.planoEm < IDADE_DO_PLANO_MS)
160}
161hooks/lote.ts 271 lines1// Os arquivos tocados num lote e as claims que eles afetam (RM-059, F2, T2.2). Modulo puro: le a saida do
2// `git status`, compara fotos, junta os caminhos das ferramentas de escrita do lote e casa os caminhos com as
3// claims e com os `touch_paths` do plano. As chamadas ao `$` ficam no register.ts.
4import type { FreioClaim, FreioFoto, FreioPedido, FreioVigia } from '../types'
5
6/** O `git status` da foto (D4): HEAD, sem renomeacao (vira saida e entrada) e com cada arquivo novo listado. */
7export const ARGV_DO_STATUS: readonly string[] = ['git', 'status', '--porcelain=v2', '--branch', '-z', '--no-renames', '--untracked-files=all']
8export const TETO_DO_STATUS_MS = 10000
9/** Caminhos sujos com `stat` por foto; acima disso, so o codigo XY entra na assinatura. */
10export const TETO_DE_STAT = 300
11/** Nome-base menor que isto nao casa com comando (D5). */
12export const MINIMO_DO_NOME_BASE = 3
13
14/** As ferramentas que escrevem um arquivo e o campo do caminho no `tool_input`. */
15const ESCRITA: Readonly<Record<string, string>> = { Edit: 'file_path', Write: 'file_path', MultiEdit: 'file_path', NotebookEdit: 'notebook_path' }
16/** Ferramentas que so leem: o lote feito so delas nao roda o `git status` (D4). */
17export const FERRAMENTAS_DE_LEITURA: readonly string[] = [
18 'Read', 'Grep', 'Glob', 'LS', 'NotebookRead', 'WebFetch', 'WebSearch', 'ToolSearch',
19 'TaskCreate', 'TaskGet', 'TaskList', 'TaskUpdate', 'TaskOutput',
20 'mcp__orkastery__ork_thread_status', 'mcp__orkastery__ork_artifact_read', 'mcp__orkastery__ork_claims_list',
21 'mcp__orkastery__ork_git_status', 'mcp__orkastery__ork_phase_list', 'mcp__orkastery__ork_hitl_pending',
22 'mcp__orkastery__ork_observe',
23]
24const EXTENSOES_DE_CODIGO = new Set(['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'mts', 'cts'])
25/** O CLI de claims dentro de um comando do `Bash` (pelo `ork` ou pelo `index.js` do CLI): o lote mexeu nas claims. */
26const CLI_DE_CLAIMS = /(?:\bork|\bindex\.js)\s+claims\s+(?:add|retirar|verificar)\b/
27/** Caminhos fora dos `touch_paths` citados por extenso num aviso; o resto vai pela contagem (D8). */
28export const TETO_DE_CAMINHOS_NO_AVISO = 20
29
30/** Uma chamada do lote como o `classic.PostToolBatch` a entrega. */
31export type Chamada = { tool_name?: unknown; tool_input?: unknown }
32export type EntradaDoStatus = { caminho: string; xy: string }
33
34/** Pasta e caminho relativo com uma barra so. */
35export function juntar(pasta: string, rel: string): string {
36 return pasta.replace(/\/+$/, '') + '/' + rel.replace(/^\/+/, '')
37}
38
39/**
40 * A saida de `git status --porcelain=v2 --branch -z`: o HEAD (`# branch.oid`, `null` antes do primeiro commit)
41 * e cada caminho sujo com o codigo XY. Entrada renomeada (`2`) pula o caminho de origem; nova vem como `??`.
42 */
43export function lerStatus(saida: string): { head: string | null; entradas: EntradaDoStatus[] } {
44 let head: string | null = null
45 const entradas: EntradaDoStatus[] = []
46 const registros = saida.split('\0')
47 for (let i = 0; i < registros.length; i++) {
48 const r = registros[i] as string
49 if (r.startsWith('# branch.oid ')) {
50 const oid = r.slice('# branch.oid '.length).trim()
51 head = /^[0-9a-f]{7,64}$/.test(oid) ? oid : null
52 continue
53 }
54 const campos = r.split(' ')
55 const xy = campos[1] ?? ''
56 if (r.startsWith('1 ') && campos.length >= 9) entradas.push({ caminho: campos.slice(8).join(' '), xy })
57 else if (r.startsWith('2 ') && campos.length >= 10) { entradas.push({ caminho: campos.slice(9).join(' '), xy }); i++ }
58 else if (r.startsWith('u ') && campos.length >= 11) entradas.push({ caminho: campos.slice(10).join(' '), xy })
59 else if (r.startsWith('? ') && r.length > 2) entradas.push({ caminho: r.slice(2), xy: '??' })
60 }
61 return { head, entradas }
62}
63
64/** A assinatura de um caminho sujo: o XY e, com `stat`, o tamanho e o `mtimeMs` (D4). */
65export function assinatura(xy: string, stat: { size?: unknown; mtimeMs?: unknown } | null | undefined, comStat: boolean): string {
66 if (!comStat) return xy
67 return stat && typeof stat.size === 'number' && typeof stat.mtimeMs === 'number' ? `${xy}|${stat.size}|${stat.mtimeMs}` : `${xy}|ausente`
68}
69
70/**
71 * Duas assinaturas do mesmo caminho dizem mudanca. A que ficou so com o XY (acima do teto de `stat`) nao se
72 * compara pelo `stat` da outra: o caminho que passa a borda do teto sem mudar nao vira tocado.
73 */
74export function mudou(antes: string | undefined, agora: string): boolean {
75 if (antes === undefined) return true
76 if (antes === agora) return false
77 const [xyAntes, ...statAntes] = antes.split('|')
78 const [xyAgora, ...statAgora] = agora.split('|')
79 return xyAntes !== xyAgora || (statAntes.length > 0 && statAgora.length > 0)
80}
81
82/**
83 * Os caminhos que mudaram entre duas fotos (D4): novo na lista, com assinatura diferente, ou que saiu da lista
84 * com o HEAD parado (reversao). Saida da lista com HEAD novo foi commit e nao conta. Sem foto anterior, nada.
85 */
86export function diferenca(antes: FreioFoto | null, agora: FreioFoto): string[] {
87 if (!antes) return []
88 const tocados = new Set<string>()
89 for (const [caminho, sig] of Object.entries(agora.caminhos)) if (mudou(antes.caminhos[caminho], sig)) tocados.add(caminho)
90 if (antes.head === agora.head) for (const caminho of Object.keys(antes.caminhos)) if (!(caminho in agora.caminhos)) tocados.add(caminho)
91 return [...tocados].sort()
92}
93
94/** O caminho relativo a raiz da sessao, ou `null` quando ele fica fora dela ou nao e caminho. */
95export function relativo(caminho: unknown, raiz: string): string | null {
96 if (typeof caminho !== 'string' || caminho.trim() === '') return null
97 const base = raiz.replace(/\/+$/, '')
98 let rel = caminho.trim()
99 if (rel.startsWith('/')) {
100 if (!rel.startsWith(base + '/')) return null
101 rel = rel.slice(base.length + 1)
102 }
103 rel = rel.replace(/^(?:\.\/)+/, '')
104 const partes = rel.split('/')
105 return rel !== '' && !partes.some(p => p === '' || p === '.' || p === '..') ? rel : null
106}
107
108const nomeDa = (c: Chamada) => (typeof c?.tool_name === 'string' ? c.tool_name : '')
109
110/** Os caminhos que as ferramentas de escrita do lote citam, dentro da raiz, sem repeticao. */
111export function caminhosDoLote(chamadas: readonly Chamada[], raiz: string): string[] {
112 const achados = new Set<string>()
113 for (const c of chamadas) {
114 const campo = ESCRITA[nomeDa(c)]
115 if (!campo) continue
116 const rel = relativo((c.tool_input as Record<string, unknown> | null)?.[campo], raiz)
117 if (rel) achados.add(rel)
118 }
119 return [...achados].sort()
120}
121
122/** O lote so tem ferramentas de leitura (ou nenhuma chamada): nao ha o que fotografar. */
123export function soLeitura(chamadas: readonly Chamada[]): boolean {
124 return chamadas.every(c => FERRAMENTAS_DE_LEITURA.includes(nomeDa(c)))
125}
126
127/** O lote registrou, retirou ou mudou claim: a lista do nucleo precisa ser relida. */
128export function mexeuNasClaims(chamadas: readonly Chamada[]): boolean {
129 return chamadas.some(c => nomeDa(c) === 'mcp__orkastery__ork_claim_add'
130 || (nomeDa(c) === 'Bash' && CLI_DE_CLAIMS.test(String((c.tool_input as { command?: unknown } | null)?.command ?? ''))))
131}
132
133/** O lote regravou um artefato de fase: o plano precisa ser relido. */
134export function mexeuNoPlano(chamadas: readonly Chamada[]): boolean {
135 return chamadas.some(c => nomeDa(c) === 'mcp__orkastery__ork_artifact_write')
136}
137
138const fronteira = (c: string) => c === '' || /[\s'"=:(),;|&<>]/.test(c)
139
140/** O texto cita `alvo` com fronteira dos dois lados. */
141function cita(texto: string, alvo: string, antes: (c: string) => boolean): boolean {
142 for (let i = texto.indexOf(alvo); i >= 0; i = texto.indexOf(alvo, i + 1)) {
143 if (antes(i === 0 ? '' : (texto[i - 1] as string)) && fronteira(texto[i + alvo.length] ?? '')) return true
144 }
145 return false
146}
147
148/** O comando cita o caminho inteiro, com ou sem `./` na frente (D5, forca 2). */
149export function citaCaminho(comando: string, caminho: string): boolean {
150 return cita(comando, caminho, fronteira) || cita(comando, './' + caminho, fronteira)
151}
152
153/**
154 * O comando cita o mesmo nome-base de um fonte TS ou JS, com extensao de codigo (D5, forca 1): pega o teste
155 * compilado (`core/test/x.test.ts` contra `core/dist-test/test/x.test.js`). Nome-base curto nao casa.
156 */
157export function citaNomeBase(comando: string, caminho: string): boolean {
158 const base = caminho.slice(caminho.lastIndexOf('/') + 1)
159 const ponto = base.lastIndexOf('.')
160 if (ponto <= 0 || !EXTENSOES_DE_CODIGO.has(base.slice(ponto + 1))) return false
161 const nome = base.slice(0, ponto)
162 if (nome.length < MINIMO_DO_NOME_BASE) return false
163 const alvo = nome + '.'
164 for (let i = comando.indexOf(alvo); i >= 0; i = comando.indexOf(alvo, i + 1)) {
165 const antes = i === 0 ? '' : (comando[i - 1] as string)
166 if (antes !== '/' && !fronteira(antes)) continue
167 let j = i + alvo.length
168 while (j < comando.length && /[a-z]/.test(comando[j] as string)) j++
169 if (EXTENSOES_DE_CODIGO.has(comando.slice(i + alvo.length, j)) && fronteira(comando[j] ?? '')) return true
170 }
171 return false
172}
173
174const numero = (id: string) => Number(id.slice(1)) || 0
175
176/** Ordem da fila: a forca maior antes; na mesma forca, a claim mais nova antes. */
177function ordenar(pedidos: FreioPedido[]): FreioPedido[] {
178 return pedidos.sort((a, b) => b.forca - a.forca || numero(b.id) - numero(a.id))
179}
180
181/**
182 * As claims que os caminhos tocados afetam, com a forca e os caminhos de cada uma (D5). Claim sem comando fica de
183 * fora: a conferencia so diria que ela nao tem como ser verificada.
184 */
185export function claimsAfetadas(claims: readonly FreioClaim[], tocados: readonly string[]): FreioPedido[] {
186 const afetadas: FreioPedido[] = []
187 for (const c of claims) {
188 if (c.comandos.length === 0) continue
189 let forca = 0
190 const caminhos: string[] = []
191 for (const t of tocados) {
192 const f = c.arquivo === t ? 3 : c.comandos.some(cmd => citaCaminho(cmd, t)) ? 2 : c.comandos.some(cmd => citaNomeBase(cmd, t)) ? 1 : 0
193 if (f > 0) { caminhos.push(t); forca = Math.max(forca, f) }
194 }
195 if (forca > 0) afetadas.push({ id: c.id, forca, caminhos })
196 }
197 return ordenar(afetadas)
198}
199
200/** A fila com as claims novas, sem repeticao: a claim que ja esta nela junta os caminhos e fica com a forca maior. */
201export function enfileirar(fila: readonly FreioPedido[], afetadas: readonly FreioPedido[]): FreioPedido[] {
202 const porId = new Map(fila.map(p => [p.id, { ...p, caminhos: [...p.caminhos] }]))
203 for (const a of afetadas) {
204 const ja = porId.get(a.id)
205 if (!ja) { porId.set(a.id, { ...a, caminhos: [...a.caminhos] }); continue }
206 ja.forca = Math.max(ja.forca, a.forca)
207 for (const c of a.caminhos) if (!ja.caminhos.includes(c)) ja.caminhos.push(c)
208 }
209 return ordenar([...porId.values()])
210}
211
212/** O caminho casa com um `touch_path`: caminho exato ou pasta (com ou sem barra no fim), `*` sem barra e `**` (D9). */
213export function casaTouchPath(caminho: string, padrao: string): boolean {
214 const p = padrao.replace(/\/+$/, '')
215 if (p === '') return false
216 if (!p.includes('*')) return caminho === p || caminho.startsWith(p + '/')
217 let fonte = '^'
218 for (let i = 0; i < p.length; i++) {
219 const c = p[i] as string
220 if (c === '*') {
221 if (p[i + 1] === '*') { fonte += '.*'; i++ } else fonte += '[^/]*'
222 } else fonte += /[.+?^${}()|[\]\\]/.test(c) ? '\\' + c : c
223 }
224 return new RegExp(fonte + '$').test(caminho)
225}
226
227/** Os caminhos tocados fora da uniao dos `touch_paths` do plano; plano sem `touch_paths` nao avisa nada (D9). */
228export function foraDosTouchPaths(tocados: readonly string[], touchPaths: readonly string[] | null): string[] {
229 if (!touchPaths || touchPaths.length === 0) return []
230 return tocados.filter(t => !touchPaths.some(p => casaTouchPath(t, p)))
231}
232
233/** A vigia de uma thread antes da primeira foto. */
234export function vigiaVazia(thread: string): FreioVigia {
235 return { thread, foto: null, seq: 0, toques: {}, fila: [], avisadosFora: [], foraPendentes: [] }
236}
237
238/**
239 * O que um lote faz na vigia: foto nova, toques, claims afetadas na fila e os caminhos novos fora dos `touch_paths`,
240 * guardados uma vez cada para o aviso do laco principal (o lote de subagente toca, mas nao recebe o aviso).
241 */
242export function aplicarLote(v: FreioVigia, lote: { foto: FreioFoto | null; doLote: readonly string[]; claims: readonly FreioClaim[] | null; touchPaths: readonly string[] | null }):
243 { vigia: FreioVigia; tocados: string[]; afetadas: FreioPedido[]; fora: string[] } {
244 const tocados = [...new Set([...lote.doLote, ...(lote.foto ? diferenca(v.foto, lote.foto) : [])])].sort()
245 const seq = v.seq + 1
246 const toques = { ...v.toques }
247 for (const t of tocados) toques[t] = seq
248 const afetadas = lote.claims ? claimsAfetadas(lote.claims, tocados) : []
249 const fora = foraDosTouchPaths(tocados, lote.touchPaths).filter(c => !v.avisadosFora.includes(c))
250 return {
251 vigia: {
252 ...v, foto: lote.foto ?? v.foto, seq, toques, fila: enfileirar(v.fila, afetadas),
253 avisadosFora: [...v.avisadosFora, ...fora], foraPendentes: [...(v.foraPendentes ?? []), ...fora],
254 },
255 tocados, afetadas, fora,
256 }
257}
258
259/** Tira os caminhos fora dos `touch_paths` ainda nao avisados, para o lote do laco principal devolver. */
260export function tomarFora(v: FreioVigia): { vigia: FreioVigia; fora: string[] } {
261 return { vigia: { ...v, foraPendentes: [] }, fora: v.foraPendentes ?? [] }
262}
263
264/** O aviso de `touch_paths` para o modelo, com teto de caminhos; sem caminho novo fora do plano, nenhum texto. */
265export function textoDoFora(fora: readonly string[]): string {
266 if (fora.length === 0) return ''
267 const citados = fora.slice(0, TETO_DE_CAMINHOS_NO_AVISO).join(', ')
268 const resto = fora.length > TETO_DE_CAMINHOS_NO_AVISO ? ` e mais ${fora.length - TETO_DE_CAMINHOS_NO_AVISO}` : ''
269 return `Freio de Lote: o lote mexeu fora dos touch_paths do plano: ${citados}${resto}.`
270}
271hooks/conferencia.ts 310 lines1// A conferencia pelo nucleo e a devolucao no lote seguinte (RM-059, F2, T2.3). Modulo puro: monta o argv do
2// `ork claims conferir`, le o JSON `ork.claims-conferencia/v1`, cuida da rodada, apura o resultado e escreve o
3// texto do lote e a linha de status. O Freio nunca roda o comando de uma claim: quem roda e o nucleo, no
4// sandbox do verify (D12 do plano dos mods), e quem verifica de verdade continua sendo o `ork verify`.
5import type { FreioEntrega, FreioFalha, FreioPedido, FreioRodada, FreioVigia } from '../types'
6
7export const FORMATO_DA_CONFERENCIA = 'ork.claims-conferencia/v1'
8/** O prazo total pedido ao nucleo, em segundos (o padrao do `ork claims conferir`, D6). */
9export const PRAZO_DA_CONFERENCIA_S = 180
10/** O vigia do mod: a conferencia que passar disto e encerrada e fica sem veredito (D6, D7). */
11export const VIGIA_DA_CONFERENCIA_MS = 240000
12/**
13 * Depois do vigia, quanto o laco espera o processo fechar; se ele seguir aberto, a conferencia fica indisponivel
14 * e nenhuma rodada nova abre enquanto ele nao fechar (duas conferencias nunca correm juntas).
15 */
16export const ESPERA_DO_FIM_MS = 30000
17/** O motivo de indisponivel quando o processo da rodada anterior nao fechou. */
18export const MOTIVO_PROCESSO_ABERTO = 'o processo anterior do ork claims conferir nao fechou depois do vigia'
19/** Claims por rodada; so uma rodada roda por vez (D5). */
20export const TETO_POR_RODADA = 6
21/** A pausa depois de uma conferencia indisponivel (D7). */
22export const PAUSA_SEM_CONFERENCIA_MS = 600000
23/** A pausa curta quando outra conducao segura a thread (saida 3): a rodada volta para a fila. */
24export const PAUSA_DE_CONDUCAO_MS = 60000
25/** Tetos do texto ao modelo (D8). */
26export const TETO_DE_CLAIMS_NO_TEXTO = 3
27export const TETO_DE_LINHAS_DA_SAIDA = 12
28export const TETO_DE_CARACTERES_DA_SAIDA = 1200
29export const TETO_DO_TEXTO = 6000
30/** O que o mod guarda da saida do processo: o JSON inteiro cabe com folga; do stderr, so o fim. */
31export const TETO_DA_SAIDA_LIDA = 4194304
32export const TETO_DO_ERRO_LIDO = 2000
33
34const ID_DE_CLAIM = /^C\d+$/
35const ID_DE_THREAD = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/
36/** A recusa do nucleo por uma claim so (retirada ou que nao existe): as outras da rodada voltam para a fila. */
37const RECUSA_POR_CLAIM = /^claims\.conferir\.(?:retirada|desconhecida): (C\d+)\b/
38/** A saida do CLI quando outra conducao segura a thread. */
39const SAIDA_DE_CONDUCAO = 3
40
41/** Uma claim como a conferencia a devolveu, so com o que o Freio entrega. */
42export type ItemConferido = {
43 id: string
44 alegacao: string
45 arquivo: string | null
46 verificado: boolean
47 motivo: string | null
48 comando: string
49 codigo: number | null
50 fim: string
51}
52
53/**
54 * A leitura de uma rodada: o resultado do nucleo; a recusa de uma claim so (retirada ou desconhecida); a thread
55 * conduzida por outro (saida 3); a conferencia indisponivel; ou o vigia do mod vencido.
56 */
57export type Leitura =
58 | { tipo: 'resultado'; itens: ItemConferido[] }
59 | { tipo: 'recusada'; id: string; motivo: string }
60 | { tipo: 'ocupada'; motivo: string }
61 | { tipo: 'indisponivel'; motivo: string }
62 | { tipo: 'prazo' }
63
64/** O argv do `ork claims conferir` de uma rodada; o comando das claims fica com o nucleo. */
65export function argvDaConferencia(thread: string, ids: readonly string[]): string[] {
66 if (!ID_DE_THREAD.test(thread)) throw new Error('ork-freio: id de thread invalido')
67 if (ids.length === 0 || ids.some(id => !ID_DE_CLAIM.test(id))) throw new Error('ork-freio: ids de claim invalidos')
68 return ['ork', 'claims', 'conferir', thread, '--claims', ids.join(','), '--prazo', String(PRAZO_DA_CONFERENCIA_S), '--json']
69}
70
71/** Junta um pedaco da saida, ate o teto; do stderr fica so o fim. */
72export function acumular(atual: string, pedaco: string, doErro: boolean): string {
73 const junto = atual + pedaco
74 if (doErro) return junto.length > TETO_DO_ERRO_LIDO ? junto.slice(-TETO_DO_ERRO_LIDO) : junto
75 return junto.length > TETO_DA_SAIDA_LIDA ? junto.slice(0, TETO_DA_SAIDA_LIDA) : junto
76}
77
78const ultimaLinha = (texto: string) => texto.trim().split('\n').filter(l => l.trim() !== '').slice(-1)[0]?.trim().slice(0, 300) ?? ''
79
80function itemDoNucleo(bruto: unknown): ItemConferido | null {
81 const i = bruto as { id?: unknown; alegacao?: unknown; arquivo?: unknown; verificado?: unknown; motivo?: unknown; execucoes?: unknown } | null
82 if (!i || typeof i.id !== 'string' || !ID_DE_CLAIM.test(i.id)) return null
83 const execucoes = Array.isArray(i.execucoes) ? i.execucoes as Array<{ comando?: unknown; ok?: unknown; code?: unknown; fim?: unknown }> : []
84 const chave = execucoes.find(x => x && x.ok === false) ?? execucoes[0]
85 return {
86 id: i.id,
87 alegacao: typeof i.alegacao === 'string' ? i.alegacao : '',
88 arquivo: typeof i.arquivo === 'string' ? i.arquivo : null,
89 verificado: i.verificado === true,
90 motivo: typeof i.motivo === 'string' ? i.motivo : null,
91 comando: typeof chave?.comando === 'string' ? chave.comando : '',
92 codigo: typeof chave?.code === 'number' ? chave.code : null,
93 fim: typeof chave?.fim === 'string' ? chave.fim : '',
94 }
95}
96
97/**
98 * A saida do `ork claims conferir --json` (D6, D7): o resultado so com o formato certo, itens em lista e saida
99 * 0 ou 1; a recusa de uma claim so (saida 2 com `claims.conferir.retirada` ou `.desconhecida`) e por claim; a
100 * saida 3 e outra conducao segurando a thread; o resto (saida 2 com `erro`, um `ork` antigo) e indisponivel.
101 */
102export function lerConferencia(stdout: string, codigo: number | null, stderr: string): Leitura {
103 let j: { formato?: unknown; itens?: unknown; erro?: unknown } | null = null
104 try { j = JSON.parse(stdout) } catch { j = null }
105 if (j && j.formato === FORMATO_DA_CONFERENCIA && Array.isArray(j.itens) && (codigo === 0 || codigo === 1)) {
106 return { tipo: 'resultado', itens: j.itens.map(itemDoNucleo).filter((i): i is ItemConferido => i !== null) }
107 }
108 const erro = j && typeof j.erro === 'string' ? j.erro.trim().slice(0, 300) : ''
109 const recusa = RECUSA_POR_CLAIM.exec(erro)
110 if (recusa) return { tipo: 'recusada', id: recusa[1] as string, motivo: erro }
111 // JSON que nao serve nao vira motivo: o fim do stderr diz mais; texto solto (um `ork` antigo) entra pelo fim.
112 const motivo = erro || ultimaLinha(stderr) || (j === null ? ultimaLinha(stdout) : '') || `saiu com codigo ${codigo ?? 'desconhecido'}`
113 if (codigo === SAIDA_DE_CONDUCAO) return { tipo: 'ocupada', motivo }
114 if (erro) return { tipo: 'indisponivel', motivo: erro }
115 return { tipo: 'indisponivel', motivo: `ork claims conferir sem resultado: ${motivo}` }
116}
117
118export function entregaVazia(): FreioEntrega {
119 return { falhas: [], semVeredito: [], recuperadas: [], indisponivel: null }
120}
121
122/** A vigia com os campos da conferencia preenchidos (a vigia gravada antes deles nasce sem eles). */
123export function comConferencia(v: FreioVigia): Required<FreioVigia> {
124 return {
125 rodada: null, entrega: entregaVazia(), emRisco: [], pausaAte: 0, indisponivel: null, avisouIndisponivel: false, foraPendentes: [],
126 avisadas: [],
127 ...v,
128 } as Required<FreioVigia>
129}
130
131/** Ha trabalho para o laco de fundo: fila com claim ou rodada aberta (a de um laco que morreu com o worker, D10). */
132export function precisaDeLaco(vigia: FreioVigia): boolean {
133 const v = comConferencia(vigia)
134 return v.fila.length > 0 || v.rodada !== null
135}
136
137const numero = (id: string) => Number(id.slice(1)) || 0
138
139/** A mesma ordem da fila do lote: a forca maior antes; na mesma forca, a claim mais nova antes. */
140function ordenar(pedidos: FreioPedido[]): FreioPedido[] {
141 return pedidos.sort((a, b) => b.forca - a.forca || numero(b.id) - numero(a.id))
142}
143
144/** Devolve pedidos a fila sem repeticao, juntando os caminhos e ficando com a forca maior. */
145function devolverAFila(fila: readonly FreioPedido[], pedidos: readonly FreioPedido[]): FreioPedido[] {
146 const porId = new Map(fila.map(p => [p.id, { ...p, caminhos: [...p.caminhos] }]))
147 for (const p of pedidos) {
148 const ja = porId.get(p.id)
149 if (!ja) { porId.set(p.id, { ...p, caminhos: [...p.caminhos] }); continue }
150 ja.forca = Math.max(ja.forca, p.forca)
151 for (const c of p.caminhos) if (!ja.caminhos.includes(c)) ja.caminhos.push(c)
152 }
153 return ordenar([...porId.values()])
154}
155
156/** Os pedidos de uma rodada, para voltarem a fila. */
157function pedidosDaRodada(rodada: FreioRodada, ids: readonly string[]): FreioPedido[] {
158 return ids.map(id => ({ id, forca: rodada.forcas?.[id] ?? 1, caminhos: [...(rodada.caminhos[id] ?? [])] }))
159}
160
161/**
162 * Abre a rodada: tira da fila ate o teto, se nao ha rodada aberta nem pausa; a rodada aberta segue como esta.
163 * Com a lista das claims ativas, a claim que saiu dela (retirada) sai da fila antes; a claim com numero acima do
164 * maior que a leitura viu (cadastrada depois dela) fica, e a recusa por claim do nucleo cobre o resto.
165 */
166export function iniciarRodada(vigia: FreioVigia, agora: number, teto = TETO_POR_RODADA,
167 ativas: { ids: readonly string[]; maior: number } | null = null): Required<FreioVigia> {
168 const v = comConferencia(vigia)
169 const fila = ativas ? v.fila.filter(p => ativas.ids.includes(p.id) || numero(p.id) > ativas.maior) : v.fila
170 if (v.rodada || fila.length === 0 || agora < v.pausaAte) return fila === v.fila ? v : { ...v, fila }
171 const escolhidos = fila.slice(0, teto)
172 const rodada: FreioRodada = {
173 ids: escolhidos.map(p => p.id),
174 caminhos: Object.fromEntries(escolhidos.map(p => [p.id, [...p.caminhos]])),
175 forcas: Object.fromEntries(escolhidos.map(p => [p.id, p.forca])),
176 inicio: v.seq,
177 }
178 return { ...v, fila: fila.slice(teto), rodada }
179}
180
181/**
182 * O resultado da claim e velho (D6): a claim voltou para a fila durante a rodada (um lote tocou qualquer caminho
183 * que a afeta: o arquivo dela, um caminho do comando ou o nome-base) ou um caminho que a abriu foi tocado depois.
184 */
185function velha(v: Required<FreioVigia>, rodada: FreioRodada, id: string): boolean {
186 return v.fila.some(p => p.id === id) || (rodada.caminhos[id] ?? []).some(c => (v.toques[c] ?? 0) > rodada.inicio)
187}
188
189/** Fecha a rodada com a leitura: quebra, sem veredito, recuperada, recusa, conducao alheia e indisponivel (D6, D7). */
190export function fecharRodada(vigia: FreioVigia, leitura: Leitura, agora: number): Required<FreioVigia> {
191 const v = comConferencia(vigia)
192 const rodada = v.rodada
193 if (!rodada) return v
194 if (leitura.tipo === 'indisponivel') {
195 return {
196 ...v, rodada: null, fila: [], pausaAte: agora + PAUSA_SEM_CONFERENCIA_MS, indisponivel: leitura.motivo,
197 entrega: v.avisouIndisponivel ? v.entrega : { ...v.entrega, indisponivel: leitura.motivo }, avisouIndisponivel: true,
198 }
199 }
200 if (leitura.tipo === 'recusada') {
201 // So a claim recusada sai; as outras voltam para a fila e rodam na proxima rodada, sem pausa e sem aviso.
202 const fila = devolverAFila(v.fila.filter(p => p.id !== leitura.id), pedidosDaRodada(rodada, rodada.ids.filter(id => id !== leitura.id)))
203 return { ...v, rodada: null, fila, emRisco: v.emRisco.filter(id => id !== leitura.id) }
204 }
205 if (leitura.tipo === 'ocupada') {
206 return { ...v, rodada: null, fila: devolverAFila(v.fila, pedidosDaRodada(rodada, rodada.ids)), pausaAte: agora + PAUSA_DE_CONDUCAO_MS }
207 }
208 const avisadas = new Set(v.avisadas)
209 const falhas = new Map(v.entrega.falhas.map(f => [f.id, f]))
210 const semVeredito = new Map(v.entrega.semVeredito.map(s => [s.id, s]))
211 const recuperadas = new Set(v.entrega.recuperadas)
212 const emRisco = new Set(v.emRisco)
213 const semVez = (id: string, motivo: string) => { semVeredito.set(id, { id, motivo }); falhas.delete(id) }
214 if (leitura.tipo === 'prazo') {
215 for (const id of rodada.ids) if (!velha(v, rodada, id)) semVez(id, 'prazo do Freio')
216 } else {
217 for (const item of leitura.itens) {
218 if (!rodada.ids.includes(item.id) || velha(v, rodada, item.id)) continue
219 if (item.verificado) {
220 // So volta como recuperada a claim cuja falha o modelo ja recebeu; a que ele nunca viu sai sem barulho.
221 falhas.delete(item.id)
222 semVeredito.delete(item.id)
223 emRisco.delete(item.id)
224 if (avisadas.delete(item.id)) recuperadas.add(item.id)
225 } else if (item.motivo === 'claims.failed') {
226 const falha: FreioFalha = { id: item.id, alegacao: item.alegacao, arquivo: item.arquivo, comando: item.comando, codigo: item.codigo, fim: item.fim }
227 falhas.set(item.id, falha)
228 semVeredito.delete(item.id)
229 recuperadas.delete(item.id)
230 emRisco.add(item.id)
231 } else semVez(item.id, item.motivo ?? 'sem motivo')
232 }
233 }
234 return {
235 ...v, rodada: null, indisponivel: null, emRisco: [...emRisco], avisadas: [...avisadas],
236 entrega: { ...v.entrega, falhas: [...falhas.values()], semVeredito: [...semVeredito.values()], recuperadas: [...recuperadas] },
237 }
238}
239
240/**
241 * A conferencia fica indisponivel sem rodada aberta (o processo da rodada anterior nao fechou): o mesmo efeito da
242 * leitura indisponivel (D7), avisando o modelo so na primeira vez.
243 */
244export function indisponivelSemRodada(vigia: FreioVigia, motivo: string, agora: number): Required<FreioVigia> {
245 const v = comConferencia(vigia)
246 return {
247 ...v, fila: [], pausaAte: agora + PAUSA_SEM_CONFERENCIA_MS, indisponivel: motivo,
248 entrega: v.avisouIndisponivel ? v.entrega : { ...v.entrega, indisponivel: motivo }, avisouIndisponivel: true,
249 }
250}
251
252/** Tira a entrega da vigia para o lote devolver: a vigia fica com a entrega vazia e lembra as falhas avisadas. */
253export function tomarEntrega(vigia: FreioVigia): { vigia: Required<FreioVigia>; entrega: FreioEntrega } {
254 const v = comConferencia(vigia)
255 const avisadas = [...new Set([...v.avisadas, ...v.entrega.falhas.map(f => f.id)])]
256 return { vigia: { ...v, entrega: entregaVazia(), avisadas }, entrega: v.entrega }
257}
258
259/** O fim da saida de uma claim: as ultimas linhas e o ultimo trecho, dentro dos tetos (D8). */
260export function fimDaSaida(fim: string): string[] {
261 let texto = fim.replace(/\s+$/, '')
262 if (texto.length > TETO_DE_CARACTERES_DA_SAIDA) texto = texto.slice(-TETO_DE_CARACTERES_DA_SAIDA)
263 return texto.split('\n').slice(-TETO_DE_LINHAS_DA_SAIDA)
264}
265
266/** O texto inteiro de um lote dentro do teto (D8), com a marca de corte. */
267export function limitarTexto(texto: string): string {
268 return texto.length > TETO_DO_TEXTO ? `${texto.slice(0, TETO_DO_TEXTO - 40)}\n(texto cortado pelo Freio de Lote)` : texto
269}
270
271const lista = (ids: readonly string[]) => ids.join(', ')
272
273/** O texto que o lote devolve ao modelo (D8); sem novidade, nenhum texto. */
274export function textoDaEntrega(e: FreioEntrega): string {
275 const partes: string[] = []
276 const n = e.falhas.length
277 if (n > 0) {
278 partes.push(`Freio de Lote: a conferencia do nucleo reprovou ${n === 1 ? '1 claim ligada' : `${n} claims ligadas`} ao que voce mexeu (ork claims conferir, no sandbox do verify, sem recibo).`)
279 for (const f of e.falhas.slice(0, TETO_DE_CLAIMS_NO_TEXTO)) {
280 partes.push(`- ${f.id}${f.arquivo ? ` (${f.arquivo})` : ''}: ${f.alegacao}`)
281 partes.push(` comando: ${f.comando}`)
282 partes.push(` saiu com codigo ${f.codigo ?? 'desconhecido'}; fim da saida:`)
283 for (const linha of fimDaSaida(f.fim)) partes.push(` | ${linha}`)
284 }
285 if (n > TETO_DE_CLAIMS_NO_TEXTO) partes.push(`- e mais ${n - TETO_DE_CLAIMS_NO_TEXTO}: ${lista(e.falhas.slice(TETO_DE_CLAIMS_NO_TEXTO).map(f => f.id))}`)
286 partes.push('Rode o comando para confirmar: teste que cria processo pode reprovar no sandbox e passar no ork verify, que continua sendo quem verifica.')
287 }
288 if (e.recuperadas.length > 0) {
289 partes.push(`Freio de Lote: ${lista(e.recuperadas)} ${e.recuperadas.length === 1 ? 'voltou' : 'voltaram'} a passar na conferencia do nucleo.`)
290 }
291 if (e.semVeredito.length > 0) {
292 const motivos = [...new Set(e.semVeredito.map(s => s.motivo))]
293 partes.push(`Freio de Lote: a conferencia de ${lista(e.semVeredito.map(s => s.id))} terminou sem veredito (${motivos.join(', ')}); o ork verify decide.`)
294 }
295 if (e.indisponivel) {
296 partes.push(`Freio de Lote: a conferencia das claims esta indisponivel nesta sessao (${e.indisponivel}). O Freio segue sem conferir; o ork verify continua valendo.`)
297 }
298 return limitarTexto(partes.join('\n'))
299}
300
301/** A linha de status do Freio (D8); `undefined` limpa a linha. */
302export function textoDoStatus(vigia: FreioVigia, agora: number): string | undefined {
303 const v = comConferencia(vigia)
304 if (v.indisponivel && agora < v.pausaAte) return 'freio: conferencia indisponivel'
305 if (v.rodada) return `freio: conferindo ${lista(v.rodada.ids)}`
306 const n = v.emRisco.length
307 if (n > 0) return `freio: ${n === 1 ? '1 claim' : `${n} claims`} em risco (${lista(v.emRisco.slice(0, 5))}${n > 5 ? ', ...' : ''})`
308 return undefined
309}
310types/index.d.ts 128 lines1// Contrato de tipos do Freio de Lote (RM-059, fatia F2). O Freio nao acrescenta noun ao `$`: este arquivo
2// declara o estado dele (`PluginState['ork-freio']`) e os tipos que os modulos puros e os testes usam.
3// Sem import nem referencia, como o motor exige de um contrato.
4
5/** Uma claim ativa da thread, como o Freio a usa: o id, o arquivo e os comandos de verificacao. */
6export type FreioClaim = {
7 id: string
8 arquivo: string | null
9 comandos: string[]
10}
11
12/** O que a carga leu do nucleo (T2.1). */
13export type FreioCarga = {
14 /** A thread do despacho, do contexto da ponte. */
15 thread: string
16 /** O status da thread no retrato do nucleo; `fechada` deixa o Freio dormente. */
17 statusDaThread: string | null
18 /** As claims nao retiradas; `null` enquanto o nucleo nao respondeu. */
19 claims: FreioClaim[] | null
20 /** Quando as claims foram lidas, em ms desde a epoca. */
21 claimsEm: number
22 /** O maior numero de claim da thread na leitura, retiradas incluidas (falta na carga gravada antes dele). */
23 maiorClaim?: number
24 /** A uniao dos `touch_paths` das tarefas do plano; `null` sem plano ou sem nenhum. */
25 touchPaths: string[] | null
26 /** O plano ja foi lido (com ou sem `touch_paths`). */
27 planoLido: boolean
28 /** Quando o plano foi lido, em ms desde a epoca. */
29 planoEm: number
30 /** O que falhou na ultima carga, para a linha de status e o teste. */
31 erros: string[]
32}
33
34/** A foto do `git status` da worktree: o HEAD e a assinatura de cada caminho sujo. */
35export type FreioFoto = {
36 head: string | null
37 /** Caminho relativo a raiz da sessao para `XY|tamanho|mtimeMs` (ou `XY|ausente`). */
38 caminhos: Record<string, string>
39}
40
41/** Uma claim afetada pelo lote, na fila da conferencia (T2.2). */
42export type FreioPedido = {
43 id: string
44 /** 3 pelo `arquivo`, 2 pelo caminho citado no comando, 1 pelo nome-base citado (D5). */
45 forca: number
46 /** Os caminhos tocados que a afetaram. */
47 caminhos: string[]
48}
49
50/** O que o Freio vigia entre os lotes. */
51export type FreioVigia = {
52 thread: string
53 /** A ultima foto do `git status`; `null` antes da primeira. */
54 foto: FreioFoto | null
55 /** O numero do lote, que marca quando cada caminho foi tocado. */
56 seq: number
57 /** O ultimo lote que tocou cada caminho. */
58 toques: Record<string, number>
59 /** As claims afetadas que esperam conferencia. */
60 fila: FreioPedido[]
61 /** Os caminhos fora dos `touch_paths` ja avisados nesta sessao. */
62 avisadosFora: string[]
63 // Os campos da conferencia (T2.3) faltam na vigia gravada antes deles; `comConferencia` os preenche.
64 /** A rodada em curso no nucleo, ou `null`. */
65 rodada?: FreioRodada | null
66 /** O que o lote seguinte devolve ao modelo. */
67 entrega?: FreioEntrega
68 /** As claims cuja ultima conferencia reprovou. */
69 emRisco?: string[]
70 /** Ate quando o Freio nao pede conferencia (depois de uma indisponivel), em ms desde a epoca. */
71 pausaAte?: number
72 /** Por que a ultima conferencia ficou indisponivel, ou `null`. */
73 indisponivel?: string | null
74 /** O modelo ja foi avisado da conferencia indisponivel nesta sessao. */
75 avisouIndisponivel?: boolean
76 /** Caminhos fora dos `touch_paths` que o laco principal ainda nao recebeu (lote de subagente nao recebe aviso). */
77 foraPendentes?: string[]
78 /** As claims cuja falha o modelo ja recebeu: so elas voltam como recuperadas quando passam. */
79 avisadas?: string[]
80}
81
82/** Uma rodada de conferencia: as claims, os caminhos que afetaram cada uma e o lote em que ela comecou (T2.3). */
83export type FreioRodada = {
84 ids: string[]
85 caminhos: Record<string, string[]>
86 /** A forca de cada claim, para ela voltar a fila no mesmo lugar (recusa por claim, conducao alheia). */
87 forcas?: Record<string, number>
88 /** O numero do lote no inicio; toque mais novo deixa o resultado da claim velho (D6). */
89 inicio: number
90}
91
92/** Uma claim que a conferencia reprovou (`claims.failed`), como volta ao modelo. */
93export type FreioFalha = {
94 id: string
95 alegacao: string
96 arquivo: string | null
97 comando: string
98 codigo: number | null
99 /** O fim da saida, ja redigido pelo nucleo. */
100 fim: string
101}
102
103/** Uma claim cuja conferencia terminou sem veredito (prazo, comando que nao rodou). */
104export type FreioSemVeredito = {
105 id: string
106 motivo: string
107}
108
109/** O que o lote seguinte devolve ao modelo. */
110export type FreioEntrega = {
111 falhas: FreioFalha[]
112 semVeredito: FreioSemVeredito[]
113 recuperadas: string[]
114 /** O motivo da conferencia indisponivel, so na primeira vez da sessao. */
115 indisponivel: string | null
116}
117
118declare module 'claude-code' {
119 interface PluginState {
120 'ork-freio': {
121 /** O que a carga leu do nucleo. */
122 carga: FreioCarga
123 /** O que o Freio vigia entre os lotes. */
124 vigia: FreioVigia
125 }
126 }
127}
128