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…

English · Português

In one sentence:
orkconducts AI coding agents (Claude Code and Codex) through parallel six-phase threads, checks every claim against the repository before it counts, and only calls you when the decision is really yours.
@orkastery/cli, with changes per version · reviewed on 2026-09-27main at PR #74, run 37102089623, 2026-10-03)docs/produto/ and docs/roadmap/, checked against the code by ork docs verificardocs/ are in Brazilian Portuguese, and the CLI reference is also in English. The CLI prints Brazilian Portuguese too, except ork doctor, ork init, ork onboarding, ork experiencia show, ork thread (except thread new --from-finding), ork phase list, ork phase run, ork verify, ork ship, ork pulse, ork master, ork retry plan, ork retry run, ork roadmap (status and reservations), ork worktree, ork claims, ork decisao placar, ork accounts, ork network (except network roadmap), ork docs, ork adapter and the help, which print English when owner.language or your locale asks for en; so do the orkastery.yaml comments, the AGENTS.md and CLAUDE.md blocks, the ork docs init templates and the skills, checklists, commands and subagents that ork adapter install copies; in English, read the quickstart on orkastery.com and the glossary of what the CLI printsnpm install -g @orkastery/cli
ork demo # 30 seconds: a false claim rejected, the fixed one accepted; no account, no model
ork doctor # what holds on this machine right now; exits != 0 when blocked
ork init # writes the orkastery.yaml; run it at the root of a git repository with at least one commit
ork modos # the four conduction modes
ork docs init # product docs and roadmap in the standard, with lint
The smallest full cycle, in #Fast mode (a single GO phase), right after ork init. <thread> is the ID that ork thread new prints.
git add orkastery.yaml AGENTS.md && git commit -m "ork init"
ork thread new "add an exclamation mark to greet" --modo fast
ork phase run <thread> GO --prompt "<the request>" # the agent runtime works in the thread's worktree
ork verify <thread> # re-runs the claims and the manifest checks on the real HEAD
ork ship <thread> --para main --autorizar-push <you> # #Fast never pushes without your authorization
ork master <thread> --aceitar-omissao # closes the thread with the index derived from the ledger
phase run stops with runtime.workspace-untrusted, run claude once in the worktree, accept the trust prompt, then ork retry run <thread>. ork pulse shows the same instruction.ork claims add <thread> <file> --claim "<statement>" --verificar "<command>".ork takes that seat. It is a deterministic TypeScript CLI, with no server and no embedded LLM.ork brings you is short and organized, with alternatives and one recommendation, so you decide in seconds without rereading a wall of text. That lightness is a design goal, meant also for people with ADHD: it keeps you conducting instead of drowning.Without ork | With ork |
|---|---|
| The agent says the tests pass | ork verify re-runs the command on the real HEAD, and CI re-runs it again on a clean runner |
| You ask for status several times a day | An hourly summary says what is pending, urgent, blocking or critical |
| Every obvious decision becomes a question | The obvious one comes decided and reported; you change it if you want |
| The runtime account runs out and the roadmap stops | The same prompt continues on the next profile or on the fallback runtime |
| Two sessions step on the same file | One worktree per thread, and leases with a queue |
| The docs lie about the code | ork docs verificar fails the PR when a page drifts |
Three layers, and business rules live only in the middle one:
flowchart TB
subgraph L1["Layer 1 - where you conduct"]
A["Claude Code, Codex, Hermes, OpenClaw<br/>adapters with no business rules"]
end
subgraph L2["Layer 2 - the ork core"]
B["schedules, isolates, verifies, serializes, records<br/>deterministic TypeScript, no LLM"]
end
subgraph L3["Layer 3 - who writes the code"]
C["claude-bg and codex runtimes<br/>their own accounts, profile rotation"]
end
A -->|"thread new, phase run, gate, ship"| B
B -->|"assembled prompt, sha256 in the ledger"| C
C -.->|"commits, diffs, command output"| B
B -.->|"summary, question batch, delivery"| A
flowchart LR
G(["GOAL"]) --> P(["PLAN"]) --> GO(["GO"]) --> C(["CHECK"]) --> S(["SHIP"]) --> M(["MASTER"])
C -.->|"failed: GO-FIX with a typed spec"| GO
| Phase | What it delivers | What ork requires to pass |
|---|---|---|
| GOAL | Objective, done criteria, claims with a command | a claim without a command is refused (claims.unverifiable) |
| PLAN | Tasks, decisions, an executable verify per task | a plan without an executable verify does not become GO |
| GO | One atomic commit per task, in the thread's worktree | writing outside the worktree is blocked by a lease |
| CHECK | Verification against the baseline, plus independent CI | a failed claim becomes claims.failed or verify.regression |
| SHIP | Serialized merge with a proven push | only counts when git ls-remote matches the local SHA |
| MASTER | Typed postmortem and a conduction index | the index comes from the ledger; nobody types it |
You write the #TAG in the request itself. The mode changes where the cycle waits for you; verifying the delivery is the same in all four.
| #TAG | Pauses | Cycle | What it is for |
|---|---|---|---|
| #Classic | 3 | GOAL* / PLAN* / GO-CHECK* / SHIP-MASTER | The ork init default: delicate premises |
| #Maestro | 1 | GOAL-PLAN* / GO-CHECK-SHIP / MASTER | A clear, quick solution |
| #Auto | 0 | GOAL-PLAN-GO-CHECK-SHIP-MASTER | Docs, studies, configuration, audits |
| #Fast | 0 | GO | A small, clear request that takes minutes; the push asks for authorization |
* marks the block that pauses. Source of truth: ork modos.#Look and #Ork were retired on 2026-09-24 (RM-043); what was recorded with them stays readable forever.#Fast runs only GO, with a minimal proof: a focused test, a cheap claim or an absence declared in the ledger. It never authorizes a push on its own and never touches a public contract (RM-042).The mode loosens the PAUSE. The mode NEVER loosens the VERIFICATION.
claims.jsonl. There are even negative claims, checked by the command that would falsify them.ork verify --baseline records the world before GO.ork ci prepare exports the claims, and the GitHub ork-verify job re-runs them on the exact SHA.git ls-remote, never by the exit code of git push.ork retry policy. cost.violation never gets an automatic retry.ork decisao registrar keeps the why, how to change it and the cost of changing it now or later; you are informed.owner.timezone, stated once per message.claude-bg (default) and codex, resolved by name in core/src/runtimes.ts; ork setup picks runtime, model and effort per block of each mode.ork accounts add <id> --runtime R --dir D runs the CLI's own login; ork never reads, copies or migrates a credential.cost.violation. Details and responsibilities in SECURITY.md.memory: orkmind, memory goes to the tenant's database; without it, it degrades to files with a typed reason.ork docs verificar: fails a source, symbol, contract or command that no longer exists, a merge commit outside main, and incoherent status.ork docs sincronizar --escrever: writes only facts from the ledger and git (merge, phase) and regenerates tables and indexes.documentacao job runs markdownlint and ork docs verificar on every PR.ork adapter list # the hosts and where each one installs
ork adapter install claude-code --dry-run
| Host | What gets installed |
|---|---|
| claude-code | A plugin with the catalog skills, phase subagents, the /orkastery:ork entry point and hooks |
| codex | The $ork entry point and the conduction catalog as project-local skills |
| hermes | A routing skill, the HITL ingress plugin and thread-opening scripts |
| openclaw | An extension with the ork_* tools, each one a CLI call |
ork modos --do-pedido; the core validates it.| Capability | Status |
|---|---|
| Threads, six phases, four modes, ledger, worktrees, leases, board | works |
Claims, baseline, verify on the real HEAD, 22 typed reasons, GO-FIX | works |
| CI as an independent CHECK on the exact SHA | works; native protection of main depends on the GitHub plan (RM-012) |
ork ship with a proven push | works |
| Account rotation across profiles and runtimes | works; account state is still per project (RM-040) |
| Layered HITL, a–d batch, decisions made and reported | works since 2026-09-24 (RM-041); automatic hourly summary since 2026-09-25 (RM-045) |
| Documentation as code with parity in CI | works (RM-044) |
#Fast mode: one phase, minimal proof, no push on its own | works since 2026-09-27 (RM-042) |
| Roadmap item reservations across machines | works since 2026-09-27 (RM-047); thread state is still per machine |
| Stable local verify on a machine whose CPU the hypervisor steals | does not exist: on such a machine, the reliable proof is CI (RM-037) |
A runtime_reported measurement of the context window | does not exist: claude-bg does not expose context usage, and the gate says unavailable |
| Deterministic code graph | works in the CLI (ork grafo: index, incremental update, queries and thread context, with each edge's provenance), for npm installs since 0.5.3; the MCP tools and the hint in the phase request stay behind the grafo.mcp flag, off by default (RM-031) |
The full picture, item by item and with git evidence, is in the roadmap index.
| Measure | Value | How to check |
|---|---|---|
| Core tests | 2,733, zero failing (CI on 2026-10-03) | npm --prefix core run test:ci |
| Behavior canaries | 24, all green | ork eval --so-canarios |
| Skills corpus | 20 skills, 101 cases, 199 assertions | ork eval --so-skills |
| Product docs and roadmap | zero parity errors | ork docs verificar |
| Runtime dependencies | 9, pinned | core/package.json |
On a virtual machine whose CPU the hypervisor steals (the steal in sar), the local verify can overrun the tests' time windows. That is why the merge gate is the independent CI, and the fix is planned in RM-037.
The docs home is organized by what you want to do now. Every page under docs/ is in Brazilian Portuguese; the CLI reference and the orchestration experience guide also have English versions, and the English quickstart lives on orkastery.com.
| Section | Start with |
|---|---|
| Getting started | Quickstart in English, and the full quickstart in Portuguese: from zero to the first full cycle |
| Guides | Modes, verification, human attention, memory, audits |
| Reference | CLI (the source is ork --help) and contracts |
| Concepts | Overview and architecture |
| Product and roadmap | What exists and what comes next, checked against the code |
Many commands still print Brazilian Portuguese (ork doctor, ork init, ork onboarding, ork experiencia show, ork thread (except thread new --from-finding), ork phase list, ork phase run, ork verify, ork ship, ork pulse, ork master, ork retry plan, ork retry run, ork roadmap (status and reservations), ork worktree, ork claims, ork decisao placar, ork accounts, ork network (except network roadmap), ork docs, ork adapter and the help follow an English locale). These are the words a first run shows.
| The CLI prints | It means |
|---|---|
PRONTO / BLOQUEADO | ready / blocked: the ork doctor verdict |
correcao | the fix for the line above it |
Proximo passo | next step |
VERDADE SUSTENTADA / REPROVADO | the claims held / failed: the ork verify verdict |
AGUARDANDO AUTORIZACAO HUMANA | waiting for your authorization |
Precisa de humano agora | needs a human now: the first line of ork pulse |
Recomendação, opção | recommendation, option |
aceitas por omissao | accepted by default, because nobody scored them |
pendente, respondida | pending, answered |
Horários em <zone> | times are shown in that time zone |
03/10 06:30 is October 3. The commands that follow an English locale print 2026-10-03 06:30.modos (modes), --modo (mode; --mode also works), --para (target branch), --por (who records it), --autorizar-push (authorize the push), --conteudo (content), --verificar (the command that verifies), decisao registrar (record a decision), docs verificar (check the docs), experiencia show (conversation preferences).owner.language: en-US sets the language of the conversation in your host (Claude Code, Codex), of the CLI commands that follow the locale and of what ork init, ork docs init and ork adapter install write for people to read (the orkastery.yaml comments, the AGENTS.md and CLAUDE.md blocks, the docs templates and the skills, checklists, commands and subagents the adapter installs); set it when machines with different locales conduct the same project, or those committed blocks switch language on each run; owner.language: pt-BR keeps them in Portuguese even with LANG=en_US.| Term | Meaning |
|---|---|
| HITL | human in the loop: a question only you can answer, with alternatives and one recommendation |
| ledger | the append-only event log of a thread, in .orkastery/threads/<thread>/ledger.jsonl; ork phase list <thread> prints it |
| claim | a statement, the file it is about and the command that judges it |
| worktree | the separate git checkout of each thread, under .claude/worktrees/ |
| lease | a lock on a shared resource, such as the main tree during a merge, with a queue |
| pulse | the hourly summary of what waits for you: ork pulse |
| maestro | the conversational overview in a host (say orkastery maestro), and the onboarding stage about you |
| GOAL … MASTER | the six phases, in the thread table |
| #Classic, #Maestro, #Auto, #Fast | the conduction modes, in the modes table |
ork docs sincronizar.ork roadmap pegar), and two never take the same one.| Project | What it is |
|---|---|
| orkastery | This repository: the ork core, the adapters, the skills and the auditors |
| OrkMind | The memory layer: Postgres with pgvector, an ontology and tag search; optional |
| orkastery.com | The website and user docs |
| orkmind.com | The OrkMind site |
orkastery maestro in a host with the adapter installed: you get an overview with sources, gaps, threads, sessions, HITL and next actions. In the terminal: ork maestro --json.No metronome ever turned anyone into Bernstein. Mastery is not tempo: it is knowing, after each performance, what you did, what it cost and how well it went, and being better at the next one. That is the phase we call MASTER, and it is why the project is called Orkastery.
hooks/register.tsx 471 lines1// 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}
471hooks/colisao.ts 421 lines1// 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}
421hooks/mapa.tsx 323 lines1// 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}
323types/index.d.ts 73 lines1// 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