SLOPSHOPPER

ork-portao

Portão de Tarefas do Orkastery no Claude Code: na sessão de fase do GO, cada tarefa do quadro é uma tarefa do plano com touch_paths e verify, só fecha com a…

newstatusprocessagents
A shopper browsing a rack in a slop shop
README

Orkastery

English · Português

npm MIT license

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

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

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

Your first thread

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

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

Why it exists

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

What changes for builders

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

How it works

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

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

The thread: six phases no agent skips

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

Four modes, chosen by #TAG

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

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

Truth, not reports

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

Layered human attention

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

Runtimes and accounts

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

Context and memory

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

Documentation as code

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

Install on your host

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

What exists today, and what does not

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

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

Verified in this tree

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

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

Documentation

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

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

Reading the CLI in English

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

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

Built with itself

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

Ecosystem

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

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

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

Credits and license

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

Source 5 files
hooks/register.ts 340 lines
1// O Portão de Tarefas (RM-059, fatia F10). Na sessão de fase do GO, o quadro de tarefas do Claude Code é o do
2// plano: a tarefa nativa só entra apontando uma tarefa do plano com `touch_paths` e verify que o núcleo confere
3// (T10.1), só fecha com essa conferência aprovada (T10.2) e, com agent teams no piloto do núcleo, vai ao teammate
4// ocioso quando a região dela está livre (T10.3). O Portão lê o núcleo pelo `$.ork` da ponte e pela ferramenta de
5// leitura do plano, nunca o estado interno do disco; age só por comando do `ork`; nunca roda o verify por conta
6// própria, nunca toma lease e nunca fala pelo dono. A lógica fica nos módulos puros ao lado; aqui só as chamadas ao
7// `$`, sempre escritas como `x.noun.metodo(...)`, como o validador exige.
8import type { EngineInterface, Register } from 'claude-code'
9import type { PortaoQuadro, PortaoTarefa } from '../types'
10import {
11  argsDoPlano, decidirCriacao, escopoDoPlano, escopoDoRetrato, FERRAMENTA_DO_PLANO, IDADE_DO_PLANO_MS, IDADE_SEM_PLANO_MS,
12  idsNoAssunto, jsonDoMcp, SERVIDOR, serveAoQuadro, tarefasDoPlano, textoDaRecusa, textoDoPlano, threadNoRetrato,
13} from './plano'
14import {
15  argvDaConferencia, assinatura, contarRepeticao, lerConferencia, textoDeOutraConferencia, textoDoBloqueio, textoDoStatus,
16  VIGIA_DA_CONFERENCIA_MS,
17} from './verify'
18import type { Leitura } from './verify'
19import {
20  escolherTarefa, nomeDoTeammate, paginaDosLeases, recusaDoTeammate, TETO_DE_PAGINAS_DE_LEASES, tetosDoAmbiente,
21  textoDaEntrega, VIVOS,
22} from './lease'
23import type { LeaseAtivo } from './lease'
24
25const QUADRO = { plugin: 'ork-portao', key: 'quadro' } as const
26
27/**
28 * O quadro desta carga do módulo (D9 do plano da thread): lido do `$.state` uma vez por thread e mudado só por
29 * `mudarQuadro`, que aplica a mudança na versão mais nova sem esperar nada no meio e então grava sem versão. Um gancho
30 * que espera uma chamada de `$` deixa outro gancho rodar: mudança feita sobre uma cópia lida antes apagaria a dele.
31 */
32let quadro: PortaoQuadro | null = null
33/** O plano lido e quando, por thread; relido depois da idade (D3). */
34let plano: { thread: string; em: number; tarefas: PortaoTarefa[] | null } | null = null
35/**
36 * A tarefa do plano que o Portão está conferindo no núcleo agora (D5): uma conferência por vez. Fica no módulo, não no
37 * `$.state`: uma troca do worker mata o processo junto com a marca.
38 */
39let conferindo: string | null = null
40
41/** A sessão de fase com thread, pelo contexto da ponte; qualquer outra sessão fica sem Portão (D1). */
42type ContextoDeFase = { thread: string; raiz: string }
43/**
44 * A sessão em que o Portão age (D1): o contexto e as tarefas do plano, ou `null` nelas quando o plano não serve ao quadro
45 * (ausente, sem tarefa que sirva ou com a leitura falhando). Sem elas, a criação e o teammate passam, mas a tarefa
46 * registrada na criação continua conferida na conclusão: o núcleo lê o plano por conta própria.
47 */
48type Sessao = { ctx: ContextoDeFase; tarefas: PortaoTarefa[] | null }
49
50const mensagem = (erro: unknown) => (erro instanceof Error ? erro.message : String(erro)).slice(0, 300)
51
52function quadroVazio(thread: string): PortaoQuadro {
53  return { thread, tarefas: {}, concluidas: [], repeticoes: {}, atribuicoes: {}, devolvidas: [], ultimaFalha: null }
54}
55
56async function contextoDeFase($: EngineInterface): Promise<ContextoDeFase | null> {
57  const ctx = await $.ork.contexto()
58  return ctx.papel === 'fase' && ctx.thread ? { thread: ctx.thread, raiz: ctx.raiz } : null
59}
60
61/** O quadro da thread: o do módulo, ou o do `$.state` na primeira leitura (a que chegar primeiro vale). */
62async function lerQuadro($: EngineInterface, thread: string): Promise<PortaoQuadro> {
63  if (quadro && quadro.thread === thread) return quadro
64  const lido = (await $.state.get(QUADRO)).value
65  if (quadro && quadro.thread === thread) return quadro
66  quadro = lido && lido.thread === thread ? { ...quadroVazio(thread), ...lido } : quadroVazio(thread)
67  return quadro
68}
69
70/**
71 * Aplica `mudar` ao quadro mais novo, sem esperar nada entre ler e trocar, e grava (D9). `mudar` que devolve o mesmo
72 * quadro não grava nada.
73 */
74async function mudarQuadro($: EngineInterface, thread: string, mudar: (q: PortaoQuadro) => PortaoQuadro): Promise<PortaoQuadro> {
75  await lerQuadro($, thread)
76  const atual = quadro as PortaoQuadro
77  const novo = mudar(atual)
78  if (novo === atual) return atual
79  quadro = novo
80  await $.state.set(QUADRO, novo)
81  return novo
82}
83
84/** As tarefas do plano pela ferramenta de leitura do núcleo, com a idade da D3; erro sobe para quem chamou. */
85async function lerTarefas($: EngineInterface, ctx: ContextoDeFase): Promise<PortaoTarefa[] | null> {
86  const agora = await $.clock.now()
87  if (plano && plano.thread === ctx.thread) {
88    const idade = agora - plano.em
89    const teto = plano.tarefas && plano.tarefas.length > 0 ? IDADE_DO_PLANO_MS : IDADE_SEM_PLANO_MS
90    if (idade >= 0 && idade < teto) return plano.tarefas
91  }
92  const r = await $.mcp.call(SERVIDOR, FERRAMENTA_DO_PLANO, argsDoPlano(ctx.thread))
93  const tarefas = tarefasDoPlano(textoDoPlano(jsonDoMcp(r, FERRAMENTA_DO_PLANO)))
94  plano = { thread: ctx.thread, em: agora, tarefas }
95  return tarefas
96}
97
98/**
99 * A sessão em que o Portão age (D1): papel `fase` com thread, thread não fechada e GO no bloco pelo retrato da ponte. O
100 * retrato que falha conta como GO e aberta, e fica na linha de status. As tarefas vêm do plano legível com ao menos uma
101 * tarefa que serve ao quadro; sem ele, `null`.
102 */
103async function sessaoDoPortao($: EngineInterface): Promise<Sessao | null> {
104  const ctx = await contextoDeFase($)
105  if (!ctx) return null
106  let retrato: { status: string | null; bloco: string | null } | null
107  try {
108    retrato = threadNoRetrato((await $.ork.retrato({ thread: ctx.thread })).retrato, ctx.thread)
109  } catch {
110    retrato = null
111  }
112  const doRetrato = escopoDoRetrato(retrato)
113  if (!doRetrato.ativo) return null
114  if (doRetrato.avisos.length > 0) $.ui.status(`portão: ${doRetrato.avisos.join('; ')}`)
115  let tarefas: PortaoTarefa[] | null
116  try {
117    tarefas = await lerTarefas($, ctx)
118  } catch (erro) {
119    await anotarFalha($, 'leitura do plano', erro, ctx.thread)
120    return { ctx, tarefas: null }
121  }
122  return { ctx, tarefas: escopoDoPlano(tarefas).ativo ? tarefas : null }
123}
124
125/**
126 * Guarda a falha do próprio Portão no quadro e a diz uma vez no transcript (não vai ao modelo) e na linha de
127 * status: nenhuma captura fica muda.
128 */
129async function anotarFalha($: EngineInterface, onde: string, erro: unknown, thread?: string): Promise<void> {
130  const motivo = mensagem(erro)
131  $.ui.status(`portão: falhou em ${onde}`)
132  const alvo = thread ?? quadro?.thread
133  if (!alvo) {
134    $.ui.log(`Portão de Tarefas: falhou em ${onde}: ${motivo}`)
135    return
136  }
137  const em = await $.clock.now()
138  let nova = false
139  await mudarQuadro($, alvo, q => {
140    if (q.ultimaFalha?.onde === onde && q.ultimaFalha.motivo === motivo) return q
141    nova = true
142    return { ...q, ultimaFalha: { onde, motivo, em } }
143  })
144  if (nova) $.ui.log(`Portão de Tarefas: falhou em ${onde}: ${motivo}`)
145}
146
147/**
148 * A tarefa do plano de uma tarefa nativa (D2): a registrada na criação vence (renomear não tira a tarefa do portão);
149 * sem registro, o assunto que cita uma tarefa do plano que serve ao quadro, e uma só. Fora disso, a tarefa não é do
150 * plano: a que o núcleo não confere nunca prende a conclusão.
151 */
152function tarefaDoEvento(assunto: unknown, taskId: string, q: PortaoQuadro, tarefas: readonly PortaoTarefa[]): string | null {
153  const registrada = q.tarefas[taskId]
154  if (registrada) return registrada
155  const ids = idsNoAssunto(assunto).filter(id => tarefas.some(t => t.id === id && serveAoQuadro(t)))
156  return ids.length === 1 ? (ids[0] as string) : null
157}
158
159/**
160 * A conferência do verify da tarefa no núcleo (D4, D5): `ork claims conferir --tarefa` por `$.process.run`, na raiz
161 * da sessão, com o vigia do mod. A espera é chamada de `$` e não conta no orçamento do gancho.
162 */
163async function rodarConferencia($: EngineInterface, ctx: ContextoDeFase, tarefa: string): Promise<Leitura> {
164  try {
165    const r = await $.process.run(argvDaConferencia(ctx.thread, tarefa), { cwd: ctx.raiz, timeoutMs: VIGIA_DA_CONFERENCIA_MS })
166    return lerConferencia(tarefa, r.stdout, r.exitCode, r.stderr)
167  } catch (erro) {
168    return { tipo: 'indisponivel', motivo: `ork claims conferir não rodou: ${mensagem(erro)}` }
169  }
170}
171
172/** Os tetos do piloto de agent teams que o núcleo passou no ambiente da sessão (D10); `null` sem eles. */
173async function lerTetos($: EngineInterface): Promise<{ teammates: number; custoUsd: number } | null> {
174  // O validador pede o nome literal; o teste do núcleo confere que ele é o de ENV_TEAMMATES e ENV_CUSTO.
175  return tetosDoAmbiente(await $.env.get('ORK_AGENT_TEAMS_TEAMMATES'), await $.env.get('ORK_AGENT_TEAMS_CUSTO_USD'))
176}
177
178/** O custo da sessão como o `/cost` soma; `null` quando ele não vem. */
179async function custoDaSessao($: EngineInterface): Promise<number | null> {
180  const uso = await $.session.usage()
181  return typeof uso.cost?.usd === 'number' ? uso.cost.usd : null
182}
183
184/**
185 * Os leases ativos de todas as threads (D8 revista), página a página pelo `nextOffset`. O servidor MCP da sessão de fase
186 * é fixado na thread e o retrato dele só traz os leases dela; por isso a leitura vai pela consulta `maestro` da lista
187 * fechada da ponte (`ork maestro --json --section leases --offset N`), que vê o projeto inteiro. Lança se uma página não
188 * vem ou se a leitura fica incompleta (lacuna de verdade, retrato em conflito, mais páginas que o teto): lista parcial
189 * nunca vira região livre.
190 */
191async function lerLeases($: EngineInterface): Promise<LeaseAtivo[]> {
192  const leases: LeaseAtivo[] = []
193  let offset = 0
194  for (let pagina = 0; pagina < TETO_DE_PAGINAS_DE_LEASES; pagina++) {
195    const pedido = { consulta: 'maestro' as const, secao: 'leases' as const, offset }
196    const p = paginaDosLeases(await $.ork.ler(pedido))
197    if (p.lacuna) throw new Error(`ork-portao: ${p.lacuna}`)
198    leases.push(...p.leases)
199    if (p.proximo === null) return leases
200    offset = p.proximo
201  }
202  throw new Error(`ork-portao: a seção leases tem mais de ${TETO_DE_PAGINAS_DE_LEASES} páginas; o resto não foi lido`)
203}
204
205export const register: Register = on => {
206  // A criação (T10.1): a tarefa nativa só entra apontando uma tarefa do plano que serve ao quadro (D2, D3). A recusa
207  // volta ao modelo pelo `block`, com o motivo e as tarefas que servem; a que passa fica registrada pelo id nativo.
208  on('classic.TaskCreated', async ($, e, next) => {
209    const sessao = await sessaoDoPortao($)
210    if (!sessao || !sessao.tarefas) return next(e)
211    const veredito = decidirCriacao(e.task_subject, sessao.tarefas)
212    if (veredito.tipo === 'recusa') return { block: textoDaRecusa(veredito, e.task_subject, sessao.tarefas) }
213    await mudarQuadro($, sessao.ctx.thread, q => ({ ...q, tarefas: { ...q.tarefas, [String(e.task_id)]: veredito.tarefa } }))
214    return next(e)
215  }).catch(async ($, e, next) => {
216    // Na reentrada o gancho nem rodou e as chamadas de `$` recusam: só a falha de verdade é anotada.
217    if (next.error && next.error.kind !== 're-entry') await anotarFalha($, 'criação de tarefa', next.error.message ?? next.error.kind)
218    return next(e)
219  })
220
221  // A conclusão (T10.2): a tarefa do plano só fecha com a conferência do núcleo aprovada (D4), uma por vez (D5).
222  // Todo bloqueio volta ao modelo com o motivo e a saída (corrigir, ou devolver a tarefa para pending), e a terceira
223  // vez seguida com o mesmo resultado manda deixar a tarefa aberta (D6). Tarefa que não é do plano passa.
224  on('classic.TaskCompleted', async ($, e, next) => {
225    const sessao = await sessaoDoPortao($)
226    if (!sessao) return next(e)
227    const { ctx } = sessao
228    const tarefa = tarefaDoEvento(e.task_subject, String(e.task_id), await lerQuadro($, ctx.thread), sessao.tarefas ?? [])
229    if (!tarefa) return next(e)
230    if (conferindo) return { block: textoDeOutraConferencia(tarefa, conferindo) }
231    conferindo = tarefa
232    let leitura: Leitura
233    try {
234      $.ui.status(`portão: conferindo ${tarefa} no núcleo`)
235      leitura = await rodarConferencia($, ctx, tarefa)
236    } finally {
237      conferindo = null
238    }
239    if (leitura.tipo === 'aprovada') {
240      const resultado = await next(e)
241      if (!resultado.block) {
242        await mudarQuadro($, ctx.thread, q => {
243          const repeticoes = { ...q.repeticoes }
244          delete repeticoes[tarefa]
245          return { ...q, concluidas: q.concluidas.includes(tarefa) ? q.concluidas : [...q.concluidas, tarefa], repeticoes }
246        })
247      }
248      $.ui.status(textoDoStatus(tarefa, leitura))
249      return resultado
250    }
251    const marca = assinatura(leitura)
252    let vezes = 1
253    await mudarQuadro($, ctx.thread, q => {
254      const repeticao = contarRepeticao(q.repeticoes[tarefa], marca)
255      vezes = repeticao.vezes
256      return { ...q, repeticoes: { ...q.repeticoes, [tarefa]: repeticao } }
257    })
258    $.ui.status(textoDoStatus(tarefa, leitura))
259    return { block: textoDoBloqueio(tarefa, leitura, vezes) }
260  }).catch(async ($, e, next) => {
261    // A conclusão fecha só aprovada (D4): se o próprio Portão falhou no meio, a tarefa registrada, ou a que cita um id
262    // de tarefa do plano no assunto, não fecha. A anotação é melhor esforço: o bloqueio não depende dela.
263    if (!next.error || next.error.kind === 're-entry') return next(e)
264    const motivo = next.error.message ?? next.error.kind
265    try { await anotarFalha($, 'conclusão de tarefa', motivo) } catch { $.ui.status('portão: falhou na conclusão de tarefa') }
266    const ids = idsNoAssunto(e.task_subject)
267    const tarefa = quadro?.tarefas[String(e.task_id)] ?? (ids.length === 1 ? ids[0] : undefined)
268    if (!tarefa) return next(e)
269    return { block: textoDoBloqueio(tarefa, { tipo: 'indisponivel', motivo: `o Portão falhou ao conferir: ${motivo}` }) }
270  })
271
272  // O teammate ocioso (T10.3): com os tetos do piloto na sessão, quem ficou ocioso sem concluir devolve a tarefa, e a
273  // próxima tarefa livre do plano (D7) vai a ele pelo `block`. Livre é sem lease `path:` ativo de nenhuma thread na
274  // região e sem cruzar as tarefas dos outros teammates (D8 revista); a escolha e a atribuição acontecem juntas sobre o
275  // quadro mais novo. No teto de custo, sem os leases legíveis ou sem tarefa livre, o teammate fica ocioso.
276  on('classic.TeammateIdle', async ($, e, next) => {
277    const sessao = await sessaoDoPortao($)
278    if (!sessao || !sessao.tarefas) return next(e)
279    const tetos = await lerTetos($)
280    const teammate = nomeDoTeammate(e.teammate_name)
281    if (!tetos || !teammate) return next(e)
282    const { ctx, tarefas } = sessao
283    await mudarQuadro($, ctx.thread, q => {
284      const tinha = q.atribuicoes[teammate]
285      if (!tinha) return q
286      const atribuicoes = { ...q.atribuicoes }
287      delete atribuicoes[teammate]
288      const devolvida = !q.concluidas.includes(tinha) && !q.devolvidas.includes(tinha)
289      return { ...q, atribuicoes, devolvidas: devolvida ? [...q.devolvidas, tinha] : q.devolvidas }
290    })
291    const custo = await custoDaSessao($)
292    if (custo === null || custo >= tetos.custoUsd) {
293      $.ui.status(custo === null ? 'portão: custo da sessão ilegível; nenhuma tarefa nova ao time' : `portão: teto de custo do piloto (US$ ${tetos.custoUsd}) atingido`)
294      return next(e)
295    }
296    let leases: LeaseAtivo[]
297    try {
298      leases = await lerLeases($)
299    } catch (erro) {
300      await anotarFalha($, 'leitura dos leases', erro, ctx.thread)
301      return next(e)
302    }
303    let entregue: PortaoTarefa | null = null
304    await mudarQuadro($, ctx.thread, q => {
305      const escolha = escolherTarefa({ tarefas, quadro: q, teammate, leases })
306      if (!escolha) return q
307      entregue = escolha
308      return { ...q, atribuicoes: { ...q.atribuicoes, [teammate]: escolha.id } }
309    })
310    const tarefa = entregue as PortaoTarefa | null
311    if (!tarefa) return next(e)
312    $.ui.status(`portão: ${tarefa.id} com ${teammate}`)
313    return { block: textoDaEntrega(tarefa, teammate) }
314  }).catch(async ($, e, next) => {
315    if (next.error && next.error.kind !== 're-entry') await anotarFalha($, 'teammate ocioso', next.error.message ?? next.error.kind)
316    return next(e)
317  })
318
319  // Os tetos do piloto (D10): em toda sessão de fase, o teammate só nasce com os tetos do núcleo, abaixo do teto de
320  // teammates vivos e do teto de custo. Subagente que não é teammate passa.
321  on('agent.spawn', async ($, e, next) => {
322    if (!e.isTeammate) return next(e)
323    const ctx = await contextoDeFase($)
324    if (!ctx) return next(e)
325    const tetos = await lerTetos($)
326    const vivos = (await $.agent.list()).filter(a => a.teammateId && VIVOS.includes(a.status)).length
327    const recusa = recusaDoTeammate(tetos, vivos, tetos ? await custoDaSessao($) : null)
328    if (recusa) {
329      $.ui.status('portão: teammate recusado pelo teto do piloto')
330      return { deny: recusa }
331    }
332    return next(e)
333  }).catch(async ($, e, next) => {
334    if (!next.error || next.error.kind === 're-entry' || !e.isTeammate || next.called) return next(e)
335    const motivo = next.error.message ?? next.error.kind
336    try { await anotarFalha($, 'teto de teammates', motivo) } catch { $.ui.status('portão: falhou no teto de teammates') }
337    return { deny: `Portão de Tarefas: teammate recusado. O Portão não conseguiu conferir os tetos do piloto (${motivo}).` }
338  })
339}
340
hooks/plano.ts 305 lines
1// O plano da thread como o Portão de Tarefas o usa (RM-059, fatia F10, T10.1). Módulo puro: interpreta o que o
2// `ork_artifact_read` devolve, acha as tarefas `T<id>` com os `touch_paths` e o verify de cada uma pela mesma regra
3// do `ork claims conferir --tarefa` do núcleo, tira o id da tarefa do assunto da tarefa nativa, decide a criação e
4// lê o escopo no retrato da ponte. As chamadas ao `$` ficam no register.ts; nada aqui lê `.orkastery/` do disco.
5import type { PortaoTarefa } from '../types'
6
7export const SERVIDOR = 'orkastery'
8/**
9 * A lista fechada de ferramentas MCP do Portão: só a leitura do plano. O `$.mcp.call` não pede permissão ao
10 * dono, então o teste do núcleo confere pela AST que o mod só chama esta.
11 */
12export const FERRAMENTA_DO_PLANO = 'ork_artifact_read'
13export const FERRAMENTAS_DO_PORTAO: readonly string[] = [FERRAMENTA_DO_PLANO]
14/** O plano lido vale por este tempo; sem plano (ou com erro), a leitura se repete depois de pouco. */
15export const IDADE_DO_PLANO_MS = 60000
16export const IDADE_SEM_PLANO_MS = 5000
17/** Quantas tarefas do plano a recusa lista ao modelo, e o teto do texto inteiro. */
18export const TETO_DE_TAREFAS_NO_TEXTO = 12
19export const TETO_DO_TEXTO = 4000
20
21const ID_DE_THREAD = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/
22/** O cabeçalho de tarefa do núcleo (`verifyDaTarefa`): `##` a `######`, `T<id>`, e o resto da linha. */
23const CABECALHO_DE_TAREFA = /^(#{2,6})\s+T(\d+(?:\.\d+)*)(?=[\s.:)]|$)(.*)$/
24/** A linha de verify nos formatos que o núcleo aceita; o grupo 1 é o que vem depois do rótulo. */
25const ROTULO_DE_VERIFY = /^\s*(?:[-*]\s+)?(?:\*\*verify:\*\*|\*\*verify\*\*\s*:|`verify`\s*:|verify\s*:)\s*(.*)$/i
26/**
27 * O rótulo de `touch_paths` nos formatos em uso nos planos (o da carga do Freio), também com a contagem entre parênteses
28 * antes dos dois-pontos (`- touch_paths (4): ...`).
29 */
30const ROTULO_DE_TOUCH_PATHS = /^\s*(?:[-*+]\s+)?(?:\*\*touch_paths(?:\s*\([^)]*\))?:?\*\*\s*:?|`touch_paths`(?:\s*\([^)]*\))?\s*:|touch_paths(?:\s*\([^)]*\))?\s*:)\s*(.*)$/i
31/** Trecho entre crases com cara de caminho: barra, ponto ou curinga. Identificador da prosa (`Modo`, `fast`) não conta. */
32const CARA_DE_CAMINHO = /[/.*?]/
33/** Linha que abre outro item de lista, cabeçalho ou cerca de código. */
34const ABRE_BLOCO = /^\s*(?:[-*+]\s|\d+[.)]\s|#{1,6}\s|```|~~~)/
35const TRECHO = /`([^`]+)`/g
36/** O id de uma tarefa do plano como palavra própria no assunto (`T2.1 ...`, `[T3]`), nunca colado a letra. */
37const ID_NO_ASSUNTO = /(?:^|[^A-Za-z0-9_])T(\d+(?:\.\d+)*)(?![A-Za-z0-9_]|\.\d)/g
38/** As fases na ordem do núcleo, com o dígito que o slug do bloco usa (`DIGITO_DA_FASE`). */
39const FASES = ['GOAL', 'PLAN', 'GO', 'CHECK', 'SHIP', 'MASTER'] as const
40
41export class ErroDoPlano extends Error {}
42
43/** Os argumentos da leitura do plano da thread do despacho. */
44export function argsDoPlano(thread: string): Record<string, unknown> {
45  if (!ID_DE_THREAD.test(thread)) throw new ErroDoPlano('ork-portao: id de thread inválido')
46  return { threadId: thread, tipo: 'plan' }
47}
48
49/** O JSON do primeiro bloco de texto de um resultado MCP; resultado com erro vira exceção. */
50export function jsonDoMcp(r: unknown, ferramenta: string): unknown {
51  const resultado = r as { content?: ReadonlyArray<{ type?: string; text?: unknown }>; isError?: boolean } | null
52  const texto = (resultado?.content ?? []).find(c => c?.type === 'text' && typeof c.text === 'string')?.text as string | undefined
53  if (resultado?.isError) throw new ErroDoPlano(`ork-portao: ${ferramenta} respondeu com erro${texto ? `: ${texto.slice(0, 200)}` : ''}`)
54  if (texto === undefined) throw new ErroDoPlano(`ork-portao: ${ferramenta} não devolveu texto`)
55  try {
56    return JSON.parse(texto)
57  } catch {
58    throw new ErroDoPlano(`ork-portao: ${ferramenta} devolveu texto que não é JSON`)
59  }
60}
61
62/** O texto do plano que o `ork_artifact_read` devolve, ou `null` quando a thread ainda não tem plano. */
63export function textoDoPlano(bruto: unknown): string | null {
64  const documento = bruto as { conteudo?: unknown } | null
65  if (!documento || typeof documento !== 'object') throw new ErroDoPlano('ork-portao: o plano lido não é objeto')
66  return typeof documento.conteudo === 'string' ? documento.conteudo : null
67}
68
69/** Caminho relativo normalizado: barras para a frente, sem `./` na frente nem barra no fim. */
70export function normalizar(caminho: string): string {
71  return caminho.trim().replace(/\\/g, '/').replace(/^(?:\.\/)+/, '').replace(/\/+$/, '')
72}
73
74function ehCaminho(texto: string): boolean {
75  return texto !== '' && !/\s/.test(texto) && !texto.startsWith('-') && !texto.split('/').includes('..')
76}
77
78const recuo = (linha: string) => (/^[ \t]*/.exec(linha)?.[0] ?? '').replace(/\t/g, '    ').length
79
80/**
81 * O item inteiro do verify da linha `k` (a regra de `itemDoVerify` do núcleo): o texto depois do rótulo, a
82 * continuação logo abaixo que não abre outro item e as linhas mais recuadas que o rótulo, mesmo depois de branco.
83 */
84function itemDoVerify(bloco: readonly string[], k: number, inicio: string): string {
85  const base = recuo(bloco[k] as string)
86  const partes = [inicio]
87  let depoisDeBranco = false
88  for (const linha of bloco.slice(k + 1)) {
89    if (!linha.trim()) { depoisDeBranco = true; continue }
90    if (recuo(linha) <= base && (depoisDeBranco || ABRE_BLOCO.test(linha))) break
91    partes.push(linha.trim())
92    depoisDeBranco = false
93  }
94  return partes.join(' ').trim()
95}
96
97/** O verify de uma tarefa pela regra do núcleo: o comando, ou o motivo com que o `ork claims conferir` recusaria. */
98function verifyDoBloco(bloco: readonly string[]): { verify: string | null; problema: PortaoTarefa['problema'] } {
99  const verifies = bloco.flatMap((l, k) => {
100    const m = ROTULO_DE_VERIFY.exec(l)
101    return m ? [itemDoVerify(bloco, k, (m[1] as string).trim())] : []
102  })
103  if (verifies.length === 0) return { verify: null, problema: 'tarefa-sem-verify' }
104  if (verifies.length > 1) return { verify: null, problema: 'tarefa-ambigua' }
105  const resto = verifies[0] as string
106  if ([...resto.matchAll(TRECHO)].length === 0) return { verify: null, problema: 'tarefa-sem-comando' }
107  const unico = /^`([^`]+)`\s*\.?$/.exec(resto)
108  if (!unico || !(unico[1] as string).trim()) return { verify: null, problema: 'tarefa-ambigua' }
109  return { verify: (unico[1] as string).trim(), problema: null }
110}
111
112/** Os `touch_paths` de uma tarefa: os trechos entre crases do item do rótulo e das linhas que o continuam. */
113function touchPathsDoBloco(bloco: readonly string[]): string[] {
114  const achados: string[] = []
115  const juntar = (texto: string) => {
116    for (const m of texto.matchAll(TRECHO)) {
117      const bruto = (m[1] as string).trim()
118      if (!CARA_DE_CAMINHO.test(bruto)) continue
119      const caminho = normalizar(bruto)
120      if (ehCaminho(caminho) && !achados.includes(caminho)) achados.push(caminho)
121    }
122  }
123  let recuoDoRotulo = -1
124  let depoisDeBranco = false
125  for (const linha of bloco) {
126    const r = recuo(linha)
127    const rotulo = ROTULO_DE_TOUCH_PATHS.exec(linha)
128    if (rotulo) {
129      juntar(rotulo[1] as string)
130      recuoDoRotulo = r
131      depoisDeBranco = false
132      continue
133    }
134    if (recuoDoRotulo < 0) continue
135    if (linha.trim() === '') { depoisDeBranco = true; continue }
136    if (r <= recuoDoRotulo && (depoisDeBranco || ABRE_BLOCO.test(linha))) { recuoDoRotulo = -1; continue }
137    juntar(linha)
138    depoisDeBranco = false
139  }
140  return achados
141}
142
143/**
144 * As tarefas do plano, na ordem do texto (D3 do plano da thread): o cabeçalho `##` a `######` com `T<id>`, o bloco
145 * até o próximo cabeçalho de nível igual ou maior, os `touch_paths` e o verify pela regra do núcleo. Id repetido
146 * fica uma vez só, com o problema `tarefa-repetida`. `null` sem plano.
147 */
148export function tarefasDoPlano(conteudo: string | null): PortaoTarefa[] | null {
149  if (typeof conteudo !== 'string') return null
150  const linhas = conteudo.replace(/\r\n/g, '\n').split('\n')
151  const inicios = linhas.flatMap((l, i) => {
152    const m = CABECALHO_DE_TAREFA.exec(l)
153    return m ? [{ i, nivel: (m[1] as string).length, id: `T${m[2] as string}`, titulo: (m[3] as string).replace(/^[\s.:)-]+/, '').trim() }] : []
154  })
155  const vistos = new Map<string, number>()
156  for (const c of inicios) vistos.set(c.id, (vistos.get(c.id) ?? 0) + 1)
157  const tarefas: PortaoTarefa[] = []
158  for (const c of inicios) {
159    if (tarefas.some(t => t.id === c.id)) continue
160    const fim = linhas.findIndex((l, j) => j > c.i && new RegExp(`^#{1,${c.nivel}}\\s`).test(l))
161    const bloco = linhas.slice(c.i + 1, fim < 0 ? undefined : fim)
162    const { verify, problema } = (vistos.get(c.id) ?? 0) > 1 ? { verify: null, problema: 'tarefa-repetida' as const } : verifyDoBloco(bloco)
163    tarefas.push({ id: c.id, titulo: c.titulo.slice(0, 120), touchPaths: touchPathsDoBloco(bloco), verify, problema })
164  }
165  return tarefas
166}
167
168/** Os ids distintos de tarefa do plano que o assunto da tarefa nativa cita, na ordem. */
169export function idsNoAssunto(assunto: unknown): string[] {
170  if (typeof assunto !== 'string') return []
171  const ids: string[] = []
172  for (const m of assunto.matchAll(ID_NO_ASSUNTO)) {
173    const id = `T${m[1] as string}`
174    if (!ids.includes(id)) ids.push(id)
175  }
176  return ids
177}
178
179/** A tarefa serve para o quadro: tem `touch_paths` e um verify que o núcleo confere. */
180export function serveAoQuadro(t: PortaoTarefa): boolean {
181  return t.touchPaths.length > 0 && t.verify !== null && t.problema === null
182}
183
184/** Por que a criação foi recusada (D2 e D3 do plano da thread). */
185export type MotivoDaRecusa = 'sem-id' | 'ambigua' | 'fora-do-plano' | 'repetida' | 'sem-touch-paths' | 'sem-verify'
186
187export type VereditoDaCriacao =
188  | { tipo: 'passa'; tarefa: string }
189  | { tipo: 'recusa'; motivo: MotivoDaRecusa; ids: string[]; tarefa: PortaoTarefa | null }
190
191/** A criação de uma tarefa nativa contra as tarefas do plano. */
192export function decidirCriacao(assunto: unknown, tarefas: readonly PortaoTarefa[]): VereditoDaCriacao {
193  const ids = idsNoAssunto(assunto)
194  if (ids.length === 0) return { tipo: 'recusa', motivo: 'sem-id', ids, tarefa: null }
195  if (ids.length > 1) return { tipo: 'recusa', motivo: 'ambigua', ids, tarefa: null }
196  const tarefa = tarefas.find(t => t.id === ids[0]) ?? null
197  if (!tarefa) return { tipo: 'recusa', motivo: 'fora-do-plano', ids, tarefa: null }
198  if (tarefa.problema === 'tarefa-repetida') return { tipo: 'recusa', motivo: 'repetida', ids, tarefa }
199  if (tarefa.touchPaths.length === 0) return { tipo: 'recusa', motivo: 'sem-touch-paths', ids, tarefa }
200  if (!serveAoQuadro(tarefa)) return { tipo: 'recusa', motivo: 'sem-verify', ids, tarefa }
201  return { tipo: 'passa', tarefa: tarefa.id }
202}
203
204const DETALHE_DO_VERIFY: Record<string, string> = {
205  'tarefa-sem-verify': 'não há linha de verify',
206  'tarefa-sem-comando': 'o verify não tem comando entre crases',
207  'tarefa-ambigua': 'o verify tem mais de uma linha, mais de um comando ou texto além do comando',
208}
209
210function motivoEmTexto(v: Extract<VereditoDaCriacao, { tipo: 'recusa' }>): string {
211  const id = v.ids[0] ?? ''
212  switch (v.motivo) {
213    case 'sem-id': return 'o assunto não traz o id de uma tarefa do plano'
214    case 'ambigua': return `o assunto cita mais de uma tarefa do plano (${v.ids.join(', ')}), e cada tarefa do quadro é uma tarefa do plano`
215    case 'fora-do-plano': return `o plano não tem a tarefa ${id}`
216    case 'repetida': return `o plano tem mais de um cabeçalho da tarefa ${id}`
217    case 'sem-touch-paths': return `a tarefa ${id} do plano não tem touch_paths (caminhos entre crases, com barra, ponto ou curinga), então ninguém sabe que arquivos ela toca`
218    case 'sem-verify': return `a tarefa ${id} do plano não tem um verify que o núcleo confira (${DETALHE_DO_VERIFY[v.tarefa?.problema ?? ''] ?? 'sem comando'})`
219  }
220}
221
222/** Uma tarefa do plano como a lista ao modelo: o id e o título. */
223export function rotulo(t: PortaoTarefa): string {
224  return t.titulo ? `${t.id} (${t.titulo})` : t.id
225}
226
227/** O texto do `block` da criação recusada: o motivo, a regra e as tarefas do plano que servem. */
228export function textoDaRecusa(v: Extract<VereditoDaCriacao, { tipo: 'recusa' }>, assunto: unknown, tarefas: readonly PortaoTarefa[]): string {
229  const nome = typeof assunto === 'string' ? assunto.trim().slice(0, 120) : ''
230  const servem = tarefas.filter(serveAoQuadro)
231  const partes = [
232    `Portão de Tarefas: a tarefa "${nome}" não entrou no quadro: ${motivoEmTexto(v)}.`,
233    'No GO desta thread, cada tarefa do quadro é uma tarefa do plano: o assunto leva o id dela (por exemplo, "T2.1 título"), ' +
234      'e a tarefa do plano precisa de touch_paths e de um verify com um comando só entre crases, que o núcleo confere ' +
235      'quando a tarefa fecha (ork claims conferir --tarefa).',
236  ]
237  if (servem.length === 0) partes.push('Nenhuma tarefa do plano serve hoje: o plano precisa de touch_paths e de verify nas tarefas antes de abrir o quadro.')
238  else {
239    const lista = servem.slice(0, TETO_DE_TAREFAS_NO_TEXTO).map(rotulo).join('; ')
240    const mais = servem.length > TETO_DE_TAREFAS_NO_TEXTO ? `; e mais ${servem.length - TETO_DE_TAREFAS_NO_TEXTO}` : ''
241    partes.push(`Tarefas do plano que servem: ${lista}${mais}.`)
242  }
243  return limitarTexto(partes.join('\n'))
244}
245
246/** Um texto ao modelo dentro do teto, com a marca de corte. */
247export function limitarTexto(texto: string): string {
248  return texto.length > TETO_DO_TEXTO ? `${texto.slice(0, TETO_DO_TEXTO - 40)}\n(texto cortado pelo Portão de Tarefas)` : texto
249}
250
251/**
252 * O GO está no bloco pelo slug que o núcleo dá a ele (`slugDasFases`): `full` são todas as fases, o nome de uma
253 * fase em minúsculas é ela só, e `f` seguido de dígitos são as fases desses dígitos. `null` quando o slug não diz.
254 */
255export function faseNoBloco(slug: unknown, fase: string = 'GO'): boolean | null {
256  if (typeof slug !== 'string' || slug === '') return null
257  if (slug === 'full') return true
258  const so = FASES.find(f => f.toLowerCase() === slug)
259  if (so) return so === fase
260  if (!/^f[1-6]+$/.test(slug)) return null
261  const digito = String(FASES.indexOf(fase as (typeof FASES)[number]) + 1)
262  return slug.slice(1).includes(digito)
263}
264
265/** O status e o slug do bloco da thread no retrato `ork.maestro-snapshot/v1`; `null` no que ele não diz. */
266export function threadNoRetrato(retrato: unknown, thread: string): { status: string | null; bloco: string | null } {
267  const itens = (retrato as { sections?: { threads?: { items?: unknown } } } | null)?.sections?.threads?.items
268  if (!Array.isArray(itens)) return { status: null, bloco: null }
269  const item = itens.find(i => (i as { id?: unknown } | null)?.id === thread) as { status?: unknown; facts?: { block?: unknown } } | undefined
270  return {
271    status: typeof item?.status === 'string' ? item.status : null,
272    bloco: typeof item?.facts?.block === 'string' ? item.facts.block : null,
273  }
274}
275
276/** O que decide se o Portão age (D1). */
277export type Escopo = { ativo: true; avisos: string[] } | { ativo: false; motivo: string }
278
279/** A metade do escopo que o retrato decide, antes de ler o plano: sem retrato, conta como bloco com GO e aberta. */
280export function escopoDoRetrato(retrato: { status: string | null; bloco: string | null } | null): Escopo {
281  if (retrato === null) return { ativo: true, avisos: ['retrato indisponível; o Portão confere mesmo assim'] }
282  if (retrato.status === 'fechada') return { ativo: false, motivo: 'thread fechada' }
283  if (faseNoBloco(retrato.bloco) === false) return { ativo: false, motivo: `o bloco ${retrato.bloco} não tem o GO` }
284  return { ativo: true, avisos: [] }
285}
286
287/**
288 * A outra metade (D1 refinada): o plano legível com ao menos uma tarefa que serve ao quadro. Plano em que nenhuma tarefa
289 * tem `touch_paths` e verify conferível não ganha portão: travaria o quadro inteiro do GO.
290 */
291export function escopoDoPlano(tarefas: readonly PortaoTarefa[] | null): Escopo {
292  if (tarefas === null) return { ativo: false, motivo: 'a thread ainda não tem plano legível' }
293  if (tarefas.length === 0) return { ativo: false, motivo: 'o plano não tem tarefas T<n>' }
294  if (!tarefas.some(serveAoQuadro)) return { ativo: false, motivo: 'nenhuma tarefa do plano tem touch_paths e verify conferível' }
295  return { ativo: true, avisos: [] }
296}
297
298/** O escopo inteiro, como o register.ts o compõe: o retrato primeiro, o plano depois. */
299export function decidirEscopo(retrato: { status: string | null; bloco: string | null } | null, tarefas: readonly PortaoTarefa[] | null): Escopo {
300  const doRetrato = escopoDoRetrato(retrato)
301  if (!doRetrato.ativo) return doRetrato
302  const doPlano = escopoDoPlano(tarefas)
303  return doPlano.ativo ? doRetrato : doPlano
304}
305
hooks/verify.ts 153 lines
1// A conclusão pela conferência do núcleo (RM-059, fatia F10, T10.2). Módulo puro: monta o argv do
2// `ork claims conferir --tarefa`, lê o JSON `ork.claims-conferencia/v1`, conta a repetição e escreve o texto do
3// bloqueio. O Portão nunca roda o verify da tarefa: quem roda é o núcleo, no sandbox do verify (D12 do plano dos
4// mods), e quem verifica de verdade continua sendo o `ork verify`.
5import type { PortaoRepeticao } from '../types'
6
7export const FORMATO_DA_CONFERENCIA = 'ork.claims-conferencia/v1'
8/** O prazo total pedido ao núcleo, em segundos (D5 corrigida: cabe no teto de dez minutos do `$.process.run`). */
9export const PRAZO_DA_CONFERENCIA_S = 540
10/** O vigia do mod: o `$.process.run` mata o processo depois disto, e a conferência fica indisponível. */
11export const VIGIA_DA_CONFERENCIA_MS = 600000
12/** Da terceira conclusão seguida bloqueada pelo mesmo resultado em diante, o texto manda deixar a tarefa aberta (D6). */
13export const REPETICOES_PARA_DEIXAR = 3
14/** Tetos do fim da saída que vai ao modelo. */
15export const TETO_DE_LINHAS_DA_SAIDA = 15
16export const TETO_DE_CARACTERES_DA_SAIDA = 1500
17
18const ID_DE_THREAD = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/
19const ID_DE_TAREFA = /^T\d+(?:\.\d+)*$/
20/** A saída do CLI quando outra condução segura a thread. */
21const SAIDA_DE_CONDUCAO = 3
22
23/** O argv do `ork claims conferir` do verify de uma tarefa; o comando da tarefa fica com o núcleo. */
24export function argvDaConferencia(thread: string, tarefa: string): string[] {
25  if (!ID_DE_THREAD.test(thread)) throw new Error('ork-portao: id de thread inválido')
26  if (!ID_DE_TAREFA.test(tarefa)) throw new Error('ork-portao: id de tarefa inválido')
27  return ['ork', 'claims', 'conferir', thread, '--tarefa', tarefa, '--prazo', String(PRAZO_DA_CONFERENCIA_S), '--json']
28}
29
30/**
31 * O que a conferência disse da tarefa: aprovada; reprovada (`claims.failed`); sem veredito (prazo, comando que não
32 * rodou); recusada pelo núcleo (`claims.conferir.*`); com outra condução na thread; ou indisponível.
33 */
34export type Leitura =
35  | { tipo: 'aprovada'; comando: string }
36  | { tipo: 'reprovada'; comando: string; codigo: number | null; fim: string }
37  | { tipo: 'sem-veredito'; motivo: string; comando: string; detalhe: string }
38  | { tipo: 'recusada'; motivo: string }
39  | { tipo: 'ocupada'; motivo: string }
40  | { tipo: 'indisponivel'; motivo: string }
41
42const ultimaLinha = (texto: string) => texto.trim().split('\n').filter(l => l.trim() !== '').slice(-1)[0]?.trim().slice(0, 300) ?? ''
43
44type Execucao = { comando?: unknown; ok?: unknown; code?: unknown; fim?: unknown }
45
46/**
47 * A saída do `ork claims conferir --tarefa --json` (D4): o resultado só com o formato certo, o item da tarefa pedida
48 * e a saída 0 ou 1; a recusa tipada é a saída 2 com `claims.conferir.*`; a saída 3 é outra condução; o resto (texto
49 * solto de um `ork` antigo, JSON sem o item) é indisponível.
50 */
51export function lerConferencia(tarefa: string, stdout: string, codigo: number | null, stderr: string): Leitura {
52  let j: { formato?: unknown; itens?: unknown; erro?: unknown; texto?: unknown } | null = null
53  try { j = JSON.parse(stdout) } catch { j = null }
54  if (j && j.formato === FORMATO_DA_CONFERENCIA && Array.isArray(j.itens) && (codigo === 0 || codigo === 1)) {
55    const item = (j.itens as Array<{ id?: unknown; verificado?: unknown; motivo?: unknown; detalhe?: unknown; execucoes?: unknown } | null>)
56      .find(i => i?.id === tarefa)
57    if (!item) return { tipo: 'indisponivel', motivo: `a conferência não devolveu a tarefa ${tarefa}` }
58    const execucoes = Array.isArray(item.execucoes) ? item.execucoes as Execucao[] : []
59    const chave = execucoes.find(x => x && x.ok === false) ?? execucoes[0]
60    const comando = typeof chave?.comando === 'string' ? chave.comando : ''
61    if (item.verificado === true && codigo === 0) return { tipo: 'aprovada', comando }
62    if (item.motivo === 'claims.failed') {
63      return { tipo: 'reprovada', comando, codigo: typeof chave?.code === 'number' ? chave.code : null, fim: typeof chave?.fim === 'string' ? chave.fim : '' }
64    }
65    return {
66      tipo: 'sem-veredito', comando,
67      motivo: typeof item.motivo === 'string' ? item.motivo : 'sem motivo',
68      detalhe: typeof item.detalhe === 'string' ? item.detalhe.slice(0, 300) : '',
69    }
70  }
71  const erro = j && typeof j.erro === 'string' ? j.erro.trim().slice(0, 300) : ''
72  if (codigo === SAIDA_DE_CONDUCAO) {
73    // Com `--json`, a recusa de condução sai no stdout (`ork.conducao-recusa/v1`), com o texto para quem lê.
74    const texto = j && typeof j.texto === 'string' ? (j.texto.trim().split('\n')[0] ?? '').slice(0, 300) : ''
75    return { tipo: 'ocupada', motivo: texto || erro || ultimaLinha(stderr) || 'outra condução segura a thread' }
76  }
77  if (erro.startsWith('claims.conferir.')) return { tipo: 'recusada', motivo: erro }
78  const motivo = erro || ultimaLinha(stderr) || (j === null ? ultimaLinha(stdout) : '') || `saiu com código ${codigo ?? 'desconhecido'}`
79  return { tipo: 'indisponivel', motivo: `ork claims conferir sem resultado: ${motivo}` }
80}
81
82/** O fim da saída de um comando: as últimas linhas e o último trecho, dentro dos tetos. */
83export function fimDaSaida(fim: string): string[] {
84  let texto = fim.replace(/\s+$/, '')
85  if (texto.length > TETO_DE_CARACTERES_DA_SAIDA) texto = texto.slice(-TETO_DE_CARACTERES_DA_SAIDA)
86  return texto === '' ? [] : texto.split('\n').slice(-TETO_DE_LINHAS_DA_SAIDA)
87}
88
89/** O que torna dois bloqueios "o mesmo resultado" para a contagem da D6. */
90export function assinatura(l: Leitura): string {
91  switch (l.tipo) {
92    case 'aprovada': return 'aprovada'
93    case 'reprovada': return JSON.stringify(['reprovada', l.comando, l.codigo, fimDaSaida(l.fim).slice(-3)])
94    case 'sem-veredito': return JSON.stringify(['sem-veredito', l.motivo, l.comando])
95    default: return JSON.stringify([l.tipo, l.motivo])
96  }
97}
98
99/** A repetição da tarefa depois deste bloqueio: a mesma assinatura soma, outra recomeça em 1. */
100export function contarRepeticao(anterior: PortaoRepeticao | undefined, nova: string): PortaoRepeticao {
101  return anterior && anterior.assinatura === nova ? { assinatura: nova, vezes: anterior.vezes + 1 } : { assinatura: nova, vezes: 1 }
102}
103
104const SAIDA = 'Se não der para corrigir agora, devolva a tarefa para pending (TaskUpdate) e siga: o ork verify continua sendo quem verifica.'
105
106/** O texto do `block` da conclusão recusada (D4 e D6), com a saída sempre dita. */
107export function textoDoBloqueio(tarefa: string, l: Exclude<Leitura, { tipo: 'aprovada' }>, vezes = 1): string {
108  const partes: string[] = []
109  switch (l.tipo) {
110    case 'reprovada':
111      partes.push(`Portão de Tarefas: a tarefa ${tarefa} não fecha: o verify do plano reprovou na conferência do núcleo (ork claims conferir, no sandbox do verify, sem recibo).`)
112      if (l.comando) partes.push(`  comando: ${l.comando}`)
113      partes.push(`  saiu com código ${l.codigo ?? 'desconhecido'}${l.fim.trim() ? '; fim da saída:' : ', sem saída'}`)
114      for (const linha of fimDaSaida(l.fim)) partes.push(`  | ${linha}`)
115      partes.push('Corrija e conclua de novo. Teste que cria processo pode reprovar no sandbox e passar no ork verify.')
116      break
117    case 'sem-veredito':
118      partes.push(`Portão de Tarefas: a tarefa ${tarefa} não fecha: a conferência do núcleo terminou sem veredito (${l.motivo}${l.detalhe ? `: ${l.detalhe}` : ''}).`)
119      if (l.comando) partes.push(`  comando: ${l.comando}`)
120      partes.push('Conclua de novo para conferir outra vez.')
121      break
122    case 'recusada':
123      partes.push(`Portão de Tarefas: a tarefa ${tarefa} não fecha: o núcleo recusou a conferência (${l.motivo}).`)
124      break
125    case 'ocupada':
126      partes.push(`Portão de Tarefas: a tarefa ${tarefa} não fecha agora: outra condução segura a thread (${l.motivo}). Conclua de novo em instantes.`)
127      break
128    case 'indisponivel':
129      partes.push(`Portão de Tarefas: a tarefa ${tarefa} não fecha: a conferência do núcleo não deu resultado (${l.motivo}).`)
130      break
131  }
132  partes.push(SAIDA)
133  if (vezes >= REPETICOES_PARA_DEIXAR) {
134    partes.push(`É a ${vezes}ª vez seguida com o mesmo resultado: deixe a tarefa aberta (pending) e siga para a próxima; o ork verify do CHECK decide.`)
135  }
136  return partes.join('\n')
137}
138
139/** O texto do `block` quando outra conferência do Portão está em curso (D5). */
140export function textoDeOutraConferencia(tarefa: string, emCurso: string): string {
141  return `Portão de Tarefas: a tarefa ${tarefa} não fecha agora: o Portão está conferindo a tarefa ${emCurso} no núcleo, uma conferência por vez. ` +
142    `Conclua de novo quando ela terminar. ${SAIDA}`
143}
144
145/** A linha de status depois de uma conferência; `undefined` limpa a linha. */
146export function textoDoStatus(tarefa: string, l: Leitura): string | undefined {
147  if (l.tipo === 'aprovada') return undefined
148  const nome: Record<Exclude<Leitura['tipo'], 'aprovada'>, string> = {
149    reprovada: 'reprovou', 'sem-veredito': 'sem veredito', recusada: 'recusada pelo núcleo', ocupada: 'outra condução', indisponivel: 'conferência indisponível',
150  }
151  return `portão: ${tarefa} não fechou (${nome[l.tipo]})`
152}
153
hooks/lease.ts 202 lines
1// O teammate ocioso e os leases (RM-059, fatia F10, T10.3). Módulo puro: a colisão de leases e de `touch_paths` pela
2// regra do núcleo (`leasesColidem` e `globsColidem` de `core/src/leases.ts`, reimplementados aqui porque mod não
3// importa código do núcleo, e cruzados no teste do núcleo), os leases ativos de todas as threads página a página do
4// retrato do `ork maestro --json`, a escolha da próxima tarefa para o teammate, os tetos do piloto de agent teams e os
5// textos. O Portão não toma nem solta lease (D8 revista): o `ork_git_commit` toma o `path:` de cada arquivo na hora do
6// commit e recusa com qualquer lease no caminho, até da própria thread. As chamadas ao `$` ficam no register.ts.
7import type { PortaoQuadro, PortaoTarefa } from '../types'
8
9/** Os tetos do piloto (D10 do plano da thread), como o núcleo os passa no `env` do `--settings`. */
10export const ENV_TEAMMATES = 'ORK_AGENT_TEAMS_TEAMMATES'
11export const ENV_CUSTO = 'ORK_AGENT_TEAMS_CUSTO_USD'
12export const TETO_MAXIMO_DE_TEAMMATES = 8
13export const TETO_MAXIMO_DE_CUSTO_USD = 500
14/**
15 * Quantas páginas da seção `leases` o Portão lê numa vez. O retrato traz até 50 itens por página e corta a página pelo
16 * teto de bytes do retrato inteiro (`maestro.page.byte_limit`), então uma página costuma vir com menos.
17 */
18export const TETO_DE_PAGINAS_DE_LEASES = 10
19/** A lacuna que o núcleo põe na página cortada pelo teto de bytes: é paginação, com `nextOffset`, não falta de dado. */
20export const LACUNA_DE_PAGINA = 'maestro.page.byte_limit'
21/** Os estados de agente que contam como teammate vivo para o teto. */
22export const VIVOS: readonly string[] = ['pending', 'running', 'waiting', 'idle']
23
24const NOME_DE_TEAMMATE = /^[A-Za-z0-9][A-Za-z0-9._@-]{0,79}$/
25const FAMILIAS = ['worktree-write', 'path', 'board', 'service', 'exec']
26
27/** A família de um lease, como `tipoDoLease` do núcleo: prefixo conhecido, `main-tree`, ou `path` por padrão. */
28export function familiaDoLease(nome: string): string {
29  if (nome === 'main-tree') return 'main-tree'
30  const prefixo = nome.split(':')[0] as string
31  return FAMILIAS.includes(prefixo) ? prefixo : 'path'
32}
33
34/** O alvo de um lease (o que vem depois dos dois pontos), como `alvoDoLease` do núcleo. */
35export function alvoDoLease(nome: string): string {
36  const corte = nome.indexOf(':')
37  return corte < 0 ? '' : nome.slice(corte + 1)
38}
39
40/** O glob de caminho (`*`, `**`, `?`) como a regex equivalente, como `globParaRegex` do núcleo. */
41export function globParaRegex(glob: string): RegExp {
42  let re = ''
43  for (let i = 0; i < glob.length; i++) {
44    const c = glob[i] as string
45    if (c === '*') {
46      if (glob[i + 1] === '*') {
47        re += '.*'
48        i++
49        if (glob[i + 1] === '/') i++
50      } else {
51        re += '[^/]*'
52      }
53    } else if (c === '?') {
54      re += '[^/]'
55    } else {
56      re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
57    }
58  }
59  return new RegExp(`^${re}$`)
60}
61
62function prefixoLiteral(glob: string): string {
63  const corte = glob.search(/[*?]/)
64  return corte < 0 ? glob : glob.slice(0, corte)
65}
66
67function prefixoDeCaminho(a: string, b: string): boolean {
68  if (a === '') return false
69  if (!b.startsWith(a)) return false
70  return a.length === b.length || a.endsWith('/') || b[a.length] === '/'
71}
72
73/** Dois globs disputam a mesma região? Conservador como o do núcleo: na dúvida, colide. */
74export function globsColidem(a: string, b: string): boolean {
75  if (a === b) return true
76  if (globParaRegex(a).test(b) || globParaRegex(b).test(a)) return true
77  const pa = prefixoLiteral(a)
78  const pb = prefixoLiteral(b)
79  return prefixoDeCaminho(pa, pb) || prefixoDeCaminho(pb, pa)
80}
81
82/** Dois nomes de lease colidem? Famílias diferentes nunca; `path` pela colisão de glob; o resto pela igualdade. */
83export function leasesColidem(a: string, b: string): boolean {
84  if (a === b) return true
85  const fa = familiaDoLease(a)
86  if (fa !== familiaDoLease(b)) return false
87  return fa === 'path' ? globsColidem(alvoDoLease(a), alvoDoLease(b)) : false
88}
89
90/** O lease `path:` de um `touch_path` da tarefa. */
91export function nomeDoLease(touchPath: string): string {
92  return `path:${touchPath}`
93}
94
95/** Um lease ativo do retrato: o nome e a thread que o segura. */
96export type LeaseAtivo = { nome: string; thread: string }
97
98/** Os leases ativos de uma página da seção `leases` do retrato `ork.maestro-snapshot/v1`, de qualquer família. */
99export function leasesDoRetrato(retrato: unknown): LeaseAtivo[] {
100  const itens = (retrato as { sections?: { leases?: { items?: unknown } } } | null)?.sections?.leases?.items
101  if (!Array.isArray(itens)) throw new Error('ork-portao: o retrato veio sem a seção leases')
102  const ativos: LeaseAtivo[] = []
103  for (const bruto of itens) {
104    const item = bruto as { id?: unknown; status?: unknown; refs?: unknown } | null
105    if (!item || typeof item.id !== 'string' || item.status !== 'active' || !Array.isArray(item.refs)) continue
106    const ref = (item.refs as Array<{ kind?: unknown; id?: unknown } | null>).find(r => r?.kind === 'thread')
107    if (ref && typeof ref.id === 'string') ativos.push({ nome: item.id, thread: ref.id })
108  }
109  return ativos
110}
111
112/** Uma página da seção `leases`: os ativos, o offset da página seguinte (`coverage.nextOffset`) e o que faltou. */
113export type PaginaDeLeases = { leases: LeaseAtivo[]; proximo: number | null; lacuna: string | null }
114
115/**
116 * A página da seção `leases` de um retrato. Seção indisponível lança (sem ela, nenhuma região está livre). A página
117 * cortada pelo teto de bytes (`maestro.page.byte_limit`) só diz que há página seguinte; qualquer outra lacuna, ou o
118 * retrato em conflito (fontes mudando durante a leitura), deixa a leitura incompleta.
119 */
120export function paginaDosLeases(retrato: unknown): PaginaDeLeases {
121  type Secao = { state?: unknown; gaps?: unknown; coverage?: { nextOffset?: unknown } }
122  const secao = (retrato as { sections?: { leases?: Secao } } | null)?.sections?.leases
123  if (!secao || typeof secao !== 'object') throw new Error('ork-portao: o retrato veio sem a seção leases')
124  const faltas = Array.isArray(secao.gaps) ? secao.gaps.filter((g): g is string => typeof g === 'string') : []
125  if (secao.state === 'unavailable') throw new Error(`ork-portao: a seção leases veio indisponível${faltas.length ? ` (${faltas.join(', ')})` : ''}`)
126  const seguinte = secao.coverage?.nextOffset
127  const proximo = typeof seguinte === 'number' && Number.isInteger(seguinte) && seguinte > 0 ? seguinte : null
128  const outras = faltas.filter(g => g !== LACUNA_DE_PAGINA)
129  const lacuna = secao.state === 'conflict' ? 'a seção leases veio em conflito (o retrato mudou durante a leitura)'
130    : outras.length > 0 ? `a seção leases veio com lacunas (${outras.join(', ')})` : null
131  return { leases: leasesDoRetrato(retrato), proximo, lacuna }
132}
133
134/** Os tetos do piloto lidos do ambiente: teammates inteiro de 1 a 8 e custo de 0 a 500 dólares; `null` sem os dois. */
135export function tetosDoAmbiente(teammates: unknown, custo: unknown): { teammates: number; custoUsd: number } | null {
136  if (typeof teammates !== 'string' || typeof custo !== 'string') return null
137  const t = /^\d+$/.test(teammates.trim()) ? Number(teammates.trim()) : NaN
138  const c = /^\d+(?:\.\d+)?$/.test(custo.trim()) ? Number(custo.trim()) : NaN
139  if (!Number.isInteger(t) || t < 1 || t > TETO_MAXIMO_DE_TEAMMATES) return null
140  if (!Number.isFinite(c) || c <= 0 || c > TETO_MAXIMO_DE_CUSTO_USD) return null
141  return { teammates: t, custoUsd: c }
142}
143
144const dolares = (v: number) => `US$ ${v.toFixed(2)}`
145
146/** Por que o `agent.spawn` de um teammate é recusado (D10); `null` quando ele passa. */
147export function recusaDoTeammate(tetos: { teammates: number; custoUsd: number } | null, vivos: number, custo: number | null): string | null {
148  if (!tetos) {
149    return 'Portão de Tarefas: teammate recusado. Na sessão de fase, agent teams só rodam no piloto do núcleo, com os tetos de ' +
150      `runtime.agent_teams no manifesto, e esta sessão não recebeu ${ENV_TEAMMATES} e ${ENV_CUSTO}.`
151  }
152  if (vivos >= tetos.teammates) return `Portão de Tarefas: teammate recusado. A sessão já tem ${vivos} teammate(s) vivo(s), e o teto do piloto é ${tetos.teammates}.`
153  if (custo === null) return 'Portão de Tarefas: teammate recusado. O custo da sessão não pôde ser lido, e sem ele o teto de custo do piloto não vale.'
154  if (custo >= tetos.custoUsd) return `Portão de Tarefas: teammate recusado. O custo da sessão (${dolares(custo)}) chegou ao teto do piloto (${dolares(tetos.custoUsd)}).`
155  return null
156}
157
158/** O nome do teammate como o evento traz, conferido; `null` quando não serve. */
159export function nomeDoTeammate(bruto: unknown): string | null {
160  return typeof bruto === 'string' && NOME_DE_TEAMMATE.test(bruto) ? bruto : null
161}
162
163/**
164 * A próxima tarefa para o teammate (D7 e D8 revista): na ordem do plano, a que serve ao quadro, não fechou, não foi
165 * devolvida, não está com outro teammate, não cruza os `touch_paths` das tarefas com outros teammates e não cruza lease
166 * `path:` ativo de nenhuma thread, a própria incluída, porque o `ork_git_commit` recusa o commit com qualquer lease no
167 * caminho. Sem tarefa livre, `null`.
168 */
169export function escolherTarefa(entrada: {
170  tarefas: readonly PortaoTarefa[]
171  quadro: PortaoQuadro
172  teammate: string
173  leases: readonly LeaseAtivo[]
174}): PortaoTarefa | null {
175  const { tarefas, quadro, teammate, leases } = entrada
176  const comOutros = Object.entries(quadro.atribuicoes)
177    .filter(([quem, tarefa]) => quem !== teammate && !quadro.concluidas.includes(tarefa))
178    .map(([, tarefa]) => tarefa)
179  const regiaoDosOutros = tarefas.filter(t => comOutros.includes(t.id)).flatMap(t => t.touchPaths)
180  for (const tarefa of tarefas) {
181    const livre = tarefa.touchPaths.length > 0 && tarefa.verify !== null && tarefa.problema === null &&
182      !quadro.concluidas.includes(tarefa.id) && !quadro.devolvidas.includes(tarefa.id) && !comOutros.includes(tarefa.id) &&
183      !tarefa.touchPaths.some(tp => regiaoDosOutros.some(outro => globsColidem(tp, outro))) &&
184      !tarefa.touchPaths.some(tp => leases.some(l => leasesColidem(nomeDoLease(tp), l.nome)))
185    if (livre) return tarefa
186  }
187  return null
188}
189
190/** O texto do `block` que entrega a tarefa ao teammate ocioso (D7). */
191export function textoDaEntrega(t: PortaoTarefa, teammate: string): string {
192  return [
193    `Portão de Tarefas: ${teammate}, pegue a tarefa ${t.id} do plano${t.titulo ? ` (${t.titulo})` : ''}.`,
194    `  touch_paths: ${t.touchPaths.join(', ')}`,
195    `  verify: ${t.verify ?? ''}`,
196    'Antes de começar, confira o quadro (TaskList): se ela já está em andamento com outro dono, não a pegue e fique ocioso. ' +
197      `Se ela não está no quadro, crie-a com o assunto começando por ${t.id}; marque-a em andamento com você como dono. ` +
198      'Quando o Portão a escolheu, nenhum lease path: ativo cruzava essa região, e o commit pelo ork_git_commit toma o lease ' +
199      'de cada arquivo na hora. Ela só fecha com o verify aprovado pela conferência do núcleo.',
200  ].join('\n')
201}
202
types/index.d.ts 61 lines
1// Contrato de tipos do Portão de Tarefas (RM-059, fatia F10). O Portão não acrescenta noun ao `$`: este arquivo
2// declara o estado dele (`PluginState['ork-portao']`) e os tipos que os módulos puros e os testes usam.
3// Sem import nem referência, como o motor exige de um contrato.
4
5/** Uma tarefa do plano como o Portão a usa: o id, o título, os `touch_paths` e o verify pela regra do núcleo. */
6export type PortaoTarefa = {
7  /** `T2.1`, como o cabeçalho do plano escreve. */
8  id: string
9  /** O resto da linha do cabeçalho. */
10  titulo: string
11  /** Os trechos entre crases do item `touch_paths`, normalizados. */
12  touchPaths: string[]
13  /** O comando do verify, quando o núcleo o aceita (`ork claims conferir --tarefa`). */
14  verify: string | null
15  /** Por que o núcleo recusaria a conferência dessa tarefa, ou `null`. */
16  problema: null | 'tarefa-sem-verify' | 'tarefa-ambigua' | 'tarefa-sem-comando' | 'tarefa-repetida'
17}
18
19/** A repetição de bloqueios da conclusão de uma tarefa (D6): a assinatura do último resultado e quantas vezes seguidas. */
20export type PortaoRepeticao = {
21  assinatura: string
22  vezes: number
23}
24
25/** Uma falha do próprio Portão: onde, o motivo e quando (ms desde a época). */
26export type PortaoFalha = {
27  onde: string
28  motivo: string
29  em: number
30}
31
32/**
33 * O quadro que o Portão guarda da sessão de fase (D9 do plano da thread). Ele vale para esta sessão: numa sessão de GO
34 * redespachada, o que fechou na anterior não está aqui.
35 */
36export type PortaoQuadro = {
37  /** A thread do despacho, do contexto da ponte. */
38  thread: string
39  /** A tarefa nativa (`task_id`) para a tarefa do plano, gravada quando a criação passou. */
40  tarefas: Record<string, string>
41  /** As tarefas do plano que fecharam com a conferência do núcleo aprovada nesta sessão. */
42  concluidas: string[]
43  /** A repetição de bloqueios da conclusão, por tarefa do plano. */
44  repeticoes: Record<string, PortaoRepeticao>
45  /** A tarefa do plano que o Portão entregou a cada teammate ocioso (T10.3), pelo nome do teammate. */
46  atribuicoes: Record<string, string>
47  /** As tarefas que um teammate devolveu ao ficar ocioso sem concluí-las: saem da distribuição automática. */
48  devolvidas: string[]
49  /** A última falha do Portão, para a linha de status e o log. */
50  ultimaFalha: PortaoFalha | null
51}
52
53declare module 'claude-code' {
54  interface PluginState {
55    'ork-portao': {
56      /** O quadro do Portão nesta sessão de fase. */
57      quadro: PortaoQuadro
58    }
59  }
60}
61