Ponte dos mods do Orkastery no Claude Code: o papel da sessão, a leitura do núcleo, a faixa e os caminhos vigiados com dono único, e a guarda de autoridade que…

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 332 lines1// A ponte dos mods do Orkastery (RM-059, onda 0). Acrescenta ao `$` o noun `$.ork`, que os outros mods
2// do Orkastery usam para saber em que sessao estao, ler o nucleo, publicar na faixa e pedir caminhos
3// vigiados, e guarda a regra de autoridade (T0.6): nenhum mod aprova gate, permissao, nota do MASTER,
4// push ou tag pelo dono, nem fala como o dono. A logica fica nos modulos puros ao lado; aqui so as
5// chamadas ao `$`, sempre escritas como `x.noun.metodo(...)`, como o validador exige.
6import type { Register } from 'claude-code'
7import type { Ork, OrkAssinatura, OrkContexto, OrkMaestro, OrkMostrado, OrkRetratoGuardado } from '../types'
8import { comporFaixa, desenharFaixa, nomeDeModValido, trocarLinha } from './faixa'
9import type { Achado, Autoridade } from './guarda'
10import {
11 avaliarAmbiente, avaliarArgv, avaliarCaminho, avaliarUses, ehModDoOrkastery, lerAutoridade, MARCA_DE_MOD, NOME_DA_PONTE,
12} from './guarda'
13import { CHAVE_NO_STORE, conferirMostrado, lerRegistro, registrarMostrado } from './mostrado'
14import {
15 argsDoMaestro, argvDaConsulta, cacheValido, caminhosCobertos, chaveDoPedido, conferirPedidoDeRetrato, FERRAMENTA_DO_RETRATO,
16 guardar, interpretarCli, interpretarMcp, raizPeloGit, SERVIDOR, subirAteOEstado, TETO_DO_CLI_MS,
17} from './nucleo'
18import { decidirPapel, juntar, MANIFESTO_DO_PROJETO, threadDoDespacho } from './papel'
19import { caminhosPadrao, fundirCaminhos, idsDeThread, ledgerDaThread, ledgersRecentes, pastaDasThreads, registrarPedido } from './vigia'
20
21const RETRATOS = { plugin: 'ork-ponte', key: 'retratos' } as const
22const FAIXA = { plugin: 'ork-ponte', key: 'faixa' } as const
23const VIGIAS = { plugin: 'ork-ponte', key: 'vigias' } as const
24const TENTATIVAS_DE_ESCRITA = 5
25
26/** O que os ganchos da ponte usam do noun, montado no `engine.create` de cada carga. */
27type Interno = {
28 contexto: () => Promise<OrkContexto>
29 raizDoEstado: () => Promise<string | null>
30 ledgersRecentes: (raiz: string) => Promise<string[]>
31}
32let interno: Interno | undefined
33/** O `classic.SessionStart` desta carga ja respondeu os `watchPaths`. */
34let inicioRespondido = false
35
36// A guarda (T0.6). A lista de autoridade e lida do disco uma vez por carga (D6 da thread): `null` quando
37// falta ou nao tem o formato, e entao tudo que a guarda cobre fecha para os mods do Orkastery.
38let autoridade: Autoridade | null | undefined
39let motivoSemLista = ''
40let raizDaPonte = ''
41/** Mods do Orkastery sem prefixo `ork-`, conhecidos pelo `orkastery-mod.json` no `plugin.register`. */
42const conhecidos = new Set<string>()
43
44function pai(dir: string): string {
45 const sem = dir.replace(/\/+$/, '')
46 const i = sem.lastIndexOf('/')
47 return i <= 0 ? '/' : sem.slice(0, i)
48}
49
50/** A lista ao lado dos mods e, na falta dela, dentro da pasta da ponte (contrato para a distribuicao). */
51async function obterAutoridade(ler: (caminho: string) => Promise<unknown>): Promise<Autoridade | null> {
52 if (autoridade !== undefined) return autoridade
53 const candidatos = [juntar(pai(raizDaPonte), 'autoridade.json'), juntar(raizDaPonte, 'autoridade.json')]
54 const motivos: string[] = []
55 for (const caminho of candidatos) {
56 try {
57 autoridade = lerAutoridade(String(await ler(caminho)))
58 return autoridade
59 } catch (erro) {
60 motivos.push(`${caminho}: ${erro instanceof Error ? erro.message : String(erro)}`)
61 }
62 }
63 motivoSemLista = motivos.join('; ').slice(0, 400)
64 autoridade = null
65 return null
66}
67
68const recusa = (a: Achado) => `ork-ponte: ${a.regra}: ${a.porque}. Mod do Orkastery nao age pelo dono; ` +
69 'registre uma decisao autonoma pelo nucleo ou leve a pergunta ao dono pelo dialogo nativo'
70const semLista = () => `ork-ponte: lista de autoridade ausente ou invalida (${motivoSemLista || 'nao lida'}); ` +
71 'a guarda fecha para os mods do Orkastery'
72const ehMod = (nome: unknown) => ehModDoOrkastery(nome, conhecidos)
73
74export const register: Register = on => {
75 on('engine.create', async ($, e, next) => {
76 const built = await next(e)
77
78 const lerContexto = async (): Promise<OrkContexto> => {
79 const dispatchId = await built.env.get('ORK_DISPATCH_ID')
80 const raiz = await built.session.root()
81 let temManifesto = false
82 let ferramentas: string[] = []
83 if (dispatchId === undefined || dispatchId.trim() === '') {
84 temManifesto = await built.fs.exists(juntar(raiz, MANIFESTO_DO_PROJETO))
85 if (temManifesto) ferramentas = (await built.tool.list()).map(t => t.name)
86 }
87 const papel = decidirPapel({ dispatchId, temManifesto, ferramentas })
88 const thread = threadDoDespacho(papel, await built.env.get('ORK_DISPATCH_THREAD'))
89 return { papel, thread, raiz }
90 }
91
92 // A raiz do estado (a pasta com `orkastery.yaml` e `.orkastery/`), achada uma vez por carga.
93 let raizDoEstado: string | null | undefined
94 const acharRaizDoEstado = async (): Promise<string | null> => {
95 if (raizDoEstado !== undefined) return raizDoEstado
96 const raiz = await built.session.root()
97 let achada = await subirAteOEstado(raiz, caminho => built.fs.exists(caminho))
98 if (achada === null) {
99 const git = await built.process.run(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'],
100 { cwd: raiz, timeoutMs: 5000 }).catch(() => undefined)
101 const candidata = git && git.exitCode === 0 ? raizPeloGit(git.stdout) : null
102 if (candidata !== null && await built.fs.exists(juntar(candidata, '.orkastery'))) achada = candidata
103 }
104 raizDoEstado = achada
105 return achada
106 }
107
108 const assinar = async (caminhos: readonly string[]): Promise<OrkAssinatura> => {
109 const assinatura: OrkAssinatura = []
110 for (const caminho of caminhos) {
111 const s = await built.fs.stat(caminho).catch(() => undefined)
112 assinatura.push(s ? { caminho, tamanho: s.size, mtimeMs: s.mtimeMs } : { caminho, ausente: true })
113 }
114 return assinatura
115 }
116
117 const lerLedgersRecentes = async (raiz: string): Promise<string[]> => {
118 const entradas = await built.fs.list(pastaDasThreads(raiz)).catch(() => [])
119 const ledgers: Array<{ caminho: string; mtimeMs?: number }> = []
120 for (const id of idsDeThread(entradas).slice(0, 400)) {
121 const caminho = ledgerDaThread(raiz, id)
122 const s = await built.fs.stat(caminho).catch(() => undefined)
123 ledgers.push(s ? { caminho, mtimeMs: s.mtimeMs } : { caminho })
124 }
125 return ledgersRecentes(ledgers)
126 }
127
128 const registrar = async (m: OrkMostrado): Promise<void> => {
129 const agora = new Date(await built.clock.now()).toISOString()
130 await built.store.set(CHAVE_NO_STORE, registrarMostrado(await built.store.get(CHAVE_NO_STORE), m, agora))
131 }
132
133 const ork: Ork = {
134 papel: async () => (await lerContexto()).papel,
135 contexto: async () => lerContexto(),
136 retrato: async bruto => {
137 const pedido = conferirPedidoDeRetrato(bruto)
138 const chave = chaveDoPedido(pedido)
139 const guardados = await built.state.get(RETRATOS)
140 const anterior = guardados.value?.[chave]
141 if (anterior !== undefined) {
142 const agora = await built.clock.now()
143 if (cacheValido(anterior, await assinar(anterior.caminhos), agora)) {
144 return { fonte: 'cache', lidoEm: anterior.lidoEm, retrato: anterior.retrato as OrkMaestro }
145 }
146 }
147 let retrato: unknown
148 let fonte: 'mcp' | 'cli'
149 try {
150 retrato = interpretarMcp(await built.mcp.call(SERVIDOR, FERRAMENTA_DO_RETRATO, argsDoMaestro(pedido)))
151 fonte = 'mcp'
152 } catch {
153 const argv = argvDaConsulta({ consulta: 'maestro', ...pedido })
154 retrato = interpretarCli(argv, await built.process.run(argv, { timeoutMs: TETO_DO_CLI_MS }))
155 fonte = 'cli'
156 }
157 const lidoEm = await built.clock.now()
158 // O `stat` sai depois da leitura: uma mudanca no meio fica no cache ate o teto de 60 s.
159 const caminhos = caminhosCobertos(await acharRaizDoEstado(), pedido, retrato)
160 if (caminhos.length > 0) {
161 const entrada: OrkRetratoGuardado = { fonte, lidoEm, retrato, caminhos, assinatura: await assinar(caminhos) }
162 // Escrita recusada (por exemplo, dentro de um desenho) so deixa de guardar.
163 await built.state.set(RETRATOS, guardar(guardados.value, chave, entrada), { ifVersion: guardados.version })
164 .catch(() => undefined)
165 }
166 return { fonte, lidoEm, retrato: retrato as OrkMaestro }
167 },
168 ler: async pedido => {
169 const argv = argvDaConsulta(pedido)
170 return interpretarCli(argv, await built.process.run(argv, { timeoutMs: TETO_DO_CLI_MS }))
171 },
172 faixa: async ({ mod, texto }) => {
173 if (!nomeDeModValido(mod)) throw new Error('ork-ponte: nome de mod invalido')
174 if (texto !== undefined && typeof texto !== 'string') throw new Error('ork-ponte: texto da faixa precisa ser texto')
175 let publicada = false
176 for (let i = 0; i < TENTATIVAS_DE_ESCRITA; i++) {
177 const atual = await built.state.get(FAIXA)
178 const linhas = trocarLinha(atual.value, mod, texto, await built.clock.now())
179 const escrita = await built.state.set(FAIXA, linhas, { ifVersion: atual.version })
180 if (escrita.isSet) { publicada = linhas.some(l => l.mod === mod); break }
181 }
182 if (publicada && texto !== undefined && (await lerContexto()).papel === 'dono') {
183 await registrar({ mod, canal: 'faixa', texto })
184 }
185 return { publicada }
186 },
187 vigiar: async ({ mod, caminhos }) => {
188 for (let i = 0; i < TENTATIVAS_DE_ESCRITA; i++) {
189 const atual = await built.state.get(VIGIAS)
190 const pedidos = registrarPedido(atual.value, mod, caminhos)
191 const escrita = await built.state.set(VIGIAS, pedidos, { ifVersion: atual.version })
192 if (escrita.isSet) return { caminhos: pedidos[mod] ?? [], valeNoProximoInicio: inicioRespondido }
193 }
194 throw new Error('ork-ponte: o pedido de caminhos nao foi guardado')
195 },
196 mostrado: async pedido => registrar(conferirMostrado(pedido)),
197 mostrados: async () => lerRegistro(await built.store.get(CHAVE_NO_STORE)),
198 }
199
200 interno = { contexto: lerContexto, raizDoEstado: acharRaizDoEstado, ledgersRecentes: lerLedgersRecentes }
201 raizDaPonte = built.plugin.root
202 return { ...built, ork }
203 })
204
205 // So a ponte responde `watchPaths` entre os mods do Orkastery: o que veio de baixo, os padroes e os pedidos.
206 on('classic.SessionStart', async ($, e, next) => {
207 const resultado = await next(e)
208 if (interno === undefined) return resultado
209 const contexto = await interno.contexto()
210 if (contexto.papel === 'dormente') return resultado
211 const raiz = await interno.raizDoEstado()
212 const recentes = contexto.papel === 'dono' && raiz !== null ? await interno.ledgersRecentes(raiz) : []
213 const pedidos = (await $.state.get(VIGIAS)).value
214 inicioRespondido = true
215 return { ...resultado, watchPaths: fundirCaminhos(resultado.watchPaths, caminhosPadrao(contexto.papel, raiz, contexto.thread, recentes), pedidos) }
216 }).catch(($, e, next) => next(e))
217
218 // A faixa acima do prompt: uma linha por mod; sem linha, a faixa fica com quem esta abaixo.
219 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
220 if (e.props.hasSurvey) return next(e)
221 const linhas = comporFaixa((await $.state.get(FAIXA)).value, e.props.bodyColumns, e.props.maxRows)
222 if (linhas.length === 0) return next(e)
223 const { Box, Text } = $.ui.resolve(e)
224 return desenharFaixa({ Box, Text }, linhas)
225 })
226
227 // A guarda em tempo de execucao (T0.6). Cada chamada de `$` e um evento que passa por todos os ganchos,
228 // de qualquer camada, menos o de quem chamou: a ponte ve a chamada do mod onde quer que ele esteja na
229 // cadeia. So um gancho mais externo que respondesse sem `next` a contornaria, e por isso esses eventos
230 // ficam reservados a ponte (o `plugin.register` abaixo recusa o mod que os registra). Cada gancho tem
231 // `.catch` que fecha: sem poder chamar o `$`, o `.catch` decide so com o evento e a lista ja lida.
232
233 // A lista tambem e lida no inicio, para a sessao saber logo quando a guarda esta fechada.
234 on('session.start', async ($, e, next) => {
235 const lida = await obterAutoridade(caminho => $.fs.read(caminho))
236 if (lida === null) $.ui.status(semLista())
237 return next(e)
238 })
239
240 on('process.run', async ($, e, next) => {
241 if (!ehMod(next.origin.plugin)) return next(e)
242 const a = await obterAutoridade(caminho => $.fs.read(caminho))
243 if (a === null) return { deny: semLista() }
244 const achado = avaliarArgv(a, e.argv, e.init?.stdin)
245 return achado ? { deny: recusa(achado) } : next(e)
246 }).catch(($, e, next) => {
247 if (next.called || !ehMod(next.origin.plugin)) return next(e)
248 if (!autoridade) return { deny: semLista() }
249 const achado = avaliarArgv(autoridade, e.argv, e.init?.stdin)
250 return achado ? { deny: recusa(achado) } : next(e)
251 })
252
253 on('process.spawn', async function* ($, e, next) {
254 if (ehMod(next.origin.plugin)) {
255 const a = await obterAutoridade(caminho => $.fs.read(caminho))
256 if (a === null) return { deny: semLista() }
257 const achado = avaliarArgv(a, e.argv, e.input)
258 if (achado) return { deny: recusa(achado) }
259 }
260 return yield* next(e)
261 }).catch(async function* ($, e, next) {
262 if (next.called || !ehMod(next.origin.plugin)) return yield* next(e)
263 if (!autoridade) return { deny: semLista() }
264 const achado = avaliarArgv(autoridade, e.argv, e.input)
265 if (achado) return { deny: recusa(achado) }
266 return yield* next(e)
267 })
268
269 // Mod do Orkastery que hooka evento proibido ou reservado, chama `$.prompt.submit` ou le `ORK_HITL_*`
270 // nem carrega. O nome `ork-ponte` fica reservado a esta pasta.
271 on('plugin.register', async ($, e, next) => {
272 if (e.root === raizDaPonte) return next(e)
273 if (e.name === NOME_DA_PONTE) return { refuse: `ork-ponte: o nome ${NOME_DA_PONTE} e reservado a ponte do Orkastery` }
274 const marcado = e.name.startsWith('ork-') || await $.fs.exists(juntar(e.root, MARCA_DE_MOD)).catch(() => false)
275 if (!marcado) return next(e)
276 conhecidos.add(e.name)
277 const a = await obterAutoridade(caminho => $.fs.read(caminho))
278 if (a === null) return { refuse: semLista() }
279 const achado = avaliarUses(a, e.uses)
280 return achado ? { refuse: recusa(achado) } : next(e)
281 }).catch(($, e, next) => (e.root !== raizDaPonte && (e.name.startsWith('ork-') || conhecidos.has(e.name))
282 ? { refuse: 'ork-ponte: a guarda falhou ao conferir o mod do Orkastery' } : next(e)))
283
284 // Texto enviado como se fosse do dono, vindo de mod do Orkastery, nao entra.
285 on('prompt.submit', ($, e, next) => {
286 const mod = e.origin.kind === 'plugin' && 'name' in e.origin ? e.origin.name : undefined
287 if (ehMod(mod)) {
288 return { drop: `ork-ponte: o mod ${String(mod)} nao fala pelo dono; use a faixa, uma notificacao ou a decisao autonoma do nucleo` }
289 }
290 return next(e)
291 }).catch(($, e, next) => (next.called ? next(e)
292 : ehMod(e.origin.kind === 'plugin' && 'name' in e.origin ? e.origin.name : undefined)
293 ? { drop: 'ork-ponte: a guarda falhou; texto de mod nao entra' } : next(e)))
294
295 on('session.append', ($, e, next) => {
296 const mod = e.origin.kind === 'plugin' && 'name' in e.origin ? e.origin.name : undefined
297 if (ehMod(mod) && e.message.type === 'user') {
298 return { deny: `ork-ponte: o mod ${String(mod)} nao grava linha de usuario; o modelo a leria como palavra do dono` }
299 }
300 return next(e)
301 }).catch(($, e, next) => (next.called ? next(e)
302 : ehMod(e.origin.kind === 'plugin' && 'name' in e.origin ? e.origin.name : undefined) && e.message.type === 'user'
303 ? { deny: 'ork-ponte: a guarda falhou; linha de usuario de mod nao entra' } : next(e)))
304
305 // A propria ponte le o ambiente e a lista do disco: so os outros mods do Orkastery passam por aqui.
306 on('env.get', async ($, e, next) => {
307 const origem = next.origin.plugin
308 if (origem === NOME_DA_PONTE || !ehMod(origem)) return next(e)
309 const a = await obterAutoridade(caminho => $.fs.read(caminho))
310 if (a === null) return { deny: semLista() }
311 const achado = avaliarAmbiente(a, e.name)
312 return achado ? { deny: recusa(achado) } : next(e)
313 }).catch(($, e, next) => {
314 if (next.called || next.origin.plugin === NOME_DA_PONTE || !ehMod(next.origin.plugin)) return next(e)
315 const achado = autoridade ? avaliarAmbiente(autoridade, e.name) : null
316 return autoridade && !achado ? next(e) : { deny: achado ? recusa(achado) : semLista() }
317 })
318
319 on('fs.read', async ($, e, next) => {
320 const origem = next.origin.plugin
321 if (origem === NOME_DA_PONTE || !ehMod(origem)) return next(e)
322 const a = await obterAutoridade(caminho => $.fs.read(caminho))
323 if (a === null) return { deny: semLista() }
324 const achado = avaliarCaminho(a, e.path)
325 return achado ? { deny: recusa(achado) } : next(e)
326 }).catch(($, e, next) => {
327 if (next.called || next.origin.plugin === NOME_DA_PONTE || !ehMod(next.origin.plugin)) return next(e)
328 const achado = autoridade ? avaliarCaminho(autoridade, e.path) : null
329 return autoridade && !achado ? next(e) : { deny: achado ? recusa(achado) : semLista() }
330 })
331}
332hooks/faixa.tsx 58 lines1// A faixa acima do prompt dos mods do Orkastery (RM-059, T0.4; D7 do plano dos mods). A faixa e uma
2// instancia so, e a arvore de um mod esconde a dos seguintes: por isso so a ponte desenha, uma linha por
3// mod, a partir do que cada mod publica por `$.ork.faixa`. Modulo puro; o `$` fica no register.ts.
4import type { OrkLinhaDaFaixa } from '../types'
5
6export const TETO_DE_LINHAS = 4
7export const TETO_DO_TEXTO = 200
8const NOME_DE_MOD = /^[a-z0-9][a-z0-9-]{0,63}$/
9
10export function nomeDeModValido(mod: unknown): mod is string {
11 return typeof mod === 'string' && NOME_DE_MOD.test(mod)
12}
13
14/** Troca a linha de um mod (ou tira, sem texto), uma linha por mod, texto de uma linha e com teto. */
15export function trocarLinha(linhas: readonly OrkLinhaDaFaixa[] | undefined, mod: string, texto: string | undefined,
16 agora: number): OrkLinhaDaFaixa[] {
17 const resto = (linhas ?? []).filter(l => l.mod !== mod)
18 const limpo = typeof texto === 'string' ? texto.replace(/\s+/g, ' ').trim().slice(0, TETO_DO_TEXTO) : ''
19 return limpo === '' ? resto : [...resto, { mod, texto: limpo, em: agora }]
20}
21
22function rotulo(mod: string): string {
23 return mod.startsWith('ork-') ? mod.slice(4) : mod
24}
25
26function cortar(texto: string, largura: number): string {
27 if (largura < 2) return ''
28 return texto.length <= largura ? texto : texto.slice(0, largura - 1) + '…'
29}
30
31/**
32 * As linhas que a faixa mostra: uma por mod, em ordem de nome, cortadas a largura do corpo, no maximo
33 * `TETO_DE_LINHAS` (e nunca mais que as linhas que a faixa pode ter), a ultima dizendo quantas ficaram.
34 */
35export function comporFaixa(linhas: readonly OrkLinhaDaFaixa[] | undefined, largura: number, maxLinhas: number): string[] {
36 const todas = [...(linhas ?? [])].filter(l => l.texto !== '').sort((a, b) => (a.mod < b.mod ? -1 : a.mod > b.mod ? 1 : 0))
37 const cabem = Math.max(0, Math.min(TETO_DE_LINHAS, Math.floor(maxLinhas)))
38 if (todas.length === 0 || cabem === 0) return []
39 const mostradas = todas.length <= cabem ? todas : todas.slice(0, cabem - 1)
40 const saida = mostradas.map(l => cortar(`${rotulo(l.mod)}: ${l.texto}`, largura))
41 if (mostradas.length < todas.length) saida.push(cortar(`+${todas.length - mostradas.length} mods do Orkastery`, largura))
42 return saida.filter(l => l !== '')
43}
44
45/** A arvore da faixa com os elementos que a superficie resolveu (`$.ui.resolve(e)` no register.ts). */
46export function desenharFaixa(el: { Box: any; Text: any }, linhas: readonly string[]) {
47 const { Box, Text } = el
48 return (
49 <Box flexDirection="column">
50 {linhas.map((linha, i) => (
51 <Text key={`ork-faixa-${i}`} dimColor wrap="truncate">
52 {linha}
53 </Text>
54 ))}
55 </Box>
56 )
57}
58hooks/guarda.ts 411 lines1// A regra de autoridade dos mods do Orkastery (RM-059, T0.5 e T0.6; D5 do plano dos mods): nenhum mod
2// aprova gate, permissao, nota do MASTER, push ou tag pelo dono, nem fala como o dono. A lista mora em
3// `adapters/claude-code/mods/autoridade.json`; este modulo so a interpreta, e e o mesmo casamento para a
4// guarda estatica (`core/scripts/checar-mods.cjs`, por transpilacao) e para a guarda em tempo de execucao
5// da ponte. Modulo puro: so dados entram e saem, nenhum `$` passa por aqui. E melhor esforco: argv montado
6// em tempo de execucao escapa da varredura estatica, e um script em arquivo escapa das duas.
7
8export const FORMATO_DA_AUTORIDADE = 'ork.mods-autoridade/v1'
9export const NOME_DA_PONTE = 'ork-ponte'
10export const MARCA_DE_MOD = 'orkastery-mod.json'
11
12export type RegraDeArgv = {
13 id: string
14 porque: string
15 /** Comando do `ork`: o prefixo das palavras posicionais, como o `parseArgs` do CLI as separa. */
16 sequencia?: string[]
17 /** Opcao que precisa estar junto (`--score`, `--por`, `--autorizar-push`). */
18 opcao?: string
19 /** Programa na posicao de comando (`git`, `gh`). */
20 programa?: string
21 /** Subcomando do programa (`push`, `tag`); sem ele, o programa inteiro. */
22 subcomando?: string
23}
24
25export type Autoridade = {
26 formato: string
27 regra: string
28 ganchosProibidos: Array<{ id: string; evento: string; componente?: string; porque: string }>
29 decisoesProibidas: Array<{ id: string; evento: string; decisao: string; porque: string }>
30 chamadasProibidas: Array<{ id: string; chamada: string; tipo?: string; porque: string }>
31 argvProibido: RegraDeArgv[]
32 ambienteProibido: Array<{ id: string; prefixo: string; porque: string }>
33 caminhosProibidos: Array<{ id: string; trecho: string; porque: string }>
34 reservadosAPonte: Array<{ id: string; evento: string; porque: string }>
35 permitidos: Array<{ id: string; sequencia: string[]; porque: string }>
36}
37
38/** O que a guarda achou: a regra (id) e o porque, para a recusa dizer o motivo. */
39export type Achado = { regra: string; porque: string }
40
41const SECOES = ['ganchosProibidos', 'decisoesProibidas', 'chamadasProibidas', 'argvProibido', 'ambienteProibido',
42 'caminhosProibidos', 'reservadosAPonte', 'permitidos'] as const
43
44function texto(v: unknown): v is string {
45 return typeof v === 'string' && v.trim() !== ''
46}
47
48function listaDeTexto(v: unknown): v is string[] {
49 return Array.isArray(v) && v.length > 0 && v.every(texto)
50}
51
52/** Confere a forma da lista; qualquer desvio e erro, porque a guarda fecha sem lista valida. */
53export function validarAutoridade(x: unknown): Autoridade {
54 const a = x as Record<string, unknown> | null
55 if (!a || typeof a !== 'object' || Array.isArray(a)) throw new Error('lista de autoridade nao e um objeto')
56 if (a.formato !== FORMATO_DA_AUTORIDADE) throw new Error(`formato da lista de autoridade: ${String(a.formato)}`)
57 if (!texto(a.regra)) throw new Error('lista de autoridade sem regra')
58 const ids = new Set<string>()
59 for (const secao of SECOES) {
60 const itens = a[secao]
61 if (!Array.isArray(itens)) throw new Error(`lista de autoridade sem a secao ${secao}`)
62 for (const bruto of itens) {
63 const item = bruto as Record<string, unknown>
64 if (!item || !texto(item.id) || !texto(item.porque)) throw new Error(`item sem id ou porque em ${secao}`)
65 if (ids.has(item.id as string)) throw new Error(`id repetido na lista de autoridade: ${String(item.id)}`)
66 ids.add(item.id as string)
67 const falta = (campo: string) => { throw new Error(`${secao}/${String(item.id)} sem ${campo}`) }
68 if ((secao === 'ganchosProibidos' || secao === 'reservadosAPonte' || secao === 'decisoesProibidas') && !texto(item.evento)) falta('evento')
69 if (secao === 'decisoesProibidas' && !texto(item.decisao)) falta('decisao')
70 if (secao === 'chamadasProibidas' && !texto(item.chamada)) falta('chamada')
71 if (secao === 'ambienteProibido' && !texto(item.prefixo)) falta('prefixo')
72 if (secao === 'caminhosProibidos' && !texto(item.trecho)) falta('trecho')
73 if (secao === 'permitidos' && !listaDeTexto(item.sequencia)) falta('sequencia')
74 if (secao === 'argvProibido') {
75 const porSequencia = listaDeTexto(item.sequencia)
76 const porPrograma = texto(item.programa)
77 if (porSequencia === porPrograma) throw new Error(`argvProibido/${String(item.id)} precisa de sequencia ou programa, um so`)
78 if (item.opcao !== undefined && !(texto(item.opcao) && (item.opcao as string).startsWith('--'))) falta('opcao com --')
79 if (item.subcomando !== undefined && !texto(item.subcomando)) falta('subcomando')
80 }
81 }
82 }
83 return a as unknown as Autoridade
84}
85
86/** Le e confere o texto do `autoridade.json`. */
87export function lerAutoridade(conteudo: string): Autoridade {
88 return validarAutoridade(JSON.parse(conteudo))
89}
90
91/** Mod do Orkastery: nome `ork-*` ou pasta com `orkastery-mod.json` (os conhecidos vem do `plugin.register`). */
92export function ehModDoOrkastery(nome: unknown, conhecidos?: { has: (n: string) => boolean }): boolean {
93 return typeof nome === 'string' && (nome.startsWith('ork-') || (conhecidos?.has(nome) ?? false))
94}
95
96function nomeBase(token: string): string {
97 const t = token.replace(/\/+$/, '')
98 return t.slice(t.lastIndexOf('/') + 1)
99}
100
101/** As palavras posicionais e as opcoes, exatamente como o `parseArgs` do CLI do ork as separa. */
102export function separarArgs(resto: readonly string[]): { posicionais: string[]; opcoes: Set<string> } {
103 const posicionais: string[] = []
104 const opcoes = new Set<string>()
105 for (let i = 0; i < resto.length; i++) {
106 const a = resto[i] as string
107 if (a.startsWith('--')) {
108 const igual = a.indexOf('=')
109 if (igual > 0) { opcoes.add(a.slice(2, igual)); continue }
110 opcoes.add(a.slice(2))
111 const proximo = resto[i + 1]
112 if (proximo !== undefined && !proximo.startsWith('--')) i++
113 } else posicionais.push(a)
114 }
115 return { posicionais, opcoes }
116}
117
118function comecaCom(lista: readonly string[], prefixo: readonly string[]): boolean {
119 return prefixo.length > 0 && prefixo.every((p, i) => lista[i] === p)
120}
121
122const WRAPPERS = new Set(['env', 'nohup', 'timeout', 'nice', 'ionice', 'setsid', 'stdbuf', 'sudo', 'doas', 'command',
123 'exec', 'time', 'xargs', 'chrt', 'taskset', 'unbuffer', 'then', 'do', 'else', 'if', 'while', 'until', '!'])
124const OPCOES_COM_VALOR: Record<string, readonly string[]> = {
125 env: ['-u', '--unset', '-C', '--chdir'], sudo: ['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-U'],
126 nice: ['-n', '--adjustment'], ionice: ['-c', '-n', '-p'], timeout: ['-k', '--kill-after', '-s', '--signal'],
127 xargs: ['-a', '-d', '-E', '-I', '-L', '-n', '-P', '-s'], chrt: ['-p'], taskset: ['-p'], doas: ['-u', '-C'],
128}
129const SHELLS = new Set(['sh', 'bash', 'dash', 'zsh', 'ksh', 'mksh', 'ash', 'fish', 'busybox'])
130const INTERPRETADORES: Record<string, readonly string[]> = {
131 node: ['-e', '--eval', '-p', '--print'], nodejs: ['-e', '--eval', '-p', '--print'], deno: ['eval'],
132 bun: ['-e', '--eval'], python: ['-c'], python3: ['-c'], perl: ['-e', '-E'], ruby: ['-e'], php: ['-r'],
133}
134const OPCOES_GLOBAIS_DO_GIT = ['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--super-prefix', '--config-env']
135const ENTRADA_DO_ORK = /(^|\/)(ork|ork\.c?js|ork\.mjs|index\.c?js|cli\.c?js)$/
136const MAX_PROFUNDIDADE = 4
137
138/**
139 * Tira o que so embrulha o comando (`env A=1`, `nohup`, `timeout 10`, `sudo -u x`) e devolve o comando de fato.
140 * `env` sem comando imprime o ambiente inteiro: `despejo` avisa isso.
141 */
142function semEmbrulho(tokens: readonly string[]): { tokens: string[]; scriptDoEnv?: string; despejo?: boolean } {
143 let t = [...tokens]
144 let ultimo = ''
145 for (let voltas = 0; voltas < 8 && t.length > 0; voltas++) {
146 while (t.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(t[0] as string)) t = t.slice(1)
147 const base = nomeBase(t[0] ?? '')
148 if (!WRAPPERS.has(base)) break
149 ultimo = base
150 t = t.slice(1)
151 const comValor = OPCOES_COM_VALOR[base] ?? []
152 while (t.length > 0) {
153 const a = t[0] as string
154 if (base === 'env' && (a === '-S' || a === '--split-string')) return { tokens: [], scriptDoEnv: t[1] ?? '' }
155 if (comValor.includes(a)) { t = t.slice(2); continue }
156 if (a.startsWith('-') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(a)) { t = t.slice(1); continue }
157 if (base === 'timeout' && /^\d+(\.\d+)?[smhd]?$/.test(a)) { t = t.slice(1); continue }
158 break
159 }
160 }
161 return { tokens: t, ...(t.length === 0 && ultimo === 'env' ? { despejo: true } : {}) }
162}
163
164/**
165 * O comando expoe o ambiente inteiro do processo (onde mora `ORK_HITL_*` quando o host o exporta):
166 * `printenv` sem nome, `export -p`, `declare -x`, `set` sem argumento, `compgen -e` ou `/proc/<pid>/environ`.
167 */
168function despejaOAmbiente(tokens: readonly string[]): boolean {
169 const base = nomeBase(tokens[0] ?? '')
170 const args = tokens.slice(1)
171 if (base === 'printenv') return args.every(t => t.startsWith('-'))
172 if (base === 'export') return args.length === 0 || args.includes('-p')
173 if (base === 'declare' || base === 'typeset') return args.some(t => /^-[A-Za-z]*[xp]/.test(t))
174 if (base === 'set') return args.length === 0
175 if (base === 'compgen') return args.includes('-e')
176 return tokens.some(t => /^\/proc\/[^/]+\/environ$/.test(t.replace(/\/{2,}/g, '/')))
177}
178
179/** Uma variavel da lista citada no texto (`echo $ORK_HITL_TOKEN`, `printenv ORK_HITL_TOKEN`). */
180function citaAmbienteProibido(a: Autoridade, texto: string): Achado | null {
181 const r = a.ambienteProibido.find(p => texto.includes(p.prefixo))
182 return r ? { regra: r.id, porque: r.porque } : null
183}
184
185/** Um texto que cita caminho ou variavel da lista, seja ou nao linha de comando (a varredura estatica usa). */
186export function avaliarCitacao(a: Autoridade, texto: unknown): Achado | null {
187 return typeof texto === 'string' ? avaliarCaminho(a, texto) ?? citaAmbienteProibido(a, texto) : null
188}
189
190function despejo(a: Autoridade): Achado | null {
191 const r = a.ambienteProibido[0]
192 return r ? { regra: r.id, porque: `${r.porque} (o comando imprime o ambiente inteiro)` } : null
193}
194
195/** O resto do argv quando ele chama o CLI do ork (por `ork`, `node .../index.js` ou `npx ork`); `null` se nao chama. */
196function restoDoOrk(tokens: readonly string[]): string[] | null {
197 const base = nomeBase(tokens[0] ?? '')
198 if (base === 'ork') return tokens.slice(1)
199 if (base === 'node' || base === 'nodejs') {
200 let i = 1
201 while (i < tokens.length && (tokens[i] as string).startsWith('-')) i += ['-r', '--require', '--import', '--loader'].includes(tokens[i] as string) ? 2 : 1
202 const script = tokens[i]
203 if (script !== undefined && (ENTRADA_DO_ORK.test(script) || script.includes('@orkastery/'))) return tokens.slice(i + 1)
204 }
205 return null
206}
207
208function avaliarOrk(a: Autoridade, resto: readonly string[]): Achado | null {
209 const { posicionais, opcoes } = separarArgs(resto)
210 if (a.permitidos.some(p => comecaCom(posicionais, p.sequencia))) return null
211 for (const r of a.argvProibido) {
212 if (!r.sequencia || !comecaCom(posicionais, r.sequencia)) continue
213 if (r.opcao !== undefined && !opcoes.has(r.opcao.replace(/^--/, ''))) continue
214 return { regra: r.id, porque: r.porque }
215 }
216 return null
217}
218
219function avaliarPrograma(a: Autoridade, tokens: readonly string[]): Achado | null {
220 const base = nomeBase(tokens[0] ?? '')
221 for (const r of a.argvProibido) {
222 if (!r.programa || r.programa !== base) continue
223 if (r.subcomando === undefined) return { regra: r.id, porque: r.porque }
224 let i = 1
225 while (i < tokens.length) {
226 const t = tokens[i] as string
227 if (base === 'git' && OPCOES_GLOBAIS_DO_GIT.includes(t)) { i += 2; continue }
228 if (t.startsWith('-')) { i++; continue }
229 break
230 }
231 if (tokens[i] === r.subcomando) return { regra: r.id, porque: r.porque }
232 }
233 return null
234}
235
236/** Um caminho lido ou citado que esta na lista (`.hermes/.env`), com as barras normalizadas. */
237export function avaliarCaminho(a: Autoridade, caminho: unknown): Achado | null {
238 if (typeof caminho !== 'string') return null
239 // Grafia normalizada e sem diferenca de maiusculas: num sistema que as ignora, `.HERMES/.env` e o mesmo arquivo.
240 const normal = caminho.replace(/\\/g, '/').replace(/\/(\.\/)+/g, '/').replace(/\/{2,}/g, '/').toLowerCase()
241 const r = a.caminhosProibidos.find(c => normal.includes(c.trecho.toLowerCase()))
242 return r ? { regra: r.id, porque: r.porque } : null
243}
244
245/** Uma variavel de ambiente da lista (`ORK_HITL_*`). */
246export function avaliarAmbiente(a: Autoridade, nome: unknown): Achado | null {
247 if (typeof nome !== 'string') return null
248 const r = a.ambienteProibido.find(p => nome.startsWith(p.prefixo))
249 return r ? { regra: r.id, porque: r.porque } : null
250}
251
252/** Separa um texto de shell em comandos e palavras: aspas respeitadas; `;`, `&`, `|`, `(`, `)`, crase e `$(` cortam. */
253export function comandosDoTexto(textoDoShell: string): string[][] {
254 const comandos: string[][] = []
255 let atual: string[] = []
256 let palavra = ''
257 let temPalavra = false
258 let aspa: '"' | "'" | null = null
259 const fecharPalavra = () => { if (temPalavra) atual.push(palavra); palavra = ''; temPalavra = false }
260 const fecharComando = () => { fecharPalavra(); if (atual.length > 0) comandos.push(atual); atual = [] }
261 for (let i = 0; i < textoDoShell.length; i++) {
262 const c = textoDoShell[i] as string
263 if (aspa !== null) {
264 if (c === aspa) aspa = null
265 else palavra += c
266 continue
267 }
268 if (c === '"' || c === "'") { aspa = c; temPalavra = true; continue }
269 if (c === '\\' && i + 1 < textoDoShell.length) { palavra += textoDoShell[++i]; temPalavra = true; continue }
270 if (c === ' ' || c === '\t') { fecharPalavra(); continue }
271 if (c === ';' || c === '&' || c === '|' || c === '\n' || c === '(' || c === ')' || c === '`' || c === '{' || c === '}') { fecharComando(); continue }
272 if (c === '$' && textoDoShell[i + 1] === '(') { fecharComando(); i++; continue }
273 palavra += c
274 temPalavra = true
275 }
276 fecharComando()
277 return comandos
278}
279
280/** Codigo de interpretador (`node -e`, `python -c`): leitura solta, por palavras, so para pegar o obvio. */
281function avaliarCodigoSolto(a: Autoridade, codigo: string): Achado | null {
282 const palavras = codigo.split(/[^A-Za-z0-9_.\/@-]+/).filter(Boolean)
283 for (let i = 0; i < palavras.length; i++) {
284 const base = nomeBase(palavras[i] as string)
285 for (const r of a.argvProibido) {
286 if (r.programa && r.programa === base) {
287 if (r.subcomando === undefined || palavras.slice(i + 1, i + 6).includes(r.subcomando)) return { regra: r.id, porque: r.porque }
288 }
289 if (r.sequencia && comecaCom(palavras.slice(i), r.sequencia)) {
290 if (r.opcao === undefined || palavras.includes(r.opcao)) return { regra: r.id, porque: r.porque }
291 }
292 }
293 }
294 return avaliarCaminho(a, codigo)
295}
296
297/** Um texto de shell: cada comando dele passa pela regra de argv. */
298export function avaliarTexto(a: Autoridade, textoDoShell: string, profundidade = 0): Achado | null {
299 const direto = avaliarCaminho(a, textoDoShell) ?? citaAmbienteProibido(a, textoDoShell)
300 if (direto) return direto
301 for (const comando of comandosDoTexto(textoDoShell)) {
302 const achado = avaliarArgv(a, comando, undefined, profundidade + 1)
303 if (achado) return achado
304 }
305 return null
306}
307
308/**
309 * Um argv que um mod quer rodar (`$.process.run`, `$.process.spawn`), com o texto do `stdin` quando o comando e
310 * um shell que le o script da entrada. Devolve o primeiro achado ou `null`.
311 */
312export function avaliarArgv(a: Autoridade, argv: readonly unknown[], stdin?: unknown, profundidade = 0): Achado | null {
313 if (profundidade > MAX_PROFUNDIDADE) return { regra: 'profundidade', porque: 'comando aninhado demais para a guarda conferir' }
314 const brutos = argv.map(t => String(t))
315 for (const t of brutos) {
316 const achado = avaliarCaminho(a, t) ?? citaAmbienteProibido(a, t)
317 if (achado) return achado
318 }
319 if (typeof stdin === 'string') {
320 const noStdin = citaAmbienteProibido(a, stdin)
321 if (noStdin) return noStdin
322 }
323 const { tokens, scriptDoEnv, despejo: soEnv } = semEmbrulho(brutos)
324 if (scriptDoEnv !== undefined) return avaliarTexto(a, scriptDoEnv, profundidade)
325 if (soEnv || despejaOAmbiente(tokens)) return despejo(a)
326 if (tokens.length === 0) return null
327 const base = nomeBase(tokens[0] as string)
328 if (base === 'eval') return avaliarTexto(a, tokens.slice(1).join(' '), profundidade)
329 if (SHELLS.has(base)) {
330 const i = tokens.findIndex((t, j) => j > 0 && /^-[A-Za-z]*c[A-Za-z]*$/.test(t))
331 if (i > 0) return tokens[i + 1] === undefined ? null : avaliarTexto(a, tokens[i + 1] as string, profundidade)
332 const temScript = tokens.slice(1).some(t => !t.startsWith('-'))
333 if (!temScript && typeof stdin === 'string') return avaliarTexto(a, stdin, profundidade)
334 return null
335 }
336 const inline = INTERPRETADORES[base]
337 if (inline) {
338 const i = tokens.findIndex((t, j) => j > 0 && inline.includes(t))
339 if (i > 0 && tokens[i + 1] !== undefined) return avaliarCodigoSolto(a, tokens[i + 1] as string)
340 }
341 if (['npx', 'pnpx', 'bunx'].includes(base)) {
342 let i = 1
343 while (i < tokens.length && (tokens[i] as string).startsWith('-')) i++
344 const alvo = tokens[i] ?? ''
345 if (alvo === 'ork' || alvo.startsWith('@orkastery/')) return avaliarOrk(a, tokens.slice(i + 1))
346 return avaliarArgv(a, tokens.slice(i), undefined, profundidade + 1)
347 }
348 if (['npm', 'pnpm', 'yarn', 'bun'].includes(base)) {
349 // `npm exec`, `pnpm dlx`, `yarn run` e tambem `yarn <bin>` e `pnpm <bin>`, que rodam o binario sem subcomando, e
350 // opcao com valor no meio (`yarn --cwd x ork ...`): cada posicao depois do gerenciador e tentada como comando.
351 for (let i = 1; i < tokens.length && i <= 12; i++) {
352 const alvo = tokens[i] as string
353 if (alvo.startsWith('-')) continue
354 const achado = alvo === 'ork' || alvo.startsWith('@orkastery/') ? avaliarOrk(a, tokens.slice(i + 1))
355 : avaliarArgv(a, tokens.slice(i), undefined, profundidade + 1)
356 if (achado) return achado
357 }
358 return null
359 }
360 const programa = avaliarPrograma(a, tokens)
361 if (programa) return programa
362 const resto = restoDoOrk(tokens)
363 return resto === null ? null : avaliarOrk(a, resto)
364}
365
366type Glob = { exato: string } | { prefixo: string }
367
368function glob(padrao: string): Glob {
369 if (padrao === '*') return { prefixo: '' }
370 return padrao.endsWith('.*') ? { prefixo: padrao.slice(0, -1) } : { exato: padrao }
371}
372
373/** O padrao de um `on(...)` (`*`, `fs.*`, `fs.read`) alcanca o evento ou o padrao da lista? */
374export function sobrepoe(padraoDoMod: string, padraoDaLista: string): boolean {
375 if (padraoDoMod.startsWith('!')) return false
376 if (padraoDoMod.startsWith('telemetry.') || padraoDaLista.startsWith('telemetry.')) return padraoDoMod === padraoDaLista
377 const m = glob(padraoDoMod)
378 const l = glob(padraoDaLista)
379 if ('exato' in m && 'exato' in l) return m.exato === l.exato
380 if ('exato' in m) return m.exato.startsWith((l as { prefixo: string }).prefixo)
381 if ('exato' in l) return l.exato.startsWith(m.prefixo)
382 return m.prefixo.startsWith(l.prefixo) || l.prefixo.startsWith(m.prefixo)
383}
384
385/** Ganchos de um mod (os padroes do `on`): proibidos (sem componente) ou reservados a ponte. */
386export function avaliarEventos(a: Autoridade, eventos: readonly string[], ehPonte = false): Achado | null {
387 for (const evento of eventos) {
388 const proibido = a.ganchosProibidos.find(g => g.componente === undefined && sobrepoe(evento, g.evento))
389 if (proibido) return { regra: proibido.id, porque: proibido.porque }
390 if (ehPonte) continue
391 const reservado = a.reservadosAPonte.find(r => sobrepoe(evento, r.evento))
392 if (reservado) return { regra: reservado.id, porque: `gancho reservado a ponte: ${reservado.porque}` }
393 }
394 return null
395}
396
397/** O que o motor escaneou de um modulo no `plugin.register`: ganchos, chamadas e variaveis lidas. */
398export function avaliarUses(a: Autoridade, uses: { events?: readonly string[]; calls?: readonly string[]; env?: { reads?: readonly string[] } },
399 ehPonte = false): Achado | null {
400 const eventos = avaliarEventos(a, uses.events ?? [], ehPonte)
401 if (eventos) return eventos
402 for (const c of a.chamadasProibidas) {
403 if (c.tipo === undefined && (uses.calls ?? []).includes(c.chamada)) return { regra: c.id, porque: c.porque }
404 }
405 for (const nome of uses.env?.reads ?? []) {
406 const achado = avaliarAmbiente(a, nome)
407 if (achado) return achado
408 }
409 return null
410}
411hooks/mostrado.ts 56 lines1// O registro do que um mod mostrou ao dono (RM-059, M5 do dono; D8 da thread). O Telegram nao deve
2// repetir o que o dono ja viu no terminal: a ponte guarda o que foi mostrado num formato versionado, no
3// proprio `$.store`, para o nucleo deduplicar depois. Um item por mod, chave e texto. Modulo puro.
4import type { OrkCanal, OrkMostrado, OrkRegistroDeMostrados } from '../types'
5
6export const FORMATO_DO_MOSTRADO = 'ork.mod-mostrado/v1'
7export const CHAVE_NO_STORE = 'mostrados'
8export const TETO_DE_MOSTRADOS = 200
9export const TETO_DO_TEXTO_MOSTRADO = 500
10export const CANAIS: readonly OrkCanal[] = ['faixa', 'painel', 'notificacao', 'toast', 'status', 'transcript']
11const NOME_DE_MOD = /^[a-z0-9][a-z0-9-]{0,63}$/
12const ID_DE_THREAD = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/
13
14function vazio(): OrkRegistroDeMostrados {
15 return { formato: FORMATO_DO_MOSTRADO, itens: [] }
16}
17
18/** O registro guardado, ou um vazio se o que estava no store nao e deste formato. */
19export function lerRegistro(guardado: unknown): OrkRegistroDeMostrados {
20 const r = guardado as Partial<OrkRegistroDeMostrados> | null | undefined
21 if (!r || r.formato !== FORMATO_DO_MOSTRADO || !Array.isArray(r.itens)) return vazio()
22 return { formato: FORMATO_DO_MOSTRADO, itens: r.itens.filter(i => i && typeof i.mod === 'string' && typeof i.texto === 'string') }
23}
24
25/** Confere o que o mod diz ter mostrado. */
26export function conferirMostrado(m: unknown): OrkMostrado {
27 const x = (m ?? {}) as Record<string, unknown>
28 if (typeof x.mod !== 'string' || !NOME_DE_MOD.test(x.mod)) throw new Error('ork-ponte: nome de mod invalido')
29 if (typeof x.canal !== 'string' || !CANAIS.includes(x.canal as OrkCanal)) throw new Error(`ork-ponte: canal desconhecido: ${String(x.canal)}`)
30 if (typeof x.texto !== 'string' || x.texto.trim() === '') throw new Error('ork-ponte: texto mostrado vazio')
31 if (x.chave !== undefined && (typeof x.chave !== 'string' || x.chave.length > 200)) throw new Error('ork-ponte: chave invalida')
32 if (x.thread !== undefined && (typeof x.thread !== 'string' || !ID_DE_THREAD.test(x.thread))) throw new Error('ork-ponte: id de thread invalido')
33 return {
34 mod: x.mod, canal: x.canal as OrkCanal, texto: x.texto,
35 ...(x.chave !== undefined ? { chave: x.chave as string } : {}),
36 ...(x.thread !== undefined ? { thread: x.thread as string } : {}),
37 }
38}
39
40/**
41 * Acrescenta o que foi mostrado: o mesmo mod, chave e texto so ganham `ultimaVez` e mais uma vez; o resto
42 * entra no fim. Acima do teto saem os vistos ha mais tempo.
43 */
44export function registrarMostrado(guardado: unknown, m: OrkMostrado, agoraIso: string): OrkRegistroDeMostrados {
45 const r = lerRegistro(guardado)
46 const texto = m.texto.replace(/\s+/g, ' ').trim().slice(0, TETO_DO_TEXTO_MOSTRADO)
47 const chave = m.chave ?? null
48 const mesmo = (i: OrkRegistroDeMostrados['itens'][number]) => i.mod === m.mod && i.chave === chave && i.texto === texto
49 const anterior = r.itens.find(mesmo)
50 const resto = r.itens.filter(i => !mesmo(i))
51 const item = anterior
52 ? { ...anterior, canal: m.canal, ultimaVez: agoraIso, vezes: anterior.vezes + 1 }
53 : { mod: m.mod, canal: m.canal, chave, thread: m.thread ?? null, texto, primeiraVez: agoraIso, ultimaVez: agoraIso, vezes: 1 }
54 return { formato: FORMATO_DO_MOSTRADO, itens: [...resto, item].slice(-TETO_DE_MOSTRADOS) }
55}
56hooks/nucleo.ts 158 lines1// A leitura do nucleo pela ponte (RM-059, T0.3; D3 e D10 da thread). Modulo puro: monta o pedido ao
2// `ork_maestro`, a lista fechada de consultas do CLI e o cache por `stat`. As chamadas ao `$` ficam
3// no register.ts. Dados so pelo nucleo: nada aqui le ou escreve estado em `.orkastery/`.
4import type { OrkAssinatura, OrkConsulta, OrkPedidoDeLeitura, OrkPedidoDeRetrato, OrkRetratoGuardado, OrkSecao } from '../types'
5
6export const SERVIDOR = 'orkastery'
7export const FERRAMENTA_DO_RETRATO = 'ork_maestro'
8/** Teto de cada consulta pelo CLI (D10): o mesmo teto total do retrato no nucleo. */
9export const TETO_DO_CLI_MS = 10000
10/** O cache vale enquanto os ledgers cobertos nao mudam, e nunca por mais que isto. */
11export const TETO_DO_CACHE_MS = 60000
12export const TETO_DE_ENTRADAS = 16
13export const TETO_DE_LEDGERS = 50
14/** As secoes do `ork.maestro-snapshot/v1` (core/src/maestro-contract.ts). */
15export const SECOES: readonly OrkSecao[] = ['portfolio', 'demands', 'threads', 'sessions', 'blockers', 'leases',
16 'retries', 'hitl', 'ship', 'master', 'nextActions']
17
18const ID_DE_THREAD = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/
19
20/** A lista fechada de consultas de leitura (D10): o argv depois de `ork`, e se exige thread. */
21export const CONSULTAS: Readonly<Record<OrkConsulta, { argv: readonly string[]; exigeThread: boolean }>> = {
22 'maestro': { argv: ['maestro', '--json'], exigeThread: false },
23 'thread.status': { argv: ['thread', 'status', '<thread>', '--json'], exigeThread: true },
24 'thread.list': { argv: ['thread', 'list', '--json'], exigeThread: false },
25 'conducao.status': { argv: ['conducao', 'status', '<thread>', '--json'], exigeThread: true },
26 'retry.plan': { argv: ['retry', 'plan', '<thread>', '--json'], exigeThread: true },
27 'retry.list': { argv: ['retry', 'list', '--json'], exigeThread: false },
28 'pulse': { argv: ['pulse', '--json'], exigeThread: false },
29 'orquestracao.status': { argv: ['orquestracao', 'status', '--json'], exigeThread: false },
30 'sessions.hitl': { argv: ['sessions', 'hitl', '--json'], exigeThread: false },
31 'roadmap.status': { argv: ['roadmap', 'status', '--json'], exigeThread: false },
32 'accounts.esgotamentos': { argv: ['accounts', 'esgotamentos', '--json'], exigeThread: false },
33 'decisao.placar': { argv: ['decisao', 'placar', '<thread>', '--json'], exigeThread: true },
34}
35
36export class ErroDeLeitura extends Error {}
37
38function conferirThread(thread: unknown): string | undefined {
39 if (thread === undefined) return undefined
40 if (typeof thread !== 'string' || !ID_DE_THREAD.test(thread)) throw new ErroDeLeitura('ork-ponte: id de thread invalido')
41 return thread
42}
43
44/** Confere o pedido de retrato: secao conhecida, offset inteiro e so junto da secao. */
45export function conferirPedidoDeRetrato(p: OrkPedidoDeRetrato | undefined): OrkPedidoDeRetrato {
46 const pedido = p ?? {}
47 const thread = conferirThread(pedido.thread)
48 if (pedido.secao !== undefined && !SECOES.includes(pedido.secao)) throw new ErroDeLeitura(`ork-ponte: secao desconhecida: ${String(pedido.secao)}`)
49 if (pedido.offset !== undefined) {
50 if (pedido.secao === undefined) throw new ErroDeLeitura('ork-ponte: offset so vale com secao')
51 if (!Number.isInteger(pedido.offset) || pedido.offset < 0 || pedido.offset > 100000) throw new ErroDeLeitura('ork-ponte: offset fora da faixa')
52 }
53 return { ...(thread ? { thread } : {}), ...(pedido.secao ? { secao: pedido.secao, offset: pedido.offset ?? 0 } : {}) }
54}
55
56/** Os argumentos do `ork_maestro`: secao e offset andam juntos, como o nucleo exige. */
57export function argsDoMaestro(p: OrkPedidoDeRetrato): Record<string, unknown> {
58 const a: Record<string, unknown> = {}
59 if (p.thread) a.threadId = p.thread
60 if (p.secao) { a.section = p.secao; a.offset = p.offset ?? 0 }
61 return a
62}
63
64/** O argv completo de uma consulta da lista fechada; qualquer outra coisa e recusada. */
65export function argvDaConsulta(p: OrkPedidoDeLeitura & { secao?: OrkSecao; offset?: number }): string[] {
66 const modelo = Object.prototype.hasOwnProperty.call(CONSULTAS, p.consulta) ? CONSULTAS[p.consulta] : undefined
67 if (!modelo) throw new ErroDeLeitura(`ork-ponte: consulta fora da lista fechada: ${String(p.consulta)}`)
68 const thread = conferirThread(p.thread)
69 if (modelo.exigeThread && !thread) throw new ErroDeLeitura(`ork-ponte: a consulta ${p.consulta} exige thread`)
70 const argv = ['ork', ...modelo.argv.map(a => (a === '<thread>' ? (thread as string) : a))]
71 if (p.consulta === 'maestro') {
72 const r = conferirPedidoDeRetrato({ thread, secao: p.secao, offset: p.offset })
73 if (r.thread) argv.push('--thread', r.thread)
74 if (r.secao) argv.push('--section', r.secao, '--offset', String(r.offset ?? 0))
75 }
76 return argv
77}
78
79/** O JSON que o `ork_maestro` devolve no primeiro bloco de texto; recusa resultado com erro. */
80export function interpretarMcp(r: { content?: ReadonlyArray<{ type: string; text?: string }>; isError?: boolean }): unknown {
81 if (r.isError) throw new ErroDeLeitura('ork-ponte: o ork_maestro respondeu com erro')
82 const texto = (r.content ?? []).find(c => c.type === 'text' && typeof c.text === 'string')?.text
83 if (texto === undefined) throw new ErroDeLeitura('ork-ponte: o ork_maestro nao devolveu texto')
84 return JSON.parse(texto)
85}
86
87/** O JSON que o CLI imprime; saida diferente de zero vira erro com o fim do stderr. */
88export function interpretarCli(argv: readonly string[], r: { exitCode: number; stdout: string; stderr: string }): unknown {
89 if (r.exitCode !== 0) {
90 const fim = r.stderr.trim().split('\n').slice(-2).join(' ').slice(0, 300)
91 throw new ErroDeLeitura(`ork-ponte: ${argv.slice(0, 3).join(' ')} saiu ${r.exitCode}${fim ? `: ${fim}` : ''}`)
92 }
93 return JSON.parse(r.stdout)
94}
95
96export type Assinatura = OrkAssinatura
97export type EntradaDoCache = OrkRetratoGuardado
98
99export function chaveDoPedido(p: OrkPedidoDeRetrato): string {
100 return JSON.stringify([p.thread ?? null, p.secao ?? null, p.offset ?? null])
101}
102
103function pai(dir: string): string {
104 const sem = dir.replace(/\/+$/, '')
105 const i = sem.lastIndexOf('/')
106 return i <= 0 ? '/' : sem.slice(0, i)
107}
108
109function juntar(pasta: string, nome: string): string {
110 return pasta.replace(/\/+$/, '') + '/' + nome
111}
112
113/** Os ledgers que o retrato cobre: o da thread pedida, ou os das threads da secao `threads` e a pasta delas. */
114export function caminhosCobertos(raizDoEstado: string | null, p: OrkPedidoDeRetrato, retrato: unknown): string[] {
115 if (!raizDoEstado) return []
116 const base = juntar(raizDoEstado, '.orkastery/threads')
117 if (p.thread) return [juntar(base, `${p.thread}/ledger.jsonl`)]
118 const itens = (retrato as { sections?: { threads?: { items?: Array<{ id?: unknown }> } } } | null)?.sections?.threads?.items ?? []
119 const ids = [...new Set(itens.map(i => i.id).filter((id): id is string => typeof id === 'string' && ID_DE_THREAD.test(id)))]
120 return [base, ...ids.slice(0, TETO_DE_LEDGERS).map(id => juntar(base, `${id}/ledger.jsonl`))]
121}
122
123/** O cache serve so com a mesma assinatura de `stat` e dentro do teto de idade. */
124export function cacheValido(e: EntradaDoCache | undefined, atual: Assinatura, agora: number, teto = TETO_DO_CACHE_MS): boolean {
125 if (!e || e.caminhos.length === 0) return false
126 const idade = agora - e.lidoEm
127 if (!(idade >= 0 && idade < teto)) return false
128 return JSON.stringify(e.assinatura) === JSON.stringify(atual)
129}
130
131/** Guarda a entrada e corta as mais velhas acima do teto de entradas. */
132export function guardar(cache: Readonly<Record<string, EntradaDoCache>> | undefined, chave: string, e: EntradaDoCache,
133 teto = TETO_DE_ENTRADAS): Record<string, EntradaDoCache> {
134 const todas = Object.entries({ ...(cache ?? {}), [chave]: e }).sort((a, b) => b[1].lidoEm - a[1].lidoEm)
135 return Object.fromEntries(todas.slice(0, teto))
136}
137
138/**
139 * Acha a raiz do estado subindo da raiz da sessao ate a pasta com `orkastery.yaml` e `.orkastery/`
140 * ao lado (a worktree da thread fica dentro do checkout principal). `existe` vem do register.ts.
141 */
142export async function subirAteOEstado(raizDaSessao: string, existe: (caminho: string) => Promise<boolean>, niveis = 8): Promise<string | null> {
143 let dir = raizDaSessao.replace(/\/+$/, '') || '/'
144 for (let i = 0; i <= niveis; i++) {
145 if (await existe(juntar(dir, 'orkastery.yaml')) && await existe(juntar(dir, '.orkastery'))) return dir
146 const acima = pai(dir)
147 if (acima === dir) break
148 dir = acima
149 }
150 return null
151}
152
153/** A raiz do checkout principal pelo `git rev-parse --git-common-dir`, quando ele termina em `/.git`. */
154export function raizPeloGit(saida: string): string | null {
155 const linha = saida.trim().split('\n')[0] ?? ''
156 return /^\/.*\/\.git\/?$/.test(linha) ? linha.replace(/\/\.git\/?$/, '') : null
157}
158hooks/papel.ts 43 lines1// O papel da sessao (RM-059, D4 do plano dos mods). Modulo puro: so dados entram e saem, para o
2// teste do nucleo transpila-lo sem o Claude Code. Nenhum `$` passa por aqui.
3import type { OrkPapel } from '../types'
4
5/** So o servidor MCP sem thread registra esta ferramenta (core/src/mcp-server.ts). */
6export const FERRAMENTA_DO_DONO = 'mcp__orkastery__ork_request_decision'
7export const MANIFESTO_DO_PROJETO = 'orkastery.yaml'
8const ID_DE_THREAD = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/
9
10export type EntradaDoPapel = {
11 /** O `ORK_DISPATCH_ID` que o despacho passa pelo `--settings`. */
12 dispatchId: string | undefined
13 /** A raiz da sessao tem `orkastery.yaml`. */
14 temManifesto: boolean
15 /** Os nomes das ferramentas que o modelo tem agora (`$.tool.list()`). */
16 ferramentas: readonly string[]
17}
18
19/**
20 * `fase` com `ORK_DISPATCH_ID`; `dono` sem ele, com `orkastery.yaml` na raiz e o servidor `orkastery`
21 * sem thread conectado; `dormente` no resto, quando nenhum gancho da ponte age alem do inicio.
22 */
23export function decidirPapel(e: EntradaDoPapel): OrkPapel {
24 if (typeof e.dispatchId === 'string' && e.dispatchId.trim() !== '') return 'fase'
25 if (e.temManifesto && e.ferramentas.includes(FERRAMENTA_DO_DONO)) return 'dono'
26 return 'dormente'
27}
28
29/** A thread do despacho na sessao de fase, so com id valido; `null` fora dela. */
30export function threadDoDespacho(papel: OrkPapel, thread: string | undefined): string | null {
31 if (papel !== 'fase' || typeof thread !== 'string') return null
32 return ID_DE_THREAD.test(thread) ? thread : null
33}
34
35export function idDeThreadValido(thread: unknown): thread is string {
36 return typeof thread === 'string' && ID_DE_THREAD.test(thread)
37}
38
39/** Junta pasta e nome com uma barra so, sem depender de `node:path` (o mod nao tem Node). */
40export function juntar(pasta: string, nome: string): string {
41 return pasta.replace(/\/+$/, '') + '/' + nome.replace(/^\/+/, '')
42}
43hooks/vigia.ts 77 lines1// Os caminhos vigiados dos mods do Orkastery (RM-059, T0.4; D7 do plano dos mods e D12 da thread). Os
2// `watchPaths` de varios ganchos de `classic.SessionStart` se fundem e a ultima escrita vence: por isso so
3// a ponte responde, juntando o que veio de baixo, os padroes do papel e os pedidos dos mods. Modulo puro.
4import type { OrkPapel } from '../types'
5
6export const TETO_DE_CAMINHOS = 64
7export const TETO_DE_LEDGERS_DO_DONO = 48
8export const TETO_POR_MOD = 16
9const ID_DE_THREAD = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/
10const NOME_DE_MOD = /^[a-z0-9][a-z0-9-]{0,63}$/
11
12function juntar(pasta: string, nome: string): string {
13 return pasta.replace(/\/+$/, '') + '/' + nome
14}
15
16export function pastaDasThreads(raizDoEstado: string): string {
17 return juntar(raizDoEstado, '.orkastery/threads')
18}
19
20export function ledgerDaThread(raizDoEstado: string, thread: string): string {
21 return juntar(pastaDasThreads(raizDoEstado), `${thread}/ledger.jsonl`)
22}
23
24/** Ids de thread validos entre as entradas da pasta das threads (so pastas). */
25export function idsDeThread(entradas: ReadonlyArray<{ name: string; kind: string }>): string[] {
26 return entradas.filter(e => e.kind === 'dir' && ID_DE_THREAD.test(e.name)).map(e => e.name).sort()
27}
28
29/** Os ledgers mais recentes por `mtimeMs`, com teto; ausente (sem `mtimeMs`) fica de fora. */
30export function ledgersRecentes(ledgers: ReadonlyArray<{ caminho: string; mtimeMs?: number }>, teto = TETO_DE_LEDGERS_DO_DONO): string[] {
31 return ledgers.filter(l => typeof l.mtimeMs === 'number')
32 .sort((a, b) => (b.mtimeMs as number) - (a.mtimeMs as number) || (a.caminho < b.caminho ? -1 : 1))
33 .slice(0, teto).map(l => l.caminho)
34}
35
36/** O que a ponte vigia por padrao: o ledger da thread na fase; a pasta e os ledgers recentes no dono. */
37export function caminhosPadrao(papel: OrkPapel, raizDoEstado: string | null, thread: string | null, recentes: readonly string[]): string[] {
38 if (raizDoEstado === null) return []
39 if (papel === 'fase') return thread !== null && ID_DE_THREAD.test(thread) ? [ledgerDaThread(raizDoEstado, thread)] : []
40 if (papel === 'dono') return [pastaDasThreads(raizDoEstado), ...recentes]
41 return []
42}
43
44/** Confere e guarda o pedido de um mod: caminhos absolutos, sem `..`, com teto por mod. */
45export function registrarPedido(pedidos: Readonly<Record<string, string[]>> | undefined, mod: unknown,
46 caminhos: unknown): Record<string, string[]> {
47 if (typeof mod !== 'string' || !NOME_DE_MOD.test(mod)) throw new Error('ork-ponte: nome de mod invalido')
48 if (!Array.isArray(caminhos)) throw new Error('ork-ponte: caminhos precisa ser uma lista')
49 const validos: string[] = []
50 for (const c of caminhos) {
51 if (typeof c !== 'string' || !c.startsWith('/') || c.split('/').includes('..')) throw new Error('ork-ponte: caminho vigiado precisa ser absoluto e sem ..')
52 if (!validos.includes(c)) validos.push(c)
53 }
54 if (validos.length > TETO_POR_MOD) throw new Error(`ork-ponte: no maximo ${TETO_POR_MOD} caminhos por mod`)
55 const todos = { ...(pedidos ?? {}) }
56 if (validos.length === 0) delete todos[mod]
57 else todos[mod] = validos
58 return todos
59}
60
61/**
62 * O `watchPaths` final: primeiro o que veio de baixo (nunca cortado, nao e da ponte), depois os padroes e
63 * os pedidos dos mods em ordem de nome, sem duplicata, ate o teto.
64 */
65export function fundirCaminhos(deBaixo: readonly string[] | undefined, padrao: readonly string[],
66 pedidos: Readonly<Record<string, readonly string[]>> | undefined, teto = TETO_DE_CAMINHOS): string[] {
67 const saida: string[] = []
68 for (const c of deBaixo ?? []) if (!saida.includes(c)) saida.push(c)
69 const limite = Math.max(teto, saida.length)
70 const nossos = [...padrao, ...Object.keys(pedidos ?? {}).sort().flatMap(m => (pedidos ?? {})[m] ?? [])]
71 for (const c of nossos) {
72 if (saida.length >= limite) break
73 if (!saida.includes(c)) saida.push(c)
74 }
75 return saida
76}
77types/index.d.ts 179 lines1// Contrato do noun `$.ork`, que a ponte acrescenta ao `$` no `engine.create` (RM-059, onda 0).
2// Um mod do Orkastery lista `ork-ponte` em `dependencies` no proprio plugin.json e recebe estes tipos
3// em `.claude-plugin/types/ork-ponte/`. Todo metodo recebe um objeto so: o host recusa noun com mais
4// de um argumento. Sem import nem referencia, como o motor exige de um contrato. O noun e servico aberto
5// a qualquer plugin carregado: o campo `mod` e o nome que quem chama declara, nao uma identidade; a
6// autoridade de cada mod fica com a guarda da ponte, que olha a origem real de cada chamada de `$`.
7
8/**
9 * O papel da sessao: `dono` na sessao interativa do dono (servidor `orkastery` sem thread),
10 * `fase` na sessao despachada pelo nucleo (`ORK_DISPATCH_ID`), `dormente` fora do Orkastery.
11 */
12export type OrkPapel = 'dono' | 'fase' | 'dormente'
13
14/** O que a ponte sabe da sessao, calculado a cada chamada (o MCP pode conectar depois do inicio). */
15export type OrkContexto = {
16 papel: OrkPapel
17 /** A thread do despacho, so na sessao de fase e com id valido. */
18 thread: string | null
19 /** A raiz da sessao, absoluta. */
20 raiz: string
21}
22
23/** As secoes do retrato `ork.maestro-snapshot/v1` do nucleo. */
24export type OrkSecao = 'portfolio' | 'demands' | 'threads' | 'sessions' | 'blockers' | 'leases' | 'retries' | 'hitl'
25 | 'ship' | 'master' | 'nextActions'
26
27/** A lista fechada de consultas de leitura do CLI, todas com `--json` (D10). */
28export type OrkConsulta = 'maestro' | 'thread.status' | 'thread.list' | 'conducao.status' | 'retry.plan' | 'retry.list'
29 | 'pulse' | 'orquestracao.status' | 'sessions.hitl' | 'roadmap.status' | 'accounts.esgotamentos' | 'decisao.placar'
30
31/** O retrato inteiro, uma secao (com `offset`, padrao 0) ou o recorte de uma thread. */
32export type OrkPedidoDeRetrato = {
33 secao?: OrkSecao
34 thread?: string
35 offset?: number
36}
37
38export type OrkPedidoDeLeitura = {
39 consulta: OrkConsulta
40 /** Obrigatoria em `thread.status`, `conducao.status`, `retry.plan` e `decisao.placar`. */
41 thread?: string
42}
43
44/** A acao que o nucleo declara para um item, quando declara (projecao de `ork.maestro-snapshot/v1`). */
45export type OrkAcaoDoMaestro = {
46 operation: string
47 available: boolean
48 reason: string
49 preconditions: string[]
50 readback: string
51}
52
53export type OrkItemDoMaestro = {
54 id: string
55 title: string
56 status: string
57 refs: Array<{ kind: string; id: string }>
58 facts: Record<string, string | number | boolean | null>
59 action?: OrkAcaoDoMaestro
60}
61
62export type OrkSecaoDoMaestro = {
63 state: 'available' | 'empty' | 'unavailable' | 'stale' | 'conflict'
64 source: string
65 observedAt: string
66 items: OrkItemDoMaestro[]
67 gaps: string[]
68}
69
70/** Projecao minima do `ork.maestro-snapshot/v1`; o resto do objeto chega como o nucleo mandou. */
71export type OrkMaestro = {
72 schema: 'ork.maestro-snapshot/v1'
73 observedAt: string
74 sections: Partial<Record<OrkSecao, OrkSecaoDoMaestro>>
75 gaps: string[]
76}
77
78export type OrkRetrato = {
79 /** De onde veio: o MCP do nucleo, a reserva pelo CLI ou o cache da ponte. */
80 fonte: 'mcp' | 'cli' | 'cache'
81 /** Quando a ponte leu, em ms desde a epoca (relogio do `$.clock`). */
82 lidoEm: number
83 retrato: OrkMaestro
84}
85
86/** O `stat` dos ledgers cobertos por um retrato guardado. */
87export type OrkAssinatura = Array<{ caminho: string; tamanho: number; mtimeMs: number } | { caminho: string; ausente: true }>
88
89export type OrkRetratoGuardado = {
90 fonte: 'mcp' | 'cli'
91 lidoEm: number
92 retrato: unknown
93 caminhos: string[]
94 assinatura: OrkAssinatura
95}
96
97/** A linha que um mod publica na faixa. */
98export type OrkLinhaDaFaixa = {
99 mod: string
100 texto: string
101 /** Quando foi publicada, em ms desde a epoca. */
102 em: number
103}
104
105/** Onde um mod mostrou algo ao dono. */
106export type OrkCanal = 'faixa' | 'painel' | 'notificacao' | 'toast' | 'status' | 'transcript'
107
108/** O que um mod diz ter mostrado ao dono: `chave` agrupa o que e a mesma coisa (por exemplo, `hitl:<pedido>`). */
109export type OrkMostrado = {
110 mod: string
111 canal: OrkCanal
112 texto: string
113 chave?: string
114 thread?: string
115}
116
117/** O registro `ork.mod-mostrado/v1` no `$.store` da ponte, para o nucleo deduplicar o Telegram depois. */
118export type OrkRegistroDeMostrados = {
119 formato: 'ork.mod-mostrado/v1'
120 itens: Array<{
121 mod: string
122 canal: OrkCanal
123 chave: string | null
124 thread: string | null
125 texto: string
126 primeiraVez: string
127 ultimaVez: string
128 vezes: number
129 }>
130}
131
132export type OrkVigia = {
133 /** Os caminhos que este mod pediu, como ficaram guardados. */
134 caminhos: string[]
135 /** O `SessionStart` desta sessao ja respondeu: o pedido so vale no proximo (`/clear`, retomada). */
136 valeNoProximoInicio: boolean
137}
138
139export type Ork = {
140 /** O papel desta sessao. */
141 papel: () => Promise<OrkPapel>
142 /** Papel, thread do despacho e raiz da sessao. */
143 contexto: () => Promise<OrkContexto>
144 /**
145 * O retrato do nucleo pelo `ork_maestro`, com reserva na consulta `maestro` do CLI (teto de 10 s).
146 * Volta do cache enquanto o `stat` dos ledgers cobertos nao muda, por no maximo 60 s.
147 */
148 retrato: (pedido?: OrkPedidoDeRetrato) => Promise<OrkRetrato>
149 /** Uma consulta da lista fechada pelo CLI, com teto de 10 s; recusa o que estiver fora dela. */
150 ler: (pedido: OrkPedidoDeLeitura) => Promise<unknown>
151 /**
152 * Publica (ou tira, sem `texto`) a linha deste mod na faixa acima do prompt, que so a ponte desenha.
153 * Na sessao do dono, a linha publicada entra no registro do que foi mostrado.
154 */
155 faixa: (pedido: { mod: string; texto?: string }) => Promise<{ publicada: boolean }>
156 /** Pede caminhos absolutos para o `watchPaths`, que so a ponte responde; sem caminhos, tira o pedido. */
157 vigiar: (pedido: { mod: string; caminhos: string[] }) => Promise<OrkVigia>
158 /** Registra o que o mod mostrou ao dono fora da faixa (painel, notificacao, toast, status). */
159 mostrado: (pedido: OrkMostrado) => Promise<void>
160 /** O registro do que os mods mostraram ao dono. */
161 mostrados: () => Promise<OrkRegistroDeMostrados>
162}
163
164declare module 'claude-code' {
165 interface EngineInterface {
166 ork: Ork
167 }
168 interface PluginState {
169 'ork-ponte': {
170 /** Os retratos guardados, por pedido. */
171 retratos: Record<string, OrkRetratoGuardado>
172 /** As linhas da faixa, uma por mod. */
173 faixa: OrkLinhaDaFaixa[]
174 /** Os caminhos que cada mod pediu para vigiar. */
175 vigias: Record<string, string[]>
176 }
177 }
178}
179