SLOPSHOPPER

orquestrador

Painel lateral e flow declarado para orquestrar changes com várias sessões do Claude Code: grill, mockup, plano OpenSpec, fases com gates de qualidade, PR, QA…

newpanebandguardcommandtoast
v0.7.4MITupdated 2026-10-15chacal88/orquestrador/orquestrador
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · orquestrador
│ ┃ 🧭 Orquestrador ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ card:self │ ⏺ Read(src/auth.ts) │ ┃ │ │ ⎿ Read 6 lines │ ┃ │ Sem lista de tarefas ainda │ ⏺ Update(src/auth.ts) │ ┃ ╰──────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Sessões filhas indisponíveis: o app não ⎿ 3 pass, 1 fail │ ┃ expõe a lista de sessões aqui. │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /orquestrador │ ⎿ orquestrador: Painel do orquestrador aberto. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 🧭 Orquestrador
╭──────────────────────────────────────────────────────────╮ │ card:self │ │ │ │ Sem lista de tarefas ainda │ ╰──────────────────────────────────────────────────────────╯ Sessões filhas indisponíveis: o app não expõe a lista de sessões aqui.
README

Orquestrador

Plugin para o Claude Code que orquestra uma change com várias sessões em paralelo e mostra tudo num painel lateral: em que passo o trabalho está, o que cada sessão está fazendo, quais gates de qualidade passaram e o que espera uma decisão sua.

A Claude Code plugin (mod) that orchestrates a change across parallel sessions through a declared flow, with a live side panel and owner decisions as panel buttons. The UI is in Brazilian Portuguese.

