@payconductor/houndsman 0.13.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.
Files changed (116) hide show
  1. package/README.md +6 -1
  2. package/package.json +1 -1
  3. package/src/cli/commands/compare-command.ts +112 -0
  4. package/src/cli/commands/context-command.ts +1 -0
  5. package/src/cli/commands/findings-command.ts +11 -1
  6. package/src/cli/commands/golden-command.ts +92 -0
  7. package/src/cli/commands/hunt-command.ts +105 -4
  8. package/src/cli/commands/review-command.ts +38 -0
  9. package/src/cli/commands/status-command.ts +3 -2
  10. package/src/cli/help.ts +6 -1
  11. package/src/cli/index.ts +5 -1
  12. package/src/cli/progress.ts +7 -2
  13. package/src/core/ai/ai-client.ts +38 -2
  14. package/src/core/ai/features/brief-enricher.ts +1 -1
  15. package/src/core/ai/features/business-analyzer.ts +61 -0
  16. package/src/core/ai/features/business-summarizer.ts +44 -0
  17. package/src/core/ai/index.ts +8 -0
  18. package/src/core/ai/prompts/brief.ts +33 -7
  19. package/src/core/ai/prompts/business.ts +76 -0
  20. package/src/core/ai/provider-capabilities.ts +12 -0
  21. package/src/core/ai/schemas.ts +70 -2
  22. package/src/core/ai/usage-recorder.ts +53 -1
  23. package/src/core/brief.ts +278 -16
  24. package/src/core/builtins.ts +108 -0
  25. package/src/core/business-summary.ts +583 -0
  26. package/src/core/commands/compare.ts +266 -0
  27. package/src/core/commands/context.ts +6 -2
  28. package/src/core/commands/findings.ts +38 -30
  29. package/src/core/commands/golden.ts +176 -0
  30. package/src/core/commands/review.ts +6 -0
  31. package/src/core/commands/scaffold.ts +29 -0
  32. package/src/core/commands/status.ts +9 -4
  33. package/src/core/compare.ts +310 -0
  34. package/src/core/concurrency.ts +30 -0
  35. package/src/core/config.ts +41 -3
  36. package/src/core/defaults.ts +50 -4
  37. package/src/core/domain/budget.ts +62 -4
  38. package/src/core/domain/classification.ts +12 -2
  39. package/src/core/engine/carryover.ts +4 -0
  40. package/src/core/engine/finalize-pipeline.ts +371 -0
  41. package/src/core/engine/prepare-pipeline.ts +1294 -0
  42. package/src/core/engine/review-context.ts +51 -0
  43. package/src/core/engine/review-options.ts +47 -0
  44. package/src/core/engine/review-scope.ts +14 -1
  45. package/src/core/engine/review-session.ts +11 -13
  46. package/src/core/engine/run-artifacts.ts +196 -0
  47. package/src/core/engine/run-format.ts +4 -0
  48. package/src/core/findings-state.ts +22 -2
  49. package/src/core/github-review.ts +5 -2
  50. package/src/core/golden.ts +346 -0
  51. package/src/core/hunt/hunt-runner.ts +621 -34
  52. package/src/core/hunt/index.ts +40 -1
  53. package/src/core/hunt/maki-intent-prompt.ts +50 -0
  54. package/src/core/hunt/maki-prompt.ts +298 -5
  55. package/src/core/hunt/maki-runner.ts +93 -16
  56. package/src/core/hunt/pool.ts +4 -26
  57. package/src/core/hunt/review-checks.ts +460 -0
  58. package/src/core/logging/index.ts +6 -1
  59. package/src/core/logging/progress.ts +85 -1
  60. package/src/core/orchestrator.ts +33 -1289
  61. package/src/core/paths.ts +6 -3
  62. package/src/core/pr-context.ts +2 -4
  63. package/src/core/prepare-report.ts +61 -0
  64. package/src/core/project-config.ts +18 -1
  65. package/src/core/propagation.ts +127 -0
  66. package/src/core/registry-check.ts +90 -27
  67. package/src/core/renderers/manifest-writer.ts +13 -0
  68. package/src/core/renderers/resume-renderer.ts +151 -11
  69. package/src/core/report-builder.ts +116 -278
  70. package/src/core/severity.ts +245 -0
  71. package/src/core/storage/file/file-storage.ts +7 -3
  72. package/src/core/storage/sqlite/mappers.ts +10 -0
  73. package/src/core/storage/sqlite/schema.ts +104 -23
  74. package/src/core/storage/sqlite/sqlite-storage.ts +22 -9
  75. package/src/core/storage/types.ts +17 -4
  76. package/src/core/storage/work-area.ts +59 -1
  77. package/src/core/types.ts +31 -0
  78. package/src/core/watchdog-registry.ts +21 -3
  79. package/src/index.ts +51 -4
  80. package/src/server/client/ArtifactsPanel.tsx +4 -2
  81. package/src/server/client/FindingCard.tsx +21 -1
  82. package/src/server/client/RunsSection.tsx +5 -0
  83. package/src/server/client/utils/constants.ts +21 -11
  84. package/src/server/client/utils/markdown.ts +1 -1
  85. package/src/server/client/utils/types.ts +14 -2
  86. package/src/server/run-repository.ts +35 -14
  87. package/src/server/styles.css +2 -0
  88. package/src/skill/OUTPUT-FORMAT.md +55 -10
  89. package/src/skill/README.md +19 -14
  90. package/src/skill/SETUP.md +1 -0
  91. package/src/skill/SKILL.md +13 -4
  92. package/src/skill/example-resume.md +43 -0
  93. package/src/skill/hunt.md +205 -58
  94. package/src/skill/train.md +3 -0
  95. package/src/tests/__snapshots__/baseline.test.ts.snap +208 -184
  96. package/src/tests/ai-core.test.ts +46 -0
  97. package/src/tests/ai-features.test.ts +95 -4
  98. package/src/tests/baseline.test.ts +29 -121
  99. package/src/tests/brief-scope.test.ts +250 -0
  100. package/src/tests/business-summary.test.ts +258 -0
  101. package/src/tests/compare.test.ts +256 -0
  102. package/src/tests/findings-state.test.ts +82 -0
  103. package/src/tests/fixtures/fake-model-provider.ts +2 -1
  104. package/src/tests/golden.test.ts +195 -0
  105. package/src/tests/hunt-maki.test.ts +710 -2
  106. package/src/tests/markdown.test.ts +3 -3
  107. package/src/tests/progress.test.ts +29 -1
  108. package/src/tests/propagation.test.ts +119 -0
  109. package/src/tests/review-resume.test.ts +60 -1
  110. package/src/tests/review-scope.test.ts +38 -29
  111. package/src/tests/review-scripts.test.ts +125 -218
  112. package/src/tests/server.test.ts +7 -4
  113. package/src/tests/severity.test.ts +235 -0
  114. package/src/tests/storage.test.ts +194 -9
  115. package/src/core/renderers/report-renderer.ts +0 -66
  116. package/src/skill/example-review.md +0 -66
