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…

English · Português

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