O que ele faz

  • Flow declarado. O trabalho segue um flow escrito em arquivo, passo a passo. Cada passo diz quem responde:
  • sessão: uma sessão faz o trabalho e informa o resultado;
  • automático: o mod observa um comando rodar, e o código de saída decide;
  • dono: só um clique seu no painel responde, e nenhuma sessão responde por você.
  • Dois flows prontos:
  • completo: grill → mockup → plano OpenSpec → validação → revisão do plano → liberação do dono pelo plan mode → fases → revisão de código → checagem final → PR → QA → produção → arquivamento;
  • rapido, para correções pequenas.
  • Um projeto pode ter flows próprios em .orquestrador/flows/*.json.
  • Gates de qualidade por fase. São checagens como testes, lint, check.sh, openspec validate, print da tela e revisão de regras:
  • comprovação: o mod só aceita um gate quando vê o comando rodar e passar;
  • validade: editar arquivos depois da comprovação desatualiza o gate;
  • pulo: só você pula um gate, com dois cliques.
  • Painel lateral (/orquestrador). Mostra o andamento da change, o OpenSpec (proposta, tarefas e evidência), os gates do flow, o que precisa de você e as tarefas de cada fase com a sessão que as executa. Uma faixa acima do campo de mensagem resume o estado.
  • Grill com respostas no painel. As rodadas de perguntas viram critérios de aceite, e você responde cada pergunta num campo do próprio painel.
  • Sessões em paralelo. Cada fase roda numa sessão própria, com teto de 3 sessões vivas. As sessões entram sozinhas no grupo da change na barra lateral e são arquivadas quando a fase fecha.
  • Jira com o seu ok. O painel avisa quando uma issue deve mudar de estado e só a move depois do seu clique.

A skill change-orchestrator, que vem junto, ensina a sessão coordenadora a seguir o flow: ela pergunta ao mod qual é o próximo passo em vez de decidir sozinha.

Requisitos

  • Claude Code com suporte a mods (plugins de function hooks).
  • O app desktop do Claude Code para a experiência completa: painel lateral, plan mode e gestão de sessões e da barra lateral. No terminal, o painel e o flow funcionam, mas as ações sobre outras sessões dependem das ferramentas do app.
  • Opcional: OpenSpec no projeto e o conector Atlassian para o Jira.

Instalação

Dentro do Claude Code:

/plugin marketplace add chacal88/orquestrador
/plugin install orquestrador@orquestrador

Ou pelo terminal:

claude plugin marketplace add chacal88/orquestrador
claude plugin install orquestrador@orquestrador

Depois de instalar, reinicie o app ou rode /reload-plugins.

Uso

  1. Peça à sessão para orquestrar uma change, ou use a skill change-orchestrator.
  2. Escolha o flow (completo ou rapido) e abra o painel com /orquestrador.
  3. Acompanhe pelo painel e responda os seus passos pelos botões.

Flows próprios

Crie .orquestrador/flows/<id>.json no repositório. Um flow do projeto com o mesmo id de um flow padrão substitui o padrão. Formato:

{
  "id": "investigacao",
  "title": "Investigação",
  "description": "Descobrir a causa de um problema.",
  "steps": [
    { "id": "grill", "title": "Entendimento", "kind": "owner",
      "outcomes": { "fechado": { "label": "Entendimento fechado", "next": "investigar" } } },
    { "id": "investigar", "title": "Investigar", "kind": "session",
      "outcomes": { "concluido": { "label": "Fatos levantados", "next": "revisao" } } },
    { "id": "revisao", "title": "Revisão do dono", "kind": "owner",
      "outcomes": { "aprovado": { "label": "Aprovar", "next": "fim" },
                    "aprofundar": { "label": "Aprofundar", "next": "investigar" } } }
  ]
}

Tipos de passo: session, automatic (com command), owner e loop (sobre as fases do tasks.md). Campos opcionais: maxAttempts (padrão 3, depois escala para o dono), when: "visual", detect, cleanup e jira.

Desenvolvimento

O código fica em orquestrador/hooks/, os tipos em orquestrador/types/ e os testes em orquestrador/tests/.

claude plugin test orquestrador
claude plugin validate orquestrador

O plano e o histórico de decisões estão em orquestrador/proposal.md, orquestrador/tasks.md e orquestrador/evidence.md.

Licença

MIT. Veja LICENSE.

Source 15 files
hooks/register.tsx 1634 lines
1import { update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
3
4import type {
5  ActivityRecord,
6  Attention,
7  FlowDef,
8  FlowEvent,
9  FlowState,
10  FlowView,
11  GateCheck,
12  GrillAnswer,
13  PrepState,
14  PrepView,
15  GateRecord,
16  GateSkip,
17  GateView,
18  JiraIssue,
19  JiraMove,
20  JiraTransition,
21  OpenSpecView,
22  OrchestrationRun,
23  PanelView,
24  PlanItem,
25  SessionCard,
26  SessionRow,
27  Waiting,
28} from '../types'
29import {
30  FINAL_SCOPE,
31  GATE_IDS,
32  GH_MERGE,
33  MAX_LIVE_SESSIONS,
34  PROTECTED_DIR,
35  familyCheck,
36  gateViews,
37  isOpenGate,
38  liveSessions,
39  mergeRecords,
40  observeBash,
41  observeTool,
42  phaseRefusal,
43} from './gates'
44import {
45  DEFAULT_FLOW_IDS,
46  applyOutcome,
47  automaticOutcome,
48  currentStep,
49  describeFlow,
50  detectedOutcome,
51  noteOwnerStep,
52  flowView,
53  mergeFlows,
54  ownerPress,
55  parseFlow,
56  pressedToast,
57  startFlow,
58} from './flow'
59import type { Moved } from './flow'
60import { closePhase, finishLoop, isClosed, loopRefusal, loopUnits, phaseSessions, uncommitted, unitRefusal, withEvents } from './lifecycle'
61import { defaultFlows } from './flows/index'
62import {
63  MOCKUP_STEP,
64  RELEASE_OUTCOME,
65  RELEASE_STEP,
66  answersPrompt,
67  approvePlan,
68  attachMockup,
69  describePrep,
70  emptyPrep,
71  grillMarkdown,
72  parseRoundItems,
73  planVerdict,
74  pressMockup,
75  ownerAnswers,
76  prepView,
77  recordRound,
78  rejectPlan,
79} from './grill'
80import { bandSummary, parseEvents, stepTimes } from './glance'
81import { renderBand, renderPane } from './pane'
82import { firstSite, issueUrl, jiraKeys, moveFor, parseIssue, parseTransitions, wishes } from './jira'
83import { changeDir, openSpecView } from './openspec'
84import {
85  activityFor,
86  activityKey,
87  applyPlanUpdate,
88  completePlan,
89  parseRun,
90  parseSessionRows,
91  findGroupId,
92  groupName,
93  phaseCounts,
94  phaseViews,
95  repoWebUrl,
96  runKey,
97  sessionActivityKey,
98  toCard,
99} from './model'
100
101const PANE = 'orquestrador'
102/** Typed as string: the laid MCP types predate this mod's own tool. */
103const PLAN_TOOL: string = 'mcp__orquestrador__plano'
104const FLOW_TOOL: string = 'mcp__orquestrador__flow'
105const LIST_SESSIONS = 'mcp__ccd_session_mgmt__list_sessions'
106const GET_SESSION = 'mcp__ccd_session_mgmt__get_session'
107const OPEN_SESSION = 'mcp__ccd_window__open_session_in'
108const LIST_GROUPS = 'mcp__ccd_sidebar__list_groups'
109const CREATE_GROUP = 'mcp__ccd_sidebar__create_group'
110const MOVE_SESSIONS = 'mcp__ccd_sidebar__move_sessions'
111const DELETE_GROUP = 'mcp__ccd_sidebar__delete_group'
112const REFRESH_MS = 15_000
113/** Jira is asked again at most this often; the panel refreshes every 15 s. */
114const JIRA_TTL_MS = 120_000
115
116const VIEW = { plugin: 'orquestrador', key: 'view' } as const
117const PLAN = { plugin: 'orquestrador', key: 'plan' } as const
118const COLLAPSED = { plugin: 'orquestrador', key: 'collapsed' } as const
119const WAITING = { plugin: 'orquestrador', key: 'waiting' } as const
120const ANSWERED = { plugin: 'orquestrador', key: 'answered' } as const
121const GATES_SEEN = { plugin: 'orquestrador', key: 'gates' } as const
122const EDITED_AT = { plugin: 'orquestrador', key: 'editedAt' } as const
123const SELF_ID = { plugin: 'orquestrador', key: 'selfId' } as const
124const ACTIVITY_TTL_MS = 7 * 24 * 60 * 60 * 1000
125const PANE_OPEN = { plugin: 'orquestrador', key: 'paneOpen' } as const
126const GRILL_DRAFTS = { plugin: 'orquestrador', key: 'grillDrafts' } as const
127const START_SESSION = 'mcp__ccd_session__start_session'
128const TRANSITION_SUFFIX = '__transitionJiraIssue'
129const ARCHIVE_SESSION = 'mcp__ccd_session_mgmt__archive_session'
130const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
131
132const GATES_DESCRIPTION =
133  `Gate ids this phase must pass before it may be marked done or its session archived: ${GATE_IDS.join(', ')}. ` +
134  'The mod watches the phase session run them (it never re-runs them); only the owner can skip one, from the panel.'
135
136const PLAN_SCHEMA = {
137  type: 'object',
138  properties: {
139    change: { type: 'string', description: 'Change id (e.g. the OpenSpec change folder name).' },
140    changePath: {
141      type: 'string',
142      description:
143        'Path to the change folder (absolute, or relative to this checkout); the panel reads its ' +
144        'tasks.md, proposal.md and evidence.md from there.',
145    },
146    jira: { type: 'string', description: 'Jira issue key of the whole change (e.g. the epic), "PROJ-123".' },
147    jiraSite: {
148      type: 'string',
149      description: 'Jira site host, e.g. "acme.atlassian.net"; omit to use the Atlassian connector\'s first site.',
150    },
151    waves: {
152      type: 'array',
153      description: 'Waves in order; a wave holds phases that may run in parallel.',
154      items: {
155        type: 'object',
156        properties: {
157          title: { type: 'string' },
158          jira: { type: 'string', description: 'Jira issue key this wave maps to, if any.' },
159          summary: {
160            type: 'string',
161            description: 'What this wave delivers and why it comes at this point, in 1-2 plain sentences (pt-BR).',
162          },
163          phases: {
164            type: 'array',
165            items: {
166              type: 'object',
167              properties: {
168                id: { type: 'string', description: 'Short phase id, e.g. "4.x".' },
169                title: { type: 'string' },
170                icon: { type: 'string', description: 'The phase icon from the orchestrator table.' },
171                tasks: { type: 'string', description: 'Task numbers, e.g. "4.1–4.3".' },
172                status: { type: 'string', enum: ['pending', 'running', 'blocked', 'done'] },
173                session: {
174                  type: 'string',
175                  description: 'The session doing it: its name, id or a unique part of its title.',
176                },
177                dependsOn: { type: 'array', items: { type: 'string' }, description: 'Phase ids it waits on.' },
178                note: { type: 'string', description: 'One line: result, blocker or next step.' },
179                jira: { type: 'string', description: 'Jira issue key this phase maps to, e.g. "PROJ-45".' },
180                gates: { type: 'array', items: { type: 'string', enum: GATE_IDS }, description: GATES_DESCRIPTION },
181                summary: {
182                  type: 'string',
183                  description:
184                    'What this phase is about, in 1-3 plain sentences (pt-BR): what it changes and why. ' +
185                    'Shown when the owner hovers the phase, so they recall it long after planning.',
186                },
187              },
188              required: ['id', 'title', 'status'],
189            },
190          },
191        },
192        required: ['phases'],
193      },
194    },
195    finalGates: {
196      type: 'array',
197      items: { type: 'string', enum: GATE_IDS },
198      description:
199        'Gates the whole change passes once, at the end (e.g. "check"): until they pass, `gh pr merge` is refused ' +
200        'in this session. Prefer heavy gates here over repeating them in every phase.',
201    },
202    ownerQuestions: {
203      type: 'array',
204      description: 'Decisions pending on the owner, each with a recommendation and where it lives.',
205      items: {
206        type: 'object',
207        properties: {
208          question: { type: 'string', description: 'The decision, as a question (pt-BR).' },
209          recommendation: {
210            type: 'string',
211            description: 'What you recommend; "Aceitar" sends it back to you as the owner\'s answer.',
212          },
213          session: { type: 'string', description: 'The session it concerns, when not this one.' },
214          pr: { type: 'number', description: 'The PR number it concerns, if any.' },
215        },
216        required: ['question'],
217      },
218    },
219  },
220  required: ['change', 'waves'],
221}
222
223type CallResult = { text?: string; deny?: string; isError?: boolean }
224
225/** The app's own tools are not in this mod's typed tool list until it loads beside them. */
226const callTool = ($: EngineInterface, input: Record<string, unknown>): Promise<CallResult> =>
227  $.tool.call(input as never) as Promise<CallResult>
228
229const baseDir = async ($: EngineInterface): Promise<string> =>
230  `${(await $.env.get('HOME')) ?? '~'}/.claude/orquestrador`
231
232const readJson = async <T,>($: EngineInterface, path: string): Promise<T | undefined> => {
233  try {
234    if (!(await $.fs.exists(path))) return undefined
235    return JSON.parse(await $.fs.read(path)) as T
236  } catch {
237    return undefined
238  }
239}
240
241/**
242 * This session's id in the app's list, asked once and kept: `$.session.id()` names the
243 * transcript, which is not the id the family's rows carry. Undefined outside the app.
244 */
245const selfId = async ($: EngineInterface): Promise<string | undefined> => {
246  const { value: known } = await $.state.get(SELF_ID)
247  if (known !== undefined) return known
248  if (!(await canCall($, GET_SESSION))) return undefined
249  const [row] = parseSessionRows((await callTool($, { tool: GET_SESSION, session_id: 'self' })).text)
250  if (row !== undefined) await $.state.set(SELF_ID, row.sessionId)
251  return row?.sessionId
252}
253
254/** Keyed by the session's id when known, so a phase in the coordinator's folder has a file of its own. */
255const activityPath = async ($: EngineInterface, sessionId: string | undefined, cwd: string): Promise<string> =>
256  `${await baseDir($)}/sessions/${sessionId === undefined ? activityKey(cwd) : sessionActivityKey(sessionId)}.json`
257
258const publishActivity = async ($: EngineInterface, lastTool: string | undefined): Promise<void> => {
259  // root, not cwd: a shell `cd` inside the session must not move where it publishes
260  const cwd = await $.session.root()
261  const sessionId = await selfId($).catch(() => undefined)
262  const { value: plan = [] } = await $.state.get(PLAN)
263  const { value: waiting } = await $.state.get(WAITING)
264  const { records, editedAt } = await ownCheck($)
265  const record: ActivityRecord = {
266    sessionId, cwd, plan, lastTool, waiting: waiting ?? undefined, updatedAt: await $.clock.now(), gates: records, editedAt,
267  }
268  await $.fs.write(await activityPath($, sessionId, cwd), JSON.stringify(record))
269}
270
271const ownCheck = async ($: EngineInterface): Promise<GateCheck> => {
272  const { value: records = {} } = await $.state.get(GATES_SEEN)
273  const { value: editedAt } = await $.state.get(EDITED_AT)
274  return { records, editedAt: editedAt ?? undefined }
275}
276
277/** Sets or clears why this session stands still; other sessions' panels read it from the activity file. */
278const setWaiting = async ($: EngineInterface, waiting: Omit<Waiting, 'since'> | null): Promise<void> => {
279  const value = waiting === null ? null : { ...waiting, since: await $.clock.now() }
280  await $.state.set(WAITING, value)
281  await publishActivity($, undefined).catch(() => undefined)
282}
283
284const toolLabel = (tool: string): string => tool.replace(/^mcp__[^_]+(?:_[^_]+)*?__/, '').replace(/_/g, ' ')
285
286const canCall = async ($: EngineInterface, tool: string): Promise<boolean> => {
287  const tools = await $.tool.list()
288  return tools.some(t => t.name === tool)
289}
290
291const loadRun = async ($: EngineInterface, cwd: string): Promise<OrchestrationRun | undefined> => {
292  const dir = `${await baseDir($)}/runs`
293  if (!(await $.fs.exists(dir))) return undefined
294  const files = (await $.fs.list(dir)).filter(f => f.kind === 'file' && f.name.endsWith('.json'))
295  const runs = await Promise.all(files.map(f => readJson<OrchestrationRun>($, `${dir}/${f.name}`)))
296  return runs
297    .filter((run): run is OrchestrationRun => run?.coordinatorCwd === cwd)
298    .sort((a, b) => b.updatedAt - a.updatedAt)[0]
299}
300
301const describeError = (step: string, error: unknown): string =>
302  `${step}: ${error instanceof Error ? error.message.slice(0, 160) : 'falhou'}`
303
304/** $.state takes JSON data only: an `undefined` field anywhere refuses the whole write. */
305const asJson = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
306
307const loadSessions = async (
308  $: EngineInterface,
309  base: string,
310  ownPlan: PlanItem[],
311): Promise<{ self: SessionCard | undefined; children: SessionCard[] }> => {
312  const [selfRow] = parseSessionRows((await callTool($, { tool: GET_SESSION, session_id: 'self' })).text)
313  const family = parseSessionRows((await callTool($, { tool: LIST_SESSIONS, linked: true, limit: 50 })).text)
314  const grouped =
315    selfRow?.groupId === undefined
316      ? []
317      : parseSessionRows((await callTool($, { tool: LIST_SESSIONS, group: selfRow.groupId, limit: 50 })).text)
318
319  const others = new Map<string, SessionRow>()
320  for (const row of [...family, ...grouped]) {
321    if (!row.isArchived && row.sessionId !== selfRow?.sessionId) others.set(row.sessionId, row)
322  }
323  const dir = `${base}/sessions`
324  const files = (await $.fs.exists(dir)) ? await $.fs.list(dir) : []
325  // a file per session id is never removed (the mod cannot delete), so a refresh skips the
326  // ones untouched for a week instead of opening every session this machine ever ran
327  const cutoff = (await $.clock.now()) - ACTIVITY_TTL_MS
328  const fresh = files.filter(f => f.name.endsWith('.json') && f.mtimeMs >= cutoff)
329  const records = (await Promise.all(fresh.map(f => readJson<ActivityRecord>($, `${dir}/${f.name}`)))).filter(
330    (r): r is ActivityRecord => r !== undefined,
331  )
332  const children = [...others.values()].map(row => toCard(row, activityFor(records, row, selfRow?.cwd)))
333  const self =
334    selfRow === undefined
335      ? undefined
336      : { ...toCard(selfRow, undefined), plan: ownPlan, hasActivity: true, gates: await ownCheck($) }
337  return { self, children }
338}
339
340const readText = async ($: EngineInterface, path: string): Promise<string | undefined> =>
341  (await $.fs.exists(path)) ? $.fs.read(path) : undefined
342
343const loadOpenSpec = async ($: EngineInterface, run: OrchestrationRun): Promise<OpenSpecView | undefined> => {
344  if (run.changePath === undefined) return undefined
345  const home = (await $.env.get('HOME')) ?? ''
346  const dir = changeDir(run.changePath.replace(/^~(?=\/)/, home), run.coordinatorCwd)
347  const [tasks, proposal, evidence] = await Promise.all(
348    ['tasks.md', 'proposal.md', 'evidence.md'].map(name => readText($, `${dir}/${name}`)),
349  )
350  if (tasks === undefined && proposal === undefined) return undefined
351  return openSpecView({ tasks, proposal, evidence })
352}
353
354/** The module's own memory: it starts over on a reload, which only costs one fresh fetch. */
355const jiraCache = new Map<string, { issue: JiraIssue; at: number }>()
356
357const findTool = async ($: EngineInterface, suffix: string): Promise<string | undefined> =>
358  (await $.tool.list()).find(t => t.name.endsWith(suffix))?.name
359
360/**
361 * Status and title of every issue the plan names, through whichever Atlassian connector
362 * this session has. Without one, or with no site it can reach, each key keeps its link only.
363 */
364const loadJira = async (
365  $: EngineInterface,
366  run: OrchestrationRun,
367): Promise<{ issues: Record<string, JiraIssue>; note: string | undefined }> => {
368  const keys = jiraKeys(run)
369  if (keys.length === 0) return { issues: {}, note: undefined }
370  const linkOnly = (site: string | undefined, note: string) => ({
371    issues: Object.fromEntries(
372      keys.map(key => [key, { key, title: undefined, status: undefined, category: 'unknown' as const, url: issueUrl(site, key) }]),
373    ),
374    note,
375  })
376  const getIssue = await findTool($, '__getJiraIssue')
377  const getSites = await findTool($, '__getAccessibleAtlassianResources')
378  if (getIssue === undefined || getSites === undefined) {
379    return linkOnly(run.jiraSite, 'Jira: conector Atlassian indisponível nesta sessão; mostrando só as chaves.')
380  }
381  const site = firstSite((await callTool($, { tool: getSites })).text)
382  const host = run.jiraSite ?? site?.url
383  if (host === undefined) {
384    return linkOnly(run.jiraSite, 'Jira: o conector Atlassian está logado, mas a autorização não inclui nenhum site do Jira. Reconecte-o em claude.ai › Configurações › Conectores e marque o site na tela da Atlassian.')
385  }
386  const cloudId = run.jiraSite ?? site?.cloudId ?? host
387  const now = await $.clock.now()
388  const failed: string[] = []
389  const issues = await Promise.all(
390    keys.map(async (key): Promise<JiraIssue> => {
391      const cached = jiraCache.get(key)
392      if (cached !== undefined && now - cached.at < JIRA_TTL_MS) return cached.issue
393      const ran = await callTool($, {
394        tool: getIssue,
395        cloudId,
396        issueIdOrKey: key,
397        fields: ['summary', 'status'],
398      }).catch(() => ({ isError: true }) as CallResult)
399      const issue = parseIssue(ran.isError === true ? undefined : ran.text, key, host)
400      if (issue.status === undefined) failed.push(key)
401      else jiraCache.set(key, { issue, at: now })
402      return issue
403    }),
404  )
405  return {
406    issues: Object.fromEntries(issues.map(issue => [issue.key, issue])),
407    note: failed.length === 0 ? undefined : `Jira: não consegui ler ${failed.join(', ')}.`,
408  }
409}
410
411/** The connector's cloud id for the plan's site, or why there is none (said to the owner as is). */
412const jiraConnection = async (
413  $: EngineInterface,
414  run: OrchestrationRun,
415): Promise<{ cloudId: string; getTransitions: string; transition: string | undefined } | { note: string }> => {
416  const getTransitions = await findTool($, '__getTransitionsForJiraIssue')
417  const getSites = await findTool($, '__getAccessibleAtlassianResources')
418  if (getTransitions === undefined || getSites === undefined) {
419    return { note: 'o conector Atlassian não está autenticado nesta sessão (claude.ai › Configurações › Conectores).' }
420  }
421  const site = firstSite((await callTool($, { tool: getSites })).text)
422  const cloudId = run.jiraSite ?? site?.cloudId
423  if (cloudId === undefined) return { note: 'a autorização do conector Atlassian não inclui nenhum site do Jira.' }
424  return { cloudId, getTransitions, transition: await findTool($, TRANSITION_SUFFIX) }
425}
426
427const transitionCache = new Map<string, { list: JiraTransition[]; at: number }>()
428
429const issueTransitions = async (
430  $: EngineInterface,
431  conn: { cloudId: string; getTransitions: string },
432  issue: JiraIssue,
433  fresh: boolean,
434): Promise<JiraTransition[] | undefined> => {
435  const id = `${issue.key}|${issue.status}`
436  const now = await $.clock.now()
437  const cached = transitionCache.get(id)
438  if (!fresh && cached !== undefined && now - cached.at < JIRA_TTL_MS) return cached.list
439  const ran = await callTool($, { tool: conn.getTransitions, cloudId: conn.cloudId, issueIdOrKey: issue.key }).catch(
440    () => ({ isError: true }) as CallResult,
441  )
442  if (ran.isError === true) return undefined
443  const list = parseTransitions(ran.text)
444  transitionCache.set(id, { list, at: now })
445  return list
446}
447
448/** The issues the flow's current step says should change state; the panel offers each move, nothing moves here. */
449const loadJiraMoves = async (
450  $: EngineInterface,
451  run: OrchestrationRun | undefined,
452  state: FlowState | undefined,
453  issues: Record<string, JiraIssue>,
454): Promise<JiraMove[]> => {
455  if (run === undefined || state === undefined || (state.status !== 'running' && state.status !== 'done')) return []
456  const stepId = state.status === 'done' ? state.flow.steps.at(-1)?.id : state.current
457  if (stepId === undefined) return []
458  const wanted = wishes(run, state.flow.steps, stepId)
459  if (wanted.length === 0) return []
460  const conn = await jiraConnection($, run)
461  const moves: JiraMove[] = []
462  for (const wish of wanted) {
463    const issue = issues[wish.key]
464    const note = 'note' in conn ? conn.note : undefined
465    const transitions =
466      'note' in conn || issue === undefined || issue.status === undefined ? undefined : await issueTransitions($, conn, issue, false)
467    const move = moveFor(wish, issue, transitions, stepId, note)
468    if (move !== undefined) moves.push(move)
469  }
470  return moves
471}
472
473/**
474 * The owner's "Mover no Jira" press, the only road to transitionJiraIssue: the transition is looked up again,
475 * then approved for that one call, which the tool.call hook refuses otherwise.
476 */
477const pressJiraMove = async ($: EngineInterface, change: string, key: string, transitionId: string): Promise<void> => {
478  const run = await loadRun($, await $.session.root())
479  if (run === undefined) return
480  const conn = await jiraConnection($, run)
481  if ('note' in conn || conn.transition === undefined) {
482    $.ui.toast(`Não movi ${key}: ${'note' in conn ? conn.note : 'o conector não oferece transitionJiraIssue.'}`)
483    return
484  }
485  const issue = (await loadJira($, run)).issues[key]
486  const transitions = issue === undefined ? undefined : await issueTransitions($, conn, issue, true)
487  const hit = transitions?.find(t => t.id === transitionId)
488  if (issue === undefined || hit === undefined) {
489    $.ui.toast(`Não movi ${key}: essa transição não está mais disponível. O painel vai se atualizar.`)
490    await refreshQuietly($)
491    return
492  }
493  approvedMove = `${key}|${transitionId}`
494  const ran = await callTool($, {
495    tool: conn.transition,
496    cloudId: conn.cloudId,
497    issueIdOrKey: key,
498    transition: { id: transitionId },
499  })
500    .catch(() => ({ isError: true }) as CallResult)
501    .finally(() => {
502      approvedMove = undefined
503    })
504  if (ran.isError === true || ran.deny !== undefined) {
505    $.ui.toast(`O Jira recusou mover ${key}${ran.text === undefined ? '.' : `: ${ran.text.slice(0, 120)}`}`)
506    return
507  }
508  jiraCache.delete(key)
509  transitionCache.clear()
510  const state = await loadFlowState($, change)
511  if (state !== undefined) {
512    const log = await flowFile($, change, 'ndjson')
513    const prior = (await readText($, log)) ?? ''
514    const note = `${key}: "${issue.status ?? '?'}" → "${hit.to ?? hit.name}" (transição "${hit.name}"), pelo clique do dono`
515    const event: FlowEvent = { seq: state.seq, at: await $.clock.now(), kind: 'jira-moved', step: state.current, by: 'owner', note }
516    await $.fs.write(log, `${prior}${JSON.stringify(event)}\n`)
517  }
518  $.ui.toast(`${key} movida para ${hit.to ?? hit.name}.`)
519  await refreshQuietly($)
520}
521
522/** The one move the owner approved, while its call runs. */
523let approvedMove: string | undefined
524
525/** Each source fails on its own: a broken session list never hides the plan, and the panel names what failed. */
526const refresh = async ($: EngineInterface): Promise<void> => {
527  await restoreActivity($).catch(() => undefined)
528  const problems: string[] = []
529  const cwd = await $.session.root()
530  const base = await baseDir($)
531  const { value: ownPlan = [] } = await $.state.get(PLAN)
532
533  const run = await loadRun($, cwd).catch(error => {
534    problems.push(describeError('plano', error))
535    return undefined
536  })
537
538  let sessions: { self: SessionCard | undefined; children: SessionCard[] } = { self: undefined, children: [] }
539  try {
540    if (await canCall($, LIST_SESSIONS)) sessions = await loadSessions($, base, ownPlan)
541    else problems.push('Sessões filhas indisponíveis: o app não expõe a lista de sessões aqui.')
542  } catch (error) {
543    problems.push(describeError('sessões', error))
544  }
545
546  const openspec =
547    run === undefined
548      ? undefined
549      : await loadOpenSpec($, run).catch(error => {
550          problems.push(describeError('openspec', error))
551          return undefined
552        })
553  const jira =
554    run === undefined
555      ? { issues: {}, note: undefined }
556      : await loadJira($, run).catch(error => ({ issues: {}, note: describeError('jira', error) }))
557
558  const skips = run === undefined ? {} : await loadSkips($, run.change).catch(() => ({}))
559  const flow = await loadFlowView($, run, cwd).catch(error => {
560    problems.push(describeError('flow', error))
561    return undefined
562  })
563  const flowState = flow === undefined ? undefined : await loadFlowState($, flow.change).catch(() => undefined)
564  const jiraMoves = await loadJiraMoves($, run, flowState, jira.issues).catch(error => {
565    problems.push(describeError('jira', error))
566    return []
567  })
568  const prep = flow === undefined ? undefined : await loadPrepView($, flow.change).catch(() => undefined)
569  const flowTimes = flow === undefined ? undefined : await loadFlowTimes($, flow.change).catch(() => undefined)
570
571  const repoUrl = await $.session
572    .repo()
573    .then(repo => repoWebUrl(repo?.remote))
574    .catch(() => undefined)
575
576  const view: PanelView = {
577    ...sessions,
578    run,
579    repoUrl,
580    openspec,
581    jira: jira.issues,
582    jiraNote: jira.note,
583    jiraMoves,
584    skips,
585    flow,
586    prep,
587    ...(flowTimes === undefined ? {} : { flowTimes }),
588    updatedAt: await $.clock.now(),
589    warning: problems.length === 0 ? undefined : problems.join(' · '),
590  }
591  await $.state.set(VIEW, asJson(view))
592}
593
594const refreshQuietly = async ($: EngineInterface): Promise<void> => {
595  try {
596    await refresh($)
597  } catch (error) {
598    $.ui.log(`orquestrador: ${describeError('refresh', error)}`)
599  }
600}
601
602/**
603 * Files the coordinator and every session it started under the change's sidebar group,
604 * creating the group once. Runs on each plan publish and after each start_session, so a
605 * new phase session never lands ungrouped.
606 */
607const groupFamily = async ($: EngineInterface, change: string): Promise<string | undefined> => {
608  if (!(await canCall($, MOVE_SESSIONS))) return 'sidebar indisponível para agrupar'
609  const name = groupName(change)
610  let groupId = findGroupId((await callTool($, { tool: LIST_GROUPS })).text, name)
611  if (groupId === undefined) groupId = findGroupId((await callTool($, { tool: CREATE_GROUP, name })).text)
612  if (groupId === undefined) return `não consegui criar o grupo ${name}`
613
614  const [self] = parseSessionRows((await callTool($, { tool: GET_SESSION, session_id: 'self' })).text)
615  const family = parseSessionRows((await callTool($, { tool: LIST_SESSIONS, linked: true, limit: 50 })).text)
616  const loose = family.filter(row => !row.isArchived && row.startedBy === self?.sessionId && row.groupId !== groupId)
617  const ids = [...(self?.groupId === groupId ? [] : ['self']), ...loose.map(row => row.sessionId)]
618  if (ids.length > 0) await callTool($, { tool: MOVE_SESSIONS, session_ids: ids, group_id: groupId })
619  return undefined
620}
621
622const groupQuietly = async ($: EngineInterface, change: string | undefined): Promise<void> => {
623  if (change === undefined) return
624  try {
625    const problem = await groupFamily($, change)
626    if (problem !== undefined) $.ui.toast(`Orquestrador: ${problem}`)
627  } catch (error) {
628    $.ui.log(`orquestrador: ${describeError('agrupar', error)}`)
629  }
630}
631
632const gatesFile = async ($: EngineInterface, change: string, ext: 'json' | 'ndjson'): Promise<string> =>
633  `${await baseDir($)}/gates/${runKey(change)}.${ext}`
634
635const loadSkips = async ($: EngineInterface, change: string): Promise<Record<string, GateSkip>> =>
636  (await readJson<{ skips?: Record<string, GateSkip> }>($, await gatesFile($, change, 'json')))?.skips ?? {}
637
638/** Append-only trail of skips and refusals, one JSON line each, for the owner to audit later. */
639const logGateEvent = async ($: EngineInterface, change: string, event: Record<string, unknown>): Promise<void> => {
640  const path = await gatesFile($, change, 'ndjson')
641  const prior = (await readText($, path)) ?? ''
642  await $.fs.write(path, `${prior}${JSON.stringify({ at: await $.clock.now(), ...event })}\n`)
643}
644
645/** Only the panel calls this, on the owner's press: the model has no path to a skip. */
646const skipGate = async ($: EngineInterface, change: string, view: GateView): Promise<void> => {
647  const skips = await loadSkips($, change)
648  await $.fs.write(
649    await gatesFile($, change, 'json'),
650    JSON.stringify({ skips: { ...skips, [view.skipKey]: { at: await $.clock.now() } } }),
651  )
652  await logGateEvent($, change, { kind: 'skipped', key: view.skipKey, state: view.state, by: 'owner' })
653}
654
655type Family = { self: SessionCard | undefined; children: SessionCard[] }
656
657const loadFamily = async ($: EngineInterface): Promise<Family | undefined> => {
658  if (!(await canCall($, LIST_SESSIONS))) return undefined
659  const { value: ownPlan = [] } = await $.state.get(PLAN)
660  return loadSessions($, await baseDir($), ownPlan)
661}
662
663/**
664 * Every phase that may not close or archive yet, with the reason. A phase with no session is the
665 * coordinator's own; a session the app cannot list proves nothing, so its gates stay open.
666 */
667const phaseRefusals = (
668  run: OrchestrationRun,
669  family: Family | undefined,
670  self: GateCheck,
671  skips: Readonly<Record<string, GateSkip>>,
672  include: (phase: OrchestrationRun['waves'][number]['phases'][number], card: SessionCard | undefined) => boolean,
673): string[] =>
674  phaseViews(run, family?.children ?? [])
675    .flat()
676    .flatMap(phase => {
677      if (phase.gates === undefined || !include(phase, phase.card)) return []
678      const own = phase.session === undefined
679      const check = own ? self : (phase.card?.gates ?? { records: {}, editedAt: undefined })
680      const where = own ? 'this session' : phase.card === undefined ? `session "${phase.session}" (not found)` : `session "${phase.card.title}"`
681      const refusal = phaseRefusal(phase, gateViews(phase.gates, phase.id, check, skips).filter(isOpenGate), where)
682      return refusal === undefined ? [] : [refusal]
683    })
684
685const finalOpenGates = async ($: EngineInterface, run: OrchestrationRun): Promise<GateView[]> => {
686  if (run.finalGates === undefined) return []
687  const family = await loadFamily($)
688  const check = familyCheck([await ownCheck($), ...(family?.children ?? []).map(c => c.gates)])
689  return gateViews(run.finalGates, FINAL_SCOPE, check, await loadSkips($, run.change)).filter(isOpenGate)
690}
691
692const publishPlan = async ($: EngineInterface, input: Record<string, unknown>): Promise<ToolCallResult<string>> => {
693  const cwd = await $.session.root()
694  const parsed = parseRun(input, cwd, await $.clock.now())
695  if ('error' in parsed) return { result: `Plan not published: ${parsed.error}`, isError: true as const }
696  const before = await readJson<OrchestrationRun>($, `${await baseDir($)}/runs/${runKey(parsed.change)}.json`)
697  const wasDone = new Set(before?.waves.flatMap(w => w.phases.filter(p => p.status === 'done').map(p => p.id)) ?? [])
698  const refusals = phaseRefusals(
699    parsed,
700    await loadFamily($),
701    await ownCheck($),
702    await loadSkips($, parsed.change),
703    phase => phase.status === 'done' && !wasDone.has(phase.id),
704  )
705  if (refusals.length > 0) {
706    await logGateEvent($, parsed.change, { kind: 'refused', action: 'done', reasons: refusals })
707    return {
708      result: `Plan not published: ${refusals.join(' | ')} Publish it again with that phase still running.`,
709      isError: true as const,
710    }
711  }
712  await $.fs.write(`${await baseDir($)}/runs/${runKey(parsed.change)}.json`, JSON.stringify(parsed))
713  await groupQuietly($, parsed.change)
714  await refreshQuietly($)
715  await showPane($)
716  const counts = phaseCounts(parsed)
717  return {
718    result:
719      `Plan published for ${parsed.change}: ${counts.done} done, ${counts.running} running, ` +
720      `${counts.blocked} blocked, ${counts.pending} pending.`,
721  }
722}
723
724/**
725 * Opens a session from the panel: beside this one when it is this session's own child (the
726 * only kind the app lets a session place), else through the app's `claude://` link, which the
727 * pane cannot follow itself. Says where to look when neither works.
728 */
729const openSession = async ($: EngineInterface, card: SessionCard, ownId: string | undefined): Promise<void> => {
730  if (ownId !== undefined && card.startedBy === ownId) {
731    const ran = await callTool($, { tool: OPEN_SESSION, session_id: card.sessionId, target: 'split' }).catch(
732      () => ({ isError: true }) as CallResult,
733    )
734    if (ran.isError !== true && ran.deny === undefined) return
735  }
736  if (card.link !== undefined && card.link.startsWith('claude://')) {
737    const opened = await $.process.run(['open', card.link], { timeoutMs: 5_000 }).catch(() => undefined)
738    if (opened?.exitCode === 0) return
739  }
740  $.ui.toast(`Não consegui abrir; procure "${card.title}" na barra lateral.`)
741}
742
743const PLAN_FAILED = { result: 'Plan not published: the orchestrator panel failed to store it.', isError: true } as const
744const FLOW_FAILED = { result: 'Flow: the orchestrator failed to read or store the flow; nothing changed. Try again.', isError: true } as const
745
746/** The gates' files are the mod's alone: a record or a skip written by the model would forge a pass. */
747/** Commands that only read: the gate files may be looked at, never written from a shell. */
748const READ_ONLY_COMMANDS = new Set(['ls', 'cat', 'head', 'tail', 'grep', 'rg', 'find', 'wc', 'stat', 'jq', 'diff', 'file', 'du'])
749
750/** The folder named with or without its trailing slash: `rm -rf ~/.claude/orquestrador` must not slip through. */
751const MENTIONS_PROTECTED_DIR = /\.claude\/orquestrador(?=$|[\/\s"'])/
752
753const isReadOnlyCommand = (command: string): boolean => {
754  const quiet = command.replace(/\d?>&\d|\d?>\s*\/dev\/null/g, '')
755  if (quiet.includes('>')) return false
756  return quiet
757    .split(/&&|\|\||[|;]/)
758    .map(part => part.trim())
759    .filter(part => part !== '')
760    .every(part => {
761      const words = part.split(/\s+/).filter(w => !/^\w+=/.test(w))
762      const head = words[0]?.split('/').pop() ?? ''
763      return READ_ONLY_COMMANDS.has(head) && !(head === 'find' && /-delete|-exec|-ok\b|-fprint/.test(part))
764    })
765}
766
767const touchesGateFiles = (tool: string, input: Record<string, unknown>): boolean => {
768  if (EDIT_TOOLS.has(tool)) {
769    const path = input.file_path ?? input.notebook_path
770    return typeof path === 'string' && path.includes(PROTECTED_DIR)
771  }
772  return (
773    tool === 'Bash' &&
774    typeof input.command === 'string' &&
775    MENTIONS_PROTECTED_DIR.test(input.command) &&
776    !isReadOnlyCommand(input.command)
777  )
778}
779
780/** Rigid: a fourth live phase session slows the owner's machine, so there is no way around it. */
781const refuseStart = async ($: EngineInterface): Promise<string | undefined> => {
782  const [self] = parseSessionRows((await callTool($, { tool: GET_SESSION, session_id: 'self' })).text)
783  const family = parseSessionRows((await callTool($, { tool: LIST_SESSIONS, linked: true, limit: 50 })).text)
784  if (self === undefined) return 'the orchestrator could not read this session, so it cannot count the live phase sessions.'
785  const live = liveSessions(family, self.sessionId, self.cwd)
786  if (live.length < MAX_LIVE_SESSIONS) return undefined
787  return (
788    `${live.length} phase sessions are alive (${live.map(r => r.name ?? r.title).join(', ')}); the ceiling is ` +
789    `${MAX_LIVE_SESSIONS}. Archive a finished one (its gates must have passed) before starting another.`
790  )
791}
792
793const refuseArchive = async ($: EngineInterface, sessionId: unknown): Promise<string | undefined> => {
794  if (typeof sessionId !== 'string' || sessionId === 'self') return undefined
795  const run = await loadRun($, await $.session.root())
796  if (run === undefined) return undefined
797  const refusals = phaseRefusals(
798    run,
799    await loadFamily($),
800    await ownCheck($),
801    await loadSkips($, run.change),
802    (phase, card) => phase.status !== 'done' && card?.sessionId === sessionId,
803  )
804  if (refusals.length === 0) return undefined
805  await logGateEvent($, run.change, { kind: 'refused', action: 'archive', reasons: refusals })
806  return refusals.join(' | ')
807}
808
809const refuseMerge = async ($: EngineInterface): Promise<string | undefined> => {
810  const run = await loadRun($, await $.session.root())
811  if (run === undefined) return undefined
812  const open = await finalOpenGates($, run)
813  if (open.length === 0) return undefined
814  await logGateEvent($, run.change, { kind: 'refused', action: 'merge', gates: open.map(v => v.gate) })
815  return (
816    `final gates of ${run.change} not passed: ${open.map(v => `${v.gate} (${v.state})`).join(', ')}. ` +
817    'Run them once on the integrated code; only the owner can skip one, from the panel.'
818  )
819}
820
821/** Edits to prose or outside the checkout (evidence.md, notes, scratch files) do not unprove a gate. */
822const editsCode = (path: unknown, root: string): boolean =>
823  typeof path === 'string' && path.startsWith(`${root}/`) && !path.endsWith('.md')
824
825type BashOutput = { backgroundTaskId?: string; interrupted?: boolean; bashEditDiff?: { files?: { filePath?: string }[] } }
826
827/** Records what a finished call proves for the gates, and when this session last edited code. */
828const observeGates = async (
829  $: EngineInterface,
830  tool: string,
831  input: Record<string, unknown>,
832  ran: CallResult & { result?: unknown },
833): Promise<void> => {
834  if (ran.deny !== undefined) return
835  const now = await $.clock.now()
836  const root = await $.session.root()
837  const failed = ran.isError === true
838  let edited = false
839  let observed: GateRecord[] = []
840  if (tool === 'Bash') {
841    const out = (failed ? undefined : ran.result) as BashOutput | undefined
842    if (out?.backgroundTaskId !== undefined || out?.interrupted === true || input.run_in_background === true) return
843    edited = (out?.bashEditDiff?.files ?? []).some(f => editsCode(f.filePath, root))
844    observed = observeBash(String(input.command ?? ''), failed, now)
845  } else if (EDIT_TOOLS.has(tool)) {
846    edited = !failed && editsCode(input.file_path ?? input.notebook_path, root)
847  } else {
848    observed = observeTool(tool, input, failed, ran.text ?? '', now)
849  }
850  if (edited) await $.state.set(EDITED_AT, now)
851  if (observed.length > 0) {
852    const { value: records = {} } = await $.state.get(GATES_SEEN)
853    await $.state.set(GATES_SEEN, mergeRecords(records, observed))
854  }
855}
856
857/** Calls whose guard must hold even when the mod itself fails: refused, never let through. */
858const isGuarded = (tool: string, input: Record<string, unknown>): boolean =>
859  tool === PLAN_TOOL ||
860  tool === FLOW_TOOL ||
861  tool === START_SESSION ||
862  tool === ARCHIVE_SESSION ||
863  tool.endsWith(TRANSITION_SUFFIX) ||
864  (tool === 'Bash' && typeof input.command === 'string' && GH_MERGE.test(input.command))
865
866const FLOW_SCHEMA = {
867  type: 'object',
868  properties: {
869    action: {
870      type: 'string',
871      enum: ['list', 'start', 'status', 'report', 'round', 'mockup', 'close', 'note'],
872      description:
873        'list: the flows on offer. start: begin a flow for a change. status: where the change stands and who ' +
874        'answers the current step. report: the outcome of the current session step. round: record one ' +
875        'grill round (questions and the owner\'s answers) while the flow is on the grill. mockup: attach the ' +
876        'mockup the owner reviews while the flow is on the mockup. close: during the phase loop, ' +
877        'close `phase` once its gates passed, its tasks are ticked in tasks.md and its work is committed; the mod ' +
878        'archives its session and ends the loop when every phase is closed (without `phase`, it only checks that). ' +
879        'note: leave `note` for the owner step that waits (e.g. what QA asked); it joins the owner\'s answer.',
880    },
881    phase: { type: 'string', description: 'close: the phase id, as in the published plan (e.g. "4.6") or a tasks.md section number.' },
882    change: {
883      type: 'string',
884      description: 'Change id. Optional for status/report when this coordinator runs a single flow.',
885    },
886    flow: { type: 'string', description: 'start: the flow id, e.g. "completo" or "rapido".' },
887    visual: { type: 'boolean', description: 'start: the change alters what the user sees (enables the mockup step).' },
888    changePath: { type: 'string', description: 'start: path to the change folder (holds evidence.md).' },
889    step: { type: 'string', description: 'report: the id of the step being reported; must be the current step.' },
890    outcome: { type: 'string', description: 'report: one of the outcomes the step declares.' },
891    note: { type: 'string', description: 'report/note: one line on what was done, or the note for the owner step (pt-BR).' },
892    questions: {
893      type: 'array',
894      description:
895        'round: the round\'s questions (pt-BR), each with the owner\'s answer once given and, optionally, the ' +
896        'acceptance criterion the answer becomes.',
897      items: {
898        type: 'object',
899        properties: {
900          question: { type: 'string' },
901          answer: { type: 'string' },
902          criterion: { type: 'string' },
903        },
904        required: ['question'],
905      },
906    },
907    amend: { type: 'boolean', description: 'round: merge into the latest round (fill answers) instead of adding one.' },
908    artifact: { type: 'string', description: 'mockup: the mockup\'s Artifact URL or file path.' },
909  },
910  required: ['action'],
911}
912
913const flowsDir = async ($: EngineInterface): Promise<string> => `${await baseDir($)}/flows`
914
915/** Every flow file of one folder, the ones that do not parse named in `problems`. */
916const readFlowDir = async ($: EngineInterface, dir: string): Promise<{ flows: FlowDef[]; problems: string[] }> => {
917  if (!(await $.fs.exists(dir))) return { flows: [], problems: [] }
918  const files = (await $.fs.list(dir)).filter(f => f.kind === 'file' && f.name.endsWith('.json'))
919  const flows: FlowDef[] = []
920  const problems: string[] = []
921  for (const file of files) {
922    let raw: unknown
923    try {
924      raw = JSON.parse(await $.fs.read(`${dir}/${file.name}`))
925    } catch {
926      problems.push(`${dir}/${file.name}: not valid JSON.`)
927      continue
928    }
929    const parsed = parseFlow(raw, `${dir}/${file.name}`)
930    if ('error' in parsed) problems.push(parsed.error)
931    else flows.push(parsed)
932  }
933  return { flows, problems }
934}
935
936/** The mod's default flows, then the project's own in `<repo>/.orquestrador/flows`, which win by id. */
937const loadFlows = async ($: EngineInterface): Promise<{ flows: Record<string, FlowDef>; problems: string[] }> => {
938  const project = await readFlowDir($, `${await $.session.root()}/.orquestrador/flows`)
939  return { flows: mergeFlows(defaultFlows(), project.flows), problems: project.problems }
940}
941
942const flowFile = async ($: EngineInterface, change: string, ext: 'json' | 'ndjson'): Promise<string> =>
943  `${await flowsDir($)}/${runKey(change)}.${ext}`
944
945const loadFlowState = async ($: EngineInterface, change: string): Promise<FlowState | undefined> =>
946  readJson<FlowState>($, await flowFile($, change, 'json'))
947
948const allFlowStates = async ($: EngineInterface): Promise<FlowState[]> => {
949  const dir = await flowsDir($)
950  if (!(await $.fs.exists(dir))) return []
951  const files = (await $.fs.list(dir)).filter(f => f.kind === 'file' && f.name.endsWith('.json'))
952  const states = await Promise.all(files.map(f => readJson<FlowState>($, `${dir}/${f.name}`)))
953  return states.filter((s): s is FlowState => s !== undefined && typeof s.change === 'string')
954}
955
956/** The state file and its append-only log, both written by the mod alone. */
957const saveFlow = async ($: EngineInterface, state: FlowState, events: readonly FlowEvent[]): Promise<void> => {
958  await $.fs.write(await flowFile($, state.change, 'json'), JSON.stringify(state))
959  const log = await flowFile($, state.change, 'ndjson')
960  const prior = (await readText($, log)) ?? ''
961  await $.fs.write(log, prior + events.map(e => `${JSON.stringify(e)}\n`).join(''))
962}
963
964const logFlowRefusal = async ($: EngineInterface, state: FlowState, step: string, by: FlowEvent['by'], note: string): Promise<void> => {
965  const log = await flowFile($, state.change, 'ndjson')
966  const prior = (await readText($, log)) ?? ''
967  const event: FlowEvent = { seq: state.seq, at: await $.clock.now(), kind: 'refused', step, by, note }
968  await $.fs.write(log, `${prior}${JSON.stringify(event)}\n`)
969}
970
971/** A flow belongs to the coordinator's checkout and to every worktree under it. */
972const ownsFlow = (state: FlowState, root: string): boolean =>
973  root === state.coordinatorCwd || root.startsWith(`${state.coordinatorCwd}/`)
974
975/** The flow this session works on: the named change, else the one flow its checkout runs. */
976const pickFlow = async ($: EngineInterface, change: unknown): Promise<FlowState | { error: string }> => {
977  if (typeof change === 'string' && change.trim() !== '') {
978    return (await loadFlowState($, change.trim())) ?? { error: `no flow was started for ${change}; start one with action "start".` }
979  }
980  const root = await $.session.root()
981  const mine = (await allFlowStates($)).filter(s => ownsFlow(s, root) && s.status !== 'done' && s.status !== 'aborted')
982  if (mine.length === 1) return mine[0]!
983  return mine.length === 0
984    ? { error: 'no flow is running for this checkout; start one with action "start".' }
985    : { error: `several flows run here (${mine.map(s => s.change).join(', ')}); name the change.` }
986}
987
988type FlowReply = ToolCallResult<string>
989const flowError = (why: string): FlowReply => ({ result: `Flow: ${why}`, isError: true as const })
990
991const handleFlowTool = async ($: EngineInterface, input: Record<string, unknown>): Promise<FlowReply> => {
992  const action = input.action
993  if (action === 'list') {
994    const { flows, problems } = await loadFlows($)
995    const lines = Object.values(flows).map(f => `${f.id} — ${f.title}: ${f.steps.map(s => s.id).join(' → ')} → fim`)
996    return { result: [...lines, ...problems.map(p => `ignored: ${p}`)].join('\n') }
997  }
998  if (action === 'start') {
999    const change = typeof input.change === 'string' ? input.change.trim() : ''
1000    if (change === '') return flowError('`change` is required to start a flow.')
1001    const { flows, problems } = await loadFlows($)
1002    const flow = typeof input.flow === 'string' ? flows[input.flow] : undefined
1003    if (flow === undefined) {
1004      return flowError(
1005        `unknown flow "${String(input.flow)}"; choose one of ${Object.keys(flows).join(', ') || DEFAULT_FLOW_IDS.join(', ')}.` +
1006          (problems.length > 0 ? ` Ignored files: ${problems.join(' ')}` : ''),
1007      )
1008    }
1009    const existing = await loadFlowState($, change)
1010    if (existing !== undefined && existing.status !== 'done' && existing.status !== 'aborted') {
1011      return flowError(`${change} already runs flow ${existing.flow.id}. ${describeFlow(existing)}`)
1012    }
1013    const started = startFlow(
1014      flow,
1015      {
1016        change,
1017        coordinatorCwd: await $.session.root(),
1018        ...(typeof input.changePath === 'string' ? { changePath: input.changePath } : {}),
1019        visual: input.visual === true,
1020      },
1021      await $.clock.now(),
1022    )
1023    await saveFlow($, started.state, started.events)
1024    await refreshQuietly($)
1025    return { result: `Started. ${await describeWithPrep($, started.state)}` }
1026  }
1027  const picked = await pickFlow($, input.change)
1028  if ('error' in picked) return flowError(picked.error)
1029  if (action === 'status') return { result: await describeWithPrep($, picked) }
1030  if (action === 'round' || action === 'mockup') return handlePrepAction($, picked, input)
1031  if (action === 'close') return closeLoopPhase($, picked, input.phase)
1032  if (action === 'note') {
1033    if (typeof input.note !== 'string' || input.note.trim() === '') return flowError('`note` is required.')
1034    const noted = noteOwnerStep(picked, input.note.trim(), await $.clock.now())
1035    if ('error' in noted) return flowError(`refused: ${noted.error}`)
1036    await saveFlow($, noted.state, noted.events)
1037    return { result: `Note kept for the owner's answer. ${describeFlow(noted.state)}` }
1038  }
1039  if (action !== 'report') return flowError('action must be list, start, status, report, round, mockup, close or note.')
1040  if (typeof input.step !== 'string' || typeof input.outcome !== 'string') {
1041    return flowError('`step` and `outcome` are required to report.')
1042  }
1043  const moved = applyOutcome(
1044    picked,
1045    { step: input.step, outcome: input.outcome, by: 'session', ...(typeof input.note === 'string' ? { note: input.note } : {}) },
1046    await $.clock.now(),
1047  )
1048  if ('error' in moved) {
1049    await logFlowRefusal($, picked, input.step, 'session', moved.error)
1050    return flowError(`refused: ${moved.error}`)
1051  }
1052  await saveFlow($, moved.state, moved.events)
1053  await refreshQuietly($)
1054  return { result: `Recorded ${input.step} → ${input.outcome}. ${await describeWithPrep($, moved.state)}` }
1055}
1056
1057/** The change's tasks.md, read from the coordinator's checkout. */
1058const changeTasks = async ($: EngineInterface, state: FlowState): Promise<string | undefined> => {
1059  if (state.changePath === undefined) return undefined
1060  const home = (await $.env.get('HOME')) ?? ''
1061  return readText($, `${changeDir(state.changePath.replace(/^~(?=\/)/, home), state.coordinatorCwd)}/tasks.md`)
1062}
1063
1064/** The uncommitted paths of a checkout; undefined when git cannot tell (no repo, no folder). */
1065const dirtyPaths = async ($: EngineInterface, cwd: string): Promise<string[] | undefined> => {
1066  const ran = await $.process.run(['git', '-C', cwd, 'status', '--porcelain'], { timeoutMs: 10_000 }).catch(() => undefined)
1067  return ran?.exitCode === 0 ? uncommitted(ran.stdout) : undefined
1068}
1069
1070const dirtyReason = (label: string, cwd: string, dirty: string[] | undefined): string | undefined =>
1071  dirty === undefined
1072    ? `git could not read the checkout of session "${label}" (${cwd}), so the mod cannot tell its work is committed.`
1073    : dirty.length === 0
1074      ? undefined
1075      : `session "${label}" has uncommitted changes in ${cwd} (${dirty.slice(0, 5).join(', ')}${dirty.length > 5 ? '…' : ''}); ` +
1076        'commit them in that session first.'
1077
1078const archiveSession = async ($: EngineInterface, sessionId: string, reason: string): Promise<string | undefined> => {
1079  const ran = await callTool($, { tool: ARCHIVE_SESSION, session_id: sessionId, reason }).catch(
1080    error => ({ isError: true, text: describeError('archive', error) }) as CallResult,
1081  )
1082  return ran.deny ?? (ran.isError === true ? (ran.text ?? 'archive failed').slice(0, 160) : undefined)
1083}
1084
1085/**
1086 * The executar loop: a phase closes only when its gates passed, its tasks are ticked and its
1087 * session's work is committed; the mod then archives that session. Once every phase is closed the
1088 * mod ends the loop itself: no session reports it.
1089 */
1090const closeLoopPhase = async ($: EngineInterface, state: FlowState, phase: unknown): Promise<FlowReply> => {
1091  const refusal = loopRefusal(state)
1092  if (refusal !== undefined) {
1093    await logFlowRefusal($, state, state.current, 'session', refusal)
1094    return flowError(`refused: ${refusal}`)
1095  }
1096  const run = await readJson<OrchestrationRun>($, `${await baseDir($)}/runs/${runKey(state.change)}.json`)
1097  const units = loopUnits(run, await changeTasks($, state))
1098  let moved: Moved = { state, events: [] }
1099  let closedNow = ''
1100  if (typeof phase === 'string' && phase.trim() !== '') {
1101    const unit = units.find(u => u.id === phase.trim())
1102    if (unit === undefined) {
1103      return flowError(`no phase "${phase.trim()}"; the phases are ${units.map(u => u.id).join(', ') || 'none'}.`)
1104    }
1105    if (!isClosed(state, unit)) {
1106      const family = await loadFamily($)
1107      const card = run === undefined ? undefined : phaseViews(run, family?.children ?? []).flat().find(p => p.id === unit.id)?.card
1108      const own = unit.session === undefined
1109      const check = own ? await ownCheck($) : (card?.gates ?? { records: {}, editedAt: undefined })
1110      const where = own ? 'this session' : card === undefined ? `session "${unit.session}" (not found)` : `session "${card.title}"`
1111      const skips = await loadSkips($, state.change)
1112      const label = card?.name ?? card?.title
1113      const why =
1114        unitRefusal(unit, gateViews(unit.gates, unit.id, check, skips).filter(isOpenGate), where) ??
1115        (card === undefined || card.isArchived ? undefined : dirtyReason(label ?? card.sessionId, card.cwd, await dirtyPaths($, card.cwd)))
1116      if (why !== undefined) {
1117        await logFlowRefusal($, state, state.current, 'session', why)
1118        return flowError(`refused: ${why}`)
1119      }
1120      const failure =
1121        card === undefined || card.isArchived ? undefined : await archiveSession($, card.sessionId, `Fase ${unit.id} fechada`)
1122      moved = closePhase(
1123        state,
1124        unit,
1125        { session: label, archived: card !== undefined && failure === undefined, ...(failure === undefined ? {} : { note: failure }) },
1126        await $.clock.now(),
1127      )
1128      closedNow =
1129        `Phase ${unit.id} closed` +
1130        (card === undefined ? '' : failure === undefined ? `, session "${label}" archived` : `; its session was not archived: ${failure}`) +
1131        '. '
1132    }
1133  }
1134  const finished = finishLoop(moved.state, units, await $.clock.now())
1135  if ('error' in finished) {
1136    await saveFlow($, moved.state, moved.events)
1137    return flowError(`${closedNow}${finished.error}`)
1138  }
1139  if ('open' in finished) {
1140    await saveFlow($, moved.state, moved.events)
1141    await refreshQuietly($)
1142    return { result: `${closedNow}Still open: ${finished.open.map(u => `${u.id} (${u.title})`).join(', ')}.` }
1143  }
1144  await saveFlow($, finished.state, [...moved.events, ...finished.events])
1145  await refreshQuietly($)
1146  return { result: `${closedNow}Every phase is closed. ${describeFlow(finished.state)}` }
1147}
1148
1149/**
1150 * The end of a change: archive the phase sessions left in its sidebar group (only those whose
1151 * work is committed) and remove the group. Each step is logged; a failure never blocks the flow.
1152 */
1153const cleanupChange = async ($: EngineInterface, state: FlowState): Promise<Omit<FlowEvent, 'seq'>[]> => {
1154  const at = await $.clock.now()
1155  const ev = (kind: FlowEvent['kind'], note: string): Omit<FlowEvent, 'seq'> => ({ at, kind, step: state.current, by: 'mod', note })
1156  const name = groupName(state.change)
1157  try {
1158    if (!(await canCall($, LIST_GROUPS))) return [ev('cleanup', 'sidebar unavailable: sessions and group left as they are')]
1159    const groupId = findGroupId((await callTool($, { tool: LIST_GROUPS })).text, name)
1160    if (groupId === undefined) return [ev('cleanup', `no sidebar group ${name}`)]
1161    const [self] = parseSessionRows((await callTool($, { tool: GET_SESSION, session_id: 'self' })).text)
1162    const rows = parseSessionRows((await callTool($, { tool: LIST_SESSIONS, group: groupId, limit: 50 })).text)
1163    const events: Omit<FlowEvent, 'seq'>[] = []
1164    for (const row of phaseSessions(rows, self?.sessionId, state.coordinatorCwd)) {
1165      const label = row.name ?? row.title
1166      const dirty = dirtyReason(label, row.cwd, await dirtyPaths($, row.cwd))
1167      const failure = dirty ?? (await archiveSession($, row.sessionId, `Change ${state.change} arquivada`))
1168      events.push(ev('session-archived', failure === undefined ? label : `${label} not archived — ${failure}`))
1169    }
1170    const removed = await callTool($, { tool: DELETE_GROUP, group_id: groupId }).catch(() => ({ isError: true }) as CallResult)
1171    const ok = removed.isError !== true && removed.deny === undefined
1172    events.push(ev('cleanup', ok ? `sidebar group ${name} removed` : `sidebar group ${name} not removed: ${removed.deny ?? removed.text ?? 'failed'}`))
1173    return events
1174  } catch (error) {
1175    return [ev('cleanup', describeError('cleanup', error))]
1176  }
1177}
1178
1179const EVIDENCE_OK = new Set(['recorded', 'green'])
1180
1181/** Whether evidence.md of the flow's change is filled in; a flow with no change folder has none. */
1182const hasEvidence = async ($: EngineInterface, state: FlowState): Promise<boolean> => {
1183  if (state.changePath === undefined) return false
1184  const home = (await $.env.get('HOME')) ?? ''
1185  const dir = changeDir(state.changePath.replace(/^~(?=\/)/, home), state.coordinatorCwd)
1186  const evidence = await readText($, `${dir}/evidence.md`)
1187  return EVIDENCE_OK.has(openSpecView({ tasks: undefined, proposal: undefined, evidence }).evidence)
1188}
1189
1190const executedTextShort = (command: string): string => `comando: ${command.replace(/\s+/g, ' ').trim().slice(0, 80)}`
1191
1192/** An automatic step moves only when the mod itself sees its command finish, never on a session's word. */
1193const observeFlows = async ($: EngineInterface, command: string, failed: boolean): Promise<void> => {
1194  const root = await $.session.root()
1195  for (const state of await allFlowStates($)) {
1196    if (state.status !== 'running' || !ownsFlow(state, root)) continue
1197    const step = currentStep(state)
1198    if (step === undefined) continue
1199    const detected = detectedOutcome(step, command, failed)
1200    if (detected !== undefined) {
hooks/gates.ts 227 lines
1import type { GateCheck, GateRecord, GateSkip, GateState, GateView, Phase } from '../types'
2
3/** The most phase sessions alive at once: more of them slows the owner's machine down. */
4export const MAX_LIVE_SESSIONS = 3
5
6type BashMatcher = { kind: 'bash'; pattern: RegExp }
7type ToolMatcher = { kind: 'tool'; matches: (tool: string, input: Record<string, unknown>) => boolean; passes: (text: string) => boolean }
8
9type GateDef = { label: string; how: string; matcher: BashMatcher | ToolMatcher }
10
11const isScreenshot = (tool: string, input: Record<string, unknown>): boolean => {
12  if (/(Claude_Browser|claude-in-chrome)__computer$/.test(tool)) return input.action === 'screenshot' || input.action === 'zoom'
13  if (/Claude_Code_iOS_Simulator__control$/.test(tool)) return input.action === 'screenshot'
14  if (/(Claude_Browser|claude-in-chrome)__browser_batch$/.test(tool) && Array.isArray(input.actions)) {
15    return input.actions.some(
16      (a: unknown) => (a as { input?: { action?: unknown } } | null)?.input?.action === 'screenshot',
17    )
18  }
19  return false
20}
21
22const REVIEWER = /adr-reviewer|spec-reviewer|reviewer-architecture|reviewer-quality/
23
24export const GATES: Readonly<Record<string, GateDef>> = {
25  testes: {
26    label: 'testes',
27    how: 'a test command (vitest, jest, pytest, npm test, claude plugin test…) exiting 0',
28    matcher: {
29      kind: 'bash',
30      pattern:
31        /\b(vitest|jest|pytest|mocha|go test|cargo test|playwright test|claude plugin test)\b|\b(npm|pnpm|yarn|bun)\s+(run\s+)?test(:\S+)?\b/,
32    },
33  },
34  lint: {
35    label: 'lint',
36    how: 'a lint/format check (eslint, prettier --check, ruff, biome, npm run lint…) exiting 0',
37    matcher: {
38      kind: 'bash',
39      pattern:
40        /\b(eslint|prettier\s+(--check|-c)|ruff|biome\s+(check|lint|ci)|stylelint|black\s+--check|flake8)\b|\b(npm|pnpm|yarn|bun)\s+(run\s+)?(lint|format:check)\b/,
41    },
42  },
43  check: {
44    label: 'check.sh',
45    how: '`sh scripts/check.sh` exiting 0',
46    matcher: { kind: 'bash', pattern: /\bscripts\/check\.sh\b/ },
47  },
48  openspec: {
49    label: 'openspec',
50    how: '`openspec validate` exiting 0',
51    matcher: { kind: 'bash', pattern: /\bopenspec(\.js)?\s+validate\b/ },
52  },
53  visual: {
54    label: 'visual',
55    how: 'a screenshot taken in the browser pane, Chrome or the iOS simulator',
56    matcher: { kind: 'tool', matches: isScreenshot, passes: () => true },
57  },
58  regras: {
59    label: 'regras',
60    how: 'a rules review (adr-reviewer, spec-reviewer, reviewer-architecture or reviewer-quality agent) approving',
61    matcher: {
62      kind: 'tool',
63      matches: (tool, input) => tool === 'Agent' && typeof input.subagent_type === 'string' && REVIEWER.test(input.subagent_type),
64      passes: text => /\b(approved|aprovad[oa]|no violations?|nenhuma viola)/i.test(text) && !/\b(rejected|reprovad[oa])\b/i.test(text),
65    },
66  },
67}
68
69export const GATE_IDS = Object.keys(GATES)
70
71/** Gate ids a plan names, checked against the catalogue. */
72export const parseGates = (raw: unknown, where: string): string[] | { error: string } | undefined => {
73  if (raw === undefined) return undefined
74  if (!Array.isArray(raw)) return { error: `${where}: \`gates\` must be a list of gate ids.` }
75  const ids = [...new Set(raw.filter((g): g is string => typeof g === 'string').map(g => g.trim()))]
76  const unknown = ids.filter(id => GATES[id] === undefined)
77  if (unknown.length > 0) {
78    return { error: `${where}: unknown gate ${unknown.join(', ')}; known gates are ${GATE_IDS.join(', ')}.` }
79  }
80  return ids.length === 0 ? undefined : ids
81}
82
83/**
84 * A pipe hands the shell the exit code of its last command, so `npm test | tail`
85 * "passes" whatever the tests did, and `npm test || true` always exits 0: such a run
86 * proves a failure, never a pass.
87 */
88export const masksExitCode = (command: string): boolean =>
89  (/(^|[^|])\|(?!\|)/.test(command) && !/pipefail/.test(command)) || /\|\|/.test(command)
90
91/**
92 * The words the shell runs, without the text it only carries: a heredoc body or a quoted
93 * string that mentions `npm test` writes a file, it does not run the tests.
94 */
95export const executedText = (command: string): string =>
96  command
97    .replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2\b/g, '<<heredoc')
98    .replace(/'[^']*'/g, "''")
99    .replace(/"(?:[^"\\]|\\.)*"/g, '""')
100
101/** The one shell step that ran the gate, short enough to read in the panel. */
102const evidenceOf = (code: string, pattern: RegExp): string => {
103  const step = code.split(/&&|\|\||[;|\n]/).find(part => pattern.test(part)) ?? code
104  return step.replace(/\s+/g, ' ').trim().slice(0, 60)
105}
106
107/** What one finished Bash command proves, gate by gate. */
108export const observeBash = (command: string, failed: boolean, at: number): GateRecord[] => {
109  const code = executedText(command)
110  return Object.entries(GATES).flatMap(([gate, def]): GateRecord[] => {
111    if (def.matcher.kind !== 'bash' || !def.matcher.pattern.test(code)) return []
112    if (!failed && masksExitCode(code)) return []
113    return [{ gate, result: failed ? 'failed' : 'passed', evidence: evidenceOf(code, def.matcher.pattern), at }]
114  })
115}
116
117/** What one finished non-Bash tool call proves: a screenshot, a reviewer's verdict. */
118export const observeTool = (
119  tool: string,
120  input: Record<string, unknown>,
121  failed: boolean,
122  text: string,
123  at: number,
124): GateRecord[] =>
125  Object.entries(GATES).flatMap(([gate, def]): GateRecord[] => {
126    if (def.matcher.kind !== 'tool' || !def.matcher.matches(tool, input)) return []
127    const passed = !failed && def.matcher.passes(text)
128    const what = tool === 'Agent' ? `agente ${String(input.subagent_type)}` : 'screenshot'
129    return [{ gate, result: passed ? 'passed' : 'failed', evidence: what, at }]
130  })
131
132/** The latest record per gate wins: a fresh pass clears an old failure and the reverse. */
133export const mergeRecords = (
134  records: Readonly<Record<string, GateRecord>>,
135  observed: readonly GateRecord[],
136): Record<string, GateRecord> => {
137  const next = { ...records }
138  for (const record of observed) {
139    const prior = next[record.gate]
140    if (prior === undefined || prior.at <= record.at) next[record.gate] = record
141  }
142  return next
143}
144
145/** Edits made after a pass leave it unproven for the code as it now stands. */
146export const gateState = (
147  record: GateRecord | undefined,
148  editedAt: number | undefined,
149  skip: GateSkip | undefined,
150): GateState => {
151  if (record?.result === 'passed' && (editedAt === undefined || editedAt <= record.at)) return 'passed'
152  if (skip !== undefined) return 'skipped'
153  if (record === undefined) return 'pending'
154  return record.result === 'failed' ? 'failed' : 'stale'
155}
156
157export const skipKey = (scope: string, gate: string): string => `${scope}:${gate}`
158export const FINAL_SCOPE = '*final'
159
160export const gateViews = (
161  gates: readonly string[] | undefined,
162  scope: string,
163  check: GateCheck,
164  skips: Readonly<Record<string, GateSkip>>,
165): GateView[] =>
166  (gates ?? []).map(gate => {
167    const record = check.records[gate]
168    const skip = skips[skipKey(scope, gate)]
169    return {
170      gate,
171      label: GATES[gate]?.label ?? gate,
172      state: gateState(record, check.editedAt, skip),
173      evidence: record?.evidence,
174      at: record?.at,
175      skipKey: skipKey(scope, gate),
176    }
177  })
178
179export const isOpenGate = (view: GateView): boolean => view.state !== 'passed' && view.state !== 'skipped'
180
181/** Final gates are proven by whichever session of the family ran them last. */
182export const familyCheck = (checks: readonly GateCheck[]): GateCheck => {
183  const records: Record<string, GateRecord> = {}
184  let editedAt: number | undefined
185  for (const check of checks) {
186    for (const record of Object.values(check.records)) {
187      const prior = records[record.gate]
188      if (prior === undefined || prior.at < record.at) records[record.gate] = record
189    }
190    if (check.editedAt !== undefined && (editedAt === undefined || check.editedAt > editedAt)) editedAt = check.editedAt
191  }
192  return { records, editedAt }
193}
194
195const STATE_WORD: Record<GateState, string> = {
196  passed: 'passed',
197  failed: 'failed',
198  pending: 'not run',
199  stale: 'stale (files edited after it passed)',
200  skipped: 'skipped by the owner',
201}
202
203/** Why a phase cannot close yet, in the words the model reads; undefined when it can. */
204export const phaseRefusal = (phase: Pick<Phase, 'id' | 'title'>, open: readonly GateView[], where: string): string | undefined =>
205  open.length === 0
206    ? undefined
207    : `phase ${phase.id} (${phase.title}) cannot close: ` +
208      open.map(v => `${v.gate} ${STATE_WORD[v.state]} — needs ${GATES[v.gate]?.how ?? v.gate}`).join('; ') +
209      ` (checked in ${where}). Run the gate in that session, or ask the owner: only they can skip a gate, from the panel.`
210
211/**
212 * Phase sessions still alive: started by this session and not archived. A session in the
213 * coordinator's own folder is a side chat or fork the owner opened, not a phase: phases
214 * always run in a worktree of their own.
215 */
216export const liveSessions = <T extends { startedBy: string | undefined; isArchived: boolean; cwd?: string }>(
217  rows: readonly T[],
218  selfId: string | undefined,
219  selfCwd?: string,
220): T[] =>
221  selfId === undefined
222    ? []
223    : rows.filter(row => row.startedBy === selfId && !row.isArchived && (selfCwd === undefined || row.cwd !== selfCwd))
224
225export const GH_MERGE = /\bgh\s+pr\s+merge\b/
226export const PROTECTED_DIR = '/.claude/orquestrador/'
227
hooks/flow.ts 493 lines
1import type {
2  FlowActor,
3  FlowChoice,
4  FlowDef,
5  FlowDetect,
6  FlowEvent,
7  FlowOutcome,
8  FlowState,
9  FlowStep,
10  FlowStepKind,
11  FlowStepView,
12  FlowView,
13} from '../types'
14import { executedText, masksExitCode } from './gates'
15import { parseJiraTarget } from './jira'
16
17/** The pseudo-step every flow ends on. */
18export const FLOW_END = 'fim'
19export const DEFAULT_MAX_ATTEMPTS = 3
20export const DEFAULT_FLOW_IDS = ['completo', 'rapido'] as const
21
22/** An escalation's two answers, pressed only by the owner. */
23export const ESCALATION_CHOICES: readonly FlowChoice[] = [
24  { outcome: 'tentar-de-novo', label: 'Tentar de novo' },
25  { outcome: 'encerrar', label: 'Encerrar o flow' },
26]
27
28/** The outcome a loop step ends on, given by the mod once every phase closed. */
29export const LOOP_DONE = 'concluidas'
30
31/** Which actor may answer a step of each kind: a session never answers for the owner, a command or the loop. */
32const ANSWERED_BY: Record<FlowStepKind, FlowActor> = {
33  session: 'session',
34  loop: 'mod',
35  automatic: 'automatic',
36  owner: 'owner',
37}
38
39const KINDS = new Set<FlowStepKind>(['session', 'automatic', 'owner', 'loop'])
40const AUTOMATIC_OUTCOMES = ['passou', 'falhou']
41
42const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
43const text = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined)
44
45/** A flow file, checked whole: every next step exists, every kind is known, every automatic step names its command. */
46export const parseFlow = (raw: unknown, source: string): FlowDef | { error: string } => {
47  const fail = (why: string) => ({ error: `${source}: ${why}` })
48  if (!isRecord(raw)) return fail('a flow is a JSON object.')
49  const id = text(raw.id)
50  const title = text(raw.title)
51  if (id === undefined || title === undefined) return fail('`id` and `title` are required.')
52  if (!Array.isArray(raw.steps) || raw.steps.length === 0) return fail('`steps` must be a non-empty list.')
53  const steps: FlowStep[] = []
54  for (const [i, s] of raw.steps.entries()) {
55    const where = `step ${i + 1}`
56    if (!isRecord(s)) return fail(`${where} is not an object.`)
57    const stepId = text(s.id)
58    const stepTitle = text(s.title)
59    if (stepId === undefined || stepTitle === undefined) return fail(`${where}: \`id\` and \`title\` are required.`)
60    if (stepId === FLOW_END) return fail(`${where}: "${FLOW_END}" is reserved for the end of the flow.`)
61    if (steps.some(p => p.id === stepId)) return fail(`step id "${stepId}" appears twice.`)
62    const kind = s.kind as FlowStepKind
63    if (!KINDS.has(kind)) return fail(`step "${stepId}": kind must be session, automatic, owner or loop.`)
64    if (!isRecord(s.outcomes) || Object.keys(s.outcomes).length === 0) return fail(`step "${stepId}": \`outcomes\` is required.`)
65    const outcomes: Record<string, FlowOutcome> = {}
66    for (const [name, o] of Object.entries(s.outcomes)) {
67      const next = isRecord(o) ? text(o.next) : text(o)
68      if (next === undefined) return fail(`step "${stepId}", outcome "${name}": \`next\` is required.`)
69      outcomes[name] = { label: (isRecord(o) ? text(o.label) : undefined) ?? name, next }
70    }
71    const maxAttempts = s.maxAttempts === undefined ? DEFAULT_MAX_ATTEMPTS : s.maxAttempts
72    if (typeof maxAttempts !== 'number' || !Number.isInteger(maxAttempts) || maxAttempts < 1) {
73      return fail(`step "${stepId}": \`maxAttempts\` must be a whole number of at least 1.`)
74    }
75    const command = text(s.command)
76    if (kind === 'automatic') {
77      if (command === undefined) return fail(`step "${stepId}": an automatic step needs the \`command\` whose exit code decides.`)
78      const missing = AUTOMATIC_OUTCOMES.filter(o => outcomes[o] === undefined)
79      const extra = Object.keys(outcomes).filter(o => !AUTOMATIC_OUTCOMES.includes(o))
80      if (missing.length > 0 || extra.length > 0) {
81        return fail(`step "${stepId}": an automatic step has exactly the outcomes passou and falhou.`)
82      }
83    }
84    if (s.when !== undefined && s.when !== 'visual') return fail(`step "${stepId}": \`when\` may only be "visual".`)
85    if (s.over !== undefined && s.over !== 'tasks') return fail(`step "${stepId}": \`over\` may only be "tasks".`)
86    if (kind === 'loop' && outcomes[LOOP_DONE] === undefined) {
87      return fail(`step "${stepId}": a loop step declares the outcome ${LOOP_DONE}, given when every phase closed.`)
88    }
89    const detect = parseDetect(s.detect, outcomes)
90    if (typeof detect === 'string') return fail(`step "${stepId}": ${detect}`)
91    const jira = parseJiraTarget(s.jira)
92    if (typeof jira === 'string') return fail(`step "${stepId}": ${jira}`)
93    steps.push({
94      id: stepId,
95      title: stepTitle,
96      description: text(s.description),
97      kind,
98      outcomes,
99      maxAttempts,
100      command,
101      requiresEvidence: s.requiresEvidence === true,
102      when: s.when === 'visual' ? 'visual' : undefined,
103      over: kind === 'loop' ? 'tasks' : undefined,
104      detect,
105      cleanup: s.cleanup === true,
106      ...(jira === undefined ? {} : { jira }),
107    })
108  }
109  const ids = new Set(steps.map(s => s.id))
110  for (const step of steps) {
111    for (const [name, o] of Object.entries(step.outcomes)) {
112      if (o.next !== FLOW_END && !ids.has(o.next)) {
113        return fail(`step "${step.id}", outcome "${name}" goes to "${o.next}", which is no step of this flow.`)
114      }
115    }
116  }
117  return { id, title, description: text(raw.description) ?? '', steps }
118}
119
120/**
121 * A step's `detect`: one command or a list (`"vercel --prod"`), or `{ commands, outcome }`; the
122 * outcome may be left out when the step declares only one.
123 */
124const parseDetect = (raw: unknown, outcomes: Record<string, FlowOutcome>): FlowDetect | undefined | string => {
125  if (raw === undefined) return undefined
126  const spec = isRecord(raw) ? raw : { commands: raw }
127  const list = typeof spec.commands === 'string' ? [spec.commands] : spec.commands
128  const commands = Array.isArray(list) ? list.map(text).filter((c): c is string => c !== undefined) : []
129  if (commands.length === 0) return '`detect` needs at least one command.'
130  const names = Object.keys(outcomes)
131  const outcome = text(spec.outcome) ?? (names.length === 1 ? names[0] : undefined)
132  if (outcome === undefined || outcomes[outcome] === undefined) {
133    return `\`detect.outcome\` must name one of the step's outcomes (${names.join(', ')}).`
134  }
135  return { commands, outcome }
136}
137
138/** The flows on offer: the mod's defaults, each replaced by a project's flow of the same id. */
139export const mergeFlows = (defaults: readonly FlowDef[], project: readonly FlowDef[]): Record<string, FlowDef> => {
140  const all: Record<string, FlowDef> = {}
141  for (const flow of [...defaults, ...project]) all[flow.id] = flow
142  return all
143}
144
145export type Moved = { state: FlowState; events: FlowEvent[] }
146
147export const stepOf = (flow: FlowDef, id: string): FlowStep | undefined => flow.steps.find(s => s.id === id)
148const indexOf = (flow: FlowDef, id: string): number => flow.steps.findIndex(s => s.id === id)
149
150const applies = (step: FlowStep, state: FlowState): boolean => step.when !== 'visual' || state.traits.visual
151
152/**
153 * Enters `target`: skips steps whose trait the change lacks, forgets the outcomes of the target and
154 * every step after it (they must be done again), and counts the attempt. A session's or a command's
155 * outcome that would enter a step past its ceiling escalates to the owner instead; the owner's own
156 * press never does, since the owner already chose.
157 */
158const enter = (state: FlowState, target: string, by: FlowActor, cause: { from: string; outcome: string }, at: number): Moved => {
159  const events: FlowEvent[] = []
160  let seq = state.seq
161  const push = (e: Omit<FlowEvent, 'seq' | 'at'>) => events.push({ seq: ++seq, at, ...e })
162  let id = target
163  while (id !== FLOW_END) {
164    const step = stepOf(state.flow, id)
165    if (step === undefined || applies(step, state)) break
166    push({ kind: 'skipped', step: id, by: 'mod', note: `only when the change is ${step.when}` })
167    id = state.flow.steps[indexOf(state.flow, id) + 1]?.id ?? FLOW_END
168  }
169  if (id === FLOW_END) {
170    push({ kind: 'done', step: FLOW_END, by: 'mod' })
171    return { state: { ...state, current: FLOW_END, status: 'done', escalation: undefined, seq, updatedAt: at }, events }
172  }
173  const step = stepOf(state.flow, id) as FlowStep
174  const from = indexOf(state.flow, id)
175  const records = Object.fromEntries(
176    Object.entries(state.records).filter(([sid]) => indexOf(state.flow, sid) < from),
177  )
178  // going back keeps why (e.g. QA's request), until the flow reaches that step again
179  const cause_ = state.records[cause.from]
180  const source = stepOf(state.flow, cause.from)
181  const reopened =
182    cause_ !== undefined && source !== undefined && from <= indexOf(state.flow, cause.from)
183      ? {
184          step: cause.from,
185          outcome: cause.outcome,
186          label: source.outcomes[cause.outcome]?.label ?? cause.outcome,
187          by: cause_.by,
188          at,
189          ...(cause_.note === undefined ? {} : { note: cause_.note }),
190        }
191      : state.reopened !== undefined && id !== state.reopened.step
192        ? state.reopened
193        : undefined
194  const { reopened: _drop, ...rest } = state
195  state = reopened === undefined ? rest : { ...rest, reopened }
196  const attempts = state.attempts[id] ?? 0
197  if (by !== 'owner' && attempts >= step.maxAttempts) {
198    const escalation = { step: id, from: cause.from, outcome: cause.outcome, attempts }
199    push({ kind: 'escalated', step: id, by: 'mod', note: `${attempts} attempts, the ceiling is ${step.maxAttempts}` })
200    return { state: { ...state, current: id, status: 'escalated', escalation, records, seq, updatedAt: at }, events }
201  }
202  push({ kind: 'entered', step: id, by: 'mod' })
203  return {
204    state: {
205      ...state,
206      current: id,
207      status: 'running',
208      escalation: undefined,
209      attempts: { ...state.attempts, [id]: attempts + 1 },
210      records,
211      seq,
212      updatedAt: at,
213    },
214    events,
215  }
216}
217
218/** A change starts on the flow's first step that applies to it. */
219export const startFlow = (
220  flow: FlowDef,
221  init: { change: string; coordinatorCwd: string; changePath?: string; visual: boolean },
222  at: number,
223): Moved => {
224  const blank: FlowState = {
225    change: init.change,
226    coordinatorCwd: init.coordinatorCwd,
227    ...(init.changePath === undefined ? {} : { changePath: init.changePath }),
228    flow,
229    traits: { visual: init.visual },
230    current: flow.steps[0]!.id,
231    status: 'running',
232    attempts: {},
233    records: {},
234    seq: 1,
235    startedAt: at,
236    updatedAt: at,
237  }
238  const started: FlowEvent = { seq: 1, at, kind: 'started', step: flow.id, by: 'mod', note: init.visual ? 'visual' : undefined }
239  const moved = enter(blank, flow.steps[0]!.id, 'owner', { from: '', outcome: '' }, at)
240  return { state: moved.state, events: [JSON.parse(JSON.stringify(started)) as FlowEvent, ...moved.events] }
241}
242
243const WHO: Record<FlowActor, string> = {
244  session: 'a session (through the flow tool)',
245  automatic: 'the mod, watching its command run',
246  owner: 'the owner, pressing a button in the panel',
247  mod: 'the mod, once every phase is closed (close each with action "close")',
248  detect: "the mod, seeing the step's detect command succeed",
249}
250
251/**
252 * One outcome reported for one step. Refused, with the reason in the model's words, when the flow
253 * is not on that step (no skipping ahead or back), when the step's kind is not answered by this
254 * actor, or when the step does not declare the outcome.
255 */
256export const applyOutcome = (
257  state: FlowState,
258  report: { step: string; outcome: string; by: FlowActor; note?: string },
259  at: number,
260): Moved | { error: string } => {
261  const asked = stepOf(state.flow, report.step)
262  if (asked === undefined) {
263    return { error: `flow ${state.flow.id} has no step "${report.step}"; its steps are ${state.flow.steps.map(s => s.id).join(', ')}.` }
264  }
265  if (state.status === 'done') return { error: `the flow of ${state.change} is finished; nothing is left to report.` }
266  if (state.status === 'aborted') return { error: `the owner ended the flow of ${state.change}.` }
267  const current = stepOf(state.flow, state.current) as FlowStep
268  if (state.status === 'escalated') {
269    return {
270      error:
271        `step "${current.id}" (${current.title}) hit its ceiling of ${current.maxAttempts} attempts; ` +
272        'the owner decides in the panel whether it goes again.',
273    }
274  }
275  if (report.step !== state.current) {
276    const ahead = indexOf(state.flow, report.step) > indexOf(state.flow, state.current)
277    return {
278      error:
279        `the flow of ${state.change} is on "${current.id}" (${current.title}), not on "${report.step}"` +
280        (ahead ? ': steps cannot be skipped' : '') +
281        `. "${current.id}" is answered by ${WHO[ANSWERED_BY[current.kind]]}.`,
282    }
283  }
284  const detected = report.by === 'detect' && asked.detect !== undefined
285  if (ANSWERED_BY[asked.kind] !== report.by && !detected) {
286    return { error: `step "${asked.id}" (${asked.title}) is a ${asked.kind} step: only ${WHO[ANSWERED_BY[asked.kind]]} answers it.` }
287  }
288  if (detected && report.outcome !== asked.detect?.outcome) {
289    return { error: `step "${asked.id}": a detected command gives only ${asked.detect?.outcome}.` }
290  }
291  const outcome = asked.outcomes[report.outcome]
292  if (outcome === undefined) {
293    return { error: `step "${asked.id}" has no outcome "${report.outcome}"; it declares ${Object.keys(asked.outcomes).join(', ')}.` }
294  }
295  const note = report.note ?? state.pendingNote
296  const record = { outcome: report.outcome, by: report.by, at, ...(note === undefined ? {} : { note }) }
297  const reported: FlowEvent = {
298    seq: state.seq + 1,
299    at,
300    kind: 'outcome',
301    step: asked.id,
302    by: report.by,
303    outcome: report.outcome,
304    ...(note === undefined ? {} : { note }),
305  }
306  const { pendingNote: _used, ...unnoted } = state
307  const moved = enter(
308    { ...unnoted, records: { ...state.records, [asked.id]: record }, seq: state.seq + 1 },
309    outcome.next,
310    report.by,
311    { from: asked.id, outcome: report.outcome },
312    at,
313  )
314  return { state: moved.state, events: [reported, ...moved.events] }
315}
316
317/** The owner's answer to an escalation: go again with a fresh count, or end the flow. */
318export const resolveEscalation = (state: FlowState, choice: string, at: number): Moved | { error: string } => {
319  if (state.status !== 'escalated' || state.escalation === undefined) return { error: 'nothing is escalated.' }
320  const step = state.escalation.step
321  if (choice === 'encerrar') {
322    const event: FlowEvent = { seq: state.seq + 1, at, kind: 'aborted', step, by: 'owner' }
323    return { state: { ...state, status: 'aborted', seq: state.seq + 1, updatedAt: at }, events: [event] }
324  }
325  if (choice !== 'tentar-de-novo') return { error: `an escalation is answered with tentar-de-novo or encerrar, not "${choice}".` }
326  const retried: FlowEvent = { seq: state.seq + 1, at, kind: 'retried', step, by: 'owner' }
327  const moved = enter(
328    { ...state, attempts: { ...state.attempts, [step]: 0 }, seq: state.seq + 1 },
329    step,
330    'owner',
331    { from: state.escalation.from, outcome: state.escalation.outcome },
332    at,
333  )
334  return { state: moved.state, events: [retried, ...moved.events] }
335}
336
337/** The owner's press: an escalation's choice while one is open, else the owner step's outcome. */
338export const ownerPress = (state: FlowState, step: string, outcome: string, at: number, note?: string): Moved | { error: string } =>
339  state.status === 'escalated' && state.escalation?.step === step
340    ? resolveEscalation(state, outcome, at)
341    : applyOutcome(state, { step, outcome, by: 'owner', ...(note === undefined ? {} : { note }) }, at)
342
343/** What the owner sees after a press: the answer recorded, in their words, and where the flow went. */
344export const pressedToast = (before: FlowState, after: FlowState, step: string, outcome: string): string => {
345  const label =
346    stepOf(before.flow, step)?.outcomes[outcome]?.label ?? ESCALATION_CHOICES.find(c => c.outcome === outcome)?.label ?? outcome
347  const next =
348    after.status === 'done'
349      ? 'Flow concluído.'
350      : after.status === 'aborted'
351        ? 'Flow encerrado.'
352        : `Próximo passo: ${stepOf(after.flow, after.current)?.title ?? after.current}.`
353  return `Registrado: ${label}. ${next}`
354}
355
356/** Words of a declared command, matched in what the shell ran: `openspec validate` in `npx openspec validate x --strict`. */
357const commandPattern = (command: string): RegExp =>
358  new RegExp(
359    `(^|[\\s/;&|(])${command
360      .trim()
361      .split(/\s+/)
362      .map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
363      .join('\\s+')}(?=$|[\\s;&|)])`,
364  )
365
366/**
367 * What one finished Bash command proves for an automatic step: passou on exit 0, falhou otherwise.
368 * A run whose exit code a pipe or `|| true` hides proves a failure, never a pass.
369 */
370export const automaticOutcome = (step: FlowStep, command: string, failed: boolean): 'passou' | 'falhou' | undefined => {
371  if (step.kind !== 'automatic' || step.command === undefined) return undefined
372  const code = executedText(command)
373  if (!commandPattern(step.command).test(code)) return undefined
374  if (!failed && masksExitCode(code)) return undefined
375  return failed ? 'falhou' : 'passou'
376}
377
378/** A step's detect command seen succeeding: its outcome. A masked exit code or a failure proves nothing. */
379export const detectedOutcome = (step: FlowStep, command: string, failed: boolean): string | undefined => {
380  if (step.detect === undefined || failed) return undefined
381  const code = executedText(command)
382  if (masksExitCode(code)) return undefined
383  return step.detect.commands.some(c => commandPattern(c).test(code)) ? step.detect.outcome : undefined
384}
385
386/** A session's note for the owner step that waits now; it joins the owner's answer. */
387export const noteOwnerStep = (state: FlowState, note: string, at: number): Moved | { error: string } => {
388  const step = currentStep(state)
389  if (step === undefined || state.status !== 'running') return { error: 'no step is waiting.' }
390  if (step.kind !== 'owner') return { error: `step "${step.id}" is not the owner's; report its outcome instead.` }
391  const event: FlowEvent = { seq: state.seq + 1, at, kind: 'noted', step: step.id, by: 'session', note }
392  return { state: { ...state, pendingNote: note, seq: state.seq + 1, updatedAt: at }, events: [event] }
393}
394
395export const currentStep = (state: FlowState): FlowStep | undefined =>
396  state.status === 'done' || state.status === 'aborted' ? undefined : stepOf(state.flow, state.current)
397
398const stepView = (state: FlowState, step: FlowStep, index: number, currentIndex: number): FlowStepView => {
399  const record = state.records[step.id]
400  const isCurrent = step.id === state.current && (state.status === 'running' || state.status === 'escalated')
401  const finished = state.status === 'done'
402  const state_: FlowStepView['state'] = isCurrent
403    ? 'current'
404    : record !== undefined
405      ? 'done'
406      : !applies(step, state) && (finished || index < currentIndex)
407        ? 'skipped'
408        : 'pending'
409  return {
410    id: step.id,
411    title: step.title,
412    description: step.description,
413    kind: step.kind,
414    state: state_,
415    attempts: state.attempts[step.id] ?? 0,
416    maxAttempts: step.maxAttempts,
417    outcome: record?.outcome,
418    outcomeLabel: record === undefined ? undefined : step.outcomes[record.outcome]?.label,
419  }
420}
421
422/** Everything the panel draws of a flow, from the saved state alone. */
423export const flowView = (state: FlowState): FlowView => {
424  const currentIndex = state.status === 'done' ? state.flow.steps.length : indexOf(state.flow, state.current)
425  const steps = state.flow.steps.map((s, i) => stepView(state, s, i, currentIndex))
426  const step = currentStep(state)
427  const ownerChoices: FlowChoice[] =
428    state.status === 'escalated'
429      ? [...ESCALATION_CHOICES]
430      : step?.kind === 'owner'
431        ? Object.entries(step.outcomes).map(([outcome, o]) => ({ outcome, label: o.label }))
432        : []
433  return {
434    change: state.change,
435    flowId: state.flow.id,
436    flowTitle: state.flow.title,
437    status: state.status,
438    current: steps.find(s => s.state === 'current'),
439    waitingOn: step === undefined ? undefined : state.status === 'escalated' ? 'owner' : ANSWERED_BY[step.kind],
440    ownerChoices,
441    escalation: state.escalation,
442    steps,
443  }
444}
445
446/** Where the flow stands, in the model's words: the step, who answers it and how. */
447export const describeFlow = (state: FlowState): string => {
448  const head = `Flow ${state.flow.id} for ${state.change}`
449  if (state.status === 'done') return `${head}: finished.`
450  if (state.status === 'aborted') return `${head}: ended by the owner.`
451  const step = stepOf(state.flow, state.current) as FlowStep
452  const where = `${head}: step "${step.id}" (${step.title}), attempt ${state.attempts[step.id] ?? 0} of ${step.maxAttempts}`
453  if (state.status === 'escalated') {
454    return `${where}; it hit its attempt ceiling and waits on the owner in the panel (retry or end the flow).`
455  }
456  const outcomes = Object.entries(step.outcomes).map(([o, d]) => `${o} → ${d.next}`).join(', ')
457  const back = state.reopened
458  const reopened =
459    back === undefined ? '' : ` Came back from "${back.step}" (${back.label})${back.note === undefined ? '' : `: ${back.note}`}.`
460  return describeStep(state, step, where, outcomes) + reopened
461}
462
463const describeStep = (state: FlowState, step: FlowStep, where: string, outcomes: string): string => {
464  switch (step.kind) {
465    case 'session':
466      return `${where}. Do the step, then report one of its outcomes with action "report": ${outcomes}.`
467    case 'loop': {
468      const closed = Object.keys(state.closed ?? {})
469      return (
470        `${where}. Run the phases of tasks.md; close each with action "close" and its \`phase\` id once its gates ` +
471        'passed, its tasks are ticked and its work is committed (the mod then archives its session). The step ends ' +
472        `on its own when every phase is closed (→ ${step.outcomes[LOOP_DONE]?.next}).` +
473        (closed.length > 0 ? ` Closed: ${closed.join(', ')}.` : '')
474      )
475    }
476    case 'automatic':
477      return (
478        `${where}. Run \`${step.command}\` in this session; the mod reads its exit code ` +
479        `(exit 0 → ${step.outcomes.passou?.next}, otherwise → ${step.outcomes.falhou?.next})` +
480        (step.requiresEvidence ? ', and a pass also needs evidence.md recorded in the change folder' : '') +
481        '. It cannot be reported.'
482      )
483    case 'owner':
484      return (
485        `${where}. Waits on the owner's press in the panel (${Object.values(step.outcomes).map(o => o.label).join(' / ')}); ` +
486        'no session can answer it, but action "note" leaves a note that joins the answer' +
487        (step.detect === undefined
488          ? '.'
489          : `. The mod also answers ${step.detect.outcome} when it sees ${step.detect.commands.map(c => `\`${c}\``).join(' or ')} succeed.`)
490      )
491  }
492}
493
hooks/lifecycle.ts 145 lines
1import type { FlowEvent, FlowState, GateView, OrchestrationRun, PhaseClosure, SessionRow, TaskItem } from '../types'
2import { LOOP_DONE, applyOutcome, currentStep } from './flow'
3import type { Moved } from './flow'
4import { phaseRefusal } from './gates'
5import { parseTaskItems, tasksForPhase } from './openspec'
6
7/**
8 * One phase the executar loop runs: a phase of the published plan (its tasks, gates and session),
9 * or, with no plan, a numbered section of tasks.md ("## 4. …" holds 4.1, 4.2…).
10 */
11export type LoopUnit = {
12  id: string
13  title: string
14  tasks: TaskItem[]
15  gates: string[] | undefined
16  session: string | undefined
17}
18
19/** The `## N. Title` headings of tasks.md, by section number. */
20const sectionTitles = (tasks: string): Map<string, string> =>
21  new Map(
22    tasks.split('\n').flatMap(line => {
23      const m = line.match(/^##\s+(\d+)\.?\s+(.+?)\s*$/)
24      return m === null ? [] : [[m[1] ?? '', (m[2] ?? '').trim()] as [string, string]]
25    }),
26  )
27
28/** The phases of the loop: the plan's when it names any, else the numbered sections of tasks.md. */
29export const loopUnits = (run: OrchestrationRun | undefined, tasks: string | undefined): LoopUnit[] => {
30  const items = tasks === undefined ? [] : parseTaskItems(tasks)
31  const phases = run?.waves.flatMap(w => w.phases) ?? []
32  if (phases.length > 0) {
33    return phases.map(p => ({ id: p.id, title: p.title, tasks: tasksForPhase(p.tasks, items), gates: p.gates, session: p.session }))
34  }
35  const titles = tasks === undefined ? new Map<string, string>() : sectionTitles(tasks)
36  const sections = [...new Set(items.map(i => i.id.split('.')[0] ?? ''))]
37  return sections.map(n => ({
38    id: n,
39    title: titles.get(n) ?? `Seção ${n}`,
40    tasks: items.filter(i => i.id.startsWith(`${n}.`)),
41    gates: undefined,
42    session: undefined,
43  }))
44}
45
46/** Why a phase cannot close yet: tasks left unticked in tasks.md, then its open gates. */
47export const unitRefusal = (unit: LoopUnit, openGates: readonly GateView[], where: string): string | undefined => {
48  const open = unit.tasks.filter(t => !t.done).map(t => t.id)
49  const tasks =
50    open.length === 0
51      ? undefined
52      : `phase ${unit.id} (${unit.title}) cannot close: tasks ${open.join(', ')} are not ticked in tasks.md.`
53  const gates = phaseRefusal(unit, openGates, where)
54  return tasks === undefined && gates === undefined ? undefined : [tasks, gates].filter(Boolean).join(' ')
55}
56
57/** A closed phase stays closed while every one of its tasks is ticked; a new unticked task reopens it. */
58export const isClosed = (state: FlowState, unit: LoopUnit): boolean =>
59  state.closed?.[unit.id] !== undefined && unit.tasks.every(t => t.done)
60
61/** Whether the flow sits on a running loop step, the only time a phase may close. */
62export const loopRefusal = (state: FlowState): string | undefined => {
63  const step = currentStep(state)
64  if (state.status !== 'running' || step === undefined) return `the flow of ${state.change} is ${state.status}; no phase closes now.`
65  if (step.kind !== 'loop') return `the flow of ${state.change} is on "${step.id}" (${step.title}), not on the phase loop.`
66  return undefined
67}
68
69/** Records one closed phase and, when the mod archived it, its session; both go to the flow log. */
70export const closePhase = (
71  state: FlowState,
72  unit: LoopUnit,
73  done: { session: string | undefined; archived: boolean; note?: string },
74  at: number,
75): Moved => {
76  const step = state.current
77  const closure: PhaseClosure = {
78    at,
79    tasks: unit.tasks.map(t => t.id),
80    ...(done.session === undefined ? {} : { session: done.session }),
81    archived: done.archived,
82  }
83  const events: FlowEvent[] = [
84    { seq: state.seq + 1, at, kind: 'phase-closed', step, by: 'mod', note: `${unit.id} ${unit.title}`.trim() },
85  ]
86  if (done.session !== undefined) {
87    events.push({
88      seq: state.seq + 2,
89      at,
90      kind: 'session-archived',
91      step,
92      by: 'mod',
93      note: done.archived ? `${unit.id}: ${done.session}` : `${unit.id}: ${done.session} not archived — ${done.note ?? 'unknown'}`,
94    })
95  }
96  return {
97    state: { ...state, closed: { ...state.closed, [unit.id]: closure }, seq: state.seq + events.length, updatedAt: at },
98    events,
99  }
100}
101
102/** The loop ends, by the mod, once every phase is closed; until then the phases still open. */
103export const finishLoop = (state: FlowState, units: readonly LoopUnit[], at: number): Moved | { open: LoopUnit[] } | { error: string } => {
104  const refusal = loopRefusal(state)
105  if (refusal !== undefined) return { error: refusal }
106  if (units.length === 0) return { error: 'the change has no plan phases and no numbered tasks in tasks.md, so no phase can close.' }
107  const open = units.filter(u => !isClosed(state, u))
108  if (open.length > 0) return { open }
109  return applyOutcome(state, { step: state.current, outcome: LOOP_DONE, by: 'mod', note: `${units.length} phase(s) closed` }, at)
110}
111
112/** `git status --porcelain` output: the paths with uncommitted changes, none when clean. */
113export const uncommitted = (porcelain: string): string[] =>
114  porcelain
115    .split('\n')
116    .map(line => line.slice(3).trim())
117    .filter(path => path !== '')
118
119/**
120 * The change's phase sessions still alive in its sidebar group: those started by another member
121 * of the group (the coordinator), never this session itself. A session in the coordinator's own
122 * folder is a side chat the owner opened, not a phase: phases run in worktrees of their own.
123 */
124export const phaseSessions = (rows: readonly SessionRow[], selfId: string | undefined, coordinatorCwd: string): SessionRow[] => {
125  const members = new Set(rows.map(r => r.sessionId))
126  return rows.filter(
127    r =>
128      !r.isArchived &&
129      r.sessionId !== selfId &&
130      r.cwd !== coordinatorCwd &&
131      r.startedBy !== undefined &&
132      members.has(r.startedBy),
133  )
134}
135
136/** Puts `extra` events before the flow's closing `done` event, renumbered in order. */
137export const withEvents = (moved: Moved, extra: readonly Omit<FlowEvent, 'seq'>[]): Moved => {
138  if (extra.length === 0) return moved
139  const done = moved.events.findIndex(e => e.kind === 'done')
140  const at = done < 0 ? moved.events.length : done
141  const first = moved.events[0]?.seq ?? moved.state.seq + 1
142  const events = [...moved.events.slice(0, at), ...extra, ...moved.events.slice(at)].map((e, i) => ({ ...e, seq: first + i }))
143  return { state: { ...moved.state, seq: first + events.length - 1 }, events }
144}
145
hooks/flows/index.ts 15 lines
1import type { FlowDef } from '../../types'
2import { parseFlow } from '../flow'
3import { completo } from './completo'
4import { rapido } from './rapido'
5
6/** The flows the mod ships, as raw JSON: each goes through parseFlow like a project's file. */
7export const DEFAULT_FLOWS: readonly { source: string; raw: unknown }[] = [
8  { source: 'default flow completo', raw: completo },
9  { source: 'default flow rapido', raw: rapido },
10]
11
12/** The default flows parsed; one that does not parse is a bug of the mod, caught by its tests. */
13export const defaultFlows = (): FlowDef[] =>
14  DEFAULT_FLOWS.map(({ source, raw }) => parseFlow(raw, source)).filter((f): f is FlowDef => !('error' in f))
15
hooks/grill.ts 274 lines
1import type { FlowEvent, FlowState, FlowView, GrillAnswer, GrillItem, PrepState, PrepView } from '../types'
2
3/**
4 * The owner steps before execution, by their ids in the flows: the grill (rounds of questions until
5 * the owner closes the understanding), the mockup (only for a visual change) and the owner's
6 * release, answered in the app's plan mode.
7 */
8export const GRILL_STEP = 'grill'
9export const MOCKUP_STEP = 'mockup'
10export const RELEASE_STEP = 'liberacao'
11export const MOCKUP_APPROVED = 'aprovado'
12export const MOCKUP_ADJUST = 'ajustes'
13export const RELEASE_OUTCOME = 'liberado'
14
15const MAX_TEXT = 2_000
16const MAX_PLAN = 8_000
17
18const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
19const text = (v: unknown, max = MAX_TEXT): string | undefined =>
20  typeof v === 'string' && v.trim() !== '' ? v.trim().slice(0, max) : undefined
21
22export const emptyPrep = (change: string): PrepState => ({ change, rounds: [], adjustments: [], planRejections: [] })
23
24/** A round's questions as the tool sends them: each with its question, and the answer and criterion once known. */
25export const parseRoundItems = (raw: unknown): GrillItem[] | { error: string } => {
26  if (!Array.isArray(raw) || raw.length === 0) {
27    return { error: '`questions` must list the round\'s questions: [{ question, answer?, criterion? }].' }
28  }
29  const items: GrillItem[] = []
30  for (const [i, q] of raw.entries()) {
31    const question = isRecord(q) ? text(q.question) : text(q)
32    if (question === undefined) return { error: `question ${i + 1} has no \`question\` text.` }
33    const answer = isRecord(q) ? text(q.answer) : undefined
34    const criterion = isRecord(q) ? text(q.criterion) : undefined
35    items.push({ question, ...(answer === undefined ? {} : { answer }), ...(criterion === undefined ? {} : { criterion }) })
36  }
37  return items
38}
39
40const onStep = (state: FlowState, step: string, what: string): string | undefined => {
41  if (state.status !== 'running' || state.current !== step) {
42    return `${what} only while the flow of ${state.change} is on "${step}"; it is on "${state.current}" (${state.status}).`
43  }
44  return undefined
45}
46
47type Prepped = { prep: PrepState; event: Pick<FlowEvent, 'kind' | 'step' | 'by' | 'note'> }
48
49/**
50 * One grill round: appended, or with `amend` merged into the latest round (answers filled by
51 * question text, new questions added). Only while the flow is on the grill; the round never closes
52 * it, since only the owner does, in the panel.
53 */
54export const recordRound = (
55  prep: PrepState,
56  state: FlowState,
57  items: readonly GrillItem[],
58  amend: boolean,
59  at: number,
60): Prepped | { error: string } => {
61  const wrong = onStep(state, GRILL_STEP, 'a grill round is recorded')
62  if (wrong !== undefined) return { error: wrong }
63  const last = prep.rounds[prep.rounds.length - 1]
64  let rounds = prep.rounds
65  if (amend && last !== undefined) {
66    const merged = [...last.items]
67    for (const item of items) {
68      const i = merged.findIndex(m => m.question === item.question)
69      if (i === -1) merged.push(item)
70      else merged[i] = { ...merged[i]!, ...item }
71    }
72    rounds = [...prep.rounds.slice(0, -1), { ...last, items: merged, at }]
73  } else {
74    rounds = [...prep.rounds, { n: prep.rounds.length + 1, at, items: [...items] }]
75  }
76  const round = rounds[rounds.length - 1]!
77  const open = round.items.filter(i => i.answer === undefined).length
78  return {
79    prep: { ...prep, rounds },
80    event: {
81      kind: 'round',
82      step: GRILL_STEP,
83      by: 'session',
84      note: `rodada ${round.n}: ${round.items.length} perguntas, ${open} em aberto`,
85    },
86  }
87}
88
89/**
90 * The owner's answers typed in the panel, merged into the latest round the way `round` with
91 * `amend: true` does; each must still be an open question of that round and none may be empty.
92 */
93export const ownerAnswers = (
94  prep: PrepState,
95  state: FlowState,
96  answers: readonly GrillAnswer[],
97  at: number,
98): Prepped | { error: string } => {
99  const filled = answers.flatMap(a => {
100    const answer = text(a.answer)
101    return answer === undefined ? [] : [{ ...a, answer }]
102  })
103  if (filled.length === 0) return { error: 'escreva a resposta antes de enviar.' }
104  const last = prep.rounds[prep.rounds.length - 1]
105  const stale = filled.find(a => last?.items.find(i => i.question === a.question && i.answer === undefined) === undefined)
106  if (stale !== undefined) return { error: `a pergunta ${stale.n} não está mais em aberto na rodada atual.` }
107  const done = recordRound(prep, state, filled.map(a => ({ question: a.question, answer: a.answer })), true, at)
108  return 'error' in done ? done : { prep: done.prep, event: { ...done.event, by: 'owner' } }
109}
110
111/** What the coordinator reads from the panel: one line per answer, as the owner's own message. */
112export const answersPrompt = (answers: readonly GrillAnswer[]): string =>
113  answers
114    .filter(a => a.answer.trim() !== '')
115    .map(a => `Resposta à pergunta ${a.n}: ${a.answer.trim()}`)
116    .join('\n')
117
118/** A URL or a file path: what the panel can show the owner as the mockup. */
119const isArtifact = (v: string): boolean =>
120  /^https?:\/\/\S+$/.test(v) || /^(\/|~\/|\.\.?\/)/.test(v) || /^[^\s:]*[/.][^\s:]*$/.test(v)
121
122/** The coordinator attaches the mockup (an Artifact URL or a file path) while the flow is on it. */
123export const attachMockup = (prep: PrepState, state: FlowState, artifact: unknown, at: number): Prepped | { error: string } => {
124  const wrong = onStep(state, MOCKUP_STEP, 'a mockup is attached')
125  if (wrong !== undefined) return { error: wrong }
126  const value = text(artifact)
127  if (value === undefined || !isArtifact(value)) {
128    return { error: '`artifact` must be the mockup\'s URL (e.g. an Artifact link) or a file path.' }
129  }
130  return {
131    prep: { ...prep, mockup: { artifact: value, at } },
132    event: { kind: 'attached', step: MOCKUP_STEP, by: 'session', note: value },
133  }
134}
135
136/**
137 * The owner's press on the mockup: approving needs an attached mockup; asking for an adjustment keeps
138 * the owner's note and the mockup it was about, and clears the mockup so a new one is attached.
139 */
140export const pressMockup = (prep: PrepState, outcome: string, note: string | undefined, at: number): PrepState | { error: string } => {
141  if (outcome === MOCKUP_APPROVED && prep.mockup === undefined) return { error: 'nenhum mockup foi anexado ainda.' }
142  if (outcome !== MOCKUP_ADJUST) return prep
143  const { mockup: _left, ...rest } = prep
144  const adjustment = {
145    note: text(note) ?? '',
146    at,
147    ...(prep.mockup === undefined ? {} : { artifact: prep.mockup.artifact }),
148  }
149  return { ...rest, adjustments: [...prep.adjustments, adjustment] }
150}
151
152type PlanRun = { result?: unknown; isError?: true; deny?: string; ref?: number; text?: string }
153
154export type PlanVerdict = { approved: true; plan: string | undefined; filePath: string | undefined } | { approved: false; feedback: string }
155
156/**
157 * What one ExitPlanMode call says of the owner's release. The tool runs only once the person
158 * approves the plan in the app's own dialog (a hook cannot dismiss it), so core's answered result
159 * is the approval and an errored call is the refusal at the dialog, its text holding the owner's
160 * words. Not counted: a call in a subagent's loop, a teammate's plan sent to its leader, a result
161 * core did not produce (no `ref`), and a plugin's deny.
162 */
163export const planVerdict = (input: { agentId?: unknown }, ran: PlanRun): PlanVerdict | undefined => {
164  if (input.agentId !== undefined || ran.deny !== undefined) return undefined
165  if (ran.isError === true) {
166    return { approved: false, feedback: text(ran.text, MAX_TEXT) ?? text(ran.result, MAX_TEXT) ?? 'plano não aprovado' }
167  }
168  if (ran.ref === undefined || !isRecord(ran.result)) return undefined
169  const result = ran.result
170  if (result.isAgent === true || result.awaitingLeaderApproval === true) return undefined
171  return { approved: true, plan: text(result.plan, MAX_PLAN), filePath: text(result.filePath) }
172}
173
174/** The owner's refusal of the execution plan, kept so the coordinator reads what to change. */
175export const rejectPlan = (prep: PrepState, feedback: string, at: number): Prepped => ({
176  prep: { ...prep, planRejections: [...prep.planRejections, { feedback, at }].slice(-10) },
177  event: { kind: 'plan-rejected', step: RELEASE_STEP, by: 'owner', note: feedback.slice(0, 300) },
178})
179
180export const approvePlan = (prep: PrepState, verdict: Extract<PlanVerdict, { approved: true }>, at: number): PrepState => ({
181  ...prep,
182  release: {
183    at,
184    ...(verdict.plan === undefined ? {} : { plan: verdict.plan }),
185    ...(verdict.filePath === undefined ? {} : { filePath: verdict.filePath }),
186  },
187})
188
189/** Every answered question becomes an acceptance criterion: its own criterion, else the answer. */
190export const criteria = (prep: PrepState): string[] =>
191  prep.rounds.flatMap(r => r.items.flatMap(i => (i.answer === undefined ? [] : [i.criterion ?? i.answer])))
192
193export const prepView = (prep: PrepState): PrepView => {
194  const last = prep.rounds[prep.rounds.length - 1]
195  const artifact = prep.mockup?.artifact
196  return {
197    change: prep.change,
198    rounds: prep.rounds.length,
199    openQuestions: last === undefined ? [] : last.items.filter(i => i.answer === undefined).map(i => i.question),
200    openItems:
201      last === undefined
202        ? []
203        : last.items.flatMap((i, k) => (i.answer === undefined ? [{ n: k + 1, question: i.question }] : [])),
204    criteria: criteria(prep),
205    ...(artifact === undefined ? {} : { mockup: artifact, mockupIsUrl: /^https?:\/\//.test(artifact) }),
206    ...(prep.adjustments.length === 0 ? {} : { lastAdjustment: prep.adjustments[prep.adjustments.length - 1]!.note }),
207    ...(prep.planRejections.length === 0 ? {} : { lastPlanFeedback: prep.planRejections[prep.planRejections.length - 1]!.feedback }),
208    released: prep.release !== undefined,
209  }
210}
211
212/**
213 * The owner buttons the panel leaves out: the release is pressed in the app's plan-mode dialog,
214 * never in the panel; the mockup is approved only once attached; and where the surface has a text
215 * field, the adjustment goes through it so the owner's note comes along.
216 */
217export const hiddenChoices = (flow: FlowView, prep: PrepView | undefined, hasInput: boolean): string[] => {
218  if (flow.status !== 'running') return []
219  switch (flow.current?.id) {
220    case RELEASE_STEP:
221      return [RELEASE_OUTCOME]
222    case MOCKUP_STEP:
223      return [...(prep?.mockup === undefined ? [MOCKUP_APPROVED] : []), ...(hasInput ? [MOCKUP_ADJUST] : [])]
224    default:
225      return []
226  }
227}
228
229/** grill.md: every round with its questions and answers, then the acceptance criteria they became. */
230export const grillMarkdown = (prep: PrepState): string => {
231  const lines = [`# Grill — ${prep.change}`, '']
232  for (const round of prep.rounds) {
233    lines.push(`## Rodada ${round.n}`, '')
234    for (const item of round.items) {
235      lines.push(`- **Pergunta:** ${item.question}`, `  **Resposta:** ${item.answer ?? '_(em aberto)_'}`)
236    }
237    lines.push('')
238  }
239  const found = criteria(prep)
240  lines.push('## Critérios de aceite', '')
241  lines.push(...(found.length === 0 ? ['_(nenhum ainda)_'] : found.map(c => `- [ ] ${c}`)), '')
242  return lines.join('\n')
243}
244
245/** What the coordinator must do on a pre-execution step, in the model's words; undefined elsewhere. */
246export const describePrep = (state: FlowState, prep: PrepState): string | undefined => {
247  if (state.status !== 'running') return undefined
248  const view = prepView(prep)
249  switch (state.current) {
250    case GRILL_STEP:
251      return (
252        `Grill: ${view.rounds} round(s) recorded, ${view.openQuestions.length} open question(s), ` +
253        `${view.criteria.length} acceptance criteria. Ask the owner one round of questions, then record it with ` +
254        'action "round" (questions with the owner\'s answers; amend: true fills the latest round). ' +
255        'Only the owner closes the understanding, in the panel.'
256      )
257    case MOCKUP_STEP:
258      return (
259        (view.mockup === undefined
260          ? 'Mockup: none attached; attach it with action "mockup" (artifact: an Artifact URL or a file path).'
261          : `Mockup attached: ${view.mockup}; the owner approves it or asks for an adjustment in the panel.`) +
262        (view.lastAdjustment === undefined ? '' : ` The owner's last adjustment: "${view.lastAdjustment}".`)
263      )
264    case RELEASE_STEP:
265      return (
266        'Release: write the execution plan and call ExitPlanMode with it; the owner\'s approval in the app\'s ' +
267        'plan dialog releases execution (no tool call can).' +
268        (view.lastPlanFeedback === undefined ? '' : ` The owner refused the last plan: "${view.lastPlanFeedback}".`)
269      )
270    default:
271      return undefined
272  }
273}
274
hooks/glance.ts 64 lines
1import type { FlowEvent, PanelView, StepTime } from '../types'
2import { attentionItems } from './model'
3
4/**
5 * When each step was last entered and, once it has an outcome, when it finished: from the flow's
6 * append-only log. Entering a step again starts its clock over.
7 */
8export const stepTimes = (events: readonly FlowEvent[]): Record<string, StepTime> => {
9  const times: Record<string, StepTime> = {}
10  for (const event of events) {
11    if (event.kind === 'entered') times[event.step] = { from: event.at }
12    else if (event.kind === 'outcome' && times[event.step] !== undefined) {
13      times[event.step] = { from: times[event.step]!.from, to: event.at }
14    }
15  }
16  return times
17}
18
19/** The flow's log, one JSON event per line; a torn or foreign line is skipped. */
20export const parseEvents = (text: string | undefined): FlowEvent[] =>
21  (text ?? '').split('\n').flatMap((line): FlowEvent[] => {
22    if (line.trim() === '') return []
23    try {
24      const event = JSON.parse(line) as FlowEvent
25      return typeof event.step === 'string' && typeof event.at === 'number' ? [event] : []
26    } catch {
27      return []
28    }
29  })
30
31/** How long, the way the app's background-tasks list says it: "40 s", "12 min", "2 h 5 min". */
32export const formatElapsed = (ms: number): string => {
33  const seconds = Math.max(0, Math.round(ms / 1000))
34  if (seconds < 60) return `${seconds} s`
35  const minutes = Math.floor(seconds / 60)
36  if (minutes < 60) return `${minutes} min`
37  const hours = Math.floor(minutes / 60)
38  return minutes % 60 === 0 ? `${hours} h` : `${hours} h ${minutes % 60} min`
39}
40
41export type BandSummary = {
42  /** The flow's current step in the owner's words, or where it ended. */
43  step: string | undefined
44  inFlight: number
45  waiting: number
46}
47
48/** What the thin band above the prompt says; undefined when this session orchestrates nothing. */
49export const bandSummary = (view: PanelView, answered: readonly string[]): BandSummary | undefined => {
50  if (view.flow === undefined && view.run === undefined) return undefined
51  const flow = view.flow
52  const step =
53    flow === undefined
54      ? undefined
55      : flow.status === 'done'
56        ? 'flow concluído'
57        : flow.status === 'aborted'
58          ? 'flow encerrado'
59          : flow.current?.title
60  const inFlight = (view.run?.waves ?? []).flatMap(w => w.phases).filter(p => p.status === 'running' || p.status === 'blocked').length
61  const waiting = attentionItems(view).filter(a => a.kind === 'flow' || !answered.includes(a.title)).length
62  return { step, inFlight, waiting }
63}
64
hooks/pane.tsx 1209 lines
1import type {
2  BoxProps,
3  ButtonProps,
4  ElementConstructor,
5  InputProps,
6  LinkProps,
7  RenderElement,
8  SvgProps,
9  TextProps,
10} from 'claude-code'
11
12import type {
13  Attention,
14  EvidenceState,
15  FlowStepKind,
16  FlowStepView,
17  GateCheck,
18  GateState,
19  GateView,
20  GrillAnswer,
21  JiraCategory,
22  OpenSpecView,
23  PanelView,
24  PhaseStatus,
25  PhaseView,
26  PlanItem,
27  SessionCard,
28} from '../types'
29import { FINAL_SCOPE, MAX_LIVE_SESSIONS, familyCheck, gateViews, isOpenGate, liveSessions } from './gates'
30import { divergence } from './jira'
31import { tasksForPhase } from './openspec'
32import { formatElapsed } from './glance'
33import type { BandSummary } from './glance'
34import { attentionItems, nowAndNext, phaseCounts, phaseViews, planProgress } from './model'
35import { hiddenFlowChoices, prepDetails } from './prep-pane'
36
37export type PaneElements = {
38  Box: ElementConstructor<BoxProps>
39  Text: ElementConstructor<TextProps>
40  Button: ElementConstructor<ButtonProps>
41  Link: ElementConstructor<LinkProps>
42  /** Only the desktop draws Svg; elsewhere the bar is drawn with text. */
43  Svg: ElementConstructor<SvgProps> | undefined
44  /** A text field, where the surface has one (not on mobile). */
45  Input?: ElementConstructor<InputProps> | undefined
46}
47
48/** What the hooks module hands in: state it owns and the closures that reach `$`. */
49export type PaneActions = {
50  collapsed: Readonly<Record<string, boolean>>
51  answered: readonly string[]
52  canOpenBeside: (card: SessionCard) => boolean
53  toggle: (id: string, byDefault: boolean) => () => void
54  openBeside: (card: SessionCard) => () => void
55  accept: (item: Attention) => () => void
56  /** The owner's skip of one gate; the pane arms it with a first press and fires on the second. */
57  skip: (gate: GateView) => () => void
58  /** The owner's answer to a flow step: the only way an owner step moves. */
59  answerFlow: (change: string, step: string, outcome: string) => () => void
60  /** The same answer with the owner's note typed in the panel (a mockup adjustment). */
61  noteFlow: (change: string, step: string, outcome: string) => (value: string) => void
62  /** What the owner has typed in the grill's answer fields, by question text. */
63  drafts: Readonly<Record<string, string>>
64  /** Keeps one answer field's text as the owner types it. */
65  draftAnswer: (question: string) => (value: string) => void
66  /** The owner's answers to open grill questions: recorded in the round and sent as their own message. */
67  answerGrill: (change: string, answers: GrillAnswer[]) => () => void
68  /** The owner's "Mover no Jira": the only way an issue changes state. */
69  moveJira: (change: string, key: string, transitionId: string) => () => void
70}
71
72/** Hex, not theme keys: the Svg bar and the legend squares must match exactly. */
73const STATUS_HEX: Record<PhaseStatus, string> = {
74  done: '#4CAF7D',
75  running: '#D97757',
76  blocked: '#D14343',
77  pending: '#B4B2A9',
78}
79const STATUS_LEGEND: Record<PhaseStatus, string> = {
80  done: 'fechadas',
81  running: 'em voo',
82  blocked: 'bloqueadas',
83  pending: 'pendentes',
84}
85const STATUS_WORD: Record<PhaseStatus, string> = {
86  done: 'fechada',
87  running: 'em voo',
88  blocked: 'bloqueada',
89  pending: 'pendente',
90}
91/** Pills: a soft tint behind a darker word of the same hue. */
92const PILL: Record<PhaseStatus, { bg: string; fg: string }> = {
93  running: { bg: '#FAECE7', fg: '#993C1D' },
94  done: { bg: '#EAF3DE', fg: '#27500A' },
95  pending: { bg: '#F1EFE8', fg: '#5F5E5A' },
96  blocked: { bg: '#FCEBEB', fg: '#A32D2D' },
97}
98/** Actions read as links. */
99const LINK = '#2F6FD6'
100const STATUS_ORDER: readonly PhaseStatus[] = ['done', 'running', 'blocked', 'pending']
101const ITEM_MARK: Record<PlanItem['status'], string> = { completed: '✓', in_progress: '▶', pending: '○' }
102const ITEM_STATUS: Record<PlanItem['status'], PhaseStatus> = {
103  completed: 'done',
104  in_progress: 'running',
105  pending: 'pending',
106}
107const ATTENTION_KIND: Record<Attention['kind'], string> = {
108  decision: 'Decisão',
109  permission: 'Permissão',
110  question: 'Pergunta',
111  pr: 'PR para revisar',
112  flow: 'Passo do flow',
113  jira: 'Jira',
114}
115
116const JIRA_HEX: Record<JiraCategory, string> = {
117  new: STATUS_HEX.pending,
118  indeterminate: STATUS_HEX.running,
119  done: STATUS_HEX.done,
120  unknown: STATUS_HEX.pending,
121}
122const EVIDENCE_LABEL: Record<EvidenceState, string> = {
123  missing: 'não existe',
124  stub: 'esqueleto',
125  recorded: 'registrada',
126  green: 'check.sh verde',
127  failing: 'check.sh falhou',
128}
129const EVIDENCE_HEX: Record<EvidenceState, string> = {
130  missing: STATUS_HEX.blocked,
131  stub: STATUS_HEX.pending,
132  recorded: STATUS_HEX.running,
133  green: STATUS_HEX.done,
134  failing: STATUS_HEX.blocked,
135}
136
137const GATE_MARK: Record<GateState, string> = { passed: '✓', failed: '✗', pending: '○', stale: '⚠', skipped: '⏭' }
138const GATE_HEX: Record<GateState, string> = {
139  passed: STATUS_HEX.done,
140  failed: STATUS_HEX.blocked,
141  pending: STATUS_HEX.pending,
142  stale: '#E0A030',
143  skipped: STATUS_HEX.pending,
144}
145const GATE_WORD: Record<GateState, string> = {
146  passed: 'passou',
147  failed: 'falhou',
148  pending: 'não rodou',
149  stale: 'desatualizado',
150  skipped: 'pulado por você',
151}
152const NO_CHECK: GateCheck = { records: {}, editedAt: undefined }
153
154/** Who moves a step: a script, a session, or the owner's press in the panel. */
155const KIND_ICON: Record<FlowStepKind, string> = { automatic: '⚙', session: '🤖', loop: '🤖', owner: '✋' }
156const STEP_MARK: Record<FlowStepView['state'], string> = { done: '✓', current: '▶', pending: '○', skipped: '⏭' }
157
158const formatTime = (ms: number): string =>
159  new Date(ms).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })
160
161const percent = (part: number, total: number): number =>
162  total === 0 ? 0 : Math.round((part / total) * 100)
163
164const waveStatus = (phases: readonly PhaseView[]): PhaseStatus =>
165  phases.some(p => p.status === 'blocked')
166    ? 'blocked'
167    : phases.some(p => p.status === 'running')
168      ? 'running'
169      : phases.every(p => p.status === 'done')
170        ? 'done'
171        : 'pending'
172
173const barSvg = (segments: readonly { value: number; hex: string }[]): string => {
174  const total = segments.reduce((sum, s) => sum + s.value, 0)
175  let x = 0
176  const rects = segments
177    .filter(s => s.value > 0)
178    .map(s => {
179      const w = (s.value / Math.max(total, 1)) * 100
180      const rect = `<rect x="${x.toFixed(2)}" y="0" width="${Math.max(w - 0.6, 0.4).toFixed(2)}" height="4" rx="2" fill="${s.hex}"/>`
181      x += w
182      return rect
183    })
184  return (
185    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 4" preserveAspectRatio="none" width="100%" height="4">` +
186    `<rect x="0" y="0" width="100" height="4" rx="2" fill="#8884"/>${rects.join('')}</svg>`
187  )
188}
189
190
191/** Pure drawing: the hooks module owns `$` and hands in the elements, the data and the actions. */
192export const renderPane = (
193  elements: PaneElements,
194  view: PanelView,
195  ownPlan: readonly PlanItem[],
196  actions: PaneActions,
197): RenderElement => {
198  const { Box, Text, Button, Link, Svg } = elements
199  const isOpen = (id: string, byDefault: boolean): boolean => !(actions.collapsed[id] ?? !byDefault)
200
201  const bar = (segments: readonly { value: number; hex: string }[], alt: string) => {
202    const total = Math.max(1, segments.reduce((a, b) => a + b.value, 0))
203    return Svg !== undefined ? (
204      <Svg source={barSvg(segments)} alt={alt} height={4} />
205    ) : (
206      <Text>
207        {segments.map(s => (
208          <Text color={s.hex}>{'━'.repeat(Math.round((s.value / total) * 24))}</Text>
209        ))}
210      </Text>
211    )
212  }
213
214  /**
215   * One line: the left side grows and shrinks (minWidth 0, or a flex child never shrinks below
216   * its text and spills past the card's border); the right side keeps its width.
217   */
218  const row = (left: RenderElement | string, right?: RenderElement | string, rightColor?: string, dim?: boolean) => (
219    <Box flexDirection="row" justifyContent="space-between" columnGap={1} minWidth={0}>
220      <Box flexDirection="row" flexShrink={1} flexGrow={1} minWidth={0}>
221        {typeof left === 'string' ? (
222          <Text wrap="truncate-end" dimColor={dim}>
223            {left}
224          </Text>
225        ) : (
226          left
227        )}
228      </Box>
229      {right !== undefined && (
230        <Box flexDirection="row" flexShrink={0}>
231          {typeof right === 'string' ? (
232            <Text color={rightColor} dimColor={rightColor === undefined} wrap="truncate-end">
233              {right}
234            </Text>
235          ) : (
236            right
237          )}
238        </Box>
239      )}
240    </Box>
241  )
242
243  /** Status as a pill: a soft tint behind a darker word of the same hue. */
244  const pill = (status: PhaseStatus) => (
245    <Text backgroundColor={PILL[status].bg} color={PILL[status].fg}>
246      {` ${STATUS_WORD[status]} `}
247    </Text>
248  )
249
250  /** The second level of text: what a thing is, the step it is on. */
251  const subtitle = (text: string, prefix = '') => (
252    <Text color="inactive" wrap="wrap">
253      {prefix}
254      {text}
255    </Text>
256  )
257
258  const square = (hex: string) => <Text color={hex}>■ </Text>
259
260  const summaryBox = (summary: string) => (
261    <Box borderStyle="round" borderDimColor paddingX={1} minWidth={0}>
262      <Text wrap="wrap" dimColor>
263        {summary}
264      </Text>
265    </Box>
266  )
267
268  /** ⓘ opens the summary below its row: the desktop draws no tooltip for a mod's pane, a press works everywhere. */
269  const infoButton = (id: string, summary: string | undefined): RenderElement[] =>
270    summary === undefined
271      ? []
272      : [
273          <Button key={`info:${id}`} plain dimColor={!isOpen(id, false)} onPress={actions.toggle(id, false)}>
274            ⓘ
275          </Button>,
276        ]
277
278  const summaryFor = (id: string, summary: string | undefined): RenderElement[] =>
279    summary !== undefined && isOpen(id, false) ? [summaryBox(summary)] : []
280
281  const card = (
282    id: string,
283    title: string,
284    children: RenderElement[],
285    aside?: string,
286    accent?: string,
287    byDefault = true,
288  ) => {
289    const open = isOpen(id, byDefault)
290    return (
291      <Box
292        flexDirection="column"
293        borderStyle="round"
294        borderColor={accent}
295        borderDimColor={accent === undefined}
296        paddingX={1}
297        minWidth={0}
298      >
299        <Box flexDirection="row" justifyContent="space-between" columnGap={1} minWidth={0}>
300          <Box flexShrink={1} minWidth={0}>
301            <Button key={`card:${id}`} plain onPress={actions.toggle(id, byDefault)}>
302              <Text bold color={accent} wrap="truncate-end">
303                {title}
304              </Text>
305              <Text dimColor>{open ? ' ⌄' : ' ›'}</Text>
306            </Button>
307          </Box>
308          {aside !== undefined && (
309            <Box flexShrink={0}>
310              <Text dimColor wrap="truncate-end">
311                {aside}
312              </Text>
313            </Box>
314          )}
315        </Box>
316        {open && (
317          <Box flexDirection="column" marginTop={1} minWidth={0}>
318            {children}
319          </Box>
320        )}
321      </Box>
322    )
323  }
324
325  const group = (
326    id: string,
327    label: string,
328    count: string,
329    byDefault: boolean,
330    body: () => RenderElement[],
331    summary?: string,
332  ) => {
333    const open = isOpen(id, byDefault)
334    return (
335      <Box flexDirection="column" minWidth={0}>
336        <Box flexDirection="row" justifyContent="space-between" columnGap={1} minWidth={0}>
337          <Box flexDirection="row" columnGap={1} alignItems="center" flexShrink={1} minWidth={0}>
338            <Button key={`group:${id}`} plain onPress={actions.toggle(id, byDefault)}>
339              <Text dimColor>{open ? '⌄ ' : '› '}</Text>
340              <Text wrap="truncate-end">{label}</Text>
341            </Button>
342            {infoButton(`summary:${id}`, summary)}
343          </Box>
344          {count !== '' && (
345            <Box flexShrink={0}>
346              <Text dimColor>{count}</Text>
347            </Box>
348          )}
349        </Box>
350        {summaryFor(`summary:${id}`, summary)}
351        {open && (
352          <Box flexDirection="column" paddingLeft={2} minWidth={0}>
353            {body()}
354          </Box>
355        )}
356      </Box>
357    )
358  }
359
360  /**
361   * "Abrir": a button in every case. The pane draws a `claude://` Link as dead text, so the
362   * hooks module opens it: beside this session when the app allows it, else through the app's link.
363   */
364  const openControl = (c: SessionCard, label = 'Abrir', scope = ''): RenderElement | undefined =>
365    actions.canOpenBeside(c) || c.link !== undefined ? (
366      <Button key={`open:${scope}:${label}:${c.sessionId}`} plain onPress={actions.openBeside(c)}>
367        <Text color={LINK}>{`${label} ↗`}</Text>
368      </Button>
369    ) : undefined
370
371  const planRows = (plan: readonly PlanItem[]) =>
372    plan.map(item =>
373      row(
374        <Text wrap="truncate-end" dimColor={item.status === 'completed'}>
375          <Text color={STATUS_HEX[ITEM_STATUS[item.status]]}>{ITEM_MARK[item.status]} </Text>
376          {item.content}
377        </Text>,
378      ),
379    )
380
381  const sessionState = (c: SessionCard): string =>
382    c.waiting !== undefined ? 'esperando você' : c.isRunning ? 'trabalhando' : 'ociosa'
383
384  const sessionRows = (c: SessionCard): RenderElement[] => {
385    const progress = planProgress(c.plan)
386    const meta = [
387      sessionState(c),
388      c.prNumber === undefined ? undefined : `PR #${c.prNumber}${c.prState ? ` ${c.prState.toLowerCase()}` : ''}`,
389      progress.total > 0 ? `${progress.done}/${progress.total}` : undefined,
390    ]
391      .filter(Boolean)
392      .join(' · ')
393    return [
394      row(meta, openControl(c), undefined, true),
395      ...(progress.current === undefined
396        ? []
397        : [
398            row(
399              <Text wrap="truncate-end" color={STATUS_HEX.running}>
400                ▶ {progress.current}
401              </Text>,
402            ),
403          ]),
404    ]
405  }
406
407  const issues = view.jira ?? {}
408  const skips = view.skips ?? {}
409
410  /** One line per gate: its mark and what proved it, then its state or the owner's two-press skip. */
411  const gateRows = (gates: readonly GateView[]): RenderElement[] =>
412    gates.flatMap(g => {
413      const armed = isOpen(`arm:${g.skipKey}`, false)
414      const line = row(
415        <Text wrap="truncate-end" dimColor={g.state === 'passed' || g.state === 'skipped'}>
416          <Text color={GATE_HEX[g.state]}>{GATE_MARK[g.state]} </Text>
417          {g.label}
418          {g.evidence !== undefined && <Text dimColor> · {g.evidence}</Text>}
419        </Text>,
420        <Box flexDirection="row" columnGap={1} flexShrink={0}>
421          <Text color={GATE_HEX[g.state]}>{GATE_WORD[g.state]}</Text>
422          {isOpenGate(g) && (
423            <Button key={`arm:${g.skipKey}`} plain dimColor onPress={actions.toggle(`arm:${g.skipKey}`, false)}>
424              {armed ? 'Cancelar' : 'Pular'}
425            </Button>
426          )}
427        </Box>,
428      )
429      // the confirmation gets a line of its own: beside the state word it fell off a narrow pane
430      if (!isOpenGate(g) || !armed) return [line]
431      return [
432        line,
433        <Box flexDirection="row" paddingLeft={2} minWidth={0}>
434          <Button key={`skip:${g.skipKey}`} variant="primary" onPress={actions.skip(g)}>
435            {`Confirmar pulo de ${g.label}`}
436          </Button>
437        </Box>,
438      ]
439    })
440
441  /** One line per Jira key: the key as a link, its status in its category's colour, its title; then any disagreement. */
442  /** What a pressed Jira key shows: the issue as far as it is known, or why it is not. */
443  const jiraDetails = (key: string): RenderElement[] => {
444    const issue = issues[key]
445    const id = `jira:${key}`
446    if (!isOpen(id, false)) return []
447    const lines = [
448      issue?.title ?? undefined,
449      issue?.status === undefined ? undefined : `Status: ${issue.status}`,
450      issue?.status === undefined
451        ? view.jiraNote ??
452          'Sem status: o painel ainda não leu esta issue do Jira.'
453        : undefined,
454      issue?.url === undefined
455        ? 'Sem link: falta o site do Jira. Reconecte o conector Atlassian (claude.ai › Configurações › Conectores) ou informe jiraSite no plano.'
456        : undefined,
457    ].filter((line): line is string => line !== undefined)
458    return [
459      <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1} minWidth={0}>
460        {lines.map(line => (
461          <Text wrap="wrap" dimColor>
462            {line}
463          </Text>
464        ))}
465        {issue?.url !== undefined && <Link href={issue.url}>{`Abrir ${key} no Jira ↗`}</Link>}
466      </Box>,
467    ]
468  }
469
470  const jiraKeyButton = (key: string): RenderElement => {
471    const issue = issues[key]
472    return (
473      <Button key={`jira:${key}`} plain onPress={actions.toggle(`jira:${key}`, false)}>
474        <Text color={JIRA_HEX[issue?.category ?? 'unknown']}>
475          {issue?.status === undefined ? key : `${key} · ${issue.status}`}
476        </Text>
477        <Text dimColor>{isOpen(`jira:${key}`, false) ? ' ⌄' : ' ›'}</Text>
478      </Button>
479    )
480  }
481
482  /** Under a title that carries the key: the pressed key's details, then any disagreement with Jira. */
483  const jiraBelow = (key: string | undefined, status: PhaseStatus): RenderElement[] => {
484    if (key === undefined) return []
485    const mismatch = divergence(status, issues[key])
486    return [
487      ...jiraDetails(key),
488      ...(mismatch === undefined
489        ? []
490        : [
491            <Text color="warning" wrap="wrap">
492              ⚠ {mismatch}
493            </Text>,
494          ]),
495    ]
496  }
497
498  const jiraRows = (key: string | undefined, status: PhaseStatus): RenderElement[] => {
499    if (key === undefined) return []
500    const issue = issues[key]
501    const mismatch = divergence(status, issue)
502    return [
503      row(
504        <Box flexDirection="row" columnGap={1} flexShrink={1} minWidth={0}>
505          <Text dimColor>Jira</Text>
506          {jiraKeyButton(key)}
507          {issue?.title !== undefined && (
508            <Text wrap="truncate-end" dimColor>
509              {issue.title}
510            </Text>
511          )}
512        </Box>,
513        issue?.status,
514        issue === undefined ? undefined : JIRA_HEX[issue.category],
515      ),
516      ...jiraDetails(key),
517      ...(mismatch === undefined
518        ? []
519        : [
520            <Text color="warning" wrap="wrap">
521              ⚠ {mismatch}
522            </Text>,
523          ]),
524    ]
525  }
526
527  const openSpecCard = (spec: OpenSpecView): RenderElement => {
528    const partial = spec.sections.filter(s => s.done > 0 && s.done < s.total).length
529    const full = spec.sections.filter(s => s.total > 0 && s.done === s.total).length
530    return card(
531      'openspec',
532      'OpenSpec',
533      [
534        ...(spec.proposalTitle === undefined && spec.proposalSummary === undefined
535          ? []
536          : [
537              row(
538                <Box flexDirection="row" columnGap={1} alignItems="center" flexShrink={1} minWidth={0}>
539                  {infoButton('openspec:proposal', spec.proposalSummary)}
540                  <Text wrap="truncate-end">
541                    <Text dimColor>Proposta </Text>
542                    {spec.proposalTitle ?? 'proposal.md'}
543                  </Text>
544                </Box>,
545              ),
546              ...summaryFor('openspec:proposal', spec.proposalSummary),
547            ]),
548        <Box marginTop={1} />,
549        ...(spec.total === 0
550          ? [<Text dimColor>tasks.md sem caixas de seleção</Text>]
551          : [
552              row(`${percent(spec.done, spec.total)}% das tarefas`, `${spec.done} / ${spec.total}`),
553              bar(
554                [
555                  { value: spec.done, hex: STATUS_HEX.done },
556                  { value: spec.total - spec.done, hex: STATUS_HEX.pending },
557                ],
558                `${spec.done} de ${spec.total} tarefas marcadas`,
559              ),
560              group(
561                'openspec:sections',
562                'Seções do tasks.md',
563                `${full}/${spec.sections.length}`,
564                true,
565                () =>
566                  spec.sections.map(section => {
567                    const status: PhaseStatus =
568                      section.done === section.total ? 'done' : section.done > 0 ? 'running' : 'pending'
569                    return row(
570                      <Text wrap="truncate-end" dimColor={status === 'done'}>
571                        {square(STATUS_HEX[status])}
572                        {section.title}
573                      </Text>,
574                      `${section.done}/${section.total}`,
575                    )
576                  }),
577                partial > 0 ? `${full} seções completas, ${partial} em andamento.` : undefined,
578              ),
579            ]),
580        <Box marginTop={1} />,
581        row(
582          <Box flexDirection="row" columnGap={1} alignItems="center" flexShrink={1} minWidth={0}>
583            <Text dimColor>Evidência</Text>
584            {infoButton('openspec:evidence', spec.evidenceSummary)}
585          </Box>,
586          EVIDENCE_LABEL[spec.evidence],
587          EVIDENCE_HEX[spec.evidence],
588        ),
589        ...summaryFor('openspec:evidence', spec.evidenceSummary),
590      ],
591      spec.total === 0 ? undefined : `${spec.done}/${spec.total}`,
592    )
593  }
594
595  const cards: RenderElement[] = []
596  const run = view.run
597
598  const attention = attentionItems(view).filter(a => a.kind === 'flow' || !actions.answered.includes(a.title))
599  const hiddenFlow = hiddenFlowChoices(elements, view)
600  const attentionCard =
601    attention.length === 0
602      ? undefined
603      : card(
604        'attention',
605        'Precisa de você',
606        attention.map(item => {
607          const canOpen = item.card !== undefined && item.card.sessionId !== view.self?.sessionId
608          return (
609            <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1} marginBottom={1} minWidth={0}>
610              <Text dimColor>{ATTENTION_KIND[item.kind]}</Text>
611              <Text bold wrap="wrap" color="warning">
612                {item.title}
613              </Text>
614              {item.recommendation !== undefined && (
615                <Text wrap="wrap">
616                  <Text dimColor>Recomendação: </Text>
617                  {item.recommendation}
618                </Text>
619              )}
620              {item.detail !== undefined && (
621                <Text dimColor wrap="truncate-end">
622                  {item.detail}
623                </Text>
624              )}
625              {item.kind === 'flow' && prepDetails(elements, view, actions)}
626              <Box flexDirection="row" columnGap={1} marginTop={1} flexWrap="wrap">
627                {item.kind === 'decision' && item.recommendation !== undefined && (
628                  <Button key={`accept:${item.id}`} variant="primary" onPress={actions.accept(item)}>
629                    Aceitar recomendação
630                  </Button>
631                )}
632                {item.flow !== undefined &&
633                  item.flow.choices.filter(c => !hiddenFlow.includes(c.outcome)).map((choice, i) => (
634                    <Button
635                      key={`flow:${item.flow!.step}:${choice.outcome}`}
636                      variant={i === 0 ? 'primary' : 'secondary'}
637                      onPress={actions.answerFlow(item.flow!.change, item.flow!.step, choice.outcome)}
638                    >
639                      {choice.label}
640                    </Button>
641                  ))}
642                {item.jira !== undefined && (
643                  <Button key={`jira-move:${item.jira.key}`} variant="primary" onPress={actions.moveJira(item.jira.change, item.jira.key, item.jira.transitionId)}>
644                    Mover no Jira
645                  </Button>
646                )}
647                {item.prUrl !== undefined && <Link href={item.prUrl}>Abrir PR</Link>}
648                {canOpen && item.card !== undefined && openControl(item.card, 'Abrir sessão')}
649              </Box>
650            </Box>
651          )
652        }),
653        `${attention.length}`,
654        'warning',
655      )
656
657  /** The coordinator's view of the change: how much is left, the live-session ceiling, final gates. */
658  const runCard = (): RenderElement[] => {
659    if (run === undefined) return []
660    const counts = phaseCounts(run)
661    const total = STATUS_ORDER.reduce((sum, s) => sum + counts[s], 0)
662    const live = liveSessions(view.children, view.self?.sessionId, view.self?.cwd).length
663    const legend = STATUS_ORDER.filter(s => counts[s] > 0)
664    const byStatus = (s: PhaseStatus) =>
665      phaseViews(run, view.children).flatMap((phases, w) => phases.filter(p => p.status === s).map(p => ({ phase: p, w })))
666    const changeIssue = run.jira === undefined ? undefined : issues[run.jira]
667    const changeMismatch = divergence(total > 0 && counts.done === total ? 'done' : 'running', changeIssue)
668    return [
669      card(
670        'run',
671        run.jira === undefined ? run.change : `${run.change} · ${run.jira}`,
672        [
673          bar(STATUS_ORDER.map(s => ({ value: counts[s], hex: STATUS_HEX[s] })), `${counts.done} de ${total} fases fechadas`),
674          <Box flexDirection="row" columnGap={2} flexWrap="wrap">
675            {legend.map(s => (
676              <Box flexDirection="row">
677                <Text color={STATUS_HEX[s]}>● </Text>
678                <Button key={`legend:${s}`} plain onPress={actions.toggle(`legend:${s}`, false)}>
679                  <Text>{`${counts[s]} ${STATUS_LEGEND[s]}`}</Text>
680                  <Text dimColor>{isOpen(`legend:${s}`, false) ? ' ⌄' : ' ›'}</Text>
681                </Button>
682              </Box>
683            ))}
684          </Box>,
685          ...legend.flatMap(s =>
686            isOpen(`legend:${s}`, false)
687              ? [
688                  <Box flexDirection="column" borderStyle="round" borderColor={STATUS_HEX[s]} paddingX={1} minWidth={0}>
689                    {byStatus(s).map(({ phase, w }) =>
690                      row(
691                        <Text wrap="truncate-end">
692                          {phase.icon ? `${phase.icon} ` : ''}
693                          {phase.title}
694                          <Text dimColor>{` · onda ${w + 1}`}</Text>
695                        </Text>,
696                        phase.card === undefined || phase.card.sessionId === view.self?.sessionId
697                          ? phase.card === undefined
698                            ? undefined
699                            : 'esta sessão'
700                          : openControl(phase.card, 'Abrir', `legend:${phase.id}`),
701                      ),
702                    )}
703                  </Box>,
704                ]
705              : [],
706          ),
707          ...(run.jira === undefined
708            ? []
709            : [
710                ...(changeIssue?.title !== undefined || changeIssue?.status !== undefined
711                  ? [
712                      row(
713                        <Text wrap="truncate-end" dimColor>
714                          {changeIssue?.title ?? ''}
715                        </Text>,
716                        changeIssue?.status,
717                        changeIssue === undefined ? undefined : JIRA_HEX[changeIssue.category],
718                      ),
719                    ]
720                  : []),
721                row(<Text dimColor>Issue da change</Text>, jiraKeyButton(run.jira)),
722                ...jiraDetails(run.jira),
723                ...(changeMismatch === undefined
724                  ? []
725                  : [
726                      <Text color="warning" wrap="wrap">
727                        ⚠ {changeMismatch}
728                      </Text>,
729                    ]),
730              ]),
731          row(
732            <Text wrap="truncate-end" dimColor>
733              Sessões trabalhando agora
734            </Text>,
735            `${live} de no máximo ${MAX_LIVE_SESSIONS}`,
736            live >= MAX_LIVE_SESSIONS ? STATUS_HEX.blocked : undefined,
737          ),
738          ...(live >= MAX_LIVE_SESSIONS
739            ? [
740                <Text dimColor wrap="wrap">
741                  No limite: uma nova fase só começa quando uma sessão for arquivada, para não pesar o computador.
742                </Text>,
743              ]
744            : []),
745          ...(view.jiraNote === undefined
746            ? []
747            : [
748                <Text dimColor wrap="wrap">
749                  {view.jiraNote}
750                </Text>,
751              ]),
752          <Text dimColor>atualizado {view.updatedAt === 0 ? '…' : formatTime(view.updatedAt)}</Text>,
753        ],
754        `${counts.done} de ${total} fases`,
755      ),
756    ]
757  }
758
759  const waves = run === undefined ? [] : phaseViews(run, view.children)
760  const { now } = run === undefined ? { now: undefined } : nowAndNext(run)
761  const waveName = (w: number) => `Onda ${w + 1}${run?.waves[w]?.title ? ` · ${run.waves[w]?.title}` : ''}`
762  const phaseKey = (w: number, phase: PhaseView) => `summary:${w}:${phase.id}`
763  /** What the session doing a phase is on now, said so the owner can read it: the step, then how far. */
764  const workRows = (c: SessionCard, scope = ''): RenderElement[] => {
765    const progress = planProgress(c.plan)
766    const tasksId = `tasks:${scope}:${c.sessionId}`
767    const who = c.sessionId === view.self?.sessionId ? 'esta sessão' : sessionState(c)
768    const pr = c.prNumber === undefined ? '' : ` · PR #${c.prNumber}`
769    const details = progress.total === 0 ? `${who}${pr}` : `passos ${progress.done} de ${progress.total} · ${who}${pr}`
770    return [
771      ...(progress.current === undefined ? [] : [subtitle(progress.current, '▶ ')]),
772      row(
773        progress.total === 0 ? (
774          <Text dimColor wrap="truncate-end">
775            {details}
776          </Text>
777        ) : (
778          <Button key={tasksId} plain onPress={actions.toggle(tasksId, false)}>
779            <Text dimColor wrap="truncate-end">
780              {details}
781            </Text>
782            <Text dimColor>{isOpen(tasksId, false) ? ' ⌄' : ' ›'}</Text>
783          </Button>
784        ),
785        c.sessionId === view.self?.sessionId ? undefined : openControl(c, 'Abrir', scope),
786      ),
787      ...(progress.total > 0 && isOpen(tasksId, false) ? planRows(c.plan) : []),
788    ]
789  }
790
791  /** The tasks.md items a phase delivers, ticked as they are done. */
792  const phaseTasks = (phase: PhaseView): RenderElement[] => {
793    const tasks = tasksForPhase(phase.tasks, view.openspec?.items ?? [])
794    if (tasks.length === 0) return []
795    const done = tasks.filter(t => t.done).length
796    return [
797      row(<Text dimColor>tasks.md</Text>, `${done} de ${tasks.length}`),
798      ...tasks.map(task =>
799        row(
800          <Text wrap="truncate-end" dimColor={task.done}>
801            <Text color={task.done ? STATUS_HEX.done : STATUS_HEX.pending}>{task.done ? '✓ ' : '○ '}</Text>
802            <Text dimColor>{task.id} </Text>
803            {task.text}
804          </Text>,
805        ),
806      ),
807    ]
808  }
809
810  /** How far a phase in progress is: its tasks.md items, else its session's own steps. */
811  const phaseBar = (phase: PhaseView, worker: SessionCard | undefined): RenderElement[] => {
812    const tasks = tasksForPhase(phase.tasks, view.openspec?.items ?? [])
813    const progress = worker === undefined ? { done: 0, total: 0 } : planProgress(worker.plan)
814    const [done, total] = tasks.length > 0 ? [tasks.filter(t => t.done).length, tasks.length] : [progress.done, progress.total]
815    if (total === 0) return []
816    return [
817      bar(
818        [
819          { value: done, hex: STATUS_HEX[phase.status] },
820          { value: total - done, hex: STATUS_HEX.pending },
821        ],
822        `${done} de ${total}`,
823      ),
824    ]
825  }
826
827  /** A title line: the icon and name in bold, its Jira key beside it. */
828  const phaseTitle = (phase: PhaseView) => (
829    <Box flexDirection="row" columnGap={1} flexShrink={1} minWidth={0}>
830      <Box flexShrink={1} minWidth={0}>
831        <Text bold wrap="truncate-end">
832          {phase.icon ? `${phase.icon} ` : ''}
833          {phase.title}
834        </Text>
835      </Box>
836      {phase.jira !== undefined && <Box flexShrink={0}>{jiraKeyButton(phase.jira)}</Box>}
837    </Box>
838  )
839
840  /** What a phase is and what happens in it: subtitle, notes, its tasks and its session. */
841  const phaseBody = (phase: PhaseView, worker: SessionCard | undefined, scope: string): RenderElement[] => [
842    ...(phase.summary === undefined ? [] : [subtitle(phase.summary)]),
843    ...(phase.note === undefined
844      ? []
845      : [
846          <Text wrap="wrap" dimColor>
847            {phase.note}
848          </Text>,
849        ]),
850    ...phaseTasks(phase),
851    ...(worker === undefined ? [] : workRows(worker, scope)),
852  ]
853
854  /** An inner card: a soft neutral border, never the status colour; the status lives in its pill. */
855  const innerCard = (children: RenderElement[]) => (
856    <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1} minWidth={0}>
857      {children}
858    </Box>
859  )
860
861  /** A phase in progress in full: title and pill, what it is, how far, its tasks and its session. */
862  const phaseCard = (phase: PhaseView): RenderElement => {
863    // a phase no session is named for is the coordinator's own work: show what this session is doing
864    const isActive = phase.status === 'running' || phase.status === 'blocked'
865    const worker = phase.card ?? (phase.session === undefined && isActive ? view.self : undefined)
866    return innerCard([
867      row(phaseTitle(phase), pill(phase.status)),
868      ...jiraBelow(phase.jira, phase.status),
869      ...(isActive ? phaseBar(phase, worker) : []),
870      ...phaseBody(phase, worker, `card:${phase.id}`),
871    ])
872  }
873
874  /** A later or closed phase as one clean row: icon and title left, a muted detail right; a press opens it. */
875  const phaseLine = (phase: PhaseView, w: number): RenderElement[] => {
876    const id = phaseKey(w, phase)
877    const open = isOpen(id, false)
878    const waits = phase.status === 'pending' && phase.dependsOn?.length ? `após ${phase.dependsOn.join(', ')}` : undefined
879    return [
880      row(
881        <Button key={id} plain onPress={actions.toggle(id, false)}>
882          <Text dimColor={phase.status === 'done'} wrap="truncate-end">
883            {phase.icon ? `${phase.icon} ` : ''}
884            {phase.title}
885          </Text>
886          <Text dimColor>{open ? ' ⌄' : ' ›'}</Text>
887        </Button>,
888        <Box flexDirection="row" columnGap={1}>
889          {phase.jira !== undefined && jiraKeyButton(phase.jira)}
890          <Text dimColor>{waits ?? STATUS_WORD[phase.status]}</Text>
891        </Box>,
892      ),
893      ...jiraBelow(phase.jira, phase.status),
894      ...(open ? [innerCard(phaseBody(phase, phase.card, `line:${w}:${phase.id}`))] : []),
895    ]
896  }
897
898  /** The current wave's words and issue, then every phase: in progress open, pending one line each, closed folded. */
899  const tasksCard = (): RenderElement[] => {
900    if (run === undefined) return []
901    const all = waves.flatMap((phases, w) => phases.map(phase => ({ phase, w })))
902    if (all.length === 0) return []
903    const active = all.filter(({ phase }) => phase.status === 'running' || phase.status === 'blocked')
904    const pending = all.filter(({ phase }) => phase.status === 'pending')
905    const closed = all.filter(({ phase }) => phase.status === 'done')
906    const wavePhases = now === undefined ? [] : (waves[now] ?? [])
907    return [
908      card(
909        'tasks',
910        'Tarefas',
911        [
912          ...(now === undefined
913            ? []
914            : [
915                <Text dimColor wrap="truncate-end">
916                  Agora: {waveName(now)}
917                </Text>,
918                ...(run.waves[now]?.summary === undefined ? [] : [subtitle(run.waves[now]?.summary ?? '')]),
919                ...jiraRows(run.waves[now]?.jira, waveStatus(wavePhases)),
920              ]),
921          ...(active.length === 0
922            ? []
923            : [
924                <Box flexDirection="column" rowGap={1} marginTop={1} minWidth={0}>
925                  {active.map(({ phase }) => phaseCard(phase))}
926                </Box>,
927              ]),
928          ...(pending.length === 0
929            ? []
930            : [
931                <Box flexDirection="column" marginTop={1} minWidth={0}>
932                  {pending.flatMap(({ phase, w }) => phaseLine(phase, w))}
933                </Box>,
934              ]),
935          ...(closed.length === 0
936            ? []
937            : [
938                <Box flexDirection="column" marginTop={1} minWidth={0}>
939                  {group('tasks:done', `✓ ${closed.length} concluídas`, '', false, () =>
940                    closed.flatMap(({ phase, w }) => phaseLine(phase, w)),
941                  )}
942                </Box>,
943              ]),
944        ],
945        `${closed.length} de ${all.length} fases`,
946        // only the outer box of the work in progress carries the accent
947        active.length > 0 ? STATUS_HEX.running : undefined,
948      ),
949    ]
950  }
951
952  const phaseGateViews = (phase: PhaseView): GateView[] =>
953    gateViews(
954      phase.gates,
955      phase.id,
956      phase.session === undefined ? (view.self?.gates ?? NO_CHECK) : (phase.card?.gates ?? NO_CHECK),
957      skips,
958    )
959
960  /** One phase of the loop: title and pill, its gates in one muted line ("testes ✓ lint ○"), then the open ones with the owner's skip. */
961  const loopPhase = (phase: PhaseView): RenderElement[] => {
962    const gates = phaseGateViews(phase)
963    const isActive = phase.status === 'running' || phase.status === 'blocked'
964    return [
965      row(
966        <Text wrap="truncate-end" dimColor={phase.status === 'done'}>
967          {phase.icon ? `${phase.icon} ` : ''}
968          {phase.title}
969        </Text>,
970        pill(phase.status),
971      ),
972      ...(gates.length === 0
973        ? []
974        : [
975            <Text wrap="wrap">
976              {gates.map((g, i) => (
977                <Text color={GATE_HEX[g.state]}>{`${i === 0 ? '' : ' '}${g.label} ${GATE_MARK[g.state]}`}</Text>
978              ))}
979            </Text>,
980          ]),
981      ...(isActive && gates.some(isOpenGate) ? gateRows(gates.filter(isOpenGate)) : []),
982    ]
983  }
984
985  const loopPhases = (): RenderElement[] =>
986    waves.flat().length === 0
987      ? [<Text dimColor>Sem plano publicado: as fases aparecem aqui quando a coordenação publicar o plano.</Text>]
988      : waves.flat().flatMap(loopPhase)
989
990  const flow = view.flow
991  const times = view.flowTimes ?? {}
992
993  const stepWord = (step: FlowStepView): { word: string; hex: string } => {
994    if (step.state === 'done') return { word: step.outcomeLabel ?? 'concluída', hex: STATUS_HEX.done }
995    if (step.state === 'skipped') return { word: 'pulada', hex: STATUS_HEX.pending }
996    if (step.state === 'pending') return { word: 'pendente', hex: STATUS_HEX.pending }
997    if (flow?.status === 'escalated') return { word: 'no limite: você decide', hex: STATUS_HEX.blocked }
998    return flow?.waitingOn === 'owner'
999      ? { word: 'esperando você', hex: GATE_HEX.stale }
1000      : { word: 'em andamento', hex: STATUS_HEX.running }
1001  }
1002
1003  const stepElapsed = (step: FlowStepView): string | undefined => {
1004    const time = times[step.id]
1005    if (time === undefined || step.state === 'pending' || step.state === 'skipped') return undefined
1006    const to = time.to ?? (step.state === 'current' ? view.updatedAt : undefined)
1007    return to === undefined || to < time.from ? undefined : formatElapsed(to - time.from)
1008  }
1009
1010  /** One step per line, as the app's background-tasks list draws a task: mark, who acts, title; time and state. */
1011  const stepLine = (step: FlowStepView): RenderElement => {
1012    const { word, hex } = stepWord(step)
1013    const elapsed = stepElapsed(step)
1014    const isCurrent = step.state === 'current'
1015    return row(
1016      <Text wrap="truncate-end" bold={isCurrent} dimColor={step.state === 'done' || step.state === 'skipped'}>
1017        <Text color={hex}>{STEP_MARK[step.state]} </Text>
1018        {`${KIND_ICON[step.kind]} ${step.title}`}
1019        {step.attempts > 1 && step.state !== 'pending' && <Text dimColor>{` · tentativa ${step.attempts} de ${step.maxAttempts}`}</Text>}
1020      </Text>,
1021      <Box flexDirection="row" columnGap={1}>
1022        {elapsed !== undefined && <Text dimColor>{elapsed}</Text>}
1023        <Text color={hex}>{word}</Text>
1024      </Box>,
1025    )
1026  }
1027
1028  const finalGateRows = (): RenderElement[] => {
1029    if (run === undefined) return []
1030    const finalGates = gateViews(
1031      run.finalGates,
1032      FINAL_SCOPE,
1033      familyCheck([view.self?.gates ?? NO_CHECK, ...view.children.map(c => c.gates ?? NO_CHECK)]),
1034      skips,
1035    )
1036    return finalGates.length === 0
1037      ? []
1038      : [
1039          group(
1040            'final-gates',
1041            'Checagens antes de juntar o código',
1042            `${finalGates.filter(g => !isOpenGate(g)).length}/${finalGates.length}`,
1043            true,
1044            () => gateRows(finalGates),
1045            'Rodam uma vez, com o código de todas as fases já junto; enquanto não passam, o código não é juntado na branch principal.',
1046          ),
1047        ]
1048  }
1049
1050  /** The harness the change follows: the flow's steps, the phases' gates while the loop runs, the final gates. */
1051  const gatesCard = (): RenderElement[] => {
1052    const anyPhaseGates = waves.flat().some(p => (p.gates?.length ?? 0) > 0)
1053    if (flow === undefined && !anyPhaseGates && (run?.finalGates?.length ?? 0) === 0) return []
1054    if (flow === undefined) {
1055      return [card('gates', 'Gates', [...(anyPhaseGates ? loopPhases() : []), ...finalGateRows()])]
1056    }
1057    const folded = flow.steps.filter(s => s.state === 'done' || s.state === 'skipped')
1058    const shown = flow.steps.filter(s => s.state === 'current' || s.state === 'pending')
1059    const hasLoop = flow.steps.some(s => s.kind === 'loop')
1060    return [
1061      card(
1062        'gates',
1063        'Gates',
1064        [
1065          ...(folded.length === 0
1066            ? []
1067            : [group('gates:done', `✓ ${folded.length} concluídas`, '', false, () => folded.map(stepLine))]),
1068          ...shown.map(step =>
1069            step.state === 'current' ? (
1070              <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1} minWidth={0}>
1071                {stepLine(step)}
1072                {step.description !== undefined && (
1073                  <Text dimColor wrap="wrap">
1074                    {step.description}
1075                  </Text>
1076                )}
1077                {flow.status === 'escalated' && (
1078                  <Text color="warning" wrap="wrap">
1079                    Chegou ao limite de {step.maxAttempts} tentativas: escolha em "Precisa de você" se tenta de novo.
1080                  </Text>
1081                )}
1082                {step.kind === 'loop' && <Box flexDirection="column" marginTop={1} minWidth={0}>{loopPhases()}</Box>}
1083              </Box>
1084            ) : (
1085              stepLine(step)
1086            ),
1087          ),
1088          ...(flow.status === 'done'
1089            ? [<Text color={STATUS_HEX.done}>Flow concluído.</Text>]
1090            : flow.status === 'aborted'
1091              ? [<Text color={STATUS_HEX.blocked}>Flow encerrado por você.</Text>]
1092              : []),
1093          ...(!hasLoop && anyPhaseGates ? [<Box flexDirection="column" marginTop={1} minWidth={0}>{loopPhases()}</Box>] : []),
1094          ...finalGateRows(),
1095          <Text dimColor wrap="wrap">
1096            ⚙ automático (um script decide) · 🤖 sessão faz · ✋ você decide no painel
1097          </Text>,
1098        ],
1099        `${flow.flowTitle} · ${folded.length}/${flow.steps.length}`,
1100        flow.status === 'escalated' ? STATUS_HEX.blocked : undefined,
1101      ),
1102    ]
1103  }
1104
1105  // Reading order: how much is left, what the change is (OpenSpec guides it), the harness it
1106  // follows, what needs the owner, then the phases and their tasks.
1107  cards.push(...runCard())
1108  if (view.openspec !== undefined) cards.push(openSpecCard(view.openspec))
1109  cards.push(...gatesCard())
1110  if (attentionCard !== undefined) cards.push(attentionCard)
1111  cards.push(...tasksCard())
1112
1113  const own = planProgress(ownPlan)
1114  cards.push(
1115    card(
1116      'self',
1117      'Esta sessão',
1118      ownPlan.length === 0
1119        ? [<Text dimColor>Sem lista de tarefas ainda</Text>]
1120        : [
1121            row(`${percent(own.done, own.total)}% concluído`, `${own.done} / ${own.total} passos`),
1122            bar(
1123              [
1124                { value: own.done, hex: STATUS_HEX.done },
1125                { value: own.current === undefined ? 0 : 1, hex: STATUS_HEX.running },
1126                { value: own.total - own.done - (own.current === undefined ? 0 : 1), hex: STATUS_HEX.pending },
1127              ],
1128              `${own.done} de ${own.total} passos`,
1129            ),
1130            <Box marginTop={1} />,
1131            ...planRows(ownPlan),
1132          ],
1133      view.self?.branch,
1134      undefined,
1135      run === undefined,
1136    ),
1137  )
1138
1139  const used = new Set(
1140    run === undefined ? [] : phaseViews(run, view.children).flat().flatMap(p => (p.card ? [p.card.sessionId] : [])),
1141  )
1142  const loose = view.children.filter(c => !used.has(c.sessionId))
1143  if (loose.length > 0) {
1144    cards.push(
1145      card(
1146        'children',
1147        run === undefined ? 'Sessões filhas' : 'Outras sessões',
1148        loose.map(c => {
1149          const progress = planProgress(c.plan)
1150          return group(
1151            `session:${c.sessionId}`,
1152            c.title,
1153            c.waiting === undefined && !c.isRunning && progress.total > 0
1154              ? `${progress.done}/${progress.total}`
1155              : sessionState(c),
1156            c.isRunning || c.waiting !== undefined,
1157            () => [...sessionRows(c), ...planRows(c.plan)],
1158          )
1159        }),
1160        `${loose.filter(c => c.isRunning).length} ativas`,
1161      ),
1162    )
1163  }
1164
1165  if (view.warning !== undefined) {
1166    cards.push(
1167      <Box paddingX={1} minWidth={0}>
1168        <Text color="warning" wrap="wrap">
1169          {view.warning}
1170        </Text>
1171      </Box>,
1172    )
1173  }
1174
1175  return (
1176    <Box flexDirection="column" rowGap={1} minWidth={0}>
1177      {cards}
1178    </Box>
1179  )
1180}
1181
1182/** The thin band above the prompt: the flow's step, phases in flight, what waits on the owner; a button for the pane. */
1183export const renderBand = (
1184  { Box, Text, Button }: Pick<PaneElements, 'Box' | 'Text' | 'Button'>,
1185  summary: BandSummary,
1186  paneOpen: boolean,
1187  togglePane: () => void,
1188): RenderElement => {
1189  const parts = [
1190    summary.step === undefined ? undefined : `passo: ${summary.step}`,
1191    `${summary.inFlight} ${summary.inFlight === 1 ? 'fase em voo' : 'fases em voo'}`,
1192  ].filter((part): part is string => part !== undefined)
1193  return (
1194    <Box flexDirection="row" justifyContent="space-between" columnGap={1} minWidth={0}>
1195      <Box flexShrink={1} minWidth={0}>
1196        <Text wrap="truncate-end">
1197          <Text dimColor>🧭 {parts.join(' · ')}</Text>
1198          {summary.waiting > 0 && (
1199            <Text color="warning">{` · ${summary.waiting} ${summary.waiting === 1 ? 'pendência sua' : 'pendências suas'}`}</Text>
1200          )}
hooks/jira.ts 198 lines
1import type { FlowStep, JiraCategory, JiraIssue, JiraMove, JiraTarget, JiraTransition, OrchestrationRun, PhaseStatus } from '../types'
2
3/** A Jira key as Jira spells it: project letters, a dash, a number. */
4export const normalizeKey = (value: unknown): string | undefined => {
5  if (typeof value !== 'string') return undefined
6  const key = value.trim().toUpperCase()
7  return /^[A-Z][A-Z0-9_]+-\d+$/.test(key) ? key : undefined
8}
9
10/** Every issue the plan names, the change's first, without repeats. */
11export const jiraKeys = (run: OrchestrationRun): string[] => {
12  const keys = [run.jira, ...run.waves.flatMap(w => [w.jira, ...w.phases.map(p => p.jira)])]
13  return [...new Set(keys.filter((k): k is string => k !== undefined))]
14}
15
16/** A bare host from either `acme.atlassian.net` or `https://acme.atlassian.net/...`. */
17export const siteHost = (site: string | undefined): string | undefined => {
18  const host = site?.trim().replace(/^https?:\/\//, '').split('/')[0]
19  return host === undefined || host === '' ? undefined : host
20}
21
22export const issueUrl = (site: string | undefined, key: string): string | undefined => {
23  const host = siteHost(site)
24  return host === undefined ? undefined : `https://${host}/browse/${key}`
25}
26
27const parse = (text: string | undefined): unknown => {
28  if (text === undefined) return undefined
29  try {
30    return JSON.parse(text)
31  } catch {
32    return undefined
33  }
34}
35
36/** The connector's first site, from getAccessibleAtlassianResources: its URL and its cloud id. */
37export const firstSite = (text: string | undefined): { url: string; cloudId: string } | undefined => {
38  const parsed = parse(text)
39  const rows = Array.isArray(parsed) ? parsed : []
40  for (const row of rows) {
41    if (typeof row !== 'object' || row === null) continue
42    const r = row as Record<string, unknown>
43    if (typeof r.url === 'string' && typeof r.id === 'string') return { url: r.url, cloudId: r.id }
44  }
45  return undefined
46}
47
48const findFields = (value: unknown, depth = 0): Record<string, unknown> | undefined => {
49  if (typeof value !== 'object' || value === null || depth > 4) return undefined
50  const v = value as Record<string, unknown>
51  if (typeof v.fields === 'object' && v.fields !== null) return v.fields as Record<string, unknown>
52  for (const child of Object.values(v)) {
53    const found = findFields(child, depth + 1)
54    if (found !== undefined) return found
55  }
56  return undefined
57}
58
59const asCategory = (value: unknown): JiraCategory =>
60  value === 'new' || value === 'indeterminate' || value === 'done' ? value : 'unknown'
61
62/** getJiraIssue's answer, read defensively: the shape is the connector's, not ours. */
63export const parseIssue = (text: string | undefined, key: string, site: string | undefined): JiraIssue => {
64  const fields = findFields(parse(text))
65  const status = fields?.status as { name?: unknown; statusCategory?: { key?: unknown } } | undefined
66  return {
67    key,
68    title: typeof fields?.summary === 'string' ? fields.summary : undefined,
69    status: typeof status?.name === 'string' ? status.name : undefined,
70    category: asCategory(status?.statusCategory?.key),
71    url: issueUrl(site, key),
72  }
73}
74
75/** Says when the panel and Jira disagree about whether the work is finished. */
76export const divergence = (phase: PhaseStatus, issue: JiraIssue | undefined): string | undefined => {
77  if (issue === undefined || issue.category === 'unknown') return undefined
78  if (phase === 'done' && issue.category !== 'done') return `Fase fechada, mas ${issue.key} segue "${issue.status ?? 'aberta'}" no Jira`
79  if (phase !== 'done' && issue.category === 'done') return `${issue.key} já está "${issue.status ?? 'concluída'}" no Jira, mas a fase não fechou`
80  return undefined
81}
82
83const IN_PROGRESS: JiraTarget = { status: ['Em andamento', 'In Progress'], category: 'indeterminate' }
84const DONE: JiraTarget = { status: ['Concluído', 'Concluída', 'Done'], category: 'done' }
85
86/** The state each flow step implies, by step id; a flow file overrides it with the step's `jira`. */
87export const DEFAULT_JIRA_TARGETS: Readonly<Record<string, JiraTarget>> = {
88  executar: IN_PROGRESS,
89  qa: { status: ['Em QA', 'QA', 'In QA', 'Em teste', 'Em validação'] },
90  producao: DONE,
91  arquivar: DONE,
92}
93
94const CATEGORIES = ['new', 'indeterminate', 'done']
95const names = (v: unknown): string[] =>
96  (Array.isArray(v) ? v : [v]).filter((n): n is string => typeof n === 'string' && n.trim() !== '').map(n => n.trim())
97
98/** A step's `jira` in a flow file: a status name, a list of names, `{ status, category }`, or false for none. */
99export const parseJiraTarget = (raw: unknown): JiraTarget | false | undefined | string => {
100  if (raw === undefined) return undefined
101  if (raw === false) return false
102  const spec = typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? (raw as Record<string, unknown>) : { status: raw }
103  const status = names(spec.status)
104  const category = spec.category
105  if (category !== undefined && !(typeof category === 'string' && CATEGORIES.includes(category))) {
106    return '`jira.category` must be new, indeterminate or done.'
107  }
108  if (status.length === 0 && category === undefined) return '`jira` needs a status name, a `category`, or false.'
109  return category === undefined ? { status } : { status, category: category as JiraTarget['category'] }
110}
111
112/** What the owner reads for a target: its first name, else its category. */
113export const targetLabel = (t: JiraTarget): string =>
114  t.status[0] ?? (t.category === 'done' ? 'Concluído' : t.category === 'new' ? 'A fazer' : 'Em andamento')
115
116export const targetOf = (steps: readonly FlowStep[], stepId: string): JiraTarget | undefined => {
117  const own = steps.find(s => s.id === stepId)?.jira
118  return own === false ? undefined : (own ?? DEFAULT_JIRA_TARGETS[stepId])
119}
120
121const same = (a: string | undefined, b: string): boolean => a !== undefined && a.trim().toLowerCase() === b.trim().toLowerCase()
122
123/** Already there: its status is one of the names, or, with no names, its category is the target's. */
124export const reached = (issue: JiraIssue, t: JiraTarget): boolean =>
125  t.status.length > 0 ? t.status.some(n => same(issue.status, n)) : t.category !== undefined && issue.category === t.category
126
127/** The issues a step wants moved: the change's own, and during the loop each phase's (running work, closed work). */
128export const wishes = (
129  run: OrchestrationRun,
130  steps: readonly FlowStep[],
131  stepId: string,
132): { key: string; target: JiraTarget }[] => {
133  const target = targetOf(steps, stepId)
134  const out: { key: string; target: JiraTarget }[] = []
135  const add = (key: string | undefined, t: JiraTarget | undefined) => {
136    if (key !== undefined && t !== undefined && !out.some(w => w.key === key)) out.push({ key, target: t })
137  }
138  add(run.jira, target)
139  if (steps.find(s => s.id === stepId)?.kind === 'loop') {
140    for (const phase of run.waves.flatMap(w => w.phases)) {
141      if (phase.status === 'running') add(phase.jira, target)
142      else if (phase.status === 'done') add(phase.jira, DONE)
143    }
144  }
145  return out
146}
147
148const asTransition = (value: unknown): JiraTransition | undefined => {
149  if (typeof value !== 'object' || value === null) return undefined
150  const t = value as { id?: unknown; name?: unknown; to?: { name?: unknown; statusCategory?: { key?: unknown } } }
151  const id = typeof t.id === 'number' ? String(t.id) : t.id
152  if (typeof id !== 'string' || typeof t.name !== 'string') return undefined
153  return { id, name: t.name, to: typeof t.to?.name === 'string' ? t.to.name : undefined, category: asCategory(t.to?.statusCategory?.key) }
154}
155
156const findTransitions = (value: unknown, depth = 0): unknown[] | undefined => {
157  if (Array.isArray(value)) return value
158  if (typeof value !== 'object' || value === null || depth > 3) return undefined
159  const v = value as Record<string, unknown>
160  if (Array.isArray(v.transitions)) return v.transitions
161  for (const child of Object.values(v)) {
162    const found = findTransitions(child, depth + 1)
163    if (found !== undefined) return found
164  }
165  return undefined
166}
167
168/** getTransitionsForJiraIssue's answer, read defensively. */
169export const parseTransitions = (text: string | undefined): JiraTransition[] =>
170  (findTransitions(parse(text)) ?? []).map(asTransition).filter((t): t is JiraTransition => t !== undefined)
171
172/** The transition that reaches the target: by destination or transition name, else by destination category. */
173export const pickTransition = (transitions: readonly JiraTransition[], t: JiraTarget): JiraTransition | undefined => {
174  for (const n of t.status) {
175    const hit = transitions.find(tr => same(tr.to, n) || same(tr.name, n))
176    if (hit !== undefined) return hit
177  }
178  return t.category === undefined ? undefined : transitions.find(tr => tr.category === t.category)
179}
180
181/** The panel's item for one wished move; undefined when the issue is already there or could not be read. */
182export const moveFor = (
183  wish: { key: string; target: JiraTarget },
184  issue: JiraIssue | undefined,
185  transitions: readonly JiraTransition[] | undefined,
186  step: string,
187  connectorNote: string | undefined,
188): JiraMove | undefined => {
189  const base = { key: wish.key, title: issue?.title, from: issue?.status, toLabel: targetLabel(wish.target), step }
190  if (connectorNote !== undefined) return { ...base, state: 'no-connector', available: [], note: connectorNote }
191  if (issue === undefined || issue.status === undefined || reached(issue, wish.target)) return undefined
192  const available = (transitions ?? []).map(tr => tr.to ?? tr.name)
193  const hit = transitions === undefined ? undefined : pickTransition(transitions, wish.target)
194  return hit === undefined
195    ? { ...base, state: 'no-transition', available }
196    : { ...base, state: 'ready', transitionId: hit.id, transitionName: hit.name, available }
197}
198
hooks/openspec.ts 166 lines
1import type { EvidenceState, OpenSpecView, TaskItem, TaskSection } from '../types'
2
3const SUMMARY_MAX = 420
4
5/** Markdown down to plain prose: the panel draws text, not markdown. */
6const plain = (text: string): string =>
7  text
8    .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
9    .replace(/[*_`]+/g, '')
10    .replace(/\s+/g, ' ')
11    .trim()
12
13const clip = (text: string): string =>
14  text.length <= SUMMARY_MAX ? text : `${text.slice(0, SUMMARY_MAX - 1).trimEnd()}…`
15
16/** tasks.md checkboxes counted per `## ` section; boxes above the first section count under "Geral". */
17export const parseTasks = (text: string): { sections: TaskSection[]; done: number; total: number } => {
18  const sections: TaskSection[] = []
19  let current: TaskSection | undefined
20  for (const line of text.split('\n')) {
21    const heading = line.match(/^##\s+(.+?)\s*$/)
22    if (heading) {
23      current = { title: plain(heading[1] ?? ''), done: 0, total: 0 }
24      sections.push(current)
25      continue
26    }
27    const box = line.match(/^\s*[-*]\s+\[([ xX])\]/)
28    if (!box) continue
29    if (current === undefined) {
30      current = { title: 'Geral', done: 0, total: 0 }
31      sections.push(current)
32    }
33    current.total += 1
34    if (box[1] !== ' ') current.done += 1
35  }
36  const withBoxes = sections.filter(s => s.total > 0)
37  return {
38    sections: withBoxes,
39    done: withBoxes.reduce((sum, s) => sum + s.done, 0),
40    total: withBoxes.reduce((sum, s) => sum + s.total, 0),
41  }
42}
43
44/** The numbered checkboxes of tasks.md; a box without a number belongs to no phase and is left out. */
45export const parseTaskItems = (text: string): TaskItem[] =>
46  text.split('\n').flatMap((line): TaskItem[] => {
47    const item = line.match(/^\s*[-*]\s+\[([ xX])\]\s+\**(\d+(?:\.\d+)+)\**\.?\s+(.*)$/)
48    if (!item) return []
49    return [{ id: item[2] ?? '', text: plain(item[3] ?? ''), done: item[1] !== ' ' }]
50  })
51
52const versionParts = (id: string): number[] => id.split('.').map(Number)
53
54const inRange = (id: string, from: string, to: string): boolean => {
55  const [a, b, c] = [versionParts(id), versionParts(from), versionParts(to)]
56  const cmp = (x: number[], y: number[]) => {
57    for (let i = 0; i < Math.max(x.length, y.length); i++) {
58      const d = (x[i] ?? 0) - (y[i] ?? 0)
59      if (d !== 0) return d
60    }
61    return 0
62  }
63  return cmp(a, b) >= 0 && cmp(a, c) <= 0
64}
65
66/**
67 * The tasks a phase delivers, from the plan's `tasks` field: "4.1–4.4" (a range), "4.x" (a
68 * whole section), "3.1" or a list of those separated by commas.
69 */
70export const tasksForPhase = (spec: string | undefined, items: readonly TaskItem[]): TaskItem[] => {
71  if (spec === undefined || spec.trim() === '') return []
72  const parts = spec.split(',').map(part => part.trim()).filter(part => part !== '')
73  return items.filter(item =>
74    parts.some(part => {
75      const section = part.match(/^(\d+(?:\.\d+)*)\.[xX*]$/)
76      if (section) return item.id.startsWith(`${section[1]}.`)
77      const range = part.match(/^(\d+(?:\.\d+)+)\s*[–—-]\s*(\d+(?:\.\d+)+)$/)
78      if (range) return inRange(item.id, range[1] ?? '', range[2] ?? '')
79      return item.id === part
80    }),
81  )
82}
83
84const isProse = (paragraph: string): boolean =>
85  paragraph !== '' &&
86  !/^(#|\||```|>|---|\*\*[^*]+:\*\*)/.test(paragraph) &&
87  !/^[-*]\s+\*\*[^*]+:\*\*/.test(paragraph)
88
89const paragraphs = (lines: readonly string[]): string[] =>
90  lines
91    .join('\n')
92    .split(/\n\s*\n/)
93    .map(p => p.trim())
94
95/**
96 * The proposal's title and the first prose paragraph of its "Why" section; without
97 * one, the first prose paragraph under the title, past the metadata block.
98 */
99export const parseProposal = (text: string): { title: string | undefined; summary: string | undefined } => {
100  const lines = text.split('\n')
101  const titleLine = lines.find(line => /^#\s+/.test(line))
102  const title = titleLine === undefined ? undefined : plain(titleLine.replace(/^#\s+(Proposal:\s*)?/i, ''))
103  const why = lines.findIndex(line => /^##\s+(Why|Por qu[eê]|Motiva|Summary|Resumo)/i.test(line))
104  if (why >= 0) {
105    const end = lines.findIndex((line, i) => i > why && /^##\s/.test(line))
106    const found = paragraphs(lines.slice(why + 1, end < 0 ? undefined : end)).find(isProse)
107    if (found !== undefined) return { title, summary: clip(plain(found)) }
108  }
109  const found = paragraphs(lines).find(isProse)
110  return { title: title === '' ? undefined : title, summary: found === undefined ? undefined : clip(plain(found)) }
111}
112
113/** The last `== check summary ==` block's PASS/FAIL rows, as check.sh prints them. */
114const lastCheckSummary = (text: string): { pass: string[]; fail: string[] } | undefined => {
115  const at = text.lastIndexOf('== check summary ==')
116  if (at < 0) return undefined
117  const pass: string[] = []
118  const fail: string[] = []
119  for (const line of text.slice(at).split('\n').slice(1)) {
120    const row = line.match(/^(PASS|FAIL)\s+(\S+)/)
121    if (row) (row[1] === 'PASS' ? pass : fail).push(row[2] ?? '')
122    else if (pass.length + fail.length > 0) break
123  }
124  return pass.length + fail.length === 0 ? undefined : { pass, fail }
125}
126
127/** Where evidence.md stands: a stub, a check.sh run (green or failing) or other recorded proof. */
128export const parseEvidence = (text: string | undefined): { state: EvidenceState; summary: string | undefined } => {
129  if (text === undefined) return { state: 'missing', summary: 'evidence.md ainda não existe.' }
130  const check = lastCheckSummary(text)
131  if (check !== undefined) {
132    if (check.fail.length > 0) {
133      return {
134        state: 'failing',
135        summary: `Último check.sh: ${check.fail.length} falha(s) — ${check.fail.slice(0, 4).join(', ')}; ${check.pass.length} ok.`,
136      }
137    }
138    return { state: 'green', summary: `Último check.sh verde: ${check.pass.length} verificações passaram.` }
139  }
140  if (/pre-execution stub|status:\s*stub|not run yet/i.test(text)) {
141    return { state: 'stub', summary: 'Esqueleto: nada verificado ainda, sem resultado do check.sh.' }
142  }
143  const sections = text.split('\n').filter(line => /^##\s/.test(line)).length
144  return { state: 'recorded', summary: `${sections} seção(ões) registradas, sem resumo do check.sh.` }
145}
146
147export const openSpecView = (
148  files: { tasks: string | undefined; proposal: string | undefined; evidence: string | undefined },
149): OpenSpecView => {
150  const tasks = files.tasks === undefined ? { sections: [], done: 0, total: 0 } : parseTasks(files.tasks)
151  const proposal = files.proposal === undefined ? { title: undefined, summary: undefined } : parseProposal(files.proposal)
152  const evidence = parseEvidence(files.evidence)
153  return {
154    ...tasks,
155    proposalTitle: proposal.title,
156    proposalSummary: proposal.summary,
157    evidence: evidence.state,
158    evidenceSummary: evidence.summary,
159    items: files.tasks === undefined ? [] : parseTaskItems(files.tasks),
160  }
161}
162
163/** The change folder: as given when absolute, else under the coordinator's checkout. */
164export const changeDir = (changePath: string, coordinatorCwd: string): string =>
165  (changePath.startsWith('/') ? changePath : `${coordinatorCwd}/${changePath.replace(/^\.\//, '')}`).replace(/\/+$/, '')
166
hooks/model.ts 420 lines
1import type {
2  ActivityRecord,
3  Attention,
4  OrchestrationRun,
5  OwnerQuestion,
6  PanelView,
7  PhaseStatus,
8  PhaseView,
9  PlanItem,
10  SessionCard,
11  SessionRow,
12} from '../types'
13import { parseGates } from './gates'
14import { normalizeKey } from './jira'
15
16export const PHASE_STATUSES: readonly PhaseStatus[] = [
17  'pending',
18  'running',
19  'blocked',
20  'done',
21]
22
23/** One file per session, keyed by its working directory: each worktree belongs to one session. */
24export const activityKey = (cwd: string): string =>
25  cwd.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(-180)
26
27/** The activity file of a session the mod knows the id of; two sessions in one folder never share it. */
28export const sessionActivityKey = (sessionId: string): string => `id-${activityKey(sessionId)}`
29
30/**
31 * What a session's card shows: the record it published under its own id, else, for a
32 * record from before ids, the freshest one from its folder. A child sharing the
33 * coordinator's folder gets no folder fallback, or it would read the coordinator's.
34 */
35export const activityFor = (
36  records: readonly ActivityRecord[],
37  row: { sessionId: string; cwd: string },
38  selfCwd: string | undefined,
39): ActivityRecord | undefined =>
40  records.filter(r => r.sessionId === row.sessionId).sort((a, b) => b.updatedAt - a.updatedAt)[0] ??
41  (row.cwd === selfCwd ? undefined : latestActivity(records.filter(r => r.sessionId === undefined), row.cwd))
42
43/**
44 * The freshest record a session published from its folder or any folder beneath it:
45 * an older copy of the mod keyed its file by the shell's cwd, which a `cd` moves.
46 */
47export const latestActivity = (
48  records: readonly ActivityRecord[],
49  cwd: string,
50): ActivityRecord | undefined =>
51  records
52    .filter(r => r.cwd === cwd || r.cwd.startsWith(`${cwd}/`))
53    .sort((a, b) => b.updatedAt - a.updatedAt)[0]
54
55export const runKey = (change: string): string =>
56  change.replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 120) || 'change'
57
58type PlanUpdate = {
59  items?: readonly { content?: unknown; status?: unknown }[]
60  advance?: boolean
61  done?: readonly number[]
62  start?: number
63}
64
65const asStatus = (value: unknown): PlanItem['status'] =>
66  value === 'completed' || value === 'in_progress' ? value : 'pending'
67
68/**
69 * Replays one update_plan call on the list the session already shows, the way
70 * the app applies it: a whole list replaces, advance moves the cursor, done/start
71 * are 1-based.
72 */
73export const applyPlanUpdate = (
74  plan: readonly PlanItem[],
75  input: PlanUpdate,
76): PlanItem[] => {
77  if (Array.isArray(input.items)) {
78    return input.items
79      .filter(item => typeof item?.content === 'string' && item.content !== '')
80      .map(item => ({ content: String(item.content), status: asStatus(item.status) }))
81  }
82  const next = plan.map(item => ({ ...item }))
83  if (input.advance === true) {
84    const current = next.findIndex(item => item.status === 'in_progress')
85    if (current >= 0) next[current] = { ...next[current]!, status: 'completed' }
86    const pending = next.findIndex(item => item.status === 'pending')
87    if (pending >= 0) next[pending] = { ...next[pending]!, status: 'in_progress' }
88  }
89  for (const n of input.done ?? []) {
90    const item = next[n - 1]
91    if (item !== undefined) next[n - 1] = { ...item, status: 'completed' }
92  }
93  if (typeof input.start === 'number') {
94    const item = next[input.start - 1]
95    if (item !== undefined) next[input.start - 1] = { ...item, status: 'in_progress' }
96  }
97  return next
98}
99
100/** wrap_up checks the whole list off. */
101export const completePlan = (plan: readonly PlanItem[]): PlanItem[] =>
102  plan.map(item => ({ ...item, status: 'completed' }))
103
104export const planProgress = (
105  plan: readonly PlanItem[],
106): { done: number; total: number; current: string | undefined } => ({
107  done: plan.filter(item => item.status === 'completed').length,
108  total: plan.length,
109  current: plan.find(item => item.status === 'in_progress')?.content,
110})
111
112/** The app's list_sessions/get_session rows, read defensively: the shape is the app's, not ours. */
113export const parseSessionRows = (text: string | undefined): SessionRow[] => {
114  if (text === undefined || text.trim() === '') return []
115  let parsed: unknown
116  try {
117    parsed = JSON.parse(text)
118  } catch {
119    return []
120  }
121  const rows = Array.isArray(parsed) ? parsed : [parsed]
122  return rows.flatMap((row): SessionRow[] => {
123    if (typeof row !== 'object' || row === null) return []
124    const r = row as Record<string, unknown>
125    if (typeof r.sessionId !== 'string' || typeof r.cwd !== 'string') return []
126    const group = r.group as { id?: unknown; name?: unknown } | null | undefined
127    return [
128      {
129        sessionId: r.sessionId,
130        title: typeof r.title === 'string' ? r.title : r.sessionId,
131        cwd: r.cwd,
132        branch: typeof r.branch === 'string' ? r.branch : undefined,
133        isRunning: r.isRunning === true,
134        isArchived: r.isArchived === true,
135        prNumber: typeof r.prNumber === 'number' ? r.prNumber : undefined,
136        prState: typeof r.prState === 'string' ? r.prState : undefined,
137        lastActivityAt: typeof r.lastActivityAt === 'string' ? r.lastActivityAt : undefined,
138        name: typeof r.name === 'string' ? r.name : undefined,
139        startedBy: typeof r.startedBy === 'string' ? r.startedBy : undefined,
140        link: typeof r.link === 'string' ? r.link : undefined,
141        groupId: typeof group?.id === 'string' ? group.id : undefined,
142        groupName: typeof group?.name === 'string' ? group.name : undefined,
143      },
144    ]
145  })
146}
147
148export const toCard = (
149  row: SessionRow,
150  activity: ActivityRecord | undefined,
151): SessionCard => ({
152  ...row,
153  plan: activity?.plan ?? [],
154  lastTool: activity?.lastTool,
155  waiting: activity?.waiting,
156  hasActivity: activity !== undefined,
157  gates: { records: activity?.gates ?? {}, editedAt: activity?.editedAt },
158})
159
160const matches = (card: SessionCard, ref: string): boolean => {
161  const needle = ref.trim().toLowerCase()
162  if (needle === '') return false
163  return (
164    card.sessionId.toLowerCase() === needle ||
165    card.name?.toLowerCase() === needle ||
166    card.title.toLowerCase().includes(needle)
167  )
168}
169
170/** Joins each phase of the coordinator's plan to the live session doing it. */
171export const phaseViews = (
172  run: OrchestrationRun,
173  cards: readonly SessionCard[],
174): PhaseView[][] =>
175  run.waves.map(wave =>
176    wave.phases.map(phase => ({
177      ...phase,
178      card:
179        phase.session === undefined
180          ? undefined
181          : cards.find(card => matches(card, phase.session ?? '')),
182    })),
183  )
184
185export const phaseCounts = (
186  run: OrchestrationRun,
187): Record<PhaseStatus, number> => {
188  const counts: Record<PhaseStatus, number> = { pending: 0, running: 0, blocked: 0, done: 0 }
189  for (const wave of run.waves) for (const phase of wave.phases) counts[phase.status] += 1
190  return counts
191}
192
193/** The coordinator's plan as the plan tool receives it, checked field by field. */
194export const parseRun = (
195  input: Record<string, unknown>,
196  coordinatorCwd: string,
197  updatedAt: number,
198): OrchestrationRun | { error: string } => {
199  const change = input.change
200  if (typeof change !== 'string' || change.trim() === '') return { error: '`change` is required.' }
201  if (!Array.isArray(input.waves) || input.waves.length === 0) {
202    return { error: '`waves` needs at least one wave.' }
203  }
204  const waves: OrchestrationRun['waves'] = []
205  for (const [w, rawWave] of input.waves.entries()) {
206    const wave = rawWave as Record<string, unknown>
207    if (!Array.isArray(wave?.phases)) return { error: `wave ${w + 1}: \`phases\` must be a list.` }
208    const phases: OrchestrationRun['waves'][number]['phases'] = []
209    for (const [p, rawPhase] of wave.phases.entries()) {
210      const phase = rawPhase as Record<string, unknown>
211      if (typeof phase?.id !== 'string' || typeof phase.title !== 'string') {
212        return { error: `wave ${w + 1}, phase ${p + 1}: \`id\` and \`title\` are required.` }
213      }
214      const status = PHASE_STATUSES.find(s => s === phase.status) ?? 'pending'
215      const gates = parseGates(phase.gates, `phase ${phase.id}`)
216      if (gates !== undefined && 'error' in gates) return gates
217      phases.push({
218        id: phase.id,
219        title: phase.title,
220        status,
221        icon: typeof phase.icon === 'string' ? phase.icon : undefined,
222        tasks: typeof phase.tasks === 'string' ? phase.tasks : undefined,
223        session: typeof phase.session === 'string' ? phase.session : undefined,
224        dependsOn: Array.isArray(phase.dependsOn)
225          ? phase.dependsOn.filter((d): d is string => typeof d === 'string')
226          : undefined,
227        note: typeof phase.note === 'string' ? phase.note : undefined,
228        summary: typeof phase.summary === 'string' ? phase.summary : undefined,
229        jira: normalizeKey(phase.jira),
230        gates,
231      })
232    }
233    waves.push({
234      title: typeof wave.title === 'string' ? wave.title : undefined,
235      summary: typeof wave.summary === 'string' ? wave.summary : undefined,
236      jira: normalizeKey(wave.jira),
237      phases,
238    })
239  }
240  const finalGates = parseGates(input.finalGates, 'finalGates')
241  if (finalGates !== undefined && 'error' in finalGates) return finalGates
242  const ownerQuestions = Array.isArray(input.ownerQuestions)
243    ? input.ownerQuestions.flatMap(parseOwnerQuestion)
244    : []
245  return {
246    change: change.trim(),
247    changePath: typeof input.changePath === 'string' ? input.changePath : undefined,
248    coordinatorCwd,
249    jira: normalizeKey(input.jira),
250    jiraSite: typeof input.jiraSite === 'string' && input.jiraSite.trim() !== '' ? input.jiraSite.trim() : undefined,
251    waves,
252    ownerQuestions,
253    finalGates,
254    updatedAt,
255  }
256}
257
258const parseOwnerQuestion = (raw: unknown): OwnerQuestion[] => {
259  if (typeof raw === 'string') {
260    return raw.trim() === '' ? [] : [{ question: raw, recommendation: undefined, session: undefined, pr: undefined }]
261  }
262  if (typeof raw !== 'object' || raw === null) return []
263  const q = raw as Record<string, unknown>
264  if (typeof q.question !== 'string' || q.question.trim() === '') return []
265  return [
266    {
267      question: q.question,
268      recommendation: typeof q.recommendation === 'string' ? q.recommendation : undefined,
269      session: typeof q.session === 'string' ? q.session : undefined,
270      pr: typeof q.pr === 'number' && q.pr > 0 ? q.pr : undefined,
271    },
272  ]
273}
274
275/** The web address of a GitHub remote, in either of git's spellings. */
276export const repoWebUrl = (remote: string | null | undefined): string | undefined => {
277  const match = remote?.match(/github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/)
278  return match ? `https://github.com/${match[1]}/${match[2]}` : undefined
279}
280
281export const prUrl = (repoUrl: string | undefined, pr: number | undefined): string | undefined =>
282  repoUrl === undefined || pr === undefined ? undefined : `${repoUrl}/pull/${pr}`
283
284export const findCard = (cards: readonly SessionCard[], ref: string | undefined): SessionCard | undefined =>
285  ref === undefined ? undefined : cards.find(card => matches(card, ref))
286
287/**
288 * Everything waiting on the owner, most blocking first: the coordinator's decisions,
289 * sessions stopped on a permission or a question, then open PRs to look at.
290 */
291export const attentionItems = (view: PanelView): Attention[] => {
292  const cards = view.self === undefined ? view.children : [view.self, ...view.children]
293  const decisions: Attention[] = (view.run?.ownerQuestions ?? []).map((q, i) => {
294    const card = findCard(cards, q.session)
295    const pr = q.pr ?? card?.prNumber
296    return {
297      id: `decision:${i}`,
298      kind: 'decision',
299      title: q.question,
300      detail: card?.title,
301      recommendation: q.recommendation,
302      card,
303      prUrl: q.pr === undefined ? undefined : prUrl(view.repoUrl, pr),
304    }
305  })
306  const waiting: Attention[] = view.children.flatMap(card =>
307    card.waiting === undefined
308      ? []
309      : [
310          {
311            id: `waiting:${card.sessionId}`,
312            kind: card.waiting.kind,
313            title:
314              card.waiting.kind === 'permission'
315                ? `Permissão pendente: ${card.waiting.text}`
316                : card.waiting.text,
317            detail: card.title,
318            recommendation: undefined,
319            card,
320            prUrl: undefined,
321          },
322        ],
323  )
324  const prs: Attention[] = cards.flatMap(card =>
325    card.prNumber === undefined || card.prState?.toUpperCase() !== 'OPEN'
326      ? []
327      : [
328          {
329            id: `pr:${card.prNumber}`,
330            kind: 'pr',
331            title: `PR #${card.prNumber} aberto`,
332            detail: card.title,
333            recommendation: undefined,
334            card,
335            prUrl: prUrl(view.repoUrl, card.prNumber),
336          },
337        ],
338  )
339  const flow = view.flow
340  const step = flow?.current
341  const flowItems: Attention[] =
342    flow === undefined || step === undefined || flow.ownerChoices.length === 0
343      ? []
344      : [
345          {
346            id: `flow:${flow.change}:${step.id}`,
347            kind: 'flow',
348            title:
349              flow.status === 'escalated'
350                ? `${step.title}: ${step.attempts} tentativas sem sucesso`
351                : step.title,
352            detail:
353              flow.status === 'escalated'
354                ? `Flow ${flow.flowTitle} de ${flow.change}: tentar de novo ou encerrar?`
355                : step.description ?? `Flow ${flow.flowTitle} de ${flow.change}`,
356            recommendation: undefined,
357            card: undefined,
358            prUrl: undefined,
359            flow: { change: flow.change, step: step.id, choices: flow.ownerChoices },
360          },
361        ]
362  const change = view.flow?.change ?? view.run?.change ?? ''
363  const jiraItems: Attention[] = (view.jiraMoves ?? []).map(move => {
364    const now = move.from === undefined ? '' : ` Hoje: "${move.from}".`
365    const base = { id: `jira:${move.key}`, kind: 'jira' as const, recommendation: undefined, card: undefined, prUrl: undefined }
366    if (move.state === 'ready') {
367      return {
368        ...base,
369        title: `A issue ${move.key} pode ir para ${move.toLabel}`,
370        detail: `${move.title ?? ''}${now} O Jira só muda quando você apertar o botão.`.trim(),
371        jira: { change, key: move.key, transitionId: move.transitionId ?? '', toLabel: move.toLabel },
372      }
373    }
374    if (move.state === 'no-transition') {
375      const options = move.available.length === 0 ? 'nenhuma transição disponível' : move.available.join(', ')
376      return {
377        ...base,
378        title: `A issue ${move.key} deveria ir para ${move.toLabel}, mas o Jira não oferece essa transição`,
379        detail: `${now.trim()} Disponíveis: ${options}. Ajuste o campo jira do passo no flow, ou mova na mão.`.trim(),
380      }
381    }
382    return {
383      ...base,
384      title: `A issue ${move.key} deveria ir para ${move.toLabel}, mas não consigo mexer no Jira`,
385      detail: move.note,
386    }
387  })
388  return [...flowItems, ...decisions, ...jiraItems, ...waiting, ...prs]
389}
390
391/** Where the plan stands: the first wave not closed, and the one after it. */
392export const nowAndNext = (
393  run: OrchestrationRun,
394): { now: number | undefined; next: number | undefined } => {
395  const now = run.waves.findIndex(w => w.phases.some(p => p.status !== 'done'))
396  if (now < 0) return { now: undefined, next: undefined }
397  return { now, next: now + 1 < run.waves.length ? now + 1 : undefined }
398}
399
400/** The sidebar group a change's sessions live in. */
401export const groupName = (change: string): string => `🪪 ${change}`.slice(0, 200)
402
403/** Reads a group id out of list_groups' array or create_group's answer, whichever shape it takes. */
404export const findGroupId = (text: string | undefined, name?: string): string | undefined => {
405  if (text === undefined) return undefined
406  try {
407    const parsed: unknown = JSON.parse(text)
408    const rows = Array.isArray(parsed) ? parsed : [parsed]
409    for (const row of rows) {
410      if (typeof row !== 'object' || row === null) continue
411      const r = row as Record<string, unknown>
412      const id = r.id ?? r.group_id ?? r.groupId
413      if (typeof id === 'string' && (name === undefined || r.name === name)) return id
414    }
415    return undefined
416  } catch {
417    return name === undefined ? text.match(/cg-[\w-]+/)?.[0] : undefined
418  }
419}
420
hooks/flows/completo.ts 135 lines
1/** The mod's default "completo" flow, written as the JSON a project's `.orquestrador/flows/completo.json` would hold. */
2export const completo: unknown = {
3  "id": "completo",
4  "title": "Completo",
5  "description": "Do entendimento ao arquivamento: grill com o dono, mockup quando a change é visual, plano OpenSpec revisado e liberado pelo dono, fases com seus portões, revisão, PR, QA e produção.",
6  "steps": [
7    {
8      "id": "grill",
9      "title": "Entendimento",
10      "description": "Rodadas de perguntas até o dono fechar o entendimento.",
11      "kind": "owner",
12      "outcomes": { "fechado": { "label": "Entendimento fechado", "next": "mockup" } }
13    },
14    {
15      "id": "mockup",
16      "title": "Mockup",
17      "description": "Só quando a change é visual: o dono aprova o mockup ou pede ajustes.",
18      "kind": "owner",
19      "when": "visual",
20      "outcomes": {
21        "aprovado": { "label": "Aprovar mockup", "next": "planejar" },
22        "ajustes": { "label": "Pedir ajustes", "next": "mockup" }
23      }
24    },
25    {
26      "id": "planejar",
27      "title": "Planejar OpenSpec",
28      "description": "Uma sessão escreve proposta, design e tarefas da change.",
29      "kind": "session",
30      "outcomes": { "pronto": { "label": "Plano escrito", "next": "validar" } }
31    },
32    {
33      "id": "validar",
34      "title": "Validar OpenSpec",
35      "description": "O mod vê o `openspec validate` rodar; o código de saída decide.",
36      "kind": "automatic",
37      "command": "openspec validate",
38      "outcomes": {
39        "passou": { "label": "Validou", "next": "revisao-plano" },
40        "falhou": { "label": "Falhou", "next": "planejar" }
41      }
42    },
43    {
44      "id": "revisao-plano",
45      "title": "Revisão do plano",
46      "description": "Revisores conferem o plano contra as regras e as specs.",
47      "kind": "session",
48      "outcomes": {
49        "aprovado": { "label": "Plano aprovado", "next": "liberacao" },
50        "rejeitado": { "label": "Plano rejeitado", "next": "planejar" }
51      }
52    },
53    {
54      "id": "liberacao",
55      "title": "Liberação do dono",
56      "description": "O dono libera a execução do plano.",
57      "kind": "owner",
58      "outcomes": {
59        "liberado": { "label": "Liberar execução", "next": "executar" },
60        "ajustes": { "label": "Pedir ajustes no plano", "next": "planejar" }
61      }
62    },
63    {
64      "id": "executar",
65      "title": "Executar fases",
66      "description": "As fases do tasks.md, uma a uma: o mod fecha cada fase quando os portões passaram, as tarefas estão marcadas e o trabalho foi commitado.",
67      "kind": "loop",
68      "over": "tasks",
69      "outcomes": { "concluidas": { "label": "Fases concluídas", "next": "revisao-codigo" } }
70    },
71    {
72      "id": "revisao-codigo",
73      "title": "Revisão de código",
74      "description": "Revisores conferem o código entregue.",
75      "kind": "session",
76      "outcomes": {
77        "aprovado": { "label": "Código aprovado", "next": "checagem" },
78        "rejeitado": { "label": "Código rejeitado", "next": "executar" }
79      }
80    },
81    {
82      "id": "checagem",
83      "title": "Checagem final",
84      "description": "O mod vê o `scripts/check.sh` passar, com o evidence.md preenchido.",
85      "kind": "automatic",
86      "command": "scripts/check.sh",
87      "requiresEvidence": true,
88      "outcomes": {
89        "passou": { "label": "Passou", "next": "pr" },
90        "falhou": { "label": "Falhou", "next": "executar" }
91      }
92    },
93    {
94      "id": "pr",
95      "title": "PR",
96      "description": "Uma sessão abre o PR da change.",
97      "kind": "session",
98      "outcomes": { "aberto": { "label": "PR aberto", "next": "qa" } }
99    },
100    {
101      "id": "qa",
102      "title": "Em QA",
103      "description": "A change espera o QA; o dono diz se passou ou se pediu ajustes.",
104      "kind": "owner",
105      "outcomes": {
106        "aprovado": { "label": "QA aprovou", "next": "producao" },
107        "ajustes": { "label": "QA pediu ajustes", "next": "executar" }
108      }
109    },
110    {
111      "id": "producao",
112      "title": "Em produção",
113      "description": "A change chegou em produção: pelo botão do dono ou quando o mod vê o deploy.",
114      "kind": "owner",
115      "detect": {
116        "commands": ["npm run deploy", "pnpm run deploy", "yarn deploy", "bun run deploy", "vercel --prod", "vercel deploy --prod", "netlify deploy --prod", "fly deploy", "flyctl deploy", "firebase deploy", "gcloud app deploy", "railway up"],
117        "outcome": "publicado"
118      },
119      "outcomes": { "publicado": { "label": "Está em produção", "next": "arquivar" } }
120    },
121    {
122      "id": "arquivar",
123      "title": "Arquivar change",
124      "description": "Uma sessão roda o `openspec archive`; quando passa, o mod arquiva as sessões restantes e remove o grupo da barra lateral.",
125      "kind": "automatic",
126      "command": "openspec archive",
127      "cleanup": true,
128      "outcomes": {
129        "passou": { "label": "Arquivada", "next": "fim" },
130        "falhou": { "label": "Falhou", "next": "arquivar" }
131      }
132    }
133  ]
134}
135