@@ -8,10 +8,21 @@
8
8
  */
9
9
 
10
10
  import { readFileSync } from "node:fs";
11
- import { parseWatchdogReport } from "../report-builder";
11
+ import { join } from "node:path";
12
+ import { GENERAL_WATCHDOG_ID } from "../builtins";
13
+ import { type BusinessSummary, parseBusinessSummary } from "../business-summary";
14
+ import { parseWatchdogReport, type WatchdogReport } from "../report-builder";
12
15
  import { systemClock } from "../services/clock";
13
16
  import type { ReviewWorkArea } from "../storage/work-area";
14
- import { renderHuntPrompt, renderRetrySuffix } from "./maki-prompt";
17
+ import { renderHuntIntentPrompt, renderIntentRetrySuffix } from "./maki-intent-prompt";
18
+ import {
19
+ renderAdversarialPrompt,
20
+ renderBookkeepingPrompt,
21
+ renderChecklistSuffix,
22
+ renderHuntPrompt,
23
+ renderResumeSuffix,
24
+ renderRetrySuffix,
25
+ } from "./maki-prompt";
15
26
  import {
16
27
  extractJsonObject,
17
28
  type MakiProcessRunner,
@@ -19,9 +30,51 @@ import {
19
30
  type MakiUsage,
20
31
  } from "./maki-runner";
21
32
  import { mapPool } from "./pool";
22
-
33
+ import {
34
+ type AtResolver,
35
+ briefDomainFiles,
36
+ briefRules,
37
+ businessScore,
38
+ type CheckCoverage,
39
+ chooseBusinessSummary,
40
+ expectedReviewItems,
41
+ mergeChecks,
42
+ mergeFindings,
43
+ mergeRuleChecks,
44
+ missingReviewItems,
45
+ missingRuleChecks,
46
+ normalizePath,
47
+ parseReviewChecks,
48
+ parseRuleChecks,
49
+ type ReviewCheck,
50
+ type RuleCheck,
51
+ summarizeChecks,
52
+ summarizeRuleChecks,
53
+ withInferredRule,
54
+ } from "./review-checks";
23
55
  /** Evento de progresso (CLI loga; core não escreve saída). */
24
56
  export type HuntEvent =
57
+ | {
58
+ type: "intent";
59
+ status: "start" | "done" | "skipped" | "failed";
60
+ source?: BusinessSummary["source"];
61
+ error?: string;
62
+ }
63
+ | {
64
+ type: "checklist";
65
+ watchdogId: string;
66
+ missing: number;
67
+ missingRules: number;
68
+ status: "start" | "done" | "failed";
69
+ error?: string;
70
+ }
71
+ | {
72
+ type: "second-opinion";
73
+ watchdogId: string;
74
+ status: "start" | "done" | "failed";
75
+ findings?: number;
76
+ error?: string;
77
+ }
25
78
  | { type: "start"; watchdogId: string }
26
79
  | {
27
80
  type: "retry";
@@ -59,6 +112,39 @@ export type HuntWatchdogResult = {
59
112
  costUsd?: number;
60
113
  usage?: MakiUsage;
61
114
  error?: string;
115
+ /** Cobertura do checklist de intenção (fluxos + invariantes). */
116
+ checks?: CheckCoverage;
117
+ /** Cobertura das regras declaradas no brief (numeradas). */
118
+ rules?: CheckCoverage;
119
+ /** Passada de checklist (custo marginal explícito). */
120
+ checklist?: {
121
+ status: "done" | "failed";
122
+ missing: number;
123
+ missingRules: number;
124
+ costUsd: number;
125
+ numTurns: number;
126
+ };
127
+ /** Resultado da passada adversarial do `general` (com custo marginal). */
128
+ secondOpinion?: {
129
+ status: "done" | "failed";
130
+ findings: number;
131
+ costUsd: number;
132
+ numTurns: number;
133
+ error?: string;
134
+ };
135
+ /** Cobertura declarada dos arquivos do domínio (`reviewedFiles` ∪ `skipped`). */
136
+ coverage?: {
137
+ total: number;
138
+ reviewed: number;
139
+ skipped: number;
140
+ undeclared: number;
141
+ };
142
+ /** Decisão sobre o modelo de intenção devolvido pelo `general`. */
143
+ business?: {
144
+ accepted: boolean;
145
+ source: BusinessSummary["source"];
146
+ score: number;
147
+ };
62
148
  };
63
149
 
64
150
  /** Agregados do relatório. */
@@ -78,6 +164,10 @@ export type HuntReport = {
78
164
  version: 1;
79
165
  engine: "maki";
80
166
  model: string;
167
+ /** Fonte do modelo de intenção usado na revisão. */
168
+ business?: BusinessSummary["source"];
169
+ /** Pontuação do modelo de intenção efetivo (fluxos/invariantes/contratos). */
170
+ businessScore?: number;
81
171
  startedAt: string;
82
172
  finishedAt: string;
83
173
  durationMs: number;
@@ -99,6 +189,16 @@ export type HuntRunnerOptions = {
99
189
  concurrency: number;
100
190
  timeoutMs: number;
101
191
  retries: number;
192
+ /**
193
+ * Passada extra por watchdog quando o checklist de intenção não foi coberto
194
+ * (default: ligada). Desligue com `hunt.checklist: false`.
195
+ */
196
+ checklist?: boolean;
197
+ /**
198
+ * Passada adversarial do `general` (5 classes: credencial/estado, capacidade,
199
+ * identificador, idempotência, contrato). Default: ligada.
200
+ */
201
+ secondOpinion?: boolean;
102
202
  runner: MakiProcessRunner;
103
203
  onEvent?: (event: HuntEvent) => void;
104
204
  };
@@ -114,14 +214,23 @@ export class HuntRunner {
114
214
  async run(activeIds: readonly string[]): Promise<HuntReport> {
115
215
  const startedAt = systemClock();
116
216
  const startedMs = Date.now();
217
+ // Intenção primeiro: o revisor `general` e os demais watchdogs leem o
218
+ // mesmo `business-summary.json` do staging.
219
+ const businessSource = await this.runIntent();
220
+ // Snapshot único: o `general` pode devolver outro modelo no meio da onda;
221
+ // os demais watchdogs precisam do mesmo contrato do brief que receberam.
222
+ const businessModel = this.options.work.readBusinessSummary();
117
223
  const watchdogs = await mapPool(activeIds, this.options.concurrency, (id) =>
118
- this.runOne(id),
224
+ this.runOne(id, businessModel),
119
225
  );
120
226
  const finishedAt = systemClock();
227
+ const effective = this.options.work.readBusinessSummary();
121
228
  return {
122
229
  version: 1,
123
230
  engine: "maki",
124
231
  model: this.options.model,
232
+ business: effective?.source ?? businessSource,
233
+ businessScore: effective ? businessScore(effective) : undefined,
125
234
  startedAt,
126
235
  finishedAt,
127
236
  durationMs: Date.now() - startedMs,
@@ -130,7 +239,91 @@ export class HuntRunner {
130
239
  };
131
240
  }
132
241
 
133
- private async runOne(watchdogId: string): Promise<HuntWatchdogResult> {
242
+ /**
243
+ * Pré-passe de intenção (IA embutida). Pula quando o prepare já gerou o
244
+ * modelo com IA; falha mantém o determinístico (fail-open).
245
+ */
246
+ private async runIntent(): Promise<BusinessSummary["source"] | undefined> {
247
+ const existing = this.options.work.readBusinessSummary();
248
+ if (existing?.source === "ai") {
249
+ this.emit({ type: "intent", status: "skipped", source: "ai" });
250
+ return "ai";
251
+ }
252
+
253
+ let brief: string;
254
+ try {
255
+ brief = readFileSync(this.options.work.businessPaths.brief, "utf8");
256
+ } catch {
257
+ return existing?.source;
258
+ }
259
+
260
+ this.emit({ type: "intent", status: "start" });
261
+ const basePrompt = renderHuntIntentPrompt({
262
+ projectName: this.options.projectName,
263
+ brief,
264
+ });
265
+ let lastError = "";
266
+ for (let attempt = 0; attempt <= this.options.retries; attempt++) {
267
+ const prompt =
268
+ attempt === 0
269
+ ? basePrompt
270
+ : `${basePrompt}\n${renderIntentRetrySuffix(lastError)}`;
271
+ const result = await this.options.runner.run({
272
+ prompt,
273
+ cwd: this.options.root,
274
+ model: this.options.model,
275
+ apiKeyEnv: this.options.apiKeyEnv,
276
+ apiKey: this.options.apiKey,
277
+ timeoutMs: this.options.timeoutMs,
278
+ });
279
+ if (!result.ok) {
280
+ lastError = result.error;
281
+ continue;
282
+ }
283
+ const raw = extractJsonObject(result.text);
284
+ if (raw === null) {
285
+ lastError = "a resposta final não contém um objeto JSON.";
286
+ continue;
287
+ }
288
+ const summary = parseBusinessSummary(
289
+ { ...raw, source: "reviewer" },
290
+ "reviewer",
291
+ );
292
+ if (!summary) {
293
+ lastError = "JSON fora do schema do modelo de intenção.";
294
+ continue;
295
+ }
296
+ // Modelo sem fluxo nem invariante não sustenta a revisão por
297
+ // invariantes: repete em vez de degradar os briefs.
298
+ if (
299
+ summary.flows.length === 0 &&
300
+ summary.invariants.length + summary.contracts.length < 2
301
+ ) {
302
+ lastError =
303
+ "modelo de intenção sem fluxos e com menos de 2 invariantes/contratos.";
304
+ continue;
305
+ }
306
+ const decision = chooseBusinessSummary(
307
+ this.options.work.readBusinessSummary(),
308
+ summary,
309
+ );
310
+ if (!decision.accepted || !decision.summary) {
311
+ lastError = "modelo de intenção inferior ao já disponível.";
312
+ continue;
313
+ }
314
+ this.options.work.writeBusinessSummary(decision.summary);
315
+ this.emit({ type: "intent", status: "done", source: "reviewer" });
316
+ return "reviewer";
317
+ }
318
+
319
+ this.emit({ type: "intent", status: "failed", error: lastError });
320
+ return existing?.source ?? "deterministic";
321
+ }
322
+
323
+ private async runOne(
324
+ watchdogId: string,
325
+ business: BusinessSummary | null,
326
+ ): Promise<HuntWatchdogResult> {
134
327
  const briefPath = this.options.work.briefFile(watchdogId);
135
328
  let brief: string;
136
329
  try {
@@ -147,35 +340,305 @@ export class HuntRunner {
147
340
  return failedResult(watchdogId, error, 0, { durationMs: 0 });
148
341
  }
149
342
 
343
+ // Revisor de fluxo: a passada base NÃO faz bookkeeping. Ele carrega 37
344
+ // arquivos e um brief de ~60 KB; competir com `checks`/`rules` no mesmo JSON
345
+ // é o que fazia o `general` devolver 2 achados e 10/10 checks sem lastro.
346
+ const isGeneral = watchdogId === GENERAL_WATCHDOG_ID;
347
+ const requestBusiness = isGeneral && business?.source !== "ai";
150
348
  const basePrompt = renderHuntPrompt({
151
349
  projectName: this.options.projectName,
152
350
  watchdogId,
153
351
  brief,
352
+ bookkeeping: !isGeneral,
353
+ requestBusiness,
154
354
  });
355
+ // Checklist derivado do brief: cada fluxo/invariante do modelo de intenção
356
+ // e cada regra numerada precisa de uma verificação declarada.
357
+ const expected = expectedReviewItems(brief, business, watchdogId);
358
+ const expectedRules = briefRules(brief);
359
+ // `at` só conta como prova quando aponta para arquivo/linha existentes.
360
+ const resolveAt = makeAtResolver(this.options.root);
155
361
 
156
362
  this.emit({ type: "start", watchdogId });
157
363
  let attempts = 0;
158
- let lastError = "";
159
364
  const accumulated: MakiRunMetrics = { durationMs: 0 };
160
365
 
366
+ const first = await this.executePass(watchdogId, basePrompt);
367
+ mergeMetrics(accumulated, first.metrics);
368
+ attempts += first.attempts;
369
+ if (!first.ok) {
370
+ this.emit({
371
+ type: "failed",
372
+ watchdogId,
373
+ error: first.error,
374
+ attempts,
375
+ metrics: accumulated,
376
+ });
377
+ return failedResult(watchdogId, first.error, attempts, accumulated);
378
+ }
379
+
380
+ let report = first.report;
381
+ let checks = first.checks;
382
+ let ruleChecks = first.ruleChecks;
383
+ let reviewerBusiness = first.reviewerBusiness;
384
+
385
+ let checklist: HuntWatchdogResult["checklist"];
386
+ if (isGeneral && this.options.checklist !== false) {
387
+ // Bookkeeping do revisor de fluxo: sessão nova, tarefa estreita e
388
+ // barata (só vereditos com `at` comprovado).
389
+ const costBefore = accumulated.costUsd ?? 0;
390
+ const turnsBefore = accumulated.numTurns ?? 0;
391
+ this.emit({
392
+ type: "checklist",
393
+ watchdogId,
394
+ missing: expected.length,
395
+ missingRules: expectedRules.length,
396
+ status: "start",
397
+ });
398
+ const verdicts = await this.executePass(
399
+ watchdogId,
400
+ renderBookkeepingPrompt({
401
+ projectName: this.options.projectName,
402
+ watchdogId,
403
+ brief,
404
+ items: expected,
405
+ ruleIds: expectedRules.map((rule) => rule.id),
406
+ }),
407
+ );
408
+ mergeMetrics(accumulated, verdicts.metrics);
409
+ attempts += verdicts.attempts;
410
+ if (verdicts.ok) {
411
+ // Só vereditos: achados desta passada seriam ruído de uma tarefa
412
+ // que não foi pedida (o prompt manda `findings: []`).
413
+ checks = mergeChecks(checks, verdicts.checks);
414
+ ruleChecks = mergeRuleChecks(ruleChecks, verdicts.ruleChecks);
415
+ }
416
+ checklist = {
417
+ status: verdicts.ok ? "done" : "failed",
418
+ missing: missingReviewItems(expected, checks).length,
419
+ missingRules: missingRuleChecks(expectedRules, ruleChecks).length,
420
+ costUsd: (accumulated.costUsd ?? 0) - costBefore,
421
+ numTurns: (accumulated.numTurns ?? 0) - turnsBefore,
422
+ };
423
+ this.emit({
424
+ type: "checklist",
425
+ watchdogId,
426
+ missing: checklist.missing,
427
+ missingRules: checklist.missingRules,
428
+ status: verdicts.ok ? "done" : "failed",
429
+ error: verdicts.ok ? undefined : verdicts.error,
430
+ });
431
+ }
432
+
433
+ const missing = missingReviewItems(expected, checks);
434
+ const missingRules = missingRuleChecks(expectedRules, ruleChecks);
435
+ if (
436
+ !isGeneral &&
437
+ this.options.checklist !== false &&
438
+ (missing.length > 0 || missingRules.length > 0)
439
+ ) {
440
+ const costBefore = accumulated.costUsd ?? 0;
441
+ const turnsBefore = accumulated.numTurns ?? 0;
442
+ this.emit({
443
+ type: "checklist",
444
+ watchdogId,
445
+ missing: missing.length,
446
+ missingRules: missingRules.length,
447
+ status: "start",
448
+ });
449
+ const suffix = renderChecklistSuffix(missing, missingRules);
450
+ // Sessão já tem o brief e as evidências: só pedimos os vereditos.
451
+ const second = accumulated.sessionId
452
+ ? await this.executePass(watchdogId, suffix, {
453
+ resume: accumulated.sessionId,
454
+ })
455
+ : await this.executePass(watchdogId, `${basePrompt}\n${suffix}`);
456
+ mergeMetrics(accumulated, second.metrics);
457
+ attempts += second.attempts;
458
+ if (second.ok) {
459
+ // A segunda passada não pode *perder* achados: une as duas.
460
+ report = mergeReport(report, second.report);
461
+ checks = mergeChecks(checks, second.checks);
462
+ ruleChecks = mergeRuleChecks(ruleChecks, second.ruleChecks);
463
+ reviewerBusiness = richerBusiness(
464
+ reviewerBusiness,
465
+ second.reviewerBusiness,
466
+ );
467
+ this.emit({
468
+ type: "checklist",
469
+ watchdogId,
470
+ missing: missingReviewItems(expected, checks).length,
471
+ missingRules: missingRuleChecks(expectedRules, ruleChecks).length,
472
+ status: "done",
473
+ });
474
+ } else {
475
+ this.emit({
476
+ type: "checklist",
477
+ watchdogId,
478
+ missing: missing.length,
479
+ missingRules: missingRules.length,
480
+ status: "failed",
481
+ error: second.error,
482
+ });
483
+ }
484
+ checklist = {
485
+ status: second.ok ? "done" : "failed",
486
+ missing: second.ok
487
+ ? missingReviewItems(expected, checks).length
488
+ : missing.length,
489
+ missingRules: second.ok
490
+ ? missingRuleChecks(expectedRules, ruleChecks).length
491
+ : missingRules.length,
492
+ costUsd: (accumulated.costUsd ?? 0) - costBefore,
493
+ numTurns: (accumulated.numTurns ?? 0) - turnsBefore,
494
+ };
495
+ }
496
+
497
+ let secondOpinion: HuntWatchdogResult["secondOpinion"];
498
+ if (this.options.secondOpinion !== false && isGeneral) {
499
+ const costBefore = accumulated.costUsd ?? 0;
500
+ const turnsBefore = accumulated.numTurns ?? 0;
501
+ this.emit({ type: "second-opinion", watchdogId, status: "start" });
502
+ // Sessão NOVA de propósito: retomar a sessão depois do checklist
503
+ // ancorava o modelo nas próprias conclusões (devolvia zero achados).
504
+ const opinion = await this.executePass(
505
+ watchdogId,
506
+ renderAdversarialPrompt({
507
+ projectName: this.options.projectName,
508
+ watchdogId,
509
+ brief,
510
+ candidates: report.findings.map((finding) => ({
511
+ rule: finding.rule,
512
+ severity: finding.severity,
513
+ file: finding.file,
514
+ line: finding.line,
515
+ message: finding.message,
516
+ })),
517
+ }),
518
+ );
519
+ mergeMetrics(accumulated, opinion.metrics);
520
+ attempts += opinion.attempts;
521
+ const costUsd = (accumulated.costUsd ?? 0) - costBefore;
522
+ const numTurns = (accumulated.numTurns ?? 0) - turnsBefore;
523
+ if (opinion.ok) {
524
+ const before = report.findings.length;
525
+ report = mergeReport(report, opinion.report);
526
+ checks = mergeChecks(checks, opinion.checks);
527
+ ruleChecks = mergeRuleChecks(ruleChecks, opinion.ruleChecks);
528
+ reviewerBusiness = richerBusiness(
529
+ reviewerBusiness,
530
+ opinion.reviewerBusiness,
531
+ );
532
+ secondOpinion = {
533
+ status: "done",
534
+ findings: report.findings.length - before,
535
+ costUsd,
536
+ numTurns,
537
+ };
538
+ this.emit({
539
+ type: "second-opinion",
540
+ watchdogId,
541
+ status: "done",
542
+ findings: secondOpinion.findings,
543
+ });
544
+ } else {
545
+ secondOpinion = {
546
+ status: "failed",
547
+ findings: 0,
548
+ costUsd,
549
+ numTurns,
550
+ error: opinion.error,
551
+ };
552
+ this.emit({
553
+ type: "second-opinion",
554
+ watchdogId,
555
+ status: "failed",
556
+ error: opinion.error,
557
+ });
558
+ }
559
+ }
560
+
561
+ const businessDecision = this.applyReviewerBusiness(watchdogId, reviewerBusiness);
562
+
563
+ // `rule` vazio quebra a identidade do finding na triagem; infere pelo
564
+ // prefixo do `basis` + primeira frase da mensagem.
565
+ report = withInferredRule(report);
566
+ this.options.work.writeFinding(
567
+ watchdogId,
568
+ `${JSON.stringify({ ...report, rules: ruleChecks, checks }, null, 2)}\n`,
569
+ );
570
+ const coverage = summarizeChecks(expected, checks, resolveAt);
571
+ const rulesCoverage = summarizeRuleChecks(expectedRules, ruleChecks, resolveAt);
572
+ const files = coverageOf(brief, report);
573
+ const done: HuntWatchdogResult = {
574
+ watchdogId,
575
+ status: "done",
576
+ findings: report.findings.length,
577
+ reviewedFiles: report.reviewedFiles.length,
578
+ attempts,
579
+ durationMs: accumulated.durationMs,
580
+ sessionId: accumulated.sessionId,
581
+ numTurns: accumulated.numTurns,
582
+ costUsd: accumulated.costUsd,
583
+ usage: accumulated.usage,
584
+ checks: coverage.expected > 0 ? coverage : undefined,
585
+ rules: rulesCoverage.expected > 0 ? rulesCoverage : undefined,
586
+ checklist,
587
+ secondOpinion,
588
+ coverage: files.total > 0 ? files : undefined,
589
+ business: businessDecision,
590
+ };
591
+ this.emit({
592
+ type: "done",
593
+ watchdogId,
594
+ findings: done.findings,
595
+ reviewedFiles: done.reviewedFiles,
596
+ attempts,
597
+ metrics: accumulated,
598
+ });
599
+ return done;
600
+ }
601
+
602
+ /** Uma passada do brief, com as retentativas de saída inválida. */
603
+ private async executePass(
604
+ watchdogId: string,
605
+ basePrompt: string,
606
+ options: { resume?: string } = {},
607
+ ): Promise<PassOutcome> {
608
+ let attempts = 0;
609
+ let lastError = "";
610
+ /** A última tentativa chegou a produzir resposta? (permite retomar a sessão) */
611
+ let canResume = false;
612
+ const metrics: MakiRunMetrics = { durationMs: 0 };
613
+
161
614
  while (attempts <= this.options.retries) {
162
615
  attempts++;
616
+ const resume =
617
+ attempts === 1
618
+ ? options.resume
619
+ : canResume
620
+ ? metrics.sessionId
621
+ : undefined;
163
622
  const prompt =
164
623
  attempts === 1
165
624
  ? basePrompt
166
- : `${basePrompt}\n${renderRetrySuffix(lastError)}`;
625
+ : resume
626
+ ? renderResumeSuffix(lastError, watchdogId)
627
+ : `${basePrompt}\n${renderRetrySuffix(lastError)}`;
167
628
  const result = await this.options.runner.run({
168
629
  prompt,
169
630
  cwd: this.options.root,
170
631
  model: this.options.model,
171
632
  apiKeyEnv: this.options.apiKeyEnv,
172
633
  apiKey: this.options.apiKey,
634
+ resume,
173
635
  timeoutMs: this.options.timeoutMs,
174
636
  });
175
- mergeMetrics(accumulated, result.metrics);
637
+ mergeMetrics(metrics, result.metrics);
176
638
 
177
639
  if (!result.ok) {
178
640
  lastError = result.error;
641
+ canResume = false;
179
642
  this.emitRetry(watchdogId, attempts, lastError);
180
643
  continue;
181
644
  }
@@ -183,6 +646,7 @@ export class HuntRunner {
183
646
  const raw = extractJsonObject(result.text);
184
647
  if (raw === null) {
185
648
  lastError = "a resposta final não contém um objeto JSON.";
649
+ canResume = true;
186
650
  this.emitRetry(watchdogId, attempts, lastError);
187
651
  continue;
188
652
  }
@@ -193,45 +657,62 @@ export class HuntRunner {
193
657
  );
194
658
  if (!report) {
195
659
  lastError = warnings[0] ?? "JSON fora do schema do watchdog.";
660
+ canResume = true;
196
661
  this.emitRetry(watchdogId, attempts, lastError);
197
662
  continue;
198
663
  }
199
664
  if (report.watchdogId !== watchdogId) {
200
665
  lastError = `watchdogId '${report.watchdogId}' não corresponde a '${watchdogId}'.`;
666
+ canResume = true;
201
667
  this.emitRetry(watchdogId, attempts, lastError);
202
668
  continue;
203
669
  }
204
670
 
205
- this.options.work.writeFinding(
206
- watchdogId,
207
- `${JSON.stringify(report, null, 2)}\n`,
208
- );
209
- const done: HuntWatchdogResult = {
210
- watchdogId,
211
- status: "done",
212
- findings: report.findings.length,
213
- reviewedFiles: report.reviewedFiles.length,
671
+ return {
672
+ ok: true,
673
+ report,
674
+ checks: parseReviewChecks(raw),
675
+ ruleChecks: parseRuleChecks(raw),
676
+ // O revisor de fluxo devolve o modelo de intenção no JSON.
677
+ reviewerBusiness:
678
+ watchdogId === GENERAL_WATCHDOG_ID
679
+ ? reviewerBusinessOf(raw)
680
+ : undefined,
214
681
  attempts,
215
- durationMs: accumulated.durationMs,
216
- sessionId: accumulated.sessionId,
217
- numTurns: accumulated.numTurns,
218
- costUsd: accumulated.costUsd,
219
- usage: accumulated.usage,
682
+ metrics,
220
683
  };
221
- this.emit({
222
- type: "done",
223
- watchdogId,
224
- findings: done.findings,
225
- reviewedFiles: done.reviewedFiles,
226
- attempts,
227
- metrics: accumulated,
228
- });
229
- return done;
230
684
  }
231
685
 
232
- const error = lastError || "falha desconhecida.";
233
- this.emit({ type: "failed", watchdogId, error, attempts, metrics: accumulated });
234
- return failedResult(watchdogId, error, attempts, accumulated);
686
+ return {
687
+ ok: false,
688
+ error: lastError || "falha desconhecida.",
689
+ attempts,
690
+ metrics,
691
+ };
692
+ }
693
+
694
+ /**
695
+ * Aplica o modelo devolvido pelo `general` somente se ele for mais rico que
696
+ * o já gravado — o revisor reconstrói a intenção sem ver o JSON inteiro e já
697
+ * substituiu um modelo da IA (5 fluxos) por um resumo sem fluxos.
698
+ */
699
+ private applyReviewerBusiness(
700
+ watchdogId: string,
701
+ incoming: BusinessSummary | undefined,
702
+ ): HuntWatchdogResult["business"] {
703
+ if (watchdogId !== GENERAL_WATCHDOG_ID || !incoming) return undefined;
704
+ const decision = chooseBusinessSummary(
705
+ this.options.work.readBusinessSummary(),
706
+ incoming,
707
+ );
708
+ if (decision.accepted && decision.summary) {
709
+ this.options.work.writeBusinessSummary(decision.summary);
710
+ }
711
+ return {
712
+ accepted: decision.accepted,
713
+ source: decision.summary?.source ?? incoming.source,
714
+ score: businessScore(incoming),
715
+ };
235
716
  }
236
717
 
237
718
  private emit(event: HuntEvent): void {
@@ -255,6 +736,112 @@ export class HuntRunner {
255
736
  }
256
737
  }
257
738
 
739
+ /** Resultado de uma passada do brief (com retries internos). */
740
+ type PassOutcome =
741
+ | {
742
+ ok: true;
743
+ report: WatchdogReport;
744
+ checks: ReviewCheck[];
745
+ ruleChecks: RuleCheck[];
746
+ reviewerBusiness?: BusinessSummary;
747
+ attempts: number;
748
+ metrics: MakiRunMetrics;
749
+ }
750
+ | { ok: false; error: string; attempts: number; metrics: MakiRunMetrics };
751
+
752
+ /** Modelo de intenção que o `general` devolve no JSON (se válido). */
753
+ function reviewerBusinessOf(raw: unknown): BusinessSummary | undefined {
754
+ if (typeof raw !== "object" || raw === null) return undefined;
755
+ const summary = (raw as Record<string, unknown>).businessSummary;
756
+ if (typeof summary !== "object" || summary === null) return undefined;
757
+ return (
758
+ parseBusinessSummary(
759
+ { ...(summary as Record<string, unknown>), source: "reviewer" },
760
+ "reviewer",
761
+ ) ?? undefined
762
+ );
763
+ }
764
+
765
+ /** Une as duas passadas: achados por chave, cobertura declarada por união. */
766
+ function mergeReport(first: WatchdogReport, second: WatchdogReport): WatchdogReport {
767
+ return {
768
+ watchdogId: first.watchdogId,
769
+ reviewedFiles: [...new Set([...first.reviewedFiles, ...second.reviewedFiles])],
770
+ skipped: [...new Set([...first.skipped, ...second.skipped])],
771
+ findings: mergeFindings(first.findings, second.findings),
772
+ };
773
+ }
774
+
775
+ /** Cobertura declarada dos arquivos do domínio do brief. */
776
+ function coverageOf(
777
+ brief: string,
778
+ report: WatchdogReport,
779
+ ): { total: number; reviewed: number; skipped: number; undeclared: number } {
780
+ const domain = new Set(briefDomainFiles(brief));
781
+ if (domain.size === 0) return { total: 0, reviewed: 0, skipped: 0, undeclared: 0 };
782
+ const reviewed = new Set(
783
+ report.reviewedFiles.map(normalizePath).filter((file) => domain.has(file)),
784
+ ).size;
785
+ const skipped = new Set(
786
+ report.skipped
787
+ .map(declaredPath)
788
+ .filter((file): file is string => file !== null && domain.has(file)),
789
+ ).size;
790
+ return {
791
+ total: domain.size,
792
+ reviewed,
793
+ skipped,
794
+ undeclared: Math.max(0, domain.size - reviewed - skipped),
795
+ };
796
+ }
797
+
798
+ /** Primeiro caminho de arquivo citado numa entrada de `skipped` (ou null). */
799
+ function declaredPath(entry: string): string | null {
800
+ const match = entry.match(/[\w./-]+\.[A-Za-z0-9]{1,5}/);
801
+ return match ? normalizePath(match[0]) : null;
802
+ }
803
+
804
+ /**
805
+ * Resolver de `at` contra o disco (cache por arquivo).
806
+ *
807
+ * O checklist é autoavaliado pelo modelo; sem checar o `at`, um `ok` inventado
808
+ * conta como cobertura. Aqui só vale se o arquivo existe na raiz da review e a
809
+ * linha cabe dentro dele. Caminho fora da raiz (`..`) nunca resolve.
810
+ */
811
+ function makeAtResolver(root: string): AtResolver {
812
+ const cache = new Map<string, number | null>();
813
+ const lineCount = (file: string): number | null => {
814
+ const cached = cache.get(file);
815
+ if (cached !== undefined) return cached;
816
+ let total: number | null = null;
817
+ if (!file.split("/").includes("..")) {
818
+ try {
819
+ const content = readFileSync(join(root, file), "utf8");
820
+ total = content.split("\n").length;
821
+ } catch {
822
+ total = null;
823
+ }
824
+ }
825
+ cache.set(file, total);
826
+ return total;
827
+ };
828
+ return (file, line) => {
829
+ const total = lineCount(file);
830
+ if (total === null) return false;
831
+ return line === undefined || (line >= 1 && line <= total);
832
+ };
833
+ }
834
+
835
+ /** Modelo de intenção mais rico entre duas passadas. */
836
+ function richerBusiness(
837
+ first: BusinessSummary | undefined,
838
+ second: BusinessSummary | undefined,
839
+ ): BusinessSummary | undefined {
840
+ if (!second) return first;
841
+ if (!first) return second;
842
+ return businessScore(second) > businessScore(first) ? second : first;
843
+ }
844
+
258
845
  /** Soma métricas de várias tentativas no acumulado. */
259
846
  function mergeMetrics(target: MakiRunMetrics, source: MakiRunMetrics): void {
260
847
  target.durationMs += source.durationMs;