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…

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.
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..orquestrador/flows/*.json.check.sh, openspec validate, print da tela e revisão de regras:/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.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.
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.
change-orchestrator.completo ou rapido) e abra o painel com /orquestrador.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.
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.
MIT. Veja LICENSE.
hooks/register.tsx 1634 lines1import { 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 lines1import 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/'
227hooks/flow.ts 493 lines1import 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}
493hooks/lifecycle.ts 145 lines1import 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}
145hooks/flows/index.ts 15 lines1import 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))
15hooks/grill.ts 274 lines1import 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}
274hooks/glance.ts 64 lines1import 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}
64hooks/pane.tsx 1209 lines1import 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 lines1import 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}
198hooks/openspec.ts 166 lines1import 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(/\/+$/, '')
166hooks/model.ts 420 lines1import 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}
420hooks/flows/completo.ts 135 lines1/** 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