SLOPSHOPPER

harness

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…

newpanebandguardcommandtoast
★ 1v0.1.0no licenseupdated 2026-10-08flaviomartil/herdr-jev/claude-plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · harness
│ ┃ Harness ✕ › fix the failing auth test and add an audit log call │ ┃ No plan yet. Ask the advisor for one. │ ┃ [ Run ready ] [ ↻ review ] ● harness: harness: agent types not registered at start: herdr-jev mo │ ┃ ⏺ Read(src/auth.ts) │ ┃ ctx █████░░░░░ 97k/200k 5h 31% ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /harness │ ⎿ harness: Harness pane opened. No plan yet: the advisor starts on │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Harness
No plan yet. Ask the advisor for one. [ Run ready ] [ ↻ review ] ctx █████░░░░░ 97k/200k 5h 31%
README

harness

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).

O que aparece

  • Banda acima do prompt: uma linha só, sem borda, no formato do task-line. Da esquerda: botão ▸/▾, 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.
  • Expandida (▾): 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.
  • Pane harness (comando /harness), no estilo do painel Sessions do frank-claude-cockpit:
  • Cabeçalho: glifo do estado, objetivo em negrito, planned <idade> em cinza e, à direita, Run ready, ↻ review e ✕ (fecha o pane). Embaixo, a barra do plano enchendo a largura e o percentual.
  • Caixa 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.
  • Seções 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.
  • Uma linha de aviso para advisor diferente de Fable e dados desatualizados, 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.
  • Seção Scope · enforce · 12 of 74 skills · 9 of 41 agents, recolhida; aberta lista o que ficou visível e o motivo.
  • Sem plano: No plan yet. Ask the advisor for one. e os botões.
  • Seção 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.
  • Última linha do pane: 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).
  • Custo por worker: o hook 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.
  • Se o modelo da sessão não for Fable, o pane avisa; o mod continua funcionando.

Scope

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:

  1. project e built-in: skills cuja origem é projectSettings ou built-in em $.session.usage({ breakdown: 'summary' }).
  2. invoked: qualquer skill que você chamou com /nome na sessão (observado em prompt.submit).
  3. 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).
  4. 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.
  5. 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.

Claims

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.
  • Subagentes: o engine atribui as chamadas pelo 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.
  • A banda acima do prompt ganha uma linha por aviso (até 3, depois +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.
  • Estado em 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):

  • Status de saída escondido (| tail, || true, ; echo) conta como passou.
  • Leitura de CI que sai com 0 mesmo com CI falhando (gh run view, gh pr view, az pipelines) conta como evidência.
  • Edições feitas por scripts ou geradores de código (python gen.py, node codegen.js, pnpm db:migrate) não são vistas.
  • Ferramentas de shell via MCP são invisíveis ao mod.
  • O estado nasce vazio depois de resume; evidência de antes não é lembrada.
  • Comandos com mais de 64 KB (sem contar corpos de heredoc) não são classificados (nem evidência nem edição) e o desembrulho de prefixos e de 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.
  • O Bash de um subagente rodando em outro worktree conta como evidência do loop principal, porque o mod não sabe em qual árvore ele rodou.

PR gate

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):

  • GitHub: 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/.
  • Azure DevOps: 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.
  • Fora: 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.
  • Bitbucket fica de fora: não existe CLI 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.

Créditos

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).

Como carregar

claude --plugin-dir /caminho/para/herdr-jev/claude-plugin

Validar e testar:

claude plugin validate claude-plugin
claude plugin test claude-plugin

Ferramentas

FerramentaEntradaO que faz
harness_planobjective, 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_runtaskId?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_statusnenhumaResumo 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.

Modelos

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

Source 12 files
hooks/register.tsx 2123 lines
1import { 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 lines
1import { 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}
669
hooks/cli.ts 151 lines
1import 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}
151
hooks/external.ts 1186 lines
1import 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}
1186
hooks/plan.ts 750 lines
1import 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}
750
hooks/pr-gate.ts 278 lines
1import { 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}
278
hooks/review-run.ts 64 lines
1export 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}
64
hooks/visual.ts 235 lines
1import 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}
235
hooks/scope.ts 254 lines
1import 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}
254
hooks/workers.ts 437 lines
1import 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}
437
hooks/pr-detect.ts 756 lines
1export 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}
756
types/index.d.ts 194 lines
1export 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