SLOPSHOPPER

ork-ponte

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…

newbandstatuspromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ork-ponte
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ ork-ponte: ork-ponte: lista de autoridade ausente ou invalida (/autoridade.json: ENOENT: no such file /autoridad
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 8 files
hooks/register.ts 332 lines
1// 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}
332
hooks/faixa.tsx 58 lines
1// 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}
58
hooks/guarda.ts 411 lines
1// 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}
411
hooks/mostrado.ts 56 lines
1// 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}
56
hooks/nucleo.ts 158 lines
1// 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}
158
hooks/papel.ts 43 lines
1// 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}
43
hooks/vigia.ts 77 lines
1// 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}
77
types/index.d.ts 179 lines
1// 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