SLOPSHOPPER

ork-freio

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…

newstatusprocesstimer
A shopper browsing a rack in a slop shop
README

Orkastery

English · Português

npm MIT license

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

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

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

Your first thread

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

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

Why it exists

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

What changes for builders

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

How it works

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

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

The thread: six phases no agent skips

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

Four modes, chosen by #TAG

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

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

Truth, not reports

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

Layered human attention

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

Runtimes and accounts

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

Context and memory

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

Documentation as code

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

Install on your host

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

What exists today, and what does not

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

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

Verified in this tree

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

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

Documentation

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

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

Reading the CLI in English

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

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

Built with itself

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

Ecosystem

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

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

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

Credits and license

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

Source 5 files
hooks/register.ts 286 lines
1// 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}
286
hooks/carga.ts 161 lines
1// 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}
161
hooks/lote.ts 271 lines
1// 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}
271
hooks/conferencia.ts 310 lines
1// 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}
310
types/index.d.ts 128 lines
1// 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