@payconductor/houndsman 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -41,7 +41,20 @@ export {
41
41
  requireMakiModel,
42
42
  resolveMakiModel,
43
43
  } from "./maki-model";
44
- export { renderHuntPrompt, renderRetrySuffix } from "./maki-prompt";
44
+ export type {
45
+ AdversarialCandidate,
46
+ AdversarialPromptInput,
47
+ BookkeepingPromptInput,
48
+ } from "./maki-prompt";
49
+ export {
50
+ ADVERSARIAL_CLASSES,
51
+ renderAdversarialPrompt,
52
+ renderBookkeepingPrompt,
53
+ renderChecklistSuffix,
54
+ renderHuntPrompt,
55
+ renderResumeSuffix,
56
+ renderRetrySuffix,
57
+ } from "./maki-prompt";
45
58
  export type {
46
59
  MakiJsonOutput,
47
60
  MakiProcessRunner,
@@ -53,9 +66,33 @@ export type {
53
66
  export {
54
67
  extractJsonObject,
55
68
  MAKI_DISALLOWED_TOOLS,
69
+ MAKI_REVIEW_SYSTEM_PROMPT,
56
70
  makiArgs,
57
71
  metricsFrom,
58
72
  parseMakiOutput,
59
73
  SpawnMakiRunner,
60
74
  } from "./maki-runner";
61
75
  export { mapPool } from "./pool";
76
+ export type {
77
+ BusinessDecision,
78
+ CheckCoverage,
79
+ CheckVerdict,
80
+ ReviewCheck,
81
+ ReviewRule,
82
+ RuleCheck,
83
+ } from "./review-checks";
84
+ export {
85
+ briefRules,
86
+ businessScore,
87
+ chooseBusinessSummary,
88
+ expectedReviewItems,
89
+ mergeFindings,
90
+ mergeRuleChecks,
91
+ missingReviewItems,
92
+ missingRuleChecks,
93
+ parseReviewChecks,
94
+ parseRuleChecks,
95
+ summarizeChecks,
96
+ summarizeRuleChecks,
97
+ withInferredRule,
98
+ } from "./review-checks";
@@ -7,22 +7,58 @@
7
7
  * não altera o brief usado pela skill `/houndsman hunt`.
8
8
  */
9
9
 
10
- import { GENERAL_WATCHDOG_ID } from "../builtins";
11
-
12
10
  /** Dados do prompt de um watchdog. */
13
11
  export type HuntPromptInput = {
14
12
  projectName: string;
15
13
  watchdogId: string;
16
14
  /** Conteúdo do brief renderizado pelo prepare. */
17
15
  brief: string;
16
+ /**
17
+ * `false` no revisor de fluxo (`general`): a passada base só caça achados e
18
+ * não gasta atenção preenchendo `checks`/`rules` — o bookkeeping roda numa
19
+ * passada própria (mais barata) depois.
20
+ */
21
+ bookkeeping?: boolean;
22
+ /** Pede o modelo de intenção no JSON (só quando não há modelo da IA). */
23
+ requestBusiness?: boolean;
18
24
  };
19
25
 
20
26
  /** Schema compacto repetido no prompt (evita depender de arquivo externo). */
21
- function outputSchema(watchdogId: string): string {
27
+ function outputSchema(
28
+ watchdogId: string,
29
+ options: { bookkeeping: boolean; business: boolean },
30
+ ): string {
31
+ const bookkeeping = options.bookkeeping
32
+ ? `,
33
+ "rules": [
34
+ {
35
+ "id": 1,
36
+ "verdict": "ok | gap | n/a",
37
+ "at": "arquivo:linha"
38
+ }
39
+ ],
40
+ "checks": [
41
+ {
42
+ "item": "fluxo ou invariante copiado do brief",
43
+ "verdict": "ok | gap | n/a",
44
+ "at": "arquivo:linha ou arquivo · símbolo"
45
+ }
46
+ ]`
47
+ : "";
48
+ const business = options.business
49
+ ? `,
50
+ "businessSummary": {
51
+ "flows": [{ "name": "fluxo", "files": ["caminho"] }],
52
+ "invariants": ["invariante"],
53
+ "contracts": ["contrato"],
54
+ "states": ["estado"],
55
+ "source": "reviewer"
56
+ }`
57
+ : "";
22
58
  return `{
23
59
  "watchdogId": "${watchdogId}",
24
60
  "reviewedFiles": ["caminho/relativo.ts"],
25
- "skipped": ["arquivo não lido + motivo"],
61
+ "skipped": ["arquivo não lido + motivo"]${bookkeeping},
26
62
  "findings": [
27
63
  {
28
64
  "rule": "regra do brief",
@@ -34,15 +70,57 @@ function outputSchema(watchdogId: string): string {
34
70
  "evidence": "trecho que comprova",
35
71
  "scenario": "gatilho concreto que expõe o gap (fora de regra)",
36
72
  "impact": "consequência observável",
73
+ "impactClass": "flow-break | contract-drift | degradation | cosmetic",
37
74
  "confidence": 0.8,
38
75
  "basis": "rule | logic | domain"
39
76
  }
40
- ]
77
+ ]${business}
41
78
  }`;
42
79
  }
43
80
 
81
+ /** Protocolo de revisão do harness (independe do domínio do watchdog). */
82
+ const PROTOCOL: readonly string[] = [
83
+ "1. **INTENÇÃO** — liste os fluxos e invariantes do *Modelo de intenção* do brief. Para cada um, localize o trecho que o implementa (ou a ausência dele).",
84
+ "2. **ESCOPO** — só é achado a `line` dentro das faixas do *Escopo do diff*. Abra primeiro os arquivos com `… [trecho truncado]`: o restante não está no brief.",
85
+ "3. **RASTREIO** — para cada símbolo alterado, siga chamadores, adapters, enums e tipos compartilhados (seção *Estrutura (AST)*/importadores) e confira os dois lados do contrato.",
86
+ "4. **PROVA** — gatilho → comportamento observado → invariante/regra violada. Sem prova no brief ou no orçamento de exploração, descarte.",
87
+ "5. **AUTOVERIFICAÇÃO** — antes de responder, reconfira cada finding: linha no escopo, `evidence` como trecho literal, `severity` compatível com o `impact`.",
88
+ "6. **COBERTURA** — `reviewedFiles` ∪ `skipped` cobre todo *Arquivos do seu domínio*; não declare arquivo que você não abriu.",
89
+ ];
90
+
91
+ /** Calibração do bookkeeping (checks/rules) — só quando o prompt o pede. */
92
+ const CALIBRATION: readonly string[] = [
93
+ "Cada fluxo ou invariante do brief deve aparecer em `checks` com o texto copiado do brief e um veredito: `ok` (implementado), `gap` (ausente/divergente) ou `n/a` (não se aplica ao domínio).",
94
+ "`rules` cobre **cada regra numerada** de `## Regras (por severidade)` — um item `{ id, verdict }` por número, sem repetir o texto. `gap` numa regra exige o finding correspondente.",
95
+ "`ok` exige `at` com `arquivo:linha` real (onde o item é implementado); sem localização comprovada, use `n/a`.",
96
+ "`gap` sem finding correspondente é inconsistência: ou inclua o finding, ou corrija o veredito.",
97
+ "O checklist cobre a **intenção**; ele não substitui as regras do brief — percorra cada regra nas linhas do escopo e reporte a violação.",
98
+ "`rule` nunca fica vazio: finding de regra copia o texto da regra do brief (sem o número); emergente usa `Lógica: <descrição>` ou `Domínio: <descrição>` (com `impactClass`).",
99
+ "Calibre pelo impacto comprovado: dinheiro/credencial roteada para outra conta, cobrança perdida ou corrupção de estado, com `scenario` + `evidence` e linha no escopo, é `blocking`. Não rebaixe para `attention` só por incerteza — nesse caso reduza `confidence`.",
100
+ "Não pare no primeiro achado de um arquivo: fluxos de pagamento têm vários lados (criação, renovação, cancelamento, webhook, erro).",
101
+ ];
102
+
103
+ /**
104
+ * Calibração do revisor de fluxo quando ele **não** faz bookkeeping.
105
+ *
106
+ * Concentra o que o modelo headless mais erra: parar no caminho feliz, tratar
107
+ * cada arquivo isoladamente e aceitar divergência entre caminhos parecidos.
108
+ */
109
+ const FLOW_CALIBRATION: readonly string[] = [
110
+ "Seu produto é **achado**, não checklist: `checks`/`rules` são preenchidos em uma passada posterior. Não gaste a resposta listando vereditos.",
111
+ "Para cada fluxo do *Modelo de intenção*: onde começa, onde termina, quem chama, que estado muda e quem lê esse estado depois. A ausência do trecho é achado tanto quanto o trecho errado.",
112
+ "Percorra os **caminhos irmãos** do fluxo e compare-os entre si: criação × renovação × cancelamento × webhook × retry/erro. Divergência de payload, credencial, split, id ou status entre irmãos é o achado mais comum e o mais caro.",
113
+ "Siga o **contrato até o consumidor**: mapa de status, enum, `Record<Enum, X>`, schema de model, tipo compartilhado. Um membro novo de enum sem consumidor atualizado quebra em tempo de compilação ou em runtime.",
114
+ "Procure **estado compartilhado**: campo de instância/módulo que guarda credencial, contexto ou id entre requisições, fallback silencioso para a credencial mestre e reset tardio.",
115
+ "`rule` nunca fica vazio: finding de regra copia o texto da regra do brief (sem o número); emergente usa `Lógica: <descrição>` ou `Domínio: <descrição>` (com `impactClass`).",
116
+ "Calibre pelo impacto comprovado: dinheiro/credencial roteada para outra conta, cobrança perdida ou corrupção de estado, com `scenario` + `evidence` e linha no escopo, é `blocking`. Não rebaixe para `attention` só por incerteza — nesse caso reduza `confidence`.",
117
+ "Não pare no primeiro achado de um arquivo: fluxos de pagamento têm vários lados (criação, renovação, cancelamento, webhook, erro).",
118
+ ];
119
+
44
120
  /** Monta o prompt enviado ao maki para um brief. */
45
121
  export function renderHuntPrompt(input: HuntPromptInput): string {
122
+ const bookkeeping = input.bookkeeping !== false;
123
+ const business = input.requestBusiness === true;
46
124
  return [
47
125
  `Você é o watchdog \`${input.watchdogId}\` do projeto ${input.projectName}.`,
48
126
  "Execute o brief abaixo e devolva os achados como JSON.",
@@ -55,15 +133,24 @@ export function renderHuntPrompt(input: HuntPromptInput): string {
55
133
  "- `watchdogId` deve ser exatamente o id acima; use `findings: []` quando não houver achado.",
56
134
  "- `line` deve estar dentro do escopo do diff descrito no brief.",
57
135
  '- Fora de regra, use `basis: "logic" | "domain"`, com `scenario` + `evidence`; sem os dois a severidade máxima é `attention`.',
58
- ...(input.watchdogId === GENERAL_WATCHDOG_ID
136
+ "- Finding de regra: copie `rule` do brief — a severidade é a declarada na regra. Emergente: declare `impactClass`; a severidade vem daí.",
137
+ ...(business
59
138
  ? [
60
- '- Este é o revisor de fluxo: inclua `businessSummary` (modelo de intenção reconstruído, `source: "reviewer"`) no JSON.',
139
+ '- Inclua `businessSummary` (modelo de intenção reconstruído, `source: "reviewer"`) no JSON.',
61
140
  ]
62
141
  : []),
63
142
  "",
143
+ "## Protocolo de revisão",
144
+ "",
145
+ ...PROTOCOL,
146
+ "",
147
+ "## Calibração",
148
+ "",
149
+ ...(bookkeeping ? CALIBRATION : FLOW_CALIBRATION).map((line) => `- ${line}`),
150
+ "",
64
151
  "### Schema",
65
152
  "",
66
- outputSchema(input.watchdogId),
153
+ outputSchema(input.watchdogId, { bookkeeping, business }),
67
154
  "",
68
155
  "## Brief",
69
156
  "",
@@ -81,3 +168,197 @@ export function renderRetrySuffix(error: string): string {
81
168
  "Responda APENAS com o objeto JSON completo, no schema acima, sem markdown.",
82
169
  ].join("\n");
83
170
  }
171
+
172
+ /**
173
+ * Correção em sessão retomada: o agente já leu o brief e explorou o código.
174
+ *
175
+ * Repetir o brief inteiro num processo novo custava uma nova exploração
176
+ * completa (~30 turnos) só porque a resposta final não era JSON parseável.
177
+ * Aqui só pedimos a saída, com o schema compacto.
178
+ */
179
+ export function renderResumeSuffix(error: string, watchdogId: string): string {
180
+ return [
181
+ "",
182
+ "## Correção obrigatória (sessão retomada)",
183
+ "",
184
+ `A resposta anterior não pôde ser lida: ${error}`,
185
+ "Não leia mais arquivos nem rode buscas. Responda apenas com o objeto JSON",
186
+ "completo no schema abaixo — sem markdown, sem texto antes ou depois.",
187
+ "",
188
+ outputSchema(watchdogId, { bookkeeping: true, business: false }),
189
+ ].join("\n");
190
+ }
191
+
192
+ /**
193
+ * Sufixo da passada de checklist: regras e itens do modelo de intenção que a
194
+ * primeira resposta não verificou.
195
+ */
196
+ export function renderChecklistSuffix(
197
+ missing: readonly string[],
198
+ missingRules: readonly number[] = [],
199
+ ): string {
200
+ return [
201
+ "",
202
+ "## Verificação obrigatória (checklist)",
203
+ "",
204
+ "A resposta anterior não verificou os itens abaixo. Para **cada um**: localize",
205
+ "o trecho no código (ou a ausência), registre o veredito (`ok`/`gap`/`n/a`) e,",
206
+ "quando `gap`, inclua o finding correspondente.",
207
+ "",
208
+ ...(missingRules.length > 0
209
+ ? [
210
+ `Regras não verificadas (ids de \`## Regras\`): ${missingRules.join(", ")}.`,
211
+ "",
212
+ ]
213
+ : []),
214
+ ...(missing.length > 0
215
+ ? [
216
+ "Fluxos/invariantes (texto copiado do brief):",
217
+ "",
218
+ ...missing.map((item) => `- ${item}`),
219
+ "",
220
+ ]
221
+ : []),
222
+ "Devolva o relatório **completo**: todos os findings já encontrados + os novos,",
223
+ "`rules`/`checks` completos e `reviewedFiles`/`skipped` atualizados.",
224
+ ].join("\n");
225
+ }
226
+
227
+ /**
228
+ * Cinco classes que o modelo headless mais deixa passar.
229
+ *
230
+ * A ordem é a da taxa de acerto observada: credencial/estado compartilhado
231
+ * (o caso clássico de `sellerApiKey` de instância) é o que mais escapava.
232
+ */
233
+ export const ADVERSARIAL_CLASSES: readonly string[] = [
234
+ "1. **Credencial e contexto compartilhado** — estado mutável de instância/módulo usado entre requisições (apiKey, token, seller, subconta), fallback silencioso para a credencial mestre, reset tardio.",
235
+ "2. **Capacidade anunciada** — feature/método/evento declarado que algum lado não honra (feature herdada, paridade criação/renovação/cancelamento, webhook).",
236
+ "3. **Identificador persistido** — o id gravado corresponde à entidade que o contrato espera (authorizationId × paymentId, externalId, endToEnd, externalReference).",
237
+ "4. **Idempotência e transição de estado** — evento repetido/atrasado, transição sem validar o estado atual, efeito colateral duplicado.",
238
+ "5. **Contrato entre lados** — mapper/schema/model divergindo do consumidor (validação, status, campos, unidade).",
239
+ ];
240
+
241
+ /** Achado já encontrado, resumido para a passada adversarial. */
242
+ export type AdversarialCandidate = {
243
+ rule: string;
244
+ severity: string;
245
+ file: string;
246
+ line?: number;
247
+ message: string;
248
+ };
249
+
250
+ /** Dados da segunda opinião em sessão nova. */
251
+ export type AdversarialPromptInput = {
252
+ projectName: string;
253
+ watchdogId: string;
254
+ brief: string;
255
+ /** Achados da primeira passada: contexto para não duplicar, não para ancorar. */
256
+ candidates: readonly AdversarialCandidate[];
257
+ };
258
+
259
+ /**
260
+ * Segunda opinião em **sessão nova**.
261
+ *
262
+ * Reexecutar a passada na mesma sessão, depois do checklist, ancora o modelo nas
263
+ * próprias conclusões (na prática devolvia zero achados novos). Aqui o revisor
264
+ * vê o brief de novo, recebe os achados apenas como lista de "já cobertos" e é
265
+ * cobrado a atacar as cinco classes com prova.
266
+ */
267
+ export function renderAdversarialPrompt(input: AdversarialPromptInput): string {
268
+ const covered =
269
+ input.candidates.length === 0
270
+ ? ["Nenhum achado na primeira passada — o brief está **inteiro** em aberto."]
271
+ : [
272
+ "Já cobertos (NÃO repita; use como pista do que o revisor anterior olhou):",
273
+ "",
274
+ ...input.candidates.map((candidate) => {
275
+ const line =
276
+ candidate.line !== undefined ? `:${candidate.line}` : "";
277
+ return `- ${candidate.severity} · \`${candidate.file}${line}\` — ${candidate.rule} — ${candidate.message.slice(0, 200)}`;
278
+ }),
279
+ ];
280
+ return [
281
+ `Você é o revisor adversarial do fluxo do projeto ${input.projectName} (watchdog \`${input.watchdogId}\`).`,
282
+ "Esta é uma **sessão nova**: ninguém aqui revisou estes arquivos ainda. O objetivo é achar o que a primeira passada não achou.",
283
+ "",
284
+ "## Contrato do harness local",
285
+ "",
286
+ "- Use apenas leitura e busca; **não escreva arquivos** e não rode comandos.",
287
+ "- Responda **somente** com um objeto JSON válido (sem markdown, sem texto antes/depois).",
288
+ `- \`watchdogId\` deve ser exatamente \`${input.watchdogId}\`.`,
289
+ "- `line` deve estar dentro do escopo do diff descrito no brief.",
290
+ "- Devolva **apenas os achados novos**; não repita os já cobertos.",
291
+ "",
292
+ ...covered,
293
+ "",
294
+ "## Classes a atacar",
295
+ "",
296
+ "Percorra **cada classe** abaixo e, para cada uma, traga o achado com",
297
+ "`arquivo:linha` e prova — ou conclua que não há ocorrência no escopo.",
298
+ "",
299
+ ...ADVERSARIAL_CLASSES,
300
+ "",
301
+ "Regras da passada:",
302
+ "- Sem `scenario` + `evidence` o achado não passa de `attention`.",
303
+ "- Não repita os achados já cobertos acima e não invente para preencher classe.",
304
+ "- Achado de regra copia o texto da regra do brief; fora de regra use",
305
+ " `Lógica:`/`Domínio:` com `impactClass`.",
306
+ "",
307
+ "### Schema",
308
+ "",
309
+ outputSchema(input.watchdogId, { bookkeeping: false, business: false }),
310
+ "",
311
+ "## Brief",
312
+ "",
313
+ input.brief.trim(),
314
+ ].join("\n");
315
+ }
316
+
317
+ /** Dados da passada de bookkeeping (só vereditos). */
318
+ export type BookkeepingPromptInput = {
319
+ projectName: string;
320
+ watchdogId: string;
321
+ brief: string;
322
+ /** Itens do modelo de intenção ainda sem veredito. */
323
+ items: readonly string[];
324
+ /** Regras numeradas do brief ainda sem veredito. */
325
+ ruleIds: readonly number[];
326
+ };
327
+
328
+ /**
329
+ * Passada de bookkeeping do revisor de fluxo (sessão nova e barata).
330
+ *
331
+ * Antes, o `general` tinha que devolver achados **e** o checklist no mesmo JSON
332
+ * de 61 KB — na prática ele entregava 2 achados e 10/10 checks "ok" sem lastro.
333
+ * Separar os papéis deixa a passada forte focada em achar e esta em provar.
334
+ */
335
+ export function renderBookkeepingPrompt(input: BookkeepingPromptInput): string {
336
+ return [
337
+ `Você é o verificador de checklist do projeto ${input.projectName} (watchdog \`${input.watchdogId}\`).`,
338
+ "Sua única tarefa é **verificar** os itens abaixo no código e devolver os vereditos.",
339
+ "",
340
+ "## Contrato",
341
+ "",
342
+ "- Use apenas leitura e busca; não escreva arquivos e não rode comandos.",
343
+ "- Responda **somente** com um objeto JSON válido (sem markdown).",
344
+ "- `findings` deve ser sempre `[]`: esta passada não caça achados.",
345
+ "- Para cada item, `at` é obrigatório quando o veredito é `ok` (`arquivo:linha` real do código); use `n/a` quando o item não se aplica ao domínio.",
346
+ "- `gap` exige `at` apontando onde o item deveria estar (ou o símbolo ausente).",
347
+ "",
348
+ "## Itens de intenção a verificar",
349
+ "",
350
+ ...input.items.map((item) => `- ${item}`),
351
+ "",
352
+ "## Regras numeradas a verificar",
353
+ "",
354
+ ...(input.ruleIds.length > 0 ? [input.ruleIds.join(", ")] : ["(nenhuma)"]),
355
+ "",
356
+ "### Schema",
357
+ "",
358
+ outputSchema(input.watchdogId, { bookkeeping: true, business: false }),
359
+ "",
360
+ "## Brief",
361
+ "",
362
+ input.brief.trim(),
363
+ ].join("\n");
364
+ }
@@ -11,6 +11,20 @@
11
11
  export const MAKI_DISALLOWED_TOOLS =
12
12
  "bash,webfetch,websearch,write,edit,multiedit,edit_lines,insert_lines,task";
13
13
 
14
+ /**
15
+ * Complemento ao system prompt do maki.
16
+ *
17
+ * O prompt nativo pede para "minimizar tokens e tool calls"; numa revisão isso
18
+ * produz leitura rasa. Aqui a prioridade é invertida: profundidade e prova
19
+ * vencem brevidade.
20
+ */
21
+ export const MAKI_REVIEW_SYSTEM_PROMPT = [
22
+ "Esta sessão é uma revisão de código de um watchdog do houndsman.",
23
+ "Profundidade vence brevidade: leia os arquivos necessários, siga chamadores",
24
+ "e confirme cada achado com evidência; não economize chamadas de ferramenta.",
25
+ "A resposta final é sempre o objeto JSON do contrato, sem texto fora dele.",
26
+ ].join(" ");
27
+
14
28
  /** Pedido de execução de um watchdog. */
15
29
  export type MakiRunRequest = {
16
30
  /** Prompt completo (brief + contrato). */
@@ -23,6 +37,8 @@ export type MakiRunRequest = {
23
37
  apiKeyEnv: string;
24
38
  /** Valor da chave (injetado só no processo filho). */
25
39
  apiKey: string;
40
+ /** Sessão a retomar (retry compacto depois de saída inválida). */
41
+ resume?: string;
26
42
  /** Timeout do processo (ms). */
27
43
  timeoutMs: number;
28
44
  };
@@ -87,7 +103,7 @@ export type MakiJsonOutput = {
87
103
  };
88
104
 
89
105
  /** Argumentos do processo (o prompt vai por stdin). */
90
- export function makiArgs(model: string): string[] {
106
+ export function makiArgs(model: string, resume?: string): string[] {
91
107
  return [
92
108
  "-p",
93
109
  "--output-format",
@@ -96,6 +112,9 @@ export function makiArgs(model: string): string[] {
96
112
  model,
97
113
  "--disallowed-tools",
98
114
  MAKI_DISALLOWED_TOOLS,
115
+ "--append-system-prompt",
116
+ MAKI_REVIEW_SYSTEM_PROMPT,
117
+ ...(resume ? ["--resume", resume] : []),
99
118
  ];
100
119
  }
101
120
 
@@ -111,13 +130,16 @@ export class SpawnMakiRunner implements MakiProcessRunner {
111
130
  const started = Date.now();
112
131
  let timedOut = false;
113
132
  try {
114
- const proc = Bun.spawn([this.binary, ...makiArgs(request.model)], {
115
- cwd: request.cwd,
116
- env: { ...process.env, [request.apiKeyEnv]: request.apiKey },
117
- stdin: "pipe",
118
- stdout: "pipe",
119
- stderr: "pipe",
120
- });
133
+ const proc = Bun.spawn(
134
+ [this.binary, ...makiArgs(request.model, request.resume)],
135
+ {
136
+ cwd: request.cwd,
137
+ env: { ...process.env, [request.apiKeyEnv]: request.apiKey },
138
+ stdin: "pipe",
139
+ stdout: "pipe",
140
+ stderr: "pipe",
141
+ },
142
+ );
121
143
 
122
144
  try {
123
145
  proc.stdin.write(request.prompt);
@@ -201,6 +223,18 @@ export function parseMakiOutput(stdout: string): MakiJsonOutput | null {
201
223
  const whole = tryParseJson(stdout.trim());
202
224
  if (whole.ok && isObject(whole.value)) return whole.value as MakiJsonOutput;
203
225
 
226
+ // Objeto indentado precedido por log: o scan por linha não enxerga o
227
+ // objeto inteiro, então usamos os objetos balanceados do texto.
228
+ const objects = balancedObjects(stdout);
229
+ for (let index = objects.length - 1; index >= 0; index--) {
230
+ const parsed = tryParseJson(objects[index]);
231
+ if (!parsed.ok || !isObject(parsed.value)) continue;
232
+ const value = parsed.value as Record<string, unknown>;
233
+ if (typeof value.type === "string" || typeof value.result === "string") {
234
+ return value as MakiJsonOutput;
235
+ }
236
+ }
237
+
204
238
  const lines = stdout.split(/\r?\n/);
205
239
  for (let index = lines.length - 1; index >= 0; index--) {
206
240
  const line = lines[index].trim();
@@ -213,7 +247,11 @@ export function parseMakiOutput(stdout: string): MakiJsonOutput | null {
213
247
 
214
248
  /**
215
249
  * Extrai o JSON dos findings da resposta final do agente.
216
- * Aceita JSON puro, bloco markdown ou objeto cercado por texto.
250
+ *
251
+ * No `--output-format json` o maki devolve em `result` **todo** o texto do
252
+ * agente (narração + blocos intermediários + resposta final), não só a última
253
+ * mensagem. Por isso a busca é do fim para o começo: o relatório válido é o
254
+ * último objeto, não o primeiro `{` do texto.
217
255
  */
218
256
  export function extractJsonObject(text: string): unknown | null {
219
257
  const trimmed = text.trim();
@@ -222,21 +260,60 @@ export function extractJsonObject(text: string): unknown | null {
222
260
  const direct = tryParseJson(trimmed);
223
261
  if (direct.ok) return direct.value;
224
262
 
225
- const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
226
- if (fenced) {
227
- const parsed = tryParseJson(fenced[1].trim());
263
+ const fences = [...trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)];
264
+ for (let index = fences.length - 1; index >= 0; index--) {
265
+ const parsed = tryParseJson(fences[index][1].trim());
228
266
  if (parsed.ok) return parsed.value;
229
267
  }
230
268
 
231
- const start = trimmed.indexOf("{");
232
- const end = trimmed.lastIndexOf("}");
233
- if (start >= 0 && end > start) {
234
- const parsed = tryParseJson(trimmed.slice(start, end + 1));
269
+ const objects = balancedObjects(trimmed);
270
+ for (let index = objects.length - 1; index >= 0; index--) {
271
+ const parsed = tryParseJson(objects[index]);
235
272
  if (parsed.ok) return parsed.value;
236
273
  }
237
274
  return null;
238
275
  }
239
276
 
277
+ /**
278
+ * Objetos `{…}` de nível superior no texto (ignora chaves dentro de strings).
279
+ *
280
+ * Sem isso, o fallback "primeiro `{` até último `}`" atravessava várias
281
+ * mensagens concatenadas e produzia um JSON inválido.
282
+ */
283
+ function balancedObjects(text: string): string[] {
284
+ const objects: string[] = [];
285
+ let depth = 0;
286
+ let start = -1;
287
+ let inString = false;
288
+ let escaped = false;
289
+ for (let index = 0; index < text.length; index++) {
290
+ const char = text[index];
291
+ if (inString) {
292
+ if (escaped) escaped = false;
293
+ else if (char === "\\") escaped = true;
294
+ else if (char === '"') inString = false;
295
+ continue;
296
+ }
297
+ if (char === '"') {
298
+ inString = true;
299
+ continue;
300
+ }
301
+ if (char === "{") {
302
+ if (depth === 0) start = index;
303
+ depth++;
304
+ continue;
305
+ }
306
+ if (char === "}" && depth > 0) {
307
+ depth--;
308
+ if (depth === 0 && start >= 0) {
309
+ objects.push(text.slice(start, index + 1));
310
+ start = -1;
311
+ }
312
+ }
313
+ }
314
+ return objects;
315
+ }
316
+
240
317
  /** Normaliza as métricas do JSON do maki. */
241
318
  export function metricsFrom(
242
319
  output: MakiJsonOutput | null,