Shows and drives Herdr-Jev role plans inside Claude Code: the advisor is the session, and the implementer, reviewer and reader models come from herdr-jev…

Mod do Claude Code que mostra e comanda o trabalho que o Herdr-Jev já decide: papel, modelo e effort de cada tarefa. O mod não escolhe modelos; ele lê herdr-jev models list --json --client claude e herdr-jev triage e dispara subagentes nativos com o id exato do Claude Code (cliModel).
▸/▾, glifo do estado (● claude rodando ou em review, ? aviso precisa de você, ! erro falha, ◆ aviso aprovada esperando o review do harness, ✓ sucesso tudo verificado com o título Done, ○ inactive planejado), título da tarefa atual (wrap="truncate-end"), barra que enche o espaço restante, liquidadas/total, percentual em negrito (sucesso, aviso, erro ou cor padrão conforme o estado), nota em negrito na cor do estado (needs you, failed ou failed: <tarefa> quando outra roda, review, review pending|running|<status>), tempo do worker atual, approved N em cinza e, à direita, os botões simples Plan (abre o pane), a contagem de pendências em aviso e × (esconde até o próximo plano). A barra usa fatias de Box com overflow="hidden" e ━ repetido (receita do frank-claude-cockpit), por isso preenche qualquer largura em terminal e desktop. No desktop uma sobreposição position="absolute" com um Button plain de NBSP faz a linha inteira alternar o cartão. all verified some sozinha após 20 s. Com workers externos rodando, a linha mostra também N native · M external em cinza (só quando M > 0); sem plano e com um run externo ativo, a banda do motor ganha apenas essa linha.▾): um cartão round com borderDimColor, paddingX={2}, paddingY={1} e rowGap={1} acima da linha, com uma linha por tarefa aberta (no máximo 4): ● <título> (papel · modelo · effort), a ferramenta atual em cinza no meio e o tempo (12s, 1m 48s) alinhado à direita por Box flexGrow. O ponto segue o estado. Depois, +N more · N done. Começa recolhida.harness (comando /harness), no estilo do painel Sessions do frank-claude-cockpit:planned <idade> em cinza e, à direita, Run ready, ↻ review e ✕ (fecha o pane). Embaixo, a barra do plano enchendo a largura e o percentual.round em aviso ── Needs you ── (só aparece quando há perguntas, como no human-in-the-loop): ☐ N task(s) for you em negrito e, por pergunta, ☐ #n <tarefa> <idade> em negrito, a pergunta na linha seguinte e Done when: <checks> em cinza.Needs you N (falhas e perguntas), Working N, Approved N (esperando o review do harness), Queued N e Done N (recolhida; o título abre). Cada linha: ▸/▾, ◐ (rodando ou review) ou ● na cor do estado, id curto, título (clique abre) e o tempo à direita; abaixo, papel · modelo · effort em cinza. Aberta (a tarefa rodando abre sozinha), mostra um bloco chave/valor com chaves cinzas alinhadas (worker, tool, deps, reason, review, note) e, para failed e needs_you, o botão Rerun.N approved, harness review ..., o último herdr-jev review e o rodapé cinza pasta · advisor <modelo> · review <status|not run> · auto-run on|off · auto-review on|off.Scope · enforce · 12 of 74 skills · 9 of 41 agents, recolhida; aberta lista o que ficou visível e o motivo.No plan yet. Ask the advisor for one. e os botões.External N, com uma linha por run de harness:codex: glifo do estado, codex · <modelo> (o modelo que o run informou, ou unconfirmed), tempo decorrido (para ao terminar) e, à direita, o custo do worker; abaixo, a última linha emitida (sem caracteres de controle, cortada na largura do pane) ou, ao terminar, patch <caminho> e o resumo do git diff --stat. Fica abaixo das seções de tarefas e também aparece sem plano. Guarda no máximo 12 runs.ctx ██████░░░░ 120k/200k · 5h 23% · week 7%, lida de $.session.usage() (chamada simples, sem breakdown, sem custo de tokens) e atualizada pelo hook session.measure. Cinza abaixo de 70%, aviso a partir de 70%, erro a partir de 90%. Dado ausente não aparece (nunca vira 0).turn.step soma, por agentId, os tokens do chunk stop (entrada, saída e cache). A linha worker de cada subagente nativo e a linha de cada run externo mostram in 1.5k out 250 cache 11k; depois que o worker termina, o total fica na linha tokens (e review tokens) da tarefa.Reduz o que o modelo enxerga (listagem de skills e agentes) sem apagar nada e sem bloquear nada. Porta do mecanismo do harness-scope (MIT, shimo4228): prompt.attachment do tipo skill_listing, agent.offer e o recibo por $.ui.log. Em vez de arquivos de perfil estáticos, a lista permitida vem do AI Harness e do Jev. Não foram portados os denies de tool.call e tool.describe nem o filtro de arquivos de instrução: CLIs, regras obrigatórias e skills chamadas de propósito continuam. Uma skill escondida da listagem ainda roda se for chamada.
Skills mantidas, na ordem do motivo:
project e built-in: skills cuja origem é projectSettings ou built-in em $.session.usage({ breakdown: 'summary' }).invoked: qualquer skill que você chamou com /nome na sessão (observado em prompt.submit).always: alwaysAllowSkills (padrão writing-clearly-and-concisely, harness-router, herdr-jev, ai-harness-context, plugin-authoring, vault, promote, code-review, simplify), também com prefixo de plugin (plug:vault).skill-select: selected[].name e cliSelected[].name de ai-harness skill-select --client claude --query <consulta> [--runbook <id>]. Roda no session.start (depois do next(e), sem bloquear) com a consulta <pasta> session e de novo a cada harness_plan com o objetivo.route-turn: decision.skill de herdr-jev route-turn <texto> --json a cada prompt (limite de 2 s, falha ignorada, nunca bloqueia).Agentes mantidos: os embutidos (general-purpose, Explore, Plan, claude-code-guide, statusline-setup, PlanChecker), harness:*, os agentes do projeto (source projectSettings) e alwaysAllowAgents (padrão: os 16 nomes do ROUTER do AI Harness). O resto (llmtrim-*, GrokForge, pr-review-toolkit:*, agentes depreciados) recebe { isOffered: false }.
scopeMode: enforce (padrão) esconde; report não esconde nada mas calcula o recibo (would hide); off desliga tudo. runbook (vazio por padrão) vai em --runbook.
Falhas: se o skill-select falha, o escopo fica partial e nenhuma skill é escondida; formato de listagem desconhecido ou skills do projeto ilegíveis deixam o texto passar sem mudança, com uma linha cinza em $.ui.log uma vez só. O escopo zera em /clear e resume (classic.SessionStart). /harness scope mostra o recibo (itens mantidos por motivo, escondidos, notas) só na tela; o pane tem a seção Scope recolhida.
Limite conhecido: o runbook não é detectado sozinho. O ai-harness skill-select roda sem --runbook (aceita) e só usa runbook quando configurado, então as CLIs que dependem de runbook só aparecem com ele.
Aviso determinístico, sem modelo, para resposta que declara verificação que nada no turno sustenta. Adaptado do anti-cheat do pourya7/claude-code-mods (MIT), sem a banda de jogo, sem sprites e sem o prompt de desafio. Só avisa: não nega chamada de ferramenta, não segura nem encerra turno e nunca envia prompt. Na dúvida, não avisa.
turn.complete da sessão principal (reason: answer) extrai afirmações da resposta, uma por tipo: test (tests pass, testes passaram), lint (typecheck clean, typecheck limpo), build, ci (CI is green, CI verde) e verified (verified, verificado, all checks pass, todos passaram). A palavra de sucesso precisa fechar a oração (pontuação, fim do texto, travessão, ( com número, after|depois, with no errors|without any errors|sem falhas ou and|but|e|mas; advérbios como successfully e com sucesso são aceitos): the build passes arguments não é afirmação. Negação, condição, instrução (confirm, before, check that), plano (when, after, quando, depois que), esperado (expected, esperado), relato de terceiros (says, answered, reported, disse, informou) e relato misto (a mesma frase, antes ou depois da afirmação, tem um verbo de falha fails|failed|falhou ou um número maior que zero antes de errors|failures|erros|falhas, sem no|none|nothing|not|zero|0|sem|nenhum|nada|não até duas palavras antes; all tests pass and the error is fixed e typecheck is clean, no type errors continuam sendo afirmação; uma falha que a própria frase diz corrigida, antes da afirmação (fixed|resolved|gone|corrigi|resolvi), não cancela a afirmação, a menos que a frase também diga still|ainda|remain|restam), critério de aceite, checklist - [ ], linha de tabela, pergunta, bloco de código, código em linha, texto entre aspas e citação (>) não são afirmação.tool.call (Edit, Write, NotebookEdit e Bash) alimenta o log da sessão. Uma edição vale como edição só se o arquivo estiver dentro da raiz da sessão ($.session.root(), que um cd no shell não move), não passar por .claude/worktrees/ abaixo dela e não terminar em .md; numa sessão aberta dentro de um worktree, o próprio worktree é a pasta da sessão e os irmãos e o checkout principal ficam de fora. Os prefixos (, {, if, while, exec, command, time, corepack, nice [-n N], sudo [-u user], timeout [-s SIG] [-k N] N, env [-u NAME] VAR=valor, VAR=$(...), rtk [-u] [test|err|proxy|summary], docker compose [-f arquivo] exec|run [flags] serviço, docker-compose e docker exec [flags] contêiner são removidos antes de classificar, bash|sh|zsh -c|-lc "..." no começo de um comando é classificado pelo texto de dentro, strings entre aspas que apontam para /tmp, /var/tmp, /dev ou $VAR contam como alvo fora da árvore, o corpo de heredoc é ignorado e <<< e << aritmético não são heredoc. Bash que escreve na árvore (redirecionamento, sed -i[sufixo], tee, mv, rm, git checkout <ref>, git restore, git reset --hard, --fix, --write) também invalida a evidência; criar branch, git -C <pasta fora da sessão>, git add, git commit, git stash list, git merge-base, alvos em /tmp e /dev e qualquer chamada que o engine marque como isReadOnly não. Bash de teste, typecheck/lint, build ou leitura de status de CI depois da última edição é evidência do tipo certo (pnpm --filter x test, bun --cwd x test, claude plugin test, herdr-jev review, az pipelines, entre outros); verified aceita qualquer um. CI vale depois do último git push que não seja --dry-run. Execução com erro ou interrompida não é evidência; se um comando composto falha com mais de um tipo de check, nenhum tipo é culpado nem creditado. Sem nenhuma edição na sessão, nada é avisado; uma afirmação de CI também fica quieta quando não houve edição nem push.agentId. A edição de um subagente invalida a evidência só quando o arquivo está na pasta da sessão; o Bash de um subagente nunca conta como edição; a execução bem-sucedida de um subagente conta como evidência e uma falha dele nunca é apontada; a resposta de um subagente nunca é checada.+N more unverified), na cor warning: unverified: "all tests pass" · no test ran after the last edit. O hook chama next(e) e acrescenta as linhas ao que veio, então a banda do plano e a de outros mods continuam. O mesmo texto vai ao transcript por $.ui.log no turn.complete, para ficar junto da resposta. O início do próximo turno (turn.start, ignorado se trouxer agentId) limpa o aviso, seja qual for a origem do turno; /clear (session.end com reason: clear) zera o aviso e o log; a próxima resposta recalcula. O TurnStartInput não traz agentId, então o mod assume que turn.start é só do loop principal.harness.claimLog (últimas 200 entradas, comando guardado com até 80 caracteres) e harness.claimWarnings. Falha ao gravar vai para o log de debug. Sem opção de configuração.Limites conhecidos (falsos negativos aceitos pela regra de não avisar na dúvida):
| tail, || true, ; echo) conta como passou.gh run view, gh pr view, az pipelines) conta como evidência.python gen.py, node codegen.js, pnpm db:migrate) não são vistas.resume; evidência de antes não é lembrada.xargs para em 16 níveis.sh -c "..." depois de docker compose run, watch e php bin/console lint:* não são reconhecidos como evidência.Quando um Bash vai criar ou atualizar um pull request, o mod anexa ao resultado da ferramenta o status do review do harness. Só contexto para o modelo: nunca nega a chamada, nunca reescreve o comando e não toca no resultado do motor além de acrescentar uma linha. Se o resultado volta deny ou isError, nada é acrescentado.
Ordem que mantém o ready: commitar, revisar, abrir o PR. O harness revalida o review contra o snapshot atual do git e, confirmado em teste contra o ai-harness real em um diretório de estado temporário, um review ready vira pending_verification quando o conteúdo revisado é commitado ou quando aparece um arquivo novo não rastreado; revisar depois do commit continua ready ao trocar branch, remoto ou config. Quem lê o status também apaga a linha ready (a leitura reinicia a verificação), por isso o mod guarda o status do review e a hora e sabe dizer stale.
Qual identidade é lida. O herdr-jev review grava o resultado sob um session gerado (jev-review-<hex>) e usa a raiz do git como cwd; a própria sessão do Claude zera sua identidade a cada chamada de ferramenta que não é leitura (inclui git commit). Por isso o gate não consulta a sessão do Claude. O mod guarda client, session, cwd, status e hora de cada review, por raiz de repositório, de três origens: /harness review, o botão Refresh review e autoReview; e um herdr-jev review rodado pelo modelo em um Bash (com ou sem --json, também atrás de rtk -u, timeout, env etc.), lido do resultado da própria chamada (Review session <id> (<client>) in <cwd> e a última linha Status: ..., ou o relatório JSON). Só é guardado quando o herdr-jev review está sozinho na chamada ou depois de um único cd <caminho literal> &&; qualquer outro comando na mesma chamada (cat, echo, | tee, && gh pr create) impede o registro, para que texto impresso por outro comando não se passe pelo cabeçalho ou pelo status. O status vem só da última linha não vazia. Por isso herdr-jev review --council em modo texto não é guardado (o bloco do conselho vem depois do Status:); use --json nesse caso. Uma execução que falhou ou cuja saída não pode ser lida não é guardada. O gate chama ai-harness review-status --client <client> --session <session> --cwd <raiz> com a identidade guardada para a raiz do repositório do PR.
Onde fica. Em harness.reviewIds (estado do Claude Code, que sobrevive a um reload do código do mod e se perde com a sessão) e, como o plano, em $.store sob reviewIds:<id da sessão>: o gate lê o estado e, se ele estiver vazio, o $.store. Um review rodado em outro processo ou terminal fora da sessão, ou depois de a sessão mudar de id, não é encontrado; o gate não adivinha sessões. Um herdr-jev review && gh pr create na mesma linha não registra o review e lê o status antes de ele rodar.
Status possíveis e o aviso:
ready: Review status read before the PR command for <raiz>: ready.stale: havia um review ready guardado e o harness agora devolve pending_verification. O aviso diz a hora do último ready, que o checkout mudou (um commit ou um arquivo novo não rastreado conta) e a ordem commit, review, PR. Nunca diz que não há review.none: o mod não tem review guardado para o repositório. Diz só isso e como produzir um: herdr-jev review em um shell (achado por este check) ou /harness review; se o PR é de outro checkout, nomeia o checkout e manda rodar cd <raiz> && herdr-jev review lá, porque /harness review revisa a pasta da sessão.pending_verification, pending_review, changes_required: diz que não há review independente pronto e a ordem commit, review, PR.unknown: só diz que o status não pôde ser lido.O que é detectado, só quando o shell de fato executaria. O comando é lido por um lexer de passagem única (aspas, \ + quebra de linha, comentários, heredocs com vários marcadores por linha, <<<, $(...), $((...)) e ((...)), crases, definições de função e atribuições de array, que não executam) e cada comando simples é analisado depois de pular atribuições de ambiente e wrappers (env, sudo, timeout, nice, ionice, xargs, rtk, rtk -u, rtk proxy, command, exec, nohup, setsid, stdbuf, if, while !, then, do):
gh pr create|new|edit|ready (com -R/--repo antes ou depois de pr), gh api com método de escrita em repos/<o>/<r>/pulls[/<n>], curl/http/wget de escrita em https://api.github.com/repos/<o>/<r>/pulls[/<n>] ou em um host Enterprise com /api/v3/.az repos pr create|update; curl/http/wget de escrita em .../_apis/git/repositories/<repo>/pullrequests[/<id>], ou numa base em variável (${API_BASE}/git/repositories/${REPO}/pullrequests, o formato do skill de PR do InvoiceCon). O casamento vale só para o operando URL (sem espaço, começando por http(s):// ou por uma variável), nunca para o valor de -d, -H ou item httpie; com host literal exige /_apis/. Escrita é -X/--request POST|PATCH|PUT, --method, ou dados (-d, -d@arquivo, --data*, --json, -F, --post-data, itens chave=valor do httpie; chave==valor é consulta) sem -G. GET nunca casa; comentários em .../pullrequests/<id>/threads também não.git push, heredocs, texto entre aspas, comentários, echo, --help/-h, --dry-run, gh pr ready --undo, definição de função e cmd=(gh pr create). --web conta como criação, de propósito.bb aqui.Limites declarados, não seguidos: bash -c '...', sh -c, eval, heredoc entregue a um shell, alias do gh, az devops invoke, curl com a URL montada em variável de comando, mutações GraphQL, a chamada de uma função de shell que contém o comando, expansão aritmética com mais de 512 caracteres.
Diretório. Só um cd <literal> && simples e inicial é seguido. Qualquer outra forma (cd a; ..., cd a em linha própria, (cd a && ...), pushd, cd no meio da cadeia, cd -P, cd --, espaço escapado, FOO=1 cd, dois cd, cd a || ..., cd $VAR) dá status unknown, nunca o diretório da sessão. O diretório vira a raiz do git (git rev-parse --show-toplevel) e aparece no aviso. Também dá unknown: --repo/-R ou --repository que não bate com um remoto do checkout; um repositório vindo de variável (${REPO}: "repository comes from a variable"); GH_REPO= no comando; gh pr create --head ou -H; gh pr edit|ready com número, URL ou argumento posicional, az repos pr update --id e PATCH por REST, que miram um PR que pode não ser a mudança revisada aqui. {owner}/{repo} em gh api é este checkout. Qualquer falha de leitura (processo, JSON inválido, objeto sem status, status fora de ^[a-z_]{1,40}$) vira unknown e o comando roda mesmo assim. Só o null de topo é none. A causa mostrada é uma frase fixa; nada de stderr é repetido.
Atraso. A raiz do git e os remotos são lidos em paralelo, 2 s cada; o status tem 5 s. Pior caso: 7 s (2 s de git + 5 s de status), contra 15 s antes (três leituras em série de 5 s).
Custo do detector: linear no tamanho do comando (lexer de passagem única; aninhamento de $( limitado a 48 níveis; a busca do fim de (( olha no máximo 512 caracteres por ocorrência); o teste lê 400 KB entre aspas, 60 mil <<, 20 mil $( e 150 mil (( em poucos segundos no total.
prGateAsk (padrão false). Ligado, quando o status é conhecido e não é ready (none, stale, pending_*, changes_required perguntam; unknown e ready não), o hook tool.check troca um allow do motor por { decision: 'ask', reason } (objeto limpo, sem rule/hook); um deny ou um ask do motor passam como vieram. A chamada fica marcada pelo tool_use_id enquanto o tool.call está em curso e é solta no finally. Coberto por teste: pergunta só para o id em curso, não para outro id nem sem id, não depois do retorno e nunca com a opção desligada. Não verificado sem uma sessão real: que o motor dispara tool.check dentro do next(e) com o mesmo tool_use_id; que em dontAsk ou headless um ask de hook é recusado, o que na prática vira deny; e o que next(e) devolve depois que a pessoa responde Não. Por isso fica desligado. Não existe modo deny.
Layouts e técnicas de render inspirados, com trechos adaptados, em projetos MIT: muellerei/task-line (linha e barra da banda), zycck/claude-mods plan-progress (linhas de agente, dobras, glifos), whats-agent-doing (cartão expansível e linhas de worker), human-in-the-loop (caixa Needs you e ☐), Nongfsq/frank-claude-cockpit (barra proporcional, espaçador flexGrow, linha clicável, seções do painel Sessions), shimo4228/harness-scope (mecanismo do Scope), pourya7/claude-code-mods anti-cheat (detecção de afirmações e classificação de evidência do Claims) e pourya7/claude-code-mods co-op (MIT, Copyright (c) 2026 Pourya: o tratamento de aspas, heredoc e substituição de comando, o prefixo de comando e o cd inicial do PR gate, reescritos aqui como lexer; ver NOTICE).
claude --plugin-dir /caminho/para/herdr-jev/claude-plugin
Validar e testar:
claude plugin validate claude-plugin
claude plugin test claude-plugin
| Ferramenta | Entrada | O que faz |
|---|---|---|
harness_plan | objective, tasks[{ title, deps?, paths?, checks?, role? }] | Roda herdr-jev models list --json --client claude uma vez e herdr-jev triage <tarefa> --json por tarefa, atribui papel e devolve o plano com ids estáveis hp-<8 hex>. Recusa trocar o plano enquanto houver workers rodando. |
harness_run | taskId? | Com taskId, roda a tarefa. Sem, roda toda tarefa proposed com dependências prontas, até maxWorkers. Chamar duas vezes (ou em paralelo) devolve o agentId existente, nunca spawna de novo. Tarefa de advisor é marcada done. |
harness_status | nenhuma | Resumo em texto do plano, workers, pendências, último review. |
As ferramentas aparecem para o modelo como mcp__harness__harness_plan, mcp__harness__harness_run e mcp__harness__harness_status. harness_plan e harness_run recusam qualquer chamada que traga agentId: só a sessão principal planeja e dispara workers.
Todo modelo e effort vem de herdr-jev models list --json --client claude. O mod não tem nomes de modelo próprios. O spawn usa o cliModel exato (por exemplo claude-sonnet-5-5) e o tipo de agente é registrado com esse modelo e o effort do papel, mapeado para o Claude Code: standard vira medium, high fica `hi
hooks/register.tsx 2123 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, PluginOptions, Register, TurnStepChunk, TurnStepResult } from 'claude-code'
3
4import type {
5 HarnessCost,
6 HarnessExternalRow,
7 ScopeState,
8 HarnessHumanAsk,
9 HarnessPlan,
10 HarnessReview,
11 HarnessRoleTable,
12 HarnessTask,
13 HarnessWorkerRow,
14} from '../types'
15import { registerClaims } from './claims'
16import { autoReview, autoRun, availableModels, cliConfig, maxWorkers, runConsultPlan, runModels, runReview, runTriage } from './cli'
17import type { CliConfig, RunPort } from './cli'
18import {
19 agentSpec,
20 cleanupAgent,
21 createExternal,
22 endAll,
23 externalRefusal,
24 externalRole,
25 externalStep,
26 maxMinutesOf,
27 noteSpawn,
28 PROGRESS_DESCRIPTION,
29 progressCall,
30 spawnDenial,
31} from './external'
32import type { AgentState, External, Ports } from './external'
33import {
34 approvedKey,
35 baseName,
36 bandState,
37 barParts,
38 claudeEffort,
39 buildPlan,
40 countTasks,
41 describeTool,
42 duration,
43 effortLabel,
44 findTask,
45 fit,
46 isAllVerified,
47 isRecord,
48 isSettled,
49 isUnblocking,
50 kindOf,
51 missingRoles,
52 parseSaved,
53 percent,
54 readyTasks,
55 reviewPhrase,
56 ROW_MARK,
57 shortModel,
58 summarize,
59} from './plan'
60import { registerPrGate } from './pr-gate'
61import { mergeReviewIds, reviewIdsKey } from './review-run'
62import type { ReviewIds } from './review-run'
63import type { ConsultTarget, Hue, PlanTaskInput, ReviewPhase, SavedState, TriageResult } from './plan'
64import {
65 addCost,
66 costText,
67 deriveExternal,
68 externalModel,
69 isReviewerResult,
70 lastLineOf,
71 orderedExternal,
72 pruneExternal,
73 recordOf,
74 REVIEWER_RULE,
75 runningExternal,
76 stopExternal,
77 usageOf,
78 usageParts,
79} from './visual'
80import type { UsagePart } from './visual'
81import {
82 agentReason,
83 DEFAULT_AGENTS,
84 DEFAULT_SKILLS,
85 EMPTY_SCOPE,
86 filterSkillListing,
87 invokedSkill,
88 keptRows,
89 listOption,
90 newReceipt,
91 receiptText,
92 runbookOption,
93 runRoute,
94 runSkillSelect,
95 scopeHeading,
96 scopeMode,
97 skillReason,
98 withNames,
99} from './scope'
100import type { Receipt, SkillReason } from './scope'
101import {
102 createGate,
103 decideConsultTurn,
104 decideReviewTurn,
105 decideWorkerTurn,
106 launchAgent,
107 launchConsult,
108 registerKinds,
109 resolveLaunch,
110} from './workers'
111import type { Gate, LaunchPorts } from './workers'
112
113const claimLogAtom = atom({ plugin: 'harness', key: 'claimLog' } as const, [])
114const claimWarningsAtom = atom({ plugin: 'harness', key: 'claimWarnings' } as const, [])
115const planAtom = atom({ plugin: 'harness', key: 'plan' } as const, null)
116const workersAtom = atom({ plugin: 'harness', key: 'workers' } as const, {})
117const needsYouAtom = atom({ plugin: 'harness', key: 'needsYou' } as const, [])
118const staleAtom = atom({ plugin: 'harness', key: 'stale' } as const, false)
119const staleNoteAtom = atom({ plugin: 'harness', key: 'staleNote' } as const, null)
120const isHiddenAtom = atom({ plugin: 'harness', key: 'isHidden' } as const, false)
121const advisorModelAtom = atom({ plugin: 'harness', key: 'advisorModel' } as const, '')
122const reviewAtom = atom({ plugin: 'harness', key: 'review' } as const, null)
123const isReviewRunningAtom = atom({ plugin: 'harness', key: 'isReviewRunning' } as const, false)
124const isExpandedAtom = atom({ plugin: 'harness', key: 'isExpanded' } as const, false)
125const foldsAtom = atom({ plugin: 'harness', key: 'folds' } as const, {})
126const reviewIdsAtom = atom({ plugin: 'harness', key: 'reviewIds' } as const, {})
127const scopeAtom = atom({ plugin: 'harness', key: 'scope' } as const, EMPTY_SCOPE as ScopeState)
128const usageAtom = atom({ plugin: 'harness', key: 'usage' } as const, null)
129const externalAtom = atom({ plugin: 'harness', key: 'external' } as const, {})
130const costsAtom = atom({ plugin: 'harness', key: 'costs' } as const, {})
131
132const PANE_ID = 'harness'
133
134const TOOL_PLAN = 'mcp__harness__harness_plan'
135const TOOL_RUN = 'mcp__harness__harness_run'
136const TOOL_STATUS = 'mcp__harness__harness_status'
137const TOOL_CODEX_PROGRESS = /^mcp__harness__codex_progress$/
138
139const ACTIVE_STATUS = ['pending', 'running', 'waiting', 'idle']
140const RESTARTABLE: readonly HarnessTask['state'][] = ['proposed', 'failed', 'needs_you']
141const MAX_TASKS = 30
142const HIDE_AFTER_MS = 20000
143const STALE_LIST_NOTE = 'agent list unavailable'
144
145const PLAN_SCHEMA = {
146 type: 'object',
147 properties: {
148 objective: { type: 'string', description: 'The goal the tasks add up to.' },
149 tasks: {
150 type: 'array',
151 items: {
152 type: 'object',
153 properties: {
154 title: { type: 'string' },
155 deps: { type: 'array', items: { type: 'string' }, description: 'Titles or ids of tasks this one waits for.' },
156 paths: { type: 'array', items: { type: 'string' }, description: 'Paths this task owns.' },
157 checks: { type: 'array', items: { type: 'string' }, description: 'Acceptance checks to run.' },
158 role: { type: 'string', description: 'Optional role hint: reader, mechanic, implementer or advisor.' },
159 },
160 required: ['title'],
161 },
162 },
163 },
164 required: ['objective', 'tasks'],
165}
166
167const RUN_SCHEMA = {
168 type: 'object',
169 properties: {
170 taskId: { type: 'string', description: 'Task id or title. Omit to run every ready proposed task.' },
171 },
172}
173
174function stringList(value: unknown): string[] {
175 return Array.isArray(value)
176 ? value.filter((one): one is string => typeof one === 'string' && one.trim().length > 0)
177 : []
178}
179
180function parsePlanInput(
181 input: Readonly<Record<string, unknown>>,
182): { objective: string; tasks: PlanTaskInput[] } | string {
183 const objective = typeof input.objective === 'string' ? input.objective.trim() : ''
184 if (objective.length === 0) return 'harness_plan needs a non-empty objective.'
185 if (!Array.isArray(input.tasks) || input.tasks.length === 0) return 'harness_plan needs at least one task.'
186
187 const tasks: PlanTaskInput[] = []
188 for (const raw of input.tasks.slice(0, MAX_TASKS)) {
189 if (!isRecord(raw) || typeof raw.title !== 'string' || raw.title.trim().length === 0) {
190 return 'every task needs a title.'
191 }
192 tasks.push({
193 title: raw.title.trim(),
194 deps: stringList(raw.deps),
195 paths: stringList(raw.paths),
196 checks: stringList(raw.checks),
197 role: typeof raw.role === 'string' ? raw.role : undefined,
198 })
199 }
200 return { objective, tasks }
201}
202
203
204async function attempt(label: string, $: EngineInterface, work: () => Promise<unknown>): Promise<void> {
205 try {
206 await work()
207 } catch (error) {
208 const message = error instanceof Error ? error.message : String(error)
209 $.ui.log(`harness: ${label} failed: ${message.slice(0, 160)}`, { to: 'debug' })
210 }
211}
212
213async function storeKey($: EngineInterface, cwd: string): Promise<string> {
214 return `state:${await $.session.id()}:${cwd}`
215}
216
217const runPort =
218 ($: EngineInterface): RunPort =>
219 (argv, init) =>
220 $.process.run(argv, init)
221
222const launchPorts = ($: EngineInterface): LaunchPorts => ({
223 spawn: args => $.agent.spawn(args),
224 register: spec => $.agent.register(spec),
225 run: (argv, init) => $.process.run(argv, init),
226})
227
228const CODEX_STORE_PREFIX = 'codex-agent:'
229
230async function codexPrefix($: EngineInterface, sessionId?: string): Promise<string> {
231 return `${CODEX_STORE_PREFIX}${sessionId !== undefined && sessionId.length > 0 ? sessionId : await $.session.id()}:`
232}
233
234const externalPorts = ($: EngineInterface, sessionId?: string): Ports => ({
235 run: (argv, init) => $.process.run(argv, init),
236 now: () => $.clock.now(),
237 sleep: (ms, signal) => $.clock.sleep(ms, { signal }),
238 cwd: () => $.session.cwd(),
239 env: async () => ({
240 stateDir: await $.env.get('HERDR_JEV_STATE_DIR'),
241 pluginId: await $.env.get('HERDR_PLUGIN_ID'),
242 pluginStateDir: await $.env.get('HERDR_PLUGIN_STATE_DIR'),
243 home: await $.env.get('HOME'),
244 testGuard: await $.env.get('HERDR_JEV_TEST_GUARD'),
245 aiHarnessTestGuard: await $.env.get('AI_HARNESS_TEST_GUARD'),
246 generatedDir: await $.env.get('AI_HARNESS_GENERATED_DIR'),
247 }),
248 readText: async path => {
249 const raw = await $.fs.read(path)
250 if (typeof raw !== 'string') throw new Error('not a text file')
251 return raw
252 },
253 exists: path => $.fs.exists(path),
254 userTexts: async agentId => {
255 const rows = await $.session.messages({ agentId })
256 return Array.isArray(rows) ? rows.filter(row => row.role === 'user').map(row => row.text) : null
257 },
258 agentType: async agentId => (await $.agent.list()).find(one => one.id === agentId)?.type,
259 loadState: async agentId => ((await $.store.get(`${await codexPrefix($, sessionId)}${agentId}`)) as AgentState | undefined) ?? null,
260 saveState: async (agentId, state) => {
261 if (state === null) await $.store.delete(`${await codexPrefix($, sessionId)}${agentId}`)
262 else await $.store.set(`${await codexPrefix($, sessionId)}${agentId}`, state)
263 },
264 sessionAgents: async () => {
265 const prefix = await codexPrefix($, sessionId)
266 return (await $.store.keys()).filter(key => key.startsWith(prefix)).map(key => key.slice(prefix.length))
267 },
268 notify: text => {
269 $.ui.toast(text.slice(0, 600), { timeoutMs: 15000 })
270 },
271})
272
273async function registerCodexAgent($: EngineInterface, external: External, options: PluginOptions): Promise<void> {
274 if (options.codex !== true) return
275 try {
276 external.progressTool = (await $.tool.register({ name: 'codex_progress', description: PROGRESS_DESCRIPTION })).tool
277 } catch {
278 external.progressTool = ''
279 }
280 if (external.progressTool === '') return
281 await $.agent.register(agentSpec(external.progressTool))
282 external.agentRegistered = true
283}
284
285async function syncExternal(
286 $: EngineInterface,
287 ports: Ports,
288 agentId: string,
289 startedAt: number,
290 blocks: ReadonlyMap<number, string>,
291): Promise<void> {
292 const state = await ports.loadState(agentId)
293 if (state === null) return
294 const order = [...blocks.keys()].sort((a, b) => a - b)
295 const streamed = state.run === null ? order.slice(0, -1) : order
296 const line = lastLineOf(streamed.map(index => blocks.get(index) ?? '').join('\n'))
297 const now = await $.clock.now()
298 const prev = (await read($, externalAtom))[agentId]
299 const row = deriveExternal(prev, agentId, state, startedAt, now, line)
300 if (row === null) return
301 await update($, externalAtom, rows => pruneExternal({ ...rows, [agentId]: row }))
302}
303
304async function* watchStep(
305 $: EngineInterface,
306 agentId: string,
307 ports: Ports | null,
308 source: AsyncGenerator<TurnStepChunk, TurnStepResult>,
309): AsyncGenerator<TurnStepChunk, TurnStepResult> {
310 let startedAt = 0
311 try {
312 startedAt = await $.clock.now()
313 } catch {
314 startedAt = 0
315 }
316 const blocks = new Map<number, string>()
317 let isDone = false
318 try {
319 for (;;) {
320 const step = await source.next()
321 if (step.done) {
322 isDone = true
323 return step.value
324 }
325 const chunk = step.value
326 if (ports !== null && (chunk.kind === 'text' || chunk.kind === 'thinking')) {
327 blocks.set(chunk.index, `${blocks.get(chunk.index) ?? ''}${chunk.text}`)
328 }
329 if (chunk.kind === 'stop' && chunk.usage !== null) {
330 const used = chunk.usage
331 await attempt('worker cost', $, () =>
332 update($, costsAtom, costs => ({ ...costs, [agentId]: addCost(costs[agentId], used) })),
333 )
334 }
335 yield chunk
336 }
337 } finally {
338 if (!isDone) await source.return(undefined as never).catch(() => undefined)
339 if (ports !== null) await attempt('external row', $, () => syncExternal($, ports, agentId, startedAt, blocks))
340 }
341}
342
343async function liveUsage($: EngineInterface) {
344 const stored = await read($, usageAtom)
345 if (stored !== null) return stored
346 try {
347 const now = await $.session.usage()
348 return usageOf(now.context, now.rateLimits)
349 } catch {
350 return null
351 }
352}
353
354async function save($: EngineInterface, cwd: string): Promise<void> {
355 try {
356 const key = await storeKey($, cwd)
357 const plan = await read($, planAtom)
358 if (plan === null) {
359 await $.store.delete(key)
360 return
361 }
362 const saved: SavedState = {
363 plan,
364 workers: await read($, workersAtom),
365 needsYou: await read($, needsYouAtom),
366 }
367 await $.store.set(key, saved)
368 } catch {
369 return
370 }
371}
372
373async function load($: EngineInterface, cwd: string): Promise<SavedState | null> {
374 try {
375 return parseSaved(await $.store.get(await storeKey($, cwd)))
376 } catch {
377 return null
378 }
379}
380
381async function patchTask(
382 $: EngineInterface,
383 id: string,
384 change: (task: HarnessTask) => HarnessTask,
385): Promise<void> {
386 await update($, planAtom, plan =>
387 plan === null
388 ? plan
389 : { ...plan, tasks: plan.tasks.map(task => (task.id === id ? change(task) : task)) },
390 )
391}
392
393async function claimTask(
394 $: EngineInterface,
395 id: string,
396 can: (task: HarnessTask) => boolean,
397 change: (task: HarnessTask) => HarnessTask,
398): Promise<boolean> {
399 let claimed = false
400 await update($, planAtom, plan => {
401 claimed = false
402 if (plan === null) return plan
403 return {
404 ...plan,
405 tasks: plan.tasks.map(task => {
406 if (task.id !== id || !can(task)) return task
407 claimed = true
408 return change(task)
409 }),
410 }
411 })
412 return claimed
413}
414
415async function setWorker($: EngineInterface, row: HarnessWorkerRow): Promise<void> {
416 await update($, workersAtom, workers => ({ ...workers, [row.agentId]: row }))
417}
418
419async function dropWorker($: EngineInterface, agentId: string): Promise<void> {
420 await update($, workersAtom, workers => {
421 const { [agentId]: _gone, ...rest } = workers
422 return rest
423 })
424}
425
426async function addAsk($: EngineInterface, ask: HarnessHumanAsk): Promise<void> {
427 await update($, needsYouAtom, asks => [...asks.filter(one => one.taskId !== ask.taskId), ask])
428}
429
430async function clearAsks($: EngineInterface, taskId: string): Promise<void> {
431 await update($, needsYouAtom, asks => asks.filter(one => one.taskId !== taskId))
432}
433
434async function setReview($: EngineInterface, review: HarnessReview | null): Promise<void> {
435 await update($, reviewAtom, () => review)
436}
437
438async function setStale($: EngineInterface, note: string | null): Promise<void> {
439 await update($, staleAtom, () => note !== null)
440 await update($, staleNoteAtom, () => note)
441}
442
443function phase(review: HarnessReview | null): ReviewPhase {
444 return review === null ? null : { isOk: review.ok, status: review.status, isTimedOut: review.isTimedOut === true }
445}
446
447type RunContext = {
448 cwd: string
449 maxWorkers: number
450 gate: Gate
451 taskId?: string
452}
453
454type StartResult = { text: string; started: boolean }
455
456async function startTask(
457 $: EngineInterface,
458 plan: HarnessPlan,
459 task: HarnessTask,
460 ctx: RunContext,
461 now: number,
462): Promise<StartResult> {
463 const kind = kindOf(task)
464 if (kind === null) return { text: `${task.id} has no worker role.`, started: false }
465
466 const target = resolveLaunch(plan, task, kind)
467 if (!target.ok) return { text: `${task.id} not started: ${target.reason}.`, started: false }
468
469 const claimed = await claimTask(
470 $,
471 task.id,
472 current => RESTARTABLE.includes(current.state),
473 current => ({
474 ...current,
475 state: 'running',
476 startedAt: now,
477 endedAt: undefined,
478 verdict: undefined,
479 note: undefined,
480 agentId: undefined,
481 reviewAgentId: undefined,
482 reviewStarting: undefined,
483 lastTool: undefined,
484 toolCount: 0,
485 }),
486 )
487
488 if (!claimed) {
489 const live = (await read($, planAtom))?.tasks.find(one => one.id === task.id)
490 const agent = live?.agentId === undefined ? '' : ` as agent ${live.agentId}`
491 return { text: `${task.id} already ${live?.state ?? 'claimed'}${agent}.`, started: false }
492 }
493
494 await clearAsks($, task.id)
495 const fresh = (await read($, planAtom)) ?? plan
496 const current = fresh.tasks.find(one => one.id === task.id) ?? task
497 const outcome = await launchAgent(launchPorts($), ctx.gate, fresh, current, kind, ctx.cwd)
498
499 if (!outcome.ok) {
500 await patchTask($, task.id, current => ({
501 ...current,
502 state: 'proposed',
503 startedAt: undefined,
504 endedAt: undefined,
505 note: `spawn refused: ${outcome.reason}`,
506 }))
507 $.ui.toast(`harness: could not start "${fit(task.title, 40)}"`)
508 return { text: `${task.id} not started: ${outcome.reason}.`, started: false }
509 }
510
511 await patchTask($, task.id, current => ({ ...current, agentId: outcome.agentId }))
512 await setWorker($, {
513 taskId: task.id,
514 agentId: outcome.agentId,
515 role: task.role,
516 writes: task.writes,
517 model: task.model,
518 lastTool: null,
519 toolCount: 0,
520 startedAt: now,
521 })
522 return { text: `${task.id} started as ${kind} agent ${outcome.agentId}.`, started: true }
523}
524
525async function startReviewer(
526 $: EngineInterface,
527 gate: Gate,
528 plan: HarnessPlan,
529 task: HarnessTask,
530 cwd: string,
531 now: number,
532): Promise<string> {
533 const target = resolveLaunch(plan, task, 'reviewer')
534 if (!target.ok) {
535 await patchTask($, task.id, current => ({ ...current, note: `reviewer not started: ${target.reason}` }))
536 $.ui.toast(`harness: reviewer for "${fit(task.title, 40)}" did not start`)
537 return `${task.id} reviewer not started: ${target.reason}.`
538 }
539
540 const claimed = await claimTask(
541 $,
542 task.id,
543 current =>
544 current.state === 'review' && current.reviewAgentId === undefined && current.reviewStarting !== true,
545 current => ({ ...current, reviewStarting: true }),
546 )
547 if (!claimed) return `${task.id} review is already starting or running.`
548
549 const live = (await read($, planAtom)) ?? plan
550 const current = live.tasks.find(one => one.id === task.id) ?? task
551 const outcome = await launchAgent(launchPorts($), gate, live, current, 'reviewer', cwd, current.report)
552
553 if (!outcome.ok) {
554 await patchTask($, task.id, one => ({
555 ...one,
556 reviewStarting: undefined,
557 note: `reviewer not started: ${outcome.reason}`,
558 }))
559 $.ui.toast(`harness: reviewer for "${fit(task.title, 40)}" did not start`)
560 return `${task.id} reviewer not started: ${outcome.reason}.`
561 }
562
563 await patchTask($, task.id, one => ({
564 ...one,
565 reviewAgentId: outcome.agentId,
566 reviewStarting: undefined,
567 note: undefined,
568 }))
569 await setWorker($, {
570 taskId: task.id,
571 agentId: outcome.agentId,
572 role: 'reviewer',
573 writes: false,
574 model: task.reviewModel,
575 lastTool: null,
576 toolCount: 0,
577 startedAt: now,
578 })
579 return `${task.id} review started as reviewer agent ${outcome.agentId}.`
580}
581
582async function rerunReview(
583 $: EngineInterface,
584 plan: HarnessPlan,
585 task: HarnessTask,
586 ctx: RunContext,
587 now: number,
588): Promise<string> {
589 const moved = await claimTask(
590 $,
591 task.id,
592 current => current.state === 'needs_you' && current.reviewAgentId !== undefined,
593 current => ({
594 ...current,
595 state: 'review',
596 note: undefined,
597 verdict: undefined,
598 endedAt: undefined,
599 reviewAgentId: undefined,
600 reviewStarting: undefined,
601 }),
602 )
603 if (!moved) return `${task.id} is no longer waiting on a review answer.`
604
605 await clearAsks($, task.id)
606 const fresh = (await read($, planAtom)) ?? plan
607 const current = fresh.tasks.find(one => one.id === task.id) ?? task
608 return startReviewer($, ctx.gate, fresh, current, ctx.cwd, now)
609}
610
611async function runTasks($: EngineInterface, ctx: RunContext): Promise<string> {
612 const plan = await read($, planAtom)
613 if (plan === null) return 'No plan yet. Call harness_plan first.'
614
615 const now = await $.clock.now()
616 const lines: string[] = []
617
618 if (ctx.taskId !== undefined) {
619 const task = findTask(plan, ctx.taskId)
620 if (task === undefined) return `Unknown task ${ctx.taskId}.`
621
622 if (task.consult?.state === 'running') {
623 return `${task.id} waits for its read-only consult${task.consult.agentId === undefined ? '' : ` (agent ${task.consult.agentId})`}; read the recommendation with harness_status, then decide.`
624 }
625 if (task.role === 'advisor') {
626 if (task.state === 'done') return `${task.id} already done.`
627 await patchTask($, task.id, current => ({ ...current, state: 'done', endedAt: now }))
628 return `${task.id} stays with the advisor session and is now marked done.`
629 }
630 if (task.state === 'running' && task.agentId !== undefined) {
631 return `${task.id} already running as agent ${task.agentId}.`
632 }
633 if (task.state === 'review' && task.reviewAgentId !== undefined) {
634 return `${task.id} already in review as agent ${task.reviewAgentId}.`
635 }
636 if (task.state === 'needs_you' && task.reviewAgentId !== undefined) {
637 return rerunReview($, plan, task, ctx, now)
638 }
639 if (task.state === 'review') return startReviewer($, ctx.gate, plan, task, ctx.cwd, now)
640 if (task.state === 'approved') {
641 return `${task.id} is approved by the reviewer and waits for the harness review (Refresh review in the pane).`
642 }
643 if (task.state === 'verified' || task.state === 'done') return `${task.id} already ${task.state}.`
644 if (task.state === 'running') return `${task.id} is starting.`
645
646 const open = new Set(plan.tasks.filter(isUnblocking).map(one => one.id))
647 const waiting = task.deps.filter(dep => !open.has(dep))
648 if (waiting.length > 0) return `${task.id} waits for ${waiting.join(', ')}.`
649
650 const busy = plan.tasks.filter(one => one.state === 'running').length
651 if (busy >= ctx.maxWorkers) return `Worker limit ${ctx.maxWorkers} reached.`
652
653 return (await startTask($, plan, task, ctx, now)).text
654 }
655
656 const pending = plan.tasks.filter(task => task.state === 'review' && task.reviewAgentId === undefined)
657 for (const task of pending) lines.push(await startReviewer($, ctx.gate, plan, task, ctx.cwd, now))
658
659 const busy = plan.tasks.filter(task => task.state === 'running').length
660 const slots = Math.max(0, ctx.maxWorkers - busy)
661 for (const task of readyTasks(plan)) {
662 const live = (await read($, planAtom)) ?? plan
663 if (live.tasks.filter(one => one.state === 'running').length >= ctx.maxWorkers) break
664 lines.push((await startTask($, live, task, ctx, now)).text)
665 }
666
667 if (lines.length > 0) return lines.join('\n')
668 if (slots === 0) return `Worker limit ${ctx.maxWorkers} reached; nothing started.`
669 const waiting = plan.tasks.filter(task => task.state === 'proposed').length
670 return `Nothing ready to start (${waiting} proposed task${waiting === 1 ? '' : 's'} waiting on dependencies).`
671}
672
673type TurnOutcome = {
674 toast: string | null
675 allVerified: boolean
676 settled: boolean
677}
678
679const SETTLING: readonly HarnessTask['state'][] = ['done', 'approved', 'verified', 'failed', 'needs_you']
680
681async function onWorkerTurnComplete(
682 $: EngineInterface,
683 gate: Gate,
684 turn: { agentId: string; answer: string; reason: string },
685 cwd: string,
686): Promise<TurnOutcome> {
687 const idle: TurnOutcome = { toast: null, allVerified: false, settled: false }
688 const plan = await read($, planAtom)
689 if (plan === null) return idle
690
691 const consulted = plan.tasks.find(one => one.consult?.state === 'running' && one.consult.agentId === turn.agentId)
692 if (consulted !== undefined) return finishConsult($, consulted, turn)
693
694 const task = plan.tasks.find(one => one.agentId === turn.agentId || one.reviewAgentId === turn.agentId)
695 if (task === undefined) return idle
696
697 const now = await $.clock.now()
698 const isReview = task.reviewAgentId === turn.agentId && task.state === 'review'
699 const isWork = task.agentId === turn.agentId && task.state === 'running'
700 if (!isReview && !isWork) return idle
701
702 const move = isReview ? decideReviewTurn(task, turn) : decideWorkerTurn(task, turn)
703 const moved = await claimTask(
704 $,
705 task.id,
706 current => current.state === (isReview ? 'review' : 'running'),
707 current => ({
708 ...current,
709 state: move.state,
710 note: move.note,
711 verdict: move.verdict ?? current.verdict,
712 report: move.report ?? current.report,
713 reviewReport: isReview ? move.reviewReport : current.reviewReport,
714 endedAt: move.isEnded ? now : undefined,
715 reviewStarting: undefined,
716 }),
717 )
718 if (!moved) return idle
719
720 await dropWorker($, turn.agentId)
721
722 if (move.question !== undefined) {
723 await addAsk($, { id: `${task.id}:ask`, taskId: task.id, question: move.question, at: now })
724 }
725
726 if (move.startReview) {
727 const fresh = await read($, planAtom)
728 const current = fresh?.tasks.find(one => one.id === task.id) ?? task
729 await startReviewer($, gate, fresh ?? plan, current, cwd, now)
730 }
731
732 const after = await read($, planAtom)
733 return {
734 toast: move.toast ?? null,
735 allVerified: after !== null && isAllVerified(after),
736 settled: SETTLING.includes(move.state),
737 }
738}
739
740async function finishConsult(
741 $: EngineInterface,
742 task: HarnessTask,
743 turn: { agentId: string; answer: string; reason: string },
744): Promise<TurnOutcome> {
745 const move = decideConsultTurn(turn)
746 const moved = await claimTask(
747 $,
748 task.id,
749 current => current.consult?.state === 'running' && current.consult.agentId === turn.agentId,
750 current => (current.consult === undefined ? current : { ...current, consult: { ...current.consult, ...move } }),
751 )
752 await dropWorker($, turn.agentId)
753 if (!moved) return { toast: null, allVerified: false, settled: false }
754 const label = fit(task.title, 40)
755 return {
756 toast: move.state === 'done'
757 ? `harness: consult ready for "${label}"; read it with harness_status and decide`
758 : `harness: consult for "${label}" failed (${move.note ?? 'unknown'})`,
759 allVerified: false,
760 settled: false,
761 }
762}
763
764async function resolveConsults(
765 run: RunPort,
766 config: CliConfig,
767 tasks: readonly HarnessTask[],
768 triages: readonly (TriageResult | null)[],
769 roles: HarnessRoleTable | null,
770 sessionModel: string,
771): Promise<{ targets: Map<string, ConsultTarget>; failures: string[] }> {
772 const targets = new Map<string, ConsultTarget>()
773 const failures: string[] = []
774 if (sessionModel.length === 0) return { targets, failures }
775 const available = availableModels(sessionModel, roles)
776 const complexities = [...new Set(triages.flatMap(triage => (triage === null ? [] : [triage.complexity])))]
777 const answers = new Map(
778 await Promise.all(
779 complexities.map(async complexity => {
780 const answer = await runConsultPlan(run, config, { model: sessionModel, complexity, availableModels: available })
781 if (!answer.ok) failures.push(answer.reason)
782 return [complexity, answer.ok ? answer.value : null] as const
783 }),
784 ),
785 )
786 tasks.forEach((task, index) => {
787 const complexity = triages[index]?.complexity
788 const target = complexity === undefined ? null : (answers.get(complexity) ?? null)
789 if (target !== null) targets.set(task.id, target)
790 })
791 return { targets, failures }
792}
793
794async function startConsults($: EngineInterface, gate: Gate, cwd: string): Promise<string[]> {
795 const plan = await read($, planAtom)
796 if (plan === null) return []
797 const lines: string[] = []
798 for (const task of plan.tasks) {
799 const consult = task.consult
800 if (consult === undefined || consult.state !== 'running' || consult.agentId !== undefined) continue
801 const outcome = await launchConsult(launchPorts($), gate, plan, task, consult, cwd)
802 const now = await $.clock.now()
803 if (!outcome.ok) {
804 await patchTask($, task.id, current =>
805 current.consult === undefined ? current : { ...current, consult: { ...current.consult, state: 'failed', note: `consult not started: ${outcome.reason}` } },
806 )
807 lines.push(`${task.id} consult not started: ${outcome.reason}.`)
808 continue
809 }
810 await patchTask($, task.id, current =>
811 current.consult === undefined ? current : { ...current, consult: { ...current.consult, agentId: outcome.agentId } },
812 )
813 await setWorker($, {
814 taskId: task.id,
815 agentId: outcome.agentId,
816 role: 'advisor',
817 writes: false,
818 model: consult.model,
819 lastTool: null,
820 toolCount: 0,
821 startedAt: now,
822 })
823 lines.push(`${task.id} read-only consult started on ${consult.model} as agent ${outcome.agentId}; it does not start until you decide with harness_run.`)
824 }
825 return lines
826}
827
828async function noteToolCall($: EngineInterface, agentId: string, summary: string): Promise<void> {
829 const row = (await read($, workersAtom))[agentId]
830 if (row === undefined) return
831
832 await update($, workersAtom, workers => {
833 const live = workers[agentId]
834 return live === undefined
835 ? workers
836 : { ...workers, [agentId]: { ...live, lastTool: summary, toolCount: live.toolCount + 1 } }
837 })
838 await patchTask($, row.taskId, task =>
839 task.agentId === agentId || task.reviewAgentId === agentId
840 ? { ...task, lastTool: summary, toolCount: task.toolCount + 1 }
841 : task,
842 )
843}
844
845async function reconcile($: EngineInterface, cwd: string): Promise<void> {
846 const live = await read($, planAtom)
847 const saved = live === null ? await load($, cwd) : null
848 const plan = live ?? saved?.plan ?? null
849 if (plan === null) return
850
851 const asks = saved?.needsYou ?? (await read($, needsYouAtom))
852 const workers = saved?.workers ?? (await read($, workersAtom))
853
854 let listed: { id: string; status: string }[] = []
855 let listFailed = false
856 try {
857 listed = (await $.agent.list()).map(one => ({ id: one.id, status: one.status }))
858 } catch {
859 listFailed = true
860 }
861
862 if (listFailed) {
863 await update($, planAtom, () => plan)
864 await update($, workersAtom, () => workers)
865 await update($, needsYouAtom, () => asks)
866 await setStale($, `${STALE_LIST_NOTE}: worker states were kept as saved and are not verified`)
867 return
868 }
869
870 const status = new Map(listed.map(one => [one.id, one.status]))
871 const active = (id: string | undefined): boolean => {
872 const found = id === undefined ? undefined : status.get(id)
873 return found !== undefined && ACTIVE_STATUS.includes(found)
874 }
875 const completed = (id: string | undefined): boolean => id !== undefined && status.get(id) === 'completed'
876 const now = await $.clock.now()
877 const added: HarnessHumanAsk[] = []
878
879 const tasks: HarnessTask[] = plan.tasks.map(original => {
880 const task = original.consult?.state === 'running' && !active(original.consult.agentId)
881 ? {
882 ...original,
883 consult: {
884 ...original.consult,
885 state: 'failed' as const,
886 note: completed(original.consult.agentId)
887 ? 'consult finished while the mod was not listening; its recommendation was not captured'
888 : 'consult agent is gone',
889 },
890 }
891 : original
892 if (task.state === 'running' && !active(task.agentId)) {
893 if (completed(task.agentId)) {
894 const note = 'worker finished while the mod was not listening; check its result'
895 added.push({ id: `${task.id}:ask`, taskId: task.id, question: `${task.title}: ${note}`, at: now })
896 return { ...task, state: 'needs_you', note }
897 }
898 return {
899 ...task,
900 state: 'proposed',
901 agentId: undefined,
902 startedAt: undefined,
903 lastTool: undefined,
904 toolCount: 0,
905 }
906 }
907 if (task.state === 'review' && !active(task.reviewAgentId)) {
908 if (completed(task.reviewAgentId)) {
909 const note = 'reviewer finished while the mod was not listening; check its verdict'
910 added.push({ id: `${task.id}:ask`, taskId: task.id, question: `${task.title}: ${note}`, at: now })
911 return { ...task, state: 'needs_you', note, reviewStarting: undefined }
912 }
913 return { ...task, reviewAgentId: undefined, reviewStarting: undefined }
914 }
915 return task
916 })
917
918 const restored: HarnessPlan = { ...plan, tasks }
919 const kept = Object.fromEntries(Object.entries(workers).filter(([id]) => active(id)))
920
921 await update($, planAtom, () => restored)
922 await update($, workersAtom, () => kept)
923 await update($, needsYouAtom, () => [...asks, ...added])
924 if (isAllVerified(restored)) await update($, isHiddenAtom, () => true)
925 const staleNote = await read($, staleNoteAtom)
926 if (typeof staleNote === 'string' && staleNote.startsWith(STALE_LIST_NOTE)) await setStale($, null)
927}
928
929async function registerAtStart(
930 $: EngineInterface,
931 options: PluginOptions,
932 gate: Gate,
933 cwd: string,
934): Promise<void> {
935 const models = await runModels(runPort($), cliConfig(options, cwd))
936 if (!models.ok) {
937 $.ui.log(`harness: agent types not registered at start: ${models.reason}`, { to: 'debug' })
938 return
939 }
940 await registerKinds(launchPorts($), gate, models.value)
941}
942
943type ScopeRuntime = {
944 receipt: Receipt
945 pending: Promise<void> | null
946 warned: Set<string>
947}
948
949function refreshScope(
950 $: EngineInterface,
951 options: PluginOptions,
952 runtime: ScopeRuntime,
953 cwd: string,
954 query: string,
955): Promise<void> {
956 if (scopeMode(options) === 'off') return Promise.resolve()
957 const work = (async () => {
958 const ran = await runSkillSelect(runPort($), cwd, query, runbookOption(options))
959 if (ran.ok) {
960 await update($, scopeAtom, (state): ScopeState => ({
961 ...state,
962 status: 'ready',
963 selected: ran.value.skills,
964 clis: ran.value.clis,
965 total: ran.value.total,
966 query,
967 note: null,
968 }))
969 return
970 }
971 await update($, scopeAtom, (state): ScopeState =>
972 state.status === 'ready'
973 ? { ...state, note: `skill-select refresh failed (${ran.reason}); kept the previous selection` }
974 : { ...state, status: 'partial', query, note: `skill-select unavailable (${ran.reason}); no skill is hidden` },
975 )
976 })()
977 runtime.pending = work.then(
978 () => undefined,
979 () => undefined,
980 )
981 return runtime.pending
982}
983
984async function ownSkills($: EngineInterface): Promise<Map<string, SkillReason> | null> {
985 try {
986 const usage = await $.session.usage({ breakdown: 'summary' })
987 const list = usage.context.breakdown?.skills?.skillFrontmatter
988 if (list === undefined) return null
989 const own = new Map<string, SkillReason>()
990 for (const one of list) {
991 if (one.source === 'projectSettings') own.set(one.name, 'project')
992 else if (one.source === 'built-in') own.set(one.name, 'built-in')
993 }
994 return own
995 } catch {
996 return null
997 }
998}
999
1000function warnOnce($: EngineInterface, runtime: ScopeRuntime, key: string, text: string): void {
1001 runtime.receipt.notes = [...runtime.receipt.notes.filter(one => one !== text), text]
1002 if (runtime.warned.has(key)) return
1003 runtime.warned.add(key)
1004 $.ui.log(`scope: ${text}`)
1005}
1006
1007async function scopedListing(
1008 $: EngineInterface,
1009 options: PluginOptions,
1010 runtime: ScopeRuntime,
1011 text: string,
1012): Promise<string> {
1013 const mode = scopeMode(options)
1014 if (mode === 'off') return text
1015 if (runtime.pending !== null) await runtime.pending
1016
1017 const state = await read($, scopeAtom)
1018 if (state.status !== 'ready') {
1019 warnOnce($, runtime, 'select', 'skill-select unavailable, so the skill listing passed through')
1020 return text
1021 }
1022 const own = await ownSkills($)
1023 if (own === null) {
1024 warnOnce($, runtime, 'own', 'could not tell project skills apart, so the skill listing passed through')
1025 return text
1026 }
1027
1028 const always = listOption(options.alwaysAllowSkills, DEFAULT_SKILLS)
1029 const kept = new Map<string, SkillReason>()
1030 const hidden = new Set<string>()
1031 const out = filterSkillListing(text, name => {
1032 const reason = skillReason(name, { own, always, state })
1033 if (reason === null) {
1034 hidden.add(name)
1035 return mode === 'report'
1036 }
1037 kept.set(name, reason)
1038 return true
1039 })
1040 if (out === null) {
1041 warnOnce($, runtime, 'format', 'the skill listing had an unexpected format, so it passed through')
1042 return text
1043 }
1044 runtime.receipt.skills = kept
1045 runtime.receipt.hiddenSkills = hidden
1046 return mode === 'enforce' ? out.text : text
1047}
1048
1049async function createPlan(
1050 $: EngineInterface,
1051 options: PluginOptions,
1052 gate: Gate,
1053 runtime: ScopeRuntime,
1054 input: { objective: string; tasks: PlanTaskInput[] },
1055): Promise<string> {
1056 const cwd = await $.session.cwd()
1057 const model = await $.session.model()
1058 const config = cliConfig(options, cwd)
1059 const failures: string[] = []
1060
1061 const models = await runModels(runPort($), config)
1062 if (!models.ok) failures.push(models.reason)
1063
1064 const triages: (TriageResult | null)[] = await Promise.all(
1065 input.tasks.map(async task => {
1066 const triage = await runTriage(runPort($), config, task.title)
1067 if (triage.ok) return triage.value
1068 failures.push(triage.reason)
1069 return null
1070 }),
1071 )
1072
1073 const built = buildPlan(input.objective, input.tasks, triages, models.ok ? models.value : null, model)
1074 const consults = await resolveConsults(runPort($), config, built.tasks, triages, models.ok ? models.value : null, model)
1075 for (const reason of consults.failures) $.ui.log(`harness: consult lookup failed: ${reason}`, { to: 'debug' })
1076 const plan: HarnessPlan = {
1077 ...built,
1078 tasks: built.tasks.map(task => {
1079 const target = consults.targets.get(task.id)
1080 return target === undefined ? task : { ...task, consult: { ...target, state: 'running' as const } }
1081 }),
1082 at: await $.clock.now(),
1083 }
1084 const missing = missingRoles(plan)
1085 if (models.ok && missing.length > 0) {
1086 failures.push(`herdr-jev models list has no model for ${missing.join(', ')}; those roles are not spawned`)
1087 }
1088
1089 const note = failures.length === 0 ? null : [...new Set(failures)].join('; ')
1090
1091 await update($, planAtom, () => plan)
1092 await update($, workersAtom, () => ({}))
1093 await update($, needsYouAtom, () => [])
1094 await setStale($, note)
1095 await update($, isHiddenAtom, () => false)
1096 await update($, foldsAtom, () => ({}))
1097 await update($, advisorModelAtom, () => model)
1098 await setReview($, null)
1099 await save($, cwd)
1100 void refreshScope($, options, runtime, cwd, input.objective)
1101
1102 const consulting = await startConsults($, gate, cwd)
1103 if (consulting.length > 0) await save($, cwd)
1104 const summary = summarize((await read($, planAtom)) ?? plan)
1105 const head = `${summary}${note === null ? '' : `\nHerdr-Jev notes (plan marked stale): ${note}`}${consulting.length === 0 ? '' : `\nConsult:\n${consulting.join('\n')}`}`
1106 if (!autoRun(options)) return head
1107
1108 const started = await runTasks($, { cwd, maxWorkers: maxWorkers(options), gate })
1109 await save($, cwd)
1110 return `${head}\nAuto-run:\n${started}`
1111}
1112
1113async function maybeAutoReview(
1114 $: EngineInterface,
1115 options: PluginOptions,
1116 reviewed: Set<string>,
1117 cwd: string,
1118): Promise<void> {
1119 if (!autoReview(options)) return
1120 const plan = await read($, planAtom)
1121 const key = plan === null ? null : approvedKey(plan)
1122 if (key === null || reviewed.has(key)) return
1123 if (await read($, isReviewRunningAtom)) return
1124
1125 reviewed.add(key)
1126 void attempt('auto review', $, async () => {
1127 const text = await refreshReview($, options, cwd)
1128 $.ui.toast(fit(text, 100))
1129 })
1130}
1131
1132async function statusText($: EngineInterface): Promise<string> {
1133 const plan = await read($, planAtom)
1134 if (plan === null) return 'No harness plan. Call harness_plan with an objective and tasks.'
1135
1136 const workers = Object.values(await read($, workersAtom))
1137 const asks = await read($, needsYouAtom)
1138 const review = await read($, reviewAtom)
1139 const isRunning = await read($, isReviewRunningAtom)
1140 const stale = await read($, staleAtom)
1141 const note = await read($, staleNoteAtom)
1142 const counts = countTasks(plan)
1143 const lines = [summarize(plan)]
1144
1145 if (workers.length > 0) {
1146 lines.push('Workers:')
1147 for (const row of workers) {
1148 lines.push(`- ${row.role}${row.writes ? ' (writes)' : ''} ${row.agentId} on ${row.taskId}: ${row.lastTool ?? 'starting'} (${row.toolCount} tool calls)`)
1149 }
1150 }
1151 for (const ask of asks) lines.push(`Needs you: ${ask.question}`)
1152 if (counts.approved > 0) {
1153 lines.push(`${counts.approved} approved, ${reviewPhrase(isRunning, phase(review))}.`)
1154 } else if (isRunning) {
1155 lines.push('Harness review running.')
1156 }
1157 if (review !== null) {
1158 lines.push(
1159 review.ok
1160 ? `Last herdr-jev review: ${review.status ?? 'unknown'}${review.detail === null ? '' : ` (${review.detail})`}`
1161 : `Last herdr-jev review ${review.isTimedOut === true ? 'timed out' : 'failed'}: ${review.reason ?? 'unknown'}`,
1162 )
1163 }
1164 if (stale) lines.push(`Plan data is stale${note === null ? '' : `: ${note}`}.`)
1165 if (!/fable/i.test(plan.advisorModel)) {
1166 lines.push(`Advisor model is ${plan.advisorModel.length > 0 ? plan.advisorModel : 'unknown'}, expected Fable.`)
1167 }
1168 return lines.join('\n')
1169}
1170
1171async function takeReviewerRule($: EngineInterface, ruled: Set<string>): Promise<boolean> {
1172 const plan = await read($, planAtom)
1173 let fresh = false
1174 for (const task of plan?.tasks ?? []) {
1175 if (task.reviewAgentId === undefined || task.state === 'review') continue
1176 const key = `agent:${task.reviewAgentId}`
1177 if (ruled.has(key)) continue
1178 ruled.add(key)
1179 fresh = true
1180 }
1181 return fresh
1182}
1183
1184async function hideWhenVerified($: EngineInterface): Promise<void> {
1185 const plan = await read($, planAtom)
1186 if (plan !== null && isAllVerified(plan)) await update($, isHiddenAtom, () => true)
1187}
1188
1189function scheduleHide($: EngineInterface): void {
1190 $.clock.after(HIDE_AFTER_MS, () => {
1191 void attempt('auto hide', $, () => hideWhenVerified($))
1192 })
1193}
1194
1195async function refreshReview($: EngineInterface, options: PluginOptions, cwd: string): Promise<string> {
1196 let claimed = false
1197 await update($, isReviewRunningAtom, running => {
1198 claimed = !running
1199 return true
1200 })hooks/claims.ts 669 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import type { ClaimCheck, ClaimEntry, ClaimKind, ClaimWarning } from '../types'
5
6const logAtom = atom({ plugin: 'harness', key: 'claimLog' } as const, [])
7const warningsAtom = atom({ plugin: 'harness', key: 'claimWarnings' } as const, [])
8
9export const LOG_LIMIT = 200
10export const SHOWN_WARNINGS = 3
11export const COMMAND_LIMIT = 80
12
13const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
14
15const LEFT = '(?<![\\p{L}\\p{N}_])'
16const RIGHT = '(?![\\p{L}\\p{N}_])'
17
18const LINKS =
19 '(?:\\s+(?:all|now|still|fully|are|is|were|was|todos?|todas?|agora|ainda|est[aá]|est[aã]o|ficou|ficaram|foi|foram|j[aá]|tamb[eé]m))*'
20const PASSED =
21 '(?:pass(?:es|ed|ing)?|green|succeed(?:s|ed)?|clean(?:ly)?|ok|passa(?:m|ndo)?|passou|passaram|verdes?|limp[oa]s?|sucesso|aprovad[oa]s?|funcion(?:a|am|ou|aram))'
22const ADVERB =
23 '(?:\\s+(?:now|again|too|locally|successfully|cleanly|agora|novamente|localmente|com sucesso))?'
24const STOP_MARKS = '[.,;!)\\]*_✅✔]'
25const JOINERS = '\\s+(?:and|but|e|mas)(?![\\p{L}\\p{N}_])'
26const LATER =
27 '\\s+(?:after|depois|(?:with(?:out)?|com|sem)\\s+(?:(?:no|zero|0|any|nenhum|nenhuma|qualquer)\\s+)?(?:[\\p{L}-]+\\s+)?(?:errors?|erros?|warnings?|failures?|falhas?))(?![\\p{L}\\p{N}_])'
28const END = `(?=\\s*(?:[:]|${STOP_MARKS}|[—–]|-\\s|\\((?:\\d|(?:no|zero|sem|nenhum)(?![\\p{L}\\p{N}_]))|$)|${JOINERS}|${LATER})`
29const END_V = `(?=\\s*(?:${STOP_MARKS}|$)|${JOINERS})`
30const TAIL = `${LINKS}\\s+${PASSED}${ADVERB}${END}`
31
32const pattern = (source: string) => new RegExp(source, 'iu')
33
34const CLAIM_PATTERNS: readonly [ClaimKind, RegExp][] = [
35 ['ci', pattern(`${LEFT}(?:(?:the |o |a )?CI|(?:the |o |a )?pipeline)${TAIL}`)],
36 [
37 'lint',
38 pattern(
39 `${LEFT}(?:lint(?:ing|er)?|type ?checks?|type-checks?|typecheck(?:ing)?|types|tsc|eslint|ruff|mypy|pyright|tipos|checagem de tipos|verifica[cç][aã]o de tipos)${TAIL}`,
40 ),
41 ],
42 ['build', pattern(`${LEFT}(?:the |o )?builds?${TAIL}`)],
43 [
44 'test',
45 pattern(
46 `${LEFT}(?:(?:the|all|os|as|todos|todas)\\s+)*(?:\\d+\\s+)?(?:(?:unit|integration|e2e|new|unit[aá]rios?|novos?)\\s+)?(?:tests?|specs?|test suite|suite|testes?|su[ií]tes?)(?:\\s+(?:unit[aá]rios?|de integra[cç][aã]o|e2e|novos?))?${TAIL}`,
47 ),
48 ],
49 ['verified', pattern(`${LEFT}(?:(?:all|todos os|os)\\s+)?(?<!type[- ])checks${TAIL}`)],
50 [
51 'verified',
52 pattern(
53 `${LEFT}(?:verified|confirmed)(?:\\s+(?:that|it|this|the fix|the change))*\\s+(?:works?|is working|fixed|fixes it)${END_V}`,
54 ),
55 ],
56 [
57 'verified',
58 pattern(
59 `${LEFT}(?:i|we)(?:'ve|\\s+have)?\\s+(?:(?:now|also|fully|successfully|already)\\s+)*verified(?:\\s+(?:it|this|everything|the fix|the change|the result))?${END_V}`,
60 ),
61 ],
62 [
63 'verified',
64 pattern(
65 `(?:^|${LEFT}(?:is|are|was|were|been|now|fully)\\s+)(?:(?:now|fully|all|successfully)\\s+)*verified${END_V}`,
66 ),
67 ],
68 [
69 'verified',
70 pattern(
71 `${LEFT}(?:verifiquei|confirmei)(?:\\s+que)?(?:\\s+(?:isso|tudo|ele|ela|a\\s+corre[cç][aã]o|o\\s+(?:fix|conserto)|a\\s+mudan[cç]a))*\\s+(?:funciona|est[aá]\\s+funcionando|corrigid[oa]|passa)${END_V}`,
72 ),
73 ],
74 ['verified', pattern(`${LEFT}verifiquei\\s+tudo${END_V}`)],
75 [
76 'verified',
77 pattern(
78 `(?:^|${LEFT}(?:est[aá]|ficou|foi|tudo|j[aá]|totalmente|devidamente|agora)\\s+)(?:(?:j[aá]|totalmente|devidamente|agora)\\s+)*verificad[oa]s?${END_V}`,
79 ),
80 ],
81 ['verified', pattern(`${LEFT}(?:tudo|todos|todas)\\s+(?:(?:j[aá]|e)\\s+)?(?:passou|passaram)${END_V}`)],
82 ['verified', pattern(`${LEFT}(?:everything${LINKS}\\s+(?:green|pass(?:es|ed|ing)?|ok)|all green)${END_V}`)],
83]
84
85const CONDITION = pattern(
86 `${LEFT}(?:unless|until|once|if|when|after|as soon as|whether|goal|expected|means|make sure|ensure|assuming|before|so that|to make|to get|confirm|check that|verify that|earlier|previously|se|quando|depois que|esperado|at[eé]|assim que|caso|desde que|antes|para que|garanta|garantir|certifique|certificar|confirme|verifique|anteriormente|mais cedo)${RIGHT}`,
87)
88
89const NEGATION = pattern(
90 `${LEFT}(?:not|no|never|none|nor|without|fail(?:s|ed|ing)?|yet|should|shall|will|would|could|might|may|must|expect(?:ed)?|hope|probably|likely|need(?:s|ed)?|cannot|can be|pending|n[aã]o|nunca|nenhum[a]?|nem|sem|falh(?:a|ou|aram|ando)|deve(?:m|ria|riam)?|poder[aá]|talvez|provavelmente|espero|precis(?:a|am|o)|falta(?:m)?|pendente)${RIGHT}|n't${RIGHT}`,
91)
92
93const REPORTED = pattern(
94 `${LEFT}(?:says?|said|reports?|reported|answered|claims?|claimed|per|according to|disse|diz|relatou|informou|respondeu|afirmou|segundo)${RIGHT}`,
95)
96
97const LABELLED = pattern(
98 '^(?:[-*]\\s*\\[ \\]|\\||(?:acceptance criteria|criteria|dod|definition of done|goal|todo|next|step \\d+|expected result|crit[eé]rios?(?: de aceite)?|objetivo|pr[oó]ximos? passos?)(?![\\p{L}\\p{N}_]))',
99)
100
101const MIXED = pattern(
102 `(?<!${LEFT}(?:no|0|zero|without|none|nothing|not|never|sem|nenhum|nenhuma|nada|n[aã]o)(?:\\s+[\\p{L}\\p{N}_-]+){0,2}\\s+)${LEFT}(?:fails?|failed|falh(?:a|am|ou|aram)|(?:[1-9]\\d*|one|two|three|four|five|several|some|many|um|uma|dois|duas|tr[eê]s|alguns|v[aá]rios)\\s+(?:[\\p{L}_-]+\\s+)?(?:errors?|failures?|erros?|falhas?))${RIGHT}`,
103)
104
105const FIXED = pattern(
106 `${LEFT}(?:fix(?:ed|es|ing)?|resolved|resolving|resolves|gone|corrigi|corrigid[oa]s?|resolvi|resolvid[oa]s?)${RIGHT}`,
107)
108const UNRESOLVED = pattern(`${LEFT}(?:still|ainda|remain|remains|remaining|restam?)${RIGHT}`)
109
110const NEAR_WORDS = 4
111
112const clauseBefore = (sentence: string, index: number) => {
113 const start = Math.max(...[',', ';', ':', '—', '–', '('].map(mark => sentence.lastIndexOf(mark, index - 1)))
114 return sentence.slice(start + 1, index)
115}
116
117const nearBefore = (clause: string) => {
118 const words = clause.trim().split(/\s+/).filter(word => word.length > 0)
119 const conjunction = words
120 .map(word => word.toLowerCase())
121 .findLastIndex(word => ['and', 'but', 'so', 'then', 'e', 'mas', 'então'].includes(word))
122 return words.slice(conjunction + 1).slice(-NEAR_WORDS).join(' ')
123}
124
125const withoutQuoted = (answer: string) =>
126 answer
127 .replace(/```[\s\S]*?```/g, '\n')
128 .replace(/^[ \t]*>.*$/gm, '\n')
129 .replace(/`[^`\n]*`/g, ' ')
130 .replace(/"[^"\n]*"/g, ' ')
131 .replace(/“[^”\n]*”/g, ' ')
132 .replace(/«[^»\n]*»/g, ' ')
133 .replace(/(?<![\p{L}\p{N}])'[^'\n]+'(?![\p{L}\p{N}])/gu, ' ')
134
135const sentencesOf = (answer: string) =>
136 withoutQuoted(answer)
137 .split(/(?<=[.!?])\s+|\n+/)
138 .map(sentence => sentence.trim())
139 .filter(sentence => sentence.length > 0 && !sentence.endsWith('?') && !LABELLED.test(sentence))
140
141export type Claim = { kind: ClaimKind; quote: string }
142
143export function detectClaims(answer: string): Claim[] {
144 const claims = new Map<ClaimKind, Claim>()
145 for (const sentence of sentencesOf(answer)) {
146 for (const [kind, regex] of CLAIM_PATTERNS) {
147 if (claims.has(kind)) continue
148 const match = regex.exec(sentence)
149 if (match === null) continue
150 if (REPORTED.test(sentence.slice(0, match.index))) continue
151 const before = sentence.slice(0, match.index)
152 if (MIXED.test(sentence.slice(match.index + match[0].length))) continue
153 if (MIXED.test(before) && (!FIXED.test(before) || UNRESOLVED.test(before))) continue
154 const clause = clauseBefore(sentence, match.index)
155 if (CONDITION.test(`${clause} ${match[0]}`)) continue
156 if (NEGATION.test(`${nearBefore(clause)} ${match[0]}`)) continue
157 claims.set(kind, { kind, quote: match[0].replace(/\s+/g, ' ').trim() })
158 }
159 }
160 return [...claims.values()]
161}
162
163const SEPARATOR = /(\|\||&&|\|&|\||;|\n)/
164
165const ELSEWHERE = /^(?:(?:\/tmp|\/var\/tmp|\/dev)(?:\/|$)|\$[A-Za-z_{])/
166
167const stripQuoted = (command: string) =>
168 command
169 .replace(/'([^']*)'|"([^"]*)"/g, (_whole, single: string | undefined, double: string | undefined) =>
170 ELSEWHERE.test(single ?? double ?? '') ? '/tmp/""' : '""',
171 )
172 .replace(/(^|\s)#.*$/gm, '$1')
173
174export const COMMAND_SIZE_LIMIT = 64 * 1024
175const UNWRAP_DEPTH = 16
176
177const WORD_PREFIX = /^(?:exec|time|command(?!\s+-[vV])|if|then|do|while|until|else|rtk(?:\s+-u)?(?:\s+(?:test|err|proxy|summary))?)\s+/
178const GROUPING = /^[({!]+\s*/
179const ASSIGN_SUBST = /^\w+=(?:\$\(|`)\s*/
180const ASSIGN = /^\w+=\S*\s+/
181
182type Wrapper = { flagsWithValue: readonly string[]; operands: number }
183
184const WRAPPERS = new Map<string, Wrapper>(Object.entries({
185 sudo: { flagsWithValue: ['-u', '-g', '-C', '-h', '-p', '-r', '-t', '-U', '-D', '-R', '-T', '--user', '--group'], operands: 0 },
186 nice: { flagsWithValue: ['-n', '--adjustment'], operands: 0 },
187 corepack: { flagsWithValue: [], operands: 0 },
188 timeout: { flagsWithValue: ['-s', '-k', '--signal', '--kill-after'], operands: 1 },
189 env: { flagsWithValue: ['-u', '-C', '-S', '--unset', '--chdir', '--split-string'], operands: 0 },
190} satisfies Record<string, Wrapper>))
191
192const COMPOSE_FLAGS = ['-e', '-v', '-u', '-w', '-p', '-l', '--env', '--volume', '--user', '--workdir', '--name', '--entrypoint', '--publish', '--label', '--cap-add', '--cap-drop', '--index', '--env-from-file', '--pull']
193const COMPOSE_GLOBAL_FLAGS = ['-f', '-p', '--file', '--project-name', '--project-directory', '--profile', '--env-file', '--ansi', '--parallel']
194
195function dropFlags(tokens: string[], flagsWithValue: readonly string[]): string[] {
196 let index = 0
197 while (index < tokens.length) {
198 const token = tokens[index] ?? ''
199 if (!token.startsWith('-') || token === '-') break
200 index += flagsWithValue.includes(token) ? 2 : 1
201 }
202 return tokens.slice(index)
203}
204
205function unwrapOnce(text: string): string {
206 const grouping = GROUPING.exec(text)
207 if (grouping !== null) return text.slice(grouping[0].length)
208 const substitution = ASSIGN_SUBST.exec(text)
209 if (substitution !== null) return text.slice(substitution[0].length)
210 const word = WORD_PREFIX.exec(text)
211 if (word !== null) return text.slice(word[0].length)
212 const assign = ASSIGN.exec(text)
213 if (assign !== null) return text.slice(assign[0].length)
214 const tokens = text.split(/\s+/).filter(token => token.length > 0)
215 const head = (tokens[0] ?? '').replace(/^.*\//, '')
216 const rest = tokens.slice(1)
217 const wrapper = WRAPPERS.get(head)
218 if (wrapper !== undefined) {
219 const after = dropFlags(rest, wrapper.flagsWithValue)
220 return after.slice(wrapper.operands).join(' ')
221 }
222 if (head === 'docker' || head === 'docker-compose') {
223 let args = rest
224 if (head === 'docker') {
225 if (args[0] === 'compose') args = args.slice(1)
226 else if (args[0] === 'exec') return dropFlags(args.slice(1), COMPOSE_FLAGS).slice(1).join(' ')
227 else return text
228 }
229 args = dropFlags(args, COMPOSE_GLOBAL_FLAGS)
230 if (args[0] !== 'exec' && args[0] !== 'run') return text
231 return dropFlags(args.slice(1), COMPOSE_FLAGS).slice(1).join(' ')
232 }
233 return text
234}
235
236type Segment = { program: string; args: string[]; text: string }
237
238function segmentOf(raw: string): Segment {
239 let end = raw.length
240 while (end > 0 && /[)}`\s]/.test(raw[end - 1] ?? '')) end -= 1
241 let text = raw.slice(0, end).trim()
242 for (let round = 0; round < UNWRAP_DEPTH; round += 1) {
243 const next = unwrapOnce(text).trim()
244 if (next === text) break
245 text = next
246 }
247 const tokens = text.split(/\s+/).filter(token => token.length > 0)
248 const program = (tokens[0] ?? '').replace(/^.*\//, '')
249 return { program, args: tokens.slice(1), text }
250}
251
252const SHELL_C =
253 /(?<=(?:^|[;&|({\n]|\b(?:sudo|exec|time|then|do|else)\s)\s*)(?:ba|z|da)?sh\s+(?:-[a-zA-Z]+\s+)*-[a-zA-Z]*c\s+(?:'([^']*)'|"([^"]*)")/g
254
255function unwrapShells(command: string): string {
256 let text = command
257 for (let round = 0; round < 4; round += 1) {
258 const next = text.replace(SHELL_C, (_whole, single: string | undefined, double: string | undefined) => single ?? double ?? '')
259 if (next === text) break
260 text = next
261 }
262 return text
263}
264
265const HEREDOC = /(?<!<)<<(?!<)-?[ \t]*(?:'([^'\n]+)'|"([^"\n]+)"|([A-Za-z_][\w-]*))/
266
267function dropHeredocs(command: string): string {
268 let text = command
269 for (let round = 0; round < 16; round += 1) {
270 const match = HEREDOC.exec(text)
271 if (match === null) break
272 const tag = match[1] ?? match[2] ?? match[3] ?? ''
273 const lineEnd = text.indexOf('\n', match.index)
274 if (lineEnd < 0) {
275 text = text.slice(0, match.index)
276 break
277 }
278 const lines = text.slice(lineEnd + 1).split('\n')
279 const close = lines.findIndex(line => line.trim() === tag)
280 const rest = close < 0 ? '' : lines.slice(close + 1).join('\n')
281 text = `${text.slice(0, match.index)}${text.slice(match.index + match[0].length, lineEnd)}\n${rest}`
282 }
283 return text
284}
285
286function segmentsOf(command: string): Segment[] {
287 const body = dropHeredocs(command)
288 if (body.length > COMMAND_SIZE_LIMIT) return []
289 return stripQuoted(unwrapShells(body))
290 .split(SEPARATOR)
291 .filter((_, index) => index % 2 === 0)
292 .map(segmentOf)
293 .filter(segment => segment.program.length > 0)
294}
295
296const NON_RUNNERS = new Set([
297 'grep', 'rg', 'ag', 'echo', 'printf', 'cat', 'less', 'head', 'tail', 'which', 'type', 'man', 'ls', 'command',
298 'find', 'sed', 'awk', 'hash', 'whereis', 'stat', 'file', 'wc', 'diff', 'cut', 'sort', 'tr', 'pwd', 'cd',
299 'pip', 'pip3', 'pipx', 'test', '[', '[[',
300])
301
302const INSTALLS =
303 /^(?:(?:pip3?|pipx|uv\s+pip|uv\s+tool|python3?\s+-m\s+pip)\s+install\b|uv\s+(?:add|remove|sync)\b|poetry\s+(?:add|install|remove)\b|cargo\s+(?:install|add)\b|go\s+(?:install|get)\b|(?:brew|gem|apt|apt-get|dnf|yum|apk)\s+(?:install|add)\b|composer\s+(?:install|require|update|remove)\b)/
304
305const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun'])
306
307const PM_NON_RUN = new Set([
308 'install', 'i', 'add', 'ci', 'remove', 'rm', 'uninstall', 'update', 'upgrade', 'publish', 'pack', 'link', 'unlink',
309 'view', 'info', 'why', 'ls', 'list', 'outdated', 'audit', 'init', 'create',
310])
311
312const SCRIPT_KINDS: readonly [RegExp, ClaimCheck][] = [
313 [/^(?:test|tests|smoke|e2e|spec|test:[\w:.-]+)$/, 'test'],
314 [/^(?:typecheck|type-check|tsc|lint|check|(?:lint|typecheck|check):[\w:.-]+)$/, 'lint'],
315 [/^build(?::[\w:.-]+)?$/, 'build'],
316]
317
318function scriptKind(args: readonly string[]): ClaimCheck | null {
319 for (const arg of args) {
320 if (arg.startsWith('-')) continue
321 if (PM_NON_RUN.has(arg)) return null
322 const found = SCRIPT_KINDS.find(([regex]) => regex.test(arg))
323 if (found !== undefined) return found[1]
324 }
325 return null
326}
327
328const TEST_RUNNERS =
329 /\b(?:pytest|jest|vitest|mocha|phpunit|rspec|playwright test|go test|cargo (?:test|nextest)|deno test|dotnet test|php artisan test|node --test|python3? -m (?:unittest|pytest)|claude plugin test|composer (?:run(?:-script)? )?test|make (?:test|check)|just test|mvn(?: \S+)* test|gradlew?(?: \S+)* test|ai-harness review-verify|herdr-jev (?:review|prove))\b|(?:^|[\s/])pest\b/
330
331const LINT_RUNNERS =
332 /\b(?:eslint|ruff(?! format)|tsc|mypy|pyright|biome|clippy|go vet|cargo check|phpstan|psalm|flake8|golangci-lint|astro check|php -l|claude plugin validate|prettier --check)(?![\w-])/
333
334const BUILD_RUNNERS =
335 /\b(?:cargo build|go build|make build|gradlew?(?: \S+)* build|mvn(?: \S+)* (?:package|install)|docker(?: compose)? build|vite build|next build|astro build)\b/
336
337const CI_READS =
338 /\b(?:gh pr checks|gh run (?:view|watch|list)|gh pr view|az pipelines|az repos pr show|az devops)\b|check-runs|statusCheckRollup/
339
340const CHECK_ORDER: readonly ClaimCheck[] = ['test', 'lint', 'build', 'ci', 'push']
341
342function checksOf(segment: Segment): ClaimCheck[] {
343 const { program, args, text } = segment
344 if (NON_RUNNERS.has(program)) return []
345 if (program === 'git') {
346 const sub = gitCommand(args)
347 return sub !== null && sub.name === 'push' && !sub.flags.some(flag => flag === '--dry-run' || /^-[a-z]*n[a-z]*$/.test(flag))
348 ? ['push']
349 : []
350 }
351 if (INSTALLS.test(text) || /\s--(?:version|help)(?:\s|$)/.test(text)) return []
352 const found = new Set<ClaimCheck>()
353 if (PACKAGE_MANAGERS.has(program)) {
354 if (args.some(arg => PM_NON_RUN.has(arg))) return []
355 const kind = scriptKind(args)
356 if (kind !== null) found.add(kind)
357 }
358 if (TEST_RUNNERS.test(text)) found.add('test')
359 if (LINT_RUNNERS.test(text)) found.add('lint')
360 if (BUILD_RUNNERS.test(text)) found.add('build')
361 if (CI_READS.test(text)) found.add('ci')
362 return CHECK_ORDER.filter(check => found.has(check))
363}
364
365export function classifyCommand(command: string): ClaimCheck[] {
366 const found = new Set<ClaimCheck>()
367 for (const segment of segmentsOf(command)) for (const check of checksOf(segment)) found.add(check)
368 return CHECK_ORDER.filter(check => found.has(check))
369}
370
371function gitCommand(args: readonly string[]): { name: string; rest: string[]; flags: string[]; dir?: string } | null {
372 let index = 0
373 let dir: string | undefined
374 while (index < args.length) {
375 const arg = args[index] ?? ''
376 if (arg === '-C') dir = args[index + 1]
377 if (['-C', '-c', '--git-dir', '--work-tree'].includes(arg)) {
378 index += 2
379 continue
380 }
381 if (arg.startsWith('-')) {
382 index += 1
383 continue
384 }
385 const rest = args.slice(index + 1)
386 return { name: arg, rest, flags: rest.filter(one => one.startsWith('-')), ...(dir === undefined ? {} : { dir }) }
387 }
388 return null
389}
390
391const OFF_TREE = /^(?:\/tmp|\/var\/tmp|\/dev)(?:\/|$)/
392const isOffTree = (path: string) => OFF_TREE.test(path)
393
394const REDIRECT = /(?<![=\-<>])(?:\d+|&)?>>?\s*(&?)([^\s;&|<>()]*)/g
395const IN_PLACE = /^-[a-zA-Z]*i[\w.~-]*(?:"")?$|^--in-place(?:=.*)?$/
396
397function gitMutates(args: readonly string[], cwd: string | undefined): boolean {
398 const git = gitCommand(args)
399 if (git === null) return false
400 const { name, rest, flags, dir } = git
401 if (dir !== undefined && cwd !== undefined && !isInside(dir, cwd)) return false
402 const has = (...names: string[]) => flags.some(flag => names.includes(flag))
403 switch (name) {
404 case 'checkout':
405 return !has('-b', '-B', '--orphan')
406 case 'switch':
407 return !has('-c', '-C', '--create', '--force-create')
408 case 'restore':
409 return !has('--staged') || has('--worktree', '-W')
410 case 'reset':
411 return has('--hard', '--merge', '--keep')
412 case 'merge':
413 case 'rebase':
414 case 'cherry-pick':
415 case 'revert':
416 case 'pull':
417 case 'am':
418 case 'mv':
419 return true
420 case 'apply':
421 return !has('--check', '--stat', '--numstat', '--summary')
422 case 'rm':
423 return !has('--cached')
424 case 'clean':
425 return !(has('--dry-run') || flags.some(flag => /^-[a-z]*n[a-z]*$/.test(flag)))
426 case 'stash': {
427 const verb = rest.find(one => !one.startsWith('-'))
428 return verb === undefined || ['push', 'save', 'pop', 'apply'].includes(verb)
429 }
430 default:
431 return false
432 }
433}
434
435function segmentMutates(segment: Segment, cwd: string | undefined, depth = 0): boolean {
436 const { program, args, text } = segment
437 if (program === 'test' || program === '[' || program === '[[') return false
438 for (const match of text.matchAll(REDIRECT)) {
439 const target = match[2] ?? ''
440 if (match[1] === '&' || target === '' || isOffTree(target)) continue
441 return true
442 }
443 const paths = args.filter(arg => !arg.startsWith('-') && !/^[<>&\d]/.test(arg))
444 switch (program) {
445 case 'xargs': {
446 const start = args.findIndex(arg => !arg.startsWith('-'))
447 return depth < UNWRAP_DEPTH && start >= 0 && segmentMutates(segmentOf(args.slice(start).join(' ')), cwd, depth + 1)
448 }
449 case 'git':
450 return gitMutates(args, cwd)
451 case 'patch':
452 return true
453 case 'tee':
454 case 'rm':
455 case 'rmdir':
456 case 'unlink':
457 case 'touch':
458 case 'truncate':
459 return paths.some(path => !isOffTree(path))
460 case 'cp':
461 case 'mv':
462 case 'install':
463 case 'ln':
464 case 'rsync': {
465 const target = paths[paths.length - 1]
466 return target !== undefined && paths.length > 1 && !isOffTree(target)
467 }
468 case 'dd':
469 return args.some(arg => arg.startsWith('of=') && !isOffTree(arg.slice(3)))
470 case 'sed':
471 case 'perl':
472 return args.some(arg => arg.length < 64 && IN_PLACE.test(arg))
473 default:
474 break
475 }
476 return (
477 /--(?:fix|write)(?![\w-])/.test(text) ||
478 /\bgofmt\s+-w\b/.test(text) ||
479 (/\bcargo\s+fmt\b/.test(text) && !/--check/.test(text)) ||
480 (/\bruff\s+format\b/.test(text) && !/--(?:check|diff)/.test(text))
481 )
482}
483
484export function isMutatingCommand(command: string, isReadOnly = false, cwd?: string): boolean {
485 if (isReadOnly) return false
486 return segmentsOf(command).some(segment => segmentMutates(segment, cwd))
487}
488
489type EntryInput =
490 | { type: 'edit'; path: string; agentId?: string }
491 | { type: 'run'; checks: ClaimCheck[]; command: string; isOk: boolean; isInterrupted: boolean; agentId?: string }
492
493export function appendEntry(log: readonly ClaimEntry[], entry: EntryInput): ClaimEntry[] {
494 const seq = (log[log.length - 1]?.seq ?? 0) + 1
495 return [...log, { ...entry, seq } as ClaimEntry].slice(-LOG_LIMIT)
496}
497
498const EVIDENCE: Record<ClaimKind, { checks: ClaimCheck[]; noun: string }> = {
499 test: { checks: ['test'], noun: 'test' },
500 lint: { checks: ['lint'], noun: 'lint or typecheck' },
501 build: { checks: ['build'], noun: 'build' },
502 ci: { checks: ['ci'], noun: 'CI check' },
503 verified: { checks: ['test', 'lint', 'build', 'ci'], noun: 'check' },
504}
505
506const lastSeq = (log: readonly ClaimEntry[], isMarker: (entry: ClaimEntry) => boolean) =>
507 [...log].reverse().find(isMarker)?.seq
508
509const isPush = (entry: ClaimEntry) => entry.type === 'run' && entry.checks.includes('push') && entry.isOk
510
511const shorten = (text: string, limit: number) => (text.length > limit ? `${text.slice(0, limit - 3)}...` : text)
512
513export function unverifiedClaims(claims: readonly Claim[], log: readonly ClaimEntry[]): ClaimWarning[] {
514 return claims.flatMap(claim => {
515 const { checks, noun } = EVIDENCE[claim.kind]
516 const isCi = claim.kind === 'ci'
517 const since = isCi ? lastSeq(log, isPush) : lastSeq(log, entry => entry.type === 'edit')
518 if (since === undefined && (!isCi || !log.some(entry => entry.type === 'edit'))) return []
519 const runs = log.filter(
520 (entry): entry is Extract<ClaimEntry, { type: 'run' }> =>
521 entry.type === 'run' &&
522 entry.seq > (since ?? 0) &&
523 entry.checks.some(check => checks.includes(check)) &&
524 (entry.agentId === undefined || entry.isOk),
525 )
526 const last = runs[runs.length - 1]
527 if (last === undefined) {
528 const when = since === undefined ? 'this session' : isCi ? 'after the last push' : 'after the last edit'
529 return [{ kind: claim.kind, quote: claim.quote, reason: `no ${noun} ran ${when}` }]
530 }
531 if (last.isInterrupted) {
532 return [{ kind: claim.kind, quote: claim.quote, reason: `the last ${noun} run was interrupted (${shorten(last.command, 40)})` }]
533 }
534 if (!last.isOk) {
535 return [{ kind: claim.kind, quote: claim.quote, reason: `the last ${noun} run failed (${shorten(last.command, 40)})` }]
536 }
537 return []
538 })
539}
540
541export const warningLine = (warning: ClaimWarning) => `unverified: "${warning.quote}" · ${warning.reason}`
542
543type RunResult = { interrupted?: unknown }
544
545function debug($: EngineInterface, label: string, error: unknown): void {
546 try {
547 const message = error instanceof Error ? error.message : String(error)
548 $.ui.log(`harness: claims ${label} failed: ${message.slice(0, 160)}`, { to: 'debug' })
549 } catch {
550 return
551 }
552}
553
554function isInside(path: string, cwd: string): boolean {
555 if (!path.startsWith('/')) return !`/${path}`.includes('/.claude/worktrees/')
556 const root = cwd.endsWith('/') ? cwd : `${cwd}/`
557 if (path !== cwd && !path.startsWith(root)) return false
558 return !path.slice(root.length - 1).includes('/.claude/worktrees/')
559}
560
561function isTracked(path: string, cwd: string): boolean {
562 return !/\.md$/i.test(path) && isInside(path, cwd)
563}
564
565const AIMED_ELSEWHERE = /(?:^|\s)-C(?:\s|$)/
566
567async function mutates($: EngineInterface, command: string, isReadOnly: boolean): Promise<boolean> {
568 if (!isMutatingCommand(command, isReadOnly)) return false
569 if (!AIMED_ELSEWHERE.test(command)) return true
570 return isMutatingCommand(command, isReadOnly, await $.session.root())
571}
572
573type RecordInput = { tool: string; agentId?: string } & Record<string, unknown>
574
575async function record(
576 $: EngineInterface,
577 input: RecordInput,
578 ran: { isError?: boolean; isReadOnly?: boolean; result?: unknown },
579): Promise<void> {
580 const agentId = typeof input.agentId === 'string' ? input.agentId : undefined
581 if (EDIT_TOOLS.has(input.tool)) {
582 if (ran.isError === true) return
583 const cwd = await $.session.root()
584 const path = String(input.file_path ?? input.notebook_path ?? '')
585 if (!isTracked(path, cwd)) return
586 await update($, logAtom, entries => appendEntry(entries ?? [], { type: 'edit', path, ...(agentId === undefined ? {} : { agentId }) }))
587 return
588 }
589 if (input.tool !== 'Bash') return
590 const command = String(input.command ?? '')
591 const result = typeof ran.result === 'object' && ran.result !== null ? (ran.result as RunResult) : {}
592 const isInterrupted = result.interrupted === true
593 const isOk = ran.isError !== true && !isInterrupted
594 let checks = classifyCommand(command)
595 if (!isOk && checks.length > 1) checks = []
596 const isMutating = agentId === undefined && (await mutates($, command, ran.isReadOnly === true))
597 if (checks.length === 0 && !isMutating) return
598 const short = shorten(command, COMMAND_LIMIT)
599 await update($, logAtom, entries => {
600 let all = entries ?? []
601 if (isMutating) all = appendEntry(all, { type: 'edit', path: short })
602 if (checks.length > 0) {
603 all = appendEntry(all, {
604 type: 'run',
605 checks,
606 command: short,
607 isOk,
608 isInterrupted,
609 ...(agentId === undefined ? {} : { agentId }),
610 })
611 }
612 return all
613 })
614}
615
616export function registerClaims(on: On): void {
617 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit', 'Bash'] }, async ($, e, next) => {
618 const ran = await next(e)
619 if (ran.deny !== undefined) return ran
620 try {
621 await record($, e as unknown as RecordInput, ran)
622 } catch (error) {
623 debug($, 'record', error)
624 }
625 return ran
626 }).catch(($, e, next) => next(e))
627
628 on('turn.start', async ($, e, next) => {
629 const started = await next(e)
630 if ((e as { agentId?: unknown }).agentId !== undefined) return started
631 try {
632 await update($, warningsAtom, current => ((current ?? []).length === 0 ? current : []))
633 } catch (error) {
634 debug($, 'clear', error)
635 }
636 return started
637 }).catch(($, e, next) => next(e))
638
639 on('turn.complete', { reason: 'answer' }, async ($, e, next) => {
640 const completed = await next(e)
641 if (e.agentId !== undefined) return completed
642 try {
643 const warnings = unverifiedClaims(detectClaims(e.answer), await read($, logAtom))
644 await update($, warningsAtom, () => warnings)
645 if (warnings.length > 0) $.ui.log(warnings.map(warningLine).join('\n'))
646 } catch (error) {
647 debug($, 'check', error)
648 }
649 return completed
650 }).catch(($, e, next) => next(e))
651
652 on('ui.render', { component: 'AbovePrompt', surface: ['terminal', 'desktop'] }, async ($, e, next) => {
653 const rendered = await next(e)
654 const warnings = await read($, warningsAtom)
655 if (e.props.hasSurvey || warnings.length === 0) return rendered
656 const { Box, Text } = $.ui.resolve(e)
657 const hidden = warnings.length - SHOWN_WARNINGS
658 const lines = warnings
659 .slice(0, SHOWN_WARNINGS)
660 .map(warning => Text({ color: 'warning', wrap: 'truncate-end', children: warningLine(warning) }))
661 if (hidden > 0) lines.push(Text({ dimColor: true, children: `+${hidden} more unverified` }))
662 return Box({
663 key: 'claims',
664 flexDirection: 'column',
665 children: [rendered, Box({ flexDirection: 'column', paddingX: 1, children: lines })],
666 })
667 }).catch(($, e, next) => next(e))
668}
669hooks/cli.ts 151 lines1import type { PluginOptions, ProcessRunInit, ProcessRunResult } from 'claude-code'
2
3import { normalizeConsult, normalizeModels, normalizeReview, normalizeTriage } from './plan'
4import type { ConsultTarget, ReviewResult, TriageResult } from './plan'
5import type { HarnessRoleTable } from '../types'
6
7export type CliResult<T> = { ok: true; value: T } | { ok: false; reason: string; timedOut?: boolean }
8
9export type RunPort = (
10 argv: readonly string[],
11 init: ProcessRunInit,
12) => Promise<ProcessRunResult>
13
14export type CliConfig = {
15 bin: string
16 cwd: string
17}
18
19const DEFAULT_BIN = 'herdr-jev'
20const QUICK_TIMEOUT_MS = 30000
21const REVIEW_PROCESS_TIMEOUT_MS = 600000
22const REVIEW_JUDGE_TIMEOUT_MS = 540000
23const TIMEOUT_MESSAGE = /time[ds]? ?out|still running/i
24
25export function cliConfig(options: PluginOptions, cwd: string): CliConfig {
26 const configured = options.herdrJevBin
27 const bin = typeof configured === 'string' && configured.trim().length > 0 ? configured.trim() : DEFAULT_BIN
28 return { bin, cwd }
29}
30
31export function maxWorkers(options: PluginOptions): number {
32 const configured = options.maxWorkers
33 return typeof configured === 'number' && Number.isFinite(configured) && configured >= 1
34 ? Math.floor(configured)
35 : 3
36}
37
38export function autoRun(options: PluginOptions): boolean {
39 return options.autoRun !== false
40}
41
42export function autoReview(options: PluginOptions): boolean {
43 return options.autoReview === true
44}
45
46function parseJson(stdout: string): CliResult<unknown> {
47 const body = stdout.trim()
48 if (body.length === 0) return { ok: false, reason: 'empty output' }
49
50 try {
51 return { ok: true, value: JSON.parse(body) as unknown }
52 } catch {
53 const start = body.indexOf('{')
54 const end = body.lastIndexOf('}')
55 if (start < 0 || end <= start) return { ok: false, reason: 'output is not JSON' }
56 try {
57 return { ok: true, value: JSON.parse(body.slice(start, end + 1)) as unknown }
58 } catch {
59 return { ok: false, reason: 'output is not valid JSON' }
60 }
61 }
62}
63
64function firstLine(value: string): string {
65 const line = value.split('\n').find(one => one.trim().length > 0)
66 return line === undefined ? '' : line.trim().slice(0, 160)
67}
68
69export async function runJson(
70 run: RunPort,
71 config: CliConfig,
72 argv: readonly string[],
73 timeoutMs: number,
74): Promise<CliResult<unknown>> {
75 const label = `${config.bin} ${argv[0] ?? ''}`.trim()
76 try {
77 const ran = await run([config.bin, ...argv], { cwd: config.cwd, timeoutMs })
78 const parsed = parseJson(ran.stdout)
79 if (parsed.ok) return parsed
80 if (ran.exitCode !== 0) {
81 const detail = firstLine(ran.stderr)
82 return {
83 ok: false,
84 reason: `${label} exited ${ran.exitCode}${detail.length > 0 ? `: ${detail}` : ''}`,
85 }
86 }
87 return { ok: false, reason: `${label}: ${parsed.reason}` }
88 } catch (error) {
89 const message = error instanceof Error ? error.message : String(error)
90 if (TIMEOUT_MESSAGE.test(message)) {
91 return { ok: false, reason: `${label} timed out after ${Math.round(timeoutMs / 1000)} s`, timedOut: true }
92 }
93 return { ok: false, reason: `${label} failed: ${message.slice(0, 160)}` }
94 }
95}
96
97export function safeTask(task: string): string {
98 const clean = task.replace(/\s+/g, ' ').trim()
99 return clean.startsWith('-') ? `Task: ${clean}` : clean
100}
101
102export async function runTriage(
103 run: RunPort,
104 config: CliConfig,
105 task: string,
106): Promise<CliResult<TriageResult>> {
107 const ran = await runJson(run, config, ['triage', safeTask(task), '--json'], QUICK_TIMEOUT_MS)
108 if (!ran.ok) return ran
109 const triage = normalizeTriage(ran.value)
110 return triage === null ? { ok: false, reason: 'triage output has no complexity' } : { ok: true, value: triage }
111}
112
113export async function runModels(
114 run: RunPort,
115 config: CliConfig,
116): Promise<CliResult<HarnessRoleTable>> {
117 const ran = await runJson(run, config, ['models', 'list', '--json', '--client', 'claude'], QUICK_TIMEOUT_MS)
118 if (!ran.ok) return ran
119 const table = normalizeModels(ran.value)
120 return table === null ? { ok: false, reason: 'models list output has no roles' } : { ok: true, value: table }
121}
122
123export function availableModels(sessionModel: string, roles: HarnessRoleTable | null): string[] {
124 const listed = roles === null ? [] : Object.values(roles).map(entry => entry?.cliModel)
125 return [...new Set([sessionModel, ...listed].filter((one): one is string => typeof one === 'string' && one.length > 0))]
126}
127
128export async function runConsultPlan(
129 run: RunPort,
130 config: CliConfig,
131 input: { model: string; complexity: string; availableModels: readonly string[] },
132): Promise<CliResult<ConsultTarget | null>> {
133 const argv = ['delegation-plan', '--client', 'claude', '--model', input.model, '--complexity', input.complexity]
134 if (input.availableModels.length > 0) argv.push('--available-models', input.availableModels.join(','))
135 const ran = await runJson(run, config, [...argv, '--json'], QUICK_TIMEOUT_MS)
136 if (!ran.ok) return ran
137 return { ok: true, value: normalizeConsult(ran.value) }
138}
139
140export async function runReview(run: RunPort, config: CliConfig): Promise<CliResult<ReviewResult>> {
141 const ran = await runJson(
142 run,
143 config,
144 ['review', '--json', '--timeout-ms', String(REVIEW_JUDGE_TIMEOUT_MS)],
145 REVIEW_PROCESS_TIMEOUT_MS,
146 )
147 if (!ran.ok) return ran
148 const review = normalizeReview(ran.value)
149 return review === null ? { ok: false, reason: 'review output is not an object' } : { ok: true, value: review }
150}
151hooks/external.ts 1186 lines1import type { HookBudget, TurnStepChunk, TurnStepResult, TurnUsage } from 'claude-code'
2
3export const HOOK_BUDGET_MS: HookBudget['ms'] = 10_000
4
5export const ENGINE = {
6 stepBudgetMs: 7_000,
7 budgetMarginMs: 2_500,
8 streamPollMs: 150,
9 progressTool: 'codex_progress',
10 handbackTool: 'SubagentHandback',
11 bouncePrefix: '[handback-send-enforce]',
12 reminderPrefix: '<system-reminder>',
13 leaseStaleSeconds: 60,
14} as const
15
16export const PROGRESS_TOOL = 'mcp__harness__codex_progress'
17export const AGENT_NAME = 'codex'
18export const AGENT_TYPE = 'harness:codex'
19
20const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9_.:/@+-]{0,63}$/
21const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
22const MODEL_PREFIX = 'codex-model:'
23const NOTE_LIMIT = 100
24const STAT_LIMIT = 2_000
25const STDERR_LIMIT = 1_500
26const GIT_TIMEOUT_MS = 120_000
27const GIT_ENV_UNSET = ['GIT_INDEX_FILE', 'GIT_DIR', 'GIT_WORK_TREE', 'GIT_PREFIX', 'GIT_COMMON_DIR', 'GIT_OBJECT_DIRECTORY'] as const
28const FILTER_KEY = /^filter\.(.+)\.(smudge|process|required)$/
29const FOLLOW_UP_TEXT = 'harness:codex follow-ups are not supported yet. Spawn a new harness:codex agent for the next task.'
30const DELIVERED_TEXT = 'Report delivered.'
31const BASE_IDENTITY = {
32 GIT_AUTHOR_NAME: 'codex-base',
33 GIT_AUTHOR_EMAIL: 'codex-base@localhost',
34 GIT_COMMITTER_NAME: 'codex-base',
35 GIT_COMMITTER_EMAIL: 'codex-base@localhost',
36 GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z',
37 GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z',
38} as const
39
40export const REFUSAL_PREFIX = 'harness:codex did not run:'
41
42export const AGENT_DESCRIPTION =
43 'Delegates one task to the Codex CLI in a throwaway copy of the session repository exported from HEAD (uncommitted work is absent from the copy). Use only when the user or the plan asks for Codex or a second opinion from another provider. Codex can change files in that copy only; the result is a patch file that is never applied. Reads are not restricted: Codex can read anything the user can. To choose the Codex model, make the first line of the prompt `codex-model: <id>`. Every genuine answer ends with a line `— answered by codex, <model>`. An answer WITHOUT that line did not come from Codex: the harness mod function hooks are not active; do not present it as Codex work.'
44
45export const AGENT_PROMPT = [
46 'If you are reading this, you are NOT Codex: the harness mod function hooks did not load, so this agent fell back to a Claude model.',
47 'Do not attempt the task and do not use any tools. Reply with exactly this, and nothing else:',
48 'The harness mod function hooks are not active, so Codex did not run this task. Enable function hooks (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1) and restart Claude Code.',
49].join('\n\n')
50
51export const RUNNER = String.raw`run=$1; work=$2; repo=$3; max=$4; lease=$5; filt=$6; shift 6
52trap 'true' TERM HUP INT
53cd "$work" || exit 1
54unset OLDPWD GIT_INDEX_FILE GIT_DIR GIT_WORK_TREE GIT_PREFIX GIT_COMMON_DIR GIT_OBJECT_DIRECTORY
55for n in $(env | awk -F= -v p="$repo" '{ i = index($0, "="); v = substr($0, i + 1); if (v == p || index(v, p "/") == 1) print substr($0, 1, i - 1) }'); do
56 case $n in
57 ''|*[!A-Za-z0-9_]*) ;;
58 *) unset "$n" ;;
59 esac
60done
61PWD=$work; export PWD
62TMPDIR=$run/tmp; export TMPDIR
63[ -e "$lease" ] || : > "$lease"
64exec 2>"$run/err"
65( timeout --foreground -k 10 "$max" "$@" <"$run/prompt" | node -e "$filt" >"$run/events" ) &
66pp=$!
67ticks=0
68limit=$((max + 10))
69while kill -s 0 "$pp" 2>/dev/null; do
70 ticks=$((ticks + 1))
71 if [ "$ticks" -gt "$limit" ] || [ -z "$(find "$lease" -newermt '60 seconds ago' 2>/dev/null)" ]; then
72 kill -s TERM -- "-$$"
73 sleep 5
74 kill -s KILL -- "-$$"
75 fi
76 sleep 1
77done
78kill -s KILL -- "-$$"
79`
80
81export const SCRIPTS = {
82 start: String.raw`umask 077
83nohup setsid sh "$@" >/dev/null 2>&1 </dev/null &
84p=$!
85st=$(sed 's/.*) //' "/proc/$p/stat" 2>/dev/null | cut -d' ' -f20)
86if [ -z "$st" ]; then
87 kill -s KILL -- "-$p" 2>/dev/null
88 exit 1
89fi
90echo "$p"
91echo "$st"`,
92 write: 'umask 077; mkdir -p "$1" && cat > "$2"',
93 mkdir: 'umask 077; mkdir -p "$1"',
94 tail: String.raw`touch -- "$4" 2>/dev/null
95s=$(ps -o stat= -p "$3" 2>/dev/null | tr -d ' ')
96alive=1
97case $s in
98 ''|Z*) ;;
99 *) alive=0 ;;
100esac
101tail -n +"$2" "$1" 2>/dev/null
102[ $alive = 0 ] || printf '\n{"type":"harness.exited"}\n'`,
103 kill: String.raw`case $1 in
104 ''|*[!0-9]*|0|1) echo gone; exit 0 ;;
105esac
106live() { ps -e -o pgid=,stat= 2>/dev/null | awk -v g="$1" '$1 == g && $2 !~ /^Z/ { f = 1 } END { exit !f }'; }
107live "$1" || { echo gone; exit 0; }
108if [ -e "/proc/$1" ]; then
109 pg=$(ps -o pgid= -p "$1" 2>/dev/null | tr -d ' ')
110 st=$(sed 's/.*) //' "/proc/$1/stat" 2>/dev/null | cut -d' ' -f20)
111 [ -n "$3" ] && [ "$pg" = "$1" ] && [ "$st" = "$3" ] || { echo gone; exit 0; }
112else
113 [ -n "$4" ] || { echo gone; exit 0; }
114 root=$(cd "$4" 2>/dev/null && pwd -P) || { echo gone; exit 0; }
115 own=1
116 for m in $(ps -e -o pid=,pgid= 2>/dev/null | awk -v g="$1" '$2 == g { print $1 }'); do
117 c=$(readlink "/proc/$m/cwd" 2>/dev/null)
118 case $c in
119 "$root"|"$root"/*) own=0 ;;
120 esac
121 done
122 [ "$own" = 0 ] || { echo gone; exit 0; }
123fi
124polls=$2
125[ -n "$polls" ] || polls=20
126kill -s TERM -- "-$1" 2>/dev/null
127i=0
128while [ "$i" -lt "$polls" ]; do
129 live "$1" || { echo gone; exit 0; }
130 sleep 0.25
131 i=$((i + 1))
132done
133kill -s KILL -- "-$1" 2>/dev/null
134i=0
135while [ "$i" -lt 20 ]; do
136 live "$1" || { echo gone; exit 0; }
137 sleep 0.25
138 i=$((i + 1))
139done
140echo alive`,
141 stderr: 'tail -c 4000 "$1" 2>/dev/null',
142 remove: 'rm -rf -- "$1"',
143 copy: 'rm -rf -- "$2" && cp -a -- "$1" "$2"',
144 rollout: 'base=${CODEX_HOME:-$HOME/.codex}; f=$(find "$base/sessions" -name "rollout-*-$1.jsonl" -print -quit 2>/dev/null); [ -n "$f" ] && grep -m1 turn_context "$f"',
145} as const
146
147export const FILTER = String.raw`
148const rl = require('readline').createInterface({ input: process.stdin })
149rl.on('line', (line) => {
150 let e
151 try { e = JSON.parse(line) } catch { return }
152 const it = e && e.item
153 if (it && typeof it.aggregated_output === 'string' && it.aggregated_output.length > 200) it.aggregated_output = it.aggregated_output.slice(0, 200)
154 if (it && Array.isArray(it.changes) && it.changes.length > 20) it.changes = it.changes.slice(0, 20)
155 const out = JSON.stringify(e)
156 process.stdout.write((out.length > 100000 ? JSON.stringify({ type: 'harness.dropped' }) : out) + '\n')
157})
158`
159
160export type CodexEvent =
161 | { k: 'thread'; id: string }
162 | { k: 'model'; model: string }
163 | { k: 'text'; t: string }
164 | { k: 'thinking'; t: string }
165 | { k: 'note'; t: string }
166 | { k: 'usage'; i: number; o: number; cr: number; cw: number }
167 | { k: 'error'; t: string }
168 | { k: 'failed' }
169 | { k: 'end' }
170 | { k: 'exited' }
171
172export type Piece = { kind: 'text' | 'thinking'; text: string }
173
174export type Usage = { i: number; o: number; cr: number; cw: number }
175
176export type Workspace = {
177 root: string
178 dir: string
179 work: string
180 tmp: string
181 pristine: string
182 repo: string
183 head: string
184 base: string
185 patch: string
186}
187
188export type Run = {
189 id: number
190 pid: string
191 start: string
192 events: string
193 errors: string
194 lease: string
195 read: number
196 steps: number
197 ended: boolean
198 failed: boolean
199 aborted: boolean
200 pending: string | null
201 report: string
202 problems: string[]
203 thread: string
204 model: string
205 requested: string | null
206 usage: Usage
207 ws: Workspace
208 note: string | null
209}
210
211export type AgentState = {
212 prompt: string
213 cwd: string | null
214 runs: number
215 deliveries: number
216 run: Run | null
217 delivered: string | null
218 lastReport: string
219 trusted?: string
220 used: boolean
221}
222
223export type ParsedPrompt = { prompt: string; model?: string; rejected?: string }
224
225export type StateEnv = {
226 stateDir?: string
227 pluginId?: string
228 pluginStateDir?: string
229 home?: string
230 testGuard?: string
231 aiHarnessTestGuard?: string
232 generatedDir?: string
233}
234
235export type RunInit = { cwd?: string; env?: Record<string, string>; stdin?: string; timeoutMs?: number }
236
237export type RunResult = { exitCode: number; stdout: string; stderr: string }
238
239export type Ports = {
240 run: (argv: readonly string[], init?: RunInit) => Promise<RunResult>
241 now: () => Promise<number>
242 sleep: (ms: number, signal: AbortSignal) => Promise<void>
243 cwd: () => Promise<string>
244 env: () => Promise<StateEnv>
245 readText: (path: string) => Promise<string>
246 exists: (path: string) => Promise<boolean>
247 userTexts: (agentId: string) => Promise<string[] | null>
248 agentType: (agentId: string) => Promise<string | undefined>
249 loadState: (agentId: string) => Promise<AgentState | null>
250 saveState: (agentId: string, state: AgentState | null) => Promise<void>
251 sessionAgents: () => Promise<string[]>
252 notify: (text: string) => void
253}
254
255export type External = {
256 progressTool: string
257 agentRegistered: boolean
258 maxMinutes: number
259 tail: Promise<void>
260 others: Set<string>
261 settled: Map<string, string>
262}
263
264export function createExternal(maxMinutes = 30): External {
265 return { progressTool: PROGRESS_TOOL, agentRegistered: false, maxMinutes, tail: Promise.resolve(), others: new Set(), settled: new Map() }
266}
267
268export function maxMinutesOf(value: unknown): number {
269 const n = typeof value === 'number' ? value : Number(value)
270 return Number.isFinite(n) && n >= 1 && n <= 720 ? Math.floor(n) : 30
271}
272
273async function exclusive<T>(ext: External, work: () => Promise<T>): Promise<T> {
274 const run = ext.tail.then(work)
275 ext.tail = run.then(
276 () => undefined,
277 () => undefined,
278 )
279 return run
280}
281
282export function parseModelLine(text: string): ParsedPrompt {
283 const lines = text.split('\n')
284 let at = 0
285 while (at < lines.length && (lines[at] ?? '').trim() === '') at += 1
286 const head = (lines[at] ?? '').trimStart()
287 if (head.slice(0, MODEL_PREFIX.length).toLowerCase() !== MODEL_PREFIX) return { prompt: text }
288 const value = head.slice(MODEL_PREFIX.length).trim()
289 const prompt = lines.slice(at + 1).join('\n').trim()
290 if (value.length > 64 || !MODEL_ID.test(value)) return { prompt, rejected: value.slice(0, 80) }
291 return { prompt, model: value }
292}
293
294export function isCodexType(subagentType: string): boolean {
295 return subagentType === AGENT_TYPE
296}
297
298export type SpawnFacts = {
299 subagentType: string
300 permissionMode?: string
301 parentAgentId?: string
302 isTeammate?: boolean
303 workflow?: unknown
304 fork?: boolean
305}
306
307export function spawnDenial(facts: SpawnFacts, enabled: boolean): string | null {
308 if (!isCodexType(facts.subagentType)) return null
309 if (!enabled) return 'harness:codex is disabled. Enable the codex option of the harness mod to use it.'
310 if (facts.permissionMode === 'plan') return 'harness:codex can change files and is refused in plan mode.'
311 if (facts.parentAgentId !== undefined) return 'harness:codex can only be started from the main session.'
312 if (facts.isTeammate === true) return 'harness:codex cannot run as a teammate.'
313 if (facts.workflow !== undefined) return 'harness:codex cannot be started by a workflow.'
314 if (facts.fork === true) return 'harness:codex cannot be forked.'
315 return null
316}
317
318export function agentSpec(progressTool: string) {
319 return {
320 name: AGENT_NAME,
321 description: AGENT_DESCRIPTION,
322 prompt: AGENT_PROMPT,
323 model: 'haiku',
324 tools: [progressTool, ENGINE.handbackTool],
325 }
326}
327
328export const PROGRESS_DESCRIPTION = 'Internal to harness:codex agents: marks a Codex run still in progress. Never call it.'
329
330function clip(text: string, limit: number): string {
331 return text.length > limit ? `${text.slice(0, limit)}…` : text
332}
333
334function asRecord(value: unknown): Record<string, unknown> | undefined {
335 return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined
336}
337
338function num(value: unknown): number {
339 return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0
340}
341
342function plainError(message: string): string {
343 try {
344 const body = asRecord(JSON.parse(message))
345 const detail = body?.detail ?? body?.message
346 if (typeof detail === 'string' && detail.trim() !== '') return detail.trim()
347 } catch {
348 return message.trim()
349 }
350 return message.trim()
351}
352
353function commandNote(command: string): string {
354 const bare = command
355 .replace(/^(?:\S*\/)?(?:ba|z|da)?sh\s+-l?c\s+/, '')
356 .replace(/^(['"])([\s\S]*)\1$/, '$2')
357 .replace(/\s+/g, ' ')
358 .trim()
359 return `▸ ${clip(bare, NOTE_LIMIT)}`
360}
361
362function changesNote(changes: unknown): string | undefined {
363 if (!Array.isArray(changes) || changes.length === 0) return undefined
364 const shown = changes.slice(0, 3).flatMap(change => {
365 const one = asRecord(change)
366 return typeof one?.path === 'string' ? [`${typeof one.kind === 'string' ? one.kind : 'change'} ${one.path}`] : []
367 })
368 if (shown.length === 0) return undefined
369 const more = changes.length > shown.length ? ` +${changes.length - shown.length} more` : ''
370 return `▸ ${clip(shown.join(', '), NOTE_LIMIT)}${more}`
371}
372
373export function mapCodexLine(line: string): CodexEvent[] {
374 let parsed: unknown
375 try {
376 parsed = JSON.parse(line)
377 } catch {
378 return []
379 }
380 const event = asRecord(parsed)
381 if (event === undefined || typeof event.type !== 'string') return []
382 const found: CodexEvent[] = []
383 if (typeof event.model === 'string' && event.model !== '') found.push({ k: 'model', model: event.model })
384
385 if (event.type === 'thread.started') {
386 if (typeof event.thread_id === 'string') found.push({ k: 'thread', id: event.thread_id })
387 return found
388 }
389 if (event.type === 'turn.completed') {
390 const usage = asRecord(event.usage)
391 const cached = num(usage?.cached_input_tokens)
392 found.push({
393 k: 'usage',
394 i: Math.max(0, num(usage?.input_tokens) - cached),
395 o: num(usage?.output_tokens),
396 cr: cached,
397 cw: num(usage?.cache_write_input_tokens),
398 })
399 found.push({ k: 'end' })
400 return found
401 }
402 if (event.type === 'turn.failed') {
403 const message = asRecord(event.error)?.message
404 if (typeof message === 'string') found.push({ k: 'error', t: plainError(message) })
405 found.push({ k: 'failed' })
406 found.push({ k: 'end' })
407 return found
408 }
409 if (event.type === 'error') {
410 if (typeof event.message === 'string') found.push({ k: 'error', t: plainError(event.message) })
411 return found
412 }
413 if (event.type === 'harness.exited') {
414 found.push({ k: 'exited' })
415 return found
416 }
417
418 const item = asRecord(event.item)
419 if (item === undefined || typeof item.type !== 'string') return found
420 if (event.type === 'item.completed' && item.type === 'agent_message' && typeof item.text === 'string') {
421 found.push({ k: 'text', t: item.text })
422 } else if (event.type === 'item.completed' && item.type === 'reasoning' && typeof item.text === 'string') {
423 found.push({ k: 'thinking', t: item.text })
424 } else if (event.type === 'item.started' && item.type === 'command_execution' && typeof item.command === 'string') {
425 found.push({ k: 'note', t: commandNote(item.command) })
426 } else if (event.type === 'item.started' && item.type === 'file_change') {
427 const note = changesNote(item.changes)
428 if (note !== undefined) found.push({ k: 'note', t: note })
429 }
430 return found
431}
432
433function withNewline(text: string): string {
434 return text.endsWith('\n') ? text : `${text}\n`
435}
436
437function flushPending(run: Run): Piece[] {
438 if (run.pending === null) return []
439 const text = withNewline(run.pending)
440 run.pending = null
441 return [{ kind: 'text', text }]
442}
443
444export function advance(run: Run, event: CodexEvent): Piece[] {
445 if (event.k === 'thread') {
446 run.thread = event.id
447 return []
448 }
449 if (event.k === 'model') {
450 run.model = event.model
451 return []
452 }
453 if (event.k === 'usage') {
454 run.usage.i += event.i
455 run.usage.o += event.o
456 run.usage.cr += event.cr
457 run.usage.cw += event.cw
458 return []
459 }
460 if (event.k === 'error') {
461 if (!run.problems.includes(event.t)) run.problems.push(event.t)
462 return []
463 }
464 if (event.k === 'failed') {
465 run.failed = true
466 return []
467 }
468 if (event.k === 'end' || event.k === 'exited') {
469 if (run.pending !== null) {
470 run.report = run.pending
471 run.pending = null
472 }
473 run.ended = true
474 return []
475 }
476 if (event.k === 'text') {
477 const flushed = flushPending(run)
478 run.pending = event.t
479 return flushed
480 }
481 if (event.k === 'thinking') {
482 return [...flushPending(run), { kind: 'thinking', text: withNewline(event.t) }]
483 }
484 return [...flushPending(run), { kind: 'text', text: withNewline(event.t) }]
485}
486
487export function takeUsage(run: Run): TurnUsage | null {
488 if (!run.model) return null
489 const { i, o, cr, cw } = run.usage
490 run.usage = { i: 0, o: 0, cr: 0, cw: 0 }
491 return {
492 model: run.model,
493 input_tokens: i,
494 output_tokens: o,
495 cache_read_input_tokens: cr,
496 cache_creation_input_tokens: cw,
497 }
498}
499
500export function stripAnsi(text: string): string {
501 return text.replace(/\u001b\[[0-9;]*m/g, '')
502}
503
504export function failureText(run: Run, stderr: string): string {
505 const detail = [...run.problems, stderr.trim()].filter(part => part !== '').join('\n')
506 return `Codex ended with no answer.\n${detail || '(no stderr; it may have reached the run time limit)'}`
507}
508
509export function signature(run: { model: string; requested: string | null }): string {
510 if (run.model) return `— answered by codex, ${run.model}`
511 if (run.requested) return `— answered by codex, requested ${run.requested}, unconfirmed`
512 return '— answered by codex, unknown model'
513}
514
515export function composeReport(parts: { answer: string; failure: string; notes: string; footer: string; signature: string }): string {
516 const body = [parts.failure || parts.answer, parts.notes, parts.footer].filter(part => part !== '').join('\n\n')
517 return parts.failure ? body : `${body}\n\n${parts.signature}`
518}
519
520export function budgetFor(remainingMs: number): number {
521 if (!Number.isFinite(remainingMs)) return ENGINE.stepBudgetMs
522 return Math.max(0, Math.min(ENGINE.stepBudgetMs, remainingMs - ENGINE.budgetMarginMs))
523}
524
525export function codexArgv(work: string, tmp: string, model: string | undefined): string[] {
526 return [
527 'codex',
528 'exec',
529 '--json',
530 '--skip-git-repo-check',
531 '--ignore-user-config',
532 '-C',
533 work,
534 '-s',
535 'workspace-write',
536 '-c',
537 'sandbox_workspace_write.exclude_slash_tmp=true',
538 '-c',
539 'sandbox_workspace_write.exclude_tmpdir_env_var=true',
540 '-c',
541 'sandbox_workspace_write.network_access=false',
542 '-c',
543 'sandbox_workspace_write.writable_roots=[]',
544 '--add-dir',
545 tmp,
546 ...(model === undefined ? [] : ['-m', model]),
547 '-',
548 ]
549}
550
551function shortId(agentId: string): string {
552 return agentId.replace(/[^A-Za-z0-9]/g, '').slice(0, 8) || 'agent'
553}
554
555function isAbsolutePath(value: string): boolean {
556 return value.startsWith('/')
557}
558
559function normalizePath(path: string): string {
560 const parts: string[] = []
561 for (const part of path.split('/')) {
562 if (part === '' || part === '.') continue
563 if (part === '..') parts.pop()
564 else parts.push(part)
565 }
566 return `/${parts.join('/')}`
567}
568
569function expandHome(value: string, home: string): string {
570 if (value === '~') return home
571 return value.startsWith('~/') ? `${home}/${value.slice(2)}` : value
572}
573
574function absoluteDir(value: string | undefined, home: string, cwd: string): string | undefined {
575 const trimmed = value?.trim()
576 if (!trimmed) return undefined
577 const expanded = expandHome(trimmed, home)
578 return normalizePath(isAbsolutePath(expanded) ? expanded : `${cwd}/${expanded}`)
579}
580
581export async function stateDirOf(ports: Pick<Ports, 'env' | 'readText' | 'cwd'>): Promise<string> {
582 const env = await ports.env()
583 const home = env.home?.trim() || '/root'
584 const cwd = await ports.cwd()
585 const explicit = absoluteDir(env.stateDir, home, cwd)
586 if (explicit !== undefined) return explicit
587 const isForeignPlugin = Boolean(env.pluginId && env.pluginId !== 'herdr-jev')
588 const plugin = isForeignPlugin ? undefined : absoluteDir(env.pluginStateDir, home, cwd)
589 if (plugin !== undefined) return plugin
590 if (env.testGuard === '1' || env.aiHarnessTestGuard === '1') throw new Error('state_dir_required_in_tests')
591 const legacy = `${env.home?.trim() || home}/.local/state/herdr-jev`
592 const generated = env.generatedDir?.trim() || `${home}/.local/share/ai-harness/generated`
593 try {
594 const raw = await ports.readText(`${generated}/tool-env.json`)
595 const entry = asRecord(asRecord(asRecord(JSON.parse(raw))?.tools)?.['herdr-jev'])
596 const value = typeof entry?.stateDir === 'string' ? expandHome(entry.stateDir.trim(), home) : ''
597 if (value && isAbsolutePath(value) && value !== legacy) return value
598 } catch {
599 return legacy
600 }
601 return legacy
602}
603
604export function gitArgv(args: readonly string[], options: { env?: Record<string, string>; isolated?: boolean } = {}): string[] {
605 const unset = GIT_ENV_UNSET.flatMap(name => ['-u', name])
606 const isolation = options.isolated ? { GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' } : {}
607 const set = Object.entries({ ...isolation, ...(options.env ?? {}) }).map(([key, value]) => `${key}=${value}`)
608 return ['sh', '-c', 'umask 077; exec "$@"', 'sh', 'env', ...unset, ...set, 'git', '-c', 'core.hooksPath=/dev/null', ...args]
609}
610
611type GitResult = { ok: boolean; out: string; err: string; code: number }
612
613async function git(
614 ports: Ports,
615 args: readonly string[],
616 options: { env?: Record<string, string>; isolated?: boolean } = {},
617): Promise<GitResult> {
618 try {
619 const result = await ports.run(gitArgv(args, options), { timeoutMs: GIT_TIMEOUT_MS })
620 return { ok: result.exitCode === 0, out: result.stdout.trim(), err: result.stderr.trim(), code: result.exitCode }
621 } catch (error) {
622 return { ok: false, out: '', err: error instanceof Error ? error.message : String(error), code: -1 }
623 }
624}
625
626export function filterDrivers(names: string): string[] {
627 const found = new Set<string>()
628 for (const line of names.split('\n')) {
629 const driver = FILTER_KEY.exec(line.trim())?.[1]
630 if (driver) found.add(driver)
631 }
632 return [...found]
633}
634
635export function filterOverrides(drivers: readonly string[]): string[] {
636 return drivers.flatMap(name => [
637 '-c',
638 `filter.${name}.smudge=`,
639 '-c',
640 `filter.${name}.process=`,
641 '-c',
642 `filter.${name}.required=false`,
643 ])
644}
645
646function quote(value: string): string {
647 return `'${value.replace(/'/g, `'\\''`)}'`
648}
649
650async function runSh(ports: Ports, script: string, args: readonly string[], init: RunInit = {}): Promise<RunResult> {
651 return ports.run(['sh', '-c', script, 'sh', ...args], init)
652}
653
654function insideRuns(ws: Pick<Workspace, 'root' | 'dir'>): boolean {
655 return ws.dir.startsWith(`${ws.root}/codex-runs/`) && !ws.dir.split('/').includes('..') && ws.dir.length > ws.root.length + 12
656}
657
658async function removeDir(ports: Ports, ws: Pick<Workspace, 'root' | 'dir'>): Promise<void> {
659 if (!insideRuns(ws)) return
660 await runSh(ports, SCRIPTS.remove, [ws.dir]).catch(() => undefined)
661}
662
663export type Exported = { ok: true; ws: Workspace } | { ok: false; reason: string }
664
665export async function exportTree(ports: Ports, cwd: string, agentId: string, nonce: string): Promise<Exported> {
666 const top = await git(ports, ['-C', cwd, 'rev-parse', '--show-toplevel'])
667 if (!top.ok || top.out === '') {
668 return { ok: false, reason: `${cwd} is not inside a git repository. Codex needs one to export a copy from.` }
669 }
670 const head = await git(ports, ['-C', top.out, 'rev-parse', '--verify', 'HEAD'])
671 if (!head.ok || head.out === '') {
672 return { ok: false, reason: `${top.out} has no commit to export (git rev-parse HEAD failed).` }
673 }
674 let root: string
675 try {
676 root = await stateDirOf(ports)
677 } catch (error) {
678 return { ok: false, reason: error instanceof Error ? error.message : String(error) }
679 }
680 let dir = `${root}/codex-runs/${shortId(agentId)}-${nonce}`
681 try {
682 if (await ports.exists(dir)) dir = `${dir}-2`
683 } catch {
684 dir = `${dir}-2`
685 }
686 const ws: Workspace = {
687 root,
688 dir,
689 work: `${dir}/work`,
690 tmp: `${dir}/tmp`,
691 pristine: `${dir}/pristine.git`,
692 repo: top.out,
693 head: head.out,
694 base: '',
695 patch: `${root}/codex-patches/${shortId(agentId)}-${nonce}.patch`,
696 }
697 const fail = async (reason: string): Promise<Exported> => {
698 await removeDir(ports, ws)
699 return { ok: false, reason }
700 }
701 try {
702 const made = await runSh(ports, SCRIPTS.mkdir, [ws.work])
703 const tmp = await runSh(ports, SCRIPTS.mkdir, [ws.tmp])
704 if (made.exitCode !== 0 || tmp.exitCode !== 0) return await fail(`could not create ${ws.dir}`)
705 const names = await git(ports, ['-C', top.out, 'config', '--name-only', '--get-regexp', '^filter\\..+\\.(smudge|process|required)$'])
706 const drivers = filterDrivers(names.out)
707 const index = { GIT_INDEX_FILE: `${ws.dir}/export.index` }
708 const read = await git(ports, ['-C', top.out, 'read-tree', head.out], { env: index })
709 if (!read.ok) return await fail(`git read-tree failed: ${clip(read.err || 'no output', 300)}`)
710 const checkout = await git(
711 ports,
712 ['-C', top.out, '-c', 'core.autocrlf=false', ...filterOverrides(drivers), 'checkout-index', '-a', '-f', `--prefix=${ws.work}/`],
713 { env: index },
714 )
715 if (!checkout.ok) return await fail(`git checkout-index failed: ${clip(checkout.err || 'no output', 300)}`)
716 const init = await git(ports, ['init', '-q', '--template=', ws.work], { isolated: true })
717 if (!init.ok) return await fail(`git init failed: ${clip(init.err || 'no output', 300)}`)
718 const add = await git(ports, ['-C', ws.work, 'add', '-A', '-f'], { isolated: true })
719 if (!add.ok) return await fail(`git add failed: ${clip(add.err || 'no output', 300)}`)
720 const commit = await git(
721 ports,
722 ['-C', ws.work, '-c', 'commit.gpgsign=false', 'commit', '-q', '--allow-empty', '--no-verify', '-m', 'codex-base'],
723 { isolated: true, env: BASE_IDENTITY },
724 )
725 if (!commit.ok) return await fail(`git commit failed: ${clip(commit.err || 'no output', 300)}`)
726 const base = await git(ports, ['-C', ws.work, 'rev-parse', '--verify', 'HEAD'], { isolated: true })
727 if (!base.ok || base.out === '') return await fail('could not read the exported commit')
728 ws.base = base.out
729 const saved = await runSh(ports, SCRIPTS.copy, [`${ws.work}/.git`, ws.pristine])
730 if (saved.exitCode !== 0) return await fail('could not keep a pristine copy of the exported git directory')
731 await runSh(ports, SCRIPTS.remove, [index.GIT_INDEX_FILE]).catch(() => undefined)
732 return { ok: true, ws }
733 } catch (error) {
734 return await fail(error instanceof Error ? error.message : String(error))
735 }
736}
737
738async function killGroup(ports: Ports, run: Pick<Run, 'pid' | 'start' | 'ws'>, polls = 20): Promise<boolean> {
739 try {
740 const result = await runSh(ports, SCRIPTS.kill, [run.pid, String(polls), run.start ?? '', run.ws.work], { timeoutMs: 30_000 })
741 return result.stdout.trim().split('\n').at(-1) === 'gone'
742 } catch {
743 return false
744 }
745}
746
747export async function settleWorkspace(ports: Ports, ext: External, ws: Workspace): Promise<string> {
748 return exclusive(ext, async () => {
749 const kept = (why: string) =>
750 `Could not build the patch (${clip(why, 200)}). The throwaway copy was kept at ${ws.dir}; nothing was applied to ${ws.repo}.`
751 const already = ext.settled.get(ws.dir)
752 if (already !== undefined) return already
753 if (!insideRuns(ws)) return kept('the run directory is outside the state directory')
754 const restored = await runSh(ports, SCRIPTS.copy, [ws.pristine, `${ws.work}/.git`])
755 if (restored.exitCode !== 0) return kept('could not restore the pristine git directory')
756 const add = await git(ports, ['-C', ws.work, 'add', '-A', '-f'], { isolated: true })
757 if (!add.ok) return kept(add.err || 'git add failed')
758 const stat = await git(ports, ['-C', ws.work, 'diff', '--cached', '--stat', '--no-ext-diff', ws.base], { isolated: true })
759 if (!stat.ok) return kept(stat.err || 'git diff failed')
760 if (stat.out === '') {
761 await removeDir(ports, ws)
762 const none = 'Codex made no changes; the throwaway copy was removed.'
763 ext.settled.set(ws.dir, none)
764 return none
765 }
766 const made = await runSh(ports, SCRIPTS.mkdir, [ws.patch.slice(0, ws.patch.lastIndexOf('/'))])
767 if (made.exitCode !== 0) return kept('could not create the patch directory')
768 const patch = await git(
769 ports,
770 ['-C', ws.work, 'diff', '--cached', '--binary', '--no-ext-diff', '--no-textconv', `--output=${ws.patch}`, ws.base],
771 { isolated: true },
772 )
773 if (!patch.ok) return kept(patch.err || 'git diff --binary failed')
774 await removeDir(ports, ws)
775 const report = [
776 `Patch (mode 0600): ${ws.patch}`,
777 `Check it first with: git -C ${quote(ws.repo)} apply --stat --check ${quote(ws.patch)}`,
778 `Then apply it with: git -C ${quote(ws.repo)} apply ${quote(ws.patch)}`,
779 `The patch was computed against HEAD ${ws.head.slice(0, 7)}; uncommitted work was not in the copy and is excluded. It may add files and symlinks, so read the --stat output before applying. Nothing was applied to ${ws.repo}.`,
780 `git diff --stat:\n${clip(stat.out, STAT_LIMIT)}`,
781 ].join('\n')
782 ext.settled.set(ws.dir, report)
783 return report
784 })
785}
786
787export async function stopAndSettle(ports: Ports, ext: External, run: Run, polls = 20): Promise<string> {
788 const stopped = await killGroup(ports, run, polls)
789 if (!stopped) {
790 return `Codex could not be stopped (process group ${run.pid}). Its copy was left untouched at ${run.ws.dir}. Stop it with: kill -s KILL -- -${run.pid}`
791 }
792 try {
793 return await settleWorkspace(ports, ext, run.ws)
794 } catch (error) {
795 return `Settling the copy failed (${clip(error instanceof Error ? error.message : String(error), 200)}). It was left at ${run.ws.dir}.`
796 }
797}
798
799class Stream {
800 index = -1;
801 kind: 'text' | 'thinking' | 'tool' | undefined;
802 shown = '';
803
804 *write(kind: 'text' | 'thinking', text: string): Generator<TurnStepChunk> {
805 if (this.kind !== kind) {
806 this.kind = kind
807 this.index += 1
808 }
809 if (kind === 'text') this.shown += text
810 yield { kind, index: this.index, text }
811 }
812
813 *block(text: string): Generator<TurnStepChunk> {
814 this.kind = undefined
815 yield* this.write('text', text)
816 }
817
818 *tool(id: string, name: string, input: unknown): Generator<TurnStepChunk> {
819 this.index += 1
820 this.kind = 'tool'
821 yield { kind: 'tool', index: this.index, id, name }
822 yield { kind: 'input', index: this.index, json: JSON.stringify(input) }
823 }
824
825 result(e: StepInput, toolUses: TurnStepResult['toolUses'], stopReason: 'end_turn' | 'tool_use', usage: TurnUsage | null): TurnStepResult {
826 return { turnId: e.turnId, index: e.index, answer: this.shown, toolUses, stopReason, usage }
827 }
828}
829
830export type StepInput = { turnId: string; index: number; agentId?: string }
831
832export function handbackFacts(userTexts: readonly string[]): { handback: boolean; bounced: boolean } {
833 const later = userTexts.slice(1)
834 const engine = later.filter(text => {
835 const head = text.trimStart()
836 return head.startsWith(ENGINE.reminderPrefix) || head.startsWith(ENGINE.bouncePrefix)
837 })
838 return {
839 handback: engine.some(text => text.includes(ENGINE.handbackTool)),
840 bounced: later.at(-1)?.trimStart().startsWith(ENGINE.bouncePrefix) ?? false,
841 }
842}
843
844async function inspectHandback(ports: Ports, agentId: string): Promise<{ handback: boolean; bounced: boolean }> {
845 try {
846 const users = await ports.userTexts(agentId)
847 return users === null ? { handback: false, bounced: false } : handbackFacts(users)
848 } catch {
849 return { handback: false, bounced: false }
850 }
851}
852
853async function readNew(ports: Ports, run: Run): Promise<string[]> {
854 const { stdout } = await runSh(ports, SCRIPTS.tail, [run.events, String(run.read + 1), run.pid, run.lease])
855 const lines = stdout.split('\n')
856 lines.pop()
857 return lines
858}
859
860async function stderrTail(ports: Ports, run: Run): Promise<string> {
861 try {
862 const { stdout } = await runSh(ports, SCRIPTS.stderr, [run.errors])
863 return clip(stripAnsi(stdout).trim().split('\n').slice(-12).join('\n'), STDERR_LIMIT)
864 } catch {
865 return ''
866 }
867}
868
869async function rolloutModel(ports: Ports, run: Run): Promise<void> {
870 if (run.model || !UUID.test(run.thread)) return
871 try {
872 const { stdout } = await runSh(ports, SCRIPTS.rollout, [run.thread])
873 const context = asRecord(asRecord(JSON.parse(stdout.trim().split('\n')[0] ?? ''))?.payload)
874 if (typeof context?.model === 'string' && MODEL_ID.test(context.model)) run.model = context.model
875 } catch {
876 return
877 }
878}
879
880type Launched = { ok: true; run: Run } | { ok: false; reason: string }
881
882async function launch(ports: Ports, ext: External, agentId: string, state: AgentState, parsed: ParsedPrompt): Promise<Launched> {
883 if (parsed.rejected !== undefined) {
884 return {
885 ok: false,
886 reason: `the codex-model line "${clip(parsed.rejected, 60)}" is not an accepted model id (letters, digits and _ . : / @ + -, up to 64 characters, not starting with a symbol).`,
887 }
888 }
889 if (parsed.prompt.trim() === '') return { ok: false, reason: 'the prompt is empty.' }
890 if (ext.progressTool === '') return { ok: false, reason: 'the progress tool is not registered, so a run could not span several steps.' }
891 const cwd = state.cwd ?? (await ports.cwd())
892 const id = state.runs + 1
893 const nonce = (await ports.now()).toString(36)
894 const exported = await exclusive(ext, () => exportTree(ports, cwd, agentId, nonce))
895 if (!exported.ok) return { ok: false, reason: exported.reason }
896 const ws = exported.ws
897 const run: Run = {
898 id,
899 pid: '',
900 start: '',
901 events: `${ws.dir}/events`,
902 errors: `${ws.dir}/err`,
903 lease: `${ws.dir}/lease`,
904 read: 0,
905 steps: 0,
906 ended: false,
907 failed: false,
908 aborted: false,
909 pending: null,
910 report: '',
911 problems: [],
912 thread: '',
913 model: '',
914 requested: parsed.model ?? null,
915 usage: { i: 0, o: 0, cr: 0, cw: 0 },
916 ws,
917 note: null,
918 }
919 const abandon = async (reason: string): Promise<Launched> => {
920 await exclusive(ext, () => removeDir(ports, ws))
921 return { ok: false, reason }
922 }
923 try {
924 const wrote = await runSh(ports, SCRIPTS.write, [ws.dir, `${ws.dir}/prompt`], { stdin: parsed.prompt })
925 const script = await runSh(ports, SCRIPTS.write, [ws.dir, `${ws.dir}/runner.sh`], { stdin: RUNNER })
926 const lease = await runSh(ports, SCRIPTS.write, [ws.dir, run.lease], { stdin: '' })
927 if (wrote.exitCode !== 0 || script.exitCode !== 0 || lease.exitCode !== 0) return await abandon('could not write the run files')
928 const started = await runSh(ports, SCRIPTS.start, [
929 `${ws.dir}/runner.sh`,
930 ws.dir,
931 ws.work,
932 ws.repo,
933 String(ext.maxMinutes * 60),
934 run.lease,
935 FILTER,
936 ...codexArgv(ws.work, ws.tmp, parsed.model),
937 ])
938 const [pid = '', start = ''] = started.stdout.trim().split('\n')
939 run.pid = pid.trim()
940 run.start = start.trim()
941 } catch (error) {
942 return await abandon(`Codex could not be started: ${clip(error instanceof Error ? error.message : String(error), 200)}`)
943 }
944 if (!/^\d+$/.test(run.pid) || !/^\d+$/.test(run.start)) return await abandon('Codex could not be started: no process id came back.')
945 return { ok: true, run }
946}
947
948async function* pump(
949 ports: Ports,
950 ext: External,
951 run: Run,
952 stream: Stream,
953 signal: AbortSignal,
954 remaining: () => number,
955): AsyncGenerator<TurnStepChunk, void> {
956 let finished = false
957 let crashed = ''
958 try {
959 const deadline = (await ports.now()) + budgetFor(remaining())
960 while (
961 !run.ended &&
962 !signal.aborted &&
963 (await ports.now()) < deadline &&
964 remaining() > ENGINE.budgetMarginMs
965 ) {
966 for (const line of await readNew(ports, run)) {
967 run.read += 1
968 for (const event of mapCodexLine(line)) {
969 for (const piece of advance(run, event)) yield* stream.write(piece.kind, piece.text)
970 }
971 }
972 if (!run.ended) await ports.sleep(ENGINE.streamPollMs, signal)
973 }
974 finished = true
975 } catch (error) {
976 if (!signal.aborted) crashed = `the Codex stream failed: ${clip(error instanceof Error ? error.message : String(error), 200)}`
977 finished = !signal.aborted
978 } finally {
979 if (!run.ended && (!finished || signal.aborted)) {
980 run.aborted = true
981 run.ended = true
982 run.note = await stopAndSettle(ports, ext, run, 8).catch(() => 'Codex was stopped.')
983 ports.notify(`harness:codex stopped. ${run.note}`)
984 }
985 }
986 if (crashed !== '') {
987 run.problems.push(crashed)
988 run.failed = true
989 run.ended = true
990 }
991}
992
993async function* conclude(
994 ports: Ports,
995 e: StepInput,
996 agentId: string,
997 state: AgentState,
998 stream: Stream,
999 report: string,
1000 usage: TurnUsage | null,
1001 forced: boolean,
1002): AsyncGenerator<TurnStepChunk, TurnStepResult> {
1003 state.lastReport = report
1004 yield* stream.block(report)
1005 const handback = forced || (await inspectHandback(ports, agentId)).handback
1006 if (!handback) {
1007 yield { kind: 'stop', stopReason: 'end_turn', usage }
1008 return stream.result(e, [], 'end_turn', usage)
1009 }
1010 state.delivered = DELIVERED_TEXT
1011 state.deliveries += 1
1012 const id = `toolu_codex_${agentId.replace(/[^A-Za-z0-9_]/g, '_')}_${ENGINE.handbackTool}_${state.deliveries}`
1013 const input = { message: report }
1014 yield* stream.tool(id, ENGINE.handbackTool, input)
1015 yield { kind: 'stop', stopReason: 'tool_use', usage }
1016 return stream.result(e, [{ name: ENGINE.handbackTool, input }], 'tool_use', usage)
1017}
1018
1019async function* refuse(e: StepInput, stream: Stream, state: AgentState | null, reason: string): AsyncGenerator<TurnStepChunk, TurnStepResult> {
1020 const text = `${REFUSAL_PREFIX} ${reason}`
1021 if (state !== null) state.lastReport = text
1022 yield* stream.block(text)
1023 yield { kind: 'stop', stopReason: 'end_turn', usage: null }
1024 return stream.result(e, [], 'end_turn', null)
1025}
1026
1027async function* drive(
1028 ports: Ports,
1029 ext: External,
1030 e: StepInput,
1031 agentId: string,
1032 state: AgentState,
1033 stream: Stream,
1034 signal: AbortSignal,
1035 remaining: () => number,
1036): AsyncGenerator<TurnStepChunk, TurnStepResult> {
1037 if (state.delivered !== null) {
1038 const text = state.delivered
1039 state.delivered = null
1040 yield* stream.block(text)
1041 yield { kind: 'stop', stopReason: 'end_turn', usage: null }
1042 return stream.result(e, [], 'end_turn', null)
1043 }
1044
1045 if (state.run === null) {
1046 if (state.used) {
1047 const { bounced } = await inspectHandback(ports, agentId)
1048 if (bounced && state.lastReport !== '') return yield* conclude(ports, e, agentId, state, stream, state.lastReport, null, true)
1049 return yield* refuse(e, stream, state, FOLLOW_UP_TEXT)
1050 }
1051 state.used = true
1052 const launched = await launch(ports, ext, agentId, state, parseModelLine(state.prompt))
1053 if (!launched.ok) return yield* refuse(e, stream, state, launched.reason)
1054 state.runs += 1
1055 state.run = launched.run
1056 await ports.saveState(agentId, state)
1057 }
1058
1059 const run = state.run
1060 yield* pump(ports, ext, run, stream, signal, remaining)
1061
1062 if (run.aborted) {
1063 state.run = null
1064 state.lastReport = run.note ?? ''
1065 state.trusted = run.note ?? ''
1066 if (run.note) yield* stream.block(run.note)
1067 yield { kind: 'stop', stopReason: 'end_turn', usage: null }
1068 return stream.result(e, [], 'end_turn', null)
1069 }
1070
1071 if (!run.ended) {
1072 const input = {}
1073 const id = `toolu_codex_${agentId.replace(/[^A-Za-z0-9_]/g, '_')}_${run.id}_${run.steps}`
1074 run.steps += 1
1075 yield* stream.tool(id, ext.progressTool, input)
1076 const usage = takeUsage(run)
1077 yield { kind: 'stop', stopReason: 'tool_use', usage }
1078 return stream.result(e, [{ name: ext.progressTool, input }], 'tool_use', usage)
1079 }
1080
1081 const answer = run.report.trim()
1082 const stderr = await stderrTail(ports, run)
1083 await rolloutModel(ports, run)
1084 const footer = await stopAndSettle(ports, ext, run)
1085 state.run = null
1086 const failure = answer === '' ? failureText(run, stderr) : ''
1087 const notes = answer !== '' && run.failed && run.problems.length > 0 ? `Codex reported an error after this answer: ${run.problems.join(' ')}` : ''
1088 const report = composeReport({ answer, failure, notes, footer, signature: signature(run) })
1089 state.trusted = failure ? footer : `${footer}\n\n${signature(run)}`
1090 return yield* conclude(ports, e, agentId, state, stream, report, takeUsage(run), false)
1091}
1092
1093export async function externalRole(ports: Ports, ext: External, agentId: string): Promise<'codex' | 'other'> {
1094 if (ext.others.has(agentId)) return 'other'
1095 try {
1096 if ((await ports.loadState(agentId)) !== null) return 'codex'
1097 const type = await ports.agentType(agentId)
1098 if (type === AGENT_TYPE) return 'codex'
1099 } catch {
1100 return 'other'
1101 }
1102 ext.others.add(agentId)
1103 return 'other'
1104}
1105
1106export async function* externalStep(
1107 ports: Ports,
1108 ext: External,
1109 e: StepInput,
1110 agentId: string,
1111 signal: AbortSignal,
1112 remaining: () => number,
1113): AsyncGenerator<TurnStepChunk, TurnStepResult> {
1114 const stream = new Stream()
1115 let state: AgentState | null = null
1116 try {
1117 state = await ports.loadState(agentId)
1118 if (state === null) return yield* refuse(e, stream, null, 'no run state exists for this agent, so it cannot continue.')
1119 return yield* drive(ports, ext, e, agentId, state, stream, signal, remaining)
1120 } catch (error) {
1121 if (state?.run) {
1122 const run = state.run
1123 state.run = null
1124 await stopAndSettle(ports, ext, run, 8).catch(() => undefined)
1125 }
1126 return yield* refuse(e, stream, state, `the mod failed (${clip(error instanceof Error ? error.message : String(error), 300)}).`)
1127 } finally {
1128 if (state?.run?.aborted) {
1129 state.lastReport = state.run.note ?? ''
1130 state.trusted = state.run.note ?? ''
1131 state.run = null
1132 }
1133 if (state !== null) await ports.saveState(agentId, state).catch(() => undefined)
1134 }
1135}
1136
1137export async function* externalRefusal(e: StepInput): AsyncGenerator<TurnStepChunk, TurnStepResult> {
1138 return yield* refuse(e, new Stream(), null, 'its hook failed, so no Claude model was allowed to answer in its place.')
1139}
1140
1141export async function noteSpawn(
1142 ports: Ports,
1143 subagentType: string,
1144 prompt: string,
1145 cwd: string | undefined,
1146 agentId: string | undefined,
1147): Promise<void> {
1148 if (!isCodexType(subagentType) || !agentId) return
1149 await ports.saveState(agentId, {
1150 prompt,
1151 cwd: cwd ?? null,
1152 runs: 0,
1153 deliveries: 0,
1154 run: null,
1155 delivered: null,
1156 lastReport: '',
1157 used: false,
1158 })
1159}
1160
1161export async function progressCall(ports: Ports, agentId: string | undefined): Promise<{ deny: string } | { result: string }> {
1162 const state = agentId === undefined ? null : await ports.loadState(agentId).catch(() => null)
1163 if (state?.run == null) return { deny: `${ENGINE.progressTool} is internal to harness:codex agents.` }
1164 return { result: 'Codex is still working.' }
1165}
1166
1167export async function cleanupAgent(ports: Ports, ext: External, agentId: string, polls = 20): Promise<void> {
1168 const state = await ports.loadState(agentId)
1169 if (state === null) return
1170 if (state.run !== null) {
1171 const text = await stopAndSettle(ports, ext, state.run, polls)
1172 state.run = null
1173 state.lastReport = text
1174 state.trusted = text
1175 ports.notify(`harness:codex stopped. ${text}`)
1176 }
1177 await ports.saveState(agentId, null)
1178}
1179
1180export async function endAll(ports: Ports, ext: External): Promise<void> {
1181 const ids = await ports.sessionAgents()
1182 for (const id of ids) {
1183 await cleanupAgent(ports, ext, id, 4).catch(() => undefined)
1184 }
1185}
1186hooks/plan.ts 750 lines1import type {
2 HarnessConsult,
3 HarnessHumanAsk,
4 HarnessModelRole,
5 HarnessPlan,
6 HarnessRole,
7 HarnessRoleModel,
8 HarnessRoleTable,
9 HarnessTask,
10 HarnessTaskState,
11 HarnessVerdict,
12 HarnessWorkerRow,
13} from '../types'
14
15export type TriageResult = {
16 complexity: string
17 confidence: number | null
18 needsResearch: boolean
19 effort: string | null
20}
21
22export type ConsultTarget = {
23 model: string
24 cliModel: string
25 effort: string | null
26}
27
28export const CONSULT_REPORT_LIMIT = 4000
29
30const CONSULT_STATES: readonly HarnessConsult['state'][] = ['running', 'done', 'failed']
31
32export type ReviewResult = {
33 status: string | null
34 detail: string | null
35 error: string | null
36 identity: { client: string; session: string; cwd: string } | null
37}
38
39export type PlanTaskInput = {
40 title: string
41 deps?: string[]
42 paths?: string[]
43 checks?: string[]
44 role?: string
45}
46
47export type RoleHints = {
48 title: string
49 hint?: string
50}
51
52export type RoleDecision = {
53 role: 'advisor' | 'implementer' | 'reader'
54 writes: boolean
55 review: boolean
56 why: string
57}
58
59export type AgentKind = 'implementer' | 'reviewer' | 'reader' | 'mechanic'
60
61export const MODEL_ROLES: readonly HarnessModelRole[] = [
62 'advisor',
63 'implementer',
64 'reviewer',
65 'researcher',
66 'reader',
67]
68
69export const TASK_ROLES: readonly HarnessRole[] = ['advisor', 'implementer', 'reviewer', 'reader']
70
71export const TASK_STATES: readonly HarnessTaskState[] = [
72 'proposed',
73 'advisor',
74 'running',
75 'review',
76 'approved',
77 'verified',
78 'failed',
79 'needs_you',
80 'done',
81]
82
83const WRITE_WORDS = /\b(scaffold\w*|fixtures?|lint\w*|convert\w*|conversions?|renam\w*)\b/i
84
85const READ_WORDS = /\b(read|reading|collect\w*|inventor\w*|research\w*)\b/i
86
87const READ_HINT_WORDS =
88 /\b(read|reading|collect\w*|inventor\w*|research\w*|list|listing|inspect\w*|audit\w*|map|mapping|summari[sz]\w*)\b/i
89
90const WRITE_VERBS = new Set([
91 'fix', 'add', 'implement', 'update', 'change', 'refactor', 'wire', 'remove', 'delete', 'rewrite',
92 'patch', 'replace', 'build', 'migrate', 'write', 'edit', 'modify', 'create', 'make', 'move',
93 'extract', 'introduce', 'enable', 'disable', 'set', 'bump', 'improve', 'handle', 'support', 'drop',
94 'restructure', 'optimize', 'optimise', 'correct', 'adjust', 'tweak', 'apply', 'upgrade', 'install',
95 'configure', 'resolve', 'repair',
96])
97
98function startsWithWriteVerb(title: string): boolean {
99 const first = title.trim().toLowerCase().match(/^[a-z]+/)
100 return first !== null && WRITE_VERBS.has(first[0])
101}
102
103function readsAs(pattern: RegExp, title: string, hint: string): boolean {
104 return (!startsWithWriteVerb(title) && pattern.test(title)) || pattern.test(hint)
105}
106
107const MANUAL_ROLES = ['advisor', 'implementer', 'reader', 'mechanic'] as const
108
109const VERDICT_LINE = /^\s*REVIEW_GATE_VERDICT:\s*(APPROVE|CHANGES_REQUIRED)\s*$/gm
110const NEEDS_YOU_LINE = /^\s*NEEDS_YOU:\s*(\S.*)$/gm
111
112export function isRecord(value: unknown): value is Record<string, unknown> {
113 return typeof value === 'object' && value !== null && !Array.isArray(value)
114}
115
116function text(value: unknown): string | null {
117 return typeof value === 'string' && value.length > 0 ? value : null
118}
119
120function strings(value: unknown): string[] {
121 return Array.isArray(value)
122 ? value.filter((one): one is string => typeof one === 'string' && one.length > 0)
123 : []
124}
125
126export function normalizeTriage(raw: unknown): TriageResult | null {
127 if (!isRecord(raw)) return null
128 const complexity = text(raw.complexity)
129 if (complexity === null) return null
130 return {
131 complexity: complexity.toLowerCase(),
132 confidence: typeof raw.confidence === 'number' ? raw.confidence : null,
133 needsResearch: raw.needsResearch === true,
134 effort: text(raw.effort),
135 }
136}
137
138function flag(value: unknown): boolean {
139 return value === true
140}
141
142function normalizeRoleModel(raw: unknown): HarnessRoleModel | null {
143 if (!isRecord(raw)) return null
144 const model = text(raw.model)
145 const cliModel = text(raw.cliModel)
146 if (model === null || cliModel === null) return null
147 return {
148 model,
149 cliModel,
150 effort: text(raw.effort) ?? 'standard',
151 readonly: flag(raw.readonly),
152 fallbackActive: flag(raw.fallbackActive),
153 }
154}
155
156function normalizeRoleTable(raw: unknown): HarnessRoleTable | null {
157 if (!isRecord(raw)) return null
158 const table: HarnessRoleTable = {}
159 for (const role of MODEL_ROLES) {
160 const entry = normalizeRoleModel(raw[role])
161 if (entry !== null) table[role] = entry
162 }
163 return table
164}
165
166export function normalizeModels(raw: unknown): HarnessRoleTable | null {
167 if (!isRecord(raw)) return null
168 if (raw.client !== undefined && raw.client !== 'claude') return null
169 return normalizeRoleTable(raw.roles)
170}
171
172export function normalizeConsult(raw: unknown): ConsultTarget | null {
173 if (!isRecord(raw) || raw.mode !== 'delegate' || !isRecord(raw.consult)) return null
174 const consult = raw.consult
175 if (consult.client !== undefined && consult.client !== 'claude') return null
176 const model = text(consult.model)
177 const cliModel = text(consult.cliModel) ?? model
178 if (model === null || cliModel === null) return null
179 return { model, cliModel, effort: text(consult.effort) }
180}
181
182export function normalizeReview(raw: unknown): ReviewResult | null {
183 if (!isRecord(raw)) return null
184 const judges = Array.isArray(raw.judges)
185 ? raw.judges.filter(isRecord).map(judge => {
186 const scope = text(judge.scope) ?? 'scope'
187 return `${scope} ${text(judge.status) ?? text(judge.error) ?? 'unknown'}`
188 })
189 : []
190 const verify = isRecord(raw.verify) ? text(raw.verify.status) : null
191 const parts = [...(verify === null ? [] : [`verify ${verify}`]), ...judges]
192 return {
193 status: text(raw.status),
194 detail: parts.length === 0 ? null : parts.join(', '),
195 error: text(raw.error),
196 identity: reviewIdentity(raw),
197 }
198}
199
200function reviewIdentity(raw: Record<string, unknown>): ReviewResult['identity'] {
201 const client = text(raw.client)
202 const session = text(raw.session)
203 const cwd = text(raw.cwd)
204 return client === null || session === null || cwd === null ? null : { client, session, cwd }
205}
206
207export function assignRole(triage: TriageResult | null, hints: RoleHints): RoleDecision {
208 const hint = (hints.hint ?? '').trim().toLowerCase()
209 const manual = MANUAL_ROLES.find(role => role === hint)
210
211 if (manual === 'mechanic') {
212 return { role: 'reader', writes: true, review: true, why: 'manual' }
213 }
214
215 if (manual !== undefined) {
216 return { role: manual, writes: false, review: manual === 'implementer', why: 'manual' }
217 }
218
219 const words = `${hints.title} ${hint}`
220
221 if (WRITE_WORDS.test(words)) {
222 return { role: 'reader', writes: true, review: true, why: 'title or hint reads as mechanical writing' }
223 }
224
225 if (readsAs(READ_WORDS, hints.title, hint)) {
226 return { role: 'reader', writes: false, review: false, why: 'title or hint reads as reading or collecting' }
227 }
228
229 const complexity = triage?.complexity ?? null
230
231 if (complexity === 'trivial') {
232 if (readsAs(READ_HINT_WORDS, hints.title, hint)) {
233 return { role: 'reader', writes: false, review: false, why: 'triage trivial with a read hint' }
234 }
235 return { role: 'reader', writes: true, review: true, why: 'triage trivial without a read hint' }
236 }
237
238 if (complexity === 'architectural') {
239 return { role: 'advisor', writes: false, review: false, why: 'triage architectural' }
240 }
241
242 return {
243 role: 'implementer',
244 writes: false,
245 review: true,
246 why: complexity === null ? 'triage unavailable' : `triage ${complexity}`,
247 }
248}
249
250export function kindOf(task: Pick<HarnessTask, 'role' | 'writes'>): AgentKind | null {
251 if (task.role === 'implementer') return 'implementer'
252 if (task.role === 'reviewer') return 'reviewer'
253 if (task.role === 'reader') return task.writes ? 'mechanic' : 'reader'
254 return null
255}
256
257export function modelRoleOf(kind: AgentKind): HarnessModelRole {
258 return kind === 'mechanic' ? 'reader' : kind
259}
260
261export type SpawnModel = 'fable' | 'opus' | 'sonnet' | 'haiku'
262
263const SPAWN_MODELS: readonly SpawnModel[] = ['fable', 'opus', 'sonnet', 'haiku']
264
265export function spawnModelOf(cliModel: string): SpawnModel | null {
266 const lower = cliModel.toLowerCase()
267 return SPAWN_MODELS.find(alias => lower.includes(alias)) ?? null
268}
269
270export function claudeEffort(effort: string | null | undefined): string | undefined {
271 if (effort === null || effort === undefined) return undefined
272 if (effort === 'standard') return 'medium'
273 return ['low', 'medium', 'high', 'xhigh', 'max'].includes(effort) ? effort : undefined
274}
275
276export function hash8(input: string): string {
277 let acc = 0x811c9dc5
278 for (let index = 0; index < input.length; index += 1) {
279 acc ^= input.charCodeAt(index)
280 acc = Math.imul(acc, 0x01000193) >>> 0
281 }
282 return acc.toString(16).padStart(8, '0')
283}
284
285export function taskId(objective: string, title: string, attempt = 0): string {
286 const seed = attempt === 0 ? `${objective}\n${title}` : `${objective}\n${title}\n#${attempt}`
287 return `hp-${hash8(seed)}`
288}
289
290function describeReason(
291 decision: RoleDecision,
292 triage: TriageResult | null,
293 entry: HarnessRoleModel | null,
294 role: string,
295 hasList: boolean,
296): string {
297 const score = triage === null ? null : triage.confidence
298 const confidence = score === null ? 'no confidence' : `confidence ${score.toFixed(2)}`
299 const cause = decision.why === 'manual' ? 'manual override' : decision.why
300 const label = decision.writes ? `${decision.role} (writes)` : decision.role
301 let origin: string
302 if (decision.role === 'advisor') origin = 'model is the session model'
303 else if (entry !== null) {
304 origin = `model ${entry.model} (${entry.cliModel}) from herdr-jev models list${entry.fallbackActive ? ', fallback active' : ''}`
305 } else if (!hasList) origin = 'herdr-jev models list unavailable, model unknown'
306 else origin = `herdr-jev models list has no ${role} model`
307 return `${label} because ${cause} (${confidence}); ${origin}.`
308}
309
310export function buildPlan(
311 objective: string,
312 tasks: readonly PlanTaskInput[],
313 triages: readonly (TriageResult | null)[],
314 roles: HarnessRoleTable | null,
315 sessionModel: string,
316): HarnessPlan {
317 const ids: string[] = []
318 const used = new Set<string>()
319
320 tasks.forEach(task => {
321 let attempt = 0
322 let id = taskId(objective, task.title, attempt)
323 while (used.has(id)) {
324 attempt += 1
325 id = taskId(objective, task.title, attempt)
326 }
327 used.add(id)
328 ids.push(id)
329 })
330
331 const byTitle = new Map<string, string>()
332 tasks.forEach((task, index) => {
333 const id = ids[index]
334 const key = task.title.trim().toLowerCase()
335 if (id !== undefined && !byTitle.has(key)) byTitle.set(key, id)
336 })
337
338 const table: HarnessRoleTable = roles ?? {}
339
340 const built: HarnessTask[] = tasks.map((task, index) => {
341 const id = ids[index] ?? taskId(objective, task.title, index + 1)
342 const triage = triages[index] ?? null
343 const decision = assignRole(triage, { title: task.title, hint: task.role })
344 const roleKey: HarnessModelRole = decision.role
345 const entry = table[roleKey] ?? null
346
347 const model =
348 decision.role === 'advisor' ? (sessionModel.length > 0 ? sessionModel : null) : (entry?.model ?? null)
349 const effort = entry?.effort ?? null
350 const reviewer = decision.review ? (table.reviewer ?? null) : null
351
352 const deps = (task.deps ?? [])
353 .map(dep => {
354 const clean = dep.trim()
355 if (ids.includes(clean)) return clean
356 return byTitle.get(clean.toLowerCase())
357 })
358 .filter((dep): dep is string => dep !== undefined && dep !== id)
359
360 return {
361 id,
362 title: task.title,
363 role: decision.role,
364 writes: decision.writes,
365 model,
366 effort,
367 reason: describeReason(decision, triage, entry, roleKey, roles !== null),
368 deps: [...new Set(deps)],
369 paths: task.paths ?? [],
370 checks: task.checks ?? [],
371 state: decision.role === 'advisor' ? 'advisor' : 'proposed',
372 review: decision.review,
373 reviewModel: reviewer?.model ?? null,
374 toolCount: 0,
375 }
376 })
377
378 return { objective, advisorModel: sessionModel, roles: table, tasks: built }
379}
380
381export function missingRoles(plan: HarnessPlan): string[] {
382 const missing = new Set<string>()
383 for (const task of plan.tasks) {
384 const kind = kindOf(task)
385 if (kind !== null && plan.roles[modelRoleOf(kind)] === undefined) missing.add(modelRoleOf(kind))
386 if (task.review && plan.roles.reviewer === undefined) missing.add('reviewer')
387 }
388 return [...missing]
389}
390
391export function findTask(plan: HarnessPlan, ref: string): HarnessTask | undefined {
392 const clean = ref.trim()
393 const lower = clean.toLowerCase()
394 return (
395 plan.tasks.find(task => task.id === clean) ??
396 plan.tasks.find(task => task.title.trim().toLowerCase() === lower)
397 )
398}
399
400export function isSettled(task: HarnessTask): boolean {
401 return task.state === 'verified' || task.state === 'done'
402}
403
404export function isUnblocking(task: HarnessTask): boolean {
405 return isSettled(task) || task.state === 'approved'
406}
407
408export function readyTasks(plan: HarnessPlan): HarnessTask[] {
409 const open = new Set(plan.tasks.filter(isUnblocking).map(task => task.id))
410 return plan.tasks.filter(
411 task =>
412 task.state === 'proposed' &&
413 task.role !== 'advisor' &&
414 task.consult === undefined &&
415 task.deps.every(dep => open.has(dep)),
416 )
417}
418
419export type PlanCounts = {
420 total: number
421 settled: number
422 approved: number
423 running: number
424 failed: number
425 needsYou: number
426}
427
428export function countTasks(plan: HarnessPlan): PlanCounts {
429 return {
430 total: plan.tasks.length,
431 settled: plan.tasks.filter(isSettled).length,
432 approved: plan.tasks.filter(task => task.state === 'approved').length,
433 running: plan.tasks.filter(task => task.state === 'running' || task.state === 'review').length,
434 failed: plan.tasks.filter(task => task.state === 'failed').length,
435 needsYou: plan.tasks.filter(task => task.state === 'needs_you').length,
436 }
437}
438
439export function isAllVerified(plan: HarnessPlan): boolean {
440 return plan.tasks.length > 0 && plan.tasks.every(isSettled)
441}
442
443export function approvedKey(plan: HarnessPlan): string | null {
444 const ready = plan.tasks.length > 0 && plan.tasks.every(task => isSettled(task) || task.state === 'approved')
445 const approved = plan.tasks.filter(task => task.state === 'approved').map(task => task.id)
446 return ready && approved.length > 0 ? approved.sort().join(',') : null
447}
448
449export type Hue = 'claude' | 'warning' | 'error' | 'success' | 'inactive' | 'dim' | 'plain'
450
451export type BandState = {
452 word: 'all verified' | 'failed' | 'needs you' | 'review' | 'running' | 'approved' | 'planned'
453 glyph: string
454 hue: Hue
455 isWorking: boolean
456 task: HarnessTask | undefined
457 failed: HarnessTask | undefined
458}
459
460export function bandState(plan: HarnessPlan, asks: number): BandState {
461 const counts = countTasks(plan)
462 const first = (state: HarnessTask['state']): HarnessTask | undefined =>
463 plan.tasks.find(task => task.state === state)
464 const running = first('running')
465 const reviewing = first('review')
466 const failed = first('failed')
467 const needs = first('needs_you')
468 const isWorking = running !== undefined || reviewing !== undefined
469 const task =
470 running ?? reviewing ?? failed ?? needs ?? first('approved') ?? plan.tasks.find(one => !isSettled(one))
471 const base = { isWorking, task, failed }
472
473 if (isAllVerified(plan)) return { ...base, word: 'all verified', glyph: '✓', hue: 'success' }
474 if (failed !== undefined) return { ...base, word: 'failed', glyph: '!', hue: 'error' }
475 if (asks > 0 || counts.needsYou > 0) return { ...base, word: 'needs you', glyph: '?', hue: 'warning' }
476 if (reviewing !== undefined) return { ...base, word: 'review', glyph: '●', hue: 'claude' }
477 if (running !== undefined) return { ...base, word: 'running', glyph: '●', hue: 'claude' }
478 if (counts.approved > 0) return { ...base, word: 'approved', glyph: '◆', hue: 'warning' }
479 return { ...base, word: 'planned', glyph: '○', hue: 'inactive' }
480}
481
482export const ROW_MARK: Readonly<Record<HarnessTaskState, { glyph: string; hue: Hue }>> = {
483 proposed: { glyph: '○', hue: 'dim' },
484 advisor: { glyph: '◇', hue: 'dim' },
485 running: { glyph: '●', hue: 'claude' },
486 review: { glyph: '◐', hue: 'dim' },
487 approved: { glyph: '◆', hue: 'warning' },
488 verified: { glyph: '✓', hue: 'success' },
489 failed: { glyph: '!', hue: 'error' },
490 needs_you: { glyph: '?', hue: 'warning' },
491 done: { glyph: '✓', hue: 'success' },
492}
493
494export function duration(ms: number): string {
495 const seconds = Math.max(0, Math.round(ms / 1000))
496 if (seconds < 60) return `${seconds}s`
497 return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
498}
499
500export function parseVerdict(answer: string): HarnessVerdict | null {
501 const matches = [...answer.matchAll(VERDICT_LINE)]
502 const last = matches[matches.length - 1]
503 const word = last?.[1]
504 return word === 'APPROVE' || word === 'CHANGES_REQUIRED' ? word : null
505}
506
507export function parseNeedsYou(answer: string): string | null {
508 const matches = [...answer.matchAll(NEEDS_YOU_LINE)]
509 const last = matches[matches.length - 1]
510 const question = last?.[1]?.trim()
511 return question === undefined || question.length === 0 ? null : question
512}
513
514export function shortModel(model: string | null): string {
515 if (model === null || model.length === 0) return '?'
516 return model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
517}
518
519export function barParts(done: number, total: number, cells = 10): { filled: string; empty: string } {
520 const count = total <= 0 ? 0 : Math.min(cells, Math.round((done / total) * cells))
521 return { filled: '█'.repeat(count), empty: '░'.repeat(cells - count) }
522}
523
524export function bar(done: number, total: number, cells = 10): string {
525 const parts = barParts(done, total, cells)
526 return parts.filled + parts.empty
527}
528
529export function percent(done: number, total: number): number {
530 return total <= 0 ? 0 : Math.round((done / total) * 100)
531}
532
533export function effortLabel(effort: string | null): string {
534 if (effort === null || effort.length === 0) return '-'
535 return effort === 'standard' ? 'std' : effort
536}
537
538export function fit(value: string, max: number): string {
539 if (max <= 0) return ''
540 if (value.length <= max) return value
541 return max === 1 ? '…' : `${value.slice(0, max - 1)}…`
542}
543
544export function baseName(path: string): string {
545 const parts = path.split('/').filter(part => part.length > 0)
546 return parts[parts.length - 1] ?? path
547}
548
549export const STATE_GLYPH: Readonly<Record<HarnessTaskState, string>> = {
550 proposed: '○',
551 advisor: '◇',
552 running: '●',
553 review: '◐',
554 approved: '◆',
555 verified: '✓',
556 failed: '✗',
557 needs_you: '?',
558 done: '✓',
559}
560
561const TARGET_FIELD: Readonly<Record<string, string>> = {
562 Read: 'file_path',
563 Edit: 'file_path',
564 Write: 'file_path',
565 Glob: 'pattern',
566}
567
568export function describeTool(tool: string, input: Readonly<Record<string, unknown>>): string {
569 const field = TARGET_FIELD[tool]
570 if (field === undefined) return tool
571 const target = text(input[field])
572 return target === null ? tool : `${tool} ${fit(baseName(target.replace(/\s+/g, ' ')), 40)}`
573}
574
575export type ReviewPhase = {
576 isOk: boolean
577 status: string | null
578 isTimedOut: boolean
579} | null
580
581export function reviewPhrase(isRunning: boolean, review: ReviewPhase): string {
582 if (isRunning) return 'harness review running'
583 if (review === null) return 'harness review pending'
584 if (!review.isOk) return review.isTimedOut ? 'harness review: timed out' : 'harness review: failed'
585 if (review.status === 'ready') return 'harness review pending'
586 return `harness review: ${review.status ?? 'unknown'}`
587}
588
589export function roleLabel(role: HarnessRole, model: string | null, effort: string | null, writes: boolean): string {
590 return `${role}/${shortModel(model)}${effort === null ? '' : `/${effort}`}${writes ? ' writes' : ''}`
591}
592
593export function consultLine(consult: HarnessConsult): string {
594 const head = ` consult ${shortModel(consult.model)}${consult.effort === null ? '' : `/${consult.effort}`} read-only [${consult.state}]`
595 if (consult.state === 'running') return `${head}${consult.agentId === undefined ? '' : ` agent ${consult.agentId}`}`
596 if (consult.state === 'failed') return `${head}: ${consult.note ?? 'no recommendation'}`
597 return `${head}, recommendation for you to weigh (you decide; run this task with harness_run taskId):\n${consult.report ?? ''}`
598}
599
600export function summarize(plan: HarnessPlan): string {
601 const counts = countTasks(plan)
602 const lines = [
603 `Objective: ${plan.objective}`,
604 `Advisor: ${plan.advisorModel.length > 0 ? plan.advisorModel : 'unknown'}`,
605 `Tasks ${counts.settled}/${counts.total} settled, ${counts.approved} approved awaiting harness review, ${counts.running} running, ${counts.failed} failed, ${counts.needsYou} need you.`,
606 ]
607 for (const task of plan.tasks) {
608 const label = roleLabel(task.role, task.model, task.effort, task.writes)
609 const deps = task.deps.length === 0 ? '' : ` deps ${task.deps.join(',')}`
610 const verdict = task.verdict === undefined ? '' : ` verdict ${task.verdict}`
611 const agent = task.agentId === undefined ? '' : ` agent ${task.agentId}`
612 lines.push(`${STATE_GLYPH[task.state]} ${task.id} [${task.state}] ${task.title} (${label})${deps}${verdict}${agent}`)
613 if (task.consult !== undefined) lines.push(consultLine(task.consult))
614 }
615 return lines.join('\n')
616}
617
618export type SavedState = {
619 plan: HarnessPlan
620 workers: Record<string, HarnessWorkerRow>
621 needsYou: HarnessHumanAsk[]
622}
623
624function optionalString(value: unknown): string | undefined {
625 return typeof value === 'string' ? value : undefined
626}
627
628function optionalNumber(value: unknown): number | undefined {
629 return typeof value === 'number' && Number.isFinite(value) ? value : undefined
630}
631
632function parseConsult(raw: unknown): HarnessConsult | undefined {
633 if (!isRecord(raw)) return undefined
634 const model = text(raw.model)
635 const cliModel = text(raw.cliModel)
636 const state = CONSULT_STATES.find(one => one === raw.state)
637 if (model === null || cliModel === null || state === undefined) return undefined
638 return {
639 model,
640 cliModel,
641 effort: text(raw.effort),
642 state,
643 agentId: optionalString(raw.agentId),
644 report: optionalString(raw.report),
645 note: optionalString(raw.note),
646 }
647}
648
649function parseTask(raw: unknown): HarnessTask | null {
650 if (!isRecord(raw)) return null
651 const id = text(raw.id)
652 const title = text(raw.title)
653 const role = TASK_ROLES.find(one => one === raw.role)
654 const state = TASK_STATES.find(one => one === raw.state)
655 if (id === null || title === null || role === undefined || state === undefined) return null
656 const verdict = raw.verdict === 'APPROVE' || raw.verdict === 'CHANGES_REQUIRED' ? raw.verdict : undefined
657 return {
658 id,
659 title,
660 role,
661 writes: flag(raw.writes),
662 model: text(raw.model),
663 effort: text(raw.effort),
664 reason: typeof raw.reason === 'string' ? raw.reason : '',
665 deps: strings(raw.deps),
666 paths: strings(raw.paths),
667 checks: strings(raw.checks),
668 state,
669 review: flag(raw.review),
670 reviewModel: text(raw.reviewModel),
671 reviewStarting: flag(raw.reviewStarting) ? true : undefined,
672 agentId: optionalString(raw.agentId),
673 reviewAgentId: optionalString(raw.reviewAgentId),
674 verdict,
675 startedAt: optionalNumber(raw.startedAt),
676 endedAt: optionalNumber(raw.endedAt),
677 lastTool: optionalString(raw.lastTool),
678 toolCount: optionalNumber(raw.toolCount) ?? 0,
679 note: optionalString(raw.note),
680 report: optionalString(raw.report),
681 reviewReport: optionalString(raw.reviewReport),
682 consult: parseConsult(raw.consult),
683 }
684}
685
686function parseWorker(raw: unknown): HarnessWorkerRow | null {
687 if (!isRecord(raw)) return null
688 const taskId = text(raw.taskId)
689 const agentId = text(raw.agentId)
690 const role = TASK_ROLES.find(one => one === raw.role)
691 const startedAt = optionalNumber(raw.startedAt)
692 if (taskId === null || agentId === null || role === undefined || startedAt === undefined) return null
693 return {
694 taskId,
695 agentId,
696 role,
697 writes: flag(raw.writes),
698 model: text(raw.model),
699 lastTool: text(raw.lastTool),
700 toolCount: optionalNumber(raw.toolCount) ?? 0,
701 startedAt,
702 }
703}
704
705function parseAsk(raw: unknown): HarnessHumanAsk | null {
706 if (!isRecord(raw)) return null
707 const id = text(raw.id)
708 const taskId = text(raw.taskId)
709 const question = text(raw.question)
710 const at = optionalNumber(raw.at)
711 return id === null || taskId === null || question === null || at === undefined
712 ? null
713 : { id, taskId, question, at }
714}
715
716export function parseSaved(raw: unknown): SavedState | null {
717 if (!isRecord(raw) || !isRecord(raw.plan) || !Array.isArray(raw.plan.tasks)) return null
718
719 const tasks: HarnessTask[] = []
720 for (const item of raw.plan.tasks) {
721 const task = parseTask(item)
722 if (task === null) return null
723 tasks.push(task)
724 }
725
726 const workers: Record<string, HarnessWorkerRow> = {}
727 if (isRecord(raw.workers)) {
728 for (const item of Object.values(raw.workers)) {
729 const row = parseWorker(item)
730 if (row !== null) workers[row.agentId] = row
731 }
732 }
733
734 const needsYou = Array.isArray(raw.needsYou)
735 ? raw.needsYou.map(parseAsk).filter((ask): ask is HarnessHumanAsk => ask !== null)
736 : []
737
738 return {
739 plan: {
740 objective: typeof raw.plan.objective === 'string' ? raw.plan.objective : '',
741 advisorModel: typeof raw.plan.advisorModel === 'string' ? raw.plan.advisorModel : '',
742 roles: normalizeRoleTable(raw.plan.roles) ?? {},
743 tasks,
744 at: optionalNumber(raw.plan.at),
745 },
746 workers,
747 needsYou,
748 }
749}
750hooks/pr-gate.ts 278 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, PluginOptions, ToolCheckResult } from 'claude-code'
3
4import { analyze } from './pr-detect'
5import type { DirectoryPlan, PrDetection, ReviewRun } from './pr-detect'
6import {
7 CLIENT_PATTERN,
8 mergeReviewIds,
9 parseReviewIds,
10 parseReviewRun,
11 reviewIdsKey,
12 STATUS_PATTERN,
13 TOKEN_PATTERN,
14} from './review-run'
15import type { ReviewIds, StoredReview } from './review-run'
16
17export { mergeReviewIds, parseReviewIds, parseReviewRun, reviewIdsKey } from './review-run'
18
19export type GateStatus = { status: string; reason?: string; since?: number }
20
21export type Held = { status: GateStatus; dir: string | null; here?: boolean }
22
23const STATUS_TIMEOUT_MS = 5000
24const GIT_TIMEOUT_MS = 2000
25
26const reviewIdsAtom = atom({ plugin: 'harness', key: 'reviewIds' } as const, {})
27
28const ADVICE =
29 'Review after committing: commit, then run `herdr-jev review`, address the findings, then open the PR; or tell the user the PR is unreviewed.'
30
31const clean = (value: string): string => value.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, 160)
32
33const quoted = (value: string): string => (/^[A-Za-z0-9_./@:+-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`)
34
35export const parseStatus = (value: unknown): GateStatus => {
36 if (value === null) return { status: 'none' }
37 if (typeof value === 'object' && !Array.isArray(value)) {
38 const status = (value as Record<string, unknown>).status
39 if (typeof status === 'string' && STATUS_PATTERN.test(status)) return { status }
40 }
41 return { status: 'unknown', reason: 'the status output had no usable status' }
42}
43
44export const resolveDir = (target: string | undefined, sessionCwd: string, home: string | undefined): string | null => {
45 if (target === undefined || target.length === 0) return sessionCwd
46 let base = target
47 if (target === '~' || target.startsWith('~/')) {
48 if (home === undefined || home.length === 0) return null
49 base = `${home}${target.slice(1)}`
50 } else if (target.startsWith('~')) {
51 return null
52 }
53 const parts = (base.startsWith('/') ? base : `${sessionCwd}/${base}`).split('/')
54 const stack: string[] = []
55 for (const part of parts) {
56 if (part === '' || part === '.') continue
57 if (part === '..') stack.pop()
58 else stack.push(part)
59 }
60 return `/${stack.join('/')}`
61}
62
63export const sameRepo = (named: string, urls: readonly string[]): boolean => {
64 const want = named.toLowerCase().replace(/\.git$/, '')
65 if (want.length === 0 || /[\s$*?{}`]/.test(want)) return false
66 return urls.some(url => {
67 const one = url.toLowerCase().replace(/\/+$/, '').replace(/\.git$/, '')
68 return one.endsWith(`/${want}`) || one.endsWith(`:${want}`)
69 })
70}
71
72const where = (dir: string | null): string => (dir === null ? '' : ` for ${clean(dir)}`)
73
74const stamp = (at: number | undefined): string => {
75 if (at === undefined || !Number.isFinite(at)) return 'an earlier time'
76 try {
77 return new Date(at).toISOString()
78 } catch {
79 return 'an earlier time'
80 }
81}
82
83export const noticeText = (status: GateStatus, dir: string | null, here = true): string => {
84 const lead = `Review status read before the PR command${where(dir)}`
85 if (status.status === 'ready') return `${lead}: ready.`
86 if (status.status === 'unknown') {
87 const why = status.reason === undefined ? '' : ` (${clean(status.reason)})`
88 return `${lead}: unknown${why}. The status could not be read, so nothing is claimed about the review. ${ADVICE}`
89 }
90 if (status.status === 'stale') {
91 return `${lead}: stale. The last review of this checkout was ready at ${stamp(status.since)}; the checkout changed since (a commit or a new untracked file counts). ${ADVICE}`
92 }
93 if (status.status === 'none') {
94 const how =
95 here || dir === null
96 ? 'run `herdr-jev review` in a shell (this check finds it) or ask the user to run `/harness review`'
97 : `run \`herdr-jev review\` in ${quoted(clean(dir))} (for example \`cd ${quoted(clean(dir))} && herdr-jev review\`); \`/harness review\` reviews the session directory instead`
98 return `${lead}: none. The mod has no review on record for this checkout; to produce one, ${how}. ${ADVICE}`
99 }
100 return `${lead}: ${status.status}. No ready independent review is recorded for this change. ${ADVICE}`
101}
102
103export const askReason = (held: Held): string =>
104 `Harness review status${where(held.dir)} is ${held.status.status}. Open the PR anyway?`
105
106export const askEnabled = (options: PluginOptions): boolean => options.prGateAsk === true
107
108export const shouldAsk = (enabled: boolean, status: GateStatus): boolean =>
109 enabled && status.status !== 'ready' && status.status !== 'unknown'
110
111export const askVerdict = (held: Held | undefined, verdict: ToolCheckResult): ToolCheckResult =>
112 held === undefined || verdict.decision !== 'allow' ? verdict : { decision: 'ask', reason: askReason(held) }
113
114const unknown = (reason: string): GateStatus => ({ status: 'unknown', reason })
115
116type Run = EngineInterface['process']['run']
117
118async function git($: EngineInterface, cwd: string, args: string[]): Promise<string | null> {
119 try {
120 const ran = await ($.process.run as Run)(['git', ...args], { cwd, timeoutMs: GIT_TIMEOUT_MS })
121 return ran.exitCode === 0 ? ran.stdout : null
122 } catch {
123 return null
124 }
125}
126
127async function queryStatus($: EngineInterface, id: StoredReview, cwd: string): Promise<GateStatus> {
128 if (!CLIENT_PATTERN.test(id.client) || !TOKEN_PATTERN.test(id.session)) {
129 return unknown('the stored review identity is not usable')
130 }
131 let stdout: string
132 let exitCode: number
133 try {
134 const ran = await $.process.run(
135 ['ai-harness', 'review-status', '--client', id.client, '--session', id.session, '--cwd', cwd],
136 { cwd, timeoutMs: STATUS_TIMEOUT_MS },
137 )
138 stdout = ran.stdout
139 exitCode = ran.exitCode
140 } catch (error) {
141 const timedOut = /time[ds]? ?out|still running/i.test(error instanceof Error ? error.message : '')
142 return unknown(timedOut ? 'the status check timed out' : 'the status check could not run')
143 }
144 let parsed: unknown
145 try {
146 parsed = JSON.parse(stdout.trim())
147 } catch {
148 return unknown(exitCode === 0 ? 'the status output could not be parsed' : 'the status check failed')
149 }
150 return parseStatus(parsed)
151}
152
153type Assessment = { status: GateStatus; dir: string | null; here: boolean }
154
155const blind = (reason: string, dir: string | null = null): Assessment => ({ status: unknown(reason), dir, here: true })
156
157function planDir(plan: DirectoryPlan): string | undefined | null {
158 if (plan.kind === 'session') return undefined
159 if (plan.kind === 'cd') return plan.target
160 return null
161}
162
163async function storedIds($: EngineInterface): Promise<ReviewIds> {
164 const live = (await read($, reviewIdsAtom)) as ReviewIds
165 if (Object.keys(live).length > 0) return live
166 try {
167 return parseReviewIds(await $.store.get(reviewIdsKey(await $.session.id())))
168 } catch {
169 return {}
170 }
171}
172
173async function assess($: EngineInterface, detection: PrDetection): Promise<Assessment> {
174 const target = planDir(detection.directory)
175 if (target === null) {
176 return blind(detection.directory.kind === 'unknown' ? detection.directory.why : 'the directory is unknown')
177 }
178 const foreign = detection.commands.find(one => one.foreign !== null)
179 if (foreign !== undefined) return blind(foreign.foreign as string)
180 if (detection.commands.some(one => one.targetsPr)) {
181 return blind('the command targets a PR by number or id, which may not be the change reviewed in this checkout')
182 }
183 const named = detection.commands.map(one => one.repo).filter((one): one is string => one !== null)
184 if (named.some(one => one.includes('$'))) return blind('repository comes from a variable')
185
186 const sessionCwd = await $.session.cwd()
187 const home = target?.startsWith('~') === true ? await $.env.get('HOME').catch(() => undefined) : undefined
188 const resolved = resolveDir(target, sessionCwd, home)
189 if (resolved === null) return blind('the directory could not be resolved')
190
191 const [top, config] = await Promise.all([
192 git($, resolved, ['rev-parse', '--show-toplevel']),
193 named.length > 0 ? git($, resolved, ['config', '--get-regexp', '^remote\\..*\\.url$']) : Promise.resolve(null),
194 ])
195 const dir = top === null ? '' : top.trim()
196 if (dir.length === 0) return blind('the directory is not inside a git checkout')
197
198 if (named.length > 0) {
199 const urls = (config ?? '')
200 .split('\n')
201 .map(line => line.trim().split(/\s+/)[1] ?? '')
202 .filter(line => line.length > 0)
203 if (!named.every(one => sameRepo(one, urls))) {
204 return blind('the command names a repository that does not match this checkout', dir)
205 }
206 }
207
208 const here = sessionCwd === dir || sessionCwd.startsWith(`${dir}/`)
209 const id = (await storedIds($))[dir]
210 if (id === undefined) return { status: { status: 'none' }, dir, here }
211 const queried = await queryStatus($, id, dir)
212 if (queried.status === 'pending_verification' && id.status === 'ready') {
213 return { status: { status: 'stale', since: id.at }, dir, here }
214 }
215 return { status: queried, dir, here }
216}
217
218async function recordRun($: EngineInterface, run: ReviewRun, output: string): Promise<void> {
219 const report = parseReviewRun(output, run.json)
220 if (report === null) return
221 const at = await $.clock.now()
222 const entry: StoredReview = { client: report.client, session: report.session, at, status: report.status }
223 await update($, reviewIdsAtom, ids => mergeReviewIds(ids as ReviewIds, report.cwd, entry))
224 await $.store.set(reviewIdsKey(await $.session.id()), await read($, reviewIdsAtom))
225}
226
227export function registerPrGate(on: On, options: PluginOptions): void {
228 const held = new Map<string, Held>()
229
230 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
231 const command = typeof e.command === 'string' ? e.command : ''
232 let pr: PrDetection | null = null
233 let review: ReviewRun | null = null
234 try {
235 const found = analyze(command)
236 pr = found.pr
237 review = found.review
238 } catch {
239 pr = null
240 review = null
241 }
242 if (pr === null && review === null) return next(e)
243
244 let found: Assessment | null = null
245 if (pr !== null) {
246 try {
247 found = await assess($, pr)
248 } catch {
249 found = blind('the status check failed')
250 }
251 }
252
253 const id = e.tool_use_id
254 if (found !== null && id !== undefined && shouldAsk(askEnabled(options), found.status)) held.set(id, found)
255 try {
256 const ran = await next(e)
257 if (ran.deny !== undefined) return ran
258 if (review !== null) {
259 try {
260 const output = typeof ran.text === 'string' ? ran.text : typeof ran.result === 'string' ? ran.result : ''
261 await recordRun($, review, output)
262 } catch {
263 void 0
264 }
265 }
266 if (found === null || ran.isError === true) return ran
267 return { ...ran, context: [...(ran.context ?? []), noticeText(found.status, found.dir, found.here)] } as typeof ran
268 } finally {
269 if (id !== undefined) held.delete(id)
270 }
271 }).catch(($, e, next) => next(e))
272
273 on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
274 const id = e.tool_use_id
275 return askVerdict(id === undefined ? undefined : held.get(id), await next(e))
276 }).catch(($, e, next) => next(e))
277}
278hooks/review-run.ts 64 lines1export type ReviewRecord = { client: string; session: string; cwd: string; status: string }
2
3export type StoredReview = { client: string; session: string; at: number; status: string | null }
4
5export type ReviewIds = Record<string, StoredReview>
6
7export const STATUS_PATTERN = /^[a-z_]{1,40}$/
8export const TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
9export const CLIENT_PATTERN = /^[a-z][a-z0-9_-]{0,30}$/
10
11export const reviewIdsKey = (sessionId: string): string => `reviewIds:${sessionId}`
12
13function accept(client: unknown, session: unknown, cwd: unknown, status: unknown): ReviewRecord | null {
14 if (typeof client !== 'string' || !CLIENT_PATTERN.test(client)) return null
15 if (typeof session !== 'string' || !TOKEN_PATTERN.test(session)) return null
16 if (typeof cwd !== 'string' || !cwd.startsWith('/') || cwd.length > 1024 || /[\u0000-\u001f\u007f]/.test(cwd)) return null
17 if (typeof status !== 'string' || !STATUS_PATTERN.test(status)) return null
18 return { client, session, cwd, status }
19}
20
21export function parseReviewText(output: string): ReviewRecord | null {
22 const header = /^Review session (\S+) \((\S+)\) in (\/[^\n]*?)[ \t]*$/m.exec(output)
23 if (header === null) return null
24 const lines = output.split('\n').map(line => line.replace(/\r$/, '')).filter(line => line.trim().length > 0)
25 const last = /^Status: ([a-z_]+)(?: \(.*\))?[ \t]*$/.exec(lines[lines.length - 1] ?? '')
26 return accept(header[2], header[1], header[3], last === null ? null : (last[1] ?? null))
27}
28
29export function parseReviewJson(output: string): ReviewRecord | null {
30 const start = output.indexOf('{')
31 const end = output.lastIndexOf('}')
32 if (start < 0 || end <= start) return null
33 let raw: unknown
34 try {
35 raw = JSON.parse(output.slice(start, end + 1))
36 } catch {
37 return null
38 }
39 if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null
40 const record = raw as Record<string, unknown>
41 return accept(record.client, record.session, record.cwd, record.status)
42}
43
44export function parseReviewRun(output: string, json: boolean): ReviewRecord | null {
45 return json ? parseReviewJson(output) : parseReviewText(output)
46}
47
48export function mergeReviewIds(ids: ReviewIds, cwd: string, entry: StoredReview): ReviewIds {
49 return { ...ids, [cwd]: entry }
50}
51
52export function parseReviewIds(value: unknown): ReviewIds {
53 const out: ReviewIds = {}
54 if (typeof value !== 'object' || value === null || Array.isArray(value)) return out
55 for (const [cwd, one] of Object.entries(value as Record<string, unknown>)) {
56 if (typeof one !== 'object' || one === null) continue
57 const entry = one as Record<string, unknown>
58 if (typeof entry.client !== 'string' || typeof entry.session !== 'string' || typeof entry.at !== 'number') continue
59 const status = typeof entry.status === 'string' ? entry.status : null
60 out[cwd] = { client: entry.client, session: entry.session, at: entry.at, status }
61 }
62 return out
63}
64hooks/visual.ts 235 lines1import type { HarnessCost, HarnessExternalRow, HarnessUsage, HarnessUsageLimit } from '../types'
2import type { AgentState } from './external'
3import { barParts } from './plan'
4import type { Hue } from './plan'
5
6export const REVIEWER_TYPE = 'harness:reviewer'
7
8export const REVIEWER_RULE =
9 "Each finding is a candidate, not a fact: it counts only with evidence (a failing test or command, a measurement, or a concrete scenario with file:line); reproduce before fixing; tell the user what you discarded and why; a requirement the user did not ask for is not a defect; 'approved with no findings' is a valid result."
10
11export const EXTERNAL_KEEP = 12
12
13const OSC = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g
14const CSI = /\u001b\[[0-?]*[ -/]*[@-~]/g
15const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g
16const INVISIBLE = /[---]/g
17const SIGNATURE = /^— answered by codex, (.+)$/m
18const PATCH_LINE = /^Patch \(mode 0600\): (.+)$/m
19const STAT_BLOCK = /git diff --stat:\n([\s\S]*?)(?:\n\n— answered by codex|$)/
20const STAT_TOTAL = /\d+ files? changed/
21const KEPT_COPY = /^Could not build the patch \((.*?)\)\./m
22const NOTE_LIMIT = 120
23
24export function cleanLine(text: string): string {
25 return text
26 .replace(OSC, '')
27 .replace(CSI, '')
28 .replace(/\t/g, ' ')
29 .replace(CONTROL, '')
30 .replace(INVISIBLE, '')
31 .replace(/ {2,}/g, ' ')
32 .trim()
33}
34
35export function lastLineOf(text: string): string {
36 const lines = text
37 .split('\n')
38 .map(cleanLine)
39 .filter(line => line.length > 0)
40 return lines[lines.length - 1] ?? ''
41}
42
43function finite(value: unknown): number | null {
44 return typeof value === 'number' && Number.isFinite(value) ? value : null
45}
46
47export function compact(value: number): string {
48 if (!Number.isFinite(value) || value <= 0) return '0'
49 if (value < 1000) return String(Math.round(value))
50 const thousands = Math.round(value / 1000)
51 if (value < 10_000) return `${(value / 1000).toFixed(1).replace(/\.0$/, '')}k`
52 if (thousands < 1000) return `${thousands}k`
53 return `${(value / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
54}
55
56type ContextInput = { tokens?: number; window?: number; percent?: number } | undefined
57
58type LimitInput = { kind?: string; percentUsed?: number }
59
60export function usageOf(context: ContextInput, limits: readonly LimitInput[] | undefined): HarnessUsage | null {
61 const tokens = finite(context?.tokens)
62 const window = finite(context?.window)
63 const hasContext = tokens !== null && window !== null && window > 0
64 const given = finite(context?.percent)
65 const percent = hasContext ? (given ?? (tokens / window) * 100) : null
66 const seen: HarnessUsageLimit[] = []
67 for (const one of limits ?? []) {
68 const used = finite(one.percentUsed)
69 if (used === null || (one.kind !== 'five_hour' && one.kind !== 'seven_day')) continue
70 seen.push({ kind: one.kind, percentUsed: used })
71 }
72 if (!hasContext && seen.length === 0) return null
73 return { tokens: hasContext ? tokens : null, window: hasContext ? window : null, percent, limits: seen }
74}
75
76export function levelHue(percent: number): Hue {
77 if (percent >= 90) return 'error'
78 if (percent >= 70) return 'warning'
79 return 'dim'
80}
81
82export type UsagePart = { key: string; text: string; hue: Hue }
83
84export function usageParts(usage: HarnessUsage | null): UsagePart[] {
85 if (usage === null) return []
86 const parts: UsagePart[] = []
87 if (usage.tokens !== null && usage.window !== null && usage.percent !== null) {
88 const bar = barParts(usage.percent, 100)
89 parts.push({
90 key: 'context',
91 text: `ctx ${bar.filled}${bar.empty} ${compact(usage.tokens)}/${compact(usage.window)}`,
92 hue: levelHue(usage.percent),
93 })
94 }
95 const five = usage.limits.find(one => one.kind === 'five_hour')
96 if (five !== undefined) parts.push({ key: 'five_hour', text: `5h ${Math.round(five.percentUsed)}%`, hue: levelHue(five.percentUsed) })
97 const week = usage.limits.find(one => one.kind === 'seven_day')
98 if (week !== undefined) parts.push({ key: 'seven_day', text: `week ${Math.round(week.percentUsed)}%`, hue: levelHue(week.percentUsed) })
99 return parts
100}
101
102type StepUsage = {
103 input_tokens: number
104 output_tokens: number
105 cache_read_input_tokens: number
106 cache_creation_input_tokens: number
107}
108
109export function addCost(prev: HarnessCost | undefined, usage: StepUsage): HarnessCost {
110 const base = prev ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, steps: 0 }
111 return {
112 input: base.input + (finite(usage.input_tokens) ?? 0),
113 output: base.output + (finite(usage.output_tokens) ?? 0),
114 cacheRead: base.cacheRead + (finite(usage.cache_read_input_tokens) ?? 0),
115 cacheWrite: base.cacheWrite + (finite(usage.cache_creation_input_tokens) ?? 0),
116 steps: base.steps + 1,
117 }
118}
119
120export function costText(cost: HarnessCost | undefined): string {
121 if (cost === undefined) return ''
122 const cache = cost.cacheRead + cost.cacheWrite
123 if (cost.input + cost.output + cache === 0) return ''
124 return [`in ${compact(cost.input)}`, `out ${compact(cost.output)}`, cache > 0 ? `cache ${compact(cache)}` : null]
125 .filter((one): one is string => one !== null)
126 .join(' ')
127}
128
129export type ReportFacts = {
130 isDone: boolean
131 model: string | null
132 patch: string | undefined
133 stat: string | undefined
134 note: string | undefined
135}
136
137export function reportFacts(trusted: string, report: string): ReportFacts {
138 const signature = SIGNATURE.exec(trusted)
139 const said = signature?.[1]?.trim()
140 const model = said === undefined || said.startsWith('requested ') || said === 'unknown model' ? null : said
141 const patch = PATCH_LINE.exec(trusted)?.[1]
142 const block = STAT_BLOCK.exec(trusted)?.[1]
143 const lines = (block ?? '')
144 .split('\n')
145 .map(cleanLine)
146 .filter(line => line.length > 0)
147 const total = lines.find(line => STAT_TOTAL.test(line))
148 const stat = trusted.includes('Codex made no changes') ? 'no changes' : (total ?? lines[lines.length - 1])
149 const kept = KEPT_COPY.exec(trusted)?.[1]
150 const first = report
151 .split('\n')
152 .map(cleanLine)
153 .find(line => line.length > 0)
154 const isDone = signature !== null
155 const note = kept !== undefined ? `no patch: ${cleanLine(kept)}` : isDone ? undefined : first?.slice(0, NOTE_LIMIT)
156 return {
157 isDone,
158 model,
159 patch: patch === undefined ? undefined : cleanLine(patch),
160 stat: stat === undefined ? undefined : cleanLine(stat),
161 note,
162 }
163}
164
165export function deriveExternal(
166 prev: HarnessExternalRow | undefined,
167 agentId: string,
168 state: AgentState | null,
169 startedAt: number,
170 now: number,
171 line: string,
172): HarnessExternalRow | null {
173 if (prev !== undefined && prev.status !== 'running') return null
174 if (state === null) return null
175 const base: HarnessExternalRow = prev ?? { agentId, client: 'codex', model: null, status: 'running', startedAt, lastLine: '' }
176 const lastLine = line === '' ? base.lastLine : line
177 if (state.run !== null) {
178 const model = state.run.model === '' ? base.model : cleanLine(state.run.model)
179 return { ...base, model, status: 'running', lastLine }
180 }
181 if (!state.used) return null
182 const facts = reportFacts(state.trusted ?? '', state.lastReport)
183 const finished: HarnessExternalRow = {
184 ...base,
185 model: facts.isDone ? facts.model : base.model,
186 status: facts.isDone ? 'done' : 'failed',
187 endedAt: now,
188 lastLine,
189 }
190 if (facts.patch !== undefined) finished.patch = facts.patch
191 if (facts.stat !== undefined) finished.stat = facts.stat
192 if (facts.note !== undefined) finished.note = facts.note
193 return finished
194}
195
196export function stopExternal(row: HarnessExternalRow, now: number): HarnessExternalRow {
197 return row.status === 'running' ? { ...row, status: 'failed', endedAt: now, note: 'stopped before it finished' } : row
198}
199
200export function pruneExternal(rows: Record<string, HarnessExternalRow>, keep = EXTERNAL_KEEP): Record<string, HarnessExternalRow> {
201 const all = Object.values(rows)
202 if (all.length <= keep) return rows
203 const finished = all
204 .filter(row => row.status !== 'running')
205 .sort((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0))
206 const drop = new Set(finished.slice(0, all.length - keep).map(row => row.agentId))
207 return Object.fromEntries(Object.entries(rows).filter(([id]) => !drop.has(id)))
208}
209
210export function orderedExternal(rows: Record<string, HarnessExternalRow>): HarnessExternalRow[] {
211 return Object.values(rows).sort((a, b) => {
212 if ((a.status === 'running') !== (b.status === 'running')) return a.status === 'running' ? -1 : 1
213 return a.status === 'running' ? a.startedAt - b.startedAt : (b.endedAt ?? 0) - (a.endedAt ?? 0)
214 })
215}
216
217export function runningExternal(rows: Record<string, HarnessExternalRow>): number {
218 return Object.values(rows).filter(row => row.status === 'running').length
219}
220
221export function externalModel(row: HarnessExternalRow): string {
222 return row.model === null || row.model === '' ? 'unconfirmed' : row.model
223}
224
225export function recordOf(value: unknown): Record<string, unknown> | null {
226 return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
227}
228
229export function isReviewerResult(input: unknown, result: unknown): boolean {
230 const call = recordOf(input)
231 const done = recordOf(result)
232 if (done === null || done.status !== 'completed') return false
233 return call?.subagent_type === REVIEWER_TYPE || done.agentType === REVIEWER_TYPE
234}
235hooks/scope.ts 254 lines1import type { PluginOptions } from 'claude-code'
2
3import type { ScopeState } from '../types'
4import { runJson, safeTask } from './cli'
5import type { CliResult, RunPort } from './cli'
6import { fit, isRecord } from './plan'
7
8export type ScopeMode = 'off' | 'report' | 'enforce'
9
10export type SkillReason = 'project' | 'built-in' | 'always' | 'skill-select' | 'route-turn' | 'invoked'
11
12export type AgentReason = 'project' | 'built-in' | 'harness' | 'always'
13
14export type Receipt = {
15 skills: Map<string, SkillReason>
16 hiddenSkills: Set<string>
17 agents: Map<string, AgentReason>
18 hiddenAgents: Set<string>
19 notes: string[]
20}
21
22export type SkillSelect = {
23 skills: string[]
24 clis: string[]
25 total: number | null
26}
27
28export type Filtered = { text: string; removed: string[] }
29
30export const EMPTY_SCOPE: ScopeState = {
31 status: 'pending',
32 selected: [],
33 clis: [],
34 turn: [],
35 invoked: [],
36 query: '',
37 total: null,
38 note: null,
39}
40
41export const DEFAULT_SKILLS: readonly string[] = [
42 'writing-clearly-and-concisely',
43 'harness-router',
44 'herdr-jev',
45 'ai-harness-context',
46 'plugin-authoring',
47 'vault',
48 'promote',
49 'code-review',
50 'simplify',
51]
52
53export const DEFAULT_AGENTS: readonly string[] = [
54 'business-operator',
55 'claudex-coordinator',
56 'commercial-operator',
57 'harness-operator',
58 'knowledge-maintainer',
59 'legacy-archaeologist',
60 'media-producer',
61 'platform-reliability',
62 'product-discovery',
63 'quality-reviewer',
64 'recording-analyst',
65 'research-analyst',
66 'security-operator',
67 'software-architect',
68 'software-engineer',
69 'web-experience',
70]
71
72export const BUILTIN_AGENTS: readonly string[] = [
73 'general-purpose',
74 'Explore',
75 'Plan',
76 'claude-code-guide',
77 'statusline-setup',
78 'PlanChecker',
79]
80
81const SKILL_HEADER = 'The following skills are available for use with the Skill tool:'
82const SELECT_TIMEOUT_MS = 8000
83const ROUTE_TIMEOUT_MS = 2000
84const ROUTE_TEXT_LIMIT = 2000
85
86export function newReceipt(): Receipt {
87 return { skills: new Map(), hiddenSkills: new Set(), agents: new Map(), hiddenAgents: new Set(), notes: [] }
88}
89
90export function scopeMode(options: PluginOptions): ScopeMode {
91 const value = options.scopeMode
92 return value === 'off' || value === 'report' ? value : 'enforce'
93}
94
95export function listOption(value: unknown, fallback: readonly string[]): string[] {
96 if (typeof value !== 'string') return [...fallback]
97 const names = value.split(/[,\n]/).map(one => one.trim()).filter(one => one.length > 0)
98 return names.length === 0 && value.trim().length === 0 ? [...fallback] : names
99}
100
101export function runbookOption(options: PluginOptions): string | null {
102 const value = options.runbook
103 return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
104}
105
106export function nameMatches(name: string, allowed: readonly string[]): boolean {
107 return allowed.some(one => name === one || name.endsWith(`:${one}`))
108}
109
110export type SkillContext = {
111 own: ReadonlyMap<string, SkillReason>
112 always: readonly string[]
113 state: ScopeState
114}
115
116export function skillReason(name: string, ctx: SkillContext): SkillReason | null {
117 const own = ctx.own.get(name)
118 if (own !== undefined) return own
119 if (ctx.state.invoked.includes(name)) return 'invoked'
120 if (nameMatches(name, ctx.always)) return 'always'
121 if (ctx.state.selected.includes(name) || ctx.state.clis.includes(name)) return 'skill-select'
122 if (ctx.state.turn.includes(name)) return 'route-turn'
123 return null
124}
125
126export function agentReason(agent: string, source: string, always: readonly string[]): AgentReason | null {
127 if (source === 'projectSettings') return 'project'
128 if (source === 'built-in' || BUILTIN_AGENTS.includes(agent)) return 'built-in'
129 if (agent.startsWith('harness:')) return 'harness'
130 if (nameMatches(agent, always)) return 'always'
131 return null
132}
133
134function names(value: unknown): string[] {
135 if (!Array.isArray(value)) return []
136 return value
137 .filter(isRecord)
138 .map(one => one.name)
139 .filter((one): one is string => typeof one === 'string' && one.length > 0)
140}
141
142export function parseSkillSelect(raw: unknown): SkillSelect | null {
143 if (!isRecord(raw) || !Array.isArray(raw.selected)) return null
144 return {
145 skills: names(raw.selected),
146 clis: names(raw.cliSelected),
147 total: typeof raw.totalEligible === 'number' ? raw.totalEligible : null,
148 }
149}
150
151export function parseRoute(raw: unknown): string | null {
152 if (!isRecord(raw) || !isRecord(raw.decision)) return null
153 const skill = raw.decision.skill
154 return typeof skill === 'string' && skill.length > 0 ? skill : null
155}
156
157export function invokedSkill(text: string): string | null {
158 const match = /^\s*\/([A-Za-z0-9][A-Za-z0-9:_.-]*)(?:\s|$)/.exec(text)
159 return match?.[1] ?? null
160}
161
162export async function runSkillSelect(
163 run: RunPort,
164 cwd: string,
165 query: string,
166 runbook: string | null,
167): Promise<CliResult<SkillSelect>> {
168 const argv = ['skill-select', '--client', 'claude', '--query', safeTask(query), ...(runbook === null ? [] : ['--runbook', runbook])]
169 const ran = await runJson(run, { bin: 'ai-harness', cwd }, argv, SELECT_TIMEOUT_MS)
170 if (!ran.ok) return ran
171 const parsed = parseSkillSelect(ran.value)
172 return parsed === null ? { ok: false, reason: 'ai-harness skill-select output has no selected list' } : { ok: true, value: parsed }
173}
174
175export async function runRoute(run: RunPort, bin: string, cwd: string, text: string): Promise<string | null> {
176 const ran = await runJson(run, { bin, cwd }, ['route-turn', safeTask(fit(text, ROUTE_TEXT_LIMIT)), '--json'], ROUTE_TIMEOUT_MS)
177 return ran.ok ? parseRoute(ran.value) : null
178}
179
180export function withNames(state: ScopeState, field: 'turn' | 'invoked', add: readonly string[]): ScopeState {
181 const merged = [...new Set([...state[field], ...add])]
182 return { ...state, [field]: merged }
183}
184
185function itemName(item: string): string {
186 const firstLine = item.slice(2).split('\n', 1)[0] ?? ''
187 const cut = firstLine.indexOf(': ')
188 return cut === -1 ? firstLine : firstLine.slice(0, cut)
189}
190
191// Ported from shimo4228/harness-scope (MIT), plugin/hooks/listing.ts: null means the format is not the one this build writes.
192export function filterSkillListing(text: string, keep: (name: string) => boolean): Filtered | null {
193 if (!text.startsWith(SKILL_HEADER)) return null
194 const parts = text.split(/\n(?=- )/)
195 const head = parts[0] ?? ''
196 const items: string[] = []
197 for (const part of parts.slice(1)) {
198 const last = items.length - 1
199 if (/\s/.test(itemName(part)) && last >= 0) items[last] = `${items[last]}\n${part}`
200 else items.push(part)
201 }
202 if (items.length === 0) return null
203 const removed: string[] = []
204 const kept = items.filter(item => {
205 const name = itemName(item)
206 if (keep(name)) return true
207 removed.push(name)
208 return false
209 })
210 return { text: [head, ...kept].join('\n'), removed }
211}
212
213export function scopeHeading(mode: ScopeMode, receipt: Receipt): string {
214 const skills = receipt.skills.size + receipt.hiddenSkills.size
215 const agents = receipt.agents.size + receipt.hiddenAgents.size
216 return [
217 'Scope',
218 mode,
219 skills === 0 ? 'skills not listed yet' : `${receipt.skills.size} of ${skills} skills`,
220 agents === 0 ? 'agents not offered yet' : `${receipt.agents.size} of ${agents} agents`,
221 ].join(' · ')
222}
223
224export function keptRows(receipt: Receipt): { kind: 'skill' | 'agent'; name: string; reason: string }[] {
225 return [
226 ...[...receipt.skills].map(([name, reason]) => ({ kind: 'skill' as const, name, reason })),
227 ...[...receipt.agents].map(([name, reason]) => ({ kind: 'agent' as const, name, reason })),
228 ]
229}
230
231function grouped(entries: ReadonlyMap<string, string>): string[] {
232 const by = new Map<string, string[]>()
233 for (const [name, reason] of entries) by.set(reason, [...(by.get(reason) ?? []), name])
234 return [...by].map(([reason, list]) => ` ${reason}: ${list.join(', ')}`)
235}
236
237export function receiptText(mode: ScopeMode, state: ScopeState, receipt: Receipt): string {
238 if (mode === 'off') return 'scope is off, nothing is hidden.'
239 const verb = mode === 'report' ? 'would hide' : 'hidden'
240 const lines = [
241 `${scopeHeading(mode, receipt)} (skill-select ${state.status}${state.query.length > 0 ? `, query "${fit(state.query, 60)}"` : ''})`,
242 `skills kept ${receipt.skills.size}, ${verb} ${receipt.hiddenSkills.size}`,
243 ...grouped(receipt.skills),
244 `agents kept ${receipt.agents.size}, ${verb} ${receipt.hiddenAgents.size}`,
245 ...grouped(receipt.agents),
246 ]
247 if (receipt.hiddenSkills.size > 0) lines.push(`${verb} skills: ${[...receipt.hiddenSkills].join(', ')}`)
248 if (receipt.hiddenAgents.size > 0) lines.push(`${verb} agents: ${[...receipt.hiddenAgents].join(', ')}`)
249 if (state.note !== null) lines.push(state.note)
250 lines.push(...receipt.notes)
251 lines.push('CLIs, rules and explicitly invoked skills are never hidden; a hidden skill still runs when called.')
252 return lines.join('\n')
253}
254hooks/workers.ts 437 lines1import type { AgentSpawnArgs, AgentSpawnResult, EngineInterface } from 'claude-code'
2
3import type { HarnessConsult, HarnessPlan, HarnessRoleTable, HarnessTask, HarnessTaskState, HarnessVerdict } from '../types'
4import type { RunPort } from './cli'
5import { claudeEffort, CONSULT_REPORT_LIMIT, fit, modelRoleOf, parseNeedsYou, parseVerdict, spawnModelOf } from './plan'
6import type { AgentKind } from './plan'
7
8export type AgentSpec = Parameters<EngineInterface['agent']['register']>[0]
9
10export type SpawnPort = (args: AgentSpawnArgs) => Promise<AgentSpawnResult>
11
12export type RegisterPort = (spec: AgentSpec) => Promise<unknown>
13
14export type LaunchPorts = {
15 spawn: SpawnPort
16 register: RegisterPort
17 run: RunPort
18}
19
20export type SpawnOutcome = { ok: true; agentId: string } | { ok: false; reason: string }
21
22export type Gate = {
23 tail: Promise<void>
24 registered: Map<string, string>
25}
26
27export type CompletedTurn = {
28 answer: string
29 reason: string
30}
31
32export type Transition = {
33 state: HarnessTaskState
34 note?: string
35 verdict?: HarnessVerdict
36 report?: string
37 reviewReport?: string
38 isEnded: boolean
39 question?: string
40 toast?: string
41 startReview: boolean
42}
43
44export const REPORT_LIMIT = 1500
45export const REVIEW_REPORT_LIMIT = 8000
46export const DIFF_LIMIT = 20000
47const STAT_LIMIT = 2000
48const GIT_TIMEOUT_MS = 30000
49
50const AGENT_KINDS: readonly AgentKind[] = ['implementer', 'reviewer', 'reader', 'mechanic']
51
52const TOOL_PLAN = 'mcp__harness__harness_plan'
53const TOOL_RUN = 'mcp__harness__harness_run'
54const TOOL_STATUS = 'mcp__harness__harness_status'
55
56const READ_ONLY_TOOLS = ['Read', 'Glob', 'Grep'] as const
57const MECHANIC_TOOLS = ['Read', 'Glob', 'Grep', 'Edit', 'Write', 'Bash'] as const
58
59const NO_EDIT = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', 'Agent'] as const
60const NO_HARNESS_CONTROL = ['Agent', TOOL_RUN, TOOL_PLAN] as const
61const NO_HARNESS_AT_ALL = [TOOL_PLAN, TOOL_RUN, TOOL_STATUS] as const
62
63const PROMPTS: Readonly<Record<AgentKind, string>> = {
64 implementer:
65 'You are the harness implementer. Implement only the task you are given, inside its owned paths. Run the acceptance checks you are given and fix what fails. Finish with a short report listing the files you changed and the check results. If you are blocked on a human decision, end with one line: NEEDS_YOU: <question>. Never spawn other agents.',
66 reviewer:
67 'You are the harness reviewer. Review the delivered change independently and strictly read-only: inspect the diff of the owned paths and judge the result against the acceptance checks. You have no shell and no edit tools; never try to change anything. End your answer with exactly one final line, either REVIEW_GATE_VERDICT: APPROVE or REVIEW_GATE_VERDICT: CHANGES_REQUIRED.',
68 reader:
69 'You are the harness reader. Read the files and data you are pointed at and report what you find, concisely and with paths. Never edit or create anything. If you need a human decision, end with one line: NEEDS_YOU: <question>.',
70 mechanic:
71 'You are the harness mechanic. Do the mechanical edit you are given (scaffold, fixture, lint fix, conversion, rename) inside its owned paths and nothing else. Run the acceptance checks you are given. Finish with a short report listing the files you changed. If you are blocked on a human decision, end with one line: NEEDS_YOU: <question>. Never spawn other agents.',
72}
73
74const DESCRIPTIONS: Readonly<Record<AgentKind, string>> = {
75 implementer: 'Harness implementer: implements one bounded task. Spawned by the harness mod only.',
76 reviewer: 'Harness reviewer: independent read-only review ending in a verdict line.',
77 reader: 'Harness reader: bulk reading and collection, read-only.',
78 mechanic: 'Harness mechanic: mechanical edits on a small model. Spawned by the harness mod only.',
79}
80
81const SCOPE_LINES: Readonly<Record<AgentKind, string>> = {
82 implementer:
83 'Implement only this task and nothing else. Run the acceptance checks, then report the files you changed. If you are blocked on a human decision, end with one line: NEEDS_YOU: <question>.',
84 reviewer:
85 'Review the delivered change independently and read-only. Inspect the diff of the owned paths and judge it against the acceptance checks. Do not edit anything. End your answer with exactly one final line, either REVIEW_GATE_VERDICT: APPROVE or REVIEW_GATE_VERDICT: CHANGES_REQUIRED.',
86 reader:
87 'Read and report only. Never edit or create files. If you need a human decision, end with one line: NEEDS_YOU: <question>.',
88 mechanic:
89 'Make only this mechanical change inside the owned paths, run the acceptance checks, then report the files you changed. If you are blocked on a human decision, end with one line: NEEDS_YOU: <question>.',
90}
91
92export function agentSpec(kind: AgentKind, spawnModel: string, effort: string | undefined): AgentSpec {
93 const common = {
94 name: kind,
95 description: DESCRIPTIONS[kind],
96 prompt: PROMPTS[kind],
97 model: spawnModel,
98 ...(effort === undefined ? {} : { effort }),
99 }
100 if (kind === 'reviewer') {
101 return { ...common, tools: READ_ONLY_TOOLS, disallowedTools: [...NO_EDIT, ...NO_HARNESS_AT_ALL] }
102 }
103 if (kind === 'reader') {
104 return { ...common, tools: READ_ONLY_TOOLS, disallowedTools: [...NO_EDIT, ...NO_HARNESS_AT_ALL] }
105 }
106 if (kind === 'mechanic') {
107 return { ...common, tools: MECHANIC_TOOLS, disallowedTools: NO_HARNESS_CONTROL }
108 }
109 return { ...common, disallowedTools: NO_HARNESS_CONTROL }
110}
111
112const CONSULT_KIND = 'consultant'
113
114const CONSULT_PROMPT =
115 'You are the harness consultant. You advise the coordinating session before any implementation starts. Work strictly read-only: read the repository with Read, Glob and Grep, never edit, create or run anything and never spawn agents. Answer with a short recommendation in three parts: decomposition (bounded tasks with owned paths), risks (what can break and how to check it) and order (what to do first and what can run in parallel). The coordinating session decides whether to follow it.'
116
117export function consultSpec(spawnModel: string, effort: string | undefined): AgentSpec {
118 return {
119 name: CONSULT_KIND,
120 description: 'Harness consultant: read-only recommendation before implementation. Spawned by the harness mod only.',
121 prompt: CONSULT_PROMPT,
122 model: spawnModel,
123 ...(effort === undefined ? {} : { effort }),
124 tools: READ_ONLY_TOOLS,
125 disallowedTools: [...NO_EDIT, ...NO_HARNESS_AT_ALL],
126 }
127}
128
129export function buildConsultPrompt(plan: HarnessPlan, task: HarnessTask): string {
130 const paths = task.paths.length === 0 ? '- none declared' : task.paths.map(path => `- ${path}`).join('\n')
131 const checks = task.checks.length === 0 ? '- none declared' : task.checks.map(check => `- ${check}`).join('\n')
132 return [
133 `Objective: ${plan.objective}`,
134 `Task ${task.id}: ${task.title}`,
135 '',
136 'Owned paths:',
137 paths,
138 '',
139 'Acceptance checks:',
140 checks,
141 '',
142 'Recommend a decomposition, the main risks and the order of work for this task. Read only; do not implement anything.',
143 ].join('\n')
144}
145
146export async function launchConsult(
147 ports: LaunchPorts,
148 gate: Gate,
149 plan: HarnessPlan,
150 task: HarnessTask,
151 consult: Pick<HarnessConsult, 'cliModel' | 'effort'>,
152 cwd: string,
153): Promise<SpawnOutcome> {
154 const spawnModel = spawnModelOf(consult.cliModel)
155 if (spawnModel === null) return { ok: false, reason: `model ${consult.cliModel} has no Claude Code alias` }
156 const effort = claudeEffort(consult.effort)
157 return exclusive(gate, async (): Promise<SpawnOutcome> => {
158 const key = registrationKey(spawnModel, effort)
159 if (gate.registered.get(CONSULT_KIND) !== key) {
160 try {
161 await ports.register(consultSpec(spawnModel, effort))
162 gate.registered.set(CONSULT_KIND, key)
163 } catch (error) {
164 const message = error instanceof Error ? error.message : String(error)
165 return { ok: false, reason: `agent type ${CONSULT_KIND} not registered: ${message.slice(0, 160)}` }
166 }
167 }
168 try {
169 const spawned = await ports.spawn({
170 subagentType: `harness:${CONSULT_KIND}`,
171 prompt: buildConsultPrompt(plan, task),
172 description: fit(`consult: ${task.title}`, 60),
173 cwd,
174 model: spawnModel,
175 })
176 if (spawned.deny !== undefined) return { ok: false, reason: spawned.deny }
177 if (spawned.agentId === undefined) return { ok: false, reason: 'spawn returned no agent id' }
178 return { ok: true, agentId: spawned.agentId }
179 } catch (error) {
180 const message = error instanceof Error ? error.message : String(error)
181 return { ok: false, reason: message.slice(0, 160) }
182 }
183 })
184}
185
186export function decideConsultTurn(turn: CompletedTurn): Pick<HarnessConsult, 'state' | 'report' | 'note'> {
187 const report = turn.answer.trim()
188 if (turn.reason !== 'answer') return { state: 'failed', note: `consult turn ended: ${turn.reason}` }
189 if (report.length === 0) return { state: 'failed', note: 'consult gave no recommendation' }
190 return { state: 'done', report: fit(report, CONSULT_REPORT_LIMIT) }
191}
192
193export function createGate(): Gate {
194 return { tail: Promise.resolve(), registered: new Map() }
195}
196
197async function exclusive<T>(gate: Gate, work: () => Promise<T>): Promise<T> {
198 const run = gate.tail.then(work)
199 gate.tail = run.then(
200 () => undefined,
201 () => undefined,
202 )
203 return run
204}
205
206function registrationKey(spawnModel: string, effort: string | undefined): string {
207 return `${spawnModel}|${effort ?? ''}`
208}
209
210export async function registerKinds(
211 ports: Pick<LaunchPorts, 'register'>,
212 gate: Gate,
213 roles: HarnessRoleTable,
214): Promise<string[]> {
215 const registered: string[] = []
216 for (const kind of AGENT_KINDS) {
217 const entry = roles[modelRoleOf(kind)]
218 if (entry === undefined) continue
219 const spawnModel = spawnModelOf(entry.cliModel)
220 if (spawnModel === null) continue
221 const effort = claudeEffort(entry.effort)
222 const key = registrationKey(spawnModel, effort)
223 await exclusive(gate, async () => {
224 if (gate.registered.get(kind) === key) return
225 try {
226 await ports.register(agentSpec(kind, spawnModel, effort))
227 gate.registered.set(kind, key)
228 registered.push(kind)
229 } catch {
230 return
231 }
232 })
233 }
234 return registered
235}
236
237async function gitText(run: RunPort, cwd: string, argv: readonly string[]): Promise<string | null> {
238 try {
239 const ran = await run(argv, { cwd, timeoutMs: GIT_TIMEOUT_MS })
240 return ran.exitCode === 0 ? ran.stdout : null
241 } catch {
242 return null
243 }
244}
245
246export async function collectDiff(run: RunPort, cwd: string, paths: readonly string[]): Promise<string> {
247 const scope = paths.length === 0 ? 'the whole repository' : paths.join(', ')
248 const stat = await gitText(run, cwd, ['git', 'diff', '--stat', '--', ...paths])
249 const diff = await gitText(run, cwd, ['git', 'diff', '--', ...paths])
250
251 const lines = [`Diff of ${scope}, produced by the harness with git diff (this block is the change under review):`, '<<<DIFF']
252 if (diff === null) {
253 lines.push('git diff failed; inspect the owned paths with Read, Glob and Grep instead.')
254 } else if (diff.trim().length === 0) {
255 lines.push('git diff is empty: there are no unstaged changes in these paths. Inspect the owned files with Read, Glob and Grep.')
256 } else {
257 if (stat !== null && stat.trim().length > 0) lines.push(fit(stat.trimEnd(), STAT_LIMIT), '')
258 if (diff.length > DIFF_LIMIT) {
259 lines.push(
260 diff.slice(0, DIFF_LIMIT),
261 `[diff truncated: first ${DIFF_LIMIT} of ${diff.length} characters; read the owned paths for the rest]`,
262 )
263 } else {
264 lines.push(diff.trimEnd())
265 }
266 }
267 lines.push('DIFF>>>')
268 return lines.join('\n')
269}
270
271export type LaunchTarget =
272 | { ok: true; cliModel: string; spawnModel: string; effort: string | undefined }
273 | { ok: false; reason: string }
274
275export function resolveLaunch(plan: HarnessPlan, task: HarnessTask, kind: AgentKind): LaunchTarget {
276 const roleKey = modelRoleOf(kind)
277 const entry = plan.roles[roleKey]
278 if (entry === undefined) {
279 return {
280 ok: false,
281 reason: `herdr-jev models list has no ${roleKey} model, so the ${kind} is not started; fix it and call harness_plan again`,
282 }
283 }
284 const spawnModel = spawnModelOf(entry.cliModel)
285 if (spawnModel === null) {
286 return { ok: false, reason: `model ${entry.cliModel} has no Claude Code alias` }
287 }
288 const effort = kind === 'reviewer' ? entry.effort : (task.effort ?? entry.effort)
289 return { ok: true, cliModel: entry.cliModel, spawnModel, effort: claudeEffort(effort) }
290}
291
292export type PromptExtras = {
293 report?: string
294 diff?: string
295}
296
297export function buildPrompt(plan: HarnessPlan, task: HarnessTask, kind: AgentKind, extras: PromptExtras = {}): string {
298 const { report, diff } = extras
299 const paths = task.paths.length === 0 ? '- none declared' : task.paths.map(path => `- ${path}`).join('\n')
300 const checks = task.checks.length === 0 ? '- none declared' : task.checks.map(check => `- ${check}`).join('\n')
301 const lines = [
302 `Objective: ${plan.objective}`,
303 `Task ${task.id}: ${task.title}`,
304 '',
305 'Owned paths:',
306 paths,
307 '',
308 'Acceptance checks:',
309 checks,
310 ]
311 if (kind === 'reviewer' && report !== undefined && report.length > 0) {
312 lines.push('', 'Implementer report:', fit(report, REPORT_LIMIT))
313 }
314 if (kind === 'reviewer' && diff !== undefined && diff.length > 0) {
315 lines.push('', diff)
316 }
317 if ((kind === 'implementer' || kind === 'mechanic') && task.reviewReport !== undefined && task.reviewReport.length > 0) {
318 lines.push(
319 '',
320 'Previous review findings (the reviewer asked for changes; address every point before anything else):',
321 fit(task.reviewReport, REVIEW_REPORT_LIMIT),
322 )
323 }
324 lines.push('', SCOPE_LINES[kind])
325 return lines.join('\n')
326}
327
328export async function launchAgent(
329 ports: LaunchPorts,
330 gate: Gate,
331 plan: HarnessPlan,
332 task: HarnessTask,
333 kind: AgentKind,
334 cwd: string,
335 report?: string,
336): Promise<SpawnOutcome> {
337 const target = resolveLaunch(plan, task, kind)
338 if (!target.ok) return target
339
340 const diff = kind === 'reviewer' ? await collectDiff(ports.run, cwd, task.paths) : undefined
341
342 return exclusive(gate, async (): Promise<SpawnOutcome> => {
343 const key = registrationKey(target.spawnModel, target.effort)
344 if (gate.registered.get(kind) !== key) {
345 try {
346 await ports.register(agentSpec(kind, target.spawnModel, target.effort))
347 gate.registered.set(kind, key)
348 } catch (error) {
349 const message = error instanceof Error ? error.message : String(error)
350 return { ok: false, reason: `agent type ${kind} not registered: ${message.slice(0, 160)}` }
351 }
352 }
353
354 try {
355 const spawned = await ports.spawn({
356 subagentType: `harness:${kind}`,
357 prompt: buildPrompt(plan, task, kind, { report, diff }),
358 description: fit(`${kind}: ${task.title}`, 60),
359 cwd,
360 model: target.spawnModel,
361 })
362 if (spawned.deny !== undefined) return { ok: false, reason: spawned.deny }
363 if (spawned.agentId === undefined) return { ok: false, reason: 'spawn returned no agent id' }
364 return { ok: true, agentId: spawned.agentId }
365 } catch (error) {
366 const message = error instanceof Error ? error.message : String(error)
367 return { ok: false, reason: message.slice(0, 160) }
368 }
369 })
370}
371
372export function decideWorkerTurn(task: HarnessTask, turn: CompletedTurn): Transition {
373 const label = fit(task.title, 40)
374 const report = fit(turn.answer.trim(), REPORT_LIMIT)
375
376 if (turn.reason !== 'answer') {
377 return {
378 state: 'failed',
379 note: `turn ended: ${turn.reason}`,
380 report,
381 isEnded: true,
382 toast: `harness: "${label}" failed (${turn.reason})`,
383 startReview: false,
384 }
385 }
386
387 const question = parseNeedsYou(turn.answer)
388 if (question !== null) {
389 return {
390 state: 'needs_you',
391 note: question,
392 report,
393 isEnded: true,
394 question,
395 toast: `harness: "${label}" needs you`,
396 startReview: false,
397 }
398 }
399
400 if (task.review) {
401 return { state: 'review', report, isEnded: false, startReview: true }
402 }
403
404 return { state: 'done', report, isEnded: true, startReview: false }
405}
406
407export function decideReviewTurn(task: HarnessTask, turn: CompletedTurn): Transition {
408 const label = fit(task.title, 40)
409 const verdict = turn.reason === 'answer' ? parseVerdict(turn.answer) : null
410
411 if (verdict === 'APPROVE') {
412 return { state: 'approved', verdict, isEnded: true, startReview: false }
413 }
414
415 if (verdict === 'CHANGES_REQUIRED') {
416 return {
417 state: 'failed',
418 verdict,
419 reviewReport: fit(turn.answer.trim(), REVIEW_REPORT_LIMIT),
420 note: fit(turn.answer.trim().split('\n').slice(-6).join(' '), 240),
421 isEnded: true,
422 toast: `harness: review requires changes on "${label}"`,
423 startReview: false,
424 }
425 }
426
427 const why = turn.reason === 'answer' ? 'reviewer gave no verdict line' : `reviewer turn ended: ${turn.reason}`
428 return {
429 state: 'needs_you',
430 note: why,
431 isEnded: true,
432 question: `${task.title}: ${why}`,
433 toast: `harness: review of "${label}" needs you`,
434 startReview: false,
435 }
436}
437hooks/pr-detect.ts 756 lines1export type Word = { text: string; literal: boolean }
2
3export type Cmd = { words: Word[]; end: string; depth: number; sub: boolean }
4
5export type PrPlatform = 'github' | 'azure'
6
7export type PrCommand = {
8 platform: PrPlatform
9 action: string
10 via: 'cli' | 'rest'
11 repo: string | null
12 targetsPr: boolean
13 foreign: string | null
14}
15
16export type ReviewRun = { json: boolean }
17
18export type Analysis = { pr: PrDetection | null; review: ReviewRun | null }
19
20export type DirectoryPlan =
21 | { kind: 'session' }
22 | { kind: 'cd'; target: string }
23 | { kind: 'unknown'; why: string }
24
25export type PrDetection = {
26 commands: PrCommand[]
27 directory: DirectoryPlan
28}
29
30type Sink = Cmd[]
31
32const MAX_NEST = 48
33
34const ARITH_SCAN = 512
35
36export function lex(src: string): Cmd[] {
37 const main: Cmd[] = []
38 const subs: Cmd[] = []
39 let pos = 0
40 let nest = 0
41
42 const run = (stop: ')' | '`' | null, base: number, sink: Sink, sub: boolean): void => {
43 let level = base
44 let words: Word[] = []
45 let text = ''
46 let literal = true
47 let inWord = false
48 let skipNext = false
49 let heredoc: { strip: boolean } | null = null
50 let fnPending = false
51 let fnBraces = 0
52 const pending: { tag: string; strip: boolean }[] = []
53
54 const endWord = (): void => {
55 if (!inWord) return
56 if (heredoc !== null) {
57 pending.push({ tag: text, strip: heredoc.strip })
58 heredoc = null
59 } else if (skipNext) {
60 skipNext = false
61 } else {
62 words.push({ text, literal })
63 }
64 text = ''
65 literal = true
66 inWord = false
67 }
68
69 const endCmd = (op: string): void => {
70 endWord()
71 skipNext = false
72 heredoc = null
73 if (words.length > 0) {
74 const first = (words[0] as Word).text
75 if (fnBraces > 0) {
76 if (first === '{') fnBraces += 1
77 else if (first === '}') fnBraces -= 1
78 } else if (fnPending && first === '{') {
79 fnPending = false
80 fnBraces = 1
81 } else if (first === 'function') {
82 fnPending = true
83 if (words.some(word => word.text === '{')) {
84 fnPending = false
85 fnBraces = 1
86 }
87 } else {
88 fnPending = false
89 sink.push({ words, end: op, depth: level, sub })
90 }
91 }
92 words = []
93 }
94
95 const discard = (): void => {
96 if (nest >= MAX_NEST) {
97 pos = src.length
98 return
99 }
100 nest += 1
101 run(')', base + 1, [], false)
102 nest -= 1
103 }
104
105 const arithEnd = (from: number): number => {
106 let depth = 2
107 const limit = Math.min(src.length, from + ARITH_SCAN)
108 for (let i = from; i < limit; i += 1) {
109 const ch = src[i]
110 if (ch === '(') depth += 1
111 else if (ch === ')') {
112 depth -= 1
113 if (depth === 0) return src[i - 1] === ')' ? i + 1 : -1
114 }
115 }
116 return -1
117 }
118
119 const dropFd = (): void => {
120 if (inWord && literal && /^\d+$/.test(text)) {
121 text = ''
122 literal = true
123 inWord = false
124 } else {
125 endWord()
126 }
127 }
128
129 const substitute = (closer: ')' | '`'): void => {
130 if (nest >= MAX_NEST) {
131 pos = src.length
132 return
133 }
134 nest += 1
135 run(closer, base + 1, subs, true)
136 nest -= 1
137 text += '$S'
138 literal = false
139 inWord = true
140 }
141
142 const readBodies = (): void => {
143 for (const one of pending.splice(0)) {
144 while (pos < src.length) {
145 const nl = src.indexOf('\n', pos)
146 const line = nl < 0 ? src.slice(pos) : src.slice(pos, nl)
147 pos = nl < 0 ? src.length : nl + 1
148 if ((one.strip ? line.replace(/^\t+/, '') : line) === one.tag) break
149 }
150 }
151 }
152
153 while (pos < src.length) {
154 const c = src[pos] as string
155 const next = src[pos + 1]
156
157 if (c === '\\') {
158 if (next === '\n') {
159 pos += 2
160 continue
161 }
162 text += next ?? ''
163 literal = false
164 inWord = true
165 pos += 2
166 continue
167 }
168
169 if (c === "'") {
170 const close = src.indexOf("'", pos + 1)
171 text += close < 0 ? src.slice(pos + 1) : src.slice(pos + 1, close)
172 inWord = true
173 pos = close < 0 ? src.length : close + 1
174 continue
175 }
176
177 if (c === '"') {
178 pos += 1
179 inWord = true
180 while (pos < src.length) {
181 const d = src[pos] as string
182 const n = src[pos + 1]
183 if (d === '"') {
184 pos += 1
185 break
186 }
187 if (d === '\\') {
188 if (n === '\n') {
189 pos += 2
190 } else if (n === '"' || n === '\\' || n === '$' || n === '`') {
191 text += n
192 literal = false
193 pos += 2
194 } else {
195 text += '\\'
196 pos += 1
197 }
198 continue
199 }
200 if (d === '$' && n === '(') {
201 if (src[pos + 2] === '(') {
202 const end = arithEnd(pos + 3)
203 if (end >= 0) {
204 text += '$A'
205 literal = false
206 pos = end
207 continue
208 }
209 }
210 pos += 2
211 substitute(')')
212 continue
213 }
214 if (d === '`') {
215 pos += 1
216 substitute('`')
217 continue
218 }
219 if (d === '$') literal = false
220 text += d
221 pos += 1
222 }
223 continue
224 }
225
226 if (c === '$' && next === '(') {
227 if (src[pos + 2] === '(') {
228 const end = arithEnd(pos + 3)
229 if (end >= 0) {
230 text += '$A'
231 literal = false
232 inWord = true
233 pos = end
234 continue
235 }
236 }
237 pos += 2
238 substitute(')')
239 continue
240 }
241
242 if (c === '`') {
243 if (stop === '`') {
244 endCmd('`')
245 pos += 1
246 return
247 }
248 pos += 1
249 substitute('`')
250 continue
251 }
252
253 if (c === '$') {
254 text += '$'
255 literal = false
256 inWord = true
257 pos += 1
258 continue
259 }
260
261 if (c === '#' && !inWord) {
262 const nl = src.indexOf('\n', pos)
263 pos = nl < 0 ? src.length : nl
264 continue
265 }
266
267 if (c === ' ' || c === '\t' || c === '\r') {
268 endWord()
269 pos += 1
270 continue
271 }
272
273 if (c === '\n') {
274 endCmd('\n')
275 pos += 1
276 readBodies()
277 continue
278 }
279
280 if (c === ';') {
281 endCmd(';')
282 pos += 1
283 continue
284 }
285
286 if (c === '&') {
287 if (next === '&') {
288 endCmd('&&')
289 pos += 2
290 } else if (next === '>') {
291 dropFd()
292 skipNext = true
293 pos += src[pos + 2] === '>' ? 3 : 2
294 } else {
295 endCmd('&')
296 pos += 1
297 }
298 continue
299 }
300
301 if (c === '|') {
302 if (next === '|') {
303 endCmd('||')
304 pos += 2
305 } else {
306 endCmd('|')
307 pos += next === '&' ? 2 : 1
308 }
309 continue
310 }
311
312 if (c === '(') {
313 if (next === '(' && !inWord && words.length === 0) {
314 const end = arithEnd(pos + 2)
315 if (end >= 0) {
316 pos = end
317 continue
318 }
319 }
320 if (inWord && /^[A-Za-z_][A-Za-z0-9_]*\+?=$/.test(text)) {
321 text = ''
322 literal = true
323 inWord = false
324 pos += 1
325 discard()
326 continue
327 }
328 if (fnPending && !inWord && words.length === 0) {
329 fnPending = false
330 pos += 1
331 discard()
332 continue
333 }
334 const closing = /^[ \t]*\)/.exec(src.slice(pos + 1, pos + 40))
335 if (closing !== null && (inWord || words.length > 0)) {
336 endWord()
337 words = []
338 fnPending = true
339 pos += 1 + closing[0].length
340 continue
341 }
342 endCmd('(')
343 level += 1
344 pos += 1
345 continue
346 }
347
348 if (c === ')') {
349 endCmd(')')
350 pos += 1
351 if (stop === ')' && level === base) return
352 if (level > base) level -= 1
353 continue
354 }
355
356 if (c === '<' || c === '>') {
357 if (next === '(') {
358 pos += 2
359 substitute(')')
360 continue
361 }
362 dropFd()
363 if (c === '<' && next === '<' && src[pos + 2] === '<') {
364 skipNext = true
365 pos += 3
366 continue
367 }
368 if (c === '<' && next === '<') {
369 const strip = src[pos + 2] === '-'
370 heredoc = { strip }
371 pos += strip ? 3 : 2
372 continue
373 }
374 skipNext = true
375 pos += 1
376 if (src[pos] === '>' || src[pos] === '&' || src[pos] === '|') pos += 1
377 continue
378 }
379
380 if (c === '*' || c === '?' || c === '[') literal = false
381 text += c
382 inWord = true
383 pos += 1
384 }
385
386 endCmd('end')
387 }
388
389 run(null, 0, main, false)
390 return [...main, ...subs]
391}
392
393const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*\+?=/
394
395const KEYWORDS = new Set(['if', 'then', 'elif', 'else', 'while', 'until', 'do', '!', '{', '}'])
396
397type Wrapper = { takes: readonly string[]; positional: number }
398
399const WRAPPERS: Record<string, Wrapper> = Object.assign(Object.create(null) as Record<string, Wrapper>, {
400 command: { takes: [], positional: 0 },
401 exec: { takes: ['-a'], positional: 0 },
402 builtin: { takes: [], positional: 0 },
403 nohup: { takes: [], positional: 0 },
404 time: { takes: ['-o', '-f'], positional: 0 },
405 setsid: { takes: [], positional: 0 },
406 stdbuf: { takes: ['-i', '-o', '-e'], positional: 0 },
407 sudo: { takes: ['-u', '-g', '-C', '-h', '-p', '-r', '-t', '-U', '-D', '-R', '-T', '--user', '--group'], positional: 0 },
408 doas: { takes: ['-u', '-C'], positional: 0 },
409 env: { takes: ['-u', '-C', '-S', '--unset', '--chdir'], positional: 0 },
410 nice: { takes: ['-n', '--adjustment'], positional: 0 },
411 ionice: { takes: ['-c', '-n', '-p', '--class', '--classdata'], positional: 0 },
412 timeout: { takes: ['-s', '-k', '--signal', '--kill-after'], positional: 1 },
413 xargs: {
414 takes: ['-I', '-n', '-P', '-L', '-d', '-E', '-s', '-a', '--max-args', '--max-procs', '--max-lines', '--delimiter', '--arg-file', '--replace'],
415 positional: 0,
416 },
417 rtk: { takes: [], positional: 0 },
418})
419
420const baseName = (word: string): string => word.split('/').pop() ?? word
421
422export function commandStart(words: readonly Word[]): number {
423 let i = 0
424 while (i < words.length) {
425 const word = (words[i] as Word).text
426 if (ASSIGNMENT.test(word) || KEYWORDS.has(word)) {
427 i += 1
428 continue
429 }
430 const base = baseName(word)
431 if (!Object.hasOwn(WRAPPERS, base)) return i
432 const spec = WRAPPERS[base] as Wrapper
433 i += 1
434 while (i < words.length) {
435 const flag = (words[i] as Word).text
436 if (flag === '--') {
437 i += 1
438 break
439 }
440 if (!flag.startsWith('-') || flag === '-') break
441 i += 1
442 if (spec.takes.includes(flag)) i += 1
443 }
444 for (let k = 0; k < spec.positional; k += 1) {
445 if (i < words.length && !(words[i] as Word).text.startsWith('-')) i += 1
446 }
447 if (base === 'rtk' && words[i]?.text === 'proxy') i += 1
448 }
449 return -1
450}
451
452
453const GH_ACTIONS = new Set(['create', 'new', 'edit', 'ready'])
454const WRITE_METHODS = new Set(['POST', 'PATCH', 'PUT'])
455const GH_PATH = /^\/?repos\/([^/\s]+\/[^/\s]+)\/pulls(?:\/\d+)?(?:[?#].*)?$/i
456const GH_HOSTED = /^https?:\/\/api\.github\.com\/repos\/([^/\s]+\/[^/\s]+)\/pulls(?:\/\d+)?(?:[?#].*)?$/i
457const GH_ENTERPRISE = /^https?:\/\/[^/\s]+\/api\/v3\/repos\/([^/\s]+\/[^/\s]+)\/pulls(?:\/\d+)?(?:[?#].*)?$/i
458const AZURE_PULLS = /\/git\/repositories\/([^/\s]+)\/pullrequests(?:\/\d+)?(?:[?#].*)?$/i
459const PLACEHOLDER = '{owner}/{repo}'
460
461const hasAny = (args: readonly string[], ...names: string[]): boolean => args.some(arg => names.includes(arg))
462
463function repoFlag(args: readonly string[], long: string, short: string | null): string | null {
464 for (let i = 0; i < args.length; i += 1) {
465 const arg = args[i] as string
466 if (arg === long || (short !== null && arg === short)) return args[i + 1] ?? ''
467 if (arg.startsWith(`${long}=`)) return arg.slice(long.length + 1)
468 if (short !== null && arg.startsWith(`${short}=`)) return arg.slice(short.length + 1)
469 }
470 return null
471}
472
473type Flags = { methodFlags: string[]; dataFlags: string[]; getFlags: string[]; valueFlags: string[]; valueShorts: string }
474
475const CURL: Flags = {
476 methodFlags: ['-X', '--request'],
477 dataFlags: ['-d', '--data', '--data-raw', '--data-binary', '--data-urlencode', '--data-ascii', '--json', '-F', '--form', '--form-string'],
478 getFlags: ['-G', '--get'],
479 valueFlags: [
480 '-X', '--request', '-H', '--header', '-d', '--data', '--data-raw', '--data-binary', '--data-urlencode', '--data-ascii',
481 '--json', '-F', '--form', '--form-string', '-u', '--user', '-o', '--output', '-A', '--user-agent', '-e', '--referer',
482 '-b', '--cookie', '-c', '--cookie-jar', '-w', '--write-out', '-T', '--upload-file', '-x', '--proxy', '-m', '--max-time',
483 '--connect-timeout', '--retry', '-K', '--config', '--cacert', '--cert', '-E', '--key', '--resolve', '--oauth2-bearer',
484 '--aws-sigv4', '-r', '--range', '--url-query', '-D', '--dump-header',
485 ],
486 valueShorts: 'XHdFuoAebcwTxmKErD',
487}
488
489const GH_API: Flags = {
490 methodFlags: ['-X', '--method'],
491 dataFlags: ['-f', '-F', '--field', '--raw-field', '--input'],
492 getFlags: [],
493 valueFlags: ['-X', '--method', '-H', '--header', '-f', '-F', '--field', '--raw-field', '--input', '-q', '--jq', '-t', '--template', '--hostname', '--cache', '-p', '--preview'],
494 valueShorts: 'XHfFqtp',
495}
496
497const WGET: Flags = {
498 methodFlags: ['--method'],
499 dataFlags: ['--post-data', '--post-file', '--body-data', '--body-file'],
500 getFlags: [],
501 valueFlags: ['--method', '--post-data', '--post-file', '--body-data', '--body-file', '--header', '--user', '--password', '-O', '--output-document', '-o', '--output-file', '-U', '--user-agent', '-e', '--execute'],
502 valueShorts: 'OoUe',
503}
504
505const HTTPIE: Flags = {
506 methodFlags: [],
507 dataFlags: [],
508 getFlags: [],
509 valueFlags: ['-a', '--auth', '-A', '--auth-type', '--session', '--session-read-only', '-o', '--output', '--timeout', '-p', '--print', '--verify', '--proxy', '--cert', '--cert-key', '--max-redirects', '-s', '--style', '--pretty'],
510 valueShorts: 'aAopsS',
511}
512
513type Combo = { letter: string | null; attached: boolean; rest: string; hasG: boolean }
514
515function comboOf(arg: string, flags: Flags): Combo | null {
516 if (!arg.startsWith('-') || arg.startsWith('--') || arg.length < 2) return null
517 for (let i = 1; i < arg.length; i += 1) {
518 const ch = arg[i] as string
519 if (flags.valueShorts.includes(ch)) {
520 return { letter: ch, attached: i < arg.length - 1, rest: arg.slice(i + 1), hasG: arg.slice(1, i).includes('G') }
521 }
522 if (!/[A-Za-z]/.test(ch)) return null
523 }
524 return { letter: null, attached: false, rest: '', hasG: arg.includes('G') }
525}
526
527function operandsOf(args: readonly string[], flags: Flags): string[] {
528 const operands: string[] = []
529 let skip = false
530 for (const arg of args) {
531 if (skip) {
532 skip = false
533 continue
534 }
535 if (arg.startsWith('-') && arg.length > 1) {
536 if (flags.valueFlags.includes(arg)) skip = true
537 else {
538 const combo = comboOf(arg, flags)
539 if (combo !== null && combo.letter !== null && !combo.attached) skip = true
540 }
541 continue
542 }
543 operands.push(arg)
544 }
545 return operands
546}
547
548function writeMethodOf(args: readonly string[], spec: Flags): string | null {
549 let method: string | null = null
550 let data = false
551 let get = false
552 const shortData = spec.dataFlags.filter(flag => /^-[A-Za-z]$/.test(flag)).map(flag => flag.slice(1))
553 for (let i = 0; i < args.length; i += 1) {
554 const arg = args[i] as string
555 if (spec.methodFlags.includes(arg)) method = (args[i + 1] ?? '').toUpperCase()
556 else if (arg.startsWith('--request=') || arg.startsWith('--method=')) method = arg.slice(arg.indexOf('=') + 1).toUpperCase()
557 else if (spec.dataFlags.some(flag => arg === flag || arg.startsWith(`${flag}=`)) && arg.startsWith('--')) data = true
558 else if (spec.getFlags.includes(arg)) get = true
559 else {
560 const combo = comboOf(arg, spec)
561 if (combo === null) continue
562 if (combo.hasG && spec.getFlags.includes('-G')) get = true
563 if (combo.letter === 'X' && spec.methodFlags.includes('-X')) {
564 method = (combo.attached ? combo.rest : (args[i + 1] ?? '')).toUpperCase()
565 } else if (combo.letter !== null && shortData.includes(combo.letter)) data = true
566 }
567 }
568 if (method !== null && method.length > 0) return method
569 return data && !get ? 'POST' : null
570}
571
572const isUrlOperand = (arg: string): boolean => !/\s/.test(arg) && (/^https?:\/\//i.test(arg) || arg.startsWith('$'))
573
574const repoFor = (raw: string): string | null => (raw === PLACEHOLDER ? null : raw)
575
576function githubRepoOf(operand: string): { repo: string | null } | null {
577 const match = GH_HOSTED.exec(operand) ?? GH_ENTERPRISE.exec(operand)
578 return match === null ? null : { repo: repoFor(match[1] ?? '') }
579}
580
581function detectGhApi(args: readonly string[]): PrCommand | null {
582 const method = writeMethodOf(args, GH_API)
583 if (method === null || !WRITE_METHODS.has(method)) return null
584 for (const operand of operandsOf(args, GH_API)) {
585 const path = GH_PATH.exec(operand)
586 const found = path !== null ? { repo: repoFor(path[1] ?? '') } : githubRepoOf(operand)
587 if (found !== null) {
588 return { platform: 'github', action: method === 'POST' ? 'create' : 'edit', via: 'rest', repo: found.repo, targetsPr: method !== 'POST', foreign: null }
589 }
590 }
591 return null
592}
593
594function detectGh(args: readonly string[]): PrCommand | null {
595 let i = 0
596 while (i < args.length && (args[i] as string).startsWith('-')) {
597 i += args[i] === '-R' || args[i] === '--repo' ? 2 : 1
598 }
599 const sub = args[i]
600 const after = args.slice(i + 1)
601 if (sub === 'api') return detectGhApi(after)
602 if (sub !== 'pr') return null
603
604 let j = 0
605 while (j < after.length && (after[j] as string).startsWith('-')) {
606 j += after[j] === '-R' || after[j] === '--repo' ? 2 : 1
607 }
608 const action = after[j]
609 if (action === undefined || !GH_ACTIONS.has(action)) return null
610 const rest = after.slice(j + 1)
611 if (hasAny(rest, '--help', '-h')) return null
612 if ((action === 'create' || action === 'new') && hasAny(rest, '--dry-run')) return null
613 if (action === 'ready' && hasAny(rest, '--undo')) return null
614
615 const repo = repoFlag(args, '--repo', '-R')
616 const first = rest[0]
617 const selector = rest.some(arg => /^#?\d+$/.test(arg) || /^https?:\/\/\S+\/pull\/\d+/.test(arg))
618 const targetsPr = (action === 'edit' || action === 'ready') && ((first !== undefined && !first.startsWith('-')) || selector)
619 const head = (action === 'create' || action === 'new') && rest.some(arg => arg === '--head' || arg === '-H' || arg.startsWith('--head='))
620 return { platform: 'github', action, via: 'cli', repo, targetsPr, foreign: head ? 'the command names another head branch (--head)' : null }
621}
622
623function detectRest(name: string, args: readonly string[]): PrCommand | null {
624 let method: string | null
625 let flags: Flags
626 if (name === 'curl') {
627 flags = CURL
628 method = writeMethodOf(args, flags)
629 } else if (name === 'wget') {
630 flags = WGET
631 method = writeMethodOf(args, flags)
632 } else {
633 flags = HTTPIE
634 const operands = operandsOf(args, flags)
635 const given = operands.find(arg => /^(GET|POST|PUT|PATCH|DELETE|HEAD)$/i.test(arg))
636 const implicit = operands.some(arg => /^[^\s=:/]+(=(?!=)|:=)/.test(arg))
637 method = given !== undefined ? given.toUpperCase() : implicit ? 'POST' : null
638 }
639 if (method === null || !WRITE_METHODS.has(method)) return null
640 for (const operand of operandsOf(args, flags)) {
641 if (!isUrlOperand(operand)) continue
642 const azure = AZURE_PULLS.exec(operand)
643 if (azure !== null && (operand.startsWith('$') || /^https?:\/\/[^/]+\/(?:[^/]+\/)*_apis\//i.test(operand))) {
644 return { platform: 'azure', action: method === 'POST' ? 'create' : 'update', via: 'rest', repo: azure[1] ?? null, targetsPr: method !== 'POST', foreign: null }
645 }
646 const github = githubRepoOf(operand)
647 if (github !== null) {
648 return { platform: 'github', action: method === 'POST' ? 'create' : 'edit', via: 'rest', repo: github.repo, targetsPr: method !== 'POST', foreign: null }
649 }
650 }
651 return null
652}
653
654function detectAz(args: readonly string[]): PrCommand | null {
655 if (args[0] !== 'repos' || args[1] !== 'pr') return null
656 const action = args[2]
657 if (action !== 'create' && action !== 'update') return null
658 const rest = args.slice(3)
659 if (hasAny(rest, '--help', '-h')) return null
660 return { platform: 'azure', action, via: 'cli', repo: repoFlag(rest, '--repository', '-r'), targetsPr: action === 'update', foreign: null }
661}
662
663export function detectCommand(words: readonly Word[]): PrCommand | null {
664 const start = commandStart(words)
665 if (start < 0) return null
666 const name = baseName((words[start] as Word).text)
667 const args = words.slice(start + 1).map(word => word.text)
668 let hit: PrCommand | null = null
669 if (name === 'gh') hit = detectGh(args)
670 else if (name === 'az') hit = detectAz(args)
671 else if (name === 'curl' || name === 'wget' || name === 'http' || name === 'https' || name === 'xh') hit = detectRest(name, args)
672 if (hit !== null && hit.foreign === null && words.slice(0, start).some(word => word.text.startsWith('GH_REPO='))) {
673 hit = { ...hit, foreign: 'the command sets GH_REPO' }
674 }
675 return hit
676}
677
678export function detectReview(words: readonly Word[]): ReviewRun | null {
679 const start = commandStart(words)
680 if (start < 0) return null
681 if (baseName((words[start] as Word).text) !== 'herdr-jev') return null
682 const args = words.slice(start + 1).map(word => word.text)
683 if (args[0] !== 'review' || hasAny(args, '--help', '-h')) return null
684 return { json: args.includes('--json') }
685}
686
687const DIRECTORY_COMMANDS = new Set(['cd', 'pushd', 'popd', 'chdir'])
688
689export function directoryPlan(cmds: readonly Cmd[]): DirectoryPlan {
690 const changing = cmds.filter(cmd => {
691 const start = commandStart(cmd.words)
692 return start >= 0 && DIRECTORY_COMMANDS.has(baseName((cmd.words[start] as Word).text))
693 })
694 if (changing.length === 0) return { kind: 'session' }
695 const first = cmds[0]
696 if (
697 changing.length === 1 &&
698 first !== undefined &&
699 changing[0] === first &&
700 first.depth === 0 &&
701 !first.sub &&
702 first.end === '&&' &&
703 first.words.length === 2 &&
704 first.words[0]?.text === 'cd'
705 ) {
706 const target = first.words[1] as Word
707 if (target.literal && target.text.length > 0 && !target.text.startsWith('-')) return { kind: 'cd', target: target.text }
708 }
709 return { kind: 'unknown', why: 'the command changes directory in a way the gate does not follow' }
710}
711
712function isLeadingCd(cmd: Cmd): boolean {
713 const target = cmd.words[1]
714 return (
715 cmd.depth === 0 &&
716 !cmd.sub &&
717 cmd.end === '&&' &&
718 cmd.words.length === 2 &&
719 cmd.words[0]?.text === 'cd' &&
720 target !== undefined &&
721 target.literal &&
722 target.text.length > 0 &&
723 !target.text.startsWith('-')
724 )
725}
726
727function isAnchoredReview(cmds: readonly Cmd[]): boolean {
728 const reviewIndex = cmds.findIndex(cmd => detectReview(cmd.words) !== null)
729 if (reviewIndex < 0) return false
730 const review = cmds[reviewIndex] as Cmd
731 if (review.depth !== 0 || review.sub) return false
732 return cmds.every((cmd, index) => index === reviewIndex || (index === 0 && reviewIndex === 1 && isLeadingCd(cmd)))
733}
734
735export function analyze(command: string): Analysis {
736 const cmds = lex(command)
737 const commands: PrCommand[] = []
738 let review: ReviewRun | null = null
739 for (const cmd of cmds) {
740 const hit = detectCommand(cmd.words)
741 if (hit !== null) commands.push(hit)
742 const run = detectReview(cmd.words)
743 if (run !== null) review = run
744 }
745 if (review !== null && !isAnchoredReview(cmds)) review = null
746 return { pr: commands.length === 0 ? null : { commands, directory: directoryPlan(cmds) }, review }
747}
748
749export function detectPr(command: string): PrDetection | null {
750 return analyze(command).pr
751}
752
753export function prCommandOf(command: string): PrCommand | null {
754 return detectPr(command)?.commands[0] ?? null
755}
756types/index.d.ts 194 lines1export type HarnessRole = 'advisor' | 'implementer' | 'reviewer' | 'reader'
2
3export type HarnessModelRole = 'advisor' | 'implementer' | 'reviewer' | 'researcher' | 'reader'
4
5export type HarnessTaskState =
6 | 'proposed'
7 | 'advisor'
8 | 'running'
9 | 'review'
10 | 'approved'
11 | 'verified'
12 | 'failed'
13 | 'needs_you'
14 | 'done'
15
16export type HarnessVerdict = 'APPROVE' | 'CHANGES_REQUIRED'
17
18export type HarnessRoleModel = {
19 model: string
20 cliModel: string
21 effort: string
22 readonly: boolean
23 fallbackActive: boolean
24}
25
26export type HarnessRoleTable = Partial<Record<HarnessModelRole, HarnessRoleModel>>
27
28export type HarnessConsultState = 'running' | 'done' | 'failed'
29
30export type HarnessConsult = {
31 model: string
32 cliModel: string
33 effort: string | null
34 state: HarnessConsultState
35 agentId?: string
36 report?: string
37 note?: string
38}
39
40export type HarnessTask = {
41 id: string
42 title: string
43 role: HarnessRole
44 writes: boolean
45 model: string | null
46 effort: string | null
47 reason: string
48 deps: string[]
49 paths: string[]
50 checks: string[]
51 state: HarnessTaskState
52 review: boolean
53 reviewModel: string | null
54 reviewStarting?: boolean
55 agentId?: string
56 reviewAgentId?: string
57 verdict?: HarnessVerdict
58 startedAt?: number
59 endedAt?: number
60 lastTool?: string
61 toolCount: number
62 note?: string
63 report?: string
64 reviewReport?: string
65 consult?: HarnessConsult
66}
67
68export type HarnessPlan = {
69 objective: string
70 advisorModel: string
71 roles: HarnessRoleTable
72 tasks: HarnessTask[]
73 at?: number
74}
75
76export type HarnessWorkerRow = {
77 taskId: string
78 agentId: string
79 role: HarnessRole
80 writes: boolean
81 model: string | null
82 lastTool: string | null
83 toolCount: number
84 startedAt: number
85}
86
87export type HarnessHumanAsk = {
88 id: string
89 taskId: string
90 question: string
91 at: number
92}
93
94export type HarnessReview = {
95 ok: boolean
96 status: string | null
97 detail: string | null
98 reason: string | null
99 isTimedOut?: boolean
100 at: number
101}
102
103export type HarnessReviewIdentity = {
104 client: string
105 session: string
106 at: number
107 status: string | null
108}
109
110export type ScopeState = {
111 status: 'pending' | 'ready' | 'partial'
112 selected: string[]
113 clis: string[]
114 turn: string[]
115 invoked: string[]
116 query: string
117 total: number | null
118 note: string | null
119}
120
121export type ClaimKind = 'test' | 'lint' | 'build' | 'ci' | 'verified'
122
123export type ClaimCheck = 'test' | 'lint' | 'build' | 'ci' | 'push'
124
125export type ClaimEntry =
126 | { seq: number; type: 'edit'; path: string; agentId?: string }
127 | { seq: number; type: 'run'; checks: ClaimCheck[]; command: string; isOk: boolean; isInterrupted: boolean; agentId?: string }
128
129export type ClaimWarning = {
130 kind: ClaimKind
131 quote: string
132 reason: string
133}
134
135export type HarnessUsageLimit = {
136 kind: 'five_hour' | 'seven_day'
137 percentUsed: number
138}
139
140export type HarnessUsage = {
141 tokens: number | null
142 window: number | null
143 percent: number | null
144 limits: HarnessUsageLimit[]
145}
146
147export type HarnessExternalStatus = 'running' | 'done' | 'failed'
148
149export type HarnessExternalRow = {
150 agentId: string
151 client: string
152 model: string | null
153 status: HarnessExternalStatus
154 startedAt: number
155 endedAt?: number
156 lastLine: string
157 patch?: string
158 stat?: string
159 note?: string
160}
161
162export type HarnessCost = {
163 input: number
164 output: number
165 cacheRead: number
166 cacheWrite: number
167 steps: number
168}
169
170declare module 'claude-code' {
171 interface PluginState {
172 harness: {
173 plan: HarnessPlan | null
174 workers: Record<string, HarnessWorkerRow>
175 needsYou: HarnessHumanAsk[]
176 stale: boolean
177 staleNote: string | null
178 isHidden: boolean
179 advisorModel: string
180 review: HarnessReview | null
181 isReviewRunning: boolean
182 isExpanded: boolean
183 folds: Record<string, boolean>
184 scope: ScopeState
185 claimLog: ClaimEntry[]
186 claimWarnings: ClaimWarning[]
187 reviewIds: Record<string, HarnessReviewIdentity>
188 usage: HarnessUsage | null
189 external: Record<string, HarnessExternalRow>
190 costs: Record<string, HarnessCost>
191 }
192 }
193}
194