SLOPSHOPPER

ork-radar

Radar de Colisão do Orkastery no Claude Code: o merge de cada thread aberta contra a origin/main e entre as threads, calculado sem worktree e sem gravar objeto…

newpanecommandtoastprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ork-radar
│ ┃ radar ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Radar de Colisão · ainda sem rodada │ ork-radar │ │ ┃ [ Atualizar ] ⏺ Read(src/auth.ts) │ radar: a ponte ork-ponte não carregou; o │ │ ⎿ Read 6 lines │ Radar de Colisão fica desligado nesta │ │ ⏺ Update(src/auth.ts) │ sessão │ │ ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ⏺ 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

Draws

Pane · radar
Radar de Colisão · ainda sem rodada [ Atualizar ]
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 4 files
hooks/register.tsx 471 lines
1// O Radar de Colisão do Orkastery (RM-059, fatia F3; ficha M10 do plano dos mods). Na sessão do dono, calcula sem
2// worktree o merge de cada thread aberta contra a origin/main local e entre as threads, cruza os conflitos com os
3// leases e desenha o mapa de calor num painel; a colisão nova vira uma linha na faixa pela ponte, e o que o dono
4// viu entra no registro da ponte (M5 do dono). Lê o núcleo pela ponte (`$.ork`), nunca `.orkastery/` por conta
5// própria; do git, só ls-remote e merge-tree com objetos temporários (D6 do dono). Só lê: nenhum caminho daqui
6// age pelo dono. A lógica fica nos módulos puros ao lado; aqui só os ganchos e as chamadas ao `$`, sempre
7// escritas como `x.noun.metodo(...)`, como o validador exige.
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10import type { RadarMapa, RadarThread } from '../types'
11import {
12  AMBIENTE_DO_GIT, ambienteDoMerge, anterioresDoMapa, argvDeApagar, argvDoLsRemoteLocal, argvDoLsRemoteRemoto, argvDoMergeTree,
13  argvDoTemporario, baseDoMapa, branchCanonica, celulaDoPar, chaveDoPar, colisoesDoMapa, contar, gitdirDoArquivo, guardarNoCache, juntar,
14  LEITURAS_EM_PARALELO, lerLsRemote, lerMergeTree, objetosDoComum, ORCAMENTO_DO_MERGE_MS, paginaDosLeases, paresComColisaoNova,
15  paresDaRodada, PERIODO_DA_RODADA_MS, PRAZO_DO_MERGE_MS, PRAZO_DO_REMOTO_MS, PRAZO_LOCAL_MS, REF_DA_BASE, REF_REMOTA, refDoStatus,
16  resumoDoMapa, rotuloDe, temporarioValido, TETO_DE_PAGINAS_DE_LEASES, threadsDaLista,
17} from './colisao'
18import type { LeaseDePath, Par, Resultado, ThreadDaLista } from './colisao'
19import { desenharPainel, linhasDoPainel, mostradosDoMapa, mostradosVisiveis, novasDepoisDaRodada, textoDaFaixa } from './mapa'
20import type { ElementosDoPainel, RadarMostrado } from './mapa'
21
22const MOD = 'ork-radar'
23const PAINEL = 'radar'
24const TITULO = 'Radar'
25const MAPA_VAZIO: RadarMapa = {
26  threads: [], base: { ref: REF_DA_BASE, sha: null, remoto: null, situacao: 'nao-conferida', erroRemoto: null },
27  celulas: [], omitidas: 0, lacunas: [], lidoEm: null, erro: null,
28}
29const mapa = atom({ plugin: 'ork-radar', key: 'mapa' } as const, MAPA_VAZIO)
30const novas = atom({ plugin: 'ork-radar', key: 'novas' } as const, [] as string[])
31/** O servidor do núcleo pode conectar depois do início da sessão: o papel é conferido de novo nestas esperas. */
32const ESPERAS_DO_PAPEL_MS = [3000, 10000, 30000]
33/** Depois delas, enquanto dormente, a cada 5 min. */
34const ESPERA_LONGA_DO_PAPEL_MS = 300000
35/** Teto de cada leitura pela ponte, que já tem o próprio teto de 10 s no CLI. */
36const PRAZO_DA_PONTE_MS = 20000
37/** A primeira rodada sai logo depois da ativação, fora do início da sessão. */
38const ESPERA_DA_PRIMEIRA_MS = 1000
39/** O que o desenho anotou vai ao registro da ponte, fora do desenho, a cada minuto. */
40const PERIODO_DO_REGISTRO_MS = 60000
41
42// O que o módulo guarda entre ganchos e não desenha. Uma recarga do módulo zera tudo, e o `session.start`
43// dispara de novo nela; o mapa, as colisões novas e a linha da faixa (na ponte) ficam em `$.state`.
44let ativa = false
45let desistiu = false
46let ciclo: { cancel: () => void } | undefined
47let registro: { cancel: () => void } | undefined
48let rodando: Promise<void> | undefined
49let pendente = false
50/** O resultado de cada par de SHAs já calculado nesta carga (D12): par que não andou não roda de novo. */
51const cache = new Map<string, Resultado>()
52/** A branch de cada thread pelo `base.branch` do `thread.status` (null: vale a `ork/<slug>`), lida uma vez por carga. */
53const branchDoStatus = new Map<string, string | null>()
54/** O diretório de objetos do repositório, achado uma vez por carga. */
55let objetos: string | undefined
56/**
57 * O `lidoEm` do último mapa que uma rodada desta carga gravou. A leitura de `$.state` num despacho vê o momento em
58 * que ele começou, e não a gravação que outro despacho faz no meio: quem compara a versão do mapa visto com a atual
59 * usa esta variável. Undefined até a primeira rodada desta carga gravar.
60 */
61let versaoGravada: number | null | undefined
62/** As colisões (par e arquivo) da rodada anterior; null até a primeira rodada desta carga ler o mapa guardado (D6). */
63let anteriores: Set<string> | null = null
64/**
65 * A última linha publicada na faixa. `null` é "não sei": uma carga nova não sabe o que a anterior deixou na faixa
66 * da ponte, então a primeira publicação sempre acontece, inclusive a que tira a linha.
67 */
68let ultimaFaixa: string | undefined | null = null
69/**
70 * O que o desenho pôs na janela de rolagem e ainda não foi ao registro da ponte, com a versão do mapa desenhado
71 * (`lidoEm`): anotação de um desenho anterior à última rodada não vale, porque o texto da tela era outro.
72 */
73const vistosNoDesenho = new Map<string, { visto: RadarMostrado; versao: number | null }>()
74/** O que já foi ao registro nesta carga, por chave e texto, com a chave do par; sai quando o conflito some do mapa. */
75const jaMostrado = new Map<string, string>()
76
77function mensagem(erro: unknown): string {
78  return (erro instanceof Error ? erro.message : String(erro)).replace(/\s+/g, ' ').trim().slice(0, 200)
79}
80
81function primeiraLinha(texto: string): string {
82  return String(texto ?? '').trim().split('\n')[0]?.trim().slice(0, 200) ?? ''
83}
84
85/** A leitura com teto próprio: o relógio da sessão recusa a espera que passar dele. */
86function comPrazo<T>($: EngineInterface, leitura: Promise<T>, ms = PRAZO_DA_PONTE_MS): Promise<T> {
87  return new Promise<T>((resolver, recusar) => {
88    const prazo = $.clock.after(ms, () => recusar(new Error(`a leitura passou de ${Math.round(ms / 1000)} s`)))
89    leitura.then(v => { prazo.cancel(); resolver(v) }, e => { prazo.cancel(); recusar(e) })
90  })
91}
92
93/**
94 * O papel pela ponte. `null` só quando o `$.ork` não existe (a ponte não carregou: a chamada nem acontece);
95 * uma falha da própria ponte conta como dormente e o papel é conferido de novo depois.
96 */
97async function papelDaSessao($: EngineInterface): Promise<string | null> {
98  try {
99    return String(await $.ork.papel())
100  } catch (erro) {
101    return erro instanceof TypeError ? null : 'dormente'
102  }
103}
104
105/** Liga o Radar na sessão do dono: o `/radar`, a rodada de 10 min, o registro do mostrado e a primeira rodada. */
106async function ativar($: EngineInterface): Promise<void> {
107  if (ativa) return
108  ativa = true
109  await $.command.register({
110    name: 'radar', description: 'Radar de Colisão: onde cada thread aberta colide com a origin/main e com as outras, antes do SHIP',
111    immediate: true,
112  })
113  ciclo?.cancel()
114  ciclo = $.clock.every(PERIODO_DA_RODADA_MS, () => { void pedirRodada($) })
115  registro?.cancel()
116  registro = $.clock.every(PERIODO_DO_REGISTRO_MS, () => { void registrarMostrados($) })
117  $.clock.after(ESPERA_DA_PRIMEIRA_MS, () => { void pedirRodada($) })
118}
119
120/** Confere o papel: liga no dono, desiste na fase ou sem a ponte, e tenta de novo enquanto dormente. */
121async function conferirPapel($: EngineInterface, tentativa: number): Promise<void> {
122  if (ativa || desistiu) return
123  const papel = await papelDaSessao($)
124  if (papel === 'dono') return ativar($)
125  if (papel === 'fase' || papel === null) {
126    desistiu = true
127    if (papel === null) $.ui.toast('radar: a ponte ork-ponte não carregou; o Radar de Colisão fica desligado nesta sessão')
128    return
129  }
130  const espera = ESPERAS_DO_PAPEL_MS[tentativa] ?? ESPERA_LONGA_DO_PAPEL_MS
131  $.clock.after(espera, () => { void conferirPapel($, tentativa + 1) })
132}
133
134/**
135 * Uma rodada por vez. Com uma rodada em andamento, o pedido só marca que outra deve rodar logo depois e volta sem
136 * nada para esperar: quem pediu (o `/radar`, o botão, o relógio) nunca espera a rodada de outro despacho, cuja
137 * espera contaria no orçamento do gancho do host. Devolve a rodada só a quem a começou.
138 */
139function pedirRodada($: EngineInterface): Promise<void> | undefined {
140  if (!ativa) return undefined
141  if (rodando) {
142    pendente = true
143    return undefined
144  }
145  const esta = (async () => {
146    try {
147      do {
148        pendente = false
149        await rodadaUmaVez($)
150      } while (pendente)
151    } finally {
152      rodando = undefined
153    }
154  })()
155  rodando = esta
156  return esta
157}
158
159/** A rodada parou antes do mapa: o mapa anterior fica, com o motivo. */
160async function gravarErro($: EngineInterface, erro: string): Promise<void> {
161  await update($, mapa, anterior => ({ ...anterior, erro }))
162}
163
164/** O SHA da base e de cada ref pedida, pelo `ls-remote` no próprio repositório. */
165async function lerShas($: EngineInterface, raiz: string, refs: readonly string[]): Promise<Record<string, string>> {
166  const r = await $.process.run(argvDoLsRemoteLocal(refs), { cwd: raiz, env: { ...AMBIENTE_DO_GIT }, timeoutMs: PRAZO_LOCAL_MS })
167  if (r.exitCode !== 0) throw new Error(primeiraLinha(r.stderr) || `o ls-remote saiu ${r.exitCode}`)
168  return lerLsRemote(r.stdout)
169}
170
171/** O SHA da `main` do `origin` (D4); falha vira "não conferida" e a rodada segue. */
172async function lerRemoto($: EngineInterface, raiz: string): Promise<{ sha: string | null; erro: string | null }> {
173  try {
174    const r = await $.process.run(argvDoLsRemoteRemoto(), { cwd: raiz, env: { ...AMBIENTE_DO_GIT }, timeoutMs: PRAZO_DO_REMOTO_MS })
175    if (r.exitCode !== 0) return { sha: null, erro: primeiraLinha(r.stderr) || `o ls-remote saiu ${r.exitCode}` }
176    const sha = lerLsRemote(r.stdout)[REF_REMOTA] ?? null
177    return { sha, erro: sha === null ? 'o remoto não listou a main' : null }
178  } catch (erro) {
179    return { sha: null, erro: mensagem(erro) }
180  }
181}
182
183/**
184 * A branch canônica de cada thread, como o núcleo a decide (`branchDaWorktree`): o `base.branch` do `thread.status`
185 * quando começa com `ork/`, senão `ork/<slug>`. O status de cada thread é lido uma vez por carga, algumas por vez;
186 * leitura que falha usa a `ork/<slug>` nesta rodada, vira lacuna e é tentada de novo na seguinte.
187 */
188async function branchesDasThreads($: EngineInterface, threads: readonly ThreadDaLista[], lacunas: string[]): Promise<Map<string, string>> {
189  const faltam = threads.filter(t => !branchDoStatus.has(t.id))
190  for (let i = 0; i < faltam.length; i += LEITURAS_EM_PARALELO) {
191    await Promise.all(faltam.slice(i, i + LEITURAS_EM_PARALELO).map(async t => {
192      try {
193        branchDoStatus.set(t.id, refDoStatus(await comPrazo($, $.ork.ler({ consulta: 'thread.status', thread: t.id }))))
194      } catch (erro) {
195        lacunas.push(`status de ${t.id} não lido; vale a ork/${t.slug} nesta rodada (${mensagem(erro)})`)
196      }
197    }))
198  }
199  return new Map(threads.map(t => [t.id, branchCanonica(branchDoStatus.get(t.id) ?? null, t.slug)]))
200}
201
202/** Os leases `path:` ativos de todas as páginas da seção `leases` do retrato, até o teto de páginas (D1 e D10). */
203async function lerLeases($: EngineInterface, lacunas: string[]): Promise<LeaseDePath[]> {
204  const leases: LeaseDePath[] = []
205  let offset = 0
206  try {
207    for (let pagina = 0; pagina < TETO_DE_PAGINAS_DE_LEASES; pagina++) {
208      const p = paginaDosLeases((await comPrazo($, $.ork.retrato({ secao: 'leases', offset }))).retrato)
209      leases.push(...p.leases)
210      if (p.lacuna) lacunas.push(p.lacuna)
211      if (p.proximo === null || p.proximo <= offset) return leases
212      offset = p.proximo
213    }
214    lacunas.push(`leases além de ${TETO_DE_PAGINAS_DE_LEASES} páginas não lidos`)
215  } catch (erro) {
216    lacunas.push(`leases não lidos (${mensagem(erro)})`)
217  }
218  return leases
219}
220
221/** Os objetos do repositório pelo `.git` da raiz: pasta, ou arquivo de worktree vinculada com `commondir` (D2). */
222async function acharObjetos($: EngineInterface, raiz: string): Promise<string> {
223  if (objetos !== undefined) return objetos
224  const ponto = juntar(raiz, '.git')
225  const st = await $.fs.stat(ponto)
226  if (st.kind === 'dir') objetos = juntar(ponto, 'objects')
227  else if (st.kind === 'file') {
228    const gitdir = gitdirDoArquivo(String(await $.fs.read(ponto)), raiz)
229    if (gitdir === null) throw new Error('o .git da raiz não aponta um gitdir')
230    const commondir = await $.fs.read(juntar(gitdir, 'commondir')).then(String, () => null)
231    objetos = objetosDoComum(gitdir, commondir)
232  } else throw new Error('a raiz da sessão não tem .git')
233  return objetos
234}
235
236/** O temporário da rodada pelo `mktemp -d`; recusa o que não tiver a forma do temporário do Radar (D5). */
237async function criarTemporario($: EngineInterface): Promise<string> {
238  const r = await $.process.run(argvDoTemporario(), { timeoutMs: PRAZO_LOCAL_MS })
239  const temporario = r.exitCode === 0 ? temporarioValido(r.stdout) : null
240  if (temporario === null) throw new Error(`o mktemp devolveu ${JSON.stringify(primeiraLinha(r.stdout || r.stderr))}`)
241  return temporario
242}
243
244/**
245 * Os pares que o cache não tinha: um `merge-tree` por par de SHAs, com objetos novos num temporário da rodada e
246 * os do repositório como alternativo (D2, D3 e D5), dentro do orçamento da rodada (D12). O temporário sai sempre.
247 */
248async function calcularPares($: EngineInterface, raiz: string, pares: readonly Par[], resultados: Map<string, Resultado>,
249  motivos: Map<string, string>, lacunas: string[]): Promise<void> {
250  const semCalculo = (motivo: string) => { for (const p of pares) motivos.set(chaveDoPar(p.shaA, p.shaB), motivo) }
251  let dirDosObjetos: string
252  try {
253    dirDosObjetos = await acharObjetos($, raiz)
254  } catch (erro) {
255    lacunas.push(`objetos do repositório não achados (${mensagem(erro)})`)
256    return semCalculo('não calculado: objetos do repositório não achados')
257  }
258  let temporario: string
259  try {
260    temporario = await criarTemporario($)
261  } catch (erro) {
262    lacunas.push(`temporário recusado (${mensagem(erro)})`)
263    return semCalculo('não calculado: temporário recusado')
264  }
265  const inicio = await $.clock.now()
266  try {
267    for (const p of pares) {
268      const chave = chaveDoPar(p.shaA, p.shaB)
269      if (resultados.has(chave)) continue
270      if ((await $.clock.now()) - inicio >= ORCAMENTO_DO_MERGE_MS) {
271        motivos.set(chave, `não calculado nesta rodada (orçamento de ${ORCAMENTO_DO_MERGE_MS / 1000} s)`)
272        continue
273      }
274      let r: Resultado
275      try {
276        r = lerMergeTree(await $.process.run(argvDoMergeTree(p.shaA, p.shaB),
277          { cwd: raiz, env: ambienteDoMerge(temporario, dirDosObjetos), timeoutMs: PRAZO_DO_MERGE_MS }))
278      } catch (erro) {
279        r = { estado: 'erro', erro: mensagem(erro) }
280      }
281      resultados.set(chave, r)
282      guardarNoCache(cache, chave, r)
283    }
284  } finally {
285    const apagado = await $.process.run(argvDeApagar(temporario), { timeoutMs: PRAZO_LOCAL_MS }).then(r => r.exitCode === 0, () => false)
286    if (!apagado) lacunas.push(`temporário ${temporario} não apagado`)
287  }
288}
289
290/** Uma rodada inteira: threads e leases pela ponte, SHAs pelo `ls-remote`, pares pelo `merge-tree`, mapa no estado. */
291async function rodadaUmaVez($: EngineInterface): Promise<void> {
292  let raiz: string
293  try {
294    const contexto = await comPrazo($, $.ork.contexto())
295    if (contexto.papel !== 'dono') return
296    raiz = contexto.raiz
297  } catch (erro) {
298    return gravarErro($, `o contexto da ponte falhou (${mensagem(erro)})`)
299  }
300  const lacunas: string[] = []
301  let escolha: ReturnType<typeof threadsDaLista>
302  try {
303    escolha = threadsDaLista(await comPrazo($, $.ork.ler({ consulta: 'thread.list' })))
304  } catch (erro) {
305    return gravarErro($, `a lista das threads abertas falhou (${mensagem(erro)})`)
306  }
307  const leases = await lerLeases($, lacunas)
308  const refs = await branchesDasThreads($, escolha.threads, lacunas)
309  let shas: Record<string, string>
310  try {
311    shas = await lerShas($, raiz, [...new Set(refs.values())])
312  } catch (erro) {
313    return gravarErro($, `o ls-remote do repositório falhou (${mensagem(erro)})`)
314  }
315  const threads: RadarThread[] = escolha.threads.map((t, i) => {
316    const branch = refs.get(t.id) as string
317    const sha = shas[branch] ?? null
318    return { id: t.id, fase: t.fase, rotulo: rotuloDe(i), branch, sha, motivo: sha === null ? 'sem branch local' : null }
319  })
320  const shaDaBase = shas[REF_DA_BASE] ?? null
321  if (shaDaBase === null) lacunas.push(`sem ${REF_DA_BASE} local: os pares com a base ficaram de fora`)
322  const remoto = await lerRemoto($, raiz)
323  const pares = paresDaRodada(threads, shaDaBase)
324  const resultados = new Map<string, Resultado>()
325  const motivos = new Map<string, string>()
326  const calcular: Par[] = []
327  for (const p of pares) {
328    const chave = chaveDoPar(p.shaA, p.shaB)
329    const guardado = cache.get(chave)
330    if (p.shaA === p.shaB) resultados.set(chave, { estado: 'limpa' })
331    else if (guardado !== undefined) resultados.set(chave, guardado)
332    else calcular.push(p)
333  }
334  if (calcular.length > 0) await calcularPares($, raiz, calcular, resultados, motivos, lacunas)
335  const celulas = pares.map(p => {
336    const chave = chaveDoPar(p.shaA, p.shaB)
337    return celulaDoPar(p, resultados.get(chave) ?? null, leases, motivos.get(chave))
338  })
339  const novo: RadarMapa = {
340    threads, base: baseDoMapa(shaDaBase, remoto), celulas, omitidas: escolha.omitidas, lacunas, lidoEm: await $.clock.now(), erro: null,
341  }
342  // D6: nova é a colisão que não estava na rodada anterior desta sessão. Depois de uma recarga do módulo, a rodada
343  // anterior é a do mapa guardado em `$.state`, lido antes de o mapa novo ir por cima dele.
344  if (anteriores === null) anteriores = anterioresDoMapa(await read($, mapa))
345  await update($, mapa, () => novo)
346  versaoGravada = novo.lidoEm
347  const novosPares = paresComColisaoNova(anteriores, novo)
348  anteriores = new Set(colisoesDoMapa(novo))
349  await update($, novas, antigas => novasDepoisDaRodada(antigas, novosPares, novo))
350  await publicarFaixa($)
351  await registrarMostrados($)
352}
353
354/** O painel do Radar está posto e à vista do dono. */
355async function painelAVista($: EngineInterface): Promise<boolean> {
356  const paineis = await $.ui.panes().catch(() => [])
357  return paineis.some(p => p.id === PAINEL && p.isPlaced && p.isShown)
358}
359
360/** Uma falha que o dono precisa ver: entra nas lacunas do mapa, que o painel e o `/radar` mostram, uma vez só. */
361async function anotarLacuna($: EngineInterface, lacuna: string): Promise<void> {
362  await update($, mapa, m => (m.lacunas.includes(lacuna) ? m : { ...m, lacunas: [...m.lacunas, lacuna] }))
363}
364
365/** A linha da faixa pela ponte (D6 e D7), só quando o texto muda; sem colisão nova não vista, a linha sai. */
366async function publicarFaixa($: EngineInterface): Promise<void> {
367  const texto = textoDaFaixa(await read($, mapa), await read($, novas))
368  if (texto === ultimaFaixa) return
369  try {
370    if (texto === undefined) await $.ork.faixa({ mod: MOD })
371    else await $.ork.faixa({ mod: MOD, texto })
372    ultimaFaixa = texto
373  } catch (erro) {
374    // A linha fica como estava; o motivo vai às lacunas, e a próxima rodada tenta de novo.
375    await anotarLacuna($, `linha da faixa não publicada (${mensagem(erro)})`)
376  }
377}
378
379/**
380 * O que o dono viu (conflito por conflito) deixa de ser novo, e a linha da faixa acompanha. Só vale para a versão
381 * do mapa que ele viu: se uma rodada gravou outra no meio, a colisão nova dela continua nova.
382 */
383async function marcarVistos($: EngineInterface, vistos: readonly RadarMostrado[], versao: number | null): Promise<void> {
384  if (versaoGravada === undefined || versao !== versaoGravada) return
385  const pares = new Set(vistos.map(v => v.chave.slice('radar:'.length)))
386  if (!(await read($, novas)).some(p => pares.has(p))) return
387  await update($, novas, atuais => atuais.filter(p => !pares.has(p)))
388  await publicarFaixa($)
389}
390
391/** Registra pela ponte o que o dono viu, uma vez por chave e texto enquanto o conflito existir no mapa. */
392async function registrar($: EngineInterface, canal: 'painel' | 'transcript', vistos: readonly RadarMostrado[]): Promise<RadarMostrado[]> {
393  const falharam: RadarMostrado[] = []
394  for (const v of vistos) {
395    const chave = `${v.chave}|${v.texto}`
396    if (jaMostrado.has(chave)) continue
397    try {
398      await $.ork.mostrado({ mod: MOD, canal, chave: v.chave, thread: v.thread, texto: v.texto })
399      jaMostrado.set(chave, v.chave)
400    } catch {
401      falharam.push(v)
402    }
403  }
404  return falharam
405}
406
407/**
408 * O que o desenho pôs na janela de rolagem do painel posto e à vista vai ao registro da ponte (M5 do dono), fora
409 * do desenho: o Telegram não repete o que o dono já viu no terminal, e nada que não estava na tela entra.
410 */
411async function registrarMostrados($: EngineInterface): Promise<void> {
412  const m = await read($, mapa)
413  // O registro já feito sai quando o par deixa de estar em conflito (a chave do par, porque o texto pode ser o
414  // pedaço que coube na largura); a anotação de outra versão do mapa sai sempre, e o próximo desenho anota de novo.
415  const emConflito = new Set(mostradosDoMapa(m).map(v => v.chave))
416  for (const [chave, par] of [...jaMostrado]) if (!emConflito.has(par)) jaMostrado.delete(chave)
417  const versao = versaoGravada
418  for (const [chave, a] of [...vistosNoDesenho]) if (versao === undefined || a.versao !== versao) vistosNoDesenho.delete(chave)
419  if (vistosNoDesenho.size === 0 || versao === undefined) return
420  // Com o painel escondido, o que foi anotado é descartado: o próximo desenho à vista anota de novo.
421  if (!await painelAVista($)) { vistosNoDesenho.clear(); return }
422  const vistos = [...vistosNoDesenho.values()].map(a => a.visto)
423  vistosNoDesenho.clear()
424  for (const v of await registrar($, 'painel', vistos)) vistosNoDesenho.set(`${v.chave}|${v.texto}`, { visto: v, versao })
425  await marcarVistos($, vistos, versao)
426}
427
428/** O desenho anota o que pôs na janela, com a versão do mapa desenhado; quem registra é o laço, fora do desenho. */
429function anotarVistos(vistos: readonly RadarMostrado[], versao: number | null): void {
430  for (const v of vistos) vistosNoDesenho.set(`${v.chave}|${v.texto}`, { visto: v, versao })
431}
432
433export const register: Register = on => {
434  on('session.start', async ($, e, next) => {
435    if (e.isInteractive) await conferirPapel($, 0)
436    return next(e)
437  })
438
439  // `/radar` abre o painel, roda uma rodada agora e responde o resumo, que o dono lê ali mesmo: os conflitos do
440  // resumo entram no registro da ponte e deixam de ser novos. Com uma rodada de outro despacho em andamento, não a
441  // espera: responde o mapa que há e a rodada pedida roda logo depois. O painel não abre sozinho (D6). O comando só
442  // existe com o Radar ligado; a conferência do papel aqui só cobre o caso raro de o comando ter sobrado de outra carga.
443  on('command.run', { command: 'radar' }, async $ => {
444    if (!ativa && !desistiu) await conferirPapel($, ESPERAS_DO_PAPEL_MS.length)
445    if (!ativa) return { text: 'O Radar de Colisão só roda na sessão do dono de um projeto do Orkastery.' }
446    const aberto = await $.ui.open({ id: PAINEL, title: TITULO }).catch(() => null)
447    const propria = pedirRodada($)
448    if (propria) await propria
449    const m = await read($, mapa)
450    const vistos = mostradosDoMapa(m)
451    const falharam = await registrar($, 'transcript', vistos)
452    if (falharam.length > 0) {
453      await anotarLacuna($, `${contar(falharam.length, 'conflito não foi', 'conflitos não foram')} ao registro do mostrado da ponte`)
454    }
455    await marcarVistos($, vistos, m.lidoEm)
456    const andamento = propria ? '' : '\nUma rodada já estava em andamento: o painel mostra a conta nova quando ela terminar.'
457    const espera = aberto && !aberto.isPlaced ? `\nO painel espera lugar: ${aberto.reason}` : ''
458    return { text: resumoDoMapa(m) + andamento + espera }
459  })
460
461  // O painel: a matriz (Raster no terminal, tabela fora dele), a lista de conflitos e o botão Atualizar. O desenho
462  // nunca escreve: só anota o que pôs na janela de rolagem, cortado na largura do painel, para o registro do mostrado.
463  on('ui.render', { component: 'Pane', requestId: PAINEL }, async ($, e) => {
464    const el = $.ui.resolve(e) as unknown as ElementosDoPainel
465    const m = await read($, mapa)
466    const linhas = linhasDoPainel(m, await read($, novas), await $.clock.now())
467    anotarVistos(mostradosVisiveis(linhas, e.props.scroll, e.props.bodyColumns), m.lidoEm)
468    return desenharPainel(el, m, linhas, { terminal: e.surface === 'terminal', atualizar: () => { void pedirRodada($) } })
469  })
470}
471
hooks/colisao.ts 421 lines
1// O cálculo do Radar de Colisão (RM-059, fatia F3; ficha M10 do plano dos mods). Módulo puro: escolhe as threads
2// da rodada, monta o argv e o ambiente de cada comando, lê as saídas do git, cruza os conflitos com os leases e
3// acha as colisões novas. Nenhum `$` passa por aqui; as chamadas ficam no register.tsx. Do git, só `ls-remote`
4// e `merge-tree` com diretório de objetos temporário (D6 do dono e D2 da thread); o `fetch` fica com o núcleo.
5import type { RadarArquivo, RadarBase, RadarCelula, RadarLease, RadarMapa, RadarThread } from '../types'
6
7/** O lado da base nos pares; `@` não cabe em id de thread, então não colide com nenhuma. */
8export const BASE = '@main'
9export const NOME_DA_BASE = 'origin/main'
10/** D7: a `origin/main` local, conferida contra a `main` do `origin`. */
11export const REF_DA_BASE = 'refs/remotes/origin/main'
12export const REMOTO = 'origin'
13export const REF_REMOTA = 'refs/heads/main'
14/** D7: no máximo 12 threads por rodada, na ordem das fases mais perto do SHIP; a fase MASTER fica fora. */
15export const TETO_DE_THREADS = 12
16export const ORDEM_DAS_FASES: readonly string[] = ['SHIP', 'CHECK', 'GO', 'PLAN', 'GOAL']
17export const FASES_FORA = ['MASTER']
18/** A rodada do relógio, a cada 10 min (ficha M10). */
19export const PERIODO_DA_RODADA_MS = 600000
20/** D12: os tetos de cada comando, o orçamento de `merge-tree` por rodada e o tamanho do cache. */
21export const PRAZO_LOCAL_MS = 5000
22export const PRAZO_DO_REMOTO_MS = 15000
23export const PRAZO_DO_MERGE_MS = 20000
24export const ORCAMENTO_DO_MERGE_MS = 60000
25export const TETO_DO_CACHE = 256
26/** Quantas leituras de `thread.status` saem ao mesmo tempo, e até quantas páginas da seção `leases` a rodada lê. */
27export const LEITURAS_EM_PARALELO = 4
28export const TETO_DE_PAGINAS_DE_LEASES = 10
29/** D5: o temporário fica no `/tmp`, com o nome do Radar e 8 caracteres do `mktemp`. */
30export const MODELO_DO_TEMPORARIO = '/tmp/ork-radar.XXXXXXXX'
31/** Quantos arquivos de um par a linha do conflito mostra antes de resumir. */
32export const ARQUIVOS_POR_LINHA = 5
33
34const ROTULOS = '123456789abcdefghijklmnopqrstuvwxyz'
35const ID_DE_THREAD = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/
36const SLUG = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,159}$/
37const BRANCH_DO_ORK = /^ork\/[a-zA-Z0-9][a-zA-Z0-9._/-]{0,199}$/
38/** SHA de commit ou de árvore, em SHA-1 ou SHA-256. */
39const SHA = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/
40const TEMPORARIO = /^\/tmp\/ork-radar\.[A-Za-z0-9]{8}$/
41const NUL = String.fromCharCode(0)
42/** O ambiente de todo git do Radar: nunca pergunta credencial no terminal. */
43export const AMBIENTE_DO_GIT: Readonly<Record<string, string>> = { GIT_TERMINAL_PROMPT: '0' }
44
45export type ThreadDaLista = { id: string; slug: string; fase: string }
46export type Escolha = { threads: ThreadDaLista[]; omitidas: number }
47
48function posicaoDaFase(fase: string): number {
49  const i = ORDEM_DAS_FASES.indexOf(fase)
50  return i < 0 ? ORDEM_DAS_FASES.length : i
51}
52
53/** As threads abertas da consulta `thread.list`, na ordem da rodada e até o teto; a fase MASTER fica fora (D7). */
54export function threadsDaLista(json: unknown, teto = TETO_DE_THREADS): Escolha {
55  const lista = (json as { threads?: unknown } | null)?.threads
56  if (!Array.isArray(lista)) throw new Error('a lista das threads abertas veio sem threads')
57  const validas: ThreadDaLista[] = []
58  const vistas = new Set<string>()
59  for (const bruto of lista) {
60    const t = (bruto ?? {}) as { id?: unknown; slug?: unknown; fase?: unknown; status?: unknown }
61    if (typeof t.id !== 'string' || !ID_DE_THREAD.test(t.id) || vistas.has(t.id)) continue
62    if (typeof t.slug !== 'string' || !SLUG.test(t.slug) || t.slug.includes('..')) continue
63    if (t.status === 'fechada') continue
64    const fase = typeof t.fase === 'string' ? t.fase : ''
65    if (FASES_FORA.includes(fase)) continue
66    vistas.add(t.id)
67    validas.push({ id: t.id, slug: t.slug, fase })
68  }
69  validas.sort((x, y) => posicaoDaFase(x.fase) - posicaoDaFase(y.fase) || (x.id < y.id ? -1 : x.id > y.id ? 1 : 0))
70  return { threads: validas.slice(0, teto), omitidas: Math.max(0, validas.length - teto) }
71}
72
73/** O nome curto da thread no mapa: de 1 a 9, depois letras. */
74export function rotuloDe(i: number): string {
75  return ROTULOS[i] ?? '?'
76}
77
78/** A branch canônica da thread pelo slug, como o núcleo a cria (`ork/<slug>`). */
79export function refDaThread(slug: string): string {
80  return `refs/heads/ork/${slug}`
81}
82
83/** A branch pelo `base.branch` da consulta `thread.status`, só quando começa com `ork/` (`branchDaWorktree` do núcleo). */
84export function refDoStatus(json: unknown): string | null {
85  const b = (json as { base?: { branch?: unknown } } | null)?.base?.branch
86  if (typeof b !== 'string' || !BRANCH_DO_ORK.test(b) || b.includes('..') || b.includes('//') || b.endsWith('/') || b.endsWith('.lock')) return null
87  return `refs/heads/${b}`
88}
89
90/**
91 * A branch canônica da thread, com a regra do núcleo (`branchDaWorktree` de `core/src/worktree.ts`): a ref do
92 * `base.branch` quando ele começa com `ork/` (`refDoStatus`), senão `ork/<slug>`.
93 */
94export function branchCanonica(refPeloStatus: string | null, slug: string): string {
95  return refPeloStatus ?? refDaThread(slug)
96}
97
98/** O `ls-remote` no próprio repositório: o SHA da base e o de cada branch pedida, sem escrever nada. */
99export function argvDoLsRemoteLocal(refs: readonly string[]): string[] {
100  return ['git', 'ls-remote', '.', REF_DA_BASE, ...refs]
101}
102
103/** O `ls-remote` no `origin`: só o SHA da `main` remota, para saber se a base local ficou para trás (D4). */
104export function argvDoLsRemoteRemoto(): string[] {
105  return ['git', 'ls-remote', REMOTO, REF_REMOTA]
106}
107
108/** As linhas `<sha> <ref>` do `ls-remote`; linha que não tem essa forma fica de fora. */
109export function lerLsRemote(stdout: string): Record<string, string> {
110  const shas: Record<string, string> = {}
111  for (const linha of stdout.split('\n')) {
112    const [sha, ref, sobra] = linha.trim().split(/\s+/)
113    if (sha && ref && sobra === undefined && SHA.test(sha) && ref.startsWith('refs/')) shas[ref] = sha
114  }
115  return shas
116}
117
118/** O diretório de objetos temporário da rodada (D5). */
119export function argvDoTemporario(): string[] {
120  return ['mktemp', '-d', MODELO_DO_TEMPORARIO]
121}
122
123/** O caminho que o `mktemp` devolveu, só quando é um temporário do Radar; senão null. */
124export function temporarioValido(saida: unknown): string | null {
125  if (typeof saida !== 'string') return null
126  const caminho = saida.trim()
127  return TEMPORARIO.test(caminho) ? caminho : null
128}
129
130/** O `rm` do temporário da rodada; recusa qualquer outro caminho. */
131export function argvDeApagar(temporario: string): string[] {
132  if (temporarioValido(temporario) !== temporario) throw new Error('o Radar só apaga o próprio temporário')
133  return ['rm', '-rf', '--', temporario]
134}
135
136/** Junta pasta e nome com uma barra só. */
137export function juntar(pasta: string, nome: string): string {
138  return pasta.replace(/\/+$/, '') + '/' + nome.replace(/^\/+/, '')
139}
140
141/** Resolve `.` e `..` de um caminho absoluto, sem tocar o disco. */
142export function normalizar(caminho: string): string {
143  const partes: string[] = []
144  for (const p of caminho.split('/')) {
145    if (p === '' || p === '.') continue
146    if (p === '..') partes.pop()
147    else partes.push(p)
148  }
149  return '/' + partes.join('/')
150}
151
152/** O `gitdir:` do arquivo `.git` de uma worktree vinculada, absoluto; null se o texto não tem essa linha. */
153export function gitdirDoArquivo(texto: string, raiz: string): string | null {
154  const linha = texto.split('\n').map(l => l.trim()).find(l => l.startsWith('gitdir:'))
155  const alvo = linha?.slice('gitdir:'.length).trim()
156  if (!alvo) return null
157  return normalizar(alvo.startsWith('/') ? alvo : juntar(raiz, alvo))
158}
159
160/** Os objetos do repositório: o `commondir` da worktree vinculada (relativo ao gitdir) ou o próprio gitdir. */
161export function objetosDoComum(gitdir: string, commondir: string | null): string {
162  const comum = commondir?.trim()
163  const base = !comum ? gitdir : comum.startsWith('/') ? comum : juntar(gitdir, comum)
164  return juntar(normalizar(base), 'objects')
165}
166
167/** O `merge-tree` de um par, só com SHAs: sem worktree e com os arquivos em conflito pelo nome. */
168export function argvDoMergeTree(a: string, b: string): string[] {
169  if (!SHA.test(a) || !SHA.test(b)) throw new Error('o merge-tree do Radar só recebe SHA')
170  return ['git', 'merge-tree', '--write-tree', '--name-only', '-z', '--no-messages', a, b]
171}
172
173/**
174 * Um caminho na lista do `GIT_ALTERNATE_OBJECT_DIRECTORIES`, que o git separa por `:`: com `:`, aspas, barra
175 * invertida ou quebra de linha, vai entre aspas no estilo C, que o git desfaz (entrada que começa com aspas).
176 */
177export function caminhoDoAlternativo(caminho: string): string {
178  if (!/[:"\\\n]/.test(caminho)) return caminho
179  return '"' + caminho.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"'
180}
181
182/**
183 * O ambiente do `merge-tree`: objetos novos no temporário, os do repositório só como alternativo (D2 e D3), e
184 * nenhum fetch preguiçoso de clone parcial (`GIT_NO_LAZY_FETCH`): o `fetch` fica com o núcleo.
185 */
186export function ambienteDoMerge(temporario: string, objetos: string): Record<string, string> {
187  return {
188    ...AMBIENTE_DO_GIT, GIT_NO_LAZY_FETCH: '1', GIT_OBJECT_DIRECTORY: temporario,
189    GIT_ALTERNATE_OBJECT_DIRECTORIES: caminhoDoAlternativo(objetos),
190  }
191}
192
193export type Resultado = { estado: 'limpa' } | { estado: 'conflito'; arquivos: string[] } | { estado: 'erro'; erro: string }
194
195/**
196 * A saída do `merge-tree`: 0 com a árvore no começo é par limpo; 1 com a árvore no começo é conflito, com os
197 * arquivos depois dela; o resto é erro. A saída 1 sozinha não basta: ref que não existe também sai 1, sem árvore.
198 */
199export function lerMergeTree(r: { exitCode: number; stdout: string; stderr: string }): Resultado {
200  const partes = String(r.stdout ?? '').split(NUL)
201  const temArvore = SHA.test(partes[0] ?? '')
202  if (temArvore && r.exitCode === 0) return { estado: 'limpa' }
203  if (temArvore && r.exitCode === 1) {
204    const arquivos = [...new Set(partes.slice(1).filter(p => p !== ''))].sort()
205    if (arquivos.length > 0) return { estado: 'conflito', arquivos }
206    return { estado: 'erro', erro: 'o merge-tree acusou conflito sem arquivo' }
207  }
208  const primeira = String(r.stderr ?? '').trim().split('\n')[0]?.trim().slice(0, 200)
209  return { estado: 'erro', erro: primeira || `o merge-tree saiu ${r.exitCode}` }
210}
211
212/** A chave de um par ou de um par de SHAs: os dois lados em ordem. */
213export function chaveDoPar(a: string, b: string): string {
214  return a < b ? `${a}|${b}` : `${b}|${a}`
215}
216
217export type Par = { a: string; b: string; shaA: string; shaB: string }
218
219/** Os pares da rodada: cada thread com SHA contra a base, depois cada par de threads com SHA, na ordem do mapa. */
220export function paresDaRodada(threads: readonly RadarThread[], shaDaBase: string | null): Par[] {
221  const com = threads.filter(t => t.sha !== null)
222  const pares: Par[] = []
223  if (shaDaBase !== null) for (const t of com) pares.push({ a: t.id, b: BASE, shaA: t.sha as string, shaB: shaDaBase })
224  for (let i = 0; i < com.length; i++) {
225    for (let j = i + 1; j < com.length; j++) {
226      const x = com[i] as RadarThread
227      const y = com[j] as RadarThread
228      pares.push({ a: x.id, b: y.id, shaA: x.sha as string, shaB: y.sha as string })
229    }
230  }
231  return pares
232}
233
234/** Guarda um resultado no cache pelo par de SHAs; acima do teto sai o mais antigo (D12). Erro não entra. */
235export function guardarNoCache(cache: Map<string, Resultado>, chave: string, r: Resultado, teto = TETO_DO_CACHE): void {
236  if (r.estado === 'erro') return
237  cache.delete(chave)
238  cache.set(chave, r)
239  while (cache.size > teto) {
240    const primeira = cache.keys().next()
241    if (primeira.done) break
242    cache.delete(primeira.value)
243  }
244}
245
246export type LeaseDePath = { glob: string; thread: string }
247
248/** Os leases `path:<glob>` ativos da seção `leases` do retrato, com a thread que segura cada um (D1 e D10). */
249export function leasesDoRetrato(retrato: unknown): LeaseDePath[] {
250  const itens = (retrato as { sections?: { leases?: { items?: unknown } } } | null)?.sections?.leases?.items
251  if (!Array.isArray(itens)) throw new Error('o retrato veio sem a seção leases')
252  const leases: LeaseDePath[] = []
253  for (const bruto of itens) {
254    const x = (bruto ?? {}) as { id?: unknown; status?: unknown; refs?: unknown }
255    if (typeof x.id !== 'string' || !x.id.startsWith('path:') || x.status !== 'active') continue
256    const refs = Array.isArray(x.refs) ? x.refs as Array<{ kind?: unknown; id?: unknown }> : []
257    const thread = refs.find(r => r && r.kind === 'thread' && typeof r.id === 'string' && ID_DE_THREAD.test(r.id))?.id
258    const glob = x.id.slice('path:'.length).trim()
259    if (typeof thread === 'string' && glob !== '') leases.push({ glob, thread })
260  }
261  return leases
262}
263
264export type PaginaDeLeases = { leases: LeaseDePath[]; proximo: number | null; lacuna: string | null }
265
266/**
267 * Uma página da seção `leases` do retrato (até 50 itens): os leases `path:` ativos, o offset da página seguinte
268 * (`coverage.nextOffset`) e o que veio faltando. Seção indisponível não traz lease nenhum, e isso vira lacuna.
269 */
270export function paginaDosLeases(retrato: unknown): PaginaDeLeases {
271  type Secao = { state?: unknown; gaps?: unknown; coverage?: { nextOffset?: unknown } }
272  const secao = (retrato as { sections?: { leases?: Secao } } | null)?.sections?.leases
273  if (!secao || typeof secao !== 'object') throw new Error('o retrato veio sem a seção leases')
274  const faltas = Array.isArray(secao.gaps) ? secao.gaps.filter((g): g is string => typeof g === 'string') : []
275  const detalhe = faltas.length > 0 ? ` (${faltas.join(', ')})` : ''
276  if (secao.state === 'unavailable') return { leases: [], proximo: null, lacuna: `a seção leases veio indisponível${detalhe}` }
277  const seguinte = secao.coverage?.nextOffset
278  const proximo = typeof seguinte === 'number' && Number.isInteger(seguinte) && seguinte > 0 ? seguinte : null
279  return { leases: leasesDoRetrato(retrato), proximo, lacuna: faltas.length > 0 ? `a seção leases veio com lacunas${detalhe}` : null }
280}
281
282/** O glob de um lease `path:` como regex, com a mesma regra do núcleo (`globParaRegex` de `core/src/leases.ts`). */
283export function globParaRegex(glob: string): RegExp {
284  let re = ''
285  for (let i = 0; i < glob.length; i++) {
286    const c = glob[i] as string
287    if (c === '*') {
288      if (glob[i + 1] === '*') {
289        re += '.*'
290        i++
291        if (glob[i + 1] === '/') i++
292      } else re += '[^/]*'
293    } else if (c === '?') re += '[^/]'
294    else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
295  }
296  return new RegExp(`^${re}$`)
297}
298
299/** Os leases que cobrem um arquivo em conflito. */
300export function leasesDoArquivo(caminho: string, leases: readonly LeaseDePath[]): RadarLease[] {
301  return leases.filter(l => globParaRegex(l.glob).test(caminho)).map(l => ({ lease: `path:${l.glob}`, thread: l.thread }))
302}
303
304/** A célula de um par: o resultado do `merge-tree` (null quando ficou para a próxima rodada) e os leases de cada arquivo. */
305export function celulaDoPar(par: { a: string; b: string }, r: Resultado | null, leases: readonly LeaseDePath[], motivo?: string): RadarCelula {
306  const base = { par: chaveDoPar(par.a, par.b), a: par.a, b: par.b }
307  if (r === null) return { ...base, estado: 'pendente', arquivos: [], erro: motivo ?? 'não calculado nesta rodada' }
308  if (r.estado === 'erro') return { ...base, estado: 'erro', arquivos: [], erro: r.erro }
309  if (r.estado === 'limpa') return { ...base, estado: 'limpa', arquivos: [], erro: null }
310  const arquivos: RadarArquivo[] = r.arquivos.map(caminho => ({ caminho, leases: leasesDoArquivo(caminho, leases) }))
311  return { ...base, estado: 'conflito', arquivos, erro: null }
312}
313
314/** A base do mapa: o SHA local e o que o remoto disse (D4: sem `fetch`, só os dois SHAs). */
315export function baseDoMapa(shaLocal: string | null, remoto: { sha: string | null; erro: string | null }): RadarBase {
316  const situacao = remoto.sha === null ? 'nao-conferida' : remoto.sha === shaLocal ? 'igual' : 'diferente'
317  return { ref: REF_DA_BASE, sha: shaLocal, remoto: remoto.sha, situacao, erroRemoto: remoto.erro }
318}
319
320/** As colisões de um mapa, uma por par e arquivo. */
321export function colisoesDoMapa(m: RadarMapa): string[] {
322  return m.celulas.flatMap(c => (c.estado === 'conflito' ? c.arquivos.map(a => `${c.par}|${a.caminho}`) : []))
323}
324
325/** As colisões da rodada anterior pelo mapa guardado (depois de uma recarga do módulo); null sem rodada anterior. */
326export function anterioresDoMapa(m: RadarMapa): Set<string> | null {
327  return m.lidoEm === null ? null : new Set(colisoesDoMapa(m))
328}
329
330/**
331 * Os pares com colisão nova (D6): par com algum arquivo em conflito que não estava na rodada anterior da sessão.
332 * Na primeira rodada (`anteriores` null), toda colisão é nova.
333 */
334export function paresComColisaoNova(anteriores: ReadonlySet<string> | null, m: RadarMapa): string[] {
335  const pares: string[] = []
336  for (const c of m.celulas) {
337    if (c.estado !== 'conflito') continue
338    if (anteriores === null || c.arquivos.some(a => !anteriores.has(`${c.par}|${a.caminho}`))) pares.push(c.par)
339  }
340  return pares
341}
342
343/** Como um lado do par aparece para o dono. */
344export function nomeDoLado(id: string): string {
345  return id === BASE ? NOME_DA_BASE : id
346}
347
348/** O comando que resolve um conflito, como texto (D10): o Radar nunca o roda. */
349export function comandoQueResolve(c: Pick<RadarCelula, 'a' | 'b'>): string {
350  if (c.b === BASE) return `ork worktree sync ${c.a} --dry-run`
351  if (c.a === BASE) return `ork worktree sync ${c.b} --dry-run`
352  return `a que entregar depois roda: ork worktree sync <${c.a} ou ${c.b}> --dry-run`
353}
354
355/** Um arquivo em conflito com os leases que o cobrem. */
356export function textoDoArquivo(a: RadarArquivo): string {
357  if (a.leases.length === 0) return a.caminho
358  return `${a.caminho} (${a.leases.map(l => `lease ${l.lease} de ${l.thread}`).join(', ')})`
359}
360
361/** Os arquivos de um par, os primeiros 5 por inteiro e o resto contado. */
362export function textoDosArquivos(c: Pick<RadarCelula, 'arquivos'>): string {
363  const mostrados = c.arquivos.slice(0, ARQUIVOS_POR_LINHA).map(textoDoArquivo).join(', ')
364  const resto = c.arquivos.length - ARQUIVOS_POR_LINHA
365  return resto > 0 ? `${mostrados} e mais ${resto}` : mostrados
366}
367
368/** Uma contagem com o substantivo no número certo. */
369export function contar(n: number, singular: string, plural: string): string {
370  return `${n} ${n === 1 ? singular : plural}`
371}
372
373/** O SHA curto, como o git mostra. */
374export function curto(sha: string | null): string {
375  return sha === null ? '?' : sha.slice(0, 7)
376}
377
378/** A situação da base numa frase (D4). */
379export function textoDaBase(b: RadarBase): string {
380  if (b.sha === null) return `sem ${NOME_DA_BASE} local`
381  if (b.situacao === 'igual') return `${NOME_DA_BASE} ${curto(b.sha)}, igual ao remoto`
382  if (b.situacao === 'diferente') return `${NOME_DA_BASE} ${curto(b.sha)}, diferente do remoto (${curto(b.remoto)}): a conta vale até o fetch do núcleo`
383  return `${NOME_DA_BASE} ${curto(b.sha)}, remoto não conferido${b.erroRemoto ? ` (${b.erroRemoto})` : ''}`
384}
385
386/**
387 * A linha de quando nenhum par está em conflito: "nenhuma colisão" sozinho só quando todo par foi calculado; com
388 * par em erro ou que ficou para a próxima rodada, diz quantos foram calculados.
389 */
390export function textoSemColisao(m: Pick<RadarMapa, 'celulas'>): string {
391  const total = m.celulas.length
392  const limpas = m.celulas.filter(c => c.estado === 'limpa').length
393  if (total === 0) return 'Nenhum par para calcular'
394  if (limpas === total) return `Nenhuma colisão: ${contar(limpas, 'par limpo', 'pares limpos')}`
395  if (limpas === 0) return 'Nenhum par calculado nesta rodada'
396  return `Nenhuma colisão entre os pares calculados (${limpas} de ${total})`
397}
398
399/** O resumo do mapa que o `/radar` devolve: base, colisões com arquivos e comando, e o que ficou de fora. */
400export function resumoDoMapa(m: RadarMapa): string {
401  if (m.lidoEm === null) return `Radar de Colisão: ainda sem rodada${m.erro ? ` (${m.erro})` : ''}.`
402  const linhas: string[] = []
403  const omitidas = m.omitidas > 0 ? ` (mais ${m.omitidas} fora do teto)` : ''
404  linhas.push(`Radar de Colisão: ${contar(m.threads.length, 'thread aberta', 'threads abertas')}${omitidas}; base ${textoDaBase(m.base)}.`)
405  if (m.erro) linhas.push(`A rodada parou: ${m.erro}.`)
406  const conflitos = m.celulas.filter(c => c.estado === 'conflito')
407  for (const c of conflitos) {
408    linhas.push(`- ${nomeDoLado(c.a)} × ${nomeDoLado(c.b)}: ${textoDosArquivos(c)}; resolve: ${comandoQueResolve(c)}`)
409  }
410  const limpas = m.celulas.filter(c => c.estado === 'limpa').length
411  if (conflitos.length === 0 && !m.erro) linhas.push(`${textoSemColisao(m)}.`)
412  else if (!m.erro) linhas.push(`${contar(conflitos.length, 'par em conflito', 'pares em conflito')} e ${contar(limpas, 'limpo', 'limpos')}.`)
413  for (const c of m.celulas.filter(x => x.estado === 'erro')) linhas.push(`Erro em ${nomeDoLado(c.a)} × ${nomeDoLado(c.b)}: ${c.erro}`)
414  const pendentes = m.celulas.filter(c => c.estado === 'pendente').length
415  if (pendentes > 0) linhas.push(`${pendentes === 1 ? '1 par ficou' : `${pendentes} pares ficaram`} para a próxima rodada.`)
416  const semBranch = m.threads.filter(t => t.sha === null).map(t => t.id)
417  if (semBranch.length > 0) linhas.push(`Sem branch local: ${semBranch.join(', ')}.`)
418  for (const l of m.lacunas) linhas.push(`Lacuna: ${l}.`)
419  return linhas.join('\n')
420}
421
hooks/mapa.tsx 323 lines
1// O painel do Radar de Colisão (RM-059, fatia F3, T3.2). Módulo puro: a grade threads × (base e threads) com a
2// faixa de calor de cada par, as células do `Raster` do terminal, a tabela das outras superfícies, as linhas do
3// painel com o que entra no registro do mostrado, a linha da faixa e as colisões novas que o dono ainda não viu.
4// Nenhum `$` passa por aqui; o register.tsx resolve os elementos da superfície e os passa ao desenho.
5import type { RadarCelula, RadarMapa, RadarThread } from '../types'
6import { BASE, chaveDoPar, comandoQueResolve, contar, nomeDoLado, NOME_DA_BASE, textoDaBase, textoDosArquivos, textoSemColisao } from './colisao'
7
8/** Largura de cada célula da matriz, em colunas do terminal. */
9export const LARGURA_DA_CELULA = 3
10/** Teto do rótulo de cada thread na coluna da esquerda. */
11export const LARGURA_DO_ROTULO = 28
12
13/** A faixa de calor de um par (D10 e T3.2): limpo, 1 arquivo, 2 ou 3, 4 ou mais, erro, não calculado ou sem par. */
14export type Faixa = 'limpa' | 'baixa' | 'media' | 'alta' | 'erro' | 'pendente' | 'vazia' | 'diagonal'
15
16/** As cores de cada faixa (fundo e frente em hex; null é a cor padrão da superfície) e o símbolo da célula. */
17export const PALETA: Readonly<Record<Faixa, { fundo: string | null; frente: string | null }>> = {
18  limpa: { fundo: '#2e7d32', frente: '#ffffff' },
19  baixa: { fundo: '#f9a825', frente: '#000000' },
20  media: { fundo: '#ef6c00', frente: '#000000' },
21  alta: { fundo: '#c62828', frente: '#ffffff' },
22  erro: { fundo: '#6a1b9a', frente: '#ffffff' },
23  pendente: { fundo: '#546e7a', frente: '#ffffff' },
24  vazia: { fundo: null, frente: null },
25  diagonal: { fundo: null, frente: '#757575' },
26}
27/** A cor padrão do terminal no `Raster` (bit 24 sozinho). */
28export const COR_PADRAO = 0x01000000
29
30export type CelulaDaGrade = { faixa: Faixa; simbolo: string; par: RadarCelula | null }
31export type Grade = { colunas: string[]; linhas: Array<{ thread: RadarThread; celulas: CelulaDaGrade[] }> }
32
33/** A faixa de um par pelo estado e pelo número de arquivos em conflito. */
34export function faixaDaCelula(c: RadarCelula | undefined): Faixa {
35  if (c === undefined) return 'vazia'
36  if (c.estado === 'limpa') return 'limpa'
37  if (c.estado === 'erro') return 'erro'
38  if (c.estado === 'pendente') return 'pendente'
39  const n = c.arquivos.length
40  return n >= 4 ? 'alta' : n >= 2 ? 'media' : 'baixa'
41}
42
43/** O símbolo da célula: o número de arquivos em conflito (`+` de 10 em diante), `.` limpo, `!` erro, `?` não calculado. */
44export function simboloDaCelula(faixa: Faixa, c: RadarCelula | undefined): string {
45  if (faixa === 'diagonal') return '-'
46  if (faixa === 'vazia') return ' '
47  if (faixa === 'limpa') return '.'
48  if (faixa === 'erro') return '!'
49  if (faixa === 'pendente') return '?'
50  const n = c?.arquivos.length ?? 0
51  return n >= 10 ? '+' : String(n)
52}
53
54/** A grade do mapa: a coluna da base (`m`) e uma por thread, e uma linha por thread, com a matriz simétrica. */
55export function gradeDoMapa(m: RadarMapa): Grade {
56  const porPar = new Map(m.celulas.map(c => [c.par, c]))
57  const colunas = ['m', ...m.threads.map(t => t.rotulo)]
58  const lados = [BASE, ...m.threads.map(x => x.id)]
59  const linhas = m.threads.map((t, i) => {
60    const celulas = lados.map((lado, j): CelulaDaGrade => {
61      if (j === i + 1) return { faixa: 'diagonal', simbolo: simboloDaCelula('diagonal', undefined), par: null }
62      const par = porPar.get(chaveDoPar(t.id, lado))
63      const faixa = faixaDaCelula(par)
64      return { faixa, simbolo: simboloDaCelula(faixa, par), par: par ?? null }
65    })
66    return { thread: t, celulas }
67  })
68  return { colunas, linhas }
69}
70
71/** Hex `#rrggbb` para a cor do `Raster` (`0x00RRGGBB`); null é a cor padrão. */
72export function corDoRaster(hex: string | null): number {
73  if (hex === null || !/^#[0-9a-fA-F]{6}$/.test(hex)) return COR_PADRAO
74  return parseInt(hex.slice(1), 16)
75}
76
77const ALFABETO = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
78
79/** Base64 padrão com preenchimento, sem depender do ambiente (o `Raster` pede as células assim). */
80export function base64(bytes: Uint8Array): string {
81  let s = ''
82  for (let i = 0; i < bytes.length; i += 3) {
83    const a = bytes[i] ?? 0
84    const b = bytes[i + 1] ?? 0
85    const c = bytes[i + 2] ?? 0
86    const n = (a << 16) | (b << 8) | c
87    s += (ALFABETO[(n >> 18) & 63] as string) + (ALFABETO[(n >> 12) & 63] as string)
88    s += i + 1 < bytes.length ? ALFABETO[(n >> 6) & 63] : '='
89    s += i + 2 < bytes.length ? ALFABETO[n & 63] : '='
90  }
91  return s
92}
93
94/** As triplas u32 `[codePoint, frente, fundo]` em little-endian, em base64, como o `Raster` as lê. */
95export function codificarCelulas(palavras: readonly number[]): string {
96  const bytes = new Uint8Array(palavras.length * 4)
97  palavras.forEach((p, i) => {
98    bytes[i * 4] = p & 255
99    bytes[i * 4 + 1] = (p >>> 8) & 255
100    bytes[i * 4 + 2] = (p >>> 16) & 255
101    bytes[i * 4 + 3] = (p >>> 24) & 255
102  })
103  return base64(bytes)
104}
105
106/**
107 * O `Raster` do terminal: uma linha de cabeçalho com o nome de cada coluna e uma linha por thread, cada célula com
108 * 3 colunas (o símbolo no meio) e o fundo da faixa de calor.
109 */
110export function rasterDoMapa(m: RadarMapa): { columns: number; rows: number; cells: string } {
111  const g = gradeDoMapa(m)
112  const palavras: number[] = []
113  const celula = (texto: string, frente: number, fundo: number) => {
114    for (const ch of texto) palavras.push(ch.codePointAt(0) as number, frente, fundo)
115  }
116  for (const nome of g.colunas) celula(` ${nome} `, COR_PADRAO, COR_PADRAO)
117  for (const linha of g.linhas) {
118    for (const c of linha.celulas) celula(` ${c.simbolo} `, corDoRaster(PALETA[c.faixa].frente), corDoRaster(PALETA[c.faixa].fundo))
119  }
120  return { columns: g.colunas.length * LARGURA_DA_CELULA, rows: g.linhas.length + 1, cells: codificarCelulas(palavras) }
121}
122
123/** O rótulo da thread na coluna da esquerda, com a largura de todos os rótulos do mapa. */
124export function rotuloDaThread(t: RadarThread, largura: number): string {
125  return `${t.rotulo} ${t.id} ${t.fase}`.slice(0, LARGURA_DO_ROTULO).padEnd(largura)
126}
127
128/** A largura da coluna de rótulos. */
129export function larguraDosRotulos(m: RadarMapa): number {
130  return Math.min(LARGURA_DO_ROTULO, Math.max(0, ...m.threads.map(t => `${t.rotulo} ${t.id} ${t.fase}`.length))) + 1
131}
132
133/** O que o painel diz ter mostrado ao dono de um conflito, para o registro da ponte (M5 do dono). */
134export type RadarMostrado = { chave: string; thread: string; texto: string }
135
136export type LinhaDoPainel =
137  | { tipo: 'titulo' | 'base' | 'erro' | 'legenda' | 'resolve' | 'nota'; texto: string }
138  | { tipo: 'botao'; texto: string }
139  | { tipo: 'matriz'; altura: number }
140  | { tipo: 'conflito'; texto: string; nova: boolean; mostrado: RadarMostrado }
141
142/** A legenda da matriz: cada símbolo que a célula pode ter. */
143export const LEGENDA = `m = ${NOME_DA_BASE} · número = arquivos em conflito (+ = 10 ou mais) · . limpo · ! erro do git · ? não calculado · - a própria thread · vazio = sem branch local`
144
145/** A idade da leitura, curta. */
146export function idade(ms: number): string {
147  const s = Math.max(0, Math.round(ms / 1000))
148  if (s < 60) return `${s} s`
149  const min = Math.round(s / 60)
150  return min < 60 ? `${min} min` : `${Math.round(min / 60)} h`
151}
152
153/** O texto de um par em conflito: os dois lados e os arquivos com os leases que os cobrem. */
154export function textoDoConflito(c: RadarCelula): string {
155  return `${nomeDoLado(c.a)} × ${nomeDoLado(c.b)}: ${textoDosArquivos(c)}`
156}
157
158/** A thread que o registro do mostrado leva: o lado que não é a base. */
159function threadDoPar(c: RadarCelula): string {
160  return c.a === BASE ? c.b : c.a
161}
162
163/**
164 * As linhas do painel, uma por linha da tela (a matriz ocupa uma por thread mais o cabeçalho): título, base, erro
165 * da última rodada, botão Atualizar, matriz, legenda, conflitos com o comando que resolve, erros e lacunas.
166 */
167export function linhasDoPainel(m: RadarMapa, novas: readonly string[], agora: number): LinhaDoPainel[] {
168  const conflitos = m.celulas.filter(c => c.estado === 'conflito')
169  if (m.lidoEm === null) {
170    const linhas: LinhaDoPainel[] = [{ tipo: 'titulo', texto: 'Radar de Colisão · ainda sem rodada' }]
171    if (m.erro) linhas.push({ tipo: 'erro', texto: `a rodada parou: ${m.erro}` })
172    linhas.push({ tipo: 'botao', texto: 'Atualizar' })
173    return linhas
174  }
175  const omitidas = m.omitidas > 0 ? ` (mais ${m.omitidas} fora do teto)` : ''
176  const comNovas = novas.length > 0 ? ` (${contar(novas.length, 'novo', 'novos')})` : ''
177  const linhas: LinhaDoPainel[] = [
178    { tipo: 'titulo', texto: `Radar de Colisão · ${contar(m.threads.length, 'thread', 'threads')}${omitidas} · ${contar(conflitos.length, 'par em conflito', 'pares em conflito')}${comNovas} · lido há ${idade(agora - m.lidoEm)}` },
179    { tipo: 'base', texto: `base: ${textoDaBase(m.base)}` },
180  ]
181  if (m.erro) linhas.push({ tipo: 'erro', texto: `a última rodada parou: ${m.erro}; o mapa é o da rodada anterior` })
182  linhas.push({ tipo: 'botao', texto: 'Atualizar' })
183  if (m.threads.length === 0) {
184    linhas.push({ tipo: 'nota', texto: 'nenhuma thread aberta para cruzar' })
185    return linhas
186  }
187  linhas.push({ tipo: 'matriz', altura: m.threads.length + 1 })
188  linhas.push({ tipo: 'legenda', texto: LEGENDA })
189  for (const c of conflitos) {
190    const texto = textoDoConflito(c)
191    const nova = novas.includes(c.par)
192    linhas.push({ tipo: 'conflito', texto: nova ? `nova: ${texto}` : texto, nova, mostrado: { chave: `radar:${c.par}`, thread: threadDoPar(c), texto } })
193    linhas.push({ tipo: 'resolve', texto: `  resolve: ${comandoQueResolve(c)}` })
194  }
195  if (conflitos.length === 0) linhas.push({ tipo: 'nota', texto: textoSemColisao(m).replace(/^N/, 'n') })
196  for (const c of m.celulas.filter(x => x.estado === 'erro')) linhas.push({ tipo: 'nota', texto: `erro em ${nomeDoLado(c.a)} × ${nomeDoLado(c.b)}: ${c.erro}` })
197  const pendentes = m.celulas.filter(c => c.estado === 'pendente').length
198  if (pendentes > 0) linhas.push({ tipo: 'nota', texto: `${pendentes === 1 ? '1 par ficou' : `${pendentes} pares ficaram`} para a próxima rodada` })
199  const semBranch = m.threads.filter(t => t.sha === null).map(t => t.id)
200  if (semBranch.length > 0) linhas.push({ tipo: 'nota', texto: `sem branch local: ${semBranch.join(', ')}` })
201  for (const l of m.lacunas) linhas.push({ tipo: 'nota', texto: `lacuna: ${l}` })
202  return linhas
203}
204
205/** Os conflitos que o mapa mostraria, com o texto do registro: o que pode ter ido ao registro e ainda vale. */
206export function mostradosDoMapa(m: RadarMapa): RadarMostrado[] {
207  return m.celulas.filter(c => c.estado === 'conflito').map(c => ({ chave: `radar:${c.par}`, thread: threadDoPar(c), texto: textoDoConflito(c) }))
208}
209
210/**
211 * O pedaço da linha do conflito que coube na largura do painel: a linha é cortada (`wrap="truncate"`, com a
212 * reticência na última coluna), e o registro leva só o que apareceu; null quando nada do conflito apareceu.
213 */
214export function mostradoNaLargura(l: Extract<LinhaDoPainel, { tipo: 'conflito' }>, colunas: number | undefined): RadarMostrado | null {
215  if (colunas === undefined || !(colunas > 0) || l.texto.length <= colunas) return l.mostrado
216  const prefixo = l.texto.length - l.mostrado.texto.length
217  const texto = l.mostrado.texto.slice(0, Math.max(0, colunas - 1 - prefixo))
218  return texto.trim() === '' ? null : { ...l.mostrado, texto }
219}
220
221/**
222 * O que está dentro da janela de rolagem: as linhas da tela de `offset` até `offset + bodyRows`, cada conflito
223 * só com o que coube na largura. Sem janela, nada conta como visto, porque o registro nunca diz que o dono viu
224 * o que talvez não estivesse na tela.
225 */
226export function mostradosVisiveis(linhas: readonly LinhaDoPainel[], janela: { offset: number; bodyRows: number } | undefined,
227  colunas?: number): RadarMostrado[] {
228  if (!janela || !(janela.bodyRows > 0)) return []
229  const fim = janela.offset + janela.bodyRows
230  const vistos: RadarMostrado[] = []
231  let linha = 0
232  for (const l of linhas) {
233    const altura = l.tipo === 'matriz' ? l.altura : 1
234    if (l.tipo === 'conflito' && linha >= janela.offset && linha < fim) {
235      const visto = mostradoNaLargura(l, colunas)
236      if (visto) vistos.push(visto)
237    }
238    linha += altura
239  }
240  return vistos
241}
242
243/**
244 * As colisões novas depois de uma rodada (D6): as que continuam em conflito somadas aos pares que a rodada
245 * achou novos, sem repetição, na ordem do mapa.
246 */
247export function novasDepoisDaRodada(antigas: readonly string[], novosPares: readonly string[], m: RadarMapa): string[] {
248  const emConflito = m.celulas.filter(c => c.estado === 'conflito').map(c => c.par)
249  const querida = new Set([...antigas, ...novosPares])
250  return emConflito.filter(par => querida.has(par))
251}
252
253/** A linha da faixa (D6): só com colisão nova ainda não vista; sem novas, a linha sai. */
254export function textoDaFaixa(m: RadarMapa, novas: readonly string[]): string | undefined {
255  const primeira = m.celulas.find(c => c.par === novas[0] && c.estado === 'conflito')
256  if (!primeira) return undefined
257  const n = primeira.arquivos.length
258  const resto = novas.length > 1 ? ` e mais ${novas.length - 1}` : ''
259  // A ponte já põe o nome do mod antes da linha (`radar: `).
260  return `colisão nova entre ${nomeDoLado(primeira.a)} e ${nomeDoLado(primeira.b)} (${contar(n, 'arquivo', 'arquivos')})${resto}; /radar abre o mapa`
261}
262
263export type ElementosDoPainel = { Box: any; Text: any; Button: any; Raster?: any }
264
265/**
266 * A matriz: no terminal, rótulos em `Text` e as células num `Raster`; fora dele, uma tabela de `Box` e `Text`. A
267 * superfície decide, não a presença do construtor: fora do terminal o `Raster` existe e vira fragmento vazio.
268 */
269function desenharMatriz(el: ElementosDoPainel, m: RadarMapa, terminal: boolean) {
270  const { Box, Text, Raster } = el
271  const largura = larguraDosRotulos(m)
272  if (terminal && Raster) {
273    const r = rasterDoMapa(m)
274    return (
275      <Box key="matriz" flexDirection="row">
276        <Box key="rotulos" flexDirection="column">
277          <Text key="rotulo-cabecalho" dimColor wrap="truncate">{''.padEnd(largura)}</Text>
278          {m.threads.map(t => <Text key={`rotulo:${t.id}`} wrap="truncate">{rotuloDaThread(t, largura)}</Text>)}
279        </Box>
280        <Raster key="mapa" columns={r.columns} rows={r.rows} cells={r.cells} />
281      </Box>
282    )
283  }
284  const g = gradeDoMapa(m)
285  return (
286    <Box key="matriz" flexDirection="column">
287      <Box key="cabecalho" flexDirection="row">
288        <Text key="rotulo-cabecalho" dimColor wrap="truncate">{''.padEnd(largura)}</Text>
289        {g.colunas.map(nome => <Text key={`coluna:${nome}`} dimColor>{` ${nome} `}</Text>)}
290      </Box>
291      {g.linhas.map(linha => (
292        <Box key={`linha:${linha.thread.id}`} flexDirection="row">
293          <Text key={`rotulo:${linha.thread.id}`} wrap="truncate">{rotuloDaThread(linha.thread, largura)}</Text>
294          {linha.celulas.map((c, j) => {
295            const cor = PALETA[c.faixa]
296            return <Text key={`celula:${linha.thread.id}:${j}`} {...(cor.frente ? { color: cor.frente } : {})} {...(cor.fundo ? { backgroundColor: cor.fundo } : {})}>{` ${c.simbolo} `}</Text>
297          })}
298        </Box>
299      ))}
300    </Box>
301  )
302}
303
304/** A árvore do painel com os elementos que a superfície resolveu, uma linha da tela por linha do painel. */
305export function desenharPainel(el: ElementosDoPainel, m: RadarMapa, linhas: readonly LinhaDoPainel[],
306  opcoes: { terminal: boolean; atualizar?: () => unknown }) {
307  const { Box, Text, Button } = el
308  return (
309    <Box flexDirection="column">
310      {linhas.map((l, i) => {
311        if (l.tipo === 'titulo') return <Text key="titulo" bold wrap="truncate">{l.texto}</Text>
312        if (l.tipo === 'base') return <Text key="base" dimColor wrap="truncate">{l.texto}</Text>
313        if (l.tipo === 'erro') return <Text key="erro" color="error" wrap="truncate">{l.texto}</Text>
314        if (l.tipo === 'botao') return <Button key="atualizar" label={l.texto} onPress={() => opcoes.atualizar?.()} />
315        if (l.tipo === 'matriz') return desenharMatriz(el, m, opcoes.terminal)
316        if (l.tipo === 'legenda') return <Text key="legenda" dimColor wrap="truncate">{l.texto}</Text>
317        if (l.tipo === 'conflito') return <Text key={`conflito:${l.mostrado.chave}`} bold={l.nova} wrap="truncate">{l.texto}</Text>
318        return <Text key={`${l.tipo}:${i}`} dimColor={l.tipo === 'nota'} wrap="truncate">{l.texto}</Text>
319      })}
320    </Box>
321  )
322}
323
types/index.d.ts 73 lines
1// Contrato do estado do Radar de Colisão (`ork-radar`, RM-059, fatia F3). O motor exige que cada chave de
2// `$.state` do mod esteja declarada aqui, em `PluginState`. O Radar não acrescenta noun ao `$`: lê o núcleo
3// pelo `$.ork` da `ork-ponte`, que o plugin.json lista em `dependencies`. Sem import nem referência.
4
5/** Como um par ficou na última conta: limpo, em conflito, com erro do git ou ainda não calculado. */
6export type RadarEstado = 'limpa' | 'conflito' | 'erro' | 'pendente'
7
8/** Um lease `path:<glob>` ativo que cobre um arquivo em conflito, com a thread que o segura. */
9export type RadarLease = { lease: string; thread: string }
10
11/** Um arquivo em conflito num par e os leases que o cobrem. */
12export type RadarArquivo = { caminho: string; leases: RadarLease[] }
13
14/** Um par: uma thread contra a base (`@main`) ou duas threads, com o que o `merge-tree` disse dele. */
15export type RadarCelula = {
16  /** Os dois lados em ordem, separados por `|`. */
17  par: string
18  a: string
19  b: string
20  estado: RadarEstado
21  arquivos: RadarArquivo[]
22  /** A primeira linha do erro do git, ou o motivo de o par não ter sido calculado. */
23  erro: string | null
24}
25
26/** Uma thread aberta da rodada: o rótulo do mapa, a branch e o SHA dela, ou por que não tem. */
27export type RadarThread = {
28  id: string
29  fase: string
30  /** O nome curto no mapa: de 1 a 9, depois a, b e c. */
31  rotulo: string
32  /** A ref da branch, como o `ls-remote` a lista. */
33  branch: string
34  sha: string | null
35  motivo: string | null
36}
37
38/** A base da conta: a `origin/main` local e o que o remoto disse dela. */
39export type RadarBase = {
40  ref: string
41  sha: string | null
42  remoto: string | null
43  /** `igual` ao remoto, `diferente` dele, ou `nao-conferida` quando o remoto não respondeu. */
44  situacao: 'igual' | 'diferente' | 'nao-conferida'
45  erroRemoto: string | null
46}
47
48/** O mapa de uma rodada. */
49export type RadarMapa = {
50  threads: RadarThread[]
51  base: RadarBase
52  celulas: RadarCelula[]
53  /** Threads abertas que ficaram de fora pelo teto da rodada. */
54  omitidas: number
55  /** O que não veio inteiro: os leases, a branch de uma thread, o orçamento do `merge-tree`, o temporário. */
56  lacunas: string[]
57  /** Quando a rodada terminou, em ms desde a época; null antes da primeira. */
58  lidoEm: number | null
59  /** Por que a rodada não foi feita, quando não foi. */
60  erro: string | null
61}
62
63declare module 'claude-code' {
64  interface PluginState {
65    'ork-radar': {
66      /** O mapa da última rodada. */
67      mapa: RadarMapa
68      /** Os pares com colisão nova que o dono ainda não viu no painel nem no `/radar` (D6). */
69      novas: string[]
70    }
71  }
72}
73